37 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
21in7
d8f5d4f1fb feat(backtest): add Kill Switch to BacktestRiskManager for fair ML comparison
Adds Fast Kill (8 consecutive losses) and Slow Kill (PF < 0.75 over 15 trades)
to the backtester, matching bot.py behavior. Without this, ML OFF overtrades
and self-destructs, making ML ON look artificially better.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:20:42 +09:00
21in7
b5a5510499 feat(backtest): add --compare-ml for ML on/off walk-forward comparison
Runs WalkForwardBacktester twice (use_ml=True/False), prints side-by-side
comparison of PF, win rate, MDD, Sharpe, and auto-judges ML filter value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:58:24 +09:00
21in7
c29d3e0569 feat(ml): add purged gap (embargo=24) to walk-forward + ablation CLI
- Add LOOKAHEAD embargo between train/val splits in all 3 WF functions
  to prevent label leakage from 6h lookahead window
- Add --ablation flag to train_model.py for signal_strength/side
  dependency diagnosis (A/B/C experiment with drop analysis)
- Criteria: A→C drop ≤0.05=good, 0.05-0.10=conditional, ≥0.10=redesign

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:42:51 +09:00
21in7
30ddb2fef4 feat(ml): relax training thresholds for 5-10x more training samples
Add TRAIN_* constants (signal_threshold=2, adx=15, vol_mult=1.5, neg_ratio=3)
as dataset_builder defaults. Remove hardcoded negative_ratio=5 from all callers.
Bot entry conditions unchanged (config.py strict values).

WF 5-fold results (all symbols AUC 0.91+):
- XRPUSDT: 0.9216 ± 0.0052
- SOLUSDT:  0.9174 ± 0.0063
- DOGEUSDT: 0.9222 ± 0.0085

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:38:15 +09:00
21in7
6830549fd6 fix: ML pipeline train-serve alignment and backtest accuracy
- Parameterize SL/TP multipliers in dataset_builder (C1)
- Pass SL/TP from all callers with CLI args --sl-mult/--tp-mult (C1)
- Align default SL/TP to 2.0/2.0 matching config.py (C1)
- Include unrealized PnL in backtester equity curve (I4)
- Remove MLX double normalization in walk-forward (C3)
- Use stratified_undersample in MLX training (I1)
- Add MLFilter.from_model() factory method (I3)
- Fix backtest_validator initial_balance hardcoding (I5)
- Deprecate legacy generate_dataset()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:44:07 +09:00
21in7
fe99885faa fix(ml): align dataset_builder default SL/TP with config (2.0/2.0)
Module-level ATR_SL_MULT was 1.5, now 2.0 to match config.py and CLI defaults.
This ensures generate_dataset_vectorized() produces correct labels even without
explicit parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:43:09 +09:00
21in7
4533118aab docs: update plan history with ml-pipeline-fixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:39:14 +09:00
21in7
c0da46c60a chore: deprecate legacy dataset generation, remove stale comparison test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:38:07 +09:00
21in7
5bad7dd691 refactor(ml): add MLFilter.from_model(), fix validator initial_balance
- MLFilter.from_model() classmethod eliminates brittle __new__() private-attribute
  manipulation in backtester walk-forward model injection
- backtest_validator._check_invariants() now accepts cfg and uses cfg.initial_balance
  instead of a hardcoded 1000.0 for the negative-balance invariant check
- backtester.py walk-forward injection block simplified to use the new factory method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:36:30 +09:00
21in7
a34fc6f996 fix(mlx): use stratified_undersample consistent with LightGBM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:33:36 +09:00
21in7
24f0faa540 fix(mlx): remove double normalization in walk-forward validation
Add normalize=False parameter to MLXFilter.fit() so external callers
can skip internal normalization. Remove the external normalization +
manual _mean/_std reset hack from walk_forward_auc() in train_mlx_model.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:31:11 +09:00
21in7
0fe87bb366 fix(backtest): include unrealized PnL in equity curve for accurate MDD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:26:09 +09:00
21in7
0cc5835b3a fix(ml): pass SL/TP multipliers to dataset generation — align train/serve
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:16:50 +09:00
21in7
75d1af7fcc feat(ml): parameterize SL/TP multipliers in dataset_builder
Add atr_sl_mult and atr_tp_mult parameters to _calc_labels_vectorized
and generate_dataset_vectorized, defaulting to existing constants (1.5,
2.0) for full backward compatibility. Callers (train scripts, backtester)
can now pass symbol-specific multipliers without modifying module-level
constants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:03:24 +09:00
45 changed files with 6392 additions and 212 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)`.
@@ -144,3 +144,14 @@ All design documents and implementation plans are stored in `docs/plans/` with t
| 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed | | 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed |
| 2026-03-21 | `dashboard-code-review-r2` (#14,#19) | Completed | | 2026-03-21 | `dashboard-code-review-r2` (#14,#19) | Completed |
| 2026-03-21 | `code-review-fixes-r2` (9 issues) | Completed | | 2026-03-21 | `code-review-fixes-r2` (9 issues) | 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 | `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-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,66 @@
# Dashboard Code Review R2
**날짜**: 2026-03-21
**상태**: Completed
**커밋**: e362329
## 원본 리뷰 (23건) → 재평가 결과
원래 23건의 코드 리뷰 항목 중 15건이 과잉 지적으로 판단되어 삭제, 5건은 Low로 하향, 3건만 Medium 유지. 이후 내부망 전용 환경 감안하여 #3 Health 503도 Low로 하향.
## 삭제 (15건)
| # | 이슈 | 삭제 사유 |
|---|------|----------|
| 1 | SQL Injection — reset_db | 테이블명 하드코딩 리스트, 코드 명백 |
| 4 | CORS wildcard + CSRF | X-API-Key 커스텀 헤더가 preflight 강제, CSRF 벡터 없음 |
| 5 | get_symbols 쿼리 비효율 | bot_status 수십 건, 최적화 불필요 |
| 6 | Signal handler sys.exit() | _shutdown=True → commit → close → exit 순서 이미 방어적 |
| 7 | SIGHUP DB 미초기화 | reset_db API가 5개 테이블 DELETE 후 SIGHUP, 설계상 올바름 |
| 8 | stale trade 삭제 f-string | parameterized query 패턴, 안전 |
| 9 | 파싱 순서 의존성 | 각 패턴이 서로 다른 한국어 키워드, 충돌 불가 |
| 10 | PID 파일 경쟁 조건 | Docker 단일 인스턴스 |
| 12 | 인라인 스타일 | S 객체로 변수화, 이 규모에서 CSS framework은 오버엔지니어링 |
| 15 | fmtTime 라벨 충돌 | 96캔들=24시간, 날짜 겹칠 일 없음 |
| 16 | prompt()/confirm() | 관리자 전용 드문 기능, 네이티브 dialog 적절 |
| 17 | 함수형 dataKey | 차트 2개 기준선, 성능 영향 0 |
| 20 | API Dockerfile requirements 미분리 | 패키지 2개, requirements.txt 오버헤드만 추가 |
| 21 | Nginx resolver 하드코딩 | Docker-only 환경 |
| 23 | private 메서드 직접 테스트 | 파서 핵심 로직 검증, 자주 리팩토링될 구조 아님 |
## Low로 하향 (5건, 수정 미진행)
| # | 이슈 | 하향 사유 |
|---|------|----------|
| 2 | DB PRAGMA 반복 | SQLite connect()는 파일 open 수준, 15초 폴링에서 병목 아님 |
| 11 | App.jsx 모놀리식 | 737줄이나 컴포넌트 파일 내 잘 분리, 추가 기능 계획 없으면 YAGNI |
| 13 | 부분 API 실패 | 이전 값 유지가 전체 초기화보다 나은 동작, 15초 후 자동 복구 |
| 18 | pos.id undefined | _handle_entry 중복 체크로 발생 확률 극히 낮음 |
| 22 | 테스트 환경변수 오염 | dashboard_api import하는 테스트 파일 하나뿐 |
## Low로 하향 (내부망 감안)
| # | 이슈 | 하향 사유 |
|---|------|----------|
| 3 | Health 에러 시 200→503 | 내부망 전용, 로드밸런서 health check 시나리오 없음 |
## 수정 완료 (2건)
### #14 Trades 페이지네이션 (Medium)
**문제**: API가 offset 파라미터를 지원하는데 프론트엔드에서 항상 `limit=50&offset=0`만 호출. tradesTotal > 50이면 나머지를 볼 수 없음.
**수정** (`dashboard/ui/src/App.jsx`):
- `tradesPage` state 추가
- fetchAll API 호출에 `offset=${tradesPage * 50}` 반영
- `useCallback` dependency에 `tradesPage` 추가
- 심볼 변경 시 `setTradesPage(0)` 리셋
- Trades 탭 하단에 이전/다음 페이지네이션 컨트롤 추가 (범위 표시: `150 / 총건수`)
### #19 package-lock.json + npm ci (Medium)
**문제**: `dashboard/ui/Dockerfile`에서 `COPY package.json .` + `npm install`만 사용. package-lock.json이 존재하는데 활용하지 않아 빌드 재현성 미보장.
**수정** (`dashboard/ui/Dockerfile`):
- `COPY package.json package-lock.json .`
- `RUN npm ci`

View File

@@ -0,0 +1,686 @@
# ML Pipeline Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** ML 파이프라인의 학습-서빙 불일치(SL/TP 배수, 언더샘플링, 정규화)와 백테스트 정확도 이슈를 수정하여 모델 평가 체계와 실전 환경을 일치시킨다.
**Architecture:** `dataset_builder.py`의 하드코딩 SL/TP 상수를 파라미터화하고, 모든 호출부(train_model, train_mlx_model, tune_hyperparams, backtester)가 동일한 값을 주입하도록 변경. MLX 학습의 이중 정규화 제거. 백테스터의 에퀴티 커브에 미실현 PnL 반영. MLFilter에 factory method 추가.
**Tech Stack:** Python, LightGBM, MLX, pandas, numpy, pytest
---
## File Structure
| 파일 | 변경 유형 | 역할 |
|------|-----------|------|
| `src/dataset_builder.py` | Modify | SL/TP 상수 → 파라미터화 |
| `src/ml_filter.py` | Modify | `from_model()` factory method 추가 |
| `src/mlx_filter.py` | Modify | fit()에 `normalize` 파라미터 추가 |
| `src/backtester.py` | Modify | 에퀴티 미실현 PnL, MLFilter factory, initial_balance |
| `src/backtest_validator.py` | Modify | initial_balance 하드코딩 제거 |
| `scripts/train_model.py` | Modify | 레거시 상수 제거, SL/TP 전달 |
| `scripts/train_mlx_model.py` | Modify | 이중 정규화 제거, stratified_undersample 적용 |
| `scripts/tune_hyperparams.py` | Modify | SL/TP 전달 |
| `tests/test_dataset_builder.py` | Modify | SL/TP 파라미터 테스트 추가 |
| `tests/test_ml_pipeline_fixes.py` | Create | 신규 수정사항 전용 테스트 |
---
### Task 1: SL/TP 배수 파라미터화 — dataset_builder.py
**Files:**
- Modify: `src/dataset_builder.py:14-16, 322-383, 385-494`
- Test: `tests/test_dataset_builder.py`
- [ ] **Step 1: 기존 테스트 통과 확인**
Run: `bash scripts/run_tests.sh -k "dataset_builder"`
Expected: 모든 테스트 PASS
- [ ] **Step 2: 파라미터화 테스트 작성**
`tests/test_ml_pipeline_fixes.py`에 추가:
```python
import numpy as np
import pandas as pd
import pytest
from src.dataset_builder import generate_dataset_vectorized, _calc_labels_vectorized
@pytest.fixture
def signal_df():
"""시그널이 발생하는 데이터."""
rng = np.random.default_rng(7)
n = 800
trend = np.linspace(1.5, 3.0, n)
noise = np.cumsum(rng.normal(0, 0.04, n))
close = np.clip(trend + noise, 0.01, None)
high = close * (1 + rng.uniform(0, 0.015, n))
low = close * (1 - rng.uniform(0, 0.015, n))
volume = rng.uniform(1e6, 3e6, n)
volume[::30] *= 3.0
return pd.DataFrame({
"open": close, "high": high, "low": low,
"close": close, "volume": volume,
})
def test_sltp_params_are_passed_through(signal_df):
"""SL/TP 배수가 generate_dataset_vectorized에 전달되어야 한다."""
r1 = generate_dataset_vectorized(
signal_df, atr_sl_mult=1.5, atr_tp_mult=2.0,
adx_threshold=0, volume_multiplier=1.5,
)
r2 = generate_dataset_vectorized(
signal_df, atr_sl_mult=2.0, atr_tp_mult=2.0,
adx_threshold=0, volume_multiplier=1.5,
)
# SL이 다르면 레이블 분포가 달라져야 한다
if len(r1) > 0 and len(r2) > 0:
# 정확히 같은 분포일 확률은 매우 낮음
assert not (r1["label"].values == r2["label"].values).all() or len(r1) != len(r2), \
"SL 배수가 다르면 레이블이 달라져야 한다"
def test_default_sltp_backward_compatible(signal_df):
"""SL/TP 파라미터 미지정 시 기존 기본값(1.5, 2.0)으로 동작해야 한다."""
r_default = generate_dataset_vectorized(
signal_df, adx_threshold=0, volume_multiplier=1.5,
)
r_explicit = generate_dataset_vectorized(
signal_df, atr_sl_mult=1.5, atr_tp_mult=2.0,
adx_threshold=0, volume_multiplier=1.5,
)
if len(r_default) > 0:
assert len(r_default) == len(r_explicit)
assert (r_default["label"].values == r_explicit["label"].values).all()
```
- [ ] **Step 3: 테스트 실패 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py -v`
Expected: FAIL — `generate_dataset_vectorized() got an unexpected keyword argument 'atr_sl_mult'`
- [ ] **Step 4: dataset_builder.py 수정**
`src/dataset_builder.py` 변경:
1. 모듈 상수 `ATR_SL_MULT`, `ATR_TP_MULT`는 기본값으로 유지 (하위 호환)
2. `_calc_labels_vectorized``atr_sl_mult`, `atr_tp_mult` 파라미터 추가
3. `generate_dataset_vectorized``atr_sl_mult`, `atr_tp_mult` 파라미터 추가하여 `_calc_labels_vectorized`에 전달
```python
# _calc_labels_vectorized 시그니처 변경:
def _calc_labels_vectorized(
d: pd.DataFrame,
feat: pd.DataFrame,
sig_idx: np.ndarray,
atr_sl_mult: float = ATR_SL_MULT,
atr_tp_mult: float = ATR_TP_MULT,
) -> tuple[np.ndarray, np.ndarray]:
# 함수 본문 (lines 350-355) 변경:
# 변경 전:
# sl = entry - atr * ATR_SL_MULT
# tp = entry + atr * ATR_TP_MULT
# 변경 후:
if signal == "LONG":
sl = entry - atr * atr_sl_mult
tp = entry + atr * atr_tp_mult
else:
sl = entry + atr * atr_sl_mult
tp = entry - atr * atr_tp_mult
# generate_dataset_vectorized 시그니처 변경:
def generate_dataset_vectorized(
df: pd.DataFrame,
btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None,
time_weight_decay: float = 0.0,
negative_ratio: int = 0,
signal_threshold: int = 3,
adx_threshold: float = 25,
volume_multiplier: float = 2.5,
atr_sl_mult: float = ATR_SL_MULT, # 추가
atr_tp_mult: float = ATR_TP_MULT, # 추가
) -> pd.DataFrame:
# _calc_labels_vectorized 호출 시 전달:
# labels, valid_mask = _calc_labels_vectorized(
# d, feat_all, sig_idx,
# atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult,
# )
```
- [ ] **Step 5: 테스트 통과 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py tests/test_dataset_builder.py -v`
Expected: ALL PASS
- [ ] **Step 6: 커밋**
```bash
git add src/dataset_builder.py tests/test_ml_pipeline_fixes.py
git commit -m "feat(ml): parameterize SL/TP multipliers in dataset_builder"
```
---
### Task 2: 호출부 SL/TP 전달 — train_model, train_mlx_model, tune_hyperparams, backtester
**Files:**
- Modify: `scripts/train_model.py:57-58, 217-221, 358-362, 448-452`
- Modify: `scripts/train_mlx_model.py:61, 179`
- Modify: `scripts/tune_hyperparams.py:67`
- Modify: `src/backtester.py:739-746`
- [ ] **Step 1: train_model.py 수정**
1. 레거시 모듈 상수 `ATR_SL_MULT=1.5`, `ATR_TP_MULT=3.0` (line 57-58)을 삭제
2. `main()`의 argparse에 `--sl-mult` (기본 2.0), `--tp-mult` (기본 2.0) CLI 인자 추가
3. `train()`, `walk_forward_auc()`, `compare()` 함수에 `atr_sl_mult`, `atr_tp_mult` 파라미터 추가하여 `generate_dataset_vectorized`에 전달
```python
# argparse에 추가:
parser.add_argument("--sl-mult", type=float, default=2.0, help="SL ATR 배수 (기본 2.0)")
parser.add_argument("--tp-mult", type=float, default=2.0, help="TP ATR 배수 (기본 2.0)")
# train() 시그니처:
def train(data_path, time_weight_decay=2.0, tuned_params_path=None,
atr_sl_mult=2.0, atr_tp_mult=2.0):
# train() 내:
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
negative_ratio=5,
atr_sl_mult=atr_sl_mult,
atr_tp_mult=atr_tp_mult,
)
# main()에서 호출:
train(args.data, ..., atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
```
- [ ] **Step 2: train_mlx_model.py 수정**
동일하게 `--sl-mult`, `--tp-mult` CLI 인자 추가. `train_mlx()`, `walk_forward_auc()` 함수에 파라미터 전달.
- [ ] **Step 3: tune_hyperparams.py 수정**
`--sl-mult`, `--tp-mult` CLI 인자 추가. `load_dataset()` 함수에 파라미터 전달.
- [ ] **Step 4: backtester.py WalkForward 수정**
`WalkForwardBacktester._train_model()` (line 739-746)에서 `generate_dataset_vectorized` 호출 시 `self.cfg.atr_sl_mult`, `self.cfg.atr_tp_mult` 전달:
```python
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=self.cfg.time_weight_decay,
negative_ratio=self.cfg.negative_ratio,
signal_threshold=self.cfg.signal_threshold,
adx_threshold=self.cfg.adx_threshold,
volume_multiplier=self.cfg.volume_multiplier,
atr_sl_mult=self.cfg.atr_sl_mult,
atr_tp_mult=self.cfg.atr_tp_mult,
)
```
- [ ] **Step 5: 전체 테스트 통과 확인**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 6: 커밋**
```bash
git add scripts/train_model.py scripts/train_mlx_model.py scripts/tune_hyperparams.py src/backtester.py
git commit -m "fix(ml): pass SL/TP multipliers to dataset generation — align train/serve"
```
---
### Task 3: 백테스터 에퀴티 커브 미실현 PnL 반영
**Files:**
- Modify: `src/backtester.py:571-578`
- Test: `tests/test_ml_pipeline_fixes.py`
- [ ] **Step 1: 테스트 작성**
```python
def test_equity_curve_includes_unrealized_pnl():
"""에퀴티 커브에 미실현 PnL이 반영되어야 한다."""
from src.backtester import Backtester, BacktestConfig, Position
import pandas as pd
cfg = BacktestConfig(symbols=["TEST"], initial_balance=1000.0)
bt = Backtester.__new__(Backtester)
bt.cfg = cfg
bt.balance = 1000.0
bt._peak_equity = 1000.0
bt.equity_curve = []
# LONG 포지션: 진입가 100, 현재가는 candle row로 전달
bt.positions = {"TEST": Position(
symbol="TEST", side="LONG", entry_price=100.0,
quantity=10.0, sl=95.0, tp=110.0,
entry_time=pd.Timestamp("2026-01-01"), entry_fee=0.4,
)}
# candle row에 close=105 → 미실현 PnL = (105-100)*10 = 50
row = pd.Series({"close": 105.0})
bt._record_equity(pd.Timestamp("2026-01-01 00:15:00"), current_prices={"TEST": 105.0})
last = bt.equity_curve[-1]
assert last["equity"] == 1050.0, f"Expected 1050.0 (1000+50), got {last['equity']}"
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py::test_equity_curve_includes_unrealized_pnl -v`
Expected: FAIL
- [ ] **Step 3: _record_equity 수정**
`src/backtester.py``_record_equity` 메서드를 수정:
```python
def _record_equity(self, ts: pd.Timestamp, current_prices: dict[str, float] | None = None):
unrealized = 0.0
for sym, pos in self.positions.items():
price = (current_prices or {}).get(sym)
if price is not None:
if pos.side == "LONG":
unrealized += (price - pos.entry_price) * pos.quantity
else:
unrealized += (pos.entry_price - price) * pos.quantity
equity = self.balance + unrealized
self.equity_curve.append({"timestamp": str(ts), "equity": round(equity, 4)})
if equity > self._peak_equity:
self._peak_equity = equity
```
메인 루프 호출부(`run()``_record_equity` 호출)도 수정:
```python
# run() 메인 루프 내:
current_prices = {}
for sym in self.cfg.symbols:
idx = ... # 현재 캔들 인덱스
current_prices[sym] = float(all_indicators[sym].iloc[...]["close"])
self._record_equity(ts, current_prices=current_prices)
```
메인 루프의 이벤트는 `(ts, sym, candle_idx)` 튜플로, 타임스탬프별로 정렬되어 있다 (line 426: `events.sort(key=lambda x: (x[0], x[1]))`). 같은 타임스탬프에 여러 심볼 이벤트가 올 수 있다.
구현: 이벤트 루프 직전에 `latest_prices: dict[str, float] = {}` 초기화. 각 이벤트에서 `latest_prices[sym] = float(row["close"])` 업데이트. `_record_equity`**매 이벤트마다** 호출 (현재 동작 유지). `latest_prices`는 점진적으로 축적되므로, 첫 번째 심볼 이벤트 시점에 다른 심볼은 이전 캔들의 가격이 사용된다. 이는 15분봉 기반에서 미미한 차이이며, 타임스탬프 그룹핑을 도입하면 코드 복잡도가 불필요하게 증가한다.
```python
# run() 메인 루프 변경:
latest_prices: dict[str, float] = {}
for ts, sym, candle_idx in events:
# ... 기존 로직
row = df_ind.iloc[candle_idx]
latest_prices[sym] = float(row["close"])
self._record_equity(ts, current_prices=latest_prices)
# ... 나머지 기존 로직 (SL/TP 체크, 진입 등)
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py -v`
Expected: ALL PASS
- [ ] **Step 5: 커밋**
```bash
git add src/backtester.py tests/test_ml_pipeline_fixes.py
git commit -m "fix(backtest): include unrealized PnL in equity curve for accurate MDD"
```
---
### Task 4: MLX 이중 정규화 제거
**Files:**
- Modify: `src/mlx_filter.py:139-155`
- Modify: `scripts/train_mlx_model.py:218-240`
- Test: `tests/test_ml_pipeline_fixes.py`
- [ ] **Step 1: 테스트 작성**
```python
def test_mlx_no_double_normalization():
"""MLXFilter.fit()에 normalize=False를 전달하면 내부 정규화를 건너뛰어야 한다."""
import numpy as np
import pandas as pd
from src.mlx_filter import MLXFilter
from src.ml_features import FEATURE_COLS
n_features = len(FEATURE_COLS)
rng = np.random.default_rng(42)
X = pd.DataFrame(
rng.standard_normal((100, n_features)).astype(np.float32),
columns=FEATURE_COLS,
)
y = pd.Series(rng.integers(0, 2, 100).astype(np.float32))
model = MLXFilter(input_dim=n_features, hidden_dim=16, epochs=1, batch_size=32)
model.fit(X, y, normalize=False)
# normalize=False면 _mean=0, _std=1이어야 한다
assert np.allclose(model._mean, 0.0), "normalize=False시 mean은 0이어야 한다"
assert np.allclose(model._std, 1.0, atol=1e-7), "normalize=False시 std는 1이어야 한다"
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py::test_mlx_no_double_normalization -v`
Expected: FAIL — `fit() got an unexpected keyword argument 'normalize'`
- [ ] **Step 3: mlx_filter.py 수정**
`MLXFilter.fit()` 시그니처에 `normalize: bool = True` 추가:
```python
def fit(
self,
X: pd.DataFrame,
y: pd.Series,
sample_weight: np.ndarray | None = None,
normalize: bool = True,
) -> "MLXFilter":
X_np = X[FEATURE_COLS].values.astype(np.float32)
y_np = y.values.astype(np.float32)
if normalize:
mean_vals = np.nanmean(X_np, axis=0)
self._mean = np.nan_to_num(mean_vals, nan=0.0)
std_vals = np.nanstd(X_np, axis=0)
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8
X_np = (X_np - self._mean) / self._std
X_np = np.nan_to_num(X_np, nan=0.0)
else:
self._mean = np.zeros(X_np.shape[1], dtype=np.float32)
self._std = np.ones(X_np.shape[1], dtype=np.float32)
X_np = np.nan_to_num(X_np, nan=0.0)
# ... 나머지 동일
```
- [ ] **Step 4: train_mlx_model.py walk-forward 수정**
`walk_forward_auc()` (line 218-240)에서 이중 정규화 해킹을 제거:
```python
# 변경 전 (해킹):
# mean = X_tr_bal.mean(axis=0)
# std = X_tr_bal.std(axis=0) + 1e-8
# X_tr_norm = (X_tr_bal - mean) / std
# X_val_norm = (X_val_raw - mean) / std
# ...
# model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
# model._mean = np.zeros(...)
# model._std = np.ones(...)
# 변경 후 (깔끔):
X_tr_df = pd.DataFrame(X_tr_bal, columns=FEATURE_COLS)
X_val_df = pd.DataFrame(X_val_raw, columns=FEATURE_COLS)
model = MLXFilter(...)
model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
# fit() 내부에서 학습 데이터 기준으로 정규화
# predict_proba()에서 동일한 mean/std 적용
proba = model.predict_proba(X_val_df)
```
- [ ] **Step 5: 테스트 통과 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py -v`
Expected: ALL PASS
- [ ] **Step 6: 커밋**
```bash
git add src/mlx_filter.py scripts/train_mlx_model.py tests/test_ml_pipeline_fixes.py
git commit -m "fix(mlx): remove double normalization in walk-forward validation"
```
---
### Task 5: MLX에 stratified_undersample 적용
**Files:**
- Modify: `scripts/train_mlx_model.py:88-104, 207-212`
- [ ] **Step 1: train_mlx_model.py train 함수 수정**
`train_mlx()` (line 88-104)의 단순 언더샘플링을 `stratified_undersample`로 교체:
```python
# 변경 전:
# pos_idx = np.where(y_train == 1)[0]
# neg_idx = np.where(y_train == 0)[0]
# if len(neg_idx) > len(pos_idx):
# np.random.seed(42)
# neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
# balanced_idx = np.concatenate([pos_idx, neg_idx])
# np.random.shuffle(balanced_idx)
# 변경 후:
from src.dataset_builder import stratified_undersample
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
source_train = source[:split]
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
```
- [ ] **Step 2: walk_forward_auc도 동일하게 수정**
`walk_forward_auc()` (line 207-212)도 `stratified_undersample`로 교체.
- [ ] **Step 3: negative_ratio 파라미터 추가**
`train_mlx()``walk_forward_auc()``generate_dataset_vectorized` 호출 모두에 `negative_ratio=5` 추가 (LightGBM과 동일):
```python
# train_mlx() 내:
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
negative_ratio=5,
atr_sl_mult=2.0,
atr_tp_mult=2.0,
)
# walk_forward_auc() 내 (line 179-181):
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
negative_ratio=5,
atr_sl_mult=2.0,
atr_tp_mult=2.0,
)
```
- [ ] **Step 4: 전체 테스트 통과 확인**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 5: 커밋**
```bash
git add scripts/train_mlx_model.py
git commit -m "fix(mlx): use stratified_undersample consistent with LightGBM"
```
---
### Task 6: MLFilter factory method + backtest_validator initial_balance
**Files:**
- Modify: `src/ml_filter.py`
- Modify: `src/backtester.py:320-329`
- Modify: `src/backtest_validator.py:123`
- Test: `tests/test_ml_pipeline_fixes.py`
- [ ] **Step 1: MLFilter factory method 테스트**
```python
def test_ml_filter_from_model():
"""MLFilter.from_model()로 LightGBM 모델을 주입할 수 있어야 한다."""
from src.ml_filter import MLFilter
from unittest.mock import MagicMock
mock_model = MagicMock()
mock_model.predict_proba.return_value = [[0.3, 0.7]]
mf = MLFilter.from_model(mock_model, threshold=0.55)
assert mf.is_model_loaded()
assert mf.active_backend == "LightGBM"
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py::test_ml_filter_from_model -v`
Expected: FAIL — `MLFilter has no attribute 'from_model'`
- [ ] **Step 3: ml_filter.py에 from_model 추가**
```python
@classmethod
def from_model(cls, model, threshold: float = 0.55) -> "MLFilter":
"""외부에서 학습된 LightGBM 모델을 주입하여 MLFilter를 생성한다.
backtester walk-forward에서 사용."""
instance = cls.__new__(cls)
instance._disabled = False
instance._onnx_session = None
instance._lgbm_model = model
instance._threshold = threshold
instance._onnx_path = Path("/dev/null")
instance._lgbm_path = Path("/dev/null")
instance._loaded_onnx_mtime = 0.0
instance._loaded_lgbm_mtime = 0.0
return instance
```
- [ ] **Step 4: backtester.py에서 factory method 사용**
`backtester.py:320-329`의 직접 조작 코드를 교체:
```python
# 변경 전:
# mf = MLFilter.__new__(MLFilter)
# mf._disabled = False
# mf._onnx_session = None
# mf._lgbm_model = ml_models[sym]
# ...
# 변경 후:
mf = MLFilter.from_model(ml_models[sym], threshold=self.cfg.ml_threshold)
self.ml_filters[sym] = mf
```
- [ ] **Step 5: backtest_validator.py initial_balance 수정**
`src/backtest_validator.py:123`:
```python
# 변경 전:
# balance = 1000.0
# 변경 후 (cfg는 항상 BacktestConfig이므로 hasattr 불필요):
balance = cfg.initial_balance
```
- [ ] **Step 6: 테스트 통과 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py -v && bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 7: 커밋**
```bash
git add src/ml_filter.py src/backtester.py src/backtest_validator.py tests/test_ml_pipeline_fixes.py
git commit -m "refactor(ml): add MLFilter.from_model(), fix validator initial_balance"
```
---
### Task 7: 레거시 코드 정리 + 최종 검증
**Files:**
- Modify: `scripts/train_model.py:56-103` (레거시 `_process_index`, `generate_dataset` 함수)
- Modify: `tests/test_dataset_builder.py:76-93` (레거시 비교 테스트)
- [ ] **Step 1: 레거시 함수 사용 여부 확인**
`scripts/train_model.py``_process_index()`, `generate_dataset()` 함수는 현재 `tests/test_dataset_builder.py:84`에서만 참조됨. 이 테스트는 레거시와 벡터화 버전의 샘플 수 비교인데, 두 버전의 SL/TP가 다르므로 (레거시 TP=3.0 vs 벡터화 TP=2.0) 비교 자체가 무의미.
- [ ] **Step 2: 레거시 비교 테스트 제거**
`tests/test_dataset_builder.py`에서 `test_matches_original_generate_dataset` 함수를 삭제.
- [ ] **Step 3: 레거시 함수에 deprecation 경고 추가**
`scripts/train_model.py``generate_dataset()`, `_process_index()` 함수 상단에:
```python
import warnings
def generate_dataset(df: pd.DataFrame, n_jobs: int | None = None) -> pd.DataFrame:
"""[Deprecated] generate_dataset_vectorized()를 사용할 것."""
warnings.warn(
"generate_dataset()는 deprecated. generate_dataset_vectorized()를 사용하세요.",
DeprecationWarning, stacklevel=2,
)
# ... 기존 코드
```
- [ ] **Step 4: 전체 테스트 실행**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 5: 커밋**
```bash
git add scripts/train_model.py tests/test_dataset_builder.py
git commit -m "chore: deprecate legacy dataset generation, remove stale comparison test"
```
---
### Task 8: README/ARCHITECTURE 동기화 + CLAUDE.md 업데이트
**Files:**
- Modify: `CLAUDE.md` (plan history table)
- Modify: `README.md` (필요시)
- Modify: `ARCHITECTURE.md` (필요시)
- [ ] **Step 1: CLAUDE.md plan history 업데이트**
`CLAUDE.md`의 plan history 테이블에 추가:
```markdown
| 2026-03-21 | `ml-pipeline-fixes` (plan) | Completed |
```
- [ ] **Step 2: 최종 전체 테스트**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 3: 커밋**
```bash
git add CLAUDE.md
git commit -m "docs: update plan history with ml-pipeline-fixes"
```

View File

@@ -0,0 +1,303 @@
# ML Validation Pipeline Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** ML 필터의 실전 가치를 검증하는 `--compare-ml` CLI를 추가하여, 완화된 임계값에서 ML on/off Walk-Forward 백테스트를 자동 비교하고 PF/승률/MDD 개선폭을 리포트한다.
**Architecture:** `scripts/run_backtest.py``--compare-ml` 플래그를 추가한다. 이 플래그가 활성화되면 WalkForwardBacktester를 `use_ml=True``use_ml=False`로 각각 실행하고, 결과를 나란히 비교하는 리포트를 출력한다. 기존 `Backtester`/`WalkForwardBacktester` 코드는 변경하지 않는다.
**Tech Stack:** Python, LightGBM, src/backtester.py (기존 모듈 재사용)
**선행 완료 항목 (이미 구현됨):**
- ✅ 학습 전용 상수 (TRAIN_SIGNAL_THRESHOLD=2, TRAIN_ADX_THRESHOLD=15, etc.)
- ✅ Purged gap (embargo=LOOKAHEAD) in all walk-forward functions
- ✅ Ablation A/B/C CLI (`--ablation`)
-`BacktestConfig.use_ml` 플래그
-`run_backtest.py --no-ml` 지원
**판단 기준 (합의됨):**
- ML on vs ML off의 **상대 PF 개선폭**으로 판단 (절대 기준 아님)
- PF 개선 + 승률 개선 + MDD 감소 → 투입 가치 있음
- PF 변화 미미 → ML 기여 낮음
---
## File Structure
| 파일 | 변경 유형 | 역할 |
|------|-----------|------|
| `scripts/run_backtest.py` | Modify | `--compare-ml` CLI + 비교 리포트 |
| `CLAUDE.md` | Modify | plan history 업데이트 |
---
### Task 1: `--compare-ml` CLI 추가
**Files:**
- Modify: `scripts/run_backtest.py:29-55, 151-211`
- [ ] **Step 1: argparse에 --compare-ml 추가**
`scripts/run_backtest.py``parse_args()` 함수에:
```python
p.add_argument("--compare-ml", action="store_true",
help="ML on vs off Walk-Forward 비교 (--walk-forward 자동 활성화)")
```
- [ ] **Step 2: compare_ml 함수 작성**
`scripts/run_backtest.py``compare_ml()` 함수 추가:
```python
def compare_ml(symbols: list[str], args):
"""ML on vs ML off Walk-Forward 백테스트 비교.
완화된 임계값(threshold=2)에서 ML 필터의 실질적 가치를 검증한다.
판단 기준: 상대 PF 개선폭 (절대 기준 아님).
"""
base_kwargs = dict(
symbols=symbols,
start=args.start,
end=args.end,
initial_balance=args.balance,
leverage=args.leverage,
fee_pct=args.fee,
slippage_pct=args.slippage,
ml_threshold=args.ml_threshold,
atr_sl_mult=args.sl_atr,
atr_tp_mult=args.tp_atr,
signal_threshold=args.signal_threshold,
adx_threshold=args.adx_threshold,
volume_multiplier=args.vol_multiplier,
train_months=args.train_months,
test_months=args.test_months,
)
results = {}
for label, use_ml in [("ML OFF", False), ("ML ON", True)]:
print(f"\n{'='*60}")
print(f" Walk-Forward 백테스트: {label}")
print(f"{'='*60}")
cfg = WalkForwardConfig(**base_kwargs, use_ml=use_ml)
wf = WalkForwardBacktester(cfg)
result = wf.run()
results[label] = result
print_summary(result["summary"], cfg, mode="walk_forward")
if result.get("folds"):
print_fold_table(result["folds"])
# 비교 리포트
_print_comparison(results, symbols)
# 결과 저장
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
if len(symbols) == 1:
out_dir = Path(f"results/{symbols[0].lower()}")
else:
out_dir = Path("results/combined")
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"ml_comparison_{ts}.json"
comparison = {
"timestamp": datetime.now().isoformat(),
"symbols": symbols,
"ml_off": results["ML OFF"]["summary"],
"ml_on": results["ML ON"]["summary"],
}
def sanitize(obj):
if isinstance(obj, bool):
return obj
if isinstance(obj, (int, float)):
if isinstance(obj, float) and obj == float("inf"):
return "Infinity"
return obj
if isinstance(obj, dict):
return {k: sanitize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [sanitize(v) for v in obj]
return obj
with open(path, "w") as f:
json.dump(sanitize(comparison), f, indent=2, ensure_ascii=False)
print(f"\n비교 결과 저장: {path}")
def _print_comparison(results: dict, symbols: list[str]):
"""ML on vs off 비교 리포트 출력."""
off = results["ML OFF"]["summary"]
on = results["ML ON"]["summary"]
print(f"\n{'='*64}")
print(f" ML ON vs OFF 비교 ({', '.join(symbols)})")
print(f"{'='*64}")
print(f" {'지표':<20} {'ML OFF':>12} {'ML ON':>12} {'Delta':>12}")
print(f"{''*64}")
metrics = [
("총 거래", "total_trades", "d"),
("총 PnL (USDT)", "total_pnl", ".2f"),
("수익률 (%)", "return_pct", ".2f"),
("승률 (%)", "win_rate", ".1f"),
("Profit Factor", "profit_factor", ".2f"),
("MDD (%)", "max_drawdown_pct", ".2f"),
("Sharpe", "sharpe_ratio", ".2f"),
]
for label, key, fmt in metrics:
v_off = off.get(key, 0)
v_on = on.get(key, 0)
# inf 처리
if v_off == float("inf"):
v_off_str = "INF"
else:
v_off_str = f"{v_off:{fmt}}"
if v_on == float("inf"):
v_on_str = "INF"
else:
v_on_str = f"{v_on:{fmt}}"
if isinstance(v_off, (int, float)) and isinstance(v_on, (int, float)) \
and v_off != float("inf") and v_on != float("inf"):
delta = v_on - v_off
sign = "+" if delta > 0 else ""
delta_str = f"{sign}{delta:{fmt}}"
else:
delta_str = "N/A"
print(f" {label:<20} {v_off_str:>12} {v_on_str:>12} {delta_str:>12}")
# 판정
pf_off = off.get("profit_factor", 0)
pf_on = on.get("profit_factor", 0)
wr_off = off.get("win_rate", 0)
wr_on = on.get("win_rate", 0)
mdd_off = off.get("max_drawdown_pct", 0)
mdd_on = on.get("max_drawdown_pct", 0)
print(f"{''*64}")
if pf_off == float("inf") or pf_on == float("inf"):
print(f" 판정: PF=INF — 한쪽 모드에서 손실 거래 없음 (거래 수 부족 가능), 판단 보류")
elif pf_off == 0:
print(f" 판정: ML OFF PF=0 — baseline 거래 없음, 판단 불가")
else:
pf_improvement = pf_on - pf_off
wr_improvement = wr_on - wr_off
mdd_improvement = mdd_off - mdd_on # MDD는 낮을수록 좋음
# 판정 임계값 (초기값 — 실제 백테스트 결과를 보고 조정 가능)
improvements = []
if pf_improvement > 0.1:
improvements.append(f"PF +{pf_improvement:.2f}")
if wr_improvement > 2.0:
improvements.append(f"승률 +{wr_improvement:.1f}%p")
if mdd_improvement > 1.0:
improvements.append(f"MDD -{mdd_improvement:.1f}%p")
if len(improvements) >= 2:
verdict = f"✅ ML 필터 투입 가치 있음 ({', '.join(improvements)})"
elif len(improvements) == 1:
verdict = f"⚠️ ML 필터 조건부 투입 ({improvements[0]}, 다른 지표 변화 미미)"
else:
verdict = f"❌ ML 필터 기여 미미 (PF {pf_improvement:+.2f}, 승률 {wr_improvement:+.1f}%p)"
print(f" 판정: {verdict}")
print(f"{'='*64}\n")
```
- [ ] **Step 3: main()에 --compare-ml 분기 추가**
`scripts/run_backtest.py``main()` 함수에서 `if args.walk_forward:` 블록 **앞에** 추가:
```python
if args.compare_ml:
if args.no_ml:
logger.warning("--no-ml is ignored when using --compare-ml")
compare_ml(symbols, args)
return
```
- [ ] **Step 4: 전체 테스트 통과 확인**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS (기존 테스트 영향 없음)
- [ ] **Step 5: 커밋**
```bash
git add scripts/run_backtest.py
git commit -m "feat(backtest): add --compare-ml for ML on/off walk-forward comparison"
```
---
### Task 2: CLAUDE.md 업데이트
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: plan history 업데이트**
```markdown
| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
```
- [ ] **Step 2: 커밋**
```bash
git add CLAUDE.md
git commit -m "docs: update plan history with ml-validation-pipeline"
```
---
## 구현 후 실행 가이드
### Phase 1: Ablation 진단 (이미 구현됨)
```bash
# 심볼별 ablation 실행
python scripts/train_model.py --symbol XRPUSDT --ablation
python scripts/train_model.py --symbol SOLUSDT --ablation
python scripts/train_model.py --symbol DOGEUSDT --ablation
```
판단:
- A→C 드롭 ≤ 0.05 → Phase 2로 진행
- A→C 드롭 ≥ 0.10 → ML 재설계 필요 (중단)
### Phase 2: ML on/off 비교 (이 플랜에서 구현)
```bash
# 완화된 임계값(threshold=2)로 ML 비교
python scripts/run_backtest.py --symbol XRPUSDT --compare-ml \
--signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward
python scripts/run_backtest.py --symbol SOLUSDT --compare-ml \
--signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward
python scripts/run_backtest.py --symbol DOGEUSDT --compare-ml \
--signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward
```
판단: 상대 PF 개선폭으로 ML 가치 평가
### Phase 3: 실전 점진적 전환 (코드 변경 불필요)
Phase 1, 2 모두 긍정적이면 `.env`로 1심볼부터 적용:
```bash
# .env에 추가 (1심볼만 먼저)
SIGNAL_THRESHOLD_XRPUSDT=2
ADX_THRESHOLD_XRPUSDT=15
VOL_MULTIPLIER_XRPUSDT=1.5
# 나머지 심볼은 기존 값 유지
# SIGNAL_THRESHOLD_SOLUSDT=3 (기본값)
# SIGNAL_THRESHOLD_DOGEUSDT=3 (기본값)
```
1~2주 운영 후 kill switch 미발동 + PnL 양호하면 나머지 심볼도 전환.

View File

@@ -0,0 +1,399 @@
# Purged Gap + Feature Ablation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Walk-Forward 검증에 purged gap(embargo)을 추가하여 레이블 누수를 제거하고, feature ablation으로 signal_strength/side 의존도를 진단하여 ML 필터의 실질적 예측력을 검증한다.
**Architecture:** 3개의 walk-forward 함수(train_model.py, train_mlx_model.py, tune_hyperparams.py)의 검증 시작 인덱스에 `LOOKAHEAD` 만큼의 embargo를 추가한다. `train_model.py``--ablation` CLI 플래그를 추가하여 A/B/C 실험을 자동 실행하고 상대 드롭을 출력한다.
**Tech Stack:** Python, LightGBM, numpy, sklearn, pytest
**판단 기준 (합의됨):**
- A→C 드롭 ≤ 0.05: ML 필터 가치 있음
- A→C 드롭 0.05~0.10: 조건부 투입
- A→C 드롭 ≥ 0.10: 재설계 필요
---
## File Structure
| 파일 | 변경 유형 | 역할 |
|------|-----------|------|
| `scripts/train_model.py` | Modify | purged gap + ablation CLI |
| `scripts/train_mlx_model.py` | Modify | purged gap |
| `scripts/tune_hyperparams.py` | Modify | purged gap |
| `tests/test_ml_pipeline_fixes.py` | Modify | purged gap 테스트 |
---
### Task 1: walk-forward에 purged gap(embargo) 추가
**Files:**
- Modify: `scripts/train_model.py:389-396`
- Modify: `scripts/train_mlx_model.py:194-204`
- Modify: `scripts/tune_hyperparams.py:153-160`
- Test: `tests/test_ml_pipeline_fixes.py`
- [ ] **Step 1: purged gap 테스트 작성**
`tests/test_ml_pipeline_fixes.py`에 추가:
```python
def test_walk_forward_purged_gap():
"""Walk-Forward 검증에서 학습/검증 사이에 LOOKAHEAD 만큼의 gap이 존재해야 한다."""
from src.dataset_builder import LOOKAHEAD
import numpy as np
# 시뮬레이션: n=1000, train_ratio=0.6, n_splits=5
n = 1000
train_ratio = 0.6
n_splits = 5
embargo = LOOKAHEAD # 24
step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio)
for fold_idx in range(n_splits):
tr_end = train_end_start + fold_idx * step
val_start = tr_end + embargo # purged gap
val_end = val_start + step
if val_end > n:
break
# 학습 마지막 인덱스와 검증 첫 인덱스 사이에 최소 embargo 캔들 gap
assert val_start - tr_end >= embargo, \
f"폴드 {fold_idx}: gap={val_start - tr_end} < embargo={embargo}"
# 검증 구간이 학습 구간과 겹치지 않아야 한다
assert val_start > tr_end, \
f"폴드 {fold_idx}: val_start={val_start} <= tr_end={tr_end}"
```
- [ ] **Step 2: 테스트 통과 확인 (로직 테스트이므로 바로 PASS)**
Run: `pytest tests/test_ml_pipeline_fixes.py::test_walk_forward_purged_gap -v`
Expected: PASS (이 테스트는 로직만 검증하므로 코드 변경 없이도 통과)
- [ ] **Step 3: train_model.py walk_forward_auc() 수정**
`scripts/train_model.py``walk_forward_auc()` 함수 내 폴드 루프(~line 389-396):
변경 전:
```python
for i in range(n_splits):
tr_end = train_end_start + i * step
val_end = tr_end + step
if val_end > n:
break
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
```
변경 후:
```python
from src.dataset_builder import LOOKAHEAD
for i in range(n_splits):
tr_end = train_end_start + i * step
val_start = tr_end + LOOKAHEAD # purged gap: 레이블 누수 방지
val_end = val_start + step
if val_end > n:
break
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
```
`source_tr`는 기존과 동일하게 `source[:tr_end]`.
출력 문자열도 업데이트:
```python
print(
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
f"검증={val_start}~{val_end} ({step}개, embargo={LOOKAHEAD}), AUC={auc:.4f} | "
f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}"
)
```
- [ ] **Step 4: train_mlx_model.py walk_forward_auc() 동일 수정**
`scripts/train_mlx_model.py``walk_forward_auc()` 폴드 루프(~line 194-204):
변경 전:
```python
X_val_raw = X_all[tr_end:val_end]
y_val = y_all[tr_end:val_end]
```
변경 후:
```python
from src.dataset_builder import LOOKAHEAD
val_start = tr_end + LOOKAHEAD
val_end = val_start + step
if val_end > n:
break
X_val_raw = X_all[val_start:val_end]
y_val = y_all[val_start:val_end]
```
- [ ] **Step 5: tune_hyperparams.py _walk_forward_cv() 동일 수정**
`scripts/tune_hyperparams.py``_walk_forward_cv()` 폴드 루프(~line 153-160):
변경 전:
```python
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
```
변경 후:
```python
from src.dataset_builder import LOOKAHEAD
val_start = tr_end + LOOKAHEAD
val_end = val_start + step
if val_end > n:
break
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
```
- [ ] **Step 6: 전체 테스트 통과 확인**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 7: 커밋**
```bash
git add scripts/train_model.py scripts/train_mlx_model.py scripts/tune_hyperparams.py tests/test_ml_pipeline_fixes.py
git commit -m "fix(ml): add purged gap (embargo=LOOKAHEAD) to walk-forward validation"
```
---
### Task 2: Feature ablation 실험 CLI 추가
**Files:**
- Modify: `scripts/train_model.py`
- [ ] **Step 1: ablation 함수 추가**
`scripts/train_model.py``ablation()` 함수를 추가:
```python
def ablation(
data_path: str,
time_weight_decay: float = 2.0,
n_splits: int = 5,
train_ratio: float = 0.6,
tuned_params_path: str | None = None,
atr_sl_mult: float = 2.0,
atr_tp_mult: float = 2.0,
) -> None:
"""Feature ablation 실험: signal_strength/side 의존도 진단.
실험 A: 전체 피처 (baseline)
실험 B: signal_strength 제거
실험 C: signal_strength + side 제거
판단 기준 (절대 AUC 차이):
A→C ≤ 0.05: ML 필터 가치 있음 (다른 피처가 충분히 기여)
A→C 0.05~0.10: 조건부 투입 (signal_strength 의존도 높지만 다른 피처도 기여)
A→C ≥ 0.10: 재설계 필요 (사실상 점수 재확인기)
"""
import warnings
from src.dataset_builder import LOOKAHEAD
print(f"\n{'='*64}")
print(f" Feature Ablation 실험 ({n_splits}폴드 Walk-Forward, embargo={LOOKAHEAD})")
print(f"{'='*64}")
df_raw = pd.read_parquet(data_path)
base_cols = ["open", "high", "low", "close", "volume"]
btc_df = eth_df = None
if "close_btc" in df_raw.columns:
btc_df = df_raw[[c + "_btc" for c in base_cols]].copy()
btc_df.columns = base_cols
if "close_eth" in df_raw.columns:
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
eth_df.columns = base_cols
df = df_raw[base_cols].copy()
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
atr_sl_mult=atr_sl_mult,
atr_tp_mult=atr_tp_mult,
)
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
y = dataset["label"].values
w = dataset["sample_weight"].values
n = len(dataset)
source = dataset["source"].values if "source" in dataset.columns else np.full(n, "signal")
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
w = (w * weight_scale).astype(np.float32)
# 실험 정의
experiments = {
"A (전체 피처)": actual_feature_cols,
"B (-signal_strength)": [c for c in actual_feature_cols if c != "signal_strength"],
"C (-signal_strength, -side)": [c for c in actual_feature_cols if c not in ("signal_strength", "side")],
}
results = {}
for exp_name, cols in experiments.items():
X = dataset[cols].values
step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio)
fold_aucs = []
fold_importances = []
for fold_idx in range(n_splits):
tr_end = train_end_start + fold_idx * step
val_start = tr_end + LOOKAHEAD
val_end = val_start + step
if val_end > n:
break
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
source_tr = source[:tr_end]
idx = stratified_undersample(y_tr, source_tr, seed=42)
model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
model.fit(X_tr[idx], y_tr[idx], sample_weight=w_tr[idx])
proba = model.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
fold_aucs.append(auc)
fold_importances.append(dict(zip(cols, model.feature_importances_)))
mean_auc = float(np.mean(fold_aucs))
std_auc = float(np.std(fold_aucs))
results[exp_name] = {
"mean_auc": mean_auc,
"std_auc": std_auc,
"fold_aucs": fold_aucs,
"importances": fold_importances,
}
print(f"\n {exp_name}: AUC={mean_auc:.4f} ± {std_auc:.4f}")
print(f" 폴드별: {[round(a, 4) for a in fold_aucs]}")
# 실험 A에서만 feature importance top 10 출력
if exp_name.startswith("A"):
avg_imp = {}
for imp in fold_importances:
for k, v in imp.items():
avg_imp[k] = avg_imp.get(k, 0) + v / len(fold_importances)
top10 = sorted(avg_imp.items(), key=lambda x: x[1], reverse=True)[:10]
print(f" Feature Importance Top 10:")
for feat_name, imp_val in top10:
marker = " ← 주의" if feat_name in ("signal_strength", "side") else ""
print(f" {feat_name:<25} {imp_val:>8.1f}{marker}")
# 드롭 분석
auc_a = results["A (전체 피처)"]["mean_auc"]
auc_b = results["B (-signal_strength)"]["mean_auc"]
auc_c = results["C (-signal_strength, -side)"]["mean_auc"]
drop_ab = auc_a - auc_b
drop_ac = auc_a - auc_c
print(f"\n{'='*64}")
print(f" 드롭 분석")
print(f"{'='*64}")
print(f" A → B (signal_strength 제거): {drop_ab:+.4f}")
print(f" A → C (signal_strength + side 제거): {drop_ac:+.4f}")
print(f"{''*64}")
if drop_ac <= 0.05:
verdict = "✅ ML 필터 가치 있음 (다른 피처가 충분히 기여)"
elif drop_ac <= 0.10:
verdict = "⚠️ 조건부 투입 (signal_strength 의존도 높지만 다른 피처도 기여)"
else:
verdict = "❌ 재설계 필요 (사실상 점수 재확인기)"
print(f" 판정: {verdict}")
print(f"{'='*64}\n")
```
- [ ] **Step 2: CLI에 --ablation 플래그 추가**
`scripts/train_model.py``main()` 내 argparse에:
```python
parser.add_argument("--ablation", action="store_true",
help="Feature ablation 실험 (signal_strength/side 의존도 진단)")
```
main() 분기에 추가:
```python
if args.ablation:
ablation(
args.data, time_weight_decay=args.decay,
tuned_params_path=args.tuned_params,
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult,
)
```
기존 `elif args.compare:` 앞에 배치.
- [ ] **Step 3: 전체 테스트 통과 확인**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 4: 커밋**
```bash
git add scripts/train_model.py
git commit -m "feat(ml): add --ablation CLI for signal_strength/side dependency diagnosis"
```
---
### Task 3: CLAUDE.md 업데이트
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: plan history 업데이트**
```markdown
| 2026-03-21 | `purged-gap-and-ablation` (plan) | Completed |
```
- [ ] **Step 2: 커밋**
```bash
git add CLAUDE.md
git commit -m "docs: update plan history with purged-gap-and-ablation"
```
---
## 구현 후 실행 가이드
구현 완료 후 다음 순서로 실행:
```bash
# 1. Purged gap 적용된 Walk-Forward (심볼별)
python scripts/train_model.py --symbol XRPUSDT --wf
python scripts/train_model.py --symbol SOLUSDT --wf
python scripts/train_model.py --symbol DOGEUSDT --wf
# 2. Ablation 실험 (심볼별)
python scripts/train_model.py --symbol XRPUSDT --ablation
python scripts/train_model.py --symbol SOLUSDT --ablation
python scripts/train_model.py --symbol DOGEUSDT --ablation
```
결과를 보고 판단:
- Purged AUC가 0.85+ 유지되면 모델 유효
- A→C 드롭이 0.05 이내면 ML 필터 실전 투입 가치 있음
- 두 조건 모두 충족 시 PF 계산(Task 미포함, 별도 판단 후 추가)으로 진행

View File

@@ -0,0 +1,254 @@
# Training Threshold Relaxation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** ML 학습용 신호 임계값을 완화하여 학습 샘플을 5~10배 증가시키고, 모델이 의미 있는 패턴을 학습할 수 있도록 한다.
**Architecture:** `dataset_builder.py`에 학습 전용 상수 블록(`TRAIN_*`)을 추가하고, `generate_dataset_vectorized()`의 기본값을 이 상수로 변경한다. 모든 호출부(train_model, train_mlx_model, tune_hyperparams)는 기본값을 따르므로 호출부 코드 변경 없이 적용된다. 실전 봇(`bot.py`)과 백테스터 시뮬레이션(`Backtester.run`)은 `config.py`의 엄격한 임계값을 별도로 사용하므로 영향 없다.
**Tech Stack:** Python, pandas, numpy, LightGBM, pytest
---
## File Structure
| 파일 | 변경 유형 | 역할 |
|------|-----------|------|
| `src/dataset_builder.py` | Modify | 학습 전용 상수 추가 + 기본값 변경 |
| `scripts/train_model.py` | Modify | 하드코딩된 `negative_ratio=5` → 기본값 사용으로 전환 |
| `scripts/train_mlx_model.py` | Modify | 동일 |
| `scripts/tune_hyperparams.py` | Modify | 동일 |
| `src/backtester.py` | Modify | `WalkForwardConfig.negative_ratio` 기본값 변경 |
| `tests/test_dataset_builder.py` | Modify | 완화된 기본값 반영 |
| `tests/test_ml_pipeline_fixes.py` | Modify | 새 기본값 검증 테스트 추가 |
---
### Task 1: dataset_builder.py에 학습 전용 상수 추가 + 기본값 변경
**Files:**
- Modify: `src/dataset_builder.py:14-17, 387-397`
- Test: `tests/test_ml_pipeline_fixes.py`
- [ ] **Step 1: 테스트 작성**
`tests/test_ml_pipeline_fixes.py`에 추가:
```python
def test_training_defaults_are_relaxed(signal_df):
"""generate_dataset_vectorized의 기본 임계값이 학습용 완화 값이어야 한다."""
from src.dataset_builder import (
TRAIN_SIGNAL_THRESHOLD, TRAIN_ADX_THRESHOLD,
TRAIN_VOLUME_MULTIPLIER, TRAIN_NEGATIVE_RATIO,
)
assert TRAIN_SIGNAL_THRESHOLD == 2
assert TRAIN_ADX_THRESHOLD == 15.0
assert TRAIN_VOLUME_MULTIPLIER == 1.5
assert TRAIN_NEGATIVE_RATIO == 3
# 완화된 기본값으로 샘플이 더 많이 생성되는지 검증
r_relaxed = generate_dataset_vectorized(signal_df)
r_strict = generate_dataset_vectorized(
signal_df, signal_threshold=3, adx_threshold=25, volume_multiplier=2.5,
)
assert len(r_relaxed) >= len(r_strict), \
f"완화된 임계값이 더 많은 샘플을 생성해야 한다: relaxed={len(r_relaxed)}, strict={len(r_strict)}"
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py::test_training_defaults_are_relaxed -v`
Expected: FAIL — `ImportError: cannot import name 'TRAIN_SIGNAL_THRESHOLD'`
- [ ] **Step 3: dataset_builder.py 수정**
`src/dataset_builder.py` 상단 상수 블록(line 14-17)을 변경:
```python
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
ATR_SL_MULT = 2.0 # config.py 기본값과 동일 (서빙 환경 일치)
ATR_TP_MULT = 2.0
WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분)
# ── 학습 전용 기본값 ──────────────────────────────────────────────
# 실전 봇(config.py)보다 완화된 임계값으로 더 많은 신호를 수집한다.
# ML 모델이 약한 신호 중에서 좋은 기회를 구분하는 법을 학습한다.
# 실전 진입은 bot.py의 엄격한 5단 게이트 + ML 필터가 최종 판단.
TRAIN_SIGNAL_THRESHOLD = 2 # 실전: 3 (config.py)
TRAIN_ADX_THRESHOLD = 15.0 # 실전: 25.0
TRAIN_VOLUME_MULTIPLIER = 1.5 # 실전: 2.5
TRAIN_NEGATIVE_RATIO = 3 # HOLD 네거티브 비율 (기존: 5)
```
`generate_dataset_vectorized()` 시그니처(line 387-397)의 기본값을 변경:
```python
def generate_dataset_vectorized(
df: pd.DataFrame,
btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None,
time_weight_decay: float = 0.0,
negative_ratio: int = TRAIN_NEGATIVE_RATIO, # 변경: 0 → 3
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD, # 변경: 3 → 2
adx_threshold: float = TRAIN_ADX_THRESHOLD, # 변경: 25 → 15
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER, # 변경: 2.5 → 1.5
atr_sl_mult: float = ATR_SL_MULT,
atr_tp_mult: float = ATR_TP_MULT,
) -> pd.DataFrame:
```
또한 `_calc_signals()`(line 57-61)의 기본값도 학습 상수로 변경:
```python
def _calc_signals(
d: pd.DataFrame,
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD, # 변경: 3 → 2
adx_threshold: float = TRAIN_ADX_THRESHOLD, # 변경: 25 → 15
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER, # 변경: 2.5 → 1.5
) -> np.ndarray:
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `pytest tests/test_ml_pipeline_fixes.py tests/test_dataset_builder.py -v`
Expected: ALL PASS
- [ ] **Step 5: 커밋**
```bash
git add src/dataset_builder.py tests/test_ml_pipeline_fixes.py
git commit -m "feat(ml): add TRAIN_* constants with relaxed thresholds for more training samples"
```
---
### Task 2: 호출부에서 하드코딩된 값 제거
**Files:**
- Modify: `scripts/train_model.py`
- Modify: `scripts/train_mlx_model.py`
- Modify: `scripts/tune_hyperparams.py`
- Modify: `src/backtester.py`
- [ ] **Step 1: train_model.py — 하드코딩 negative_ratio=5 제거**
`train()`, `walk_forward_auc()`, `compare()``generate_dataset_vectorized()` 호출에서 `negative_ratio=5`를 삭제하여 기본값(`TRAIN_NEGATIVE_RATIO=3`)을 사용하도록 변경.
변경 전 (3곳):
```python
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
negative_ratio=5,
atr_sl_mult=atr_sl_mult,
atr_tp_mult=atr_tp_mult,
)
```
변경 후:
```python
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
atr_sl_mult=atr_sl_mult,
atr_tp_mult=atr_tp_mult,
)
```
- [ ] **Step 2: train_mlx_model.py — 동일 변경**
`train_mlx()``walk_forward_auc()``negative_ratio=5` 삭제 (2곳).
- [ ] **Step 3: tune_hyperparams.py — 동일 변경**
`load_dataset()``negative_ratio=5` 삭제 (1곳).
- [ ] **Step 4: backtester.py — WalkForwardConfig 기본값 변경**
`WalkForwardConfig` 데이터클래스(~line 601)에서:
변경 전:
```python
negative_ratio: int = 5
```
변경 후:
```python
negative_ratio: int = 3
```
- [ ] **Step 5: 전체 테스트 통과 확인**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 6: 커밋**
```bash
git add scripts/train_model.py scripts/train_mlx_model.py scripts/tune_hyperparams.py src/backtester.py
git commit -m "refactor(ml): remove hardcoded negative_ratio=5, use dataset_builder defaults"
```
---
### Task 3: 기존 테스트 기본값 정합성 확인 + 수정
**Files:**
- Modify: `tests/test_dataset_builder.py`
- [ ] **Step 1: 기존 테스트가 기본값 변경에 영향받는지 확인**
`tests/test_dataset_builder.py`의 기존 테스트 중 `generate_dataset_vectorized(sample_df)` 처럼 기본값에 의존하는 호출이 있음. 기본값이 완화되었으므로:
- `signal_threshold=2`에서 더 많은 신호가 발생 → 기존 테스트의 assertion이 깨질 수 있음
- `negative_ratio=3`이 기본값이 되므로, 기본 호출 시 HOLD 네거티브가 포함됨
기존 테스트가 실패하면, **원래 의도를 유지하면서** 명시적 파라미터를 추가:
예: `test_returns_dataframe`이 기본 호출로 충분한 결과를 기대한다면 그대로 동작할 가능성이 높음. 하지만 `test_has_required_columns`에서 "source" 컬럼 유무가 달라질 수 있음 (negative_ratio=3 → source 컬럼 존재).
- [ ] **Step 2: 테스트 실행 및 실패 확인**
Run: `pytest tests/test_dataset_builder.py -v`
실패하는 테스트를 파악하고, 각각 수정:
- 기본값에 의존하는 테스트에 명시적 파라미터 추가 (기존 동작 테스트 시 `signal_threshold=3, adx_threshold=25, volume_multiplier=2.5, negative_ratio=0` 명시)
- 또는 새 기본값에서도 assertion이 유효하면 그대로 둠
- [ ] **Step 3: 전체 테스트 통과 확인**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 4: 커밋**
```bash
git add tests/test_dataset_builder.py
git commit -m "test: update dataset_builder tests for relaxed training defaults"
```
---
### Task 4: CLAUDE.md 업데이트
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: CLAUDE.md plan history 업데이트**
plan history 테이블에 추가:
```markdown
| 2026-03-21 | `training-threshold-relaxation` (plan) | Completed |
```
- [ ] **Step 2: 최종 전체 테스트**
Run: `bash scripts/run_tests.sh`
Expected: ALL PASS
- [ ] **Step 3: 커밋**
```bash
git add CLAUDE.md
git commit -m "docs: update plan history with training-threshold-relaxation"
```

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
@@ -50,6 +52,8 @@ def parse_args():
# Walk-Forward # Walk-Forward
p.add_argument("--walk-forward", action="store_true", help="Walk-Forward 백테스트 (기간별 모델 학습/검증)") p.add_argument("--walk-forward", action="store_true", help="Walk-Forward 백테스트 (기간별 모델 학습/검증)")
p.add_argument("--compare-ml", action="store_true",
help="ML on vs off Walk-Forward 비교 (--walk-forward 자동 활성화)")
p.add_argument("--train-months", type=int, default=6, help="WF 학습 윈도우 개월 (기본: 6)") p.add_argument("--train-months", type=int, default=6, help="WF 학습 윈도우 개월 (기본: 6)")
p.add_argument("--test-months", type=int, default=1, help="WF 검증 윈도우 개월 (기본: 1)") p.add_argument("--test-months", type=int, default=1, help="WF 검증 윈도우 개월 (기본: 1)")
return p.parse_args() return p.parse_args()
@@ -105,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")
@@ -148,6 +320,164 @@ def save_result(result: dict, cfg):
return path return path
def compare_ml(symbols: list[str], args):
"""ML on vs ML off Walk-Forward 백테스트 비교."""
base_kwargs = dict(
symbols=symbols,
start=args.start,
end=args.end,
initial_balance=args.balance,
leverage=args.leverage,
fee_pct=args.fee,
slippage_pct=args.slippage,
ml_threshold=args.ml_threshold,
atr_sl_mult=args.sl_atr,
atr_tp_mult=args.tp_atr,
signal_threshold=args.signal_threshold,
adx_threshold=args.adx_threshold,
volume_multiplier=args.vol_multiplier,
train_months=args.train_months,
test_months=args.test_months,
)
results = {}
for label, use_ml in [("ML OFF", False), ("ML ON", True)]:
print(f"\n{'='*60}")
print(f" Walk-Forward 백테스트: {label}")
print(f"{'='*60}")
cfg = WalkForwardConfig(**base_kwargs, use_ml=use_ml)
wf = WalkForwardBacktester(cfg)
result = wf.run()
results[label] = result
print_summary(result["summary"], cfg, mode="walk_forward")
if result.get("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)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
if len(symbols) == 1:
out_dir = Path(f"results/{symbols[0].lower()}")
else:
out_dir = Path("results/combined")
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"ml_comparison_{ts}.json"
comparison = {
"timestamp": datetime.now().isoformat(),
"symbols": symbols,
"ml_off": results["ML OFF"]["summary"],
"ml_on": results["ML ON"]["summary"],
}
def sanitize(obj):
if isinstance(obj, bool):
return obj
if isinstance(obj, (int, float)):
if isinstance(obj, float) and obj == float("inf"):
return "Infinity"
return obj
if isinstance(obj, dict):
return {k: sanitize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [sanitize(v) for v in obj]
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return float(obj)
return obj
with open(path, "w") as f:
json.dump(sanitize(comparison), f, indent=2, ensure_ascii=False)
print(f"\n비교 결과 저장: {path}")
def _print_comparison(results: dict, symbols: list[str]):
"""ML on vs off 비교 리포트 출력."""
off = results["ML OFF"]["summary"]
on = results["ML ON"]["summary"]
print(f"\n{'='*64}")
print(f" ML ON vs OFF 비교 ({', '.join(symbols)})")
print(f"{'='*64}")
print(f" {'지표':<20} {'ML OFF':>12} {'ML ON':>12} {'Delta':>12}")
print(f"{''*64}")
metrics = [
("총 거래", "total_trades", "d"),
("총 PnL (USDT)", "total_pnl", ".2f"),
("수익률 (%)", "return_pct", ".2f"),
("승률 (%)", "win_rate", ".1f"),
("Profit Factor", "profit_factor", ".2f"),
("MDD (%)", "max_drawdown_pct", ".2f"),
("Sharpe", "sharpe_ratio", ".2f"),
]
for label, key, fmt in metrics:
v_off = off.get(key, 0)
v_on = on.get(key, 0)
if v_off == float("inf"):
v_off_str = "INF"
else:
v_off_str = f"{v_off:{fmt}}"
if v_on == float("inf"):
v_on_str = "INF"
else:
v_on_str = f"{v_on:{fmt}}"
if isinstance(v_off, (int, float)) and isinstance(v_on, (int, float)) \
and v_off != float("inf") and v_on != float("inf"):
delta = v_on - v_off
sign = "+" if delta > 0 else ""
delta_str = f"{sign}{delta:{fmt}}"
else:
delta_str = "N/A"
print(f" {label:<20} {v_off_str:>12} {v_on_str:>12} {delta_str:>12}")
pf_off = off.get("profit_factor", 0)
pf_on = on.get("profit_factor", 0)
wr_off = off.get("win_rate", 0)
wr_on = on.get("win_rate", 0)
mdd_off = off.get("max_drawdown_pct", 0)
mdd_on = on.get("max_drawdown_pct", 0)
print(f"{''*64}")
if pf_off == float("inf") or pf_on == float("inf"):
print(f" 판정: PF=INF — 한쪽 모드에서 손실 거래 없음 (거래 수 부족 가능), 판단 보류")
elif pf_off == 0:
print(f" 판정: ML OFF PF=0 — baseline 거래 없음, 판단 불가")
else:
pf_improvement = pf_on - pf_off
wr_improvement = wr_on - wr_off
mdd_improvement = mdd_off - mdd_on
improvements = []
if pf_improvement > 0.1:
improvements.append(f"PF +{pf_improvement:.2f}")
if wr_improvement > 2.0:
improvements.append(f"승률 +{wr_improvement:.1f}%p")
if mdd_improvement > 1.0:
improvements.append(f"MDD -{mdd_improvement:.1f}%p")
if len(improvements) >= 2:
verdict = f"ML 필터 투입 가치 있음 ({', '.join(improvements)})"
elif len(improvements) == 1:
verdict = f"ML 필터 조건부 투입 ({improvements[0]}, 다른 지표 변화 미미)"
else:
verdict = f"ML 필터 기여 미미 (PF {pf_improvement:+.2f}, 승률 {wr_improvement:+.1f}%p)"
print(f" 판정: {verdict}")
print(f"{'='*64}\n")
def main(): def main():
args = parse_args() args = parse_args()
@@ -156,6 +486,12 @@ def main():
else: else:
symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()] symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
if args.compare_ml:
if args.no_ml:
logger.warning("--no-ml is ignored when using --compare-ml")
compare_ml(symbols, args)
return
if args.walk_forward: if args.walk_forward:
cfg = WalkForwardConfig( cfg = WalkForwardConfig(
symbols=symbols, symbols=symbols,
@@ -182,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())

View File

@@ -17,7 +17,7 @@ import numpy as np
import pandas as pd import pandas as pd
from sklearn.metrics import roc_auc_score, classification_report from sklearn.metrics import roc_auc_score, classification_report
from src.dataset_builder import generate_dataset_vectorized from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
from src.ml_features import FEATURE_COLS from src.ml_features import FEATURE_COLS
from src.mlx_filter import MLXFilter from src.mlx_filter import MLXFilter
@@ -45,7 +45,7 @@ def _split_combined(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame | None
return xrp_df, btc_df, eth_df return xrp_df, btc_df, eth_df
def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float: def train_mlx(data_path: str, time_weight_decay: float = 2.0, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0) -> float:
print(f"데이터 로드: {data_path}") print(f"데이터 로드: {data_path}")
raw = pd.read_parquet(data_path) raw = pd.read_parquet(data_path)
print(f"캔들 수: {len(raw)}") print(f"캔들 수: {len(raw)}")
@@ -58,7 +58,8 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
print("\n데이터셋 생성 중...") print("\n데이터셋 생성 중...")
t0 = time.perf_counter() t0 = time.perf_counter()
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay) dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay,
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult)
t1 = time.perf_counter() t1 = time.perf_counter()
print(f"데이터셋 생성 완료: {t1 - t0:.1f}초, {len(dataset)}개 샘플") print(f"데이터셋 생성 완료: {t1 - t0:.1f}초, {len(dataset)}개 샘플")
@@ -85,16 +86,10 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
y_train, y_val = y.iloc[:split], y.iloc[split:] y_train, y_val = y.iloc[:split], y.iloc[split:]
w_train = w[:split] w_train = w[:split]
# --- 클래스 불균형 처리: 언더샘플링 (가중치 인덱스 보존) --- # --- 클래스 불균형 처리: stratified 언더샘플링 (Signal 전수 유지, HOLD만 샘플링) ---
pos_idx = np.where(y_train == 1)[0] source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
neg_idx = np.where(y_train == 0)[0] source_train = source[:split]
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
if len(neg_idx) > len(pos_idx):
np.random.seed(42)
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
balanced_idx = np.concatenate([pos_idx, neg_idx])
np.random.shuffle(balanced_idx)
X_train = X_train.iloc[balanced_idx] X_train = X_train.iloc[balanced_idx]
y_train = y_train.iloc[balanced_idx] y_train = y_train.iloc[balanced_idx]
@@ -170,6 +165,8 @@ def walk_forward_auc(
time_weight_decay: float = 2.0, time_weight_decay: float = 2.0,
n_splits: int = 5, n_splits: int = 5,
train_ratio: float = 0.6, train_ratio: float = 0.6,
atr_sl_mult: float = 2.0,
atr_tp_mult: float = 2.0,
) -> None: ) -> None:
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복.""" """Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복."""
print(f"\n=== Walk-Forward 검증 ({n_splits}폴드, decay={time_weight_decay}) ===") print(f"\n=== Walk-Forward 검증 ({n_splits}폴드, decay={time_weight_decay}) ===")
@@ -177,7 +174,8 @@ def walk_forward_auc(
df, btc_df, eth_df = _split_combined(raw) df, btc_df, eth_df = _split_combined(raw)
dataset = generate_dataset_vectorized( dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay,
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult,
) )
missing = [c for c in FEATURE_COLS if c not in dataset.columns] missing = [c for c in FEATURE_COLS if c not in dataset.columns]
for col in missing: for col in missing:
@@ -186,46 +184,37 @@ def walk_forward_auc(
X_all = dataset[FEATURE_COLS].values.astype(np.float32) X_all = dataset[FEATURE_COLS].values.astype(np.float32)
y_all = dataset["label"].values.astype(np.float32) y_all = dataset["label"].values.astype(np.float32)
w_all = dataset["sample_weight"].values.astype(np.float32) w_all = dataset["sample_weight"].values.astype(np.float32)
source_all = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
n = len(dataset) n = len(dataset)
step = max(1, int(n * (1 - train_ratio) / n_splits)) step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio) train_end_start = int(n * train_ratio)
aucs = [] aucs = []
from src.dataset_builder import LOOKAHEAD
for i in range(n_splits): for i in range(n_splits):
tr_end = train_end_start + i * step tr_end = train_end_start + i * step
val_end = tr_end + step val_start = tr_end + LOOKAHEAD # purged gap
val_end = val_start + step
if val_end > n: if val_end > n:
break break
X_tr_raw = X_all[:tr_end] X_tr_raw = X_all[:tr_end]
y_tr = y_all[:tr_end] y_tr = y_all[:tr_end]
w_tr = w_all[:tr_end] w_tr = w_all[:tr_end]
X_val_raw = X_all[tr_end:val_end] X_val_raw = X_all[val_start:val_end]
y_val = y_all[tr_end:val_end] y_val = y_all[val_start:val_end]
pos_idx = np.where(y_tr == 1)[0] source_tr = source_all[:tr_end]
neg_idx = np.where(y_tr == 0)[0] bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
if len(neg_idx) > len(pos_idx):
np.random.seed(42)
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
bal_idx = np.sort(np.concatenate([pos_idx, neg_idx]))
X_tr_bal = X_tr_raw[bal_idx] X_tr_bal = X_tr_raw[bal_idx]
y_tr_bal = y_tr[bal_idx] y_tr_bal = y_tr[bal_idx]
w_tr_bal = w_tr[bal_idx] w_tr_bal = w_tr[bal_idx]
# 폴드별 정규화 (학습 데이터 기준으로 계산, 검증에도 동일 적용) X_tr_df = pd.DataFrame(X_tr_bal, columns=FEATURE_COLS)
mean = X_tr_bal.mean(axis=0) X_val_df = pd.DataFrame(X_val_raw, columns=FEATURE_COLS)
std = X_tr_bal.std(axis=0) + 1e-8
X_tr_norm = (X_tr_bal - mean) / std
X_val_norm = (X_val_raw - mean) / std
# DataFrame으로 래핑해서 MLXFilter.fit()에 전달
# fit() 내부 정규화가 덮어쓰지 않도록 이미 정규화된 데이터를 넘기고
# _mean=0, _std=1로 고정해 이중 정규화를 방지
X_tr_df = pd.DataFrame(X_tr_norm, columns=FEATURE_COLS)
X_val_df = pd.DataFrame(X_val_norm, columns=FEATURE_COLS)
model = MLXFilter( model = MLXFilter(
input_dim=len(FEATURE_COLS), input_dim=len(FEATURE_COLS),
@@ -235,16 +224,13 @@ def walk_forward_auc(
batch_size=256, batch_size=256,
) )
model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal) model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
# fit()이 내부에서 다시 정규화하므로 저장된 mean/std를 항등 변환으로 교체
model._mean = np.zeros(len(FEATURE_COLS), dtype=np.float32)
model._std = np.ones(len(FEATURE_COLS), dtype=np.float32)
proba = model.predict_proba(X_val_df) proba = model.predict_proba(X_val_df)
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5 auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
aucs.append(auc) aucs.append(auc)
print( print(
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, " f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f}" f"검증={val_start}~{val_end} ({step}, embargo={LOOKAHEAD}), AUC={auc:.4f}"
) )
print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}") print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}")
@@ -260,12 +246,16 @@ def main():
) )
parser.add_argument("--wf", action="store_true", help="Walk-Forward 검증 실행") parser.add_argument("--wf", action="store_true", help="Walk-Forward 검증 실행")
parser.add_argument("--wf-splits", type=int, default=5, help="Walk-Forward 폴드 수") parser.add_argument("--wf-splits", type=int, default=5, help="Walk-Forward 폴드 수")
parser.add_argument("--sl-mult", type=float, default=2.0, help="SL ATR 배수 (기본 2.0)")
parser.add_argument("--tp-mult", type=float, default=2.0, help="TP ATR 배수 (기본 2.0)")
args = parser.parse_args() args = parser.parse_args()
if args.wf: if args.wf:
walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits) walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits,
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
else: else:
train_mlx(args.data, time_weight_decay=args.decay) train_mlx(args.data, time_weight_decay=args.decay,
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -9,6 +9,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse import argparse
import json import json
import math import math
import warnings
from datetime import datetime from datetime import datetime
from multiprocessing import Pool, cpu_count from multiprocessing import Pool, cpu_count
from pathlib import Path from pathlib import Path
@@ -54,8 +55,6 @@ def _cgroup_cpu_count() -> int:
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 (dataset_builder.py와 동기화) LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 (dataset_builder.py와 동기화)
ATR_SL_MULT = 1.5
ATR_TP_MULT = 3.0
MODEL_PATH = Path("models/lgbm_filter.pkl") MODEL_PATH = Path("models/lgbm_filter.pkl")
PREV_MODEL_PATH = Path("models/lgbm_filter_prev.pkl") PREV_MODEL_PATH = Path("models/lgbm_filter_prev.pkl")
LOG_PATH = Path("models/training_log.json") LOG_PATH = Path("models/training_log.json")
@@ -63,6 +62,8 @@ LOG_PATH = Path("models/training_log.json")
def _process_index(args: tuple) -> dict | None: def _process_index(args: tuple) -> dict | None:
"""단일 인덱스에 대해 피처+레이블을 계산한다. Pool worker 함수.""" """단일 인덱스에 대해 피처+레이블을 계산한다. Pool worker 함수."""
ATR_SL_MULT = 1.5 # legacy values
ATR_TP_MULT = 3.0
i, df_values, df_columns = args i, df_values, df_columns = args
df = pd.DataFrame(df_values, columns=df_columns) df = pd.DataFrame(df_values, columns=df_columns)
@@ -104,7 +105,11 @@ def _process_index(args: tuple) -> dict | None:
def generate_dataset(df: pd.DataFrame, n_jobs: int | None = None) -> pd.DataFrame: def generate_dataset(df: pd.DataFrame, n_jobs: int | None = None) -> pd.DataFrame:
"""신호 발생 시점마다 피처와 레이블을 병렬로 생성한다.""" """[Deprecated] generate_dataset_vectorized()를 사용할 것."""
warnings.warn(
"generate_dataset()는 deprecated. generate_dataset_vectorized()를 사용하세요.",
DeprecationWarning, stacklevel=2,
)
total = len(df) total = len(df)
indices = range(60, total - LOOKAHEAD) indices = range(60, total - LOOKAHEAD)
@@ -191,7 +196,7 @@ def _load_lgbm_params(tuned_params_path: str | None) -> tuple[dict, float]:
return lgbm_params, weight_scale return lgbm_params, weight_scale
def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None): def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0):
print(f"데이터 로드: {data_path}") print(f"데이터 로드: {data_path}")
df_raw = pd.read_parquet(data_path) df_raw = pd.read_parquet(data_path)
print(f"캔들 수: {len(df_raw)}, 컬럼: {list(df_raw.columns)}") print(f"캔들 수: {len(df_raw)}, 컬럼: {list(df_raw.columns)}")
@@ -217,7 +222,9 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
dataset = generate_dataset_vectorized( dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df, df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay, time_weight_decay=time_weight_decay,
negative_ratio=5,
atr_sl_mult=atr_sl_mult,
atr_tp_mult=atr_tp_mult,
) )
if dataset.empty or "label" not in dataset.columns: if dataset.empty or "label" not in dataset.columns:
@@ -335,6 +342,8 @@ def walk_forward_auc(
n_splits: int = 5, n_splits: int = 5,
train_ratio: float = 0.6, train_ratio: float = 0.6,
tuned_params_path: str | None = None, tuned_params_path: str | None = None,
atr_sl_mult: float = 2.0,
atr_tp_mult: float = 2.0,
) -> None: ) -> None:
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복. """Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복.
@@ -358,7 +367,9 @@ def walk_forward_auc(
dataset = generate_dataset_vectorized( dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df, df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay, time_weight_decay=time_weight_decay,
negative_ratio=5,
atr_sl_mult=atr_sl_mult,
atr_tp_mult=atr_tp_mult,
) )
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns] actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
X = dataset[actual_feature_cols].values X = dataset[actual_feature_cols].values
@@ -375,14 +386,17 @@ def walk_forward_auc(
aucs = [] aucs = []
fold_metrics = [] fold_metrics = []
from src.dataset_builder import LOOKAHEAD
for i in range(n_splits): for i in range(n_splits):
tr_end = train_end_start + i * step tr_end = train_end_start + i * step
val_end = tr_end + step val_start = tr_end + LOOKAHEAD # purged gap: 레이블 누수 방지
val_end = val_start + step
if val_end > n: if val_end > n:
break break
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end] X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end] X_val, y_val = X[val_start:val_end], y[val_start:val_end]
source_tr = source[:tr_end] source_tr = source[:tr_end]
idx = stratified_undersample(y_tr, source_tr, seed=42) idx = stratified_undersample(y_tr, source_tr, seed=42)
@@ -410,7 +424,7 @@ def walk_forward_auc(
fold_metrics.append({"auc": auc, "precision": f_prec, "recall": f_rec, "threshold": f_thr}) fold_metrics.append({"auc": auc, "precision": f_prec, "recall": f_rec, "threshold": f_thr})
print( print(
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, " f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f} | " f"검증={val_start}~{val_end} ({step}, embargo={LOOKAHEAD}), AUC={auc:.4f} | "
f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}" f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}"
) )
@@ -422,7 +436,7 @@ def walk_forward_auc(
print(f" 폴드별: {[round(a, 4) for a in aucs]}") print(f" 폴드별: {[round(a, 4) for a in aucs]}")
def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None): def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0):
"""기존 피처 vs OI 파생 피처 추가 버전 A/B 비교.""" """기존 피처 vs OI 파생 피처 추가 버전 A/B 비교."""
import warnings import warnings
@@ -448,7 +462,9 @@ def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: s
dataset = generate_dataset_vectorized( dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df, df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay, time_weight_decay=time_weight_decay,
negative_ratio=5,
atr_sl_mult=atr_sl_mult,
atr_tp_mult=atr_tp_mult,
) )
if dataset.empty: if dataset.empty:
@@ -529,6 +545,135 @@ def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: s
print(f" {feat_name:<25} {imp_val:>6}{marker}") print(f" {feat_name:<25} {imp_val:>6}{marker}")
def ablation(
data_path: str,
time_weight_decay: float = 2.0,
n_splits: int = 5,
train_ratio: float = 0.6,
tuned_params_path: str | None = None,
atr_sl_mult: float = 2.0,
atr_tp_mult: float = 2.0,
) -> None:
"""Feature ablation 실험: signal_strength/side 의존도 진단.
실험 A: 전체 피처 (baseline)
실험 B: signal_strength 제거
실험 C: signal_strength + side 제거
"""
from src.dataset_builder import LOOKAHEAD
print(f"\n{'='*64}")
print(f" Feature Ablation 실험 ({n_splits}폴드 Walk-Forward, embargo={LOOKAHEAD})")
print(f"{'='*64}")
df_raw = pd.read_parquet(data_path)
base_cols = ["open", "high", "low", "close", "volume"]
btc_df = eth_df = None
if "close_btc" in df_raw.columns:
btc_df = df_raw[[c + "_btc" for c in base_cols]].copy()
btc_df.columns = base_cols
if "close_eth" in df_raw.columns:
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
eth_df.columns = base_cols
df = df_raw[base_cols].copy()
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
atr_sl_mult=atr_sl_mult,
atr_tp_mult=atr_tp_mult,
)
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
y = dataset["label"].values
w = dataset["sample_weight"].values
n = len(dataset)
source = dataset["source"].values if "source" in dataset.columns else np.full(n, "signal")
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
w = (w * weight_scale).astype(np.float32)
experiments = {
"A (전체 피처)": actual_feature_cols,
"B (-signal_strength)": [c for c in actual_feature_cols if c != "signal_strength"],
"C (-signal_strength, -side)": [c for c in actual_feature_cols if c not in ("signal_strength", "side")],
}
results = {}
for exp_name, cols in experiments.items():
X = dataset[cols].values
step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio)
fold_aucs = []
fold_importances = []
for fold_idx in range(n_splits):
tr_end = train_end_start + fold_idx * step
val_start = tr_end + LOOKAHEAD
val_end = val_start + step
if val_end > n:
break
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
source_tr = source[:tr_end]
idx = stratified_undersample(y_tr, source_tr, seed=42)
model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
model.fit(X_tr[idx], y_tr[idx], sample_weight=w_tr[idx])
proba = model.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
fold_aucs.append(auc)
fold_importances.append(dict(zip(cols, model.feature_importances_)))
mean_auc = float(np.mean(fold_aucs))
std_auc = float(np.std(fold_aucs))
results[exp_name] = {
"mean_auc": mean_auc,
"std_auc": std_auc,
"fold_aucs": fold_aucs,
"importances": fold_importances,
}
print(f"\n {exp_name}: AUC={mean_auc:.4f} ± {std_auc:.4f}")
print(f" 폴드별: {[round(a, 4) for a in fold_aucs]}")
if exp_name.startswith("A"):
avg_imp = {}
for imp in fold_importances:
for k, v in imp.items():
avg_imp[k] = avg_imp.get(k, 0) + v / len(fold_importances)
top10 = sorted(avg_imp.items(), key=lambda x: x[1], reverse=True)[:10]
print(f" Feature Importance Top 10:")
for feat_name, imp_val in top10:
marker = " <- 주의" if feat_name in ("signal_strength", "side") else ""
print(f" {feat_name:<25} {imp_val:>8.1f}{marker}")
auc_a = results["A (전체 피처)"]["mean_auc"]
auc_b = results["B (-signal_strength)"]["mean_auc"]
auc_c = results["C (-signal_strength, -side)"]["mean_auc"]
drop_ab = auc_a - auc_b
drop_ac = auc_a - auc_c
print(f"\n{'='*64}")
print(f" 드롭 분석")
print(f"{'='*64}")
print(f" A -> B (signal_strength 제거): {drop_ab:+.4f}")
print(f" A -> C (signal_strength + side 제거): {drop_ac:+.4f}")
print(f"{''*64}")
if drop_ac <= 0.05:
verdict = "ML 필터 가치 있음 (다른 피처가 충분히 기여)"
elif drop_ac <= 0.10:
verdict = "조건부 투입 (signal_strength 의존도 높지만 다른 피처도 기여)"
else:
verdict = "재설계 필요 (사실상 점수 재확인기)"
print(f" 판정: {verdict}")
print(f"{'='*64}\n")
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--data", default=None) parser.add_argument("--data", default=None)
@@ -544,8 +689,12 @@ def main():
"--tuned-params", type=str, default=None, "--tuned-params", type=str, default=None,
help="Optuna 튜닝 결과 JSON 경로 (지정 시 기본 파라미터를 덮어씀)", help="Optuna 튜닝 결과 JSON 경로 (지정 시 기본 파라미터를 덮어씀)",
) )
parser.add_argument("--ablation", action="store_true",
help="Feature ablation 실험 (signal_strength/side 의존도 진단)")
parser.add_argument("--compare", action="store_true", parser.add_argument("--compare", action="store_true",
help="OI 파생 피처 추가 전후 A/B 성능 비교") help="OI 파생 피처 추가 전후 A/B 성능 비교")
parser.add_argument("--sl-mult", type=float, default=2.0, help="SL ATR 배수 (기본 2.0)")
parser.add_argument("--tp-mult", type=float, default=2.0, help="TP ATR 배수 (기본 2.0)")
args = parser.parse_args() args = parser.parse_args()
# --symbol 모드: 심볼별 디렉토리 경로 자동 결정 # --symbol 모드: 심볼별 디렉토리 경로 자동 결정
@@ -562,17 +711,27 @@ def main():
elif args.data is None: elif args.data is None:
args.data = "data/combined_15m.parquet" args.data = "data/combined_15m.parquet"
if args.compare: if args.ablation:
compare(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params) ablation(
args.data, time_weight_decay=args.decay,
tuned_params_path=args.tuned_params,
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult,
)
elif args.compare:
compare(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params,
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
elif args.wf: elif args.wf:
walk_forward_auc( walk_forward_auc(
args.data, args.data,
time_weight_decay=args.decay, time_weight_decay=args.decay,
n_splits=args.wf_splits, n_splits=args.wf_splits,
tuned_params_path=args.tuned_params, tuned_params_path=args.tuned_params,
atr_sl_mult=args.sl_mult,
atr_tp_mult=args.tp_mult,
) )
else: else:
train(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params) train(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params,
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -39,7 +39,7 @@ from src.dataset_builder import generate_dataset_vectorized, stratified_undersam
# 데이터 로드 및 데이터셋 생성 (1회 캐싱) # 데이터 로드 및 데이터셋 생성 (1회 캐싱)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: def load_dataset(data_path: str, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
""" """
parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환. parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
study 시작 전 1회만 호출하여 모든 trial이 공유한다. study 시작 전 1회만 호출하여 모든 trial이 공유한다.
@@ -64,7 +64,8 @@ def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np
df = df_raw[base_cols].copy() df = df_raw[base_cols].copy()
print("\n데이터셋 생성 중 (1회만 실행)...") print("\n데이터셋 생성 중 (1회만 실행)...")
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0, negative_ratio=5) dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0,
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult)
if dataset.empty or "label" not in dataset.columns: if dataset.empty or "label" not in dataset.columns:
raise ValueError("데이터셋 생성 실패: 샘플 0개") raise ValueError("데이터셋 생성 실패: 샘플 0개")
@@ -149,14 +150,17 @@ def _walk_forward_cv(
fold_n_pos: list[int] = [] fold_n_pos: list[int] = []
scores_so_far: list[float] = [] scores_so_far: list[float] = []
from src.dataset_builder import LOOKAHEAD
for fold_idx in range(n_splits): for fold_idx in range(n_splits):
tr_end = train_end_start + fold_idx * step tr_end = train_end_start + fold_idx * step
val_end = tr_end + step val_start = tr_end + LOOKAHEAD # purged gap
val_end = val_start + step
if val_end > n: if val_end > n:
break break
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end] X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end] X_val, y_val = X[val_start:val_end], y[val_start:val_end]
# 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼 # 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼
source_tr = source[:tr_end] source_tr = source[:tr_end]
@@ -527,6 +531,8 @@ def main():
parser.add_argument("--train-ratio", type=float, default=0.6, help="학습 구간 비율 (기본: 0.6)") parser.add_argument("--train-ratio", type=float, default=0.6, help="학습 구간 비율 (기본: 0.6)")
parser.add_argument("--min-recall", type=float, default=0.35, help="최소 재현율 제약 (기본: 0.35)") parser.add_argument("--min-recall", type=float, default=0.35, help="최소 재현율 제약 (기본: 0.35)")
parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀") parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀")
parser.add_argument("--sl-mult", type=float, default=2.0, help="SL ATR 배수 (기본 2.0)")
parser.add_argument("--tp-mult", type=float, default=2.0, help="TP ATR 배수 (기본 2.0)")
args = parser.parse_args() args = parser.parse_args()
# --symbol 모드: 심볼별 디렉토리 경로 자동 결정 # --symbol 모드: 심볼별 디렉토리 경로 자동 결정
@@ -538,7 +544,7 @@ def main():
args.data = "data/combined_15m.parquet" args.data = "data/combined_15m.parquet"
# 1. 데이터셋 로드 (1회) # 1. 데이터셋 로드 (1회)
X, y, w, source = load_dataset(args.data) X, y, w, source = load_dataset(args.data, atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
# 2. 베이스라인 측정 # 2. 베이스라인 측정
if args.symbol: if args.symbol:

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

@@ -30,7 +30,7 @@ def validate(trades: list[dict], summary: dict, cfg) -> dict:
results: list[CheckResult] = [] results: list[CheckResult] = []
# 검증 1: 논리적 불변 조건 # 검증 1: 논리적 불변 조건
results.extend(_check_invariants(trades)) results.extend(_check_invariants(trades, cfg))
# 검증 2: 통계적 이상 감지 # 검증 2: 통계적 이상 감지
results.extend(_check_statistics(trades, summary)) results.extend(_check_statistics(trades, summary))
@@ -47,7 +47,7 @@ def validate(trades: list[dict], summary: dict, cfg) -> dict:
} }
def _check_invariants(trades: list[dict]) -> list[CheckResult]: def _check_invariants(trades: list[dict], cfg=None) -> list[CheckResult]:
"""논리적 불변 조건. 하나라도 위반 시 FAIL.""" """논리적 불변 조건. 하나라도 위반 시 FAIL."""
results = [] results = []
@@ -120,7 +120,7 @@ def _check_invariants(trades: list[dict]) -> list[CheckResult]:
)) ))
# 5. 잔고가 음수가 된 적 없음 # 5. 잔고가 음수가 된 적 없음
balance = 1000.0 # cfg.initial_balance를 몰라도 trades에서 추적 가능 balance = cfg.initial_balance if cfg is not None else 1000.0
min_balance = balance min_balance = balance
for t in trades: for t in trades:
balance += t["net_pnl"] balance += t["net_pnl"]

View File

@@ -144,6 +144,11 @@ class Position:
# ── 동기 RiskManager ───────────────────────────────────────────────── # ── 동기 RiskManager ─────────────────────────────────────────────────
class BacktestRiskManager: class BacktestRiskManager:
# Kill Switch 상수 (bot.py와 동일)
_FAST_KILL_STREAK = 8
_SLOW_KILL_WINDOW = 15
_SLOW_KILL_PF_THRESHOLD = 0.75
def __init__(self, cfg: BacktestConfig): def __init__(self, cfg: BacktestConfig):
self.cfg = cfg self.cfg = cfg
self.daily_pnl: float = 0.0 self.daily_pnl: float = 0.0
@@ -151,6 +156,8 @@ class BacktestRiskManager:
self.base_balance: float = cfg.initial_balance self.base_balance: float = cfg.initial_balance
self.open_positions: dict[str, str] = {} # {symbol: side} self.open_positions: dict[str, str] = {} # {symbol: side}
self._current_date: str | None = None self._current_date: str | None = None
self._trade_history: list[float] = [] # 최근 net_pnl 기록
self._killed: bool = False
def new_day(self, date_str: str): def new_day(self, date_str: str):
if self._current_date != date_str: if self._current_date != date_str:
@@ -158,12 +165,31 @@ class BacktestRiskManager:
self.daily_pnl = 0.0 self.daily_pnl = 0.0
def is_trading_allowed(self) -> bool: def is_trading_allowed(self) -> bool:
if self._killed:
return False
if self.initial_balance <= 0: if self.initial_balance <= 0:
return True return True
if self.daily_pnl < 0 and abs(self.daily_pnl) / self.initial_balance >= self.cfg.max_daily_loss_pct: if self.daily_pnl < 0 and abs(self.daily_pnl) / self.initial_balance >= self.cfg.max_daily_loss_pct:
return False return False
return True return True
def record_trade(self, net_pnl: float):
"""거래 기록 후 Kill Switch 검사."""
self._trade_history.append(net_pnl)
# Fast Kill: 8연속 순손실
if len(self._trade_history) >= self._FAST_KILL_STREAK:
recent = self._trade_history[-self._FAST_KILL_STREAK:]
if all(p < 0 for p in recent):
self._killed = True
return
# Slow Kill: 최근 15거래 PF < 0.75
if len(self._trade_history) >= self._SLOW_KILL_WINDOW:
recent = self._trade_history[-self._SLOW_KILL_WINDOW:]
gross_profit = sum(p for p in recent if p > 0)
gross_loss = abs(sum(p for p in recent if p < 0))
if gross_loss > 0 and gross_profit / gross_loss < self._SLOW_KILL_PF_THRESHOLD:
self._killed = True
def can_open(self, symbol: str, side: str) -> bool: def can_open(self, symbol: str, side: str) -> bool:
if len(self.open_positions) >= self.cfg.max_positions: if len(self.open_positions) >= self.cfg.max_positions:
return False return False
@@ -180,6 +206,7 @@ class BacktestRiskManager:
def close(self, symbol: str, pnl: float): def close(self, symbol: str, pnl: float):
self.open_positions.pop(symbol, None) self.open_positions.pop(symbol, None)
self.daily_pnl += pnl self.daily_pnl += pnl
self.record_trade(pnl)
def get_dynamic_margin_ratio(self, balance: float) -> float: def get_dynamic_margin_ratio(self, balance: float) -> float:
ratio = self.cfg.margin_max_ratio - ( ratio = self.cfg.margin_max_ratio - (
@@ -317,16 +344,9 @@ class Backtester:
self.ml_filters = {} self.ml_filters = {}
for sym in self.cfg.symbols: for sym in self.cfg.symbols:
if sym in ml_models and ml_models[sym] is not None: if sym in ml_models and ml_models[sym] is not None:
mf = MLFilter.__new__(MLFilter) self.ml_filters[sym] = MLFilter.from_model(
mf._disabled = False ml_models[sym], threshold=self.cfg.ml_threshold
mf._onnx_session = None )
mf._lgbm_model = ml_models[sym]
mf._threshold = self.cfg.ml_threshold
mf._onnx_path = Path("/dev/null")
mf._lgbm_path = Path("/dev/null")
mf._loaded_onnx_mtime = 0.0
mf._loaded_lgbm_mtime = 0.0
self.ml_filters[sym] = mf
else: else:
self.ml_filters[sym] = None self.ml_filters[sym] = None
@@ -335,6 +355,7 @@ class Backtester:
logger.info(f"총 이벤트: {len(events):,}") logger.info(f"총 이벤트: {len(events):,}")
# 메인 루프 # 메인 루프
latest_prices: dict[str, float] = {}
for ts, sym, candle_idx in events: for ts, sym, candle_idx in events:
date_str = str(ts.date()) date_str = str(ts.date())
self.risk.new_day(date_str) self.risk.new_day(date_str)
@@ -342,9 +363,10 @@ class Backtester:
df_ind = all_indicators[sym] df_ind = all_indicators[sym]
signal = all_signals[sym][candle_idx] signal = all_signals[sym][candle_idx]
row = df_ind.iloc[candle_idx] row = df_ind.iloc[candle_idx]
latest_prices[sym] = float(row["close"])
# 에퀴티 기록 # 에퀴티 기록
self._record_equity(ts) self._record_equity(ts, current_prices=latest_prices)
# 1) 일일 손실 체크 # 1) 일일 손실 체크
if not self.risk.is_trading_allowed(): if not self.risk.is_trading_allowed():
@@ -568,12 +590,15 @@ class Backtester:
} }
self.trades.append(trade) self.trades.append(trade)
def _record_equity(self, ts: pd.Timestamp): def _record_equity(self, ts: pd.Timestamp, current_prices: dict[str, float] | None = None):
# 미실현 PnL 포함 에퀴티
unrealized = 0.0 unrealized = 0.0
for pos in self.positions.values(): for sym, pos in self.positions.items():
# 에퀴티 기록 시점에는 현재가를 알 수 없으므로 entry_price 기준으로 0 처리 price = (current_prices or {}).get(sym)
pass if price is not None:
if pos.side == "LONG":
unrealized += (price - pos.entry_price) * pos.quantity
else:
unrealized += (pos.entry_price - price) * pos.quantity
equity = self.balance + unrealized equity = self.balance + unrealized
self.equity_curve.append({"timestamp": str(ts), "equity": round(equity, 4)}) self.equity_curve.append({"timestamp": str(ts), "equity": round(equity, 4)})
if equity > self._peak_equity: if equity > self._peak_equity:
@@ -600,7 +625,7 @@ class WalkForwardConfig(BacktestConfig):
train_months: int = 6 # 학습 윈도우 (개월) train_months: int = 6 # 학습 윈도우 (개월)
test_months: int = 1 # 검증 윈도우 (개월) test_months: int = 1 # 검증 윈도우 (개월)
time_weight_decay: float = 2.0 time_weight_decay: float = 2.0
negative_ratio: int = 5 negative_ratio: int = 3
class WalkForwardBacktester: class WalkForwardBacktester:
@@ -676,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"],
}) })
@@ -743,6 +770,8 @@ class WalkForwardBacktester:
signal_threshold=self.cfg.signal_threshold, signal_threshold=self.cfg.signal_threshold,
adx_threshold=self.cfg.adx_threshold, adx_threshold=self.cfg.adx_threshold,
volume_multiplier=self.cfg.volume_multiplier, volume_multiplier=self.cfg.volume_multiplier,
atr_sl_mult=self.cfg.atr_sl_mult,
atr_tp_mult=self.cfg.atr_tp_mult,
) )
except Exception as e: except Exception as e:
logger.warning(f" [{symbol}] 데이터셋 생성 실패: {e}") logger.warning(f" [{symbol}] 데이터셋 생성 실패: {e}")

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,6 +452,17 @@ 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}")
# Demo 모드: 고정 퍼센트 SL/TP (ATR이 너무 작아 즉시 트리거 방지)
if self.config.testnet:
sl_pct = 0.005 # 0.5%
tp_pct = 0.02
if signal == "LONG":
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:
# df는 이미 calculate_all() 적용된 df_with_indicators이므로 # df는 이미 calculate_all() 적용된 df_with_indicators이므로
# Indicators를 재생성하지 않고 ATR을 직접 사용 # Indicators를 재생성하지 않고 ATR을 직접 사용
atr = df["atr"].iloc[-1] atr = df["atr"].iloc[-1]
@@ -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,8 +35,16 @@ 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.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
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_key = os.getenv("BINANCE_API_KEY", "")
self.api_secret = os.getenv("BINANCE_API_SECRET", "") self.api_secret = os.getenv("BINANCE_API_SECRET", "")
self.symbol = os.getenv("SYMBOL", "XRPUSDT") self.symbol = os.getenv("SYMBOL", "XRPUSDT")
@@ -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

@@ -12,10 +12,19 @@ import pandas_ta as ta
from src.ml_features import FEATURE_COLS from src.ml_features import FEATURE_COLS
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰 LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
ATR_SL_MULT = 1.5 ATR_SL_MULT = 2.0 # config.py 기본값과 동일 (서빙 환경 일치)
ATR_TP_MULT = 2.0 ATR_TP_MULT = 2.0
WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분) WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분)
# ── 학습 전용 기본값 ──────────────────────────────────────────────
# 실전 봇(config.py)보다 완화된 임계값으로 더 많은 신호를 수집한다.
# ML 모델이 약한 신호 중에서 좋은 기회를 구분하는 법을 학습한다.
# 실전 진입은 bot.py의 엄격한 5단 게이트 + ML 필터가 최종 판단.
TRAIN_SIGNAL_THRESHOLD = 2 # 실전: 3 (config.py)
TRAIN_ADX_THRESHOLD = 15.0 # 실전: 25.0
TRAIN_VOLUME_MULTIPLIER = 1.5 # 실전: 2.5
TRAIN_NEGATIVE_RATIO = 3 # HOLD 네거티브 비율 (기존: 5)
def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame: def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""전체 시계열에 기술 지표를 1회 계산한다.""" """전체 시계열에 기술 지표를 1회 계산한다."""
@@ -56,9 +65,9 @@ def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
def _calc_signals( def _calc_signals(
d: pd.DataFrame, d: pd.DataFrame,
signal_threshold: int = 3, signal_threshold: int = TRAIN_SIGNAL_THRESHOLD,
adx_threshold: float = 25, adx_threshold: float = TRAIN_ADX_THRESHOLD,
volume_multiplier: float = 2.5, volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER,
) -> np.ndarray: ) -> np.ndarray:
""" """
indicators.py get_signal() 로직을 numpy 배열 연산으로 재현한다. indicators.py get_signal() 로직을 numpy 배열 연산으로 재현한다.
@@ -323,6 +332,8 @@ def _calc_labels_vectorized(
d: pd.DataFrame, d: pd.DataFrame,
feat: pd.DataFrame, feat: pd.DataFrame,
sig_idx: np.ndarray, sig_idx: np.ndarray,
atr_sl_mult: float = ATR_SL_MULT,
atr_tp_mult: float = ATR_TP_MULT,
) -> tuple[np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray]:
""" """
label_builder.py build_labels() 로직을 numpy 2D 배열로 벡터화한다. label_builder.py build_labels() 로직을 numpy 2D 배열로 벡터화한다.
@@ -348,11 +359,11 @@ def _calc_labels_vectorized(
continue continue
if signal == "LONG": if signal == "LONG":
sl = entry - atr * ATR_SL_MULT sl = entry - atr * atr_sl_mult
tp = entry + atr * ATR_TP_MULT tp = entry + atr * atr_tp_mult
else: else:
sl = entry + atr * ATR_SL_MULT sl = entry + atr * atr_sl_mult
tp = entry - atr * ATR_TP_MULT tp = entry - atr * atr_tp_mult
end = min(idx + 1 + LOOKAHEAD, n_total) end = min(idx + 1 + LOOKAHEAD, n_total)
fut_high = highs[idx + 1 : end] fut_high = highs[idx + 1 : end]
@@ -387,10 +398,12 @@ def generate_dataset_vectorized(
btc_df: pd.DataFrame | None = None, btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None, eth_df: pd.DataFrame | None = None,
time_weight_decay: float = 0.0, time_weight_decay: float = 0.0,
negative_ratio: int = 0, negative_ratio: int = TRAIN_NEGATIVE_RATIO,
signal_threshold: int = 3, signal_threshold: int = TRAIN_SIGNAL_THRESHOLD,
adx_threshold: float = 25, adx_threshold: float = TRAIN_ADX_THRESHOLD,
volume_multiplier: float = 2.5, volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER,
atr_sl_mult: float = ATR_SL_MULT,
atr_tp_mult: float = ATR_TP_MULT,
) -> pd.DataFrame: ) -> pd.DataFrame:
""" """
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다. 전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
@@ -435,7 +448,10 @@ def generate_dataset_vectorized(
print(f" 신호 발생 인덱스: {len(sig_idx):,}") print(f" 신호 발생 인덱스: {len(sig_idx):,}")
print(" [3/3] 레이블 계산...") print(" [3/3] 레이블 계산...")
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx) labels, valid_mask = _calc_labels_vectorized(
d, feat_all, sig_idx,
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult,
)
final_sig_idx = sig_idx[valid_mask] final_sig_idx = sig_idx[valid_mask]
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns] available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns]

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 내역을 조회한다.

View File

@@ -155,6 +155,21 @@ class MLFilter:
logger.warning(f"ML 필터 예측 오류 (진입 차단): {e}") logger.warning(f"ML 필터 예측 오류 (진입 차단): {e}")
return False return False
@classmethod
def from_model(cls, model, threshold: float = 0.55) -> "MLFilter":
"""외부에서 학습된 LightGBM 모델을 주입하여 MLFilter를 생성한다.
backtester walk-forward에서 사용."""
instance = cls.__new__(cls)
instance._disabled = False
instance._onnx_session = None
instance._lgbm_model = model
instance._threshold = threshold
instance._onnx_path = Path("/dev/null")
instance._lgbm_path = Path("/dev/null")
instance._loaded_onnx_mtime = 0.0
instance._loaded_lgbm_mtime = 0.0
return instance
def reload_model(self): def reload_model(self):
"""외부에서 강제 리로드할 때 사용 (하위 호환).""" """외부에서 강제 리로드할 때 사용 (하위 호환)."""
prev_backend = self.active_backend prev_backend = self.active_backend

View File

@@ -141,10 +141,12 @@ class MLXFilter:
X: pd.DataFrame, X: pd.DataFrame,
y: pd.Series, y: pd.Series,
sample_weight: np.ndarray | None = None, sample_weight: np.ndarray | None = None,
normalize: bool = True,
) -> "MLXFilter": ) -> "MLXFilter":
X_np = X[FEATURE_COLS].values.astype(np.float32) X_np = X[FEATURE_COLS].values.astype(np.float32)
y_np = y.values.astype(np.float32) y_np = y.values.astype(np.float32)
if normalize:
# nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치 # nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치
# (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값) # (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
mean_vals = np.nanmean(X_np, axis=0) mean_vals = np.nanmean(X_np, axis=0)
@@ -153,6 +155,10 @@ class MLXFilter:
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0 self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0
X_np = (X_np - self._mean) / self._std X_np = (X_np - self._mean) / self._std
X_np = np.nan_to_num(X_np, nan=0.0) X_np = np.nan_to_num(X_np, nan=0.0)
else:
self._mean = np.zeros(X_np.shape[1], dtype=np.float32)
self._std = np.ones(X_np.shape[1], dtype=np.float32)
X_np = np.nan_to_num(X_np, nan=0.0)
w_np = sample_weight.astype(np.float32) if sample_weight is not None else None w_np = sample_weight.astype(np.float32) if sample_weight is not None else None

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 메인 루프 — 봇 종료 시까지 실행."""
await self._run_loop(api_key, api_secret, testnet)
async def _run_loop(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
"""연결 → 재연결 무한 루프.
매 재연결마다 AsyncClient + BinanceSocketManager를 새로 생성한다.
keepalive ping timeout 후 기존 BinanceSocketManager의 listenKey가
무효화되면 재사용 시 이벤트를 수신하지 못하는 "조용한 실패"가 발생하므로,
반드시 새 인스턴스를 만들어야 한다.
"""
while True:
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,
) )
try:
bm = BinanceSocketManager(client) bm = BinanceSocketManager(client)
try:
await self._run_loop(bm)
finally:
await client.close_connection()
async def _run_loop(self, bm: BinanceSocketManager) -> None:
"""연결 → 재연결 무한 루프. BinanceSocketManager가 listenKey keepalive를 내부 처리한다."""
while True:
try:
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,6 +75,12 @@ class UserDataStream:
f"User Data Stream 끊김: {e}" f"User Data Stream 끊김: {e}"
f"{_RECONNECT_DELAY}초 후 재연결" f"{_RECONNECT_DELAY}초 후 재연결"
) )
finally:
try:
await client.close_connection()
except Exception:
pass
await asyncio.sleep(_RECONNECT_DELAY) await asyncio.sleep(_RECONNECT_DELAY)
async def _handle_message(self, msg: dict) -> None: async def _handle_message(self, msg: dict) -> None:
@@ -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

@@ -73,25 +73,6 @@ def test_generate_dataset_vectorized_with_btc_eth_has_21_feature_cols():
assert "label" in result.columns assert "label" in result.columns
def test_matches_original_generate_dataset(sample_df):
"""벡터화 버전과 기존 버전의 샘플 수가 유사해야 한다.
벡터화 버전은 전체 시계열로 지표를 1회 계산하고, 기존 버전은 61행 슬라이딩
윈도우로 매번 재계산한다. EMA 등 지수 이동평균은 초기값에 따라 수렴 속도가
달라지므로 두 방식의 신호 수는 완전히 동일하지 않을 수 있다. ±50% 범위를
허용한다.
"""
from scripts.train_model import generate_dataset
orig = generate_dataset(sample_df, n_jobs=1)
vec = generate_dataset_vectorized(sample_df)
if len(orig) == 0:
assert len(vec) == 0
return
ratio = len(vec) / len(orig)
assert 0.5 <= ratio <= 2.0, (
f"샘플 수 차이가 너무 큼: 벡터화={len(vec)}, 기존={len(orig)}, 비율={ratio:.2f}"
)
def test_epsilon_no_division_by_zero(): def test_epsilon_no_division_by_zero():
"""bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다.""" """bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다."""

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)

View File

@@ -0,0 +1,162 @@
import numpy as np
import pandas as pd
import pytest
from src.dataset_builder import generate_dataset_vectorized, _calc_labels_vectorized
@pytest.fixture
def signal_df():
"""시그널이 발생하는 데이터."""
rng = np.random.default_rng(7)
n = 800
trend = np.linspace(1.5, 3.0, n)
noise = np.cumsum(rng.normal(0, 0.04, n))
close = np.clip(trend + noise, 0.01, None)
high = close * (1 + rng.uniform(0, 0.015, n))
low = close * (1 - rng.uniform(0, 0.015, n))
volume = rng.uniform(1e6, 3e6, n)
volume[::30] *= 3.0
return pd.DataFrame({
"open": close, "high": high, "low": low,
"close": close, "volume": volume,
})
def test_training_defaults_are_relaxed(signal_df):
"""generate_dataset_vectorized의 기본 임계값이 학습용 완화 값이어야 한다."""
from src.dataset_builder import (
TRAIN_SIGNAL_THRESHOLD, TRAIN_ADX_THRESHOLD,
TRAIN_VOLUME_MULTIPLIER, TRAIN_NEGATIVE_RATIO,
)
assert TRAIN_SIGNAL_THRESHOLD == 2
assert TRAIN_ADX_THRESHOLD == 15.0
assert TRAIN_VOLUME_MULTIPLIER == 1.5
assert TRAIN_NEGATIVE_RATIO == 3
# 완화된 기본값으로 샘플이 더 많이 생성되는지 검증
r_relaxed = generate_dataset_vectorized(signal_df)
r_strict = generate_dataset_vectorized(
signal_df, signal_threshold=3, adx_threshold=25, volume_multiplier=2.5,
)
assert len(r_relaxed) >= len(r_strict), \
f"완화된 임계값이 더 많은 샘플을 생성해야 한다: relaxed={len(r_relaxed)}, strict={len(r_strict)}"
def test_sltp_params_are_passed_through(signal_df):
"""SL/TP 배수가 generate_dataset_vectorized에 전달되어야 한다."""
# 파라미터가 수용되는지(TypeError 없이) 확인하는 것이 핵심
# negative_ratio=0으로 시그널 샘플만 비교 (HOLD 노이즈 제거)
r1 = generate_dataset_vectorized(
signal_df, atr_sl_mult=1.5, atr_tp_mult=2.0,
adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
)
r2 = generate_dataset_vectorized(
signal_df, atr_sl_mult=2.0, atr_tp_mult=2.0,
adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
)
# 두 결과 모두 DataFrame이어야 한다
assert isinstance(r1, pd.DataFrame)
assert isinstance(r2, pd.DataFrame)
# 신호가 충분히 많을 경우, 다른 SL 배수는 레이블 분포에 영향을 줄 수 있다
# 소규모 데이터에서는 동일한 결과가 나올 수 있으므로 50개 이상일 때만 검증
if len(r1) > 50 and len(r2) > 50:
assert not (r1["label"].values == r2["label"].values).all() or len(r1) != len(r2), \
"SL 배수가 다르면 레이블이 달라져야 한다"
def test_default_sltp_backward_compatible(signal_df):
"""SL/TP 파라미터 미지정 시 기본값(2.0, 2.0)으로 동작해야 한다."""
r_default = generate_dataset_vectorized(
signal_df, adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
)
r_explicit = generate_dataset_vectorized(
signal_df, atr_sl_mult=2.0, atr_tp_mult=2.0,
adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
)
if len(r_default) > 0:
assert len(r_default) == len(r_explicit)
assert (r_default["label"].values == r_explicit["label"].values).all()
def test_equity_curve_includes_unrealized_pnl():
"""에퀴티 커브에 미실현 PnL이 반영되어야 한다."""
from src.backtester import Backtester, BacktestConfig, Position
import pandas as pd
cfg = BacktestConfig(symbols=["TEST"], initial_balance=1000.0)
bt = Backtester.__new__(Backtester)
bt.cfg = cfg
bt.balance = 1000.0
bt._peak_equity = 1000.0
bt.equity_curve = []
bt.positions = {"TEST": Position(
symbol="TEST", side="LONG", entry_price=100.0,
quantity=10.0, sl=95.0, tp=110.0,
entry_time=pd.Timestamp("2026-01-01"), entry_fee=0.4,
)}
bt._record_equity(pd.Timestamp("2026-01-01 00:15:00"), current_prices={"TEST": 105.0})
last = bt.equity_curve[-1]
assert last["equity"] == 1050.0, f"Expected 1050.0 (1000+50), got {last['equity']}"
def test_mlx_no_double_normalization():
"""MLXFilter.fit()에 normalize=False를 전달하면 내부 정규화를 건너뛰어야 한다."""
pytest.importorskip("mlx.core")
import numpy as np
import pandas as pd
from src.mlx_filter import MLXFilter
from src.ml_features import FEATURE_COLS
n_features = len(FEATURE_COLS)
rng = np.random.default_rng(42)
X = pd.DataFrame(
rng.standard_normal((100, n_features)).astype(np.float32),
columns=FEATURE_COLS,
)
y = pd.Series(rng.integers(0, 2, 100).astype(np.float32))
model = MLXFilter(input_dim=n_features, hidden_dim=16, epochs=1, batch_size=32)
model.fit(X, y, normalize=False)
assert np.allclose(model._mean, 0.0), "normalize=False시 mean은 0이어야 한다"
assert np.allclose(model._std, 1.0), "normalize=False시 std는 1이어야 한다"
def test_walk_forward_purged_gap():
"""Walk-Forward 검증에서 학습/검증 사이에 LOOKAHEAD 만큼의 gap이 존재해야 한다."""
from src.dataset_builder import LOOKAHEAD
n = 1000
train_ratio = 0.6
n_splits = 5
embargo = LOOKAHEAD # 24
step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio)
for fold_idx in range(n_splits):
tr_end = train_end_start + fold_idx * step
val_start = tr_end + embargo
val_end = val_start + step
if val_end > n:
break
assert val_start - tr_end >= embargo, \
f"폴드 {fold_idx}: gap={val_start - tr_end} < embargo={embargo}"
assert val_start > tr_end, \
f"폴드 {fold_idx}: val_start={val_start} <= tr_end={tr_end}"
def test_ml_filter_from_model():
"""MLFilter.from_model()로 LightGBM 모델을 주입할 수 있어야 한다."""
from src.ml_filter import MLFilter
from unittest.mock import MagicMock
mock_model = MagicMock()
mock_model.predict_proba.return_value = [[0.3, 0.7]]
mf = MLFilter.from_model(mock_model, threshold=0.55)
assert mf.is_model_loaded()
assert mf.active_backend == "LightGBM"

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