86 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
21in7
41b0aa3f28 fix: address code review round 2 — 9 issues (2 critical, 3 important, 4 minor)
Critical:
- #2: Add _entry_lock in RiskManager to serialize concurrent entry (balance race)
- #3: Add startTime to get_recent_income + record _entry_time_ms (SYNC PnL fix)

Important:
- #1: Add threading.Lock + _run_api() helper for thread-safe Client access
- #4: Convert reset_daily to async with lock
- #8: Add 24h TTL to exchange_info_cache

Minor:
- #7: Remove duplicate Indicators creation in _open_position (use ATR directly)
- #11: Add input validation for LEVERAGE, MARGIN ratios, ML_THRESHOLD
- #12: Replace hardcoded corr[0]/corr[1] with dict-based dynamic access
- #14: Add fillna(0.0) to LightGBM path for NaN consistency with ONNX

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:26:15 +09:00
21in7
e3623293f7 fix(dashboard): trades pagination + reproducible Docker build
- Add pagination controls to Trades tab (prev/next, offset support)
- Reset page on symbol change
- Use package-lock.json + npm ci for reproducible UI builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:15:48 +09:00
21in7
13c2b95c8e docs: update README/ARCHITECTURE with critical bugfix features
Add SL/TP atomicity (retry + emergency close), graceful shutdown,
_close_lock race condition fix, and update startup log examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:02:02 +09:00
21in7
9f0057e29d fix(dashboard): address code review — auth, DB stability, idempotency, UI fixes
C1: /api/reset에 API key 인증 추가 (DASHBOARD_RESET_KEY 환경변수)
C2: /proc 스캐닝 제거, PID file + SIGHUP 기반 파서 재파싱으로 교체
C3: daily_pnl 업데이트를 trades 테이블에서 재계산하여 idempotent하게 변경
I1: CORS origins를 CORS_ORIGINS 환경변수로 설정 가능하게 변경
I2: offset 파라미터에 ge=0 검증 추가
I3: 매 줄 commit → 파일 단위 배치 commit으로 성능 개선
I4: _pending_candles 크기 제한으로 메모리 누적 방지
I5: bot.log glob 중복 파싱 제거 (sorted(set(...)))
I6: /api/health 에러 메시지에서 내부 경로 미노출
I7: RSI 차트(데이터 없음)를 OI 변화율 차트로 교체
M1: pnlColor 변수 shadowing 수정 (posPnlColor)
M2: 거래 목록에 API total 필드 사용
M3: dashboard/ui/.dockerignore 추가
M4: API Dockerfile Python 3.11→3.12
M5: 테스트 fixture에서 temp DB cleanup 추가
M6: 누락 테스트 9건 추가 (health, daily, reset 인증, offset, pagination)
M7: 파서 SIGTERM graceful shutdown + entrypoint.sh signal forwarding
DB: 양쪽 busy_timeout=5000 + WAL pragma 설정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:00:16 +09:00
21in7
f14c521302 fix: critical bugs — double fee, SL/TP atomicity, PnL race, graceful shutdown
C5: Remove duplicate entry_fee deduction in backtester (balance and net_pnl)
C1: Add SL/TP retry (3x) with emergency market close on final failure
C3: Add _close_lock to prevent PnL double recording between callback and monitor
C8: Add SIGTERM/SIGINT handler with per-symbol order cancellation before exit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:55:14 +09:00
21in7
e648ae7ca0 fix: critical production issues — WebSocket reconnect, ghost positions, ONNX NaN
- fix(data_stream): add reconnect loop to MultiSymbolStream matching UserDataStream pattern
  Prevents bot-wide crash on WebSocket disconnect (#3 Critical)

- fix(data_stream): increase buffer_size 200→300 and preload 200→300
  Ensures z-score window (288) has sufficient data (#5 Important)

- fix(bot): sync risk manager when Binance has no position but local state does
  Prevents ghost entries in open_positions blocking future trades (#1 Critical)

- fix(ml_filter): add np.nan_to_num for ONNX input to handle NaN features
  Prevents all signals being blocked during initial ~2h warmup (#2 Critical)

- fix(bot): replace _close_handled_by_sync with current_trade_side==None guard
  Eliminates race window in SYNC PnL double recording (#4 Important)

- feat(bot): add _ensure_sl_tp_orders in _recover_position
  Detects and re-places missing SL/TP orders on bot restart (#6 Important)

- feat(exchange): add get_open_orders method for SL/TP verification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:37:47 +09:00
21in7
e3a78974b3 fix: address follow-up review findings
- fix(notifier): capture fire-and-forget Future exceptions via done_callback
- fix(bot): add _close_event.set() in SYNC path to unblock _close_and_reenter
- fix(ml_features): apply z-score to oi_price_spread (oi_z - ret_1_z) matching training
- fix(backtester): clean up import ordering after _calc_trade_stats extraction
- fix(backtester): correct Sharpe annualization for 24/7 crypto (365d × 96 = 35,040)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:10:02 +09:00
21in7
181f82d3c0 fix: address critical code review issues (PnL double recording, sync HTTP, race conditions)
- fix(bot): prevent PnL double recording in _close_and_reenter using asyncio.Event
- fix(bot): prevent SYNC detection PnL duplication with _close_handled_by_sync flag
- fix(notifier): move sync HTTP call to background thread via run_in_executor
- fix(risk_manager): make is_trading_allowed async with lock for thread safety
- fix(exchange): cache exchange info at class level (1 API call for all symbols)
- fix(exchange): use `is not None` instead of truthy check for price/stop_price
- refactor(backtester): extract _calc_trade_stats to eliminate code duplication
- fix(ml_features): apply rolling z-score to OI/funding rate in serving (train-serve skew)
- fix(bot): use config.correlation_symbols instead of hardcoded BTCUSDT/ETHUSDT
- fix(bot): expand OI/funding history deque to 96 for z-score window
- cleanup(config): remove unused stop_loss_pct, take_profit_pct, trailing_stop_pct fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:03:52 +09:00
21in7
24ed7ddec0 docs: add kill switch, SOL symbol swap, and analysis tools to docs
- README: add kill switch section with Slow Bleed explanation, env vars
- README: update SYMBOLS to XRPUSDT,SOLUSDT,DOGEUSDT, add SOL params
- README: add compare_symbols.py and position_sizing_analysis.py to tree
- ARCHITECTURE: add Gate 0 (kill switch) to entry flow, update risk table
- ARCHITECTURE: add trade recording + kill check to TP/SL scenario
- ARCHITECTURE: update weekly report pipeline (7 steps with kill monitoring)
- CLAUDE.md: add kill switch description to architecture section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 00:04:06 +09:00
21in7
b86aa8b072 feat(weekly-report): add kill switch monitoring section
- Load trade history from data/trade_history/{symbol}.jsonl
- Show per-symbol: consecutive loss streak vs threshold, recent 15-trade PF
- 2-tier alert: clean numbers for normal, ⚠/🔴 KILLED for danger zone
- Inserted before ML retraining checklist in Discord report

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:58:22 +09:00
21in7
42e53b9ae4 perf: optimize kill switch - tail-read only last N lines on boot
- Replace full file scan with _tail_lines() that reads from EOF
- Only load max(FAST_KILL=8, SLOW_KILL=15) lines on boot
- Trim in-memory trade history to prevent unbounded growth
- No I/O bottleneck regardless of history file size

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:50:17 +09:00
21in7
4930140b19 feat: add dual-layer kill switch (Fast Kill + Slow Kill)
- Fast Kill: 8 consecutive net losses → block new entries for symbol
- Slow Kill: last 15 trades PF < 0.75 → block new entries for symbol
- Trade history persisted to data/trade_history/{symbol}.jsonl (survives restart)
- Boot-time retrospective check restores kill state from history
- Manual reset via RESET_KILL_SWITCH_{SYMBOL}=True in .env + restart
- Entry blocked, exits (SL/TP/manual) always work normally
- Discord alert on kill switch activation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:48:52 +09:00
21in7
f890009a92 chore: replace TRXUSDT with SOLUSDT in trading symbols
- Switch SYMBOLS from XRPUSDT,TRXUSDT,DOGEUSDT to XRPUSDT,SOLUSDT,DOGEUSDT
- Add SOLUSDT params: SL=1.0x, TP=4.0x, ADX=20, margin_max=0.08
- Remove TRXUSDT params from .env.example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:38:32 +09:00
21in7
5b3f6af13c feat: add symbol comparison and position sizing analysis tools
- Add payoff_ratio and max_consecutive_losses to backtester summary
- Add compare_symbols.py: per-symbol parameter sweep for candidate evaluation
- Add position_sizing_analysis.py: robust Monte Carlo position sizing
- Fetch historical data for SOL, LINK, AVAX candidates (365 days)
- Update existing symbol data (XRP, TRX, DOGE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:35:42 +09:00
21in7
9d9f4960fc fix(dashboard): update signal regex to match new log format with extra fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:57:51 +09:00
21in7
8c1cd0422f fix(dashboard): use actual leverage from bot_status instead of hardcoded 10x
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:07:54 +09:00
21in7
4792b0f9cf fix(dashboard): clean up stale position cache on unmatched close events
_handle_close was not clearing _current_positions when no matching OPEN
trade was found in DB, causing all subsequent entries for the same
symbol+direction to be silently skipped. Also add PYTHONUNBUFFERED=1
and python -u to make log parser crashes visible in docker logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:10:15 +09:00
21in7
652990082d fix(weekly-report): calculate combined metrics directly from trades
The combined summary (PF, MDD, win_rate) was indirectly reconstructed
from per-symbol averages using round(win_rate * n), which introduced
rounding errors. MDD was max() of individual symbol MDDs, ignoring
simultaneous drawdowns across the correlated crypto portfolio.

Now computes all combined metrics directly from the trade list:
- PF: sum(wins) / sum(losses) from actual trade PnLs
- MDD: portfolio equity curve from time-sorted trades
- Win rate: direct count from trade PnLs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:12:42 +09:00
21in7
5e3a207af4 chore: update strategy parameters and add weekly report for 2026-03-15
- Increased ATR_SL_MULT_TRXUSDT from 1.0 to 2.0 and adjusted ADX_THRESHOLD_TRXUSDT from 30 to 25 in README.
- Added new weekly report files in HTML and JSON formats for 2026-03-15, including detailed backtest summaries and trade data for XRPUSDT, TRXUSDT, and DOGEUSDT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:53:31 +09:00
21in7
ab032691d4 docs: update README/ARCHITECTURE with per-symbol strategy params
- Add per-symbol env var override examples to README strategy section
- Add per-symbol env vars to environment variable reference table
- Update ARCHITECTURE multi-symbol section with SymbolStrategyParams
- Update CLAUDE.md configuration section
- Update test counts to 138

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:31:47 +09:00
21in7
55c20012a3 feat: add per-symbol strategy params with sweep-optimized values
Support per-symbol strategy parameters (ATR_SL_MULT_XRPUSDT, etc.)
via env vars, falling back to global defaults. Sweep results:
- XRPUSDT: SL=1.5 TP=4.0 ADX=30 (PF 2.39, Sharpe 61.0)
- TRXUSDT: SL=1.0 TP=4.0 ADX=30 (PF 3.87, Sharpe 62.8)
- DOGEUSDT: SL=2.0 TP=2.0 ADX=30 (PF 1.80, Sharpe 44.1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:28:14 +09:00
21in7
106eaf182b fix: accumulate partial fills in UserDataStream for accurate PnL
MARKET orders can fill in multiple trades (PARTIALLY_FILLED → FILLED).
Previously only the last fill's rp/commission was captured, causing
under-reported PnL. Now accumulates rp and commission across all
partial fills per order_id and sums them on final FILLED event.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:47:34 +09:00
21in7
64f56806d2 fix: resolve 6 warning issues from code review
5. Add daily PnL reset loop — UTC midnight auto-reset via
   _daily_reset_loop in main.py, prevents stale daily_pnl accumulation
6. Fix set_base_balance race condition — call once in main.py before
   spawning bots, instead of each bot calling independently
7. Remove realized_pnl != 0 from close detection — prevents entry
   orders with small rp values being misclassified as closes
8. Rename xrp_btc_rs/xrp_eth_rs → primary_btc_rs/primary_eth_rs —
   generic column names for multi-symbol support (dataset_builder,
   ml_features, and tests updated consistently)
9. Replace asyncio.get_event_loop() → get_running_loop() — fixes
   DeprecationWarning on Python 3.10+
10. Parallelize candle preload — asyncio.gather for all symbols
    instead of sequential REST calls, ~3x faster startup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 22:44:40 +09:00
21in7
8803c71bf9 fix: resolve 4 critical bugs from code review
1. Margin ratio calculated on per_symbol_balance instead of total balance
   — previously amplified margin reduction by num_symbols factor
2. Replace Algo Order API (algoType=CONDITIONAL) with standard
   futures_create_order for SL/TP — algo API is for VP/TWAP, not
   conditional orders; SL/TP may have silently failed
3. Fallback PnL (SYNC close) now sums all recent income rows instead
   of using only the last entry — prevents daily_pnl corruption in
   multi-fill scenarios
4. Explicit state transition in _close_and_reenter — clear local
   position state after close order to prevent race with User Data
   Stream callback on position count

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 22:39:51 +09:00
21in7
b188607d58 fix(dashboard): use dynamic DNS resolution for API proxy
Nginx caches DNS at startup, so when dashboard-api restarts and gets
a new container IP, the proxy breaks with 502. Use Docker's embedded
DNS resolver (127.0.0.11) with a variable-based proxy_pass to
re-resolve on every request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:07:52 +09:00
21in7
9644cf4ff0 fix(dashboard): prevent duplicate trades on container restart
Add UNIQUE(symbol, entry_time, direction) constraint to trades table
and use INSERT OR IGNORE to skip duplicates. Includes auto-migration
to clean up existing duplicate entries on startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:34:05 +09:00
21in7
805f1b0528 fix: fetch actual PnL from Binance income API on SYNC close detection
When the position monitor detects a missed close via API fallback, it
now queries Binance futures_income_history to get the real realized PnL
and commission instead of logging zeros. Exit price is estimated from
entry price + PnL/quantity. This ensures the dashboard records accurate
profit data even when WebSocket events are missed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:43:27 +09:00
21in7
363234ac7c fix: add fallback position sync check to detect missed WebSocket closes
The position monitor now checks Binance API every 5 minutes to verify
the bot's internal state matches the actual position. If the bot thinks
a position is open but Binance shows none, it syncs state and logs a
SYNC close event. This prevents the bot from being stuck in a phantom
position when User Data Stream misses a TP/SL fill event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:41:23 +09:00
21in7
de27f85e6d chore: update .gitignore, modify log file pattern, and add package-lock.json
- Added entries to .gitignore for node_modules and dist directories in the dashboard UI.
- Updated log file pattern in log_parser.py to match 'bot*.log' instead of 'bot_*.log'.
- Introduced package-lock.json for the dashboard UI to manage dependencies.
- Updated CLAUDE.md to reflect the status of code review improvements.
- Added new weekly report files in HTML and JSON formats for 2026-03-07.
- Updated binary parquet files for dogeusdt, trxusdt, and xrpusdt with new data.
2026-03-09 22:57:23 +09:00
21in7
cdde1795db fix(dashboard): preserve DB data across log parser restarts
Changed _init_db() from DROP+CREATE to CREATE IF NOT EXISTS so that
trade history, candle data, and daily PnL survive container restarts.
Previously all tables were dropped on every parser start, losing
historical data if log files had been rotated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:55:04 +09:00
21in7
d03012bb04 fix(dashboard): detect symbols from any bot_status key, not just last_start
The /api/symbols endpoint only returned symbols that had a :last_start
key, which requires the log parser to catch the bot start log. If the
dashboard was deployed after the bot started, the start log was already
past the file position and symbols showed as 0. Now extracts symbols
from any colon-prefixed key in bot_status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:52:21 +09:00
21in7
af91b36467 feat(dashboard): show unrealized PnL on position cards (5min update)
Parse position monitor logs (5min interval) to update current_price,
unrealized_pnl and unrealized_pnl_pct in bot_status. Position cards
now display USDT amount and percentage, colored green/red. Falls back
to entry/current price calculation if monitor data unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:55:53 +09:00
21in7
c6c60b274c fix: use dynamic quantity/price precision per symbol from exchange info
Hardcoded round(qty, 1) caused -1111 Precision errors for TRXUSDT and
DOGEUSDT (stepSize=1, requires integers). Now lazily loads quantityPrecision
and pricePrecision from Binance futures_exchange_info per symbol. SL/TP
prices also use symbol-specific precision instead of hardcoded 4 decimals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:07:23 +09:00
21in7
97aef14d6c fix: add retry with exponential backoff to OI/funding API calls (#4)
_fetch_oi_hist and _fetch_funding_rate had no retry logic, causing
crashes on rate limit (429) or transient errors during weekly report
data collection. Added _get_json_with_retry helper (max 3 attempts,
exponential backoff). Updated code-review docs to reflect completion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:00:52 +09:00
21in7
afdbacaabd fix(dashboard): prevent infinite API polling loop
useCallback dependency on `symbols` state caused fetchAll to recreate
on every call (since it sets symbols), triggering useEffect to restart
the interval immediately. Use symbolsRef to break the cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:31:28 +09:00
21in7
9b76313500 feat: add quantstats HTML report to weekly strategy report
- generate_quantstats_report() converts backtest trades to daily returns
  and generates full HTML report (Sharpe, Sortino, drawdown chart, etc.)
- Weekly report now saves report_YYYY-MM-DD.html alongside JSON
- Added quantstats to requirements.txt
- 2 new tests (HTML generation + empty trades handling)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:14:30 +09:00
21in7
60510c026b fix: resolve critical/important bugs from code review (#1,#2,#4,#5,#6,#8)
- #1: OI division by zero — already fixed (prev_oi == 0.0 guard exists)
- #2: cumulative trade count used max() instead of sum(), breaking ML trigger
- #4: fetch_history API calls now retry 3x with exponential backoff
- #5: parquet upsert now deduplicates timestamps before sort
- #6: record_pnl() is now async with Lock for multi-symbol safety
- #8: exit_price=0.0 skips close handling with warning log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:06:48 +09:00
21in7
0a8748913e feat: add signal score detail to bot logs for HOLD reason debugging
get_signal() now returns (signal, detail) tuple with long/short scores,
ADX value, volume surge status, and HOLD reason for easier diagnosis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:20:44 +09:00
21in7
c577019793 docs: update architecture and README for improved clarity and structure
- Revised the architecture document to enhance clarity on system overview, trading decision process, and technical stack.
- Updated the README to emphasize the bot's operational guidelines and risk management features.
- Added new sections in the architecture document detailing the trading decision gates and data pipeline flow.
- Improved the table of contents for better navigation and understanding of the bot's architecture.
2026-03-07 02:12:48 +09:00
21in7
2a767c35d4 feat(weekly-report): implement weekly report generation with live trade data and performance tracking
- Added functionality to fetch live trade data from the dashboard API.
- Implemented weekly report generation that includes backtest results, live trade statistics, and performance trends.
- Enhanced error handling for API requests and improved logging for better traceability.
- Updated tests to cover new features and ensure reliability of the report generation process.
2026-03-07 01:13:03 +09:00
21in7
6a6740d708 docs: update CLAUDE.md with weekly-report commands and plan status
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:53:13 +09:00
21in7
f47ad26156 fix(weekly-report): handle numpy.bool_ in JSON serialization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:52:51 +09:00
21in7
1b1542d51f feat(weekly-report): add main orchestration, CLI, JSON save
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:49:16 +09:00
21in7
90d99a1662 feat(weekly-report): add Discord report formatting and sending
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:46:03 +09:00
21in7
58596785aa feat(weekly-report): add ML trigger check and degradation sweep
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:42:53 +09:00
21in7
3b0335f57e feat(weekly-report): add trend tracking from previous reports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:40:39 +09:00
21in7
35177bf345 feat(weekly-report): add live trade log parser
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:39:03 +09:00
21in7
9011344aab feat(weekly-report): add data fetch and WF backtest core
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:34:48 +09:00
21in7
2e788c0d0f docs: add weekly strategy report implementation plan
8-task plan covering: data fetch, WF backtest, log parsing, trend tracking,
ML re-trigger check, degradation sweep, Discord formatting, CLI orchestration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:28:28 +09:00
93 changed files with 68285 additions and 1110 deletions

View File

@@ -2,16 +2,22 @@ BINANCE_API_KEY=
BINANCE_API_SECRET= BINANCE_API_SECRET=
SYMBOLS=XRPUSDT 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
MAX_SAME_DIRECTION=2 MAX_SAME_DIRECTION=2
# Global defaults (fallback when no per-symbol override)
ATR_SL_MULT=2.0 ATR_SL_MULT=2.0
ATR_TP_MULT=2.0 ATR_TP_MULT=2.0
SIGNAL_THRESHOLD=3 SIGNAL_THRESHOLD=3
ADX_THRESHOLD=25 ADX_THRESHOLD=25
VOL_MULTIPLIER=2.5 VOL_MULTIPLIER=2.5
# Per-symbol strategy params (2026-03-21 운영 설정)
ATR_SL_MULT_XRPUSDT=1.5
ATR_TP_MULT_XRPUSDT=4.0
ADX_THRESHOLD_XRPUSDT=25
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=

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ data/*.parquet
.worktrees/ .worktrees/
.venv .venv
dashboard/ui/node_modules/
dashboard/ui/dist/

View File

@@ -1,40 +1,72 @@
# CoinTrader — 아키텍처 문서 # CoinTrader — 아키텍처 문서
> 이 문서는 CoinTrader 코드베이스를 처음 접하는 개발자와 트레이딩 배경 독자 모두를 위해 작성되었습니다. > 이 문서는 CoinTrader의 내부 구조를 설명합니다.
> 기술 스택, 레이어별 역할, MLOps 파이프라인, 핵심 동작 시나리오를 순서대로 설명합니다. > **봇 사용법**은 [README.md](README.md)를 참고하세요.
--- ---
## 목차 ## 목차
1. [시스템 오버뷰](#1-시스템-오버뷰) 1. [시스템 개요](#1-시스템-개요) — 봇이 무엇을 하는지, 어떤 구조인지
2. [코어 레이어 아키텍처](#2-코어-레이어-아키텍처) 2. [매매 판단 과정](#2-매매-판단-과정) — 15분마다 어떤 과정을 거쳐 매매하는지
3. [MLOps 파이프라인 — 자가 진화 시스템](#3-mlops-파이프라인--자가-진화-시스템) 3. [5개 레이어 상세](#3-5개-레이어-상세) — 각 레이어의 역할과 동작 원리
4. [핵심 동작 시나리오](#4-핵심-동작-시나리오) 4. [MLOps 파이프라인](#4-mlops-파이프라인) — ML 모델의 학습·배포·모니터링 전체 흐름
5. [테스트 커버리지](#5-테스트-커버리지) 5. [핵심 동작 시나리오](#5-핵심-동작-시나리오) — 실제 상황별 봇의 동작 흐름도
5-1. [MTF Pullback Bot](#5-1-mtf-pullback-bot) — 멀티타임프레임 풀백 전략 Dry-run 봇
6. [테스트 커버리지](#6-테스트-커버리지) — 무엇을 어떻게 테스트하는지
7. [파일 구조](#7-파일-구조) — 전체 파일 역할 요약
--- ---
## 1. 시스템 오버뷰 ## 1. 시스템 개요
CoinTrader는 **Binance Futures 자동매매 봇**입니다. 기술 지표 신호를 1차 필터로, LightGBM(또는 MLX 신경망) 모델을 2차 필터로 사용하여 다중 심볼(XRP, TRX, DOGE 등) 선물 포지션을 동시에 자동 진입·청산합니다. CoinTrader는 **Binance Futures 자동매매 봇**입니다.
### 멀티심볼 아키텍처 **한 줄 요약**: 15분마다 기술 지표로 매매 신호를 생성하고, ML 모델로 한 번 더 검증한 뒤, 조건을 충족하면 자동으로 주문을 넣습니다.
### 1.1 전체 흐름 (간략)
```
15분봉 마감 → 기술 지표 계산 → 매매 신호 생성 → ML 필터 검증 → 리스크 체크 → 주문 실행 → Discord 알림
```
### 1.2 멀티심볼 아키텍처
여러 심볼을 동시에 거래합니다. 각 심볼은 독립된 봇 인스턴스로 실행되며, 리스크 관리만 공유합니다.
``` ```
main.py main.py
└─ Config (SYMBOLS=XRPUSDT,TRXUSDT,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="TRXUSDT", risk=shared_risk),
TradingBot(symbol="DOGEUSDT", risk=shared_risk),
) )
``` ```
각 봇은 독립적인 `Exchange`, `MLFilter`, `DataStream`을 소유합니다. `RiskManager`만 공유 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한, 최대 포지션 수)를 관리합니다. > **운영 이력**: SOL/DOGE/TRX는 파라미터 스윕에서 모든 조합에서 PF < 1.0으로 제외 (2026-03-21).
### 전체 데이터 파이프라인 흐름도 - **독립**: 각 봇은 자체 `Exchange`, `MLFilter`, `DataStream`, `SymbolStrategyParams`를 소유
- **공유**: `RiskManager`만 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한) 관리
- **병렬**: `asyncio.gather()`로 동시 실행, 서로 간섭 없음
- **심볼별 전략**: `config.get_symbol_params(symbol)`로 SL/TP/ADX 등을 심볼별 독립 설정 (`ATR_SL_MULT_XRPUSDT` 등 환경변수)
### 1.3 기술 스택
| 분류 | 기술 |
|------|------|
| 언어 | Python 3.11+ |
| 비동기 런타임 | `asyncio` + `python-binance` WebSocket |
| 기술 지표 | `pandas-ta` (RSI, MACD, BB, EMA, StochRSI, ATR, ADX) |
| ML 프레임워크 | `LightGBM` (CPU) / `MLX` (Apple Silicon GPU) |
| 모델 서빙 | `onnxruntime` (ONNX 우선) / `joblib` (LightGBM 폴백) |
| 하이퍼파라미터 탐색 | `Optuna` (TPE Sampler + MedianPruner) |
| 데이터 저장 | `Parquet` (pyarrow) |
| 로깅 | `Loguru` |
| 알림 | Discord Webhook (`httpx`) |
| 컨테이너화 | Docker + Docker Compose |
| CI/CD | Jenkins + Gitea Container Registry |
### 1.4 데이터 파이프라인 전체 흐름도
```mermaid ```mermaid
flowchart TD flowchart TD
@@ -47,21 +79,21 @@ flowchart TD
subgraph 실시간봇["실시간 봇 (bot.py — asyncio)"] subgraph 실시간봇["실시간 봇 (bot.py — asyncio)"]
DS["data_stream.py<br/>MultiSymbolStream (심볼별)<br/>캔들 버퍼 (deque 200개)"] DS["data_stream.py<br/>MultiSymbolStream (심볼별)<br/>캔들 버퍼 (deque 200개)"]
IND["indicators.py<br/>기술 지표 계산<br/>RSI·MACD·BB·EMA·StochRSI·ATR·ADX"] IND["indicators.py<br/>기술 지표 계산<br/>RSI·MACD·BB·EMA·StochRSI·ATR·ADX"]
MF["ml_features.py<br/>23개 피처 추출<br/>(XRP 13 + BTC/ETH 8 + OI/FR 2)"] MF["ml_features.py<br/>26개 피처 추출<br/>(XRP 13 + BTC/ETH 8 + OI/FR 2 + OI파생 2 + ADX 1)"]
ML["ml_filter.py<br/>MLFilter<br/>ONNX 우선 / LightGBM 폴백<br/>확률 ≥ 0.60 시 진입 허용"] ML["ml_filter.py<br/>MLFilter<br/>ONNX 우선 / LightGBM 폴백<br/>확률 ≥ 0.55 시 진입 허용"]
RM["risk_manager.py<br/>RiskManager (공유 싱글턴)<br/>일일 손실 5% 한도<br/>동적 증거금 비율<br/>동일 방향 제한"] RM["risk_manager.py<br/>RiskManager (공유 싱글턴)<br/>일일 손실 5% 한도<br/>동적 증거금 비율<br/>동일 방향 제한"]
EX["exchange.py<br/>BinanceFuturesClient<br/>주문·레버리지·잔고 API"] EX["exchange.py<br/>BinanceFuturesClient<br/>주문·레버리지·잔고 API"]
UDS["user_data_stream.py<br/>UserDataStream<br/>TP/SL 즉시 감지"] UDS["user_data_stream.py<br/>UserDataStream<br/>TP/SL 즉시 감지"]
NT["notifier.py<br/>DiscordNotifier<br/>진입·청산·오류 알림"] NT["notifier.py<br/>DiscordNotifier<br/>진입·청산·오류 알림"]
end end
subgraph mlops["MLOps 파이프라인 (맥미니 — 수동/크론)"] subgraph mlops["MLOps 파이프라인 (수동/크론)"]
FH["fetch_history.py<br/>과거 캔들 + OI/펀딩비<br/>Parquet Upsert"] FH["fetch_history.py<br/>과거 캔들 + OI/펀딩비<br/>Parquet Upsert"]
DB["dataset_builder.py<br/>벡터화 데이터셋 생성<br/>레이블: ATR SL/TP 6시간 룩어헤드"] DB["dataset_builder.py<br/>벡터화 데이터셋 생성<br/>레이블: ATR SL/TP 6시간 룩어헤드"]
TM["train_model.py<br/>LightGBM 학습<br/>Walk-Forward 5폴드 검증"] TM["train_model.py<br/>LightGBM 학습<br/>Walk-Forward 5폴드 검증"]
TN["tune_hyperparams.py<br/>Optuna 50 trials<br/>TPE + MedianPruner"] TN["tune_hyperparams.py<br/>Optuna 50 trials<br/>TPE + MedianPruner"]
AP["active_lgbm_params.json<br/>Active Config 패턴<br/>승인된 파라미터 저장"] AP["active_lgbm_params.json<br/>Active Config 패턴<br/>승인된 파라미터 저장"]
DM["deploy_model.sh<br/>rsync → LXC 서버<br/>봇 핫리로드 트리거"] DM["deploy_model.sh<br/>rsync → 운영 서버<br/>봇 핫리로드 트리거"]
end end
WS1 -->|캔들 마감 이벤트| DS WS1 -->|캔들 마감 이벤트| DS
@@ -84,26 +116,59 @@ flowchart TD
DM -->|모델 파일 전송| ML DM -->|모델 파일 전송| ML
``` ```
### 기술 스택 요약 ---
| 분류 | 기술 | ## 2. 매매 판단 과정
|------|------|
| 언어 | Python 3.11+ | 봇이 매매를 결정하는 과정을 단계별로 설명합니다. 코드를 읽기 전에 이 섹션을 먼저 이해하면 전체 구조가 명확해집니다.
| 비동기 런타임 | `asyncio` + `python-binance` WebSocket |
| 기술 지표 | `pandas-ta` (RSI, MACD, BB, EMA, StochRSI, ATR) | ### 2.1 진입 판단 (5단계 게이트)
| ML 프레임워크 | `LightGBM` (CPU) / `MLX` (Apple Silicon GPU) |
| 모델 서빙 | `onnxruntime` (ONNX 우선) / `joblib` (LightGBM 폴백) | ```
| 하이퍼파라미터 탐색 | `Optuna` (TPE Sampler + MedianPruner) | Gate 0: 킬스위치 확인
| 데이터 저장 | `Parquet` (pyarrow) | └─ 해당 심볼이 킬 상태인가? → 킬이면 즉시 return (신규 진입 차단)
| 로깅 | `Loguru` | └─ Fast Kill: 8연속 순손실 / Slow Kill: 최근 15거래 PF < 0.75
| 알림 | Discord Webhook (`httpx`) |
| 컨테이너화 | Docker + Docker Compose | Gate 1: 추세 존재 확인
| CI/CD | Jenkins + Gitea Container Registry | └─ ADX ≥ 25 인가? → 미만이면 HOLD (횡보장 진입 차단)
| 운영 서버 | LXC 컨테이너 (`10.1.10.24`) |
Gate 2: 기술 지표 신호 생성
└─ RSI, MACD, 볼린저, EMA, StochRSI 점수 합산
└─ 합계 ≥ SIGNAL_THRESHOLD(기본 3)인가?
Gate 3: 거래량 확인
└─ 거래량 ≥ 20MA × VOL_MULTIPLIER(기본 2.5)인가?
└─ 또는 신호 점수가 SIGNAL_THRESHOLD + 1 이상인가?
Gate 4: ML 필터 (활성화 시)
└─ 26개 피처로 성공 확률 예측
└─ 확률 ≥ ML_THRESHOLD(기본 0.55)인가?
Gate 5: 리스크 관리
└─ 일일 손실 한도 미초과?
└─ 동일 방향 포지션 2개 미만?
└─ 같은 심볼 기존 포지션 없음?
→ 6개 게이트 모두 통과 → 주문 실행
```
### 2.2 청산 메커니즘
| 청산 방식 | 설명 |
|-----------|------|
| **TP (익절)** | 진입가 ± ATR × ATR_TP_MULT 도달 시 자동 청산 |
| **SL (손절)** | 진입가 ∓ ATR × ATR_SL_MULT 도달 시 자동 청산 |
| **반대 시그널** | 보유 중 반대 방향 신호 → 즉시 청산 후 반대 방향 재진입 |
### 2.3 현재 ML 필터 상태
**현재 비활성화** (`NO_ML_FILTER=true`)
Walk-Forward 검증 결과 각 폴드 학습 세트에 유효 신호가 약 27건으로, LightGBM이 의미 있는 패턴을 학습하기엔 표본이 부족합니다. 전략 파라미터 스윕에서 ADX 필터 + 거래량 배수 조합만으로 PF 1.57~2.39를 달성하여, 충분한 트레이드 데이터가 축적될 때까지 ML 없이 운영합니다.
--- ---
## 2. 코어 레이어 아키텍처 ## 3. 5개 레이어 상세
봇은 5개의 레이어로 구성됩니다. 각 레이어는 단일 책임을 가지며, 위에서 아래로 데이터가 흐릅니다. 봇은 5개의 레이어로 구성됩니다. 각 레이어는 단일 책임을 가지며, 위에서 아래로 데이터가 흐릅니다.
@@ -134,7 +199,7 @@ flowchart TD
**파일:** `src/data_stream.py` **파일:** `src/data_stream.py`
각 봇 인스턴스가 시작되면 가장 먼저 실행되는 레이어입니다. Binance Combined WebSocket 단일 연결로 주 거래 심볼 + 상관관계 심볼(BTC/ETH)의 15분봉 캔들을 동시에 수신합니다. Binance Combined WebSocket 단일 연결로 주 거래 심볼 + 상관관계 심볼(BTC/ETH)의 15분봉 캔들을 동시에 수신합니다.
**핵심 동작:** **핵심 동작:**
@@ -170,7 +235,7 @@ flowchart TD
| ADX | length=14 | 추세 강도 측정 → 횡보장 필터 (ADX < 25 시 진입 차단) | | ADX | length=14 | 추세 강도 측정 → 횡보장 필터 (ADX < 25 시 진입 차단) |
| Volume MA | length=20 | 거래량 급증 감지 | | Volume MA | length=20 | 거래량 급증 감지 |
**신호 생성 로직 (ADX 필터 + 가중치 합산):** **신호 생성 로직:**
``` ```
[1단계] ADX 횡보장 필터: [1단계] ADX 횡보장 필터:
@@ -183,9 +248,13 @@ flowchart TD
EMA 정배열 (9 > 21 > 50) → +1 EMA 정배열 (9 > 21 > 50) → +1
StochRSI K < 20 and K > D → +1 StochRSI K < 20 and K > D → +1
진입 조건: 점수 ≥ 3 AND (거래량 급증 OR 점수 ≥ 4) 진입 조건: 점수 ≥ SIGNAL_THRESHOLD(기본 3)
SL = 진입가 - ATR × 1.5 AND (거래량 ≥ 20MA × VOL_MULTIPLIER(기본 2.5) OR 점수 ≥ SIGNAL_THRESHOLD + 1)
TP = 진입가 + ATR × 3.0 (리스크:리워드 = 1:2)
SL = 진입가 - ATR × ATR_SL_MULT (기본 2.0)
TP = 진입가 + ATR × ATR_TP_MULT (기본 2.0)
※ SL/TP/신호임계값/ADX/거래량배수 모두 환경변수로 설정 가능 (심볼별 오버라이드 지원)
``` ```
숏 신호는 롱의 대칭 조건으로 계산됩니다. 숏 신호는 롱의 대칭 조건으로 계산됩니다.
@@ -196,7 +265,7 @@ TP = 진입가 + ATR × 3.0 (리스크:리워드 = 1:2)
**파일:** `src/ml_filter.py`, `src/ml_features.py` **파일:** `src/ml_filter.py`, `src/ml_features.py`
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입(억까 타점)을 줄이는 2차 게이트키퍼입니다. 기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입을 줄이는 2차 게이트키퍼입니다.
**모델 우선순위:** **모델 우선순위:**
@@ -206,7 +275,7 @@ ONNX (MLX 신경망) → LightGBM → 폴백(항상 허용)
모델 파일이 없으면 모든 신호를 허용합니다. 봇 재시작 없이 모델 파일을 교체하면 다음 캔들 마감 시 자동으로 핫리로드됩니다(`mtime` 감지). 모델 파일이 없으면 모든 신호를 허용합니다. 봇 재시작 없이 모델 파일을 교체하면 다음 캔들 마감 시 자동으로 핫리로드됩니다(`mtime` 감지).
**23개 ML 피처:** **26개 ML 피처:**
``` ```
XRP 기술 지표 (13개): XRP 기술 지표 (13개):
@@ -222,6 +291,13 @@ BTC/ETH 상관관계 (8개):
시장 미시구조 (2개): 시장 미시구조 (2개):
oi_change ← 이전 캔들 대비 미결제약정 변화율 oi_change ← 이전 캔들 대비 미결제약정 변화율
funding_rate ← 현재 펀딩비 funding_rate ← 현재 펀딩비
OI 파생 피처 (2개):
oi_change_ma5 ← OI 변화율 5캔들 이동평균 (스마트머니 추세)
oi_price_spread ← OI 변화율 - 가격 변화율 (OI-가격 괴리도)
추세 강도 (1개):
adx ← ADX 값 (ML 모델이 횡보/추세 판단에 활용)
``` ```
`oi_change``funding_rate`는 캔들 마감마다 Binance REST API로 실시간 조회합니다. API 실패 시 `0.0`으로 폴백하여 봇이 멈추지 않습니다. `oi_change``funding_rate`는 캔들 마감마다 Binance REST API로 실시간 조회합니다. API 실패 시 `0.0`으로 폴백하여 봇이 멈추지 않습니다.
@@ -230,7 +306,7 @@ BTC/ETH 상관관계 (8개):
```python ```python
proba = model.predict_proba(features)[0][1] # 성공 확률 proba = model.predict_proba(features)[0][1] # 성공 확률
return proba >= 0.60 # 임계값 60% return proba >= 0.55 # 임계값 (ML_THRESHOLD 환경변수로 조절)
``` ```
--- ---
@@ -254,28 +330,33 @@ ML 필터를 통과한 신호를 실제 주문으로 변환하고, 리스크 한
**주문 흐름:** **주문 흐름:**
``` ```
1. set_leverage(10x) 1. set_leverage(20x)
2. place_order(MARKET) ← 진입 2. place_order(MARKET) ← 진입
3. place_order(STOP_MARKET) ← SL 설정 3. place_order(STOP_MARKET) ← SL 설정 (3회 재시도)
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정 4. place_order(TAKE_PROFIT_MARKET) ← TP 설정 (3회 재시도)
※ SL/TP 최종 실패 시 → 긴급 시장가 청산 + Discord 알림
``` ```
SL/TP 주문은 `/fapi/v1/algoOrder` 엔드포인트로 전송됩니다 (일반 계정의 `-4120` 오류 대응). **SL/TP 원자성 보장:** SL/TP 배치는 `_place_sl_tp_with_retry()`로 3회 재시도합니다. 개별 추적(SL 성공 후 TP만 재시도)하여 불필요한 중복 주문을 방지합니다. 모든 재시도 실패 시 `_emergency_close()`가 포지션을 즉시 시장가 청산하고 Discord로 긴급 알림을 전송합니다.
**리스크 제어:** **리스크 제어:**
| 제어 항목 | 기준 | | 제어 항목 | 기준 | 방어 대상 |
|----------|------| |----------|------|-----------|
| 일일 최대 손실 | 기준 잔고의 5% | | 일일 최대 손실 | 기준 잔고의 5% | 단일 충격 (하루 급락) |
| 최대 동시 포지션 | 3개 (전체 심볼 합산) | | 킬스위치 Fast Kill | 8연속 순손실 | 전략 급격 붕괴 |
| 동일 방향 제한 | 2개 (LONG 2개면 3번째 LONG 차단) | | 킬스위치 Slow Kill | 최근 15거래 PF < 0.75 | 점진적 엣지 소실 (Slow Bleed) |
| 같은 심볼 중복 | 차단 (1심볼 1포지션) | | 최대 동시 포지션 | 3개 (전체 심볼 합산) | 과노출 |
| 최소 명목금액 | $5 USDT | | 동일 방향 제한 | 2개 (LONG 2개면 3번째 LONG 차단) | 방향 편중 |
| 같은 심볼 중복 | 차단 (1심볼 1포지션) | 중복 진입 |
| 최소 명목금액 | $5 USDT | 거래소 제약 |
**반대 시그널 재진입:** 보유 포지션과 반대 방향 신호 발생 시 기존 포지션을 즉시 청산하고, ML 필터 통과 시 반대 방향으로 재진입합니다. 재진입 중 User Data Stream 콜백이 신규 포지션 상태를 덮어쓰지 않도록 `_is_reentering` 플래그로 보호합니다. **반대 시그널 재진입:** 보유 포지션과 반대 방향 신호 발생 시 기존 포지션을 즉시 청산하고, ML 필터 통과 시 반대 방향으로 재진입합니다. 재진입 중 User Data Stream 콜백이 신규 포지션 상태를 덮어쓰지 않도록 `_is_reentering` 플래그로 보호합니다.
**마진 균등 배분:** 멀티심볼 모드에서 각 봇은 전체 잔고를 심볼 수로 나눈 금액만큼만 사용합니다 (`balance / len(symbols)`). 공유 `RiskManager``asyncio.Lock`으로 동시 포지션 등록/해제 시 경합 조건을 방지합니다. **마진 균등 배분:** 멀티심볼 모드에서 각 봇은 전체 잔고를 심볼 수로 나눈 금액만큼만 사용합니다 (`balance / len(symbols)`). 공유 `RiskManager``asyncio.Lock`으로 동시 포지션 등록/해제 시 경합 조건을 방지합니다.
**Graceful Shutdown:** `main.py`에서 `SIGTERM`/`SIGINT` 시그널을 수신하면 `_graceful_shutdown()`이 실행됩니다. 각 봇의 오픈 주문을 심볼별로 취소(5초 타임아웃)한 후 모든 asyncio 태스크를 정리합니다. Docker `docker stop` 또는 `kill` 시 고아 주문이 거래소에 남지 않습니다.
--- ---
### Layer 5: Event / Alert Layer ### Layer 5: Event / Alert Layer
@@ -307,7 +388,7 @@ Binance `ORDER_TRADE_UPDATE` 웹소켓 이벤트를 구독하여 TP/SL 체결을
net_pnl = realized_pnl - commission net_pnl = realized_pnl - commission
``` ```
**Discord 알림 포맷:** **Discord 알림 예시:**
진입 시: 진입 시:
``` ```
@@ -319,7 +400,7 @@ RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
청산 시: 청산 시:
``` ```
[XRPUSDT] LONG TP 청산 [XRPUSDT] LONG TP 청산
청산가: 2.4150 청산가: 2.4150
예상 수익: +7.0000 USDT 예상 수익: +7.0000 USDT
실제 순수익: +6.7800 USDT 실제 순수익: +6.7800 USDT
@@ -328,20 +409,20 @@ RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
--- ---
## 3. MLOps 파이프라인 — 자가 진화 시스템 ## 4. MLOps 파이프라인
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다. 전체 라이프사이클은 다음과 같습니다. 봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다.
### 3.1 전체 라이프사이클 ### 4.1 전체 라이프사이클
```mermaid ```mermaid
flowchart LR flowchart LR
A["주말 수동 트리거<br/>tune_hyperparams.py<br/>(Optuna 50 trials, ~30분)"] A["주말 수동 트리거<br/>tune_hyperparams.py<br/>(Optuna 50 trials)"]
B["결과 검토<br/>tune_results_YYYYMMDD.json<br/>Best AUC vs Baseline 비교"] B["결과 검토<br/>tune_results_YYYYMMDD.json<br/>Best AUC vs Baseline 비교"]
C{"개선폭 충분?<br/>(AUC +0.01 이상<br/>폴드 분산 낮음)"} C{"개선폭 충분?<br/>(AUC +0.01 이상<br/>폴드 분산 낮음)"}
D["active_lgbm_params.json<br/>업데이트<br/>(Active Config 패턴)"] D["active_lgbm_params.json<br/>업데이트<br/>(Active Config 패턴)"]
E["새벽 2시 크론탭<br/>train_and_deploy.sh<br/>(데이터 수집 → 학습 → 배포)"] E["크론탭 또는 수동 실행<br/>train_and_deploy.sh<br/>(데이터 수집 → 학습 → 배포)"]
F["LXC 서버<br/>lgbm_filter.pkl 교체"] F["운영 서버<br/>lgbm_filter.pkl 교체"]
G["봇 핫리로드<br/>다음 캔들 mtime 감지<br/>→ 자동 리로드"] G["봇 핫리로드<br/>다음 캔들 mtime 감지<br/>→ 자동 리로드"]
A --> B A --> B
@@ -354,7 +435,7 @@ flowchart LR
G --> A G --> A
``` ```
### 3.2 단계별 상세 설명 ### 4.2 단계별 상세
#### Step 1: Optuna 하이퍼파라미터 탐색 #### Step 1: Optuna 하이퍼파라미터 탐색
@@ -404,7 +485,7 @@ Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사
> **주의**: Optuna 결과는 과적합 위험이 있습니다. 폴드별 AUC 분산이 크거나 (std > 0.05), 개선폭이 미미하면 (< 0.01) 적용하지 않는 것을 권장합니다. > **주의**: Optuna 결과는 과적합 위험이 있습니다. 폴드별 AUC 분산이 크거나 (std > 0.05), 개선폭이 미미하면 (< 0.01) 적용하지 않는 것을 권장합니다.
#### Step 3: 자동 학습 및 배포 (크론탭) #### Step 3: 자동 학습 및 배포
`scripts/train_and_deploy.sh`는 3단계를 자동으로 실행합니다: `scripts/train_and_deploy.sh`는 3단계를 자동으로 실행합니다:
@@ -421,8 +502,8 @@ Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사
- Walk-Forward 5폴드 검증 후 최종 모델 저장 - Walk-Forward 5폴드 검증 후 최종 모델 저장
- 학습 로그: models/{symbol}/training_log.json - 학습 로그: models/{symbol}/training_log.json
[3/3] LXC 배포 (deploy_model.sh --symbol {SYM}) [3/3] 운영 서버 배포 (deploy_model.sh --symbol {SYM})
- rsync로 models/{symbol}/lgbm_filter.pkl → LXC 서버 전송 - rsync로 models/{symbol}/lgbm_filter.pkl → 운영 서버 전송
- 기존 모델 자동 백업 (lgbm_filter_prev.pkl) - 기존 모델 자동 백업 (lgbm_filter_prev.pkl)
- ONNX 파일 충돌 방지 (우선순위 보장) - ONNX 파일 충돌 방지 (우선순위 보장)
``` ```
@@ -444,14 +525,34 @@ if onnx_changed or lgbm_changed:
매 캔들 마감(15분)마다 모델 파일의 `mtime`을 확인합니다. 변경이 감지되면 즉시 리로드합니다. 매 캔들 마감(15분)마다 모델 파일의 `mtime`을 확인합니다. 변경이 감지되면 즉시 리로드합니다.
### 3.3 레이블 생성 방식 ### 4.3 주간 전략 모니터링
`scripts/weekly_report.py`가 매주 자동으로 전략 성능을 측정하고 Discord로 리포트를 전송합니다.
```
[매주 일요일 크론탭]
[1/7] 데이터 수집 (fetch_history.py × 심볼 수, 최근 35일 Upsert)
[2/7] Walk-Forward 백테스트 (심볼별 → 합산 PF/승률/MDD)
[3/7] 운영 대시보드 API 조회 (GET /api/trades + GET /api/stats → 실전 거래 통계)
[4/7] 추이 분석 (이전 리포트에서 PF/승률/MDD 추이 로드)
[5/7] 킬스위치 모니터링 (심볼별 연속 손실/15거래 PF → 2단계 경고 출력)
[6/7] ML 재학습 체크 (누적 트레이드 ≥ 150, PF < 1.0, PF 3주 하락 → 2/3 충족 시 권장)
[7/7] PF < 1.0이면 파라미터 스윕 실행 → 상위 3개 대안 제시
→ Discord 알림 + results/weekly/report_YYYY-MM-DD.json 저장
```
**전략 파라미터 스윕**: 성능 저하 감지 시 324개 파라미터 조합(SL/TP/ADX/신호임계값/거래량배수)을 자동 탐색하여 현재보다 높은 PF의 대안을 제시합니다. 자동 적용되지 않으며, 사람이 검토 후 승인해야 합니다.
### 4.4 레이블 생성 방식
학습 데이터의 레이블은 **미래 6시간(24캔들) 룩어헤드**로 생성됩니다. 학습 데이터의 레이블은 **미래 6시간(24캔들) 룩어헤드**로 생성됩니다.
``` ```
신호 발생 시점 기준: 신호 발생 시점 기준:
SL = 진입가 - ATR × 1.5 SL = 진입가 - ATR × ATR_SL_MULT (기본 2.0)
TP = 진입가 + ATR × 3.0 TP = 진입가 + ATR × ATR_TP_MULT (기본 2.0)
향후 24캔들 동안: 향후 24캔들 동안:
- 저가가 SL에 먼저 닿으면 → label = 0 (실패) - 저가가 SL에 먼저 닿으면 → label = 0 (실패)
@@ -463,11 +564,11 @@ if onnx_changed or lgbm_changed:
--- ---
## 4. 핵심 동작 시나리오 ## 5. 핵심 동작 시나리오
### 시나리오 1: 15분 캔들 마감 시 봇의 동작 흐름 ### 시나리오 1: 15분 캔들 마감 → 진입 판단
> "XRP 15분봉이 마감되면 봇은 무엇을 하는가?" > "15분봉이 마감되면 봇은 무엇을 하는가?"
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -496,13 +597,13 @@ sequenceDiagram
alt 신호 = LONG 또는 SHORT, 포지션 없음 alt 신호 = LONG 또는 SHORT, 포지션 없음
BOT->>MF: build_features(df, signal, btc_df, eth_df, oi_change, funding_rate) BOT->>MF: build_features(df, signal, btc_df, eth_df, oi_change, funding_rate)
MF-->>BOT: features (23개 피처 Series) MF-->>BOT: features (26개 피처 Series)
BOT->>ML: should_enter(features) BOT->>ML: should_enter(features)
ML-->>BOT: proba=0.73 ≥ 0.60 → True ML-->>BOT: proba=0.73 ≥ 0.55 → True
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)
@@ -520,7 +621,7 @@ sequenceDiagram
--- ---
### 시나리오 2: TP/SL 체결 시 봇의 동작 흐름 ### 시나리오 2: TP/SL 체결 → 포지션 종료
> "거래소에서 TP가 작동하면 봇은 어떻게 반응하는가?" > "거래소에서 TP가 작동하면 봇은 어떻게 반응하는가?"
@@ -550,6 +651,9 @@ sequenceDiagram
BOT->>NT: notify_close(TP, exit=2.4150, est=+7.00, net=+6.78, diff=-0.22) BOT->>NT: notify_close(TP, exit=2.4150, est=+7.00, net=+6.78, diff=-0.22)
NT->>NT: Discord 웹훅 전송 NT->>NT: Discord 웹훅 전송
BOT->>BOT: _append_trade(net_pnl, "TP") [JSONL 파일에 기록]
BOT->>BOT: _check_kill_switch() [8연패/PF<0.75 검사]
BOT->>BOT: current_trade_side = None BOT->>BOT: current_trade_side = None
BOT->>BOT: _entry_price = None BOT->>BOT: _entry_price = None
BOT->>BOT: _entry_quantity = None BOT->>BOT: _entry_quantity = None
@@ -558,112 +662,321 @@ sequenceDiagram
**핵심 포인트:** **핵심 포인트:**
- User Data Stream은 `asyncio.gather()`로 캔들 스트림과 **병렬** 실행 - User Data Stream은 `asyncio.gather()`로 캔들 스트림과 **병렬** 실행
- 체결 즉시 감지 (최대 15분 지연이었던 폴링 방식 대비 실시간) - 체결 즉시 감지 (폴링 방식의 최대 15분 지연 해소)
- `realized_pnl - commission` = 정확한 순수익 (슬리피지·수수료 포함) - `realized_pnl - commission` = 정확한 순수익 (슬리피지·수수료 포함)
- `_is_reentering` 플래그: 반대 시그널 재진입 중에는 콜백이 신규 포지션 상태를 초기화하지 않음 - `_is_reentering` 플래그: 반대 시그널 재진입 중에는 콜백이 신규 포지션 상태를 초기화하지 않음
- `_close_lock`: 콜백(`_on_position_closed`)과 포지션 모니터(`_position_monitor` SYNC 경로) 간 PnL 이중기록 방지. asyncio await 포인트 사이 경쟁 조건을 Lock으로 원자화
--- ---
## 5. 테스트 커버리지 ## 5-1. MTF Pullback Bot
### 5.1 테스트 파일 구성 기존 메인 봇(`bot.py`)과 **별도로** 운영되는 멀티타임프레임 풀백 전략 봇입니다. 4월 OOS(Out-of-Sample) 검증 기간 동안 Dry-run 모드로 실행됩니다.
`tests/` 폴더에 14개 테스트 파일, 총 **99개의 테스트 케이스**가 작성되어 있습니다. **파일:** `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.1 테스트 실행
```bash ```bash
pytest tests/ -v # 전체 실행 pytest tests/ -v # 전체 실행
bash scripts/run_tests.sh # 래퍼 스크립트 실행 bash scripts/run_tests.sh # 래퍼 스크립트 실행
``` ```
### 5.2 모듈별 테스트 현황 `tests/` 폴더에 19개 테스트 파일, 총 **191개의 테스트 케이스**가 작성되어 있습니다.
| 테스트 파일 | 대상 모듈 | 테스트 케이스 | 주요 검증 항목 | ### 6.2 모듈 테스트 현황
|------------|----------|:------------:|--------------|
| `test_bot.py` | `src/bot.py` | 11 | 반대 시그널 재진입 흐름, ML 차단 시 재진입 스킵, OI/펀딩비 피처 전달, OI 변화율 계산 | | 테스트 파일 | 대상 모듈 | 케이스 | 주요 검증 항목 |
| `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위(0~100), MACD 컬럼 존재, 볼린저 밴드 상하단 대소관계, 신호 반환값 유효성, ADX 컬럼 존재, ADX<25 횡보장 차단, ADX NaN 폴스루 | |------------|----------|:------:|--------------|
| `test_ml_features.py` | `src/ml_features.py` | 11 | 23개 피처 수, BTC/ETH 포함 시 피처 수, RS 분모 0 처리, NaN 없음, side 인코딩, OI/펀딩비 파라미터 반영 | | `test_bot.py` | `src/bot.py` | 18 | 반대 시그널 재진입, ML 차단 시 스킵, OI/펀딩비 피처 전달 |
| `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백 허용, 임계값 이상/미만 판단, 핫리로드 후 상태 변화 | | `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위, MACD 컬럼, 볼린저 대소관계, ADX 횡보장 차단 |
| `test_risk_manager.py` | `src/risk_manager.py` | 13 | 일일 손실 한도 초과 차단, 최대 포지션 수 제한, 동일 방향 제한, 심볼 중복 차단, 비동기 포지션 등록/해제, 동적 증거금 비율 상한/하한 클램핑 | | `test_ml_features.py` | `src/ml_features.py` | 14 | 26개 피처 수, RS 분모 0 처리, NaN 없음 |
| `test_exchange.py` | `src/exchange.py` | 8 | 수량 계산(기본/최소명목금액/잔고0), OI·펀딩비 조회 정상/오류 시 반환값 | | `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백, 임계값 판단, 핫리로드 |
| `test_data_stream.py` | `src/data_stream.py` | 6 | 3심볼 버퍼 존재, 빈 버퍼 None 반환, 캔들 파싱, 마감 캔들 콜백 호출, 프리로드 200개 | | `test_risk_manager.py` | `src/risk_manager.py` | 15 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 |
| `test_label_builder.py` | `src/label_builder.py` | 4 | LONG TP 도달 → 1, LONG SL 도달 → 0, 미결 → None, SHORT TP 도달 → 1 | | `test_exchange.py` | `src/exchange.py` | 12 | 수량 계산, OI·펀딩비 조회 정상/오류 |
| `test_dataset_builder.py` | `src/dataset_builder.py` | 9 | DataFrame 반환, 필수 컬럼 존재, 레이블 이진값, BTC/ETH 포함 시 23개 피처, inf/NaN 없음, OI nan 마스킹, RS 분모 0 처리 | | `test_data_stream.py` | `src/data_stream.py` | 7 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 |
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 디바이스 확인, 학습 전 예측 형태, 학습 후 유효 확률, NaN 피처 처리, 저장/로드 후 동일 예측 | | `test_label_builder.py` | `src/label_builder.py` | 4 | TP/SL 도달 레이블, 미결 → None |
| `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 구간 Upsert, 신규 행 추가, 기존 비0값 보존, 파일 없을 때 신규 반환, 타임스탬프 오름차순 정렬 | | `test_dataset_builder.py` | `src/dataset_builder.py` | 14 | DataFrame 반환, 필수 컬럼, inf/NaN 없음 |
| `test_config.py` | `src/config.py` | 6 | 환경변수 로드, 동적 증거금 파라미터 로드, `symbols` 리스트, `correlation_symbols`, `max_same_direction`, SYMBOL→symbols 폴백 | | `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_config.py` | `src/config.py` | 9 | 환경변수 로드, symbols 리스트 파싱 |
| `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` 패키지)이 없는 환경에서 자동 스킵됩니다.
### 5.3 커버리지 매트릭스 ### 6.3 커버리지 매트릭스
아래는 핵심 비즈니스 로직의 테스트 커버 여부입니다. | 기능 | 단위 | 통합 | 비고 |
|------|:----:|:----:|------|
| 기능 | 단위 테스트 | 통합 수준 테스트 | 비고 | | 기술 지표 계산 | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` |
|------|:----------:|:--------------:|------|
| 기술 지표 계산 (RSI/MACD/BB/EMA/StochRSI/ADX) | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` |
| 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` | | 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` |
| ADX 횡보장 필터 (ADX < 25 차단) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` (`_calc_signals` 실제 호출) | | ADX 횡보장 필터 | ✅ | ✅ | `test_indicators` |
| ML 피처 추출 (23개) | ✅ | ✅ | `test_ml_features` + `test_dataset_builder` (`_calc_features_vectorized` 실제 호출) | | ML 피처 추출 (26개) | ✅ | ✅ | `test_ml_features` + `test_dataset_builder` |
| ML 필터 추론 (임계값 판단) | ✅ | — | `test_ml_filter` | | ML 필터 추론 | ✅ | — | `test_ml_filter` |
| MLX 신경망 학습/저장/로드 | ✅ | — | `test_mlx_filter` (Apple Silicon 전용) | | MLX 신경망 학습/저장/로드 | ✅ | — | `test_mlx_filter` (Apple Silicon 전용) |
| 레이블 생성 (SL/TP 룩어헤드) | ✅ | ✅ | `test_label_builder` + `test_dataset_builder` (전체 파이프라인 실제 호출) | | 레이블 생성 (SL/TP 룩어헤드) | ✅ | ✅ | `test_label_builder` + `test_dataset_builder` |
| 벡터화 데이터셋 빌더 | ✅ | ✅ | `test_dataset_builder` | | 벡터화 데이터셋 빌더 | ✅ | ✅ | `test_dataset_builder` |
| 동적 증거금 비율 계산 | ✅ | — | `test_risk_manager` | | 동적 증거금 비율 | ✅ | — | `test_risk_manager` |
| 동일 방향 포지션 제한 | ✅ | — | `test_risk_manager` | | 동일 방향 포지션 제한 | ✅ | — | `test_risk_manager` |
| 심볼 중복 진입 차단 | ✅ | — | `test_risk_manager` | | 일일 손실 한도 | ✅ | — | `test_risk_manager` |
| 일일 손실 한도 제어 | ✅ | — | `test_risk_manager` |
| 포지션 수량 계산 | ✅ | — | `test_exchange` | | 포지션 수량 계산 | ✅ | — | `test_exchange` |
| OI/펀딩비 API 조회 (정상/오류) | ✅ | ✅ | `test_exchange` + `test_bot` (`process_candle` → OI/펀딩비 → `build_features` 전달) | | OI/펀딩비 API 조회 | ✅ | ✅ | `test_exchange` + `test_bot` |
| 반대 시그널 재진입 흐름 | ✅ | ✅ | `test_bot` | | 반대 시그널 재진입 | ✅ | ✅ | `test_bot` |
| ML 차단 시 재진입 스킵 | ✅ | ✅ | `test_bot` (`_close_and_reenter` → ML 판단 → 스킵 전체 흐름) | | OI 변화율 계산 | ✅ | ✅ | `test_bot` |
| OI 변화율 계산 (API 실패 폴백) | ✅ | | `test_bot` (`process_candle` → OI 조회 → `_calc_oi_change` 흐름) | | Parquet Upsert | ✅ | | `test_fetch_history` |
| 캔들 버퍼 관리 및 프리로드 | ✅ | | `test_data_stream` | | 주간 리포트 | ✅ | | `test_weekly_report` |
| Parquet Upsert (OI=0 보충) | ✅ | | `test_fetch_history` | | MTF Pullback Bot | ✅ | | `test_mtf_bot` (20 cases) |
| User Data Stream TP/SL 감지 | ❌ | — | 미작성 (실제 WebSocket 의존) | | User Data Stream TP/SL | ❌ | — | 미작성 (WebSocket 의존) |
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) | | Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
| CI/CD 파이프라인 | ❌ | — | Jenkins 환경 의존 |
### 5.4 테스트 전략 ### 6.4 테스트 전략
**Mock 활용 원칙:** - **Mock 원칙**: Binance API 호출은 모두 `unittest.mock.AsyncMock`으로 대체. 외부 의존성(Discord, WebSocket)은 테스트 대상에서 제외.
- Binance API 호출(`BinanceFuturesClient`, `AsyncClient`)은 모두 `unittest.mock.AsyncMock`으로 대체합니다. - **비동기 테스트**: `pytest-asyncio` + `@pytest.mark.asyncio`
- 외부 의존성(Discord Webhook, Binance WebSocket)은 테스트 대상에서 제외합니다. - **경계값 중심**: 분모 0 처리, API 실패 폴백, 최소 주문 금액 미달, OI=0 구간 Upsert
- `tmp_path` pytest fixture로 Parquet 파일 I/O를 격리합니다.
**비동기 테스트:**
- `pytest-asyncio`를 사용하며, `@pytest.mark.asyncio` 데코레이터로 `async def` 테스트를 실행합니다.
**경계값 및 엣지 케이스 중심:**
- 분모 0 (RS 계산, bb_range, vol_ma20)
- API 실패 시 `None` 반환 및 `0.0` 폴백
- 최소 명목금액 미달 시 주문 스킵
- OI=0 구간 Parquet Upsert 보존/덮어쓰기 조건
--- ---
## 부록: 파일별 역할 요약 ## 7. 파일 구조
| 파일 | 레이어 | 역할 | | 파일 | 레이어 | 역할 |
|------|--------|------| |------|--------|------|
| `main.py` | — | 진입점. 심볼별 `TradingBot` 생성 + 공유 `RiskManager` + `asyncio.gather()` | | `main.py` | — | 진입점. 심볼별 `TradingBot` 생성 + 공유 `RiskManager` + `asyncio.gather()` + SIGTERM/SIGINT graceful shutdown |
| `src/bot.py` | 오케스트레이터 | 심볼별 독립 트레이딩 루프 (symbol, risk 주입) | | `src/bot.py` | 오케스트레이터 | 심볼별 독립 트레이딩 루프 + 듀얼 레이어 킬스위치 |
| `src/config.py` | — | 환경변수 기반 설정 (`symbols` 리스트, `correlation_symbols`) | | `src/config.py` | — | 환경변수 기반 설정 (`symbols` 리스트, `correlation_symbols`, 심볼별 `SymbolStrategyParams`) |
| `src/data_stream.py` | Data | Combined WebSocket 캔들 수신·버퍼 관리 | | `src/data_stream.py` | Data | Combined WebSocket 캔들 수신·버퍼 관리 |
| `src/indicators.py` | Signal | 기술 지표 계산 및 복합 신호 생성 | | `src/indicators.py` | Signal | 기술 지표 계산 및 복합 신호 생성 |
| `src/ml_features.py` | ML Filter | 23개 ML 피처 추출 | | `src/ml_features.py` | ML Filter | 26개 ML 피처 추출 |
| `src/ml_filter.py` | ML Filter | ONNX/LightGBM 모델 로드·추론·핫리로드 | | `src/ml_filter.py` | ML Filter | ONNX/LightGBM 모델 로드·추론·핫리로드 |
| `src/mlx_filter.py` | ML Filter | Apple Silicon GPU 학습 + ONNX export | | `src/mlx_filter.py` | ML Filter | Apple Silicon GPU 학습 + ONNX export |
| `src/exchange.py` | Execution | Binance Futures REST API 클라이언트 (심볼별 독립) | | `src/exchange.py` | Execution | Binance Futures REST API 클라이언트 |
| `src/risk_manager.py` | Risk | 공유 싱글턴 — 일일 손실 한도·동일 방향 제한·동적 증거금 비율 | | `src/risk_manager.py` | Risk | 공유 싱글턴 — 일일 손실 한도·동일 방향 제한·동적 증거금 비율 |
| `src/user_data_stream.py` | Event | User Data Stream TP/SL 즉시 감지 | | `src/user_data_stream.py` | Event | User Data Stream TP/SL 즉시 감지 |
| `src/notifier.py` | Alert | Discord 웹훅 알림 | | `src/notifier.py` | Alert | Discord 웹훅 알림 |
| `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/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/펀딩비 수집 (`--symbol` 지원) | | `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 |
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 (`--symbol` 지원) | | `scripts/train_model.py` | MLOps | LightGBM 모델 학습 |
| `scripts/train_mlx_model.py` | MLOps | MLX 신경망 학습 (Apple Silicon GPU) | | `scripts/train_mlx_model.py` | MLOps | MLX 신경망 학습 (Apple Silicon GPU) |
| `scripts/tune_hyperparams.py` | MLOps | Optuna 하이퍼파라미터 탐색 (`--symbol` 지원) | | `scripts/tune_hyperparams.py` | MLOps | Optuna 하이퍼파라미터 탐색 |
| `scripts/train_and_deploy.sh` | MLOps | 전체 파이프라인 (`--symbol` / `--all` 지원) | | `scripts/train_and_deploy.sh` | MLOps | 전체 파이프라인 (수집 → 학습 → 배포) |
| `scripts/deploy_model.sh` | MLOps | 모델 파일 LXC 서버 전송 (`--symbol` 지원) | | `scripts/deploy_model.sh` | MLOps | 모델 파일 운영 서버 전송 |
| `models/{symbol}/active_lgbm_params.json` | MLOps | 심볼별 승인된 LightGBM 파라미터 (Active Config) | | `scripts/strategy_sweep.py` | MLOps | 전략 파라미터 그리드 스윕 (324개 조합) |
| `scripts/weekly_report.py` | MLOps | 주간 전략 리포트 (백테스트+킬스위치+대시보드API+추이+스윕+Discord) |
| `scripts/compare_symbols.py` | MLOps | 종목 비교 백테스트 (심볼별 파라미터 sweep) |
| `scripts/position_sizing_analysis.py` | MLOps | Robust Monte Carlo 포지션 사이징 분석 |
| `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 파라미터 |

View File

@@ -36,6 +36,12 @@ bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
# Hyperparameter tuning (50 trials, 5-fold walk-forward) # Hyperparameter tuning (50 trials, 5-fold walk-forward)
python scripts/tune_hyperparams.py --symbol XRPUSDT python scripts/tune_hyperparams.py --symbol XRPUSDT
# Weekly strategy report (manual, skip data fetch)
python scripts/weekly_report.py --skip-fetch
# Weekly report with data refresh
python scripts/weekly_report.py
# Fetch historical data (single symbol with auto correlation) # Fetch historical data (single symbol with auto correlation)
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365 python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
@@ -59,6 +65,8 @@ bash scripts/deploy_model.sh --symbol XRPUSDT
4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%), same-direction limit 4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%), same-direction limit
5. `src/user_data_stream.py` + `src/notifier.py` — Real-time TP/SL detection via WebSocket, Discord webhooks 5. `src/user_data_stream.py` + `src/notifier.py` — Real-time TP/SL detection via WebSocket, Discord webhooks
**Dual-layer kill switch** (per-symbol, in `src/bot.py`): Fast Kill (8 consecutive net losses) + Slow Kill (last 15 trades PF < 0.75). Trade history persisted to `data/trade_history/{symbol}.jsonl`. Blocks new entries only; existing SL/TP exits work normally. Manual reset via `RESET_KILL_SWITCH_{SYMBOL}=True` env var + restart.
**Parallel execution**: Per-symbol bots run independently via `asyncio.gather()`. Each bot's `user_data_stream` also runs in parallel. **Parallel execution**: Per-symbol bots run independently via `asyncio.gather()`. Each bot's `user_data_stream` also runs in parallel.
**Model/data directories**: `models/{symbol}/` and `data/{symbol}/` for per-symbol models. Falls back to `models/` root if symbol dir doesn't exist. **Model/data directories**: `models/{symbol}/` and `data/{symbol}/` for per-symbol models. Falls back to `models/` root if symbol dir doesn't exist.
@@ -82,9 +90,9 @@ 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. `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)`.
## Deployment ## Deployment
@@ -131,3 +139,19 @@ All design documents and implementation plans are stored in `docs/plans/` with t
| 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed | | 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed |
| 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed | | 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed |
| 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed | | 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed |
| 2026-03-07 | `weekly-report` (plan) | Completed |
| 2026-03-07 | `code-review-improvements` | Partial (#1,#2,#4,#5,#6,#8 완료) |
| 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed |
| 2026-03-21 | `dashboard-code-review-r2` (#14,#19) | 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}
""" """
} }

660
README.md
View File

@@ -1,36 +1,267 @@
# CoinTrader # CoinTrader
Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 다중 심볼(XRP, TRX, DOGE 등) 선물 포지션을 동시에 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다. Binance Futures 자동매매 봇. 복합 기술 지표와 킬스위치로 XRPUSDT 선물 포지션을 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다. 멀티심볼 아키텍처를 지원하지만, 현재 XRP만 운영 중입니다.
> **아키텍처 문서**: 코드 구조, 레이어별 역할, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요. > **심볼 운영 이력**: SOL, DOGE, TRX는 파라미터 스윕에서 모든 ADX 수준에서 PF < 1.0으로, 현재 전략으로는 수익을 낼 수 없어 제외되었습니다 (2026-03-21). ML 필터도 기술 지표 기반 피처의 예측력 한계로 비활성화 상태 (`NO_ML_FILTER=true`).
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
--- ---
## 주요 기능 ## 주요 기능
- **복합 기술 지표 신호**: RSI, MACD 크로스, 볼린저 밴드, EMA 정/역배열, Stochastic RSI, 거래량 급증 — 가중치 합계 ≥ 3 시 진입 - **멀티심볼 동시 거래**: 심볼별 독립 봇 인스턴스를 병렬 실행, 공유 RiskManager로 글로벌 리스크 관리
- **ML 필터 (ONNX 우선 / LightGBM 폴백)**: 기술 지표 신호를 한 번 더 검증하여 오진입 차단. 우선순위: ONNX > LightGBM > 폴백(항상 허용) - **복합 기술 지표 신호**: RSI, MACD, 볼린저 밴드, EMA, Stochastic RSI, ADX, 거래량 급증 — 가중치 합산 시스템
- **모델 핫리로드**: 캔들마다 모델 파일 mtime을 감지해 변경 시 자동 리로드 (봇 재시작 불필요) - **ML 필터 (선택)**: LightGBM / ONNX 모델로 오진입 차단 (비활성화 가능)
- **멀티심볼 스트림**: XRP/BTC/ETH 3개 심볼을 단일 Combined WebSocket으로 수신, BTC·ETH 상관관계 피처 활용 - **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산, 환경변수로 배수 조절
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (캔들 마감 시 실시간 조회, 실패 시 0으로 폴백) - **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 재진입
- **점진적 OI 데이터 축적 (Upsert)**: 바이낸스 OI 히스토리 API는 최근 30일치만 제공. `fetch_history.py` 실행 시 기존 parquet의 `oi_change/funding_rate=0` 구간을 신규 값으로 채워 학습 데이터 품질을 점진적으로 개선 - **리스크 관리**: 동일 방향 포지션 제한, 일일 손실 한도(5%), 동적 증거금 비율
- **실시간 OI/펀딩비 조회**: 캔들 마감마다 `get_open_interest()` / `get_funding_rate()`를 비동기 병렬 조회하여 ML 피처에 전달. 이전 캔들 대비 OI 변화율로 변환하여 train-serve skew 해소 - **듀얼 레이어 킬스위치**: Fast Kill(8연속 순손실) + Slow Kill(15거래 PF<0.75) — 심볼별 독립 차단, 기존 포지션 청산은 정상 작동
- **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산 (1.5× / 3.0× ATR) - **SL/TP 원자성 보장**: SL/TP 배치 3회 재시도 + 최종 실패 시 긴급 시장가 청산
- **Algo Order API 지원**: 계정 설정에 따라 STOP_MARKET/TAKE_PROFIT_MARKET 주문을 `/fapi/v1/algoOrder` 엔드포인트로 자동 전송 (오류 코드 -4120 대응) - **실시간 TP/SL 감지**: Binance User Data Stream으로 즉시 감지
- **동적 증거금 비율**: 잔고 증가에 따라 선형 감소 (최대 50% → 최소 20%) - **Graceful Shutdown**: SIGTERM/SIGINT 시 심볼별 오픈 주문 취소 후 정상 종료
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 ML 필터 통과 시 반대 방향 재진입 - **Discord 알림**: 진입·청산·킬스위치 발동·긴급 청산·오류 이벤트 실시간 웹훅 알림
- **멀티심볼 동시 거래**: 심볼별 독립 봇 인스턴스를 `asyncio.gather()`로 병렬 실행. 공유 RiskManager로 글로벌 리스크 관리 - **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 동일 방향 포지션 제한(기본 2개), 일일 손실 한도(5%) 제어 - **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원 - **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음) - **MTF Pullback Bot**: 1h MetaFilter(EMA50/200 + ADX) + 15m 3캔들 풀백 시퀀스 기반 Dry-run 봇 (OOS 검증용)
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결. `stream.recv()` 기반으로 수신하며, 라이브러리 내부 에러 페이로드(`{"e":"error"}`) 감지 시 즉시 재연결하여 좀비 커넥션 방지
- **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림
- **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포 (LXC 운영 서버 자동 적용)
--- ---
# 봇 사용 가이드
봇을 설치하고 운영하려는 사용자를 위한 섹션입니다.
## 요구사항
- Python 3.11+ (또는 Docker)
- Binance Futures 계정 + API 키
- (선택) Discord 웹훅 URL
## 빠른 시작
### 1. 환경변수 설정
```bash
git clone <repository-url>
cd cointrader
cp .env.example .env
```
`.env` 파일을 열어 아래 필수 값을 채웁니다.
```env
# 필수
BINANCE_API_KEY=your_api_key
BINANCE_API_SECRET=your_api_secret
SYMBOLS=XRPUSDT # 거래할 심볼 (쉼표 구분, 멀티심볼 지원)
# 권장
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
LEVERAGE=10
```
> 처음 사용 시 Binance Testnet에서 먼저 테스트하는 것을 권장합니다. `BINANCE_TESTNET_API_KEY`와 `BINANCE_TESTNET_API_SECRET`을 설정하세요.
### 2-A. Docker로 실행 (권장)
```bash
docker compose up -d
```
로그 확인:
```bash
docker compose logs -f cointrader
```
### 2-B. 로컬 실행
```bash
pip install -r requirements.txt
python main.py
```
### 3. 정상 동작 확인
봇이 정상 실행되면 다음과 같은 로그가 출력됩니다:
```
INFO | 기준 잔고 설정: 1000.00 USDT
INFO | [XRPUSDT] 봇 시작, 레버리지 10x | SL=2.0x TP=2.0x Signal≥3 ADX≥25.0 Vol≥2.5x
INFO | [XRPUSDT] 기존 포지션 없음 - 신규 진입 대기
INFO | [XRPUSDT] OI 히스토리 초기화: 5개
INFO | Kline WebSocket 연결 완료
```
Discord 웹훅을 설정했다면 진입/청산 시 실시간 알림을 받게 됩니다.
---
## 매매 전략
### 기술 지표 신호 (15분봉)
| 지표 | 롱 조건 | 숏 조건 | 가중치 |
|------|---------|---------|--------|
| RSI (14) | < 35 | > 65 | 1 |
| MACD 크로스 | 골든크로스 | 데드크로스 | 2 |
| 볼린저 밴드 | 하단 이탈 | 상단 돌파 | 1 |
| EMA 정배열 (9/21/50) | 정배열 | 역배열 | 1 |
| Stochastic RSI | < 20 + K>D | > 80 + K<D | 1 |
| 거래량 | 20MA × `VOL_MULTIPLIER` 이상 시 신호 강화 | — | 보조 |
**진입 조건**: 가중치 합계 ≥ `SIGNAL_THRESHOLD` + (거래량 급증 또는 가중치 합계 ≥ `SIGNAL_THRESHOLD` + 1)
**ADX 필터**: ADX < `ADX_THRESHOLD` 시 횡보장으로 판단, 진입 차단
**손절/익절**: ATR × `ATR_SL_MULT` / ATR × `ATR_TP_MULT`
### 전략 파라미터 조절
환경변수로 전략 파라미터를 조절할 수 있습니다. 기본값은 Walk-Forward 백테스트 스윕 결과에서 선정된 값입니다.
**전역 기본값** (심볼별 오버라이드 없을 때 적용):
| 환경변수 | 기본값 | 설명 |
|---------|--------|------|
| `ATR_SL_MULT` | `2.0` | 손절 ATR 배수 |
| `ATR_TP_MULT` | `2.0` | 익절 ATR 배수 |
| `SIGNAL_THRESHOLD` | `3` | 진입을 위한 최소 가중치 점수 |
| `ADX_THRESHOLD` | `25` | ADX 횡보장 필터 (0=비활성) |
| `VOL_MULTIPLIER` | `2.5` | 거래량 급증 감지 배수 |
**심볼별 오버라이드**: `{환경변수}_{심볼}` 형태로 심볼마다 독립 설정 가능. 미설정 시 전역 기본값 사용.
```env
# 현재 운영 설정 (2026-03-21)
ATR_SL_MULT_XRPUSDT=1.5
ATR_TP_MULT_XRPUSDT=4.0
ADX_THRESHOLD_XRPUSDT=25
```
> **제외된 심볼**: SOLUSDT(PF 0.00~0.83), DOGEUSDT(PF 0.70~0.83), TRXUSDT(PF 0.08) — 모든 파라미터 조합에서 PF < 1.0.
### ML 필터
ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차단합니다. 기본적으로 **비활성화** 상태입니다.
- `NO_ML_FILTER=true` (기본값) — ML 없이 기술 지표만으로 운영
- `NO_ML_FILTER=false` — 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만으로는 수수료를 이기는 알파를 만들 수 없었습니다. 오더북/청산 데이터 등 새로운 피처 소스에서 알파가 확인되면 재활성화 예정.
---
## 리스크 관리
| 설정 | 기본값 | 설명 |
|------|--------|------|
| `LEVERAGE` | `10` | 레버리지 배수 |
| `MAX_SAME_DIRECTION` | `2` | 동일 방향 최대 포지션 수 |
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비) |
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비) |
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
- **일일 손실 한도**: 기준 잔고의 5% 초과 시 당일 거래 중단 (단일 충격 방어)
- **듀얼 레이어 킬스위치**: 구조적 엣지 소실에 의한 점진적 계좌 우하향(Slow Bleed) 방어
- **동적 증거금**: 잔고가 늘어날수록 비율을 선형으로 줄여 과노출 방지
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
### 킬스위치
일일 손실 한도는 단일 충격 방어용이지, 누적 승률 하락 방어용이 아닙니다. 매일 한도 근처까지 손실을 내고 멈추는 패턴이 반복되면 한 달 뒤 계좌의 30~40%가 조용히 증발합니다. 킬스위치는 이 Slow Bleed를 자동으로 차단합니다.
| 레이어 | 조건 | 방어 대상 |
|--------|------|-----------|
| **Fast Kill** | 8연속 순손실 (net_pnl, 수수료 포함) | 급격한 전략 붕괴 |
| **Slow Kill** | 최근 15거래 Profit Factor < 0.75 | 점진적 엣지 소실 |
**동작 방식:**
- 심볼별 독립 제어: 한 심볼이 킬되어도 다른 심볼은 정상 운영
- 진입만 차단: 기존 포지션의 SL/TP 청산은 정상 작동 (물린 상태 방치 방지)
- 거래 이력 persist: `data/trade_history/{symbol}.jsonl`에 매 청산마다 기록
- 봇 재시작 시 소급 검증: 이력 파일에서 마지막 15건을 읽어 킬스위치 상태 복원
- 수동 해제: `.env``RESET_KILL_SWITCH_{SYMBOL}=True` 추가 후 봇 재시작
**주간 리포트 모니터링:**
```
[킬스위치 모니터링]
XRP: 연속손실 2/8 | 15거래PF 1.42
```
| 환경변수 | 설명 |
|---------|------|
| `RESET_KILL_SWITCH_{SYMBOL}` | `True`로 설정 후 재시작하면 해당 심볼 킬스위치 해제. 해제 후 반드시 제거할 것 |
---
## 대시보드
봇 로그를 실시간으로 파싱하여 거래 내역, 수익 통계, 차트를 웹에서 조회할 수 있습니다.
```bash
docker compose up -d
# 접속: http://<서버IP>:8080
```
| 탭 | 내용 |
|----|------|
| **Overview** | 총 수익, 승률, 거래 수, 최대 수익/손실 KPI + 일별 PnL 차트 + 누적 수익 곡선 |
| **Trades** | 전체 거래 내역 — 진입/청산가, 방향, 레버리지, 기술 지표, SL/TP, 순익 상세 |
| **Chart** | 15분봉 가격 차트 + RSI 지표 + ADX 추세 강도 |
### API 엔드포인트
| 엔드포인트 | 설명 |
|-----------|------|
| `GET /api/position` | 현재 포지션 + 봇 상태 |
| `GET /api/trades` | 청산 거래 내역 (페이지네이션) |
| `GET /api/daily` | 일별 PnL 집계 |
| `GET /api/stats` | 전체 통계 (총 거래, 승률, 수수료 등) |
| `GET /api/candles` | 최근 캔들 + 기술 지표 |
| `GET /api/health` | 헬스 체크 |
---
## 환경변수 전체 레퍼런스
| 변수 | 기본값 | 필수 | 설명 |
|------|--------|:----:|------|
| `BINANCE_API_KEY` | — | ✅ | Binance API 키 |
| `BINANCE_API_SECRET` | — | ✅ | Binance API 시크릿 |
| `SYMBOLS` | `XRPUSDT` | | 거래 심볼 목록 (쉼표 구분) |
| `CORRELATION_SYMBOLS` | `BTCUSDT,ETHUSDT` | | 상관관계 심볼 (BTC/ETH 피처용) |
| `LEVERAGE` | `10` | | 레버리지 배수 |
| `MAX_SAME_DIRECTION` | `2` | | 동일 방향 최대 포지션 수 |
| `DISCORD_WEBHOOK_URL` | — | | Discord 웹훅 URL |
| `MARGIN_MAX_RATIO` | `0.50` | | 최대 증거금 비율 |
| `MARGIN_MIN_RATIO` | `0.20` | | 최소 증거금 비율 |
| `MARGIN_DECAY_RATE` | `0.0006` | | 잔고 증가 시 감소 속도 |
| `NO_ML_FILTER` | `true` | | ML 필터 비활성화 |
| `ML_THRESHOLD` | `0.55` | | ML 예측 확률 임계값 |
| `ATR_SL_MULT` | `2.0` | | 손절 ATR 배수 (전역 기본값) |
| `ATR_TP_MULT` | `2.0` | | 익절 ATR 배수 (전역 기본값) |
| `SIGNAL_THRESHOLD` | `3` | | 최소 가중치 점수 (전역 기본값) |
| `ADX_THRESHOLD` | `25` | | ADX 횡보장 필터 (전역 기본값, 0=비활성) |
| `VOL_MULTIPLIER` | `2.5` | | 거래량 급증 배수 (전역 기본값) |
| `ATR_SL_MULT_{SYMBOL}` | — | | 심볼별 손절 ATR 배수 오버라이드 |
| `ATR_TP_MULT_{SYMBOL}` | — | | 심볼별 익절 ATR 배수 오버라이드 |
| `SIGNAL_THRESHOLD_{SYMBOL}` | — | | 심볼별 최소 가중치 점수 오버라이드 |
| `ADX_THRESHOLD_{SYMBOL}` | — | | 심볼별 ADX 필터 오버라이드 |
| `VOL_MULTIPLIER_{SYMBOL}` | — | | 심볼별 거래량 배수 오버라이드 |
| `DASHBOARD_API_URL` | `http://10.1.10.24:8000` | | 대시보드 API 주소 (주간 리포트용) |
| `MARGIN_MAX_RATIO_{SYMBOL}` | — | | 심볼별 최대 증거금 비율 오버라이드 |
| `RESET_KILL_SWITCH_{SYMBOL}` | — | | `True`로 설정 후 재시작하면 킬스위치 해제 (해제 후 반드시 제거) |
| `BINANCE_TESTNET_API_KEY` | — | | Testnet API 키 |
| `BINANCE_TESTNET_API_SECRET` | — | | Testnet API 시크릿 |
---
# 개발 가이드
코드를 수정하거나 기능을 추가하려는 개발자를 위한 섹션입니다.
> **아키텍처 문서**: 5-레이어 구조, 데이터 흐름, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요.
## 프로젝트 구조 ## 프로젝트 구조
``` ```
@@ -47,6 +278,8 @@ cointrader/
│ ├── mlx_filter.py # MLX 신경망 필터 (Apple Silicon GPU 학습 + ONNX export) │ ├── mlx_filter.py # MLX 신경망 필터 (Apple Silicon GPU 학습 + ONNX export)
│ ├── label_builder.py # 학습 레이블 생성 │ ├── label_builder.py # 학습 레이블 생성
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용) │ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
│ ├── 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 로거 설정
@@ -56,246 +289,48 @@ cointrader/
│ ├── train_mlx_model.py # MLX 신경망 학습 (Apple Silicon GPU) │ ├── train_mlx_model.py # MLX 신경망 학습 (Apple Silicon GPU)
│ ├── train_and_deploy.sh # 전체 파이프라인 (--symbol / --all 지원) │ ├── train_and_deploy.sh # 전체 파이프라인 (--symbol / --all 지원)
│ ├── tune_hyperparams.py # Optuna 하이퍼파라미터 자동 탐색 (--symbol 지원) │ ├── tune_hyperparams.py # Optuna 하이퍼파라미터 자동 탐색 (--symbol 지원)
│ ├── strategy_sweep.py # 전략 파라미터 그리드 스윕 (324개 조합)
│ ├── compare_symbols.py # 종목 비교 백테스트 (심볼별 파라미터 sweep)
│ ├── position_sizing_analysis.py # Robust Monte Carlo 포지션 사이징 분석
│ ├── weekly_report.py # 주간 전략 리포트 (백테스트+킬스위치+대시보드API+추이+Discord)
│ ├── run_backtest.py # 단일 백테스트 CLI
│ ├── deploy_model.sh # 모델 파일 LXC 서버 전송 (--symbol 지원) │ ├── deploy_model.sh # 모델 파일 LXC 서버 전송 (--symbol 지원)
│ └── run_tests.sh # 전체 테스트 실행 │ └── run_tests.sh # 전체 테스트 실행
├── dashboard/ ├── dashboard/
│ ├── api/ # FastAPI 백엔드 (로그 파서 + REST API) │ ├── api/ # FastAPI 백엔드 (로그 파서 + REST API)
│ └── ui/ # React 프론트엔드 (Vite + Recharts) │ └── ui/ # React 프론트엔드 (Vite + Recharts)
├── models/ # 학습된 모델 저장 (심볼별 하위 디렉토리) ├── models/ # 학습된 모델 저장 (심볼별 하위 디렉토리)
│ ├── xrpusdt/ # models/xrpusdt/lgbm_filter.pkl
│ ├── trxusdt/ # models/trxusdt/lgbm_filter.pkl
│ └── dogeusdt/ # models/dogeusdt/lgbm_filter.pkl
├── data/ # 과거 데이터 캐시 (심볼별 하위 디렉토리) ├── data/ # 과거 데이터 캐시 (심볼별 하위 디렉토리)
── xrpusdt/ # data/xrpusdt/combined_15m.parquet ── trade_history/ # 킬스위치용 실전 거래 이력 (심볼별 JSONL)
│ ├── trxusdt/ # data/trxusdt/combined_15m.parquet ├── results/
│ └── dogeusdt/ # data/dogeusdt/combined_15m.parquet │ └── weekly/ # 주간 리포트 JSON 저장
├── logs/ # 로그 파일 ├── logs/ # 로그 파일
├── docs/plans/ # 설계 문서 및 구현 플랜 ├── docs/plans/ # 설계 문서 및 구현 플랜
├── tests/ # 테스트 코드 ├── tests/ # 테스트 코드 (15파일, 138개 케이스)
├── Dockerfile ├── Dockerfile
├── docker-compose.yml ├── docker-compose.yml
├── Jenkinsfile ├── Jenkinsfile
└── requirements.txt └── requirements.txt
``` ```
--- ## 개발 환경 설정
## 빠른 시작
### 1. 환경변수 설정
```bash ```bash
# 가상환경 생성 및 활성화
python -m venv .venv
source .venv/bin/activate
# 의존성 설치
pip install -r requirements.txt
# 환경변수 설정
cp .env.example .env cp .env.example .env
``` ```
`.env` 파일을 열어 아래 값을 채웁니다.
```env
BINANCE_API_KEY=your_api_key
BINANCE_API_SECRET=your_api_secret
SYMBOLS=XRPUSDT,TRXUSDT,DOGEUSDT
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
LEVERAGE=10
MAX_SAME_DIRECTION=2
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
```
### 2. 로컬 실행
```bash
pip install -r requirements.txt
python main.py
```
### 3. Docker Compose로 실행
```bash
docker compose up -d
```
로그 확인:
```bash
docker compose logs -f cointrader
```
---
## ML 모델 학습
봇은 모델 파일이 없으면 ML 필터 없이 동작합니다. 최초 실행 전 또는 수동 재학습 시 아래 순서로 진행합니다.
### 전체 파이프라인 (권장)
맥미니에서 데이터 수집 → 학습 → LXC 배포까지 한 번에 실행합니다.
> **자동 분기**: `data/{symbol}/combined_15m.parquet`가 없으면 1년치(365일) 전체 수집, 있으면 35일치 Upsert로 자동 전환합니다. 서버 이전이나 데이터 유실 시에도 사람의 개입 없이 자동 복구됩니다.
```bash
# 전체 심볼 학습 + 배포 (SYMBOLS 환경변수의 모든 심볼)
bash scripts/train_and_deploy.sh
# 단일 심볼만 학습 + 배포
bash scripts/train_and_deploy.sh --symbol TRXUSDT
# MLX GPU 학습 (단일 심볼)
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
# LightGBM + Walk-Forward 3폴드
bash scripts/train_and_deploy.sh lgbm 3
# 학습만 (배포 없이)
bash scripts/train_and_deploy.sh lgbm 0
```
### 단계별 수동 실행
```bash
# 1. 과거 데이터 수집 (단일 심볼 — 상관관계 심볼 자동 추가)
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
# → data/trxusdt/combined_15m.parquet 에 저장
# 1-alt. 명시적 심볼 지정 (기존 방식도 지원)
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 15m \
--days 365 \
--output data/combined_15m.parquet
# 2-A. LightGBM 모델 학습 (심볼별)
python scripts/train_model.py --symbol TRXUSDT
# → models/trxusdt/lgbm_filter.pkl 에 저장
# 2-B. MLX 신경망 학습 (Apple Silicon GPU)
python scripts/train_mlx_model.py --data data/xrpusdt/combined_15m.parquet
# 3. LXC 서버에 모델 배포
bash scripts/deploy_model.sh --symbol XRPUSDT
bash scripts/deploy_model.sh mlx --symbol XRPUSDT
```
학습된 모델은 `models/{symbol}/lgbm_filter.pkl` (LightGBM) 또는 `models/{symbol}/mlx_filter.weights.onnx` (MLX) 에 저장됩니다. 심볼별 디렉토리가 없으면 `models/` 루트로 폴백합니다.
> **모델 핫리로드**: 봇이 실행 중일 때 모델 파일을 교체하면, 다음 캔들 마감 시 자동으로 감지해 리로드합니다. 봇 재시작이 필요 없습니다.
### 하이퍼파라미터 자동 튜닝 (Optuna)
봇 성능이 저하되거나 데이터가 충분히 축적되었을 때 Optuna로 최적 LightGBM 파라미터를 탐색합니다.
결과를 확인하고 직접 승인한 후 재학습에 반영하는 **수동 트리거** 방식입니다.
```bash
# 심볼별 튜닝 (50 trials, 5폴드 Walk-Forward, ~30분)
python scripts/tune_hyperparams.py --symbol XRPUSDT
# 빠른 테스트 (10 trials, 3폴드, ~5분)
python scripts/tune_hyperparams.py --symbol TRXUSDT --trials 10 --folds 3
# 베이스라인 측정 없이 탐색만
python scripts/tune_hyperparams.py --symbol XRPUSDT --no-baseline
```
결과는 `models/{symbol}/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
콘솔에 Best Params, 베이스라인 대비 개선폭, 폴드별 AUC를 출력하므로 직접 확인 후 판단하세요.
> **주의**: Optuna가 찾은 파라미터는 과적합 위험이 있습니다. Best Params를 `train_model.py`에 반영하기 전에 반드시 폴드별 AUC 분산과 개선폭을 검토하세요.
### Apple Silicon GPU 가속 학습 (M1/M2/M3/M4)
M 시리즈 맥에서는 MLX를 사용해 통합 GPU(Metal)로 학습할 수 있습니다.
> **설치**: `mlx`는 Apple Silicon 전용이며 `requirements.txt`에 포함되지 않습니다.
> 맥미니에서 별도 설치: `pip install mlx`
MLX로 학습한 모델은 ONNX 포맷으로 export되어 Linux 서버에서 `onnxruntime`으로 추론합니다.
> **참고**: LightGBM은 Apple Silicon GPU를 공식 지원하지 않습니다. MLX는 Apple이 만든 ML 프레임워크로 통합 GPU를 자동으로 활용합니다.
---
## 매매 전략
### 기술 지표 신호 (15분봉)
| 지표 | 롱 조건 | 숏 조건 | 가중치 |
|------|---------|---------|--------|
| RSI (14) | < 35 | > 65 | 1 |
| MACD 크로스 | 골든크로스 | 데드크로스 | 2 |
| 볼린저 밴드 | 하단 이탈 | 상단 돌파 | 1 |
| EMA 정배열 (9/21/50) | 정배열 | 역배열 | 1 |
| Stochastic RSI | < 20 + K>D | > 80 + K<D | 1 |
| 거래량 | 20MA × 1.5 이상 시 신호 강화 | — | 보조 |
**진입 조건**: 가중치 합계 ≥ 3 + (거래량 급증 또는 가중치 합계 ≥ 4)
**손절/익절**: ATR × 1.5 / ATR × 3.0 (리스크:리워드 = 1:2)
**ML 필터**: 예측 확률 ≥ 0.60 이어야 최종 진입
### 반대 시그널 재진입
보유 포지션과 반대 방향 신호가 발생하면:
1. 기존 포지션 즉시 청산 (미체결 SL/TP 주문 취소 포함)
2. ML 필터 통과 시 반대 방향으로 즉시 재진입
---
## CI/CD
`main` 브랜치에 푸시하면 Jenkins 파이프라인이 자동으로 실행됩니다.
1. **Notify Build Start** — Discord 빌드 시작 알림
2. **Git Clone from Gitea** — 소스 체크아웃
3. **Build Docker Image** — Docker 이미지 빌드 (`:{BUILD_NUMBER}` + `:latest` 태그)
4. **Push to Gitea Registry** — Gitea Container Registry(`10.1.10.28:3000`)에 푸시
5. **Deploy to Prod LXC** — 운영 LXC 서버(`10.1.10.24`)에 자동 배포 (`docker compose pull && up -d`)
6. **Cleanup** — 빌드 서버 로컬 이미지 정리
빌드 성공/실패 결과는 Discord로 자동 알림됩니다.
---
## 대시보드
봇 로그를 실시간으로 파싱하여 거래 내역, 수익 통계, 차트를 웹에서 조회할 수 있는 모니터링 대시보드입니다.
### 기술 스택
- **프론트엔드**: React 18 + Vite + Recharts, Nginx 정적 서빙
- **백엔드**: FastAPI + SQLite, 로그 파서(5초 주기 폴링)
- **배포**: Docker Compose 3컨테이너 (`dashboard-ui`, `dashboard-api`, `cointrader`)
### 주요 화면
| 탭 | 내용 |
|----|------|
| **Overview** | 총 수익, 승률, 거래 수, 최대 수익/손실 KPI + 일별 PnL 차트 + 누적 수익 곡선 |
| **Trades** | 전체 거래 내역 — 진입/청산가, 방향, 레버리지, 기술 지표(RSI, MACD, ATR), SL/TP, 순익 상세 |
| **Chart** | XRP/USDT 15분봉 가격 차트 + RSI 지표 + ADX 추세 강도 |
### API 엔드포인트
| 엔드포인트 | 설명 |
|-----------|------|
| `GET /api/position` | 현재 포지션 + 봇 상태 |
| `GET /api/trades` | 청산 거래 내역 (페이지네이션) |
| `GET /api/daily` | 일별 PnL 집계 |
| `GET /api/stats` | 전체 통계 (총 거래, 승률, 수수료 등) |
| `GET /api/candles` | 최근 캔들 + 기술 지표 |
| `GET /api/health` | 헬스 체크 |
| `POST /api/reset` | DB 초기화 + 로그 파서 재시작 |
### 실행
```bash
docker compose up -d
```
대시보드는 `http://<서버IP>:8080`에서 접속할 수 있습니다. 봇 로그를 읽기 전용으로 마운트하여 봇 코드를 수정하지 않는 디커플드 설계입니다.
---
## 테스트 ## 테스트
```bash ```bash
# 전체 테스트 # 전체 테스트 (138개)
bash scripts/run_tests.sh bash scripts/run_tests.sh
# 특정 키워드 필터 # 특정 키워드 필터
@@ -305,29 +340,134 @@ bash scripts/run_tests.sh -k bot
pytest tests/ -v pytest tests/ -v
``` ```
--- 모든 외부 API(Binance, Discord)는 `unittest.mock.AsyncMock`으로 대체되며, 비동기 테스트는 `@pytest.mark.asyncio`를 사용합니다.
## 환경변수 레퍼런스 ## ML 모델 학습
| 변수 | 기본값 | 설명 | 봇은 모델 파일이 없으면 ML 필터 없이 동작합니다. 모델을 학습하려면:
|------|--------|------|
| `BINANCE_API_KEY` | — | Binance API 키 |
| `BINANCE_API_SECRET` | — | Binance API 시크릿 |
| `SYMBOLS` | `XRPUSDT` | 거래 심볼 목록 (쉼표 구분, 예: `XRPUSDT,TRXUSDT,DOGEUSDT`) |
| `CORRELATION_SYMBOLS` | `BTCUSDT,ETHUSDT` | 상관관계 심볼 (BTC/ETH 수익률·상대강도 피처용) |
| `LEVERAGE` | `10` | 레버리지 배수 |
| `MAX_SAME_DIRECTION` | `2` | 동일 방향 최대 포지션 수 (LONG 2개면 3번째 LONG 차단) |
| `DISCORD_WEBHOOK_URL` | — | Discord 웹훅 URL |
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비 50%) |
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비 20%) |
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
| `NO_ML_FILTER` | — | `true`/`1`/`yes` 설정 시 ML 필터 완전 비활성화 — 모델 로드 없이 모든 신호 허용 |
| `ML_THRESHOLD` | `0.55` | ML 필터 예측 확률 임계값 — 이 값 이상이어야 진입 허용 (기본값 0.55) |
--- ### 전체 파이프라인 (권장)
## 주의사항 ```bash
# 전체 심볼 학습 + 배포
bash scripts/train_and_deploy.sh
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요. # 단일 심볼만 학습 + 배포
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다. bash scripts/train_and_deploy.sh --symbol TRXUSDT
> 성투기원합니다.
# MLX GPU 학습 (Apple Silicon, 단일 심볼)
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
# 학습만 (배포 없이)
bash scripts/train_and_deploy.sh lgbm 0
```
> **자동 분기**: `data/{symbol}/combined_15m.parquet`가 없으면 1년치 전체 수집, 있으면 35일치 Upsert로 자동 전환.
### 단계별 수동 실행
```bash
# 1. 과거 데이터 수집
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
# 2. LightGBM 모델 학습
python scripts/train_model.py --symbol TRXUSDT
# 3. 서버에 모델 배포
bash scripts/deploy_model.sh --symbol TRXUSDT
```
> **모델 핫리로드**: 봇 실행 중 모델 파일을 교체하면, 다음 캔들 마감 시 자동으로 감지해 리로드합니다.
### 하이퍼파라미터 튜닝 (Optuna)
```bash
# 심볼별 튜닝 (50 trials, 5폴드 Walk-Forward, ~30분)
python scripts/tune_hyperparams.py --symbol XRPUSDT
# 빠른 테스트 (10 trials, 3폴드, ~5분)
python scripts/tune_hyperparams.py --symbol TRXUSDT --trials 10 --folds 3
```
결과는 `models/{symbol}/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다. Optuna가 찾은 파라미터는 과적합 위험이 있으므로 폴드별 AUC 분산과 개선폭을 반드시 검토하세요.
### Apple Silicon GPU 가속 (M1/M2/M3/M4)
```bash
pip install mlx # Apple Silicon 전용, requirements.txt에 미포함
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
```
MLX로 학습한 모델은 ONNX 포맷으로 export되어 Linux 서버에서 `onnxruntime`으로 추론합니다.
## 전략 파라미터 스윕
기술 지표 전략의 최적 파라미터를 Walk-Forward 백테스트로 탐색합니다.
```bash
# 전체 스윕 (324개 조합, ~30분)
python scripts/strategy_sweep.py --symbols XRPUSDT --train-months 3 --test-months 1
```
5개 파라미터 × 3~4개 값 = 324개 조합을 순차 테스트:
| 파라미터 | 값 | 설명 |
|---------|------|------|
| `ATR_SL_MULT` | 1.0, 1.5, 2.0 | 손절 ATR 배수 |
| `ATR_TP_MULT` | 2.0, 3.0, 4.0 | 익절 ATR 배수 |
| `SIGNAL_THRESHOLD` | 3, 4, 5 | 최소 가중치 점수 |
| `ADX_THRESHOLD` | 0, 20, 25, 30 | ADX 필터 |
| `VOL_MULTIPLIER` | 1.5, 2.0, 2.5 | 거래량 급증 배수 |
> **핵심 발견**: ADX ≥ 25 필터가 가장 영향력 있는 파라미터. 횡보장 노이즈 신호를 효과적으로 필터링.
## 주간 전략 리포트
매주 자동으로 전략 성능을 측정하고 Discord로 리포트를 전송합니다.
```bash
# 수동 실행 (데이터 수집 스킵)
python scripts/weekly_report.py --skip-fetch
# 전체 실행 (데이터 수집 포함)
python scripts/weekly_report.py
# 특정 날짜 리포트
python scripts/weekly_report.py --date 2026-03-07
```
**리포트 내용:**
- Walk-Forward 백테스트 성능 (심볼별 PF/승률/MDD)
- 운영 대시보드 API에서 실전 트레이드 통계 조회 (거래 수/순수익/승률)
- 성능 추이 (최근 4주 PF/승률/MDD 변화)
- ML 재도전 체크리스트 (3개 조건 자동 판단)
- PF < 1.0 시 파라미터 스윕 대안 제시
> 실전 데이터는 운영 대시보드 API(`GET /api/trades`, `GET /api/stats`)에서 조회합니다. `DASHBOARD_API_URL` 환경변수로 주소를 설정하세요.
**크론탭 설정:**
```bash
# 매주 일요일 새벽 3시 KST
0 18 * * 6 cd /app && python scripts/weekly_report.py >> logs/cron.log 2>&1
```
## CI/CD
`main` 브랜치에 푸시하면 Jenkins 파이프라인이 자동 실행됩니다.
1. **Notify Build Start** — Discord 빌드 시작 알림
2. **Git Clone from Gitea** — 소스 체크아웃
3. **Build Docker Image** — Docker 이미지 빌드 (`:{BUILD_NUMBER}` + `:latest`)
4. **Push to Gitea Registry** — Container Registry에 푸시
5. **Deploy to Prod** — 운영 서버에 자동 배포 (`docker compose pull && up -d`)
6. **Cleanup** — 로컬 이미지 정리
빌드 성공/실패 결과는 Discord로 자동 알림됩니다.
## 설계 문서
모든 설계 문서와 구현 계획은 `docs/plans/`에 저장됩니다.
- `YYYY-MM-DD-feature-name-design.md` — 설계 결정 문서
- `YYYY-MM-DD-feature-name-plan.md` — 단계별 구현 계획
- [ARCHITECTURE.md](./ARCHITECTURE.md) — 전체 아키텍처 (5-레이어, MLOps 파이프라인, 동작 시나리오, 테스트 커버리지)

View File

@@ -1,4 +1,4 @@
FROM python:3.11-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn RUN pip install --no-cache-dir fastapi uvicorn
COPY log_parser.py . COPY log_parser.py .

View File

@@ -5,27 +5,31 @@ dashboard_api.py — 멀티심볼 대시보드 API
import sqlite3 import sqlite3
import os import os
import signal import signal
from fastapi import FastAPI, Query from fastapi import FastAPI, Query, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
from contextlib import contextmanager from contextlib import contextmanager
from typing import Optional from typing import Optional
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db") DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
PARSER_PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid")
DASHBOARD_RESET_KEY = os.environ.get("DASHBOARD_RESET_KEY", "")
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else ["*"]
app = FastAPI(title="Trading Dashboard API") app = FastAPI(title="Trading Dashboard API")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=CORS_ORIGINS,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
@contextmanager @contextmanager
def get_db(): def get_db():
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
try: try:
yield conn yield conn
finally: finally:
@@ -37,9 +41,9 @@ def get_symbols():
"""활성 심볼 목록 반환.""" """활성 심볼 목록 반환."""
with get_db() as db: with get_db() as db:
rows = db.execute( rows = db.execute(
"SELECT key FROM bot_status WHERE key LIKE '%:last_start'" "SELECT DISTINCT key FROM bot_status WHERE key LIKE '%:%'"
).fetchall() ).fetchall()
symbols = [r["key"].split(":")[0] for r in rows] symbols = {r["key"].split(":")[0] for r in rows}
return {"symbols": sorted(symbols)} return {"symbols": sorted(symbols)}
@@ -64,7 +68,7 @@ def get_position(symbol: Optional[str] = None):
def get_trades( def get_trades(
symbol: Optional[str] = None, symbol: Optional[str] = None,
limit: int = Query(50, ge=1, le=500), limit: int = Query(50, ge=1, le=500),
offset: int = 0, offset: int = Query(0, ge=0),
): ):
with get_db() as db: with get_db() as db:
if symbol: if symbol:
@@ -166,28 +170,28 @@ def health():
with get_db() as db: with get_db() as db:
cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"] cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"]
return {"status": "ok", "candles_count": cnt} return {"status": "ok", "candles_count": cnt}
except Exception as e: except Exception:
return {"status": "error", "detail": str(e)} return {"status": "error", "detail": "database unavailable"}
@app.post("/api/reset") @app.post("/api/reset")
def reset_db(): def reset_db(x_api_key: Optional[str] = Header(None)):
"""DB 초기화 + 파서에 SIGHUP으로 재파싱 요청."""
# C1: API key 인증 (DASHBOARD_RESET_KEY가 설정된 경우)
if DASHBOARD_RESET_KEY and x_api_key != DASHBOARD_RESET_KEY:
raise HTTPException(status_code=403, detail="invalid api key")
with get_db() as db: with get_db() as db:
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]: for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
db.execute(f"DELETE FROM {table}") db.execute(f"DELETE FROM {table}")
db.commit() db.commit()
import subprocess, signal # C2: PID file + SIGHUP으로 파서에 재파싱 요청 (프로세스 재시작 불필요)
for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []: try:
if not pid_str.isdigit(): with open(PARSER_PID_FILE) as f:
continue pid = int(f.read().strip())
try: os.kill(pid, signal.SIGHUP)
with open(f"/proc/{pid_str}/cmdline", "r") as f: except (FileNotFoundError, ValueError, ProcessLookupError, OSError):
cmdline = f.read() pass
if "log_parser.py" in cmdline and str(os.getpid()) != pid_str:
os.kill(int(pid_str), signal.SIGTERM)
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
pass
subprocess.Popen(["python", "log_parser.py"])
return {"status": "ok", "message": "DB 초기화 완료, 파서 재시작"} return {"status": "ok", "message": "DB 초기화 완료, 파서 재파싱 시작"}

View File

@@ -6,13 +6,29 @@ echo "LOG_DIR=${LOG_DIR:-/app/logs}"
echo "DB_PATH=${DB_PATH:-/app/data/dashboard.db}" echo "DB_PATH=${DB_PATH:-/app/data/dashboard.db}"
# 로그 파서를 백그라운드로 실행 # 로그 파서를 백그라운드로 실행
python log_parser.py & python -u log_parser.py &
PARSER_PID=$! PARSER_PID=$!
echo "Log parser started (PID: $PARSER_PID)" echo "Log parser started (PID: $PARSER_PID)"
# 파서가 기존 로그를 처리할 시간 부여 # 파서가 기존 로그를 처리할 시간 부여
sleep 3 sleep 3
# FastAPI 서버 실행 # SIGTERM/SIGINT → 파서에도 전달 후 대기
cleanup() {
echo "Shutting down..."
kill -TERM "$PARSER_PID" 2>/dev/null
wait "$PARSER_PID" 2>/dev/null
kill -TERM "$UVICORN_PID" 2>/dev/null
wait "$UVICORN_PID" 2>/dev/null
exit 0
}
trap cleanup SIGTERM SIGINT
# FastAPI 서버를 백그라운드로 실행 (exec 대신 — 셸이 PID 1을 유지해야 signal forwarding 가능)
echo "Starting API server on :8080" echo "Starting API server on :8080"
exec uvicorn dashboard_api:app --host 0.0.0.0 --port 8080 --log-level info uvicorn dashboard_api:app --host 0.0.0.0 --port 8080 --log-level info &
UVICORN_PID=$!
# 자식 프로세스 중 하나라도 종료되면 전체 종료
wait -n "$PARSER_PID" "$UVICORN_PID" 2>/dev/null
cleanup

View File

@@ -11,7 +11,8 @@ import time
import glob import glob
import os import os
import json import json
import threading import signal
import sys
from datetime import datetime, date from datetime import datetime, date
from pathlib import Path from pathlib import Path
@@ -19,12 +20,13 @@ from pathlib import Path
LOG_DIR = os.environ.get("LOG_DIR", "/app/logs") LOG_DIR = os.environ.get("LOG_DIR", "/app/logs")
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db") DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초 POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초
PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid")
# ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ───────────────── # ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ─────────────────
PATTERNS = { PATTERNS = {
"signal": re.compile( "signal": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \| 현재가: (?P<price>[\d.]+) USDT" r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \|.*현재가: (?P<price>[\d.]+)"
), ),
"adx": re.compile( "adx": re.compile(
@@ -68,6 +70,12 @@ PATTERNS = {
r".*\[(?P<symbol>\w+)\] 오늘 누적 PnL: (?P<pnl>[+\-\d.]+) USDT" r".*\[(?P<symbol>\w+)\] 오늘 누적 PnL: (?P<pnl>[+\-\d.]+) USDT"
), ),
"position_monitor": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] 포지션 모니터 \| (?P<direction>\w+) \| "
r"현재가=(?P<price>[\d.]+) \| PnL=(?P<pnl>[+\-\d.]+) USDT \((?P<pnl_pct>[+\-\d.]+)%\)"
),
"bot_start": re.compile( "bot_start": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] 봇 시작, 레버리지 (?P<leverage>\d+)x" r".*\[(?P<symbol>\w+)\] 봇 시작, 레버리지 (?P<leverage>\d+)x"
@@ -88,25 +96,30 @@ PATTERNS = {
class LogParser: class LogParser:
def __init__(self): def __init__(self):
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(DB_PATH) self.conn = sqlite3.connect(DB_PATH, timeout=10)
self.conn.row_factory = sqlite3.Row self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA busy_timeout=5000")
self._init_db() self._init_db()
self._file_positions = {} self._file_positions = {}
self._current_positions = {} # {symbol: position_dict} self._current_positions = {} # {symbol: position_dict}
self._pending_candles = {} # {symbol: {ts_key: {data}}} self._pending_candles = {} # {symbol: {ts_key: {data}}}
self._balance = 0 self._balance = 0
self._shutdown = False
self._dirty = False # batch commit 플래그
# PID 파일 기록
with open(PID_FILE, "w") as f:
f.write(str(os.getpid()))
# 시그널 핸들러
signal.signal(signal.SIGTERM, self._handle_sigterm)
signal.signal(signal.SIGHUP, self._handle_sighup)
def _init_db(self): def _init_db(self):
self.conn.executescript(""" self.conn.executescript("""
DROP TABLE IF EXISTS trades; CREATE TABLE IF NOT EXISTS trades (
DROP TABLE IF EXISTS candles;
DROP TABLE IF EXISTS daily_pnl;
DROP TABLE IF EXISTS bot_status;
DROP TABLE IF EXISTS parse_state;
CREATE TABLE trades (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL, symbol TEXT NOT NULL,
direction TEXT NOT NULL, direction TEXT NOT NULL,
@@ -128,10 +141,11 @@ class LogParser:
net_pnl REAL, net_pnl REAL,
status TEXT NOT NULL DEFAULT 'OPEN', status TEXT NOT NULL DEFAULT 'OPEN',
close_reason TEXT, close_reason TEXT,
extra TEXT extra TEXT,
UNIQUE(symbol, entry_time, direction)
); );
CREATE TABLE candles ( CREATE TABLE IF NOT EXISTS candles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL, symbol TEXT NOT NULL,
ts TEXT NOT NULL, ts TEXT NOT NULL,
@@ -144,7 +158,7 @@ class LogParser:
UNIQUE(symbol, ts) UNIQUE(symbol, ts)
); );
CREATE TABLE daily_pnl ( CREATE TABLE IF NOT EXISTS daily_pnl (
symbol TEXT NOT NULL, symbol TEXT NOT NULL,
date TEXT NOT NULL, date TEXT NOT NULL,
cumulative_pnl REAL DEFAULT 0, cumulative_pnl REAL DEFAULT 0,
@@ -155,24 +169,46 @@ class LogParser:
PRIMARY KEY(symbol, date) PRIMARY KEY(symbol, date)
); );
CREATE TABLE bot_status ( CREATE TABLE IF NOT EXISTS bot_status (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT, value TEXT,
updated_at TEXT updated_at TEXT
); );
CREATE TABLE parse_state ( CREATE TABLE IF NOT EXISTS parse_state (
filepath TEXT PRIMARY KEY, filepath TEXT PRIMARY KEY,
position INTEGER DEFAULT 0 position INTEGER DEFAULT 0
); );
CREATE INDEX idx_candles_symbol_ts ON candles(symbol, ts); CREATE INDEX IF NOT EXISTS idx_candles_symbol_ts ON candles(symbol, ts);
CREATE INDEX idx_trades_status ON trades(status); CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status);
CREATE INDEX idx_trades_symbol ON trades(symbol); CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol);
CREATE UNIQUE INDEX IF NOT EXISTS idx_trades_unique
ON trades(symbol, entry_time, direction);
""") """)
self.conn.commit() self.conn.commit()
self._migrate_deduplicate()
self._load_state() self._load_state()
def _migrate_deduplicate(self):
"""기존 DB에 중복 trades가 있으면 제거 (가장 오래된 id만 유지)."""
dupes = self.conn.execute("""
SELECT symbol, entry_time, direction, MIN(id) AS keep_id, COUNT(*) AS cnt
FROM trades
GROUP BY symbol, entry_time, direction
HAVING cnt > 1
""").fetchall()
if not dupes:
return
for row in dupes:
self.conn.execute(
"DELETE FROM trades WHERE symbol=? AND entry_time=? AND direction=? AND id!=?",
(row["symbol"], row["entry_time"], row["direction"], row["keep_id"]),
)
self.conn.commit()
total = sum(r["cnt"] - 1 for r in dupes)
print(f"[LogParser] 마이그레이션: 중복 trades {total}건 제거")
def _load_state(self): def _load_state(self):
rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall() rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall()
self._file_positions = {r["filepath"]: r["position"] for r in rows} self._file_positions = {r["filepath"]: r["position"] for r in rows}
@@ -192,8 +228,48 @@ class LogParser:
"ON CONFLICT(filepath) DO UPDATE SET position=?", "ON CONFLICT(filepath) DO UPDATE SET position=?",
(filepath, pos, pos) (filepath, pos, pos)
) )
self._dirty = True
def _handle_sigterm(self, signum, frame):
"""Graceful shutdown — DB 커넥션을 안전하게 닫음."""
print("[LogParser] SIGTERM 수신 — 종료")
self._shutdown = True
try:
if self._dirty:
self.conn.commit()
self.conn.close()
except Exception:
pass
try:
os.unlink(PID_FILE)
except OSError:
pass
sys.exit(0)
def _handle_sighup(self, signum, frame):
"""SIGHUP → 파싱 상태 초기화, 처음부터 재파싱."""
print("[LogParser] SIGHUP 수신 — 상태 초기화, 재파싱 시작")
self._file_positions = {}
self._current_positions = {}
self._pending_candles = {}
self.conn.execute("DELETE FROM parse_state")
self.conn.commit() self.conn.commit()
def _batch_commit(self):
"""배치 커밋 — _dirty 플래그가 설정된 경우에만 커밋."""
if self._dirty:
self.conn.commit()
self._dirty = False
def _cleanup_pending_candles(self, max_per_symbol=50):
"""오래된 pending candle 데이터 정리 (I4: 메모리 누적 방지)."""
for symbol in list(self._pending_candles):
pending = self._pending_candles[symbol]
if len(pending) > max_per_symbol:
keys = sorted(pending.keys())
for k in keys[:-max_per_symbol]:
del pending[k]
def _set_status(self, key, value): def _set_status(self, key, value):
now = datetime.now().isoformat() now = datetime.now().isoformat()
self.conn.execute( self.conn.execute(
@@ -201,12 +277,12 @@ class LogParser:
"ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?", "ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?",
(key, str(value), now, str(value), now) (key, str(value), now, str(value), now)
) )
self.conn.commit() self._dirty = True
# ── 메인 루프 ──────────────────────────────────────────────── # ── 메인 루프 ────────────────────────────────────────────────
def run(self): def run(self):
print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s") print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s")
while True: while not self._shutdown:
try: try:
self._scan_logs() self._scan_logs()
except Exception as e: except Exception as e:
@@ -214,12 +290,11 @@ class LogParser:
time.sleep(POLL_INTERVAL) time.sleep(POLL_INTERVAL)
def _scan_logs(self): def _scan_logs(self):
log_files = sorted(glob.glob(os.path.join(LOG_DIR, "bot_*.log"))) log_files = sorted(set(glob.glob(os.path.join(LOG_DIR, "bot*.log"))))
main_log = os.path.join(LOG_DIR, "bot.log")
if os.path.exists(main_log):
log_files.append(main_log)
for filepath in log_files: for filepath in log_files:
self._parse_file(filepath) self._parse_file(filepath)
self._batch_commit()
self._cleanup_pending_candles()
def _parse_file(self, filepath): def _parse_file(self, filepath):
last_pos = self._file_positions.get(filepath, 0) last_pos = self._file_positions.get(filepath, 0)
@@ -272,6 +347,15 @@ class LogParser:
self._set_status("ml_threshold", m.group("threshold")) self._set_status("ml_threshold", m.group("threshold"))
return return
# 포지션 모니터 (5분 간격 현재가·PnL 갱신)
m = PATTERNS["position_monitor"].search(line)
if m:
symbol = m.group("symbol")
self._set_status(f"{symbol}:current_price", m.group("price"))
self._set_status(f"{symbol}:unrealized_pnl", m.group("pnl"))
self._set_status(f"{symbol}:unrealized_pnl_pct", m.group("pnl_pct"))
return
# 포지션 복구 (재시작 시) # 포지션 복구 (재시작 시)
m = PATTERNS["position_recover"].search(line) m = PATTERNS["position_recover"].search(line)
if m: if m:
@@ -355,7 +439,7 @@ class LogParser:
price, signal, price, signal,
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")), extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")),
) )
self.conn.commit() self._dirty = True
except Exception as e: except Exception as e:
print(f"[LogParser] 캔들 저장 에러: {e}") print(f"[LogParser] 캔들 저장 에러: {e}")
return return
@@ -387,7 +471,7 @@ class LogParser:
ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""", ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
(symbol, day, pnl, ts, pnl, ts) (symbol, day, pnl, ts, pnl, ts)
) )
self.conn.commit() self._dirty = True
self._set_status(f"{symbol}:daily_pnl", str(pnl)) self._set_status(f"{symbol}:daily_pnl", str(pnl))
return return
@@ -396,7 +480,11 @@ class LogParser:
leverage=None, sl=None, tp=None, is_recovery=False, leverage=None, sl=None, tp=None, is_recovery=False,
rsi=None, macd_hist=None, atr=None): rsi=None, macd_hist=None, atr=None):
if leverage is None: if leverage is None:
leverage = 10 row = self.conn.execute(
"SELECT value FROM bot_status WHERE key=?",
(f"{symbol}:leverage",),
).fetchone()
leverage = int(row["value"]) if row else 10
# 중복 체크 — 같은 심볼+방향의 OPEN 포지션이 이미 있으면 스킵 # 중복 체크 — 같은 심볼+방향의 OPEN 포지션이 이미 있으면 스킵
current = self._current_positions.get(symbol) current = self._current_positions.get(symbol)
@@ -417,7 +505,7 @@ class LogParser:
return return
cur = self.conn.execute( cur = self.conn.execute(
"""INSERT INTO trades(symbol, direction, entry_time, entry_price, """INSERT OR IGNORE INTO trades(symbol, direction, entry_time, entry_price,
quantity, leverage, sl, tp, status, extra, rsi, macd_hist, atr) quantity, leverage, sl, tp, status, extra, rsi, macd_hist, atr)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(symbol, direction, ts, (symbol, direction, ts,
@@ -425,7 +513,7 @@ class LogParser:
json.dumps({"recovery": is_recovery}), json.dumps({"recovery": is_recovery}),
rsi, macd_hist, atr), rsi, macd_hist, atr),
) )
self.conn.commit() self._dirty = True
self._current_positions[symbol] = { self._current_positions[symbol] = {
"id": cur.lastrowid, "id": cur.lastrowid,
"direction": direction, "direction": direction,
@@ -447,6 +535,7 @@ class LogParser:
if not open_trades: if not open_trades:
print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음") print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음")
self._current_positions.pop(symbol, None)
return return
primary_id = open_trades[0]["id"] primary_id = open_trades[0]["id"]
@@ -461,6 +550,8 @@ class LogParser:
reason, primary_id) reason, primary_id)
) )
self._dirty = True
if len(open_trades) > 1: if len(open_trades) > 1:
stale_ids = [r["id"] for r in open_trades[1:]] stale_ids = [r["id"] for r in open_trades[1:]]
self.conn.execute( self.conn.execute(
@@ -469,21 +560,24 @@ class LogParser:
) )
print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제") print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제")
# 심볼별 일별 요약 # 심볼별 일별 요약 (trades 테이블에서 재계산 — idempotent)
day = ts[:10] day = ts[:10]
win = 1 if net_pnl > 0 else 0 row = self.conn.execute(
loss = 1 if net_pnl <= 0 else 0 """SELECT COUNT(*) as cnt,
SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END) as wins,
SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END) as losses
FROM trades WHERE status='CLOSED' AND symbol=? AND exit_time LIKE ?""",
(symbol, f"{day}%"),
).fetchone()
self.conn.execute( self.conn.execute(
"""INSERT INTO daily_pnl(symbol, date, cumulative_pnl, trade_count, wins, losses, last_updated) """INSERT INTO daily_pnl(symbol, date, trade_count, wins, losses, last_updated)
VALUES(?, ?, ?, 1, ?, ?, ?) VALUES(?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, date) DO UPDATE SET ON CONFLICT(symbol, date) DO UPDATE SET
trade_count = trade_count + 1, trade_count=?, wins=?, losses=?, last_updated=?""",
wins = wins + ?, (symbol, day, row["cnt"], row["wins"], row["losses"], ts,
losses = losses + ?, row["cnt"], row["wins"], row["losses"], ts),
last_updated = ?""",
(symbol, day, net_pnl, win, loss, ts, win, loss, ts)
) )
self.conn.commit() self._dirty = True
self._set_status(f"{symbol}:position_status", "NONE") self._set_status(f"{symbol}:position_status", "NONE")
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}") print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
@@ -492,4 +586,10 @@ class LogParser:
if __name__ == "__main__": if __name__ == "__main__":
parser = LogParser() parser = LogParser()
parser.run() try:
parser.run()
finally:
try:
os.unlink(PID_FILE)
except OSError:
pass

View File

@@ -0,0 +1,3 @@
node_modules
dist
.git

View File

@@ -1,7 +1,7 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json . COPY package.json package-lock.json .
RUN npm install RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build

File diff suppressed because one or more lines are too long

20
dashboard/ui/dist/index.html vendored Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trading Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700&display=swap" rel="stylesheet" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #08080f; }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
</style>
<script type="module" crossorigin src="/assets/index-50uRhrJe.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -10,7 +10,9 @@ server {
# API 프록시 → 백엔드 컨테이너 # API 프록시 → 백엔드 컨테이너
location /api/ { location /api/ {
proxy_pass http://dashboard-api:8080; resolver 127.0.0.11 valid=10s;
set $backend http://dashboard-api:8080;
proxy_pass $backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }

2055
dashboard/ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -267,6 +267,7 @@ export default function App() {
const [lastUpdate, setLastUpdate] = useState(null); const [lastUpdate, setLastUpdate] = useState(null);
const [symbols, setSymbols] = useState([]); const [symbols, setSymbols] = useState([]);
const symbolsRef = useRef([]);
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
const [stats, setStats] = useState({ const [stats, setStats] = useState({
@@ -277,24 +278,29 @@ export default function App() {
const [positions, setPositions] = useState([]); const [positions, setPositions] = useState([]);
const [botStatus, setBotStatus] = useState({}); const [botStatus, setBotStatus] = useState({});
const [trades, setTrades] = useState([]); const [trades, setTrades] = useState([]);
const [tradesTotal, setTradesTotal] = useState(0);
const [tradesPage, setTradesPage] = useState(0);
const [daily, setDaily] = useState([]); const [daily, setDaily] = useState([]);
const [candles, setCandles] = useState([]); const [candles, setCandles] = useState([]);
/* ── 데이터 폴링 ─────────────────────────────────────────── */ /* ── 데이터 폴링 ─────────────────────────────────────────── */
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
const sym = selectedSymbol ? `?symbol=${selectedSymbol}` : ""; const sym = selectedSymbol ? `?symbol=${selectedSymbol}` : "";
const symRequired = selectedSymbol || symbols[0] || "XRPUSDT"; const symRequired = selectedSymbol || symbolsRef.current[0] || "XRPUSDT";
const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([ const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([
api("/symbols"), api("/symbols"),
api(`/stats${sym}`), api(`/stats${sym}`),
api(`/position${sym}`), api(`/position${sym}`),
api(`/trades${sym}${sym ? "&" : "?"}limit=50`), api(`/trades${sym}${sym ? "&" : "?"}limit=50&offset=${tradesPage * 50}`),
api(`/daily${sym}`), api(`/daily${sym}`),
api(`/candles?symbol=${symRequired}&limit=96`), api(`/candles?symbol=${symRequired}&limit=96`),
]); ]);
if (symRes?.symbols) setSymbols(symRes.symbols); if (symRes?.symbols) {
symbolsRef.current = symRes.symbols;
setSymbols(symRes.symbols);
}
if (sRes && sRes.total_trades !== undefined) { if (sRes && sRes.total_trades !== undefined) {
setStats(sRes); setStats(sRes);
setIsLive(true); setIsLive(true);
@@ -304,10 +310,13 @@ export default function App() {
setPositions(pRes.positions || []); setPositions(pRes.positions || []);
if (pRes.bot) setBotStatus(pRes.bot); if (pRes.bot) setBotStatus(pRes.bot);
} }
if (tRes?.trades) setTrades(tRes.trades); if (tRes?.trades) {
setTrades(tRes.trades);
setTradesTotal(tRes.total || tRes.trades.length);
}
if (dRes?.daily) setDaily(dRes.daily); if (dRes?.daily) setDaily(dRes.daily);
if (cRes?.candles) setCandles(cRes.candles); if (cRes?.candles) setCandles(cRes.candles);
}, [selectedSymbol, symbols]); }, [selectedSymbol, tradesPage]);
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
@@ -399,7 +408,20 @@ export default function App() {
{/* 오픈 포지션 — 복수 표시 */} {/* 오픈 포지션 — 복수 표시 */}
{positions.length > 0 && ( {positions.length > 0 && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{positions.map((pos) => ( {positions.map((pos) => {
const curP = parseFloat(botStatus[`${pos.symbol}:current_price`] || 0);
const entP = parseFloat(pos.entry_price || 0);
const isShort = pos.direction === "SHORT";
const uPnl = botStatus[`${pos.symbol}:unrealized_pnl`];
const uPnlPct = botStatus[`${pos.symbol}:unrealized_pnl_pct`];
const pnlPct = uPnlPct != null
? parseFloat(uPnlPct)
: (entP > 0 && curP > 0
? ((isShort ? entP - curP : curP - entP) / entP * 100 * (pos.leverage || 10))
: null);
const pnlUsdt = uPnl != null ? parseFloat(uPnl) : null;
const posPnlColor = pnlPct > 0 ? S.green : pnlPct < 0 ? S.red : S.text3;
return (
<div key={pos.id} style={{ <div key={pos.id} style={{
background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)", background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)",
border: "1px solid rgba(99,102,241,0.15)", borderRadius: 14, border: "1px solid rgba(99,102,241,0.15)", borderRadius: 14,
@@ -410,17 +432,24 @@ export default function App() {
</div> </div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}> <div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Badge <Badge
bg={pos.direction === "SHORT" ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"} bg={isShort ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
color={pos.direction === "SHORT" ? S.red : S.green} color={isShort ? S.red : S.green}
> >
{pos.direction} {pos.leverage || 10}x {pos.direction} {pos.leverage || 10}x
</Badge> </Badge>
<span style={{ fontSize: 14, fontWeight: 700, fontFamily: S.mono }}> <span style={{ fontSize: 14, fontWeight: 700, fontFamily: S.mono }}>
{fmt(pos.entry_price)} {fmt(pos.entry_price)}
</span> </span>
{pnlPct !== null && (
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: S.mono, color: posPnlColor }}>
{pnlUsdt != null ? `${pnlUsdt > 0 ? "+" : ""}${pnlUsdt.toFixed(4)}` : ""}
{` (${pnlPct > 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`}
</span>
)}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>
@@ -432,7 +461,7 @@ export default function App() {
padding: 4, width: "fit-content", padding: 4, width: "fit-content",
}}> }}>
<button <button
onClick={() => setSelectedSymbol(null)} onClick={() => { setSelectedSymbol(null); setTradesPage(0); }}
style={{ style={{
background: selectedSymbol === null ? "rgba(99,102,241,0.15)" : "transparent", background: selectedSymbol === null ? "rgba(99,102,241,0.15)" : "transparent",
border: "none", border: "none",
@@ -444,7 +473,7 @@ export default function App() {
{symbols.map((sym) => ( {symbols.map((sym) => (
<button <button
key={sym} key={sym}
onClick={() => setSelectedSymbol(sym)} onClick={() => { setSelectedSymbol(sym); setTradesPage(0); }}
style={{ style={{
background: selectedSymbol === sym ? "rgba(99,102,241,0.15)" : "transparent", background: selectedSymbol === sym ? "rgba(99,102,241,0.15)" : "transparent",
border: "none", border: "none",
@@ -570,7 +599,7 @@ export default function App() {
marginTop: 6, marginTop: 6,
}} }}
> >
전체 {trades.length} 보기 전체 {tradesTotal} 보기
</div> </div>
)} )}
</div> </div>
@@ -583,7 +612,7 @@ export default function App() {
fontSize: 10, color: S.text3, letterSpacing: 1.2, fontSize: 10, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12, fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
}}> }}>
전체 거래 내역 ({trades.length}) 전체 거래 내역 ({tradesTotal})
</div> </div>
{trades.map((t) => ( {trades.map((t) => (
<TradeRow <TradeRow
@@ -593,6 +622,38 @@ export default function App() {
onToggle={() => setExpanded(expanded === t.id ? null : t.id)} onToggle={() => setExpanded(expanded === t.id ? null : t.id)}
/> />
))} ))}
{tradesTotal > 50 && (
<div style={{
display: "flex", justifyContent: "center", alignItems: "center",
gap: 12, marginTop: 14,
}}>
<button
disabled={tradesPage === 0}
onClick={() => setTradesPage((p) => Math.max(0, p - 1))}
style={{
fontSize: 11, fontFamily: S.mono, padding: "6px 14px",
background: tradesPage === 0 ? "transparent" : "rgba(99,102,241,0.1)",
color: tradesPage === 0 ? S.text4 : S.indigo,
border: `1px solid ${tradesPage === 0 ? S.border : "rgba(99,102,241,0.2)"}`,
borderRadius: 8, cursor: tradesPage === 0 ? "default" : "pointer",
}}
> 이전</button>
<span style={{ fontSize: 11, color: S.text3, fontFamily: S.mono }}>
{tradesPage * 50 + 1}{Math.min((tradesPage + 1) * 50, tradesTotal)} / {tradesTotal}
</span>
<button
disabled={(tradesPage + 1) * 50 >= tradesTotal}
onClick={() => setTradesPage((p) => p + 1)}
style={{
fontSize: 11, fontFamily: S.mono, padding: "6px 14px",
background: (tradesPage + 1) * 50 >= tradesTotal ? "transparent" : "rgba(99,102,241,0.1)",
color: (tradesPage + 1) * 50 >= tradesTotal ? S.text4 : S.indigo,
border: `1px solid ${(tradesPage + 1) * 50 >= tradesTotal ? S.border : "rgba(99,102,241,0.2)"}`,
borderRadius: 8, cursor: (tradesPage + 1) * 50 >= tradesTotal ? "default" : "pointer",
}}
>다음 </button>
</div>
)}
</div> </div>
)} )}
@@ -624,17 +685,22 @@ export default function App() {
display: "grid", gridTemplateColumns: "1fr 1fr", display: "grid", gridTemplateColumns: "1fr 1fr",
gap: 10, marginTop: 12, gap: 10, marginTop: 12,
}}> }}>
<ChartBox title="RSI"> <ChartBox title="OI 변화율">
<ResponsiveContainer width="100%" height={150}> <ResponsiveContainer width="100%" height={150}>
<LineChart data={candles.map((c) => ({ ts: fmtTime(c.ts), rsi: c.rsi }))}> <AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), oi_change: c.oi_change }))}>
<defs>
<linearGradient id="gOI" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={S.amber} stopOpacity={0.15} />
<stop offset="100%" stopColor={S.amber} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" /> <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" /> <XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
<YAxis domain={[0, 100]} {...axisStyle} /> <YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} /> <Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey={() => 70} stroke="rgba(248,113,113,0.2)" strokeDasharray="4 4" dot={false} name="과매수" /> <Line type="monotone" dataKey={() => 0} stroke="rgba(255,255,255,0.1)" strokeDasharray="4 4" dot={false} name="기준선" />
<Line type="monotone" dataKey={() => 30} stroke="rgba(139,92,246,0.2)" strokeDasharray="4 4" dot={false} name="과매도" /> <Area type="monotone" dataKey="oi_change" name="OI변화율" stroke={S.amber} strokeWidth={1.5} fill="url(#gOI)" dot={false} />
<Line type="monotone" dataKey="rsi" name="RSI" stroke={S.amber} strokeWidth={1.5} dot={false} /> </AreaChart>
</LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</ChartBox> </ChartBox>
@@ -673,10 +739,16 @@ export default function App() {
</span> </span>
<button <button
onClick={async () => { onClick={async () => {
const key = prompt("Reset API Key를 입력하세요:");
if (!key) return;
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return; if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
try { try {
const r = await fetch("/api/reset", { method: "POST" }); const r = await fetch("/api/reset", {
method: "POST",
headers: { "X-API-Key": key },
});
if (r.ok) { alert("초기화 완료. 잠시 후 데이터가 다시 채워집니다."); location.reload(); } if (r.ok) { alert("초기화 완료. 잠시 후 데이터가 다시 채워집니다."); location.reload(); }
else if (r.status === 403) alert("API Key가 올바르지 않습니다.");
else alert("초기화 실패: " + r.statusText); else alert("초기화 실패: " + r.statusText);
} catch (e) { alert("초기화 실패: " + e.message); } } catch (e) { alert("초기화 실패: " + e.message); }
}} }}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -23,6 +23,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- TZ=Asia/Seoul - TZ=Asia/Seoul
- PYTHONUNBUFFERED=1
- LOG_DIR=/app/logs - LOG_DIR=/app/logs
- DB_PATH=/app/data/dashboard.db - DB_PATH=/app/data/dashboard.db
- POLL_INTERVAL=5 - POLL_INTERVAL=5
@@ -51,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

@@ -1,86 +1,85 @@
# Strategy Parameter Sweep Plan # 전략 파라미터 스윕 계획
**Date**: 2026-03-06 **날짜**: 2026-03-06
**Status**: Completed **상태**: 완료
## Goal ## 목표
Find profitable parameter combinations for the base technical indicator strategy (ML OFF) using walk-forward backtesting, targeting PF >= 1.0 as foundation for ML redesign. Walk-Forward 백테스트를 활용하여 기본 기술 지표 전략(ML OFF)의 수익성 높은 파라미터 조합을 탐색하고, PF >= 1.0을 ML 재설계의 기반으로 확보한다.
## Background ## 배경
Walk-forward backtest revealed the current XRP strategy is unprofitable (PF 0.71, -641 PnL). The strategy parameter sweep systematically tests 324 combinations of 5 parameters to find profitable regimes. Walk-Forward 백테스트 결과 현재 XRP 전략이 비수익적(PF 0.71, -641 PnL)으로 확인되었다. 전략 파라미터 스윕은 5개 파라미터의 324개 조합을 체계적으로 테스트하여 수익 구간을 탐색한다.
## Parameters Swept ## 스윕 파라미터
| Parameter | Values | Description | | 파라미터 | | 설명 |
| ------------------- | ------------- | ----------------------------------------- | | ------------------- | ------------- | ------------------------------------------- |
| `atr_sl_mult` | 1.0, 1.5, 2.0 | Stop-loss ATR multiplier | | `atr_sl_mult` | 1.0, 1.5, 2.0 | 손절 ATR 배수 |
| `atr_tp_mult` | 2.0, 3.0, 4.0 | Take-profit ATR multiplier | | `atr_tp_mult` | 2.0, 3.0, 4.0 | 익절 ATR 배수 |
| `signal_threshold` | 3, 4, 5 | Min weighted indicator score for entry | | `signal_threshold` | 3, 4, 5 | 진입을 위한 최소 가중치 지표 점수 |
| `adx_threshold` | 0, 20, 25, 30 | ADX filter (0=disabled, N=require ADX>=N) | | `adx_threshold` | 0, 20, 25, 30 | ADX 필터 (0=비활성, N=ADX>=N 필요) |
| `volume_multiplier` | 1.5, 2.0, 2.5 | Volume surge detection multiplier | | `volume_multiplier` | 1.5, 2.0, 2.5 | 거래량 급증 감지 배수 |
Total combinations: 3 x 3 x 3 x 4 x 3 = **324** 총 조합: 3 x 3 x 3 x 4 x 3 = **324**
## Implementation ## 구현
### Files Modified ### 수정된 파일
- `src/indicators.py``get_signal()` accepts `signal_threshold`, `adx_threshold`, `volume_multiplier` params - `src/indicators.py``get_signal()` `signal_threshold`, `adx_threshold`, `volume_multiplier` 파라미터 추가
- `src/dataset_builder.py``_calc_signals()` accepts same params for vectorized computation - `src/dataset_builder.py``_calc_signals()`에 동일 파라미터를 받아 벡터화 계산에 적용
- `src/backtester.py``BacktestConfig` includes strategy params; `WalkForwardBacktester` propagates them to test folds - `src/backtester.py``BacktestConfig`에 전략 파라미터 포함; `WalkForwardBacktester`가 테스트 폴드에 전파
### Files Created ### 신규 생성 파일
- `scripts/strategy_sweep.py`CLI tool for parameter grid sweep - `scripts/strategy_sweep.py`파라미터 그리드 스윕 CLI 도구
### Bug Fix ### 버그 수정
- `WalkForwardBacktester` was not passing `signal_threshold`, `adx_threshold`, `volume_multiplier`, or `use_ml` to fold `BacktestConfig`. All signal params were silently using defaults, making ADX/volume/threshold sweeps have zero effect. - `WalkForwardBacktester` `signal_threshold`, `adx_threshold`, `volume_multiplier`, `use_ml`을 폴드 `BacktestConfig`에 전달하지 않는 버그 수정. 모든 신호 파라미터가 기본값으로 적용되어 ADX/거래량/임계값 스윕이 효과 없이 실행되고 있었음.
## Results (XRPUSDT, Walk-Forward 3/1) ## 결과 (XRPUSDT, Walk-Forward 3/1)
### Top 10 Combinations ### 상위 10개 조합
| Rank | SL×ATR | TP×ATR | Signal | ADX | Vol | Trades | WinRate | PF | MDD | PnL | Sharpe | | 순위 | SL×ATR | TP×ATR | 신호 | ADX | 거래량 | 거래 수 | 승률 | PF | MDD | PnL | 샤프 |
| ---- | ------ | ------ | ------ | --- | --- | ------ | ------- | ---- | ----- | ---- | ------ | | ---- | ------ | ------ | ---- | --- | ------ | ------- | ------- | ---- | ----- | ---- | ---- |
| 1 | 1.5 | 4.0 | 3 | 30 | 2.5 | 19 | 52.6% | 2.39 | 7.0% | +469 | 61.0 | | 1 | 1.5 | 4.0 | 3 | 30 | 2.5 | 19 | 52.6% | 2.39 | 7.0% | +469 | 61.0 |
| 2 | 1.5 | 2.0 | 3 | 30 | 2.5 | 19 | 68.4% | 2.23 | 6.5% | +282 | 61.2 | | 2 | 1.5 | 2.0 | 3 | 30 | 2.5 | 19 | 68.4% | 2.23 | 6.5% | +282 | 61.2 |
| 3 | 1.0 | 2.0 | 3 | 30 | 2.5 | 19 | 57.9% | 1.98 | 5.0% | +213 | 50.8 | | 3 | 1.0 | 2.0 | 3 | 30 | 2.5 | 19 | 57.9% | 1.98 | 5.0% | +213 | 50.8 |
| 4 | 1.0 | 4.0 | 3 | 30 | 2.5 | 19 | 36.8% | 1.80 | 7.7% | +248 | 37.1 | | 4 | 1.0 | 4.0 | 3 | 30 | 2.5 | 19 | 36.8% | 1.80 | 7.7% | +248 | 37.1 |
| 5 | 1.5 | 3.0 | 3 | 30 | 2.5 | 19 | 52.6% | 1.76 | 10.1% | +258 | 40.9 | | 5 | 1.5 | 3.0 | 3 | 30 | 2.5 | 19 | 52.6% | 1.76 | 10.1% | +258 | 40.9 |
| 6 | 1.5 | 4.0 | 3 | 25 | 2.5 | 28 | 42.9% | 1.75 | 13.1% | +381 | 36.8 | | 6 | 1.5 | 4.0 | 3 | 25 | 2.5 | 28 | 42.9% | 1.75 | 13.1% | +381 | 36.8 |
| 7 | 2.0 | 4.0 | 3 | 30 | 1.5 | 39 | 48.7% | 1.67 | 16.9% | +572 | 35.3 | | 7 | 2.0 | 4.0 | 3 | 30 | 1.5 | 39 | 48.7% | 1.67 | 16.9% | +572 | 35.3 |
| 8 | 1.0 | 2.0 | 3 | 25 | 2.5 | 28 | 50.0% | 1.64 | 5.8% | +205 | 35.7 | | 8 | 1.0 | 2.0 | 3 | 25 | 2.5 | 28 | 50.0% | 1.64 | 5.8% | +205 | 35.7 |
| 9 | 1.5 | 2.0 | 3 | 25 | 2.5 | 28 | 57.1% | 1.62 | 10.3% | +229 | 35.7 | | 9 | 1.5 | 2.0 | 3 | 25 | 2.5 | 28 | 57.1% | 1.62 | 10.3% | +229 | 35.7 |
| 10 | 2.0 | 2.0 | 3 | 25 | 2.5 | 27 | 66.7% | 1.57 | 12.0% | +217 | 33.3 | | 10 | 2.0 | 2.0 | 3 | 25 | 2.5 | 27 | 66.7% | 1.57 | 12.0% | +217 | 33.3 |
### Current Production (Rank 93/324) ### 현재 프로덕션 (324개 중 93위)
| SL×ATR | TP×ATR | Signal | ADX | Vol | Trades | WinRate | PF | MDD | PnL | | SL×ATR | TP×ATR | 신호 | ADX | 거래량 | 거래 수 | 승률 | PF | MDD | PnL |
| ------ | ------ | ------ | --- | --- | ------ | ------- | ---- | ----- | ---- | | ------ | ------ | ---- | --- | ------ | ------- | ------- | ---- | ----- | ---- |
| 1.5 | 3.0 | 3 | 0 | 1.5 | 118 | 30.5% | 0.71 | 65.9% | -641 | | 1.5 | 3.0 | 3 | 0 | 1.5 | 118 | 30.5% | 0.71 | 65.9% | -641 |
### Key Findings ### 핵심 발견 사항
1. **ADX filter is the single most impactful parameter.** All top 10 results use ADX >= 25, with ADX=30 dominating the top 5. This filters out sideways/ranging markets where signals are noise. 1. **ADX 필터가 가장 영향력 있는 단일 파라미터.** 상위 10개 결과 모두 ADX >= 25를 사용하며, 상위 5개는 ADX=30이 지배적. 횡보/박스권 시장에서 노이즈 신호를 필터링한다.
2. **Volume multiplier 2.5 dominates.** Higher volume thresholds ensure entries only on strong conviction (genuine breakouts vs. noise). 2. **거래량 배수 2.5가 지배적.** 높은 거래량 임계값은 진정한 돌파에서만 진입을 보장한다 (노이즈 대비 실질 돌파).
3. **Signal threshold 3 is optimal.** Higher thresholds (4, 5) produced too few trades or zero trades in most ADX-filtered regimes. 3. **신호 임계값 3이 최적.** 더 높은 임계값(4, 5)은 대부분의 ADX 필터링 구간에서 거래가 너무 적거나 0건이었다.
4. **SL/TP ratios matter less than entry filters.** The top results span all SL/TP combos, but all share ADX=25-30 + Vol=2.5. 4. **SL/TP 비율보다 진입 필터가 더 중요.** 상위 결과는 모든 SL/TP 조합에 걸쳐 있지만, 모두 ADX=25-30 + Vol=2.5를 공유한다.
5. **Trade count drops significantly with filters.** Top combos have 19-39 trades vs. 118 for current. Fewer but higher quality entries. 5. **필터 적용 시 거래 수가 크게 감소.** 상위 조합은 19-39건 vs 현재 118건. 적지만 높은 품질의 진입.
6. **41 combinations achieved PF >= 1.0** out of 324 total (12.7%). 6. **324개 중 41개 조합이 PF >= 1.0 달성** (12.7%).
## Recommended Next Steps ## 권장 다음 단계
1. **Update production defaults**: ADX=25, volume_multiplier=2.0 as a conservative choice (more trades than ADX=30)
2. **Validate on TRXUSDT and DOGEUSDT** to confirm ADX filter is not XRP-specific
3. **Retrain ML models** with updated strategy params — the ML filter should now have a profitable base to improve upon
4. **Fine-tune sweep** around the profitable zone: ADX [25-35], Vol [2.0-3.0]
1. **프로덕션 기본값 업데이트**: ADX=25, volume_multiplier=2.0을 보수적 선택으로 적용 (ADX=30보다 더 많은 거래 확보)
2. **TRXUSDT, DOGEUSDT에서 검증**: ADX 필터가 XRP에만 특화된 것이 아닌지 확인
3. **ML 모델 재학습**: 업데이트된 전략 파라미터로 — ML 필터가 수익성 있는 기반 위에서 개선 가능
4. **수익 구간 주변 세밀 스윕**: ADX [25-35], Vol [2.0-3.0]

View File

@@ -0,0 +1,146 @@
# 코드 리뷰 개선 사항
**날짜**: 2026-03-07
**상태**: 부분 완료 (#1/#2/#4/#5/#6/#8 완료, #9 보류, #3/#7/#10~13 다음 스프린트)
## 목표
전체 코드베이스 리뷰에서 발견된 버그, 엣지 케이스, 로직 오류를 우선순위별로 정리하고 수정한다.
---
## Critical (즉시 수정 필요)
### 1. OI 변화율 계산 시 Division by Zero
**파일**: `src/bot.py:120`
`_prev_oi`가 0.0일 때 `(current_oi - self._prev_oi) / self._prev_oi`에서 ZeroDivisionError 발생. `get_open_interest()` 실패 시 0.0을 반환하므로 실제로 발생 가능.
**수정**: `_prev_oi == 0.0`이면 `oi_change = 0.0`으로 처리.
### 2. 누적 트레이드 수 계산 로직 오류
**파일**: `scripts/weekly_report.py:415-423`
```python
# 현재 (잘못됨) — max()로 비교하여 누적이 아닌 최대값만 가져옴
cumulative = live_count
for rpath in sorted(rdir.glob("report_*.json")):
cumulative = max(cumulative, prev.get("live_trades", {}).get("count", 0))
```
ML 재학습 트리거 조건(`≥ 150건`)이 제대로 작동하지 않음.
**수정**: 이전 리포트의 `live_trades.count`를 합산하도록 변경.
---
## Important (이번 주 수정 권장)
### 3. Training-Serving Skew (OI/펀딩비 피처)
**파일**: `src/dataset_builder.py` vs `src/ml_features.py`
- 학습 시: OI=0 구간을 NaN으로 마스킹 후 z-score
- 서빙 시: OI 값을 그대로 NaN으로 설정
ML 활성화 시 학습/서빙 간 피처 분포 불일치 발생. 현재 ML OFF이므로 당장은 영향 없지만, ML 재활성화 전 반드시 수정 필요.
### 4. `fetch_history.py` — API 실패/Rate Limit 미처리
**파일**: `scripts/fetch_history.py:46-61`
`futures_klines()` 호출에 retry 로직이 없음. Rate limit(429) 발생 시 예외로 크래시. `weekly_report.py`의 subprocess가 무한 대기할 수 있음.
**수정**: `tenacity` 또는 수동 retry 로직 추가 (최대 3회, exponential backoff).
### 5. Parquet Upsert 시 중복 타임스탬프 미제거
**파일**: `scripts/fetch_history.py:314`
`sort_index()`만 하고 `drop_duplicates()`를 하지 않음. API 응답에 중복 타임스탬프가 있으면 지표 계산이 이중 계산됨.
**수정**: `sort_index()` 앞에 `df[~df.index.duplicated(keep='last')]` 추가.
### 6. `record_pnl()`에 asyncio.Lock 미사용
**파일**: `src/risk_manager.py:55`
`record_pnl()``self.daily_pnl`을 수정하지만 `async with self._lock`을 사용하지 않음. 멀티심볼 환경에서 동시 호출 시 일일 손실 한도 체크가 부정확할 수 있음.
**수정**: `record_pnl()`을 async로 변경하고 `async with self._lock:` 추가.
### 7. 백테스터 Equity Curve 미구현
**파일**: `src/backtester.py:509-510`
`_record_equity()``pass`로 비어 있음. MDD 계산이 실현 PnL 기준이지 포트폴리오 가치(미실현 PnL 포함) 기준이 아님. MDD가 과소평가될 수 있음.
**수정**: 미실현 PnL을 포함한 equity 계산 구현.
### 8. User Data Stream — exit_price 기본값 0.0
**파일**: `src/user_data_stream.py:95`
`order.get("ap", "0")`에서 필드 누락 시 exit_price=0.0으로 설정되어 PnL이 완전히 잘못 계산됨.
**수정**: `exit_price == 0.0`이면 청산 처리를 스킵하고 WARNING 로그 출력.
---
## Minor (다음 스프린트)
### 9. 거래량 급증 진입 조건 의도 불일치
**파일**: `src/indicators.py:115-118`
`(vol_surge or long_signals >= signal_threshold + 1)` — 거래량 급증만으로도 진입 허용됨. "강한 신호 + 거래량 급증"이 의도라면 AND 조건이어야 하는데, 현재 OR로 구현됨. 현재 전략 파라미터 스윕 결과(ADX=25, Vol=2.5)에서는 큰 문제 없으나, 의도를 확인하고 정리 필요.
### 10. ML 모델 피처 불일치 시 Silent Failure
**파일**: `src/ml_filter.py:152`
ONNX 모델과 현재 FEATURE_COLS가 다르면 예외를 잡고 `False`를 반환(모든 신호 차단). 사용자에게 원인이 보이지 않아 디버깅이 어려움.
**수정**: 피처 수 불일치는 WARNING이 아닌 ERROR로 로깅하고, 최초 발생 시 Discord 알림 전송.
### 11. `train_model.py` — 빈 데이터셋 미처리
**파일**: `scripts/train_model.py:196`
`generate_dataset_vectorized()`가 빈 DataFrame을 반환하면 Walk-Forward 검증에서 step=0이 되어 무한 루프 가능.
**수정**: 빈 데이터셋 시 `ValueError("No samples generated")` raise.
### 12. `data_stream.py` — AsyncClient 생성 실패 시 전체 크래시
**파일**: `src/data_stream.py:79-82`
네트워크 단절 상태에서 봇 시작 시 `AsyncClient.create()` 실패로 모든 심볼이 함께 크래시.
**수정**: retry with exponential backoff (최대 5회) 추가.
### 13. `fetch_history.py` — Parquet 타임존 처리 불일치
**파일**: `scripts/fetch_history.py:286-289`
`tz_localize("UTC")` 호출 시 기존 데이터가 실제로 UTC인지 검증하지 않음. 타임존이 다른 데이터가 섞이면 OI/펀딩비 병합이 시간축으로 어긋남.
**수정**: `tz_localize(tz='UTC', ambiguous='raise', nonexistent='raise')` 사용.
---
## 수정 우선순위
| 우선순위 | 이슈 | 난이도 | 영향도 |
|---------|------|--------|--------|
| 즉시 | #1 OI division by zero | 5분 | 봇 크래시 |
| 즉시 | #2 누적 트레이드 계산 | 5분 | ML 트리거 오작동 |
| 이번주 | #4 fetch_history retry | 30분 | 데이터 수집 행 |
| 이번주 | #5 Parquet 중복 제거 | 5분 | 지표 이중 계산 |
| 이번주 | #6 record_pnl Lock | 5분 | 리스크 한도 부정확 |
| 이번주 | #8 exit_price=0 방어 | 10분 | PnL 오계산 |
| ML 재활성화 전 | #3 Training-Serving skew | 30분 | 예측 품질 저하 |
| 다음 스프린트 | #7 Equity curve 구현 | 1시간 | MDD 과소평가 |
| 다음 스프린트 | #9-13 기타 | 각 10-30분 | 안정성 개선 |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
# Critical Bugfixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 4 critical bugs identified in code review (C5, C1, C3, C8)
**Architecture:** Direct fixes to backtester.py, bot.py, main.py — no new files needed
**Tech Stack:** Python asyncio, signal handling
---
## Task 1: C5 — Backtester double fee deduction + atr≤0 fee leak
**Files:**
- Modify: `src/backtester.py:494-501`
- [x] Remove `self.balance -= entry_fee` at L496. The fee is already deducted in `_close_position` via `net_pnl = gross_pnl - entry_fee - exit_fee`.
- [x] This also fixes the atr≤0 early return bug — since balance is no longer modified before ATR check, early return doesn't leak fees.
## Task 2: C1 — SL/TP atomicity with retry and emergency close
**Files:**
- Modify: `src/bot.py:461-475`
- [x] Wrap SL/TP placement in `_place_sl_tp_with_retry()` with 3 retries and 1s backoff
- [x] Track `sl_placed` and `tp_placed` independently to avoid re-placing successful orders
- [x] On final failure, call `_emergency_close()` which market-closes the position and notifies via Discord
- [x] `_emergency_close` also handles its own failure with critical log + Discord alert
## Task 3: C3 — PnL double recording race condition
**Files:**
- Modify: `src/bot.py` (init, _on_position_closed, _position_monitor)
- [x] Add `self._close_lock = asyncio.Lock()` to `__init__`
- [x] Wrap `_on_position_closed` body with `async with self._close_lock`
- [x] Wrap SYNC path in `_position_monitor` with `async with self._close_lock`
- [x] Add double-check after lock acquisition in monitor (callback may have already processed)
## Task 4: C8 — Graceful shutdown with signal handler
**Files:**
- Modify: `main.py`
- [x] Add `signal.SIGTERM` and `signal.SIGINT` handlers via `loop.add_signal_handler()`
- [x] Use `asyncio.Event` + `asyncio.wait(FIRST_COMPLETED)` pattern
- [x] `_graceful_shutdown()`: cancel all open orders per bot (with 5s timeout), then cancel tasks
- [x] Log shutdown progress for each symbol
## Verification
- [x] All 138 existing tests pass (0 failures)

View File

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

View File

@@ -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로 정확히 분류되는지 확인
- 위 모든 항목 통과 후 실전 배포

View File

@@ -0,0 +1,507 @@
# CoinTrader 프로젝트 종합 검토 보고서
**검토 일자**: 2026년 3월 7일
**검토자**: Claude AI
**대상**: CoinTrader — Binance Futures 자동매매 봇 (47개 설계/계획 문서 + README + ARCHITECTURE)
**기준**: 객관성, 내용의 정확성, 아키텍처 일관성, 코드 품질
---
## 요약
**프로젝트 상태**: 초기 단계 + 활발한 개발 중
CoinTrader는 Binance Futures에서 15분 봉의 기술 지표와 ML 필터를 결합하여 XRP, TRX, DOGE 등 다중 심볼을 동시 거래하는 자동매매 봇입니다. **5-레이어 아키텍처**(Data → Signal → ML Filter → Execution & Risk → Event/Alert)로 구성되어 있으며, 136개의 단위 테스트와 완전한 MLOps 파이프라인을 갖추고 있습니다.
그러나 **즉시 수정이 필요한 버그 2개**(OI division by zero, 누적 트레이드 계산 오류)와 **이번 주 중 해결해야 할 문제 4~5개**(API retry, Parquet 중복, async Lock, exit_price 방어)가 존재합니다. 또한 ML 필터가 현재 비활성화되어 있으며, 그 이유(학습 데이터 부족)가 타당해 보입니다.
---
## 1. 아키텍처 분석
### 1.1 전체 구조의 강점
**✓ 명확한 5-레이어 분리**
- Layer 1 (Data): WebSocket 캔들 수신, Parquet 버퍼
- Layer 2 (Signal): 기술 지표 + 가중치 신호 생성
- Layer 3 (ML Filter): ONNX/LightGBM 선택적 활성화
- Layer 4 (Execution & Risk): 주문 실행 + 공유 RiskManager
- Layer 5 (Event/Alert): User Data Stream TP/SL 감지 + Discord
각 레이어가 단일 책임을 가지고 있으며, 의존성 방향이 명확함.
**✓ 멀티심볼 동시 거래의 실제 구현**
- 심볼별 **독립 TradingBot 인스턴스** → 각자 `Exchange`, `MLFilter`, `DataStream` 소유
- **공유 RiskManager (싱글턴)** → asyncio.Lock으로 일일 손실 한도, 동일 방향 제한 관리
- `asyncio.gather()`로 병렬 실행 → 심볼 간 간섭 없음
이는 멀티심볼 거래에서 흔한 함정(단일 데이터 경로의 병목, 공유 상태의 경쟁 조건)을 잘 피함.
**✓ 완전한 MLOps 파이프라인**
- 과거 데이터 수집 → 벡터화 데이터셋 생성 → LightGBM/MLX 학습
- Walk-Forward 5폴드 검증 → Optuna 하이퍼파라미터 튜닝
- 모델 핫리로드 (변경 감지 후 자동 로드)
- 주간 백테스트 리포트 + Discord 자동 알림
### 1.2 설계 결정의 타당성
**기술 지표 선택 (RSI, MACD, 볼린저, EMA, StochRSI, ADX)**
- 각 지표의 역할이 명확함 (과매수/과매도, 추세 전환, 가격 이탈, 추세 강도)
- 가중치 합산 시스템 (ADX ≥ 25 필터가 가장 효과적이라고 문서에서 언급)
- 전략 파라미터 스윕 결과: ADX=25 + Vol=2.5 조합에서 PF 1.57~2.39 달성
**ML 필터 현재 비활성화 (NO_ML_FILTER=true)**
- 이유: Walk-Forward 검증에서 각 폴드 학습 세트에 유효 신호가 ~27건으로 부족
- ADX + 거래량 배수만으로도 PF 1.5 이상 → ML 없이 운영하겠다는 판단은 보수적이고 합리적
- "충분한 거래 데이터(150건 이상) 축적 후 재활성화" 기준도 명확함
**동적 증증금 비율**
- 잔고가 늘어날수록 비율 감소 (과노출 방지)
- `MARGIN_MIN_RATIO=0.20`, `MARGIN_MAX_RATIO=0.50`, `DECAY_RATE=0.0006`
- 수식: `margin_ratio = MAX - (balance_growth) × DECAY_RATE` (선형 감소)
### 1.3 아키텍처의 약점
**△ User Data Stream TP/SL 감지 미테스트**
- 코드는 구현되어 있으나, WebSocket 의존성 때문에 테스트가 없음 (COVERAGE 6.3 표참조)
- 실제 운영 중 `ORDER_TRADE_UPDATE` 이벤트 처리에 버그가 있을 수 있음
- 특히 `exit_price = 0.0` 기본값 문제 (#8 이슈)가 이를 증명
**△ 반대 시그널 재진입의 경쟁 조건 가능성**
- `_is_reentering` 플래그로 보호하고 있으나, 극단적인 타이밍에서는 여전히 버그 가능
- 멀티심볼에서 각 심볼의 캔들 마감 시점이 다르면, 한 심볼의 청산 콜백이 다른 심볼의 신호 처리와 겹칠 수 있음
**△ Parquet Upsert 시 타임존 처리**
- `tz_localize("UTC")` 호출이 기존 데이터가 실제 UTC인지 검증하지 않음 (#13 이슈)
- OI/펀딩비 데이터가 다른 타임존이면 시계열 병합이 어긋남
---
## 2. 코드 품질 분석
### 2.1 즉시 수정이 필요한 버그 (Critical)
#### 버그 #1: OI 변화율 계산 시 Division by Zero
**파일**: `src/bot.py:120`
**심각도**: 높음 (봇 크래시 가능)
**원인**: `_prev_oi == 0.0`일 때 `(current_oi - self._prev_oi) / self._prev_oi` 계산
**영향**: `get_open_interest()` API 실패 시 0.0 반환 → ZeroDivisionError 발생
**수정**: `if self._prev_oi == 0.0: oi_change = 0.0 else: oi_change = ...`
**예상 수정 시간**: 5분
#### 버그 #2: 누적 트레이드 수 계산 로직 오류
**파일**: `scripts/weekly_report.py:415-423`
**심각도**: 높음 (ML 재학습 트리거 오작동)
**원인**: `max(cumulative, prev_count)`로 최대값만 취함 → 누적이 아님
**영향**: ML 재학습 조건 "≥ 150 누적 거래" 판단 오류
**수정**: `cumulative += prev.get("live_trades", {}).get("count", 0)` (합산)
**예상 수정 시간**: 5분
### 2.2 이번 주 중 수정 권장 (Important)
#### 이슈 #3: Training-Serving Skew (OI/펀딩비 피처)
**파일**: `src/dataset_builder.py` vs `src/ml_features.py`
**심각도**: 중간 (ML 재활성화 시에만 영향)
**문제**:
- 학습: OI=0 구간 → NaN으로 마스킹 후 z-score 정규화
- 서빙: OI 값 → NaN으로 직접 설정
- 결과: 피처 분포 불일치 (학습/서빙 간 스큐)
**현재 상태**: ML OFF이므로 당장은 무영향
**필요 시점**: ML 재활성화 전 반드시 해결
**예상 수정 시간**: 30분
#### 이슈 #4: fetch_history.py — API 실패/Rate Limit 미처리
**파일**: `scripts/fetch_history.py:46-61`
**심각도**: 중간 (데이터 수집 중단, 주간 리포트 행)
**문제**: `futures_klines()` 호출에 retry 로직 없음
**영향**: Rate limit(429) 발생 시 크래시 → subprocess 무한 대기
**수정**: `tenacity` 라이브러리 또는 수동 retry (최대 3회, exponential backoff)
**예상 수정 시간**: 30분
#### 이슈 #5: Parquet Upsert 시 중복 타임스탬프 미제거
**파일**: `scripts/fetch_history.py:314`
**심각도**: 중간 (지표 이중 계산)
**문제**: `sort_index()`만 하고 `drop_duplicates()` 미수행
**영향**: API 응답에 중복 타임스탬프 있으면 RSI/MACD 등이 이중 계산됨
**수정**: `df[~df.index.duplicated(keep='last')]` 추가
**예상 수정 시간**: 5분
#### 이슈 #6: record_pnl()에 asyncio.Lock 미사용
**파일**: `src/risk_manager.py:55`
**심각도**: 중간 (멀티심볼에서 일일 손실 한도 부정확)
**문제**: `record_pnl()``self.daily_pnl` 수정하지만 Lock 미사용
**영향**: 멀티심볼 동시 호출 시 경쟁 조건 → 일일 손실 한도 체크 오류
**수정**: `async def record_pnl()` + `async with self._lock:` 추가
**예상 수정 시간**: 5분
#### 이슈 #8: User Data Stream — exit_price 기본값 0.0
**파일**: `src/user_data_stream.py:95`
**심각도**: 중간 (PnL 오계산)
**문제**: `order.get("ap", "0")` → exit_price=0.0 (필드 누락 시)
**영향**: 청산가가 0이면 PnL 계산 완전 오류
**수정**: `if exit_price == 0.0: return; logger.warning(...)`
**예상 수정 시간**: 10분
### 2.3 다음 스프린트 (Minor)
#### 이슈 #7: 백테스터 Equity Curve 미구현
**파일**: `src/backtester.py:509-510`
**문제**: `_record_equity()``pass`로 비어 있음
**영향**: MDD 계산이 실현 PnL만 기준 → 미실현 PnL 무시 → MDD 과소평가
**수정**: 포트폴리오 가치(equity) = 초기 자본 + 누적 PnL 계산
**예상 수정 시간**: 1시간
#### 이슈 #9: 거래량 급증 진입 조건 의도 불일치
**파일**: `src/indicators.py:115-118`
**문제**: `(vol_surge or long_signals >= threshold + 1)` — OR 조건
**의도 추측**: "강한 신호(threshold+1) + 거래량 급증" = AND
**현재**: 거래량 급증만으로도 진입 허용 = OR
**현재 상태**: 전략 스윕(ADX=25, Vol=2.5)에서는 큰 문제 없음
**필요**: 의도 확인 후 조건 정리
**예상 수정 시간**: 10분 (확인 후)
#### 이슈 #10: ML 모델 피처 불일치 시 Silent Failure
**파일**: `src/ml_filter.py:152`
**문제**: ONNX와 FEATURE_COLS 불일치 → 예외 잡고 `False` 반환 (모든 신호 차단)
**영향**: 사용자가 원인을 알 수 없음 (디버깅 어려움)
**수정**: ERROR 로깅 + Discord 초회 알림
**예상 수정 시간**: 15분
#### 이슈 #11-13: 기타 (데이터셋 검증, AsyncClient retry, 타임존 처리)
**총 예상 시간**: 각 10-30분
### 2.4 테스트 커버리지
**전체 테스트**: 15개 파일, 136개 케이스
**커버되는 항목**:
- ✅ 기술 지표 계산 (RSI 범위, MACD 컬럼, 볼린저 부등식)
- ✅ ADX 횡보장 필터 (ADX < 25 시 신호 차단)
- ✅ ML 피처 추출 (26개 피처, RS 분모 0 처리, NaN 없음)
- ✅ 동적 증거금 비율 계산
- ✅ 동일 방향 포지션 제한
- ✅ 일일 손실 한도 (5%)
- ✅ 반대 시그널 재진입
- ✅ Parquet Upsert + OI=0 처리
- ✅ 주간 리포트 (백테스트, 대시보드 API, 추이 분석)
**커버되지 않는 항목**:
- ❌ User Data Stream TP/SL (WebSocket 의존)
- ❌ Discord 알림 전송 (외부 서비스)
**평가**: 핵심 로직은 잘 테스트되어 있으나, WebSocket 기반 실시간 이벤트 처리는 미테스트. 이는 실제 운영에서 버그의 원천이 될 수 있음 (#8 이슈 예시).
---
## 3. 설계 문서 분석 (47개 파일)
### 3.1 문서 조직과 진행 상황
**초기 단계 (2026-03-01~02)**
- `2026-03-01-xrp-futures-autotrader.md` (1325줄): 프로젝트 전체 초기 계획
- `2026-03-01-ml-filter-design.md`: ML 필터 설계 (최소한)
- `2026-03-01-*-design/plan`: 15개 주요 기능별 설계+계획 쌍
**중기 개발 (2026-03-03~04)**
- ADX ML 피처 마이그레이션
- Optuna 하이퍼파라미터 튜닝
- OI 파생 피처 설계
**최근 (2026-03-05~07)**
- 멀티심볼 거래 설계 + 구현 (정상 작동 중)
- 다중심볼 대시보드 설계 + 계획
- 전략 파라미터 스윕 계획 (실행됨, PF 1.57~2.39 달성)
- **코드 리뷰 개선사항** (2026-03-07): 13개 이슈 정리
### 3.2 문서 품질
**강점**:
- **명확한 설계 의도**: 각 문서가 "목적 → 선택이유 → 기각된 대안 → 구현"의 구조
- **예시 코드 포함**: 설계를 검증할 구체적 코드 샘플 제시
- **트레이드오프 분석**: 멀티심볼 거래 시 "단일 Bot + 라우팅 vs 독립 Bot 인스턴스" 비교
**약점**:
- **기술 부채 시각화 미흡**: 47개 문서가 있지만, "전체 진행률/리스크/미해결 항목"을 한눈에 보는 대시보드 없음
- **의사결정 추적성 부족**: "왜 ADX=25 필터를 선택했는가?" 같은 근거가 전략 스윕 이후에 추가됨 (역순 설계)
- **문서 간 중복**: ML 필터 설계, 피처 설계, 데이터셋 빌더 등이 서로 겹침
### 3.3 설계의 실제 반영도
**잘 반영된 항목**:
- ✅ 5-레이어 아키텍처 (README + ARCHITECTURE 명시, 코드 구조 일치)
- ✅ 멀티심볼 독립 Bot + 공유 RiskManager (설계 문서 + 실제 코드 일치)
- ✅ ML 필터 선택적 활성화 (`NO_ML_FILTER=true` 기본값, 근거 문서 확보)
- ✅ Walk-Forward 검증 (백테스트 엔진 구현, 주간 리포트에 적용)
- ✅ Discord 알림 (설계 문서 + 구현 + 테스트)
**부분적으로 반영된 항목**:
- △ 전략 파라미터 자동 스윕 (설계 문서는 있으나, "파라미터 자동 적용"은 수동 검토 단계)
- △ 하이퍼파라미터 튜닝 (Optuna 설계 문서 있으나, 실제 사용 현황 불명)
**미반영 항목**:
- ❌ Equity curve (문서는 설계되었으나, 코드는 `pass`)
- ❌ Testnet 자동 검증 (2026-03-03 문서는 1m/125x 테스트넷 계획, 현재 상태 불명)
---
## 4. 운영 안정성 평가
### 4.1 리스크 관리 메커니즘
| 기능 | 구현 | 테스트 | 평가 |
|------|:----:|:-----:|------|
| 일일 손실 한도 (5%) | ✅ | ✅ | 명확함 |
| 동적 증거금 비율 | ✅ | ✅ | 선형 감소 로직 검증됨 |
| 동일 방향 제한 (2개) | ✅ | ✅ | asyncio.Lock 필요 (#6) |
| 포지션 복구 (봇 재시작) | ✅ | △ | 코드는 있으나 테스트 미흡 |
| TP/SL 자동 청산 | ✅ | ❌ | WebSocket 미테스트 (#8 버그 증명) |
| 반대 시그널 재진입 | ✅ | △ | 경쟁 조건 가능성 |
### 4.2 외부 의존성
| 서비스 | 용도 | Retry 로직 | 평가 |
|--------|------|:----------:|------|
| Binance Futures REST API | 주문, 잔고, OI, 펀딩비 | △ (부분) | #4 이슈: fetch_history retry 없음 |
| Binance WebSocket | 캔들, User Data | △ | #12 이슈: AsyncClient 생성 실패 시 전체 크래시 |
| Discord Webhook | 알림 | ❌ | 실패 시 봇 중단될 수 있음 (현황 불명) |
### 4.3 운영 자동화
**진행 중인 자동화**:
- ✅ 매주 일요일 3시 KST: `weekly_report.py` (크론탭)
- 데이터 수집 → Walk-Forward 백테스트 → 실전 통계 조회 → 추이 분석 → Discord 알림
- ✅ 모델 핫리로드: mtime 변경 감지 후 자동 리로드 (15분마다)
- ✅ CI/CD (Jenkins + Gitea Registry): `main` 푸시 → 빌드 → 레지스트리 푸시 → 운영 배포
**자동화 부족**:
- ❌ 에러 자동 복구 (AsyncClient 생성 실패 시 5회 retry 필요)
- ❌ API Rate Limit 자동 처리 (exponential backoff 필요)
- ❌ Parquet 데이터 검증 (타임존, 중복 타임스탬프)
---
## 5. 성능 및 검증 기준
### 5.1 전략 파라미터 스윕 결과
**테스트 기간**: 과거 데이터 (Walk-Forward 방식)
**테스트 심볼**: XRPUSDT
**조합 수**: 324개 (5 파라미터 × 3~4 값 각각)
| 파라미터 | 범위 | 최적값 | 영향도 |
|---------|------|--------|--------|
| ADX_THRESHOLD | 0, 20, 25, 30 | 25 | ⭐⭐⭐ (가장 중요) |
| ATR_SL_MULT | 1.0, 1.5, 2.0 | 2.0 | ⭐⭐ |
| ATR_TP_MULT | 2.0, 3.0, 4.0 | 2.0 | ⭐⭐ |
| SIGNAL_THRESHOLD | 3, 4, 5 | 3 | ⭐ |
| VOL_MULTIPLIER | 1.5, 2.0, 2.5 | 2.5 | ⭐⭐ |
**결과**: **PF 1.57 ~ 2.39** (심볼·조합에 따라 변동)
**평가**:
- ADX ≥ 25 필터가 가장 효과적 (횡보장 노이즈 신호 제거)
- 전략 파라미터가 타당한 범위에서 탐색됨
- 그러나 **과거 데이터 기반** → 현재 시장에서도 동일 성능 보장 불가
- **실전 거래 통계**는 README에 없음 (운영 대시보드 API 조회만 가능)
### 5.2 ML 모델 평가
**현재 상태**: 비활성화 (`NO_ML_FILTER=true`)
**근거**:
- Walk-Forward 5폴드 검증에서 각 폴드 학습 세트 ~27건 유효 신호
- LightGBM이 의미 있는 패턴을 학습하기에는 표본 부족
- ADX + 거래량만으로 PF 1.5 이상 달성 → ML 추가 필요성 낮음
**재활성화 조건**:
- 누적 거래 ≥ 150건 (현재: 불명, 버그 #2로 인해 계산 오류)
- PF < 1.0 또는 PF 3주 연속 하락
**평가**: 보수적이고 합리적 판단. 다만 150건 기준이 실제 달성되는지 확인 필요.
---
## 6. 개발 프로세스 평가
### 6.1 설계-구현 프로세스
**강점**:
- 기능별로 `*-design.md` + `*-plan.md` 쌍 작성 (설계 의도 기록)
- ARCHITECTURE.md에 5-레이어 구조와 동작 시나리오 상세 기술
- 코드 리뷰 문서(2026-03-07)로 이슈 우선순위 정리
**약점**:
- 47개 문서 중 많은 부분이 "과거 설계 기록" (실제 구현과 시차)
- "설계 → 검증(테스트) → 문서화"의 역순 진행 보임 (특히 전략 파라미터 스윕은 후행 검증)
- 마이그레이션/리팩토링 문서가 많음 (ADX 마이그레이션, OI 피처 마이그레이션) → 초기 설계에 미흡했음을 시사
### 6.2 코드 리뷰 프로세스
**현황**:
- 2026-03-07 코드 리뷰에서 **13개 이슈 발견 및 우선순위 정리**
- Critical 2개: 즉시 수정 필요
- Important 6개: 이번 주 수정 권장
- Minor 5개: 다음 스프린트
**평가**:
- ✅ 이슈를 체계적으로 정리하고 우선순위 명시
- ✅ 각 이슈에 대해 파일명, 라인 수, 영향도, 수정 시간 제시
- ❌ 어느 이슈가 실제로 수정되었는지 추적이 없음 (상태: "부분 완료")
---
## 7. 문제점 및 개선 제안
### 7.1 즉시 조치 (오늘~내일)
| 번호 | 이슈 | 영향 | 수정 시간 |
|------|------|------|---------|
| #1 | OI division by zero | 봇 크래시 | 5분 |
| #2 | 누적 트레이드 계산 오류 | ML 재학습 트리거 오작동 | 5분 |
**조치 없을 시 리스크**:
- #1: 당일 운영 중 봇 크래시 가능
- #2: ML 재활성화 시점 오판
### 7.2 이번 주 조치
| 번호 | 이슈 | 우선도 | 수정 시간 |
|------|------|--------|---------|
| #4 | fetch_history retry | 높음 | 30분 |
| #5 | Parquet 중복 제거 | 중간 | 5분 |
| #6 | record_pnl Lock | 높음 | 5분 |
| #8 | exit_price=0 방어 | 높음 | 10분 |
**조치 없을 시 리스크**:
- #4: 주간 데이터 수집 실패 → 주간 리포트 미생성
- #6: 멀티심볼 운영 시 일일 손실 한도 부정확 (위험)
- #8: TP/SL 체결 시 PnL 오계산 (통계 왜곡)
### 7.3 ML 재활성화 전 (필수)
| 번호 | 이슈 | 수정 시간 |
|------|------|---------|
| #3 | Training-Serving Skew (OI/펀딩비 피처) | 30분 |
### 7.4 구조적 개선 제안
#### 제안 1: 설계 의도 문서화
**현황**: 47개 문서가 분산되어 있어 "현재 상태 파악"이 어려움
**개선**:
- `IMPLEMENTATION_STATUS.md` 추가
- 각 기능별 설계 → 구현 → 테스트 → 배포 상태 추적
- 마지막 수정 날짜 + 담당자 명시
#### 제안 2: WebSocket 기반 이벤트 테스트
**현황**: User Data Stream TP/SL 감지가 미테스트
**개선**:
- `test_user_data_stream_integration.py` 추가
- 모의 WebSocket 메시지 시뮬레이션 (pytest-asyncio)
- 특히 `exit_price=0.0` 엣지 케이스 테스트
#### 제안 3: 멀티심볼 동시성 테스트
**현황**: 단위 테스트는 있으나, "N개 심볼 동시 거래 시 경쟁 조건" 미테스트
**개선**:
- `test_multisymbol_concurrent.py` 추가
- 각 심볼이 동시에 포지션 진입/청산 시뮬레이션
- asyncio.Lock이 제대로 작동하는지 검증
#### 제안 4: API Retry 정책 통일
**현황**: fetch_history.py에만 retry 없음 → 다른 모듈도 검토 필요
**개선**:
- `src/binance_client.py` (또는 exchange.py)에 retry decorator 추가
- `tenacity` 라이브러리 사용 (exponential backoff + jitter)
- Rate limit(429) 감지 → 최대 5회 재시도
#### 제안 5: 실전 성능 대시보드 추가
**현황**: 백테스트 성능(PF 1.57~2.39)은 있으나, 실전 거래 성능 미기록
**개선**:
- `scripts/extract_live_stats.py` 추가
- 운영 대시보드 API(`GET /api/trades`, `GET /api/stats`) 조회 후 JSON 저장
- README에 "실전 거래 성능" 섹션 추가
---
## 8. 결론
### 8.1 종합 평가
| 항목 | 평가 | 비고 |
|------|------|------|
| 아키텍처 설계 | ⭐⭐⭐⭐ (90/100) | 5-레이어 분리 명확, 멀티심볼 구현 양호 |
| 코드 품질 | ⭐⭐⭐ (75/100) | 핵심 로직은 건실하나, 엣지 케이스 미흡 |
| 테스트 커버리지 | ⭐⭐⭐ (75/100) | 136개 케이스, 단위 테스트 양호 / WebSocket 미테스트 |
| 설계 문서 | ⭐⭐⭐ (80/100) | 47개 파일로 상세하나, 진행 상황 추적 미흡 |
| 운영 자동화 | ⭐⭐⭐ (80/100) | 주간 리포트 + CI/CD 갖춤 / 에러 자동 복구 부족 |
| **종합** | **⭐⭐⭐⭐ (80/100)** | **초기 단계 프로젝트로는 양호, 즉시 수정 필요 항목 2개** |
### 8.2 가동 여부 판단
**현재 가동 가능**: 예, 그러나 위험 요소 있음
**조건**:
1. **즉시**: 버그 #1, #2 수정 (합계 10분)
2. **당일**: 이슈 #4, #6, #8 수정 (합계 45분)
3. **이번 주**: 이슈 #5, #3(ML 활성화 계획 있으면) 수정
**위험 요소**:
- ❌ User Data Stream TP/SL이 미테스트 → 실제 청산이 작동하지 않을 가능성
- ❌ 멀티심볼 동시성: `record_pnl()` Lock 미사용 → 리스크 한도 부정확 가능
- ❌ 데이터 품질: Parquet 중복/타임존 미처리 → 지표 계산 오류 가능
### 8.3 다음 단계
**즉시 (오늘 중)**:
- [x] 버그 #1 수정: OI division by zero _(commit 60510c0)_
- [x] 버그 #2 수정: 누적 트레이드 계산 _(commit 60510c0)_
**당일 야간**:
- [x] 이슈 #4 수정: fetch_history retry 로직
- [x] 이슈 #6 수정: record_pnl asyncio.Lock _(commit 60510c0)_
- [x] 이슈 #8 수정: exit_price=0.0 방어 _(commit 60510c0)_
**이번 주**:
- [x] 이슈 #5 수정: Parquet 중복 제거 _(commit 60510c0)_
- [ ] 이슈 #13 수정: 타임존 처리
- [ ] 이슈 #3 분석: Training-Serving Skew (ML 재활성화 계획이면)
**다음 2주**:
- [ ] IMPLEMENTATION_STATUS.md 작성 (설계→구현→테스트→배포 추적)
- [ ] WebSocket 통합 테스트 작성
- [ ] 멀티심볼 동시성 테스트 작성
### 8.4 최종 의견
CoinTrader는 **아키텍처가 건실하고 설계 의도가 명확한 프로젝트**입니다. 5-레이어 분리, 멀티심볼 동시 거래, 완전한 MLOps 파이프라인 등은 초기 자동매매 봇 프로젝트 치고는 수준이 높습니다.
그러나 **즉시 수정이 필요한 버그 2개**(Division by Zero, 누적 계산 오류)와 **엣지 케이스 미흡**(WebSocket 미테스트, asyncio 경쟁 조건, API retry 부족)이 있어서, 실제 운영 환경에 투입하기 전에 최소 1주일의 안정화 기간이 필요합니다.
특히 **User Data Stream TP/SL 감지**가 미테스트되어 있다는 점이 가장 우려스럽습니다. 이 부분이 작동하지 않으면 포지션이 영구히 열려 있을 수 있습니다.
---
## 부록: 검토 범위
**검토 대상**:
- `README.md` — 사용자 가이드
- `ARCHITECTURE.md` — 기술 아키텍처
- 47개 설계/계획 문서 (2026-03-01 ~ 2026-03-07)
- 코드 리뷰 개선사항 (2026-03-07-code-review-improvements.md)
**검토 제외**:
- 실제 소스 코드 (src/, scripts/, tests/)
- 운영 로그 및 실전 거래 데이터
- Docker 설정 및 CI/CD 파이프라인 상세
**검토 방식**:
- 문서 정합성 검증
- 설계 결정 타당성 분석
- 버그 및 이슈 우선순위 검토
- 아키텍처 강점/약점 평가
---
**보고서 작성**: 2026-03-07
**담당자**: Claude AI
**버전**: 1.0

View File

@@ -0,0 +1,253 @@
# CoinTrader 코드 점검 보고서
> 작성일: 2026-03-16
> 대상: CoinTrader 전체 소스 코드 (bot.py, exchange.py, risk_manager.py, data_stream.py, user_data_stream.py, ml_filter.py, ml_features.py, config.py)
---
## 요약
| 심각도 | 건수 |
|--------|------|
| 🔴 심각 (버그 / 실제 자금 손실 위험) | 4 (✅ 전부 수정 완료) |
| 🟡 경고 (논리 오류 / 운영 리스크) | 6 (✅ 전부 수정 완료) |
| 🔵 개선 (코드 품질 / 유지보수) | 5 |
아키텍처 설계 자체(멀티심볼 독립 인스턴스, 공유 RiskManager)는 합리적이다. 문제는 멀티심볼 확장 과정에서 공유 상태(`RiskManager`)에 대한 동시성 처리가 불완전하고, 자금 관련 계산 로직(마진 비율, PnL 폴백)에 실제 버그가 존재한다는 점이다.
---
## 🔴 심각 — 버그 / 실제 자금 손실 위험
### 1. 마진 비율 계산 불일치 (`bot.py` L190-196)
**문제:**
```python
per_symbol_balance = balance / num_symbols # 심볼별로 나눔
margin_ratio = self.risk.get_dynamic_margin_ratio(balance) # 전체 잔고 기준
quantity = self.exchange.calculate_quantity(
balance=per_symbol_balance, # 나눈 값
margin_ratio=margin_ratio # 전체 기준 비율 → 불일치
)
```
`margin_ratio`는 전체 잔고 기준으로 계산되었는데, `per_symbol_balance`(나눈 값)에 곱해진다. 결과적으로 마진 비율 감소 효과가 의도한 것의 `num_symbols`배로 증폭된다.
**수정 방향:**
```python
per_symbol_balance = balance / num_symbols
margin_ratio = self.risk.get_dynamic_margin_ratio(per_symbol_balance) # 나눈 값 기준
```
또는 전체 잔고로 수량을 계산하고 나중에 심볼 수로 나누는 방식으로 통일해야 한다.
---
### 2. `_place_algo_order`의 `algoType="CONDITIONAL"` 하드코딩 (`exchange.py` L149)
**문제:**
```python
params = dict(
symbol=self.symbol,
side=side,
algoType="CONDITIONAL", # 하드코딩
type=order_type,
...
)
```
Binance FAPI `/fapi/v1/algoOrder``algoType``VP`, `TWAP` 등 실행 알고리즘용이다. `STOP_MARKET` / `TAKE_PROFIT_MARKET` 같은 조건부 주문은 `/fapi/v1/order``reduceOnly=true`로 전송해야 한다. 이 경로가 실제로 동작하지 않으면 SL/TP 주문이 아예 등록되지 않아 무한 손실 가능.
**수정 방향:** 테스트넷에서 즉시 검증. 실패 시 일반 `place_order` 경로로 대체하고 `_place_algo_order` 삭제.
---
### 3. 폴백 PnL 계산 오류 (`bot.py` L328-334)
**문제:**
```python
pnl_rows, comm_rows = await self.exchange.get_recent_income(limit=5)
if pnl_rows:
realized_pnl = float(pnl_rows[-1].get("income", "0")) # 마지막 1건만 사용
```
멀티심볼 환경에서 `limit=5` 조회 시 다른 심볼의 PnL이 섞일 수 있다. 마지막 항목 하나만 쓰는 것은 다중 체결 건이 있을 때 틀린 값을 기록한다. SYNC 청산에서 잘못된 PnL이 기록되면 `daily_pnl`이 오염되어 손실 한도 체크 자체가 무의미해진다.
**수정 방향:** 조회 시 `symbol` 파라미터로 필터링하고, 해당 포지션의 거래 ID 범위를 기준으로 합산해야 한다.
---
### 4. `_is_reentering` 타이밍 레이스 컨디션 (`bot.py` L401, L421)
**문제:**
```python
self._is_reentering = True
try:
await self._close_position(position) # 청산 주문 전송
# ← 이 시점에 User Data Stream 콜백 도착 가능
await self._open_position(signal, df) # 신규 진입
finally:
self._is_reentering = False
```
청산 주문 전송 직후 User Data Stream 콜백이 도착하면, `_is_reentering = True`인 상태에서 `risk.close_position`이 호출된다. 그 직후 `_open_position``risk.register_position`을 호출하며 상태가 겹친다. `asyncio`의 단일 스레드 특성 덕분에 `await` 사이에는 안전하지만, 콜백 순서와 타이밍에 따라 포지션 카운트가 틀어질 수 있다.
**수정 방향:** `_close_and_reenter` 내에서 포지션 상태 전환을 명시적으로 관리하고, `_on_position_closed`에서 `_is_reentering` 플래그를 확인하는 것 외에도 명시적인 상태 머신 전환을 추가한다.
---
## 🟡 경고 — 논리 오류 / 운영 리스크
### 5. `reset_daily()` 자동 호출 없음 (`risk_manager.py`)
메서드는 정의되어 있으나 어디서도 호출되지 않는다. 봇이 며칠 연속 실행되면 `daily_pnl`이 계속 누적되어 일일 손실 한도 체크가 무의미해진다.
**수정 방향:**
```python
# main.py 또는 bot.run() 내에서
async def _daily_reset_loop(risk: RiskManager):
while True:
now = datetime.utcnow()
next_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)
await asyncio.sleep((next_midnight - now).total_seconds())
risk.reset_daily()
```
---
### 6. 공유 `RiskManager`에서 `set_base_balance` 경쟁 조건 (`bot.py` L429)
`asyncio.gather`로 3개 봇이 거의 동시에 `run()`을 실행하면 각자 `set_base_balance(balance)`를 호출한다. 마지막으로 호출한 봇의 잔고로 덮어씌워지며, Lock이 없어 순서도 보장되지 않는다.
**수정 방향:** `initial_balance` 설정을 `main.py`에서 한 번만 수행하고 공유 RiskManager에 주입하거나, 설정 시 Lock으로 보호한다.
---
### 7. 진입 주문이 청산으로 잘못 판별 가능 (`user_data_stream.py` L89)
```python
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0
```
일부 상황에서 진입 주문 체결 시 소액의 `rp`(실현 손익)가 붙는 경우가 있다. `realized_pnl != 0` 단독 조건이 너무 넓어 진입 주문이 청산으로 잘못 처리될 수 있다.
**수정 방향:**
```python
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES
# realized_pnl != 0 조건 제거
```
---
### 8. 피처 컬럼명이 XRP에 하드코딩 (`ml_features.py` L10-11)
```python
FEATURE_COLS = [
...
"xrp_btc_rs", "xrp_eth_rs", # XRP 하드코딩
]
```
TRX/DOGE 봇도 동일한 피처명을 사용한다. 학습과 추론 간 컬럼명 불일치는 없지만, 의미가 잘못되어 있고 심볼별 모델 학습 시 혼란을 유발한다.
**수정 방향:** `build_features_aligned` 함수에서 심볼명을 동적으로 포함하거나, 컬럼명을 `primary_btc_rs`, `primary_eth_rs`로 범용화한다.
---
### 9. `asyncio.get_event_loop()` deprecated 패턴 (`exchange.py` 전반)
Python 3.10+에서 실행 중인 루프가 없을 때 `get_event_loop()``DeprecationWarning`을 발생시킨다.
**수정 방향:**
```python
# Before
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: ...)
# After
await asyncio.to_thread(lambda: ...)
# 또는
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: ...)
```
---
### 10. 프리로드가 순차적으로 처리됨 (`data_stream.py` L164-183)
```python
for symbol in self.symbols: # 순차 처리
klines = await client.futures_klines(...)
```
심볼 3개를 순차 REST 조회하면 시작 시간이 약 3배 길어진다.
**수정 방향:**
```python
async def _preload_one(client, symbol):
...
await asyncio.gather(*[_preload_one(client, s) for s in self.symbols])
```
---
## 🔵 개선 — 코드 품질 / 유지보수
### 11. `config.py` 데드 필드
`stop_loss_pct`, `take_profit_pct`, `trailing_stop_pct`가 dataclass 기본값으로만 존재하고 `__post_init__`에서 환경변수로 로드되지 않는다. `atr_sl_mult`/`atr_tp_mult`로 대체되었으나 정리되지 않았다. 혼란을 줄이기 위해 삭제하거나 환경변수 로드를 추가해야 한다.
---
### 12. 매 캔들마다 불필요한 REST API 조회 (`bot.py` L158)
```python
position = await self.exchange.get_position() # 15분마다 호출
```
`current_trade_side`로 로컬 상태를 이미 관리하고 있다. User Data Stream 콜백과 `_position_monitor` 폴백이 있으므로, `process_candle`에서는 로컬 상태만 확인하면 충분하다. 불필요한 API rate limit을 소비하고 있다.
---
### 13. `main.py` 파일 없음
README와 ARCHITECTURE.md에 진입점으로 언급되지만 실제 파일이 없다. 배포 시 어떻게 봇을 실행하는지 코드로 확인할 수 없다.
---
### 14. `MIN_NOTIONAL = 5.0` 하드코딩 (`exchange.py` L20)
Binance의 최소 명목금액은 심볼마다 다르고 정책 변경이 가능하다. `exchange_info``filters`에서 `MIN_NOTIONAL` 또는 `NOTIONAL` 필터를 읽어야 정확하다.
---
### 15. ML 필터 예측 오류 시 무조건 진입 차단 (`ml_filter.py` L153)
```python
except Exception as e:
logger.warning(f"ML 필터 예측 오류 (진입 차단): {e}")
return False # 모든 거래 차단
```
모델에 버그가 생기면 거래가 전면 중단된다. 오류 유형에 따라 `True`(폴백 허용)를 반환할지 `False`(차단)를 반환할지 구분하고, 오류 횟수를 카운팅하여 Discord 알림을 보내는 것이 바람직하다.
---
## 우선 처리 권장 순서
1. **즉시**: `_place_algo_order` API 경로 테스트넷 검증 (#2)
2. **즉시**: 마진 비율 계산 불일치 수정 (#1)
3. **이번 주**: `reset_daily()` 자동 호출 추가 (#5)
4. **이번 주**: `set_base_balance` 경쟁 조건 수정 (#6)
5. **이번 주**: 폴백 PnL 조회 로직 개선 (#3)
6. **다음 배포 전**: `is_close` 판별 조건 수정 (#7), `asyncio.get_event_loop` 교체 (#9), 프리로드 병렬화 (#10)

68
main.py
View File

@@ -1,4 +1,6 @@
import asyncio import asyncio
import signal
from datetime import datetime, timedelta, timezone
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger from loguru import logger
from src.config import Config from src.config import Config
@@ -9,18 +11,82 @@ from src.logger_setup import setup_logger
load_dotenv() load_dotenv()
async def _daily_reset_loop(risk: RiskManager):
"""매일 UTC 자정에 daily_pnl을 초기화한다."""
while True:
now = datetime.now(timezone.utc)
next_midnight = (now + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0,
)
await asyncio.sleep((next_midnight - now).total_seconds())
await risk.reset_daily()
async def _graceful_shutdown(bots: list[TradingBot], tasks: list[asyncio.Task]):
"""모든 봇의 오픈 주문 취소 후 태스크를 정리한다."""
logger.info("Graceful shutdown 시작 — 오픈 주문 취소 중...")
for bot in bots:
try:
await asyncio.wait_for(bot.exchange.cancel_all_orders(), timeout=5)
logger.info(f"[{bot.symbol}] 오픈 주문 취소 완료")
except Exception as e:
logger.warning(f"[{bot.symbol}] 오픈 주문 취소 실패 (무시): {e}")
for task in tasks:
task.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception) and not isinstance(r, asyncio.CancelledError):
logger.warning(f"태스크 종료 중 예외: {r}")
logger.info("Graceful shutdown 완료")
async def main(): async def main():
setup_logger(log_level="INFO") setup_logger(log_level="INFO")
config = Config() config = Config()
risk = RiskManager(config) risk = RiskManager(config)
# 기준 잔고를 main에서 한 번만 설정 (경쟁 조건 방지)
from src.exchange import BinanceFuturesClient
exchange = BinanceFuturesClient(config, symbol=config.symbols[0])
balance = await exchange.get_balance()
risk.set_base_balance(balance)
logger.info(f"기준 잔고 설정: {balance:.2f} USDT")
bots = [] bots = []
for symbol in config.symbols: for symbol in config.symbols:
bot = TradingBot(config, symbol=symbol, risk=risk) bot = TradingBot(config, symbol=symbol, risk=risk)
bots.append(bot) bots.append(bot)
logger.info(f"멀티심볼 봇 시작: {config.symbols} ({len(bots)}개 인스턴스)") logger.info(f"멀티심볼 봇 시작: {config.symbols} ({len(bots)}개 인스턴스)")
await asyncio.gather(*[bot.run() for bot in bots])
# 시그널 핸들러 등록
loop = asyncio.get_running_loop()
shutdown_event = asyncio.Event()
def _signal_handler():
logger.warning("종료 시그널 수신 (SIGTERM/SIGINT)")
shutdown_event.set()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _signal_handler)
tasks = [
asyncio.create_task(bot.run(), name=f"bot-{bot.symbol}")
for bot in bots
]
tasks.append(asyncio.create_task(_daily_reset_loop(risk), name="daily-reset"))
# 종료 시그널 대기 vs 태스크 완료 (먼저 발생하는 쪽)
shutdown_task = asyncio.create_task(shutdown_event.wait(), name="shutdown-wait")
done, pending = await asyncio.wait(
tasks + [shutdown_task],
return_when=asyncio.FIRST_COMPLETED,
)
# 시그널이든 태스크 종료든 graceful shutdown 수행
shutdown_task.cancel()
await _graceful_shutdown(bots, tasks)
if __name__ == "__main__": if __name__ == "__main__":

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
@@ -14,3 +14,5 @@ joblib>=1.3.0
pyarrow>=15.0.0 pyarrow>=15.0.0
onnxruntime>=1.18.0 onnxruntime>=1.18.0
optuna>=3.6.0 optuna>=3.6.0
quantstats>=0.0.81
ccxt>=4.5.0

View File

@@ -0,0 +1,86 @@
[
{
"symbol": "SOLUSDT",
"best_params": {
"atr_sl_mult": 1.0,
"atr_tp_mult": 4.0,
"signal_threshold": 3,
"adx_threshold": 20,
"volume_multiplier": 2.5
},
"summary": {
"total_trades": 31,
"total_pnl": 909.294,
"return_pct": 90.93,
"win_rate": 38.71,
"avg_win": 117.2904,
"avg_loss": -26.2206,
"payoff_ratio": 4.47,
"max_consecutive_losses": 6,
"profit_factor": 2.83,
"max_drawdown_pct": 10.87,
"sharpe_ratio": 57.43,
"total_fees": 117.2484,
"close_reasons": {
"TAKE_PROFIT": 12,
"STOP_LOSS": 19
}
}
},
{
"symbol": "LINKUSDT",
"best_params": {
"atr_sl_mult": 2.0,
"atr_tp_mult": 3.0,
"signal_threshold": 3,
"adx_threshold": 0,
"volume_multiplier": 2.5
},
"summary": {
"total_trades": 38,
"total_pnl": 12.3248,
"return_pct": 1.23,
"win_rate": 39.47,
"avg_win": 88.1543,
"avg_loss": -56.9561,
"payoff_ratio": 1.55,
"max_consecutive_losses": 5,
"profit_factor": 1.01,
"max_drawdown_pct": 24.28,
"sharpe_ratio": 0.67,
"total_fees": 142.4705,
"close_reasons": {
"TAKE_PROFIT": 15,
"STOP_LOSS": 23
}
}
},
{
"symbol": "AVAXUSDT",
"best_params": {
"atr_sl_mult": 1.5,
"atr_tp_mult": 3.0,
"signal_threshold": 3,
"adx_threshold": 25,
"volume_multiplier": 2.5
},
"summary": {
"total_trades": 20,
"total_pnl": 497.5511,
"return_pct": 49.76,
"win_rate": 55.0,
"avg_win": 90.6485,
"avg_loss": -55.5092,
"payoff_ratio": 1.63,
"max_consecutive_losses": 3,
"profit_factor": 2.0,
"max_drawdown_pct": 8.89,
"sharpe_ratio": 47.39,
"total_fees": 76.184,
"close_reasons": {
"STOP_LOSS": 9,
"TAKE_PROFIT": 11
}
}
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

0
results/weekly/.gitkeep Normal file
View File

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

203
scripts/compare_symbols.py Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
종목 비교 백테스트: 후보 심볼별 파라미터 sweep → 최적 파라미터 기준 비교표 출력.
사용법:
python scripts/compare_symbols.py --symbols SOLUSDT LINKUSDT AVAXUSDT
python scripts/compare_symbols.py --symbols SOLUSDT LINKUSDT AVAXUSDT --skip-fetch
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import json
import subprocess
from datetime import date
from loguru import logger
from src.backtester import WalkForwardBacktester, WalkForwardConfig
from scripts.strategy_sweep import generate_combinations, PARAM_GRID
TRAIN_MONTHS = 3
TEST_MONTHS = 1
FETCH_DAYS = 365
def fetch_data(symbols: list[str], days: int = FETCH_DAYS) -> None:
script = str(Path(__file__).parent / "fetch_history.py")
for sym in symbols:
cmd = [
sys.executable, script,
"--symbol", sym,
"--interval", "15m",
"--days", str(days),
]
logger.info(f"데이터 수집: {sym} (최근 {days}일)")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.error(f" {sym} 수집 실패: {result.stderr[:300]}")
else:
logger.info(f" {sym} 수집 완료")
def run_backtest(symbol: str, params: dict) -> dict:
cfg = WalkForwardConfig(
symbols=[symbol],
use_ml=False,
train_months=TRAIN_MONTHS,
test_months=TEST_MONTHS,
**params,
)
wf = WalkForwardBacktester(cfg)
return wf.run()
def sweep_symbol(symbol: str) -> dict:
"""심볼별 파라미터 sweep 실행 → 최적 조합 반환."""
combos = generate_combinations(PARAM_GRID)
logger.info(f"[{symbol}] 파라미터 sweep 시작: {len(combos)}개 조합")
best = None
best_params = None
for i, params in enumerate(combos):
try:
result = run_backtest(symbol, params)
summary = result["summary"]
# 거래 5건 미만은 스킵
if summary["total_trades"] < 5:
continue
# PF 기준으로 최적 선택 (동률 시 승률 → 손익비 순)
if best is None or _is_better(summary, best):
best = summary
best_params = params
except Exception as e:
logger.warning(f" [{symbol}] 조합 {i+1} 실패: {e}")
if (i + 1) % 50 == 0:
logger.info(f" [{symbol}] {i+1}/{len(combos)} 완료")
logger.info(f"[{symbol}] sweep 완료 → 최적 PF: {best['profit_factor'] if best else 'N/A'}")
return {"symbol": symbol, "best_params": best_params, "summary": best}
def _is_better(new: dict, old: dict) -> bool:
"""PF → 손익비 → 승률 순으로 비교."""
new_pf = new["profit_factor"] if new["profit_factor"] != float("inf") else 999
old_pf = old["profit_factor"] if old["profit_factor"] != float("inf") else 999
if new_pf != old_pf:
return new_pf > old_pf
new_pr = new.get("payoff_ratio", 0) or 0
old_pr = old.get("payoff_ratio", 0) or 0
if new_pr != old_pr:
return new_pr > old_pr
return new["win_rate"] > old["win_rate"]
def print_comparison(results: list[dict]) -> None:
header = (
f"{'심볼':<10} {'파라미터':^30} {'거래수':>6} {'승률':>7} "
f"{'손익비':>7} {'연속손실':>8} {'PF':>6} {'수익률':>8} {'MDD':>6} {'총PnL':>10}"
)
sep = "=" * len(header)
print(f"\n{sep}")
print("종목 비교 백테스트 결과 (심볼별 최적 파라미터)")
print(sep)
print(header)
print("-" * len(header))
for r in results:
s = r["summary"]
p = r["best_params"]
if not s or not p:
print(f"{r['symbol'].replace('USDT', ''):<10} {'데이터 부족 또는 sweep 실패':^30}")
continue
short = r["symbol"].replace("USDT", "")
param_str = f"SL={p['atr_sl_mult']}/TP={p['atr_tp_mult']}/ADX={p['adx_threshold']}"
pf = s["profit_factor"]
pf_str = f"{pf:.2f}" if pf != float("inf") else "INF"
print(
f"{short:<10} {param_str:^30} {s['total_trades']:>6} "
f"{s['win_rate']:>6.1f}% "
f"{s.get('payoff_ratio', 0):>7.2f} "
f"{s.get('max_consecutive_losses', 0):>8} "
f"{pf_str:>6} "
f"{s['return_pct']:>7.2f}% "
f"{s['max_drawdown_pct']:>5.1f}% "
f"{s['total_pnl']:>+10.2f}"
)
print("-" * len(header))
print("\n[판정 기준]")
print(" - 승률 50%+ & 손익비 1.0+ → 실전 지속 가능")
print(" - 연속 손실 5회 이하 → 멘탈 관리 가능")
print(" - 거래 20건+ → 통계적 유의성 있음")
print()
# 상세 파라미터 출력
print("[심볼별 최적 파라미터 상세]")
for r in results:
if r["best_params"]:
p = r["best_params"]
print(f" {r['symbol']}: SL={p['atr_sl_mult']}, TP={p['atr_tp_mult']}, "
f"Signal={p['signal_threshold']}, ADX={p['adx_threshold']}, "
f"Vol={p['volume_multiplier']}")
print()
def main():
parser = argparse.ArgumentParser(description="종목 비교 백테스트 (심볼별 파라미터 sweep)")
parser.add_argument(
"--symbols", nargs="+", required=True,
help="비교할 심볼 리스트 (e.g., SOLUSDT LINKUSDT AVAXUSDT)",
)
parser.add_argument("--skip-fetch", action="store_true", help="데이터 수집 스킵")
parser.add_argument("--days", type=int, default=FETCH_DAYS, help="데이터 수집 기간 (일)")
args = parser.parse_args()
# 1) 데이터 수집
if not args.skip_fetch:
fetch_data(args.symbols, args.days)
# 2) 심볼별 sweep
results = []
for sym in args.symbols:
try:
result = sweep_symbol(sym)
results.append(result)
except Exception as e:
logger.error(f" {sym} sweep 실패: {e}")
results.append({"symbol": sym, "best_params": None, "summary": None})
# 3) 비교표
if results:
print_comparison(results)
# 4) JSON 저장
out_dir = Path("results/compare")
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"compare_{date.today().isoformat()}.json"
with open(out_path, "w") as f:
json.dump(
[{
"symbol": r["symbol"],
"best_params": r["best_params"],
"summary": r["summary"],
} for r in results],
f, indent=2, ensure_ascii=False,
default=lambda x: str(x) if isinstance(x, float) and x == float("inf") else x,
)
logger.info(f"결과 저장: {out_path}")
if __name__ == "__main__":
main()

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()

View File

@@ -28,6 +28,35 @@ load_dotenv()
# 심볼 간 딜레이 없이 연속 요청하면 레이트 리밋(-1003) 발생 # 심볼 간 딜레이 없이 연속 요청하면 레이트 리밋(-1003) 발생
_REQUEST_DELAY = 0.3 # 초당 ~3.3 req → 안전 마진 충분 _REQUEST_DELAY = 0.3 # 초당 ~3.3 req → 안전 마진 충분
_FAPI_BASE = "https://fapi.binance.com" _FAPI_BASE = "https://fapi.binance.com"
_MAX_RETRIES = 3
async def _get_json_with_retry(
session: aiohttp.ClientSession,
url: str,
params: dict,
symbol: str,
) -> list | dict | None:
"""aiohttp GET 요청 + exponential backoff retry (최대 3회)."""
for attempt in range(_MAX_RETRIES):
try:
async with session.get(url, params=params) as resp:
if resp.status == 429:
wait = 2 ** (attempt + 1)
print(f" [{symbol}] Rate limit(429), {wait}초 후 재시도 ({attempt+1}/{_MAX_RETRIES})")
await asyncio.sleep(wait)
continue
resp.raise_for_status()
return await resp.json()
except Exception as e:
if attempt < _MAX_RETRIES - 1:
wait = 2 ** (attempt + 1)
print(f" [{symbol}] API 오류 ({e}), {wait}초 후 재시도 ({attempt+1}/{_MAX_RETRIES})")
await asyncio.sleep(wait)
else:
print(f" [{symbol}] API {_MAX_RETRIES}회 실패: {e}")
return None
return None
def _now_ms() -> int: def _now_ms() -> int:
@@ -44,12 +73,23 @@ async def _fetch_klines_with_client(
start_ts = int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp() * 1000) start_ts = int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp() * 1000)
all_klines = [] all_klines = []
while True: while True:
klines = await client.futures_klines( for attempt in range(3):
symbol=symbol, try:
interval=interval, klines = await client.futures_klines(
startTime=start_ts, symbol=symbol,
limit=1500, interval=interval,
) startTime=start_ts,
limit=1500,
)
break
except Exception as e:
if attempt < 2:
wait = 2 ** (attempt + 1)
print(f" [{symbol}] API 오류 ({e}), {wait}초 후 재시도 ({attempt+1}/3)")
await asyncio.sleep(wait)
else:
print(f" [{symbol}] API 3회 실패, 수집 중단: {e}")
raise
if not klines: if not klines:
break break
all_klines.extend(klines) all_klines.extend(klines)
@@ -137,8 +177,7 @@ async def _fetch_oi_hist(
"limit": 500, "limit": 500,
"startTime": start_ts, "startTime": start_ts,
} }
async with session.get(url, params=params) as resp: data = await _get_json_with_retry(session, url, params, symbol)
data = await resp.json()
if not data or not isinstance(data, list): if not data or not isinstance(data, list):
break break
@@ -188,8 +227,7 @@ async def _fetch_funding_rate(
"startTime": start_ts, "startTime": start_ts,
"limit": 1000, "limit": 1000,
} }
async with session.get(url, params=params) as resp: data = await _get_json_with_retry(session, url, params, symbol)
data = await resp.json()
if not data or not isinstance(data, list): if not data or not isinstance(data, list):
break break
@@ -311,6 +349,7 @@ def upsert_parquet(path: "Path | str", new_df: pd.DataFrame) -> pd.DataFrame:
if col in existing.columns: if col in existing.columns:
existing[col] = existing[col].fillna(0.0) existing[col] = existing[col].fillna(0.0)
existing = existing[~existing.index.duplicated(keep='last')]
return existing.sort_index() return existing.sort_index()

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

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
포지션 사이징 분석: Robust Monte Carlo 방식.
핵심: 백테스트 31건의 승률/손익비를 고정값으로 믿지 않고,
불확실성 범위(승률 30~45%, 손익비 3.0~5.0)를 넣어
worst-case 조합에서도 파산하지 않는 리스크 비중을 산출한다.
사용법:
python scripts/position_sizing_analysis.py --symbol SOLUSDT
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import numpy as np
from loguru import logger
from src.backtester import WalkForwardBacktester, WalkForwardConfig
def run_backtest(symbol: str, params: dict) -> dict:
cfg = WalkForwardConfig(
symbols=[symbol],
use_ml=False,
train_months=3,
test_months=1,
**params,
)
wf = WalkForwardBacktester(cfg)
return wf.run()
def extract_r_multiples(trades: list[dict]) -> np.ndarray:
"""각 트레이드의 R-multiple을 추출 (1R = SL 히트 시 손실)."""
r_multiples = []
for t in trades:
sl_distance = abs(t["entry_price"] - t["sl"])
sl_loss = sl_distance * t["quantity"]
if sl_loss <= 0:
continue
r_multiples.append(t["net_pnl"] / sl_loss)
return np.array(r_multiples)
def kelly_criterion(win_rate: float, avg_win_r: float, avg_loss_r: float) -> float:
"""Kelly: f* = (W * avg_win_R - (1-W) * |avg_loss_R|) / avg_win_R"""
if avg_win_r <= 0:
return 0.0
expectancy = win_rate * avg_win_r - (1 - win_rate) * abs(avg_loss_r)
if expectancy <= 0:
return 0.0
return expectancy / avg_win_r
def consecutive_loss_survival(risk_pct: float, n: int) -> float:
"""n연패 후 잔고 비율(%)."""
return (1 - risk_pct) ** n * 100
def robust_monte_carlo(
risk_pct: float,
win_rate_range: tuple[float, float],
payoff_range: tuple[float, float],
loss_r_range: tuple[float, float],
n_simulations: int = 10000,
n_trades: int = 200,
initial_balance: float = 1000.0,
ruin_threshold: float = 0.20,
) -> dict:
"""Robust Monte Carlo: 매 시뮬레이션마다 승률/손익비를 범위 내에서 샘플링.
각 시뮬레이션:
1) 승률을 win_rate_range에서 uniform 추출
2) 승리 R-multiple을 payoff_range에서 uniform 추출
3) 패배 R-multiple을 loss_r_range에서 uniform 추출
4) 해당 파라미터로 n_trades건을 생성하여 에퀴티 시뮬레이션
"""
rng = np.random.default_rng(42)
final_balances = np.zeros(n_simulations)
max_drawdowns = np.zeros(n_simulations)
ruin_count = 0
for sim in range(n_simulations):
# 파라미터 샘플링
wr = rng.uniform(*win_rate_range)
win_r = rng.uniform(*payoff_range)
loss_r = rng.uniform(*loss_r_range)
# 트레이드 생성
outcomes = rng.random(n_trades)
r_multiples = np.where(outcomes < wr, win_r, loss_r)
# 에퀴티 시뮬레이션
balance = initial_balance
peak = balance
max_dd = 0.0
ruined = False
for r in r_multiples:
pnl = balance * risk_pct * r
balance += pnl
if balance <= initial_balance * ruin_threshold:
ruined = True
break
peak = max(peak, balance)
dd = (peak - balance) / peak
max_dd = max(max_dd, dd)
if ruined:
ruin_count += 1
final_balances[sim] = 0
max_drawdowns[sim] = 1.0
else:
final_balances[sim] = balance
max_drawdowns[sim] = max_dd
return {
"risk_pct": risk_pct,
"ruin_probability": round(ruin_count / n_simulations * 100, 2),
"median_return": round((np.median(final_balances) - initial_balance) / initial_balance * 100, 1),
"p5_return": round((np.percentile(final_balances, 5) - initial_balance) / initial_balance * 100, 1),
"p25_return": round((np.percentile(final_balances, 25) - initial_balance) / initial_balance * 100, 1),
"p75_return": round((np.percentile(final_balances, 75) - initial_balance) / initial_balance * 100, 1),
"p95_return": round((np.percentile(final_balances, 95) - initial_balance) / initial_balance * 100, 1),
"median_max_dd": round(np.median(max_drawdowns) * 100, 1),
"p95_max_dd": round(np.percentile(max_drawdowns, 95) * 100, 1),
}
def main():
parser = argparse.ArgumentParser(description="포지션 사이징 분석 (Robust Monte Carlo)")
parser.add_argument("--symbol", required=True, type=str)
parser.add_argument("--sl-mult", type=float, default=1.0)
parser.add_argument("--tp-mult", type=float, default=4.0)
parser.add_argument("--signal-threshold", type=int, default=3)
parser.add_argument("--adx", type=float, default=20)
parser.add_argument("--vol-mult", type=float, default=2.5)
args = parser.parse_args()
symbol = args.symbol.upper()
params = {
"atr_sl_mult": args.sl_mult,
"atr_tp_mult": args.tp_mult,
"signal_threshold": args.signal_threshold,
"adx_threshold": args.adx,
"volume_multiplier": args.vol_mult,
}
# 1) 백테스트로 기준값 추출
logger.info(f"[{symbol}] 백테스트 실행")
result = run_backtest(symbol, params)
trades = result.get("trades", [])
summary = result["summary"]
if len(trades) < 5:
logger.error(f"트레이드 {len(trades)}건 — 분석 불가")
return
r_multiples = extract_r_multiples(trades)
wins = r_multiples[r_multiples > 0]
losses = r_multiples[r_multiples <= 0]
obs_wr = len(wins) / len(r_multiples)
obs_win_r = float(np.mean(wins)) if len(wins) > 0 else 0
obs_loss_r = float(np.mean(losses)) if len(losses) > 0 else 0
obs_expectancy = obs_wr * obs_win_r + (1 - obs_wr) * obs_loss_r
obs_kelly = kelly_criterion(obs_wr, obs_win_r, abs(obs_loss_r))
# 불확실성 범위 설정 (관측값 기준 ±보정)
# 승률: 관측 38.7% → 30~45% (하방으로 더 넓게)
wr_lo = max(0.25, obs_wr - 0.10)
wr_hi = min(0.55, obs_wr + 0.07)
# 승리 R: 관측 3.85R → 3.0~5.0
win_r_lo = max(2.0, obs_win_r - 1.0)
win_r_hi = obs_win_r + 1.2
# 패배 R: 관측 -1.18R → -1.5 ~ -0.9
loss_r_lo = min(-0.8, obs_loss_r + 0.3)
loss_r_hi = obs_loss_r - 0.3
print(f"\n{'=' * 85}")
print(f" 포지션 사이징 분석: {symbol} (Robust Monte Carlo)")
print(f" 파라미터: SL={args.sl_mult}x ATR, TP={args.tp_mult}x ATR, ADX={args.adx}")
print(f"{'=' * 85}")
# 관측값
print(f"\n[백테스트 관측값] ({len(trades)}건)")
print(f" 승률: {obs_wr*100:.1f}% | 승리 평균: +{obs_win_r:.2f}R | 패배 평균: {obs_loss_r:.2f}R")
print(f" 기대값: {obs_expectancy:.2f}R | Kelly: {obs_kelly*100:.1f}%")
print(f" 최대 연속 손실: {summary.get('max_consecutive_losses', 'N/A')}")
# R-multiple 분포 (간략)
print(f"\n R 분포: 패배 {obs_loss_r:.2f}R (SL+수수료) | 승리 +{obs_win_r:.2f}R (TP-수수료)")
print(f" → 거의 바이너리: SL 아니면 TP, 중간 청산 없음")
# 불확실성 범위
print(f"\n[불확실성 범위] (실전 괴리 반영)")
print(f" 승률: {wr_lo*100:.0f}% ~ {wr_hi*100:.0f}% (관측: {obs_wr*100:.1f}%)")
print(f" 승리 R: +{win_r_lo:.1f}R ~ +{win_r_hi:.1f}R (관측: +{obs_win_r:.2f}R)")
print(f" 패배 R: {loss_r_hi:.1f}R ~ {loss_r_lo:.1f}R (관측: {obs_loss_r:.2f}R)")
# Worst-case Kelly
worst_kelly = kelly_criterion(wr_lo, win_r_lo, abs(loss_r_hi))
best_kelly = kelly_criterion(wr_hi, win_r_hi, abs(loss_r_lo))
print(f"\n Worst-case Kelly: {worst_kelly*100:.1f}% | Best-case Kelly: {best_kelly*100:.1f}%")
# 연속 손실 생존 테이블
print(f"\n[연속 손실 생존 테이블]")
print(f" {'리스크%':>8} {'4연패':>7} {'6연패':>7} {'8연패':>7} {'10연패':>7} {'12연패':>7}")
print(f" {'-' * 50}")
for rp in [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05]:
cols = [f"{consecutive_loss_survival(rp, n):.1f}%" for n in [4, 6, 8, 10, 12]]
print(f" {rp*100:>7.1f}% {' '.join(f'{c:>7}' for c in cols)}")
# Robust Monte Carlo
risk_levels = [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.07]
print(f"\n[Robust Monte Carlo (10,000회 × 200건, 파라미터 매회 랜덤 샘플링)]")
print(f" 파산 기준: 잔고 ≤ 20%")
print(f" {'리스크%':>8} {'파산%':>6} {'하위5%':>9} {'하위25%':>9} {'중위':>9} "
f"{'상위75%':>9} {'상위95%':>10} {'중위MDD':>7} {'95%MDD':>7}")
print(f" {'-' * 85}")
best_risk = None
best_score = -999
mc_results = []
for rp in risk_levels:
mc = robust_monte_carlo(
risk_pct=rp,
win_rate_range=(wr_lo, wr_hi),
payoff_range=(win_r_lo, win_r_hi),
loss_r_range=(loss_r_hi, loss_r_lo), # hi is more negative
)
mc_results.append(mc)
# 숫자 포맷
def fmt_ret(v):
if abs(v) >= 10000:
return f"{v/1000:>+7.0f}k%"
return f"{v:>+8.1f}%"
print(f" {rp*100:>7.1f}% {mc['ruin_probability']:>5.1f}% "
f"{fmt_ret(mc['p5_return'])} {fmt_ret(mc['p25_return'])} "
f"{fmt_ret(mc['median_return'])} {fmt_ret(mc['p75_return'])} "
f"{fmt_ret(mc['p95_return']):>10} {mc['median_max_dd']:>6.1f}% "
f"{mc['p95_max_dd']:>6.1f}%")
# 선정 기준: 파산 <1% AND 95%MDD ≤ 30% 에서 중위 수익 최대
if (mc["ruin_probability"] <= 1.0
and mc["p95_max_dd"] <= 30.0
and mc["median_return"] > best_score):
best_score = mc["median_return"]
best_risk = rp
# Worst-case 전용 MC (승률 30%, 손익비 3.0 고정)
print(f"\n[Worst-Case 시나리오 (승률={wr_lo*100:.0f}%, 승리R=+{win_r_lo:.1f}, 패배R={loss_r_hi:.1f})]")
print(f" {'리스크%':>8} {'파산%':>6} {'중위수익':>9} {'95%MDD':>7}")
print(f" {'-' * 35}")
worst_best_risk = None
worst_best_score = -999
for rp in risk_levels:
mc = robust_monte_carlo(
risk_pct=rp,
win_rate_range=(wr_lo, wr_lo + 0.001), # 거의 고정
payoff_range=(win_r_lo, win_r_lo + 0.001),
loss_r_range=(loss_r_hi, loss_r_hi + 0.001),
)
def fmt_ret(v):
if abs(v) >= 10000:
return f"{v/1000:>+7.0f}k%"
return f"{v:>+8.1f}%"
print(f" {rp*100:>7.1f}% {mc['ruin_probability']:>5.1f}% "
f"{fmt_ret(mc['median_return'])} {mc['p95_max_dd']:>6.1f}%")
if (mc["ruin_probability"] <= 1.0
and mc["p95_max_dd"] <= 30.0
and mc["median_return"] > worst_best_score):
worst_best_score = mc["median_return"]
worst_best_risk = rp
# 최종 권장
print(f"\n{'=' * 85}")
print(f" 최종 권장")
print(f"{'=' * 85}")
# 가장 보수적인 값: worst-case MC 최적과 robust MC 최적 중 작은 값
candidates = [r for r in [best_risk, worst_best_risk, worst_kelly / 2] if r and r > 0]
recommended = min(candidates) if candidates else 0.01
recommended = max(0.005, min(recommended, 0.05))
print(f" Robust MC 최적 (파산<1%, 95%MDD≤30%): {best_risk*100:.1f}%" if best_risk else " Robust MC: 조건 충족 없음")
print(f" Worst-Case MC 최적: {worst_best_risk*100:.1f}%" if worst_best_risk else " Worst-Case MC: 조건 충족 없음")
print(f" Worst-Case Half Kelly: {worst_kelly/2*100:.1f}%")
print(f"\n >>> 실전 권장: 1회 리스크 = 계좌의 {recommended*100:.1f}%")
print(f" 근거: worst-case에서도 파산하지 않는 가장 보수적 기준")
survival_6 = consecutive_loss_survival(recommended, 6)
survival_10 = consecutive_loss_survival(recommended, 10)
print(f" 6연패 후 잔고: {survival_6:.1f}% | 10연패 후: {survival_10:.1f}%")
print(f"\n [.env 설정 가이드]")
print(f" ATR_SL_MULT_SOLUSDT={args.sl_mult}")
print(f" ATR_TP_MULT_SOLUSDT={args.tp_mult}")
print(f" ADX_THRESHOLD_SOLUSDT={args.adx}")
print(f" SIGNAL_THRESHOLD_SOLUSDT={args.signal_threshold}")
for atr_pct in [0.01, 0.012, 0.015]:
margin_ratio = recommended / (10 * atr_pct)
margin_ratio = min(margin_ratio, 0.50)
print(f" ATR≈{atr_pct*100:.1f}% → MARGIN_MAX_RATIO_SOLUSDT ≈ {margin_ratio:.2f}")
print()
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)
@@ -73,7 +74,7 @@ def _process_index(args: tuple) -> dict | None:
if df_ind.iloc[-1].isna().any(): if df_ind.iloc[-1].isna().any():
return None return None
signal = ind.get_signal(df_ind) signal, _ = ind.get_signal(df_ind)
if signal == "HOLD": if signal == "HOLD":
return None return None
@@ -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())

607
scripts/weekly_report.py Normal file
View File

@@ -0,0 +1,607 @@
#!/usr/bin/env python3
"""
주간 전략 리포트: 데이터 수집 → WF 백테스트 → 실전 로그 → 추이 → Discord 알림.
사용법:
python scripts/weekly_report.py
python scripts/weekly_report.py --skip-fetch
python scripts/weekly_report.py --date 2026-03-07
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import json
import os
import subprocess
from datetime import date, timedelta
import httpx
import numpy as np
import pandas as pd
from dotenv import load_dotenv
from loguru import logger
load_dotenv()
from src.backtester import WalkForwardBacktester, WalkForwardConfig
from src.notifier import DiscordNotifier
# ── 프로덕션 파라미터 ──────────────────────────────────────────────
SYMBOLS = ["XRPUSDT"]
PROD_PARAMS = {
"atr_sl_mult": 1.5,
"atr_tp_mult": 4.0,
"signal_threshold": 3,
"adx_threshold": 25,
"volume_multiplier": 2.5,
}
TRAIN_MONTHS = 3
TEST_MONTHS = 1
FETCH_DAYS = 35
def fetch_latest_data(symbols: list[str], days: int = FETCH_DAYS) -> None:
"""심볼별로 fetch_history.py를 subprocess로 호출하여 최신 데이터를 수집한다."""
script = str(Path(__file__).parent / "fetch_history.py")
for sym in symbols:
cmd = [
sys.executable, script,
"--symbol", sym,
"--interval", "15m",
"--days", str(days),
]
logger.info(f"데이터 수집: {sym} (최근 {days}일)")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.warning(f" {sym} 수집 실패: {result.stderr[:200]}")
else:
logger.info(f" {sym} 수집 완료")
def run_backtest(
symbols: list[str],
train_months: int,
test_months: int,
params: dict,
) -> dict:
"""현재 파라미터로 Walk-Forward 백테스트를 실행하고 결과를 반환한다."""
cfg = WalkForwardConfig(
symbols=symbols,
use_ml=False,
train_months=train_months,
test_months=test_months,
**params,
)
wf = WalkForwardBacktester(cfg)
return wf.run()
# ── 대시보드 API에서 실전 트레이드 가져오기 ──────────────────────────
DASHBOARD_API_URL = os.getenv("DASHBOARD_API_URL", "http://10.1.10.24:8000")
def fetch_live_trades(api_url: str = DASHBOARD_API_URL, limit: int = 500) -> list[dict]:
"""운영 LXC 대시보드 API에서 청산된 트레이드 내역을 가져온다."""
try:
resp = httpx.get(f"{api_url}/api/trades", params={"limit": limit}, timeout=10)
resp.raise_for_status()
return resp.json().get("trades", [])
except Exception as e:
logger.warning(f"대시보드 API 트레이드 조회 실패: {e}")
return []
def fetch_live_stats(api_url: str = DASHBOARD_API_URL) -> dict:
"""운영 LXC 대시보드 API에서 전체 통계를 가져온다."""
try:
resp = httpx.get(f"{api_url}/api/stats", timeout=10)
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.warning(f"대시보드 API 통계 조회 실패: {e}")
return {}
# ── 추이 추적 ────────────────────────────────────────────────────
WEEKLY_DIR = Path("results/weekly")
def load_trend(report_dir: str, weeks: int = 4) -> dict:
"""이전 주간 리포트에서 PF/승률/MDD 추이를 로드한다."""
rdir = Path(report_dir)
if not rdir.exists():
return {"pf": [], "win_rate": [], "mdd": [], "pf_declining_3w": False}
reports = sorted(rdir.glob("report_*.json"))
recent = reports[-weeks:] if len(reports) >= weeks else reports
pf_list, wr_list, mdd_list = [], [], []
for rpath in recent:
try:
data = json.loads(rpath.read_text())
s = data["backtest"]["summary"]
pf_list.append(s["profit_factor"])
wr_list.append(s["win_rate"])
mdd_list.append(s["max_drawdown_pct"])
except (json.JSONDecodeError, KeyError):
continue
declining = False
if len(pf_list) >= 3:
last3 = pf_list[-3:]
declining = last3[0] > last3[1] > last3[2]
return {
"pf": pf_list,
"win_rate": wr_list,
"mdd": mdd_list,
"pf_declining_3w": declining,
}
# ── ML 재학습 트리거 & 성능 저하 스윕 ─────────────────────────────
from scripts.strategy_sweep import (
run_single_backtest,
generate_combinations,
PARAM_GRID,
)
ML_TRADE_THRESHOLD = 150
def check_ml_trigger(
cumulative_trades: int,
current_pf: float,
pf_declining_3w: bool,
) -> dict:
"""ML 재학습 조건 체크. 3개 중 2개 이상 충족 시 권장."""
conditions = {
"cumulative_trades_enough": cumulative_trades >= ML_TRADE_THRESHOLD,
"pf_below_1": current_pf < 1.0,
"pf_declining_3w": pf_declining_3w,
}
met = sum(conditions.values())
return {
"conditions": conditions,
"met_count": met,
"recommend": met >= 2,
"cumulative_trades": cumulative_trades,
"threshold": ML_TRADE_THRESHOLD,
}
def run_degradation_sweep(
symbols: list[str],
train_months: int,
test_months: int,
top_n: int = 3,
) -> list[dict]:
"""전체 파라미터 스윕을 실행하고 PF 상위 N개 대안을 반환한다."""
combos = generate_combinations(PARAM_GRID)
results = []
for params in combos:
try:
summary = run_single_backtest(symbols, params, train_months, test_months)
results.append({"params": params, "summary": summary})
except Exception as e:
logger.warning(f"스윕 실패: {e}")
results.sort(
key=lambda r: r["summary"]["profit_factor"]
if r["summary"]["profit_factor"] != float("inf") else 999,
reverse=True,
)
return results[:top_n]
# ── 킬스위치 모니터링 ──────────────────────────────────────────────
_KILL_HISTORY_DIR = Path("data/trade_history")
_FAST_KILL_STREAK = 8
_SLOW_KILL_WINDOW = 15
_SLOW_KILL_PF_THRESHOLD = 0.75
def load_kill_switch_status(symbols: list[str]) -> dict[str, dict]:
"""심볼별 킬스위치 지표를 거래 이력 파일에서 산출한다."""
result = {}
for sym in symbols:
path = _KILL_HISTORY_DIR / f"{sym.lower()}.jsonl"
trades: list[dict] = []
if path.exists():
try:
with open(path) as f:
for line in f:
line = line.strip()
if line:
trades.append(json.loads(line))
except Exception:
pass
# 현재 연속 손실 카운트 (뒤에서부터)
consec_loss = 0
for t in reversed(trades):
if t.get("net_pnl", 0) < 0:
consec_loss += 1
else:
break
# 최근 15거래 PF
recent_pf = None
if len(trades) >= _SLOW_KILL_WINDOW:
recent = trades[-_SLOW_KILL_WINDOW:]
gp = sum(t["net_pnl"] for t in recent if t["net_pnl"] > 0)
gl = abs(sum(t["net_pnl"] for t in recent if t["net_pnl"] < 0))
recent_pf = round(gp / gl, 2) if gl > 0 else float("inf")
# 킬 상태 판정
killed = (
consec_loss >= _FAST_KILL_STREAK
or (recent_pf is not None and recent_pf < _SLOW_KILL_PF_THRESHOLD)
)
result[sym] = {
"total_trades": len(trades),
"consec_loss": consec_loss,
"recent_pf": recent_pf,
"killed": killed,
}
return result
# ── Discord 리포트 포맷 & 전송 ─────────────────────────────────────
_EMOJI_CHART = "\U0001F4CA"
_EMOJI_ALERT = "\U0001F6A8"
_EMOJI_BELL = "\U0001F514"
_CHECK = "\u2705"
_UNCHECK = "\u2610"
_WARN = "\u26A0"
_ARROW = "\u2192"
def format_report(data: dict) -> str:
"""리포트 데이터를 Discord 메시지 텍스트로 포맷한다."""
d = data["date"]
bt = data["backtest"]["summary"]
pf = bt["profit_factor"]
pf_str = f"{pf:.2f}" if pf != float("inf") else "INF"
status = ""
if pf < 1.0:
status = f" {_EMOJI_ALERT} 손실 구간"
lines = [
f"{_EMOJI_CHART} 주간 전략 리포트 ({d})",
"",
"[현재 성능 — Walk-Forward 백테스트]",
f" 합산 PF: {pf_str} | 승률: {bt['win_rate']:.0f}% | MDD: {bt['max_drawdown_pct']:.0f}%{status}",
]
# 심볼별 성능
per_sym = data["backtest"].get("per_symbol", {})
if per_sym:
sym_parts = []
for sym, s in per_sym.items():
short = sym.replace("USDT", "")
spf = f"{s['profit_factor']:.2f}" if s["profit_factor"] != float("inf") else "INF"
sym_parts.append(f"{short}: PF {spf} ({s['total_trades']}건)")
lines.append(f" {' | '.join(sym_parts)}")
# 실전 트레이드
lt = data["live_trades"]
if lt["count"] > 0:
lines += [
"",
"[실전 트레이드 (이번 주)]",
f" 거래: {lt['count']}건 | 순수익: {lt['net_pnl']:+.2f} USDT | 승률: {lt['win_rate']:.1f}%",
]
# 추이
trend = data["trend"]
if trend["pf"]:
pf_trend = f" {_ARROW} ".join(f"{v:.2f}" for v in trend["pf"])
warn = f" {_WARN} 하락 추세" if trend["pf_declining_3w"] else ""
pf_len = len(trend["pf"])
lines += ["", f"[추이 (최근 {pf_len}주)]", f" PF: {pf_trend}{warn}"]
if trend["win_rate"]:
wr_trend = f" {_ARROW} ".join(f"{v:.0f}%" for v in trend["win_rate"])
lines.append(f" 승률: {wr_trend}")
if trend["mdd"]:
mdd_trend = f" {_ARROW} ".join(f"{v:.0f}%" for v in trend["mdd"])
lines.append(f" MDD: {mdd_trend}")
# 킬스위치 모니터링
ks = data.get("kill_switch", {})
if ks:
lines += ["", "[킬스위치 모니터링]"]
for sym, status in ks.items():
short = sym.replace("USDT", "")
cl = status["consec_loss"]
# 연속 손실 경고: 6회 이상이면 ⚠
cl_warn = f" {_WARN}" if cl >= 6 else ""
cl_str = f"연속손실 {cl}/{_FAST_KILL_STREAK}{cl_warn}"
# PF 표시
rpf = status["recent_pf"]
if rpf is not None:
pf_str = f"{_SLOW_KILL_WINDOW}거래PF {rpf:.2f}"
else:
n = status["total_trades"]
pf_str = f"{_SLOW_KILL_WINDOW}거래PF -.-- ({n}건)"
# KILLED 표시
kill_tag = " \U0001F534 KILLED" if status["killed"] else ""
lines.append(f" {short}: {cl_str} | {pf_str}{kill_tag}")
# ML 재도전 체크리스트
ml = data["ml_trigger"]
cond = ml["conditions"]
threshold = ml["threshold"]
cum_trades = ml["cumulative_trades"]
c1 = _CHECK if cond["cumulative_trades_enough"] else _UNCHECK
c2 = _CHECK if cond["pf_below_1"] else _UNCHECK
c3 = _CHECK if cond["pf_declining_3w"] else _UNCHECK
pf_below_label = "" if cond["pf_below_1"] else "아니오"
pf_dec_label = f"{_WARN}" if cond["pf_declining_3w"] else "아니오"
lines += [
"",
"[ML 재도전 체크리스트]",
f" {c1} 누적 트레이드 \u2265 {threshold}건: {cum_trades}/{threshold}",
f" {c2} PF < 1.0: {pf_below_label} (현재 {pf_str})",
f" {c3} PF 3주 연속 하락: {pf_dec_label}",
]
met_count = ml["met_count"]
if ml["recommend"]:
lines.append(f" {_ARROW} {_EMOJI_BELL} ML 재학습 권장! ({met_count}/3 충족)")
else:
lines.append(f" {_ARROW} ML 재도전 시점: 아직 아님 ({met_count}/3 충족)")
# 파라미터 스윕
sweep = data.get("sweep")
if sweep:
lines += ["", "[파라미터 스윕 결과]"]
lines.append(f" 현재: {_param_str(PROD_PARAMS)} {_ARROW} PF {pf_str}")
for i, alt in enumerate(sweep):
apf = alt["summary"]["profit_factor"]
apf_str = f"{apf:.2f}" if apf != float("inf") else "INF"
diff = apf - pf
idx = i + 1
lines.append(f" 대안 {idx}: {_param_str(alt['params'])} {_ARROW} PF {apf_str} ({diff:+.2f})")
lines.append("")
lines.append(f" {_WARN} 자동 적용되지 않음. 검토 후 승인 필요.")
elif pf >= 1.0:
lines += ["", "[파라미터 스윕]", " 현재 파라미터가 최적 — 스윕 불필요"]
return "\n".join(lines)
def _param_str(p: dict) -> str:
return (f"SL={p.get('atr_sl_mult', '?')}, TP={p.get('atr_tp_mult', '?')}, "
f"ADX={p.get('adx_threshold', '?')}, Vol={p.get('volume_multiplier', '?')}")
def send_report(content: str, webhook_url: str | None = None) -> None:
"""Discord 웹훅으로 리포트를 전송한다."""
url = webhook_url or os.getenv("DISCORD_WEBHOOK_URL", "")
if not url:
logger.warning("DISCORD_WEBHOOK_URL이 설정되지 않아 전송 스킵")
return
notifier = DiscordNotifier(url)
notifier._send(content)
logger.info("Discord 리포트 전송 완료")
def _sanitize(obj):
"""JSON 직렬화를 위해 numpy/inf 값을 변환."""
if isinstance(obj, (bool, np.bool_)):
return bool(obj)
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return float(obj)
if isinstance(obj, float) and (obj == float("inf") or obj == float("-inf")):
return str(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
def generate_quantstats_report(
trades: list[dict],
output_path: str,
title: str = "CoinTrader 주간 전략 리포트",
initial_balance: float = 1000.0,
) -> str | None:
"""백테스트 트레이드 결과로 quantstats HTML 리포트를 생성한다."""
if not trades:
logger.warning("트레이드가 없어 quantstats 리포트를 생성할 수 없습니다.")
return None
try:
import quantstats as qs
# 트레이드 PnL을 일별 수익률 시계열로 변환
records = []
for t in trades:
exit_time = pd.Timestamp(t["exit_time"])
records.append({"date": exit_time.date(), "pnl": t["net_pnl"]})
df = pd.DataFrame(records)
daily_pnl = df.groupby("date")["pnl"].sum()
daily_pnl.index = pd.to_datetime(daily_pnl.index)
daily_pnl = daily_pnl.sort_index()
# PnL → 수익률로 변환 (equity 기반)
equity = initial_balance + daily_pnl.cumsum()
returns = equity.pct_change().fillna(daily_pnl.iloc[0] / initial_balance)
qs.reports.html(returns, output=output_path, title=title, download_filename=output_path)
logger.info(f"quantstats HTML 리포트 저장: {output_path}")
return output_path
except Exception as e:
logger.warning(f"quantstats 리포트 생성 실패: {e}")
return None
def save_report(report: dict, report_dir: str) -> Path:
"""리포트를 JSON으로 저장하고 경로를 반환한다."""
rdir = Path(report_dir)
rdir.mkdir(parents=True, exist_ok=True)
path = rdir / f"report_{report['date']}.json"
with open(path, "w") as f:
json.dump(_sanitize(report), f, indent=2, ensure_ascii=False)
logger.info(f"리포트 저장: {path}")
return path
def _calc_combined_summary(trades: list[dict], initial_balance: float = 1000.0) -> dict:
"""개별 트레이드 리스트에서 합산 지표를 직접 계산한다."""
if not trades:
return {
"profit_factor": 0.0, "win_rate": 0.0, "max_drawdown_pct": 0.0,
"total_trades": 0, "total_pnl": 0.0,
}
pnls = [t["net_pnl"] 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.0
gross_loss = abs(sum(losses)) if losses else 0.0
# 시간순 정렬 후 포트폴리오 equity curve 기반 MDD
sorted_trades = sorted(trades, key=lambda t: t["exit_time"])
sorted_pnls = [t["net_pnl"] for t in sorted_trades]
cumulative = np.cumsum(sorted_pnls)
equity = initial_balance + cumulative
peak = np.maximum.accumulate(equity)
drawdown = (peak - equity) / peak
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
return {
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
"win_rate": round(len(wins) / len(trades) * 100, 1),
"max_drawdown_pct": round(mdd, 1),
"total_trades": len(trades),
"total_pnl": round(sum(pnls), 2),
}
def generate_report(
symbols: list[str],
report_dir: str = str(WEEKLY_DIR),
report_date: date | None = None,
api_url: str | None = None,
) -> dict:
"""전체 주간 리포트를 생성한다."""
today = report_date or date.today()
dashboard_url = api_url or DASHBOARD_API_URL
# 1) Walk-Forward 백테스트 (심볼별)
logger.info("백테스트 실행 중...")
bt_results = {}
all_bt_trades = []
for sym in symbols:
result = run_backtest([sym], TRAIN_MONTHS, TEST_MONTHS, PROD_PARAMS)
bt_results[sym] = result["summary"]
all_bt_trades.extend(result.get("trades", []))
# 합산 지표를 개별 트레이드에서 직접 계산 (간접 역산 제거)
backtest_summary = _calc_combined_summary(all_bt_trades)
# 2) 운영 대시보드 API에서 실전 트레이드 조회
logger.info(f"대시보드 API에서 실전 트레이드 조회 중... ({dashboard_url})")
live_stats = fetch_live_stats(dashboard_url)
live_trades_list = fetch_live_trades(dashboard_url)
live_count = live_stats.get("total_trades", len(live_trades_list))
live_wins = live_stats.get("wins", 0)
live_pnl = live_stats.get("total_pnl", 0)
live_summary = {
"count": live_count,
"net_pnl": round(float(live_pnl), 2),
"win_rate": round(live_wins / live_count * 100, 1) if live_count > 0 else 0,
}
# 3) 추이 로드
trend = load_trend(report_dir)
# 4) 누적 트레이드 수 (실전 + 이전 리포트)
cumulative = live_count
rdir = Path(report_dir)
if rdir.exists():
for rpath in sorted(rdir.glob("report_*.json")):
try:
prev = json.loads(rpath.read_text())
cumulative += prev.get("live_trades", {}).get("count", 0)
except (json.JSONDecodeError, KeyError):
pass
# 5) ML 트리거 체크
current_pf = backtest_summary["profit_factor"]
ml_trigger = check_ml_trigger(
cumulative_trades=cumulative,
current_pf=current_pf,
pf_declining_3w=trend["pf_declining_3w"],
)
# 6) 킬스위치 모니터링
kill_switch = load_kill_switch_status(symbols)
# 7) PF < 1.0이면 스윕 실행
sweep = None
if current_pf < 1.0:
logger.info("PF < 1.0 — 파라미터 스윕 실행 중...")
sweep = run_degradation_sweep(symbols, TRAIN_MONTHS, TEST_MONTHS)
return {
"date": today.isoformat(),
"backtest": {"summary": backtest_summary, "per_symbol": bt_results, "trades": all_bt_trades},
"live_trades": live_summary,
"trend": trend,
"kill_switch": kill_switch,
"ml_trigger": ml_trigger,
"sweep": sweep,
}
def main():
parser = argparse.ArgumentParser(description="주간 전략 리포트")
parser.add_argument("--skip-fetch", action="store_true", help="데이터 수집 스킵")
parser.add_argument("--date", type=str, help="리포트 날짜 (YYYY-MM-DD)")
args = parser.parse_args()
report_date = date.fromisoformat(args.date) if args.date else date.today()
# 1) 데이터 수집
if not args.skip_fetch:
fetch_latest_data(SYMBOLS)
# 2) 리포트 생성
report = generate_report(symbols=SYMBOLS, report_date=report_date)
# 3) 저장
save_report(report, str(WEEKLY_DIR))
# 4) quantstats HTML 리포트
bt_trades = report["backtest"].get("trades", [])
if bt_trades:
html_path = str(WEEKLY_DIR / f"report_{report['date']}.html")
generate_quantstats_report(bt_trades, html_path, title=f"CoinTrader 주간 리포트 ({report['date']})")
# 5) Discord 전송
text = format_report(report)
print(text)
send_report(text)
if __name__ == "__main__":
main()

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

@@ -6,18 +6,86 @@
from __future__ import annotations from __future__ import annotations
import json import json
import warnings
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import joblib
import lightgbm as lgb
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from loguru import logger from loguru import logger
import warnings # 크립토 24/7 시장: 15분봉 × 96봉/일 × 365일 = 35,040
_ANNUALIZE_FACTOR = 35_040
import joblib
import lightgbm as lgb def _calc_trade_stats(trades: list[dict], initial_balance: float) -> dict:
"""거래 리스트에서 통계 요약을 계산한다. Backtester와 WalkForward 공통 사용."""
if not trades:
return {
"total_trades": 0, "total_pnl": 0.0, "return_pct": 0.0,
"win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0,
"payoff_ratio": 0.0, "max_consecutive_losses": 0,
"profit_factor": 0.0, "max_drawdown_pct": 0.0,
"sharpe_ratio": 0.0, "total_fees": 0.0, "close_reasons": {},
}
pnls = [t["net_pnl"] for t in trades]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
total_pnl = sum(pnls)
total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in trades)
gross_profit = sum(wins) if wins else 0.0
gross_loss = abs(sum(losses)) if losses else 0.0
cumulative = np.cumsum(pnls)
equity = initial_balance + cumulative
peak = np.maximum.accumulate(equity)
drawdown = (peak - equity) / peak
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
if len(pnls) > 1:
pnl_arr = np.array(pnls)
sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(_ANNUALIZE_FACTOR)) if np.std(pnl_arr) > 0 else 0.0
else:
sharpe = 0.0
avg_w = float(np.mean(wins)) if wins else 0.0
avg_l = float(np.mean(losses)) if losses else 0.0
payoff_ratio = round(avg_w / abs(avg_l), 2) if avg_l != 0 else float("inf")
max_consec_loss = 0
cur_streak = 0
for p in pnls:
if p <= 0:
cur_streak += 1
max_consec_loss = max(max_consec_loss, cur_streak)
else:
cur_streak = 0
reasons = {}
for t in trades:
r = t["close_reason"]
reasons[r] = reasons.get(r, 0) + 1
return {
"total_trades": len(trades),
"total_pnl": round(total_pnl, 4),
"return_pct": round(total_pnl / initial_balance * 100, 2),
"win_rate": round(len(wins) / len(trades) * 100, 2),
"avg_win": round(avg_w, 4),
"avg_loss": round(avg_l, 4),
"payoff_ratio": payoff_ratio,
"max_consecutive_losses": max_consec_loss,
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
"max_drawdown_pct": round(mdd, 2),
"sharpe_ratio": round(sharpe, 2),
"total_fees": round(total_fees, 4),
"close_reasons": reasons,
}
from src.dataset_builder import ( from src.dataset_builder import (
_calc_indicators, _calc_signals, _calc_features_vectorized, _calc_indicators, _calc_signals, _calc_features_vectorized,
@@ -76,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
@@ -83,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:
@@ -90,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
@@ -112,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 - (
@@ -249,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
@@ -267,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)
@@ -274,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():
@@ -423,9 +513,8 @@ class Backtester:
buy_side = "BUY" if signal == "LONG" else "SELL" buy_side = "BUY" if signal == "LONG" else "SELL"
entry_price = _apply_slippage(price, buy_side, self.cfg.slippage_pct) entry_price = _apply_slippage(price, buy_side, self.cfg.slippage_pct)
# 수수료 # 수수료 (청산 시 net_pnl에서 차감하므로 여기서 balance 차감하지 않음)
entry_fee = _calc_fee(entry_price, quantity, self.cfg.fee_pct) entry_fee = _calc_fee(entry_price, quantity, self.cfg.fee_pct)
self.balance -= entry_fee
# SL/TP 계산 # SL/TP 계산
atr = float(row.get("atr", 0)) atr = float(row.get("atr", 0))
@@ -501,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:
@@ -524,63 +616,7 @@ class Backtester:
} }
def _calc_summary(self) -> dict: def _calc_summary(self) -> dict:
if not self.trades: return _calc_trade_stats(self.trades, self.cfg.initial_balance)
return {
"total_trades": 0,
"total_pnl": 0.0,
"return_pct": 0.0,
"win_rate": 0.0,
"avg_win": 0.0,
"avg_loss": 0.0,
"profit_factor": 0.0,
"max_drawdown_pct": 0.0,
"sharpe_ratio": 0.0,
"total_fees": 0.0,
"close_reasons": {},
}
pnls = [t["net_pnl"] for t in self.trades]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
total_pnl = sum(pnls)
total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in self.trades)
gross_profit = sum(wins) if wins else 0.0
gross_loss = abs(sum(losses)) if losses else 0.0
# MDD 계산
cumulative = np.cumsum(pnls)
equity = self.cfg.initial_balance + cumulative
peak = np.maximum.accumulate(equity)
drawdown = (peak - equity) / peak
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
# 샤프비율 (연율화, 15분봉 기준: 252일 * 96봉 = 24192)
if len(pnls) > 1:
pnl_arr = np.array(pnls)
sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(24192)) if np.std(pnl_arr) > 0 else 0.0
else:
sharpe = 0.0
# 청산 사유별 비율
reasons = {}
for t in self.trades:
r = t["close_reason"]
reasons[r] = reasons.get(r, 0) + 1
return {
"total_trades": len(self.trades),
"total_pnl": round(total_pnl, 4),
"return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2),
"win_rate": round(len(wins) / len(self.trades) * 100, 2) if self.trades else 0.0,
"avg_win": round(np.mean(wins), 4) if wins else 0.0,
"avg_loss": round(np.mean(losses), 4) if losses else 0.0,
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
"max_drawdown_pct": round(mdd, 2),
"sharpe_ratio": round(sharpe, 2),
"total_fees": round(total_fees, 4),
"close_reasons": reasons,
}
# ── Walk-Forward 백테스트 ───────────────────────────────────────────── # ── Walk-Forward 백테스트 ─────────────────────────────────────────────
@@ -589,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:
@@ -665,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"],
}) })
@@ -732,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}")
@@ -793,52 +833,7 @@ class WalkForwardBacktester:
"""폴드별 결과를 합산하여 전체 Walk-Forward 결과 생성.""" """폴드별 결과를 합산하여 전체 Walk-Forward 결과 생성."""
from src.backtest_validator import validate from src.backtest_validator import validate
# 전체 통계 계산 summary = _calc_trade_stats(all_trades, self.cfg.initial_balance)
if not all_trades:
summary = {"total_trades": 0, "total_pnl": 0.0, "return_pct": 0.0,
"win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0,
"profit_factor": 0.0, "max_drawdown_pct": 0.0,
"sharpe_ratio": 0.0, "total_fees": 0.0, "close_reasons": {}}
else:
pnls = [t["net_pnl"] for t in all_trades]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
total_pnl = sum(pnls)
total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in all_trades)
gross_profit = sum(wins) if wins else 0.0
gross_loss = abs(sum(losses)) if losses else 0.0
cumulative = np.cumsum(pnls)
equity = self.cfg.initial_balance + cumulative
peak = np.maximum.accumulate(equity)
drawdown = (peak - equity) / peak
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
if len(pnls) > 1:
pnl_arr = np.array(pnls)
sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(24192)) if np.std(pnl_arr) > 0 else 0.0
else:
sharpe = 0.0
reasons = {}
for t in all_trades:
r = t["close_reason"]
reasons[r] = reasons.get(r, 0) + 1
summary = {
"total_trades": len(all_trades),
"total_pnl": round(total_pnl, 4),
"return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2),
"win_rate": round(len(wins) / len(all_trades) * 100, 2),
"avg_win": round(np.mean(wins), 4) if wins else 0.0,
"avg_loss": round(np.mean(losses), 4) if losses else 0.0,
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
"max_drawdown_pct": round(mdd, 2),
"sharpe_ratio": round(sharpe, 2),
"total_fees": round(total_fees, 4),
"close_reasons": reasons,
}
validation = validate(all_trades, summary, self.cfg) validation = validate(all_trades, summary, self.cfg)
return { return {

View File

@@ -1,5 +1,9 @@
import asyncio import asyncio
import json
import os
import time
from collections import deque from collections import deque
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
from loguru import logger from loguru import logger
@@ -13,13 +17,48 @@ from src.ml_filter import MLFilter
from src.ml_features import build_features_aligned from src.ml_features import build_features_aligned
from src.user_data_stream import UserDataStream from src.user_data_stream import UserDataStream
# ── 킬스위치 상수 ──────────────────────────────────────────────────
_FAST_KILL_STREAK = 8 # 연속 손실 N회 → 즉시 중단
_SLOW_KILL_WINDOW = 15 # 최근 N거래 PF 산출
_SLOW_KILL_PF_THRESHOLD = 0.75 # PF < 이 값이면 중단
_TRADE_HISTORY_DIR = Path("data/trade_history")
def _tail_lines(path: Path, n: int) -> list[str]:
"""파일 끝에서 최대 n줄을 효율적으로 읽는다 (전체 파일 로드 없이)."""
with open(path, "rb") as f:
f.seek(0, 2) # EOF
fsize = f.tell()
if fsize == 0:
return []
# 뒤에서부터 청크 단위로 읽기
chunk_size = min(4096, fsize)
lines: list[str] = []
pos = fsize
remaining = b""
while pos > 0 and len(lines) < n + 1:
read_size = min(chunk_size, pos)
pos -= read_size
f.seek(pos)
chunk = f.read(read_size) + remaining
remaining = b""
split = chunk.split(b"\n")
# 첫 조각은 이전 청크와 이어질 수 있으므로 따로 보관
remaining = split[0]
lines = [s.decode() for s in split[1:] if s.strip()] + lines
# 남은 조각 처리
if remaining.strip():
lines = [remaining.decode()] + lines
return lines[-n:]
class TradingBot: class TradingBot:
def __init__(self, config: Config, symbol: str = None, risk: RiskManager = None): def __init__(self, config: Config, symbol: str = None, risk: RiskManager = None):
self.config = config self.config = config
self.symbol = symbol or config.symbol self.symbol = symbol or config.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()}")
@@ -38,35 +77,156 @@ class TradingBot:
self._entry_price: float | None = None self._entry_price: float | None = None
self._entry_quantity: float | None = None self._entry_quantity: float | None = None
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지 self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
self._entry_time_ms: int | None = None # 포지션 진입 시각 (ms, SYNC PnL 범위 제한용)
self._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_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix)
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값 self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
self._oi_history: deque = deque(maxlen=5) self._oi_history: deque = deque(maxlen=96) # z-score 윈도우(96=1일분 15분봉)
self._funding_history: deque = deque(maxlen=96)
self._latest_ret_1: float = 0.0 self._latest_ret_1: float = 0.0
self._killed: bool = False # 킬스위치 발동 상태
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,
) )
# 부팅 시 거래 이력 복원 및 킬스위치 소급 검증
self._restore_trade_history()
self._restore_kill_switch()
# ── 킬스위치 ──────────────────────────────────────────────────────
def _trade_history_path(self) -> Path:
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:
"""부팅 시 파일 마지막 N줄만 읽어 거래 이력을 복원한다.
킬스위치 판단에 필요한 최대 윈도우(_SLOW_KILL_WINDOW)만큼만 유지."""
path = self._trade_history_path()
if not path.exists():
return
try:
tail_n = max(_FAST_KILL_STREAK, _SLOW_KILL_WINDOW)
lines = _tail_lines(path, tail_n)
for line in lines:
line = line.strip()
if line:
self._trade_history.append(json.loads(line))
logger.info(f"[{self.symbol}] 거래 이력 복원: {len(self._trade_history)}건 (최근 {tail_n}건)")
except Exception as e:
logger.warning(f"[{self.symbol}] 거래 이력 복원 실패: {e}")
def _restore_kill_switch(self) -> None:
"""부팅 시 .env 리셋 플래그 확인 후, 이력 기반으로 킬스위치 소급 검증."""
reset_key = f"RESET_KILL_SWITCH_{self.symbol}"
if os.environ.get(reset_key, "").lower() == "true":
logger.info(f"[{self.symbol}] 킬스위치 수동 해제 감지 ({reset_key}=True)")
self._killed = False
return
# 소급 검증
if self._check_kill_switch(silent=True):
logger.warning(f"[{self.symbol}] 부팅 시 킬스위치 조건 충족 — 신규 진입 차단")
def _append_trade(self, net_pnl: float, close_reason: str) -> None:
"""거래 기록을 메모리 + 파일에 추가한다."""
record = {
"net_pnl": round(net_pnl, 4),
"reason": close_reason,
"ts": datetime.now(timezone.utc).isoformat(),
}
self._trade_history.append(record)
# 메모리에는 킬스위치 윈도우만큼만 유지
max_window = max(_FAST_KILL_STREAK, _SLOW_KILL_WINDOW)
if len(self._trade_history) > max_window * 2:
self._trade_history = self._trade_history[-max_window:]
# 파일에 append (JSONL)
try:
_TRADE_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
with open(self._trade_history_path(), "a") as f:
f.write(json.dumps(record) + "\n")
except Exception as e:
logger.warning(f"[{self.symbol}] 거래 기록 저장 실패: {e}")
def _check_kill_switch(self, silent: bool = False) -> bool:
"""킬스위치 조건을 검사하고, 발동 시 True를 반환한다.
Fast Kill: 최근 8연속 순손실
Slow Kill: 최근 15거래 PF < 0.75
"""
trades = self._trade_history
if not trades:
return False
# Fast Kill: 8연속 순손실
if len(trades) >= _FAST_KILL_STREAK:
recent = trades[-_FAST_KILL_STREAK:]
if all(t["net_pnl"] < 0 for t in recent):
reason = f"Fast Kill ({_FAST_KILL_STREAK}연속 순손실)"
self._trigger_kill_switch(reason, silent)
return True
# Slow Kill: 최근 15거래 PF < 0.75
if len(trades) >= _SLOW_KILL_WINDOW:
recent = trades[-_SLOW_KILL_WINDOW:]
gross_profit = sum(t["net_pnl"] for t in recent if t["net_pnl"] > 0)
gross_loss = abs(sum(t["net_pnl"] for t in recent if t["net_pnl"] < 0))
if gross_loss > 0:
pf = gross_profit / gross_loss
if pf < _SLOW_KILL_PF_THRESHOLD:
reason = f"Slow Kill (최근 {_SLOW_KILL_WINDOW}거래 PF={pf:.2f})"
self._trigger_kill_switch(reason, silent)
return True
return False
def _trigger_kill_switch(self, reason: str, silent: bool = False) -> None:
"""킬스위치 발동: 상태 변경 + 알림."""
self._killed = True
msg = (
f"🚨 [KILL SWITCH] {self.symbol} 신규 진입 중단\n"
f"사유: {reason}\n"
f"기존 포지션 SL/TP는 정상 작동합니다.\n"
f"해제: RESET_KILL_SWITCH_{self.symbol}=True 후 봇 재시작"
)
logger.error(msg)
if not silent:
self.notifier.notify_info(msg)
async def _on_candle_closed(self, candle: dict): async def _on_candle_closed(self, candle: dict):
primary_df = self.stream.get_dataframe(self.symbol) primary_df = self.stream.get_dataframe(self.symbol)
btc_df = self.stream.get_dataframe("BTCUSDT") corr = self.config.correlation_symbols
eth_df = self.stream.get_dataframe("ETHUSDT") corr_dfs = {s: self.stream.get_dataframe(s) for s in corr}
btc_df = corr_dfs.get("BTCUSDT")
eth_df = corr_dfs.get("ETHUSDT")
if primary_df is not None: if primary_df is not None:
await self.process_candle(primary_df, btc_df=btc_df, eth_df=eth_df) await self.process_candle(primary_df, btc_df=btc_df, eth_df=eth_df)
async def _recover_position(self) -> None: async def _recover_position(self) -> None:
"""재시작 시 바이낸스에서 현재 포지션을 조회하여 상태 복구.""" """재시작 시 바이낸스에서 현재 포지션을 조회하여 상태 복구.
SL/TP 주문이 누락된 경우 ATR 기반으로 재배치한다."""
position = await self.exchange.get_position() position = await self.exchange.get_position()
if position is not None: if position is not None:
amt = float(position["positionAmt"]) amt = float(position["positionAmt"])
self.current_trade_side = "LONG" if amt > 0 else "SHORT" self.current_trade_side = "LONG" if amt > 0 else "SHORT"
self._entry_price = float(position["entryPrice"]) self._entry_price = float(position["entryPrice"])
self._entry_quantity = abs(amt) self._entry_quantity = abs(amt)
self._entry_time_ms = int(float(position.get("updateTime", time.time() * 1000)))
entry = float(position["entryPrice"]) entry = float(position["entryPrice"])
logger.info( logger.info(
f"[{self.symbol}] 기존 포지션 복구: {self.current_trade_side} | " f"[{self.symbol}] 기존 포지션 복구: {self.current_trade_side} | "
f"진입가={entry:.4f} | 수량={abs(amt)}" f"진입가={entry:.4f} | 수량={abs(amt)}"
) )
# SL/TP 주문 존재 여부 확인 후 누락 시 재배치
await self._ensure_sl_tp_orders(position)
self.notifier.notify_info( self.notifier.notify_info(
f"봇 재시작 - 기존 포지션 감지: {self.current_trade_side} " f"봇 재시작 - 기존 포지션 감지: {self.current_trade_side} "
f"진입가={entry:.4f} 수량={abs(amt)}" f"진입가={entry:.4f} 수량={abs(amt)}"
@@ -74,6 +234,62 @@ class TradingBot:
else: else:
logger.info(f"[{self.symbol}] 기존 포지션 없음 - 신규 진입 대기") logger.info(f"[{self.symbol}] 기존 포지션 없음 - 신규 진입 대기")
async def _ensure_sl_tp_orders(self, position: dict) -> None:
"""포지션에 SL/TP 주문이 없으면 ATR 기반으로 재배치한다."""
try:
open_orders = await self.exchange.get_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)
# 오픈 주문에서 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:
return
missing = []
if not has_sl:
missing.append("SL")
if not has_tp:
missing.append("TP")
logger.warning(f"[{self.symbol}] {'/'.join(missing)} 주문 누락 감지 — 재배치")
# 캔들 데이터로 ATR 기반 SL/TP 계산
primary_df = self.stream.get_dataframe(self.symbol)
if primary_df is None:
logger.warning(f"[{self.symbol}] 캔들 데이터 부족 — SL/TP 재배치 건너뜀")
return
ind = Indicators(primary_df)
df_ind = ind.calculate_all()
entry = self._entry_price
qty = self._entry_quantity
sl, tp = ind.get_atr_stop(
df_ind, self.current_trade_side, entry,
atr_sl_mult=self.strategy.atr_sl_mult,
atr_tp_mult=self.strategy.atr_tp_mult,
)
sl_side = "SELL" if self.current_trade_side == "LONG" else "BUY"
if not has_sl:
await self.exchange.place_order(
side=sl_side, quantity=qty,
order_type="STOP_MARKET",
stop_price=self.exchange._round_price(sl),
reduce_only=True,
)
logger.info(f"[{self.symbol}] SL 재배치: {sl:.4f}")
if not has_tp:
await self.exchange.place_order(
side=sl_side, quantity=qty,
order_type="TAKE_PROFIT_MARKET",
stop_price=self.exchange._round_price(tp),
reduce_only=True,
)
logger.info(f"[{self.symbol}] TP 재배치: {tp:.4f}")
except Exception as e:
logger.warning(f"[{self.symbol}] SL/TP 재배치 실패: {e}")
async def _init_oi_history(self) -> None: async def _init_oi_history(self) -> None:
"""봇 시작 시 최근 OI 변화율 히스토리를 조회하여 deque를 채운다.""" """봇 시작 시 최근 OI 변화율 히스토리를 조회하여 deque를 채운다."""
try: try:
@@ -99,9 +315,13 @@ class TradingBot:
oi_change = 0.0 oi_change = 0.0
fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0 fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0
# OI 히스토리 업데이트 및 MA5 계산 # 히스토리 업데이트 (z-score 계산용)
self._oi_history.append(oi_change) self._oi_history.append(oi_change)
oi_ma5 = sum(self._oi_history) / len(self._oi_history) if self._oi_history else 0.0 self._funding_history.append(fr_float)
# OI MA5 계산
recent_5 = list(self._oi_history)[-5:]
oi_ma5 = sum(recent_5) / len(recent_5) if recent_5 else 0.0
# OI-가격 스프레드 # OI-가격 스프레드
oi_price_spread = oi_change - self._latest_ret_1 oi_price_spread = oi_change - self._latest_ret_1
@@ -122,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용)
@@ -133,26 +370,47 @@ class TradingBot:
# 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백) # 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백)
oi_change, funding_rate, oi_ma5, oi_price_spread = await self._fetch_market_microstructure() oi_change, funding_rate, oi_ma5, oi_price_spread = await self._fetch_market_microstructure()
if not self.risk.is_trading_allowed(): if not await self.risk.is_trading_allowed():
logger.warning(f"[{self.symbol}] 리스크 한도 초과 - 거래 중단") logger.warning(f"[{self.symbol}] 리스크 한도 초과 - 거래 중단")
return return
# 킬스위치: 신규 진입만 차단, 기존 포지션 모니터링은 계속
if self._killed:
return
ind = Indicators(df) ind = Indicators(df)
df_with_indicators = ind.calculate_all() df_with_indicators = ind.calculate_all()
raw_signal = ind.get_signal( raw_signal, signal_detail = ind.get_signal(
df_with_indicators, df_with_indicators,
signal_threshold=self.config.signal_threshold, signal_threshold=self.strategy.signal_threshold,
adx_threshold=self.config.adx_threshold, adx_threshold=self.strategy.adx_threshold,
volume_multiplier=self.config.volume_multiplier, volume_multiplier=self.strategy.volume_multiplier,
) )
current_price = df_with_indicators["close"].iloc[-1] current_price = df_with_indicators["close"].iloc[-1]
logger.info(f"[{self.symbol}] 신호: {raw_signal} | 현재가: {current_price:.4f} USDT") adx_str = f"ADX={signal_detail['adx']:.1f}" if signal_detail['adx'] is not None else "ADX=N/A"
vol_str = "Vol급증" if signal_detail['vol_surge'] else "Vol정상"
score_str = f"L={signal_detail['long']} S={signal_detail['short']}"
if raw_signal == "HOLD" and signal_detail['hold_reason']:
logger.info(f"[{self.symbol}] 신호: HOLD | {score_str} | {adx_str} | {vol_str} | 사유: {signal_detail['hold_reason']} | 현재가: {current_price:.4f}")
else:
logger.info(f"[{self.symbol}] 신호: {raw_signal} | {score_str} | {adx_str} | {vol_str} | 현재가: {current_price:.4f}")
position = await self.exchange.get_position() position = await self.exchange.get_position()
if position is None and raw_signal != "HOLD": if position is None and raw_signal != "HOLD":
self.current_trade_side = None # Binance에 포지션이 없는데 로컬에 남아있으면 risk manager 동기화
if self.current_trade_side is not None:
logger.warning(
f"[{self.symbol}] 포지션 불일치: 로컬={self.current_trade_side}, "
f"바이낸스=없음 — risk manager 동기화"
)
await self.risk.close_position(self.symbol, 0.0)
self.current_trade_side = None
self._entry_price = 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
@@ -162,6 +420,8 @@ class TradingBot:
btc_df=btc_df, eth_df=eth_df, btc_df=btc_df, eth_df=eth_df,
oi_change=oi_change, funding_rate=funding_rate, oi_change=oi_change, funding_rate=funding_rate,
oi_change_ma5=oi_ma5, oi_price_spread=oi_price_spread, oi_change_ma5=oi_ma5, oi_price_spread=oi_price_spread,
oi_history=list(self._oi_history),
funding_history=list(self._funding_history),
) )
if self.ml_filter.is_model_loaded(): if self.ml_filter.is_model_loaded():
if not self.ml_filter.should_enter(features): if not self.ml_filter.should_enter(features):
@@ -181,44 +441,65 @@ class TradingBot:
) )
async def _open_position(self, signal: str, df): async def _open_position(self, signal: str, df):
balance = await self.exchange.get_balance() # 동시 진입 시 잔고 레이스 방지: entry_lock으로 잔고 조회→주문→등록을 직렬화
num_symbols = len(self.config.symbols) async with self.risk._entry_lock:
per_symbol_balance = balance / num_symbols balance = await self.exchange.get_balance()
price = df["close"].iloc[-1] num_symbols = len(self.config.symbols)
margin_ratio = self.risk.get_dynamic_margin_ratio(balance) per_symbol_balance = balance / num_symbols
quantity = self.exchange.calculate_quantity( price = df["close"].iloc[-1]
balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio margin_ratio = self.risk.get_dynamic_margin_ratio(per_symbol_balance)
) quantity = self.exchange.calculate_quantity(
logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}") balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio
stop_loss, take_profit = Indicators(df).get_atr_stop(
df, signal, price,
atr_sl_mult=self.config.atr_sl_mult,
atr_tp_mult=self.config.atr_tp_mult,
)
notional = quantity * price
if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
logger.warning(
f"주문 건너뜀: 명목금액 {notional:.2f} USDT < 최소 {self.exchange.MIN_NOTIONAL} USDT "
f"(잔고={balance:.2f}, 수량={quantity})"
) )
return logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
# 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이므로
# Indicators를 재생성하지 않고 ATR을 직접 사용
atr = df["atr"].iloc[-1]
if signal == "LONG":
stop_loss = price - atr * self.strategy.atr_sl_mult
take_profit = price + atr * self.strategy.atr_tp_mult
else:
stop_loss = price + atr * self.strategy.atr_sl_mult
take_profit = price - atr * self.strategy.atr_tp_mult
side = "BUY" if signal == "LONG" else "SELL" notional = quantity * price
await self.exchange.set_leverage(self.config.leverage) if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
await self.exchange.place_order(side=side, quantity=quantity) logger.warning(
f"주문 건너뜀: 명목금액 {notional:.2f} USDT < 최소 {self.exchange.MIN_NOTIONAL} USDT "
f"(잔고={balance:.2f}, 수량={quantity})"
)
return
last_row = df.iloc[-1] side = "BUY" if signal == "LONG" else "SELL"
signal_snapshot = { await self.exchange.set_margin_type("ISOLATED")
"rsi": float(last_row["rsi"]) if "rsi" in last_row.index and pd.notna(last_row["rsi"]) else 0.0, await self.exchange.set_leverage(self.config.leverage)
"macd_hist": float(last_row["macd_hist"]) if "macd_hist" in last_row.index and pd.notna(last_row["macd_hist"]) else 0.0, await self.exchange.place_order(side=side, quantity=quantity)
"atr": float(last_row["atr"]) if "atr" in last_row.index and pd.notna(last_row["atr"]) else 0.0,
}
await self.risk.register_position(self.symbol, signal) last_row = df.iloc[-1]
self.current_trade_side = signal signal_snapshot = {
self._entry_price = price "rsi": float(last_row["rsi"]) if "rsi" in last_row.index and pd.notna(last_row["rsi"]) else 0.0,
self._entry_quantity = quantity "macd_hist": float(last_row["macd_hist"]) if "macd_hist" in last_row.index and pd.notna(last_row["macd_hist"]) else 0.0,
"atr": float(last_row["atr"]) if "atr" in last_row.index and pd.notna(last_row["atr"]) else 0.0,
}
await self.risk.register_position(self.symbol, signal)
self.current_trade_side = signal
self._entry_price = price
self._entry_quantity = quantity
self._entry_time_ms = int(time.time() * 1000)
self._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,
@@ -238,20 +519,108 @@ class TradingBot:
) )
sl_side = "SELL" if signal == "LONG" else "BUY" sl_side = "SELL" if signal == "LONG" else "BUY"
await self.exchange.place_order( try:
side=sl_side, await self._place_sl_tp_with_retry(
quantity=quantity, sl_side, quantity, stop_loss, take_profit
order_type="STOP_MARKET", )
stop_price=round(stop_loss, 4), except Exception as e:
reduce_only=True, logger.error(
) f"[{self.symbol}] SL/TP 배치 최종 실패 — 긴급 청산: {e}"
await self.exchange.place_order( )
side=sl_side, await self._emergency_close(side, quantity)
quantity=quantity,
order_type="TAKE_PROFIT_MARKET", _SL_TP_MAX_RETRIES = 3
stop_price=round(take_profit, 4),
reduce_only=True, async def _place_sl_tp_with_retry(
) self, sl_side: str, quantity: float, stop_loss: float, take_profit: float
) -> None:
"""SL/TP 주문을 재시도 로직과 함께 배치한다. 최종 실패 시 예외를 raise."""
sl_placed = False
tp_placed = False
last_error = None
for attempt in range(1, self._SL_TP_MAX_RETRIES + 1):
try:
if not sl_placed:
sl_result = await self.exchange.place_order(
side=sl_side,
quantity=quantity,
order_type="STOP_MARKET",
stop_price=self.exchange._round_price(stop_loss),
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
if not tp_placed:
tp_result = await self.exchange.place_order(
side=sl_side,
quantity=quantity,
order_type="TAKE_PROFIT_MARKET",
stop_price=self.exchange._round_price(take_profit),
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
return # 둘 다 성공
except Exception as e:
last_error = e
logger.warning(
f"[{self.symbol}] SL/TP 배치 실패 (시도 {attempt}/{self._SL_TP_MAX_RETRIES}): {e}"
)
if attempt < self._SL_TP_MAX_RETRIES:
await asyncio.sleep(1)
raise last_error # 모든 재시도 실패
async def _emergency_close(self, entry_side: str, quantity: float) -> None:
"""SL/TP 배치 실패 시 포지션을 긴급 시장가 청산한다."""
try:
close_side = "SELL" if entry_side == "BUY" else "BUY"
await self.exchange.cancel_all_orders()
await self.exchange.place_order(
side=close_side, quantity=quantity, reduce_only=True
)
await self.risk.close_position(self.symbol, 0.0)
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
self._sl_price = None
self._tp_price = None
self.notifier.notify_info(
f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료"
)
logger.warning(f"[{self.symbol}] 긴급 청산 완료")
except Exception as e:
logger.critical(
f"[{self.symbol}] 긴급 청산마저 실패! 수동 개입 필요: {e}"
)
self.notifier.notify_info(
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 계산 (수수료 미반영)."""
@@ -268,41 +637,139 @@ class TradingBot:
exit_price: float, exit_price: float,
) -> None: ) -> None:
"""User Data Stream에서 청산 감지 시 호출되는 콜백.""" """User Data Stream에서 청산 감지 시 호출되는 콜백."""
estimated_pnl = self._calc_estimated_pnl(exit_price) async with self._close_lock:
diff = net_pnl - estimated_pnl # 이미 Flat 상태면 중복 처리 방지 (SYNC 또는 process_candle에서 먼저 처리됨)
if self.current_trade_side is None and not self._is_reentering:
logger.debug(f"[{self.symbol}] 이미 Flat 상태 — 콜백 건너뜀")
self._close_event.set()
return
await self.risk.close_position(self.symbol, net_pnl) # 실전 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})"
)
self.notifier.notify_close( estimated_pnl = self._calc_estimated_pnl(exit_price)
symbol=self.symbol, diff = net_pnl - estimated_pnl
side=self.current_trade_side or "UNKNOWN",
close_reason=close_reason,
exit_price=exit_price,
estimated_pnl=estimated_pnl,
net_pnl=net_pnl,
diff=diff,
)
logger.success( await self.risk.close_position(self.symbol, net_pnl)
f"[{self.symbol}] 포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
)
# _close_and_reenter 중이면 신규 포지션 상태를 덮어쓰지 않는다 self.notifier.notify_close(
if self._is_reentering: symbol=self.symbol,
return side=self.current_trade_side or "UNKNOWN",
close_reason=close_reason,
exit_price=exit_price,
estimated_pnl=estimated_pnl,
net_pnl=net_pnl,
diff=diff,
)
# Flat 상태로 초기화 logger.success(
self.current_trade_side = None f"[{self.symbol}] 포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
self._entry_price = None f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
self._entry_quantity = None )
# 거래 기록 저장 + 킬스위치 검사 (청산 후 항상 수행)
self._append_trade(net_pnl, close_reason)
self._check_kill_switch()
# _close_and_reenter 대기 해제
self._close_event.set()
# _close_and_reenter 중이면 신규 포지션 상태를 덮어쓰지 않는다
if self._is_reentering:
return
# 잔여 SL/TP 고아 주문 취소
await self._cancel_remaining_orders("UDS 청산 콜백")
# Flat 상태로 초기화
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
self._entry_time_ms = None
self._sl_price = None
self._tp_price = None
_MONITOR_INTERVAL = 300 # 5분 _MONITOR_INTERVAL = 300 # 5분
async def _position_monitor(self): async def _position_monitor(self):
"""포지션 보유 중일 때 5분마다 현재가·미실현 PnL을 로깅한다.""" """포지션 보유 중일 때 5분마다 현재가·미실현 PnL을 로깅한다.
또한 Binance API를 조회하여 WebSocket 이벤트 누락 시 청산을 감지한다."""
while True: while True:
await asyncio.sleep(self._MONITOR_INTERVAL) await asyncio.sleep(self._MONITOR_INTERVAL)
# ── 폴백: Binance API로 실제 포지션 상태 확인 ──
if self.current_trade_side is not None and not self._is_reentering:
try:
actual_pos = await self.exchange.get_position()
if actual_pos is None:
async with self._close_lock:
# Lock 획득 후 재확인 (콜백이 먼저 처리했을 수 있음)
if self.current_trade_side is None:
continue
logger.warning(
f"[{self.symbol}] 포지션 불일치 감지: "
f"봇={self.current_trade_side}, 바이낸스=포지션 없음 — 상태 동기화"
)
# Binance income API에서 실제 PnL 조회
realized_pnl = 0.0
commission = 0.0
exit_price = 0.0
try:
pnl_rows, comm_rows = await self.exchange.get_recent_income(
limit=10, start_time=self._entry_time_ms,
)
if pnl_rows:
realized_pnl = sum(float(r.get("income", "0")) for r in pnl_rows)
if comm_rows:
commission = sum(abs(float(r.get("income", "0"))) for r in comm_rows)
except Exception:
pass
net_pnl = realized_pnl - commission
# exit_price 추정: 진입가 + PnL/수량
if self._entry_quantity and self._entry_quantity > 0 and self._entry_price:
if self.current_trade_side == "LONG":
exit_price = self._entry_price + realized_pnl / self._entry_quantity
else:
exit_price = self._entry_price - realized_pnl / self._entry_quantity
await self.risk.close_position(self.symbol, net_pnl)
self.notifier.notify_close(
symbol=self.symbol,
side=self.current_trade_side,
close_reason="SYNC",
exit_price=exit_price,
estimated_pnl=realized_pnl,
net_pnl=net_pnl,
diff=net_pnl - realized_pnl,
)
logger.info(
f"[{self.symbol}] 청산 감지(SYNC): exit={exit_price:.4f}, "
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
f"net_pnl={net_pnl:+.4f}"
)
self._append_trade(net_pnl, "SYNC")
self._check_kill_switch()
# 잔여 SL/TP 주문 취소
await self._cancel_remaining_orders("SYNC 폴백")
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
self._entry_time_ms = None
self._sl_price = None
self._tp_price = None
self._close_event.set()
continue
except Exception as e:
logger.debug(f"[{self.symbol}] 포지션 동기화 확인 실패 (무시): {e}")
if self.current_trade_side is None: if self.current_trade_side is None:
continue continue
price = self.stream.latest_price price = self.stream.latest_price
@@ -340,9 +807,32 @@ class TradingBot:
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다.""" """기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
# 재진입 플래그: User Data Stream 콜백이 신규 포지션 상태를 초기화하지 않도록 보호 # 재진입 플래그: User Data Stream 콜백이 신규 포지션 상태를 초기화하지 않도록 보호
self._is_reentering = True self._is_reentering = True
self._close_event.clear()
try: try:
await self._close_position(position) await self._close_position(position)
# 콜백이 PnL을 기록할 때까지 대기 (최대 10초)
try:
await asyncio.wait_for(self._close_event.wait(), timeout=10)
except asyncio.TimeoutError:
logger.warning(f"[{self.symbol}] 청산 콜백 타임아웃 — 수동 동기화")
await self.risk.close_position(self.symbol, 0.0)
# 로컬 상태를 Flat으로 전환
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = 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:
logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)")
return
if not await self.risk.can_open_new_position(self.symbol, signal): if not await self.risk.can_open_new_position(self.symbol, signal):
logger.info(f"[{self.symbol}] 최대 포지션 수 도달 — 재진입 건너뜀") logger.info(f"[{self.symbol}] 최대 포지션 수 도달 — 재진입 건너뜀")
return return
@@ -353,6 +843,8 @@ class TradingBot:
btc_df=btc_df, eth_df=eth_df, btc_df=btc_df, eth_df=eth_df,
oi_change=oi_change, funding_rate=funding_rate, oi_change=oi_change, funding_rate=funding_rate,
oi_change_ma5=oi_change_ma5, oi_price_spread=oi_price_spread, oi_change_ma5=oi_change_ma5, oi_price_spread=oi_price_spread,
oi_history=list(self._oi_history),
funding_history=list(self._funding_history),
) )
if not self.ml_filter.should_enter(features): if not self.ml_filter.should_enter(features):
logger.info(f"[{self.symbol}] ML 필터 차단: {signal} 재진입 무시") logger.info(f"[{self.symbol}] ML 필터 차단: {signal} 재진입 무시")
@@ -363,12 +855,25 @@ class TradingBot:
self._is_reentering = False self._is_reentering = False
async def run(self): async def run(self):
logger.info(f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x") if self.config.testnet:
logger.warning("⚠️ TESTNET MODE ENABLED — 실제 자금이 아닌 테스트넷에서 실행 중")
s = self.strategy
logger.info(
f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x | "
f"SL={s.atr_sl_mult}x TP={s.atr_tp_mult}x Signal≥{s.signal_threshold} "
f"ADX≥{s.adx_threshold} Vol≥{s.volume_multiplier}x"
)
await self._recover_position() await self._recover_position()
await self._init_oi_history() await self._init_oi_history()
balance = await self.exchange.get_balance()
self.risk.set_base_balance(balance) # 봇 시작 시 포지션 없으면 고아 주문 정리 (저장된 ID 없으므로 cancel_all 사용)
logger.info(f"[{self.symbol}] 기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)") 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,
@@ -379,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

@@ -5,6 +5,16 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
@dataclass
class SymbolStrategyParams:
"""Per-symbol strategy parameters (from sweep optimization)."""
atr_sl_mult: float = 2.0
atr_tp_mult: float = 2.0
signal_threshold: int = 3
adx_threshold: float = 25.0
volume_multiplier: float = 2.5
@dataclass @dataclass
class Config: class Config:
api_key: str = "" api_key: str = ""
@@ -15,9 +25,6 @@ class Config:
leverage: int = 10 leverage: int = 10
max_positions: int = 3 max_positions: int = 3
max_same_direction: int = 2 max_same_direction: int = 2
stop_loss_pct: float = 0.015 # 1.5%
take_profit_pct: float = 0.045 # 4.5% (3:1 RR)
trailing_stop_pct: float = 0.01 # 1%
discord_webhook_url: str = "" discord_webhook_url: str = ""
margin_max_ratio: float = 0.50 margin_max_ratio: float = 0.50
margin_min_ratio: float = 0.20 margin_min_ratio: float = 0.20
@@ -28,10 +35,18 @@ class Config:
signal_threshold: int = 3 signal_threshold: int = 3
adx_threshold: float = 25.0 adx_threshold: float = 25.0
volume_multiplier: float = 2.5 volume_multiplier: float = 2.5
kline_interval: str = "15m"
testnet: bool = False
def __post_init__(self): def __post_init__(self):
self.api_key = os.getenv("BINANCE_API_KEY", "") self.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
if self.testnet:
self.api_key = os.getenv("BINANCE_DEMO_API_KEY", "")
self.api_secret = os.getenv("BINANCE_DEMO_API_SECRET", "")
else:
self.api_key = os.getenv("BINANCE_API_KEY", "")
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
self.symbol = os.getenv("SYMBOL", "XRPUSDT") self.symbol = os.getenv("SYMBOL", "XRPUSDT")
self.leverage = int(os.getenv("LEVERAGE", "10")) self.leverage = int(os.getenv("LEVERAGE", "10"))
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "") self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
@@ -45,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", "")
@@ -57,3 +73,36 @@ class Config:
corr_env = os.getenv("CORRELATION_SYMBOLS", "BTCUSDT,ETHUSDT") corr_env = os.getenv("CORRELATION_SYMBOLS", "BTCUSDT,ETHUSDT")
self.correlation_symbols = [s.strip() for s in corr_env.split(",") if s.strip()] self.correlation_symbols = [s.strip() for s in corr_env.split(",") if s.strip()]
# 입력 검증
if self.leverage < 1:
raise ValueError(f"LEVERAGE는 1 이상이어야 합니다: {self.leverage}")
if not (0.0 < self.margin_max_ratio <= 1.0):
raise ValueError(f"MARGIN_MAX_RATIO는 (0, 1] 범위여야 합니다: {self.margin_max_ratio}")
if not (0.0 < self.margin_min_ratio <= 1.0):
raise ValueError(f"MARGIN_MIN_RATIO는 (0, 1] 범위여야 합니다: {self.margin_min_ratio}")
if self.margin_min_ratio > self.margin_max_ratio:
raise ValueError(f"MARGIN_MIN_RATIO({self.margin_min_ratio}) > MARGIN_MAX_RATIO({self.margin_max_ratio})")
if not (0.0 < self.ml_threshold <= 1.0):
raise ValueError(f"ML_THRESHOLD는 (0, 1] 범위여야 합니다: {self.ml_threshold}")
# Per-symbol strategy params: {symbol: SymbolStrategyParams}
self._symbol_params: dict[str, SymbolStrategyParams] = {}
for sym in self.symbols:
self._symbol_params[sym] = SymbolStrategyParams(
atr_sl_mult=float(os.getenv(f"ATR_SL_MULT_{sym}", str(self.atr_sl_mult))),
atr_tp_mult=float(os.getenv(f"ATR_TP_MULT_{sym}", str(self.atr_tp_mult))),
signal_threshold=int(os.getenv(f"SIGNAL_THRESHOLD_{sym}", str(self.signal_threshold))),
adx_threshold=float(os.getenv(f"ADX_THRESHOLD_{sym}", str(self.adx_threshold))),
volume_multiplier=float(os.getenv(f"VOL_MULTIPLIER_{sym}", str(self.volume_multiplier))),
)
def get_symbol_params(self, symbol: str) -> SymbolStrategyParams:
"""Get strategy params for a symbol. Falls back to global defaults."""
return self._symbol_params.get(symbol, SymbolStrategyParams(
atr_sl_mult=self.atr_sl_mult,
atr_tp_mult=self.atr_tp_mult,
signal_threshold=self.signal_threshold,
adx_threshold=self.adx_threshold,
volume_multiplier=self.volume_multiplier,
))

View File

@@ -10,8 +10,10 @@ from loguru import logger
_MIN_CANDLES_FOR_SIGNAL = 100 _MIN_CANDLES_FOR_SIGNAL = 100
# 초기 구동 시 REST API로 가져올 과거 캔들 수. # 초기 구동 시 REST API로 가져올 과거 캔들 수.
# 15분봉 200개 = 50시간치 — EMA50(12.5h) 대비 4배 여유. # z-score 윈도우(288) + EMA50(50) 안정화 여유분. 15분봉 300개 = 75시간.
_PRELOAD_LIMIT = 200 _PRELOAD_LIMIT = 300
_RECONNECT_DELAY = 5 # WebSocket 재연결 대기 초
@@ -75,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)
@@ -105,7 +108,7 @@ class MultiSymbolStream:
self, self,
symbols: list[str], symbols: list[str],
interval: str = "15m", interval: str = "15m",
buffer_size: int = 200, buffer_size: int = 300,
on_candle: Callable = None, on_candle: Callable = None,
): ):
self.symbols = [s.lower() for s in symbols] self.symbols = [s.lower() for s in symbols]
@@ -161,31 +164,37 @@ class MultiSymbolStream:
df.set_index("timestamp", inplace=True) df.set_index("timestamp", inplace=True)
return df return df
async def _preload_history(self, client: AsyncClient, limit: int = _PRELOAD_LIMIT): async def _preload_one(self, client: AsyncClient, symbol: str, limit: int):
"""REST API로 모든 심볼의 과거 캔들을 버퍼에 미리 채운다.""" """단일 심볼의 과거 캔들을 버퍼에 채운다."""
for symbol in self.symbols: logger.info(f"{symbol.upper()} 과거 캔들 {limit}개 로드 중...")
logger.info(f"{symbol.upper()} 과거 캔들 {limit}개 로드 중...") klines = await client.futures_klines(
klines = await client.futures_klines( symbol=symbol.upper(),
symbol=symbol.upper(), interval=self.interval,
interval=self.interval, limit=limit,
limit=limit, )
) for k in klines[:-1]:
for k in klines[:-1]: self.buffers[symbol].append({
self.buffers[symbol].append({ "timestamp": k[0],
"timestamp": k[0], "open": float(k[1]),
"open": float(k[1]), "high": float(k[2]),
"high": float(k[2]), "low": float(k[3]),
"low": float(k[3]), "close": float(k[4]),
"close": float(k[4]), "volume": float(k[5]),
"volume": float(k[5]), "is_closed": True,
"is_closed": True, })
}) logger.info(f"{symbol.upper()} {len(self.buffers[symbol])}개 로드 완료")
logger.info(f"{symbol.upper()} {len(self.buffers[symbol])}개 로드 완료")
async def start(self, api_key: str, api_secret: str): async def _preload_history(self, client: AsyncClient, limit: int = _PRELOAD_LIMIT):
"""REST API로 모든 심볼의 과거 캔들을 병렬로 버퍼에 미리 채운다."""
await asyncio.gather(*[
self._preload_one(client, symbol, limit) for symbol in self.symbols
])
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)
@@ -194,9 +203,34 @@ class MultiSymbolStream:
] ]
logger.info(f"Combined WebSocket 시작: {streams}") logger.info(f"Combined WebSocket 시작: {streams}")
try: try:
async with bm.futures_multiplex_socket(streams) as stream: await self._run_loop(bm, streams)
while True:
msg = await stream.recv()
await self.handle_message(msg)
finally: finally:
await client.close_connection() await client.close_connection()
async def _run_loop(self, bm: BinanceSocketManager, streams: list[str]) -> None:
"""WebSocket 연결 → 재연결 무한 루프."""
while True:
try:
async with bm.futures_multiplex_socket(streams) as stream:
logger.info("Kline WebSocket 연결 완료")
while True:
msg = await stream.recv()
if isinstance(msg, dict) and msg.get("e") == "error":
logger.warning(
f"Kline WebSocket 에러 수신: {msg.get('m', msg)} — 재연결"
)
break
await self.handle_message(msg)
except asyncio.CancelledError:
logger.info("Kline WebSocket 정상 종료")
raise
except Exception as e:
logger.warning(
f"Kline WebSocket 끊김: {e}"
f"{_RECONNECT_DELAY}초 후 재연결"
)
await asyncio.sleep(_RECONNECT_DELAY)

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 배열 연산으로 재현한다.
@@ -266,15 +275,15 @@ def _calc_features_vectorized(
eth_r3 = _align(eth_ret_3, n).astype(np.float32) eth_r3 = _align(eth_ret_3, n).astype(np.float32)
eth_r5 = _align(eth_ret_5, n).astype(np.float32) eth_r5 = _align(eth_ret_5, n).astype(np.float32)
xrp_r1 = ret_1.astype(np.float32) primary_r1 = ret_1.astype(np.float32)
xrp_btc_rs_raw = np.divide( primary_btc_rs_raw = np.divide(
xrp_r1, btc_r1, primary_r1, btc_r1,
out=np.zeros_like(xrp_r1), out=np.zeros_like(primary_r1),
where=(btc_r1 != 0), where=(btc_r1 != 0),
).astype(np.float32) ).astype(np.float32)
xrp_eth_rs_raw = np.divide( primary_eth_rs_raw = np.divide(
xrp_r1, eth_r1, primary_r1, eth_r1,
out=np.zeros_like(xrp_r1), out=np.zeros_like(primary_r1),
where=(eth_r1 != 0), where=(eth_r1 != 0),
).astype(np.float32) ).astype(np.float32)
@@ -285,8 +294,8 @@ def _calc_features_vectorized(
"eth_ret_1": _rolling_zscore(eth_r1), "eth_ret_1": _rolling_zscore(eth_r1),
"eth_ret_3": _rolling_zscore(eth_r3), "eth_ret_3": _rolling_zscore(eth_r3),
"eth_ret_5": _rolling_zscore(eth_r5), "eth_ret_5": _rolling_zscore(eth_r5),
"xrp_btc_rs": _rolling_zscore(xrp_btc_rs_raw), "primary_btc_rs": _rolling_zscore(primary_btc_rs_raw),
"xrp_eth_rs": _rolling_zscore(xrp_eth_rs_raw), "primary_eth_rs": _rolling_zscore(primary_eth_rs_raw),
}, index=d.index) }, index=d.index)
result = pd.concat([result, extra], axis=1) result = pd.concat([result, extra], axis=1)
@@ -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

@@ -1,4 +1,7 @@
import asyncio import asyncio
import math
import threading
import time as _time
from binance.client import Client from binance.client import Client
from binance.exceptions import BinanceAPIException from binance.exceptions import BinanceAPIException
from loguru import logger from loguru import logger
@@ -6,48 +9,127 @@ from src.config import Config
class BinanceFuturesClient: class BinanceFuturesClient:
# 클래스 레벨 exchange info 캐시 (TTL 24시간)
_exchange_info_cache: dict | None = None
_exchange_info_time: float = 0.0
_EXCHANGE_INFO_TTL: float = 86400.0 # 24시간
def __init__(self, config: Config, symbol: str = None): def __init__(self, config: Config, symbol: str = None):
self.config = config self.config = config
self.symbol = symbol or config.symbol self.symbol = symbol or config.symbol
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._price_precision: int | None = None
self._api_lock = threading.Lock() # requests.Session 스레드 안전성 보장
MIN_NOTIONAL = 5.0 # 바이낸스 선물 최소 명목금액 (USDT) MIN_NOTIONAL = 5.0 # 바이낸스 선물 최소 명목금액 (USDT)
async def _run_api(self, func):
"""동기 API 호출을 스레드 풀에서 실행하되, _api_lock으로 직렬화한다."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: self._call_with_lock(func),
)
def _call_with_lock(self, func):
with self._api_lock:
return func()
@classmethod
def _get_exchange_info(cls, client: Client) -> dict | None:
"""exchange info를 클래스 레벨로 캐시한다 (TTL 24시간)."""
now = _time.monotonic()
if cls._exchange_info_cache is None or (now - cls._exchange_info_time) > cls._EXCHANGE_INFO_TTL:
try:
cls._exchange_info_cache = client.futures_exchange_info()
cls._exchange_info_time = now
except Exception as e:
logger.warning(f"exchange info 조회 실패: {e}")
return cls._exchange_info_cache # 만료돼도 기존 캐시 반환
return cls._exchange_info_cache
def _load_symbol_precision(self) -> None:
"""바이낸스 exchange info에서 심볼별 수량/가격 정밀도를 로드한다."""
info = self._get_exchange_info(self.client)
if info is not None:
for s in info["symbols"]:
if s["symbol"] == self.symbol:
self._qty_precision = s.get("quantityPrecision", 1)
self._price_precision = s.get("pricePrecision", 2)
logger.info(
f"[{self.symbol}] 정밀도 로드: qty={self._qty_precision}, price={self._price_precision}"
)
return
logger.warning(f"[{self.symbol}] exchange info에서 심볼 미발견, 기본 정밀도 사용")
self._qty_precision = 1
self._price_precision = 2
@property
def qty_precision(self) -> int:
if self._qty_precision is None:
self._load_symbol_precision()
return self._qty_precision
@property
def price_precision(self) -> int:
if self._price_precision is None:
self._load_symbol_precision()
return self._price_precision
def _round_qty(self, qty: float) -> float:
"""심볼의 quantityPrecision에 맞춰 수량을 내림(truncate)한다."""
p = self.qty_precision
factor = 10 ** p
return math.floor(qty * factor) / factor
def _round_price(self, price: float) -> float:
"""심볼의 pricePrecision에 맞춰 가격을 반올림한다."""
return round(price, self.price_precision)
def calculate_quantity(self, balance: float, price: float, leverage: int, margin_ratio: float) -> float: def calculate_quantity(self, balance: float, price: float, leverage: int, margin_ratio: float) -> float:
"""동적 증거금 비율 기반 포지션 크기 계산 (최소 명목금액 $5 보장)""" """동적 증거금 비율 기반 포지션 크기 계산 (최소 명목금액 $5 보장)"""
notional = balance * margin_ratio * leverage notional = balance * margin_ratio * leverage
if notional < self.MIN_NOTIONAL: if notional < self.MIN_NOTIONAL:
notional = self.MIN_NOTIONAL notional = self.MIN_NOTIONAL
quantity = notional / price quantity = notional / price
qty_rounded = round(quantity, 1) qty_rounded = self._round_qty(quantity)
if qty_rounded * price < self.MIN_NOTIONAL: if qty_rounded * price < self.MIN_NOTIONAL:
qty_rounded = round(self.MIN_NOTIONAL / price + 0.05, 1) qty_rounded = self._round_qty(self.MIN_NOTIONAL / price + 10 ** -self.qty_precision)
return qty_rounded return qty_rounded
async def set_leverage(self, leverage: int) -> dict: async def set_leverage(self, leverage: int) -> dict:
loop = asyncio.get_event_loop() return await self._run_api(
return await loop.run_in_executor(
None,
lambda: self.client.futures_change_leverage( lambda: self.client.futures_change_leverage(
symbol=self.symbol, leverage=leverage symbol=self.symbol, leverage=leverage
), ),
) )
async def 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:
loop = asyncio.get_event_loop() balances = await self._run_api(self.client.futures_account_balance)
balances = await loop.run_in_executor(
None, self.client.futures_account_balance
)
for b in balances: for b in balances:
if b["asset"] == "USDT": if b["asset"] == "USDT":
return float(b["balance"]) return float(b["balance"])
return 0.0 return 0.0
_ALGO_ORDER_TYPES = {"STOP_MARKET", "TAKE_PROFIT_MARKET", "STOP", "TAKE_PROFIT", "TRAILING_STOP_MARKET"}
async def place_order( async def place_order(
self, self,
side: str, side: str,
@@ -57,17 +139,6 @@ class BinanceFuturesClient:
stop_price: float = None, stop_price: float = None,
reduce_only: bool = False, reduce_only: bool = False,
) -> dict: ) -> dict:
loop = asyncio.get_event_loop()
if order_type in self._ALGO_ORDER_TYPES:
return await self._place_algo_order(
side=side,
quantity=quantity,
order_type=order_type,
stop_price=stop_price,
reduce_only=reduce_only,
)
params = dict( params = dict(
symbol=self.symbol, symbol=self.symbol,
side=side, side=side,
@@ -75,51 +146,21 @@ class BinanceFuturesClient:
quantity=quantity, quantity=quantity,
reduceOnly=reduce_only, reduceOnly=reduce_only,
) )
if price: if price is not None:
params["price"] = price params["price"] = price
params["timeInForce"] = "GTC" params["timeInForce"] = "GTC"
if stop_price: if stop_price is not None:
params["stopPrice"] = stop_price params["stopPrice"] = stop_price
try: try:
return await loop.run_in_executor( return await self._run_api(
None, lambda: self.client.futures_create_order(**params) lambda: self.client.futures_create_order(**params)
) )
except BinanceAPIException as e: except BinanceAPIException as e:
logger.error(f"주문 실패: {e}") logger.error(f"주문 실패: {e}")
raise raise
async def _place_algo_order(
self,
side: str,
quantity: float,
order_type: str,
stop_price: float = None,
reduce_only: bool = False,
) -> dict:
"""STOP_MARKET / TAKE_PROFIT_MARKET 등 Algo Order API(/fapi/v1/algoOrder)로 전송."""
loop = asyncio.get_event_loop()
params = dict(
symbol=self.symbol,
side=side,
algoType="CONDITIONAL",
type=order_type,
quantity=quantity,
reduceOnly="true" if reduce_only else "false",
)
if stop_price:
params["triggerPrice"] = stop_price
try:
return await loop.run_in_executor(
None, lambda: self.client.futures_create_algo_order(**params)
)
except BinanceAPIException as e:
logger.error(f"Algo 주문 실패: {e}")
raise
async def get_position(self) -> dict | None: async def get_position(self) -> dict | None:
loop = asyncio.get_event_loop() positions = await self._run_api(
positions = await loop.run_in_executor(
None,
lambda: self.client.futures_position_information( lambda: self.client.futures_position_information(
symbol=self.symbol symbol=self.symbol
), ),
@@ -129,31 +170,85 @@ class BinanceFuturesClient:
return p return p
return None return None
async def get_open_orders(self) -> list[dict]:
"""현재 심볼의 오픈 주문 + algo 주문을 병합 반환한다."""
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_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 오픈 주문을 모두 취소한다.""" """일반 주문 + algo 주문을 모두 취소한다."""
loop = asyncio.get_event_loop() await self._run_api(
await loop.run_in_executor(
None,
lambda: self.client.futures_cancel_all_open_orders( lambda: self.client.futures_cancel_all_open_orders(
symbol=self.symbol symbol=self.symbol
), ),
) )
try: try:
await loop.run_in_executor( await self._run_api(
None, lambda: self.client.futures_cancel_all_algo_open_orders(symbol=self.symbol)
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]]:
"""최근 REALIZED_PNL + COMMISSION 내역을 조회한다.
Args:
limit: 최대 조회 건수
start_time: 밀리초 단위 시작 시각. 지정 시 해당 시각 이후 데이터만 반환.
"""
try:
pnl_params = dict(symbol=self.symbol, incomeType="REALIZED_PNL", limit=limit)
comm_params = dict(symbol=self.symbol, incomeType="COMMISSION", limit=limit)
if start_time is not None:
pnl_params["startTime"] = start_time
comm_params["startTime"] = start_time
rows = await self._run_api(
lambda: self.client.futures_income_history(**pnl_params),
)
commissions = await self._run_api(
lambda: self.client.futures_income_history(**comm_params),
)
return rows, commissions
except Exception as e: except Exception as e:
logger.warning(f"Algo 주문 전체 취소 실패 (무시): {e}") logger.warning(f"[{self.symbol}] 수익 내역 조회 실패: {e}")
return [], []
async def get_open_interest(self) -> float | None: async def get_open_interest(self) -> float | None:
"""현재 미결제약정(OI)을 조회한다. 오류 시 None 반환.""" """현재 미결제약정(OI)을 조회한다. 오류 시 None 반환."""
loop = asyncio.get_event_loop()
try: try:
result = await loop.run_in_executor( result = await self._run_api(
None,
lambda: self.client.futures_open_interest(symbol=self.symbol), lambda: self.client.futures_open_interest(symbol=self.symbol),
) )
return float(result["openInterest"]) return float(result["openInterest"])
@@ -163,10 +258,8 @@ class BinanceFuturesClient:
async def get_funding_rate(self) -> float | None: async def get_funding_rate(self) -> float | None:
"""현재 펀딩비를 조회한다. 오류 시 None 반환.""" """현재 펀딩비를 조회한다. 오류 시 None 반환."""
loop = asyncio.get_event_loop()
try: try:
result = await loop.run_in_executor( result = await self._run_api(
None,
lambda: self.client.futures_mark_price(symbol=self.symbol), lambda: self.client.futures_mark_price(symbol=self.symbol),
) )
return float(result["lastFundingRate"]) return float(result["lastFundingRate"])
@@ -176,10 +269,8 @@ class BinanceFuturesClient:
async def get_oi_history(self, limit: int = 5) -> list[float]: async def get_oi_history(self, limit: int = 5) -> list[float]:
"""최근 OI 변화율 히스토리를 조회한다 (봇 초기화용). 실패 시 빈 리스트.""" """최근 OI 변화율 히스토리를 조회한다 (봇 초기화용). 실패 시 빈 리스트."""
loop = asyncio.get_event_loop()
try: try:
result = await loop.run_in_executor( result = await self._run_api(
None,
lambda: self.client.futures_open_interest_hist( lambda: self.client.futures_open_interest_hist(
symbol=self.symbol, period="15m", limit=limit + 1, symbol=self.symbol, period="15m", limit=limit + 1,
), ),
@@ -200,27 +291,18 @@ class BinanceFuturesClient:
async def create_listen_key(self) -> str: async def create_listen_key(self) -> str:
"""POST /fapi/v1/listenKey — listenKey 신규 발급""" """POST /fapi/v1/listenKey — listenKey 신규 발급"""
loop = asyncio.get_event_loop() return await self._run_api(self.client.futures_stream_get_listen_key)
result = await loop.run_in_executor(
None,
lambda: self.client.futures_stream_get_listen_key(),
)
return result
async def keepalive_listen_key(self, listen_key: str) -> None: async def keepalive_listen_key(self, listen_key: str) -> None:
"""PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)""" """PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)"""
loop = asyncio.get_event_loop() await self._run_api(
await loop.run_in_executor(
None,
lambda: self.client.futures_stream_keepalive(listenKey=listen_key), lambda: self.client.futures_stream_keepalive(listenKey=listen_key),
) )
async def delete_listen_key(self, listen_key: str) -> None: async def delete_listen_key(self, listen_key: str) -> None:
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)""" """DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
loop = asyncio.get_event_loop()
try: try:
await loop.run_in_executor( await self._run_api(
None,
lambda: self.client.futures_stream_close(listenKey=listen_key), lambda: self.client.futures_stream_close(listenKey=listen_key),
) )
except Exception as e: except Exception as e:

View File

@@ -58,23 +58,29 @@ class Indicators:
signal_threshold: int = 3, signal_threshold: int = 3,
adx_threshold: float = 25, adx_threshold: float = 25,
volume_multiplier: float = 2.5, volume_multiplier: float = 2.5,
) -> str: ) -> tuple[str, dict]:
""" """
복합 지표 기반 매매 신호 생성. 복합 지표 기반 매매 신호 생성.
signal_threshold: 최소 가중치 합계 (기본 3) signal_threshold: 최소 가중치 합계 (기본 3)
adx_threshold: ADX 최소값 필터 (0=비활성화, 25=ADX<25이면 HOLD) adx_threshold: ADX 최소값 필터 (0=비활성화, 25=ADX<25이면 HOLD)
volume_multiplier: 거래량 급증 배수 (기본 1.5) volume_multiplier: 거래량 급증 배수 (기본 1.5)
Returns:
(signal, detail) — signal은 "LONG"/"SHORT"/"HOLD",
detail은 {"long": int, "short": int, "vol_surge": bool, "adx": float|None, "hold_reason": str}
""" """
last = df.iloc[-1] last = df.iloc[-1]
prev = df.iloc[-2] prev = df.iloc[-2]
# ADX 필터 # ADX 필터
adx = last.get("adx", None) adx = last.get("adx", None)
if adx is not None and not pd.isna(adx): adx_val = adx if adx is not None and not pd.isna(adx) else None
logger.debug(f"ADX: {adx:.1f}") if adx_val is not None:
if adx_threshold > 0 and adx < adx_threshold: logger.debug(f"ADX: {adx_val:.1f}")
return "HOLD" if adx_threshold > 0 and adx_val < adx_threshold:
detail = {"long": 0, "short": 0, "vol_surge": False, "adx": adx_val, "hold_reason": f"ADX({adx_val:.1f}) < {adx_threshold}"}
return "HOLD", detail
long_signals = 0 long_signals = 0
short_signals = 0 short_signals = 0
@@ -112,11 +118,23 @@ class Indicators:
# 6. 거래량 확인 (신호 강화) # 6. 거래량 확인 (신호 강화)
vol_surge = last["volume"] > last["vol_ma20"] * volume_multiplier vol_surge = last["volume"] > last["vol_ma20"] * volume_multiplier
detail = {"long": long_signals, "short": short_signals, "vol_surge": vol_surge, "adx": adx_val, "hold_reason": ""}
if long_signals >= signal_threshold and (vol_surge or long_signals >= signal_threshold + 1): if long_signals >= signal_threshold and (vol_surge or long_signals >= signal_threshold + 1):
return "LONG" return "LONG", detail
elif short_signals >= signal_threshold and (vol_surge or short_signals >= signal_threshold + 1): elif short_signals >= signal_threshold and (vol_surge or short_signals >= signal_threshold + 1):
return "SHORT" return "SHORT", detail
return "HOLD"
# HOLD 사유 구성
best_side = "LONG" if long_signals >= short_signals else "SHORT"
best_score = max(long_signals, short_signals)
reasons = []
if best_score < signal_threshold:
reasons.append(f"{best_side} 점수({best_score}) < 임계값({signal_threshold})")
elif not vol_surge and best_score < signal_threshold + 1:
reasons.append(f"거래량 미급증 & {best_side} 점수({best_score}) < {signal_threshold + 1}")
detail["hold_reason"] = ", ".join(reasons) if reasons else "점수 부족"
return "HOLD", detail
def get_atr_stop( def get_atr_stop(
self, df: pd.DataFrame, side: str, entry_price: float, self, df: pd.DataFrame, side: str, entry_price: float,

View File

@@ -7,7 +7,7 @@ FEATURE_COLS = [
"ret_1", "ret_3", "ret_5", "signal_strength", "side", "ret_1", "ret_3", "ret_5", "signal_strength", "side",
"btc_ret_1", "btc_ret_3", "btc_ret_5", "btc_ret_1", "btc_ret_3", "btc_ret_5",
"eth_ret_1", "eth_ret_3", "eth_ret_5", "eth_ret_1", "eth_ret_3", "eth_ret_5",
"xrp_btc_rs", "xrp_eth_rs", "primary_btc_rs", "primary_eth_rs",
# 시장 미시구조: OI 변화율(z-score), 펀딩비(z-score) # 시장 미시구조: OI 변화율(z-score), 펀딩비(z-score)
"oi_change", "funding_rate", "oi_change", "funding_rate",
# OI 파생 피처 # OI 파생 피처
@@ -28,11 +28,11 @@ def _calc_ret(closes: pd.Series, n: int) -> float:
return (closes.iloc[-1] - prev) / prev if prev != 0 else 0.0 return (closes.iloc[-1] - prev) / prev if prev != 0 else 0.0
def _calc_rs(xrp_ret: float, other_ret: float) -> float: def _calc_rs(primary_ret: float, other_ret: float) -> float:
"""상대강도 = xrp_ret / other_ret. 분모 0이면 0.0.""" """상대강도 = primary_ret / other_ret. 분모 0이면 0.0."""
if other_ret == 0.0: if other_ret == 0.0:
return 0.0 return 0.0
return xrp_ret / other_ret return primary_ret / other_ret
def _rolling_zscore_last(arr: np.ndarray, window: int = _ZSCORE_WINDOW) -> float: def _rolling_zscore_last(arr: np.ndarray, window: int = _ZSCORE_WINDOW) -> float:
@@ -144,8 +144,8 @@ def build_features(
"eth_ret_1": float(eth_ret_1), "eth_ret_1": float(eth_ret_1),
"eth_ret_3": float(eth_ret_3), "eth_ret_3": float(eth_ret_3),
"eth_ret_5": float(eth_ret_5), "eth_ret_5": float(eth_ret_5),
"xrp_btc_rs": float(_calc_rs(ret_1, btc_ret_1)), "primary_btc_rs": float(_calc_rs(ret_1, btc_ret_1)),
"xrp_eth_rs": float(_calc_rs(ret_1, eth_ret_1)), "primary_eth_rs": float(_calc_rs(ret_1, eth_ret_1)),
}) })
# 실시간에서 실제 값이 제공되면 사용, 없으면 0으로 채운다 # 실시간에서 실제 값이 제공되면 사용, 없으면 0으로 채운다
@@ -167,6 +167,8 @@ def build_features_aligned(
funding_rate: float | None = None, funding_rate: float | None = None,
oi_change_ma5: float | None = None, oi_change_ma5: float | None = None,
oi_price_spread: float | None = None, oi_price_spread: float | None = None,
oi_history: list[float] | None = None,
funding_history: list[float] | None = None,
) -> pd.Series: ) -> pd.Series:
""" """
학습(dataset_builder._calc_features_vectorized)과 동일한 rolling z-score를 학습(dataset_builder._calc_features_vectorized)과 동일한 rolling z-score를
@@ -293,17 +295,41 @@ def build_features_aligned(
"eth_ret_1": _rolling_zscore_last(eth_r1), "eth_ret_1": _rolling_zscore_last(eth_r1),
"eth_ret_3": _rolling_zscore_last(eth_r3), "eth_ret_3": _rolling_zscore_last(eth_r3),
"eth_ret_5": _rolling_zscore_last(eth_r5), "eth_ret_5": _rolling_zscore_last(eth_r5),
"xrp_btc_rs": _rolling_zscore_last(rs_btc), "primary_btc_rs": _rolling_zscore_last(rs_btc),
"xrp_eth_rs": _rolling_zscore_last(rs_eth), "primary_eth_rs": _rolling_zscore_last(rs_eth),
}) })
# OI/펀딩비 z-score (실시간 값이 제공되면 히스토리 끝에 추가하여 z-score) # OI/펀딩비 z-score (학습과 동일한 rolling z-score 적용)
# 서빙 시 OI/펀딩비 히스토리가 없으므로 단일 값 → z-score 불가, NaN 처리 if oi_history and len(oi_history) >= 2 and oi_change is not None:
# LightGBM은 NaN을 자체 처리함 oi_arr = np.array(oi_history, dtype=np.float64)
base["oi_change"] = float(oi_change) if oi_change is not None else np.nan base["oi_change"] = _rolling_zscore_last(oi_arr, window=_ZSCORE_WINDOW_OI)
base["funding_rate"] = float(funding_rate) if funding_rate is not None else np.nan else:
base["oi_change_ma5"] = float(oi_change_ma5) if oi_change_ma5 is not None else np.nan base["oi_change"] = np.nan
base["oi_price_spread"] = float(oi_price_spread) if oi_price_spread is not None else np.nan
if funding_history and len(funding_history) >= 2 and funding_rate is not None:
fr_arr = np.array(funding_history, dtype=np.float64)
base["funding_rate"] = _rolling_zscore_last(fr_arr, window=_ZSCORE_WINDOW_OI)
else:
base["funding_rate"] = np.nan
if oi_history and len(oi_history) >= 5 and oi_change_ma5 is not None:
# OI MA5 히스토리로 z-score
oi_arr = np.array(oi_history, dtype=np.float64)
ma5 = pd.Series(oi_arr).rolling(5, min_periods=1).mean().values
base["oi_change_ma5"] = _rolling_zscore_last(ma5, window=_ZSCORE_WINDOW_OI)
else:
base["oi_change_ma5"] = np.nan
# oi_price_spread = oi_z - ret_1_z (학습과 동일하게 z-score 적용된 값의 차이)
if oi_history and len(oi_history) >= 2 and oi_price_spread is not None:
oi_z = base.get("oi_change", np.nan)
ret_1_z = base.get("ret_1", 0.0)
if not np.isnan(oi_z):
base["oi_price_spread"] = oi_z - ret_1_z
else:
base["oi_price_spread"] = np.nan
else:
base["oi_price_spread"] = np.nan
base["adx"] = adx_z base["adx"] = adx_z
return pd.Series(base) return pd.Series(base)

View File

@@ -139,10 +139,12 @@ class MLFilter:
if self._onnx_session is not None: if self._onnx_session is not None:
input_name = self._onnx_session.get_inputs()[0].name input_name = self._onnx_session.get_inputs()[0].name
X = features[FEATURE_COLS].values.astype(np.float32).reshape(1, -1) X = features[FEATURE_COLS].values.astype(np.float32).reshape(1, -1)
X = np.nan_to_num(X, nan=0.0)
proba = float(self._onnx_session.run(None, {input_name: X})[0][0]) proba = float(self._onnx_session.run(None, {input_name: X})[0][0])
else: else:
available = [c for c in FEATURE_COLS if c in features.index] available = [c for c in FEATURE_COLS if c in features.index]
X = pd.DataFrame([features[available].values.astype(np.float64)], columns=available) X = pd.DataFrame([features[available].values.astype(np.float64)], columns=available)
X = X.fillna(0.0) # ONNX(nan_to_num)와 동일한 NaN 처리
proba = float(self._lgbm_model.predict_proba(X)[0][1]) proba = float(self._lgbm_model.predict_proba(X)[0][1])
logger.debug( logger.debug(
f"ML 필터 [{self.active_backend}] 확률: {proba:.3f} " f"ML 필터 [{self.active_backend}] 확률: {proba:.3f} "
@@ -153,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,18 +141,24 @@ 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)
# nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치 if normalize:
# (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값) # nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치
mean_vals = np.nanmean(X_np, axis=0) # (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
self._mean = np.nan_to_num(mean_vals, nan=0.0) # 전체-NaN 컬럼 → 평균 0.0 mean_vals = np.nanmean(X_np, axis=0)
std_vals = np.nanstd(X_np, axis=0) self._mean = np.nan_to_num(mean_vals, nan=0.0) # 전체-NaN 컬럼 → 평균 0.0
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0 std_vals = np.nanstd(X_np, axis=0)
X_np = (X_np - self._mean) / self._std self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0
X_np = np.nan_to_num(X_np, nan=0.0) 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)
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

@@ -1,3 +1,4 @@
import asyncio
import httpx import httpx
from loguru import logger from loguru import logger
@@ -5,14 +6,28 @@ 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
try:
loop = asyncio.get_running_loop()
fut = loop.run_in_executor(None, self._send_sync, content)
fut.add_done_callback(
lambda f: f.exception() and logger.warning(f"Discord 전송 실패: {f.exception()}")
)
except RuntimeError:
self._send_sync(content)
def _send_sync(self, content: str) -> None:
try: try:
resp = httpx.post( resp = httpx.post(
self.webhook_url, self.webhook_url,

View File

@@ -11,18 +11,20 @@ class RiskManager:
self.initial_balance: float = 0.0 self.initial_balance: float = 0.0
self.open_positions: dict[str, str] = {} # {symbol: side} self.open_positions: dict[str, str] = {} # {symbol: side}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._entry_lock = asyncio.Lock() # 동시 진입 시 잔고 레이스 방지
def is_trading_allowed(self) -> bool: async def is_trading_allowed(self) -> bool:
"""일일 최대 손실 초과 시 거래 중단""" """일일 최대 손실 초과 시 거래 중단"""
if self.initial_balance <= 0: async with self._lock:
if self.initial_balance <= 0:
return True
loss_pct = abs(self.daily_pnl) / self.initial_balance
if self.daily_pnl < 0 and loss_pct >= self.max_daily_loss_pct:
logger.warning(
f"일일 손실 한도 초과: {loss_pct:.2%} >= {self.max_daily_loss_pct:.2%}"
)
return False
return True return True
loss_pct = abs(self.daily_pnl) / self.initial_balance
if self.daily_pnl < 0 and loss_pct >= self.max_daily_loss_pct:
logger.warning(
f"일일 손실 한도 초과: {loss_pct:.2%} >= {self.max_daily_loss_pct:.2%}"
)
return False
return True
async def can_open_new_position(self, symbol: str, side: str) -> bool: async def can_open_new_position(self, symbol: str, side: str) -> bool:
"""포지션 오픈 가능 여부 (전체 한도 + 중복 진입 + 동일 방향 제한)""" """포지션 오픈 가능 여부 (전체 한도 + 중복 진입 + 동일 방향 제한)"""
@@ -52,14 +54,16 @@ class RiskManager:
self.daily_pnl += pnl self.daily_pnl += pnl
logger.info(f"포지션 종료: {symbol}, PnL={pnl:+.4f}, 누적={self.daily_pnl:+.4f}") logger.info(f"포지션 종료: {symbol}, PnL={pnl:+.4f}, 누적={self.daily_pnl:+.4f}")
def record_pnl(self, pnl: float): async def record_pnl(self, pnl: float):
self.daily_pnl += pnl async with self._lock:
logger.info(f"오늘 누적 PnL: {self.daily_pnl:.4f} USDT") self.daily_pnl += pnl
logger.info(f"오늘 누적 PnL: {self.daily_pnl:.4f} USDT")
def reset_daily(self): async def reset_daily(self):
"""매일 자정 초기화""" """매일 자정 초기화"""
self.daily_pnl = 0.0 async with self._lock:
logger.info("일일 PnL 초기화") self.daily_pnl = 0.0
logger.info("일일 PnL 초기화")
def set_base_balance(self, balance: float) -> None: def set_base_balance(self, balance: float) -> None:
"""봇 시작 시 기준 잔고 설정""" """봇 시작 시 기준 잔고 설정"""

View File

@@ -12,9 +12,10 @@ 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에서 합산 콜백
""" """
def __init__( def __init__(
@@ -24,23 +25,29 @@ class UserDataStream:
): ):
self._symbol = symbol.upper() self._symbol = symbol.upper()
self._on_order_filled = on_order_filled self._on_order_filled = on_order_filled
# 부분 체결 누적용: order_id → {rp, commission}
self._partial_fills: dict[int, dict[str, float]] = {}
async def start(self, api_key: str, api_secret: str) -> None: async def start(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행.""" """User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
client = await AsyncClient.create( await self._run_loop(api_key, api_secret, testnet)
api_key=api_key,
api_secret=api_secret,
)
bm = BinanceSocketManager(client)
try:
await self._run_loop(bm)
finally:
await client.close_connection()
async def _run_loop(self, bm: BinanceSocketManager) -> None: async def _run_loop(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
"""연결 → 재연결 무한 루프. BinanceSocketManager가 listenKey keepalive를 내부 처리한다.""" """연결 → 재연결 무한 루프.
매 재연결마다 AsyncClient + BinanceSocketManager를 새로 생성한다.
keepalive ping timeout 후 기존 BinanceSocketManager의 listenKey가
무효화되면 재사용 시 이벤트를 수신하지 못하는 "조용한 실패"가 발생하므로,
반드시 새 인스턴스를 만들어야 한다.
"""
while True: while True:
client = await AsyncClient.create(
api_key=api_key,
api_secret=api_secret,
demo=testnet,
)
try: try:
bm = BinanceSocketManager(client)
async with bm.futures_user_socket() as stream: async with bm.futures_user_socket() as stream:
logger.info(f"User Data Stream 연결 완료 (심볼 필터: {self._symbol})") logger.info(f"User Data Stream 연결 완료 (심볼 필터: {self._symbol})")
while True: while True:
@@ -57,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:
@@ -64,7 +75,13 @@ class UserDataStream:
f"User Data Stream 끊김: {e}" f"User Data Stream 끊김: {e}"
f"{_RECONNECT_DELAY}초 후 재연결" f"{_RECONNECT_DELAY}초 후 재연결"
) )
await asyncio.sleep(_RECONNECT_DELAY) finally:
try:
await client.close_connection()
except Exception:
pass
await asyncio.sleep(_RECONNECT_DELAY)
async def _handle_message(self, msg: dict) -> None: async def _handle_message(self, msg: dict) -> None:
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다.""" """ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
@@ -77,23 +94,66 @@ class UserDataStream:
if order.get("s", "") != self._symbol: if order.get("s", "") != self._symbol:
return return
# x: Execution Type, X: Order Status logger.info(
if order.get("x") != "TRADE" or order.get("X") != "FILLED": 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만 처리
if order.get("x") != "TRADE":
return return
order_type = order.get("o", "") order_status = order.get("X", "")
order_type = order.get("ot", order.get("o", ""))
is_reduce = order.get("R", False) is_reduce = order.get("R", False)
realized_pnl = float(order.get("rp", "0")) order_id = order.get("i", 0)
# 청산 주문 판별: reduceOnly이거나, TP/SL 타입이거나, rp != 0 # 청산 주문 판별: reduceOnly이거나 TP/SL 타입
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0 is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES
if not is_close: if not is_close:
return return
commission = abs(float(order.get("n", "0"))) 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_commission = abs(float(order.get("n", "0")))
if order_status == "PARTIALLY_FILLED":
# 부분 체결: rp와 commission을 누적
if order_id not in self._partial_fills:
self._partial_fills[order_id] = {"rp": 0.0, "commission": 0.0}
self._partial_fills[order_id]["rp"] += fill_rp
self._partial_fills[order_id]["commission"] += fill_commission
logger.debug(
f"[{self._symbol}] 부분 체결 누적 (order_id={order_id}): "
f"rp={fill_rp:+.4f}, commission={fill_commission:.4f}"
)
return
if order_status != "FILLED":
return
# 최종 체결: 이전 부분 체결분 합산
accumulated = self._partial_fills.pop(order_id, {"rp": 0.0, "commission": 0.0})
realized_pnl = accumulated["rp"] + fill_rp
commission = accumulated["commission"] + fill_commission
net_pnl = realized_pnl - commission net_pnl = realized_pnl - commission
exit_price = float(order.get("ap", "0")) exit_price = float(order.get("ap", "0"))
if exit_price == 0.0:
logger.warning(
f"[{self._symbol}] 청산 이벤트에서 exit_price=0.0 — "
f"ap 필드 누락 가능. 청산 처리 스킵 (rp={realized_pnl:+.4f})"
)
return
if order_type == "TAKE_PROFIT_MARKET": if order_type == "TAKE_PROFIT_MARKET":
close_reason = "TP" close_reason = "TP"
elif order_type == "STOP_MARKET": elif order_type == "STOP_MARKET":

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),
}) })
@@ -84,7 +86,7 @@ async def test_bot_processes_signal(config, sample_df):
bot.exchange.MIN_NOTIONAL = 5.0 bot.exchange.MIN_NOTIONAL = 5.0
bot.risk = MagicMock() bot.risk = MagicMock()
bot.risk.is_trading_allowed.return_value = True bot.risk.is_trading_allowed = AsyncMock(return_value=True)
bot.risk.can_open_new_position = AsyncMock(return_value=True) bot.risk.can_open_new_position = AsyncMock(return_value=True)
bot.risk.register_position = AsyncMock() bot.risk.register_position = AsyncMock()
bot.risk.get_dynamic_margin_ratio.return_value = 0.50 bot.risk.get_dynamic_margin_ratio.return_value = 0.50
@@ -92,7 +94,7 @@ async def test_bot_processes_signal(config, sample_df):
with patch("src.bot.Indicators") as MockInd: with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock() mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "LONG" mock_ind.get_signal.return_value = ("LONG", {"long": 3, "short": 0, "vol_surge": True, "adx": 30.0, "hold_reason": ""})
mock_ind.get_atr_stop.return_value = (0.48, 0.56) mock_ind.get_atr_stop.return_value = (0.48, 0.56)
MockInd.return_value = mock_ind MockInd.return_value = mock_ind
await bot.process_candle(sample_df) await bot.process_candle(sample_df)
@@ -108,10 +110,14 @@ async def test_close_and_reenter_calls_open_when_ml_passes(config, sample_df):
bot._open_position = AsyncMock() bot._open_position = AsyncMock()
bot.risk = MagicMock() bot.risk = MagicMock()
bot.risk.can_open_new_position = AsyncMock(return_value=True) bot.risk.can_open_new_position = AsyncMock(return_value=True)
bot.risk.close_position = AsyncMock()
bot.ml_filter = MagicMock() bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = True bot.ml_filter.is_model_loaded.return_value = True
bot.ml_filter.should_enter.return_value = True bot.ml_filter.should_enter.return_value = True
# 콜백 대기를 건너뛰도록 Event 미리 설정
bot._close_event.set()
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"} position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df) await bot._close_and_reenter(position, "SHORT", sample_df)
@@ -129,10 +135,13 @@ async def test_close_and_reenter_skips_open_when_ml_blocks(config, sample_df):
bot._open_position = AsyncMock() bot._open_position = AsyncMock()
bot.risk = MagicMock() bot.risk = MagicMock()
bot.risk.can_open_new_position = AsyncMock(return_value=True) bot.risk.can_open_new_position = AsyncMock(return_value=True)
bot.risk.close_position = AsyncMock()
bot.ml_filter = MagicMock() bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = True bot.ml_filter.is_model_loaded.return_value = True
bot.ml_filter.should_enter.return_value = False bot.ml_filter.should_enter.return_value = False
bot._close_event.set()
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"} position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df) await bot._close_and_reenter(position, "SHORT", sample_df)
@@ -150,6 +159,9 @@ async def test_close_and_reenter_skips_open_when_max_positions_reached(config, s
bot._open_position = AsyncMock() bot._open_position = AsyncMock()
bot.risk = MagicMock() bot.risk = MagicMock()
bot.risk.can_open_new_position = AsyncMock(return_value=False) bot.risk.can_open_new_position = AsyncMock(return_value=False)
bot.risk.close_position = AsyncMock()
bot._close_event.set()
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"} position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df) await bot._close_and_reenter(position, "SHORT", sample_df)
@@ -178,7 +190,7 @@ async def test_process_candle_calls_close_and_reenter_on_reverse_signal(config,
with patch("src.bot.Indicators") as MockInd: with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock() mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "SHORT" # 현재 LONG 포지션에 반대 시그널 mock_ind.get_signal.return_value = ("SHORT", {"long": 0, "short": 3, "vol_surge": True, "adx": 30.0, "hold_reason": ""}) # 현재 LONG 포지션에 반대 시그널
MockInd.return_value = mock_ind MockInd.return_value = mock_ind
await bot.process_candle(sample_df) await bot.process_candle(sample_df)
@@ -207,7 +219,7 @@ async def test_process_candle_passes_raw_signal_to_close_and_reenter_even_if_ml_
with patch("src.bot.Indicators") as MockInd: with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock() mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "SHORT" mock_ind.get_signal.return_value = ("SHORT", {"long": 0, "short": 3, "vol_surge": True, "adx": 30.0, "hold_reason": ""})
MockInd.return_value = mock_ind MockInd.return_value = mock_ind
await bot.process_candle(sample_df) await bot.process_candle(sample_df)
@@ -234,7 +246,7 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df):
bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001) bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001)
bot.risk = MagicMock() bot.risk = MagicMock()
bot.risk.is_trading_allowed.return_value = True bot.risk.is_trading_allowed = AsyncMock(return_value=True)
bot.risk.can_open_new_position = AsyncMock(return_value=True) bot.risk.can_open_new_position = AsyncMock(return_value=True)
bot.risk.register_position = AsyncMock() bot.risk.register_position = AsyncMock()
bot.risk.get_dynamic_margin_ratio.return_value = 0.50 bot.risk.get_dynamic_margin_ratio.return_value = 0.50
@@ -243,7 +255,7 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df):
with patch("src.bot.Indicators") as mock_ind_cls: with patch("src.bot.Indicators") as mock_ind_cls:
mock_ind = MagicMock() mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "LONG" mock_ind.get_signal.return_value = ("LONG", {"long": 3, "short": 0, "vol_surge": True, "adx": 30.0, "hold_reason": ""})
mock_ind_cls.return_value = mock_ind mock_ind_cls.return_value = mock_ind
with patch("src.bot.build_features_aligned") as mock_build: with patch("src.bot.build_features_aligned") as mock_build:
@@ -266,7 +278,7 @@ def test_bot_has_oi_history_deque(config):
with patch("src.bot.BinanceFuturesClient"): with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config) bot = TradingBot(config)
assert isinstance(bot._oi_history, deque) assert isinstance(bot._oi_history, deque)
assert bot._oi_history.maxlen == 5 assert bot._oi_history.maxlen == 96
@pytest.mark.asyncio @pytest.mark.asyncio

View File

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

View File

@@ -6,10 +6,11 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
# DB_PATH를 테스트용 임시 파일로 설정 (import 전에) # DB_PATH와 DASHBOARD_RESET_KEY를 테스트용으로 설정 (import 전에)
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) _tmp_dir = tempfile.mkdtemp()
os.environ["DB_PATH"] = _tmp_db.name _tmp_db_path = os.path.join(_tmp_dir, "test_dashboard.db")
_tmp_db.close() os.environ["DB_PATH"] = _tmp_db_path
os.environ["DASHBOARD_RESET_KEY"] = "test-reset-key"
import dashboard_api # noqa: E402 import dashboard_api # noqa: E402
from fastapi.testclient import TestClient # noqa: E402 from fastapi.testclient import TestClient # noqa: E402
@@ -90,9 +91,18 @@ def setup_db():
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)", "INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
("TRXUSDT", "2026-03-06 00:00:00", 0.23, "SHORT"), ("TRXUSDT", "2026-03-06 00:00:00", 0.23, "SHORT"),
) )
conn.execute(
"INSERT INTO daily_pnl(symbol,date,cumulative_pnl,trade_count,wins,losses) VALUES(?,?,?,?,?,?)",
("TRXUSDT", "2026-03-05", 10.0, 1, 1, 0),
)
conn.commit() conn.commit()
conn.close() conn.close()
yield yield
# cleanup
try:
os.unlink(db_path)
except OSError:
pass
client = TestClient(dashboard_api.app) client = TestClient(dashboard_api.app)
@@ -122,8 +132,10 @@ def test_get_position_by_symbol():
def test_get_trades_by_symbol(): def test_get_trades_by_symbol():
r = client.get("/api/trades?symbol=TRXUSDT") r = client.get("/api/trades?symbol=TRXUSDT")
assert r.status_code == 200 assert r.status_code == 200
assert len(r.json()["trades"]) == 1 data = r.json()
assert r.json()["trades"][0]["symbol"] == "TRXUSDT" assert len(data["trades"]) == 1
assert data["trades"][0]["symbol"] == "TRXUSDT"
assert data["total"] == 1
def test_get_candles_by_symbol(): def test_get_candles_by_symbol():
@@ -142,3 +154,77 @@ def test_get_stats_by_symbol():
r = client.get("/api/stats?symbol=TRXUSDT") r = client.get("/api/stats?symbol=TRXUSDT")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["total_trades"] == 1 assert r.json()["total_trades"] == 1
# ── M6: 누락된 테스트 추가 ──────────────────────────────────────
def test_health():
r = client.get("/api/health")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["candles_count"] >= 0
def test_daily():
r = client.get("/api/daily?symbol=TRXUSDT")
assert r.status_code == 200
data = r.json()
assert len(data["daily"]) == 1
assert data["daily"][0]["net_pnl"] == 10.0
def test_daily_all():
r = client.get("/api/daily")
assert r.status_code == 200
assert "daily" in r.json()
def test_reset_requires_api_key():
"""C1: API key 없이 reset 호출 시 403."""
r = client.post("/api/reset")
assert r.status_code == 403
def test_reset_wrong_api_key():
"""C1: 잘못된 API key로 reset 호출 시 403."""
r = client.post("/api/reset", headers={"X-API-Key": "wrong-key"})
assert r.status_code == 403
def test_reset_with_valid_key():
"""C1+C2: 올바른 API key로 reset 호출 시 성공."""
r = client.post("/api/reset", headers={"X-API-Key": "test-reset-key"})
assert r.status_code == 200
assert r.json()["status"] == "ok"
# DB가 비워졌는지 확인
r2 = client.get("/api/trades")
assert r2.json()["total"] == 0
def test_trades_offset_validation():
"""I2: 음수 offset은 422 에러."""
r = client.get("/api/trades?offset=-1")
assert r.status_code == 422
def test_trades_pagination():
"""M6: 페이지네이션 동작 확인."""
r = client.get("/api/trades?limit=1&offset=0")
assert r.status_code == 200
data = r.json()
assert len(data["trades"]) <= 1
assert "total" in data
def test_health_error_no_detail_leak():
"""I6: health에서 에러 시 내부 경로 미노출."""
# 일시적으로 DB 경로를 존재하지 않는 곳으로 설정
original = dashboard_api.DB_PATH
dashboard_api.DB_PATH = "/nonexistent/path/db.sqlite"
r = client.get("/api/health")
dashboard_api.DB_PATH = original
data = r.json()
assert data["status"] == "error"
assert "/nonexistent" not in data.get("detail", "")

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가 발생하지 않아야 한다."""
@@ -203,11 +184,11 @@ def test_rs_zero_denominator():
signal_arr = _calc_signals(d) signal_arr = _calc_signals(d)
feat = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df) feat = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
assert "xrp_btc_rs" in feat.columns, "xrp_btc_rs 컬럼이 있어야 함" assert "primary_btc_rs" in feat.columns, "primary_btc_rs 컬럼이 있어야 함"
assert not feat["xrp_btc_rs"].isin([np.inf, -np.inf]).any(), \ assert not feat["primary_btc_rs"].isin([np.inf, -np.inf]).any(), \
"xrp_btc_rs에 inf가 있으면 안 됨" "primary_btc_rs에 inf가 있으면 안 됨"
assert not feat["xrp_btc_rs"].isna().all(), \ assert not feat["primary_btc_rs"].isna().all(), \
"xrp_btc_rs가 전부 nan이면 안 됨" "primary_btc_rs가 전부 nan이면 안 됨"
@pytest.fixture @pytest.fixture

View File

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

View File

@@ -66,10 +66,11 @@ def test_adx_filter_blocks_low_adx(sample_df):
df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 3 df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 3
df["adx"] = 15.0 df["adx"] = 15.0
# 기본 adx_threshold=25이므로 ADX=15은 HOLD # 기본 adx_threshold=25이므로 ADX=15은 HOLD
signal = ind.get_signal(df) signal, detail = ind.get_signal(df)
assert signal == "HOLD" assert signal == "HOLD"
assert "ADX" in detail["hold_reason"]
# adx_threshold=0이면 ADX 필터 비활성화 → LONG # adx_threshold=0이면 ADX 필터 비활성화 → LONG
signal = ind.get_signal(df, adx_threshold=0) signal, detail = ind.get_signal(df, adx_threshold=0)
assert signal == "LONG" assert signal == "LONG"
@@ -78,13 +79,15 @@ def test_adx_nan_falls_through(sample_df):
ind = Indicators(sample_df) ind = Indicators(sample_df)
df = ind.calculate_all() df = ind.calculate_all()
df["adx"] = float("nan") df["adx"] = float("nan")
signal = ind.get_signal(df) signal, detail = ind.get_signal(df)
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나 # NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
assert signal in ("LONG", "SHORT", "HOLD") assert signal in ("LONG", "SHORT", "HOLD")
assert detail["adx"] is None
def test_signal_returns_direction(sample_df): def test_signal_returns_direction(sample_df):
ind = Indicators(sample_df) ind = Indicators(sample_df)
df = ind.calculate_all() df = ind.calculate_all()
signal = ind.get_signal(df) signal, detail = ind.get_signal(df)
assert signal in ("LONG", "SHORT", "HOLD") assert signal in ("LONG", "SHORT", "HOLD")
assert "long" in detail and "short" in detail

View File

@@ -48,7 +48,7 @@ def test_build_features_rs_zero_when_btc_ret_zero():
btc_df["close"] = 50000.0 # 모든 캔들 동일 btc_df["close"] = 50000.0 # 모든 캔들 동일
eth_df = _make_df(10, base_price=3000.0) eth_df = _make_df(10, base_price=3000.0)
features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df) features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df)
assert features["xrp_btc_rs"] == 0.0 assert features["primary_btc_rs"] == 0.0
def test_feature_cols_has_24_items(): def test_feature_cols_has_24_items():
"""Legacy test — updated to 26 after OI derived features added.""" """Legacy test — updated to 26 after OI derived features added."""

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

View File

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

341
tests/test_weekly_report.py Normal file
View File

@@ -0,0 +1,341 @@
import pytest
from unittest.mock import patch, MagicMock
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
def test_fetch_latest_data_calls_subprocess():
"""fetch_latest_data가 심볼별로 fetch_history.py를 호출하는지 확인."""
from scripts.weekly_report import fetch_latest_data
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
fetch_latest_data(["XRPUSDT", "TRXUSDT"], days=35)
assert mock_run.call_count == 2
args_0 = mock_run.call_args_list[0][0][0]
assert "--symbol" in args_0
assert "XRPUSDT" in args_0
assert "--days" in args_0
assert "35" in args_0
def test_run_backtest_returns_summary():
"""run_backtest가 WF 백테스트를 실행하고 결과를 반환하는지 확인."""
from scripts.weekly_report import run_backtest
mock_result = {
"summary": {
"total_trades": 27, "total_pnl": 217.0, "return_pct": 21.7,
"win_rate": 66.7, "profit_factor": 1.57, "max_drawdown_pct": 12.0,
"sharpe_ratio": 33.3, "avg_win": 20.0, "avg_loss": -10.0,
"total_fees": 5.0, "close_reasons": {},
},
"folds": [], "trades": [],
}
with patch("scripts.weekly_report.WalkForwardBacktester") as MockWF:
MockWF.return_value.run.return_value = mock_result
result = run_backtest(
symbols=["XRPUSDT"], train_months=3, test_months=1,
params={"atr_sl_mult": 2.0, "atr_tp_mult": 2.0,
"signal_threshold": 3, "adx_threshold": 25,
"volume_multiplier": 2.5},
)
assert result["summary"]["profit_factor"] == 1.57
assert result["summary"]["total_trades"] == 27
def test_fetch_live_trades_from_api():
"""대시보드 API에서 청산 트레이드를 가져오는지 확인."""
from scripts.weekly_report import fetch_live_trades
mock_response = MagicMock()
mock_response.json.return_value = {
"trades": [
{"symbol": "XRPUSDT", "direction": "LONG", "net_pnl": 19.568,
"commission": 0.216, "status": "CLOSED"},
],
"total": 1,
}
mock_response.raise_for_status = MagicMock()
with patch("scripts.weekly_report.httpx.get", return_value=mock_response):
trades = fetch_live_trades("http://test:8000")
assert len(trades) == 1
assert trades[0]["symbol"] == "XRPUSDT"
assert trades[0]["net_pnl"] == pytest.approx(19.568)
def test_fetch_live_trades_api_failure():
"""API 실패 시 빈 리스트 반환."""
from scripts.weekly_report import fetch_live_trades
with patch("scripts.weekly_report.httpx.get", side_effect=Exception("connection refused")):
trades = fetch_live_trades("http://unreachable:8000")
assert trades == []
def test_fetch_live_stats_from_api():
"""대시보드 API에서 전체 통계를 가져오는지 확인."""
from scripts.weekly_report import fetch_live_stats
mock_response = MagicMock()
mock_response.json.return_value = {
"total_trades": 15, "wins": 9, "losses": 6,
"total_pnl": 42.5, "total_fees": 3.2,
}
mock_response.raise_for_status = MagicMock()
with patch("scripts.weekly_report.httpx.get", return_value=mock_response):
stats = fetch_live_stats("http://test:8000")
assert stats["total_trades"] == 15
assert stats["wins"] == 9
import json
from datetime import date, timedelta
def test_load_trend_reads_previous_reports(tmp_path):
"""이전 주간 리포트를 읽어 PF/승률/MDD 추이를 반환."""
from scripts.weekly_report import load_trend
for i, (pf, wr, mdd) in enumerate([
(1.31, 48.0, 9.0), (1.24, 45.0, 11.0),
(1.20, 44.0, 12.0), (1.18, 43.0, 14.0),
]):
d = date(2026, 3, 7) - timedelta(weeks=3 - i)
report = {
"date": d.isoformat(),
"backtest": {"summary": {
"profit_factor": pf, "win_rate": wr, "max_drawdown_pct": mdd,
"total_trades": 20,
}},
}
(tmp_path / f"report_{d.isoformat()}.json").write_text(json.dumps(report))
trend = load_trend(str(tmp_path), weeks=4)
assert len(trend["pf"]) == 4
assert trend["pf"] == [1.31, 1.24, 1.20, 1.18]
assert trend["pf_declining_3w"] is True
def test_load_trend_empty_dir(tmp_path):
"""리포트가 없으면 빈 추이 반환."""
from scripts.weekly_report import load_trend
trend = load_trend(str(tmp_path), weeks=4)
assert trend["pf"] == []
assert trend["pf_declining_3w"] is False
def test_check_ml_trigger_all_met():
"""3개 조건 모두 충족 시 recommend=True."""
from scripts.weekly_report import check_ml_trigger
result = check_ml_trigger(
cumulative_trades=200, current_pf=0.85, pf_declining_3w=True,
)
assert result["recommend"] is True
assert result["met_count"] == 3
def test_check_ml_trigger_none_met():
"""조건 미충족 시 recommend=False."""
from scripts.weekly_report import check_ml_trigger
result = check_ml_trigger(
cumulative_trades=50, current_pf=1.5, pf_declining_3w=False,
)
assert result["recommend"] is False
assert result["met_count"] == 0
def test_run_degradation_sweep_returns_top_n():
"""스윕을 실행하고 PF 상위 N개 대안을 반환."""
from scripts.weekly_report import run_degradation_sweep
from unittest.mock import patch
fake_summaries = [
{"profit_factor": 1.15, "total_trades": 30, "total_pnl": 50, "return_pct": 5,
"win_rate": 55, "avg_win": 10, "avg_loss": -8, "max_drawdown_pct": 10,
"sharpe_ratio": 2.0, "total_fees": 1, "close_reasons": {}},
{"profit_factor": 1.08, "total_trades": 25, "total_pnl": 30, "return_pct": 3,
"win_rate": 50, "avg_win": 8, "avg_loss": -7, "max_drawdown_pct": 12,
"sharpe_ratio": 1.5, "total_fees": 1, "close_reasons": {}},
{"profit_factor": 0.95, "total_trades": 20, "total_pnl": -10, "return_pct": -1,
"win_rate": 40, "avg_win": 6, "avg_loss": -9, "max_drawdown_pct": 15,
"sharpe_ratio": 0.5, "total_fees": 1, "close_reasons": {}},
]
fake_combos = [
{"atr_sl_mult": 1.5}, {"atr_sl_mult": 1.0}, {"atr_sl_mult": 2.0},
]
with patch("scripts.weekly_report.run_single_backtest") as mock_bt:
mock_bt.side_effect = fake_summaries
with patch("scripts.weekly_report.generate_combinations", return_value=fake_combos):
alternatives = run_degradation_sweep(
symbols=["XRPUSDT"], train_months=3, test_months=1, top_n=3,
)
assert len(alternatives) <= 3
assert alternatives[0]["summary"]["profit_factor"] >= alternatives[1]["summary"]["profit_factor"]
def test_format_report_normal():
"""정상 상태(PF >= 1.0) 리포트 포맷."""
from scripts.weekly_report import format_report
report_data = {
"date": "2026-03-07",
"backtest": {
"summary": {
"profit_factor": 1.24, "win_rate": 45.0,
"max_drawdown_pct": 12.0, "total_trades": 88,
},
"per_symbol": {
"XRPUSDT": {"profit_factor": 1.57, "total_trades": 27, "win_rate": 66.7},
"TRXUSDT": {"profit_factor": 1.29, "total_trades": 25, "win_rate": 52.0},
"DOGEUSDT": {"profit_factor": 1.09, "total_trades": 36, "win_rate": 44.4},
},
},
"live_trades": {"count": 8, "net_pnl": 12.34, "win_rate": 62.5},
"trend": {"pf": [1.31, 1.24], "win_rate": [48.0, 45.0], "mdd": [9.0, 12.0], "pf_declining_3w": False},
"ml_trigger": {"recommend": False, "met_count": 0, "conditions": {
"cumulative_trades_enough": False, "pf_below_1": False, "pf_declining_3w": False,
}, "cumulative_trades": 47, "threshold": 150},
"sweep": None,
}
text = format_report(report_data)
assert "\uc8fc\uac04 \uc804\ub7b5 \ub9ac\ud3ec\ud2b8" in text
assert "1.24" in text
assert "XRPUSDT" in text or "XRP" in text
def test_format_report_degraded():
"""PF < 1.0일 때 스윕 결과 + ML 권장이 포함되는지 확인."""
from scripts.weekly_report import format_report
report_data = {
"date": "2026-06-07",
"backtest": {
"summary": {"profit_factor": 0.87, "win_rate": 38.0, "max_drawdown_pct": 22.0, "total_trades": 90},
"per_symbol": {},
},
"live_trades": {"count": 0, "net_pnl": 0, "win_rate": 0},
"trend": {"pf": [1.1, 1.0, 0.87], "win_rate": [], "mdd": [], "pf_declining_3w": True},
"ml_trigger": {"recommend": True, "met_count": 3, "conditions": {
"cumulative_trades_enough": True, "pf_below_1": True, "pf_declining_3w": True,
}, "cumulative_trades": 182, "threshold": 150},
"sweep": [
{"params": {"atr_sl_mult": 2.0, "atr_tp_mult": 2.5, "adx_threshold": 30, "volume_multiplier": 2.5, "signal_threshold": 3},
"summary": {"profit_factor": 1.15, "total_trades": 30}},
],
}
text = format_report(report_data)
assert "0.87" in text
assert "ML" in text
assert "1.15" in text
def test_send_report_uses_notifier():
"""Discord 웹훅으로 리포트를 전송."""
from scripts.weekly_report import send_report
from unittest.mock import patch
with patch("scripts.weekly_report.DiscordNotifier") as MockNotifier:
instance = MockNotifier.return_value
send_report("test report content", webhook_url="https://example.com/webhook")
instance._send.assert_called_once_with("test report content")
def test_generate_report_orchestration(tmp_path):
"""generate_report가 모든 단계를 조합하여 리포트 dict를 반환."""
from scripts.weekly_report import generate_report
from unittest.mock import patch
# 합산 지표는 개별 트레이드에서 직접 계산되므로 mock에 트레이드 포함
mock_trades = [
{"net_pnl": 20.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-10 12:00:00"},
{"net_pnl": 15.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-11 12:00:00"},
{"net_pnl": -10.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-12 12:00:00"},
]
mock_bt_result = {
"summary": {
"profit_factor": 1.24, "win_rate": 45.0,
"max_drawdown_pct": 12.0, "total_trades": 3,
"total_pnl": 25.0, "return_pct": 2.5,
"avg_win": 17.5, "avg_loss": -10.0,
"sharpe_ratio": 33.0, "total_fees": 6.0,
"close_reasons": {},
},
"folds": [], "trades": mock_trades,
}
with patch("scripts.weekly_report.run_backtest", return_value=mock_bt_result):
with patch("scripts.weekly_report.fetch_live_stats", return_value={"total_trades": 0, "wins": 0, "total_pnl": 0}):
with patch("scripts.weekly_report.fetch_live_trades", return_value=[]):
with patch("scripts.weekly_report.load_trend", return_value={
"pf": [1.31], "win_rate": [48.0], "mdd": [9.0], "pf_declining_3w": False,
}):
report = generate_report(
symbols=["XRPUSDT"],
report_dir=str(tmp_path),
report_date=date(2026, 3, 7),
)
assert report["date"] == "2026-03-07"
# PF는 개별 트레이드에서 직접 계산: GP=35, GL=10 → 3.5
assert report["backtest"]["summary"]["profit_factor"] == 3.5
assert report["backtest"]["summary"]["total_trades"] == 3
assert report["sweep"] is None # PF >= 1.0이면 스윕 안 함
def test_save_report_creates_json(tmp_path):
"""리포트를 JSON으로 저장."""
from scripts.weekly_report import save_report
report = {"date": "2026-03-07", "test": True}
save_report(report, str(tmp_path))
saved = list(tmp_path.glob("report_*.json"))
assert len(saved) == 1
loaded = json.loads(saved[0].read_text())
assert loaded["date"] == "2026-03-07"
def test_generate_quantstats_report_creates_html(tmp_path):
"""트레이드 데이터로 quantstats HTML 리포트를 생성."""
from scripts.weekly_report import generate_quantstats_report
trades = [
{"exit_time": "2026-03-01 12:00:00", "net_pnl": 5.0},
{"exit_time": "2026-03-02 15:00:00", "net_pnl": -2.0},
{"exit_time": "2026-03-03 09:00:00", "net_pnl": 8.0},
{"exit_time": "2026-03-04 18:00:00", "net_pnl": -1.5},
{"exit_time": "2026-03-05 10:00:00", "net_pnl": 3.0},
]
output = str(tmp_path / "test_report.html")
result = generate_quantstats_report(trades, output)
assert result is not None
assert Path(result).exists()
content = Path(result).read_text()
assert "CoinTrader" in content
def test_generate_quantstats_report_empty_trades(tmp_path):
"""트레이드가 없으면 None 반환."""
from scripts.weekly_report import generate_quantstats_report
output = str(tmp_path / "empty.html")
result = generate_quantstats_report([], output)
assert result is None