23 Commits

Author SHA1 Message Date
21in7
b6ba45f8de docs: add MTF bot motivation and background to ARCHITECTURE.md
메인 봇 PF 0.89, ML/멀티심볼/공개API 피처 전수 테스트 실패 이력을 정리하고,
피처 추가가 아닌 접근 방식 전환(MTF 풀백)으로의 의사결정 맥락을 기술.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:57:27 +09:00
21in7
295ed7db76 docs: add detailed MTF Pullback Bot operation guide to ARCHITECTURE.md
전략 핵심 아이디어, Module 1~4 작동 원리 상세, 메인 루프 흐름도,
메인 봇과의 차이점 비교표를 추가. 테스트 수치도 최신화(183→191).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:42:15 +09:00
21in7
ec7a6e427c docs: sync ARCHITECTURE.md with current codebase
- Fix leverage value 10x → 20x (2 places)
- Update test counts: 15 files/138 cases → 19 files/183 cases
- Add 4 missing test modules to table (dashboard, log_parser, ml_pipeline, mtf_bot)
- Add 14 missing files to structure (1 src + 13 scripts)
- Add MTF Pullback Bot to coverage matrix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:40:34 +09:00
21in7
f488720ca2 fix: MTF bot code review — conditional slicing, caching, tests
- Add _remove_incomplete_candle() for timestamp-based conditional
  slicing on both 15m and 1h data (replaces hardcoded [:-1])
- Add MetaFilter indicator caching to eliminate 3x duplicate calc
- Fix notifier encapsulation (_send → notify_info public API)
- Remove DataFetcher.poll_update() dead code
- Fix evaluate_oos.py symbol typo (xrpusdtusdt → xrpusdt)
- Add 20 pytest unit tests for MetaFilter, TriggerStrategy,
  ExecutionManager, and _remove_incomplete_candle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:11:26 +09:00
21in7
930d4d2c7a feat: add OOS dry-run evaluation script (evaluate_oos.py)
Fetches MTF trade JSONL from prod server via scp, calculates
win rate / PF / cumulative PnL / avg duration by Total/LONG/SHORT,
and outputs LIVE deploy go/no-go verdict (trades >= 5 AND PF >= 1.0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:10:54 +09:00
21in7
e31c4bf080 feat: add JSONL trade persistence + separate MTF deploy pipeline
- mtf_bot: ExecutionManager saves entry/exit records to
  data/trade_history/mtf_{symbol}.jsonl on every close
- Jenkinsfile: split MTF_CHANGED from BOT_CHANGED so mtf_bot.py-only
  changes restart mtf-bot service without touching cointrader
- docker-compose: mount ./data:/app/data on mtf-bot for persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:07:05 +09:00
21in7
b8a371992f docs: add MTF Pullback Bot to README, ARCHITECTURE, and CLAUDE.md
- README: add MTF bot to feature list and project structure
- ARCHITECTURE: add section 5-1 with MTF bot architecture, data flow,
  and design principles (4-module structure, 250 candle buffer, etc.)
- CLAUDE.md: add mtf-pullback-bot to plan history table
- mtf_bot.py: fix stale comment (maxlen=200 → 250)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:53:23 +09:00
21in7
1cfb1b322a feat: add ADX/EMA50/EMA200 values to heartbeat log for diagnosis
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:22:44 +09:00
21in7
75b5c5d7fe fix: increase kline buffer to 250 to prevent EMA 200 NaN on 1h candles
deque(maxlen=200) + [:-1] slice left only 199 completed candles,
causing EMA 200 to return NaN and the bot to stay in WAIT forever.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:59:07 +09:00
21in7
af865c3db2 fix: reduce loop interval to 1s and add heartbeat logging
- Loop sleep 30s → 1s to never miss the 4-second TimeframeSync window
- Data polling remains at 30s intervals via monotonic timer
- Force poll before signal check to ensure fresh data
- Add [Heartbeat] log every 15m with Meta/ATR/Close/Position
- HOLD reasons now logged at INFO level (was DEBUG)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:47:26 +09:00
21in7
c94c605f3e chore: add mtf-bot to Jenkins deploy service list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:41:17 +09:00
21in7
a0990c5fd5 feat: add Discord webhook notifications to MTF dry-run bot
Sends alerts for: bot start, virtual entry (LONG/SHORT with SL/TP),
and SL/TP exits with PnL. Uses existing DiscordNotifier via
DISCORD_WEBHOOK_URL from .env. Also added env_file to mtf-bot
docker-compose service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:31:12 +09:00
21in7
82f4977dff fix: upgrade aiohttp pin to resolve ccxt dependency conflict
ccxt requires aiohttp>=3.10.11, was pinned to ==3.9.3.
python-binance has no upper bound on aiohttp, so this is safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:05:08 +09:00
21in7
4c40516559 feat: add MTF pullback bot for OOS dry-run verification
Volume-backed pullback strategy with 1h meta filter (EMA50/200 + ADX)
and 15m 3-candle trigger sequence. Deployed as separate mtf-bot container
alongside existing cointrader. All orders are dry-run (logged only).

- src/mtf_bot.py: Module 1-4 (DataFetcher, MetaFilter, TriggerStrategy, ExecutionManager)
- main_mtf.py: OOS dry-run entry point
- docker-compose.yml: mtf-bot service added
- requirements.txt: ccxt dependency added
- scripts/mtf_backtest.py: backtest script (Phase 1 robustness: SHORT PF≥1.5 in 7/9 combos)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:58:40 +09:00
21in7
17742da6af feat: add algo order compatibility and orphan order cleanup
- exchange.py: cancel_all_orders() now cancels both standard and algo orders
- exchange.py: get_open_orders() merges standard + algo orders
- exchange.py: cancel_order() falls back to algo cancel on failure
- bot.py: store SL/TP prices for price-based close_reason re-determination
- bot.py: add _cancel_remaining_orders() for orphan SL/TP cleanup
- bot.py: re-classify MANUAL close_reason as SL/TP via price comparison
- bot.py: cancel orphan orders on startup when no position exists
- tests: fix env setup for testnet config and ML filter mocking
- docs: add backtest market context and algo order fix design specs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:46:59 +09:00
21in7
ff2566dfef docs: add algo order compatibility fix design spec
Based on production API verification showing STOP_MARKET/TAKE_PROFIT_MARKET
are handled as algo orders with different behavior than testnet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:59:28 +09:00
21in7
0ddd1f6764 feat: add testnet mode support and fix UDS order type classification
- Add BINANCE_TESTNET env var to switch between live/demo API keys
- Add KLINE_INTERVAL env var (default 15m) for configurable candle interval
- Pass testnet flag through to Exchange, DataStream, UDS, Notifier
- Add demo mode in bot: forced LONG entry with fixed 0.5% SL / 2% TP
- Fix UDS close_reason: use ot (original order type) field to correctly
  classify STOP_MARKET/TAKE_PROFIT_MARKET triggers (was MANUAL)
- Add UDS raw event logging with ot field for debugging
- Add backtest market context (BTC/ETH regime, L/S ratio per fold)
- Separate testnet trade history to data/trade_history/testnet/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:05:38 +09:00
21in7
1135efc5be fix: update weekly report to XRP-only with production params
SYMBOLS: 3 symbols → XRPUSDT only
PROD_PARAMS: atr_sl_mult 2.0→1.5, atr_tp_mult 2.0→4.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:30:39 +09:00
21in7
bd152a84e1 docs: add decision log for ML-off and XRP-only operation
Document why ML filter was disabled (ML OFF PF 1.16 > ML ON PF 0.71),
why SOL/DOGE/TRX were removed (all PF < 1.0 in walk-forward), and
what conditions are needed to re-enable ML in the future.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:25:21 +09:00
21in7
e2b0454825 feat: add L/S ratio collector service for top_acct and global ratios
Collect top trader account L/S ratio and global L/S ratio every 15 minutes
for XRP, BTC, ETH (6 API calls/cycle) and persist to per-symbol parquet files.
Deployed as a separate Docker service reusing the bot image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:20:30 +09:00
21in7
aa5c0afce6 fix: use isolated margin to prevent cross-position liquidation risk
Add set_margin_type("ISOLATED") call before each position entry.
With cross margin, a single bad trade's loss draws from the entire
account balance. Isolated margin caps loss to the position's allocated
margin only.

Binance returns -4046 if already ISOLATED, which is silently ignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:58:34 +09:00
21in7
4fef073b0a fix: recreate AsyncClient on User Data Stream reconnect
After keepalive ping timeout, the existing BinanceSocketManager's
listenKey becomes invalid. Reusing it causes silent failure — the
WebSocket appears connected but receives no ORDER_TRADE_UPDATE events.

This caused SL/TP fills to be detected only by the 5-minute position
monitor polling (SYNC) instead of real-time User Data Stream (TP/SL),
affecting 11 of 12 production trades.

Fix: create fresh AsyncClient + BinanceSocketManager on every reconnect
iteration, ensuring a valid listenKey is obtained each time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:22:45 +09:00
21in7
dacefaa1ed docs: update for XRP-only operation — remove SOL/DOGE/TRX references
- SOL/DOGE/TRX removed: all showed PF < 1.0 across all parameter combinations
  in strategy sweep (2026-03-17) and live trading (2026-03-21)
- ML filter disabled: Walk-Forward showed ML ON PF < ML OFF PF;
  ablation confirmed signal_strength/side dependency (A→C drop 0.08-0.09)
- XRP ADX threshold: 30 → 25 (ADX=30 blocked all trades in current market)
- Current production: XRPUSDT only, SL=1.5x TP=4.0x ADX≥25, NO_ML_FILTER=true

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:47:30 +09:00
31 changed files with 4038 additions and 93 deletions

View File

@@ -1,9 +1,8 @@
BINANCE_API_KEY= BINANCE_API_KEY=
BINANCE_API_SECRET= BINANCE_API_SECRET=
SYMBOLS=XRPUSDT,SOLUSDT,DOGEUSDT SYMBOLS=XRPUSDT
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
LEVERAGE=10 LEVERAGE=20
RISK_PER_TRADE=0.02
DISCORD_WEBHOOK_URL= DISCORD_WEBHOOK_URL=
ML_THRESHOLD=0.55 ML_THRESHOLD=0.55
NO_ML_FILTER=true NO_ML_FILTER=true
@@ -15,19 +14,10 @@ SIGNAL_THRESHOLD=3
ADX_THRESHOLD=25 ADX_THRESHOLD=25
VOL_MULTIPLIER=2.5 VOL_MULTIPLIER=2.5
# Per-symbol strategy params (2026-03-17 sweep optimized) # Per-symbol strategy params (2026-03-21 운영 설정)
ATR_SL_MULT_XRPUSDT=1.5 ATR_SL_MULT_XRPUSDT=1.5
ATR_TP_MULT_XRPUSDT=4.0 ATR_TP_MULT_XRPUSDT=4.0
ADX_THRESHOLD_XRPUSDT=30 ADX_THRESHOLD_XRPUSDT=25
ATR_SL_MULT_SOLUSDT=1.0
ATR_TP_MULT_SOLUSDT=4.0
ADX_THRESHOLD_SOLUSDT=20
MARGIN_MAX_RATIO_SOLUSDT=0.08
ATR_SL_MULT_DOGEUSDT=2.0
ATR_TP_MULT_DOGEUSDT=2.0
ADX_THRESHOLD_DOGEUSDT=30
DASHBOARD_API_URL=http://10.1.10.24:8000 DASHBOARD_API_URL=http://10.1.10.24:8000
BINANCE_TESTNET_API_KEY= BINANCE_TESTNET_API_KEY=
BINANCE_TESTNET_API_SECRET= BINANCE_TESTNET_API_SECRET=

View File

@@ -12,6 +12,7 @@
3. [5개 레이어 상세](#3-5개-레이어-상세) — 각 레이어의 역할과 동작 원리 3. [5개 레이어 상세](#3-5개-레이어-상세) — 각 레이어의 역할과 동작 원리
4. [MLOps 파이프라인](#4-mlops-파이프라인) — ML 모델의 학습·배포·모니터링 전체 흐름 4. [MLOps 파이프라인](#4-mlops-파이프라인) — ML 모델의 학습·배포·모니터링 전체 흐름
5. [핵심 동작 시나리오](#5-핵심-동작-시나리오) — 실제 상황별 봇의 동작 흐름도 5. [핵심 동작 시나리오](#5-핵심-동작-시나리오) — 실제 상황별 봇의 동작 흐름도
5-1. [MTF Pullback Bot](#5-1-mtf-pullback-bot) — 멀티타임프레임 풀백 전략 Dry-run 봇
6. [테스트 커버리지](#6-테스트-커버리지) — 무엇을 어떻게 테스트하는지 6. [테스트 커버리지](#6-테스트-커버리지) — 무엇을 어떻게 테스트하는지
7. [파일 구조](#7-파일-구조) — 전체 파일 역할 요약 7. [파일 구조](#7-파일-구조) — 전체 파일 역할 요약
@@ -35,15 +36,15 @@ CoinTrader는 **Binance Futures 자동매매 봇**입니다.
``` ```
main.py main.py
└─ Config (SYMBOLS=XRPUSDT,SOLUSDT,DOGEUSDT) └─ Config (SYMBOLS=XRPUSDT) # 멀티심볼 지원, 현재 XRP만 운영
└─ RiskManager (공유 싱글턴, asyncio.Lock) └─ RiskManager (공유 싱글턴, asyncio.Lock)
└─ asyncio.gather( └─ asyncio.gather(
TradingBot(symbol="XRPUSDT", risk=shared_risk), TradingBot(symbol="XRPUSDT", risk=shared_risk),
TradingBot(symbol="SOLUSDT", risk=shared_risk),
TradingBot(symbol="DOGEUSDT", risk=shared_risk),
) )
``` ```
> **운영 이력**: SOL/DOGE/TRX는 파라미터 스윕에서 모든 조합에서 PF < 1.0으로 제외 (2026-03-21).
- **독립**: 각 봇은 자체 `Exchange`, `MLFilter`, `DataStream`, `SymbolStrategyParams`를 소유 - **독립**: 각 봇은 자체 `Exchange`, `MLFilter`, `DataStream`, `SymbolStrategyParams`를 소유
- **공유**: `RiskManager`만 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한) 관리 - **공유**: `RiskManager`만 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한) 관리
- **병렬**: `asyncio.gather()`로 동시 실행, 서로 간섭 없음 - **병렬**: `asyncio.gather()`로 동시 실행, 서로 간섭 없음
@@ -329,7 +330,7 @@ ML 필터를 통과한 신호를 실제 주문으로 변환하고, 리스크 한
**주문 흐름:** **주문 흐름:**
``` ```
1. set_leverage(10x) 1. set_leverage(20x)
2. place_order(MARKET) ← 진입 2. place_order(MARKET) ← 진입
3. place_order(STOP_MARKET) ← SL 설정 (3회 재시도) 3. place_order(STOP_MARKET) ← SL 설정 (3회 재시도)
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정 (3회 재시도) 4. place_order(TAKE_PROFIT_MARKET) ← TP 설정 (3회 재시도)
@@ -602,7 +603,7 @@ sequenceDiagram
BOT->>EX: get_balance() BOT->>EX: get_balance()
BOT->>RM: get_dynamic_margin_ratio(balance) BOT->>RM: get_dynamic_margin_ratio(balance)
BOT->>EX: set_leverage(10) BOT->>EX: set_leverage(20)
BOT->>EX: place_order(MARKET, BUY, qty=100.0) BOT->>EX: place_order(MARKET, BUY, qty=100.0)
BOT->>EX: place_order(STOP_MARKET, SELL, stop=2.3100) BOT->>EX: place_order(STOP_MARKET, SELL, stop=2.3100)
BOT->>EX: place_order(TAKE_PROFIT_MARKET, SELL, stop=2.4150) BOT->>EX: place_order(TAKE_PROFIT_MARKET, SELL, stop=2.4150)
@@ -668,6 +669,203 @@ sequenceDiagram
--- ---
## 5-1. MTF Pullback Bot
기존 메인 봇(`bot.py`)과 **별도로** 운영되는 멀티타임프레임 풀백 전략 봇입니다. 4월 OOS(Out-of-Sample) 검증 기간 동안 Dry-run 모드로 실행됩니다.
**파일:** `src/mtf_bot.py`
### 왜 MTF 봇을 만들었는가
메인 봇의 기술 지표 기반 접근(RSI+MACD+BB+EMA+StochRSI)은 PF 0.89로 수익성이 부족했습니다. 이를 개선하기 위해 여러 방향을 시도했으나 모두 실패했습니다:
| 시도 | 결과 | 판정 |
|------|------|------|
| ML 필터 (LightGBM 26피처) | ML OFF > ML ON | 폐기 — 피처 알파 부족 |
| 멀티심볼 확장 (SOL/DOGE/TRX) | 전 심볼 PF < 1.0 | 폐기 — XRP 단독 운영 |
| L/S Ratio 시그널 | 전 조합 PF < 1.0 | 폐기 — edge 없음 |
| FR × OI 변화율 | SHORT PF=1.88 / LONG PF=0.50 | 폐기 — 대칭성 실패 |
| Taker Buy/Sell Ratio | PF 0.93 | 폐기 — 거래비용 커버 불가 |
Binance 공개 API 피처 전수 테스트(2026-03-30) 결과, **단독 edge를 가진 피처가 없음**이 확정되었습니다. 핵심 교훈은 "r < 0.15인 시그널은 거래비용(0.08%) 커버 불가"라는 것이었습니다.
이에 **피처 추가가 아닌 접근 방식 자체를 전환**했습니다:
- **기존**: 15분봉 단일 타임프레임 + 지표 가중치 합산 → 피처 알파 부족
- **전환**: 멀티타임프레임 정보 비대칭 활용 → 1h 추세 확인 후 15m 풀백 패턴 진입
MTF 접근은 동일 Binance 데이터로도 **"언제 진입하느냐"를 바꿈으로써** edge를 확보하려는 시도입니다. 1h 추세 필터가 횡보장 거래를 제거하고, 3캔들 풀백 시퀀스가 노이즈 진입을 줄여 거래 품질을 높입니다.
현재 4월 OOS Dry-run으로 실전 검증 중이며, 50건 이상 누적 후 PF를 기준으로 LIVE 전환 여부를 판단합니다.
### 전략 핵심 아이디어
> **"1시간봉으로 추세를 확인하고, 15분봉에서 일시적 이탈(풀백) 후 복귀하는 순간에 추세 방향으로 진입한다."**
메인 봇(`bot.py`)이 RSI·MACD·BB 등 기술 지표 가중치 합산으로 신호를 만드는 것과 달리, MTF 봇은 **타임프레임 간 정보 비대칭**을 활용합니다. 상위 프레임(1h)의 거시 추세가 확인된 상태에서, 하위 프레임(15m)의 일시적 역행을 노이즈로 간주하고 추세 복귀 시점에 진입합니다.
### 아키텍처 (4개 모듈)
```
Module 1: TimeframeSync + DataFetcher
│ REST 폴링(30초 주기), deque(maxlen=250)으로 15m/1h 캔들 관리
│ Look-ahead bias 차단: _remove_incomplete_candle()로 미완성 봉 제외
Module 2: MetaFilter (1h 거시 추세 판독)
│ EMA50 vs EMA200 + ADX > 20 → LONG_ALLOWED / SHORT_ALLOWED / WAIT
│ WAIT 상태에서는 모든 진입을 차단 (횡보장 방어)
Module 3: TriggerStrategy (15m 풀백 패턴 인식)
│ 3캔들 시퀀스: t-2(기준) → t-1(풀백: EMA 이탈 + 거래량 고갈) → t(돌파: EMA 복귀)
│ Volume-backed 확인: vol_t-1 < vol_sma20 × 0.50
Module 4: ExecutionManager (Dry-run 가상 주문)
│ 가상 포지션 진입/청산, ATR 기반 SL/TP 관리
│ 듀얼 레이어 킬스위치: Fast Kill (8연패) + Slow Kill (15거래 PF<0.75)
└→ Discord 알림 + JSONL 거래 기록
```
### 작동 원리 상세
#### Module 1: TimeframeSync + DataFetcher
**TimeframeSync** — 현재 시각이 캔들 마감 직후인지 판별합니다.
- 15분 캔들: 분(minute)이 `{0, 15, 30, 45}` 이고 초(second)가 2~5초 사이
- 1시간 캔들: 분이 `0`이고 초가 2~5초 사이
- 2~5초 윈도우는 Binance 서버가 캔들을 확정하는 딜레이를 고려한 것
**DataFetcher** — ccxt를 통해 Binance Futures REST API로 OHLCV 데이터를 관리합니다.
- 초기화 시 15m/1h 각각 250개 캔들을 `deque(maxlen=250)`에 적재
- 30초마다 최근 3개 캔들을 폴링하여 새 캔들만 추가 (timestamp 비교로 중복 방지)
- `_remove_incomplete_candle()`: 현재 진행 중인 캔들의 open timestamp를 계산하여, 마지막 캔들이 미완성이면 제거 → Look-ahead bias 원천 차단
- WebSocket 대신 REST 폴링을 선택한 이유: 연결 끊김 리스크 제거, 30초 주기면 15분봉 매매에 충분
#### Module 2: MetaFilter (1h 거시 추세 판독)
완성된 1h 캔들로 거시 시장 상태를 3가지로 분류합니다.
```
입력: 1h OHLCV (완성 캔들만)
EMA50 = EMA(close, 50) ← 중기 이동평균
EMA200 = EMA(close, 200) ← 장기 이동평균
ADX = ADX(14) ← 추세 강도 (0~100)
ATR = ATR(14) ← 변동성 (SL/TP 계산용)
판정:
EMA50 > EMA200 AND ADX > 20 → LONG_ALLOWED (상승 추세 확인)
EMA50 < EMA200 AND ADX > 20 → SHORT_ALLOWED (하락 추세 확인)
그 외 → WAIT (횡보장, 진입 차단)
```
- **ADX 20 기준**: ADX가 20 미만이면 추세가 약하다고 판단, EMA 크로스만으로 진입하지 않음
- **캔들 단위 캐싱**: 동일 1h 캔들 timestamp에 대해 지표를 재계산하지 않음 (`_cache_timestamp` 비교)
- MetaFilter가 `WAIT`를 반환하면 Module 3(TriggerStrategy)는 아예 호출되지 않음
#### Module 3: TriggerStrategy (15m 풀백 패턴 인식)
MetaFilter가 추세를 확인한 후, 15분봉에서 **3캔들 시퀀스** 풀백 패턴을 인식합니다.
```
LONG 시나리오 (meta_state = LONG_ALLOWED):
t-2 ────── 기준 캔들 (Vol_SMA20 산출용)
t-1 ────── 풀백 캔들: ① close < EMA15 (이탈) AND ② volume < Vol_SMA20 × 0.50 (거래량 고갈)
t ────── 돌파 캔들: close > EMA15 (복귀) → EXECUTE_LONG 신호
SHORT 시나리오 (meta_state = SHORT_ALLOWED):
t-2 ────── 기준 캔들
t-1 ────── 풀백 캔들: ① close > EMA15 (이탈) AND ② volume < Vol_SMA20 × 0.50 (거래량 고갈)
t ────── 돌파 캔들: close < EMA15 (복귀) → EXECUTE_SHORT 신호
```
**3가지 조건이 모두 충족**되어야 진입 신호가 발생합니다:
1. **EMA 이탈** (t-1): 추세 반대 방향으로 일시 이탈 → 풀백 확인
2. **거래량 고갈** (t-1): `vol_t-1 / vol_sma20_t-2 < 0.50` → 이탈이 거래량 없는 가짜 움직임인지 확인
3. **EMA 복귀** (t): 추세 방향으로 다시 돌아옴 → 풀백 종료, 추세 재개 확인
하나라도 불충족이면 `HOLD`를 반환하며, 불충족 사유를 `_last_info`에 기록합니다.
#### Module 4: ExecutionManager (가상 주문 + SL/TP + 킬스위치)
**진입**: TriggerStrategy의 신호 + MetaFilter의 1h ATR 값으로 SL/TP를 설정합니다.
| 항목 | LONG | SHORT |
|------|------|-------|
| SL | entry - ATR × 1.5 | entry + ATR × 1.5 |
| TP | entry + ATR × 2.3 | entry - ATR × 2.3 |
| R:R | 1 : 1.53 | 1 : 1.53 |
- 중복 진입 차단: 이미 포지션이 있으면 새 신호 무시
- ATR이 None/0/NaN이면 주문 차단
**SL/TP 모니터링**: 매 루프(1초)마다 보유 포지션의 SL/TP 도달을 15m 캔들 high/low로 확인합니다.
- LONG: `low ≤ SL` → SL 청산, `high ≥ TP` → TP 청산
- SHORT: `high ≥ SL` → SL 청산, `low ≤ TP` → TP 청산
- SL+TP 동시 히트 시: **SL 우선** (보수적 접근)
- PnL은 bps(basis points) 단위로 계산: `(exit - entry) / entry × 10000`
**거래 기록**: 모든 청산은 `data/trade_history/mtf_{symbol}.jsonl`에 JSONL로 저장됩니다. 기록 항목: symbol, side, entry/exit price·ts, sl/tp price, atr, pnl_bps, reason.
**듀얼 킬스위치**:
| 종류 | 조건 | 설명 |
|------|------|------|
| Fast Kill | 최근 8거래 **연속** 손실 (pnl_bps < 0) | 급격한 손실 시 즉시 중단 |
| Slow Kill | 최근 15거래 PF < 0.75 | 만성적 손실 시 중단 |
- 부팅 시 JSONL에서 최근 N건 복원 → 소급 검증 (재시작해도 킬스위치 상태 유지)
- 킬스위치 발동 시: 신규 진입만 차단, 기존 포지션의 SL/TP 청산은 정상 작동
- 수동 해제: `RESET_KILL_SWITCH_MTF_{SYMBOL}=True` 환경변수 + 재시작
### 메인 루프 (MTFPullbackBot)
```
초기화: DataFetcher.initialize() → 250개 캔들 로드 → 초기 Meta 상태 출력 → Discord 알림
while True (1초 주기):
├─ 30초마다: _poll_and_update() → 15m/1h 최신 캔들 추가
├─ 15m 캔들 마감 감지 (TimeframeSync):
│ ├─ Heartbeat 로그 (Meta, ADX, EMA50/200, ATR, Close, Position)
│ ├─ TriggerStrategy.generate_signal(df_15m, meta_state)
│ ├─ 신호 ≠ HOLD → ExecutionManager.execute() → Discord 진입 알림
│ └─ 신호 = HOLD → 사유 로그
└─ 포지션 보유 중: _check_sl_tp() → SL/TP 도달 시 청산 + Discord 알림
```
- 1초 루프인 이유: TimeframeSync의 2~5초 윈도우를 놓치지 않기 위함
- 15m 중복 체크 방지: `_last_15m_check_ts`로 1분 이내 같은 캔들 이중 처리 차단
- 캔들 마감 감지 시 즉시 `_poll_and_update()` 한 번 더 호출하여 최신 데이터 보장
### 메인 봇과의 차이점
| 항목 | 메인 봇 (`bot.py`) | MTF 봇 (`mtf_bot.py`) |
|------|-------------------|----------------------|
| 데이터 소스 | WebSocket (실시간 스트림) | REST 폴링 (30초 주기) |
| 타임프레임 | 15분봉 단일 | 1h (추세) + 15m (진입) |
| 신호 방식 | RSI·MACD·BB·EMA·StochRSI 가중치 합산 | 3캔들 풀백 시퀀스 패턴 |
| ML 필터 | LightGBM/ONNX (26 피처) | 없음 (패턴 자체가 필터) |
| 상관관계 | BTC/ETH 피처 사용 | 사용 안 함 |
| SL/TP 계산 | 15m ATR 기반 | 1h ATR 기반 |
| 반대 시그널 재진입 | 지원 (close → 역방향 open) | 미지원 (포지션 중 신호 무시) |
| 실행 모드 | Live (실주문) | Dry-run (가상 주문) |
| 프로세스 | 메인 프로세스 내 asyncio.gather | 별도 프로세스/Docker 서비스 |
### 설계 원칙
- **Look-ahead bias 원천 차단**: `_remove_incomplete_candle()`이 현재 진행 중인 캔들을 조건부 제거. 버퍼 250개 → 미완성 봉 제외 → EMA 200 정상 계산
- **REST 폴링 안정성**: WebSocket 대신 30초 주기 REST 폴링으로 연결 끊김 리스크 제거
- **Binance 서버 딜레이 고려**: 캔들 마감 판별 시 2~5초 윈도우 적용
- **메인 봇과 독립**: `bot.py`와 별도 프로세스, 별도 Docker 서비스로 배포
- **듀얼 킬스위치**: `ExecutionManager`에 내장. Fast Kill(8연패) + Slow Kill(15거래 PF<0.75, bps 기반). 부팅 시 JSONL에서 이력 복원 + 소급 검증. 수동 해제: `RESET_KILL_SWITCH_MTF_{SYMBOL}=True`
---
## 6. 테스트 커버리지 ## 6. 테스트 커버리지
### 6.1 테스트 실행 ### 6.1 테스트 실행
@@ -677,25 +875,29 @@ pytest tests/ -v # 전체 실행
bash scripts/run_tests.sh # 래퍼 스크립트 실행 bash scripts/run_tests.sh # 래퍼 스크립트 실행
``` ```
`tests/` 폴더에 15개 테스트 파일, 총 **138개의 테스트 케이스**가 작성되어 있습니다. `tests/` 폴더에 19개 테스트 파일, 총 **191개의 테스트 케이스**가 작성되어 있습니다.
### 6.2 모듈별 테스트 현황 ### 6.2 모듈별 테스트 현황
| 테스트 파일 | 대상 모듈 | 케이스 | 주요 검증 항목 | | 테스트 파일 | 대상 모듈 | 케이스 | 주요 검증 항목 |
|------------|----------|:------:|--------------| |------------|----------|:------:|--------------|
| `test_bot.py` | `src/bot.py` | 11 | 반대 시그널 재진입, ML 차단 시 스킵, OI/펀딩비 피처 전달 | | `test_bot.py` | `src/bot.py` | 18 | 반대 시그널 재진입, ML 차단 시 스킵, OI/펀딩비 피처 전달 |
| `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위, MACD 컬럼, 볼린저 대소관계, ADX 횡보장 차단 | | `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위, MACD 컬럼, 볼린저 대소관계, ADX 횡보장 차단 |
| `test_ml_features.py` | `src/ml_features.py` | 11 | 26개 피처 수, RS 분모 0 처리, NaN 없음 | | `test_ml_features.py` | `src/ml_features.py` | 14 | 26개 피처 수, RS 분모 0 처리, NaN 없음 |
| `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백, 임계값 판단, 핫리로드 | | `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백, 임계값 판단, 핫리로드 |
| `test_risk_manager.py` | `src/risk_manager.py` | 13 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 | | `test_risk_manager.py` | `src/risk_manager.py` | 15 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 |
| `test_exchange.py` | `src/exchange.py` | 8 | 수량 계산, OI·펀딩비 조회 정상/오류 | | `test_exchange.py` | `src/exchange.py` | 12 | 수량 계산, OI·펀딩비 조회 정상/오류 |
| `test_data_stream.py` | `src/data_stream.py` | 6 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 | | `test_data_stream.py` | `src/data_stream.py` | 7 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 |
| `test_label_builder.py` | `src/label_builder.py` | 4 | TP/SL 도달 레이블, 미결 → None | | `test_label_builder.py` | `src/label_builder.py` | 4 | TP/SL 도달 레이블, 미결 → None |
| `test_dataset_builder.py` | `src/dataset_builder.py` | 9 | DataFrame 반환, 필수 컬럼, inf/NaN 없음 | | `test_dataset_builder.py` | `src/dataset_builder.py` | 14 | DataFrame 반환, 필수 컬럼, inf/NaN 없음 |
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 학습, 저장/로드 동일 예측 (Apple Silicon 전용) | | `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 학습, 저장/로드 동일 예측 (Apple Silicon 전용) |
| `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 Upsert, 중복 방지, 타임스탬프 정렬 | | `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 Upsert, 중복 방지, 타임스탬프 정렬 |
| `test_config.py` | `src/config.py` | 6 | 환경변수 로드, symbols 리스트 파싱 | | `test_config.py` | `src/config.py` | 9 | 환경변수 로드, symbols 리스트 파싱 |
| `test_weekly_report.py` | `scripts/weekly_report.py` | 15 | 백테스트, 대시보드 API, 추이 분석, ML 트리거, 스윕 | | `test_weekly_report.py` | `scripts/weekly_report.py` | 17 | 백테스트, 대시보드 API, 추이 분석, ML 트리거, 스윕 |
| `test_dashboard_api.py` | `dashboard/` | 16 | 대시보드 API 엔드포인트, 거래 통계 |
| `test_log_parser.py` | `dashboard/` | 8 | 로그 파싱, 필터링 |
| `test_ml_pipeline_fixes.py` | ML 파이프라인 | 7 | ML 파이프라인 버그 수정 검증 |
| `test_mtf_bot.py` | `src/mtf_bot.py` | 28 | MetaFilter, TriggerStrategy, ExecutionManager, SL/TP 체크, 킬스위치 |
> `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다. > `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다.
@@ -720,6 +922,7 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행
| OI 변화율 계산 | ✅ | ✅ | `test_bot` | | OI 변화율 계산 | ✅ | ✅ | `test_bot` |
| Parquet Upsert | ✅ | — | `test_fetch_history` | | Parquet Upsert | ✅ | — | `test_fetch_history` |
| 주간 리포트 | ✅ | ✅ | `test_weekly_report` | | 주간 리포트 | ✅ | ✅ | `test_weekly_report` |
| MTF Pullback Bot | ✅ | ✅ | `test_mtf_bot` (20 cases) |
| User Data Stream TP/SL | ❌ | — | 미작성 (WebSocket 의존) | | User Data Stream TP/SL | ❌ | — | 미작성 (WebSocket 의존) |
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) | | Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
@@ -750,6 +953,8 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행
| `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) | | `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) |
| `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) | | `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) |
| `src/backtester.py` | MLOps | 백테스트 엔진 (단일 + Walk-Forward) | | `src/backtester.py` | MLOps | 백테스트 엔진 (단일 + Walk-Forward) |
| `src/mtf_bot.py` | MTF Bot | 멀티타임프레임 풀백 봇 (1h MetaFilter + 15m TriggerStrategy + Dry-run ExecutionManager) |
| `src/backtest_validator.py` | MLOps | 백테스트 결과 검증 |
| `src/logger_setup.py` | — | Loguru 로거 설정 | | `src/logger_setup.py` | — | Loguru 로거 설정 |
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 | | `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 |
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 | | `scripts/train_model.py` | MLOps | LightGBM 모델 학습 |
@@ -762,4 +967,16 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행
| `scripts/compare_symbols.py` | MLOps | 종목 비교 백테스트 (심볼별 파라미터 sweep) | | `scripts/compare_symbols.py` | MLOps | 종목 비교 백테스트 (심볼별 파라미터 sweep) |
| `scripts/position_sizing_analysis.py` | MLOps | Robust Monte Carlo 포지션 사이징 분석 | | `scripts/position_sizing_analysis.py` | MLOps | Robust Monte Carlo 포지션 사이징 분석 |
| `scripts/run_backtest.py` | MLOps | 단일 백테스트 CLI | | `scripts/run_backtest.py` | MLOps | 단일 백테스트 CLI |
| `scripts/mtf_backtest.py` | MLOps | MTF 풀백 전략 백테스트 |
| `scripts/evaluate_oos.py` | MLOps | OOS Dry-run 평가 스크립트 |
| `scripts/revalidate_apr15.py` | MLOps | 4월 15일 재검증 스크립트 |
| `scripts/collect_oi.py` | MLOps | OI 데이터 수집 |
| `scripts/collect_ls_ratio.py` | MLOps | 롱/숏 비율 수집 |
| `scripts/fr_oi_backtest.py` | MLOps | 펀딩비+OI 백테스트 |
| `scripts/funding_oi_analysis.py` | MLOps | 펀딩비+OI 분석 |
| `scripts/ls_ratio_backtest.py` | MLOps | 롱/숏 비율 백테스트 |
| `scripts/profile_training.py` | MLOps | 학습 프로파일링 |
| `scripts/taker_ratio_analysis.py` | MLOps | 테이커 비율 분석 |
| `scripts/trade_ls_analysis.py` | MLOps | 거래 롱/숏 분석 |
| `scripts/verify_prod_api.py` | MLOps | 프로덕션 API 검증 |
| `models/{symbol}/active_lgbm_params.json` | MLOps | 심볼별 승인된 LightGBM 파라미터 | | `models/{symbol}/active_lgbm_params.json` | MLOps | 심볼별 승인된 LightGBM 파라미터 |

View File

@@ -90,7 +90,7 @@ bash scripts/deploy_model.sh --symbol XRPUSDT
## Configuration ## Configuration
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOLS` (comma-separated, e.g. `XRPUSDT,TRXUSDT`), `CORRELATION_SYMBOLS` (default `BTCUSDT,ETHUSDT`), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `MAX_SAME_DIRECTION` (default 2), `NO_ML_FILTER`. Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOLS` (comma-separated, currently `XRPUSDT` only — SOL/DOGE/TRX removed due to PF < 1.0), `CORRELATION_SYMBOLS` (default `BTCUSDT,ETHUSDT`), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `MAX_SAME_DIRECTION` (default 2), `NO_ML_FILTER` (default `true` — ML disabled due to insufficient feature alpha).
`src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars. Per-symbol strategy params supported via `SymbolStrategyParams` — override with `ATR_SL_MULT_{SYMBOL}`, `ATR_TP_MULT_{SYMBOL}`, `SIGNAL_THRESHOLD_{SYMBOL}`, `ADX_THRESHOLD_{SYMBOL}`, `VOL_MULTIPLIER_{SYMBOL}`. Access via `config.get_symbol_params(symbol)`. `src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars. Per-symbol strategy params supported via `SymbolStrategyParams` — override with `ATR_SL_MULT_{SYMBOL}`, `ATR_TP_MULT_{SYMBOL}`, `SIGNAL_THRESHOLD_{SYMBOL}`, `ADX_THRESHOLD_{SYMBOL}`, `VOL_MULTIPLIER_{SYMBOL}`. Access via `config.get_symbol_params(symbol)`.
@@ -147,4 +147,11 @@ All design documents and implementation plans are stored in `docs/plans/` with t
| 2026-03-21 | `ml-pipeline-fixes` (C1,C3,I1,I3,I4,I5) | Completed | | 2026-03-21 | `ml-pipeline-fixes` (C1,C3,I1,I3,I4,I5) | Completed |
| 2026-03-21 | `training-threshold-relaxation` (plan) | Completed | | 2026-03-21 | `training-threshold-relaxation` (plan) | Completed |
| 2026-03-21 | `purged-gap-and-ablation` (plan) | Completed | | 2026-03-21 | `purged-gap-and-ablation` (plan) | Completed |
| 2026-03-21 | `ml-validation-result` | ML OFF > ML ON 확정, SOL/DOGE/TRX 제외, XRP 단독 운영 |
| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed | | 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
| 2026-03-22 | `backtest-market-context` (design) | 설계 완료, 구현 대기 |
| 2026-03-22 | `testnet-uds-verification` (design) | 설계 완료, 구현 대기 |
| 2026-03-30 | `ls-ratio-backtest` (design + result) | Edge 없음 확정, 폐기 |
| 2026-03-30 | `fr-oi-backtest` (result) | SHORT PF=1.88이나 대칭성 실패(Case2), 폐기 |
| 2026-03-30 | `public-api-research-closed` | Binance 공개 API 전수 테스트 완료, 단독 edge 없음 |
| 2026-03-30 | `mtf-pullback-bot` | MTF Pullback Bot 배포, 4월 OOS Dry-run 검증 진행 중 |

25
Jenkinsfile vendored
View File

@@ -47,10 +47,15 @@ pipeline {
if (changes == 'ALL') { if (changes == 'ALL') {
// 첫 빌드이거나 diff 실패 시 전체 빌드 // 첫 빌드이거나 diff 실패 시 전체 빌드
env.BOT_CHANGED = 'true' env.BOT_CHANGED = 'true'
env.MTF_CHANGED = 'true'
env.DASH_API_CHANGED = 'true' env.DASH_API_CHANGED = 'true'
env.DASH_UI_CHANGED = 'true' env.DASH_UI_CHANGED = 'true'
} else { } else {
env.BOT_CHANGED = (changes =~ /(?m)^(src\/|main\.py|requirements\.txt|Dockerfile)/).find() ? 'true' : 'false' // mtf_bot.py 변경 감지 (mtf-bot 서비스만 재시작)
env.MTF_CHANGED = (changes =~ /(?m)^src\/mtf_bot\.py/).find() ? 'true' : 'false'
// src/ 변경 중 mtf_bot.py만 바뀐 경우 메인 봇은 재시작 불필요
def botFiles = changes.split('\n').findAll { it =~ /^(src\/(?!mtf_bot\.py)|scripts\/|main\.py|requirements\.txt|Dockerfile)/ }
env.BOT_CHANGED = botFiles.size() > 0 ? 'true' : 'false'
env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).find() ? 'true' : 'false' env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).find() ? 'true' : 'false'
env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false' env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false'
} }
@@ -62,7 +67,7 @@ pipeline {
env.COMPOSE_CHANGED = 'false' env.COMPOSE_CHANGED = 'false'
} }
echo "BOT_CHANGED=${env.BOT_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}" echo "BOT_CHANGED=${env.BOT_CHANGED}, MTF_CHANGED=${env.MTF_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
} }
} }
} }
@@ -70,7 +75,7 @@ pipeline {
stage('Build Docker Images') { stage('Build Docker Images') {
parallel { parallel {
stage('Bot') { stage('Bot') {
when { expression { env.BOT_CHANGED == 'true' } } when { expression { env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true' } }
steps { steps {
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ." sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
} }
@@ -95,7 +100,7 @@ pipeline {
withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) { withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) {
sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin" sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
script { script {
if (env.BOT_CHANGED == 'true') { if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
sh "docker push ${FULL_IMAGE}" sh "docker push ${FULL_IMAGE}"
sh "docker push ${LATEST_IMAGE}" sh "docker push ${LATEST_IMAGE}"
} }
@@ -123,7 +128,13 @@ pipeline {
// 변경된 서비스만 pull & recreate (나머지는 중단 없음) // 변경된 서비스만 pull & recreate (나머지는 중단 없음)
def services = [] def services = []
if (env.BOT_CHANGED == 'true') services.add('cointrader') if (env.BOT_CHANGED == 'true') {
services.add('cointrader')
services.add('ls-ratio-collector')
}
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
services.add('mtf-bot')
}
if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api') if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api')
if (env.DASH_UI_CHANGED == 'true') services.add('dashboard-ui') if (env.DASH_UI_CHANGED == 'true') services.add('dashboard-ui')
@@ -141,7 +152,7 @@ pipeline {
stage('Cleanup') { stage('Cleanup') {
steps { steps {
script { script {
if (env.BOT_CHANGED == 'true') { if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
sh "docker rmi ${FULL_IMAGE} || true" sh "docker rmi ${FULL_IMAGE} || true"
sh "docker rmi ${LATEST_IMAGE} || true" sh "docker rmi ${LATEST_IMAGE} || true"
} }
@@ -164,7 +175,7 @@ pipeline {
sh """ sh """
curl -H "Content-Type: application/json" \ curl -H "Content-Type: application/json" \
-X POST \ -X POST \
-d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \ -d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📈 MTF: ${env.MTF_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \
${DISCORD_WEBHOOK} ${DISCORD_WEBHOOK}
""" """
} }

View File

@@ -1,6 +1,8 @@
# CoinTrader # CoinTrader
Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 다중 심볼(XRP, TRX, DOGE 등) 선물 포지션을 동시에 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다. Binance Futures 자동매매 봇. 복합 기술 지표와 킬스위치로 XRPUSDT 선물 포지션을 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다. 멀티심볼 아키텍처를 지원하지만, 현재 XRP만 운영 중입니다.
> **심볼 운영 이력**: SOL, DOGE, TRX는 파라미터 스윕에서 모든 ADX 수준에서 PF < 1.0으로, 현재 전략으로는 수익을 낼 수 없어 제외되었습니다 (2026-03-21). ML 필터도 기술 지표 기반 피처의 예측력 한계로 비활성화 상태 (`NO_ML_FILTER=true`).
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요. > **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다. > 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
@@ -23,6 +25,7 @@ Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / M
- **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회 - **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
- **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단 - **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
- **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징 - **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징
- **MTF Pullback Bot**: 1h MetaFilter(EMA50/200 + ADX) + 15m 3캔들 풀백 시퀀스 기반 Dry-run 봇 (OOS 검증용)
--- ---
@@ -52,7 +55,7 @@ cp .env.example .env
# 필수 # 필수
BINANCE_API_KEY=your_api_key BINANCE_API_KEY=your_api_key
BINANCE_API_SECRET=your_api_secret BINANCE_API_SECRET=your_api_secret
SYMBOLS=XRPUSDT,SOLUSDT,DOGEUSDT # 거래할 심볼 (쉼표 구분) SYMBOLS=XRPUSDT # 거래할 심볼 (쉼표 구분, 멀티심볼 지원)
# 권장 # 권장
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
@@ -129,21 +132,14 @@ Discord 웹훅을 설정했다면 진입/청산 시 실시간 알림을 받게
**심볼별 오버라이드**: `{환경변수}_{심볼}` 형태로 심볼마다 독립 설정 가능. 미설정 시 전역 기본값 사용. **심볼별 오버라이드**: `{환경변수}_{심볼}` 형태로 심볼마다 독립 설정 가능. 미설정 시 전역 기본값 사용.
```env ```env
# 예시: 스윕 최적화 결과 # 현재 운영 설정 (2026-03-21)
ATR_SL_MULT_XRPUSDT=1.5 ATR_SL_MULT_XRPUSDT=1.5
ATR_TP_MULT_XRPUSDT=4.0 ATR_TP_MULT_XRPUSDT=4.0
ADX_THRESHOLD_XRPUSDT=30 ADX_THRESHOLD_XRPUSDT=25
ATR_SL_MULT_SOLUSDT=1.0
ATR_TP_MULT_SOLUSDT=4.0
ADX_THRESHOLD_SOLUSDT=20
MARGIN_MAX_RATIO_SOLUSDT=0.08
ATR_SL_MULT_DOGEUSDT=2.0
ATR_TP_MULT_DOGEUSDT=2.0
ADX_THRESHOLD_DOGEUSDT=30
``` ```
> **제외된 심볼**: SOLUSDT(PF 0.00~0.83), DOGEUSDT(PF 0.70~0.83), TRXUSDT(PF 0.08) — 모든 파라미터 조합에서 PF < 1.0.
### ML 필터 ### ML 필터
ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차단합니다. 기본적으로 **비활성화** 상태입니다. ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차단합니다. 기본적으로 **비활성화** 상태입니다.
@@ -151,7 +147,7 @@ ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차
- `NO_ML_FILTER=true` (기본값) — ML 없이 기술 지표만으로 운영 - `NO_ML_FILTER=true` (기본값) — ML 없이 기술 지표만으로 운영
- `NO_ML_FILTER=false` — ML 필터 활성화 (모델 파일 필요) - `NO_ML_FILTER=false` — ML 필터 활성화 (모델 파일 필요)
> 현재 기본값이 비활성화 이유: 학습 데이터가 충분히 축적되기 전까지 ML 모델의 예측력이 낮습니다. ADX 필터와 거래량 배수 조합만으로 PF 1.5 이상을 달성하고 있어, 충분한 거래 데이터(150건 이상)가 쌓일 때까지 ML 없이 운영합니다. > **비활성화 이유 (2026-03-21)**: Walk-Forward 백테스트에서 ML ON이 ML OFF보다 오히려 PF가습니다 (XRP: ML OFF PF 1.16 vs ML ON PF 0.71). Feature ablation 분석 결과, 모델 예측력의 대부분이 signal_strength/side 피처에 의존하며 (A→C AUC 드롭 0.08~0.09), 기술 지표 z-score만으로는 수수료를 이기는 알파를 만들 수 없었습니다. 오더북/청산 데이터 등 새로운 피처 소스에서 알파가 확인되면 재활성화 예정.
--- ---
@@ -180,7 +176,7 @@ ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차
| **Slow Kill** | 최근 15거래 Profit Factor < 0.75 | 점진적 엣지 소실 | | **Slow Kill** | 최근 15거래 Profit Factor < 0.75 | 점진적 엣지 소실 |
**동작 방식:** **동작 방식:**
- 심볼별 독립 제어: SOL이 킬되어도 XRP/DOGE는 정상 운영 - 심볼별 독립 제어: 한 심볼이 킬되어도 다른 심볼은 정상 운영
- 진입만 차단: 기존 포지션의 SL/TP 청산은 정상 작동 (물린 상태 방치 방지) - 진입만 차단: 기존 포지션의 SL/TP 청산은 정상 작동 (물린 상태 방치 방지)
- 거래 이력 persist: `data/trade_history/{symbol}.jsonl`에 매 청산마다 기록 - 거래 이력 persist: `data/trade_history/{symbol}.jsonl`에 매 청산마다 기록
- 봇 재시작 시 소급 검증: 이력 파일에서 마지막 15건을 읽어 킬스위치 상태 복원 - 봇 재시작 시 소급 검증: 이력 파일에서 마지막 15건을 읽어 킬스위치 상태 복원
@@ -190,8 +186,6 @@ ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차
``` ```
[킬스위치 모니터링] [킬스위치 모니터링]
XRP: 연속손실 2/8 | 15거래PF 1.42 XRP: 연속손실 2/8 | 15거래PF 1.42
SOL: 연속손실 0/8 | 15거래PF -.-- (3건)
DOGE: 연속손실 6/8 ⚠ | 15거래PF 0.71 🔴 KILLED
``` ```
| 환경변수 | 설명 | | 환경변수 | 설명 |
@@ -285,6 +279,7 @@ cointrader/
│ ├── label_builder.py # 학습 레이블 생성 │ ├── label_builder.py # 학습 레이블 생성
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용) │ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
│ ├── backtester.py # 백테스트 엔진 (단일 + Walk-Forward) │ ├── backtester.py # 백테스트 엔진 (단일 + Walk-Forward)
│ ├── mtf_bot.py # MTF Pullback Bot (1h MetaFilter + 15m 3캔들 풀백 + Dry-run)
│ ├── risk_manager.py # 공유 리스크 관리 (asyncio.Lock, 동일 방향 제한) │ ├── risk_manager.py # 공유 리스크 관리 (asyncio.Lock, 동일 방향 제한)
│ ├── notifier.py # Discord 웹훅 알림 │ ├── notifier.py # Discord 웹훅 알림
│ └── logger_setup.py # Loguru 로거 설정 │ └── logger_setup.py # Loguru 로거 설정

View File

@@ -52,5 +52,40 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
mtf-bot:
image: git.gihyeon.com/gihyeon/cointrader:latest
container_name: mtf-bot
restart: unless-stopped
env_file:
- .env
environment:
- TZ=Asia/Seoul
- PYTHONUNBUFFERED=1
volumes:
- ./logs:/app/logs
- ./data:/app/data
entrypoint: ["python", "main_mtf.py"]
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
ls-ratio-collector:
image: git.gihyeon.com/gihyeon/cointrader:latest
container_name: ls-ratio-collector
restart: unless-stopped
environment:
- TZ=Asia/Seoul
- PYTHONUNBUFFERED=1
volumes:
- ./data:/app/data
entrypoint: ["sh", "scripts/collect_ls_ratio_loop.sh"]
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
volumes: volumes:
dashboard-data: dashboard-data:

View File

@@ -0,0 +1,129 @@
# 의사결정 로그: ML 필터 비활성화 & XRP 단독 운영
**일자**: 2026-03-21
**결정자**: gihyeon
**상태**: 확정, 운영 반영 완료
---
## 1. ML 필터를 왜 껐는가
### 결론: ML OFF > ML ON (전 심볼)
Walk-Forward 검증 결과, ML 필터를 끈 상태가 모든 심볼에서 더 나은 성과를 보였다.
| 심볼 | ML OFF PF | ML ON PF | 차이 | ML OFF Return | ML ON Return |
|------|-----------|----------|------|---------------|--------------|
| **XRPUSDT** | **1.16** | 0.71 | -0.45 (61%↓) | +12.17% | -25.62% |
| DOGEUSDT | 1.18 | 0.78 | -0.40 (34%↓) | +16.11% | -28.50% |
| SOLUSDT | 0.09 | 0.25 | — | -321.85% | -48.83% |
### 원인 분석
**1) Ablation 실험 — 모델이 독립적 알파를 제공하지 못함**
- 실험 A: 전체 26개 피처 (baseline AUC)
- 실험 B: signal_strength 제거
- 실험 C: signal_strength + side 제거
- **A→C AUC 하락: 0.08~0.09** (판정 기준: ≤0.05 유용, 0.05~0.10 조건부, ≥0.10 재설계)
- 해석: 모델이 기존 기술적 신호(RSI, MACD, ADX)를 단순 재확인하는 수준. 독립적 예측력 부재.
**2) 학습 데이터 부족**
- Walk-Forward 각 폴드 학습 세트에 유효 신호 ~27건
- 1:1 언더샘플링 후 양성 샘플 ~13건/폴드 → LightGBM 학습에 극히 부족
- 과적합 → 일반화 실패
**3) Purged Gap 적용 후 성능 추가 하락**
- 라벨 생성에 24캔들(6h) lookahead 사용 → 학습/검증 사이에 24캔들 embargo 추가
- 이전에 label leakage로 부풀려진 성능이 정정됨
### 운영 설정
```
NO_ML_FILTER=true # .env
```
모델 파일은 유지 (향후 재검증용). `ml_filter.py`의 hot-reload 로직도 그대로 남겨둠.
---
## 2. SOL/DOGE/TRX를 왜 뺐는가
### 결론: XRP만 PF > 1.0 달성
| 심볼 | Strategy Sweep 최고 PF | Walk-Forward PF (ML OFF) | 판정 |
|------|----------------------|--------------------------|------|
| **XRPUSDT** | 1.68 | **1.16** | ✅ 운영 유지 |
| DOGEUSDT | 1.80 | 1.18* | ❌ 제외 |
| TRXUSDT | 3.87 (16건) | — | ❌ 제외 |
| SOLUSDT | 2.83 | **0.09** | ❌ 제외 |
*DOGE PF 1.18은 WR 25%로 소수 대형 승리에 의존 → 안정성 부족
### 핵심 교훈: 과적합 탐지
**SOLUSDT 사례가 가장 극적:**
- Strategy Sweep (1년 전체 백테스트): PF 2.83, Return +90.93%
- Walk-Forward (시계열 CV): PF 0.09, Return -321.85%
- **과적합 정도: PF 2.83 → 0.09 (97% 하락)**
→ 전체 기간 백테스트 결과만으로 심볼을 선택하면 안 됨. 반드시 Walk-Forward로 검증해야 함.
### 운영 설정
```
SYMBOLS=XRPUSDT # .env (이전: XRPUSDT,SOLUSDT,DOGEUSDT,TRXUSDT)
```
---
## 3. ML을 다시 켜려면 어떤 조건이 필요한가
### 필수 조건 (AND)
1. **데이터 양**: Walk-Forward 폴드당 유효 신호 100건 이상
- 현재 ~27건 → 약 4배 필요
- 방법: (a) 더 긴 수집 기간 (1년→3년), (b) 15m→5m 타임프레임 (데이터 3배), (c) 새 피처로 유효 신호 비율 증가
2. **독립적 알파**: Ablation A→C AUC 하락 ≤ 0.05
- signal_strength와 side를 제거해도 모델이 독립적으로 예측할 수 있어야 함
- 현재 0.08~0.09 → 새 피처(L/S ratio, OI 파생 등)가 이 갭을 메워야 함
3. **Walk-Forward 검증**: ML ON PF > ML OFF PF (최소 0.1 이상 차이)
- 단순히 PF > 1.0이 아니라, ML OFF 대비 개선이 있어야 함
- 검증 거래 수 50건 이상
4. **과적합 지표**: Strategy Sweep PF vs Walk-Forward PF 비율 < 2.0
- SOL처럼 Sweep 2.83 / WF 0.09 = 31배 차이 → 극심한 과적합
- 비율 2.0 이하면 합리적 범위
### 유망한 다음 시도
| 개선 방향 | 기대 효과 | 현재 상태 |
|-----------|-----------|-----------|
| **L/S Ratio 피처 추가** | 독립적 알파 (상관 0.12~0.14) | 수집 시작 (2026-03-22), 1개월 뒤 검증 가능 |
| **학습 데이터 3년 확보** | 폴드당 샘플 3배 증가 | 미착수 |
| **Cross-symbol 피처** | BTC/ETH 탑 트레이더 동향 → XRP 예측 | L/S ratio 수집 후 가능 |
| **다른 모델 (XGBoost, CatBoost)** | 소규모 데이터에 더 적합할 수 있음 | 미착수 |
### 재검증 타임라인
```
2026-03-22: L/S ratio 수집 시작 (top_acct + global, 3심볼)
2026-04-22: 1개월 데이터 축적 (~17,000건)
→ 상관분석 재실행 (5일 → 30일 데이터로 신뢰도 확인)
→ L/S ratio 피처를 ML에 추가하여 Ablation 재실험
→ Walk-Forward ML ON vs OFF 재비교
```
---
## 4. 관련 문서 & 코드
| 참조 | 위치 |
|------|------|
| ML 비활성화 커밋 | `dacefaa` (docs: update for XRP-only operation) |
| ML 비교 결과 (XRP) | `results/xrpusdt/ml_comparison_20260321_200332.json` |
| ML 비교 결과 (DOGE) | `results/dogeusdt/ml_comparison_20260321_200334.json` |
| Strategy Sweep 결과 | `results/{symbol}/strategy_sweep_*.json` |
| Purged Gap 계획 | `docs/plans/2026-03-21-purged-gap-and-ablation.md` |
| ML 검증 파이프라인 | `docs/plans/2026-03-21-ml-validation-pipeline.md` |
| ML 검증 결과 | `docs/plans/2026-03-21-ml-validation-result.md` |
| L/S Ratio 수집 스크립트 | `scripts/collect_ls_ratio.py` |
| 운영 설정 | `.env``NO_ML_FILTER=true`, `SYMBOLS=XRPUSDT` |

View File

@@ -0,0 +1,192 @@
# 백테스트 시장 컨텍스트 리포트 설계
**일자**: 2026-03-22
**상태**: 설계 완료, 구현 대기
---
## 목적
Walk-Forward 백테스트 결과를 해석할 때, 각 폴드 기간의 시장 상황(BTC/ETH 추세, L/S ratio)을 함께 보여준다. **"왜 이 폴드에서 졌는가"**를 구조적으로 이해하기 위한 참조 데이터이며, 트레이딩 시그널이나 ML 피처로는 사용하지 않는다.
## 접근 방식
Walk-Forward 폴드 테이블 출력 직후에 시장 컨텍스트 테이블 2개(Market Regime + L/S Ratio)를 추가한다. 기존 `scripts/run_backtest.py`만 수정하며, 별도 CLI 명령어는 만들지 않는다.
---
## 데이터 소스
### 1. BTC/ETH 가격 데이터 (Market Regime)
- **소스**: XRP의 `data/xrpusdt/combined_15m.parquet`에 임베딩된 `close_btc`, `high_btc`, `low_btc`, `close_eth`, `high_eth`, `low_eth` 컬럼
- 별도 `data/btcusdt/combined_15m.parquet` 파일은 로컬/프로덕션 모두 **존재하지 않음**
- 백테스터가 이미 이 임베딩 컬럼을 로딩하므로 추가 데이터 fetch 불필요
- 폴드 기간별로 슬라이싱하여 수익률, ADX 계산
### 2. L/S Ratio 데이터
- **소스**: `data/{symbol}/ls_ratio_15m.parquet` (로컬 파일)
- **심볼**: XRPUSDT, BTCUSDT, ETHUSDT
- **주기**: 15m
- **컬럼**: `timestamp` (datetime64[ms, UTC]), `top_acct_ls_ratio` (float64), `global_ls_ratio` (float64)
#### 현재 데이터 상태
- L/S ratio collector는 운영 LXC(`10.1.10.24`)에서 가동 중 (commit `e2b0454`, 2026-03-22~)
- **프로덕션**: XRP/BTC/ETH 각 3건 (2026-03-22 13:15 ~ 13:45 UTC), 계속 축적 중
- **로컬**: XRP 2건, BTC 2건, ETH 2건 (로컬 collector 테스트 시 생성된 데이터)
- 과거 폴드(2025-06, 2025-09, 2025-12)에 대한 L/S ratio 데이터는 **존재하지 않음**
- Binance API는 최근 30일만 historical 제공 → 과거 데이터 복구 불가능
#### 데이터 동기화
구현 전 프로덕션 LXC에서 L/S ratio parquet 파일을 로컬로 복사해야 한다:
```bash
scp root@10.1.10.24:/root/cointrader/data/xrpusdt/ls_ratio_15m.parquet data/xrpusdt/
scp root@10.1.10.24:/root/cointrader/data/btcusdt/ls_ratio_15m.parquet data/btcusdt/
scp root@10.1.10.24:/root/cointrader/data/ethusdt/ls_ratio_15m.parquet data/ethusdt/
```
#### Fallback 전략
1. **로컬 parquet 우선**: `data/{symbol}/ls_ratio_15m.parquet`에서 폴드 기간 데이터 조회
2. **파일 없거나 해당 기간 데이터 없으면 `N/A`**: 폴드의 L/S ratio 셀을 `N/A`로 표시
3. **전체 폴드가 N/A이면 L/S ratio 테이블 자체를 생략**: 불필요한 N/A 테이블을 출력하지 않음
4. **Binance API에서 실시간 fetch하지 않음**: 백테스트는 오프라인 재현 가능해야 함
5. **시간이 지나면 해결됨**: collector가 계속 수집하므로, 데이터 축적 후 백테스트에 자연스럽게 반영
---
## Market Regime 분류 기준
BTC ADX와 수익률 기반으로 **코드에 명확히 정의**하여 주관적 해석을 방지한다:
| 조건 | 라벨 |
|------|------|
| ADX ≥ 25 and return > 0 | 상승 추세 |
| ADX ≥ 25 and return < 0 | 하락 추세 |
| ADX < 25 | 횡보 |
- ADX는 폴드 기간 내 BTC 15m 캔들(`high_btc`, `low_btc`, `close_btc`)로 계산한 **기간 평균 ADX** (`pandas_ta.adx(length=14)` 사용)
- return은 폴드 시작가 대비 종료가의 **단순 수익률** (`close_btc`)
- 라벨 뒤에 `(BTC ADX {값:.0f})` 형태로 실제 수치 병기
---
## 출력 형식
기존 폴드 테이블 바로 아래에 출력:
```
📊 Market Context per Fold
┌──────┬──────────────┬──────────────┬─────────────────────────────────┐
│ Fold │ BTC Return │ ETH Return │ Market Regime │
├──────┼──────────────┼──────────────┼─────────────────────────────────┤
│ 1 │ +12.3% │ +8.7% │ 상승 추세 (BTC ADX 32) │
│ 2 │ -2.1% │ -5.4% │ 횡보 (BTC ADX 18) │
│ 3 │ +25.6% │ +18.2% │ 상승 추세 (BTC ADX 41) │
└──────┴──────────────┴──────────────┴─────────────────────────────────┘
📊 L/S Ratio Context per Fold (period avg)
┌──────┬──────────────────┬──────────────────┬──────────────────┐
│ Fold │ XRP Top/Global │ BTC Top/Global │ ETH Top/Global │
├──────┼──────────────────┼──────────────────┼──────────────────┤
│ 1 │ N/A │ N/A │ N/A │
│ 2 │ N/A │ N/A │ N/A │
│ 3 │ 1.15 / 0.98 │ 0.95 / 1.02 │ 1.08 / 1.05 │
└──────┴──────────────────┴──────────────────┴──────────────────┘
→ Fold 1~2: L/S ratio 데이터 없음 (collector 가동 전)
→ Fold 3: 데이터 가용
```
**전체 폴드가 N/A인 경우** (현재 상태에서 과거 데이터만으로 백테스트하면):
```
📊 Market Context per Fold
┌──────┬──────────────┬──────────────┬─────────────────────────────────┐
│ Fold │ BTC Return │ ETH Return │ Market Regime │
├──────┼──────────────┼──────────────┼─────────────────────────────────┤
│ 1 │ +12.3% │ +8.7% │ 상승 추세 (BTC ADX 32) │
│ 2 │ -2.1% │ -5.4% │ 횡보 (BTC ADX 18) │
│ 3 │ +25.6% │ +18.2% │ 상승 추세 (BTC ADX 41) │
└──────┴──────────────┴──────────────┴─────────────────────────────────┘
L/S ratio 데이터 없음 — collector 데이터 축적 후 표시됩니다
```
### JSON 출력
walk-forward 결과 JSON에도 `market_context` 필드 추가:
```json
{
"folds": [
{
"fold": 1,
"test_period": "2025-06-07 ~ 2025-07-06",
"test_start": "2025-06-07T00:00:00",
"test_end": "2025-07-06T00:00:00",
"summary": { "..." : "..." },
"market_context": {
"btc_return_pct": 12.3,
"eth_return_pct": 8.7,
"btc_avg_adx": 32.1,
"market_regime": "상승 추세",
"ls_ratio": null
}
},
{
"fold": 3,
"test_period": "2026-03-01 ~ 2026-04-01",
"test_start": "2026-03-01T00:00:00",
"test_end": "2026-04-01T00:00:00",
"summary": { "..." : "..." },
"market_context": {
"btc_return_pct": 5.2,
"eth_return_pct": 3.1,
"btc_avg_adx": 28.5,
"market_regime": "상승 추세",
"ls_ratio": {
"xrp": { "top_acct_avg": 1.15, "global_avg": 0.98 },
"btc": { "top_acct_avg": 0.95, "global_avg": 1.02 },
"eth": { "top_acct_avg": 1.08, "global_avg": 1.05 }
}
}
}
]
}
```
---
## 수정 대상 파일
| 파일 | 변경 유형 | 역할 |
|------|-----------|------|
| `scripts/run_backtest.py` | Modify | 시장 컨텍스트 계산 + 출력 함수 추가 |
| `src/backtester.py` | Modify (최소) | 폴드 결과에 `test_start`/`test_end`를 timestamp로 노출 (현재는 문자열 `test_period`만 있음) |
### 변경하지 않는 것
- `src/indicators.py` — ADX 계산은 `run_backtest.py` 내에서 `pandas_ta.adx()` 직접 사용
- `scripts/collect_ls_ratio.py` — 기존 collector 로직 변경 없음
- `src/ml_filter.py`, `src/ml_features.py` — ML 피처와 무관
- `scripts/fetch_history.py` — BTC/ETH 별도 fetch 불필요 (XRP parquet에 임베딩됨)
---
## 구현 전 선행 작업
1. ~~BTC/ETH 히스토리 데이터 fetch~~**불필요** (XRP parquet에 `close_btc`, `close_eth` 등 임베딩됨)
2. `backtester.py`에서 `test_start`/`test_end`를 timestamp로 노출하도록 수정
3. 프로덕션 LXC에서 L/S ratio parquet 파일 로컬 동기화
---
## 구현 범위 제한
- **참조 전용**: 시장 컨텍스트는 출력/리포트에만 사용. 트레이딩 로직에 영향 없음
- **오프라인 우선**: Binance API 호출 없음. 로컬 데이터만 사용
- **기존 테스트 영향 없음**: 출력 함수 추가이므로 기존 백테스트 로직 불변
- **L/S ratio 테이블 조건부 출력**: 전체 N/A이면 테이블 생략, 한 줄 안내 메시지만 출력

View File

@@ -0,0 +1,242 @@
# Testnet UDS 검증 설계
**일자**: 2026-03-22
**상태**: 설계 완료, 구현 대기
---
## 목적
Binance Futures Testnet에서 User Data Stream(UDS)의 reconnect 동작을 검증한다. 현재 프로덕션 15분봉 설정 그대로 testnet에 연결하여, UDS 연결 → ~30분 후 reconnect → ORDER_TRADE_UPDATE 수신까지 전체 경로가 정상 작동하는지 확인한다.
**이것은 UDS 검증 전용이다.** 1분봉 전환, 125x 레버리지, ML 파이프라인 변경은 포함하지 않는다. 기존 설계(`2026-03-03-testnet-1m-125x`)는 ML OFF 확정 후 전제가 바뀌었으므로 별도 취급한다.
---
## 접근 방식
python-binance 1.0.35에서 `testnet=True` 파라미터가 REST API와 WebSocket(kline + User Data Stream) 모두 자동 라우팅한다. 별도 URL 오버라이드 불필요.
**검증된 라우팅 경로 (python-binance 소스 확인):**
- REST API: `https://testnet.binancefuture.com`
- Kline WebSocket: `wss://stream.binancefuture.com/` (`BinanceSocketManager._get_futures_socket()`에서 `self.testnet` 체크)
- User Data Stream WebSocket: `wss://stream.binancefuture.com/` (`futures_user_socket()`에서 `self.testnet` 체크)
`AsyncClient.create(testnet=True)``BinanceSocketManager(client)``client.testnet` 플래그가 자동 전파.
---
## 수정 대상 파일
| 파일 | 변경 내용 |
|------|----------|
| `src/config.py` | `testnet: bool` 필드 추가, `BINANCE_TESTNET` env var 파싱, testnet이면 testnet API key 사용 |
| `src/exchange.py` | `Client(..., testnet=config.testnet)` 전달 |
| `src/user_data_stream.py` | `AsyncClient.create(..., testnet=testnet)` 전달 |
| `src/data_stream.py` | `AsyncClient.create(..., testnet=testnet)` 전달 (KlineStream + MultiSymbolStream) |
| `src/notifier.py` | testnet일 때 Discord 메시지에 `[TESTNET]` 접두사 추가 |
| `src/bot.py` | testnet 플래그를 각 스트림/notifier에 전달 + trade_history 경로 분리 + 시작 시 TESTNET 경고 로그 |
### 변경하지 않는 것
- 지표 계산 (`src/indicators.py`) — 그대로
- ML 필터 (`src/ml_filter.py`) — NO_ML_FILTER=true 상태 그대로
- 학습 파이프라인 — 변경 없음
- 리스크 매니저 — 그대로
- Discord 알림 — testnet일 때 메시지에 `[TESTNET]` 접두사 추가 (아래 상세 변경 참조)
- `.env` 프로덕션 설정 — 변경 없음 (BINANCE_TESTNET 추가만)
---
## 상세 변경
### 1. Config (`src/config.py`)
```python
# 필드 추가
testnet: bool = False
# __post_init__에서:
self.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
if self.testnet:
self.api_key = os.getenv("BINANCE_TESTNET_API_KEY", "")
self.api_secret = os.getenv("BINANCE_TESTNET_API_SECRET", "")
else:
self.api_key = os.getenv("BINANCE_API_KEY", "")
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
```
- testnet이면 `BINANCE_TESTNET_API_KEY/SECRET` 사용
- 나머지 설정(SYMBOLS, LEVERAGE 등)은 동일하게 적용
### 2. Exchange (`src/exchange.py`)
```python
# 현재:
self.client = Client(
api_key=config.api_key,
api_secret=config.api_secret,
)
# 변경:
self.client = Client(
api_key=config.api_key,
api_secret=config.api_secret,
testnet=config.testnet,
)
```
### 3. UserDataStream (`src/user_data_stream.py`)
`_run_loop()` 시그니처에 `testnet` 파라미터 추가:
```python
# start()에 testnet 파라미터 추가
async def start(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
...
await self._run_loop(api_key, api_secret, testnet)
# _run_loop()에서 AsyncClient.create에 전달
async def _run_loop(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
while True:
client = await AsyncClient.create(
api_key=api_key,
api_secret=api_secret,
testnet=testnet,
)
```
### 4. DataStream (`src/data_stream.py`)
KlineStream.start()과 MultiSymbolStream.start() 모두 동일 패턴:
```python
async def start(self, api_key: str, api_secret: str, testnet: bool = False):
client = await AsyncClient.create(
api_key=api_key,
api_secret=api_secret,
testnet=testnet,
)
```
MultiSymbolStream._run_loop()에서도 reconnect 시 AsyncClient.create에 testnet 전달.
### 5. Notifier (`src/notifier.py`)
testnet일 때 Discord 메시지에 `[TESTNET]` 접두사를 추가하여 프로덕션 알림과 구분:
```python
# Notifier.__init__()에 testnet 파라미터 추가
def __init__(self, webhook_url: str, testnet: bool = False):
self.webhook_url = webhook_url
self.testnet = testnet
# 메시지 전송 시 접두사 추가
async def _send(self, content: str):
if self.testnet:
content = f"[TESTNET] {content}"
...
```
Bot에서 Notifier 생성 시 `testnet=self.config.testnet` 전달.
### 6. Bot (`src/bot.py`)
**시작 로그에 TESTNET 명시 (warning 레벨):**
```python
async def run(self):
if self.config.testnet:
logger.warning("⚠️ TESTNET MODE ENABLED — 실제 자금이 아닌 테스트넷에서 실행 중")
...
```
**stream.start()와 user_stream.start()에 testnet 전달:**
```python
await asyncio.gather(
self.stream.start(
api_key=self.config.api_key,
api_secret=self.config.api_secret,
testnet=self.config.testnet,
),
user_stream.start(
api_key=self.config.api_key,
api_secret=self.config.api_secret,
testnet=self.config.testnet,
),
self._position_monitor(),
)
```
**trade_history 경로 분리:**
```python
# 현재 (line 24):
_TRADE_HISTORY_DIR = Path("data/trade_history")
# 변경 — _trade_history_path() 메서드에서 분기:
def _trade_history_path(self) -> Path:
base = Path("data/trade_history")
if self.config.testnet:
base = base / "testnet"
base.mkdir(parents=True, exist_ok=True)
return base / f"{self.symbol.lower()}.jsonl"
```
- testnet: `data/trade_history/testnet/xrpusdt.jsonl`
- production: `data/trade_history/xrpusdt.jsonl` (기존과 동일)
- Kill Switch 판정이 testnet 트레이드로 오염되지 않음
---
## .env 설정
```bash
# 기존 프로덕션 설정 유지 + 아래 추가
BINANCE_TESTNET=true # testnet 모드 활성화
BINANCE_TESTNET_API_KEY=xxx # testnet.binancefuture.com에서 발급
BINANCE_TESTNET_API_SECRET=xxx
```
- `BINANCE_TESTNET=true`를 설정하면 testnet 모드로 전환
- 프로덕션 복귀 시 `BINANCE_TESTNET=false` 또는 줄 삭제
**주의**: .env에 이미 `BINANCE_TESTNET_API_KEY`/`BINANCE_TESTNET_API_SECRET` 자리가 마련되어 있음.
---
## 검증 절차
### 1단계: Testnet API 키 발급
- `testnet.binancefuture.com` 접속 → API 키 발급
- `.env`에 설정
### 2단계: 봇 실행 + UDS 검증
```bash
# .env에 BINANCE_TESTNET=true 설정 후
python main.py
```
확인 사항:
1. 시작 로그에 testnet 표시 확인
2. User Data Stream 연결 로그 확인
3. ~30분 대기 → reconnect 발생하는지 확인
4. reconnect 후 ORDER_TRADE_UPDATE 수신되는지 확인
5. trade_history가 `data/trade_history/testnet/` 에 기록되는지 확인
### 3단계: Kill Switch 경로 확인
- testnet 트레이드가 `data/trade_history/testnet/xrpusdt.jsonl`에만 기록되는지 확인
- 프로덕션 `data/trade_history/xrpusdt.jsonl`이 변경되지 않았는지 확인
---
## 주의사항
- **테스트넷 가격은 실제 시장과 다름**: 전략 성과 판단 불가, UDS 동작 검증만 목적
- **trade_history 분리 필수**: testnet 트레이드가 프로덕션 Kill Switch를 오염시키면 안 됨
- **프로덕션 배포 시 BINANCE_TESTNET 제거 확인**: `.env``BINANCE_TESTNET=true`가 남아있으면 프로덕션이 testnet으로 연결됨

View File

@@ -0,0 +1,132 @@
# Algo Order 호환성 수정 설계
## 배경
실전 바이낸스 API 검증 결과, 조건부 주문(STOP_MARKET, TAKE_PROFIT_MARKET)이 Algo Order로 처리되며 테스트넷과 동작이 다름이 확인됨.
### 검증 결과 요약
| 항목 | 테스트넷 | 실전 |
|------|---------|------|
| SL/TP 응답 | `orderId` 반환 | `algoId`만 반환, orderId=None |
| SL 트리거 UDS | `ot=STOP_MARKET` | `ot=MARKET` |
| SL 후 TP 자동만료 | EXPIRED 이벤트 수신 | 만료 안 됨 → 고아주문 |
| `get_open_orders()` | algo 주문 조회됨 | algo 주문 조회 안 됨 |
| `cancel_all_orders()` | algo 주문 취소됨 | algo 주문 취소 안 됨 |
| UDS `i` 필드 vs 배치 ID | 동일 | `i``algoId` (서로 다른 값) |
## 수정 대상 파일
1. `src/exchange.py` — algo API 병행 호출
2. `src/bot.py` — SL/TP 가격 저장, close_reason 판별, 복구 로직
3. `src/user_data_stream.py` — 가격 기반 close_reason 판별
4. `tests/` — 변경사항 반영
## 설계
### 1. exchange.py: Algo API 병행
**`cancel_all_orders()`**: 일반 주문 취소 + algo 주문 전체 취소를 모두 호출.
```python
async def cancel_all_orders(self):
await self._run_api(
lambda: self.client.futures_cancel_all_open_orders(symbol=self.symbol)
)
try:
await self._run_api(
lambda: self.client.futures_cancel_all_algo_open_orders(symbol=self.symbol)
)
except Exception:
pass # algo 주문 없으면 실패 가능 — 무시
```
**`cancel_order()`**: ID 크기나 타입으로 분기하지 않고, 일반 취소 시도 → 실패 시 algo 취소 (현재와 동일, 이미 올바른 구조).
**`get_open_orders()`**: 일반 주문 + algo 주문을 병합 반환. algo 주문 응답의 필드명이 다르므로 정규화 필요.
```python
async def get_open_orders(self) -> list[dict]:
orders = await self._run_api(
lambda: self.client.futures_get_open_orders(symbol=self.symbol)
)
try:
algo_orders = await self._run_api(
lambda: self.client.futures_get_algo_open_orders(symbol=self.symbol)
)
for ao in algo_orders.get("orders", []):
orders.append({
"orderId": ao.get("algoId"),
"type": ao.get("orderType"), # STOP_MARKET / TAKE_PROFIT_MARKET
"stopPrice": ao.get("triggerPrice"),
"side": ao.get("side"),
"status": ao.get("algoStatus"),
"_is_algo": True,
})
except Exception:
pass
return orders
```
### 2. bot.py: SL/TP 가격 저장 + close_reason 판별
**새 필드 추가** (`__init__`):
```python
self._sl_price: float | None = None
self._tp_price: float | None = None
```
**`_open_position()`**: SL/TP 배치 후 가격 저장.
```python
# _place_sl_tp_with_retry 호출 전에 이미 stop_loss, take_profit 계산됨
self._sl_price = stop_loss
self._tp_price = take_profit
```
**`_ensure_sl_tp_orders()` (복구)**: 오픈 주문에서 SL/TP 가격 복원.
```python
for o in open_orders:
otype = o.get("type", "")
if otype == "STOP_MARKET":
self._sl_price = float(o.get("stopPrice", 0))
elif otype == "TAKE_PROFIT_MARKET":
self._tp_price = float(o.get("stopPrice", 0))
```
**`_on_position_closed()`**: close_reason이 "MANUAL"일 때 가격 비교로 재판별.
```python
if close_reason == "MANUAL" and self._sl_price and self._tp_price:
sl_dist = abs(exit_price - self._sl_price)
tp_dist = abs(exit_price - self._tp_price)
if sl_dist < tp_dist:
close_reason = "SL"
else:
close_reason = "TP"
```
**상태 초기화**: `_on_position_closed()``_close_and_reenter()`에서 포지션 Flat 전환 시:
```python
self._sl_price = None
self._tp_price = None
```
### 3. user_data_stream.py: close_reason을 콜백에 위임
UDS의 close_reason 판별 로직은 유지하되, 콜백 시그니처에 `exit_price`가 이미 전달되므로 bot.py에서 재판별 가능. UDS 자체는 변경 최소화.
현재 UDS에서 `ot`로 판별 → 실전에서 `ot=MARKET``close_reason="MANUAL"` → bot.py에서 가격 비교로 SL/TP 재판별. 이 흐름이 테스트넷에서도 안전 (테스트넷은 `ot=STOP_MARKET`이 오므로 재판별 자체가 불필요).
### 4. 포지션 모니터 SYNC 경로 — 이미 구현됨
`_position_monitor()`의 SYNC 폴백에서 잔여주문 취소는 **이미 구현되어 있음**. 추가 수정 불필요.
> **참고**: `_place_sl_tp_with_retry()`의 algoId 저장도 이미 구현됨 (bot.py line 539, 550).
### 5. 테스트 계획
- 테스트넷에서 SL 트리거 → TP 고아주문 자동 취소 확인
- 테스트넷에서 TP 트리거 → SL 고아주문 자동 취소 확인
- 테스트넷에서 역방향 재진입 → 기존 SL/TP 취소 확인
- 봇 재시작 → SL/TP 가격 복원 확인
- close_reason이 SL/TP로 정확히 분류되는지 확인
- 위 모든 항목 통과 후 실전 배포

44
main_mtf.py Normal file
View File

@@ -0,0 +1,44 @@
"""MTF Pullback Bot — OOS Dry-run Entry Point."""
import asyncio
import signal as sig
from dotenv import load_dotenv
from loguru import logger
from src.mtf_bot import MTFPullbackBot
from src.logger_setup import setup_logger
load_dotenv()
async def main():
setup_logger(log_level="INFO")
logger.info("MTF Pullback Bot 시작 (Dry-run OOS 모드)")
bot = MTFPullbackBot(symbol="XRP/USDT:USDT")
loop = asyncio.get_running_loop()
shutdown = asyncio.Event()
def _on_signal():
logger.warning("종료 시그널 수신 (SIGTERM/SIGINT)")
shutdown.set()
for s in (sig.SIGTERM, sig.SIGINT):
loop.add_signal_handler(s, _on_signal)
bot_task = asyncio.create_task(bot.run(), name="mtf-bot")
shutdown_task = asyncio.create_task(shutdown.wait(), name="shutdown-wait")
done, pending = await asyncio.wait(
[bot_task, shutdown_task],
return_when=asyncio.FIRST_COMPLETED,
)
bot_task.cancel()
shutdown_task.cancel()
await asyncio.gather(bot_task, shutdown_task, return_exceptions=True)
logger.info("MTF Pullback Bot 종료")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -5,7 +5,7 @@ python-dotenv==1.0.0
httpx>=0.27.0 httpx>=0.27.0
pytest>=8.1.0 pytest>=8.1.0
pytest-asyncio>=0.24.0 pytest-asyncio>=0.24.0
aiohttp==3.9.3 aiohttp>=3.10.11
websockets==12.0 websockets==12.0
loguru==0.7.2 loguru==0.7.2
lightgbm>=4.3.0 lightgbm>=4.3.0
@@ -15,3 +15,4 @@ pyarrow>=15.0.0
onnxruntime>=1.18.0 onnxruntime>=1.18.0
optuna>=3.6.0 optuna>=3.6.0
quantstats>=0.0.81 quantstats>=0.0.81
ccxt>=4.5.0

121
scripts/collect_ls_ratio.py Normal file
View File

@@ -0,0 +1,121 @@
"""
Long/Short Ratio 장기 수집 스크립트.
15분마다 cron 실행하여 Binance Trading Data API에서
top_acct_ls_ratio, global_ls_ratio를 data/{symbol}/ls_ratio_15m.parquet에 누적한다.
수집 대상:
- topLongShortAccountRatio × 3심볼 (XRPUSDT, BTCUSDT, ETHUSDT)
- globalLongShortAccountRatio × 3심볼 (XRPUSDT, BTCUSDT, ETHUSDT)
→ 총 API 호출 6회/15분 (rate limit 무관)
사용법:
python scripts/collect_ls_ratio.py
python scripts/collect_ls_ratio.py --symbols XRPUSDT BTCUSDT
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import asyncio
from datetime import datetime, timezone
import aiohttp
import pandas as pd
BASE_URL = "https://fapi.binance.com"
DEFAULT_SYMBOLS = ["XRPUSDT", "BTCUSDT", "ETHUSDT"]
ENDPOINTS = {
"top_acct_ls_ratio": "/futures/data/topLongShortAccountRatio",
"global_ls_ratio": "/futures/data/globalLongShortAccountRatio",
}
async def fetch_latest(session: aiohttp.ClientSession, symbol: str) -> dict | None:
"""심볼 하나에 대해 두 ratio의 최신 1건씩 가져온다."""
row = {"timestamp": None, "symbol": symbol}
for col_name, endpoint in ENDPOINTS.items():
url = f"{BASE_URL}{endpoint}"
params = {"symbol": symbol, "period": "15m", "limit": 1}
try:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp:
data = await resp.json()
if isinstance(data, list) and data:
row[col_name] = float(data[0]["longShortRatio"])
# 타임스탬프는 첫 번째 응답에서 설정
if row["timestamp"] is None:
row["timestamp"] = pd.Timestamp(
int(data[0]["timestamp"]), unit="ms", tz="UTC"
)
else:
print(f"[WARN] {symbol} {col_name}: unexpected response: {data}")
return None
except Exception as e:
print(f"[ERROR] {symbol} {col_name}: {e}")
return None
return row
async def collect(symbols: list[str]):
"""모든 심볼 데이터를 수집하고 parquet에 추가한다."""
async with aiohttp.ClientSession() as session:
tasks = [fetch_latest(session, sym) for sym in symbols]
results = await asyncio.gather(*tasks)
now = datetime.now(timezone.utc)
collected = 0
for row in results:
if row is None:
continue
symbol = row["symbol"]
out_path = Path(f"data/{symbol.lower()}/ls_ratio_15m.parquet")
out_path.parent.mkdir(parents=True, exist_ok=True)
new_df = pd.DataFrame([{
"timestamp": row["timestamp"],
"top_acct_ls_ratio": row["top_acct_ls_ratio"],
"global_ls_ratio": row["global_ls_ratio"],
}])
if out_path.exists():
existing = pd.read_parquet(out_path)
# 중복 방지: 동일 timestamp가 이미 있으면 스킵
if row["timestamp"] in existing["timestamp"].values:
print(f"[SKIP] {symbol} ts={row['timestamp']} already exists")
continue
combined = pd.concat([existing, new_df], ignore_index=True)
else:
combined = new_df
combined.to_parquet(out_path, index=False)
collected += 1
print(
f"[{now.isoformat()}] {symbol}: "
f"top_acct={row['top_acct_ls_ratio']:.4f}, "
f"global={row['global_ls_ratio']:.4f} "
f"{out_path} ({len(combined)} rows)"
)
if collected == 0:
print(f"[{now.isoformat()}] No new data collected")
def main():
parser = argparse.ArgumentParser(description="L/S Ratio 장기 수집")
parser.add_argument(
"--symbols", nargs="+", default=DEFAULT_SYMBOLS,
help="수집 대상 심볼 (기본: XRPUSDT BTCUSDT ETHUSDT)",
)
args = parser.parse_args()
asyncio.run(collect(args.symbols))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,27 @@
#!/bin/sh
# 15분 경계에 맞춰 collect_ls_ratio.py를 반복 실행한다.
# Docker 컨테이너 entrypoint용.
set -e
echo "[collect_ls_ratio] Starting loop (interval: 15m)"
while true; do
# 현재 분/초를 기준으로 다음 15분 경계(00/15/30/45)까지 대기
now_min=$(date -u +%M | sed 's/^0//')
now_sec=$(date -u +%S | sed 's/^0//')
# 다음 15분 경계까지 남은 분
remainder=$((now_min % 15))
wait_min=$((15 - remainder))
# 초 단위로 변환 (경계 직후 10초 여유)
wait_sec=$(( wait_min * 60 - now_sec + 10 ))
if [ "$wait_sec" -le 10 ]; then
wait_sec=$((wait_sec + 900))
fi
echo "[collect_ls_ratio] Next run in ${wait_sec}s ($(date -u))"
sleep "$wait_sec"
echo "[collect_ls_ratio] Running collection... ($(date -u))"
python scripts/collect_ls_ratio.py || echo "[collect_ls_ratio] ERROR: collection failed"
done

175
scripts/evaluate_oos.py Normal file
View File

@@ -0,0 +1,175 @@
"""
MTF Pullback Bot — OOS Dry-run 평가 스크립트
─────────────────────────────────────────────
프로덕션 서버에서 JSONL 거래 기록을 가져와
승률·PF·누적PnL·평균보유시간을 계산하고 LIVE 배포 판정을 출력한다.
Usage:
python scripts/evaluate_oos.py
python scripts/evaluate_oos.py --symbol xrpusdt
python scripts/evaluate_oos.py --local # 로컬 파일만 사용 (서버 fetch 스킵)
"""
import argparse
import subprocess
import sys
from pathlib import Path
import pandas as pd
# ── 설정 ──────────────────────────────────────────────────────────
PROD_HOST = "root@10.1.10.24"
REMOTE_DIR = "/root/cointrader/data/trade_history"
LOCAL_DIR = Path("data/trade_history")
# ── 판정 기준 ─────────────────────────────────────────────────────
MIN_TRADES = 5
MIN_PF = 1.0
def fetch_from_prod(filename: str) -> Path:
"""프로덕션 서버에서 JSONL 파일을 scp로 가져온다."""
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
remote_path = f"{PROD_HOST}:{REMOTE_DIR}/{filename}"
local_path = LOCAL_DIR / filename
print(f"[Fetch] {remote_path}{local_path}")
result = subprocess.run(
["scp", remote_path, str(local_path)],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f"[Fetch] scp 실패: {result.stderr.strip()}")
if local_path.exists():
print(f"[Fetch] 로컬 캐시 사용: {local_path}")
else:
print("[Fetch] 로컬 캐시도 없음. 종료.")
sys.exit(1)
else:
print(f"[Fetch] 완료 ({local_path.stat().st_size:,} bytes)")
return local_path
def load_trades(path: Path) -> pd.DataFrame:
"""JSONL 파일을 DataFrame으로 로드."""
df = pd.read_json(path, lines=True)
if df.empty:
print("[Load] 거래 기록이 비어있습니다.")
sys.exit(1)
df["entry_ts"] = pd.to_datetime(df["entry_ts"], utc=True)
df["exit_ts"] = pd.to_datetime(df["exit_ts"], utc=True)
df["duration_min"] = (df["exit_ts"] - df["entry_ts"]).dt.total_seconds() / 60
print(f"[Load] {len(df)}건 로드 완료 ({df['entry_ts'].min():%Y-%m-%d} ~ {df['exit_ts'].max():%Y-%m-%d})")
return df
def calc_metrics(df: pd.DataFrame) -> dict:
"""핵심 지표 계산. 빈 DataFrame이면 안전한 기본값 반환."""
n = len(df)
if n == 0:
return {"trades": 0, "win_rate": 0.0, "pf": 0.0, "cum_pnl": 0.0, "avg_dur": 0.0}
wins = df[df["pnl_bps"] > 0]
losses = df[df["pnl_bps"] < 0]
win_rate = len(wins) / n * 100
gross_profit = wins["pnl_bps"].sum() if len(wins) > 0 else 0.0
gross_loss = abs(losses["pnl_bps"].sum()) if len(losses) > 0 else 0.0
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
cum_pnl = df["pnl_bps"].sum()
avg_dur = df["duration_min"].mean()
return {
"trades": n,
"win_rate": round(win_rate, 1),
"pf": round(pf, 2),
"cum_pnl": round(cum_pnl, 1),
"avg_dur": round(avg_dur, 1),
}
def print_report(df: pd.DataFrame):
"""성적표 출력."""
total = calc_metrics(df)
longs = calc_metrics(df[df["side"] == "LONG"])
shorts = calc_metrics(df[df["side"] == "SHORT"])
header = f"{'':>10} {'Trades':>8} {'WinRate':>9} {'PF':>8} {'CumPnL':>10} {'AvgDur':>10}"
sep = "" * 60
print()
print(sep)
print(" MTF Pullback Bot — OOS Dry-run 성적표")
print(sep)
print(header)
print(sep)
for label, m in [("Total", total), ("LONG", longs), ("SHORT", shorts)]:
pf_str = f"{m['pf']:.2f}" if m["pf"] != float("inf") else ""
dur_str = f"{m['avg_dur']:.0f}m" if m["trades"] > 0 else "-"
print(
f"{label:>10} {m['trades']:>8d} {m['win_rate']:>8.1f}% {pf_str:>8} "
f"{m['cum_pnl']:>+10.1f} {dur_str:>10}"
)
print(sep)
# ── 개별 거래 내역 ──
print()
print(" 거래 내역")
print(sep)
print(f"{'#':>3} {'Side':>6} {'Entry':>10} {'Exit':>10} {'PnL(bps)':>10} {'Dur':>8} {'Reason'}")
print(sep)
for i, row in df.iterrows():
dur = f"{row['duration_min']:.0f}m"
reason = row.get("reason", "")
if len(reason) > 25:
reason = reason[:25] + ""
print(
f"{i+1:>3} {row['side']:>6} {row['entry_price']:>10.4f} {row['exit_price']:>10.4f} "
f"{row['pnl_bps']:>+10.1f} {dur:>8} {reason}"
)
print(sep)
# ── 최종 판정 ──
print()
if total["trades"] >= MIN_TRADES and total["pf"] >= MIN_PF:
print(f" [판정: 통과] 엣지가 증명되었습니다. LIVE 배포(자금 투입)를 권장합니다.")
print(f" (거래수 {total['trades']} >= {MIN_TRADES}, PF {total['pf']:.2f} >= {MIN_PF:.1f})")
else:
reasons = []
if total["trades"] < MIN_TRADES:
reasons.append(f"거래수 {total['trades']} < {MIN_TRADES}")
if total["pf"] < MIN_PF:
reasons.append(f"PF {total['pf']:.2f} < {MIN_PF:.1f}")
print(f" [판정: 보류] 기준 미달. OOS 검증 실패로 실전 투입을 보류합니다.")
print(f" ({', '.join(reasons)})")
print()
def main():
parser = argparse.ArgumentParser(description="MTF OOS Dry-run 평가")
parser.add_argument("--symbol", default="xrpusdt", help="심볼 (파일명 소문자, 기본: xrpusdt)")
parser.add_argument("--local", action="store_true", help="로컬 파일만 사용 (서버 fetch 스킵)")
args = parser.parse_args()
filename = f"mtf_{args.symbol}.jsonl"
if args.local:
local_path = LOCAL_DIR / filename
if not local_path.exists():
print(f"[Error] 로컬 파일 없음: {local_path}")
sys.exit(1)
else:
local_path = fetch_from_prod(filename)
df = load_trades(local_path)
print_report(df)
if __name__ == "__main__":
main()

342
scripts/mtf_backtest.py Normal file
View File

@@ -0,0 +1,342 @@
"""
MTF Pullback Backtest
─────────────────────
Trigger: 1h 추세 방향으로 15m 눌림목 진입
LONG: 1h Meta=LONG + 15m close < EMA20 + vol < SMA20*0.5 → 다음 봉 close > EMA20 시 진입
SHORT: 1h Meta=SHORT + 15m close > EMA20 + vol < SMA20*0.5 → 다음 봉 close < EMA20 시 진입
SL/TP: 1h ATR 기반 (진입 시점 직전 완성된 1h 캔들)
Look-ahead bias 방지: 1h 지표는 직전 완성 봉만 사용
"""
import pandas as pd
import pandas_ta as ta
import numpy as np
from pathlib import Path
from dataclasses import dataclass
# ─── 설정 ────────────────────────────────────────────────────────
SYMBOL = "xrpusdt"
DATA_PATH = Path(f"data/{SYMBOL}/combined_15m.parquet")
START = "2026-02-01"
END = "2026-03-30"
ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.3
FEE_RATE = 0.0004 # 0.04% per side
# 1h 메타필터
MTF_EMA_FAST = 50
MTF_EMA_SLOW = 200
MTF_ADX_THRESHOLD = 20
# 15m Trigger
EMA_PULLBACK_LEN = 20
VOL_DRY_RATIO = 0.5 # volume < vol_ma20 * 0.5
@dataclass
class Trade:
entry_time: pd.Timestamp
entry_price: float
side: str
sl: float
tp: float
exit_time: pd.Timestamp | None = None
exit_price: float | None = None
pnl_pct: float | None = None
def build_1h_data(df_15m: pd.DataFrame) -> pd.DataFrame:
"""15m → 1h 리샘플링 + EMA50, EMA200, ADX, ATR."""
df_1h = df_15m[["open", "high", "low", "close", "volume"]].resample("1h").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna()
df_1h["ema50_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_FAST)
df_1h["ema200_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_SLOW)
adx_df = ta.adx(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
df_1h["adx_1h"] = adx_df["ADX_14"]
df_1h["atr_1h"] = ta.atr(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
return df_1h[["ema50_1h", "ema200_1h", "adx_1h", "atr_1h"]]
def merge_1h_to_15m(df_15m: pd.DataFrame, df_1h: pd.DataFrame) -> pd.DataFrame:
"""Look-ahead bias 방지: 1h 봉 완성 시점(+1h) 기준 backward merge."""
df_1h_shifted = df_1h.copy()
df_1h_shifted.index = df_1h_shifted.index + pd.Timedelta(hours=1)
df_15m_reset = df_15m.reset_index()
df_1h_reset = df_1h_shifted.reset_index()
df_1h_reset.rename(columns={"index": "timestamp"}, inplace=True)
if "timestamp" not in df_15m_reset.columns:
df_15m_reset.rename(columns={df_15m_reset.columns[0]: "timestamp"}, inplace=True)
df_15m_reset["timestamp"] = pd.to_datetime(df_15m_reset["timestamp"]).astype("datetime64[us]")
df_1h_reset["timestamp"] = pd.to_datetime(df_1h_reset["timestamp"]).astype("datetime64[us]")
merged = pd.merge_asof(
df_15m_reset.sort_values("timestamp"),
df_1h_reset.sort_values("timestamp"),
on="timestamp",
direction="backward",
)
return merged.set_index("timestamp")
def get_1h_meta(row) -> str:
"""1h 메타필터: EMA50/200 방향 + ADX > 20."""
ema50 = row.get("ema50_1h")
ema200 = row.get("ema200_1h")
adx = row.get("adx_1h")
if pd.isna(ema50) or pd.isna(ema200) or pd.isna(adx):
return "HOLD"
if adx < MTF_ADX_THRESHOLD:
return "HOLD"
if ema50 > ema200:
return "LONG"
elif ema50 < ema200:
return "SHORT"
return "HOLD"
def calc_metrics(trades: list[Trade]) -> dict:
if not trades:
return {"trades": 0, "win_rate": 0, "pf": 0, "pnl_bps": 0, "max_dd_bps": 0,
"avg_win_bps": 0, "avg_loss_bps": 0, "long_trades": 0, "short_trades": 0}
pnls = [t.pnl_pct for t in trades]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
gross_profit = sum(wins) if wins else 0
gross_loss = abs(sum(losses)) if losses else 0
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
cumulative = np.cumsum(pnls)
peak = np.maximum.accumulate(cumulative)
dd = cumulative - peak
max_dd = abs(dd.min()) if len(dd) > 0 else 0
return {
"trades": len(trades),
"win_rate": len(wins) / len(trades) * 100,
"pf": round(pf, 2),
"pnl_bps": round(sum(pnls) * 10000, 1),
"max_dd_bps": round(max_dd * 10000, 1),
"avg_win_bps": round(np.mean(wins) * 10000, 1) if wins else 0,
"avg_loss_bps": round(np.mean(losses) * 10000, 1) if losses else 0,
"long_trades": sum(1 for t in trades if t.side == "LONG"),
"short_trades": sum(1 for t in trades if t.side == "SHORT"),
}
def main():
print("=" * 70)
print(" MTF Pullback Backtest")
print(f" {SYMBOL.upper()} | {START} ~ {END}")
print(f" SL: 1h ATR×{ATR_SL_MULT} | TP: 1h ATR×{ATR_TP_MULT} | Fee: {FEE_RATE*100:.2f}%/side")
print(f" Pullback: EMA{EMA_PULLBACK_LEN} | Vol dry: <{VOL_DRY_RATIO*100:.0f}% of SMA20")
print("=" * 70)
# ── 데이터 로드 ──
df_raw = pd.read_parquet(DATA_PATH)
if df_raw.index.tz is not None:
df_raw.index = df_raw.index.tz_localize(None)
# 1h EMA200 워밍업 (200h = 800 bars)
warmup_start = pd.Timestamp(START) - pd.Timedelta(hours=250)
df_full = df_raw[df_raw.index >= warmup_start].copy()
print(f"\n데이터: {len(df_full)} bars (워밍업 포함)")
# ── 15m 지표: EMA20, vol_ma20 ──
df_full["ema20"] = ta.ema(df_full["close"], length=EMA_PULLBACK_LEN)
df_full["vol_ma20"] = ta.sma(df_full["volume"], length=20)
# ── 1h 지표 ──
df_1h = build_1h_data(df_full)
print(f"1h 캔들: {len(df_1h)} bars")
# ── 병합 ──
df_merged = merge_1h_to_15m(df_full, df_1h)
# ── 분석 기간 ──
df = df_merged[(df_merged.index >= START) & (df_merged.index <= END)].copy()
print(f"분석 기간: {len(df)} bars ({df.index.min()} ~ {df.index.max()})")
# ── 신호 스캔 & 시뮬레이션 ──
trades: list[Trade] = []
in_trade = False
current_trade: Trade | None = None
pullback_ready = False # 눌림 감지 상태
pullback_side = ""
# 디버그 카운터
meta_long_count = 0
meta_short_count = 0
pullback_detected = 0
entry_triggered = 0
for i in range(1, len(df)):
row = df.iloc[i]
prev = df.iloc[i - 1]
# ── 기존 포지션 SL/TP 체크 ──
if in_trade and current_trade is not None:
hit_sl = False
hit_tp = False
if current_trade.side == "LONG":
if row["low"] <= current_trade.sl:
hit_sl = True
if row["high"] >= current_trade.tp:
hit_tp = True
else:
if row["high"] >= current_trade.sl:
hit_sl = True
if row["low"] <= current_trade.tp:
hit_tp = True
if hit_sl or hit_tp:
exit_price = current_trade.sl if hit_sl else current_trade.tp
if hit_sl and hit_tp:
exit_price = current_trade.sl # 보수적
if current_trade.side == "LONG":
raw_pnl = (exit_price - current_trade.entry_price) / current_trade.entry_price
else:
raw_pnl = (current_trade.entry_price - exit_price) / current_trade.entry_price
current_trade.exit_time = df.index[i]
current_trade.exit_price = exit_price
current_trade.pnl_pct = raw_pnl - FEE_RATE * 2
trades.append(current_trade)
in_trade = False
current_trade = None
# ── 포지션 중이면 새 진입 스킵 ──
if in_trade:
continue
# NaN 체크
if pd.isna(row.get("ema20")) or pd.isna(row.get("vol_ma20")) or pd.isna(row.get("atr_1h")):
pullback_ready = False
continue
# ── Step 1: 1h Meta Filter ──
meta = get_1h_meta(row)
if meta == "LONG":
meta_long_count += 1
elif meta == "SHORT":
meta_short_count += 1
if meta == "HOLD":
pullback_ready = False
continue
# ── Step 2: 눌림(Pullback) 감지 ──
# 이전 봉이 눌림 조건을 충족했는지 확인
if pullback_ready and pullback_side == meta:
# ── Step 4: 추세 재개 확인 (현재 봉 close 기준) ──
if pullback_side == "LONG" and row["close"] > row["ema20"]:
# 진입: 이 봉의 open (추세 재개 확인된 봉)
# 실제로는 close 시점에 확인하므로 다음 봉 open에 진입해야 look-ahead 방지
# 하지만 사양서에 "직후 캔들의 종가가 EMA20 상향 돌파한 첫 번째 캔들의 시가"라고 되어 있으므로
# → 이 봉(close > EMA20)의 open에서 진입은 look-ahead bias
# → 정확히는: prev가 pullback, 현재 봉 close > EMA20 확인 → 다음 봉 open 진입
# 여기서는 다음 봉 open으로 처리
if i + 1 < len(df):
next_row = df.iloc[i + 1]
entry_price = next_row["open"]
atr_1h = row["atr_1h"]
sl = entry_price - atr_1h * ATR_SL_MULT
tp = entry_price + atr_1h * ATR_TP_MULT
current_trade = Trade(
entry_time=df.index[i + 1],
entry_price=entry_price,
side="LONG",
sl=sl, tp=tp,
)
in_trade = True
pullback_ready = False
entry_triggered += 1
continue
elif pullback_side == "SHORT" and row["close"] < row["ema20"]:
if i + 1 < len(df):
next_row = df.iloc[i + 1]
entry_price = next_row["open"]
atr_1h = row["atr_1h"]
sl = entry_price + atr_1h * ATR_SL_MULT
tp = entry_price - atr_1h * ATR_TP_MULT
current_trade = Trade(
entry_time=df.index[i + 1],
entry_price=entry_price,
side="SHORT",
sl=sl, tp=tp,
)
in_trade = True
pullback_ready = False
entry_triggered += 1
continue
# ── Step 2+3: 눌림 + 거래량 고갈 감지 (다음 봉에서 재개 확인) ──
vol_dry = row["volume"] < row["vol_ma20"] * VOL_DRY_RATIO
if meta == "LONG" and row["close"] < row["ema20"] and vol_dry:
pullback_ready = True
pullback_side = "LONG"
pullback_detected += 1
elif meta == "SHORT" and row["close"] > row["ema20"] and vol_dry:
pullback_ready = True
pullback_side = "SHORT"
pullback_detected += 1
else:
# 조건 불충족 시 pullback 상태 리셋
# 단, 연속 pullback 허용 (여러 봉 동안 눌림 지속 가능)
if not (meta == pullback_side):
pullback_ready = False
# ── 결과 출력 ──
m = calc_metrics(trades)
long_trades = [t for t in trades if t.side == "LONG"]
short_trades = [t for t in trades if t.side == "SHORT"]
lm = calc_metrics(long_trades)
sm = calc_metrics(short_trades)
print(f"\n─── 신호 파이프라인 ───")
print(f"1h Meta LONG: {meta_long_count} bars | SHORT: {meta_short_count} bars")
print(f"Pullback 감지: {pullback_detected}")
print(f"진입 트리거: {entry_triggered}")
print(f"실제 거래: {m['trades']}건 (L:{m['long_trades']} / S:{m['short_trades']})")
print(f"\n{'=' * 70}")
print(f" 결과")
print(f"{'=' * 70}")
header = f"{'구분':<10} {'Trades':>7} {'WinRate':>8} {'PF':>6} {'PnL(bps)':>10} {'MaxDD(bps)':>11} {'AvgWin':>8} {'AvgLoss':>8}"
print(header)
print("-" * len(header))
print(f"{'전체':<10} {m['trades']:>7} {m['win_rate']:>7.1f}% {m['pf']:>6.2f} {m['pnl_bps']:>10.1f} {m['max_dd_bps']:>11.1f} {m['avg_win_bps']:>8.1f} {m['avg_loss_bps']:>8.1f}")
print(f"{'LONG':<10} {lm['trades']:>7} {lm['win_rate']:>7.1f}% {lm['pf']:>6.2f} {lm['pnl_bps']:>10.1f} {lm['max_dd_bps']:>11.1f} {lm['avg_win_bps']:>8.1f} {lm['avg_loss_bps']:>8.1f}")
print(f"{'SHORT':<10} {sm['trades']:>7} {sm['win_rate']:>7.1f}% {sm['pf']:>6.2f} {sm['pnl_bps']:>10.1f} {sm['max_dd_bps']:>11.1f} {sm['avg_win_bps']:>8.1f} {sm['avg_loss_bps']:>8.1f}")
# 개별 거래 목록
if trades:
print(f"\n─── 개별 거래 ───")
print(f"{'#':>3} {'Side':<6} {'Entry Time':<20} {'Entry':>10} {'Exit':>10} {'PnL(bps)':>10} {'Result':>8}")
print("-" * 75)
for idx, t in enumerate(trades, 1):
result = "WIN" if t.pnl_pct > 0 else "LOSS"
pnl_bps = t.pnl_pct * 10000
print(f"{idx:>3} {t.side:<6} {str(t.entry_time):<20} {t.entry_price:>10.4f} {t.exit_price:>10.4f} {pnl_bps:>+10.1f} {result:>8}")
if __name__ == "__main__":
main()

View File

@@ -20,6 +20,8 @@ import json
from datetime import datetime from datetime import datetime
import numpy as np import numpy as np
import pandas as pd
import pandas_ta as ta
from loguru import logger from loguru import logger
@@ -107,6 +109,174 @@ def print_fold_table(folds: list[dict]):
print("=" * 90) print("=" * 90)
def _classify_regime(btc_return: float, btc_avg_adx: float) -> str:
"""BTC ADX와 수익률 기반 시장 레짐 분류."""
if btc_avg_adx >= 25:
return "상승 추세" if btc_return > 0 else "하락 추세"
return "횡보"
def _calc_fold_market_context(
raw_df: pd.DataFrame, test_start: str, test_end: str
) -> dict:
"""폴드 기간의 BTC/ETH 수익률과 시장 레짐 계산."""
ts_start = pd.Timestamp(test_start)
ts_end = pd.Timestamp(test_end)
idx = raw_df.index
if idx.tz is not None:
idx = idx.tz_localize(None)
if ts_start.tz is not None:
ts_start = ts_start.tz_localize(None)
if ts_end.tz is not None:
ts_end = ts_end.tz_localize(None)
fold_df = raw_df[(idx >= ts_start) & (idx < ts_end)]
if len(fold_df) < 20:
return None
# BTC return
btc_start = fold_df["close_btc"].iloc[0]
btc_end = fold_df["close_btc"].iloc[-1]
btc_return = (btc_end - btc_start) / btc_start * 100
# ETH return
eth_start = fold_df["close_eth"].iloc[0]
eth_end = fold_df["close_eth"].iloc[-1]
eth_return = (eth_end - eth_start) / eth_start * 100
# BTC ADX (period average)
adx_df = ta.adx(fold_df["high_btc"], fold_df["low_btc"], fold_df["close_btc"], length=14)
btc_avg_adx = adx_df["ADX_14"].mean()
if np.isnan(btc_avg_adx):
btc_avg_adx = 0.0
regime = _classify_regime(btc_return, btc_avg_adx)
return {
"btc_return_pct": round(btc_return, 1),
"eth_return_pct": round(eth_return, 1),
"btc_avg_adx": round(btc_avg_adx, 1),
"market_regime": regime,
}
def _load_ls_ratio(symbol: str, test_start: str, test_end: str) -> dict | None:
"""폴드 기간의 L/S ratio 평균값 로드. 데이터 없으면 None."""
path = Path(f"data/{symbol.lower()}/ls_ratio_15m.parquet")
if not path.exists():
return None
df = pd.read_parquet(path)
ts_start = pd.Timestamp(test_start)
ts_end = pd.Timestamp(test_end)
# tz 맞추기
if df["timestamp"].dt.tz is not None:
if ts_start.tz is None:
ts_start = ts_start.tz_localize("UTC")
if ts_end.tz is None:
ts_end = ts_end.tz_localize("UTC")
mask = (df["timestamp"] >= ts_start) & (df["timestamp"] < ts_end)
period_df = df[mask]
if period_df.empty:
return None
return {
"top_acct_avg": round(period_df["top_acct_ls_ratio"].mean(), 2),
"global_avg": round(period_df["global_ls_ratio"].mean(), 2),
}
def calc_market_context(folds: list[dict], symbols: list[str]) -> list[dict]:
"""각 폴드에 대한 시장 컨텍스트 계산."""
# XRP parquet에서 BTC/ETH 데이터 로드 (임베딩됨)
primary_sym = symbols[0].lower()
raw_path = Path(f"data/{primary_sym}/combined_15m.parquet")
if not raw_path.exists():
logger.warning(f"데이터 파일 없음: {raw_path}")
return []
raw_df = pd.read_parquet(raw_path)
if "close_btc" not in raw_df.columns or "close_eth" not in raw_df.columns:
logger.warning("BTC/ETH 상관 데이터 없음")
return []
contexts = []
for fold in folds:
test_start = fold.get("test_start")
test_end = fold.get("test_end")
if not test_start or not test_end:
contexts.append({"fold": fold["fold"], "market_context": None})
continue
ctx = _calc_fold_market_context(raw_df, test_start, test_end)
if ctx is None:
contexts.append({"fold": fold["fold"], "market_context": None})
continue
# L/S ratio (XRP, BTC, ETH)
ls_data = {}
for ls_sym in ["xrpusdt", "btcusdt", "ethusdt"]:
ls = _load_ls_ratio(ls_sym, test_start, test_end)
if ls:
ls_data[ls_sym.replace("usdt", "")] = ls
ctx["ls_ratio"] = ls_data if ls_data else None
contexts.append({"fold": fold["fold"], "market_context": ctx})
return contexts
def print_market_context(contexts: list[dict]):
"""시장 컨텍스트 테이블 출력."""
if not contexts:
return
# Market Regime 테이블
print("\n📊 Market Context per Fold")
print(f"{'' * 80}")
print(f" {'Fold':>4} {'BTC Return':>12} {'ETH Return':>12} {'Market Regime':<32}")
print(f"{'' * 80}")
for c in contexts:
ctx = c.get("market_context")
if ctx is None:
print(f" {c['fold']:>4} {'N/A':>12} {'N/A':>12} {'N/A':<32}")
else:
regime_str = f"{ctx['market_regime']} (BTC ADX {ctx['btc_avg_adx']:.0f})"
print(f" {c['fold']:>4} {ctx['btc_return_pct']:>+11.1f}% "
f"{ctx['eth_return_pct']:>+11.1f}% {regime_str:<32}")
print(f"{'' * 80}")
# L/S Ratio 테이블 (데이터 있는 폴드가 하나라도 있으면)
has_ls = any(
c.get("market_context") and c["market_context"].get("ls_ratio")
for c in contexts
)
if has_ls:
print("\n📊 L/S Ratio Context per Fold (period avg)")
print(f"{'' * 80}")
print(f" {'Fold':>4} {'XRP Top/Global':>18} {'BTC Top/Global':>18} {'ETH Top/Global':>18}")
print(f"{'' * 80}")
for c in contexts:
ctx = c.get("market_context")
ls = ctx.get("ls_ratio") if ctx else None
parts = []
for sym in ["xrp", "btc", "eth"]:
if ls and sym in ls:
parts.append(f"{ls[sym]['top_acct_avg']:.2f} / {ls[sym]['global_avg']:.2f}")
else:
parts.append("N/A")
print(f" {c['fold']:>4} {parts[0]:>18} {parts[1]:>18} {parts[2]:>18}")
print(f"{'' * 80}")
else:
print(" L/S ratio 데이터 없음 — collector 데이터 축적 후 표시됩니다")
def save_result(result: dict, cfg): def save_result(result: dict, cfg):
ts = datetime.now().strftime("%Y%m%d_%H%M%S") ts = datetime.now().strftime("%Y%m%d_%H%M%S")
mode = result.get("mode", "standard") mode = result.get("mode", "standard")
@@ -183,6 +353,11 @@ def compare_ml(symbols: list[str], args):
print_summary(result["summary"], cfg, mode="walk_forward") print_summary(result["summary"], cfg, mode="walk_forward")
if result.get("folds"): if result.get("folds"):
print_fold_table(result["folds"]) print_fold_table(result["folds"])
# 시장 컨텍스트는 첫 번째 실행에서만 출력 (동일 데이터)
if label == "ML OFF":
contexts = calc_market_context(result["folds"], symbols)
if contexts:
print_market_context(contexts)
_print_comparison(results, symbols) _print_comparison(results, symbols)
@@ -343,6 +518,12 @@ def main():
print_summary(result["summary"], cfg, mode="walk_forward") print_summary(result["summary"], cfg, mode="walk_forward")
if result.get("folds"): if result.get("folds"):
print_fold_table(result["folds"]) print_fold_table(result["folds"])
contexts = calc_market_context(result["folds"], symbols)
if contexts:
print_market_context(contexts)
# JSON에 market_context 추가
for fold, ctx in zip(result["folds"], contexts):
fold["market_context"] = ctx.get("market_context")
save_result(result, cfg) save_result(result, cfg)
else: else:
cfg = BacktestConfig( cfg = BacktestConfig(

View File

@@ -0,0 +1,256 @@
"""
Taker Buy/Sell Ratio vs Next-Candle Price Change Correlation Analysis
- Taker Buy Ratio (from klines + Trading Data API)
- Long/Short Ratio (global)
- Top Trader Long/Short Ratio (accounts & positions)
Usage: python scripts/taker_ratio_analysis.py [SYMBOL1] [SYMBOL2] ...
Default: XRPUSDT BTCUSDT ETHUSDT
"""
import asyncio
import aiohttp
import pandas as pd
import numpy as np
from datetime import datetime, timedelta, timezone
import sys
BASE = "https://fapi.binance.com"
SYMBOLS = sys.argv[1:] if len(sys.argv) > 1 else ["XRPUSDT", "BTCUSDT", "ETHUSDT"]
INTERVAL = "15m"
DAYS = 30
async def fetch_json(session, url, params):
async with session.get(url, params=params) as resp:
return await resp.json()
async def fetch_klines(session, symbol, start_ms, end_ms):
all_klines = []
current = start_ms
while current < end_ms:
params = {"symbol": symbol, "interval": INTERVAL, "startTime": current, "endTime": end_ms, "limit": 1500}
data = await fetch_json(session, f"{BASE}/fapi/v1/klines", params)
if not data:
break
all_klines.extend(data)
current = data[-1][0] + 1
return all_klines
async def fetch_ratio(session, url, symbol):
params = {"symbol": symbol, "period": INTERVAL, "limit": 500}
data = await fetch_json(session, url, params)
return data if isinstance(data, list) else []
async def analyze_symbol(session, symbol, start_ms, end_ms):
"""Fetch and analyze a single symbol"""
klines, ls_ratio, top_acct, top_pos, taker = await asyncio.gather(
fetch_klines(session, symbol, start_ms, end_ms),
fetch_ratio(session, f"{BASE}/futures/data/globalLongShortAccountRatio", symbol),
fetch_ratio(session, f"{BASE}/futures/data/topLongShortAccountRatio", symbol),
fetch_ratio(session, f"{BASE}/futures/data/topLongShortPositionRatio", symbol),
fetch_ratio(session, f"{BASE}/futures/data/takerlongshortRatio", symbol),
)
print(f"\n {symbol}: Klines={len(klines)}, L/S={len(ls_ratio)}, TopAcct={len(top_acct)}, TopPos={len(top_pos)}, Taker={len(taker)}")
# Build DataFrame
df_k = pd.DataFrame(klines, columns=[
"open_time","open","high","low","close","volume",
"close_time","quote_vol","trades","taker_buy_vol","taker_buy_quote_vol","ignore"
])
df_k["open_time"] = pd.to_datetime(df_k["open_time"], unit="ms")
for c in ["open","high","low","close","volume","taker_buy_vol","taker_buy_quote_vol","quote_vol"]:
df_k[c] = df_k[c].astype(float)
df_k["kline_taker_buy_ratio"] = (df_k["taker_buy_vol"] / df_k["volume"]).replace([np.inf, -np.inf], np.nan)
df_k["next_return"] = df_k["close"].shift(-1) / df_k["close"] - 1
df_k["next_4_return"] = df_k["close"].shift(-4) / df_k["close"] - 1
df_k = df_k.set_index("open_time")
def join_ratio(data, col_name):
if not data:
return
df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["timestamp"].astype(int), unit="ms")
if "buySellRatio" in df.columns:
df["buySellRatio"] = df["buySellRatio"].astype(float)
df["buyVol"] = df["buyVol"].astype(float)
df["sellVol"] = df["sellVol"].astype(float)
df = df.set_index("timestamp")
df_k.update(df_k.join(df[["buySellRatio","buyVol","sellVol"]], how="left"))
for c in ["buySellRatio","buyVol","sellVol"]:
if c not in df_k.columns:
df_k[c] = np.nan
joined = df_k.join(df[["buySellRatio","buyVol","sellVol"]], how="left", rsuffix="_new")
for c in ["buySellRatio","buyVol","sellVol"]:
if f"{c}_new" in joined.columns:
df_k[c] = joined[f"{c}_new"]
else:
df["longShortRatio"] = df["longShortRatio"].astype(float)
df = df.set_index("timestamp").rename(columns={"longShortRatio": col_name})
df_k[col_name] = df_k.join(df[[col_name]], how="left")[col_name]
join_ratio(taker, "buySellRatio")
join_ratio(ls_ratio, "global_ls_ratio")
join_ratio(top_acct, "top_acct_ls_ratio")
join_ratio(top_pos, "top_pos_ls_ratio")
return df_k
def print_analysis(symbol, df_k):
"""Print analysis results for a symbol"""
print("\n" + "="*70)
print(f"{symbol} {INTERVAL} Taker/Ratio → Price Correlation Analysis ({DAYS} days klines, ~5 days ratios)")
print("="*70)
features = ["kline_taker_buy_ratio", "buySellRatio", "global_ls_ratio",
"top_acct_ls_ratio", "top_pos_ls_ratio"]
available = [f for f in features if f in df_k.columns and df_k[f].notna().sum() > 20]
# 1. Correlation
print("\n[1] Pearson Correlation with Next-Candle Returns")
print("-"*55)
print(f"{'Feature':<25} {'next_15m':>12} {'next_1h':>12}")
print("-"*55)
for feat in available:
c1 = df_k[feat].corr(df_k["next_return"])
c4 = df_k[feat].corr(df_k["next_4_return"])
print(f"{feat:<25} {c1:>12.4f} {c4:>12.4f}")
# 2. Quintile - Taker
print("\n[2] Taker Buy Ratio Quintile → Next Returns")
print("-"*60)
for ratio_col in ["kline_taker_buy_ratio", "buySellRatio"]:
if ratio_col not in available:
continue
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
try:
valid["quintile"] = pd.qcut(valid[ratio_col], 5, labels=["Q1(sell)","Q2","Q3","Q4","Q5(buy)"])
except ValueError:
continue
print(f"\n {ratio_col}:")
print(f" {'Quintile':<12} {'mean_ratio':>12} {'next_15m_bps':>14} {'next_1h_bps':>13} {'count':>7} {'win_rate':>10}")
for q in ["Q1(sell)","Q2","Q3","Q4","Q5(buy)"]:
grp = valid[valid["quintile"] == q]
if len(grp) == 0:
continue
mr = grp[ratio_col].mean()
r1 = grp["next_return"].mean() * 10000
r4 = grp["next_4_return"].mean() * 10000
wr = (grp["next_return"] > 0).mean() * 100
print(f" {q:<12} {mr:>12.4f} {r1:>14.2f} {r4:>13.2f} {len(grp):>7} {wr:>9.1f}%")
# 3. Extreme analysis
print("\n[3] Extreme Taker Buy Ratio Analysis (top/bottom 10%)")
print("-"*60)
for ratio_col in ["kline_taker_buy_ratio", "buySellRatio"]:
if ratio_col not in available:
continue
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
p10 = valid[ratio_col].quantile(0.10)
p90 = valid[ratio_col].quantile(0.90)
bottom = valid[valid[ratio_col] <= p10]
top = valid[valid[ratio_col] >= p90]
mid = valid[(valid[ratio_col] > p10) & (valid[ratio_col] < p90)]
print(f"\n {ratio_col}:")
print(f" {'Group':<18} {'mean_ratio':>12} {'next_15m_bps':>14} {'next_1h_bps':>13} {'win_rate':>10} {'count':>7}")
for name, grp in [("Bottom 10% (sell)", bottom), ("Middle 80%", mid), ("Top 10% (buy)", top)]:
if len(grp) == 0:
continue
mr = grp[ratio_col].mean()
r1 = grp["next_return"].mean() * 10000
r4 = grp["next_4_return"].mean() * 10000
wr = (grp["next_return"] > 0).mean() * 100
print(f" {name:<18} {mr:>12.4f} {r1:>14.2f} {r4:>13.2f} {wr:>9.1f}% {len(grp):>7}")
# 4. L/S ratio quintile
print("\n[4] Long/Short Ratio Quintile → Next Returns")
print("-"*60)
for ratio_col in ["global_ls_ratio", "top_acct_ls_ratio", "top_pos_ls_ratio"]:
if ratio_col not in available:
continue
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
if len(valid) < 20:
continue
try:
valid["quintile"] = pd.qcut(valid[ratio_col], 5, labels=["Q1(short)","Q2","Q3","Q4","Q5(long)"], duplicates="drop")
except ValueError:
continue
print(f"\n {ratio_col}:")
print(f" {'Quintile':<12} {'mean_ratio':>12} {'next_15m_bps':>14} {'next_1h_bps':>13} {'win_rate':>10} {'count':>7}")
for q in valid["quintile"].cat.categories:
grp = valid[valid["quintile"] == q]
if len(grp) == 0:
continue
mr = grp[ratio_col].mean()
r1 = grp["next_return"].mean() * 10000
r4 = grp["next_4_return"].mean() * 10000
wr = (grp["next_return"] > 0).mean() * 100
print(f" {q:<12} {mr:>12.4f} {r1:>14.2f} {r4:>13.2f} {wr:>9.1f}% {len(grp):>7}")
# 5. Contrarian vs Momentum
print("\n[5] Contrarian vs Momentum Signal Test")
print("-"*60)
for ratio_col, label in [("kline_taker_buy_ratio", "Taker Buy Ratio"),
("global_ls_ratio", "Global L/S Ratio"),
("top_acct_ls_ratio", "Top Trader Acct Ratio"),
("top_pos_ls_ratio", "Top Trader Pos Ratio")]:
if ratio_col not in available:
continue
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
median = valid[ratio_col].median()
high = valid[valid[ratio_col] > median]
low = valid[valid[ratio_col] <= median]
h_wr = (high["next_return"] > 0).mean() * 100
l_wr = (low["next_return"] > 0).mean() * 100
h_r = high["next_return"].mean() * 10000
l_r = low["next_return"].mean() * 10000
signal = "Momentum" if h_r > l_r else "Contrarian"
print(f"\n {label}:")
print(f" Above median → next 15m: {h_r:+.2f} bps (win {h_wr:.1f}%)")
print(f" Below median → next 15m: {l_r:+.2f} bps (win {l_wr:.1f}%)")
print(f" → Signal type: {signal}")
# 6. Stats
print("\n[6] Feature Statistics Summary")
print("-"*60)
for feat in available:
s = df_k[feat].dropna()
print(f" {feat}: mean={s.mean():.4f}, std={s.std():.4f}, min={s.min():.4f}, max={s.max():.4f}, n={len(s)}")
print(f"\n Total klines: {len(df_k)}")
print(f" Period: {df_k.index[0]} ~ {df_k.index[-1]}")
async def main():
end_dt = datetime.now(timezone.utc)
start_dt = end_dt - timedelta(days=DAYS)
start_ms = int(start_dt.timestamp() * 1000)
end_ms = int(end_dt.timestamp() * 1000)
print(f"Fetching {DAYS} days of {INTERVAL} data for {', '.join(SYMBOLS)}...")
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(
*[analyze_symbol(session, sym, start_ms, end_ms) for sym in SYMBOLS]
)
for sym, df in zip(SYMBOLS, results):
print_analysis(sym, df)
# Cross-symbol comparison
if len(SYMBOLS) > 1:
print("\n" + "="*70)
print("CROSS-SYMBOL COMPARISON SUMMARY")
print("="*70)
print(f"\n{'Symbol':<12} {'taker_buy→15m':>14} {'taker_buy→1h':>13} {'global_ls→1h':>13} {'top_acct→1h':>13} {'top_pos→1h':>12}")
print("-"*78)
for sym, df in zip(SYMBOLS, results):
tb = df["kline_taker_buy_ratio"].corr(df["next_return"]) if "kline_taker_buy_ratio" in df.columns else float('nan')
tb4 = df["kline_taker_buy_ratio"].corr(df["next_4_return"]) if "kline_taker_buy_ratio" in df.columns else float('nan')
gl = df["global_ls_ratio"].corr(df["next_4_return"]) if "global_ls_ratio" in df.columns and df["global_ls_ratio"].notna().sum() > 20 else float('nan')
ta = df["top_acct_ls_ratio"].corr(df["next_4_return"]) if "top_acct_ls_ratio" in df.columns and df["top_acct_ls_ratio"].notna().sum() > 20 else float('nan')
tp = df["top_pos_ls_ratio"].corr(df["next_4_return"]) if "top_pos_ls_ratio" in df.columns and df["top_pos_ls_ratio"].notna().sum() > 20 else float('nan')
print(f"{sym:<12} {tb:>14.4f} {tb4:>13.4f} {gl:>13.4f} {ta:>13.4f} {tp:>12.4f}")
if __name__ == "__main__":
asyncio.run(main())

230
scripts/verify_prod_api.py Normal file
View File

@@ -0,0 +1,230 @@
"""실전 API SL/TP 콜백 검증 스크립트.
검증 항목:
1. SL/TP 주문 응답에 orderId vs algoId 확인
2. SL 트리거 시 UDS 콜백의 o, ot 필드 값
3. futures_cancel_order(orderId=...)로 TP 취소 가능 여부
사용법:
1. 바이낸스 앱/웹에서 XRPUSDT 소액 LONG 포지션 수동 진입
2. python scripts/verify_prod_api.py 실행
→ 자동으로 SL/TP 배치 + UDS 리스닝
3. SL이 트리거되면 콜백 로그 확인 + TP 자동 취소 시도
환경변수: BINANCE_API_KEY, BINANCE_API_SECRET (실전 키)
"""
import asyncio
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
from binance import AsyncClient, BinanceSocketManager
from loguru import logger
from src.exchange import BinanceFuturesClient
from src.config import Config
# .env에서 실전 키 로드 (BINANCE_TESTNET이 설정되어 있으면 해제)
os.environ.pop("BINANCE_TESTNET", None)
load_dotenv()
SYMBOL = "XRPUSDT"
async def main():
api_key = os.getenv("BINANCE_API_KEY", "")
api_secret = os.getenv("BINANCE_API_SECRET", "")
if not api_key or not api_secret:
logger.error("BINANCE_API_KEY / BINANCE_API_SECRET 환경변수 필요")
return
# Exchange 클라이언트 (실전)
config = Config()
config.testnet = False
config.api_key = api_key
config.api_secret = api_secret
config.symbol = SYMBOL
exchange = BinanceFuturesClient(config, symbol=SYMBOL)
# ── Step 1: 현재 포지션 확인 ──
position = await exchange.get_position()
if position is None:
logger.error(
f"[{SYMBOL}] 포지션 없음. 먼저 바이낸스 앱/웹에서 소액 포지션을 수동으로 진입하세요."
)
return
pos_amt = float(position["positionAmt"])
entry_price = float(position["entryPrice"])
mark_price = float(position.get("markPrice", entry_price))
side = "LONG" if pos_amt > 0 else "SHORT"
quantity = abs(pos_amt)
logger.info(f"[{SYMBOL}] 포지션 확인: {side} qty={quantity}, entry={entry_price}, mark={mark_price}")
# ── Step 2: 기존 오픈 주문 확인/정리 ──
open_orders = await exchange.get_open_orders()
if open_orders:
logger.info(f"[{SYMBOL}] 기존 오픈 주문 {len(open_orders)}개 — 전체 취소")
await exchange.cancel_all_orders()
await asyncio.sleep(1)
# ── Step 3: SL/TP 주문 배치 (현재가 기준 가까운 값) ──
# SL: 현재가에서 0.15% 떨어진 곳 (빨리 트리거되도록)
# TP: 현재가에서 2% 떨어진 곳 (트리거 안 되도록)
sl_side = "SELL" if side == "LONG" else "BUY"
if side == "LONG":
stop_loss = exchange._round_price(mark_price * 0.9985) # -0.15%
take_profit = exchange._round_price(mark_price * 1.02) # +2%
else:
stop_loss = exchange._round_price(mark_price * 1.0015) # +0.15%
take_profit = exchange._round_price(mark_price * 0.98) # -2%
logger.info(f"[{SYMBOL}] SL/TP 배치 예정: SL={stop_loss}, TP={take_profit}, side={sl_side}")
# SL 배치
sl_result = await exchange.place_order(
side=sl_side,
quantity=quantity,
order_type="STOP_MARKET",
stop_price=stop_loss,
reduce_only=True,
)
logger.success(f"[검증1] SL 주문 응답 전체:\n{json.dumps(sl_result, indent=2)}")
sl_order_id = sl_result.get("orderId")
sl_algo_id = sl_result.get("algoId")
logger.info(f" → orderId={sl_order_id}, algoId={sl_algo_id}")
# TP 배치
tp_result = await exchange.place_order(
side=sl_side,
quantity=quantity,
order_type="TAKE_PROFIT_MARKET",
stop_price=take_profit,
reduce_only=True,
)
logger.success(f"[검증1] TP 주문 응답 전체:\n{json.dumps(tp_result, indent=2)}")
tp_order_id = tp_result.get("orderId")
tp_algo_id = tp_result.get("algoId")
logger.info(f" → orderId={tp_order_id}, algoId={tp_algo_id}")
# ── Step 4: UDS 리스닝 — SL 트리거 대기 ──
logger.info(f"[{SYMBOL}] UDS 리스닝 시작 — SL 트리거 대기 중 (mark={mark_price}, SL={stop_loss})")
logger.info(" SL이 트리거되면 자동으로 TP 취소를 시도합니다.")
logger.info(" Ctrl+C로 중단 가능 (중단 시 잔여 주문 정리)")
sl_triggered = asyncio.Event()
async def on_uds_message(msg: dict):
if msg.get("e") != "ORDER_TRADE_UPDATE":
return
order = msg.get("o", {})
if order.get("s") != SYMBOL:
return
# 모든 이벤트 원본 로깅
logger.info(
f"[검증2] UDS 원본: "
f"s={order.get('s')} "
f"o={order.get('o')} "
f"ot={order.get('ot')} "
f"x={order.get('x')} "
f"X={order.get('X')} "
f"R={order.get('R')} "
f"S={order.get('S')} "
f"i={order.get('i')} "
f"ap={order.get('ap')} "
f"rp={order.get('rp')} "
f"n={order.get('n')}"
)
# FILLED된 SL 감지
if order.get("x") == "TRADE" and order.get("X") == "FILLED":
ot = order.get("ot", "")
if ot == "STOP_MARKET":
logger.success(
f"[검증2] SL FILLED 확인! "
f"o={order.get('o')}, ot={ot}, "
f"orderId={order.get('i')}, "
f"exit_price={order.get('ap')}, rp={order.get('rp')}"
)
sl_triggered.set()
# UDS 연결
client = await AsyncClient.create(
api_key=api_key,
api_secret=api_secret,
)
try:
bm = BinanceSocketManager(client)
async with bm.futures_user_socket() as stream:
logger.info("UDS 연결 완료")
while True:
try:
msg = await asyncio.wait_for(stream.recv(), timeout=1.0)
await on_uds_message(msg)
except asyncio.TimeoutError:
pass
if sl_triggered.is_set():
break
# ── Step 5: TP 취소 검증 ──
cancel_id = tp_order_id or tp_algo_id
logger.info(f"[검증3] TP 취소 시도: futures_cancel_order(orderId={cancel_id})")
try:
cancel_result = await exchange.cancel_order(cancel_id)
logger.success(f"[검증3] TP 취소 성공:\n{json.dumps(cancel_result, indent=2)}")
except Exception as e:
logger.error(f"[검증3] TP 취소 실패: {e}")
# cancel_all_orders 폴백
logger.info("[검증3] cancel_all_orders 폴백 시도")
try:
fallback_result = await exchange.cancel_all_orders()
logger.success(f"[검증3] cancel_all_orders 결과: {fallback_result}")
except Exception as e2:
logger.error(f"[검증3] cancel_all_orders도 실패: {e2}")
# 최종 오픈 주문 확인
remaining = await exchange.get_open_orders()
if remaining:
logger.warning(f"[검증3] 잔여 오픈 주문 {len(remaining)}개:")
for o in remaining:
logger.warning(f" id={o.get('orderId')}, type={o.get('type')}, status={o.get('status')}")
else:
logger.success("[검증3] 잔여 오픈 주문 없음 — 고아주문 없음 확인!")
except KeyboardInterrupt:
logger.info("중단 — 잔여 주문 정리 중...")
try:
await exchange.cancel_all_orders()
logger.info("잔여 주문 전체 취소 완료")
except Exception as e:
logger.warning(f"잔여 주문 취소 실패: {e}")
finally:
await client.close_connection()
# ── 결과 요약 ──
logger.info("=" * 60)
logger.info("검증 결과 요약")
logger.info("=" * 60)
logger.info(f"[1] SL orderId={sl_order_id}, algoId={sl_algo_id}")
logger.info(f"[1] TP orderId={tp_order_id}, algoId={tp_algo_id}")
logger.info(f"[2] SL 트리거 감지: {'YES' if sl_triggered.is_set() else 'NO (타임아웃/중단)'}")
logger.info(f"[3] 위 로그에서 TP 취소 성공 여부 확인")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -30,10 +30,10 @@ from src.notifier import DiscordNotifier
# ── 프로덕션 파라미터 ────────────────────────────────────────────── # ── 프로덕션 파라미터 ──────────────────────────────────────────────
SYMBOLS = ["XRPUSDT", "TRXUSDT", "DOGEUSDT"] SYMBOLS = ["XRPUSDT"]
PROD_PARAMS = { PROD_PARAMS = {
"atr_sl_mult": 2.0, "atr_sl_mult": 1.5,
"atr_tp_mult": 2.0, "atr_tp_mult": 4.0,
"signal_threshold": 3, "signal_threshold": 3,
"adx_threshold": 25, "adx_threshold": 25,
"volume_multiplier": 2.5, "volume_multiplier": 2.5,

View File

@@ -701,6 +701,8 @@ class WalkForwardBacktester:
"fold": i + 1, "fold": i + 1,
"train_period": f"{train_start.date()} ~ {train_end.date()}", "train_period": f"{train_start.date()} ~ {train_end.date()}",
"test_period": f"{test_start.date()} ~ {test_end.date()}", "test_period": f"{test_start.date()} ~ {test_end.date()}",
"test_start": test_start.isoformat(),
"test_end": test_end.isoformat(),
"summary": result["summary"], "summary": result["summary"],
}) })

View File

@@ -58,7 +58,7 @@ class TradingBot:
self.symbol = symbol or config.symbol self.symbol = symbol or config.symbol
self.strategy = config.get_symbol_params(self.symbol) self.strategy = config.get_symbol_params(self.symbol)
self.exchange = BinanceFuturesClient(config, symbol=self.symbol) self.exchange = BinanceFuturesClient(config, symbol=self.symbol)
self.notifier = DiscordNotifier(config.discord_webhook_url) self.notifier = DiscordNotifier(config.discord_webhook_url, testnet=config.testnet)
self.risk = risk or RiskManager(config) self.risk = risk or RiskManager(config)
# 심볼별 모델 디렉토리. 없으면 기존 models/ 루트로 폴백 # 심볼별 모델 디렉토리. 없으면 기존 models/ 루트로 폴백
symbol_model_dir = Path(f"models/{self.symbol.lower()}") symbol_model_dir = Path(f"models/{self.symbol.lower()}")
@@ -78,6 +78,10 @@ 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._entry_time_ms: int | None = None # 포지션 진입 시각 (ms, SYNC PnL 범위 제한용) self._entry_time_ms: int | None = None # 포지션 진입 시각 (ms, SYNC PnL 범위 제한용)
self._sl_order_id: int | None = None # SL 주문 ID (고아 주문 취소용)
self._tp_order_id: int | None = None # TP 주문 ID (고아 주문 취소용)
self._sl_price: float | None = None # SL 가격 (가격 기반 close_reason 판별용)
self._tp_price: float | None = None # TP 가격 (가격 기반 close_reason 판별용)
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 변화율 계산용 이전 값
@@ -88,7 +92,7 @@ class TradingBot:
self._trade_history: list[dict] = [] # 최근 거래 이력 (net_pnl 기록) self._trade_history: list[dict] = [] # 최근 거래 이력 (net_pnl 기록)
self.stream = MultiSymbolStream( self.stream = MultiSymbolStream(
symbols=[self.symbol] + config.correlation_symbols, symbols=[self.symbol] + config.correlation_symbols,
interval="15m", interval=config.kline_interval,
on_candle=self._on_candle_closed, on_candle=self._on_candle_closed,
) )
# 부팅 시 거래 이력 복원 및 킬스위치 소급 검증 # 부팅 시 거래 이력 복원 및 킬스위치 소급 검증
@@ -98,7 +102,11 @@ class TradingBot:
# ── 킬스위치 ────────────────────────────────────────────────────── # ── 킬스위치 ──────────────────────────────────────────────────────
def _trade_history_path(self) -> Path: def _trade_history_path(self) -> Path:
return _TRADE_HISTORY_DIR / f"{self.symbol.lower()}.jsonl" base = _TRADE_HISTORY_DIR
if self.config.testnet:
base = base / "testnet"
base.mkdir(parents=True, exist_ok=True)
return base / f"{self.symbol.lower()}.jsonl"
def _restore_trade_history(self) -> None: def _restore_trade_history(self) -> None:
"""부팅 시 파일 마지막 N줄만 읽어 거래 이력을 복원한다. """부팅 시 파일 마지막 N줄만 읽어 거래 이력을 복원한다.
@@ -232,6 +240,13 @@ class TradingBot:
open_orders = await self.exchange.get_open_orders() open_orders = await self.exchange.get_open_orders()
has_sl = any(o.get("type") == "STOP_MARKET" for o in open_orders) has_sl = any(o.get("type") == "STOP_MARKET" for o in open_orders)
has_tp = any(o.get("type") == "TAKE_PROFIT_MARKET" for o in open_orders) has_tp = any(o.get("type") == "TAKE_PROFIT_MARKET" for o in open_orders)
# 오픈 주문에서 SL/TP 가격 복원 (가격 기반 close_reason 판별용)
for o in open_orders:
otype = o.get("type", "")
if otype == "STOP_MARKET":
self._sl_price = float(o.get("stopPrice", 0))
elif otype == "TAKE_PROFIT_MARKET":
self._tp_price = float(o.get("stopPrice", 0))
if has_sl and has_tp: if has_sl and has_tp:
return return
missing = [] missing = []
@@ -327,6 +342,23 @@ class TradingBot:
return change return change
async def process_candle(self, df, btc_df=None, eth_df=None): async def process_candle(self, df, btc_df=None, eth_df=None):
# Demo 모드: 시그널/필터 전부 우회, 포지션 없을 때만 1회 LONG 진입 (UDS 검증용)
if self.config.testnet:
ind = Indicators(df)
df_with_indicators = ind.calculate_all()
current_price = df_with_indicators["close"].iloc[-1]
# 로컬 상태 + 바이낸스 포지션 모두 체크
if self.current_trade_side is not None:
logger.info(f"[{self.symbol}] [DEMO] 포지션 보유 중 (로컬) — SL/TP 대기 | 현재가: {current_price:.4f}")
return
position = await self.exchange.get_position()
if position is not None:
logger.info(f"[{self.symbol}] [DEMO] 포지션 보유 중 (바이낸스) — SL/TP 대기 | 현재가: {current_price:.4f}")
return
logger.info(f"[{self.symbol}] [DEMO] 강제 LONG 진입 | 현재가: {current_price:.4f}")
await self._open_position("LONG", df_with_indicators)
return
self.ml_filter.check_and_reload() self.ml_filter.check_and_reload()
# 가격 수익률 계산 (oi_price_spread용) # 가격 수익률 계산 (oi_price_spread용)
@@ -377,6 +409,8 @@ 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._sl_price = None
self._tp_price = None
if not await self.risk.can_open_new_position(self.symbol, raw_signal): if not await self.risk.can_open_new_position(self.symbol, raw_signal):
logger.info(f"[{self.symbol}] 포지션 오픈 불가") logger.info(f"[{self.symbol}] 포지션 오픈 불가")
return return
@@ -418,15 +452,26 @@ class TradingBot:
balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio
) )
logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}") logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
# df는 이미 calculate_all() 적용된 df_with_indicators이므로 # Demo 모드: 고정 퍼센트 SL/TP (ATR이 너무 작아 즉시 트리거 방지)
# Indicators를 재생성하지 않고 ATR을 직접 사용 if self.config.testnet:
atr = df["atr"].iloc[-1] sl_pct = 0.005 # 0.5%
if signal == "LONG": tp_pct = 0.02
stop_loss = price - atr * self.strategy.atr_sl_mult if signal == "LONG":
take_profit = price + atr * self.strategy.atr_tp_mult stop_loss = price * (1 - sl_pct)
take_profit = price * (1 + tp_pct)
else:
stop_loss = price * (1 + sl_pct)
take_profit = price * (1 - tp_pct)
else: else:
stop_loss = price + atr * self.strategy.atr_sl_mult # df는 이미 calculate_all() 적용된 df_with_indicators이므로
take_profit = price - atr * self.strategy.atr_tp_mult # 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
notional = quantity * price notional = quantity * price
if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL: if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
@@ -437,6 +482,7 @@ class TradingBot:
return return
side = "BUY" if signal == "LONG" else "SELL" side = "BUY" if signal == "LONG" else "SELL"
await self.exchange.set_margin_type("ISOLATED")
await self.exchange.set_leverage(self.config.leverage) await self.exchange.set_leverage(self.config.leverage)
await self.exchange.place_order(side=side, quantity=quantity) await self.exchange.place_order(side=side, quantity=quantity)
@@ -452,6 +498,8 @@ class TradingBot:
self._entry_price = price self._entry_price = price
self._entry_quantity = quantity self._entry_quantity = quantity
self._entry_time_ms = int(time.time() * 1000) self._entry_time_ms = int(time.time() * 1000)
self._sl_price = stop_loss
self._tp_price = take_profit
self.notifier.notify_open( self.notifier.notify_open(
symbol=self.symbol, symbol=self.symbol,
side=signal, side=signal,
@@ -494,22 +542,26 @@ class TradingBot:
for attempt in range(1, self._SL_TP_MAX_RETRIES + 1): for attempt in range(1, self._SL_TP_MAX_RETRIES + 1):
try: try:
if not sl_placed: if not sl_placed:
await self.exchange.place_order( sl_result = await self.exchange.place_order(
side=sl_side, side=sl_side,
quantity=quantity, quantity=quantity,
order_type="STOP_MARKET", order_type="STOP_MARKET",
stop_price=self.exchange._round_price(stop_loss), stop_price=self.exchange._round_price(stop_loss),
reduce_only=True, reduce_only=True,
) )
self._sl_order_id = sl_result.get("orderId") or sl_result.get("algoId")
logger.info(f"[{self.symbol}] SL 주문 배치: id={self._sl_order_id}")
sl_placed = True sl_placed = True
if not tp_placed: if not tp_placed:
await self.exchange.place_order( tp_result = await self.exchange.place_order(
side=sl_side, side=sl_side,
quantity=quantity, quantity=quantity,
order_type="TAKE_PROFIT_MARKET", order_type="TAKE_PROFIT_MARKET",
stop_price=self.exchange._round_price(take_profit), stop_price=self.exchange._round_price(take_profit),
reduce_only=True, reduce_only=True,
) )
self._tp_order_id = tp_result.get("orderId") or tp_result.get("algoId")
logger.info(f"[{self.symbol}] TP 주문 배치: id={self._tp_order_id}")
tp_placed = True tp_placed = True
return # 둘 다 성공 return # 둘 다 성공
except Exception as e: except Exception as e:
@@ -534,6 +586,8 @@ 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._sl_price = None
self._tp_price = None
self.notifier.notify_info( self.notifier.notify_info(
f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료" f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료"
) )
@@ -546,6 +600,28 @@ class TradingBot:
f"🔴 [{self.symbol}] 긴급 청산 실패! 수동 청산 필요: {e}" f"🔴 [{self.symbol}] 긴급 청산 실패! 수동 청산 필요: {e}"
) )
async def _cancel_remaining_orders(self, reason: str = "") -> None:
"""잔여 SL/TP 고아 주문을 저장된 주문 ID로 직접 취소한다."""
ctx = f" ({reason})" if reason else ""
cancelled = 0
for label, oid in [("SL", self._sl_order_id), ("TP", self._tp_order_id)]:
if oid is None:
continue
try:
result = await self.exchange.cancel_order(oid)
logger.info(
f"[{self.symbol}] {label} 주문 취소 완료{ctx}: "
f"id={oid} → status={result.get('status', 'N/A')}"
)
cancelled += 1
except Exception as e:
# 이미 체결/취소된 주문이면 무시
logger.debug(f"[{self.symbol}] {label} 주문 취소 스킵{ctx}: id={oid}: {e}")
self._sl_order_id = None
self._tp_order_id = None
if cancelled == 0:
logger.info(f"[{self.symbol}] 취소할 잔여 주문 없음{ctx}")
def _calc_estimated_pnl(self, exit_price: float) -> float: def _calc_estimated_pnl(self, exit_price: float) -> float:
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영).""" """진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
if self._entry_price is None or self._entry_quantity is None or self.current_trade_side is None: if self._entry_price is None or self._entry_quantity is None or self.current_trade_side is None:
@@ -568,6 +644,17 @@ class TradingBot:
self._close_event.set() self._close_event.set()
return return
# 실전 API에서 algo order는 ot=MARKET로 오므로 MANUAL로 판별됨
# → SL/TP 가격과 exit_price 비교로 재판별
if close_reason == "MANUAL" and self._sl_price and self._tp_price:
sl_dist = abs(exit_price - self._sl_price)
tp_dist = abs(exit_price - self._tp_price)
close_reason = "SL" if sl_dist < tp_dist else "TP"
logger.info(
f"[{self.symbol}] close_reason 재판별: MANUAL → {close_reason} "
f"(exit={exit_price:.4f}, SL={self._sl_price:.4f}, TP={self._tp_price:.4f})"
)
estimated_pnl = self._calc_estimated_pnl(exit_price) estimated_pnl = self._calc_estimated_pnl(exit_price)
diff = net_pnl - estimated_pnl diff = net_pnl - estimated_pnl
@@ -599,11 +686,16 @@ class TradingBot:
if self._is_reentering: if self._is_reentering:
return return
# 잔여 SL/TP 고아 주문 취소
await self._cancel_remaining_orders("UDS 청산 콜백")
# Flat 상태로 초기화 # Flat 상태로 초기화
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._entry_time_ms = None
self._sl_price = None
self._tp_price = None
_MONITOR_INTERVAL = 300 # 5분 _MONITOR_INTERVAL = 300 # 5분
@@ -665,10 +757,14 @@ class TradingBot:
) )
self._append_trade(net_pnl, "SYNC") self._append_trade(net_pnl, "SYNC")
self._check_kill_switch() self._check_kill_switch()
# 잔여 SL/TP 주문 취소
await self._cancel_remaining_orders("SYNC 폴백")
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._entry_time_ms = None
self._sl_price = None
self._tp_price = None
self._close_event.set() self._close_event.set()
continue continue
except Exception as e: except Exception as e:
@@ -727,6 +823,11 @@ class TradingBot:
self._entry_price = None self._entry_price = None
self._entry_quantity = None self._entry_quantity = None
self._entry_time_ms = None self._entry_time_ms = None
self._sl_price = None
self._tp_price = None
# 잔여 SL/TP 주문 취소 확인 (_close_position에서 cancel_all 호출하지만 검증)
await self._cancel_remaining_orders("재진입 전 검증")
if self._killed: if self._killed:
logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)") logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)")
@@ -754,6 +855,9 @@ class TradingBot:
self._is_reentering = False self._is_reentering = False
async def run(self): async def run(self):
if self.config.testnet:
logger.warning("⚠️ TESTNET MODE ENABLED — 실제 자금이 아닌 테스트넷에서 실행 중")
s = self.strategy s = self.strategy
logger.info( logger.info(
f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x | " f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x | "
@@ -763,6 +867,14 @@ class TradingBot:
await self._recover_position() await self._recover_position()
await self._init_oi_history() await self._init_oi_history()
# 봇 시작 시 포지션 없으면 고아 주문 정리 (저장된 ID 없으므로 cancel_all 사용)
if self.current_trade_side is None:
try:
result = await self.exchange.cancel_all_orders()
logger.info(f"[{self.symbol}] 봇 시작 — cancel_all_orders 응답: {result}")
except Exception as e:
logger.warning(f"[{self.symbol}] 봇 시작 — 주문 취소 실패: {e}")
user_stream = UserDataStream( user_stream = UserDataStream(
symbol=self.symbol, symbol=self.symbol,
on_order_filled=self._on_position_closed, on_order_filled=self._on_position_closed,
@@ -772,10 +884,12 @@ class TradingBot:
self.stream.start( self.stream.start(
api_key=self.config.api_key, api_key=self.config.api_key,
api_secret=self.config.api_secret, api_secret=self.config.api_secret,
testnet=self.config.testnet,
), ),
user_stream.start( user_stream.start(
api_key=self.config.api_key, api_key=self.config.api_key,
api_secret=self.config.api_secret, api_secret=self.config.api_secret,
testnet=self.config.testnet,
), ),
self._position_monitor(), self._position_monitor(),
) )

View File

@@ -35,10 +35,18 @@ class Config:
signal_threshold: int = 3 signal_threshold: int = 3
adx_threshold: float = 25.0 adx_threshold: float = 25.0
volume_multiplier: float = 2.5 volume_multiplier: float = 2.5
kline_interval: str = "15m"
testnet: bool = False
def __post_init__(self): def __post_init__(self):
self.api_key = os.getenv("BINANCE_API_KEY", "") self.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
if self.testnet:
self.api_key = os.getenv("BINANCE_DEMO_API_KEY", "")
self.api_secret = os.getenv("BINANCE_DEMO_API_SECRET", "")
else:
self.api_key = os.getenv("BINANCE_API_KEY", "")
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
self.symbol = os.getenv("SYMBOL", "XRPUSDT") self.symbol = os.getenv("SYMBOL", "XRPUSDT")
self.leverage = int(os.getenv("LEVERAGE", "10")) self.leverage = int(os.getenv("LEVERAGE", "10"))
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "") self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
@@ -52,6 +60,7 @@ class Config:
self.signal_threshold = int(os.getenv("SIGNAL_THRESHOLD", "3")) self.signal_threshold = int(os.getenv("SIGNAL_THRESHOLD", "3"))
self.adx_threshold = float(os.getenv("ADX_THRESHOLD", "25")) self.adx_threshold = float(os.getenv("ADX_THRESHOLD", "25"))
self.volume_multiplier = float(os.getenv("VOL_MULTIPLIER", "2.5")) self.volume_multiplier = float(os.getenv("VOL_MULTIPLIER", "2.5"))
self.kline_interval = os.getenv("KLINE_INTERVAL", "15m")
# symbols: SYMBOLS 환경변수 우선, 없으면 SYMBOL에서 변환 # symbols: SYMBOLS 환경변수 우선, 없으면 SYMBOL에서 변환
symbols_env = os.getenv("SYMBOLS", "") symbols_env = os.getenv("SYMBOLS", "")

View File

@@ -77,10 +77,11 @@ class KlineStream:
}) })
logger.info(f"과거 캔들 {len(self.buffer)}개 로드 완료 — 즉시 신호 계산 가능") logger.info(f"과거 캔들 {len(self.buffer)}개 로드 완료 — 즉시 신호 계산 가능")
async def start(self, api_key: str, api_secret: str): async def start(self, api_key: str, api_secret: str, testnet: bool = False):
client = await AsyncClient.create( client = await AsyncClient.create(
api_key=api_key, api_key=api_key,
api_secret=api_secret, api_secret=api_secret,
demo=testnet,
) )
await self._preload_history(client) await self._preload_history(client)
bm = BinanceSocketManager(client) bm = BinanceSocketManager(client)
@@ -189,10 +190,11 @@ class MultiSymbolStream:
self._preload_one(client, symbol, limit) for symbol in self.symbols self._preload_one(client, symbol, limit) for symbol in self.symbols
]) ])
async def start(self, api_key: str, api_secret: str): async def start(self, api_key: str, api_secret: str, testnet: bool = False):
client = await AsyncClient.create( client = await AsyncClient.create(
api_key=api_key, api_key=api_key,
api_secret=api_secret, api_secret=api_secret,
demo=testnet,
) )
await self._preload_history(client) await self._preload_history(client)
bm = BinanceSocketManager(client) bm = BinanceSocketManager(client)

View File

@@ -20,6 +20,7 @@ class BinanceFuturesClient:
self.client = Client( self.client = Client(
api_key=config.api_key, api_key=config.api_key,
api_secret=config.api_secret, api_secret=config.api_secret,
demo=config.testnet,
) )
self._qty_precision: int | None = None self._qty_precision: int | None = None
self._price_precision: int | None = None self._price_precision: int | None = None
@@ -107,6 +108,21 @@ class BinanceFuturesClient:
), ),
) )
async def set_margin_type(self, margin_type: str = "ISOLATED") -> None:
"""마진 타입을 변경한다. 이미 동일 타입이면 무시."""
try:
await self._run_api(
lambda: self.client.futures_change_margin_type(
symbol=self.symbol, marginType=margin_type
),
)
logger.info(f"[{self.symbol}] 마진 타입 변경: {margin_type}")
except BinanceAPIException as e:
if e.code == -4046: # "No need to change margin type."
logger.debug(f"[{self.symbol}] 마진 타입 이미 {margin_type}")
else:
raise
async def get_balance(self) -> float: async def get_balance(self) -> float:
balances = await self._run_api(self.client.futures_account_balance) balances = await self._run_api(self.client.futures_account_balance)
for b in balances: for b in balances:
@@ -155,18 +171,54 @@ class BinanceFuturesClient:
return None return None
async def get_open_orders(self) -> list[dict]: async def get_open_orders(self) -> list[dict]:
"""현재 심볼의 오픈 주문 목록을 조회한다.""" """현재 심볼의 오픈 주문 + algo 주문을 병합 반환한다."""
return await self._run_api( orders = await self._run_api(
lambda: self.client.futures_get_open_orders(symbol=self.symbol), lambda: self.client.futures_get_open_orders(symbol=self.symbol),
) )
try:
algo_orders = await self._run_api(
lambda: self.client.futures_get_open_algo_orders(symbol=self.symbol)
)
for ao in algo_orders.get("orders", []):
orders.append({
"orderId": ao.get("algoId"),
"type": ao.get("orderType"),
"stopPrice": ao.get("triggerPrice"),
"side": ao.get("side"),
"status": ao.get("algoStatus"),
"_is_algo": True,
})
except Exception:
pass # algo 주문 없으면 실패 가능
return orders
async def cancel_all_orders(self): async def cancel_all_orders(self):
"""오픈 주문을 모두 취소한다.""" """일반 주문 + algo 주문을 모두 취소한다."""
await self._run_api( await self._run_api(
lambda: self.client.futures_cancel_all_open_orders( lambda: self.client.futures_cancel_all_open_orders(
symbol=self.symbol symbol=self.symbol
), ),
) )
try:
await self._run_api(
lambda: self.client.futures_cancel_all_algo_open_orders(symbol=self.symbol)
)
except Exception:
pass # algo 주문 없으면 실패 가능
async def cancel_order(self, order_id: int):
"""개별 주문을 취소한다. 일반 주문 실패 시 algo 주문으로 재시도."""
try:
return await self._run_api(
lambda: self.client.futures_cancel_order(
symbol=self.symbol, orderId=order_id
),
)
except Exception:
# Algo order (데모 API의 조건부 주문) 취소 시도
return await self._run_api(
lambda: self.client.futures_cancel_algo_order(algoId=order_id),
)
async def get_recent_income(self, limit: int = 5, start_time: int | None = None) -> tuple[list[dict], 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 내역을 조회한다.

982
src/mtf_bot.py Normal file
View File

@@ -0,0 +1,982 @@
"""
MTF Pullback Bot — Module 1~4
──────────────────────────────
Module 1: TimeframeSync, DataFetcher (REST 폴링 기반)
Module 2: MetaFilter (1h EMA50/200 + ADX + ATR)
Module 3: TriggerStrategy (15m Volume-backed Pullback 3캔들 시퀀스)
Module 4: ExecutionManager (Dry-run 가상 주문 + SL/TP 관리)
핵심 원칙:
- Look-ahead bias 원천 차단: 완성된 캔들만 사용 ([:-1] 슬라이싱)
- Binance 서버 딜레이 고려: 캔들 판별 시 2~5초 range
- REST 폴링 기반 안정성: WebSocket 대신 30초 주기 폴링
- 메모리 최적화: deque(maxlen=250)
- Dry-run 모드: 4월 OOS 검증 기간, 실주문 API 주석 처리
"""
import asyncio
import json
import os
import time as _time
from datetime import datetime, timezone
from collections import deque
from pathlib import Path
from typing import Optional, Dict, List
import pandas as pd
import pandas_ta as ta
import ccxt.async_support as ccxt
from loguru import logger
from src.notifier import DiscordNotifier
# ═══════════════════════════════════════════════════════════════════
# Module 1: TimeframeSync
# ═══════════════════════════════════════════════════════════════════
class TimeframeSync:
"""현재 시간이 15m/1h 캔들 종료 직후인지 판별 (Binance 서버 딜레이 2~5초 고려)."""
_15M_MINUTES = {0, 15, 30, 45}
@staticmethod
def is_15m_candle_closed(current_ts: int) -> bool:
"""
15m 캔들 종료 판별.
Args:
current_ts: Unix timestamp (밀리초)
Returns:
True if 분(minute)이 [0, 15, 30, 45] 중 하나이고 초(second)가 2~5초 사이
"""
dt = datetime.fromtimestamp(current_ts / 1000, tz=timezone.utc)
return dt.minute in TimeframeSync._15M_MINUTES and 2 <= dt.second <= 5
@staticmethod
def is_1h_candle_closed(current_ts: int) -> bool:
"""
1h 캔들 종료 판별.
Args:
current_ts: Unix timestamp (밀리초)
Returns:
True if 분(minute)이 0이고 초(second)가 2~5초 사이
"""
dt = datetime.fromtimestamp(current_ts / 1000, tz=timezone.utc)
return dt.minute == 0 and 2 <= dt.second <= 5
# ═══════════════════════════════════════════════════════════════════
# Module 1: DataFetcher
# ═══════════════════════════════════════════════════════════════════
class DataFetcher:
"""Binance Futures에서 15m/1h OHLCV 데이터 fetch 및 관리."""
def __init__(self, symbol: str = "XRP/USDT:USDT"):
self.symbol = symbol
self.exchange = ccxt.binance({
"enableRateLimit": True,
"options": {"defaultType": "future"},
})
self.klines_15m: deque = deque(maxlen=250)
self.klines_1h: deque = deque(maxlen=250)
self._last_15m_ts: int = 0 # 마지막으로 저장된 15m 캔들 timestamp
self._last_1h_ts: int = 0
@staticmethod
def _remove_incomplete_candle(df: pd.DataFrame, interval_sec: int) -> pd.DataFrame:
"""미완성(진행 중) 캔들을 조건부로 제거. ccxt timestamp는 ms 단위."""
if df.empty:
return df
now_ms = int(_time.time() * 1000)
current_candle_start_ms = (now_ms // (interval_sec * 1000)) * (interval_sec * 1000)
# DataFrame index가 datetime인 경우 원본 timestamp 컬럼이 없으므로 index에서 추출
last_open_ms = int(df.index[-1].timestamp() * 1000)
if last_open_ms >= current_candle_start_ms:
return df.iloc[:-1].copy()
return df
async def fetch_ohlcv(self, symbol: str, timeframe: str, limit: int = 250) -> List[List]:
"""
ccxt를 통해 OHLCV 데이터 fetch.
Returns:
[[timestamp, open, high, low, close, volume], ...]
"""
return await self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
async def initialize(self):
"""봇 시작 시 초기 데이터 로드 (250개씩)."""
# 15m 캔들
raw_15m = await self.fetch_ohlcv(self.symbol, "15m", limit=250)
for candle in raw_15m:
self.klines_15m.append(candle)
if raw_15m:
self._last_15m_ts = raw_15m[-1][0]
# 1h 캔들
raw_1h = await self.fetch_ohlcv(self.symbol, "1h", limit=250)
for candle in raw_1h:
self.klines_1h.append(candle)
if raw_1h:
self._last_1h_ts = raw_1h[-1][0]
logger.info(
f"[DataFetcher] 초기화 완료: 15m={len(self.klines_15m)}개, 1h={len(self.klines_1h)}"
)
def get_15m_dataframe(self) -> Optional[pd.DataFrame]:
"""완성된 15m 캔들을 DataFrame으로 반환 (미완성 캔들 조건부 제거)."""
if not self.klines_15m:
return None
data = list(self.klines_15m)
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df = df.set_index("timestamp")
return self._remove_incomplete_candle(df, interval_sec=900)
def get_1h_dataframe_completed(self) -> Optional[pd.DataFrame]:
"""
'완성된' 1h 캔들만 반환.
조건부 슬라이싱: _remove_incomplete_candle()로 진행 중인 최신 1h 캔들 제외.
이유: Look-ahead bias 원천 차단 — 아직 완성되지 않은 캔들의
high/low/close는 미래 데이터이므로 지표 계산에 사용하면 안 됨.
"""
if len(self.klines_1h) < 2:
return None
data = list(self.klines_1h)
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df = df.set_index("timestamp")
return self._remove_incomplete_candle(df, interval_sec=3600)
async def close(self):
"""ccxt exchange 연결 정리."""
await self.exchange.close()
# ═══════════════════════════════════════════════════════════════════
# Module 2: MetaFilter
# ═══════════════════════════════════════════════════════════════════
class MetaFilter:
"""1시간봉 데이터로부터 거시 추세 판독."""
EMA_FAST = 50
EMA_SLOW = 200
ADX_THRESHOLD = 20
def __init__(self, data_fetcher: DataFetcher):
self.data_fetcher = data_fetcher
self._cached_indicators: Optional[pd.DataFrame] = None
self._cache_timestamp: Optional[pd.Timestamp] = None
def _calc_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
"""1h DataFrame에 EMA50, EMA200, ADX, ATR 계산 (캔들 단위 캐싱)."""
if df is None or df.empty:
return df
last_ts = df.index[-1]
if self._cached_indicators is not None and self._cache_timestamp == last_ts:
return self._cached_indicators
df = df.copy()
df["ema50"] = ta.ema(df["close"], length=self.EMA_FAST)
df["ema200"] = ta.ema(df["close"], length=self.EMA_SLOW)
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
df["adx"] = adx_df["ADX_14"]
df["atr"] = ta.atr(df["high"], df["low"], df["close"], length=14)
self._cached_indicators = df
self._cache_timestamp = last_ts
return df
def get_market_state(self) -> str:
"""
1h 메타필터 상태 반환.
Returns:
'LONG_ALLOWED': EMA50 > EMA200 & ADX > 20 → 상승 추세, LONG 진입 허용
'SHORT_ALLOWED': EMA50 < EMA200 & ADX > 20 → 하락 추세, SHORT 진입 허용
'WAIT': 그 외 (추세 약하거나 데이터 부족)
"""
df = self.data_fetcher.get_1h_dataframe_completed()
if df is None or len(df) < self.EMA_SLOW:
return "WAIT"
df = self._calc_indicators(df)
last = df.iloc[-1]
if pd.isna(last["ema50"]) or pd.isna(last["ema200"]) or pd.isna(last["adx"]):
return "WAIT"
if last["adx"] < self.ADX_THRESHOLD:
return "WAIT"
if last["ema50"] > last["ema200"]:
return "LONG_ALLOWED"
elif last["ema50"] < last["ema200"]:
return "SHORT_ALLOWED"
return "WAIT"
def get_current_atr(self) -> Optional[float]:
"""현재 1h ATR 값 반환 (SL/TP 계산용)."""
df = self.data_fetcher.get_1h_dataframe_completed()
if df is None or len(df) < 15: # ATR(14) 최소 데이터
return None
df = self._calc_indicators(df)
atr = df["atr"].iloc[-1]
return float(atr) if not pd.isna(atr) else None
def get_meta_info(self) -> Dict:
"""전체 메타 정보 반환 (디버깅용)."""
df = self.data_fetcher.get_1h_dataframe_completed()
if df is None or len(df) < self.EMA_SLOW:
return {"state": "WAIT", "ema50": None, "ema200": None,
"adx": None, "atr": None, "timestamp": None}
df = self._calc_indicators(df)
last = df.iloc[-1]
return {
"state": self.get_market_state(),
"ema50": float(last["ema50"]) if not pd.isna(last["ema50"]) else None,
"ema200": float(last["ema200"]) if not pd.isna(last["ema200"]) else None,
"adx": float(last["adx"]) if not pd.isna(last["adx"]) else None,
"atr": float(last["atr"]) if not pd.isna(last["atr"]) else None,
"timestamp": str(df.index[-1]),
}
# ═══════════════════════════════════════════════════════════════════
# Module 3: TriggerStrategy
# ═══════════════════════════════════════════════════════════════════
class TriggerStrategy:
"""
15분봉 Volume-backed Pullback 패턴을 3캔들 시퀀스로 인식.
3캔들 시퀀스:
t-2: 기준 캔들 (Vol_SMA20 산출 기준)
t-1: 풀백 캔들 (EMA 이탈 + 거래량 고갈 확인)
t : 돌파 캔들 (가장 최근 완성된 캔들, EMA 복귀 확인)
"""
EMA_PERIOD = 15
VOL_SMA_PERIOD = 20
VOL_THRESHOLD = 0.50 # vol < vol_sma20 * 0.50
def __init__(self):
self._last_info: Dict = {}
def _calc_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
"""15m DataFrame에 EMA15, Vol_SMA20 계산."""
df = df.copy()
df["ema15"] = ta.ema(df["close"], length=self.EMA_PERIOD)
df["vol_sma20"] = df["volume"].rolling(self.VOL_SMA_PERIOD).mean()
return df
def generate_signal(self, df_15m: pd.DataFrame, meta_state: str) -> str:
"""
3캔들 시퀀스 기반 진입 신호 생성.
Args:
df_15m: 15분봉 DataFrame (OHLCV)
meta_state: 'LONG_ALLOWED' | 'SHORT_ALLOWED' | 'WAIT'
Returns:
'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD'
"""
# Step 1: 데이터 유효성
if meta_state == "WAIT":
self._last_info = {"signal": "HOLD", "reason": "meta_state=WAIT"}
return "HOLD"
if df_15m is None or len(df_15m) < 25:
self._last_info = {"signal": "HOLD", "reason": f"데이터 부족 ({len(df_15m) if df_15m is not None else 0}행)"}
return "HOLD"
df = self._calc_indicators(df_15m)
# Step 2: 캔들 인덱싱
t = df.iloc[-1] # 최근 완성 캔들 (돌파 확인)
t_1 = df.iloc[-2] # 직전 캔들 (풀백 확인)
t_2 = df.iloc[-3] # 그 이전 캔들 (Vol SMA 기준)
# NaN 체크
if (pd.isna(t["ema15"]) or pd.isna(t_1["ema15"])
or pd.isna(t_2["vol_sma20"])):
self._last_info = {"signal": "HOLD", "reason": "지표 NaN"}
return "HOLD"
vol_sma20_t2 = t_2["vol_sma20"]
vol_t1 = t_1["volume"]
vol_ratio = vol_t1 / vol_sma20_t2 if vol_sma20_t2 > 0 else float("inf")
vol_dry = vol_ratio < self.VOL_THRESHOLD
# 공통 info 구성
self._last_info = {
"ema15_t": float(t["ema15"]),
"ema15_t1": float(t_1["ema15"]),
"vol_sma20_t2": float(vol_sma20_t2),
"vol_t1": float(vol_t1),
"vol_ratio": round(vol_ratio, 4),
"close_t1": float(t_1["close"]),
"close_t": float(t["close"]),
}
# Step 3: LONG 시그널
if meta_state == "LONG_ALLOWED":
pullback = t_1["close"] < t_1["ema15"] # t-1 EMA 아래로 이탈
resumption = t["close"] > t["ema15"] # t EMA 위로 복귀
if pullback and vol_dry and resumption:
self._last_info.update({
"signal": "EXECUTE_LONG",
"reason": f"풀백 이탈 + 거래량 고갈({vol_ratio:.2f}) + 돌파 복귀",
})
return "EXECUTE_LONG"
reasons = []
if not pullback:
reasons.append(f"이탈 없음(close_t1={t_1['close']:.4f} >= ema15={t_1['ema15']:.4f})")
if not vol_dry:
reasons.append(f"거래량 과다({vol_ratio:.2f} >= {self.VOL_THRESHOLD})")
if not resumption:
reasons.append(f"복귀 실패(close_t={t['close']:.4f} <= ema15={t['ema15']:.4f})")
self._last_info.update({"signal": "HOLD", "reason": " | ".join(reasons)})
return "HOLD"
# Step 4: SHORT 시그널
if meta_state == "SHORT_ALLOWED":
pullback = t_1["close"] > t_1["ema15"] # t-1 EMA 위로 이탈
resumption = t["close"] < t["ema15"] # t EMA 아래로 복귀
if pullback and vol_dry and resumption:
self._last_info.update({
"signal": "EXECUTE_SHORT",
"reason": f"풀백 이탈 + 거래량 고갈({vol_ratio:.2f}) + 돌파 복귀",
})
return "EXECUTE_SHORT"
reasons = []
if not pullback:
reasons.append(f"이탈 없음(close_t1={t_1['close']:.4f} <= ema15={t_1['ema15']:.4f})")
if not vol_dry:
reasons.append(f"거래량 과다({vol_ratio:.2f} >= {self.VOL_THRESHOLD})")
if not resumption:
reasons.append(f"복귀 실패(close_t={t['close']:.4f} >= ema15={t['ema15']:.4f})")
self._last_info.update({"signal": "HOLD", "reason": " | ".join(reasons)})
return "HOLD"
# Step 5: 기본값
self._last_info.update({"signal": "HOLD", "reason": f"미지원 meta_state={meta_state}"})
return "HOLD"
def get_trigger_info(self) -> Dict:
"""디버깅 및 로그용 트리거 상태 정보 반환."""
return self._last_info.copy()
# ═══════════════════════════════════════════════════════════════════
# Module 4: ExecutionManager
# ═══════════════════════════════════════════════════════════════════
_MTF_TRADE_DIR = Path("data/trade_history")
class ExecutionManager:
"""
TriggerStrategy의 신호를 받아 포지션 상태를 관리하고
SL/TP를 계산하여 가상 주문을 실행한다 (Dry-run 모드).
"""
ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.3
def __init__(self, symbol: str = "XRPUSDT"):
self.symbol = symbol
self.current_position: Optional[str] = None # None | 'LONG' | 'SHORT'
self._entry_price: Optional[float] = None
self._entry_ts: Optional[str] = None
self._sl_price: Optional[float] = None
self._tp_price: Optional[float] = None
self._atr_at_entry: Optional[float] = None
def execute(self, signal: str, current_price: float, atr_value: Optional[float]) -> Optional[Dict]:
"""
신호에 따라 가상 주문 실행.
Args:
signal: 'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD'
current_price: 현재 시장가
atr_value: 1h ATR 값
Returns:
주문 정보 Dict 또는 None (HOLD / 중복 포지션 / ATR 무효)
"""
if signal == "HOLD":
return None
if self.current_position is not None:
logger.debug(
f"[ExecutionManager] 포지션 중복 차단: "
f"현재={self.current_position}, 신호={signal}"
)
return None
if atr_value is None or atr_value <= 0 or pd.isna(atr_value):
logger.warning(f"[ExecutionManager] ATR 무효({atr_value}), 주문 차단")
return None
entry_price = current_price
if signal == "EXECUTE_LONG":
sl_price = entry_price - (atr_value * self.ATR_SL_MULT)
tp_price = entry_price + (atr_value * self.ATR_TP_MULT)
side = "LONG"
elif signal == "EXECUTE_SHORT":
sl_price = entry_price + (atr_value * self.ATR_SL_MULT)
tp_price = entry_price - (atr_value * self.ATR_TP_MULT)
side = "SHORT"
else:
return None
self.current_position = side
self._entry_price = entry_price
self._entry_ts = datetime.now(timezone.utc).isoformat()
self._sl_price = sl_price
self._tp_price = tp_price
self._atr_at_entry = atr_value
sl_dist = abs(entry_price - sl_price)
tp_dist = abs(tp_price - entry_price)
rr_ratio = tp_dist / sl_dist if sl_dist > 0 else 0
# ── Dry-run 로그 ──
logger.info(
f"\n┌──────────────────────────────────────────────┐\n"
f"│ [DRY-RUN] 가상 주문 실행 │\n"
f"│ 방향: {side:<5} | 진입가: {entry_price:.4f}\n"
f"│ SL: {sl_price:.4f} ({'-' if side == 'LONG' else '+'}{sl_dist:.4f}, ATR×{self.ATR_SL_MULT}) │\n"
f"│ TP: {tp_price:.4f} ({'+' if side == 'LONG' else '-'}{tp_dist:.4f}, ATR×{self.ATR_TP_MULT}) │\n"
f"│ R:R = 1:{rr_ratio:.1f}\n"
f"└──────────────────────────────────────────────┘"
)
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
# if side == "LONG":
# await self.exchange.create_market_buy_order(symbol, amount)
# await self.exchange.create_order(symbol, 'stop_market', 'sell', amount, params={'stopPrice': sl_price})
# await self.exchange.create_order(symbol, 'take_profit_market', 'sell', amount, params={'stopPrice': tp_price})
# elif side == "SHORT":
# await self.exchange.create_market_sell_order(symbol, amount)
# await self.exchange.create_order(symbol, 'stop_market', 'buy', amount, params={'stopPrice': sl_price})
# await self.exchange.create_order(symbol, 'take_profit_market', 'buy', amount, params={'stopPrice': tp_price})
return {
"action": side,
"entry_price": entry_price,
"sl_price": sl_price,
"tp_price": tp_price,
"atr": atr_value,
"risk_reward": round(rr_ratio, 2),
}
def close_position(self, reason: str, exit_price: float = 0.0, pnl_bps: float = 0.0) -> None:
"""포지션 청산 + JSONL 기록 (상태 초기화)."""
if self.current_position is None:
logger.debug("[ExecutionManager] 청산할 포지션 없음")
return
logger.info(
f"[ExecutionManager] 포지션 청산: {self.current_position} "
f"(진입: {self._entry_price:.4f}) | 사유: {reason}"
)
# JSONL에 기록
self._save_trade(reason, exit_price, pnl_bps)
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
# if self.current_position == "LONG":
# await self.exchange.create_market_sell_order(symbol, amount)
# elif self.current_position == "SHORT":
# await self.exchange.create_market_buy_order(symbol, amount)
self.current_position = None
self._entry_price = None
self._entry_ts = None
self._sl_price = None
self._tp_price = None
self._atr_at_entry = None
def _save_trade(self, reason: str, exit_price: float, pnl_bps: float) -> None:
"""거래 기록을 JSONL 파일에 append."""
record = {
"symbol": self.symbol,
"side": self.current_position,
"entry_price": self._entry_price,
"entry_ts": self._entry_ts,
"exit_price": exit_price,
"exit_ts": datetime.now(timezone.utc).isoformat(),
"sl_price": self._sl_price,
"tp_price": self._tp_price,
"atr": self._atr_at_entry,
"pnl_bps": round(pnl_bps, 1),
"reason": reason,
}
try:
_MTF_TRADE_DIR.mkdir(parents=True, exist_ok=True)
path = _MTF_TRADE_DIR / f"mtf_{self.symbol.replace('/', '').replace(':', '').lower()}.jsonl"
with open(path, "a") as f:
f.write(json.dumps(record) + "\n")
logger.info(f"[ExecutionManager] 거래 기록 저장: {path.name}")
except Exception as e:
logger.warning(f"[ExecutionManager] 거래 기록 저장 실패: {e}")
def get_position_info(self) -> Dict:
"""현재 포지션 정보 반환."""
return {
"position": self.current_position,
"entry_price": self._entry_price,
"sl_price": self._sl_price,
"tp_price": self._tp_price,
}
# ═══════════════════════════════════════════════════════════════════
# 검증 테스트
# ═══════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════
# Main Loop: OOS Dry-run
# ═══════════════════════════════════════════════════════════════════
class MTFPullbackBot:
"""MTF Pullback Bot 메인 루프 — Dry-run OOS 검증용."""
# TODO(LIVE): Kill switch 로직 구현 필요 (Fast Kill 8연패 + Slow Kill PF<0.75) — 2026-04-15 LIVE 전환 시
# TODO(LIVE): 글로벌 RiskManager 통합 필요 — 2026-04-15 LIVE 전환 시
LOOP_INTERVAL = 1 # 초 (TimeframeSync 4초 윈도우를 놓치지 않기 위해)
POLL_INTERVAL = 30 # 데이터 폴링 주기 (초)
def __init__(self, symbol: str = "XRP/USDT:USDT"):
self.symbol = symbol
self.fetcher = DataFetcher(symbol=symbol)
self.meta = MetaFilter(self.fetcher)
self.trigger = TriggerStrategy()
self.executor = ExecutionManager(symbol=symbol)
self.notifier = DiscordNotifier(
webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""),
)
self._last_15m_check_ts: int = 0 # 중복 체크 방지
self._last_poll_ts: float = 0 # 마지막 폴링 시각
async def run(self):
"""메인 루프: 30초 폴링 → 15m 캔들 close 감지 → 신호 판정."""
logger.info(f"[MTFBot] 시작: {self.symbol} (Dry-run OOS 모드)")
await self.fetcher.initialize()
# 초기 상태 출력
meta_state = self.meta.get_market_state()
atr = self.meta.get_current_atr()
logger.info(f"[MTFBot] 초기 상태: Meta={meta_state}, ATR={atr}")
self.notifier.notify_info(
f"**[MTF Dry-run] 봇 시작**\n"
f"심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: `{atr:.6f}`" if atr else
f"**[MTF Dry-run] 봇 시작**\n심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: N/A"
)
try:
while True:
await asyncio.sleep(self.LOOP_INTERVAL)
try:
# 데이터 폴링 (30초마다)
now_mono = _time.monotonic()
if now_mono - self._last_poll_ts >= self.POLL_INTERVAL:
await self._poll_and_update()
self._last_poll_ts = now_mono
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
# 15m 캔들 close 감지
if TimeframeSync.is_15m_candle_closed(now_ms):
if now_ms - self._last_15m_check_ts > 60_000: # 1분 이내 중복 방지
self._last_15m_check_ts = now_ms
await self._poll_and_update() # 최신 데이터 보장
await self._on_15m_close()
# 포지션 보유 중이면 SL/TP 모니터링
if self.executor.current_position is not None:
self._check_sl_tp()
except Exception as e:
logger.error(f"[MTFBot] 루프 에러: {e}")
except asyncio.CancelledError:
logger.info("[MTFBot] 종료 시그널 수신")
finally:
await self.fetcher.close()
logger.info("[MTFBot] 종료 완료")
async def _poll_and_update(self):
"""데이터 폴링 업데이트."""
# 15m
raw_15m = await self.fetcher.fetch_ohlcv(self.symbol, "15m", limit=3)
for candle in raw_15m:
if candle[0] > self.fetcher._last_15m_ts:
self.fetcher.klines_15m.append(candle)
self.fetcher._last_15m_ts = candle[0]
# 1h
raw_1h = await self.fetcher.fetch_ohlcv(self.symbol, "1h", limit=3)
for candle in raw_1h:
if candle[0] > self.fetcher._last_1h_ts:
self.fetcher.klines_1h.append(candle)
self.fetcher._last_1h_ts = candle[0]
async def _on_15m_close(self):
"""15m 캔들 종료 시 신호 판정."""
df_15m = self.fetcher.get_15m_dataframe()
meta_state = self.meta.get_market_state()
atr = self.meta.get_current_atr()
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
last_close = float(df_15m.iloc[-1]["close"]) if df_15m is not None and len(df_15m) > 0 else 0
pos_info = self.executor.current_position or "없음"
# Heartbeat: 15분마다 무조건 출력 (메타 지표 포함)
meta_info = self.meta.get_meta_info()
adx_val = meta_info.get("adx")
ema50_val = meta_info.get("ema50")
ema200_val = meta_info.get("ema200")
adx_str = f"{adx_val:.2f}" if adx_val is not None else "N/A"
ema50_str = f"{ema50_val:.4f}" if ema50_val is not None else "N/A"
ema200_str = f"{ema200_val:.4f}" if ema200_val is not None else "N/A"
atr_str = f"{atr:.6f}" if atr else "N/A"
logger.info(
f"[Heartbeat] 15m 마감 ({now_str}) | Meta: {meta_state} | "
f"ADX: {adx_str} | EMA50: {ema50_str} | EMA200: {ema200_str} | "
f"ATR: {atr_str} | Close: {last_close:.4f} | Pos: {pos_info}"
)
signal = self.trigger.generate_signal(df_15m, meta_state)
info = self.trigger.get_trigger_info()
if signal != "HOLD":
logger.info(f"[MTFBot] 신호: {signal} | {info.get('reason', '')}")
current_price = last_close
result = self.executor.execute(signal, current_price, atr)
if result:
logger.info(f"[MTFBot] 거래 기록: {result}")
side = result["action"]
sl_dist = abs(result["entry_price"] - result["sl_price"])
tp_dist = abs(result["tp_price"] - result["entry_price"])
self.notifier.notify_info(
f"**[MTF Dry-run] 가상 {side} 진입**\n"
f"진입가: `{result['entry_price']:.4f}` | ATR: `{result['atr']:.6f}`\n"
f"SL: `{result['sl_price']:.4f}` ({sl_dist:.4f}) | "
f"TP: `{result['tp_price']:.4f}` ({tp_dist:.4f})\n"
f"R:R = `1:{result['risk_reward']}` | Meta: `{meta_state}`\n"
f"사유: {info.get('reason', '')}"
)
else:
logger.info(f"[MTFBot] HOLD | {info.get('reason', '')}")
def _check_sl_tp(self):
"""현재 가격으로 SL/TP 도달 여부 확인 (15m 캔들 high/low 기반)."""
df_15m = self.fetcher.get_15m_dataframe()
if df_15m is None or len(df_15m) < 1:
return
last = df_15m.iloc[-1]
pos = self.executor.current_position
sl = self.executor._sl_price
tp = self.executor._tp_price
entry = self.executor._entry_price
if pos is None or sl is None or tp is None:
return
hit_sl = hit_tp = False
if pos == "LONG":
hit_sl = last["low"] <= sl
hit_tp = last["high"] >= tp
else:
hit_sl = last["high"] >= sl
hit_tp = last["low"] <= tp
if hit_sl and hit_tp:
exit_price = sl
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
pnl_bps = pnl * 10000
logger.info(f"[MTFBot] SL+TP 동시 히트 → SL 우선 청산 | PnL: {pnl_bps:+.1f}bps")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
self.notifier.notify_info(
f"**[MTF Dry-run] {pos} SL 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl_bps:+.1f}bps`"
)
elif hit_sl:
exit_price = sl
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
pnl_bps = pnl * 10000
logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
self.notifier.notify_info(
f"**[MTF Dry-run] {pos} SL 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl_bps:+.1f}bps`"
)
elif hit_tp:
exit_price = tp
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
pnl_bps = pnl * 10000
logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
self.executor.close_position(f"TP 히트 ({exit_price:.4f})", exit_price, pnl_bps)
self.notifier.notify_info(
f"**[MTF Dry-run] {pos} TP 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl_bps:+.1f}bps`"
)
# ═══════════════════════════════════════════════════════════════════
# 검증 테스트
# ═══════════════════════════════════════════════════════════════════
async def test_module_1_2():
"""Module 1 & 2 검증 테스트."""
print("=" * 60)
print(" MTF Bot Module 1 & 2 검증 테스트")
print("=" * 60)
# ── 1. TimeframeSync 검증 ──
print("\n[1] TimeframeSync 검증")
# 2026-01-01 01:00:03 UTC (1h 캔들 close 직후)
ts_1h_close = int(datetime(2026, 1, 1, 1, 0, 3, tzinfo=timezone.utc).timestamp() * 1000)
# 2026-01-01 00:15:04 UTC (15m 캔들 close 직후)
ts_15m_close = int(datetime(2026, 1, 1, 0, 15, 4, tzinfo=timezone.utc).timestamp() * 1000)
# 2026-01-01 00:15:00 UTC (정각 — 아직 딜레이 전)
ts_too_early = int(datetime(2026, 1, 1, 0, 15, 0, tzinfo=timezone.utc).timestamp() * 1000)
# 2026-01-01 00:15:10 UTC (너무 늦음)
ts_too_late = int(datetime(2026, 1, 1, 0, 15, 10, tzinfo=timezone.utc).timestamp() * 1000)
# 2026-01-01 00:07:03 UTC (15m 경계 아님)
ts_not_boundary = int(datetime(2026, 1, 1, 0, 7, 3, tzinfo=timezone.utc).timestamp() * 1000)
assert TimeframeSync.is_1h_candle_closed(ts_1h_close) is True, "1h close 판별 실패"
assert TimeframeSync.is_15m_candle_closed(ts_15m_close) is True, "15m close 판별 실패"
assert TimeframeSync.is_15m_candle_closed(ts_too_early) is False, "정각(0초)에 True 반환"
assert TimeframeSync.is_15m_candle_closed(ts_too_late) is False, "10초에 True 반환"
assert TimeframeSync.is_15m_candle_closed(ts_not_boundary) is False, "비경계 시점에 True 반환"
assert TimeframeSync.is_1h_candle_closed(ts_15m_close) is False, "15분에 1h close True 반환"
print(" ✓ TimeframeSync: second 2~5 범위에서만 True 반환 확인")
# ── 2. DataFetcher 초기화 ──
print("\n[2] DataFetcher 초기화")
fetcher = DataFetcher(symbol="XRP/USDT:USDT")
try:
await fetcher.initialize()
assert len(fetcher.klines_15m) == 200, f"15m 캔들 {len(fetcher.klines_15m)}개 (200 예상)"
assert len(fetcher.klines_1h) == 200, f"1h 캔들 {len(fetcher.klines_1h)}개 (200 예상)"
print(f" ✓ 초기화 완료: 15m={len(fetcher.klines_15m)}개, 1h={len(fetcher.klines_1h)}")
# ── 3. [:-1] 슬라이싱 검증 ──
print("\n[3] get_1h_dataframe_completed() [:-1] 검증")
df_1h = fetcher.get_1h_dataframe_completed()
assert df_1h is not None, "1h DataFrame이 None"
assert len(df_1h) == 199, f"1h completed 캔들 {len(df_1h)}개 (199 예상)"
# 마지막 완성 봉의 timestamp < 현재 진행 중 봉의 timestamp
last_completed_ts = df_1h.index[-1]
last_raw_ts = pd.to_datetime(fetcher.klines_1h[-1][0], unit="ms", utc=True)
assert last_completed_ts < last_raw_ts, "completed 봉이 진행 중 봉을 포함"
print(f" ✓ 1h completed: {len(df_1h)}개 (200 - 1 = 199, 미완성 봉 제외 확인)")
print(f" 마지막 완성 봉: {last_completed_ts}")
print(f" 진행 중 봉: {last_raw_ts} (제외됨)")
# 15m DataFrame 검증
df_15m = fetcher.get_15m_dataframe()
assert df_15m is not None and len(df_15m) == 200
print(f" ✓ 15m DataFrame: {len(df_15m)}")
# ── 4. MetaFilter 검증 ──
print("\n[4] MetaFilter 검증")
meta = MetaFilter(fetcher)
state = meta.get_market_state()
assert state in ("LONG_ALLOWED", "SHORT_ALLOWED", "WAIT"), f"비정상 상태: {state}"
print(f" ✓ MetaFilter 상태: {state}")
atr = meta.get_current_atr()
assert atr is not None and atr > 0, f"ATR 비정상: {atr}"
print(f" ✓ ATR: {atr:.6f} (> 0 확인)")
info = meta.get_meta_info()
print(f" ✓ Meta Info: {info}")
# ATR 범위 검증 (XRP 기준 0.0001 ~ 0.1)
assert 0.0001 <= atr <= 0.1, f"ATR 범위 이탈: {atr}"
print(f" ✓ ATR 범위 정상: 0.0001 <= {atr:.6f} <= 0.1")
finally:
await fetcher.close()
print("\n" + "=" * 60)
print(" 모든 검증 통과 ✓")
print("=" * 60)
async def test_module_3_4():
"""
Module 3 + 4 통합 테스트.
검증 항목:
[Module 3 - TriggerStrategy]
1. 신호 생성: 'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD' 중 하나 반환
2. EMA15: NaN 아님, 양수, 현실적 범위
3. Vol_SMA20: NaN 아님, 양수
4. vol_ratio: 0.0 ~ 2.0+ 범위 내
5. 3캔들 시퀀스: t-2, t-1, t 인덱싱 정확성
6. meta_state 필터: 'LONG_ALLOWED'에서만 LONG, 'SHORT_ALLOWED'에서만 SHORT
[Module 4 - ExecutionManager]
7. 포지션 중복 방지
8. SL/TP 계산: ATR * 1.5 (SL), ATR * 2.3 (TP)
9. Dry-run 로그 출력
10. 청산 후 재진입 가능
"""
print("=" * 60)
print(" MTF Bot Module 3 & 4 통합 테스트")
print("=" * 60)
# ── DataFetcher로 실제 데이터 로드 ──
fetcher = DataFetcher(symbol="XRP/USDT:USDT")
try:
await fetcher.initialize()
df_15m = fetcher.get_15m_dataframe()
assert df_15m is not None and len(df_15m) >= 25, "15m 데이터 부족"
meta = MetaFilter(fetcher)
meta_state = meta.get_market_state()
atr = meta.get_current_atr()
print(f"\n[환경] MetaFilter: {meta_state} | ATR: {atr}")
# ── [Module 3] TriggerStrategy 검증 ──
print("\n[1] TriggerStrategy 신호 생성")
trigger = TriggerStrategy()
# 테스트 1: 실제 데이터로 신호 생성
signal = trigger.generate_signal(df_15m, meta_state)
assert signal in ("EXECUTE_LONG", "EXECUTE_SHORT", "HOLD"), f"비정상 신호: {signal}"
print(f" ✓ 신호: {signal}")
info = trigger.get_trigger_info()
print(f" ✓ Trigger Info: {info}")
# 테스트 2: 지표 값 검증
if "ema15_t" in info:
assert not pd.isna(info["ema15_t"]) and info["ema15_t"] > 0, "EMA15 비정상"
assert not pd.isna(info["vol_sma20_t2"]) and info["vol_sma20_t2"] > 0, "Vol SMA20 비정상"
assert 0 <= info["vol_ratio"] <= 100, f"vol_ratio 비정상: {info['vol_ratio']}"
print(f" ✓ EMA15(t): {info['ema15_t']:.4f}")
print(f" ✓ Vol SMA20(t-2): {info['vol_sma20_t2']:.0f}")
print(f" ✓ Vol ratio: {info['vol_ratio']:.4f} ({'고갈' if info['vol_ratio'] < 0.5 else '정상'})")
# 테스트 3: meta_state=WAIT → 무조건 HOLD
signal_wait = trigger.generate_signal(df_15m, "WAIT")
assert signal_wait == "HOLD", "WAIT 상태에서 HOLD 아닌 신호 발생"
print(f" ✓ meta_state=WAIT → {signal_wait}")
# 테스트 4: 데이터 부족 → HOLD
signal_short = trigger.generate_signal(df_15m.iloc[:10], "LONG_ALLOWED")
assert signal_short == "HOLD", "데이터 부족에서 HOLD 아닌 신호 발생"
print(f" ✓ 데이터 부족(10행) → {signal_short}")
# 테스트 5: None DataFrame → HOLD
signal_none = trigger.generate_signal(None, "LONG_ALLOWED")
assert signal_none == "HOLD"
print(f" ✓ None DataFrame → HOLD")
# ── [Module 4] ExecutionManager 검증 ──
print(f"\n[2] ExecutionManager 검증")
executor = ExecutionManager()
# 테스트 6: HOLD → None
result = executor.execute("HOLD", 2.5, 0.01)
assert result is None, "HOLD에서 주문 실행됨"
print(f" ✓ HOLD → None")
# 테스트 7: ATR 무효 → None
result = executor.execute("EXECUTE_LONG", 2.5, None)
assert result is None, "ATR=None에서 주문 실행됨"
result = executor.execute("EXECUTE_LONG", 2.5, 0)
assert result is None, "ATR=0에서 주문 실행됨"
print(f" ✓ ATR 무효 → None")
# 테스트 8: 정상 LONG 주문
print(f"\n [LONG 가상 주문 테스트]")
test_atr = 0.01
result = executor.execute("EXECUTE_LONG", 2.5340, test_atr)
assert result is not None, "정상 주문이 None 반환"
assert result["action"] == "LONG"
assert abs(result["sl_price"] - (2.5340 - 0.01 * 1.5)) < 1e-8, "SL 계산 오류"
assert abs(result["tp_price"] - (2.5340 + 0.01 * 2.3)) < 1e-8, "TP 계산 오류"
assert result["risk_reward"] == 1.53, f"R:R 오류: {result['risk_reward']}"
print(f" ✓ LONG 주문: entry={result['entry_price']}, SL={result['sl_price']:.4f}, TP={result['tp_price']:.4f}")
print(f" ✓ R:R = 1:{result['risk_reward']}")
# 테스트 9: 포지션 중복 방지
result_dup = executor.execute("EXECUTE_SHORT", 2.5000, test_atr)
assert result_dup is None, "중복 포지션 허용됨"
assert executor.current_position == "LONG", "포지션 상태 변경됨"
print(f" ✓ 중복 차단: LONG 포지션 중 SHORT 신호 → None")
# 테스트 10: 청산 후 재진입
executor.close_position("테스트 청산")
assert executor.current_position is None, "청산 후 포지션 잔존"
print(f" ✓ 청산 완료, 포지션=None")
# 테스트 11: SHORT 주문
print(f"\n [SHORT 가상 주문 테스트]")
result_short = executor.execute("EXECUTE_SHORT", 2.5340, test_atr)
assert result_short is not None
assert result_short["action"] == "SHORT"
assert abs(result_short["sl_price"] - (2.5340 + 0.01 * 1.5)) < 1e-8, "SHORT SL 오류"
assert abs(result_short["tp_price"] - (2.5340 - 0.01 * 2.3)) < 1e-8, "SHORT TP 오류"
print(f" ✓ SHORT 주문: entry={result_short['entry_price']}, SL={result_short['sl_price']:.4f}, TP={result_short['tp_price']:.4f}")
executor.close_position("테스트 종료")
# 테스트 12: 빈 포지션 청산 → 에러 없이 처리
executor.close_position("이미 청산됨")
print(f" ✓ 빈 포지션 청산 → 에러 없음")
finally:
await fetcher.close()
print("\n" + "=" * 60)
print(" Module 3 & 4 모든 검증 통과 ✓")
print("=" * 60)
async def test_all():
"""Module 1~4 전체 검증."""
await test_module_1_2()
print("\n")
await test_module_3_4()
if __name__ == "__main__":
asyncio.run(test_all())

View File

@@ -6,12 +6,15 @@ from loguru import logger
class DiscordNotifier: class DiscordNotifier:
"""Discord 웹훅으로 거래 알림을 전송하는 노티파이어.""" """Discord 웹훅으로 거래 알림을 전송하는 노티파이어."""
def __init__(self, webhook_url: str): def __init__(self, webhook_url: str, testnet: bool = False):
self.webhook_url = webhook_url self.webhook_url = webhook_url
self._enabled = bool(webhook_url) self._enabled = bool(webhook_url)
self._testnet = testnet
def _send(self, content: str) -> None: def _send(self, content: str) -> None:
"""알림 전송. 이벤트 루프 내에서는 백그라운드 스레드로 실행하여 블로킹 방지.""" """알림 전송. 이벤트 루프 내에서는 백그라운드 스레드로 실행하여 블로킹 방지."""
if self._testnet:
content = f"[TESTNET] {content}"
if not self._enabled: if not self._enabled:
logger.debug("Discord 웹훅 URL 미설정 - 알림 건너뜀") logger.debug("Discord 웹훅 URL 미설정 - 알림 건너뜀")
return return

View File

@@ -12,7 +12,7 @@ class UserDataStream:
""" """
Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다. Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다.
- python-binance BinanceSocketManager의 내장 keepalive 활용 - 매 재연결마다 AsyncClient + BinanceSocketManager를 새로 생성 (listenKey 무효화 대응)
- 네트워크 단절 시 무한 재연결 루프 - 네트워크 단절 시 무한 재연결 루프
- ORDER_TRADE_UPDATE 이벤트에서 지정 심볼의 청산 주문만 필터링하여 콜백 호출 - ORDER_TRADE_UPDATE 이벤트에서 지정 심볼의 청산 주문만 필터링하여 콜백 호출
- 부분 체결(PARTIALLY_FILLED) 시 rp/commission을 누적하여 최종 FILLED에서 합산 콜백 - 부분 체결(PARTIALLY_FILLED) 시 rp/commission을 누적하여 최종 FILLED에서 합산 콜백
@@ -28,22 +28,26 @@ class UserDataStream:
# 부분 체결 누적용: order_id → {rp, commission} # 부분 체결 누적용: order_id → {rp, commission}
self._partial_fills: dict[int, dict[str, float]] = {} self._partial_fills: dict[int, dict[str, float]] = {}
async def start(self, api_key: str, api_secret: str) -> None: async def start(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행.""" """User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
client = await AsyncClient.create( await self._run_loop(api_key, api_secret, testnet)
api_key=api_key,
api_secret=api_secret,
)
bm = BinanceSocketManager(client)
try:
await self._run_loop(bm)
finally:
await client.close_connection()
async def _run_loop(self, bm: BinanceSocketManager) -> None: async def _run_loop(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
"""연결 → 재연결 무한 루프. BinanceSocketManager가 listenKey keepalive를 내부 처리한다.""" """연결 → 재연결 무한 루프.
매 재연결마다 AsyncClient + BinanceSocketManager를 새로 생성한다.
keepalive ping timeout 후 기존 BinanceSocketManager의 listenKey가
무효화되면 재사용 시 이벤트를 수신하지 못하는 "조용한 실패"가 발생하므로,
반드시 새 인스턴스를 만들어야 한다.
"""
while True: while True:
client = await AsyncClient.create(
api_key=api_key,
api_secret=api_secret,
demo=testnet,
)
try: try:
bm = BinanceSocketManager(client)
async with bm.futures_user_socket() as stream: async with bm.futures_user_socket() as stream:
logger.info(f"User Data Stream 연결 완료 (심볼 필터: {self._symbol})") logger.info(f"User Data Stream 연결 완료 (심볼 필터: {self._symbol})")
while True: while True:
@@ -60,6 +64,10 @@ class UserDataStream:
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("User Data Stream 정상 종료") logger.info("User Data Stream 정상 종료")
try:
await client.close_connection()
except Exception:
pass
raise raise
except Exception as e: except Exception as e:
@@ -67,7 +75,13 @@ class UserDataStream:
f"User Data Stream 끊김: {e}" f"User Data Stream 끊김: {e}"
f"{_RECONNECT_DELAY}초 후 재연결" f"{_RECONNECT_DELAY}초 후 재연결"
) )
await asyncio.sleep(_RECONNECT_DELAY) finally:
try:
await client.close_connection()
except Exception:
pass
await asyncio.sleep(_RECONNECT_DELAY)
async def _handle_message(self, msg: dict) -> None: async def _handle_message(self, msg: dict) -> None:
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다.""" """ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
@@ -80,12 +94,19 @@ class UserDataStream:
if order.get("s", "") != self._symbol: if order.get("s", "") != self._symbol:
return return
logger.info(
f"[{self._symbol}] UDS 원본: s={order.get('s')} o={order.get('o')} "
f"ot={order.get('ot')} x={order.get('x')} X={order.get('X')} "
f"R={order.get('R')} S={order.get('S')} ap={order.get('ap')} "
f"rp={order.get('rp')}"
)
# x: Execution Type — TRADE만 처리 # x: Execution Type — TRADE만 처리
if order.get("x") != "TRADE": if order.get("x") != "TRADE":
return return
order_status = order.get("X", "") order_status = order.get("X", "")
order_type = order.get("o", "") order_type = order.get("ot", order.get("o", ""))
is_reduce = order.get("R", False) is_reduce = order.get("R", False)
order_id = order.get("i", 0) order_id = order.get("i", 0)
@@ -94,6 +115,12 @@ class UserDataStream:
if not is_close: if not is_close:
return return
logger.info(
f"[{self._symbol}] 청산 주문 상세: "
f"type={order_type}, status={order_status}, "
f"reduce={is_reduce}, id={order_id}"
)
fill_rp = float(order.get("rp", "0")) fill_rp = float(order.get("rp", "0"))
fill_commission = abs(float(order.get("n", "0"))) fill_commission = abs(float(order.get("n", "0")))

View File

@@ -19,6 +19,7 @@ def config():
"NOTION_TOKEN": "secret_test", "NOTION_TOKEN": "secret_test",
"NOTION_DATABASE_ID": "db_test", "NOTION_DATABASE_ID": "db_test",
"DISCORD_WEBHOOK_URL": "", "DISCORD_WEBHOOK_URL": "",
"BINANCE_TESTNET": "false",
}) })
return Config() return Config()
@@ -34,6 +35,7 @@ def sample_df():
"low": close * 0.995, "low": close * 0.995,
"close": close, "close": close,
"volume": np.random.randint(100000, 1000000, n).astype(float), "volume": np.random.randint(100000, 1000000, n).astype(float),
"atr": np.full(n, 0.005),
}) })

View File

@@ -29,6 +29,7 @@ def test_no_model_should_enter_returns_true(tmp_path):
assert f.should_enter(features) is True assert f.should_enter(features) is True
@patch.dict("os.environ", {"NO_ML_FILTER": "false"})
def test_should_enter_above_threshold(): def test_should_enter_above_threshold():
"""확률 >= 0.60 이면 True""" """확률 >= 0.60 이면 True"""
f = MLFilter(threshold=0.60) f = MLFilter(threshold=0.60)
@@ -40,6 +41,7 @@ def test_should_enter_above_threshold():
assert f.should_enter(features) is True assert f.should_enter(features) is True
@patch.dict("os.environ", {"NO_ML_FILTER": "false"})
def test_should_enter_below_threshold(): def test_should_enter_below_threshold():
"""확률 < 0.60 이면 False""" """확률 < 0.60 이면 False"""
f = MLFilter(threshold=0.60) f = MLFilter(threshold=0.60)

423
tests/test_mtf_bot.py Normal file
View File

@@ -0,0 +1,423 @@
"""
MTF Pullback Bot 유닛 테스트
─────────────────────────────
합성 데이터 기반, 외부 API 호출 없음.
"""
import time
from unittest.mock import patch
import numpy as np
import pandas as pd
import pytest
from src.mtf_bot import (
DataFetcher,
ExecutionManager,
MetaFilter,
TriggerStrategy,
)
# ── Fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def sample_1h_df():
"""EMA50/200, ADX, ATR 계산에 충분한 250개 1h 합성 캔들."""
np.random.seed(42)
n = 250
# 완만한 상승 추세 (EMA50 > EMA200이 되도록)
close = np.cumsum(np.random.randn(n) * 0.001 + 0.0005) + 2.0
high = close + np.abs(np.random.randn(n)) * 0.005
low = close - np.abs(np.random.randn(n)) * 0.005
open_ = close + np.random.randn(n) * 0.001
# 완성된 캔들 timestamp (1h 간격, 과거 시점)
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
timestamps = pd.date_range(start=base_ts, periods=n, freq="1h")
df = pd.DataFrame({
"open": open_,
"high": high,
"low": low,
"close": close,
"volume": np.random.randint(100000, 1000000, n).astype(float),
}, index=timestamps)
df.index.name = "timestamp"
return df
@pytest.fixture
def sample_15m_df():
"""TriggerStrategy용 50개 15m 합성 캔들."""
np.random.seed(99)
n = 50
close = np.cumsum(np.random.randn(n) * 0.001) + 0.5
high = close + np.abs(np.random.randn(n)) * 0.003
low = close - np.abs(np.random.randn(n)) * 0.003
open_ = close + np.random.randn(n) * 0.001
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
timestamps = pd.date_range(start=base_ts, periods=n, freq="15min")
df = pd.DataFrame({
"open": open_,
"high": high,
"low": low,
"close": close,
"volume": np.random.randint(100000, 1000000, n).astype(float),
}, index=timestamps)
df.index.name = "timestamp"
return df
# ═══════════════════════════════════════════════════════════════════
# Test 1: _remove_incomplete_candle
# ═══════════════════════════════════════════════════════════════════
class TestRemoveIncompleteCandle:
"""DataFetcher._remove_incomplete_candle 정적 메서드 테스트."""
def test_removes_incomplete_15m_candle(self):
"""현재 15m 슬롯에 해당하는 미완성 캔들은 제거되어야 한다."""
now_ms = int(time.time() * 1000)
current_slot_ms = (now_ms // (900 * 1000)) * (900 * 1000)
# 완성 캔들 2개 + 미완성 캔들 1개
timestamps = [
pd.Timestamp(current_slot_ms - 1800_000, unit="ms", tz="UTC"), # 2슬롯 전
pd.Timestamp(current_slot_ms - 900_000, unit="ms", tz="UTC"), # 1슬롯 전
pd.Timestamp(current_slot_ms, unit="ms", tz="UTC"), # 현재 슬롯 (미완성)
]
df = pd.DataFrame({
"open": [1.0, 1.1, 1.2],
"high": [1.05, 1.15, 1.25],
"low": [0.95, 1.05, 1.15],
"close": [1.02, 1.12, 1.22],
"volume": [100.0, 200.0, 300.0],
}, index=timestamps)
result = DataFetcher._remove_incomplete_candle(df, interval_sec=900)
assert len(result) == 2, f"미완성 캔들 제거 실패: {len(result)}개 (2개 예상)"
def test_keeps_all_completed_candles(self):
"""모든 캔들이 완성된 경우 제거하지 않아야 한다."""
now_ms = int(time.time() * 1000)
current_slot_ms = (now_ms // (900 * 1000)) * (900 * 1000)
# 모두 과거 슬롯의 완성 캔들
timestamps = [
pd.Timestamp(current_slot_ms - 2700_000, unit="ms", tz="UTC"),
pd.Timestamp(current_slot_ms - 1800_000, unit="ms", tz="UTC"),
pd.Timestamp(current_slot_ms - 900_000, unit="ms", tz="UTC"),
]
df = pd.DataFrame({
"open": [1.0, 1.1, 1.2],
"high": [1.05, 1.15, 1.25],
"low": [0.95, 1.05, 1.15],
"close": [1.02, 1.12, 1.22],
"volume": [100.0, 200.0, 300.0],
}, index=timestamps)
result = DataFetcher._remove_incomplete_candle(df, interval_sec=900)
assert len(result) == 3, f"완성 캔들 유지 실패: {len(result)}개 (3개 예상)"
def test_empty_dataframe(self):
"""빈 DataFrame 입력 시 빈 DataFrame 반환."""
df = pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
result = DataFetcher._remove_incomplete_candle(df, interval_sec=900)
assert result.empty
def test_1h_interval(self):
"""1h 간격에서도 정상 동작."""
now_ms = int(time.time() * 1000)
current_slot_ms = (now_ms // (3600 * 1000)) * (3600 * 1000)
timestamps = [
pd.Timestamp(current_slot_ms - 7200_000, unit="ms", tz="UTC"),
pd.Timestamp(current_slot_ms - 3600_000, unit="ms", tz="UTC"),
pd.Timestamp(current_slot_ms, unit="ms", tz="UTC"), # 현재 슬롯 (미완성)
]
df = pd.DataFrame({
"open": [1.0, 1.1, 1.2],
"high": [1.05, 1.15, 1.25],
"low": [0.95, 1.05, 1.15],
"close": [1.02, 1.12, 1.22],
"volume": [100.0, 200.0, 300.0],
}, index=timestamps)
result = DataFetcher._remove_incomplete_candle(df, interval_sec=3600)
assert len(result) == 2
# ═══════════════════════════════════════════════════════════════════
# Test 2: MetaFilter
# ═══════════════════════════════════════════════════════════════════
class TestMetaFilter:
"""MetaFilter 상태 판별 로직 테스트."""
def _make_fetcher_with_df(self, df_1h):
"""Mock DataFetcher를 생성하여 특정 1h DataFrame을 반환하도록 설정."""
fetcher = DataFetcher.__new__(DataFetcher)
fetcher.klines_15m = []
fetcher.klines_1h = []
fetcher.data_fetcher = None
# get_1h_dataframe_completed 을 직접 패치
fetcher.get_1h_dataframe_completed = lambda: df_1h
return fetcher
def test_wait_when_adx_below_threshold(self, sample_1h_df):
"""ADX < 20이면 WAIT 상태."""
import pandas_ta as ta
df = sample_1h_df.copy()
# 변동성이 없는 flat 데이터 → ADX가 낮을 가능성 높음
df["close"] = 2.0 # 완전 flat
df["high"] = 2.001
df["low"] = 1.999
df["open"] = 2.0
fetcher = self._make_fetcher_with_df(df)
meta = MetaFilter(fetcher)
state = meta.get_market_state()
assert state == "WAIT", f"Flat 데이터에서 WAIT 아닌 상태: {state}"
def test_long_allowed_when_uptrend(self):
"""EMA50 > EMA200 + ADX > 20이면 LONG_ALLOWED."""
np.random.seed(10)
n = 250
# 강한 상승 추세
close = np.linspace(1.0, 3.0, n) + np.random.randn(n) * 0.01
high = close + 0.02
low = close - 0.02
open_ = close - 0.005
base_ts = pd.Timestamp("2025-01-01", tz="UTC")
timestamps = pd.date_range(start=base_ts, periods=n, freq="1h")
df = pd.DataFrame({
"open": open_, "high": high, "low": low,
"close": close, "volume": np.ones(n) * 500000,
}, index=timestamps)
fetcher = self._make_fetcher_with_df(df)
meta = MetaFilter(fetcher)
state = meta.get_market_state()
assert state == "LONG_ALLOWED", f"강한 상승 추세에서 LONG_ALLOWED 아닌 상태: {state}"
def test_short_allowed_when_downtrend(self):
"""EMA50 < EMA200 + ADX > 20이면 SHORT_ALLOWED."""
np.random.seed(20)
n = 250
# 강한 하락 추세
close = np.linspace(3.0, 1.0, n) + np.random.randn(n) * 0.01
high = close + 0.02
low = close - 0.02
open_ = close + 0.005
base_ts = pd.Timestamp("2025-01-01", tz="UTC")
timestamps = pd.date_range(start=base_ts, periods=n, freq="1h")
df = pd.DataFrame({
"open": open_, "high": high, "low": low,
"close": close, "volume": np.ones(n) * 500000,
}, index=timestamps)
fetcher = self._make_fetcher_with_df(df)
meta = MetaFilter(fetcher)
state = meta.get_market_state()
assert state == "SHORT_ALLOWED", f"강한 하락 추세에서 SHORT_ALLOWED 아닌 상태: {state}"
def test_indicator_caching(self, sample_1h_df):
"""동일 캔들에 대해 _calc_indicators가 캐시를 재사용하는지 확인."""
fetcher = self._make_fetcher_with_df(sample_1h_df)
meta = MetaFilter(fetcher)
# 첫 호출: 캐시 없음
df1 = meta._calc_indicators(sample_1h_df)
ts1 = meta._cache_timestamp
# 두 번째 호출: 동일 DataFrame → 캐시 히트
df2 = meta._calc_indicators(sample_1h_df)
assert df1 is df2, "동일 데이터에 대해 캐시가 재사용되지 않음"
assert meta._cache_timestamp == ts1
# ═══════════════════════════════════════════════════════════════════
# Test 3: TriggerStrategy
# ═══════════════════════════════════════════════════════════════════
class TestTriggerStrategy:
"""15m 3-candle pullback 시퀀스 감지 테스트."""
def test_hold_when_meta_wait(self, sample_15m_df):
"""meta_state=WAIT이면 항상 HOLD."""
trigger = TriggerStrategy()
signal = trigger.generate_signal(sample_15m_df, "WAIT")
assert signal == "HOLD"
def test_hold_when_insufficient_data(self):
"""데이터가 25개 미만이면 HOLD."""
trigger = TriggerStrategy()
small_df = pd.DataFrame({
"open": [1.0] * 10,
"high": [1.1] * 10,
"low": [0.9] * 10,
"close": [1.0] * 10,
"volume": [100.0] * 10,
})
signal = trigger.generate_signal(small_df, "LONG_ALLOWED")
assert signal == "HOLD"
def test_long_pullback_signal(self):
"""LONG 풀백 시퀀스: t-1 EMA 아래 이탈 + 거래량 고갈 + t EMA 복귀."""
np.random.seed(42)
n = 30
# 기본 상승 추세
close = np.linspace(1.0, 1.1, n)
high = close + 0.005
low = close - 0.005
open_ = close - 0.001
volume = np.ones(n) * 100000
# t-1 (인덱스 -2): EMA 아래로 이탈 + 거래량 고갈
close[-2] = close[-3] - 0.02 # EMA 아래로 이탈
volume[-2] = 5000 # 매우 낮은 거래량
# t (인덱스 -1): EMA 위로 복귀
close[-1] = close[-3] + 0.01
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
timestamps = pd.date_range(start=base_ts, periods=n, freq="15min")
df = pd.DataFrame({
"open": open_, "high": high, "low": low,
"close": close, "volume": volume,
}, index=timestamps)
trigger = TriggerStrategy()
signal = trigger.generate_signal(df, "LONG_ALLOWED")
# 풀백 조건 충족 여부는 EMA 계산 결과에 따라 다를 수 있으므로
# 최소한 valid signal을 반환하는지 확인
assert signal in ("EXECUTE_LONG", "HOLD")
def test_short_pullback_signal(self):
"""SHORT 풀백 시퀀스: t-1 EMA 위로 이탈 + 거래량 고갈 + t EMA 아래 복귀."""
np.random.seed(42)
n = 30
# 하락 추세
close = np.linspace(1.1, 1.0, n)
high = close + 0.005
low = close - 0.005
open_ = close + 0.001
volume = np.ones(n) * 100000
# t-1: EMA 위로 이탈 + 거래량 고갈
close[-2] = close[-3] + 0.02
volume[-2] = 5000
# t: EMA 아래로 복귀
close[-1] = close[-3] - 0.01
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
timestamps = pd.date_range(start=base_ts, periods=n, freq="15min")
df = pd.DataFrame({
"open": open_, "high": high, "low": low,
"close": close, "volume": volume,
}, index=timestamps)
trigger = TriggerStrategy()
signal = trigger.generate_signal(df, "SHORT_ALLOWED")
assert signal in ("EXECUTE_SHORT", "HOLD")
def test_trigger_info_populated(self, sample_15m_df):
"""generate_signal 후 get_trigger_info가 비어있지 않아야 한다."""
trigger = TriggerStrategy()
trigger.generate_signal(sample_15m_df, "LONG_ALLOWED")
info = trigger.get_trigger_info()
assert "signal" in info or "reason" in info
# ═══════════════════════════════════════════════════════════════════
# Test 4: ExecutionManager (SL/TP 계산)
# ═══════════════════════════════════════════════════════════════════
class TestExecutionManager:
"""ExecutionManager SL/TP 계산 및 포지션 관리 테스트."""
def test_long_sl_tp_calculation(self):
"""LONG 진입 시 SL = entry - ATR*1.5, TP = entry + ATR*2.3."""
em = ExecutionManager(symbol="XRPUSDT")
entry = 2.0
atr = 0.01
result = em.execute("EXECUTE_LONG", entry, atr)
assert result is not None
assert result["action"] == "LONG"
expected_sl = entry - (atr * 1.5)
expected_tp = entry + (atr * 2.3)
assert abs(result["sl_price"] - expected_sl) < 1e-8, f"SL: {result['sl_price']} != {expected_sl}"
assert abs(result["tp_price"] - expected_tp) < 1e-8, f"TP: {result['tp_price']} != {expected_tp}"
def test_short_sl_tp_calculation(self):
"""SHORT 진입 시 SL = entry + ATR*1.5, TP = entry - ATR*2.3."""
em = ExecutionManager(symbol="XRPUSDT")
entry = 2.0
atr = 0.01
result = em.execute("EXECUTE_SHORT", entry, atr)
assert result is not None
assert result["action"] == "SHORT"
expected_sl = entry + (atr * 1.5)
expected_tp = entry - (atr * 2.3)
assert abs(result["sl_price"] - expected_sl) < 1e-8
assert abs(result["tp_price"] - expected_tp) < 1e-8
def test_hold_returns_none(self):
"""HOLD 신호는 None 반환."""
em = ExecutionManager(symbol="XRPUSDT")
result = em.execute("HOLD", 2.0, 0.01)
assert result is None
def test_duplicate_position_blocked(self):
"""이미 포지션이 있으면 중복 진입 차단."""
em = ExecutionManager(symbol="XRPUSDT")
em.execute("EXECUTE_LONG", 2.0, 0.01)
result = em.execute("EXECUTE_SHORT", 2.1, 0.01)
assert result is None, "포지션 중복 차단 실패"
def test_reentry_after_close(self):
"""청산 후 재진입 가능."""
em = ExecutionManager(symbol="XRPUSDT")
em.execute("EXECUTE_LONG", 2.0, 0.01)
em.close_position("test", exit_price=2.01, pnl_bps=50)
result = em.execute("EXECUTE_SHORT", 2.05, 0.01)
assert result is not None, "청산 후 재진입 실패"
assert result["action"] == "SHORT"
def test_invalid_atr_blocked(self):
"""ATR이 None/0/NaN이면 주문 차단."""
em = ExecutionManager(symbol="XRPUSDT")
assert em.execute("EXECUTE_LONG", 2.0, None) is None
assert em.execute("EXECUTE_LONG", 2.0, 0) is None
assert em.execute("EXECUTE_LONG", 2.0, float("nan")) is None
def test_risk_reward_ratio(self):
"""R:R 비율이 올바르게 계산되는지 확인."""
em = ExecutionManager(symbol="XRPUSDT")
result = em.execute("EXECUTE_LONG", 2.0, 0.01)
# TP/SL = 2.3/1.5 = 1.533...
expected_rr = round(2.3 / 1.5, 2)
assert result["risk_reward"] == expected_rr