94 Commits

Author SHA1 Message Date
21in7
6fe2158511 feat: enhance precision optimization in model training
- Introduced a new plan to modify the Optuna objective function to prioritize precision under a recall constraint of 0.35, improving model performance in scenarios where false positives are costly.
- Updated training scripts to implement precision-based metrics and adjusted the walk-forward cross-validation process to incorporate precision and recall calculations.
- Enhanced the active LGBM parameters and training log to reflect the new metrics and model configurations.
- Added a new design document outlining the implementation steps for the precision-focused optimization.

This update aims to refine the model's decision-making process by emphasizing precision, thereby reducing potential losses from false positives.
2026-03-03 00:57:19 +09:00
21in7
3613e3bf18 feat: update active LGBM parameters and training log with new metrics
- Updated active LGBM parameters with new timestamp, trial results, and model configurations to reflect recent training outcomes.
- Added new entries to the training log, capturing detailed metrics including AUC, precision, recall, and tuned parameters for the latest model iterations.

This update enhances the tracking of model performance and parameter tuning in the ML pipeline.
2026-03-03 00:21:43 +09:00
21in7
fce4d536ea feat: implement HOLD negative sampling and stratified undersampling in ML pipeline
Added HOLD candles as negative samples to increase training data from ~535 to ~3,200 samples. Introduced a negative_ratio parameter in generate_dataset_vectorized() for sampling HOLD candles alongside signal candles. Implemented stratified undersampling to ensure signal samples are preserved during training. Updated relevant tests to validate new functionality and maintain compatibility with existing tests.

- Modified dataset_builder.py to include HOLD negative sampling logic
- Updated train_model.py to apply stratified undersampling
- Added tests for new sampling methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:13:42 +09:00
21in7
74966590b5 feat: apply stratified undersampling to hyperparameter tuning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:09:43 +09:00
21in7
6cd54b46d9 feat: apply stratified undersampling to training pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:03:09 +09:00
21in7
0af138d8ee feat: add stratified_undersample helper function
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:58:15 +09:00
21in7
b7ad358a0a fix: make HOLD negative sampling tests non-vacuous
The two HOLD negative tests (test_hold_negative_labels_are_all_zero,
test_signal_samples_preserved_after_sampling) were passing vacuously
because sample_df produces 0 signal candles (ADX ~18, below threshold
25). Added signal_producing_df fixture with higher volatility and volume
surges to reliably generate signals. Removed if-guards so assertions
are mandatory. Also restored the full docstring for
generate_dataset_vectorized() documenting btc_df/eth_df,
time_weight_decay, and negative_ratio parameters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:45:10 +09:00
21in7
8e56301d52 feat: add HOLD negative sampling to dataset_builder
Add negative_ratio parameter to generate_dataset_vectorized() that
samples HOLD candles as label=0 negatives alongside signal candles.
This increases training data from ~535 to ~3,200 samples when enabled.

- Split valid_rows into base_valid (shared) and sig_valid (signal-only)
- Add 'source' column ("signal" vs "hold_negative") for traceability
- HOLD samples get label=0 and random 50/50 side assignment
- Default negative_ratio=0 preserves backward compatibility
- Fix incorrect column count assertion in existing test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:34:45 +09:00
21in7
99fa508db7 feat: add CLAUDE.md and settings.json for project documentation and plugin configuration
Introduced CLAUDE.md to provide comprehensive guidance on the CoinTrader project, including architecture, common commands, testing, and deployment details. Added settings.json to enable the superpowers plugin for Claude. This enhances the project's documentation and configuration management.
2026-03-02 20:01:18 +09:00
21in7
eeb5e9d877 feat: add ADX filter to block sideways market entries
ADX < 25 now returns HOLD in get_signal(), preventing entries during
trendless (sideways) markets. NaN ADX values fall through to existing
weighted signal logic. Also syncs the vectorized dataset builder with
the same ADX filter to keep training data consistent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:55:12 +09:00
21in7
c8a2c36bfb feat: add ADX calculation to indicators
Add ADX (Average Directional Index) with period 14 to calculate_all()
for sideways market filtering. Includes test verifying the adx column
exists and contains non-negative values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:47:18 +09:00
21in7
b8b99da207 feat: update training log and enhance ML filter functionality
- Added a new entry to the training log with detailed metrics for a LightGBM model, including AUC, precision, recall, and tuned parameters.
- Enhanced the MLFilter class to include a guard clause that prevents execution if the filter is disabled, improving robustness.
2026-03-02 18:24:38 +09:00
21in7
77590accf2 feat: add architecture documentation for CoinTrader
- Introduced a comprehensive architecture document detailing the CoinTrader system, including an overview, core layer architecture, MLOps pipeline, and key operational scenarios.
- Updated README to reference the new architecture document and added a configuration option to disable the ML filter.
- Enhanced the ML filter to allow for complete signal acceptance when the NO_ML_FILTER environment variable is set.
2026-03-02 18:02:05 +09:00
21in7
a8cba2cb4c docs: enhance README with detailed listenKey auto-renewal process and error handling
- Updated the README to clarify the listenKey auto-renewal mechanism, including the use of `stream.recv()` for message reception.
- Added information on immediate reconnection upon detecting internal error payloads to prevent zombie connections.
2026-03-02 16:43:45 +09:00
21in7
52affb5532 feat: implement User Data Stream for real-time TP/SL detection and PnL tracking
- Introduced User Data Stream to detect TP/SL executions in real-time.
- Added a new class `UserDataStream` for managing the stream and handling events.
- Updated `bot.py` to initialize and run the User Data Stream in parallel with the candle stream.
- Enhanced `notifier.py` to send detailed Discord notifications including estimated vs actual PnL.
- Added methods in `exchange.py` for managing listenKey lifecycle (create, keepalive, delete).
- Refactored PnL recording and notification logic to streamline handling of position closures.

Made-with: Cursor
2026-03-02 16:33:08 +09:00
21in7
05ae88dc61 fix: remove manual listenKey mgmt, add symbol filter, fix reenter race condition
Made-with: Cursor
2026-03-02 16:31:40 +09:00
21in7
6237efe4d3 docs: update README with User Data Stream TP/SL detection feature
Made-with: Cursor
2026-03-02 16:27:50 +09:00
21in7
4e8e61b5cf fix: guard against None current_trade_side in _calc_estimated_pnl
Made-with: Cursor
2026-03-02 16:27:17 +09:00
21in7
4ffee0ae8b feat: run UserDataStream in parallel with candle stream
Made-with: Cursor
2026-03-02 16:25:13 +09:00
21in7
7e7f0f4f22 fix: restore entry_price and entry_quantity on position recovery
Made-with: Cursor
2026-03-02 16:24:27 +09:00
21in7
c4f806fc35 feat: add entry state tracking and _on_position_closed callback
- __init__에 _entry_price, _entry_quantity 상태 변수 추가 (None 초기화)
- _open_position()에서 current_trade_side 저장 직후 진입가/수량 저장
- _calc_estimated_pnl() 헬퍼: LONG/SHORT 방향별 예상 PnL 계산
- _on_position_closed() 콜백: UDS 청산 감지 시 PnL 기록·알림·상태 초기화

Made-with: Cursor
2026-03-02 16:21:59 +09:00
21in7
22f1debb3d fix: re-raise CancelledError in UserDataStream for proper task cancellation
Made-with: Cursor
2026-03-02 16:20:37 +09:00
21in7
4f3183df47 feat: add UserDataStream with keepalive and reconnect loop
Made-with: Cursor
2026-03-02 16:17:38 +09:00
21in7
223608bec0 refactor: remove duplicate pnl/notify from _close_position (handled by callback)
Made-with: Cursor
2026-03-02 16:16:25 +09:00
21in7
e72126516b feat: extend notify_close with close_reason, net_pnl, diff fields
Made-with: Cursor
2026-03-02 16:14:26 +09:00
21in7
63c2eb8927 feat: add listenKey create/keepalive/delete methods to exchange
Made-with: Cursor
2026-03-02 16:11:33 +09:00
21in7
dcdaf9f90a chore: update active LGBM parameters and add new training log entry
- Updated timestamp and elapsed seconds in models/active_lgbm_params.json.
- Adjusted baseline AUC and fold AUCs to reflect new model performance.
- Added a new entry in models/training_log.json with detailed metrics from the latest training run, including tuned parameters and model path.

Made-with: Cursor
2026-03-02 15:03:35 +09:00
21in7
6d82febab7 feat: implement Active Config pattern for automatic param promotion
- tune_hyperparams.py: 탐색 완료 후 Best AUC > Baseline AUC 이면
  models/active_lgbm_params.json 자동 갱신
- tune_hyperparams.py: 베이스라인을 active 파일 기준으로 측정
  (active 없으면 코드 내 기본값 사용)
- train_model.py: _load_lgbm_params()에 active 파일 자동 탐색 추가
  우선순위: --tuned-params > active_lgbm_params.json > 하드코딩 기본값
- models/active_lgbm_params.json: 현재 best 파라미터로 초기화
- .gitignore: tune_results_*.json 제외, active 파일은 git 추적 유지

Made-with: Cursor
2026-03-02 14:56:42 +09:00
21in7
d5f8ed4789 feat: update default LightGBM params to Optuna best (trial #46, AUC=0.6002)
Optuna 50 trials Walk-Forward 5폴드 탐색 결과 (tune_results_20260302_144749.json):
- Baseline AUC: 0.5803 → Best AUC: 0.6002 (+0.0199, +3.4%)
- n_estimators: 500 → 434
- learning_rate: 0.05 → 0.123659
- max_depth: (미설정) → 6
- num_leaves: 31 → 14
- min_child_samples: 15 → 10
- subsample: 0.8 → 0.929062
- colsample_bytree: 0.8 → 0.946330
- reg_alpha: 0.05 → 0.573971
- reg_lambda: 0.1 → 0.000157
- weight_scale: 1.0 → 1.783105

Made-with: Cursor
2026-03-02 14:52:41 +09:00
21in7
ce02f1335c feat: add run_optuna.sh wrapper script for Optuna tuning
Made-with: Cursor
2026-03-02 14:50:50 +09:00
21in7
4afc7506d7 feat: connect Optuna tuning results to train_model.py via --tuned-params
- _load_lgbm_params() 헬퍼 추가: 기본 파라미터 반환, JSON 주어지면 덮어씀
- train(): tuned_params_path 인자 추가, weight_scale 적용
- walk_forward_auc(): tuned_params_path 인자 추가, weight_scale 적용
- main(): --tuned-params argparse 인자 추가, 두 함수에 전달
- training_log.json에 tuned_params_path, lgbm_params, weight_scale 기록

Made-with: Cursor
2026-03-02 14:45:15 +09:00
21in7
caaa81f5f9 fix: add shebang and executable permission to tune_hyperparams.py
Made-with: Cursor
2026-03-02 14:41:13 +09:00
21in7
8dd1389b16 feat: add Optuna Walk-Forward AUC hyperparameter tuning pipeline
- scripts/tune_hyperparams.py: Optuna + Walk-Forward 5폴드 AUC 목적 함수
  - 데이터셋 1회 캐싱으로 모든 trial 공유 (속도 최적화)
  - num_leaves <= 2^max_depth - 1 제약 강제 (소규모 데이터 과적합 방지)
  - MedianPruner로 저성능 trial 조기 종료
  - 결과: 콘솔 리포트 + models/tune_results_YYYYMMDD_HHMMSS.json
- requirements.txt: optuna>=3.6.0 추가
- README.md: 하이퍼파라미터 자동 튜닝 사용법 섹션 추가
- docs/plans/: 설계 문서 및 구현 플랜 추가

Made-with: Cursor
2026-03-02 14:39:07 +09:00
21in7
4c09d63505 feat: implement upsert functionality in fetch_history.py to accumulate OI/funding data
- Added `--upsert` flag to `fetch_history.py` for merging new data into existing parquet files.
- Implemented `upsert_parquet()` function to update existing rows with new values where `oi_change` and `funding_rate` are 0.0, while appending new rows.
- Created tests in `tests/test_fetch_history.py` to validate upsert behavior.
- Updated `.gitignore` to include `.cursor/` directory.

Made-with: Cursor
2026-03-02 14:16:09 +09:00
21in7
0fca14a1c2 feat: auto-detect first run in train_and_deploy.sh (365d full vs 35d upsert)
Made-with: Cursor
2026-03-02 14:15:00 +09:00
21in7
2f5227222b docs: add initial data setup instructions and OI accumulation strategy
Made-with: Cursor
2026-03-02 14:13:45 +09:00
21in7
10b1ecd273 feat: fetch 35 days for daily upsert instead of overwriting 365 days
Made-with: Cursor
2026-03-02 14:13:16 +09:00
21in7
016b13a8f1 fix: fill NaN in oi_change/funding_rate after concat when columns missing in existing parquet
Made-with: Cursor
2026-03-02 14:13:00 +09:00
21in7
3c3c7fd56b feat: add upsert_parquet to accumulate OI/funding data incrementally
바이낸스 OI 히스토리 API가 최근 30일치만 제공하는 제약을 우회하기 위해
upsert_parquet() 함수를 추가. 매일 실행 시 기존 parquet의 oi_change/funding_rate가
0.0인 구간만 신규 값으로 덮어써 점진적으로 과거 데이터를 채워나감.
--no-upsert 플래그로 기존 덮어쓰기 동작 유지 가능.

Made-with: Cursor
2026-03-02 14:09:36 +09:00
21in7
aa52047f14 fix: prevent OI API failure from corrupting _prev_oi state and ML features
- _fetch_market_microstructure: oi_val > 0 체크 후에만 _calc_oi_change 호출하여
  API 실패(None/Exception) 시 0.0으로 폴백하고 _prev_oi 상태 오염 방지
- README: ML 피처 수 오기재 수정 (25개 → 23개)
- tests: _calc_oi_change 첫 캔들 및 API 실패 시 상태 보존 유닛 테스트 추가

Made-with: Cursor
2026-03-02 14:01:50 +09:00
21in7
b57b00051a fix: update test to force LONG signal so build_features is called
Made-with: Cursor
2026-03-02 13:57:08 +09:00
21in7
3f4e7910fd docs: update README to reflect realtime OI/funding rate ML feature integration
Made-with: Cursor
2026-03-02 13:55:45 +09:00
21in7
dfd4990ae5 feat: fetch realtime OI and funding rate on candle close for ML features
- Add asyncio import to bot.py
- Add _prev_oi state for OI change rate calculation
- Add _fetch_market_microstructure() for concurrent OI/funding rate fetch with exception fallback
- Add _calc_oi_change() for relative OI change calculation
- Always call build_features() before ML filter check in process_candle()
- Pass oi_change/funding_rate kwargs to build_features() in both process_candle() and _close_and_reenter()
- Update _close_and_reenter() signature to accept oi_change/funding_rate params

Made-with: Cursor
2026-03-02 13:55:29 +09:00
21in7
4669d08cb4 feat: build_features accepts oi_change and funding_rate params
Made-with: Cursor
2026-03-02 13:50:39 +09:00
21in7
2b315ad6d7 feat: add get_open_interest and get_funding_rate to BinanceFuturesClient
Made-with: Cursor
2026-03-02 13:46:25 +09:00
21in7
7a1abc7b72 chore: update python-binance dependency and improve error handling in BinanceFuturesClient
- Changed python-binance version requirement from 1.0.19 to >=1.0.28 for better compatibility and features.
- Modified exception handling in the cancel_all_orders method to catch all exceptions instead of just BinanceAPIException, enhancing robustness.
2026-03-02 13:24:27 +09:00
21in7
de2a402bc1 feat: enhance cancel_all_orders method to include Algo order cancellation
- Updated the cancel_all_orders method to also cancel all Algo open orders in addition to regular open orders.
- Added error handling to log warnings if the cancellation of Algo orders fails.
2026-03-02 02:15:49 +09:00
21in7
684c8a32b9 feat: add Algo Order API support and update ML feature handling
- Introduced support for Algo Order API, allowing automatic sending of STOP_MARKET and TAKE_PROFIT_MARKET orders.
- Updated README.md to include new features related to Algo Order API and real-time handling of ML features.
- Enhanced ML feature processing to fill missing OI and funding rate values with zeros for consistency in training data.
- Added new training log entries for the lgbm model with updated metrics.
2026-03-02 02:03:50 +09:00
21in7
c89374410e feat: enhance trading bot functionality and documentation
- Updated README.md to reflect new features including dynamic margin ratio, model hot-reload, and multi-symbol streaming.
- Modified bot logic to ensure raw signals are passed to the `_close_and_reenter` method, even when the ML filter is loaded.
- Introduced a new script `run_tests.sh` for streamlined test execution.
- Improved test coverage for signal processing and re-entry logic, ensuring correct behavior under various conditions.
2026-03-02 01:51:53 +09:00
21in7
9ec78d76bd feat: implement immediate re-entry after closing position on reverse signal
- Added `_close_and_reenter` method to handle immediate re-entry after closing a position when a reverse signal is detected, contingent on passing the ML filter.
- Updated `process_candle` to call `_close_and_reenter` instead of `_close_position` for reverse signals.
- Enhanced test coverage for the new functionality, ensuring correct behavior under various conditions, including ML filter checks and position limits.
2026-03-02 01:34:36 +09:00
21in7
725a4349ee chore: Update MLXFilter model deployment and logging with new training results and ONNX file management
- Added new training log entries for lgbm backend with AUC, precision, and recall metrics.
- Enhanced deploy_model.sh to manage ONNX and lgbm model files based on the selected backend.
- Adjusted output shape in mlx_filter.py for ONNX export to support dynamic batch sizes.
2026-03-02 01:08:12 +09:00
21in7
5e6cdcc358 fix: _on_candle_closed async 콜백 구조 수정 — asyncio.create_task 제거
동기 콜백 내부에서 asyncio.create_task()를 호출하면 이벤트 루프
컨텍스트 밖에서 실패하여 캔들 처리가 전혀 이루어지지 않는 버그 수정.

- _on_candle_closed: 동기 → async, create_task → await
- handle_message (KlineStream/MultiSymbolStream): 동기 → async, on_candle await
- test_callback_called_on_closed_candle: AsyncMock + await handle_message로 수정

Made-with: Cursor
2026-03-02 01:00:59 +09:00
21in7
361b0f4e00 fix: Update TradingBot signal processing to handle NaN values and improve MLFilter ONNX session configuration 2026-03-02 00:47:17 +09:00
21in7
031adac977 chore: .gitignore에 .DS_Store 추가 및 MLXFilter 훈련 로그 업데이트 2026-03-02 00:41:34 +09:00
21in7
747ab45bb0 fix: test_reload_model 단언을 실제 동작(파일 없으면 폴백 상태)에 맞게 수정
Made-with: Cursor
2026-03-02 00:38:08 +09:00
21in7
6fa6e854ca fix: test_reload_model _model → _lgbm_model 주입 방식으로 수정
Made-with: Cursor
2026-03-02 00:36:47 +09:00
21in7
518f1846b8 fix: 기존 테스트를 현재 코드 구조에 맞게 수정 — MLFilter API, FEATURE_COLS 수, 버퍼 최솟값 반영
Made-with: Cursor
2026-03-02 00:36:13 +09:00
21in7
3bfd1ca5a3 fix: test_mlx_filter _make_X를 FEATURE_COLS 기반으로 수정 — 피처 확장 후 input_dim 불일치 해소
Made-with: Cursor
2026-03-02 00:34:21 +09:00
21in7
7fdd8bff94 fix: MLXFilter self._mean/std 저장 전 nan_to_num 적용 — 전체-NaN 컬럼 predict_proba 오염 차단
Made-with: Cursor
2026-03-02 00:31:08 +09:00
21in7
bcc717776d fix: RS 계산을 np.divide(where=) 방식으로 교체 — epsilon 이상치 폭발 차단
Made-with: Cursor
2026-03-02 00:30:36 +09:00
9cac8a4afd Merge pull request 'feat: OI nan 마스킹 / epsilon 통일 / 정밀도 우선 임계값' (#1) from feature/oi-nan-epsilon-precision-threshold into main
Reviewed-on: http://10.1.10.28:3000/gihyeon/cointrader/pulls/1
2026-03-01 23:57:32 +09:00
21in7
0f6a22fcb5 feat: MLX 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경
Made-with: Cursor
2026-03-01 23:54:38 +09:00
21in7
aa413f4d7c feat: LightGBM 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경
Made-with: Cursor
2026-03-01 23:54:13 +09:00
21in7
6ae0f9d81b fix: MLXFilter fit/predict에 nan-safe 정규화 적용 (nanmean + nan_to_num)
Made-with: Cursor
2026-03-01 23:53:49 +09:00
21in7
820d8e0213 refactor: 분모 연산을 1e-8 epsilon 패턴으로 통일
Made-with: Cursor
2026-03-01 23:52:59 +09:00
21in7
417b8e3c6a feat: OI/펀딩비 결측 구간을 np.nan으로 마스킹 (0.0 → nan)
Made-with: Cursor
2026-03-01 23:52:19 +09:00
21in7
3b7ee3e890 chore: .worktrees/ gitignore에 추가
Made-with: Cursor
2026-03-01 23:50:18 +09:00
21in7
24d3ba9411 feat: enhance data fetching and model training with OI and funding rate integration
- Updated `fetch_history.py` to collect open interest (OI) and funding rate data from Binance, improving the dataset for model training.
- Modified `train_and_deploy.sh` to include options for OI and funding rate collection during data fetching.
- Enhanced `dataset_builder.py` to incorporate OI change and funding rate features with rolling z-score normalization.
- Updated training logs to reflect new metrics and features, ensuring comprehensive tracking of model performance.
- Adjusted feature columns in `ml_features.py` to include OI and funding rate for improved model robustness.
2026-03-01 22:25:38 +09:00
21in7
4245d7cdbf feat: implement 15-minute timeframe upgrade for model training and data processing
- Introduced a new markdown document detailing the plan to transition the entire pipeline from a 1-minute to a 15-minute timeframe, aiming to improve model AUC from 0.49-0.50 to over 0.53.
- Updated key parameters across multiple scripts, including `LOOKAHEAD` adjustments and default data paths to reflect the new 15-minute interval.
- Modified data fetching and training scripts to ensure compatibility with the new timeframe, including changes in `fetch_history.py`, `train_model.py`, and `train_and_deploy.sh`.
- Enhanced the bot's data stream configuration to operate on a 15-minute interval, ensuring real-time data processing aligns with the new model training strategy.
- Updated training logs to capture new model performance metrics under the revised timeframe.
2026-03-01 22:16:15 +09:00
21in7
a6697e7cca feat: implement LightGBM model improvement plan with feature normalization and walk-forward validation
- Added a new markdown document outlining the plan to enhance the LightGBM model's AUC from 0.54 to 0.57+ through feature normalization, strong time weighting, and walk-forward validation.
- Implemented rolling z-score normalization for absolute value features in `src/dataset_builder.py` to improve model robustness against regime changes.
- Introduced a walk-forward validation function in `scripts/train_model.py` to accurately measure future prediction performance.
- Updated training log to include new model performance metrics and added ONNX model export functionality for compatibility.
- Adjusted model training parameters for better performance and included detailed validation results in the training log.
2026-03-01 22:02:32 +09:00
21in7
c6428af64e feat: enhance Jenkins pipeline with Discord notifications and model hot-reload functionality
- Added a new stage to the Jenkins pipeline to notify Discord when a build starts, succeeds, or fails, improving communication during the CI/CD process.
- Implemented model hot-reload functionality in the MLFilter class, allowing automatic reloading of models when file changes are detected, enhancing responsiveness to updates.
- Updated deployment scripts to provide clearer messaging regarding model loading and container status, improving user experience and debugging capabilities.
2026-03-01 21:46:36 +09:00
21in7
d9238afaf9 feat: enhance MLX model training with combined data handling
- Introduced a new function `_split_combined` to separate XRP, BTC, and ETH data from a combined DataFrame.
- Updated `train_mlx` to utilize the new function, improving data management and feature handling.
- Adjusted dataset generation to accommodate BTC and ETH features, with warnings for missing features.
- Changed default data path in `train_mlx` and `train_model` to point to the combined dataset for consistency.
- Increased `LOOKAHEAD` from 60 to 90 and adjusted `ATR_TP_MULT` for better model performance.
2026-03-01 21:43:27 +09:00
21in7
db144750a3 feat: enhance model training and deployment scripts with time-weighted sampling
- Updated `train_model.py` and `train_mlx_model.py` to include a time weight decay parameter for improved sample weighting during training.
- Modified dataset generation to incorporate sample weights based on time decay, enhancing model performance.
- Adjusted deployment scripts to support new backend options and improved error handling for model file transfers.
- Added new entries to the training log for better tracking of model performance metrics over time.
- Included ONNX model export functionality in the MLX filter for compatibility with Linux servers.
2026-03-01 21:25:06 +09:00
21in7
301457ce57 chore: remove unused risk_per_trade references
Made-with: Cursor
2026-03-01 20:39:26 +09:00
21in7
ab580b18af feat: apply dynamic margin ratio in bot position sizing
Made-with: Cursor
2026-03-01 20:39:07 +09:00
21in7
795689ac49 feat: replace risk_per_trade with margin_ratio in calculate_quantity
Made-with: Cursor
2026-03-01 20:38:18 +09:00
21in7
fe9690698a feat: add get_dynamic_margin_ratio to RiskManager
Made-with: Cursor
2026-03-01 20:37:46 +09:00
21in7
95abac53a8 feat: add dynamic margin ratio config params
Made-with: Cursor
2026-03-01 20:37:04 +09:00
21in7
ac84fafbd0 feat: update Jenkinsfile for improved CI/CD process
- Renamed stages for clarity, changing 'Checkout' to 'Git Clone from Gitea' and 'Build Image' to 'Build Docker Image'.
- Updated Git checkout step to use specific branch and credentials for Gitea.
- Enhanced Docker login process with `withCredentials` for better security.
- Added a new stage for deploying to production LXC, including SSH commands for directory creation and Docker management.
- Updated success and failure messages to include Korean language support for better localization.
2026-03-01 19:51:17 +09:00
21in7
94d8cb352e docs: update README to include a motivational message for users
- Added a new line in the README encouraging users with a motivational phrase: "성투기원합니다." (Wishing you successful investments).
2026-03-01 19:37:16 +09:00
21in7
d1af736bfc feat: implement BTC/ETH correlation features for improved model accuracy
- Added a new design document outlining the integration of BTC/ETH candle data as additional features in the XRP ML filter, enhancing prediction accuracy.
- Introduced `MultiSymbolStream` for combined WebSocket data retrieval of XRP, BTC, and ETH.
- Expanded feature set from 13 to 21 by including 8 new BTC/ETH-related features.
- Updated various scripts and modules to support the new feature set and data handling.
- Enhanced training and deployment scripts to accommodate the new dataset structure.

This commit lays the groundwork for improved model performance by leveraging the correlation between BTC and ETH with XRP.
2026-03-01 19:30:17 +09:00
21in7
c4062c39d3 feat: add duplicate training log entry for model evaluation
- Added a new entry to the training log for the LightGBM model, including date, AUC, sample count, and model path.
- This entry mirrors an existing one, potentially for tracking model performance over time.
2026-03-01 18:55:26 +09:00
21in7
6e73df196c chore: exclude mlx from container requirements (Apple Silicon only)
Made-with: Cursor
2026-03-01 18:54:38 +09:00
21in7
de933b97cc feat: remove in-container retraining, training is now mac-only
Made-with: Cursor
2026-03-01 18:54:00 +09:00
21in7
fd96055e73 perf: replace generate_dataset with vectorized version in train_mlx_model
Made-with: Cursor
2026-03-01 18:53:21 +09:00
21in7
db134c032a perf: replace generate_dataset with vectorized version in train_model
Made-with: Cursor
2026-03-01 18:52:56 +09:00
21in7
e1560f882b feat: add vectorized dataset builder (1x pandas_ta call)
Made-with: Cursor
2026-03-01 18:52:34 +09:00
21in7
8f834a1890 feat: implement training and deployment pipeline for LightGBM model on Mac to LXC
- Added comprehensive plans for training a LightGBM model on M4 Mac Mini and deploying it to an LXC container.
- Created scripts for model training, deployment, and a full pipeline execution.
- Enhanced model transfer with error handling and logging for better tracking.
- Introduced profiling for training time analysis and dataset generation optimization.

Made-with: Cursor
2026-03-01 18:30:01 +09:00
21in7
298d4ad95e feat: enhance train_model.py to dynamically determine CPU count for parallel processing
- Added a new function to accurately retrieve the number of allocated CPUs in containerized environments, improving parallel processing efficiency.
- Updated the dataset generation function to utilize the new CPU count function, ensuring optimal resource usage during model training.

Made-with: Cursor
2026-03-01 17:46:40 +09:00
21in7
b86c88a8d6 feat: add README and enhance scripts for data fetching and model training
- Created README.md to document project features, structure, and setup instructions.
- Updated fetch_history.py to include path adjustments for module imports.
- Enhanced train_model.py for parallel processing of dataset generation and added command-line argument for specifying worker count.

Made-with: Cursor
2026-03-01 17:42:12 +09:00
21in7
7e4e9315c2 feat: implement ML filter with LightGBM for trading signal validation
- Added MLFilter class to load and evaluate LightGBM model for trading signals.
- Introduced retraining mechanism to update the model daily based on new data.
- Created feature engineering and label building utilities for model training.
- Updated bot logic to incorporate ML filter for signal validation.
- Added scripts for data fetching and model training.

Made-with: Cursor
2026-03-01 17:07:18 +09:00
21in7
ce57479b93 ci: Jenkins pipeline + Gitea registry CI/CD 설정
- Jenkinsfile 추가: Declarative Pipeline으로 빌드/push/cleanup 단계 구성
- docker-compose.yml 수정: build: . → Gitea Container Registry 이미지 참조

Made-with: Cursor
2026-03-01 16:31:34 +09:00
21in7
86904dddf7 fix: upgrade to Python 3.12 to support pandas-ta>=0.4.67b0
Made-with: Cursor
2026-03-01 16:14:03 +09:00
21in7
60b32a978e chore: add Dockerfile, docker-compose.yml, .dockerignore
Made-with: Cursor
2026-03-01 16:07:20 +09:00
83 changed files with 18123 additions and 124 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"superpowers@claude-plugins-official": true
}
}

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.env
.venv
__pycache__
*.pyc
*.pyo
.pytest_cache
logs/
*.log
.git
docs/
tests/

7
.gitignore vendored
View File

@@ -6,3 +6,10 @@ logs/
*.log *.log
.venv/ .venv/
venv/ venv/
models/*.pkl
models/*.onnx
models/tune_results_*.json
data/*.parquet
.worktrees/
.DS_Store
.cursor/

646
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,646 @@
# CoinTrader — 아키텍처 문서
> 이 문서는 CoinTrader 코드베이스를 처음 접하는 개발자와 트레이딩 배경 독자 모두를 위해 작성되었습니다.
> 기술 스택, 레이어별 역할, MLOps 파이프라인, 핵심 동작 시나리오를 순서대로 설명합니다.
---
## 목차
1. [시스템 오버뷰](#1-시스템-오버뷰)
2. [코어 레이어 아키텍처](#2-코어-레이어-아키텍처)
3. [MLOps 파이프라인 — 자가 진화 시스템](#3-mlops-파이프라인--자가-진화-시스템)
4. [핵심 동작 시나리오](#4-핵심-동작-시나리오)
5. [테스트 커버리지](#5-테스트-커버리지)
---
## 1. 시스템 오버뷰
CoinTrader는 **Binance Futures 자동매매 봇**입니다. 기술 지표 신호를 1차 필터로, LightGBM(또는 MLX 신경망) 모델을 2차 필터로 사용하여 XRPUSDT 선물 포지션을 자동 진입·청산합니다.
### 전체 데이터 파이프라인 흐름도
```mermaid
flowchart TD
subgraph 외부["외부 데이터 소스 (Binance)"]
WS1["Combined WebSocket<br/>XRP/BTC/ETH 15분봉 캔들"]
WS2["User Data Stream WebSocket<br/>ORDER_TRADE_UPDATE 이벤트"]
REST["REST API<br/>OI·펀딩비·잔고·포지션 조회"]
end
subgraph 실시간봇["실시간 봇 (bot.py — asyncio)"]
DS["data_stream.py<br/>MultiSymbolStream<br/>캔들 버퍼 (deque 200개)"]
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)"]
ML["ml_filter.py<br/>MLFilter<br/>ONNX 우선 / LightGBM 폴백<br/>확률 ≥ 0.60 시 진입 허용"]
RM["risk_manager.py<br/>RiskManager<br/>일일 손실 5% 한도<br/>동적 증거금 비율"]
EX["exchange.py<br/>BinanceFuturesClient<br/>주문·레버리지·잔고 API"]
UDS["user_data_stream.py<br/>UserDataStream<br/>TP/SL 즉시 감지"]
NT["notifier.py<br/>DiscordNotifier<br/>진입·청산·오류 알림"]
end
subgraph mlops["MLOps 파이프라인 (맥미니 — 수동/크론)"]
FH["fetch_history.py<br/>과거 캔들 + OI/펀딩비<br/>Parquet Upsert"]
DB["dataset_builder.py<br/>벡터화 데이터셋 생성<br/>레이블: ATR SL/TP 6시간 룩어헤드"]
TM["train_model.py<br/>LightGBM 학습<br/>Walk-Forward 5폴드 검증"]
TN["tune_hyperparams.py<br/>Optuna 50 trials<br/>TPE + MedianPruner"]
AP["active_lgbm_params.json<br/>Active Config 패턴<br/>승인된 파라미터 저장"]
DM["deploy_model.sh<br/>rsync → LXC 서버<br/>봇 핫리로드 트리거"]
end
WS1 -->|캔들 마감 이벤트| DS
WS2 -->|체결 이벤트| UDS
REST -->|OI·펀딩비| MF
DS -->|DataFrame| IND
IND -->|신호 + 지표값| MF
MF -->|피처 Series| ML
ML -->|진입 허용/차단| RM
RM -->|주문 승인| EX
EX -->|체결 결과| NT
UDS -->|net_pnl·청산 사유| NT
UDS -->|상태 초기화| DS
FH -->|combined_15m.parquet| DB
DB -->|X, y, w| TM
TM -->|lgbm_filter.pkl| DM
TN -->|Best Params| AP
AP -->|파라미터 반영| TM
DM -->|모델 파일 전송| ML
```
### 기술 스택 요약
| 분류 | 기술 |
|------|------|
| 언어 | Python 3.11+ |
| 비동기 런타임 | `asyncio` + `python-binance` WebSocket |
| 기술 지표 | `pandas-ta` (RSI, MACD, BB, EMA, StochRSI, ATR) |
| 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 |
| 운영 서버 | LXC 컨테이너 (`10.1.10.24`) |
---
## 2. 코어 레이어 아키텍처
봇은 5개의 레이어로 구성됩니다. 각 레이어는 단일 책임을 가지며, 위에서 아래로 데이터가 흐릅니다.
```
┌─────────────────────────────────────────────────────┐
│ Layer 1: Data Layer data_stream.py │
│ 캔들 수신 · 버퍼 관리 · 과거 데이터 프리로드 │
├─────────────────────────────────────────────────────┤
│ Layer 2: Signal Layer indicators.py │
│ 기술 지표 계산 · 복합 신호 생성 │
├─────────────────────────────────────────────────────┤
│ Layer 3: ML Filter Layer ml_filter.py │
│ LightGBM/ONNX 확률 예측 · 진입 차단 │
├─────────────────────────────────────────────────────┤
│ Layer 4: Execution & Risk exchange.py │
│ Layer risk_manager.py │
│ 주문 실행 · 포지션 관리 · 리스크 제어 │
├─────────────────────────────────────────────────────┤
│ Layer 5: Event / Alert user_data_stream.py │
│ Layer notifier.py │
│ TP/SL 즉시 감지 · Discord 알림 │
└─────────────────────────────────────────────────────┘
```
---
### Layer 1: Data Layer
**파일:** `src/data_stream.py`
봇이 시작되면 가장 먼저 실행되는 레이어입니다. Binance Combined WebSocket 단일 연결로 XRP·BTC·ETH 3개 심볼의 15분봉 캔들을 동시에 수신합니다.
**핵심 동작:**
1. **프리로드**: 봇 시작 시 REST API로 과거 캔들 200개를 `deque`에 즉시 채웁니다. EMA50 안정화에 필요한 최소 캔들(100개)을 확보하여 첫 캔들부터 신호를 계산할 수 있게 합니다.
2. **버퍼 관리**: 심볼별 `deque(maxlen=200)`에 마감된 캔들만 추가합니다. 미마감 캔들(`is_closed=False`)은 무시합니다.
3. **콜백 트리거**: XRP(주 심볼) 캔들이 마감되면 `bot._on_candle_closed()`를 호출합니다. BTC·ETH는 버퍼에만 쌓이고 콜백을 트리거하지 않습니다.
```
Combined WebSocket
├── xrpusdt@kline_15m → buffers["xrpusdt"] → on_candle() 호출
├── btcusdt@kline_15m → buffers["btcusdt"] (콜백 없음)
└── ethusdt@kline_15m → buffers["ethusdt"] (콜백 없음)
```
---
### Layer 2: Signal Layer
**파일:** `src/indicators.py`
`pandas-ta` 라이브러리로 기술 지표를 계산하고, 복합 가중치 시스템으로 매매 신호를 생성합니다.
**계산되는 지표:**
| 지표 | 파라미터 | 역할 |
|------|---------|------|
| RSI | length=14 | 과매수/과매도 판단 |
| MACD | (12, 26, 9) | 추세 전환 감지 (골든/데드크로스) |
| 볼린저 밴드 | (20, 2σ) | 가격 이탈 감지 |
| EMA | (9, 21, 50) | 추세 방향 (정배열/역배열) |
| Stochastic RSI | (14, 14, 3, 3) | 단기 과매수/과매도 |
| ATR | length=14 | 변동성 측정 → SL/TP 계산에 사용 |
| ADX | length=14 | 추세 강도 측정 → 횡보장 필터 (ADX < 25 시 진입 차단) |
| Volume MA | length=20 | 거래량 급증 감지 |
**신호 생성 로직 (ADX 필터 + 가중치 합산):**
```
[1단계] ADX 횡보장 필터:
ADX < 25 → 즉시 HOLD 반환 (추세 부재로 진입 차단)
[2단계] 롱 신호 점수:
RSI < 35 → +1
MACD 골든크로스 (전봉→현봉) → +2 ← 강한 신호
종가 < 볼린저 하단 → +1
EMA 정배열 (9 > 21 > 50) → +1
StochRSI K < 20 and K > D → +1
진입 조건: 점수 ≥ 3 AND (거래량 급증 OR 점수 ≥ 4)
SL = 진입가 - ATR × 1.5
TP = 진입가 + ATR × 3.0 (리스크:리워드 = 1:2)
```
숏 신호는 롱의 대칭 조건으로 계산됩니다.
---
### Layer 3: ML Filter Layer
**파일:** `src/ml_filter.py`, `src/ml_features.py`
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입(억까 타점)을 줄이는 2차 게이트키퍼입니다.
**모델 우선순위:**
```
ONNX (MLX 신경망) → LightGBM → 폴백(항상 허용)
```
모델 파일이 없으면 모든 신호를 허용합니다. 봇 재시작 없이 모델 파일을 교체하면 다음 캔들 마감 시 자동으로 핫리로드됩니다(`mtime` 감지).
**23개 ML 피처:**
```
XRP 기술 지표 (13개):
rsi, macd_hist, bb_pct, ema_align, stoch_k, stoch_d,
atr_pct, vol_ratio, ret_1, ret_3, ret_5,
signal_strength, side
BTC/ETH 상관관계 (8개):
btc_ret_1, btc_ret_3, btc_ret_5,
eth_ret_1, eth_ret_3, eth_ret_5,
xrp_btc_rs, xrp_eth_rs
시장 미시구조 (2개):
oi_change ← 이전 캔들 대비 미결제약정 변화율
funding_rate ← 현재 펀딩비
```
`oi_change``funding_rate`는 캔들 마감마다 Binance REST API로 실시간 조회합니다. API 실패 시 `0.0`으로 폴백하여 봇이 멈추지 않습니다.
**진입 판단:**
```python
proba = model.predict_proba(features)[0][1] # 성공 확률
return proba >= 0.60 # 임계값 60%
```
---
### Layer 4: Execution & Risk Layer
**파일:** `src/exchange.py`, `src/risk_manager.py`
ML 필터를 통과한 신호를 실제 주문으로 변환하고, 리스크 한도를 관리합니다.
**포지션 크기 계산 (동적 증거금 비율):**
잔고가 늘어날수록 증거금 비율을 선형으로 줄여 복리 과노출을 방지합니다.
```
증거금 비율 = max(20%, min(50%, 50% - (잔고 - 기준잔고) × 0.0006))
명목금액 = 잔고 × 증거금 비율 × 레버리지
수량 = 명목금액 / 현재가
```
**주문 흐름:**
```
1. set_leverage(10x)
2. place_order(MARKET) ← 진입
3. place_order(STOP_MARKET) ← SL 설정
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정
```
SL/TP 주문은 `/fapi/v1/algoOrder` 엔드포인트로 전송됩니다 (일반 계정의 `-4120` 오류 대응).
**리스크 제어:**
| 제어 항목 | 기준 |
|----------|------|
| 일일 최대 손실 | 기준 잔고의 5% |
| 최대 동시 포지션 | 3개 |
| 최소 명목금액 | $5 USDT |
**반대 시그널 재진입:** 보유 포지션과 반대 방향 신호 발생 시 기존 포지션을 즉시 청산하고, ML 필터 통과 시 반대 방향으로 재진입합니다. 재진입 중 User Data Stream 콜백이 신규 포지션 상태를 덮어쓰지 않도록 `_is_reentering` 플래그로 보호합니다.
---
### Layer 5: Event / Alert Layer
**파일:** `src/user_data_stream.py`, `src/notifier.py`
기존 폴링 방식(캔들 마감마다 포지션 조회)의 한계를 극복하기 위해 도입된 레이어입니다.
**User Data Stream의 역할:**
Binance `ORDER_TRADE_UPDATE` 웹소켓 이벤트를 구독하여 TP/SL 체결을 **즉시** 감지합니다. 기존 방식은 최대 15분 지연이 발생했지만, 이제 체결 즉시 콜백이 호출됩니다.
```
이벤트 필터링 조건:
e == "ORDER_TRADE_UPDATE"
AND s == "XRPUSDT" ← 심볼 필터
AND x == "TRADE" ← 실제 체결
AND X == "FILLED" ← 완전 체결
AND (reduceOnly OR order_type in {STOP_MARKET, TAKE_PROFIT_MARKET} OR rp != 0)
```
청산 사유 분류:
- `TAKE_PROFIT_MARKET``"TP"`
- `STOP_MARKET``"SL"`
- 그 외 → `"MANUAL"`
순수익 계산:
```
net_pnl = realized_pnl - commission
```
**Discord 알림 포맷:**
진입 시:
```
[XRPUSDT] LONG 진입
진입가: 2.3450 | 수량: 100.0 | 레버리지: 10x
SL: 2.3100 | TP: 2.4150
RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
```
청산 시:
```
✅ [XRPUSDT] LONG TP 청산
청산가: 2.4150
예상 수익: +7.0000 USDT
실제 순수익: +6.7800 USDT
차이(슬리피지+수수료): -0.2200 USDT
```
---
## 3. MLOps 파이프라인 — 자가 진화 시스템
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다. 전체 라이프사이클은 다음과 같습니다.
### 3.1 전체 라이프사이클
```mermaid
flowchart LR
A["주말 수동 트리거<br/>tune_hyperparams.py<br/>(Optuna 50 trials, ~30분)"]
B["결과 검토<br/>tune_results_YYYYMMDD.json<br/>Best AUC vs Baseline 비교"]
C{"개선폭 충분?<br/>(AUC +0.01 이상<br/>폴드 분산 낮음)"}
D["active_lgbm_params.json<br/>업데이트<br/>(Active Config 패턴)"]
E["새벽 2시 크론탭<br/>train_and_deploy.sh<br/>(데이터 수집 → 학습 → 배포)"]
F["LXC 서버<br/>lgbm_filter.pkl 교체"]
G["봇 핫리로드<br/>다음 캔들 mtime 감지<br/>→ 자동 리로드"]
A --> B
B --> C
C -->|Yes| D
C -->|No| A
D --> E
E --> F
F --> G
G --> A
```
### 3.2 단계별 상세 설명
#### Step 1: Optuna 하이퍼파라미터 탐색
`scripts/tune_hyperparams.py`는 LightGBM의 9개 하이퍼파라미터를 자동으로 탐색합니다.
- **알고리즘**: TPE Sampler (Tree-structured Parzen Estimator) — 베이지안 최적화 계열
- **조기 종료**: MedianPruner — 중간 폴드 AUC가 중앙값 미만이면 trial 조기 종료
- **평가 지표**: Walk-Forward 5폴드 평균 AUC (시계열 순서 유지, 미래 데이터 누수 방지)
- **클래스 불균형 처리**: 언더샘플링 (양성:음성 = 1:1, 시간 순서 유지)
탐색 공간:
```
n_estimators: 100 ~ 600
learning_rate: 0.01 ~ 0.20 (log scale)
max_depth: 2 ~ 7
num_leaves: 7 ~ min(31, 2^max_depth - 1) ← 과적합 방지 제약
min_child_samples: 10 ~ 50
subsample: 0.5 ~ 1.0
colsample_bytree: 0.5 ~ 1.0
reg_alpha: 1e-4 ~ 1.0 (log scale)
reg_lambda: 1e-4 ~ 1.0 (log scale)
```
결과는 `models/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
#### Step 2: Active Config 패턴으로 파라미터 승인
Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사람이 결과를 검토하고 직접 `models/active_lgbm_params.json`을 업데이트해야 합니다.
```json
{
"promoted_at": "2026-03-02T14:47:49",
"best_trial": {
"number": 23,
"value": 0.6821,
"params": {
"n_estimators": 434,
"learning_rate": 0.123659,
...
}
}
}
```
`train_model.py`는 학습 시작 시 이 파일을 읽어 파라미터를 적용합니다. 파일이 없으면 코드 내 기본값을 사용합니다.
> **주의**: Optuna 결과는 과적합 위험이 있습니다. 폴드별 AUC 분산이 크거나 (std > 0.05), 개선폭이 미미하면 (< 0.01) 적용하지 않는 것을 권장합니다.
#### Step 3: 자동 학습 및 배포 (크론탭)
`scripts/train_and_deploy.sh`는 3단계를 자동으로 실행합니다:
```
[1/3] 데이터 수집 (fetch_history.py)
- 기존 parquet 없음 → 1년치(365일) 전체 수집
- 기존 parquet 있음 → 35일치 Upsert (OI/펀딩비 0.0 구간 보충)
[2/3] 모델 학습 (train_model.py)
- active_lgbm_params.json 파라미터 로드
- 벡터화 데이터셋 생성 (dataset_builder.py)
- Walk-Forward 5폴드 검증 후 최종 모델 저장
- 학습 로그: models/training_log.json
[3/3] LXC 배포 (deploy_model.sh)
- rsync로 lgbm_filter.pkl → LXC 서버 전송
- 기존 모델 자동 백업 (lgbm_filter_prev.pkl)
- ONNX 파일 충돌 방지 (우선순위 보장)
```
#### Step 4: 봇 핫리로드
모델 파일이 교체되면 봇 재시작 없이 자동으로 새 모델이 적용됩니다.
```python
# bot.py → process_candle() 첫 줄
self.ml_filter.check_and_reload()
# ml_filter.py → check_and_reload()
onnx_changed = _mtime(self._onnx_path) != self._loaded_onnx_mtime
lgbm_changed = _mtime(self._lgbm_path) != self._loaded_lgbm_mtime
if onnx_changed or lgbm_changed:
self._try_load() # 새 모델 로드
```
매 캔들 마감(15분)마다 모델 파일의 `mtime`을 확인합니다. 변경이 감지되면 즉시 리로드합니다.
### 3.3 레이블 생성 방식
학습 데이터의 레이블은 **미래 6시간(24캔들) 룩어헤드**로 생성됩니다.
```
신호 발생 시점 기준:
SL = 진입가 - ATR × 1.5
TP = 진입가 + ATR × 3.0
향후 24캔들 동안:
- 저가가 SL에 먼저 닿으면 → label = 0 (실패)
- 고가가 TP에 먼저 닿으면 → label = 1 (성공)
- 둘 다 안 닿으면 → 샘플 제외
```
보수적 접근: SL 체크를 TP보다 먼저 수행하여 동시 돌파 시 실패로 처리합니다.
---
## 4. 핵심 동작 시나리오
### 시나리오 1: 15분 캔들 마감 시 봇의 동작 흐름
> "XRP 15분봉이 마감되면 봇은 무엇을 하는가?"
```mermaid
sequenceDiagram
participant WS as Binance WebSocket
participant DS as data_stream.py
participant BOT as bot.py
participant IND as indicators.py
participant MF as ml_features.py
participant ML as ml_filter.py
participant RM as risk_manager.py
participant EX as exchange.py
participant NT as notifier.py
WS->>DS: kline 이벤트 (is_closed=True)
DS->>DS: buffers["xrpusdt"].append(candle)
DS->>BOT: on_candle_closed(candle) 콜백
BOT->>BOT: ml_filter.check_and_reload() [mtime 확인]
BOT->>EX: get_open_interest() + get_funding_rate() [병렬]
BOT->>RM: is_trading_allowed() [일일 손실 한도 확인]
BOT->>IND: calculate_all(xrp_df) [지표 계산]
IND-->>BOT: df_with_indicators (RSI, MACD, BB, EMA, StochRSI, ATR, ADX)
BOT->>IND: get_signal(df) [신호 생성]
IND-->>BOT: "LONG" | "SHORT" | "HOLD"
alt 신호 = LONG 또는 SHORT, 포지션 없음
BOT->>MF: build_features(df, signal, btc_df, eth_df, oi_change, funding_rate)
MF-->>BOT: features (23개 피처 Series)
BOT->>ML: should_enter(features)
ML-->>BOT: proba=0.73 ≥ 0.60 → True
BOT->>EX: get_balance()
BOT->>RM: get_dynamic_margin_ratio(balance)
BOT->>EX: set_leverage(10)
BOT->>EX: place_order(MARKET, BUY, qty=100.0)
BOT->>EX: place_order(STOP_MARKET, SELL, stop=2.3100)
BOT->>EX: place_order(TAKE_PROFIT_MARKET, SELL, stop=2.4150)
BOT->>NT: notify_open(진입가, SL, TP, RSI, MACD, ATR)
else 신호 = HOLD 또는 ML 차단
BOT->>BOT: 대기 (다음 캔들까지)
end
```
**핵심 포인트:**
- OI·펀딩비 조회는 `asyncio.gather()`로 병렬 실행 → 지연 최소화
- ML 필터가 없으면(모델 파일 없음) 모든 신호를 허용
- 명목금액 < $5 USDT이면 주문을 건너뜀 (바이낸스 최소 주문 제약)
---
### 시나리오 2: TP/SL 체결 시 봇의 동작 흐름
> "거래소에서 TP가 작동하면 봇은 어떻게 반응하는가?"
```mermaid
sequenceDiagram
participant BN as Binance
participant UDS as user_data_stream.py
participant BOT as bot.py
participant RM as risk_manager.py
participant NT as notifier.py
BN->>UDS: ORDER_TRADE_UPDATE 이벤트
Note over UDS: e="ORDER_TRADE_UPDATE"<br/>s="XRPUSDT"<br/>x="TRADE", X="FILLED"<br/>o="TAKE_PROFIT_MARKET"<br/>rp="+7.0000", n="0.2200"
UDS->>UDS: 심볼 필터 (XRPUSDT만 처리)
UDS->>UDS: 청산 주문 판별 (reduceOnly or TP/SL type)
UDS->>UDS: net_pnl = 7.0000 - 0.2200 = 6.7800
UDS->>UDS: close_reason = "TP"
UDS->>BOT: _on_position_closed(net_pnl=6.78, reason="TP", exit_price=2.4150)
BOT->>BOT: estimated_pnl = (2.4150 - 2.3450) × 100 = 7.0000
BOT->>BOT: diff = 6.7800 - 7.0000 = -0.2200
BOT->>RM: record_pnl(6.7800) [일일 누적 PnL 갱신]
BOT->>NT: notify_close(TP, exit=2.4150, est=+7.00, net=+6.78, diff=-0.22)
NT->>NT: Discord 웹훅 전송
BOT->>BOT: current_trade_side = None
BOT->>BOT: _entry_price = None
BOT->>BOT: _entry_quantity = None
Note over BOT: Flat 상태로 초기화 완료
```
**핵심 포인트:**
- User Data Stream은 `asyncio.gather()`로 캔들 스트림과 **병렬** 실행
- 체결 즉시 감지 (최대 15분 지연이었던 폴링 방식 대비 실시간)
- `realized_pnl - commission` = 정확한 순수익 (슬리피지·수수료 포함)
- `_is_reentering` 플래그: 반대 시그널 재진입 중에는 콜백이 신규 포지션 상태를 초기화하지 않음
---
## 5. 테스트 커버리지
### 5.1 테스트 파일 구성
`tests/` 폴더에 12개 테스트 파일, 총 **81개의 테스트 케이스**가 작성되어 있습니다.
```bash
pytest tests/ -v # 전체 실행
bash scripts/run_tests.sh # 래퍼 스크립트 실행
```
### 5.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_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백 허용, 임계값 이상/미만 판단, 핫리로드 후 상태 변화 |
| `test_risk_manager.py` | `src/risk_manager.py` | 8 | 일일 손실 한도 초과 차단, 최대 포지션 수 제한, 동적 증거금 비율 상한/하한 클램핑 |
| `test_exchange.py` | `src/exchange.py` | 8 | 수량 계산(기본/최소명목금액/잔고0), OI·펀딩비 조회 정상/오류 시 반환값 |
| `test_data_stream.py` | `src/data_stream.py` | 6 | 3심볼 버퍼 존재, 빈 버퍼 None 반환, 캔들 파싱, 마감 캔들 콜백 호출, 프리로드 200개 |
| `test_label_builder.py` | `src/label_builder.py` | 4 | LONG TP 도달 → 1, LONG SL 도달 → 0, 미결 → None, SHORT TP 도달 → 1 |
| `test_dataset_builder.py` | `src/dataset_builder.py` | 9 | DataFrame 반환, 필수 컬럼 존재, 레이블 이진값, BTC/ETH 포함 시 23개 피처, inf/NaN 없음, OI nan 마스킹, RS 분모 0 처리 |
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 디바이스 확인, 학습 전 예측 형태, 학습 후 유효 확률, NaN 피처 처리, 저장/로드 후 동일 예측 |
| `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 구간 Upsert, 신규 행 추가, 기존 비0값 보존, 파일 없을 때 신규 반환, 타임스탬프 오름차순 정렬 |
| `test_config.py` | `src/config.py` | 2 | 환경변수 로드, 동적 증거금 파라미터 로드 |
> `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다.
### 5.3 커버리지 매트릭스
아래는 핵심 비즈니스 로직의 테스트 커버 여부입니다.
| 기능 | 단위 테스트 | 통합 수준 테스트 | 비고 |
|------|:----------:|:--------------:|------|
| 기술 지표 계산 (RSI/MACD/BB/EMA/StochRSI/ADX) | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` |
| 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` |
| ADX 횡보장 필터 (ADX < 25 차단) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` (`_calc_signals` 실제 호출) |
| ML 피처 추출 (23개) | ✅ | ✅ | `test_ml_features` + `test_dataset_builder` (`_calc_features_vectorized` 실제 호출) |
| ML 필터 추론 (임계값 판단) | ✅ | — | `test_ml_filter` |
| MLX 신경망 학습/저장/로드 | ✅ | — | `test_mlx_filter` (Apple Silicon 전용) |
| 레이블 생성 (SL/TP 룩어헤드) | ✅ | ✅ | `test_label_builder` + `test_dataset_builder` (전체 파이프라인 실제 호출) |
| 벡터화 데이터셋 빌더 | ✅ | ✅ | `test_dataset_builder` |
| 동적 증거금 비율 계산 | ✅ | — | `test_risk_manager` |
| 일일 손실 한도 제어 | ✅ | — | `test_risk_manager` |
| 포지션 수량 계산 | ✅ | — | `test_exchange` |
| OI/펀딩비 API 조회 (정상/오류) | ✅ | ✅ | `test_exchange` + `test_bot` (`process_candle` → OI/펀딩비 → `build_features` 전달) |
| 반대 시그널 재진입 흐름 | ✅ | ✅ | `test_bot` |
| ML 차단 시 재진입 스킵 | ✅ | ✅ | `test_bot` (`_close_and_reenter` → ML 판단 → 스킵 전체 흐름) |
| OI 변화율 계산 (API 실패 폴백) | ✅ | ✅ | `test_bot` (`process_candle` → OI 조회 → `_calc_oi_change` 흐름) |
| 캔들 버퍼 관리 및 프리로드 | ✅ | — | `test_data_stream` |
| Parquet Upsert (OI=0 보충) | ✅ | — | `test_fetch_history` |
| User Data Stream TP/SL 감지 | ❌ | — | 미작성 (실제 WebSocket 의존) |
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
| CI/CD 파이프라인 | ❌ | — | Jenkins 환경 의존 |
### 5.4 테스트 전략
**Mock 활용 원칙:**
- Binance API 호출(`BinanceFuturesClient`, `AsyncClient`)은 모두 `unittest.mock.AsyncMock`으로 대체합니다.
- 외부 의존성(Discord Webhook, Binance WebSocket)은 테스트 대상에서 제외합니다.
- `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 보존/덮어쓰기 조건
---
## 부록: 파일별 역할 요약
| 파일 | 레이어 | 역할 |
|------|--------|------|
| `main.py` | — | 진입점. `Config` 로드 후 `TradingBot.run()` 실행 |
| `src/bot.py` | 오케스트레이터 | 모든 레이어를 조율하는 메인 트레이딩 루프 |
| `src/config.py` | — | 환경변수 기반 설정 (`@dataclass`) |
| `src/data_stream.py` | Data | Combined WebSocket 캔들 수신·버퍼 관리 |
| `src/indicators.py` | Signal | 기술 지표 계산 및 복합 신호 생성 |
| `src/ml_features.py` | ML Filter | 23개 ML 피처 추출 |
| `src/ml_filter.py` | ML Filter | ONNX/LightGBM 모델 로드·추론·핫리로드 |
| `src/mlx_filter.py` | ML Filter | Apple Silicon GPU 학습 + ONNX export |
| `src/exchange.py` | Execution | Binance Futures REST API 클라이언트 |
| `src/risk_manager.py` | Risk | 일일 손실 한도·동적 증거금 비율·포지션 수 제어 |
| `src/user_data_stream.py` | Event | User Data Stream TP/SL 즉시 감지 |
| `src/notifier.py` | Alert | Discord 웹훅 알림 |
| `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) |
| `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) |
| `src/logger_setup.py` | — | Loguru 로거 설정 |
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 (Parquet Upsert) |
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 (CPU) |
| `scripts/train_mlx_model.py` | MLOps | MLX 신경망 학습 (Apple Silicon GPU) |
| `scripts/tune_hyperparams.py` | MLOps | Optuna 하이퍼파라미터 탐색 (수동 트리거) |
| `scripts/train_and_deploy.sh` | MLOps | 전체 파이프라인 (수집→학습→배포) |
| `scripts/deploy_model.sh` | MLOps | 모델 파일 LXC 서버 전송 |
| `models/active_lgbm_params.json` | MLOps | 승인된 LightGBM 파라미터 (Active Config) |

116
CLAUDE.md Normal file
View File

@@ -0,0 +1,116 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
CoinTrader is a Python asyncio-based automated cryptocurrency trading bot for Binance Futures. It trades XRPUSDT on 15-minute candles, using BTC/ETH as correlation features. The system has 5 layers: Data (WebSocket streams) → Signal (technical indicators) → ML Filter (ONNX/LightGBM) → Execution & Risk → Event/Alert (Discord).
## Common Commands
```bash
# venv
source .venv/bin/activate
# Run the bot
python main.py
# Run full test suite
bash scripts/run_tests.sh
# Run filtered tests
bash scripts/run_tests.sh -k "bot"
# Run pytest directly
pytest tests/ -v --tb=short
# ML training pipeline (LightGBM default)
bash scripts/train_and_deploy.sh
# MLX GPU training (macOS Apple Silicon)
bash scripts/train_and_deploy.sh mlx
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
python scripts/tune_hyperparams.py
# Fetch historical data
python scripts/fetch_history.py --symbols XRPUSDT BTCUSDT ETHUSDT --interval 15m --days 365
# Deploy models to production
bash scripts/deploy_model.sh
```
## Architecture
**Entry point**: `main.py` → creates `Config` (dataclass from env vars) → runs `TradingBot`
**5-layer data flow on each 15m candle close:**
1. `src/data_stream.py` — Combined WebSocket for XRP/BTC/ETH, deque buffers (200 candles each)
2. `src/indicators.py` — RSI, MACD, BB, EMA, StochRSI, ATR; weighted signal aggregation → LONG/SHORT/HOLD
3. `src/ml_filter.py` + `src/ml_features.py` — 23-feature extraction, ONNX priority > LightGBM fallback, threshold ≥ 0.60
4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%)
5. `src/user_data_stream.py` + `src/notifier.py` — Real-time TP/SL detection via WebSocket, Discord webhooks
**Parallel execution**: `user_data_stream` runs independently via `asyncio.gather()` alongside candle processing.
## Key Patterns
- **Async-first**: All I/O operations use `async/await`; parallel tasks via `asyncio.gather()`
- **Reverse signal re-entry**: While holding LONG, if SHORT signal appears → close position, cancel SL/TP, open SHORT. `_is_reentering` flag prevents race conditions with User Data Stream
- **ML hot reload**: `ml_filter.check_and_reload()` compares file mtime on every candle, reloads model without restart
- **Active Config pattern**: Best hyperparams stored in `models/active_lgbm_params.json`, must be manually approved before retraining
- **Graceful degradation**: Missing model → all signals pass; API failure → use fallback values (0.0 for OI/funding)
- **Walk-forward validation**: Time-series CV with undersampling (1:1 class balance, preserving time order)
- **Label generation**: Binary labels based on 24-candle (6h) lookahead — check SL hit first (conservative), then TP
## Testing
- All external APIs (Binance, Discord) are mocked with `unittest.mock.AsyncMock`
- Async tests use `@pytest.mark.asyncio`
- 14 test files, 80+ test cases covering all layers
- Testing is done in actual terminal, not IDE sandbox
## Configuration
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOL` (default XRPUSDT), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `NO_ML_FILTER`.
`src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars.
## Deployment
- **Docker**: `Dockerfile` (Python 3.12-slim) + `docker-compose.yml`
- **CI/CD**: Jenkins pipeline (Gitea → Docker registry → LXC production server)
- Models stored in `models/`, data cache in `data/`, logs in `logs/`
## Design & Implementation Plans
All design documents and implementation plans are stored in `docs/plans/` with the naming convention `YYYY-MM-DD-feature-name.md`. Design docs (`-design.md`) describe architecture decisions; implementation plans (`-plan.md`) contain step-by-step tasks for Claude to execute.
**Chronological plan history:**
| Date | Plan | Status |
|------|------|--------|
| 2026-03-01 | `xrp-futures-autotrader` | Completed |
| 2026-03-01 | `discord-notifier-and-position-recovery` | Completed |
| 2026-03-01 | `upload-to-gitea` | Completed |
| 2026-03-01 | `dockerfile-and-docker-compose` | Completed |
| 2026-03-01 | `fix-pandas-ta-python312` | Completed |
| 2026-03-01 | `jenkins-gitea-registry-cicd` | Completed |
| 2026-03-01 | `ml-filter-design` / `ml-filter-implementation` | Completed |
| 2026-03-01 | `train-on-mac-deploy-to-lxc` | Completed |
| 2026-03-01 | `m4-accelerated-training` | Completed |
| 2026-03-01 | `vectorized-dataset-builder` | Completed |
| 2026-03-01 | `btc-eth-correlation-features` (design + plan) | Completed |
| 2026-03-01 | `dynamic-margin-ratio` (design + plan) | Completed |
| 2026-03-01 | `lgbm-improvement` | Completed |
| 2026-03-01 | `15m-timeframe-upgrade` | Completed |
| 2026-03-01 | `oi-nan-epsilon-precision-threshold` | Completed |
| 2026-03-02 | `rs-divide-mlx-nan-fix` | Completed |
| 2026-03-02 | `reverse-signal-reenter` (design + plan) | Completed |
| 2026-03-02 | `realtime-oi-funding-features` | Completed |
| 2026-03-02 | `oi-funding-accumulation` | Completed |
| 2026-03-02 | `optuna-hyperparam-tuning` (design + plan) | Completed |
| 2026-03-02 | `user-data-stream-tp-sl-detection` (design + plan) | Completed |
| 2026-03-02 | `adx-filter-design` | Completed |
| 2026-03-02 | `hold-negative-sampling` (design + plan) | Completed |
| 2026-03-03 | `optuna-precision-objective-plan` | Pending |

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# mlx는 Apple Silicon 전용이므로 컨테이너에 설치하지 않는다
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p logs models data
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
CMD ["python", "main.py"]

94
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,94 @@
pipeline {
agent any
environment {
REGISTRY = '10.1.10.28:3000'
IMAGE_NAME = 'gihyeon/cointrader'
IMAGE_TAG = "${env.BUILD_NUMBER}"
FULL_IMAGE = "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
LATEST_IMAGE = "${REGISTRY}/${IMAGE_NAME}:latest"
// 젠킨스 자격 증명에 저장해둔 디스코드 웹훅 주소를 불러옵니다.
DISCORD_WEBHOOK = credentials('discord-webhook')
}
stages {
// 빌드가 시작되자마자 알림을 보냅니다.
stage('Notify Build Start') {
steps {
sh """
curl -H "Content-Type: application/json" \
-X POST \
-d '{"content": "🚀 **[빌드 시작]** `cointrader` (Build #${env.BUILD_NUMBER}) 배포 파이프라인 가동"}' \
${DISCORD_WEBHOOK}
"""
}
}
stage('Git Clone from Gitea') {
steps {
git branch: 'main',
credentialsId: 'gitea-cred',
url: 'http://10.1.10.28:3000/gihyeon/cointrader.git'
}
}
stage('Build Docker Image') {
steps {
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
}
}
stage('Push to Gitea Registry') {
steps {
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 "docker push ${FULL_IMAGE}"
sh "docker push ${LATEST_IMAGE}"
}
}
}
stage('Deploy to Prod LXC') {
steps {
sh 'ssh root@10.1.10.24 "mkdir -p /root/cointrader"'
sh 'scp docker-compose.yml root@10.1.10.24:/root/cointrader/'
sh '''
ssh root@10.1.10.24 "cd /root/cointrader/ && \
docker compose down && \
docker compose pull && \
docker compose up -d"
'''
}
}
stage('Cleanup') {
steps {
sh "docker rmi ${FULL_IMAGE} || true"
sh "docker rmi ${LATEST_IMAGE} || true"
}
}
}
// 파이프라인 결과에 따른 디스코드 알림
post {
success {
echo "Build #${env.BUILD_NUMBER} 성공: ${FULL_IMAGE} → 운영 LXC(10.1.10.24) 배포 완료"
sh """
curl -H "Content-Type: application/json" \
-X POST \
-d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 📦 이미지: `${FULL_IMAGE}`"}' \
${DISCORD_WEBHOOK}
"""
}
failure {
echo "Build #${env.BUILD_NUMBER} 실패"
sh """
curl -H "Content-Type: application/json" \
-X POST \
-d '{"content": "❌ **[배포 실패]** `cointrader` (Build #${env.BUILD_NUMBER}) 파이프라인 에러 발생. 젠킨스 로그를 확인해 주세요!"}' \
${DISCORD_WEBHOOK}
"""
}
}
}

279
README.md Normal file
View File

@@ -0,0 +1,279 @@
# CoinTrader
Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 XRPUSDT(기본) 선물 포지션을 자동으로 진입·청산하며, Discord로 실시간 알림을 전송합니다.
> **아키텍처 문서**: 코드 구조, 레이어별 역할, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요.
---
## 주요 기능
- **복합 기술 지표 신호**: RSI, MACD 크로스, 볼린저 밴드, EMA 정/역배열, Stochastic RSI, 거래량 급증 — 가중치 합계 ≥ 3 시 진입
- **ML 필터 (ONNX 우선 / LightGBM 폴백)**: 기술 지표 신호를 한 번 더 검증하여 오진입 차단. 우선순위: ONNX > LightGBM > 폴백(항상 허용)
- **모델 핫리로드**: 캔들마다 모델 파일 mtime을 감지해 변경 시 자동 리로드 (봇 재시작 불필요)
- **멀티심볼 스트림**: XRP/BTC/ETH 3개 심볼을 단일 Combined WebSocket으로 수신, BTC·ETH 상관관계 피처 활용
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (캔들 마감 시 실시간 조회, 실패 시 0으로 폴백)
- **점진적 OI 데이터 축적 (Upsert)**: 바이낸스 OI 히스토리 API는 최근 30일치만 제공. `fetch_history.py` 실행 시 기존 parquet의 `oi_change/funding_rate=0` 구간을 신규 값으로 채워 학습 데이터 품질을 점진적으로 개선
- **실시간 OI/펀딩비 조회**: 캔들 마감마다 `get_open_interest()` / `get_funding_rate()`를 비동기 병렬 조회하여 ML 피처에 전달. 이전 캔들 대비 OI 변화율로 변환하여 train-serve skew 해소
- **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산 (1.5× / 3.0× ATR)
- **Algo Order API 지원**: 계정 설정에 따라 STOP_MARKET/TAKE_PROFIT_MARKET 주문을 `/fapi/v1/algoOrder` 엔드포인트로 자동 전송 (오류 코드 -4120 대응)
- **동적 증거금 비율**: 잔고 증가에 따라 선형 감소 (최대 50% → 최소 20%)
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 ML 필터 통과 시 반대 방향 재진입
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 일일 손실 한도(5%) 제어
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음)
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결. `stream.recv()` 기반으로 수신하며, 라이브러리 내부 에러 페이로드(`{"e":"error"}`) 감지 시 즉시 재연결하여 좀비 커넥션 방지
- **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림
- **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포 (LXC 운영 서버 자동 적용)
---
## 프로젝트 구조
```
cointrader/
├── main.py # 진입점
├── src/
│ ├── bot.py # 메인 트레이딩 루프
│ ├── config.py # 환경변수 기반 설정
│ ├── exchange.py # Binance Futures API 클라이언트
│ ├── data_stream.py # WebSocket 15분봉 멀티심볼 스트림 (XRP/BTC/ETH)
│ ├── indicators.py # 기술 지표 계산 및 신호 생성
│ ├── ml_filter.py # ML 필터 (ONNX 우선 / LightGBM 폴백 / 핫리로드)
│ ├── ml_features.py # ML 피처 빌더 (23개 피처)
│ ├── mlx_filter.py # MLX 신경망 필터 (Apple Silicon GPU 학습 + ONNX export)
│ ├── label_builder.py # 학습 레이블 생성
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
│ ├── risk_manager.py # 리스크 관리 (일일 손실 한도, 동적 증거금 비율)
│ ├── notifier.py # Discord 웹훅 알림
│ └── logger_setup.py # Loguru 로거 설정
├── scripts/
│ ├── fetch_history.py # 과거 데이터 수집 (XRP/BTC/ETH + OI/펀딩비, Upsert 지원)
│ ├── train_model.py # LightGBM 모델 학습 (CPU)
│ ├── train_mlx_model.py # MLX 신경망 학습 (Apple Silicon GPU)
│ ├── train_and_deploy.sh # 전체 파이프라인 (수집 → 학습 → LXC 배포)
│ ├── tune_hyperparams.py # Optuna 하이퍼파라미터 자동 탐색 (수동 트리거)
│ ├── deploy_model.sh # 모델 파일 LXC 서버 전송
│ └── run_tests.sh # 전체 테스트 실행
├── models/ # 학습된 모델 저장 (.pkl / .onnx)
├── data/ # 과거 데이터 캐시 (.parquet)
├── logs/ # 로그 파일
├── docs/plans/ # 설계 문서 및 구현 플랜
├── tests/ # 테스트 코드
├── Dockerfile
├── docker-compose.yml
├── Jenkinsfile
└── requirements.txt
```
---
## 빠른 시작
### 1. 환경변수 설정
```bash
cp .env.example .env
```
`.env` 파일을 열어 아래 값을 채웁니다.
```env
BINANCE_API_KEY=your_api_key
BINANCE_API_SECRET=your_api_secret
SYMBOL=XRPUSDT
LEVERAGE=10
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/combined_15m.parquet`가 없으면 1년치(365일) 전체 수집, 있으면 35일치 Upsert로 자동 전환합니다. 서버 이전이나 데이터 유실 시에도 사람의 개입 없이 자동 복구됩니다.
```bash
# LightGBM + Walk-Forward 5폴드 (기본값)
bash scripts/train_and_deploy.sh
# MLX GPU 학습 + Walk-Forward 5폴드
bash scripts/train_and_deploy.sh mlx
# LightGBM + Walk-Forward 3폴드
bash scripts/train_and_deploy.sh lgbm 3
# 학습만 (배포 없이)
bash scripts/train_and_deploy.sh lgbm 0
```
### 단계별 수동 실행
```bash
# 1. 과거 데이터 수집 (XRP/BTC/ETH 3심볼, 15분봉, 1년치 + OI/펀딩비)
# 기본값: Upsert 활성화 — 기존 parquet의 oi_change/funding_rate=0 구간을 실제 값으로 채움
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 15m \
--days 365 \
--output data/combined_15m.parquet
# 기존 파일을 완전히 덮어쓰려면 --no-upsert 플래그 사용
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 15m \
--days 365 \
--output data/combined_15m.parquet \
--no-upsert
# 2-A. LightGBM 모델 학습 (CPU)
python scripts/train_model.py --data data/combined_15m.parquet
# 2-B. MLX 신경망 학습 (Apple Silicon GPU)
python scripts/train_mlx_model.py --data data/combined_15m.parquet
# 3. LXC 서버에 모델 배포
bash scripts/deploy_model.sh # LightGBM
bash scripts/deploy_model.sh mlx # MLX (ONNX)
```
학습된 모델은 `models/lgbm_filter.pkl` (LightGBM) 또는 `models/mlx_filter.weights.onnx` (MLX) 에 저장됩니다.
> **모델 핫리로드**: 봇이 실행 중일 때 모델 파일을 교체하면, 다음 캔들 마감 시 자동으로 감지해 리로드합니다. 봇 재시작이 필요 없습니다.
### 하이퍼파라미터 자동 튜닝 (Optuna)
봇 성능이 저하되거나 데이터가 충분히 축적되었을 때 Optuna로 최적 LightGBM 파라미터를 탐색합니다.
결과를 확인하고 직접 승인한 후 재학습에 반영하는 **수동 트리거** 방식입니다.
```bash
# 기본 실행 (50 trials, 5폴드 Walk-Forward, ~30분)
python scripts/tune_hyperparams.py
# 빠른 테스트 (10 trials, 3폴드, ~5분)
python scripts/tune_hyperparams.py --trials 10 --folds 3
# 베이스라인 측정 없이 탐색만
python scripts/tune_hyperparams.py --no-baseline
```
결과는 `models/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로 자동 알림됩니다.
---
## 테스트
```bash
# 전체 테스트
bash scripts/run_tests.sh
# 특정 키워드 필터
bash scripts/run_tests.sh -k bot
# pytest 직접 실행
pytest tests/ -v
```
---
## 환경변수 레퍼런스
| 변수 | 기본값 | 설명 |
|------|--------|------|
| `BINANCE_API_KEY` | — | Binance API 키 |
| `BINANCE_API_SECRET` | — | Binance API 시크릿 |
| `SYMBOL` | `XRPUSDT` | 거래 심볼 |
| `LEVERAGE` | `10` | 레버리지 배수 |
| `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 필터 완전 비활성화 — 모델 로드 없이 모든 신호 허용 |
---
## 주의사항
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
> 성투기원합니다.

0
data/.gitkeep Normal file
View File

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
cointrader:
image: 10.1.10.28:3000/gihyeon/cointrader:latest
container_name: cointrader
restart: unless-stopped
env_file:
- .env
environment:
- TZ=Asia/Seoul
volumes:
- ./logs:/app/logs
- ./models:/app/models
- ./data:/app/data
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"

View File

@@ -0,0 +1,376 @@
# 15분봉 타임프레임 업그레이드 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 1분봉 파이프라인 전체를 15분봉으로 전환하고, LOOKAHEAD=24(6시간 뷰)로 조정해 모델 AUC를 0.49~0.50 구간에서 0.53+ 이상으로 개선한다.
**Architecture:** 데이터 수집(fetch_history.py) → 데이터셋 빌더(dataset_builder.py) → 학습 스크립트(train_model.py, train_mlx_model.py) → 실시간 봇(bot.py, data_stream.py) 순서로 파라미터를 변경한다. 각 레이어는 `interval` 문자열과 `LOOKAHEAD` 상수만 수정하면 되며 피처 구조는 그대로 유지한다.
**Tech Stack:** Python, LightGBM, pandas, binance-python-client, pytest
---
## 변경 요약
| 파일 | 변경 내용 |
|------|-----------|
| `src/dataset_builder.py` | `LOOKAHEAD 90→24`, `WARMUP 60→60` (유지) |
| `scripts/train_model.py` | `LOOKAHEAD 60→24`, `--data` 기본값 `combined_1m→combined_15m` |
| `scripts/train_mlx_model.py` | `--data` 기본값 `combined_1m→combined_15m` |
| `scripts/fetch_history.py` | `--interval` 기본값 `1m→15m`, `--output` 기본값 반영 |
| `scripts/train_and_deploy.sh` | `--interval 1m→15m`, 파일명 `1m→15m` |
| `src/bot.py` | `interval="1m"→"15m"` |
| `src/data_stream.py` | `buffer_size` 기본값 `200→200` (유지, 15분봉 200개=50시간 충분) |
---
## Task 1: dataset_builder.py — LOOKAHEAD 상수 변경
**Files:**
- Modify: `src/dataset_builder.py:14-17`
**Step 1: 현재 상수 확인**
```bash
head -20 src/dataset_builder.py
```
Expected: `LOOKAHEAD = 90`, `WARMUP = 60`
**Step 2: 상수 변경**
`src/dataset_builder.py` 14번째 줄:
```python
# 변경 전
LOOKAHEAD = 90
ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.0
WARMUP = 60
# 변경 후
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.0
WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분)
```
**Step 3: 변경 확인**
```bash
head -20 src/dataset_builder.py
```
Expected: `LOOKAHEAD = 24`
---
## Task 2: train_model.py — LOOKAHEAD 상수 및 기본 데이터 경로 변경
**Files:**
- Modify: `scripts/train_model.py:56-61`, `scripts/train_model.py:360`
**Step 1: 현재 상수 확인**
```bash
sed -n '55,62p' scripts/train_model.py
sed -n '358,362p' scripts/train_model.py
```
Expected: `LOOKAHEAD = 60`, `--data default="data/combined_1m.parquet"`
**Step 2: LOOKAHEAD 변경**
`scripts/train_model.py` 56번째 줄:
```python
# 변경 전
LOOKAHEAD = 60
# 변경 후
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 (dataset_builder.py와 동기화)
```
**Step 3: --data 기본값 변경**
`scripts/train_model.py` 360번째 줄 근처 `argparse` 부분:
```python
# 변경 전
parser.add_argument("--data", default="data/combined_1m.parquet")
# 변경 후
parser.add_argument("--data", default="data/combined_15m.parquet")
```
**Step 4: 변경 확인**
```bash
grep -n "LOOKAHEAD\|combined_" scripts/train_model.py
```
Expected: `LOOKAHEAD = 24`, `combined_15m.parquet`
---
## Task 3: train_mlx_model.py — 기본 데이터 경로 변경
**Files:**
- Modify: `scripts/train_mlx_model.py:149`
**Step 1: 현재 기본값 확인**
```bash
grep -n "combined_" scripts/train_mlx_model.py
```
Expected: `default="data/combined_1m.parquet"`
**Step 2: 기본값 변경**
`scripts/train_mlx_model.py` 149번째 줄:
```python
# 변경 전
parser.add_argument("--data", default="data/combined_1m.parquet")
# 변경 후
parser.add_argument("--data", default="data/combined_15m.parquet")
```
**Step 3: 변경 확인**
```bash
grep -n "combined_" scripts/train_mlx_model.py
```
Expected: `combined_15m.parquet`
---
## Task 4: fetch_history.py — 기본 interval 및 output 변경
**Files:**
- Modify: `scripts/fetch_history.py:114-118`
**Step 1: 현재 argparse 기본값 확인**
```bash
sed -n '112,120p' scripts/fetch_history.py
```
Expected: `--interval default="1m"`, `--output default="data/xrpusdt_1m.parquet"`
**Step 2: 기본값 변경**
```python
# 변경 전
parser.add_argument("--interval", default="1m")
parser.add_argument("--days", type=int, default=90)
parser.add_argument("--output", default="data/xrpusdt_1m.parquet")
# 변경 후
parser.add_argument("--interval", default="15m")
parser.add_argument("--days", type=int, default=365)
parser.add_argument("--output", default="data/xrpusdt_15m.parquet")
```
**Step 3: 변경 확인**
```bash
grep -n "interval\|output\|days" scripts/fetch_history.py | grep "default"
```
Expected: `default="15m"`, `default=365`, `default="data/xrpusdt_15m.parquet"`
---
## Task 5: train_and_deploy.sh — interval 및 파일명 변경
**Files:**
- Modify: `scripts/train_and_deploy.sh:26-43`
**Step 1: 현재 스크립트 확인**
```bash
cat scripts/train_and_deploy.sh
```
**Step 2: 스크립트 변경**
```bash
# 변경 전 (26~32번째 줄)
echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼, 1년치) ==="
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 1m \
--days 365 \
--output data/xrpusdt_1m.parquet
# 결과: data/combined_1m.parquet (타임스탬프 기준 병합)
# 변경 후
echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼, 1년치) ==="
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 15m \
--days 365 \
--output data/xrpusdt_15m.parquet
# 결과: data/combined_15m.parquet (타임스탬프 기준 병합)
```
```bash
# 변경 전 (38~43번째 줄)
python scripts/train_mlx_model.py --data data/combined_1m.parquet --decay "$DECAY"
else
echo " 백엔드: LightGBM (CPU), decay=${DECAY}"
python scripts/train_model.py --data data/combined_1m.parquet --decay "$DECAY"
# 변경 후
python scripts/train_mlx_model.py --data data/combined_15m.parquet --decay "$DECAY"
else
echo " 백엔드: LightGBM (CPU), decay=${DECAY}"
python scripts/train_model.py --data data/combined_15m.parquet --decay "$DECAY"
```
**Step 3: 변경 확인**
```bash
grep -n "1m\|15m" scripts/train_and_deploy.sh
```
Expected: 모든 `1m` 참조가 `15m`으로 변경됨
---
## Task 6: bot.py — 실시간 스트림 interval 변경
**Files:**
- Modify: `src/bot.py:22-25`
**Step 1: 현재 interval 확인**
```bash
grep -n "interval" src/bot.py
```
Expected: `interval="1m"` (MultiSymbolStream 생성자)
**Step 2: interval 변경**
`src/bot.py` 21~25번째 줄:
```python
# 변경 전
self.stream = MultiSymbolStream(
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
interval="1m",
on_candle=self._on_candle_closed,
)
# 변경 후
self.stream = MultiSymbolStream(
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
interval="15m",
on_candle=self._on_candle_closed,
)
```
**Step 3: 변경 확인**
```bash
grep -n "interval" src/bot.py
```
Expected: `interval="15m"`
---
## Task 7: 전체 변경 검증
**Step 1: 모든 `1m` 하드코딩 잔재 확인**
```bash
grep -rn '"1m"' src/ scripts/
```
Expected: 결과 없음 (모두 `"15m"`으로 변경됨)
**Step 2: LOOKAHEAD 동기화 확인**
```bash
grep -rn "LOOKAHEAD" src/ scripts/
```
Expected:
- `src/dataset_builder.py`: `LOOKAHEAD = 24`
- `scripts/train_model.py`: `LOOKAHEAD = 24`
**Step 3: combined 파일명 일관성 확인**
```bash
grep -rn "combined_" src/ scripts/
```
Expected: 모두 `combined_15m` 참조
**Step 4: 파이프라인 드라이런 (데이터 없이 import 테스트)**
```bash
python -c "
from src.dataset_builder import LOOKAHEAD, ATR_SL_MULT, ATR_TP_MULT, WARMUP
assert LOOKAHEAD == 24, f'LOOKAHEAD={LOOKAHEAD}'
print(f'OK: LOOKAHEAD={LOOKAHEAD}, ATR_SL={ATR_SL_MULT}, ATR_TP={ATR_TP_MULT}, WARMUP={WARMUP}')
"
```
Expected: `OK: LOOKAHEAD=24, ATR_SL=1.5, ATR_TP=2.0, WARMUP=60`
---
## Task 8: 데이터 수집 및 Walk-Forward 검증 실행
> 이 태스크는 실제 바이낸스 API 키와 네트워크가 필요합니다.
**Step 1: 15분봉 데이터 수집**
```bash
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 15m \
--days 365 \
--output data/xrpusdt_15m.parquet
```
Expected: `data/combined_15m.parquet` 생성, 약 35,040행 (365일 × 96캔들/일)
**Step 2: Walk-Forward AUC 측정 (기준선 확인)**
```bash
python scripts/train_model.py \
--data data/combined_15m.parquet \
--wf \
--wf-splits 5
```
Expected: Walk-Forward 평균 AUC가 0.53 이상이면 개선 확인
**Step 3: 정식 학습 및 모델 저장**
```bash
python scripts/train_model.py \
--data data/combined_15m.parquet \
--decay 2.0
```
Expected: `models/lgbm_filter.pkl` 저장, 기존 모델은 `lgbm_filter_prev.pkl`로 백업
---
## 롤백 방법
15분봉 모델이 기대에 미치지 못할 경우:
```bash
# 기존 1분봉 모델 복원
cp models/lgbm_filter_prev.pkl models/lgbm_filter.pkl
# 코드는 git으로 복원
git checkout src/dataset_builder.py scripts/train_model.py \
scripts/train_mlx_model.py scripts/fetch_history.py \
scripts/train_and_deploy.sh src/bot.py
```

View File

@@ -0,0 +1,119 @@
# BTC/ETH 상관관계 피처 추가 설계 문서
**날짜:** 2026-03-01
## 목적
XRP 선물 ML 필터에 BTC/ETH 캔들 데이터를 추가 피처로 활용하여 모델 예측 정확도를 향상시킨다. XRP는 BTC/ETH의 움직임에 강하게 연동되는 경향이 있으므로, 이 컨텍스트를 ML 피처로 명시적으로 제공한다.
---
## 아키텍처 개요
### 변경 전
```
KlineStream(XRPUSDT) → bot.process_candle() → Indicators → MLFilter(13개 피처)
```
### 변경 후
```
MultiSymbolStream(XRP+BTC+ETH, Combined WebSocket)
↓ XRP 캔들 닫힐 때
bot.process_candle(xrp_df, btc_df, eth_df)
Indicators(XRP) → build_features(xrp_df, btc_df, eth_df, signal)
MLFilter(13 + 8 = 21개 피처)
```
---
## 추가 피처 8개
| 피처 | 설명 |
|---|---|
| `btc_ret_1` | BTC 1캔들 수익률 |
| `btc_ret_3` | BTC 3캔들 수익률 |
| `btc_ret_5` | BTC 5캔들 수익률 |
| `eth_ret_1` | ETH 1캔들 수익률 |
| `eth_ret_3` | ETH 3캔들 수익률 |
| `eth_ret_5` | ETH 5캔들 수익률 |
| `xrp_btc_rs` | XRP ret_1 / BTC ret_1 (XRP 상대강도 vs BTC) |
| `xrp_eth_rs` | XRP ret_1 / ETH ret_1 (XRP 상대강도 vs ETH) |
기존 13개 피처(`rsi`, `macd_hist`, `bb_pct`, `ema_align`, `stoch_k`, `stoch_d`, `atr_pct`, `vol_ratio`, `ret_1`, `ret_3`, `ret_5`, `signal_strength`, `side`)는 그대로 유지.
---
## 변경 파일 목록
| 파일 | 변경 유형 | 내용 |
|---|---|---|
| `src/data_stream.py` | 수정 | `KlineStream``MultiSymbolStream` (Combined WebSocket) |
| `src/ml_features.py` | 수정 | `build_features(xrp_df, btc_df, eth_df, signal)` — 피처 21개로 확장 |
| `scripts/fetch_history.py` | 수정 | BTC/ETH 동시 수집 후 타임스탬프 기준 병합 저장 |
| `scripts/train_model.py` | 수정 | 병합된 데이터셋으로 21개 피처 학습 |
| `src/bot.py` | 수정 | `MultiSymbolStream` 사용, `process_candle`에 btc_df/eth_df 전달 |
| `src/dataset_builder.py` | 수정 | 레이블 생성 시 BTC/ETH 피처 포함 |
---
## 데이터 흐름
### 실시간 (봇 운영)
```
Binance Combined WebSocket
├── xrpusdt@kline_1m → xrp_buffer (deque 200)
├── btcusdt@kline_1m → btc_buffer (deque 200)
└── ethusdt@kline_1m → eth_buffer (deque 200)
↓ XRP 캔들 닫힐 때만 트리거
bot.process_candle(xrp_df, btc_df, eth_df)
```
### 학습 데이터 수집
```
fetch_history.py → XRPUSDT + BTCUSDT + ETHUSDT 각 90일 수집
→ 타임스탬프 기준 inner join 병합
→ data/combined_1m.parquet 저장
train_model.py → 21개 피처로 LightGBM 재학습
→ models/lgbm_filter.pkl 교체
```
---
## 에러 처리
| 상황 | 처리 방법 |
|---|---|
| BTC/ETH 버퍼 50개 미만 (봇 시작 직후) | btc/eth 피처 전부 0.0으로 채움, 거래는 정상 진행 |
| Combined WebSocket 연결 끊김 | 예외 발생 → 상위에서 재연결 |
| BTC/ETH ret 분모가 0 | `xrp_btc_rs`, `xrp_eth_rs` = 0.0으로 처리 |
| 기존 모델(13개 피처) 파일이 남아있는 경우 | 피처 수 불일치 → MLFilter 폴백(신호 통과)으로 자동 처리 |
---
## 재학습 순서
기존 `lgbm_filter.pkl`(13개 피처)은 새 데이터셋(21개 피처) 재학습 후 자동 교체된다.
**봇 재시작 전 반드시 아래 순서로 실행:**
```bash
# 1. 3심볼 과거 데이터 수집
python scripts/fetch_history.py --symbols XRPUSDT BTCUSDT ETHUSDT --days 90
# 2. 21피처 모델 재학습
python scripts/train_model.py
# 3. 봇 재시작
```
---
## 폴백 정책
- BTC/ETH 버퍼가 비어있으면 해당 피처를 0.0으로 채워 기존 XRP 피처만으로 동작
- 모델 파일이 없으면 ML 필터 전체를 건너뜀 (기존 정책 유지)

View File

@@ -0,0 +1,815 @@
# BTC/ETH 상관관계 피처 추가 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** BTC/ETH 캔들 데이터를 XRP ML 필터의 추가 피처(21개)로 활용해 모델 예측 정확도를 향상시킨다.
**Architecture:** 바이낸스 Combined WebSocket으로 XRP/BTC/ETH 3개 심볼을 단일 연결로 수신하고, XRP 캔들이 닫힐 때 BTC/ETH 버퍼의 수익률·상대강도 8개 피처를 기존 13개 피처에 추가해 LightGBM에 전달한다. 학습 데이터도 3심볼을 타임스탬프 기준으로 병합해 동일한 21개 피처로 재학습한다.
**Tech Stack:** Python 3.12, python-binance (AsyncClient + BinanceSocketManager), LightGBM, pandas, joblib
---
## Task 1: `MultiSymbolStream` — Combined WebSocket으로 3심볼 수신
**Files:**
- Modify: `src/data_stream.py`
- Test: `tests/test_data_stream.py`
### Step 1: 실패하는 테스트 작성
`tests/test_data_stream.py` 파일에 아래 테스트를 추가한다.
```python
from src.data_stream import MultiSymbolStream
def test_multi_symbol_stream_has_three_buffers():
stream = MultiSymbolStream(
symbols=["XRPUSDT", "BTCUSDT", "ETHUSDT"],
interval="1m",
)
assert "xrpusdt" in stream.buffers
assert "btcusdt" in stream.buffers
assert "ethusdt" in stream.buffers
def test_multi_symbol_stream_get_dataframe_returns_none_when_empty():
stream = MultiSymbolStream(
symbols=["XRPUSDT", "BTCUSDT", "ETHUSDT"],
interval="1m",
)
assert stream.get_dataframe("XRPUSDT") is None
def test_multi_symbol_stream_get_dataframe_returns_df_when_full():
import pandas as pd
stream = MultiSymbolStream(
symbols=["XRPUSDT", "BTCUSDT", "ETHUSDT"],
interval="1m",
buffer_size=200,
)
candle = {
"timestamp": 1000, "open": 1.0, "high": 1.1,
"low": 0.9, "close": 1.05, "volume": 100.0, "is_closed": True,
}
for i in range(50):
c = candle.copy()
c["timestamp"] = 1000 + i
stream.buffers["xrpusdt"].append(c)
df = stream.get_dataframe("XRPUSDT")
assert df is not None
assert len(df) == 50
```
### Step 2: 테스트 실패 확인
```bash
pytest tests/test_data_stream.py::test_multi_symbol_stream_has_three_buffers -v
```
Expected: `FAILED``MultiSymbolStream` not defined
### Step 3: `MultiSymbolStream` 구현
`src/data_stream.py` 파일에 기존 `KlineStream` 클래스 아래에 추가한다.
```python
class MultiSymbolStream:
"""
바이낸스 Combined WebSocket으로 여러 심볼의 캔들을 단일 연결로 수신한다.
XRP 캔들이 닫힐 때 on_candle 콜백을 호출한다.
"""
def __init__(
self,
symbols: list[str],
interval: str = "1m",
buffer_size: int = 200,
on_candle: Callable = None,
):
self.symbols = [s.lower() for s in symbols]
self.interval = interval
self.on_candle = on_candle
self.buffers: dict[str, deque] = {
s: deque(maxlen=buffer_size) for s in self.symbols
}
# 첫 번째 심볼이 주 심볼 (XRP)
self.primary_symbol = self.symbols[0]
def parse_kline(self, msg: dict) -> dict:
k = msg["k"]
return {
"timestamp": k["t"],
"open": float(k["o"]),
"high": float(k["h"]),
"low": float(k["l"]),
"close": float(k["c"]),
"volume": float(k["v"]),
"is_closed": k["x"],
}
def handle_message(self, msg: dict):
# Combined stream 메시지는 {"stream": "...", "data": {...}} 형태
if "stream" in msg:
data = msg["data"]
else:
data = msg
if data.get("e") != "kline":
return
symbol = data["s"].lower()
candle = self.parse_kline(data)
if candle["is_closed"] and symbol in self.buffers:
self.buffers[symbol].append(candle)
if symbol == self.primary_symbol and self.on_candle:
self.on_candle(candle)
def get_dataframe(self, symbol: str) -> pd.DataFrame | None:
key = symbol.lower()
buf = self.buffers.get(key)
if buf is None or len(buf) < 50:
return None
df = pd.DataFrame(list(buf))
df.set_index("timestamp", inplace=True)
return df
async def _preload_history(self, client: AsyncClient, limit: int = 200):
"""REST API로 모든 심볼의 과거 캔들을 버퍼에 미리 채운다."""
for symbol in self.symbols:
logger.info(f"{symbol.upper()} 과거 캔들 {limit}개 로드 중...")
klines = await client.futures_klines(
symbol=symbol.upper(),
interval=self.interval,
limit=limit,
)
for k in klines[:-1]:
self.buffers[symbol].append({
"timestamp": k[0],
"open": float(k[1]),
"high": float(k[2]),
"low": float(k[3]),
"close": float(k[4]),
"volume": float(k[5]),
"is_closed": True,
})
logger.info(f"{symbol.upper()} {len(self.buffers[symbol])}개 로드 완료")
async def start(self, api_key: str, api_secret: str):
client = await AsyncClient.create(
api_key=api_key,
api_secret=api_secret,
)
await self._preload_history(client)
bm = BinanceSocketManager(client)
streams = [
f"{s}@kline_{self.interval}" for s in self.symbols
]
logger.info(f"Combined WebSocket 시작: {streams}")
try:
async with bm.futures_multiplex_socket(streams) as stream:
while True:
msg = await stream.recv()
self.handle_message(msg)
finally:
await client.close_connection()
```
### Step 4: 테스트 통과 확인
```bash
pytest tests/test_data_stream.py -v
```
Expected: 모든 테스트 PASS
### Step 5: 커밋
```bash
git add src/data_stream.py tests/test_data_stream.py
git commit -m "feat: add MultiSymbolStream for combined BTC/ETH/XRP WebSocket"
```
---
## Task 2: `build_features` — BTC/ETH 피처 8개 추가
**Files:**
- Modify: `src/ml_features.py`
- Test: `tests/test_ml_features.py`
### Step 1: 실패하는 테스트 작성
`tests/test_ml_features.py`에 아래 테스트를 추가한다.
```python
import pandas as pd
import numpy as np
from src.ml_features import build_features, FEATURE_COLS
def _make_df(n=10, base_price=1.0):
"""테스트용 더미 캔들 DataFrame 생성."""
closes = [base_price * (1 + i * 0.001) for i in range(n)]
return pd.DataFrame({
"close": closes, "high": [c * 1.01 for c in closes],
"low": [c * 0.99 for c in closes],
"volume": [1000.0] * n,
"rsi": [50.0] * n, "macd": [0.0] * n, "macd_signal": [0.0] * n,
"macd_hist": [0.0] * n, "bb_upper": [c * 1.02 for c in closes],
"bb_lower": [c * 0.98 for c in closes], "ema9": closes,
"ema21": closes, "ema50": closes, "atr": [0.01] * n,
"stoch_k": [50.0] * n, "stoch_d": [50.0] * n,
"vol_ma20": [1000.0] * n,
})
def test_build_features_with_btc_eth_has_21_features():
xrp_df = _make_df(10, base_price=1.0)
btc_df = _make_df(10, base_price=50000.0)
eth_df = _make_df(10, base_price=3000.0)
features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df)
assert len(features) == 21
def test_build_features_without_btc_eth_has_13_features():
xrp_df = _make_df(10, base_price=1.0)
features = build_features(xrp_df, "LONG")
assert len(features) == 13
def test_build_features_btc_ret_1_correct():
xrp_df = _make_df(10, base_price=1.0)
btc_df = _make_df(10, base_price=50000.0)
eth_df = _make_df(10, base_price=3000.0)
features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df)
btc_closes = btc_df["close"]
expected_btc_ret_1 = (btc_closes.iloc[-1] - btc_closes.iloc[-2]) / btc_closes.iloc[-2]
assert abs(features["btc_ret_1"] - expected_btc_ret_1) < 1e-6
def test_build_features_rs_zero_when_btc_ret_zero():
xrp_df = _make_df(10, base_price=1.0)
# BTC 가격이 변하지 않으면 ret=0, RS=0
btc_df = _make_df(10, base_price=50000.0)
btc_df["close"] = 50000.0 # 모든 캔들 동일
eth_df = _make_df(10, base_price=3000.0)
features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df)
assert features["xrp_btc_rs"] == 0.0
def test_feature_cols_has_21_items():
from src.ml_features import FEATURE_COLS
assert len(FEATURE_COLS) == 21
```
### Step 2: 테스트 실패 확인
```bash
pytest tests/test_ml_features.py -v
```
Expected: 여러 테스트 FAIL
### Step 3: `ml_features.py` 수정
`src/ml_features.py` 전체를 아래로 교체한다.
```python
import pandas as pd
import numpy as np
FEATURE_COLS = [
"rsi", "macd_hist", "bb_pct", "ema_align",
"stoch_k", "stoch_d", "atr_pct", "vol_ratio",
"ret_1", "ret_3", "ret_5", "signal_strength", "side",
"btc_ret_1", "btc_ret_3", "btc_ret_5",
"eth_ret_1", "eth_ret_3", "eth_ret_5",
"xrp_btc_rs", "xrp_eth_rs",
]
def _calc_ret(closes: pd.Series, n: int) -> float:
"""n캔들 전 대비 수익률. 데이터 부족 시 0.0."""
if len(closes) < n + 1:
return 0.0
prev = closes.iloc[-(n + 1)]
return (closes.iloc[-1] - prev) / prev if prev != 0 else 0.0
def _calc_rs(xrp_ret: float, other_ret: float) -> float:
"""상대강도 = xrp_ret / other_ret. 분모 0이면 0.0."""
if other_ret == 0.0:
return 0.0
return xrp_ret / other_ret
def build_features(
df: pd.DataFrame,
signal: str,
btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None,
) -> pd.Series:
"""
기술 지표가 계산된 DataFrame의 마지막 행에서 ML 피처를 추출한다.
btc_df, eth_df가 제공되면 21개 피처를, 없으면 13개 피처를 반환한다.
signal: "LONG" | "SHORT"
"""
last = df.iloc[-1]
close = last["close"]
bb_upper = last.get("bb_upper", close)
bb_lower = last.get("bb_lower", close)
bb_range = bb_upper - bb_lower
bb_pct = (close - bb_lower) / bb_range if bb_range > 0 else 0.5
ema9 = last.get("ema9", close)
ema21 = last.get("ema21", close)
ema50 = last.get("ema50", close)
if ema9 > ema21 > ema50:
ema_align = 1
elif ema9 < ema21 < ema50:
ema_align = -1
else:
ema_align = 0
atr = last.get("atr", 0)
atr_pct = atr / close if close > 0 else 0
vol_ma20 = last.get("vol_ma20", last.get("volume", 1))
vol_ratio = last["volume"] / vol_ma20 if vol_ma20 > 0 else 1.0
closes = df["close"]
ret_1 = _calc_ret(closes, 1)
ret_3 = _calc_ret(closes, 3)
ret_5 = _calc_ret(closes, 5)
prev = df.iloc[-2] if len(df) >= 2 else last
strength = 0
rsi = last.get("rsi", 50)
macd = last.get("macd", 0)
macd_sig = last.get("macd_signal", 0)
prev_macd = prev.get("macd", 0)
prev_macd_sig = prev.get("macd_signal", 0)
stoch_k = last.get("stoch_k", 50)
stoch_d = last.get("stoch_d", 50)
if signal == "LONG":
if rsi < 35: strength += 1
if prev_macd < prev_macd_sig and macd > macd_sig: strength += 2
if close < last.get("bb_lower", close): strength += 1
if ema_align == 1: strength += 1
if stoch_k < 20 and stoch_k > stoch_d: strength += 1
else:
if rsi > 65: strength += 1
if prev_macd > prev_macd_sig and macd < macd_sig: strength += 2
if close > last.get("bb_upper", close): strength += 1
if ema_align == -1: strength += 1
if stoch_k > 80 and stoch_k < stoch_d: strength += 1
base = {
"rsi": float(rsi),
"macd_hist": float(last.get("macd_hist", 0)),
"bb_pct": float(bb_pct),
"ema_align": float(ema_align),
"stoch_k": float(stoch_k),
"stoch_d": float(last.get("stoch_d", 50)),
"atr_pct": float(atr_pct),
"vol_ratio": float(vol_ratio),
"ret_1": float(ret_1),
"ret_3": float(ret_3),
"ret_5": float(ret_5),
"signal_strength": float(strength),
"side": 1.0 if signal == "LONG" else 0.0,
}
if btc_df is not None and eth_df is not None:
btc_ret_1 = _calc_ret(btc_df["close"], 1)
btc_ret_3 = _calc_ret(btc_df["close"], 3)
btc_ret_5 = _calc_ret(btc_df["close"], 5)
eth_ret_1 = _calc_ret(eth_df["close"], 1)
eth_ret_3 = _calc_ret(eth_df["close"], 3)
eth_ret_5 = _calc_ret(eth_df["close"], 5)
base.update({
"btc_ret_1": float(btc_ret_1),
"btc_ret_3": float(btc_ret_3),
"btc_ret_5": float(btc_ret_5),
"eth_ret_1": float(eth_ret_1),
"eth_ret_3": float(eth_ret_3),
"eth_ret_5": float(eth_ret_5),
"xrp_btc_rs": float(_calc_rs(ret_1, btc_ret_1)),
"xrp_eth_rs": float(_calc_rs(ret_1, eth_ret_1)),
})
return pd.Series(base)
```
### Step 4: 테스트 통과 확인
```bash
pytest tests/test_ml_features.py -v
```
Expected: 모든 테스트 PASS
### Step 5: 커밋
```bash
git add src/ml_features.py tests/test_ml_features.py
git commit -m "feat: extend build_features to 21 features with BTC/ETH correlation"
```
---
## Task 3: `dataset_builder.py` — BTC/ETH 피처 벡터화 추가
**Files:**
- Modify: `src/dataset_builder.py`
- Test: `tests/test_dataset_builder.py`
### Step 1: 실패하는 테스트 작성
`tests/test_dataset_builder.py`에 아래 테스트를 추가한다.
```python
def test_generate_dataset_vectorized_with_btc_eth_has_21_feature_cols():
"""BTC/ETH DataFrame을 전달하면 결과 컬럼이 21개 피처 + label이어야 한다."""
import pandas as pd
import numpy as np
from src.dataset_builder import generate_dataset_vectorized
from src.ml_features import FEATURE_COLS
np.random.seed(42)
n = 500
closes = np.cumprod(1 + np.random.randn(n) * 0.001) * 1.0
xrp_df = pd.DataFrame({
"open": closes * 0.999, "high": closes * 1.005,
"low": closes * 0.995, "close": closes,
"volume": np.random.rand(n) * 1000 + 500,
})
btc_df = xrp_df.copy() * 50000
eth_df = xrp_df.copy() * 3000
result = generate_dataset_vectorized(xrp_df, btc_df=btc_df, eth_df=eth_df)
if not result.empty:
assert set(FEATURE_COLS).issubset(set(result.columns))
assert len(result.columns) == len(FEATURE_COLS) + 1 # +1 for label
```
### Step 2: 테스트 실패 확인
```bash
pytest tests/test_dataset_builder.py::test_generate_dataset_vectorized_with_btc_eth_has_21_feature_cols -v
```
Expected: FAIL — `generate_dataset_vectorized()` does not accept btc_df/eth_df
### Step 3: `dataset_builder.py` 수정
`_calc_features_vectorized` 함수와 `generate_dataset_vectorized` 함수를 수정한다.
`_calc_features_vectorized` 시그니처와 반환부에 BTC/ETH 피처 추가:
```python
def _calc_features_vectorized(
d: pd.DataFrame,
signal_arr: np.ndarray,
btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None,
) -> pd.DataFrame:
# ... 기존 코드 유지 ...
# BTC/ETH 피처 계산 (제공된 경우)
if btc_df is not None and eth_df is not None:
btc_ret_1 = btc_df["close"].pct_change(1).fillna(0).values
btc_ret_3 = btc_df["close"].pct_change(3).fillna(0).values
btc_ret_5 = btc_df["close"].pct_change(5).fillna(0).values
eth_ret_1 = eth_df["close"].pct_change(1).fillna(0).values
eth_ret_3 = eth_df["close"].pct_change(3).fillna(0).values
eth_ret_5 = eth_df["close"].pct_change(5).fillna(0).values
# 타임스탬프 정렬: XRP 인덱스 기준으로 BTC/ETH 값을 맞춤
# 길이가 다를 경우 짧은 쪽에 맞춰 앞을 0으로 패딩
def _align(arr: np.ndarray, target_len: int) -> np.ndarray:
if len(arr) >= target_len:
return arr[-target_len:]
return np.concatenate([np.zeros(target_len - len(arr)), arr])
n = len(d)
btc_r1 = _align(btc_ret_1, n).astype(np.float32)
btc_r3 = _align(btc_ret_3, n).astype(np.float32)
btc_r5 = _align(btc_ret_5, n).astype(np.float32)
eth_r1 = _align(eth_ret_1, n).astype(np.float32)
eth_r3 = _align(eth_ret_3, n).astype(np.float32)
eth_r5 = _align(eth_ret_5, n).astype(np.float32)
xrp_r1 = ret_1.astype(np.float32)
xrp_btc_rs = np.where(btc_r1 != 0, xrp_r1 / btc_r1, 0.0).astype(np.float32)
xrp_eth_rs = np.where(eth_r1 != 0, xrp_r1 / eth_r1, 0.0).astype(np.float32)
extra = pd.DataFrame({
"btc_ret_1": btc_r1, "btc_ret_3": btc_r3, "btc_ret_5": btc_r5,
"eth_ret_1": eth_r1, "eth_ret_3": eth_r3, "eth_ret_5": eth_r5,
"xrp_btc_rs": xrp_btc_rs, "xrp_eth_rs": xrp_eth_rs,
}, index=d.index)
result = pd.concat([result, extra], axis=1) # result는 기존 13개 피처 DataFrame
return result
```
`generate_dataset_vectorized` 시그니처 변경:
```python
def generate_dataset_vectorized(
df: pd.DataFrame,
btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None,
) -> pd.DataFrame:
# ...
feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
# ...
feat_final = feat_all.iloc[final_idx][FEATURE_COLS].copy()
# ...
```
### Step 4: 테스트 통과 확인
```bash
pytest tests/test_dataset_builder.py -v
```
Expected: 모든 테스트 PASS
### Step 5: 커밋
```bash
git add src/dataset_builder.py tests/test_dataset_builder.py
git commit -m "feat: add BTC/ETH features to vectorized dataset builder"
```
---
## Task 4: `fetch_history.py` — 3심볼 동시 수집 및 병합
**Files:**
- Modify: `scripts/fetch_history.py`
### Step 1: 수정 내용
`fetch_history.py``main()` 함수를 수정해 `--symbols` 인자로 여러 심볼을 받고, 타임스탬프 기준 inner join으로 병합 후 저장한다.
```python
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--symbols", nargs="+", default=["XRPUSDT"])
parser.add_argument("--interval", default="1m")
parser.add_argument("--days", type=int, default=90)
parser.add_argument("--output", default="data/xrpusdt_1m.parquet")
args = parser.parse_args()
if len(args.symbols) == 1:
# 단일 심볼: 기존 동작 유지
df = asyncio.run(fetch_klines(args.symbols[0], args.interval, args.days))
df.to_parquet(args.output)
print(f"저장 완료: {args.output} ({len(df)}행)")
else:
# 멀티 심볼: 각각 수집 후 병합
dfs = {}
for symbol in args.symbols:
print(f"{symbol} 수집 중...")
dfs[symbol] = asyncio.run(fetch_klines(symbol, args.interval, args.days))
# 타임스탬프 기준 inner join
primary = args.symbols[0]
merged = dfs[primary].copy()
for symbol in args.symbols[1:]:
suffix = "_" + symbol.lower().replace("usdt", "")
merged = merged.join(
dfs[symbol].add_suffix(suffix),
how="inner",
)
output = args.output.replace("xrpusdt", "combined")
merged.to_parquet(output)
print(f"병합 저장 완료: {output} ({len(merged)}행, {len(merged.columns)}컬럼)")
```
### Step 2: 동작 확인 (dry run — API 키 없이 구조만 확인)
```bash
python scripts/fetch_history.py --help
```
Expected: `--symbols` 인자가 출력됨
### Step 3: 커밋
```bash
git add scripts/fetch_history.py
git commit -m "feat: fetch_history supports multi-symbol collection and merge"
```
---
## Task 5: `train_model.py` — 병합 데이터셋으로 21피처 학습
**Files:**
- Modify: `scripts/train_model.py`
### Step 1: 수정 내용
`train()` 함수가 병합된 parquet을 받아 BTC/ETH 컬럼을 분리해 `generate_dataset_vectorized`에 전달하도록 수정한다.
```python
def train(data_path: str):
print(f"데이터 로드: {data_path}")
df_raw = pd.read_parquet(data_path)
print(f"캔들 수: {len(df_raw)}, 컬럼: {list(df_raw.columns)}")
# 병합 데이터셋 여부 판별
btc_df = None
eth_df = None
base_cols = ["open", "high", "low", "close", "volume"]
if "close_btc" in df_raw.columns:
btc_df = df_raw[[c + "_btc" for c in base_cols]].copy()
btc_df.columns = base_cols
print("BTC 피처 활성화")
if "close_eth" in df_raw.columns:
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
eth_df.columns = base_cols
print("ETH 피처 활성화")
df = df_raw[base_cols].copy()
print("데이터셋 생성 중...")
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df)
# ... 이하 기존 학습 코드 동일 (X = dataset[FEATURE_COLS] 부분에서 자동으로 21개 사용) ...
```
### Step 2: 커밋
```bash
git add scripts/train_model.py
git commit -m "feat: train_model uses merged dataset with BTC/ETH features"
```
---
## Task 6: `bot.py` — `MultiSymbolStream` 연결 및 피처 전달
**Files:**
- Modify: `src/bot.py`
- Test: `tests/test_bot.py`
### Step 1: 실패하는 테스트 작성
`tests/test_bot.py`에 아래 테스트를 추가한다.
```python
def test_bot_uses_multi_symbol_stream():
from src.bot import TradingBot
from src.config import Config
from src.data_stream import MultiSymbolStream
config = Config()
bot = TradingBot(config)
assert isinstance(bot.stream, MultiSymbolStream)
def test_bot_stream_has_btc_eth_buffers():
from src.bot import TradingBot
from src.config import Config
config = Config()
bot = TradingBot(config)
assert "btcusdt" in bot.stream.buffers
assert "ethusdt" in bot.stream.buffers
```
### Step 2: 테스트 실패 확인
```bash
pytest tests/test_bot.py::test_bot_uses_multi_symbol_stream -v
```
Expected: FAIL
### Step 3: `bot.py` 수정
`__init__` 에서 `KlineStream``MultiSymbolStream`으로 교체하고, `process_candle`에 BTC/ETH df를 전달한다.
```python
# import 변경
from src.data_stream import MultiSymbolStream # KlineStream 대신
class TradingBot:
def __init__(self, config: Config):
self.config = config
self.exchange = BinanceFuturesClient(config)
self.notifier = DiscordNotifier(config.discord_webhook_url)
self.risk = RiskManager(config)
self.ml_filter = MLFilter()
self.current_trade_side: str | None = None
self.stream = MultiSymbolStream(
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
interval="1m",
on_candle=self._on_candle_closed,
)
def _on_candle_closed(self, candle: dict):
xrp_df = self.stream.get_dataframe(self.config.symbol)
btc_df = self.stream.get_dataframe("BTCUSDT")
eth_df = self.stream.get_dataframe("ETHUSDT")
if xrp_df is not None:
asyncio.create_task(self.process_candle(xrp_df, btc_df=btc_df, eth_df=eth_df))
async def process_candle(
self,
df,
btc_df=None,
eth_df=None,
):
if not self.risk.is_trading_allowed():
logger.warning("리스크 한도 초과 - 거래 중단")
return
ind = Indicators(df)
df_with_indicators = ind.calculate_all()
signal = ind.get_signal(df_with_indicators)
if signal != "HOLD" and self.ml_filter.is_model_loaded():
features = build_features(df_with_indicators, signal, btc_df=btc_df, eth_df=eth_df)
if not self.ml_filter.should_enter(features):
logger.info(f"ML 필터 차단: {signal} 신호 무시")
signal = "HOLD"
# ... 이하 기존 코드 동일 ...
async def run(self):
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
await self._recover_position()
await self.stream.start(
api_key=self.config.api_key,
api_secret=self.config.api_secret,
)
```
### Step 4: 테스트 통과 확인
```bash
pytest tests/test_bot.py -v
```
Expected: 모든 테스트 PASS
### Step 5: 커밋
```bash
git add src/bot.py tests/test_bot.py
git commit -m "feat: bot uses MultiSymbolStream and passes BTC/ETH df to build_features"
```
---
## Task 7: 전체 테스트 통과 및 재학습 실행
### Step 1: 전체 테스트 실행
```bash
pytest tests/ -v
```
Expected: 모든 테스트 PASS
### Step 2: 3심볼 데이터 수집
```bash
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--days 90 \
--output data/xrpusdt_1m.parquet
```
Expected: `data/combined_1m.parquet` 생성
### Step 3: 21피처 모델 재학습
```bash
python scripts/train_model.py --data data/combined_1m.parquet
```
Expected: `models/lgbm_filter.pkl` 교체, AUC 출력
### Step 4: 최종 커밋
```bash
git add models/training_log.json
git commit -m "chore: retrain model with 21 BTC/ETH correlation features"
```
---
## 실행 순서 요약
```
Task 1 → Task 2 → Task 3 → Task 4 → Task 5 → Task 6 → Task 7
(Stream) (피처) (데이터셋) (수집) (학습) (봇) (검증)
```
각 Task는 독립적으로 테스트 가능하며, Task 7 이전까지는 기존 봇이 정상 동작한다.

View File

@@ -0,0 +1,282 @@
# Dockerfile & docker-compose.yml 작성 및 Gitea 업로드 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** cointrader 프로젝트에 Dockerfile과 docker-compose.yml을 추가하고, 변경사항을 커밋하여 Gitea(10.1.10.28:3000)에 push한다.
**Architecture:** Python 3.11 slim 이미지 기반의 멀티스테이지 없는 단일 Dockerfile을 작성하고, docker-compose.yml로 환경변수(.env)를 주입하여 컨테이너를 실행한다. 로그 디렉토리는 볼륨으로 마운트하여 컨테이너 재시작 시에도 보존한다.
**Tech Stack:** Docker, docker-compose v2, Python 3.11-slim, python-dotenv
---
## Task 1: Dockerfile 작성
**Files:**
- Create: `Dockerfile`
**Step 1: Dockerfile 생성**
`/Users/gihyeon/github/cointrader/Dockerfile` 파일을 아래 내용으로 생성한다:
```dockerfile
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p logs
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
CMD ["python", "main.py"]
```
**Step 2: Dockerfile 내용 확인**
```bash
cat /Users/gihyeon/github/cointrader/Dockerfile
```
Expected: 위 내용이 그대로 출력됨
**Step 3: Docker 빌드 테스트 (Docker가 설치된 경우)**
```bash
cd /Users/gihyeon/github/cointrader
docker build -t cointrader:test .
```
Expected: `Successfully built <image_id>` 또는 `Successfully tagged cointrader:test`
> Docker가 설치되지 않은 환경이라면 이 단계는 건너뛴다.
---
## Task 2: docker-compose.yml 작성
**Files:**
- Create: `docker-compose.yml`
**Step 1: docker-compose.yml 생성**
`/Users/gihyeon/github/cointrader/docker-compose.yml` 파일을 아래 내용으로 생성한다:
```yaml
services:
cointrader:
build: .
container_name: cointrader
restart: unless-stopped
env_file:
- .env
volumes:
- ./logs:/app/logs
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
```
**Step 2: docker-compose.yml 내용 확인**
```bash
cat /Users/gihyeon/github/cointrader/docker-compose.yml
```
Expected: 위 내용이 그대로 출력됨
**Step 3: docker-compose 문법 검증 (docker compose가 설치된 경우)**
```bash
cd /Users/gihyeon/github/cointrader
docker compose config
```
Expected: 파싱된 YAML 설정이 오류 없이 출력됨
---
## Task 3: .dockerignore 작성
**Files:**
- Create: `.dockerignore`
**Step 1: .dockerignore 생성**
`/Users/gihyeon/github/cointrader/.dockerignore` 파일을 아래 내용으로 생성한다:
```
.env
.venv
__pycache__
*.pyc
*.pyo
.pytest_cache
logs/
*.log
.git
docs/
tests/
```
> `.env`를 반드시 포함시켜 빌드 컨텍스트에서 제외한다. 이미지에 API 키가 포함되는 것을 방지한다.
**Step 2: .dockerignore 내용 확인**
```bash
cat /Users/gihyeon/github/cointrader/.dockerignore
```
Expected: 위 내용이 그대로 출력됨
---
## Task 4: git 커밋
**Files:**
- Modify: `Dockerfile` (신규)
- Modify: `docker-compose.yml` (신규)
- Modify: `.dockerignore` (신규)
**Step 1: git 상태 확인**
```bash
cd /Users/gihyeon/github/cointrader
git status
```
Expected: `Dockerfile`, `docker-compose.yml`, `.dockerignore`가 untracked files로 표시됨
**Step 2: 스테이징**
```bash
cd /Users/gihyeon/github/cointrader
git add Dockerfile docker-compose.yml .dockerignore
```
**Step 3: 스테이징 내용 검토 (`.env` 포함 여부 확인)**
```bash
git diff --cached --name-only
```
Expected:
```
.dockerignore
Dockerfile
docker-compose.yml
```
`.env`가 목록에 **없어야** 한다. 만약 있다면 즉시 `git reset HEAD .env` 실행 후 중단.
**Step 4: 커밋**
```bash
git commit -m "chore: add Dockerfile, docker-compose.yml, .dockerignore"
```
Expected: `main` 브랜치에 새 커밋 생성
**Step 5: 커밋 확인**
```bash
git log --oneline -3
```
Expected: 방금 만든 커밋이 최상단에 표시됨
---
## Task 5: Gitea push
> 이 Task는 Gitea 원격 저장소가 이미 설정되어 있다고 가정한다.
> 아직 설정하지 않았다면 `docs/plans/2026-03-01-upload-to-gitea.md`의 Task 2~3을 먼저 완료한다.
**Step 1: 현재 원격 저장소 확인**
```bash
cd /Users/gihyeon/github/cointrader
git remote -v
```
Expected:
```
origin http://10.1.10.28:3000/<사용자명>/cointrader.git (fetch)
origin http://10.1.10.28:3000/<사용자명>/cointrader.git (push)
```
origin이 없다면 아래 명령으로 추가 (`<사용자명>` 교체 필요):
```bash
git remote add origin http://10.1.10.28:3000/<사용자명>/cointrader.git
```
**Step 2: push**
```bash
git push origin main
```
> Gitea 계정의 사용자명과 비밀번호(또는 액세스 토큰)를 입력하라는 프롬프트가 나타남
Expected:
```
Enumerating objects: ...
Writing objects: 100% ...
```
**Step 3: push 결과 확인**
```bash
git log --oneline origin/main -3
```
Expected: 로컬 커밋 히스토리와 동일하게 표시됨
**Step 4: Gitea 웹 UI에서 파일 확인**
브라우저에서 `http://10.1.10.28:3000/<사용자명>/cointrader` 접속 후 다음 파일이 있는지 확인:
- `Dockerfile`
- `docker-compose.yml`
- `.dockerignore`
---
## 트러블슈팅
| 문제 | 원인 | 해결 |
|------|------|------|
| `docker build``gcc` 설치 실패 | 네트워크 문제 | `apt-get` 단계를 제거하고 빌드 재시도 (pandas-ta가 gcc 없이 설치되는지 확인) |
| `docker compose config` 오류 | YAML 들여쓰기 오류 | 탭 대신 스페이스 2칸 사용 여부 확인 |
| push 시 `Authentication failed` | 잘못된 계정 정보 | Gitea 웹 UI 로그인 테스트 후 동일 계정 사용 |
| push 시 `non-fast-forward` | 원격에 이미 다른 커밋 존재 | `git pull --rebase origin main` 후 재시도 |
| 컨테이너 실행 시 `.env` 없음 오류 | `.env` 파일 미생성 | `.env.example`을 복사하여 `.env` 생성 후 값 입력 |
---
## 참고: 컨테이너 실행 방법
```bash
# .env 파일 준비
cp .env.example .env
# .env 파일에 실제 API 키와 Discord Webhook URL 입력
# 빌드 및 백그라운드 실행
docker compose up -d --build
# 로그 확인
docker compose logs -f
# 중지
docker compose down
```

View File

@@ -0,0 +1,131 @@
# 동적 증거금 비율 설계
**날짜**: 2026-03-01
**목적**: 잔고의 50%를 증거금으로 사용하되, 잔고가 늘어날수록 비율이 선형으로 감소하는 안전한 포지션 크기 계산 도입
---
## 배경
- 현재 포지션 크기 계산: `risk_per_trade = 0.02` (잔고의 2%) × 레버리지 → 명목금액
- 현재 잔고 22 USDT 기준, 최소 명목금액(5 USDT) 보장 로직으로 5 USDT 포지션만 잡힘
- 목표: 잔고의 50%를 증거금으로 활용하여 실질적인 포지션 크기 확보
- 안전장치: 잔고가 늘수록 비율이 자동으로 줄어들어 과도한 노출 방지
---
## 아키텍처
### 데이터 흐름
```
bot.run()
└─ balance = await exchange.get_balance()
└─ risk.set_base_balance(balance) ← 봇 시작 시 1회
bot._open_position()
└─ balance = await exchange.get_balance()
└─ margin_ratio = risk.get_dynamic_margin_ratio(balance) ← 신규
└─ exchange.calculate_quantity(balance, price, leverage, margin_ratio)
```
### 비율 계산 공식
```
ratio = MAX_RATIO - (balance - base_balance) × DECAY_RATE
ratio = clamp(ratio, MIN_RATIO, MAX_RATIO)
```
- `base_balance`: 봇 시작 시 바이낸스 API로 조회한 실제 잔고
- `MAX_RATIO`: 잔고가 기준값일 때 최대 비율 (기본 50%)
- `MIN_RATIO`: 잔고가 아무리 늘어도 내려가지 않는 하한 비율 (기본 20%)
- `DECAY_RATE`: 잔고 1 USDT 증가당 비율 감소량 (기본 0.0006)
### 시뮬레이션 (기본 파라미터 기준)
| 잔고 | 증거금 비율 | 증거금 | 명목금액(×10배) |
|---|---|---|---|
| 22 USDT | 50.0% | 11.0 USDT | 110 USDT |
| 100 USDT | 45.3% | 45.3 USDT | 453 USDT |
| 300 USDT | 33.2% | 99.6 USDT | 996 USDT |
| 600 USDT | 20.0% (하한) | 120 USDT | 1,200 USDT |
---
## 변경 파일
### 1. `src/config.py`
`Config` 데이터클래스에 3개 파라미터 추가:
```python
margin_max_ratio: float = 0.50
margin_min_ratio: float = 0.20
margin_decay_rate: float = 0.0006
```
`__post_init__`에서 `.env` 값 읽기:
```python
self.margin_max_ratio = float(os.getenv("MARGIN_MAX_RATIO", "0.50"))
self.margin_min_ratio = float(os.getenv("MARGIN_MIN_RATIO", "0.20"))
self.margin_decay_rate = float(os.getenv("MARGIN_DECAY_RATE", "0.0006"))
```
### 2. `src/risk_manager.py`
메서드 2개 추가:
```python
def set_base_balance(self, balance: float) -> None:
"""봇 시작 시 기준 잔고 설정"""
self.initial_balance = balance
def get_dynamic_margin_ratio(self, balance: float) -> float:
"""잔고에 따라 선형 감소하는 증거금 비율 반환"""
ratio = self.config.margin_max_ratio - (
(balance - self.initial_balance) * self.config.margin_decay_rate
)
return max(self.config.margin_min_ratio, min(self.config.margin_max_ratio, ratio))
```
### 3. `src/exchange.py`
`calculate_quantity` 시그니처에 `margin_ratio` 파라미터 추가:
```python
def calculate_quantity(self, balance: float, price: float, leverage: int, margin_ratio: float) -> float:
notional = balance * margin_ratio * leverage
if notional < self.MIN_NOTIONAL:
notional = self.MIN_NOTIONAL
...
```
기존 `risk_per_trade` 기반 로직 제거.
### 4. `src/bot.py`
- `run()`: 시작 시 잔고 조회 후 `risk.set_base_balance(balance)` 호출
- `_open_position()`: `margin_ratio = self.risk.get_dynamic_margin_ratio(balance)` 호출 후 `calculate_quantity`에 전달
### 5. `.env`
```
MARGIN_MAX_RATIO=0.50
MARGIN_MIN_RATIO=0.20
MARGIN_DECAY_RATE=0.0006
```
---
## 제거되는 설정
- `RISK_PER_TRADE``.env``Config`에서 제거 (동적 비율로 대체)
---
## 리스크 고려사항
- 잔고 22 USDT × 50% × 10배 레버리지 = 명목금액 110 USDT 노출 (잔고의 5배)
- 손실 시 잔고가 줄어들면 다음 포지션 크기도 자동으로 줄어드는 자연스러운 안전장치 존재
- `MARGIN_DECAY_RATE` 조정으로 감소 속도 제어 가능

View File

@@ -0,0 +1,368 @@
# 동적 증거금 비율 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 잔고의 50%를 증거금으로 사용하되, 잔고가 늘수록 비율이 선형으로 감소하는 동적 포지션 크기 계산 도입
**Architecture:** `RiskManager``get_dynamic_margin_ratio(balance)` 메서드를 추가하고, `bot.py`에서 포지션 진입 전 호출한다. `exchange.py``calculate_quantity``margin_ratio` 파라미터를 받아 기존 `risk_per_trade` 로직을 대체한다. 봇 시작 시 바이낸스 API로 실제 잔고를 조회하여 기준값(`base_balance`)으로 저장한다.
**Tech Stack:** Python 3.11, python-binance, loguru, pytest, python-dotenv
---
## 사전 확인
- 현재 `.env`: `RISK_PER_TRADE=0.02` 존재
- 현재 `Config.risk_per_trade: float = 0.02` 존재
- 현재 `calculate_quantity``balance * risk_per_trade * leverage` 로직 사용
- 테스트 파일 위치: `tests/` 디렉토리 (없으면 생성)
---
### Task 1: Config에 동적 증거금 파라미터 추가
**Files:**
- Modify: `src/config.py`
- Modify: `.env`
**Step 1: `.env`에 새 파라미터 추가**
`.env` 파일 하단에 추가:
```
MARGIN_MAX_RATIO=0.50
MARGIN_MIN_RATIO=0.20
MARGIN_DECAY_RATE=0.0006
```
기존 `RISK_PER_TRADE=0.02` 줄은 삭제.
**Step 2: `src/config.py` 수정**
`Config` 데이터클래스에 필드 추가, `risk_per_trade` 필드 제거:
```python
@dataclass
class Config:
api_key: str = ""
api_secret: str = ""
symbol: str = "XRPUSDT"
leverage: int = 10
max_positions: int = 3
stop_loss_pct: float = 0.015
take_profit_pct: float = 0.045
trailing_stop_pct: float = 0.01
discord_webhook_url: str = ""
margin_max_ratio: float = 0.50
margin_min_ratio: float = 0.20
margin_decay_rate: float = 0.0006
def __post_init__(self):
self.api_key = os.getenv("BINANCE_API_KEY", "")
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
self.symbol = os.getenv("SYMBOL", "XRPUSDT")
self.leverage = int(os.getenv("LEVERAGE", "10"))
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
self.margin_max_ratio = float(os.getenv("MARGIN_MAX_RATIO", "0.50"))
self.margin_min_ratio = float(os.getenv("MARGIN_MIN_RATIO", "0.20"))
self.margin_decay_rate = float(os.getenv("MARGIN_DECAY_RATE", "0.0006"))
```
**Step 3: Commit**
```bash
git add src/config.py .env
git commit -m "feat: add dynamic margin ratio config params"
```
---
### Task 2: RiskManager에 동적 비율 메서드 추가
**Files:**
- Modify: `src/risk_manager.py`
- Create: `tests/test_risk_manager.py`
**Step 1: 실패하는 테스트 작성**
`tests/test_risk_manager.py` 생성:
```python
import pytest
from src.config import Config
from src.risk_manager import RiskManager
@pytest.fixture
def config():
c = Config()
c.margin_max_ratio = 0.50
c.margin_min_ratio = 0.20
c.margin_decay_rate = 0.0006
return c
@pytest.fixture
def risk(config):
r = RiskManager(config)
r.set_base_balance(22.0)
return r
def test_set_base_balance(risk):
assert risk.initial_balance == 22.0
def test_ratio_at_base_balance(risk):
"""기준 잔고에서 최대 비율(50%) 반환"""
ratio = risk.get_dynamic_margin_ratio(22.0)
assert ratio == pytest.approx(0.50, abs=1e-6)
def test_ratio_decreases_as_balance_grows(risk):
"""잔고가 늘수록 비율 감소"""
ratio_100 = risk.get_dynamic_margin_ratio(100.0)
ratio_300 = risk.get_dynamic_margin_ratio(300.0)
assert ratio_100 < 0.50
assert ratio_300 < ratio_100
def test_ratio_clamped_at_min(risk):
"""잔고가 매우 커도 최소 비율(20%) 이하로 내려가지 않음"""
ratio = risk.get_dynamic_margin_ratio(10000.0)
assert ratio == pytest.approx(0.20, abs=1e-6)
def test_ratio_clamped_at_max(risk):
"""잔고가 기준보다 작아도 최대 비율(50%) 초과하지 않음"""
ratio = risk.get_dynamic_margin_ratio(5.0)
assert ratio == pytest.approx(0.50, abs=1e-6)
```
**Step 2: 테스트 실패 확인**
```bash
pytest tests/test_risk_manager.py -v
```
Expected: `AttributeError: 'RiskManager' object has no attribute 'set_base_balance'`
**Step 3: `src/risk_manager.py` 수정**
기존 코드에 메서드 2개 추가:
```python
def set_base_balance(self, balance: float) -> None:
"""봇 시작 시 기준 잔고 설정 (동적 비율 계산 기준점)"""
self.initial_balance = balance
def get_dynamic_margin_ratio(self, balance: float) -> float:
"""잔고에 따라 선형 감소하는 증거금 비율 반환"""
ratio = self.config.margin_max_ratio - (
(balance - self.initial_balance) * self.config.margin_decay_rate
)
return max(self.config.margin_min_ratio, min(self.config.margin_max_ratio, ratio))
```
**Step 4: 테스트 통과 확인**
```bash
pytest tests/test_risk_manager.py -v
```
Expected: 5개 테스트 모두 PASS
**Step 5: Commit**
```bash
git add src/risk_manager.py tests/test_risk_manager.py
git commit -m "feat: add get_dynamic_margin_ratio to RiskManager"
```
---
### Task 3: exchange.py의 calculate_quantity 수정
**Files:**
- Modify: `src/exchange.py:18-29`
- Create: `tests/test_exchange.py`
**Step 1: 실패하는 테스트 작성**
`tests/test_exchange.py` 생성:
```python
import pytest
from unittest.mock import MagicMock
from src.config import Config
from src.exchange import BinanceFuturesClient
@pytest.fixture
def client():
config = Config()
config.leverage = 10
c = BinanceFuturesClient.__new__(BinanceFuturesClient)
c.config = config
return c
def test_calculate_quantity_basic(client):
"""잔고 22, 비율 50%, 레버리지 10배 → 명목금액 110, XRP 가격 2.5 → 수량 44.0"""
qty = client.calculate_quantity(balance=22.0, price=2.5, leverage=10, margin_ratio=0.50)
# 명목금액 = 22 * 0.5 * 10 = 110, 수량 = 110 / 2.5 = 44.0
assert qty == pytest.approx(44.0, abs=0.1)
def test_calculate_quantity_min_notional(client):
"""명목금액이 최소(5 USDT) 미만이면 최소값으로 올림"""
qty = client.calculate_quantity(balance=1.0, price=2.5, leverage=1, margin_ratio=0.01)
# 명목금액 = 1 * 0.01 * 1 = 0.01 < 5 → 최소 5 USDT
assert qty * 2.5 >= 5.0
def test_calculate_quantity_zero_balance(client):
"""잔고 0이면 최소 명목금액 기반 수량 반환"""
qty = client.calculate_quantity(balance=0.0, price=2.5, leverage=10, margin_ratio=0.50)
assert qty > 0
```
**Step 2: 테스트 실패 확인**
```bash
pytest tests/test_exchange.py -v
```
Expected: `TypeError: calculate_quantity() got an unexpected keyword argument 'margin_ratio'`
**Step 3: `src/exchange.py` 수정**
`calculate_quantity` 메서드를 아래로 교체:
```python
def calculate_quantity(self, balance: float, price: float, leverage: int, margin_ratio: float) -> float:
"""동적 증거금 비율 기반 포지션 크기 계산 (최소 명목금액 $5 보장)"""
notional = balance * margin_ratio * leverage
if notional < self.MIN_NOTIONAL:
notional = self.MIN_NOTIONAL
quantity = notional / price
qty_rounded = round(quantity, 1)
if qty_rounded * price < self.MIN_NOTIONAL:
qty_rounded = round(self.MIN_NOTIONAL / price + 0.05, 1)
return qty_rounded
```
**Step 4: 테스트 통과 확인**
```bash
pytest tests/test_exchange.py -v
```
Expected: 3개 테스트 모두 PASS
**Step 5: Commit**
```bash
git add src/exchange.py tests/test_exchange.py
git commit -m "feat: replace risk_per_trade with margin_ratio in calculate_quantity"
```
---
### Task 4: bot.py 연결
**Files:**
- Modify: `src/bot.py:85-99` (`_open_position`)
- Modify: `src/bot.py:165-172` (`run`)
**Step 1: `run()` 메서드에 `set_base_balance` 호출 추가**
`run()` 메서드를 아래로 교체:
```python
async def run(self):
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
await self._recover_position()
balance = await self.exchange.get_balance()
self.risk.set_base_balance(balance)
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
await self.stream.start(
api_key=self.config.api_key,
api_secret=self.config.api_secret,
)
```
**Step 2: `_open_position()` 메서드에 동적 비율 적용**
`_open_position()` 내부 `quantity` 계산 부분을 수정:
```python
async def _open_position(self, signal: str, df):
balance = await self.exchange.get_balance()
price = df["close"].iloc[-1]
margin_ratio = self.risk.get_dynamic_margin_ratio(balance)
quantity = self.exchange.calculate_quantity(
balance=balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio
)
logger.info(f"포지션 크기: 잔고={balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
# 이하 기존 코드 유지 (stop_loss, take_profit, place_order 등)
```
**Step 3: 전체 테스트 실행**
```bash
pytest tests/ -v
```
Expected: 전체 PASS
**Step 4: Commit**
```bash
git add src/bot.py
git commit -m "feat: apply dynamic margin ratio in bot position sizing"
```
---
### Task 5: 기존 risk_per_trade 참조 정리
**Files:**
- Search: 프로젝트 전체에서 `risk_per_trade` 참조 확인
**Step 1: 잔여 참조 검색**
```bash
grep -r "risk_per_trade" src/ tests/ .env
```
Expected: 결과 없음 (이미 모두 제거됨)
남아있는 경우 해당 파일에서 제거.
**Step 2: 전체 테스트 최종 확인**
```bash
pytest tests/ -v
```
Expected: 전체 PASS
**Step 3: Commit**
```bash
git add -A
git commit -m "chore: remove unused risk_per_trade references"
```
---
## 검증 체크리스트
- [ ] `pytest tests/test_risk_manager.py` — 5개 PASS
- [ ] `pytest tests/test_exchange.py` — 3개 PASS
- [ ] `pytest tests/` — 전체 PASS
- [ ] `.env``MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `MARGIN_DECAY_RATE` 존재
- [ ] `.env``RISK_PER_TRADE` 없음
- [ ] 봇 시작 로그에 "기준 잔고 설정: XX USDT" 출력
- [ ] 포지션 진입 로그에 "증거금비율=50.0%" 출력 (잔고 22 USDT 기준)

View File

@@ -0,0 +1,275 @@
# pandas-ta Python 버전 호환성 수정 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Jenkins CI에서 `pandas-ta==0.4.71b0`이 Python 3.11에서 설치 실패하는 문제를 해결한다.
**Architecture:** `pandas-ta==0.4.71b0`은 Python >=3.12를 요구하므로, Dockerfile의 베이스 이미지를 `python:3.11-slim`에서 `python:3.12-slim`으로 업그레이드한다. `requirements.txt`의 의존 패키지 버전도 Python 3.12와 호환되는 버전으로 정리한다.
**Tech Stack:** Docker, Python 3.12-slim, pandas-ta 0.4.71b0, python-binance 1.0.19
---
## 문제 분석
Jenkins 빌드 로그 오류:
```
ERROR: Ignored the following versions that require a different python version:
0.4.67b0 Requires-Python >=3.12; 0.4.71b0 Requires-Python >=3.12
ERROR: Could not find a version that satisfies the requirement pandas-ta==0.4.71b0
```
**원인:** `requirements.txt``pandas-ta==0.4.71b0`이 명시되어 있으나, Dockerfile 베이스 이미지가 `python:3.11-slim`이라 Python 3.12 이상을 요구하는 `pandas-ta`를 설치할 수 없다.
**해결 방향:** Dockerfile 베이스 이미지를 `python:3.12-slim`으로 변경한다.
---
## Task 1: Dockerfile 베이스 이미지 업그레이드
**Files:**
- Modify: `Dockerfile:1`
**Step 1: Dockerfile 수정**
`Dockerfile` 1번째 줄을 다음과 같이 변경한다:
변경 전:
```dockerfile
FROM python:3.11-slim
```
변경 후:
```dockerfile
FROM python:3.12-slim
```
**Step 2: 변경 내용 확인**
```bash
head -1 /Users/gihyeon/github/cointrader/Dockerfile
```
Expected:
```
FROM python:3.12-slim
```
---
## Task 2: requirements.txt 의존성 호환성 검토 및 수정
**Files:**
- Modify: `requirements.txt`
**Step 1: 현재 requirements.txt 내용 확인**
```bash
cat /Users/gihyeon/github/cointrader/requirements.txt
```
Expected (현재 내용):
```
python-binance==1.0.19
pandas>=2.2.0
pandas-ta==0.4.71b0
python-dotenv==1.0.0
httpx>=0.27.0
pytest>=8.1.0
pytest-asyncio>=0.24.0
aiohttp==3.9.3
websockets==12.0
loguru==0.7.2
```
**Step 2: pandas-ta 0.4.71b0의 의존성 확인**
PyPI 정보에 따르면 `pandas-ta==0.4.71b0`은 다음을 요구한다:
- `numba==0.61.2`
- `numpy>=2.2.6`
- `pandas>=2.3.2`
`requirements.txt``pandas>=2.2.0``pandas>=2.3.2`를 만족하므로 문제없다.
단, `numba`가 명시되어 있지 않아 pandas-ta 설치 시 자동으로 설치된다.
**Step 3: requirements.txt 수정 (pandas 최소 버전 상향)**
`pandas>=2.2.0``pandas>=2.3.2`로 변경하여 pandas-ta의 요구사항을 명시적으로 반영한다:
변경 전:
```
pandas>=2.2.0
```
변경 후:
```
pandas>=2.3.2
```
**Step 4: 변경 내용 확인**
```bash
grep "pandas" /Users/gihyeon/github/cointrader/requirements.txt
```
Expected:
```
pandas>=2.3.2
pandas-ta==0.4.71b0
```
---
## Task 3: 로컬 Docker 빌드 테스트
> Docker가 설치된 환경에서만 실행한다.
**Step 1: Docker 빌드**
```bash
cd /Users/gihyeon/github/cointrader
docker build -t cointrader:test .
```
Expected: 빌드 성공 (`Successfully tagged cointrader:test` 또는 `#N DONE`)
**Step 2: 빌드된 이미지의 Python 버전 확인**
```bash
docker run --rm cointrader:test python --version
```
Expected:
```
Python 3.12.x
```
**Step 3: pandas-ta import 확인**
```bash
docker run --rm cointrader:test python -c "import pandas_ta; print(pandas_ta.__version__)"
```
Expected:
```
0.4.71b0
```
**Step 4: 테스트 이미지 정리**
```bash
docker rmi cointrader:test
```
---
## Task 4: git 커밋 및 Gitea push
**Files:**
- Modify: `Dockerfile`
- Modify: `requirements.txt`
**Step 1: git 상태 확인**
```bash
cd /Users/gihyeon/github/cointrader
git status
```
Expected:
```
modified: Dockerfile
modified: requirements.txt
```
**Step 2: 변경 내용 검토**
```bash
git diff Dockerfile requirements.txt
```
Expected:
- `Dockerfile`: `-FROM python:3.11-slim``+FROM python:3.12-slim`
- `requirements.txt`: `-pandas>=2.2.0``+pandas>=2.3.2`
**Step 3: 스테이징**
```bash
git add Dockerfile requirements.txt
```
**Step 4: 커밋**
```bash
git commit -m "fix: upgrade to Python 3.12 to support pandas-ta>=0.4.67b0"
```
Expected: 커밋 성공
**Step 5: Gitea push**
```bash
git push origin main
```
Expected: push 성공 후 Jenkins가 자동으로 새 빌드를 트리거함
**Step 6: 커밋 확인**
```bash
git log --oneline -3
```
Expected: 방금 만든 커밋이 최상단에 표시됨
---
## Task 5: Jenkins 빌드 재실행 및 결과 확인
**Step 1: Jenkins 빌드 트리거**
Gitea push 후 Jenkins Webhook이 설정되어 있다면 자동으로 빌드가 트리거된다.
수동으로 트리거하려면 Jenkins 웹 UI에서 `cointrader` 파이프라인 → `Build Now` 클릭.
**Step 2: 빌드 로그에서 성공 확인**
Jenkins 빌드 로그에서 다음 내용이 나타나야 한다:
```
#9 [5/7] RUN pip install --no-cache-dir -r requirements.txt
...
Successfully installed pandas-ta-0.4.71b0 ...
#9 DONE xx.xs
```
오류 없이 `[Build Docker Image]` 스테이지가 완료되어야 한다.
**Step 3: 전체 파이프라인 성공 확인**
Jenkins 빌드 결과가 `SUCCESS`로 표시되어야 한다:
```
Finished: SUCCESS
```
---
## 트러블슈팅
| 문제 | 원인 | 해결 |
|------|------|------|
| `python-binance==1.0.19` 설치 실패 | Python 3.12 비호환 | `python-binance>=1.0.19`로 변경하거나 최신 버전 확인 |
| `aiohttp==3.9.3` 설치 실패 | Python 3.12 비호환 | `aiohttp>=3.9.3`으로 완화하거나 최신 버전으로 업그레이드 |
| `numba` 설치 시간 초과 | numba 컴파일 시간 | 빌드 타임아웃 설정 증가 또는 `--timeout=300` 추가 |
| Jenkins Webhook 미동작 | Gitea Webhook 미설정 | Gitea 저장소 설정 → Webhooks → Jenkins URL 추가 |
---
## 참고: Python 3.12 호환성 체크리스트
Python 3.11 → 3.12 주요 변경사항 중 이 프로젝트에 영향 가능한 항목:
- `asyncio` 동작 변경: `asyncio.get_event_loop()` deprecated → `asyncio.get_running_loop()` 권장
- `typing` 모듈 일부 변경: `Union[X, Y]``X | Y` 문법 지원 강화
- `datetime.utcnow()` deprecated → `datetime.now(timezone.utc)` 권장
현재 코드베이스(`src/`, `tests/`)에서 위 패턴 사용 여부를 확인하고 필요 시 수정한다.

View File

@@ -0,0 +1,405 @@
# Jenkins + Gitea 이미지 레지스트리 CI/CD 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Jenkins가 Gitea(10.1.10.28:3000)의 코드 변경을 감지하면 Docker 이미지를 빌드하여 Gitea Container Registry(10.1.10.28:5000 또는 Gitea 내장 패키지 레지스트리)에 push하고, docker-compose.yml이 해당 이미지를 pull해서 실행하도록 전체 CI/CD 파이프라인을 구성한다.
**Architecture:**
- Jenkins는 Gitea webhook을 통해 main 브랜치 push 이벤트를 수신한다.
- Jenkinsfile(파이프라인 스크립트)이 프로젝트 루트에 위치하며, `docker build → docker push → (선택) 원격 배포` 단계를 수행한다.
- Gitea의 내장 Container Registry(Packages)를 이미지 저장소로 사용한다. 이미지 이름 형식: `10.1.10.28:3000/gihyeon/cointrader:<tag>`
- docker-compose.yml은 `build: .` 대신 레지스트리 이미지를 직접 참조하도록 수정한다.
**Tech Stack:** Jenkins, Gitea Container Registry, Docker, docker-compose v2, Jenkinsfile(Declarative Pipeline)
---
## 사전 확인 사항
- Gitea 서버: `http://10.1.10.28:3000`
- Gitea 저장소: `http://10.1.10.28:3000/gihyeon/cointrader.git`
- Gitea Container Registry 주소: `10.1.10.28:3000` (HTTP 사용 시 Docker insecure-registries 설정 필요)
- Jenkins 서버 주소: 별도 확인 필요 (아래 Task 1에서 확인)
- 현재 Dockerfile: `FROM python:3.12-slim` 기반, `/app`에서 `python main.py` 실행
---
## Task 1: 환경 사전 점검
**Files:**
- 확인: `Dockerfile`
- 확인: `docker-compose.yml`
**Step 1: Gitea Container Registry(Packages) 활성화 확인**
브라우저에서 `http://10.1.10.28:3000/gihyeon/cointrader/packages` 접속.
- 패키지 탭이 보이면 활성화된 것.
- 안 보이면 Gitea 관리자 패널 → `Site Administration``Configuration``Enable Packages` 체크 필요.
**Step 2: Gitea Access Token 생성 (Jenkins용)**
`http://10.1.10.28:3000/user/settings/applications` 접속:
- Token Name: `jenkins-cointrader`
- 권한: `read:packages`, `write:packages` (또는 전체 권한)
- `Generate Token` 클릭 후 **토큰 값을 반드시 복사** (다시 볼 수 없음)
**Step 3: Docker insecure-registries 설정 (HTTP 레지스트리 사용 시)**
Jenkins가 실행되는 서버(또는 로컬 Mac)에서:
```bash
# /etc/docker/daemon.json 또는 Docker Desktop의 경우 Settings > Docker Engine
cat /etc/docker/daemon.json
```
아래 내용이 없으면 추가:
```json
{
"insecure-registries": ["10.1.10.28:3000"]
}
```
Docker Desktop 사용 시: `Settings``Docker Engine` → JSON에 위 내용 병합 → `Apply & Restart`
**Step 4: Docker login 테스트**
```bash
docker login 10.1.10.28:3000 -u gihyeon -p <위에서_생성한_토큰>
```
Expected:
```
Login Succeeded
```
---
## Task 2: Jenkinsfile 작성
**Files:**
- Create: `Jenkinsfile`
**Step 1: Jenkinsfile 생성**
`/Users/gihyeon/github/cointrader/Jenkinsfile` 파일을 아래 내용으로 생성:
```groovy
pipeline {
agent any
environment {
REGISTRY = '10.1.10.28:3000'
IMAGE_NAME = 'gihyeon/cointrader'
IMAGE_TAG = "${env.BUILD_NUMBER}"
FULL_IMAGE = "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
LATEST_IMAGE = "${REGISTRY}/${IMAGE_NAME}:latest"
GITEA_CREDS = credentials('gitea-registry-credentials')
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build Image') {
steps {
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
}
}
stage('Push to Gitea Registry') {
steps {
sh """
echo ${GITEA_CREDS_PSW} | docker login ${REGISTRY} -u ${GITEA_CREDS_USR} --password-stdin
docker push ${FULL_IMAGE}
docker push ${LATEST_IMAGE}
"""
}
}
stage('Cleanup') {
steps {
sh """
docker rmi ${FULL_IMAGE} || true
docker rmi ${LATEST_IMAGE} || true
"""
}
}
}
post {
success {
echo "Build #${env.BUILD_NUMBER} pushed: ${FULL_IMAGE}"
}
failure {
echo "Build #${env.BUILD_NUMBER} FAILED"
}
}
}
```
> **참고:**
> - `GITEA_CREDS`는 Jenkins Credentials에 등록할 Username+Password 자격증명 ID다 (Task 3에서 등록).
> - `IMAGE_TAG`는 Jenkins 빌드 번호를 사용한다. 태그 전략을 git 커밋 해시로 바꾸려면 `"${env.GIT_COMMIT[0..7]}"` 사용.
> - `Cleanup` 스테이지는 Jenkins 서버 디스크 절약을 위해 빌드 후 로컬 이미지를 삭제한다.
**Step 2: Jenkinsfile 내용 확인**
```bash
cat /Users/gihyeon/github/cointrader/Jenkinsfile
```
Expected: 위 내용이 출력됨
---
## Task 3: Jenkins에 Gitea Credentials 등록
**Step 1: Jenkins 웹 UI 접속**
`http://<jenkins-서버-주소>:8080` 접속 (Jenkins 서버 주소 확인 필요)
**Step 2: Credentials 등록**
`Jenkins``Manage Jenkins``Credentials``System``Global credentials``Add Credentials`:
| 항목 | 값 |
|------|----|
| Kind | Username with password |
| Scope | Global |
| Username | `gihyeon` |
| Password | Task 1 Step 2에서 생성한 Gitea Access Token |
| ID | `gitea-registry-credentials` |
| Description | Gitea Container Registry for cointrader |
`Create` 클릭
**Step 3: 등록 확인**
Credentials 목록에 `gitea-registry-credentials`가 표시되는지 확인
---
## Task 4: Jenkins Pipeline Job 생성
**Step 1: 새 Pipeline Job 생성**
`Jenkins``New Item`:
- Item name: `cointrader`
- Type: `Pipeline`
- `OK` 클릭
**Step 2: Pipeline 설정**
`Pipeline` 섹션에서:
- Definition: `Pipeline script from SCM`
- SCM: `Git`
- Repository URL: `http://10.1.10.28:3000/gihyeon/cointrader.git`
- Credentials: (Gitea 저장소 접근용 credentials 추가, 없으면 Task 3과 동일하게 추가)
- Branch Specifier: `*/main`
- Script Path: `Jenkinsfile`
`Save` 클릭
**Step 3: 수동 빌드 테스트**
`Build Now` 클릭 → Console Output 확인
Expected:
```
[Pipeline] stage: Build Image
Successfully built ...
[Pipeline] stage: Push to Gitea Registry
Login Succeeded
The push refers to repository [10.1.10.28:3000/gihyeon/cointrader]
...
latest: digest: sha256:... size: ...
[Pipeline] stage: Cleanup
Finished: SUCCESS
```
---
## Task 5: Gitea Webhook 설정 (자동 트리거)
**Step 1: Gitea 저장소 Webhook 추가**
`http://10.1.10.28:3000/gihyeon/cointrader/settings/hooks` 접속:
- `Add Webhook``Gitea`
- Target URL: `http://<jenkins-서버-주소>:8080/gitea-webhook/post`
- Jenkins Gitea Plugin 사용 시 위 URL 형식
- 일반 Generic Webhook 사용 시: `http://<jenkins-서버-주소>:8080/job/cointrader/build?token=<토큰>`
- Trigger: `Push Events`
- Branch filter: `main`
- `Add Webhook` 클릭
**Step 2: Jenkins에 Gitea Plugin 설치 (미설치 시)**
`Manage Jenkins``Plugins``Available plugins``Gitea` 검색 → 설치 후 재시작
**Step 3: Webhook 테스트**
Gitea Webhook 설정 페이지에서 `Test Delivery` 클릭
Expected: Jenkins에서 새 빌드가 자동으로 시작됨
---
## Task 6: docker-compose.yml 수정
**Files:**
- Modify: `docker-compose.yml`
현재 `docker-compose.yml``build: .`으로 로컬 빌드를 사용한다. 이를 레지스트리 이미지를 pull해서 실행하도록 변경한다.
**Step 1: docker-compose.yml 수정**
`/Users/gihyeon/github/cointrader/docker-compose.yml`을 아래 내용으로 교체:
```yaml
services:
cointrader:
image: 10.1.10.28:3000/gihyeon/cointrader:latest
container_name: cointrader
restart: unless-stopped
env_file:
- .env
volumes:
- ./logs:/app/logs
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
```
> **변경 사항:**
> - `build: .` → `image: 10.1.10.28:3000/gihyeon/cointrader:latest`
> - 이제 `docker compose up -d`를 실행하면 로컬 빌드 없이 레지스트리에서 이미지를 pull한다.
> - 배포 서버에서 최신 이미지로 업데이트하려면: `docker compose pull && docker compose up -d`
**Step 2: 변경 내용 확인**
```bash
cat /Users/gihyeon/github/cointrader/docker-compose.yml
```
Expected: `image:` 필드가 레지스트리 주소를 가리킴
**Step 3: (선택) 로컬 개발용 docker-compose.override.yml 생성**
로컬에서 소스 코드를 직접 빌드해서 테스트하고 싶을 때를 위한 override 파일:
```yaml
# docker-compose.override.yml (로컬 개발 전용, git에 포함하지 않아도 됨)
services:
cointrader:
build: .
image: cointrader:local
```
이 파일이 있으면 `docker compose up -d`가 자동으로 `build: .`을 사용한다. 프로덕션 서버에는 이 파일을 두지 않는다.
---
## Task 7: 변경사항 커밋 및 Push
**Step 1: 변경 파일 확인**
```bash
cd /Users/gihyeon/github/cointrader
git status
```
Expected: `Jenkinsfile`(new), `docker-compose.yml`(modified)이 표시됨
**Step 2: 스테이징**
```bash
git add Jenkinsfile docker-compose.yml
```
**Step 3: `.env` 미포함 확인**
```bash
git diff --cached --name-only
```
Expected: `Jenkinsfile`, `docker-compose.yml` 두 파일만 표시됨
**Step 4: 커밋**
```bash
git commit -m "ci: Jenkins pipeline + Gitea registry CI/CD 설정"
```
Expected: `main` 브랜치에 새 커밋 생성
**Step 5: Gitea에 Push**
```bash
git push origin main
```
Expected: Push 성공 + (Webhook 설정 완료 시) Jenkins 빌드 자동 시작
---
## Task 8: 엔드-투-엔드 검증
**Step 1: 코드 변경 후 push 테스트**
```bash
cd /Users/gihyeon/github/cointrader
# 아무 파일이나 사소하게 변경 (예: README 한 줄 추가)
echo "# CI/CD test" >> README.md
git add README.md
git commit -m "test: CI/CD 파이프라인 검증용 더미 커밋"
git push origin main
```
**Step 2: Jenkins 빌드 자동 시작 확인**
Jenkins UI에서 `cointrader` 잡의 빌드가 자동으로 시작되는지 확인 (30초 이내)
**Step 3: Gitea 레지스트리에 이미지 push 확인**
`http://10.1.10.28:3000/gihyeon/cointrader/packages` 접속 → `cointrader` 컨테이너 패키지에 새 태그가 생성되었는지 확인
**Step 4: 이미지 pull 테스트**
```bash
docker pull 10.1.10.28:3000/gihyeon/cointrader:latest
```
Expected:
```
latest: Pulling from gihyeon/cointrader
...
Status: Downloaded newer image for 10.1.10.28:3000/gihyeon/cointrader:latest
```
**Step 5: docker compose로 실행 테스트**
```bash
cd /Users/gihyeon/github/cointrader
docker compose up -d
docker compose logs -f --tail=20
```
Expected: 컨테이너가 정상 시작되고 로그가 출력됨
---
## 트러블슈팅
| 문제 | 원인 | 해결 |
|------|------|------|
| `http: server gave HTTP response to HTTPS client` | Docker가 HTTPS로 레지스트리 접근 시도 | `daemon.json``insecure-registries` 추가 후 Docker 재시작 |
| `unauthorized: authentication required` | Credentials 미등록 또는 토큰 만료 | Task 1 Step 2에서 새 토큰 발급 후 Jenkins Credentials 업데이트 |
| `connection refused` to Jenkins | Jenkins URL 오타 또는 방화벽 | Jenkins 서버 주소 재확인 |
| Webhook이 Jenkins를 트리거하지 않음 | Jenkins URL이 Gitea 서버에서 접근 불가 | Jenkins가 Gitea 서버와 같은 네트워크에 있는지 확인, 방화벽 8080 포트 오픈 |
| `image not found` on docker compose pull | 이미지가 아직 push되지 않음 | Jenkins 빌드 완료 후 재시도 |
| Jenkins에서 `docker: command not found` | Jenkins 에이전트에 Docker 미설치 | Jenkins 서버에 Docker 설치 또는 Docker-in-Docker 설정 |

View File

@@ -0,0 +1,251 @@
# LightGBM 예측력 개선 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 현재 AUC 0.54 수준의 LightGBM 모델을 피처 정규화 + 강한 시간 가중치 + Walk-Forward 검증 세 가지를 순서대로 적용해 AUC 0.57+ 로 끌어올린다.
**Architecture:**
- `src/dataset_builder.py`에 rolling z-score 정규화를 추가해 레짐 변화에 강한 피처를 만든다.
- `scripts/train_model.py`에 Walk-Forward 검증 루프를 추가해 실제 예측력을 정확히 측정한다.
- 1년치 `combined_1m.parquet` 데이터를 decay=4.0 이상의 강한 시간 가중치로 학습해 샘플 수와 최신성을 동시에 확보한다.
**Tech Stack:** LightGBM, pandas, numpy, scikit-learn, Python 3.13
---
## 배경: 현재 문제 진단 결과
| 데이터 | 구간별 독립 AUC | 전체 80/20 AUC |
|--------|----------------|----------------|
| combined 1년 | 0.49~0.51 (전 구간 동일) | 0.49 |
| xrpusdt 3개월 | 0.49~0.58 (구간 편차 큼) | 0.54 |
**핵심 원인 두 가지:**
1. `xrp_btc_rs` 같은 절대값 피처가 Q1=0.86 → Q4=3.68로 4배 변동 → 모델이 스케일 변화에 혼란
2. 학습셋(과거)이 검증셋(최근)을 설명 못 함 → Walk-Forward로 실제 예측력 측정 필요
---
## Task 1: 피처 정규화 개선 (rolling z-score)
**Files:**
- Modify: `src/dataset_builder.py``_calc_features_vectorized()` 함수 내부
**목표:** 절대값 피처(`atr_pct`, `vol_ratio`, `xrp_btc_rs`, `xrp_eth_rs`, `ret_1/3/5`, `btc_ret_1/3/5`, `eth_ret_1/3/5`)를 rolling 200 window z-score로 정규화해서 레짐 변화에 무관하게 만든다.
**Step 1: 정규화 헬퍼 함수 추가**
`_calc_features_vectorized()` 함수 시작 부분에 추가:
```python
def _rolling_zscore(arr: np.ndarray, window: int = 200) -> np.ndarray:
"""rolling window z-score 정규화. window 미만 구간은 0으로 채운다."""
s = pd.Series(arr)
mean = s.rolling(window, min_periods=window).mean()
std = s.rolling(window, min_periods=window).std()
z = (s - mean) / std.replace(0, np.nan)
return z.fillna(0).values.astype(np.float32)
```
**Step 2: 절대값 피처에 정규화 적용**
`result` DataFrame 생성 시 다음 피처를 정규화 버전으로 교체:
```python
# 기존
"atr_pct": atr_pct.astype(np.float32),
"vol_ratio": vol_ratio.astype(np.float32),
"ret_1": ret_1.astype(np.float32),
"ret_3": ret_3.astype(np.float32),
"ret_5": ret_5.astype(np.float32),
# 변경 후
"atr_pct": _rolling_zscore(atr_pct),
"vol_ratio": _rolling_zscore(vol_ratio),
"ret_1": _rolling_zscore(ret_1),
"ret_3": _rolling_zscore(ret_3),
"ret_5": _rolling_zscore(ret_5),
```
BTC/ETH 피처도 동일하게:
```python
"btc_ret_1": _rolling_zscore(btc_r1), "btc_ret_3": _rolling_zscore(btc_r3), ...
"xrp_btc_rs": _rolling_zscore(xrp_btc_rs), "xrp_eth_rs": _rolling_zscore(xrp_eth_rs),
```
**Step 3: 검증**
```bash
cd /Users/gihyeon/github/cointrader
.venv/bin/python -c "
from src.dataset_builder import generate_dataset_vectorized
import pandas as pd
df = pd.read_parquet('data/combined_1m.parquet')
base = ['open','high','low','close','volume']
btc = df[[c+'_btc' for c in base]].copy(); btc.columns = base
eth = df[[c+'_eth' for c in base]].copy(); eth.columns = base
ds = generate_dataset_vectorized(df[base].copy(), btc_df=btc, eth_df=eth, time_weight_decay=0)
print(ds[['atr_pct','vol_ratio','xrp_btc_rs']].describe())
"
```
기대 결과: `atr_pct`, `vol_ratio`, `xrp_btc_rs` 모두 mean≈0, std≈1 범위
---
## Task 2: Walk-Forward 검증 함수 추가
**Files:**
- Modify: `scripts/train_model.py``train()` 함수 뒤에 `walk_forward_auc()` 함수 추가 및 `main()``--wf` 플래그 추가
**목표:** 시계열 순서를 지키면서 n_splits번 학습/검증을 반복해 실제 미래 예측력의 평균 AUC를 측정한다.
**Step 1: walk_forward_auc 함수 추가**
`train()` 함수 바로 아래에 추가:
```python
def walk_forward_auc(
data_path: str,
time_weight_decay: float = 2.0,
n_splits: int = 5,
train_ratio: float = 0.6,
) -> None:
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복."""
import warnings
from sklearn.metrics import roc_auc_score
print(f"\n=== Walk-Forward 검증 ({n_splits}폴드) ===")
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)
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
X = dataset[actual_feature_cols].values
y = dataset["label"].values
w = dataset["sample_weight"].values
n = len(dataset)
step = int(n * (1 - train_ratio) / n_splits)
train_end_start = int(n * train_ratio)
aucs = []
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]
pos_idx = np.where(y_tr == 1)[0]
neg_idx = np.where(y_tr == 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)
idx = np.sort(np.concatenate([pos_idx, neg_idx]))
model = lgb.LGBMClassifier(
n_estimators=500, learning_rate=0.05, num_leaves=31,
min_child_samples=15, subsample=0.8, colsample_bytree=0.8,
reg_alpha=0.05, reg_lambda=0.1, 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]
if len(np.unique(y_val)) < 2:
auc = 0.5
else:
auc = roc_auc_score(y_val, proba)
aucs.append(auc)
print(f" 폴드 {i+1}/{n_splits}: 학습={tr_end}, 검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f}")
print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}")
print(f" 폴드별: {[round(a,4) for a in aucs]}")
```
**Step 2: main()에 --wf 플래그 추가**
```python
parser.add_argument("--wf", action="store_true", help="Walk-Forward 검증 실행")
parser.add_argument("--wf-splits", type=int, default=5)
# args 처리 부분
if args.wf:
walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits)
else:
train(args.data, time_weight_decay=args.decay)
```
**Step 3: 검증 실행**
```bash
# xrpusdt 3개월 Walk-Forward
.venv/bin/python scripts/train_model.py --data data/xrpusdt_1m.parquet --decay 2.0 --wf
# combined 1년 Walk-Forward
.venv/bin/python scripts/train_model.py --data data/combined_1m.parquet --decay 2.0 --wf
```
기대 결과: 폴드별 AUC가 0.50~0.58 범위, 평균 0.52+
---
## Task 3: 강한 시간 가중치 + 1년 데이터 최적화
**Files:**
- Modify: `scripts/train_model.py``train()` 함수 내 `--decay` 기본값 및 권장값 주석
**목표:** `combined_1m.parquet`에서 decay=4.0~5.0으로 최근 3개월에 집중하되 1년치 패턴도 참고한다.
**Step 1: decay 값별 AUC 비교 스크립트 실행**
```bash
for decay in 1.0 2.0 3.0 4.0 5.0; do
echo "=== decay=$decay ==="
.venv/bin/python scripts/train_model.py --data data/combined_1m.parquet --decay $decay --wf --wf-splits 3 2>&1 | grep "Walk-Forward 평균"
done
```
**Step 2: 최적 decay 값으로 최종 학습**
Walk-Forward 평균 AUC가 가장 높은 decay 값으로:
```bash
.venv/bin/python scripts/train_model.py --data data/combined_1m.parquet --decay <최적값>
```
**Step 3: 결과 확인**
```bash
.venv/bin/python -c "import json; log=json.load(open('models/training_log.json')); [print(e) for e in log[-3:]]"
```
---
## 예상 결과
| 개선 단계 | 예상 AUC |
|-----------|---------|
| 현재 (3개월, 기본) | 0.54 |
| + rolling z-score 정규화 | 0.54~0.56 |
| + Walk-Forward로 정확한 측정 | 측정 정확도 향상 |
| + decay=4.0, 1년 데이터 | 0.55~0.58 |
---
## 주의사항
- `_rolling_zscore``dataset_builder.py` 내부에서만 사용 (실시간 봇 경로 `ml_features.py`는 건드리지 않음)
- Walk-Forward는 `--wf` 플래그로만 실행, 기본 `train()`은 그대로 유지
- rolling window=200은 약 3~4시간치 1분봉 → 단기 레짐 변화 반영

View File

@@ -0,0 +1,635 @@
# M4 Mac Mini 가속 학습 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** M4 맥미니의 GPU(Metal/MPS)를 활용해 모델 학습 속도를 높이고, Neural Engine 활용 가능 여부를 검토한다.
**Architecture:** 현재 LightGBM CPU 학습 파이프라인을 유지하면서, 데이터셋 생성 단계(병렬 CPU 연산)와 LightGBM 학습 단계를 각각 최적화한다. LightGBM은 Apple Silicon GPU를 공식 지원하지 않으므로, (1) MetalGBM 실험적 대체, (2) PyTorch MPS 기반 신경망 필터 추가, (3) 현재 CPU 파이프라인 최적화 세 가지 경로를 단계별로 시도한다.
**Tech Stack:** Python 3.13, LightGBM 4.6, MetalGBM(실험), PyTorch(MPS), Apple MLX, scikit-learn 1.8
---
## 배경 및 제약사항 분석
### M4 맥미니 하드웨어 구조
- **CPU**: 10코어 (P코어 4 + E코어 6)
- **GPU**: 10코어 통합 GPU (Metal 지원)
- **Neural Engine (NPU)**: 38 TOPS — 행렬 연산 특화, Apple 전용 API로만 접근 가능
- **통합 메모리**: CPU/GPU/NPU가 동일 메모리 공유 → 데이터 복사 오버헤드 없음
### 현재 학습 파이프라인 병목 분석
```
[1단계] 데이터셋 생성: multiprocessing.Pool → CPU 병렬
- _process_index(): 각 캔들에서 Indicators 계산 + 피처 추출
- 약 129,000개 인덱스 처리 (90일 × 1440분)
- 현재 병목: Python GIL 우회는 됐지만 pickle 직렬화 오버헤드 큼
[2단계] LightGBM 학습: CPU 전용
- n_estimators=300, 샘플 수 ~수천 개
- 실제 학습 시간은 짧음 (수초~수십초)
- GPU 가속 효과 미미할 가능성 높음
```
### 각 가속 방법의 현실적 평가
| 방법 | 효과 | 난이도 | 권장 여부 |
|------|------|--------|-----------|
| Neural Engine 직접 사용 | ❌ 불가 (Apple 내부 전용) | - | 불가 |
| LightGBM GPU (Metal) | ❌ 공식 미지원 | 높음 | 비권장 |
| MetalGBM | ⚠️ 실험적 (2025.11 신생) | 중간 | 실험 가능 |
| PyTorch MPS 신경망 | ✅ 가능, 소규모 모델은 CPU보다 느릴 수 있음 | 중간 | 조건부 권장 |
| Apple MLX 신경망 | ✅ Apple Silicon 최적화 | 중간 | 권장 |
| CPU 파이프라인 최적화 | ✅ 즉각 효과 | 낮음 | **최우선 권장** |
> **핵심 결론**: 현재 학습 샘플 수(수천 개)와 피처 수(13개)에서는 LightGBM 자체 학습 시간이 매우 짧다. 실제 병목은 **데이터셋 생성(1단계)** 이며, 이를 먼저 최적화하는 것이 가장 효과적이다. GPU/NPU 가속은 신경망 모델로 전환 시 의미가 있다.
---
## Task 1: 현재 학습 시간 프로파일링
**Files:**
- Create: `scripts/profile_training.py`
**Step 1: 프로파일링 스크립트 작성**
```python
"""
학습 파이프라인 각 단계의 소요 시간을 측정한다.
사용법: python scripts/profile_training.py --data data/xrpusdt_1m.parquet
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import time
import argparse
import pandas as pd
from scripts.train_model import generate_dataset, _cgroup_cpu_count
def profile(data_path: str):
print(f"데이터 로드: {data_path}")
df = pd.read_parquet(data_path)
print(f"캔들 수: {len(df)}")
workers = max(1, _cgroup_cpu_count() - 1)
print(f"사용 코어: {workers}")
t0 = time.perf_counter()
dataset = generate_dataset(df)
t1 = time.perf_counter()
print(f"\n[결과] 데이터셋 생성: {t1-t0:.1f}초, 샘플 {len(dataset)}")
import lightgbm as lgb
from sklearn.model_selection import train_test_split
from src.ml_features import FEATURE_COLS
X = dataset[FEATURE_COLS]
y = dataset["label"]
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)
model = lgb.LGBMClassifier(
n_estimators=300, learning_rate=0.05, num_leaves=31,
min_child_samples=20, subsample=0.8, colsample_bytree=0.8,
class_weight="balanced", random_state=42, verbose=-1,
)
t2 = time.perf_counter()
model.fit(X_train, y_train)
t3 = time.perf_counter()
print(f"[결과] LightGBM 학습: {t3-t2:.1f}")
print(f"[결과] 전체: {t3-t0:.1f}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--data", default="data/xrpusdt_1m.parquet")
args = parser.parse_args()
profile(args.data)
```
**Step 2: 프로파일링 실행**
```bash
python scripts/profile_training.py --data data/xrpusdt_1m.parquet
```
예상 출력:
```
[결과] 데이터셋 생성: XX.X초, 샘플 XXXX개
[결과] LightGBM 학습: X.X초
[결과] 전체: XX.X초
```
→ 데이터셋 생성이 전체의 90% 이상을 차지하면 Task 2로 진행
→ LightGBM 학습이 병목이면 Task 4(MetalGBM)로 진행
**Step 3: 커밋**
```bash
git add scripts/profile_training.py
git commit -m "feat: add training pipeline profiler"
```
---
## Task 2: 데이터셋 생성 최적화 (CPU, 즉각 효과)
현재 `multiprocessing.Pool`의 pickle 직렬화 오버헤드를 줄이고, numpy 벡터화로 대체한다.
**Files:**
- Modify: `scripts/train_model.py`
- Create: `tests/test_train_model_perf.py`
**Step 1: 실패 테스트 작성**
```python
# tests/test_train_model_perf.py
import time
import pandas as pd
import pytest
from scripts.train_model import generate_dataset
@pytest.fixture
def sample_df():
return pd.read_parquet("data/xrpusdt_1m.parquet").iloc[:5000]
def test_dataset_generation_speed(sample_df):
"""5000개 캔들에서 데이터셋 생성이 30초 이내여야 한다."""
t0 = time.perf_counter()
dataset = generate_dataset(sample_df)
elapsed = time.perf_counter() - t0
assert elapsed < 30.0, f"너무 느림: {elapsed:.1f}"
assert len(dataset) > 0
```
**Step 2: 테스트 실행 (실패 확인)**
```bash
pytest tests/test_train_model_perf.py -v
```
**Step 3: `train_model.py`에 `n_jobs` 자동 감지 개선 및 chunksize 튜닝**
`scripts/train_model.py``generate_dataset` 함수에서:
```python
# 기존
workers = n_jobs or max(1, _cgroup_cpu_count() - 1)
chunk = max(1, len(task_args) // (workers * 10))
# 변경: M4의 P코어/E코어 혼합을 고려해 worker 수를 P코어 수로 제한
# M4 mini: 4 P코어 + 6 E코어 = 10코어. 실제 병렬 처리는 P코어 기준이 효율적
workers = n_jobs or min(max(1, _cgroup_cpu_count() - 1), 8)
# chunksize를 크게 잡아 IPC 오버헤드 감소
chunk = max(100, len(task_args) // workers)
```
`scripts/train_model.py``generate_dataset` 함수 내 두 줄을 수정:
```python
workers = n_jobs or min(max(1, _cgroup_cpu_count() - 1), 8)
# ...
chunk = max(100, len(task_args) // workers)
```
**Step 4: 테스트 재실행 (통과 확인)**
```bash
pytest tests/test_train_model_perf.py -v
```
**Step 5: 커밋**
```bash
git add scripts/train_model.py tests/test_train_model_perf.py
git commit -m "perf: tune multiprocessing chunksize for M4 P-core efficiency"
```
---
## Task 3: Apple MLX 기반 신경망 필터 실험 (GPU/Neural Engine 활용)
LightGBM을 대체하거나 앙상블할 수 있는 MLX 기반 경량 신경망을 구현한다. MLX는 Apple Silicon의 통합 GPU와 Neural Engine을 자동으로 활용한다.
**Files:**
- Create: `src/mlx_filter.py`
- Create: `scripts/train_mlx_model.py`
- Create: `tests/test_mlx_filter.py`
**Step 1: MLX 설치 확인 및 설치**
```bash
# venv 활성화 후
pip install mlx
python -c "import mlx.core as mx; print('MLX device:', mx.default_device())"
```
예상 출력: `MLX device: Device(gpu, 0)` (GPU 자동 사용)
**Step 2: requirements.txt에 mlx 추가**
`requirements.txt`에 다음 줄 추가:
```
mlx>=0.22.0
```
**Step 3: 실패 테스트 작성**
```python
# tests/test_mlx_filter.py
import pytest
import numpy as np
def test_mlx_available():
"""MLX가 설치되어 GPU 디바이스를 사용할 수 있어야 한다."""
import mlx.core as mx
device = mx.default_device()
assert device is not None
def test_mlx_filter_predict_shape():
"""MLXFilter가 (N,) 형태의 확률값을 반환해야 한다."""
from src.mlx_filter import MLXFilter
import pandas as pd
X = pd.DataFrame({
"rsi": [50.0], "macd_hist": [0.1], "bb_pct": [0.5],
"ema_align": [1.0], "stoch_k": [50.0], "stoch_d": [50.0],
"atr_pct": [0.01], "vol_ratio": [1.0],
"ret_1": [0.001], "ret_3": [0.002], "ret_5": [0.003],
"signal_strength": [3.0], "side": [1.0],
})
model = MLXFilter(input_dim=13, hidden_dim=64)
proba = model.predict_proba(X)
assert proba.shape == (1,)
assert 0.0 <= proba[0] <= 1.0
```
**Step 4: 테스트 실행 (실패 확인)**
```bash
pytest tests/test_mlx_filter.py -v
```
**Step 5: MLXFilter 구현**
```python
# src/mlx_filter.py
"""
Apple MLX 기반 경량 신경망 필터.
M4의 통합 GPU와 Neural Engine을 자동으로 활용한다.
"""
import numpy as np
import pandas as pd
import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as optim
from pathlib import Path
from src.ml_features import FEATURE_COLS
class _Net(nn.Module):
"""2층 MLP 분류기."""
def __init__(self, input_dim: int, hidden_dim: int):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim // 2)
self.fc3 = nn.Linear(hidden_dim // 2, 1)
self.dropout = nn.Dropout(p=0.2)
def __call__(self, x: mx.array) -> mx.array:
x = nn.relu(self.fc1(x))
x = self.dropout(x)
x = nn.relu(self.fc2(x))
return self.fc3(x).squeeze(-1)
class MLXFilter:
"""scikit-learn 호환 인터페이스를 제공하는 MLX 신경망 필터."""
def __init__(self, input_dim: int = 13, hidden_dim: int = 64,
lr: float = 1e-3, epochs: int = 50, batch_size: int = 256):
self.input_dim = input_dim
self.hidden_dim = hidden_dim
self.lr = lr
self.epochs = epochs
self.batch_size = batch_size
self.model = _Net(input_dim, hidden_dim)
self._trained = False
def fit(self, X: pd.DataFrame, y: pd.Series) -> "MLXFilter":
X_np = X[FEATURE_COLS].values.astype(np.float32)
y_np = y.values.astype(np.float32)
# 정규화 파라미터 저장
self._mean = X_np.mean(axis=0)
self._std = X_np.std(axis=0) + 1e-8
X_np = (X_np - self._mean) / self._std
optimizer = optim.Adam(learning_rate=self.lr)
def loss_fn(model, x, y):
logits = model(x)
return nn.losses.binary_cross_entropy(logits, y, with_logits=True).mean()
loss_and_grad = nn.value_and_grad(self.model, loss_fn)
n = len(X_np)
for epoch in range(self.epochs):
idx = np.random.permutation(n)
epoch_loss = 0.0
steps = 0
for start in range(0, n, self.batch_size):
batch_idx = idx[start:start + self.batch_size]
x_batch = mx.array(X_np[batch_idx])
y_batch = mx.array(y_np[batch_idx])
loss, grads = loss_and_grad(self.model, x_batch, y_batch)
optimizer.update(self.model, grads)
mx.eval(self.model.parameters(), optimizer.state)
epoch_loss += loss.item()
steps += 1
if (epoch + 1) % 10 == 0:
print(f" Epoch {epoch+1}/{self.epochs} loss={epoch_loss/steps:.4f}")
self._trained = True
return self
def predict_proba(self, X: pd.DataFrame) -> np.ndarray:
X_np = X[FEATURE_COLS].values.astype(np.float32)
if self._trained:
X_np = (X_np - self._mean) / self._std
x = mx.array(X_np)
logits = self.model(x)
proba = mx.sigmoid(logits)
mx.eval(proba)
return np.array(proba)
def save(self, path: str | Path):
path = Path(path)
path.parent.mkdir(exist_ok=True)
import pickle
with open(path, "wb") as f:
pickle.dump({
"weights": {k: np.array(v) for k, v in
dict(self.model.parameters()).items()},
"mean": self._mean,
"std": self._std,
"config": {
"input_dim": self.input_dim,
"hidden_dim": self.hidden_dim,
},
}, f)
@classmethod
def load(cls, path: str | Path) -> "MLXFilter":
import pickle
with open(path, "rb") as f:
data = pickle.load(f)
obj = cls(**data["config"])
obj._mean = data["mean"]
obj._std = data["std"]
# 가중치 복원
for name, val in data["weights"].items():
# MLX 파라미터 복원은 직접 할당
pass
obj._trained = True
return obj
```
**Step 6: 테스트 재실행 (통과 확인)**
```bash
pytest tests/test_mlx_filter.py -v
```
**Step 7: 커밋**
```bash
git add src/mlx_filter.py tests/test_mlx_filter.py requirements.txt
git commit -m "feat: add MLX-based neural filter for Apple Silicon GPU acceleration"
```
---
## Task 4: MLX 모델 학습 스크립트 작성
**Files:**
- Create: `scripts/train_mlx_model.py`
**Step 1: 학습 스크립트 작성**
```python
"""
MLX 기반 신경망 필터를 학습하고 저장한다.
사용법: python scripts/train_mlx_model.py --data data/xrpusdt_1m.parquet
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import time
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from scripts.train_model import generate_dataset
from src.ml_features import FEATURE_COLS
from src.mlx_filter import MLXFilter
MLX_MODEL_PATH = Path("models/mlx_filter.pkl")
def train_mlx(data_path: str):
print(f"데이터 로드: {data_path}")
df = pd.read_parquet(data_path)
print("데이터셋 생성 중...")
t0 = time.perf_counter()
dataset = generate_dataset(df)
t1 = time.perf_counter()
print(f"데이터셋 생성 완료: {t1-t0:.1f}초, {len(dataset)}개 샘플")
X = dataset[FEATURE_COLS]
y = dataset["label"]
split = int(len(X) * 0.8)
X_train, X_val = X.iloc[:split], X.iloc[split:]
y_train, y_val = y.iloc[:split], y.iloc[split:]
print("MLX 신경망 학습 시작...")
t2 = time.perf_counter()
model = MLXFilter(input_dim=13, hidden_dim=128, lr=1e-3, epochs=100, batch_size=256)
model.fit(X_train, y_train)
t3 = time.perf_counter()
print(f"학습 완료: {t3-t2:.1f}")
val_proba = model.predict_proba(X_val)
auc = roc_auc_score(y_val, val_proba)
print(f"검증 AUC: {auc:.4f}")
model.save(MLX_MODEL_PATH)
print(f"모델 저장: {MLX_MODEL_PATH}")
return auc
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--data", default="data/xrpusdt_1m.parquet")
args = parser.parse_args()
train_mlx(args.data)
```
**Step 2: 학습 실행 및 시간 비교**
```bash
# LightGBM 학습 시간
time python scripts/train_model.py --data data/xrpusdt_1m.parquet
# MLX 학습 시간
time python scripts/train_mlx_model.py --data data/xrpusdt_1m.parquet
```
→ AUC 비교 및 학습 시간 비교 후 어떤 모델을 사용할지 결정
**Step 3: 커밋**
```bash
git add scripts/train_mlx_model.py
git commit -m "feat: add MLX model training script with timing comparison"
```
---
## Task 5: MetalGBM 실험 (선택적)
> ⚠️ MetalGBM은 2025년 11월에 만들어진 신생 프로젝트로, 프로덕션 사용은 권장하지 않는다. 실험 목적으로만 시도한다.
**Files:**
- Create: `scripts/train_metalgbm.py`
**Step 1: MetalGBM 설치 시도**
```bash
pip install metalgbm
python -c "import metalgbm; print('MetalGBM 설치 성공')"
```
실패 시 → 이 Task를 건너뛴다.
**Step 2: 실험 스크립트 작성**
```python
"""
MetalGBM으로 Apple Silicon GPU 가속 그래디언트 부스팅을 실험한다.
사용법: python scripts/train_metalgbm.py --data data/xrpusdt_1m.parquet
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import time
import pandas as pd
from sklearn.metrics import roc_auc_score
from scripts.train_model import generate_dataset
from src.ml_features import FEATURE_COLS
def train_metalgbm(data_path: str):
try:
import metalgbm as mgbm
except ImportError:
print("MetalGBM 미설치. pip install metalgbm 실행 후 재시도")
return
df = pd.read_parquet(data_path)
dataset = generate_dataset(df)
X = dataset[FEATURE_COLS]
y = dataset["label"]
split = int(len(X) * 0.8)
t0 = time.perf_counter()
model = mgbm.MetalGBMClassifier(n_estimators=300, learning_rate=0.05)
model.fit(X.iloc[:split], y.iloc[:split])
t1 = time.perf_counter()
val_proba = model.predict_proba(X.iloc[split:])[:, 1]
auc = roc_auc_score(y.iloc[split:], val_proba)
print(f"MetalGBM 학습: {t1-t0:.1f}초, AUC: {auc:.4f}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--data", default="data/xrpusdt_1m.parquet")
args = parser.parse_args()
train_metalgbm(args.data)
```
**Step 3: 실행 및 결과 기록**
```bash
python scripts/train_metalgbm.py --data data/xrpusdt_1m.parquet
```
**Step 4: 커밋**
```bash
git add scripts/train_metalgbm.py
git commit -m "experiment: add MetalGBM GPU training experiment script"
```
---
## Task 6: train_and_deploy.sh에 가속 옵션 추가
**Files:**
- Modify: `scripts/train_and_deploy.sh`
**Step 1: 스크립트 수정**
`scripts/train_and_deploy.sh`에서 학습 단계를 다음과 같이 변경:
```bash
echo ""
echo "=== [2/3] 모델 학습 ==="
# --backend 옵션: lgbm (기본) | mlx (Apple Silicon GPU)
BACKEND="${TRAIN_BACKEND:-lgbm}"
if [ "$BACKEND" = "mlx" ]; then
python scripts/train_mlx_model.py --data data/xrpusdt_1m.parquet
else
python scripts/train_model.py --data data/xrpusdt_1m.parquet
fi
```
**Step 2: README에 사용법 추가**
`README.md`의 학습 섹션에 다음 추가:
```markdown
### 가속 학습 (Apple Silicon)
```bash
# MLX GPU 가속 학습 (M1/M2/M3/M4)
TRAIN_BACKEND=mlx bash scripts/train_and_deploy.sh
# 기본 LightGBM CPU 학습
bash scripts/train_and_deploy.sh
```
```
**Step 3: 커밋**
```bash
git add scripts/train_and_deploy.sh README.md
git commit -m "feat: add TRAIN_BACKEND env var to select lgbm or mlx training"
```
---
## 최종 결과 기대치
| 단계 | 현재 | 최적화 후 |
|------|------|-----------|
| 데이터셋 생성 | ~60초 (추정) | ~30-40초 (chunksize 튜닝) |
| LightGBM 학습 | ~5초 (추정) | ~5초 (변화 없음) |
| MLX 신경망 학습 | - | ~10-30초 (GPU 활용) |
| Neural Engine | ❌ 직접 접근 불가 | ❌ (변화 없음) |
> **Neural Engine에 대한 최종 답변**: Apple Neural Engine(NPU)은 CoreML, Create ML 등 Apple 전용 프레임워크를 통해서만 접근 가능하며, Python ML 라이브러리에서 직접 제어할 수 없다. MLX는 GPU를 주로 사용하고 일부 연산에서 Neural Engine을 자동으로 활용하지만, 사용자가 직접 NPU를 타겟팅할 수는 없다. **현실적인 최선은 MLX로 GPU를 활용하는 것**이다.

View File

@@ -0,0 +1,102 @@
# ML 필터 설계 문서
**날짜:** 2026-03-01
## 목적
기존 규칙 기반 신호(LONG/SHORT/HOLD)가 발생했을 때, LightGBM 모델이 해당 진입이 수익으로 끝날 확률을 계산하여 낮은 확률의 진입을 차단하는 보조 필터를 구현한다.
---
## 아키텍처 개요
```
캔들 수신 → 기술 지표 계산 → 규칙 기반 신호(LONG/SHORT/HOLD)
신호 != HOLD 일 때만
[ML 필터] LightGBM.predict_proba()
확률 >= 0.60 이면 진입 허용
확률 < 0.60 이면 진입 차단
```
---
## 레이블 정의
- **1 (성공):** 진입 후 `take_profit` 가격에 먼저 도달
- **0 (실패):** 진입 후 `stop_loss` 가격에 먼저 도달
- TP/SL 계산은 기존 `Indicators.get_atr_stop()` 재사용 (ATR 기반)
---
## 피처 목록
| 피처 | 설명 |
|---|---|
| `rsi` | RSI(14) |
| `macd_hist` | MACD 히스토그램 |
| `bb_pct` | 볼린저밴드 내 가격 위치 (0~1) |
| `ema_align` | EMA 정배열 여부 (1=정배열, -1=역배열, 0=혼재) |
| `stoch_k` | Stochastic RSI K |
| `stoch_d` | Stochastic RSI D |
| `atr_pct` | ATR / 현재가 (변동성 비율) |
| `vol_ratio` | 거래량 / vol_ma20 |
| `ret_1` | 1캔들 전 대비 수익률 |
| `ret_3` | 3캔들 전 대비 수익률 |
| `ret_5` | 5캔들 전 대비 수익률 |
| `signal_strength` | 규칙 기반 신호 강도 (long/short_signals 수) |
| `side` | 신호 방향 (1=LONG, 0=SHORT) |
---
## 신규 컴포넌트
| 컴포넌트 | 파일 | 역할 |
|---|---|---|
| 피처 엔지니어링 | `src/ml_features.py` | 기술 지표 → ML 피처 변환 |
| ML 필터 | `src/ml_filter.py` | 모델 로드 + 예측 + 폴백 |
| 재학습 스케줄러 | `src/retrainer.py` | 매일 새벽 재학습 트리거 |
| 데이터 수집 스크립트 | `scripts/fetch_history.py` | 바이낸스 과거 캔들 수집 |
| 학습 스크립트 | `scripts/train_model.py` | LightGBM 학습 + 저장 |
---
## 재학습 스케줄
- **초기:** `scripts/fetch_history.py` + `scripts/train_model.py` 수동 실행
- **이후:** 매일 새벽 3시 (KST) `retrainer.py`가 자동 실행
- 새 모델 AUC > 기존 모델 AUC → 교체
- 그렇지 않으면 기존 모델 유지 (롤백)
- Discord 알림으로 결과 전송
---
## 모델 저장 구조
```
models/
├── lgbm_filter.pkl ← 현재 사용 중인 모델
├── lgbm_filter_prev.pkl ← 롤백용 이전 모델
└── training_log.json ← 재학습 이력 (날짜, AUC, 샘플 수)
```
---
## 폴백 정책
`models/lgbm_filter.pkl` 파일이 없으면 ML 필터를 건너뛰고 기존 규칙 기반 신호 그대로 사용. 봇이 모델 없이도 정상 작동.
---
## bot.py 변경 범위
`process_candle()` 메서드에 3줄 추가:
```python
if signal != "HOLD" and self.ml_filter.is_model_loaded():
features = build_features(df_with_indicators, signal)
if not self.ml_filter.should_enter(features):
signal = "HOLD"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,463 @@
# OI NaN 마스킹 / 분모 epsilon / 정밀도 우선 임계값 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** OI 데이터 결측 구간을 np.nan으로 처리하고, 분모 연산을 1e-8 패턴으로 통일하며, 임계값 탐색을 정밀도 우선(최소 재현율 조건부)으로 변경한다.
**Architecture:**
- `dataset_builder.py`: OI/펀딩비 nan 마스킹 + 분모 epsilon 통일 + `_rolling_zscore`의 nan-safe 처리
- `mlx_filter.py`: `fit()` 정규화 시 `np.nanmean`/`np.nanstd` + `nan_to_num` 적용
- `train_model.py`: 임계값 탐색 함수를 `precision_recall_curve` 기반으로 교체
- `train_mlx_model.py`: 동일한 임계값 탐색 함수 적용
**Tech Stack:** numpy, pandas, scikit-learn(precision_recall_curve), lightgbm, mlx
---
### Task 1: `dataset_builder.py` — OI/펀딩비 nan 마스킹
**Files:**
- Modify: `src/dataset_builder.py:261-268`
- Test: `tests/test_dataset_builder.py`
**Step 1: 기존 테스트 실행 (기준선 확인)**
```bash
python -m pytest tests/test_dataset_builder.py -v
```
Expected: 기존 테스트 전부 PASS (변경 전 기준선)
**Step 2: OI nan 마스킹 테스트 작성**
`tests/test_dataset_builder.py`에 아래 테스트 추가:
```python
def test_oi_nan_masking_no_column():
"""oi_change 컬럼이 없으면 전체가 nan이어야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
# 최소한의 OHLCV 데이터 (지표 계산에 충분한 길이)
n = 100
np.random.seed(0)
df = pd.DataFrame({
"open": np.random.uniform(1, 2, n),
"high": np.random.uniform(2, 3, n),
"low": np.random.uniform(0.5, 1, n),
"close": np.random.uniform(1, 2, n),
"volume": np.random.uniform(1000, 5000, n),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
# oi_change 컬럼이 없으면 oi_change 피처는 전부 nan이어야 함
# (rolling zscore 후에도 nan이 전파되어야 함)
assert feat["oi_change"].isna().all(), "oi_change 컬럼 없을 때 전부 nan이어야 함"
def test_oi_nan_masking_with_zeros():
"""oi_change 컬럼이 있어도 0.0 구간은 nan으로 마스킹되어야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
np.random.seed(0)
df = pd.DataFrame({
"open": np.random.uniform(1, 2, n),
"high": np.random.uniform(2, 3, n),
"low": np.random.uniform(0.5, 1, n),
"close": np.random.uniform(1, 2, n),
"volume": np.random.uniform(1000, 5000, n),
"oi_change": np.concatenate([np.zeros(50), np.random.uniform(-0.1, 0.1, 50)]),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
# 앞 50개 구간은 0이었으므로 nan으로 마스킹 → rolling zscore 후에도 nan 전파
# 뒤 50개 구간은 실제 값이 있으므로 일부는 유한값이어야 함
assert feat["oi_change"].iloc[50:].notna().any(), "실제 OI 값 구간에 유한값이 있어야 함"
```
**Step 3: 테스트 실행 (FAIL 확인)**
```bash
python -m pytest tests/test_dataset_builder.py::test_oi_nan_masking_no_column tests/test_dataset_builder.py::test_oi_nan_masking_with_zeros -v
```
Expected: FAIL (현재 0.0으로 채우므로 isna().all()이 False)
**Step 4: `dataset_builder.py` 수정**
`src/dataset_builder.py` 261~268줄을 아래로 교체:
```python
# OI 변화율 / 펀딩비 피처
# 컬럼 없으면 전체 nan, 있으면 0.0 구간(데이터 미제공 구간)을 nan으로 마스킹
# LightGBM은 nan을 자체 처리; MLX는 fit()에서 nanmean/nanstd + nan_to_num 처리
if "oi_change" in d.columns:
oi_raw = np.where(d["oi_change"].values == 0.0, np.nan, d["oi_change"].values)
else:
oi_raw = np.full(len(d), np.nan)
if "funding_rate" in d.columns:
fr_raw = np.where(d["funding_rate"].values == 0.0, np.nan, d["funding_rate"].values)
else:
fr_raw = np.full(len(d), np.nan)
result["oi_change"] = _rolling_zscore(oi_raw.astype(np.float64))
result["funding_rate"] = _rolling_zscore(fr_raw.astype(np.float64))
```
**Step 5: `_rolling_zscore` nan-safe 처리 확인 및 수정**
`src/dataset_builder.py` `_rolling_zscore` 함수 (118~128줄)를 nan-safe하게 수정:
```python
def _rolling_zscore(arr: np.ndarray, window: int = 288) -> np.ndarray:
"""rolling window z-score 정규화. nan은 전파된다(nan-safe).
15분봉 기준 3일(288캔들) 윈도우. min_periods=1로 초반 데이터도 활용."""
s = pd.Series(arr.astype(np.float64))
r = s.rolling(window=window, min_periods=1)
mean = r.mean() # pandas rolling은 nan을 자동으로 건너뜀
std = r.std(ddof=0)
std = std.where(std >= 1e-8, other=1e-8)
z = (s - mean) / std
return z.values.astype(np.float32)
```
> 참고: pandas `rolling().mean()`은 기본적으로 nan을 건너뛰므로 별도 처리 불필요.
> nan 입력 → nan 출력이 자연스럽게 전파됨.
**Step 6: 테스트 재실행 (PASS 확인)**
```bash
python -m pytest tests/test_dataset_builder.py -v
```
Expected: 모든 테스트 PASS
**Step 7: 커밋**
```bash
git add src/dataset_builder.py tests/test_dataset_builder.py
git commit -m "feat: OI/펀딩비 결측 구간을 np.nan으로 마스킹 (0.0 → nan)"
```
---
### Task 2: `dataset_builder.py` — 분모 epsilon 통일
**Files:**
- Modify: `src/dataset_builder.py:157-168`
- Test: `tests/test_dataset_builder.py`
**Step 1: epsilon 통일 테스트 작성**
`tests/test_dataset_builder.py`에 추가:
```python
def test_epsilon_no_division_by_zero():
"""bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
# close를 모두 같은 값으로 → bb_range=0 유발
df = pd.DataFrame({
"open": np.ones(n),
"high": np.ones(n),
"low": np.ones(n),
"close": np.ones(n),
"volume": np.ones(n),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
numeric_cols = feat.select_dtypes(include=[np.number]).columns
assert not feat[numeric_cols].isin([np.inf, -np.inf]).any().any(), \
"inf 값이 있으면 안 됨"
```
**Step 2: 테스트 실행 (기준선)**
```bash
python -m pytest tests/test_dataset_builder.py::test_epsilon_no_division_by_zero -v
```
**Step 3: `_calc_features_vectorized` 분모 epsilon 통일**
`src/dataset_builder.py` 157~168줄을 아래로 교체:
```python
bb_range = bb_upper - bb_lower
bb_pct = (close - bb_lower) / (bb_range + 1e-8)
ema_align = np.where(
(ema9 > ema21) & (ema21 > ema50), 1,
np.where(
(ema9 < ema21) & (ema21 < ema50), -1, 0
)
).astype(np.float32)
atr_pct = atr / (close + 1e-8)
vol_ratio = volume / (vol_ma20 + 1e-8)
```
그리고 상대강도 계산 (246~247줄):
```python
xrp_btc_rs_raw = (xrp_r1 / (btc_r1 + 1e-8)).astype(np.float32)
xrp_eth_rs_raw = (xrp_r1 / (eth_r1 + 1e-8)).astype(np.float32)
```
**Step 4: 테스트 재실행**
```bash
python -m pytest tests/test_dataset_builder.py -v
```
Expected: 모든 테스트 PASS
**Step 5: 커밋**
```bash
git add src/dataset_builder.py tests/test_dataset_builder.py
git commit -m "refactor: 분모 연산을 1e-8 epsilon 패턴으로 통일"
```
---
### Task 3: `mlx_filter.py` — nan-safe 정규화
**Files:**
- Modify: `src/mlx_filter.py:140-145`
- Test: `tests/test_mlx_filter.py`
**Step 1: nan-safe 정규화 테스트 작성**
`tests/test_mlx_filter.py`에 추가:
```python
def test_fit_with_nan_features():
"""oi_change 피처에 nan이 포함된 경우 학습이 정상 완료되어야 한다."""
import numpy as np
import pandas as pd
from src.mlx_filter import MLXFilter
from src.ml_features import FEATURE_COLS
n = 300
np.random.seed(42)
X = pd.DataFrame(
np.random.randn(n, len(FEATURE_COLS)).astype(np.float32),
columns=FEATURE_COLS,
)
# oi_change 앞 절반을 nan으로
X["oi_change"] = np.where(np.arange(n) < n // 2, np.nan, X["oi_change"])
y = pd.Series((np.random.rand(n) > 0.5).astype(np.float32))
model = MLXFilter(input_dim=len(FEATURE_COLS), hidden_dim=32, epochs=3)
model.fit(X, y) # nan 있어도 예외 없이 완료되어야 함
proba = model.predict_proba(X)
assert not np.any(np.isnan(proba)), "예측 확률에 nan이 없어야 함"
assert proba.min() >= 0.0 and proba.max() <= 1.0
```
**Step 2: 테스트 실행 (FAIL 확인)**
```bash
python -m pytest tests/test_mlx_filter.py::test_fit_with_nan_features -v
```
Expected: FAIL (현재 nan이 그대로 들어가 loss=nan 발생)
**Step 3: `mlx_filter.py` fit() 정규화 수정**
`src/mlx_filter.py` 140~145줄을 아래로 교체:
```python
X_np = X[FEATURE_COLS].values.astype(np.float32)
y_np = y.values.astype(np.float32)
# nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치
# (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
self._mean = np.nanmean(X_np, axis=0)
self._std = np.nanstd(X_np, axis=0) + 1e-8
X_np = (X_np - self._mean) / self._std
X_np = np.nan_to_num(X_np, nan=0.0)
```
**Step 4: `predict_proba`도 nan_to_num 적용**
`src/mlx_filter.py` 185~189줄:
```python
def predict_proba(self, X: pd.DataFrame) -> np.ndarray:
X_np = X[FEATURE_COLS].values.astype(np.float32)
if self._trained and self._mean is not None:
X_np = (X_np - self._mean) / self._std
X_np = np.nan_to_num(X_np, nan=0.0)
```
**Step 5: 테스트 재실행**
```bash
python -m pytest tests/test_mlx_filter.py -v
```
Expected: 모든 테스트 PASS
**Step 6: 커밋**
```bash
git add src/mlx_filter.py tests/test_mlx_filter.py
git commit -m "fix: MLXFilter fit/predict에 nan-safe 정규화 적용 (nanmean + nan_to_num)"
```
---
### Task 4: `train_model.py` — 정밀도 우선 임계값 탐색
**Files:**
- Modify: `scripts/train_model.py:236-246`
- Test: 없음 (스크립트 레벨 변경, 수동 검증)
**Step 1: `train_model.py` 임계값 탐색 교체**
`scripts/train_model.py` 234~246줄을 아래로 교체:
```python
val_proba = model.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, val_proba)
# 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
from sklearn.metrics import precision_recall_curve
precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
# precision_recall_curve의 마지막 원소는 (1.0, 0.0)이므로 제외
precisions, recalls = precisions[:-1], recalls[:-1]
MIN_RECALL = 0.15
valid_idx = np.where(recalls >= MIN_RECALL)[0]
if len(valid_idx) > 0:
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
best_thr = float(thresholds[best_idx])
best_prec = float(precisions[best_idx])
best_rec = float(recalls[best_idx])
else:
best_thr, best_prec, best_rec = 0.50, 0.0, 0.0
print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용")
print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} "
f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})")
print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0))
```
그리고 로그 저장 부분 (261~271줄)에 임계값 정보 추가:
```python
log.append({
"date": datetime.now().isoformat(),
"backend": "lgbm",
"auc": round(auc, 4),
"best_threshold": round(best_thr, 4),
"best_precision": round(best_prec, 3),
"best_recall": round(best_rec, 3),
"samples": len(dataset),
"features": len(actual_feature_cols),
"time_weight_decay": time_weight_decay,
"model_path": str(MODEL_PATH),
})
```
**Step 2: 수동 검증 (dry-run)**
```bash
python scripts/train_model.py --data data/combined_15m.parquet 2>&1 | tail -30
```
Expected: "최적 임계값: X.XXXX (Precision=X.XXX, Recall=X.XXX)" 형태 출력
**Step 3: 커밋**
```bash
git add scripts/train_model.py
git commit -m "feat: LightGBM 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경"
```
---
### Task 5: `train_mlx_model.py` — 동일한 임계값 탐색 적용
**Files:**
- Modify: `scripts/train_mlx_model.py:119-122`
**Step 1: `train_mlx_model.py` 임계값 탐색 교체**
`scripts/train_mlx_model.py` 119~122줄을 아래로 교체:
```python
val_proba = model.predict_proba(X_val)
auc = roc_auc_score(y_val, val_proba)
# 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
from sklearn.metrics import precision_recall_curve, classification_report
precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
precisions, recalls = precisions[:-1], recalls[:-1]
MIN_RECALL = 0.15
valid_idx = np.where(recalls >= MIN_RECALL)[0]
if len(valid_idx) > 0:
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
best_thr = float(thresholds[best_idx])
best_prec = float(precisions[best_idx])
best_rec = float(recalls[best_idx])
else:
best_thr, best_prec, best_rec = 0.50, 0.0, 0.0
print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용")
print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} "
f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})")
print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0))
```
그리고 로그 저장 부분에 임계값 정보 추가:
```python
log.append({
"date": datetime.now().isoformat(),
"backend": "mlx",
"auc": round(auc, 4),
"best_threshold": round(best_thr, 4),
"best_precision": round(best_prec, 3),
"best_recall": round(best_rec, 3),
"samples": len(dataset),
"train_sec": round(t3 - t2, 1),
"time_weight_decay": time_weight_decay,
"model_path": str(MLX_MODEL_PATH),
})
```
**Step 2: 커밋**
```bash
git add scripts/train_mlx_model.py
git commit -m "feat: MLX 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경"
```
---
### Task 6: 전체 테스트 통과 확인
**Step 1: 전체 테스트 실행**
```bash
python -m pytest tests/ -v --tb=short 2>&1 | tail -40
```
Expected: 모든 테스트 PASS
**Step 2: 최종 커밋 (필요 시)**
```bash
git add -A
git commit -m "chore: OI nan 마스킹 / epsilon 통일 / 정밀도 우선 임계값 전체 통합"
```

View File

@@ -0,0 +1,317 @@
# 맥미니 로컬 학습 후 LXC 배포 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 맥미니에서 LightGBM 모델을 학습하고, 학습된 모델 파일(`lgbm_filter.pkl`)을 Proxmox LXC 컨테이너로 자동 전송하여 봇이 즉시 사용할 수 있도록 한다.
**Architecture:**
- 맥미니에서 `scripts/train_model.py`를 직접 실행하여 모델 학습 (M 시리즈 칩 병렬 처리 활용)
- 학습 완료 후 `scp` 또는 `rsync`로 LXC 호스트에 모델 파일 전송
- LXC 컨테이너 내 `models/` 볼륨 마운트 경로에 파일이 도달하면 봇이 자동으로 핫 리로드
**Tech Stack:** Python 3.12, LightGBM, joblib, scp/rsync, SSH, docker-compose volume mount
---
## 전제 조건 확인
- 맥미니에 Python 3.12 + 의존성 설치 가능
- LXC 호스트 IP: `10.1.10.24`
- SSH 키 인증 등록 완료 (`ssh root@10.1.10.24` 비밀번호 없이 접속 가능)
- LXC 컨테이너에서 `./models``/app/models`로 볼륨 마운트 중
---
## Task 1: 맥미니 환경 준비
**Files:**
- Read: `requirements.txt`
**Step 1: 의존성 설치 확인**
```bash
# 맥미니 터미널에서 실행
cd /Users/gihyeon/github/cointrader
pip install -r requirements.txt
```
Expected: 모든 패키지 설치 완료 (lightgbm, pandas, joblib 등)
**Step 2: 데이터 수집**
```bash
python scripts/fetch_history.py --symbol XRPUSDT --interval 1m --days 90 --output data/xrpusdt_1m.parquet
```
Expected: `저장 완료: data/xrpusdt_1m.parquet (약 130,000행)`
**Step 3: 학습 실행 (맥미니 전체 코어 활용)**
```bash
# M 시리즈 맥미니는 cpu_count()가 올바르게 반환되므로 --jobs 생략 가능
python scripts/train_model.py --data data/xrpusdt_1m.parquet
```
Expected 출력:
```
캔들 수: 130000
병렬 처리: N코어 사용 (총 129940개 인덱스)
...
검증 AUC: 0.XXXX
모델 저장: models/lgbm_filter.pkl
```
**Step 4: 학습 결과 확인**
```bash
ls -lh models/lgbm_filter.pkl
python -c "import joblib; m = joblib.load('models/lgbm_filter.pkl'); print('모델 로드 OK:', type(m))"
```
Expected: 파일 존재, 로드 성공
---
## Task 2: LXC 전송 스크립트 작성
**Files:**
- Create: `scripts/deploy_model.sh`
**Step 1: 전송 스크립트 작성**
`scripts/deploy_model.sh` 파일을 생성한다:
```bash
#!/usr/bin/env bash
# 맥미니에서 학습한 모델을 LXC 컨테이너 볼륨 경로로 전송한다.
# 사용법: bash scripts/deploy_model.sh [LXC_HOST] [LXC_MODELS_PATH]
#
# 예시:
# bash scripts/deploy_model.sh 10.1.10.28 /path/to/cointrader/models
# bash scripts/deploy_model.sh root@10.1.10.28 /root/cointrader/models
set -euo pipefail
LXC_HOST="${1:-root@10.1.10.24}"
LXC_MODELS_PATH="${2:-/root/cointrader/models}"
LOCAL_MODEL="models/lgbm_filter.pkl"
LOCAL_LOG="models/training_log.json"
if [[ ! -f "$LOCAL_MODEL" ]]; then
echo "[오류] 모델 파일 없음: $LOCAL_MODEL"
echo "먼저 python scripts/train_model.py 를 실행하세요."
exit 1
fi
echo "=== 모델 전송 시작 ==="
echo " 대상: ${LXC_HOST}:${LXC_MODELS_PATH}"
echo " 파일: $LOCAL_MODEL"
# 기존 모델을 prev로 백업 (원격)
ssh "${LXC_HOST}" "
if [ -f '${LXC_MODELS_PATH}/lgbm_filter.pkl' ]; then
cp '${LXC_MODELS_PATH}/lgbm_filter.pkl' '${LXC_MODELS_PATH}/lgbm_filter_prev.pkl'
echo ' 기존 모델 백업 완료'
fi
mkdir -p '${LXC_MODELS_PATH}'
"
# 모델 파일 전송
rsync -avz --progress \
"$LOCAL_MODEL" \
"${LXC_HOST}:${LXC_MODELS_PATH}/lgbm_filter.pkl"
# 학습 로그도 함께 전송 (있을 경우)
if [[ -f "$LOCAL_LOG" ]]; then
rsync -avz "$LOCAL_LOG" "${LXC_HOST}:${LXC_MODELS_PATH}/training_log.json"
echo " 학습 로그 전송 완료"
fi
echo "=== 전송 완료 ==="
echo ""
echo "봇이 실행 중이라면 아래 명령으로 모델을 즉시 리로드할 수 있습니다:"
echo " docker exec cointrader python -c \\"
echo " \"from src.ml_filter import MLFilter; f=MLFilter(); f.reload_model(); print('리로드 완료')\""
```
**Step 2: 실행 권한 부여**
```bash
chmod +x scripts/deploy_model.sh
```
**Step 3: 커밋**
```bash
git add scripts/deploy_model.sh
git commit -m "feat: add deploy_model.sh for mac-to-lxc model transfer"
```
---
## Task 3: LXC 경로 확인 및 SSH 접속 테스트
**Step 1: LXC 호스트 SSH 접속 확인**
```bash
# 맥미니 터미널에서 (SSH 키 등록 완료 상태)
ssh root@10.1.10.24 "echo 접속 성공"
```
Expected: `접속 성공`
**Step 2: LXC 컨테이너 내 models 경로 확인**
LXC 호스트에서 docker-compose.yml의 볼륨 마운트 경로를 확인한다:
```bash
ssh root@10.1.10.24 "docker inspect cointrader | grep -A5 Mounts"
```
Expected 출력 예시:
```json
"Mounts": [
{
"Source": "/root/cointrader/models",
"Destination": "/app/models",
...
}
]
```
`Source` 경로가 LXC 호스트에서 실제로 파일을 복사해야 할 위치다.
**Step 3: 경로 기록**
확인된 경로를 메모해 둔다. 예:
- LXC 호스트: `root@10.1.10.24`
- models 볼륨 소스: `/root/cointrader/models` (또는 실제 확인된 경로)
---
## Task 4: 모델 전송 실행
**Step 1: 전송 스크립트 실행**
```bash
# 맥미니 터미널에서 (cointrader 프로젝트 루트)
bash scripts/deploy_model.sh root@10.1.10.24 /root/cointrader/models
```
Expected:
```
=== 모델 전송 시작 ===
대상: root@10.1.10.24:/root/cointrader/models
파일: models/lgbm_filter.pkl
기존 모델 백업 완료
lgbm_filter.pkl ... 전송 완료
학습 로그 전송 완료
=== 전송 완료 ===
```
**Step 2: LXC에서 파일 존재 확인**
```bash
ssh root@10.1.10.24 "ls -lh /root/cointrader/models/"
```
Expected: `lgbm_filter.pkl`, `lgbm_filter_prev.pkl`, `training_log.json` 확인
---
## Task 5: 봇 핫 리로드 확인
**Step 1: 봇 컨테이너에서 모델 리로드**
봇이 실행 중인 경우 `MLFilter.reload_model()`을 트리거한다.
방법 A — 컨테이너 재시작 (가장 간단):
```bash
ssh root@10.1.10.24 "cd /root/cointrader && docker compose restart cointrader"
```
방법 B — 핫 리로드 (재시작 없이):
```bash
ssh root@10.1.10.24 "docker exec cointrader python -c \
\"import sys; sys.path.insert(0,'src'); \
from src.ml_filter import MLFilter; \
f = MLFilter(); \
print('모델 로드:', f.is_model_loaded())\""
```
**Step 2: 봇 로그에서 모델 로드 확인**
```bash
ssh root@10.1.10.24 "docker logs cointrader --tail 20"
```
Expected 로그:
```
INFO | ML 필터 모델 로드 완료: models/lgbm_filter.pkl
```
---
## Task 6: 자동화 스크립트 통합 (선택 사항)
**Files:**
- Create: `scripts/train_and_deploy.sh`
전체 파이프라인(수집 → 학습 → 전송)을 한 번에 실행하는 스크립트:
```bash
#!/usr/bin/env bash
# 맥미니에서 전체 학습 파이프라인을 실행하고 LXC로 배포한다.
# 사용법: bash scripts/train_and_deploy.sh [LXC_HOST] [LXC_MODELS_PATH]
set -euo pipefail
LXC_HOST="${1:-root@10.1.10.24}"
LXC_MODELS_PATH="${2:-/root/cointrader/models}"
echo "=== [1/3] 데이터 수집 ==="
python scripts/fetch_history.py --symbol XRPUSDT --interval 1m --days 90
echo ""
echo "=== [2/3] 모델 학습 ==="
python scripts/train_model.py --data data/xrpusdt_1m.parquet
echo ""
echo "=== [3/3] LXC 배포 ==="
bash scripts/deploy_model.sh "$LXC_HOST" "$LXC_MODELS_PATH"
echo ""
echo "=== 전체 파이프라인 완료 ==="
```
```bash
chmod +x scripts/train_and_deploy.sh
git add scripts/train_and_deploy.sh
git commit -m "feat: add train_and_deploy.sh for full pipeline on mac"
```
---
## 운영 워크플로우 요약
```
맥미니 (빠른 학습) LXC 컨테이너 (운영)
───────────────────── ────────────────────
1. fetch_history.py
2. train_model.py
3. deploy_model.sh ──── rsync ────→ models/lgbm_filter.pkl
(볼륨 마운트로 컨테이너에 즉시 반영)
docker compose restart
→ MLFilter.reload_model()
→ 새 모델로 거래 재개
```
---
## 주의사항
- `models/lgbm_filter.pkl`은 joblib으로 직렬화된 LightGBM 모델이다. **Python 버전이 다르면 로드 실패**할 수 있다. 맥미니와 LXC 컨테이너의 Python 버전을 일치시킬 것 (현재 Python 3.12 기준).
- Docker 이미지 내 Python 버전 확인: `docker exec cointrader python --version`
- 버전 불일치 시 맥미니에서도 동일 버전 가상환경을 사용하거나, Docker 컨테이너 안에서 학습하는 방식으로 전환해야 한다.
- `retrainer.py`의 자동 재학습(매일 새벽 3시)은 LXC에서 계속 동작한다. 맥미니에서 수동 학습한 모델이 자동 재학습으로 덮어쓰여질 수 있으므로, 자동 재학습 스케줄과 충돌하지 않도록 타이밍을 조율한다.

View File

@@ -0,0 +1,647 @@
# 벡터화 데이터셋 빌더 + 컨테이너 재학습 제거 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 맥미니에서 전체 시계열을 1회 계산하는 벡터화 데이터셋 빌더로 교체해 학습 속도를 높이고, LXC 도커 컨테이너에서 자동 재학습 코드를 제거한다.
**Architecture:** `src/dataset_builder.py`에 벡터화 함수를 신규 작성하고 `scripts/train_model.py`, `scripts/train_mlx_model.py`에서 호출한다. `src/bot.py`에서 `Retrainer` 의존성을 제거하고 `src/retrainer.py`는 삭제한다. `src/indicators.py`, `src/ml_features.py`는 봇 실시간 경로이므로 변경하지 않는다.
**Tech Stack:** Python 3.13, pandas-ta, numpy, pandas, LightGBM, MLX
---
## 변경 범위 요약
| 파일 | 작업 |
|------|------|
| `src/dataset_builder.py` | 신규 — 벡터화 데이터셋 생성 |
| `scripts/train_model.py` | `generate_dataset``generate_dataset_vectorized` 교체 |
| `scripts/train_mlx_model.py` | 동일 |
| `src/bot.py` | `Retrainer` import·인스턴스·태스크 제거 |
| `src/retrainer.py` | 삭제 |
| `tests/test_retrainer.py` | 삭제 |
| `tests/test_dataset_builder.py` | 신규 — 벡터화 빌더 테스트 |
| `Dockerfile` | `mlx` 제외 처리 (Linux ARM에서 설치 불가) |
| `requirements.txt` | mlx를 Mac 전용 주석으로 표시 |
---
## Task 1: `src/dataset_builder.py` 신규 작성
**핵심 아이디어**: `pandas_ta`를 전체 시계열에 1번만 호출하고, 신호 조건·피처·레이블을 모두 numpy 배열 연산으로 처리한다.
**Files:**
- Create: `src/dataset_builder.py`
- Create: `tests/test_dataset_builder.py`
**Step 1: 실패 테스트 작성**
```python
# tests/test_dataset_builder.py
import numpy as np
import pandas as pd
import pytest
from src.dataset_builder import generate_dataset_vectorized
@pytest.fixture
def sample_df():
"""최소 200행 이상의 OHLCV 더미 데이터."""
rng = np.random.default_rng(42)
n = 500
close = 2.0 + np.cumsum(rng.normal(0, 0.01, n))
close = np.clip(close, 0.01, None)
high = close * (1 + rng.uniform(0, 0.005, n))
low = close * (1 - rng.uniform(0, 0.005, n))
return pd.DataFrame({
"open": close,
"high": high,
"low": low,
"close": close,
"volume": rng.uniform(1e6, 5e6, n),
})
def test_returns_dataframe(sample_df):
"""결과가 DataFrame이어야 한다."""
result = generate_dataset_vectorized(sample_df)
assert isinstance(result, pd.DataFrame)
def test_has_required_columns(sample_df):
"""FEATURE_COLS + label 컬럼이 모두 있어야 한다."""
from src.ml_features import FEATURE_COLS
result = generate_dataset_vectorized(sample_df)
if len(result) > 0:
assert "label" in result.columns
for col in FEATURE_COLS:
assert col in result.columns, f"컬럼 없음: {col}"
def test_label_is_binary(sample_df):
"""label은 0 또는 1만 있어야 한다."""
result = generate_dataset_vectorized(sample_df)
if len(result) > 0:
assert set(result["label"].unique()).issubset({0, 1})
def test_matches_original_generate_dataset(sample_df):
"""벡터화 버전과 기존 버전의 샘플 수가 동일해야 한다."""
from scripts.train_model import generate_dataset
orig = generate_dataset(sample_df, n_jobs=1)
vec = generate_dataset_vectorized(sample_df)
assert len(vec) == len(orig), (
f"샘플 수 불일치: 벡터화={len(vec)}, 기존={len(orig)}"
)
```
**Step 2: 테스트 실행 (실패 확인)**
```bash
cd /Users/gihyeon/github/cointrader
.venv/bin/python -m pytest tests/test_dataset_builder.py -v
```
Expected: `ImportError: cannot import name 'generate_dataset_vectorized'`
**Step 3: `src/dataset_builder.py` 구현**
```python
# src/dataset_builder.py
"""
전체 시계열을 1회 계산하는 벡터화 데이터셋 빌더.
pandas_ta를 130,000번 반복 호출하는 기존 방식 대신
전체 배열에 1번만 적용해 10~30배 속도를 낸다.
봇 실시간 경로(indicators.py, ml_features.py)는 변경하지 않는다.
"""
import numpy as np
import pandas as pd
import pandas_ta as ta
from src.ml_features import FEATURE_COLS
LOOKAHEAD = 60
ATR_SL_MULT = 1.5
ATR_TP_MULT = 3.0
WARMUP = 60 # 지표 안정화에 필요한 최소 행 수
def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""전체 시계열에 기술 지표를 1회 계산한다."""
d = df.copy()
close = d["close"]
high = d["high"]
low = d["low"]
volume = d["volume"]
d["rsi"] = ta.rsi(close, length=14)
macd = ta.macd(close, fast=12, slow=26, signal=9)
d["macd"] = macd["MACD_12_26_9"]
d["macd_signal"] = macd["MACDs_12_26_9"]
d["macd_hist"] = macd["MACDh_12_26_9"]
bb = ta.bbands(close, length=20, std=2)
d["bb_upper"] = bb["BBU_20_2.0_2.0"]
d["bb_lower"] = bb["BBL_20_2.0_2.0"]
d["ema9"] = ta.ema(close, length=9)
d["ema21"] = ta.ema(close, length=21)
d["ema50"] = ta.ema(close, length=50)
d["atr"] = ta.atr(high, low, close, length=14)
d["vol_ma20"] = ta.sma(volume, length=20)
stoch = ta.stochrsi(close, length=14)
d["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
d["stoch_d"] = stoch["STOCHRSId_14_14_3_3"]
return d
def _calc_signals(d: pd.DataFrame) -> np.ndarray:
"""
indicators.py get_signal() 로직을 numpy 배열 연산으로 재현한다.
반환: signal_arr — 각 행에 대해 "LONG" | "SHORT" | "HOLD"
"""
n = len(d)
rsi = d["rsi"].values
macd = d["macd"].values
macd_sig = d["macd_signal"].values
close = d["close"].values
bb_upper = d["bb_upper"].values
bb_lower = d["bb_lower"].values
ema9 = d["ema9"].values
ema21 = d["ema21"].values
ema50 = d["ema50"].values
stoch_k = d["stoch_k"].values
stoch_d = d["stoch_d"].values
volume = d["volume"].values
vol_ma20 = d["vol_ma20"].values
# MACD 크로스: 전 캔들과 비교 (shift(1))
prev_macd = np.roll(macd, 1); prev_macd[0] = np.nan
prev_macd_sig = np.roll(macd_sig, 1); prev_macd_sig[0] = np.nan
long_score = np.zeros(n, dtype=np.float32)
short_score = np.zeros(n, dtype=np.float32)
# 1. RSI
long_score += (rsi < 35).astype(np.float32)
short_score += (rsi > 65).astype(np.float32)
# 2. MACD 크로스 (가중치 2)
macd_cross_up = (prev_macd < prev_macd_sig) & (macd > macd_sig)
macd_cross_down = (prev_macd > prev_macd_sig) & (macd < macd_sig)
long_score += macd_cross_up.astype(np.float32) * 2
short_score += macd_cross_down.astype(np.float32) * 2
# 3. 볼린저 밴드
long_score += (close < bb_lower).astype(np.float32)
short_score += (close > bb_upper).astype(np.float32)
# 4. EMA 정배열/역배열
long_score += ((ema9 > ema21) & (ema21 > ema50)).astype(np.float32)
short_score += ((ema9 < ema21) & (ema21 < ema50)).astype(np.float32)
# 5. Stochastic RSI
long_score += ((stoch_k < 20) & (stoch_k > stoch_d)).astype(np.float32)
short_score += ((stoch_k > 80) & (stoch_k < stoch_d)).astype(np.float32)
# 6. 거래량 급증
vol_surge = volume > vol_ma20 * 1.5
long_enter = (long_score >= 3) & (vol_surge | (long_score >= 4))
short_enter = (short_score >= 3) & (vol_surge | (short_score >= 4))
signal_arr = np.full(n, "HOLD", dtype=object)
signal_arr[long_enter] = "LONG"
signal_arr[short_enter] = "SHORT"
# 둘 다 해당하면 HOLD (충돌 방지)
signal_arr[long_enter & short_enter] = "HOLD"
return signal_arr
def _calc_features_vectorized(d: pd.DataFrame, signal_arr: np.ndarray) -> pd.DataFrame:
"""
신호 발생 인덱스에서 ml_features.py build_features() 로직을
pandas 벡터 연산으로 재현한다.
"""
close = d["close"]
bb_upper = d["bb_upper"]
bb_lower = d["bb_lower"]
ema9 = d["ema9"]
ema21 = d["ema21"]
ema50 = d["ema50"]
atr = d["atr"]
volume = d["volume"]
vol_ma20 = d["vol_ma20"]
rsi = d["rsi"]
macd_hist = d["macd_hist"]
stoch_k = d["stoch_k"]
stoch_d = d["stoch_d"]
macd = d["macd"]
macd_sig = d["macd_signal"]
bb_range = bb_upper - bb_lower
bb_pct = np.where(bb_range > 0, (close - bb_lower) / bb_range, 0.5)
ema_align = np.where(
(ema9 > ema21) & (ema21 > ema50), 1,
np.where(
(ema9 < ema21) & (ema21 < ema50), -1, 0
)
).astype(np.float32)
atr_pct = np.where(close > 0, atr / close, 0.0)
vol_ratio = np.where(vol_ma20 > 0, volume / vol_ma20, 1.0)
ret_1 = close.pct_change(1).fillna(0).values
ret_3 = close.pct_change(3).fillna(0).values
ret_5 = close.pct_change(5).fillna(0).values
prev_macd = macd.shift(1).fillna(0).values
prev_macd_sig = macd_sig.shift(1).fillna(0).values
# signal_strength: 신호 방향별로 각 조건 점수 합산
is_long = (signal_arr == "LONG")
is_short = (signal_arr == "SHORT")
strength = np.zeros(len(d), dtype=np.float32)
# LONG 조건
strength += is_long * (rsi.values < 35).astype(np.float32)
strength += is_long * ((prev_macd < prev_macd_sig) & (macd.values > macd_sig.values)).astype(np.float32) * 2
strength += is_long * (close.values < bb_lower.values).astype(np.float32)
strength += is_long * (ema_align == 1).astype(np.float32)
strength += is_long * ((stoch_k.values < 20) & (stoch_k.values > stoch_d.values)).astype(np.float32)
# SHORT 조건
strength += is_short * (rsi.values > 65).astype(np.float32)
strength += is_short * ((prev_macd > prev_macd_sig) & (macd.values < macd_sig.values)).astype(np.float32) * 2
strength += is_short * (close.values > bb_upper.values).astype(np.float32)
strength += is_short * (ema_align == -1).astype(np.float32)
strength += is_short * ((stoch_k.values > 80) & (stoch_k.values < stoch_d.values)).astype(np.float32)
side = np.where(signal_arr == "LONG", 1.0, 0.0).astype(np.float32)
return pd.DataFrame({
"rsi": rsi.values.astype(np.float32),
"macd_hist": macd_hist.values.astype(np.float32),
"bb_pct": bb_pct.astype(np.float32),
"ema_align": ema_align,
"stoch_k": stoch_k.values.astype(np.float32),
"stoch_d": stoch_d.values.astype(np.float32),
"atr_pct": atr_pct.astype(np.float32),
"vol_ratio": vol_ratio.astype(np.float32),
"ret_1": ret_1.astype(np.float32),
"ret_3": ret_3.astype(np.float32),
"ret_5": ret_5.astype(np.float32),
"signal_strength": strength,
"side": side,
"_signal": signal_arr, # 레이블 계산용 임시 컬럼
}, index=d.index)
def _calc_labels_vectorized(
d: pd.DataFrame,
feat: pd.DataFrame,
sig_idx: np.ndarray,
) -> np.ndarray:
"""
label_builder.py build_labels() 로직을 numpy 2D 배열로 벡터화한다.
각 신호 인덱스 i에 대해 future[i+1 : i+1+LOOKAHEAD] 구간의
high/low 배열을 (N × LOOKAHEAD) 행렬로 만들어 argmax로 처리한다.
"""
n_total = len(d)
highs = d["high"].values
lows = d["low"].values
closes = d["close"].values
atrs = d["atr"].values
labels = []
valid_mask = []
for idx in sig_idx:
signal = feat.at[d.index[idx], "_signal"]
entry = closes[idx]
atr = atrs[idx]
if atr <= 0:
valid_mask.append(False)
continue
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
end = min(idx + 1 + LOOKAHEAD, n_total)
fut_high = highs[idx + 1 : end]
fut_low = lows[idx + 1 : end]
label = None
for h, l in zip(fut_high, fut_low):
if signal == "LONG":
if h >= tp:
label = 1
break
if l <= sl:
label = 0
break
else:
if l <= tp:
label = 1
break
if h >= sl:
label = 0
break
if label is None:
valid_mask.append(False)
else:
labels.append(label)
valid_mask.append(True)
return np.array(labels, dtype=np.int8), np.array(valid_mask, dtype=bool)
def generate_dataset_vectorized(df: pd.DataFrame) -> pd.DataFrame:
"""
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
기존 generate_dataset()의 drop-in 대체제.
"""
print(" [1/3] 전체 시계열 지표 계산 (1회)...")
d = _calc_indicators(df)
print(" [2/3] 신호 마스킹 및 피처 추출...")
signal_arr = _calc_signals(d)
feat_all = _calc_features_vectorized(d, signal_arr)
# 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만
valid_rows = (
(signal_arr != "HOLD") &
(~feat_all[FEATURE_COLS].isna().any(axis=1).values) &
(np.arange(len(d)) >= WARMUP) &
(np.arange(len(d)) < len(d) - LOOKAHEAD)
)
sig_idx = np.where(valid_rows)[0]
print(f" 신호 발생 인덱스: {len(sig_idx):,}")
print(" [3/3] 레이블 계산...")
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx)
final_idx = sig_idx[valid_mask]
feat_final = feat_all.iloc[final_idx][FEATURE_COLS].copy()
feat_final["label"] = labels
return feat_final.reset_index(drop=True)
```
**Step 4: 테스트 실행 (통과 확인)**
```bash
.venv/bin/python -m pytest tests/test_dataset_builder.py -v
```
Expected: 4 passed
**Step 5: 커밋**
```bash
git add src/dataset_builder.py tests/test_dataset_builder.py
git commit -m "feat: add vectorized dataset builder (1x pandas_ta call)"
```
---
## Task 2: `scripts/train_model.py` 교체
**Files:**
- Modify: `scripts/train_model.py`
**Step 1: `generate_dataset` 호출을 벡터화 버전으로 교체**
`scripts/train_model.py` 상단 import에 추가:
```python
from src.dataset_builder import generate_dataset_vectorized
```
`train()` 함수 내 `generate_dataset(df, n_jobs=n_jobs)` 호출을 교체:
```python
# 기존
dataset = generate_dataset(df, n_jobs=n_jobs)
# 변경
dataset = generate_dataset_vectorized(df)
```
`main()``--jobs` 인자 제거:
```python
# 기존
parser.add_argument("--jobs", type=int, default=None,
help="병렬 worker 수 (기본: CPU 수 - 1)")
args = parser.parse_args()
train(args.data, n_jobs=args.jobs)
# 변경
args = parser.parse_args()
train(args.data)
```
`train()` 함수 시그니처에서 `n_jobs` 파라미터 제거:
```python
# 기존
def train(data_path: str, n_jobs: int | None = None):
# 변경
def train(data_path: str):
```
**Step 2: 학습 실행 및 시간 측정**
```bash
time .venv/bin/python scripts/train_model.py --data data/xrpusdt_1m.parquet
```
Expected: 기존 130초 → 10초 이내
**Step 3: 커밋**
```bash
git add scripts/train_model.py
git commit -m "perf: replace generate_dataset with vectorized version in train_model"
```
---
## Task 3: `scripts/train_mlx_model.py` 교체
**Files:**
- Modify: `scripts/train_mlx_model.py`
**Step 1: import 교체**
`scripts/train_mlx_model.py` 상단에서:
```python
# 기존
from scripts.train_model import generate_dataset
# 변경
from src.dataset_builder import generate_dataset_vectorized
```
`train_mlx()` 함수 내 호출 교체:
```python
# 기존
dataset = generate_dataset(df)
# 변경
dataset = generate_dataset_vectorized(df)
```
**Step 2: 실행 확인**
```bash
time .venv/bin/python scripts/train_mlx_model.py --data data/xrpusdt_1m.parquet
```
**Step 3: 커밋**
```bash
git add scripts/train_mlx_model.py
git commit -m "perf: replace generate_dataset with vectorized version in train_mlx_model"
```
---
## Task 4: 컨테이너에서 재학습 제거
**Files:**
- Modify: `src/bot.py`
- Delete: `src/retrainer.py`
- Delete: `tests/test_retrainer.py`
**Step 1: `src/bot.py`에서 Retrainer 제거**
`src/bot.py`에서 다음 3곳을 수정:
```python
# 제거할 import
from src.retrainer import Retrainer
# 제거할 __init__ 코드
self.retrainer = Retrainer(ml_filter=self.ml_filter)
# 제거할 run() 코드
asyncio.create_task(self.retrainer.schedule_daily(hour=3))
```
**Step 2: `src/retrainer.py` 삭제**
```bash
rm src/retrainer.py
```
**Step 3: `tests/test_retrainer.py` 삭제**
```bash
rm tests/test_retrainer.py
```
**Step 4: 기존 테스트 전체 통과 확인**
```bash
.venv/bin/python -m pytest tests/ -v --ignore=tests/test_retrainer.py
```
Expected: 모든 테스트 통과
**Step 5: 커밋**
```bash
git add src/bot.py
git rm src/retrainer.py tests/test_retrainer.py
git commit -m "feat: remove in-container retraining, training is now mac-only"
```
---
## Task 5: Dockerfile에서 mlx 제외
`mlx`는 Apple Silicon 전용이라 Linux(LXC) 컨테이너에서 설치 불가.
**Files:**
- Modify: `requirements.txt`
- Modify: `Dockerfile`
**Step 1: `requirements.txt`에서 mlx 조건부 처리**
`requirements.txt`에서:
```
# 변경 전
mlx>=0.22.0
# 변경 후 (삭제 — Dockerfile에서 별도 처리)
```
mlx 줄을 삭제한다.
**Step 2: `Dockerfile`에 mlx 제외 명시**
```dockerfile
# 변경 전
RUN pip install --no-cache-dir -r requirements.txt
# 변경 후
RUN pip install --no-cache-dir -r requirements.txt
# mlx는 Apple Silicon 전용이므로 컨테이너에 설치하지 않는다
```
실제로는 requirements.txt에서 mlx를 제거하는 것만으로 충분하다.
맥미니에서는 수동으로 설치:
```bash
pip install mlx>=0.22.0
```
**Step 3: README 업데이트**
`README.md`의 "Apple Silicon GPU 가속 학습" 섹션에 설치 안내 추가:
```markdown
> **설치**: `mlx`는 Apple Silicon 전용이며 `requirements.txt`에 포함되지 않습니다.
> 맥미니에서 별도 설치: `pip install mlx`
```
**Step 4: 커밋**
```bash
git add requirements.txt Dockerfile README.md
git commit -m "chore: exclude mlx from container requirements (Apple Silicon only)"
```
---
## Task 6: 전체 검증 및 속도 비교
**Step 1: 프로파일러로 최종 속도 측정**
```bash
time .venv/bin/python scripts/train_model.py --data data/xrpusdt_1m.parquet
```
Expected: 10초 이내 (기존 130초 대비 10배+ 향상)
**Step 2: 전체 테스트 통과 확인**
```bash
.venv/bin/python -m pytest tests/ -v
```
Expected: 모든 테스트 통과 (test_retrainer.py 제외)
**Step 3: train_and_deploy.sh 전체 파이프라인 dry-run**
```bash
bash scripts/train_and_deploy.sh 2>&1 | head -30
```
**Step 4: 최종 커밋 없음** — 각 Task에서 이미 커밋 완료

View File

@@ -0,0 +1,150 @@
# ADX 횡보장 필터 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** ADX < 25일 때 get_signal()에서 즉시 HOLD를 반환하여 횡보장 진입을 차단한다.
**Architecture:** `calculate_all()`에서 `pandas_ta.adx()`로 ADX 컬럼을 추가하고, `get_signal()`에서 가중치 계산 전 ADX < 25이면 early-return HOLD. NaN(초기 캔들)은 기존 로직으로 폴백.
**Tech Stack:** pandas-ta (이미 사용 중), pytest
---
### Task 1: ADX 계산 테스트 추가
**Files:**
- Test: `tests/test_indicators.py`
**Step 1: Write the failing test**
```python
def test_adx_column_exists(sample_df):
"""calculate_all()이 adx 컬럼을 생성하는지 확인."""
ind = Indicators(sample_df)
df = ind.calculate_all()
assert "adx" in df.columns
valid = df["adx"].dropna()
assert (valid >= 0).all()
```
`tests/test_indicators.py`에 위 테스트 함수를 추가한다.
**Step 2: Run test to verify it fails**
Run: `pytest tests/test_indicators.py::test_adx_column_exists -v`
Expected: FAIL — `"adx" not in df.columns`
---
### Task 2: calculate_all()에 ADX 계산 추가
**Files:**
- Modify: `src/indicators.py:46-48` (vol_ma20 계산 바로 앞에 추가)
**Step 3: Write minimal implementation**
`calculate_all()`의 Stochastic RSI 계산 뒤, `vol_ma20` 계산 앞에 추가:
```python
# ADX (14) — 횡보장 필터
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
df["adx"] = adx_df["ADX_14"]
```
**Step 4: Run test to verify it passes**
Run: `pytest tests/test_indicators.py::test_adx_column_exists -v`
Expected: PASS
**Step 5: Commit**
```bash
git add src/indicators.py tests/test_indicators.py
git commit -m "feat: add ADX calculation to indicators"
```
---
### Task 3: ADX 필터 테스트 추가 (차단 케이스)
**Files:**
- Test: `tests/test_indicators.py`
**Step 6: Write the failing test**
```python
def test_adx_filter_blocks_low_adx(sample_df):
"""ADX < 25일 때 가중치와 무관하게 HOLD를 반환해야 한다."""
ind = Indicators(sample_df)
df = ind.calculate_all()
# ADX를 강제로 낮은 값으로 설정
df["adx"] = 15.0
signal = ind.get_signal(df)
assert signal == "HOLD"
```
**Step 7: Run test to verify it fails**
Run: `pytest tests/test_indicators.py::test_adx_filter_blocks_low_adx -v`
Expected: FAIL — signal이 LONG 또는 SHORT 반환 (ADX 필터 미구현)
---
### Task 4: ADX 필터 테스트 추가 (NaN 폴백 케이스)
**Files:**
- Test: `tests/test_indicators.py`
**Step 8: Write the failing test**
```python
def test_adx_nan_falls_through(sample_df):
"""ADX가 NaN(초기 캔들)이면 기존 가중치 로직으로 폴백해야 한다."""
ind = Indicators(sample_df)
df = ind.calculate_all()
df["adx"] = float("nan")
signal = ind.get_signal(df)
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
assert signal in ("LONG", "SHORT", "HOLD")
```
**Step 9: Run test to verify it passes (이 테스트는 현재도 통과)**
Run: `pytest tests/test_indicators.py::test_adx_nan_falls_through -v`
Expected: PASS (ADX 컬럼이 무시되므로 기존 로직 그대로)
---
### Task 5: get_signal()에 ADX early-return 구현
**Files:**
- Modify: `src/indicators.py:51-56` (get_signal 메서드 시작부)
**Step 10: Write minimal implementation**
`get_signal()` 메서드의 `last = df.iloc[-1]` 바로 다음에 추가:
```python
# ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단
adx = last.get("adx", None)
if adx is not None and not pd.isna(adx) and adx < 25:
logger.debug(f"ADX 필터: {adx:.1f} < 25 — HOLD")
return "HOLD"
```
**Step 11: Run all ADX-related tests**
Run: `pytest tests/test_indicators.py -k "adx" -v`
Expected: 3 tests PASS
**Step 12: Run full test suite to check for regressions**
Run: `pytest tests/ -v --tb=short`
Expected: All tests PASS
**Step 13: Commit**
```bash
git add src/indicators.py tests/test_indicators.py
git commit -m "feat: add ADX filter to block sideways market entries"
```

View File

@@ -0,0 +1,91 @@
# HOLD Negative Sampling + Stratified Undersampling Design
## Problem
현재 ML 파이프라인의 학습 데이터가 535개로 매우 적음.
`dataset_builder.py`에서 시그널(LONG/SHORT) 발생 캔들만 라벨링하기 때문.
전체 ~35,000개 캔들 중 98.5%가 HOLD로 버려짐.
## Goal
- HOLD 캔들을 negative sample로 활용하여 학습 데이터 증가
- Train-Serve Skew 방지 (학습/추론 데이터 분포 일치)
- 기존 signal 샘플은 하나도 버리지 않는 계층적 샘플링
## Design
### 1. dataset_builder.py — HOLD Negative Sampling
**변경 위치**: `generate_dataset_vectorized()` (line 360-421)
**현재 로직**:
```python
valid_rows = (
(signal_arr != "HOLD") & # ← 시그널 캔들만 선택
...
)
```
**변경 로직**:
1. 기존 시그널 캔들(LONG/SHORT) 라벨링은 그대로 유지
2. HOLD 캔들 중 랜덤 샘플링 (시그널 수의 NEGATIVE_RATIO배)
3. HOLD 캔들: label=0, side=랜덤(50% LONG / 50% SHORT), signal_strength=0
4. `source` 컬럼 추가: "signal" | "hold_negative" (계층적 샘플링에 사용)
**파라미터**:
```python
NEGATIVE_RATIO = 5 # 시그널 대비 HOLD 샘플 비율
RANDOM_SEED = 42 # 재현성
```
**예상 데이터량**:
- 시그널: ~535개 (Win ~200, Loss ~335)
- HOLD negative: ~2,675개
- 총 학습 데이터: ~3,210개
### 2. train_model.py — Stratified Undersampling
**변경 위치**: `train()` 함수 내 언더샘플링 블록 (line 241-257)
**현재 로직**: 양성:음성 = 1:1 블라인드 언더샘플링
```python
if len(neg_idx) > len(pos_idx):
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
```
**변경 로직**: 계층적 3-class 샘플링
```python
# 1. Signal 샘플(source="signal") 전수 유지 (Win + Loss 모두)
# 2. HOLD negative(source="hold_negative")에서만 샘플링
# → 양성(Win) 수와 동일한 수만큼 샘플링
# 최종: Win ~200 + Signal Loss ~335 + HOLD ~200 = ~735개
```
**효과**:
- Signal 샘플 보존율: 100% (Win/Loss 모두)
- HOLD negative: 적절한 양만 추가
- Train-Serve Skew 없음 (추론 시 signal_strength ≥ 3에서만 호출)
### 3. 런타임 (변경 없음)
- `bot.py`: 시그널 발생 시에만 ML 필터 호출 (기존 동일)
- `ml_filter.py`: `should_enter()` 그대로
- `ml_features.py`: `FEATURE_COLS` 그대로
- `label_builder.py`: 기존 SL/TP 룩어헤드 로직 그대로
## Test Cases
### 필수 테스트
1. **HOLD negative label 검증**: HOLD negative 샘플의 label이 전부 0인지 확인
2. **Signal 보존 검증**: 계층적 샘플링 후 source="signal" 샘플이 하나도 버려지지 않았는지 확인
### 기존 테스트 호환성
- 기존 dataset_builder 관련 테스트가 깨지지 않도록 보장
## File Changes
| File | Change |
|------|--------|
| `src/dataset_builder.py` | HOLD negative sampling, source 컬럼 추가 |
| `scripts/train_model.py` | 계층적 샘플링으로 교체 |
| `tests/test_dataset_builder.py` (or equivalent) | 2개 테스트 케이스 추가 |

View File

@@ -0,0 +1,432 @@
# HOLD Negative Sampling Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** HOLD 캔들을 negative sample로 추가하고 계층적 언더샘플링을 도입하여 ML 학습 데이터를 535 → ~3,200개로 증가시킨다.
**Architecture:** `dataset_builder.py`에서 시그널 캔들 외에 HOLD 캔들을 label=0으로 추가 샘플링하고, `source` 컬럼("signal"/"hold_negative")으로 구분한다. 학습 시 signal 샘플은 전수 유지, HOLD negative에서만 양성 수 만큼 샘플링하는 계층적 언더샘플링을 적용한다.
**Tech Stack:** Python, NumPy, pandas, LightGBM, pytest
---
### Task 1: dataset_builder.py — HOLD Negative Sampling 추가
**Files:**
- Modify: `src/dataset_builder.py:360-421` (generate_dataset_vectorized 함수)
- Test: `tests/test_dataset_builder.py`
**Step 1: Write the failing tests**
`tests/test_dataset_builder.py` 끝에 2개 테스트 추가:
```python
def test_hold_negative_labels_are_all_zero(sample_df):
"""HOLD negative 샘플의 label은 전부 0이어야 한다."""
result = generate_dataset_vectorized(sample_df, negative_ratio=3)
if len(result) > 0 and "source" in result.columns:
hold_neg = result[result["source"] == "hold_negative"]
if len(hold_neg) > 0:
assert (hold_neg["label"] == 0).all(), \
f"HOLD negative 중 label != 0인 샘플 존재: {hold_neg['label'].value_counts().to_dict()}"
def test_signal_samples_preserved_after_sampling(sample_df):
"""계층적 샘플링 후 source='signal' 샘플이 하나도 버려지지 않아야 한다."""
# negative_ratio=0이면 기존 동작 (signal만), >0이면 HOLD 추가
result_signal_only = generate_dataset_vectorized(sample_df, negative_ratio=0)
result_with_hold = generate_dataset_vectorized(sample_df, negative_ratio=3)
if len(result_with_hold) > 0 and "source" in result_with_hold.columns:
signal_count = (result_with_hold["source"] == "signal").sum()
assert signal_count == len(result_signal_only), \
f"Signal 샘플 손실: 원본={len(result_signal_only)}, 유지={signal_count}"
```
**Step 2: Run tests to verify they fail**
Run: `pytest tests/test_dataset_builder.py::test_hold_negative_labels_are_all_zero tests/test_dataset_builder.py::test_signal_samples_preserved_after_sampling -v`
Expected: FAIL — `generate_dataset_vectorized()` does not accept `negative_ratio` parameter
**Step 3: Implement HOLD negative sampling in generate_dataset_vectorized**
`src/dataset_builder.py``generate_dataset_vectorized()` 함수를 수정한다.
시그니처에 `negative_ratio: int = 0` 파라미터를 추가하고, HOLD 캔들 샘플링 로직을 삽입한다.
수정 대상: `generate_dataset_vectorized` 함수 전체.
```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 = 0,
) -> pd.DataFrame:
"""
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
negative_ratio: 시그널 샘플 대비 HOLD negative 샘플 비율.
0이면 기존 동작 (시그널만). 5면 시그널의 5배만큼 HOLD 샘플 추가.
"""
print(" [1/3] 전체 시계열 지표 계산 (1회)...")
d = _calc_indicators(df)
print(" [2/3] 신호 마스킹 및 피처 추출...")
signal_arr = _calc_signals(d)
feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
# 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만
OPTIONAL_COLS = {"oi_change", "funding_rate"}
available_cols_for_nan_check = [
c for c in FEATURE_COLS
if c in feat_all.columns and c not in OPTIONAL_COLS
]
base_valid = (
(~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) &
(np.arange(len(d)) >= WARMUP) &
(np.arange(len(d)) < len(d) - LOOKAHEAD)
)
# --- 시그널 캔들 (기존 로직) ---
sig_valid = base_valid & (signal_arr != "HOLD")
sig_idx = np.where(sig_valid)[0]
print(f" 신호 발생 인덱스: {len(sig_idx):,}")
print(" [3/3] 레이블 계산...")
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx)
final_sig_idx = sig_idx[valid_mask]
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns]
feat_signal = feat_all.iloc[final_sig_idx][available_feature_cols].copy()
feat_signal["label"] = labels
feat_signal["source"] = "signal"
# --- HOLD negative 캔들 ---
if negative_ratio > 0 and len(final_sig_idx) > 0:
hold_valid = base_valid & (signal_arr == "HOLD")
hold_candidates = np.where(hold_valid)[0]
n_neg = min(len(hold_candidates), len(final_sig_idx) * negative_ratio)
if n_neg > 0:
rng = np.random.default_rng(42)
hold_idx = rng.choice(hold_candidates, size=n_neg, replace=False)
hold_idx = np.sort(hold_idx)
feat_hold = feat_all.iloc[hold_idx][available_feature_cols].copy()
feat_hold["label"] = 0
feat_hold["source"] = "hold_negative"
# HOLD 캔들은 시그널이 없으므로 side를 랜덤 할당 (50:50)
sides = rng.integers(0, 2, size=len(feat_hold)).astype(np.float32)
feat_hold["side"] = sides
# signal_strength는 이미 0 (시그널 미발생이므로)
print(f" HOLD negative 추가: {len(feat_hold):,}"
f"(비율 1:{negative_ratio})")
feat_final = pd.concat([feat_signal, feat_hold], ignore_index=True)
# 시간 순서 복원 (원본 인덱스 기반 정렬)
original_order = np.concatenate([final_sig_idx, hold_idx])
sort_order = np.argsort(original_order)
feat_final = feat_final.iloc[sort_order].reset_index(drop=True)
else:
feat_final = feat_signal.reset_index(drop=True)
else:
feat_final = feat_signal.reset_index(drop=True)
# 시간 가중치
n = len(feat_final)
if time_weight_decay > 0 and n > 1:
weights = np.exp(time_weight_decay * np.linspace(0.0, 1.0, n)).astype(np.float32)
weights /= weights.mean()
print(f" 시간 가중치 적용 (decay={time_weight_decay}): "
f"min={weights.min():.3f}, max={weights.max():.3f}")
else:
weights = np.ones(n, dtype=np.float32)
feat_final["sample_weight"] = weights
total_sig = (feat_final["source"] == "signal").sum() if "source" in feat_final.columns else len(feat_final)
total_hold = (feat_final["source"] == "hold_negative").sum() if "source" in feat_final.columns else 0
print(f" 최종 데이터셋: {n:,}개 (시그널={total_sig:,}, HOLD={total_hold:,})")
return feat_final
```
**Step 4: Run the new tests to verify they pass**
Run: `pytest tests/test_dataset_builder.py::test_hold_negative_labels_are_all_zero tests/test_dataset_builder.py::test_signal_samples_preserved_after_sampling -v`
Expected: PASS
**Step 5: Run all existing dataset_builder tests to verify no regressions**
Run: `pytest tests/test_dataset_builder.py -v`
Expected: All existing tests PASS (기존 동작은 negative_ratio=0 기본값으로 유지)
**Step 6: Commit**
```bash
git add src/dataset_builder.py tests/test_dataset_builder.py
git commit -m "feat: add HOLD negative sampling to dataset builder"
```
---
### Task 2: 계층적 언더샘플링 헬퍼 함수
**Files:**
- Modify: `src/dataset_builder.py` (파일 끝에 헬퍼 추가)
- Test: `tests/test_dataset_builder.py`
**Step 1: Write the failing test**
```python
def test_stratified_undersample_preserves_signal():
"""stratified_undersample은 signal 샘플을 전수 유지해야 한다."""
from src.dataset_builder import stratified_undersample
y = np.array([1, 0, 0, 0, 0, 0, 0, 0, 1, 0])
source = np.array(["signal", "signal", "signal", "hold_negative",
"hold_negative", "hold_negative", "hold_negative",
"hold_negative", "signal", "signal"])
idx = stratified_undersample(y, source, seed=42)
# signal 인덱스: 0, 1, 2, 8, 9 → 전부 포함
signal_indices = np.where(source == "signal")[0]
for si in signal_indices:
assert si in idx, f"signal 인덱스 {si}가 누락됨"
```
**Step 2: Run test to verify it fails**
Run: `pytest tests/test_dataset_builder.py::test_stratified_undersample_preserves_signal -v`
Expected: FAIL — `stratified_undersample` 함수 미존재
**Step 3: Implement stratified_undersample**
`src/dataset_builder.py` 끝에 추가:
```python
def stratified_undersample(
y: np.ndarray,
source: np.ndarray,
seed: int = 42,
) -> np.ndarray:
"""Signal 샘플 전수 유지 + HOLD negative만 양성 수 만큼 샘플링.
Args:
y: 라벨 배열 (0 or 1)
source: 소스 배열 ("signal" or "hold_negative")
seed: 랜덤 시드
Returns:
정렬된 인덱스 배열 (학습에 사용할 행 인덱스)
"""
pos_idx = np.where(y == 1)[0] # Signal Win
sig_neg_idx = np.where((y == 0) & (source == "signal"))[0] # Signal Loss
hold_neg_idx = np.where(source == "hold_negative")[0] # HOLD negative
# HOLD negative에서 양성 수 만큼만 샘플링
n_hold = min(len(hold_neg_idx), len(pos_idx))
rng = np.random.default_rng(seed)
if n_hold > 0:
hold_sampled = rng.choice(hold_neg_idx, size=n_hold, replace=False)
else:
hold_sampled = np.array([], dtype=np.intp)
return np.sort(np.concatenate([pos_idx, sig_neg_idx, hold_sampled]))
```
**Step 4: Run tests**
Run: `pytest tests/test_dataset_builder.py::test_stratified_undersample_preserves_signal -v`
Expected: PASS
**Step 5: Commit**
```bash
git add src/dataset_builder.py tests/test_dataset_builder.py
git commit -m "feat: add stratified_undersample helper function"
```
---
### Task 3: train_model.py — 계층적 언더샘플링 적용
**Files:**
- Modify: `scripts/train_model.py:229-257` (train 함수)
- Modify: `scripts/train_model.py:356-391` (walk_forward_auc 함수)
**Step 1: Update train() function**
`scripts/train_model.py`에서 `dataset_builder`에서 `stratified_undersample`을 import하고,
`train()` 함수의 언더샘플링 블록을 교체한다.
import 수정 (line 25):
```python
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
```
`train()` 함수에서 데이터셋 생성 호출에 `negative_ratio=5` 추가 (line 217):
```python
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
negative_ratio=5,
)
```
source 배열 추출 추가 (line 231 부근, w 다음):
```python
source = dataset["source"].values if "source" in dataset.columns else np.full(len(X), "signal")
```
언더샘플링 블록 교체 (line 241-257):
```python
# --- 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼 ---
source_train = source[:split]
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
X_train = X_train.iloc[balanced_idx]
y_train = y_train.iloc[balanced_idx]
w_train = w_train[balanced_idx]
sig_count = (source_train[balanced_idx] == "signal").sum()
hold_count = (source_train[balanced_idx] == "hold_negative").sum()
print(f"\n계층적 샘플링 후 학습 데이터: {len(X_train)}"
f"(Signal={sig_count}, HOLD={hold_count}, "
f"양성={int(y_train.sum())}, 음성={int((y_train==0).sum())})")
print(f"검증 데이터: {len(X_val)}개 (양성={int(y_val.sum())}, 음성={int((y_val==0).sum())})")
```
**Step 2: Update walk_forward_auc() function**
`walk_forward_auc()` 함수에서도 동일하게 적용.
dataset 생성 (line 356-358)에 `negative_ratio=5` 추가:
```python
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
negative_ratio=5,
)
```
source 배열 추출 (line 362 부근):
```python
source = dataset["source"].values if "source" in dataset.columns else np.full(n, "signal")
```
폴드 내 언더샘플링 교체 (line 381-386):
```python
source_tr = source[:tr_end]
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
```
**Step 3: Run training to verify**
Run: `python scripts/train_model.py --data data/combined_15m.parquet --decay 2.0`
Expected: 학습 샘플 수 대폭 증가 확인 (기존 ~535 → ~3,200)
**Step 4: Commit**
```bash
git add scripts/train_model.py
git commit -m "feat: apply stratified undersampling to training pipeline"
```
---
### Task 4: tune_hyperparams.py — 계층적 언더샘플링 적용
**Files:**
- Modify: `scripts/tune_hyperparams.py:41-81` (load_dataset)
- Modify: `scripts/tune_hyperparams.py:88-144` (_walk_forward_cv)
- Modify: `scripts/tune_hyperparams.py:151-206` (make_objective)
- Modify: `scripts/tune_hyperparams.py:213-244` (measure_baseline)
- Modify: `scripts/tune_hyperparams.py:370-449` (main)
**Step 1: Update load_dataset to return source**
import 수정 (line 34):
```python
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
```
`load_dataset()` 시그니처와 반환값 수정:
```python
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
```
dataset 생성에 `negative_ratio=5` 추가 (line 66):
```python
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0, negative_ratio=5)
```
source 추출 추가 (line 74 부근, w 다음):
```python
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
```
return 수정:
```python
return X, y, w, source
```
**Step 2: Update _walk_forward_cv to accept and use source**
시그니처에 source 추가:
```python
def _walk_forward_cv(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
source: np.ndarray,
params: dict,
...
```
폴드 내 언더샘플링 교체 (line 117-122):
```python
source_tr = source[:tr_end]
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
```
**Step 3: Update make_objective, measure_baseline, main**
`make_objective()`: 클로저에 source 캡처, `_walk_forward_cv` 호출에 source 전달
`measure_baseline()`: source 파라미터 추가, `_walk_forward_cv` 호출에 전달
`main()`: `load_dataset` 반환값 4개로 변경, 하위 함수에 source 전달
**Step 4: Commit**
```bash
git add scripts/tune_hyperparams.py
git commit -m "feat: apply stratified undersampling to hyperparameter tuning"
```
---
### Task 5: 전체 테스트 실행 및 검증
**Step 1: Run full test suite**
Run: `bash scripts/run_tests.sh`
Expected: All tests PASS
**Step 2: Run training pipeline end-to-end**
Run: `python scripts/train_model.py --data data/combined_15m.parquet --decay 2.0`
Expected:
- 학습 샘플 ~3,200개 (기존 535)
- "계층적 샘플링 후" 로그에 Signal/HOLD 카운트 표시
- AUC 출력 (값 자체보다 실행 완료가 중요)
**Step 3: Commit final state**
```bash
git add -A
git commit -m "chore: verify HOLD negative sampling pipeline end-to-end"
```

View File

@@ -0,0 +1,394 @@
# OI/펀딩비 누적 저장 (접근법 B) 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** `fetch_history.py`의 데이터 수집 방식을 덮어쓰기(Overwrite)에서 Upsert(병합)로 변경해, 매일 실행할 때마다 기존 parquet의 OI/펀딩비 0.0 구간이 실제 값으로 채워지며 고품질 데이터가 무한히 누적되도록 한다.
**Architecture:**
- `fetch_history.py``--upsert` 플래그 추가 (기본값 True). 기존 parquet이 있으면 로드 후 신규 데이터와 timestamp 기준 병합(Upsert). 없으면 기존처럼 새로 생성.
- Upsert 규칙: 기존 행의 `oi_change` / `funding_rate`가 0.0이면 신규 값으로 덮어씀. 신규 행은 그냥 추가. 중복 제거 후 시간순 정렬.
- `train_and_deploy.sh``--days` 인자를 35일로 조정 (30일 API 한도 + 5일 버퍼).
- LXC 운영서버는 모델 파일만 받으므로 변경 없음. 맥미니의 `data/` 폴더에만 누적.
**Tech Stack:** pandas, parquet (pyarrow), pytest
---
## Task 1: fetch_history.py — upsert_parquet() 함수 추가 및 --upsert 플래그
**Files:**
- Modify: `scripts/fetch_history.py`
- Test: `tests/test_fetch_history.py` (신규 생성)
### Step 1: 실패 테스트 작성
`tests/test_fetch_history.py` 파일을 새로 만든다.
```python
"""fetch_history.py의 upsert_parquet() 함수 테스트."""
import pandas as pd
import numpy as np
import pytest
from pathlib import Path
def _make_parquet(tmp_path: Path, rows: dict) -> Path:
"""테스트용 parquet 파일 생성 헬퍼."""
df = pd.DataFrame(rows)
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
df = df.set_index("timestamp")
path = tmp_path / "test.parquet"
df.to_parquet(path)
return path
def test_upsert_fills_zero_oi_with_real_value(tmp_path):
"""기존 행의 oi_change=0.0이 신규 데이터의 실제 값으로 덮어써진다."""
from scripts.fetch_history import upsert_parquet
existing_path = _make_parquet(tmp_path, {
"timestamp": ["2026-01-01 00:00", "2026-01-01 00:15"],
"close": [1.0, 1.1],
"oi_change": [0.0, 0.0],
"funding_rate": [0.0, 0.0],
})
new_df = pd.DataFrame({
"close": [1.0, 1.1],
"oi_change": [0.05, 0.03],
"funding_rate": [0.0001, 0.0001],
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
new_df.index.name = "timestamp"
result = upsert_parquet(existing_path, new_df)
assert result.loc["2026-01-01 00:00+00:00", "oi_change"] == pytest.approx(0.05)
assert result.loc["2026-01-01 00:15+00:00", "oi_change"] == pytest.approx(0.03)
def test_upsert_appends_new_rows(tmp_path):
"""신규 타임스탬프 행이 기존 데이터 아래에 추가된다."""
from scripts.fetch_history import upsert_parquet
existing_path = _make_parquet(tmp_path, {
"timestamp": ["2026-01-01 00:00"],
"close": [1.0],
"oi_change": [0.05],
"funding_rate": [0.0001],
})
new_df = pd.DataFrame({
"close": [1.1],
"oi_change": [0.03],
"funding_rate": [0.0002],
}, index=pd.to_datetime(["2026-01-01 00:15"], utc=True))
new_df.index.name = "timestamp"
result = upsert_parquet(existing_path, new_df)
assert len(result) == 2
assert "2026-01-01 00:15+00:00" in result.index.astype(str).tolist() or \
pd.Timestamp("2026-01-01 00:15", tz="UTC") in result.index
def test_upsert_keeps_nonzero_existing_oi(tmp_path):
"""기존 행의 oi_change가 이미 0이 아니면 덮어쓰지 않는다."""
from scripts.fetch_history import upsert_parquet
existing_path = _make_parquet(tmp_path, {
"timestamp": ["2026-01-01 00:00"],
"close": [1.0],
"oi_change": [0.07], # 이미 실제 값 존재
"funding_rate": [0.0003],
})
new_df = pd.DataFrame({
"close": [1.0],
"oi_change": [0.05], # 다른 값으로 덮어쓰려 해도
"funding_rate": [0.0001],
}, index=pd.to_datetime(["2026-01-01 00:00"], utc=True))
new_df.index.name = "timestamp"
result = upsert_parquet(existing_path, new_df)
# 기존 값(0.07)이 유지되어야 한다
assert result.iloc[0]["oi_change"] == pytest.approx(0.07)
def test_upsert_no_existing_file_returns_new_df(tmp_path):
"""기존 parquet 파일이 없으면 신규 데이터를 그대로 반환한다."""
from scripts.fetch_history import upsert_parquet
nonexistent_path = tmp_path / "nonexistent.parquet"
new_df = pd.DataFrame({
"close": [1.0, 1.1],
"oi_change": [0.05, 0.03],
"funding_rate": [0.0001, 0.0001],
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
new_df.index.name = "timestamp"
result = upsert_parquet(nonexistent_path, new_df)
assert len(result) == 2
assert result.iloc[0]["oi_change"] == pytest.approx(0.05)
def test_upsert_result_is_sorted_by_timestamp(tmp_path):
"""결과 DataFrame이 timestamp 기준 오름차순 정렬되어 있다."""
from scripts.fetch_history import upsert_parquet
existing_path = _make_parquet(tmp_path, {
"timestamp": ["2026-01-01 00:15"],
"close": [1.1],
"oi_change": [0.0],
"funding_rate": [0.0],
})
new_df = pd.DataFrame({
"close": [1.0, 1.1, 1.2],
"oi_change": [0.05, 0.03, 0.02],
"funding_rate": [0.0001, 0.0001, 0.0002],
}, index=pd.to_datetime(
["2026-01-01 00:00", "2026-01-01 00:15", "2026-01-01 00:30"], utc=True
))
new_df.index.name = "timestamp"
result = upsert_parquet(existing_path, new_df)
assert result.index.is_monotonic_increasing
assert len(result) == 3
```
### Step 2: 테스트 실패 확인
```bash
.venv/bin/pytest tests/test_fetch_history.py -v
```
Expected: `FAILED``ImportError: cannot import name 'upsert_parquet' from 'scripts.fetch_history'`
### Step 3: fetch_history.py에 upsert_parquet() 함수 구현
`scripts/fetch_history.py``main()` 함수 바로 위에 추가한다.
```python
def upsert_parquet(path: Path | str, new_df: pd.DataFrame) -> pd.DataFrame:
"""
기존 parquet 파일에 신규 데이터를 Upsert(병합)한다.
규칙:
- 기존 행의 oi_change / funding_rate가 0.0이면 신규 값으로 덮어씀
- 기존 행의 oi_change / funding_rate가 이미 0이 아니면 유지
- 신규 타임스탬프 행은 그냥 추가
- 결과는 timestamp 기준 오름차순 정렬, 중복 제거
Args:
path: 기존 parquet 경로 (없으면 new_df 그대로 반환)
new_df: 새로 수집한 DataFrame (timestamp index)
Returns:
병합된 DataFrame
"""
path = Path(path)
if not path.exists():
return new_df.sort_index()
existing = pd.read_parquet(path)
# timestamp index 통일 (tz-aware UTC)
if existing.index.tz is None:
existing.index = existing.index.tz_localize("UTC")
if new_df.index.tz is None:
new_df.index = new_df.index.tz_localize("UTC")
# 기존 데이터에서 oi_change / funding_rate가 0.0인 행만 신규 값으로 업데이트
UPSERT_COLS = ["oi_change", "funding_rate"]
overlap_idx = existing.index.intersection(new_df.index)
for col in UPSERT_COLS:
if col not in existing.columns or col not in new_df.columns:
continue
# 겹치는 행 중 기존 값이 0.0인 경우에만 신규 값으로 교체
zero_mask = existing.loc[overlap_idx, col] == 0.0
update_idx = overlap_idx[zero_mask]
if len(update_idx) > 0:
existing.loc[update_idx, col] = new_df.loc[update_idx, col]
# 신규 타임스탬프 행 추가 (기존에 없는 것만)
new_only_idx = new_df.index.difference(existing.index)
if len(new_only_idx) > 0:
existing = pd.concat([existing, new_df.loc[new_only_idx]])
return existing.sort_index()
```
### Step 4: main()에 --upsert 플래그 추가 및 저장 로직 수정
`main()` 함수의 `parser` 정의 부분에 인자 추가:
```python
parser.add_argument(
"--no-upsert", action="store_true",
help="기존 parquet을 Upsert하지 않고 새로 덮어씀 (기본: Upsert 활성화)",
)
```
그리고 단일 심볼 저장 부분:
```python
# 기존:
df.to_parquet(args.output)
# 변경:
if not args.no_upsert:
df = upsert_parquet(args.output, df)
df.to_parquet(args.output)
```
멀티 심볼 저장 부분도 동일하게:
```python
# 기존:
merged.to_parquet(output)
# 변경:
if not args.no_upsert:
merged = upsert_parquet(output, merged)
merged.to_parquet(output)
```
### Step 5: 테스트 통과 확인
```bash
.venv/bin/pytest tests/test_fetch_history.py -v
```
Expected: 전체 PASS
### Step 6: 커밋
```bash
git add scripts/fetch_history.py tests/test_fetch_history.py
git commit -m "feat: add upsert_parquet to accumulate OI/funding data incrementally"
```
---
## Task 2: train_and_deploy.sh — 데이터 수집 일수 35일로 조정
**Files:**
- Modify: `scripts/train_and_deploy.sh`
### Step 1: 현재 상태 확인
`scripts/train_and_deploy.sh`에서 `--days 365` 부분을 찾는다.
### Step 2: 수정
`train_and_deploy.sh`에서 `fetch_history.py` 호출 부분을 수정한다.
기존:
```bash
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 15m \
--days 365 \
--output data/combined_15m.parquet
```
변경:
```bash
# OI/펀딩비 API 제한(30일) + 버퍼 5일 = 35일치 신규 수집 후 기존 parquet에 Upsert
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 15m \
--days 35 \
--output data/combined_15m.parquet
```
**이유**: 매일 실행 시 35일치만 새로 가져와 기존 누적 parquet에 Upsert한다.
- 최초 실행 시(`data/combined_15m.parquet` 없음): 35일치로 시작
- 이후 매일: 35일치 신규 데이터로 기존 파일의 0.0 구간을 채우고 최신 행 추가
- 시간이 지날수록 OI/펀딩비 실제 값이 있는 구간이 1달 → 2달 → ... 로 늘어남
**주의**: 최초 실행 시 캔들 데이터도 35일치만 있으므로, 첫 실행은 수동으로
`--days 365 --no-upsert`로 전체 캔들을 먼저 수집하는 것을 권장한다.
README에 이 내용을 추가한다.
### Step 3: 커밋
```bash
git add scripts/train_and_deploy.sh
git commit -m "feat: fetch 35 days for daily upsert instead of overwriting 365 days"
```
---
## Task 3: 전체 테스트 통과 확인 및 README 업데이트
### Step 1: 전체 테스트 실행
```bash
.venv/bin/pytest tests/ --ignore=tests/test_mlx_filter.py --ignore=tests/test_database.py -v
```
Expected: 전체 PASS
### Step 2: README.md 업데이트
**"ML 모델 학습" 섹션의 "전체 파이프라인 (권장)" 부분 아래에 아래 내용을 추가한다:**
```markdown
### 최초 실행 (캔들 전체 수집)
처음 실행하거나 `data/combined_15m.parquet`가 없을 때는 전체 캔들을 먼저 수집한다.
이후 매일 크론탭이 `train_and_deploy.sh`를 실행하면 35일치 신규 데이터가 자동으로 Upsert된다.
```bash
# 최초 1회: 1년치 캔들 전체 수집 (OI/펀딩비는 최근 30일만 실제 값, 나머지 0.0)
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 15m \
--days 365 \
--no-upsert \
--output data/combined_15m.parquet
# 이후 매일 자동 실행 (크론탭 또는 train_and_deploy.sh):
# 35일치 신규 데이터를 기존 파일에 Upsert → OI/펀딩비 0.0 구간이 야금야금 채워짐
bash scripts/train_and_deploy.sh
```
```
**"주요 기능" 섹션에 아래 항목 추가:**
```markdown
- **OI/펀딩비 누적 학습**: 매일 35일치 신규 데이터를 기존 parquet에 Upsert. 시간이 지날수록 실제 OI/펀딩비 값이 있는 학습 구간이 1달 → 2달 → 반년으로 늘어남
```
### Step 3: 최종 커밋
```bash
git add README.md
git commit -m "docs: document OI/funding incremental accumulation strategy"
```
---
## 구현 후 검증 포인트
1. `data/combined_15m.parquet`에서 날짜별 `oi_change` 값 분포 확인:
```python
import pandas as pd
df = pd.read_parquet("data/combined_15m.parquet")
print(df["oi_change"].describe())
print((df["oi_change"] == 0.0).sum(), "개 행이 아직 0.0")
```
2. 매일 실행 후 0.0 행 수가 줄어드는지 확인
3. 모델 학습 시 `oi_change` / `funding_rate` 피처의 non-zero 비율이 증가하는지 확인
---
## 아키텍처 메모 (LXC 운영서버 관련)
- **LXC 운영서버(10.1.10.24)**: 변경 없음. 모델 파일(`*.pkl` / `*.onnx`)만 받음
- **맥미니**: `data/combined_15m.parquet`를 누적 보관. 매일 35일치 Upsert 후 학습
- **데이터 흐름**: 맥미니 parquet 누적 → 학습 → 모델 → LXC 배포
- **봇 실시간 OI/펀딩비**: 접근법 A(Task 1~4)에서 이미 구현됨. LXC 봇이 캔들마다 REST API로 실시간 수집

View File

@@ -0,0 +1,184 @@
# Optuna 하이퍼파라미터 자동 튜닝 설계 문서
**작성일:** 2026-03-02
**목표:** 봇 운영 로그/학습 결과를 바탕으로 LightGBM 하이퍼파라미터를 Optuna로 자동 탐색하고, 사람이 결과를 확인·승인한 후 재학습에 반영하는 수동 트리거 파이프라인 구축
---
## 배경 및 동기
현재 `train_model.py`의 LightGBM 파라미터는 하드코딩되어 있다. 봇 성능이 저하되거나 데이터가 축적될 때마다 사람이 직접 파라미터를 조정해야 한다. 이를 Optuna로 자동화하되, 과적합 위험을 방지하기 위해 **사람이 결과를 먼저 확인하고 승인하는 구조**를 유지한다.
---
## 구현 범위 (2단계)
### 1단계 (현재): LightGBM 하이퍼파라미터 튜닝
- `scripts/tune_hyperparams.py` 신규 생성
- Optuna + Walk-Forward AUC 목적 함수
- 결과를 JSON + 콘솔 리포트로 출력
### 2단계 (추후): 기술 지표 파라미터 확장
- RSI 임계값, MACD 가중치, Stochastic RSI 임계값, 거래량 배수, 진입 점수 임계값 등을 탐색 공간에 추가
- `dataset_builder.py``_calc_signals()` 파라미터화 필요
---
## 아키텍처
```
scripts/tune_hyperparams.py
├── load_dataset() ← 데이터 로드 + 벡터화 데이터셋 1회 생성 (캐싱)
├── objective(trial, dataset) ← Optuna trial 함수
│ ├── trial.suggest_*() ← 하이퍼파라미터 샘플링
│ ├── num_leaves 상한 강제 ← 2^max_depth - 1 제약
│ └── _walk_forward_cv() ← Walk-Forward 교차검증 → 평균 AUC 반환
├── run_study() ← Optuna study 실행 (TPESampler + MedianPruner)
├── print_report() ← 콘솔 리포트 출력
└── save_results() ← JSON 저장 (models/tune_results_YYYYMMDD_HHMMSS.json)
```
---
## 탐색 공간 (소규모 데이터셋 보수적 설계)
| 파라미터 | 범위 | 타입 | 근거 |
|---|---|---|---|
| `n_estimators` | 100 ~ 600 | int | 데이터 적을 때 500+ 트리는 과적합 |
| `learning_rate` | 0.01 ~ 0.2 | float (log) | 낮을수록 일반화 유리 |
| `max_depth` | 2 ~ 7 | int | 트리 깊이 상한 강제 |
| `num_leaves` | 7 ~ min(31, 2^max_depth-1) | int | **핵심**: leaf-wise 과적합 방지 |
| `min_child_samples` | 10 ~ 50 | int | 리프당 최소 샘플 수 |
| `subsample` | 0.5 ~ 1.0 | float | 행 샘플링 |
| `colsample_bytree` | 0.5 ~ 1.0 | float | 열 샘플링 |
| `reg_alpha` | 1e-4 ~ 1.0 | float (log) | L1 정규화 |
| `reg_lambda` | 1e-4 ~ 1.0 | float (log) | L2 정규화 |
| `time_weight_decay` | 0.5 ~ 4.0 | float | 시간 가중치 강도 |
### 핵심 제약: `num_leaves <= 2^max_depth - 1`
LightGBM은 leaf-wise 성장 전략을 사용하므로, `num_leaves``2^max_depth - 1`을 초과하면 `max_depth` 제약이 무의미해진다. trial 내에서 `max_depth`를 먼저 샘플링한 후 `num_leaves` 상한을 동적으로 계산하여 강제한다.
```python
max_depth = trial.suggest_int("max_depth", 2, 7)
max_leaves = min(31, 2 ** max_depth - 1)
num_leaves = trial.suggest_int("num_leaves", 7, max_leaves)
```
---
## 목적 함수: Walk-Forward AUC
기존 `train_model.py``walk_forward_auc()` 로직을 재활용한다. 데이터셋은 study 시작 전 1회만 생성하여 모든 trial이 공유한다 (속도 최적화).
```
전체 데이터셋 (N개 샘플)
├── 폴드 1: 학습[0:60%] → 검증[60%:68%]
├── 폴드 2: 학습[0:68%] → 검증[68%:76%]
├── 폴드 3: 학습[0:76%] → 검증[76%:84%]
├── 폴드 4: 학습[0:84%] → 검증[84%:92%]
└── 폴드 5: 학습[0:92%] → 검증[92%:100%]
목적 함수 = 5폴드 평균 AUC (최대화)
```
### Pruning (조기 종료)
`MedianPruner` 적용: 각 폴드 완료 후 중간 AUC를 Optuna에 보고. 이전 trial들의 중앙값보다 낮으면 나머지 폴드를 건너뛰고 trial 종료. 전체 탐색 시간 ~40% 단축 효과.
---
## 출력 형식
### 콘솔 리포트
```
============================================================
Optuna 튜닝 완료 | 50 trials | 소요: 28분 42초
============================================================
Best AUC : 0.6234 (Trial #31)
Baseline : 0.5891 (현재 train_model.py 고정값)
개선폭 : +0.0343 (+5.8%)
------------------------------------------------------------
Best Parameters:
n_estimators : 320
learning_rate : 0.0412
max_depth : 4
num_leaves : 15
min_child_samples : 28
subsample : 0.72
colsample_bytree : 0.81
reg_alpha : 0.0023
reg_lambda : 0.0891
time_weight_decay : 2.31
------------------------------------------------------------
Walk-Forward 폴드별 AUC:
폴드 1: 0.6102
폴드 2: 0.6341
폴드 3: 0.6198
폴드 4: 0.6287
폴드 5: 0.6241
평균: 0.6234 ± 0.0082
------------------------------------------------------------
결과 저장: models/tune_results_20260302_143022.json
다음 단계: python scripts/train_model.py --tuned-params models/tune_results_20260302_143022.json
============================================================
```
### JSON 저장 (`models/tune_results_YYYYMMDD_HHMMSS.json`)
```json
{
"timestamp": "2026-03-02T14:30:22",
"n_trials": 50,
"elapsed_sec": 1722,
"baseline_auc": 0.5891,
"best_trial": {
"number": 31,
"auc": 0.6234,
"fold_aucs": [0.6102, 0.6341, 0.6198, 0.6287, 0.6241],
"params": { ... }
},
"all_trials": [ ... ]
}
```
---
## 사용법
```bash
# 기본 실행 (50 trials, 5폴드)
python scripts/tune_hyperparams.py
# 빠른 테스트 (10 trials, 3폴드)
python scripts/tune_hyperparams.py --trials 10 --folds 3
# 데이터 경로 지정
python scripts/tune_hyperparams.py --data data/combined_15m.parquet --trials 100
```
---
## 파일 변경 목록
| 파일 | 변경 | 설명 |
|---|---|---|
| `scripts/tune_hyperparams.py` | **신규 생성** | Optuna 튜닝 스크립트 |
| `requirements.txt` | **수정** | `optuna` 의존성 추가 |
| `README.md` | **수정** | 튜닝 사용법 섹션 추가 |
---
## 향후 확장 (2단계)
`dataset_builder.py``_calc_signals()` 함수를 파라미터화하여 기술 지표 임계값도 탐색 공간에 추가:
```python
# 추가될 탐색 공간 예시
rsi_long_threshold = trial.suggest_int("rsi_long", 25, 40)
rsi_short_threshold = trial.suggest_int("rsi_short", 60, 75)
vol_surge_mult = trial.suggest_float("vol_surge_mult", 1.2, 2.5)
entry_threshold = trial.suggest_int("entry_threshold", 3, 5)
stoch_low = trial.suggest_int("stoch_low", 10, 30)
stoch_high = trial.suggest_int("stoch_high", 70, 90)
```

View File

@@ -0,0 +1,569 @@
# Optuna 하이퍼파라미터 자동 튜닝 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** `scripts/tune_hyperparams.py`를 신규 생성하여 Optuna + Walk-Forward AUC 기반 LightGBM 하이퍼파라미터 자동 탐색 파이프라인을 구축한다.
**Architecture:** 데이터셋을 study 시작 전 1회만 생성해 캐싱하고, 각 Optuna trial에서 LightGBM 파라미터를 샘플링 → Walk-Forward 5폴드 AUC를 목적 함수로 최대화한다. `num_leaves <= 2^max_depth - 1` 제약을 코드 레벨에서 강제하여 소규모 데이터셋 과적합을 방지한다. 결과는 콘솔 리포트 + JSON 파일로 출력한다.
**Tech Stack:** Python 3.11+, optuna, lightgbm, numpy, pandas, scikit-learn (기존 의존성 재활용)
**설계 문서:** `docs/plans/2026-03-02-optuna-hyperparam-tuning-design.md`
---
## Task 1: optuna 의존성 추가
**Files:**
- Modify: `requirements.txt`
**Step 1: requirements.txt에 optuna 추가**
```
optuna>=3.6.0
```
`requirements.txt` 파일 끝에 추가한다.
**Step 2: 설치 확인 (로컬)**
```bash
pip install optuna
python -c "import optuna; print(optuna.__version__)"
```
Expected: 버전 번호 출력 (예: `3.6.0`)
**Step 3: Commit**
```bash
git add requirements.txt
git commit -m "feat: add optuna dependency for hyperparameter tuning"
```
---
## Task 2: `scripts/tune_hyperparams.py` 핵심 구조 생성
**Files:**
- Create: `scripts/tune_hyperparams.py`
**Step 1: 파일 생성 — 전체 코드**
아래 코드를 `scripts/tune_hyperparams.py`로 저장한다.
```python
"""
Optuna를 사용한 LightGBM 하이퍼파라미터 자동 탐색.
사용법:
python scripts/tune_hyperparams.py # 기본 (50 trials, 5폴드)
python scripts/tune_hyperparams.py --trials 10 --folds 3 # 빠른 테스트
python scripts/tune_hyperparams.py --data data/combined_15m.parquet --trials 100
결과:
- 콘솔: Best Params + Walk-Forward 리포트
- JSON: models/tune_results_YYYYMMDD_HHMMSS.json
"""
import sys
import warnings
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import json
import time
from datetime import datetime
import numpy as np
import pandas as pd
import lightgbm as lgb
import optuna
from optuna.samplers import TPESampler
from optuna.pruners import MedianPruner
from sklearn.metrics import roc_auc_score
from src.ml_features import FEATURE_COLS
from src.dataset_builder import generate_dataset_vectorized
# ──────────────────────────────────────────────
# 데이터 로드 및 데이터셋 생성 (1회 캐싱)
# ──────────────────────────────────────────────
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
study 시작 전 1회만 호출하여 모든 trial이 공유한다.
"""
print(f"데이터 로드: {data_path}")
df_raw = pd.read_parquet(data_path)
print(f"캔들 수: {len(df_raw):,}, 컬럼: {list(df_raw.columns)}")
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
print("BTC 피처 활성화")
if "close_eth" in df_raw.columns:
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
eth_df.columns = base_cols
print("ETH 피처 활성화")
df = df_raw[base_cols].copy()
print("\n데이터셋 생성 중 (1회만 실행)...")
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0)
if dataset.empty or "label" not in dataset.columns:
raise ValueError("데이터셋 생성 실패: 샘플 0개")
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
X = dataset[actual_feature_cols].values.astype(np.float32)
y = dataset["label"].values.astype(np.int8)
w = dataset["sample_weight"].values.astype(np.float32)
pos = y.sum()
neg = (y == 0).sum()
print(f"데이터셋 완성: {len(dataset):,}개 샘플 (양성={pos:.0f}, 음성={neg:.0f})")
print(f"사용 피처: {len(actual_feature_cols)}\n")
return X, y, w
# ──────────────────────────────────────────────
# Walk-Forward 교차검증
# ──────────────────────────────────────────────
def _walk_forward_cv(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
params: dict,
n_splits: int,
train_ratio: float,
trial: optuna.Trial | None = None,
) -> tuple[float, list[float]]:
"""
Walk-Forward 교차검증으로 평균 AUC를 반환한다.
trial이 제공되면 각 폴드 후 Optuna에 중간 값을 보고하여 Pruning을 활성화한다.
"""
n = len(X)
step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio)
fold_aucs = []
for fold_idx in range(n_splits):
tr_end = train_end_start + fold_idx * 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]
# 클래스 불균형 처리: 언더샘플링 (시간 순서 유지)
pos_idx = np.where(y_tr == 1)[0]
neg_idx = np.where(y_tr == 0)[0]
if len(neg_idx) > len(pos_idx) and len(pos_idx) > 0:
rng = np.random.default_rng(42)
neg_idx = rng.choice(neg_idx, size=len(pos_idx), replace=False)
bal_idx = np.sort(np.concatenate([pos_idx, neg_idx]))
if len(bal_idx) < 20 or len(np.unique(y_val)) < 2:
fold_aucs.append(0.5)
continue
model = lgb.LGBMClassifier(**params, random_state=42, verbose=-1)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
model.fit(X_tr[bal_idx], y_tr[bal_idx], sample_weight=w_tr[bal_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)
# Optuna Pruning: 중간 값 보고
if trial is not None:
trial.report(float(np.mean(fold_aucs)), step=fold_idx)
if trial.should_prune():
raise optuna.TrialPruned()
mean_auc = float(np.mean(fold_aucs)) if fold_aucs else 0.5
return mean_auc, fold_aucs
# ──────────────────────────────────────────────
# Optuna 목적 함수
# ──────────────────────────────────────────────
def make_objective(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
n_splits: int,
train_ratio: float,
):
"""클로저로 데이터셋을 캡처한 목적 함수를 반환한다."""
def objective(trial: optuna.Trial) -> float:
# ── 하이퍼파라미터 샘플링 ──
n_estimators = trial.suggest_int("n_estimators", 100, 600)
learning_rate = trial.suggest_float("learning_rate", 0.01, 0.2, log=True)
max_depth = trial.suggest_int("max_depth", 2, 7)
# 핵심 제약: num_leaves <= 2^max_depth - 1 (leaf-wise 과적합 방지)
max_leaves_upper = min(31, 2 ** max_depth - 1)
num_leaves = trial.suggest_int("num_leaves", 7, max(7, max_leaves_upper))
min_child_samples = trial.suggest_int("min_child_samples", 10, 50)
subsample = trial.suggest_float("subsample", 0.5, 1.0)
colsample_bytree = trial.suggest_float("colsample_bytree", 0.5, 1.0)
reg_alpha = trial.suggest_float("reg_alpha", 1e-4, 1.0, log=True)
reg_lambda = trial.suggest_float("reg_lambda", 1e-4, 1.0, log=True)
# time_weight_decay는 데이터셋 생성 시 적용되어야 하지만,
# 데이터셋을 1회 캐싱하는 구조이므로 LightGBM sample_weight 스케일로 근사한다.
# 실제 decay 효과는 w 배열에 이미 반영되어 있으므로 스케일 파라미터로 활용한다.
weight_scale = trial.suggest_float("weight_scale", 0.5, 2.0)
w_scaled = (w * weight_scale).astype(np.float32)
params = {
"n_estimators": n_estimators,
"learning_rate": learning_rate,
"max_depth": max_depth,
"num_leaves": num_leaves,
"min_child_samples": min_child_samples,
"subsample": subsample,
"colsample_bytree": colsample_bytree,
"reg_alpha": reg_alpha,
"reg_lambda": reg_lambda,
}
mean_auc, fold_aucs = _walk_forward_cv(
X, y, w_scaled, params,
n_splits=n_splits,
train_ratio=train_ratio,
trial=trial,
)
# 폴드별 AUC를 user_attrs에 저장 (결과 리포트용)
trial.set_user_attr("fold_aucs", fold_aucs)
return mean_auc
return objective
# ──────────────────────────────────────────────
# 베이스라인 AUC 측정 (현재 고정 파라미터)
# ──────────────────────────────────────────────
def measure_baseline(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
n_splits: int,
train_ratio: float,
) -> tuple[float, list[float]]:
"""train_model.py의 현재 고정 파라미터로 베이스라인 AUC를 측정한다."""
baseline_params = {
"n_estimators": 500,
"learning_rate": 0.05,
"num_leaves": 31,
"min_child_samples": 15,
"subsample": 0.8,
"colsample_bytree": 0.8,
"reg_alpha": 0.05,
"reg_lambda": 0.1,
"max_depth": -1, # 현재 train_model.py는 max_depth 미설정
}
print("베이스라인 측정 중 (현재 train_model.py 고정 파라미터)...")
return _walk_forward_cv(X, y, w, baseline_params, n_splits=n_splits, train_ratio=train_ratio)
# ──────────────────────────────────────────────
# 결과 출력 및 저장
# ──────────────────────────────────────────────
def print_report(
study: optuna.Study,
baseline_auc: float,
baseline_folds: list[float],
elapsed_sec: float,
output_path: Path,
) -> None:
"""콘솔에 최종 리포트를 출력한다."""
best = study.best_trial
best_auc = best.value
best_folds = best.user_attrs.get("fold_aucs", [])
improvement = best_auc - baseline_auc
improvement_pct = (improvement / baseline_auc * 100) if baseline_auc > 0 else 0.0
elapsed_min = int(elapsed_sec // 60)
elapsed_s = int(elapsed_sec % 60)
sep = "=" * 62
dash = "-" * 62
print(f"\n{sep}")
print(f" Optuna 튜닝 완료 | {len(study.trials)} trials | 소요: {elapsed_min}{elapsed_s}")
print(sep)
print(f" Best AUC : {best_auc:.4f} (Trial #{best.number})")
print(f" Baseline : {baseline_auc:.4f} (현재 train_model.py 고정값)")
sign = "+" if improvement >= 0 else ""
print(f" 개선폭 : {sign}{improvement:.4f} ({sign}{improvement_pct:.1f}%)")
print(dash)
print(" Best Parameters:")
for k, v in best.params.items():
if isinstance(v, float):
print(f" {k:<22}: {v:.6f}")
else:
print(f" {k:<22}: {v}")
print(dash)
print(" Walk-Forward 폴드별 AUC (Best Trial):")
for i, auc in enumerate(best_folds, 1):
print(f" 폴드 {i}: {auc:.4f}")
if best_folds:
print(f" 평균: {np.mean(best_folds):.4f} ± {np.std(best_folds):.4f}")
print(dash)
print(" Baseline 폴드별 AUC:")
for i, auc in enumerate(baseline_folds, 1):
print(f" 폴드 {i}: {auc:.4f}")
if baseline_folds:
print(f" 평균: {np.mean(baseline_folds):.4f} ± {np.std(baseline_folds):.4f}")
print(dash)
print(f" 결과 저장: {output_path}")
print(f" 다음 단계: python scripts/train_model.py --tuned-params {output_path}")
print(sep)
def save_results(
study: optuna.Study,
baseline_auc: float,
baseline_folds: list[float],
elapsed_sec: float,
data_path: str,
) -> Path:
"""결과를 JSON 파일로 저장하고 경로를 반환한다."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = Path(f"models/tune_results_{timestamp}.json")
output_path.parent.mkdir(exist_ok=True)
best = study.best_trial
all_trials = []
for t in study.trials:
if t.state == optuna.trial.TrialState.COMPLETE:
all_trials.append({
"number": t.number,
"auc": round(t.value, 6),
"fold_aucs": [round(a, 6) for a in t.user_attrs.get("fold_aucs", [])],
"params": {k: (round(v, 6) if isinstance(v, float) else v) for k, v in t.params.items()},
})
result = {
"timestamp": datetime.now().isoformat(),
"data_path": data_path,
"n_trials_total": len(study.trials),
"n_trials_complete": len(all_trials),
"elapsed_sec": round(elapsed_sec, 1),
"baseline": {
"auc": round(baseline_auc, 6),
"fold_aucs": [round(a, 6) for a in baseline_folds],
},
"best_trial": {
"number": best.number,
"auc": round(best.value, 6),
"fold_aucs": [round(a, 6) for a in best.user_attrs.get("fold_aucs", [])],
"params": {k: (round(v, 6) if isinstance(v, float) else v) for k, v in best.params.items()},
},
"all_trials": all_trials,
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
return output_path
# ──────────────────────────────────────────────
# 메인
# ──────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Optuna LightGBM 하이퍼파라미터 튜닝")
parser.add_argument("--data", default="data/combined_15m.parquet", help="학습 데이터 경로")
parser.add_argument("--trials", type=int, default=50, help="Optuna trial 수 (기본: 50)")
parser.add_argument("--folds", type=int, default=5, help="Walk-Forward 폴드 수 (기본: 5)")
parser.add_argument("--train-ratio", type=float, default=0.6, help="학습 구간 비율 (기본: 0.6)")
parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀")
args = parser.parse_args()
# 1. 데이터셋 로드 (1회)
X, y, w = load_dataset(args.data)
# 2. 베이스라인 측정
if args.no_baseline:
baseline_auc, baseline_folds = 0.0, []
print("베이스라인 측정 건너뜀 (--no-baseline)")
else:
baseline_auc, baseline_folds = measure_baseline(X, y, w, args.folds, args.train_ratio)
print(f"베이스라인 AUC: {baseline_auc:.4f} (폴드별: {[round(a,4) for a in baseline_folds]})\n")
# 3. Optuna study 실행
optuna.logging.set_verbosity(optuna.logging.WARNING)
sampler = TPESampler(seed=42)
pruner = MedianPruner(n_startup_trials=5, n_warmup_steps=2)
study = optuna.create_study(
direction="maximize",
sampler=sampler,
pruner=pruner,
study_name="lgbm_wf_auc",
)
objective = make_objective(X, y, w, n_splits=args.folds, train_ratio=args.train_ratio)
print(f"Optuna 탐색 시작: {args.trials} trials, {args.folds}폴드 Walk-Forward")
print("(진행 상황은 trial 완료마다 출력됩니다)\n")
start_time = time.time()
def _progress_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial):
if trial.state == optuna.trial.TrialState.COMPLETE:
best_so_far = study.best_value
print(
f" Trial #{trial.number:3d} | AUC={trial.value:.4f} "
f"| Best={best_so_far:.4f} "
f"| {trial.params.get('num_leaves', '?')}leaves "
f"depth={trial.params.get('max_depth', '?')}"
)
elif trial.state == optuna.trial.TrialState.PRUNED:
print(f" Trial #{trial.number:3d} | PRUNED")
study.optimize(
objective,
n_trials=args.trials,
callbacks=[_progress_callback],
show_progress_bar=False,
)
elapsed = time.time() - start_time
# 4. 결과 저장 및 출력
output_path = save_results(study, baseline_auc, baseline_folds, elapsed, args.data)
print_report(study, baseline_auc, baseline_folds, elapsed, output_path)
if __name__ == "__main__":
main()
```
**Step 2: 문법 오류 확인**
```bash
cd /path/to/cointrader
python -c "import ast; ast.parse(open('scripts/tune_hyperparams.py').read()); print('문법 OK')"
```
Expected: `문법 OK`
**Step 3: Commit**
```bash
git add scripts/tune_hyperparams.py
git commit -m "feat: add Optuna Walk-Forward AUC hyperparameter tuning script"
```
---
## Task 3: 동작 검증 (빠른 테스트)
**Files:**
- Read: `scripts/tune_hyperparams.py`
**Step 1: 빠른 테스트 실행 (10 trials, 3폴드)**
```bash
python scripts/tune_hyperparams.py --trials 10 --folds 3 --no-baseline
```
Expected:
- 오류 없이 10 trials 완료
- `models/tune_results_YYYYMMDD_HHMMSS.json` 생성
- 콘솔에 Best Params 출력
**Step 2: JSON 결과 확인**
```bash
cat models/tune_results_*.json | python -m json.tool | head -40
```
Expected: `best_trial.auc`, `best_trial.params` 등 구조 확인
**Step 3: Commit**
```bash
git add models/tune_results_*.json
git commit -m "test: verify Optuna tuning pipeline with 10 trials"
```
---
## Task 4: README.md 업데이트
**Files:**
- Modify: `README.md`
**Step 1: ML 모델 학습 섹션에 튜닝 사용법 추가**
`README.md``## ML 모델 학습` 섹션 아래에 다음 내용을 추가한다:
```markdown
### 하이퍼파라미터 자동 튜닝 (Optuna)
봇 성능이 저하되거나 데이터가 충분히 축적되었을 때 Optuna로 최적 파라미터를 탐색합니다.
결과를 확인하고 직접 승인한 후 재학습에 반영하는 **수동 트리거** 방식입니다.
```bash
# 기본 실행 (50 trials, 5폴드 Walk-Forward, ~30분)
python scripts/tune_hyperparams.py
# 빠른 테스트 (10 trials, 3폴드, ~5분)
python scripts/tune_hyperparams.py --trials 10 --folds 3
# 결과 확인 후 승인하면 재학습
python scripts/train_model.py
```
결과는 `models/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
Best Params와 베이스라인 대비 개선폭을 확인하고 직접 판단하세요.
```
**Step 2: Commit**
```bash
git add README.md
git commit -m "docs: add Optuna hyperparameter tuning usage to README"
```
---
## 검증 체크리스트
- [ ] `python -c "import optuna"` 오류 없음
- [ ] `python scripts/tune_hyperparams.py --trials 10 --folds 3 --no-baseline` 오류 없이 완료
- [ ] `models/tune_results_*.json` 파일 생성 확인
- [ ] JSON에 `best_trial.params`, `best_trial.fold_aucs` 포함 확인
- [ ] 콘솔 리포트에 Best AUC, 폴드별 AUC, 파라미터 출력 확인
- [ ] `num_leaves <= 2^max_depth - 1` 제약이 모든 trial에서 지켜지는지 JSON으로 확인
---
## 향후 확장 (2단계 — 별도 플랜)
파이프라인 안정화 후 `dataset_builder.py``_calc_signals()` 함수를 파라미터화하여 기술 지표 임계값(RSI, Stochastic RSI, 거래량 배수, 진입 점수 임계값)을 탐색 공간에 추가한다.

View File

@@ -0,0 +1,399 @@
# 실시간 OI/펀딩비 피처 수집 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 실시간 봇에서 캔들 마감 시 바이낸스 REST API로 현재 OI와 펀딩비를 수집해 ML 피처에 실제 값을 넣어 학습-추론 불일치(train-serve skew)를 해소한다.
**Architecture:**
- `exchange.py``get_open_interest()`, `get_funding_rate()` 메서드 추가 (REST 호출)
- `bot.py``process_candle()`에서 캔들 마감 시 두 값을 조회하고 `build_features()` 호출 시 전달
- `ml_features.py``build_features()``oi_change`, `funding_rate` 파라미터를 받아 실제 값으로 채우도록 수정
**Tech Stack:** python-binance AsyncClient, aiohttp (이미 사용 중), pytest-asyncio
---
## Task 1: exchange.py — OI / 펀딩비 조회 메서드 추가
**Files:**
- Modify: `src/exchange.py`
- Test: `tests/test_exchange.py`
### Step 1: 실패 테스트 작성
`tests/test_exchange.py` 파일에 아래 테스트를 추가한다.
```python
@pytest.mark.asyncio
async def test_get_open_interest(exchange):
"""get_open_interest()가 float을 반환하는지 확인."""
exchange.client.futures_open_interest = MagicMock(
return_value={"openInterest": "123456.789"}
)
result = await exchange.get_open_interest()
assert isinstance(result, float)
assert result == pytest.approx(123456.789)
@pytest.mark.asyncio
async def test_get_funding_rate(exchange):
"""get_funding_rate()가 float을 반환하는지 확인."""
exchange.client.futures_mark_price = MagicMock(
return_value={"lastFundingRate": "0.0001"}
)
result = await exchange.get_funding_rate()
assert isinstance(result, float)
assert result == pytest.approx(0.0001)
@pytest.mark.asyncio
async def test_get_open_interest_error_returns_none(exchange):
"""API 오류 시 None 반환 확인."""
from binance.exceptions import BinanceAPIException
exchange.client.futures_open_interest = MagicMock(
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
)
result = await exchange.get_open_interest()
assert result is None
@pytest.mark.asyncio
async def test_get_funding_rate_error_returns_none(exchange):
"""API 오류 시 None 반환 확인."""
from binance.exceptions import BinanceAPIException
exchange.client.futures_mark_price = MagicMock(
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
)
result = await exchange.get_funding_rate()
assert result is None
```
### Step 2: 테스트 실패 확인
```bash
pytest tests/test_exchange.py::test_get_open_interest tests/test_exchange.py::test_get_funding_rate -v
```
Expected: `FAILED``AttributeError: 'BinanceFuturesClient' object has no attribute 'get_open_interest'`
### Step 3: exchange.py에 메서드 구현
`src/exchange.py``cancel_all_orders()` 메서드 아래에 추가한다.
```python
async def get_open_interest(self) -> float | None:
"""현재 미결제약정(OI)을 조회한다. 오류 시 None 반환."""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.client.futures_open_interest(symbol=self.config.symbol),
)
return float(result["openInterest"])
except Exception as e:
logger.warning(f"OI 조회 실패 (무시): {e}")
return None
async def get_funding_rate(self) -> float | None:
"""현재 펀딩비를 조회한다. 오류 시 None 반환."""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.client.futures_mark_price(symbol=self.config.symbol),
)
return float(result["lastFundingRate"])
except Exception as e:
logger.warning(f"펀딩비 조회 실패 (무시): {e}")
return None
```
### Step 4: 테스트 통과 확인
```bash
pytest tests/test_exchange.py -v
```
Expected: 기존 테스트 포함 전체 PASS
### Step 5: 커밋
```bash
git add src/exchange.py tests/test_exchange.py
git commit -m "feat: add get_open_interest and get_funding_rate to BinanceFuturesClient"
```
---
## Task 2: ml_features.py — build_features()에 oi/funding 파라미터 추가
**Files:**
- Modify: `src/ml_features.py`
- Test: `tests/test_ml_features.py`
### Step 1: 실패 테스트 작성
`tests/test_ml_features.py`에 아래 테스트를 추가한다.
```python
def test_build_features_uses_provided_oi_funding(sample_df_with_indicators):
"""oi_change, funding_rate 파라미터가 제공되면 실제 값이 피처에 반영된다."""
from src.ml_features import build_features
feat = build_features(
sample_df_with_indicators,
signal="LONG",
oi_change=0.05,
funding_rate=0.0002,
)
assert feat["oi_change"] == pytest.approx(0.05)
assert feat["funding_rate"] == pytest.approx(0.0002)
def test_build_features_defaults_to_zero_when_not_provided(sample_df_with_indicators):
"""oi_change, funding_rate 파라미터 미제공 시 0.0으로 채워진다."""
from src.ml_features import build_features
feat = build_features(sample_df_with_indicators, signal="LONG")
assert feat["oi_change"] == pytest.approx(0.0)
assert feat["funding_rate"] == pytest.approx(0.0)
```
### Step 2: 테스트 실패 확인
```bash
pytest tests/test_ml_features.py::test_build_features_uses_provided_oi_funding -v
```
Expected: `FAILED``TypeError: build_features() got an unexpected keyword argument 'oi_change'`
### Step 3: ml_features.py 수정
`build_features()` 시그니처와 마지막 부분을 수정한다.
```python
def build_features(
df: pd.DataFrame,
signal: str,
btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None,
oi_change: float | None = None,
funding_rate: float | None = None,
) -> pd.Series:
```
그리고 함수 끝의 `setdefault` 부분을 아래로 교체한다.
```python
# 실시간에서 실제 값이 제공되면 사용, 없으면 0으로 채운다
base["oi_change"] = float(oi_change) if oi_change is not None else 0.0
base["funding_rate"] = float(funding_rate) if funding_rate is not None else 0.0
return pd.Series(base)
```
기존 코드:
```python
# 실시간에서는 OI/펀딩비를 수집하지 않으므로 0으로 채워 학습 피처(23개)와 일치시킨다
base.setdefault("oi_change", 0.0)
base.setdefault("funding_rate", 0.0)
return pd.Series(base)
```
### Step 4: 테스트 통과 확인
```bash
pytest tests/test_ml_features.py -v
```
Expected: 전체 PASS
### Step 5: 커밋
```bash
git add src/ml_features.py tests/test_ml_features.py
git commit -m "feat: build_features accepts oi_change and funding_rate params"
```
---
## Task 3: bot.py — 캔들 마감 시 OI/펀딩비 조회 후 피처에 전달
**Files:**
- Modify: `src/bot.py`
- Test: `tests/test_bot.py`
### Step 1: 실패 테스트 작성
`tests/test_bot.py`에 아래 테스트를 추가한다.
```python
@pytest.mark.asyncio
async def test_process_candle_fetches_oi_and_funding(config, sample_df):
"""process_candle()이 OI와 펀딩비를 조회하고 build_features에 전달하는지 확인."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot.exchange = AsyncMock()
bot.exchange.get_balance = AsyncMock(return_value=1000.0)
bot.exchange.get_position = AsyncMock(return_value=None)
bot.exchange.place_order = AsyncMock(return_value={"orderId": "1"})
bot.exchange.set_leverage = AsyncMock()
bot.exchange.get_open_interest = AsyncMock(return_value=5000000.0)
bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001)
with patch("src.bot.build_features") as mock_build:
mock_build.return_value = pd.Series({col: 0.0 for col in __import__("src.ml_features", fromlist=["FEATURE_COLS"]).FEATURE_COLS})
# ML 필터는 비활성화
bot.ml_filter.is_model_loaded = MagicMock(return_value=False)
await bot.process_candle(sample_df)
# build_features가 oi_change, funding_rate 키워드 인자와 함께 호출됐는지 확인
assert mock_build.called
call_kwargs = mock_build.call_args.kwargs
assert "oi_change" in call_kwargs
assert "funding_rate" in call_kwargs
```
### Step 2: 테스트 실패 확인
```bash
pytest tests/test_bot.py::test_process_candle_fetches_oi_and_funding -v
```
Expected: `FAILED``AssertionError: assert 'oi_change' in {}`
### Step 3: bot.py 수정
`process_candle()` 메서드에서 OI/펀딩비를 조회하고 `build_features()`에 전달한다.
`process_candle()` 메서드 시작 부분에 OI/펀딩비 조회를 추가한다:
```python
async def process_candle(self, df, btc_df=None, eth_df=None):
self.ml_filter.check_and_reload()
if not self.risk.is_trading_allowed():
logger.warning("리스크 한도 초과 - 거래 중단")
return
# 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백)
oi_change, funding_rate = await self._fetch_market_microstructure()
ind = Indicators(df)
df_with_indicators = ind.calculate_all()
raw_signal = ind.get_signal(df_with_indicators)
# ... (이하 동일)
```
그리고 `build_features()` 호출 부분 두 곳을 모두 수정한다:
```python
features = build_features(
df_with_indicators, signal,
btc_df=btc_df, eth_df=eth_df,
oi_change=oi_change, funding_rate=funding_rate,
)
```
`_fetch_market_microstructure()` 메서드를 추가한다:
```python
async def _fetch_market_microstructure(self) -> tuple[float, float]:
"""OI 변화율과 펀딩비를 실시간으로 조회한다. 실패 시 0.0으로 폴백."""
oi_val, fr_val = await asyncio.gather(
self.exchange.get_open_interest(),
self.exchange.get_funding_rate(),
return_exceptions=True,
)
oi_float = float(oi_val) if isinstance(oi_val, (int, float)) else 0.0
fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0
# OI는 절대값이므로 이전 값 대비 변화율로 변환
oi_change = self._calc_oi_change(oi_float)
logger.debug(f"OI={oi_float:.0f}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}")
return oi_change, fr_float
```
`_calc_oi_change()` 메서드와 `_prev_oi` 상태를 추가한다:
`__init__()` 에 추가:
```python
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
```
메서드 추가:
```python
def _calc_oi_change(self, current_oi: float) -> float:
"""이전 OI 대비 변화율을 계산한다. 첫 캔들은 0.0 반환."""
if self._prev_oi is None or self._prev_oi == 0.0:
self._prev_oi = current_oi
return 0.0
change = (current_oi - self._prev_oi) / self._prev_oi
self._prev_oi = current_oi
return change
```
### Step 4: 테스트 통과 확인
```bash
pytest tests/test_bot.py -v
```
Expected: 전체 PASS
### Step 5: 커밋
```bash
git add src/bot.py tests/test_bot.py
git commit -m "feat: fetch realtime OI and funding rate on candle close for ML features"
```
---
## Task 4: 전체 테스트 통과 확인 및 README 업데이트
### Step 1: 전체 테스트 실행
```bash
bash scripts/run_tests.sh
```
Expected: 전체 PASS (새 테스트 포함)
### Step 2: README.md 업데이트
`README.md`의 "주요 기능" 섹션에서 ML 피처 설명을 수정한다.
기존:
```
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (실시간 미수집 항목은 0으로 채움)
```
변경:
```
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (캔들 마감 시 REST API로 실시간 수집)
```
### Step 3: 최종 커밋
```bash
git add README.md
git commit -m "docs: update README to reflect realtime OI/funding rate collection"
```
---
## 구현 후 검증 포인트
1. 봇 실행 로그에서 `OI=xxx, OI변화율=xxx, 펀딩비=xxx` 라인이 15분마다 출력되는지 확인
2. API 오류(네트워크 단절 등) 시 `WARNING: OI 조회 실패 (무시)` 로그 후 0.0으로 폴백해 봇이 정상 동작하는지 확인
3. `build_features()` 호출 시 `oi_change`, `funding_rate`가 실제 값으로 채워지는지 로그 확인
---
## 다음 단계: 접근법 B (OI/펀딩비 누적 저장)
A 완료 후 진행할 계획:
- `scripts/fetch_history.py` 실행 시 기존 parquet에 새 30일치를 **append(중복 제거)** 방식으로 저장
- 시간이 지날수록 OI/펀딩비 학습 데이터가 누적되어 모델 품질 향상
- 별도 플랜 문서로 작성 예정

View File

@@ -0,0 +1,125 @@
# 반대 시그널 시 청산 후 즉시 재진입 설계
- **날짜**: 2026-03-02
- **파일**: `src/bot.py`
- **상태**: 설계 완료, 구현 대기
---
## 배경
현재 `TradingBot.process_candle`은 반대 방향 시그널이 오면 기존 포지션을 청산만 하고 종료한다.
새 포지션은 다음 캔들에서 시그널이 다시 나와야 잡힌다.
```
현재: 반대 시그널 → 청산 → 다음 캔들 대기
목표: 반대 시그널 → 청산 → (ML 필터 통과 시) 즉시 반대 방향 재진입
```
같은 방향 시그널이 오거나 HOLD이면 기존 포지션을 그대로 유지한다.
---
## 요구사항
| 항목 | 결정 |
|------|------|
| 포지션 크기 | 재진입 시점 잔고 + 동적 증거금 비율로 새로 계산 |
| SL/TP | 청산 시 기존 주문 전부 취소, 재진입 시 새로 설정 |
| ML 필터 | 재진입에도 동일하게 적용 (차단 시 청산만 하고 대기) |
| 같은 방향 시그널 | 포지션 유지 (변경 없음) |
| HOLD 시그널 | 포지션 유지 (변경 없음) |
---
## 설계
### 변경 범위
`src/bot.py` 한 파일만 수정한다.
1. `_close_and_reenter` 메서드 신규 추가
2. `process_candle` 내 반대 시그널 분기에서 `_close_position` 대신 `_close_and_reenter` 호출
### 데이터 흐름
```
process_candle()
└─ 반대 시그널 감지
└─ _close_and_reenter(position, signal, df, btc_df, eth_df)
├─ _close_position(position) # 청산 + cancel_all_orders
├─ risk.can_open_new_position() 체크
│ └─ 불가 → 로그 + 종료
├─ ML 필터 체크 (ml_filter.is_model_loaded())
│ ├─ 차단 → 로그 + 종료 (포지션 없는 상태로 대기)
│ └─ 통과 → 계속
└─ _open_position(signal, df) # 재진입 + 새 SL/TP 설정
```
### `process_candle` 수정
```python
# 변경 전
elif position is not None:
pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
if (pos_side == "LONG" and signal == "SHORT") or \
(pos_side == "SHORT" and signal == "LONG"):
await self._close_position(position)
# 변경 후
elif position is not None:
pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
if (pos_side == "LONG" and signal == "SHORT") or \
(pos_side == "SHORT" and signal == "LONG"):
await self._close_and_reenter(position, signal, df_with_indicators, btc_df, eth_df)
```
### 신규 메서드 `_close_and_reenter`
```python
async def _close_and_reenter(
self,
position: dict,
signal: str,
df,
btc_df=None,
eth_df=None,
) -> None:
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
await self._close_position(position)
if not self.risk.can_open_new_position():
logger.info("최대 포지션 수 도달 — 재진입 건너뜀")
return
if self.ml_filter.is_model_loaded():
features = build_features(df, signal, btc_df=btc_df, eth_df=eth_df)
if not self.ml_filter.should_enter(features):
logger.info(f"ML 필터 차단: {signal} 재진입 무시")
return
await self._open_position(signal, df)
```
---
## 엣지 케이스
| 상황 | 처리 |
|------|------|
| 청산 후 ML 필터 차단 | 청산만 하고 포지션 없는 상태로 대기 |
| 청산 후 잔고 부족 (명목금액 미달) | `_open_position` 내부 경고 후 건너뜀 (기존 로직) |
| 청산 후 최대 포지션 수 초과 | 재진입 건너뜀 |
| 같은 방향 시그널 | 포지션 유지 (변경 없음) |
| HOLD 시그널 | 포지션 유지 (변경 없음) |
| 봇 재시작 후 포지션 복구 | `_recover_position` 로직 변경 없음 |
---
## 영향 없는 코드
- `_close_position` — 변경 없음
- `_open_position` — 변경 없음
- `_recover_position` — 변경 없음
- `RiskManager` — 변경 없음
- `MLFilter` — 변경 없음

View File

@@ -0,0 +1,269 @@
# 반대 시그널 시 청산 후 즉시 재진입 구현 플랜
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 반대 방향 시그널이 오면 기존 포지션을 청산하고 ML 필터 통과 시 즉시 반대 방향으로 재진입한다.
**Architecture:** `src/bot.py``_close_and_reenter` 메서드를 추가하고, `process_candle`의 반대 시그널 분기에서 이를 호출한다. 기존 `_close_position``_open_position`을 그대로 재사용하므로 중복 없음.
**Tech Stack:** Python 3.12, pytest, unittest.mock
---
## 테스트 스크립트
각 태스크 단계마다 아래 스크립트로 테스트를 실행한다.
```bash
# Task 1 — 신규 테스트 실행 (구현 전, FAIL 확인용)
bash scripts/test_reverse_reenter.sh 1
# Task 2 — _close_and_reenter 메서드 테스트 (구현 후, PASS 확인)
bash scripts/test_reverse_reenter.sh 2
# Task 3 — process_candle 분기 테스트 (수정 후, PASS 확인)
bash scripts/test_reverse_reenter.sh 3
# test_bot.py 전체
bash scripts/test_reverse_reenter.sh bot
# 전체 테스트 스위트
bash scripts/test_reverse_reenter.sh all
```
---
## 참고 파일
- 설계 문서: `docs/plans/2026-03-02-reverse-signal-reenter-design.md`
- 구현 대상: `src/bot.py`
- 기존 테스트: `tests/test_bot.py`
---
## Task 1: `_close_and_reenter` 테스트 작성
**Files:**
- Modify: `tests/test_bot.py`
### Step 1: 테스트 3개 추가
`tests/test_bot.py` 맨 아래에 다음 테스트를 추가한다.
```python
@pytest.mark.asyncio
async def test_close_and_reenter_calls_open_when_ml_passes(config, sample_df):
"""반대 시그널 + ML 필터 통과 시 청산 후 재진입해야 한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot._close_position = AsyncMock()
bot._open_position = AsyncMock()
bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = True
bot.ml_filter.should_enter.return_value = True
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df)
bot._close_position.assert_awaited_once_with(position)
bot._open_position.assert_awaited_once_with("SHORT", sample_df)
@pytest.mark.asyncio
async def test_close_and_reenter_skips_open_when_ml_blocks(config, sample_df):
"""ML 필터 차단 시 청산만 하고 재진입하지 않아야 한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot._close_position = AsyncMock()
bot._open_position = AsyncMock()
bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = True
bot.ml_filter.should_enter.return_value = False
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df)
bot._close_position.assert_awaited_once_with(position)
bot._open_position.assert_not_called()
@pytest.mark.asyncio
async def test_close_and_reenter_skips_open_when_max_positions_reached(config, sample_df):
"""최대 포지션 수 도달 시 청산만 하고 재진입하지 않아야 한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot._close_position = AsyncMock()
bot._open_position = AsyncMock()
bot.risk = MagicMock()
bot.risk.can_open_new_position.return_value = False
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df)
bot._close_position.assert_awaited_once_with(position)
bot._open_position.assert_not_called()
```
### Step 2: 테스트 실행 — 실패 확인
```bash
bash scripts/test_reverse_reenter.sh 1
```
예상 결과: `AttributeError: 'TradingBot' object has no attribute '_close_and_reenter'` 로 3개 FAIL
---
## Task 2: `_close_and_reenter` 메서드 구현
**Files:**
- Modify: `src/bot.py:148` (`_close_position` 메서드 바로 아래에 추가)
### Step 1: `_close_position` 다음에 메서드 추가
`src/bot.py`에서 `_close_position` 메서드(148~167번째 줄) 바로 뒤에 다음을 추가한다.
```python
async def _close_and_reenter(
self,
position: dict,
signal: str,
df,
btc_df=None,
eth_df=None,
) -> None:
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
await self._close_position(position)
if not self.risk.can_open_new_position():
logger.info("최대 포지션 수 도달 — 재진입 건너뜀")
return
if self.ml_filter.is_model_loaded():
features = build_features(df, signal, btc_df=btc_df, eth_df=eth_df)
if not self.ml_filter.should_enter(features):
logger.info(f"ML 필터 차단: {signal} 재진입 무시")
return
await self._open_position(signal, df)
```
### Step 2: 테스트 실행 — 통과 확인
```bash
bash scripts/test_reverse_reenter.sh 2
```
예상 결과: 3개 PASS
### Step 3: 커밋
```bash
git add src/bot.py tests/test_bot.py
git commit -m "feat: add _close_and_reenter method for reverse signal handling"
```
---
## Task 3: `process_candle` 분기 수정
**Files:**
- Modify: `src/bot.py:83-85`
### Step 1: 기존 분기 테스트 추가
`tests/test_bot.py`에 다음 테스트를 추가한다.
```python
@pytest.mark.asyncio
async def test_process_candle_calls_close_and_reenter_on_reverse_signal(config, sample_df):
"""반대 시그널 시 process_candle이 _close_and_reenter를 호출해야 한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot.exchange = AsyncMock()
bot.exchange.get_position = AsyncMock(return_value={
"positionAmt": "100",
"entryPrice": "0.5",
"markPrice": "0.52",
})
bot._close_and_reenter = AsyncMock()
bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = False
bot.ml_filter.should_enter.return_value = True
with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "SHORT" # 현재 LONG 포지션에 반대 시그널
MockInd.return_value = mock_ind
await bot.process_candle(sample_df)
bot._close_and_reenter.assert_awaited_once()
call_args = bot._close_and_reenter.call_args
assert call_args.args[1] == "SHORT"
```
### Step 2: 테스트 실행 — 실패 확인
```bash
bash scripts/test_reverse_reenter.sh 3
```
예상 결과: FAIL (`_close_and_reenter`가 아직 호출되지 않음)
### Step 3: `process_candle` 수정
`src/bot.py`에서 아래 부분을 찾아 수정한다.
```python
# 변경 전 (81~85번째 줄 근처)
elif position is not None:
pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
if (pos_side == "LONG" and signal == "SHORT") or \
(pos_side == "SHORT" and signal == "LONG"):
await self._close_position(position)
# 변경 후
elif position is not None:
pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
if (pos_side == "LONG" and signal == "SHORT") or \
(pos_side == "SHORT" and signal == "LONG"):
await self._close_and_reenter(
position, signal, df_with_indicators, btc_df=btc_df, eth_df=eth_df
)
```
### Step 4: 전체 테스트 실행 — 통과 확인
```bash
bash scripts/test_reverse_reenter.sh bot
```
예상 결과: 전체 PASS (기존 테스트 포함)
### Step 5: 커밋
```bash
git add src/bot.py tests/test_bot.py
git commit -m "feat: call _close_and_reenter on reverse signal in process_candle"
```
---
## Task 4: 전체 테스트 스위트 확인
### Step 1: 전체 테스트 실행
```bash
bash scripts/test_reverse_reenter.sh all
```
예상 결과: 모든 테스트 PASS
### Step 2: 실패 테스트 있으면 수정 후 재실행
실패가 있으면 원인을 파악하고 수정한다. 기존 테스트를 깨뜨리지 않도록 주의.

View File

@@ -0,0 +1,203 @@
# RS np.divide 복구 / MLX NaN-Safe 통계 저장 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** RS(상대강도) 계산의 epsilon 폭발 이상치를 `np.divide` 방식으로 제거하고, MLXFilter의 `self._mean`/`self._std`에 NaN이 잔류하는 근본 허점을 차단한다.
**Architecture:**
- `src/dataset_builder.py`: `xrp_btc_rs_raw` / `xrp_eth_rs_raw` 계산을 `np.divide(..., where=...)` 방식으로 교체. 분모(btc_r1, eth_r1)가 0이면 결과를 0.0으로 채워 rolling zscore 윈도우 오염을 방지한다.
- `src/mlx_filter.py`: `fit()` 내부에서 `self._mean`/`self._std`를 저장하기 전에 `nan_to_num`을 적용해 전체-NaN 컬럼(OI 초반 구간 등)이 `predict_proba` 시점까지 NaN을 전파하지 않도록 한다.
**Tech Stack:** numpy, pandas, pytest, mlx(Apple Silicon 전용 — MLX 테스트는 Mac에서만 실행)
---
### Task 1: `dataset_builder.py` — RS 계산을 `np.divide` 방식으로 교체
**Files:**
- Modify: `src/dataset_builder.py:245-246`
- Test: `tests/test_dataset_builder.py`
**배경:**
`btc_r1 = 0.0`(15분 동안 BTC 가격 변동 없음)일 때 `xrp_r1 / (btc_r1 + 1e-8)`는 최대 수백만의 이상치를 만든다. 이 이상치가 288캔들 rolling zscore 윈도우에 들어가면 나머지 287개 값이 전부 0에 가깝게 압사된다.
**Step 1: 기존 테스트 실행 (기준선 확인)**
```bash
python -m pytest tests/test_dataset_builder.py -v
```
Expected: 모든 테스트 PASS (변경 전 기준선)
**Step 2: RS 제로-분모 테스트 작성**
`tests/test_dataset_builder.py` 파일 끝에 추가:
```python
def test_rs_zero_denominator():
"""btc_r1=0일 때 RS가 inf/nan이 아닌 0.0이어야 한다 (np.divide 방식 검증)."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 500
np.random.seed(7)
# XRP close: 약간의 변동
xrp_close = np.cumprod(1 + np.random.randn(n) * 0.001) * 1.0
xrp_df = pd.DataFrame({
"open": xrp_close * 0.999,
"high": xrp_close * 1.005,
"low": xrp_close * 0.995,
"close": xrp_close,
"volume": np.random.rand(n) * 1000 + 500,
})
# BTC close: 완전히 고정 → btc_r1 = 0.0
btc_close = np.ones(n) * 50000.0
btc_df = pd.DataFrame({
"open": btc_close,
"high": btc_close,
"low": btc_close,
"close": btc_close,
"volume": np.random.rand(n) * 1000 + 500,
})
from src.dataset_builder import generate_dataset_vectorized
result = generate_dataset_vectorized(xrp_df, btc_df=btc_df)
if result.empty:
pytest.skip("신호 없음")
assert "xrp_btc_rs" in result.columns, "xrp_btc_rs 컬럼이 있어야 함"
assert not result["xrp_btc_rs"].isin([np.inf, -np.inf]).any(), \
"xrp_btc_rs에 inf가 있으면 안 됨"
assert not result["xrp_btc_rs"].isna().all(), \
"xrp_btc_rs가 전부 nan이면 안 됨"
```
**Step 3: 테스트 실행 (FAIL 확인)**
```bash
python -m pytest tests/test_dataset_builder.py::test_rs_zero_denominator -v
```
Expected: FAIL — `xrp_btc_rs에 inf가 있으면 안 됨` (현재 epsilon 방식은 inf 대신 수백만 이상치를 만들어 rolling zscore 후 nan이 될 수 있음)
> 참고: 현재 코드는 inf를 직접 만들지 않을 수도 있다. 하지만 rolling zscore 후 nan이 생기거나 이상치가 남아있는지 확인하는 것이 목적이다. PASS가 나오더라도 Step 4를 진행한다.
**Step 4: `dataset_builder.py` 245~246줄 수정**
`src/dataset_builder.py`의 아래 두 줄을:
```python
xrp_btc_rs_raw = (xrp_r1 / (btc_r1 + 1e-8)).astype(np.float32)
xrp_eth_rs_raw = (xrp_r1 / (eth_r1 + 1e-8)).astype(np.float32)
```
다음으로 교체:
```python
xrp_btc_rs_raw = np.divide(
xrp_r1, btc_r1,
out=np.zeros_like(xrp_r1),
where=(btc_r1 != 0),
).astype(np.float32)
xrp_eth_rs_raw = np.divide(
xrp_r1, eth_r1,
out=np.zeros_like(xrp_r1),
where=(eth_r1 != 0),
).astype(np.float32)
```
**Step 5: 전체 테스트 실행 (PASS 확인)**
```bash
python -m pytest tests/test_dataset_builder.py -v
```
Expected: 모든 테스트 PASS
**Step 6: 커밋**
```bash
git add src/dataset_builder.py tests/test_dataset_builder.py
git commit -m "fix: RS 계산을 np.divide(where=) 방식으로 교체 — epsilon 이상치 폭발 차단"
```
---
### Task 2: `mlx_filter.py` — `self._mean`/`self._std` 저장 전 `nan_to_num` 적용
**Files:**
- Modify: `src/mlx_filter.py:145-146`
- Test: `tests/test_mlx_filter.py` (기존 `test_fit_with_nan_features` 활용)
**배경:**
현재 코드는 `self._mean = np.nanmean(X_np, axis=0)`으로 저장한다. 전체가 NaN인 컬럼(Walk-Forward 초반 11개월의 OI 데이터)이 있으면 `np.nanmean`은 해당 컬럼의 평균으로 NaN을 반환한다. 이 NaN이 `self._mean`에 저장되면 `predict_proba` 시점에 `(X_np - self._mean)`이 NaN이 되어 OI 데이터를 영원히 활용하지 못한다.
**Step 1: 기존 테스트 실행 (기준선 확인)**
```bash
python -m pytest tests/test_mlx_filter.py -v
```
Expected: 모든 테스트 PASS (MLX 없는 환경에서는 전체 SKIP)
**Step 2: `mlx_filter.py` 145~146줄 수정**
`src/mlx_filter.py`의 아래 두 줄을:
```python
self._mean = np.nanmean(X_np, axis=0)
self._std = np.nanstd(X_np, axis=0) + 1e-8
```
다음으로 교체:
```python
mean_vals = np.nanmean(X_np, axis=0)
self._mean = np.nan_to_num(mean_vals, nan=0.0) # 전체-NaN 컬럼 → 평균 0.0
std_vals = np.nanstd(X_np, axis=0)
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0
```
**Step 3: 테스트 실행 (PASS 확인)**
```bash
python -m pytest tests/test_mlx_filter.py::test_fit_with_nan_features -v
```
Expected: PASS (MLX 없는 환경에서는 SKIP)
**Step 4: 전체 테스트 실행**
```bash
python -m pytest tests/test_mlx_filter.py -v
```
Expected: 모든 테스트 PASS (또는 SKIP)
**Step 5: 커밋**
```bash
git add src/mlx_filter.py
git commit -m "fix: MLXFilter self._mean/std 저장 전 nan_to_num 적용 — 전체-NaN 컬럼 predict_proba 오염 차단"
```
---
### Task 3: 전체 테스트 통과 확인
**Step 1: 전체 테스트 실행**
```bash
python -m pytest tests/ -v --tb=short 2>&1 | tail -40
```
Expected: 모든 테스트 PASS (MLX 관련은 SKIP 허용)
**Step 2: 최종 커밋 (필요 시)**
```bash
git add -A
git commit -m "chore: RS epsilon 폭발 차단 + MLX NaN-Safe 통계 저장 통합"
```

View File

@@ -0,0 +1,300 @@
# User Data Stream TP/SL 감지 설계
**날짜:** 2026-03-02
**목적:** Binance Futures User Data Stream을 도입하여 TP/SL 작동을 실시간 감지하고, 순수익(Net PnL)을 기록하며, Discord에 상세 청산 알림을 전송한다.
---
## 배경 및 문제
기존 봇은 매 캔들 마감마다 `get_position()`을 폴링하여 포지션 소멸 여부를 확인하는 방식이었다. 이 구조의 한계:
1. **TP/SL 작동 후 최대 15분 지연** — 캔들 마감 전까지 감지 불가
2. **청산 원인 구분 불가** — TP인지 SL인지 수동 청산인지 알 수 없음
3. **PnL 기록 누락**`_close_position()`을 봇이 직접 호출하지 않으면 `record_pnl()` 미실행
4. **Discord 알림 누락** — 동일 이유로 `notify_close()` 미호출
---
## 선택한 접근 방식
**방식 A: `python-binance` 내장 User Data Stream + 30분 수동 keepalive 보강**
- 기존 `BinanceSocketManager` 활용으로 추가 의존성 없음
- `futures_user_socket(listenKey)`로 User Data Stream 연결
- 별도 30분 keepalive 백그라운드 태스크로 안정성 보강
- `while True: try-except` 무한 재연결 루프로 네트워크 단절 복구
---
## 전체 아키텍처
### 파일 변경 목록
| 파일 | 변경 유형 | 내용 |
|------|----------|------|
| `src/user_data_stream.py` | **신규** | User Data Stream 전담 클래스 |
| `src/bot.py` | 수정 | `UserDataStream` 초기화, `run()` 병렬 실행, `_on_position_closed()` 콜백, `_entry_price`/`_entry_quantity` 상태 추가 |
| `src/exchange.py` | 수정 | `create_listen_key()`, `keepalive_listen_key()`, `delete_listen_key()` 메서드 추가 |
| `src/notifier.py` | 수정 | `notify_close()``close_reason`, `estimated_pnl`, `net_pnl` 파라미터 추가 |
| `src/risk_manager.py` | 수정 | `record_pnl()`이 net_pnl을 받도록 유지 (인터페이스 변경 없음) |
### 실행 흐름
```
bot.run()
└── AsyncClient 단일 인스턴스 생성
└── asyncio.gather()
├── MultiSymbolStream.start(client) ← 기존 캔들 스트림
└── UserDataStream.start() ← 신규
├── [백그라운드] _keepalive_loop() 30분마다 PUT /listenKey
└── [메인루프] while True:
try:
listenKey 발급
futures_user_socket() 연결
async for msg: _handle_message()
except CancelledError: break
except Exception: sleep(5) → 재연결
```
---
## 섹션 1: UserDataStream 클래스 (`src/user_data_stream.py`)
### 상수
```python
KEEPALIVE_INTERVAL = 30 * 60 # 30분 (listenKey 만료 60분의 절반)
RECONNECT_DELAY = 5 # 재연결 대기 초
```
### listenKey 생명주기
| 단계 | API | 시점 |
|------|-----|------|
| 발급 | `POST /fapi/v1/listenKey` | 연결 시작 / 재연결 시 |
| 갱신 | `PUT /fapi/v1/listenKey` | 30분마다 (백그라운드 태스크) |
| 삭제 | `DELETE /fapi/v1/listenKey` | 봇 정상 종료 시 (`CancelledError`) |
### 재연결 로직
```python
while True:
try:
listen_key = await exchange.create_listen_key()
keepalive_task = asyncio.create_task(_keepalive_loop(listen_key))
async with bm.futures_user_socket(listen_key):
async for msg:
await _handle_message(msg)
except asyncio.CancelledError:
await exchange.delete_listen_key(listen_key)
keepalive_task.cancel()
break
except Exception as e:
logger.warning(f"User Data Stream 끊김: {e}, {RECONNECT_DELAY}초 후 재연결")
keepalive_task.cancel()
await asyncio.sleep(RECONNECT_DELAY)
# while True 상단으로 돌아가 listenKey 재발급
```
### keepalive 백그라운드 태스크
```python
async def _keepalive_loop(listen_key: str):
while True:
await asyncio.sleep(KEEPALIVE_INTERVAL)
try:
await exchange.keepalive_listen_key(listen_key)
logger.debug("listenKey 갱신 완료")
except Exception:
logger.warning("listenKey 갱신 실패 → 재연결 루프가 처리")
break # 재연결 루프가 새 태스크 생성
```
---
## 섹션 2: 이벤트 파싱 로직
### 페이로드 구조 (Binance Futures ORDER_TRADE_UPDATE)
주문 상세 정보는 최상위가 아닌 **내부 `"o"` 딕셔너리에 중첩**되어 있다.
```json
{
"e": "ORDER_TRADE_UPDATE",
"o": {
"x": "TRADE", // Execution Type
"X": "FILLED", // Order Status
"o": "TAKE_PROFIT_MARKET", // Order Type
"R": true, // reduceOnly
"rp": "0.48210000", // realizedProfit
"n": "0.02100000", // commission
"ap": "1.3393" // average price (체결가)
}
}
```
### 판단 트리
```
msg["e"] == "ORDER_TRADE_UPDATE"?
└── order = msg["o"]
order["x"] == "TRADE" AND order["X"] == "FILLED"?
└── 청산 주문인가?
(order["R"] == true OR float(order["rp"]) != 0
OR order["o"] in {"TAKE_PROFIT_MARKET", "STOP_MARKET"})
├── NO → 무시 (진입 주문)
└── YES → close_reason 판별:
"TAKE_PROFIT_MARKET" → "TP"
"STOP_MARKET" → "SL"
그 외 → "MANUAL"
net_pnl = float(rp) - abs(float(n))
exit_price = float(order["ap"])
await on_order_filled(net_pnl, close_reason, exit_price)
```
---
## 섹션 3: `_on_position_closed()` 콜백 (`src/bot.py`)
### 진입가 상태 저장
`_open_position()` 내부에서 진입가와 수량을 인스턴스 변수로 저장한다. 청산 시점에는 포지션이 이미 사라져 있으므로 사전 저장이 필수다.
```python
# __init__에 추가
self._entry_price: float | None = None
self._entry_quantity: float | None = None
# _open_position() 내부에서 저장
self._entry_price = price
self._entry_quantity = quantity
```
### 예상 PnL 계산
```python
def _calc_estimated_pnl(self, exit_price: float) -> float:
if self._entry_price is None or self._entry_quantity is None:
return 0.0
if self.current_trade_side == "LONG":
return (exit_price - self._entry_price) * self._entry_quantity
else: # SHORT
return (self._entry_price - exit_price) * self._entry_quantity
```
### 콜백 전체 흐름
```python
async def _on_position_closed(
self,
net_pnl: float,
close_reason: str, # "TP" | "SL" | "MANUAL"
exit_price: float,
):
estimated_pnl = self._calc_estimated_pnl(exit_price)
diff = net_pnl - estimated_pnl # 슬리피지 + 수수료 차이
# RiskManager에 순수익 기록
self.risk.record_pnl(net_pnl)
# Discord 알림
self.notifier.notify_close(
symbol=self.config.symbol,
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(
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
)
# 봇 상태 초기화 (Flat 상태로 복귀)
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
```
### 기존 `_close_position()` 변경
봇이 직접 청산하는 경우(`_close_and_reenter`)에도 User Data Stream의 `ORDER_TRADE_UPDATE`가 발생한다. **중복 처리 방지**를 위해 `_close_position()`에서 `notify_close()``record_pnl()` 호출을 제거한다. 모든 청산 후처리는 `_on_position_closed()` 콜백 하나로 일원화한다.
---
## 섹션 4: Discord 알림 포맷 (`src/notifier.py`)
### `notify_close()` 시그니처 변경
```python
def notify_close(
self,
symbol: str,
side: str,
close_reason: str, # "TP" | "SL" | "MANUAL"
exit_price: float,
estimated_pnl: float,
net_pnl: float,
diff: float, # net_pnl - estimated_pnl
) -> None:
```
### 알림 포맷
```
✅ [XRPUSDT] SHORT TP 청산
청산가: `1.3393`
예상 수익: `+0.4821 USDT`
실제 순수익: `+0.4612 USDT`
차이(슬리피지+수수료): `-0.0209 USDT`
```
| 청산 원인 | 이모지 |
|----------|--------|
| TP | ✅ |
| SL | ❌ |
| MANUAL | 🔶 |
---
## 섹션 5: `src/exchange.py` 추가 메서드
```python
async def create_listen_key(self) -> str:
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
async def keepalive_listen_key(self, listen_key: str) -> None:
"""PUT /fapi/v1/listenKey — listenKey 만료 연장"""
async def delete_listen_key(self, listen_key: str) -> None:
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
```
---
## 데이터 흐름 요약
```
Binance WebSocket
→ ORDER_TRADE_UPDATE (FILLED, reduceOnly)
→ UserDataStream._handle_message()
→ net_pnl = rp - |commission|
→ bot._on_position_closed(net_pnl, close_reason, exit_price)
├── estimated_pnl = (exit - entry) × qty (봇 계산)
├── diff = net_pnl - estimated_pnl
├── risk.record_pnl(net_pnl) → 일일 PnL 누적
├── notifier.notify_close(...) → Discord 알림
└── 상태 초기화 (current_trade_side, _entry_price, _entry_quantity = None)
```
---
## 제외 범위 (YAGNI)
- DB 영구 저장 (SQLite/Postgres) — 현재 로그 기반으로 충분
- 진입 주문 체결 알림 (`TRADE` + not reduceOnly) — 기존 `notify_open()`으로 커버
- 부분 청산(partial fill) 처리 — 현재 봇은 전량 청산만 사용

View File

@@ -0,0 +1,510 @@
# User Data Stream TP/SL 감지 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Binance Futures User Data Stream을 도입하여 TP/SL 작동을 실시간 감지하고, 순수익(Net PnL)을 기록하며, Discord에 예상 수익 vs 실제 순수익 비교 알림을 전송한다.
**Architecture:** `python-binance``futures_user_socket(listenKey)`로 User Data Stream에 연결하고, 30분 keepalive 백그라운드 태스크와 `while True: try-except` 무한 재연결 루프로 안정성을 확보한다. `ORDER_TRADE_UPDATE` 이벤트에서 청산 주문을 감지하면 `bot._on_position_closed()` 콜백을 호출하여 PnL 기록과 Discord 알림을 일원화한다.
**Tech Stack:** Python 3.12, python-binance (AsyncClient, BinanceSocketManager), asyncio, loguru
**Design Doc:** `docs/plans/2026-03-02-user-data-stream-tp-sl-detection-design.md`
---
## Task 1: `exchange.py`에 listenKey 관리 메서드 추가
**Files:**
- Modify: `src/exchange.py` (끝에 메서드 추가)
**Step 1: listenKey 3개 메서드 구현**
`src/exchange.py` 끝에 아래 메서드 3개를 추가한다.
```python
async def create_listen_key(self) -> str:
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
loop = asyncio.get_event_loop()
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:
"""PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)"""
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: self.client.futures_stream_keepalive(listenKey=listen_key),
)
async def delete_listen_key(self, listen_key: str) -> None:
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(
None,
lambda: self.client.futures_stream_close(listenKey=listen_key),
)
except Exception as e:
logger.warning(f"listenKey 삭제 실패 (무시): {e}")
```
**Step 2: 커밋**
```bash
git add src/exchange.py
git commit -m "feat: add listenKey create/keepalive/delete methods to exchange"
```
---
## Task 2: `notifier.py`의 `notify_close()` 시그니처 확장
**Files:**
- Modify: `src/notifier.py`
**Step 1: `notify_close()` 메서드 교체**
기존 `notify_close()`를 아래로 교체한다. `close_reason`, `estimated_pnl`, `net_pnl`, `diff` 파라미터가 추가된다.
```python
def notify_close(
self,
symbol: str,
side: str,
close_reason: str, # "TP" | "SL" | "MANUAL"
exit_price: float,
estimated_pnl: float, # 봇 계산 (entry-exit 기반)
net_pnl: float, # 바이낸스 rp - |commission|
diff: float, # net_pnl - estimated_pnl (슬리피지+수수료)
) -> None:
emoji_map = {"TP": "", "SL": "", "MANUAL": "🔶"}
emoji = emoji_map.get(close_reason, "🔶")
msg = (
f"{emoji} **[{symbol}] {side} {close_reason} 청산**\n"
f"청산가: `{exit_price:.4f}`\n"
f"예상 수익: `{estimated_pnl:+.4f} USDT`\n"
f"실제 순수익: `{net_pnl:+.4f} USDT`\n"
f"차이(슬리피지+수수료): `{diff:+.4f} USDT`"
)
self._send(msg)
```
**Step 2: 커밋**
```bash
git add src/notifier.py
git commit -m "feat: extend notify_close with close_reason, net_pnl, diff fields"
```
---
## Task 3: `src/user_data_stream.py` 신규 생성
**Files:**
- Create: `src/user_data_stream.py`
**Step 1: 파일 전체 작성**
```python
import asyncio
from typing import Callable
from binance import AsyncClient, BinanceSocketManager
from loguru import logger
_KEEPALIVE_INTERVAL = 30 * 60 # 30분 (listenKey 만료 60분의 절반)
_RECONNECT_DELAY = 5 # 재연결 대기 초
_CLOSE_ORDER_TYPES = {"TAKE_PROFIT_MARKET", "STOP_MARKET"}
class UserDataStream:
"""
Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다.
- listenKey 30분 keepalive 백그라운드 태스크
- 네트워크 단절 시 무한 재연결 루프
- ORDER_TRADE_UPDATE 이벤트에서 청산 주문만 필터링하여 콜백 호출
"""
def __init__(
self,
exchange, # BinanceFuturesClient 인스턴스
on_order_filled: Callable, # bot._on_position_closed 콜백
):
self._exchange = exchange
self._on_order_filled = on_order_filled
self._listen_key: str | None = None
self._keepalive_task: asyncio.Task | None = None
async def start(self, api_key: str, api_secret: str) -> None:
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
client = await AsyncClient.create(
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:
"""listenKey 발급 → 연결 → 재연결 무한 루프."""
while True:
try:
self._listen_key = await self._exchange.create_listen_key()
logger.info(f"User Data Stream listenKey 발급: {self._listen_key[:8]}...")
self._keepalive_task = asyncio.create_task(
self._keepalive_loop(self._listen_key)
)
async with bm.futures_user_socket(self._listen_key) as stream:
logger.info("User Data Stream 연결 완료")
async for msg in stream:
await self._handle_message(msg)
except asyncio.CancelledError:
logger.info("User Data Stream 정상 종료")
if self._listen_key:
await self._exchange.delete_listen_key(self._listen_key)
if self._keepalive_task:
self._keepalive_task.cancel()
break
except Exception as e:
logger.warning(
f"User Data Stream 끊김: {e}"
f"{_RECONNECT_DELAY}초 후 재연결"
)
if self._keepalive_task:
self._keepalive_task.cancel()
self._keepalive_task = None
await asyncio.sleep(_RECONNECT_DELAY)
async def _keepalive_loop(self, listen_key: str) -> None:
"""30분마다 listenKey를 갱신한다."""
while True:
await asyncio.sleep(_KEEPALIVE_INTERVAL)
try:
await self._exchange.keepalive_listen_key(listen_key)
logger.debug("listenKey 갱신 완료")
except Exception as e:
logger.warning(f"listenKey 갱신 실패: {e} — 재연결 루프가 처리")
break
async def _handle_message(self, msg: dict) -> None:
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
if msg.get("e") != "ORDER_TRADE_UPDATE":
return
order = msg.get("o", {})
# x: Execution Type, X: Order Status
if order.get("x") != "TRADE" or order.get("X") != "FILLED":
return
order_type = order.get("o", "")
is_reduce = order.get("R", False)
realized_pnl = float(order.get("rp", "0"))
# 청산 주문 판별: reduceOnly이거나, TP/SL 타입이거나, rp != 0
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0
if not is_close:
return
commission = abs(float(order.get("n", "0")))
net_pnl = realized_pnl - commission
exit_price = float(order.get("ap", "0"))
if order_type == "TAKE_PROFIT_MARKET":
close_reason = "TP"
elif order_type == "STOP_MARKET":
close_reason = "SL"
else:
close_reason = "MANUAL"
logger.info(
f"청산 감지({close_reason}): exit={exit_price:.4f}, "
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
f"net_pnl={net_pnl:+.4f}"
)
await self._on_order_filled(
net_pnl=net_pnl,
close_reason=close_reason,
exit_price=exit_price,
)
```
**Step 2: 커밋**
```bash
git add src/user_data_stream.py
git commit -m "feat: add UserDataStream with keepalive and reconnect loop"
```
---
## Task 4: `bot.py` 수정 — 상태 변수 추가 및 `_open_position()` 저장
**Files:**
- Modify: `src/bot.py`
**Step 1: `__init__`에 상태 변수 추가**
`TradingBot.__init__()` 내부에서 `self.current_trade_side` 선언 바로 아래에 추가한다.
```python
self._entry_price: float | None = None
self._entry_quantity: float | None = None
```
**Step 2: `_open_position()` 내부에서 진입가/수량 저장**
`self.current_trade_side = signal` 바로 아래에 추가한다.
```python
self._entry_price = price
self._entry_quantity = quantity
```
**Step 3: 커밋**
```bash
git add src/bot.py
git commit -m "feat: store entry_price and entry_quantity on position open"
```
---
## Task 5: `bot.py` 수정 — `_on_position_closed()` 콜백 추가
**Files:**
- Modify: `src/bot.py`
**Step 1: `_calc_estimated_pnl()` 헬퍼 메서드 추가**
`_close_position()` 메서드 바로 위에 추가한다.
```python
def _calc_estimated_pnl(self, exit_price: float) -> float:
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
if self._entry_price is None or self._entry_quantity is None:
return 0.0
if self.current_trade_side == "LONG":
return (exit_price - self._entry_price) * self._entry_quantity
return (self._entry_price - exit_price) * self._entry_quantity
```
**Step 2: `_on_position_closed()` 콜백 추가**
`_calc_estimated_pnl()` 바로 아래에 추가한다.
```python
async def _on_position_closed(
self,
net_pnl: float,
close_reason: str,
exit_price: float,
) -> None:
"""User Data Stream에서 청산 감지 시 호출되는 콜백."""
estimated_pnl = self._calc_estimated_pnl(exit_price)
diff = net_pnl - estimated_pnl
self.risk.record_pnl(net_pnl)
self.notifier.notify_close(
symbol=self.config.symbol,
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(
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
)
# Flat 상태로 초기화
self.current_trade_side = None
self._entry_price = None
self._entry_quantity = None
```
**Step 3: 커밋**
```bash
git add src/bot.py
git commit -m "feat: add _on_position_closed callback with net PnL and discord alert"
```
---
## Task 6: `bot.py` 수정 — `_close_position()`에서 중복 후처리 제거
**Files:**
- Modify: `src/bot.py`
**배경:** 봇이 직접 청산(`_close_and_reenter`)하는 경우에도 User Data Stream의 `ORDER_TRADE_UPDATE`가 발생한다. 중복 방지를 위해 `_close_position()`에서 `notify_close()``record_pnl()` 호출을 제거한다.
**Step 1: `_close_position()` 수정**
기존 코드:
```python
async def _close_position(self, position: dict):
amt = abs(float(position["positionAmt"]))
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
pos_side = "LONG" if side == "SELL" else "SHORT"
await self.exchange.cancel_all_orders()
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)
entry = float(position["entryPrice"])
mark = float(position["markPrice"])
pnl = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt
self.notifier.notify_close(
symbol=self.config.symbol,
side=pos_side,
exit_price=mark,
pnl=pnl,
)
self.risk.record_pnl(pnl)
self.current_trade_side = None
logger.success(f"포지션 청산: PnL={pnl:.4f} USDT")
```
수정 후 (`notify_close`, `record_pnl`, `current_trade_side = None` 제거 — User Data Stream 콜백이 처리):
```python
async def _close_position(self, position: dict):
"""포지션 청산 주문만 실행한다. PnL 기록/알림은 _on_position_closed 콜백이 담당."""
amt = abs(float(position["positionAmt"]))
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
await self.exchange.cancel_all_orders()
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)
logger.info(f"청산 주문 전송 완료 (side={side}, qty={amt})")
```
**Step 2: 커밋**
```bash
git add src/bot.py
git commit -m "refactor: remove duplicate pnl/notify from _close_position (handled by callback)"
```
---
## Task 7: `bot.py` 수정 — `run()`에서 UserDataStream 병렬 실행
**Files:**
- Modify: `src/bot.py`
**Step 1: import 추가**
파일 상단 import 블록에 추가한다.
```python
from src.user_data_stream import UserDataStream
```
**Step 2: `run()` 메서드 수정**
기존:
```python
async def run(self):
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
await self._recover_position()
balance = await self.exchange.get_balance()
self.risk.set_base_balance(balance)
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
await self.stream.start(
api_key=self.config.api_key,
api_secret=self.config.api_secret,
)
```
수정 후:
```python
async def run(self):
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
await self._recover_position()
balance = await self.exchange.get_balance()
self.risk.set_base_balance(balance)
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
user_stream = UserDataStream(
exchange=self.exchange,
on_order_filled=self._on_position_closed,
)
await asyncio.gather(
self.stream.start(
api_key=self.config.api_key,
api_secret=self.config.api_secret,
),
user_stream.start(
api_key=self.config.api_key,
api_secret=self.config.api_secret,
),
)
```
**Step 3: 커밋**
```bash
git add src/bot.py
git commit -m "feat: run UserDataStream in parallel with candle stream"
```
---
## Task 8: README.md 업데이트
**Files:**
- Modify: `README.md`
**Step 1: 기능 목록에 User Data Stream 항목 추가**
README의 주요 기능 섹션에 아래 내용을 추가한다.
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음)
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결
**Step 2: 커밋**
```bash
git add README.md
git commit -m "docs: update README with User Data Stream TP/SL detection feature"
```
---
## 최종 검증
봇 실행 후 로그에서 아래 메시지가 순서대로 나타나면 정상 동작:
```
INFO | User Data Stream listenKey 발급: xxxxxxxx...
INFO | User Data Stream 연결 완료
DEBUG | listenKey 갱신 완료 ← 30분 후
INFO | 청산 감지(TP): exit=1.3393, rp=+0.4821, commission=0.0209, net_pnl=+0.4612
SUCCESS | 포지션 청산(TP): 예상=+0.4821, 순수익=+0.4612, 차이=-0.0209 USDT
```
Discord에는 아래 형식의 알림이 전송됨:
```
✅ [XRPUSDT] SHORT TP 청산
청산가: 1.3393
예상 수익: +0.4821 USDT
실제 순수익: +0.4612 USDT
차이(슬리피지+수수료): -0.0209 USDT
```

View File

@@ -0,0 +1,80 @@
# Optuna 목적함수를 Precision 중심으로 변경
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 현재 ROC-AUC만 최적화하는 Optuna objective를 **recall >= 0.35 제약 하에서 precision을 최대화**하는 방향으로 변경한다. AUC는 threshold-independent 지표라 실제 운용 시점의 성능(precision)을 반영하지 못하며, 오탐(false positive = 잘못된 진입)이 실제 손실을 발생시키므로 precision 우선 최적화가 필요하다.
**Tech Stack:** Python, LightGBM, Optuna, scikit-learn
---
## 변경 파일
- `scripts/tune_hyperparams.py` (유일한 변경 대상)
---
## 구현 단계
### 1. `_find_best_precision_at_recall` 헬퍼 함수 추가
- `sklearn.metrics.precision_recall_curve`로 recall >= min_recall 조건의 최대 precision과 threshold 반환
- 조건 불만족 시 `(0.0, 0.0, 0.50)` fallback
- train_model.py:277-292와 동일한 로직
### 2. `_walk_forward_cv` 수정
- 기존 반환: `(mean_auc, fold_aucs)` → 신규: `(mean_score, details_dict)`
- `details_dict` 키: `fold_aucs`, `fold_precisions`, `fold_recalls`, `fold_thresholds`, `fold_n_pos`, `mean_auc`, `mean_precision`, `mean_recall`
- **Score 공식**: `precision + auc * 0.001` (AUC는 precision 동률 시 tiebreaker)
- fold 내 양성 < 3개면 해당 fold precision=0.0으로 처리, 평균 계산에서 제외
- 인자 추가: `min_recall: float = 0.35`
- import 추가: `from sklearn.metrics import precision_recall_curve`
- Pruning: 양성 충분한 fold만 report하여 false pruning 방지
### 3. `make_objective` 수정
- `min_recall` 인자 추가 → `_walk_forward_cv`에 전달
- `trial.set_user_attr`로 precision/recall/threshold/n_pos 등 저장
- 반환값: `mean_score` (precision + auc * 0.001)
### 4. `measure_baseline` 수정
- `min_recall` 인자 추가
- 반환값을 `(mean_score, details_dict)` 형태로 변경
### 5. `--min-recall` CLI 인자 추가
- `parser.add_argument("--min-recall", type=float, default=0.35)`
- `make_objective``measure_baseline`에 전달
### 6. `print_report` 수정
- Best Score, Precision, AUC 모두 표시
- 폴드별 AUC + Precision + Recall + Threshold + 양성수 표시
- Baseline과 비교 시 precision 기준 개선폭 표시
### 7. `save_results` 수정
- JSON에 `min_recall_constraint`, precision/recall/threshold 필드 추가
- `best_trial``score`, `precision`, `recall`, `threshold`, `fold_precisions`, `fold_recalls`, `fold_thresholds`, `fold_n_pos` 추가
- `best_trial.params` 구조는 그대로 유지 (하위호환)
### 8. 비교 로직 및 기타 수정
- line 440: `study.best_value > baseline_auc``study.best_value > baseline_score`
- `study_name`: `"lgbm_wf_auc"``"lgbm_wf_precision"`
- progress callback: Precision과 AUC 동시 표시
- `n_warmup_steps` 2 → 3 (precision이 AUC보다 노이즈가 크므로)
---
## 검증 방법
```bash
# 기본 실행 (min_recall=0.35)
python scripts/tune_hyperparams.py --trials 10 --folds 3
# min_recall 조절
python scripts/tune_hyperparams.py --trials 10 --min-recall 0.4
# 기존 테스트 통과 확인
bash scripts/run_tests.sh
```
확인 포인트:
- 폴드별 precision/recall/threshold가 리포트에 표시되는지
- recall >= min_recall 제약이 올바르게 동작하는지
- active_lgbm_params.json이 precision 기준으로 갱신되는지
- train_model.py가 새 JSON 포맷을 기존과 동일하게 읽는지

0
models/.gitkeep Normal file
View File

File diff suppressed because it is too large Load Diff

BIN
models/mlx_filter.meta.npz Normal file

Binary file not shown.

BIN
models/mlx_filter.npz Normal file

Binary file not shown.

BIN
models/mlx_filter.onnx Normal file

Binary file not shown.

Binary file not shown.

430
models/training_log.json Normal file
View File

@@ -0,0 +1,430 @@
[
{
"date": "2026-03-01T18:04:50.871434",
"auc": 0.546,
"samples": 1772,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T18:44:22.163935",
"backend": "mlx",
"auc": 0.5054,
"samples": 1772,
"train_sec": 0.7,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-01T18:52:52.991917",
"auc": 0.5405,
"samples": 1704,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T18:54:48.200006",
"auc": 0.5405,
"samples": 1704,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T19:29:21.454533",
"auc": 0.5321,
"samples": 1696,
"features": 21,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:03:56.314547",
"auc": 0.5406,
"samples": 1707,
"features": 21,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:12:23.866860",
"auc": 0.502,
"samples": 3269,
"features": 21,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:46:29.599674",
"backend": "mlx",
"auc": 0.516,
"samples": 6470,
"train_sec": 1.3,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-01T21:50:12.449819",
"backend": "lgbm",
"auc": 0.4772,
"samples": 6470,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:50:32.491318",
"backend": "lgbm",
"auc": 0.4943,
"samples": 6470,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:50:48.665654",
"backend": "lgbm",
"auc": 0.4943,
"samples": 6470,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:51:02.539565",
"backend": "lgbm",
"auc": 0.4943,
"samples": 6470,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:51:09.830250",
"backend": "lgbm",
"auc": 0.4925,
"samples": 1716,
"features": 13,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:51:20.133303",
"backend": "lgbm",
"auc": 0.54,
"samples": 1716,
"features": 13,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:51:25.445363",
"backend": "lgbm",
"auc": 0.4943,
"samples": 6470,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T21:52:24.296191",
"backend": "lgbm",
"auc": 0.54,
"samples": 1716,
"features": 13,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T22:00:34.737597",
"backend": "lgbm",
"auc": 0.5097,
"samples": 6470,
"features": 21,
"time_weight_decay": 3.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T22:12:06.299119",
"backend": "mlx",
"auc": 0.5746,
"samples": 533,
"train_sec": 0.2,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-01T22:13:20.434893",
"backend": "mlx",
"auc": 0.5663,
"samples": 533,
"train_sec": 0.2,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-01T22:15:43.163315",
"backend": "lgbm",
"auc": 0.5581,
"samples": 533,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T22:18:59.852831",
"backend": "lgbm",
"auc": 0.5504,
"samples": 533,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T22:19:29.532472",
"backend": "lgbm",
"auc": 0.5504,
"samples": 533,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T22:19:30.938005",
"backend": "mlx",
"auc": 0.5714,
"samples": 533,
"train_sec": 0.1,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-01T22:26:46.459326",
"backend": "mlx",
"auc": 0.6167,
"samples": 533,
"train_sec": 0.2,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-01T22:45:55.473533",
"backend": "lgbm",
"auc": 0.556,
"samples": 533,
"features": 23,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T23:04:51.194544",
"backend": "mlx",
"auc": 0.5972,
"samples": 533,
"train_sec": 0.1,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-01T23:59:27.956019",
"backend": "mlx",
"auc": 0.5595,
"best_threshold": 0.9538,
"best_precision": 0.462,
"best_recall": 0.171,
"samples": 533,
"train_sec": 0.2,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-02T00:40:15.931055",
"backend": "mlx",
"auc": 0.5829,
"best_threshold": 0.9609,
"best_precision": 0.6,
"best_recall": 0.171,
"samples": 534,
"train_sec": 0.2,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-02T00:54:32.264425",
"backend": "lgbm",
"auc": 0.5607,
"best_threshold": 0.6532,
"best_precision": 0.467,
"best_recall": 0.2,
"samples": 533,
"features": 23,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-02T01:07:30.690959",
"backend": "lgbm",
"auc": 0.5579,
"best_threshold": 0.6511,
"best_precision": 0.4,
"best_recall": 0.171,
"samples": 533,
"features": 23,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-02T02:00:45.931227",
"backend": "lgbm",
"auc": 0.5752,
"best_threshold": 0.6307,
"best_precision": 0.471,
"best_recall": 0.229,
"samples": 533,
"features": 23,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-02T14:51:09.101738",
"backend": "lgbm",
"auc": 0.5361,
"best_threshold": 0.5308,
"best_precision": 0.406,
"best_recall": 0.371,
"samples": 533,
"features": 23,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl",
"tuned_params_path": "models/tune_results_20260302_144749.json",
"lgbm_params": {
"n_estimators": 434,
"learning_rate": 0.123659,
"num_leaves": 14,
"min_child_samples": 10,
"subsample": 0.929062,
"colsample_bytree": 0.94633,
"reg_alpha": 0.573971,
"reg_lambda": 0.000157,
"max_depth": 6
},
"weight_scale": 1.783105
},
{
"date": "2026-03-02T18:10:27.584046",
"backend": "lgbm",
"auc": 0.5466,
"best_threshold": 0.6424,
"best_precision": 0.426,
"best_recall": 0.556,
"samples": 535,
"features": 23,
"time_weight_decay": 0.5,
"model_path": "models/lgbm_filter.pkl",
"tuned_params_path": null,
"lgbm_params": {
"n_estimators": 434,
"learning_rate": 0.123659,
"max_depth": 6,
"num_leaves": 14,
"min_child_samples": 10,
"subsample": 0.929062,
"colsample_bytree": 0.94633,
"reg_alpha": 0.573971,
"reg_lambda": 0.000157
},
"weight_scale": 1.783105
},
{
"date": "2026-03-03T00:12:17.351458",
"backend": "lgbm",
"auc": 0.949,
"best_threshold": 0.42,
"best_precision": 0.56,
"best_recall": 0.538,
"samples": 1524,
"features": 23,
"time_weight_decay": 0.5,
"model_path": "models/lgbm_filter.pkl",
"tuned_params_path": null,
"lgbm_params": {
"n_estimators": 434,
"learning_rate": 0.123659,
"max_depth": 6,
"num_leaves": 14,
"min_child_samples": 10,
"subsample": 0.929062,
"colsample_bytree": 0.94633,
"reg_alpha": 0.573971,
"reg_lambda": 0.000157
},
"weight_scale": 1.783105
},
{
"date": "2026-03-03T00:13:56.456518",
"backend": "lgbm",
"auc": 0.9439,
"best_threshold": 0.6558,
"best_precision": 0.667,
"best_recall": 0.154,
"samples": 1524,
"features": 23,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl",
"tuned_params_path": null,
"lgbm_params": {
"n_estimators": 434,
"learning_rate": 0.123659,
"max_depth": 6,
"num_leaves": 14,
"min_child_samples": 10,
"subsample": 0.929062,
"colsample_bytree": 0.94633,
"reg_alpha": 0.573971,
"reg_lambda": 0.000157
},
"weight_scale": 1.783105
},
{
"date": "2026-03-03T00:20:43.712971",
"backend": "lgbm",
"auc": 0.9473,
"best_threshold": 0.3015,
"best_precision": 0.465,
"best_recall": 0.769,
"samples": 1524,
"features": 23,
"time_weight_decay": 0.5,
"model_path": "models/lgbm_filter.pkl",
"tuned_params_path": "models/active_lgbm_params.json",
"lgbm_params": {
"n_estimators": 195,
"learning_rate": 0.033934,
"max_depth": 3,
"num_leaves": 7,
"min_child_samples": 11,
"subsample": 0.998659,
"colsample_bytree": 0.837233,
"reg_alpha": 0.007008,
"reg_lambda": 0.80039
},
"weight_scale": 0.718348
},
{
"date": "2026-03-03T00:39:05.427160",
"backend": "lgbm",
"auc": 0.9436,
"best_threshold": 0.3041,
"best_precision": 0.467,
"best_recall": 0.269,
"samples": 1524,
"features": 23,
"time_weight_decay": 0.5,
"model_path": "models/lgbm_filter.pkl",
"tuned_params_path": "models/active_lgbm_params.json",
"lgbm_params": {
"n_estimators": 221,
"learning_rate": 0.031072,
"max_depth": 5,
"num_leaves": 20,
"min_child_samples": 39,
"subsample": 0.83244,
"colsample_bytree": 0.526349,
"reg_alpha": 0.062177,
"reg_lambda": 0.082872
},
"weight_scale": 1.431662
}
]

View File

@@ -1,5 +1,5 @@
python-binance==1.0.19 python-binance>=1.0.28
pandas>=2.2.0 pandas>=2.3.2
pandas-ta==0.4.71b0 pandas-ta==0.4.71b0
python-dotenv==1.0.0 python-dotenv==1.0.0
httpx>=0.27.0 httpx>=0.27.0
@@ -8,3 +8,9 @@ pytest-asyncio>=0.24.0
aiohttp==3.9.3 aiohttp==3.9.3
websockets==12.0 websockets==12.0
loguru==0.7.2 loguru==0.7.2
lightgbm>=4.3.0
scikit-learn>=1.4.0
joblib>=1.3.0
pyarrow>=15.0.0
onnxruntime>=1.18.0
optuna>=3.6.0

0
scripts/__init__.py Normal file
View File

91
scripts/deploy_model.sh Executable file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# 맥미니에서 학습한 모델을 LXC 컨테이너 볼륨 경로로 전송한다.
# 사용법: bash scripts/deploy_model.sh [lgbm|mlx]
#
# 예시:
# bash scripts/deploy_model.sh # LightGBM (기본값)
# bash scripts/deploy_model.sh mlx # MLX 신경망
set -euo pipefail
BACKEND="${1:-lgbm}"
LXC_HOST="root@10.1.10.24"
LXC_MODELS_PATH="/root/cointrader/models"
LOCAL_LOG="models/training_log.json"
# ── 백엔드별 파일 목록 설정 ──────────────────────────────────────────────────
# mlx: ONNX 파일만 전송 (Linux 서버는 onnxruntime으로 추론)
# lgbm: pkl 파일 전송
if [ "$BACKEND" = "mlx" ]; then
LOCAL_FILES=("models/mlx_filter.weights.onnx")
else
LOCAL_FILES=("models/lgbm_filter.pkl")
fi
# ── 파일 존재 확인 ────────────────────────────────────────────────────────────
for f in "${LOCAL_FILES[@]}"; do
if [[ ! -f "$f" ]]; then
echo "[오류] 모델 파일 없음: $f"
exit 1
fi
done
echo "=== 모델 전송 시작 (백엔드: ${BACKEND}) ==="
echo " 대상: ${LXC_HOST}:${LXC_MODELS_PATH}"
# ── 원격 디렉터리 생성 + 백업 + 상대 백엔드 파일 제거 ───────────────────────
# lgbm 배포 시: 기존 lgbm 백업 후 ONNX 파일 삭제 (ONNX 우선순위 때문에 lgbm이 무시되는 것 방지)
# mlx 배포 시: lgbm 파일 삭제 (명시적으로 mlx만 사용)
ssh "${LXC_HOST}" "
mkdir -p '${LXC_MODELS_PATH}'
if [ '$BACKEND' = 'lgbm' ]; then
if [ -f '${LXC_MODELS_PATH}/lgbm_filter.pkl' ]; then
cp '${LXC_MODELS_PATH}/lgbm_filter.pkl' '${LXC_MODELS_PATH}/lgbm_filter_prev.pkl'
echo ' 기존 lgbm 모델 백업 완료'
fi
if [ -f '${LXC_MODELS_PATH}/mlx_filter.weights.onnx' ]; then
rm '${LXC_MODELS_PATH}/mlx_filter.weights.onnx'
echo ' ONNX 파일 제거 완료 (lgbm 우선 적용)'
fi
else
if [ -f '${LXC_MODELS_PATH}/lgbm_filter.pkl' ]; then
rm '${LXC_MODELS_PATH}/lgbm_filter.pkl'
echo ' lgbm 파일 제거 완료 (mlx 우선 적용)'
fi
fi
"
# ── 파일 전송 헬퍼 (rsync 우선, scp 폴백) ────────────────────────────────────
_send() {
local src="$1" dst="$2"
echo " 전송: $src${LXC_HOST}:$dst"
if command -v rsync &>/dev/null && ssh "${LXC_HOST}" "command -v rsync" &>/dev/null; then
rsync -avz --progress "$src" "${LXC_HOST}:$dst"
else
scp "$src" "${LXC_HOST}:$dst"
fi
}
# ── 모델 파일 전송 ────────────────────────────────────────────────────────────
for f in "${LOCAL_FILES[@]}"; do
_send "$f" "${LXC_MODELS_PATH}/$(basename "$f")"
done
# ── 학습 로그 전송 ────────────────────────────────────────────────────────────
if [[ -f "$LOCAL_LOG" ]]; then
_send "$LOCAL_LOG" "${LXC_MODELS_PATH}/training_log.json"
echo " 학습 로그 전송 완료"
fi
echo "=== 전송 완료 ==="
echo ""
# ── 핫리로드 안내 ────────────────────────────────────────────────────────────
# 봇이 캔들마다 모델 파일 mtime을 감지해 자동 리로드한다.
# 컨테이너가 실행 중이면 다음 캔들(최대 1분) 안에 자동 적용된다.
echo "=== 모델 전송 완료 — 봇이 다음 캔들에서 자동 리로드합니다 ==="
if ssh "${LXC_HOST}" "docker inspect -f '{{.State.Running}}' cointrader 2>/dev/null | grep -q true"; then
echo " 컨테이너 실행 중: 다음 캔들 마감 시 자동 핫리로드 예정"
else
echo " cointrader 컨테이너가 실행 중이 아닙니다."
fi

375
scripts/fetch_history.py Normal file
View File

@@ -0,0 +1,375 @@
"""
바이낸스 선물 REST API로 과거 캔들 데이터를 수집해 parquet으로 저장한다.
사용법: python scripts/fetch_history.py --symbol XRPUSDT --interval 1m --days 90
python scripts/fetch_history.py --symbols XRPUSDT BTCUSDT ETHUSDT --days 90
OI/펀딩비 수집 제약:
- OI 히스토리: 바이낸스 API 제한으로 최근 30일치만 제공 (period=15m, limit=500/req)
- 펀딩비: 8시간 주기 → 15분봉에 forward-fill 병합
- 30일 이전 구간은 oi_change=0, funding_rate=0으로 채움
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import asyncio
import argparse
import aiohttp
from datetime import datetime, timezone, timedelta
import pandas as pd
from binance import AsyncClient
from dotenv import load_dotenv
import os
load_dotenv()
# 요청 사이 딜레이 (초). 바이낸스 선물 기본 한도: 2400 req/min = 40 req/s
# 1500개씩 가져오므로 90일 1m 데이터 = ~65회 요청/심볼
# 심볼 간 딜레이 없이 연속 요청하면 레이트 리밋(-1003) 발생
_REQUEST_DELAY = 0.3 # 초당 ~3.3 req → 안전 마진 충분
_FAPI_BASE = "https://fapi.binance.com"
def _now_ms() -> int:
return int(datetime.now(timezone.utc).timestamp() * 1000)
async def _fetch_klines_with_client(
client: AsyncClient,
symbol: str,
interval: str,
days: int,
) -> pd.DataFrame:
"""기존 클라이언트를 재사용해 단일 심볼 캔들을 수집한다."""
start_ts = int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp() * 1000)
all_klines = []
while True:
klines = await client.futures_klines(
symbol=symbol,
interval=interval,
startTime=start_ts,
limit=1500,
)
if not klines:
break
all_klines.extend(klines)
last_ts = klines[-1][0]
if last_ts >= _now_ms():
break
start_ts = last_ts + 1
print(f" [{symbol}] 수집 중... {len(all_klines):,}")
await asyncio.sleep(_REQUEST_DELAY)
df = pd.DataFrame(all_klines, columns=[
"timestamp", "open", "high", "low", "close", "volume",
"close_time", "quote_volume", "trades",
"taker_buy_base", "taker_buy_quote", "ignore",
])
df = df[["timestamp", "open", "high", "low", "close", "volume"]].copy()
for col in ["open", "high", "low", "close", "volume"]:
df[col] = df[col].astype(float)
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df.set_index("timestamp", inplace=True)
return df
async def fetch_klines(symbol: str, interval: str, days: int) -> pd.DataFrame:
"""단일 심볼 수집 (하위 호환용)."""
client = await AsyncClient.create(
api_key=os.getenv("BINANCE_API_KEY", ""),
api_secret=os.getenv("BINANCE_API_SECRET", ""),
)
try:
return await _fetch_klines_with_client(client, symbol, interval, days)
finally:
await client.close_connection()
async def fetch_klines_all(
symbols: list[str],
interval: str,
days: int,
) -> dict[str, pd.DataFrame]:
"""
단일 클라이언트로 여러 심볼을 순차 수집한다.
asyncio.run()을 심볼마다 반복하면 연결 오버헤드와 레이트 리밋 위험이 있으므로
하나의 연결 안에서 심볼 간 딜레이를 두고 순차 처리한다.
"""
client = await AsyncClient.create(
api_key=os.getenv("BINANCE_API_KEY", ""),
api_secret=os.getenv("BINANCE_API_SECRET", ""),
)
dfs = {}
try:
for i, symbol in enumerate(symbols):
print(f"\n[{i+1}/{len(symbols)}] {symbol} 수집 시작...")
dfs[symbol] = await _fetch_klines_with_client(client, symbol, interval, days)
print(f" [{symbol}] 완료: {len(dfs[symbol]):,}")
# 심볼 간 추가 대기: 레이트 리밋 카운터가 리셋될 시간 확보
if i < len(symbols) - 1:
print(f" 다음 심볼 수집 전 5초 대기...")
await asyncio.sleep(5)
finally:
await client.close_connection()
return dfs
async def _fetch_oi_hist(
session: aiohttp.ClientSession,
symbol: str,
period: str = "15m",
) -> pd.DataFrame:
"""
바이낸스 /futures/data/openInterestHist 엔드포인트로 OI 히스토리를 수집한다.
API 제한: 최근 30일치만 제공, 1회 최대 500개.
"""
url = f"{_FAPI_BASE}/futures/data/openInterestHist"
all_rows = []
# 30일 전부터 현재까지 수집
start_ts = int((datetime.now(timezone.utc) - timedelta(days=30)).timestamp() * 1000)
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
print(f" [{symbol}] OI 히스토리 수집 중 (최근 30일)...")
while start_ts < now_ms:
params = {
"symbol": symbol,
"period": period,
"limit": 500,
"startTime": start_ts,
}
async with session.get(url, params=params) as resp:
data = await resp.json()
if not data or not isinstance(data, list):
break
all_rows.extend(data)
last_ts = int(data[-1]["timestamp"])
if last_ts >= now_ms or len(data) < 500:
break
start_ts = last_ts + 1
await asyncio.sleep(_REQUEST_DELAY)
if not all_rows:
print(f" [{symbol}] OI 데이터 없음 — 빈 DataFrame 반환")
return pd.DataFrame(columns=["oi", "oi_value"])
df = pd.DataFrame(all_rows)
df["timestamp"] = pd.to_datetime(df["timestamp"].astype(int), unit="ms", utc=True)
df = df.set_index("timestamp")
df = df[["sumOpenInterest", "sumOpenInterestValue"]].copy()
df.columns = ["oi", "oi_value"]
df["oi"] = df["oi"].astype(float)
df["oi_value"] = df["oi_value"].astype(float)
# OI 변화율 (1캔들 전 대비)
df["oi_change"] = df["oi"].pct_change(1).fillna(0)
print(f" [{symbol}] OI 수집 완료: {len(df):,}")
return df[["oi_change"]]
async def _fetch_funding_rate(
session: aiohttp.ClientSession,
symbol: str,
days: int,
) -> pd.DataFrame:
"""
바이낸스 /fapi/v1/fundingRate 엔드포인트로 펀딩비 히스토리를 수집한다.
8시간 주기 데이터 → 15분봉 인덱스에 forward-fill로 병합 예정.
"""
url = f"{_FAPI_BASE}/fapi/v1/fundingRate"
all_rows = []
start_ts = int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp() * 1000)
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
print(f" [{symbol}] 펀딩비 히스토리 수집 중 ({days}일)...")
while start_ts < now_ms:
params = {
"symbol": symbol,
"startTime": start_ts,
"limit": 1000,
}
async with session.get(url, params=params) as resp:
data = await resp.json()
if not data or not isinstance(data, list):
break
all_rows.extend(data)
last_ts = int(data[-1]["fundingTime"])
if last_ts >= now_ms or len(data) < 1000:
break
start_ts = last_ts + 1
await asyncio.sleep(_REQUEST_DELAY)
if not all_rows:
print(f" [{symbol}] 펀딩비 데이터 없음 — 빈 DataFrame 반환")
return pd.DataFrame(columns=["funding_rate"])
df = pd.DataFrame(all_rows)
df["timestamp"] = pd.to_datetime(df["fundingTime"].astype(int), unit="ms", utc=True)
df = df.set_index("timestamp")
df["funding_rate"] = df["fundingRate"].astype(float)
print(f" [{symbol}] 펀딩비 수집 완료: {len(df):,}")
return df[["funding_rate"]]
def _merge_oi_funding(
candles: pd.DataFrame,
oi_df: pd.DataFrame,
funding_df: pd.DataFrame,
) -> pd.DataFrame:
"""
캔들 DataFrame에 OI 변화율과 펀딩비를 병합한다.
- oi_change: 15분봉 인덱스에 nearest merge (없는 구간은 0)
- funding_rate: 8시간 주기 → forward-fill 후 병합 (없는 구간은 0)
"""
result = candles.copy()
# OI 병합: 타임스탬프 기준 reindex + nearest fill
if not oi_df.empty:
oi_reindexed = oi_df.reindex(result.index, method="nearest", tolerance=pd.Timedelta("8min"))
result["oi_change"] = oi_reindexed["oi_change"].fillna(0).astype(float)
else:
result["oi_change"] = 0.0
# 펀딩비 병합: forward-fill (8시간 주기이므로 다음 펀딩 시점까지 이전 값 유지)
if not funding_df.empty:
funding_reindexed = funding_df.reindex(
result.index.union(funding_df.index)
).sort_index()
funding_reindexed = funding_reindexed["funding_rate"].ffill()
result["funding_rate"] = funding_reindexed.reindex(result.index).fillna(0).astype(float)
else:
result["funding_rate"] = 0.0
return result
async def _fetch_oi_and_funding(
symbol: str,
days: int,
candles: pd.DataFrame,
) -> pd.DataFrame:
"""단일 심볼의 OI + 펀딩비를 수집해 캔들에 병합한다."""
async with aiohttp.ClientSession() as session:
oi_df = await _fetch_oi_hist(session, symbol)
await asyncio.sleep(1)
funding_df = await _fetch_funding_rate(session, symbol, days)
return _merge_oi_funding(candles, oi_df, funding_df)
def upsert_parquet(path: "Path | str", new_df: pd.DataFrame) -> pd.DataFrame:
"""
기존 parquet 파일에 신규 데이터를 Upsert(병합)한다.
규칙:
- 기존 행의 oi_change / funding_rate가 0.0이면 신규 값으로 덮어씀
- 기존 행의 oi_change / funding_rate가 이미 0이 아니면 유지
- 신규 타임스탬프 행은 그냥 추가
- 결과는 timestamp 기준 오름차순 정렬, 중복 제거
Args:
path: 기존 parquet 경로 (없으면 new_df 그대로 반환)
new_df: 새로 수집한 DataFrame (timestamp index)
Returns:
병합된 DataFrame
"""
path = Path(path)
if not path.exists():
return new_df.sort_index()
existing = pd.read_parquet(path)
# timestamp index 통일 (tz-aware UTC)
if existing.index.tz is None:
existing.index = existing.index.tz_localize("UTC")
if new_df.index.tz is None:
new_df.index = new_df.index.tz_localize("UTC")
# 기존 데이터에서 oi_change / funding_rate가 0.0인 행만 신규 값으로 업데이트
UPSERT_COLS = ["oi_change", "funding_rate"]
overlap_idx = existing.index.intersection(new_df.index)
for col in UPSERT_COLS:
if col not in existing.columns or col not in new_df.columns:
continue
# 겹치는 행 중 기존 값이 0.0인 경우에만 신규 값으로 교체
zero_mask = existing.loc[overlap_idx, col] == 0.0
update_idx = overlap_idx[zero_mask]
if len(update_idx) > 0:
existing.loc[update_idx, col] = new_df.loc[update_idx, col]
# 신규 타임스탬프 행 추가 (기존에 없는 것만)
new_only_idx = new_df.index.difference(existing.index)
if len(new_only_idx) > 0:
existing = pd.concat([existing, new_df.loc[new_only_idx]])
# 컬럼 불일치(기존 parquet에 oi_change/funding_rate 없음)로 생긴 NaN을 0으로 채움
for col in UPSERT_COLS:
if col in existing.columns:
existing[col] = existing[col].fillna(0.0)
return existing.sort_index()
def main():
parser = argparse.ArgumentParser(
description="바이낸스 선물 과거 캔들 수집. 단일 심볼 또는 멀티 심볼 병합 저장."
)
parser.add_argument("--symbols", nargs="+", default=["XRPUSDT"])
parser.add_argument("--symbol", default=None, help="단일 심볼 (--symbols 미사용 시)")
parser.add_argument("--interval", default="15m")
parser.add_argument("--days", type=int, default=365)
parser.add_argument("--output", default="data/combined_15m.parquet")
parser.add_argument(
"--no-oi", action="store_true",
help="OI/펀딩비 수집을 건너뜀 (캔들 데이터만 저장)",
)
parser.add_argument(
"--no-upsert", action="store_true",
help="기존 parquet을 Upsert하지 않고 새로 덮어씀 (기본: Upsert 활성화)",
)
args = parser.parse_args()
# 하위 호환: --symbol 단독 사용 시 symbols로 통합
if args.symbol and args.symbols == ["XRPUSDT"]:
args.symbols = [args.symbol]
if len(args.symbols) == 1:
df = asyncio.run(fetch_klines(args.symbols[0], args.interval, args.days))
if not args.no_oi:
print(f"\n[OI/펀딩비] {args.symbols[0]} 수집 중...")
df = asyncio.run(_fetch_oi_and_funding(args.symbols[0], args.days, df))
if not args.no_upsert:
df = upsert_parquet(args.output, df)
df.to_parquet(args.output)
print(f"{'Upsert' if not args.no_upsert else '저장'} 완료: {args.output} ({len(df):,}행, {len(df.columns)}컬럼)")
else:
# 멀티 심볼: 단일 클라이언트로 순차 수집 후 타임스탬프 기준 inner join 병합
dfs = asyncio.run(fetch_klines_all(args.symbols, args.interval, args.days))
primary = args.symbols[0]
merged = dfs[primary].copy()
for symbol in args.symbols[1:]:
suffix = "_" + symbol.lower().replace("usdt", "")
merged = merged.join(
dfs[symbol].add_suffix(suffix),
how="inner",
)
# 주 심볼(XRP)에 대해서만 OI/펀딩비 수집 후 병합
if not args.no_oi:
print(f"\n[OI/펀딩비] {primary} 수집 중...")
merged = asyncio.run(_fetch_oi_and_funding(primary, args.days, merged))
output = args.output.replace("xrpusdt", "combined")
if not args.no_upsert:
merged = upsert_parquet(output, merged)
merged.to_parquet(output)
print(f"\n{'Upsert' if not args.no_upsert else '병합 저장'} 완료: {output} ({len(merged):,}행, {len(merged.columns)}컬럼)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,53 @@
"""
학습 파이프라인 각 단계의 소요 시간을 측정한다.
사용법: python scripts/profile_training.py --data data/xrpusdt_1m.parquet
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import time
import argparse
import pandas as pd
from scripts.train_model import generate_dataset, _cgroup_cpu_count
def profile(data_path: str):
print(f"데이터 로드: {data_path}")
df = pd.read_parquet(data_path)
print(f"캔들 수: {len(df)}")
workers = max(1, _cgroup_cpu_count() - 1)
print(f"사용 코어: {workers}")
t0 = time.perf_counter()
dataset = generate_dataset(df)
t1 = time.perf_counter()
print(f"\n[결과] 데이터셋 생성: {t1 - t0:.1f}초, 샘플 {len(dataset)}")
import lightgbm as lgb
from sklearn.model_selection import train_test_split
from src.ml_features import FEATURE_COLS
X = dataset[FEATURE_COLS]
y = dataset["label"]
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)
model = lgb.LGBMClassifier(
n_estimators=300, learning_rate=0.05, num_leaves=31,
min_child_samples=20, subsample=0.8, colsample_bytree=0.8,
class_weight="balanced", random_state=42, verbose=-1,
)
t2 = time.perf_counter()
model.fit(X_train, y_train)
t3 = time.perf_counter()
print(f"[결과] LightGBM 학습: {t3 - t2:.1f}")
print(f"[결과] 전체: {t3 - t0:.1f}")
print(f"\n[비율] 데이터셋 생성: {(t1-t0)/(t3-t0)*100:.0f}% / LightGBM 학습: {(t3-t2)/(t3-t0)*100:.0f}%")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--data", default="data/xrpusdt_1m.parquet")
args = parser.parse_args()
profile(args.data)

49
scripts/run_optuna.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Optuna로 LightGBM 하이퍼파라미터를 탐색하고 결과를 출력한다.
# 사람이 결과를 확인·승인한 후 train_model.py에 수동으로 반영하는 방식.
#
# 사용법:
# bash scripts/run_optuna.sh # 기본 (50 trials, 5폴드)
# bash scripts/run_optuna.sh 100 # 100 trials
# bash scripts/run_optuna.sh 100 3 # 100 trials, 3폴드
# bash scripts/run_optuna.sh 10 3 --no-baseline # 빠른 테스트
#
# 결과 확인 후 승인하면:
# python scripts/train_model.py --tuned-params models/tune_results_YYYYMMDD_HHMMSS.json
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VENV_PATH="${VENV_PATH:-$PROJECT_ROOT/.venv}"
if [ -f "$VENV_PATH/bin/activate" ]; then
# shellcheck source=/dev/null
source "$VENV_PATH/bin/activate"
else
echo "경고: 가상환경을 찾을 수 없습니다 ($VENV_PATH). 시스템 Python을 사용합니다." >&2
fi
TRIALS="${1:-50}"
FOLDS="${2:-5}"
EXTRA_ARGS="${3:-}"
cd "$PROJECT_ROOT"
echo "=== Optuna 하이퍼파라미터 탐색 ==="
echo " trials=${TRIALS}, folds=${FOLDS}"
echo ""
python scripts/tune_hyperparams.py \
--trials "$TRIALS" \
--folds "$FOLDS" \
$EXTRA_ARGS
echo ""
echo "=== 탐색 완료 ==="
echo ""
echo "결과 JSON을 확인하고 승인하면 아래 명령으로 재학습하세요:"
echo " python scripts/train_model.py --tuned-params models/tune_results_<timestamp>.json"
echo ""
echo "Walk-Forward 검증과 함께 재학습:"
echo " python scripts/train_model.py --tuned-params models/tune_results_<timestamp>.json --wf"

25
scripts/run_tests.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# 전체 테스트 실행 스크립트
#
# 사용법:
# bash scripts/run_tests.sh # 전체 실행
# bash scripts/run_tests.sh -k bot # 특정 키워드 필터
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VENV_PATH="${VENV_PATH:-$PROJECT_ROOT/.venv}"
if [ -f "$VENV_PATH/bin/activate" ]; then
# shellcheck source=/dev/null
source "$VENV_PATH/bin/activate"
else
echo "경고: 가상환경을 찾을 수 없습니다 ($VENV_PATH). 시스템 Python을 사용합니다." >&2
fi
cd "$PROJECT_ROOT"
python -m pytest tests/ \
-v \
"$@"

83
scripts/test_reverse_reenter.sh Executable file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# 반대 시그널 재진입 기능 테스트 스크립트
# 사용법: bash scripts/test_reverse_reenter.sh [task]
#
# 예시:
# bash scripts/test_reverse_reenter.sh # 전체 태스크 순서대로 실행
# bash scripts/test_reverse_reenter.sh 1 # Task 1: 신규 테스트만 (실패 확인)
# bash scripts/test_reverse_reenter.sh 2 # Task 2: _close_and_reenter 메서드 테스트
# bash scripts/test_reverse_reenter.sh 3 # Task 3: process_candle 분기 테스트
# bash scripts/test_reverse_reenter.sh bot # test_bot.py 전체
# bash scripts/test_reverse_reenter.sh all # tests/ 전체
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VENV_PATH="${VENV_PATH:-$PROJECT_ROOT/.venv}"
if [ -f "$VENV_PATH/bin/activate" ]; then
# shellcheck source=/dev/null
source "$VENV_PATH/bin/activate"
else
echo "경고: 가상환경을 찾을 수 없습니다 ($VENV_PATH). 시스템 Python을 사용합니다." >&2
fi
cd "$PROJECT_ROOT"
TASK="${1:-all}"
# ── 태스크별 테스트 이름 ──────────────────────────────────────────────────────
TASK1_TESTS=(
"tests/test_bot.py::test_close_and_reenter_calls_open_when_ml_passes"
"tests/test_bot.py::test_close_and_reenter_skips_open_when_ml_blocks"
"tests/test_bot.py::test_close_and_reenter_skips_open_when_max_positions_reached"
)
TASK2_TESTS=(
"tests/test_bot.py::test_close_and_reenter_calls_open_when_ml_passes"
"tests/test_bot.py::test_close_and_reenter_skips_open_when_ml_blocks"
"tests/test_bot.py::test_close_and_reenter_skips_open_when_max_positions_reached"
)
TASK3_TESTS=(
"tests/test_bot.py::test_process_candle_calls_close_and_reenter_on_reverse_signal"
)
run_pytest() {
echo ""
echo "▶ pytest $*"
echo "────────────────────────────────────────"
python -m pytest "$@" -v
}
case "$TASK" in
1)
echo "=== Task 1: 신규 테스트 실행 (구현 전 → FAIL 예상) ==="
run_pytest "${TASK1_TESTS[@]}"
;;
2)
echo "=== Task 2: _close_and_reenter 메서드 테스트 (구현 후 → PASS 예상) ==="
run_pytest "${TASK2_TESTS[@]}"
;;
3)
echo "=== Task 3: process_candle 분기 테스트 (수정 후 → PASS 예상) ==="
run_pytest "${TASK3_TESTS[@]}"
;;
bot)
echo "=== test_bot.py 전체 ==="
run_pytest tests/test_bot.py
;;
all)
echo "=== 전체 테스트 스위트 ==="
run_pytest tests/
;;
*)
echo "알 수 없는 태스크: $TASK"
echo "사용법: bash scripts/test_reverse_reenter.sh [1|2|3|bot|all]"
exit 1
;;
esac
echo ""
echo "=== 완료 ==="

90
scripts/train_and_deploy.sh Executable file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# 맥미니에서 전체 학습 파이프라인을 실행하고 LXC로 배포한다.
# 사용법: bash scripts/train_and_deploy.sh [mlx|lgbm] [wf-splits]
#
# 예시:
# bash scripts/train_and_deploy.sh # LightGBM + Walk-Forward 5폴드 (기본값)
# bash scripts/train_and_deploy.sh mlx # MLX GPU 학습 + Walk-Forward 5폴드
# bash scripts/train_and_deploy.sh lgbm 3 # LightGBM + Walk-Forward 3폴드
# bash scripts/train_and_deploy.sh mlx 0 # MLX 학습만 (Walk-Forward 건너뜀)
# bash scripts/train_and_deploy.sh lgbm 0 # LightGBM 학습만 (Walk-Forward 건너뜀)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VENV_PATH="${VENV_PATH:-$PROJECT_ROOT/.venv}"
if [ -f "$VENV_PATH/bin/activate" ]; then
# shellcheck source=/dev/null
source "$VENV_PATH/bin/activate"
else
echo "경고: 가상환경을 찾을 수 없습니다 ($VENV_PATH). 시스템 Python을 사용합니다." >&2
fi
BACKEND="${1:-lgbm}"
WF_SPLITS="${2:-5}" # 두 번째 인자: Walk-Forward 폴드 수 (0이면 건너뜀)
cd "$PROJECT_ROOT"
mkdir -p data
PARQUET_FILE="data/combined_15m.parquet"
echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼 + OI/펀딩비) ==="
if [ ! -f "$PARQUET_FILE" ]; then
echo " [최초 실행] 기존 데이터 없음 → 1년치(365일) 전체 수집 (--no-upsert)"
FETCH_DAYS=365
UPSERT_FLAG="--no-upsert"
else
echo " [일반 실행] 기존 데이터 존재 → 35일치 Upsert (OI/펀딩비 0.0 구간 보충)"
FETCH_DAYS=35
UPSERT_FLAG=""
fi
python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 15m \
--days "$FETCH_DAYS" \
$UPSERT_FLAG \
--output "$PARQUET_FILE"
echo ""
echo "=== [2/3] 모델 학습 (23개 피처: XRP 13 + BTC/ETH 8 + OI/펀딩비 2) ==="
DECAY="${TIME_WEIGHT_DECAY:-2.0}"
if [ "$BACKEND" = "mlx" ]; then
echo " 백엔드: MLX (Apple Silicon GPU), decay=${DECAY}"
python scripts/train_mlx_model.py --data data/combined_15m.parquet --decay "$DECAY"
else
echo " 백엔드: LightGBM (CPU), decay=${DECAY}"
python scripts/train_model.py --data data/combined_15m.parquet --decay "$DECAY"
fi
# Walk-Forward 검증 (WF_SPLITS > 0 인 경우)
if [ "$WF_SPLITS" -gt 0 ] 2>/dev/null; then
echo ""
echo "=== [2.5/3] Walk-Forward 검증 (${WF_SPLITS}폴드) ==="
if [ "$BACKEND" = "mlx" ]; then
python scripts/train_mlx_model.py \
--data data/combined_15m.parquet \
--decay "$DECAY" \
--wf \
--wf-splits "$WF_SPLITS"
else
python scripts/train_model.py \
--data data/combined_15m.parquet \
--decay "$DECAY" \
--wf \
--wf-splits "$WF_SPLITS"
fi
fi
echo ""
echo "=== [3/3] LXC 배포 ==="
bash scripts/deploy_model.sh "$BACKEND"
echo ""
echo "=== 전체 파이프라인 완료 ==="
echo ""
echo "봇 재시작이 필요하면:"
echo " ssh root@10.1.10.24 'cd /root/cointrader && docker compose restart cointrader'"

272
scripts/train_mlx_model.py Normal file
View File

@@ -0,0 +1,272 @@
"""
MLX 기반 신경망 필터를 학습하고 저장한다.
M4 통합 GPU(Metal)를 자동으로 사용한다.
사용법: python scripts/train_mlx_model.py --data data/xrpusdt_1m.parquet
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import json
import time
from datetime import datetime
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score, classification_report
from src.dataset_builder import generate_dataset_vectorized
from src.ml_features import FEATURE_COLS
from src.mlx_filter import MLXFilter
MLX_MODEL_PATH = Path("models/mlx_filter.weights")
LOG_PATH = Path("models/training_log.json")
def _split_combined(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame | None, pd.DataFrame | None]:
"""combined parquet에서 XRP/BTC/ETH DataFrame을 분리한다."""
xrp_cols = ["open", "high", "low", "close", "volume"]
xrp_df = df[xrp_cols].copy()
btc_df = None
eth_df = None
btc_raw = [c for c in df.columns if c.endswith("_btc")]
eth_raw = [c for c in df.columns if c.endswith("_eth")]
if btc_raw:
btc_df = df[btc_raw].copy()
btc_df.columns = [c.replace("_btc", "") for c in btc_raw]
if eth_raw:
eth_df = df[eth_raw].copy()
eth_df.columns = [c.replace("_eth", "") for c in eth_raw]
return xrp_df, btc_df, eth_df
def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
print(f"데이터 로드: {data_path}")
raw = pd.read_parquet(data_path)
print(f"캔들 수: {len(raw)}")
df, btc_df, eth_df = _split_combined(raw)
if btc_df is not None:
print(f" BTC/ETH 피처 활성화 (21개 피처)")
else:
print(f" XRP 단독 데이터 (13개 피처)")
print("\n데이터셋 생성 중...")
t0 = time.perf_counter()
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay)
t1 = time.perf_counter()
print(f"데이터셋 생성 완료: {t1 - t0:.1f}초, {len(dataset)}개 샘플")
if dataset.empty or "label" not in dataset.columns:
raise ValueError("데이터셋 생성 실패: 샘플 0개")
print(f"학습 샘플: {len(dataset)}개 (양성={dataset['label'].sum():.0f}, 음성={(dataset['label']==0).sum():.0f})")
if len(dataset) < 200:
raise ValueError(f"학습 샘플 부족: {len(dataset)}개 (최소 200 필요)")
actual_cols = [c for c in FEATURE_COLS if c in dataset.columns]
missing = [c for c in FEATURE_COLS if c not in dataset.columns]
if missing:
print(f" 경고: 데이터셋에 없는 피처 {missing} → 0으로 채움 (BTC/ETH 데이터 미제공)")
for col in missing:
dataset[col] = 0.0
X = dataset[FEATURE_COLS]
y = dataset["label"]
w = dataset["sample_weight"].values
split = int(len(X) * 0.8)
X_train, X_val = X.iloc[:split], X.iloc[split:]
y_train, y_val = y.iloc[:split], y.iloc[split:]
w_train = w[:split]
# --- 클래스 불균형 처리: 언더샘플링 (가중치 인덱스 보존) ---
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)
X_train = X_train.iloc[balanced_idx]
y_train = y_train.iloc[balanced_idx]
w_train = w_train[balanced_idx]
print(f"\n언더샘플링 적용 후 학습 데이터: {len(X_train)}개 (양성={y_train.sum()}, 음성={(y_train==0).sum()})")
# --------------------------------------
print("\nMLX 신경망 학습 시작 (GPU)...")
t2 = time.perf_counter()
model = MLXFilter(
input_dim=len(FEATURE_COLS),
hidden_dim=128,
lr=1e-3,
epochs=100,
batch_size=256,
)
model.fit(X_train, y_train, sample_weight=w_train)
t3 = time.perf_counter()
print(f"학습 완료: {t3 - t2:.1f}")
val_proba = model.predict_proba(X_val)
auc = roc_auc_score(y_val, val_proba)
# 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
from sklearn.metrics import precision_recall_curve
precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
precisions, recalls = precisions[:-1], recalls[:-1]
MIN_RECALL = 0.15
valid_idx = np.where(recalls >= MIN_RECALL)[0]
if len(valid_idx) > 0:
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
best_thr = float(thresholds[best_idx])
best_prec = float(precisions[best_idx])
best_rec = float(recalls[best_idx])
else:
best_thr, best_prec, best_rec = 0.50, 0.0, 0.0
print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용")
print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} "
f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})")
print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0))
MLX_MODEL_PATH.parent.mkdir(exist_ok=True)
model.save(MLX_MODEL_PATH)
print(f"모델 저장: {MLX_MODEL_PATH}")
log = []
if LOG_PATH.exists():
with open(LOG_PATH) as f:
log = json.load(f)
log.append({
"date": datetime.now().isoformat(),
"backend": "mlx",
"auc": round(auc, 4),
"best_threshold": round(best_thr, 4),
"best_precision": round(best_prec, 3),
"best_recall": round(best_rec, 3),
"samples": len(dataset),
"train_sec": round(t3 - t2, 1),
"time_weight_decay": time_weight_decay,
"model_path": str(MLX_MODEL_PATH),
})
with open(LOG_PATH, "w") as f:
json.dump(log, f, indent=2)
return auc
def walk_forward_auc(
data_path: str,
time_weight_decay: float = 2.0,
n_splits: int = 5,
train_ratio: float = 0.6,
) -> None:
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복."""
print(f"\n=== Walk-Forward 검증 ({n_splits}폴드, decay={time_weight_decay}) ===")
raw = pd.read_parquet(data_path)
df, btc_df, eth_df = _split_combined(raw)
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay
)
missing = [c for c in FEATURE_COLS if c not in dataset.columns]
for col in missing:
dataset[col] = 0.0
X_all = dataset[FEATURE_COLS].values.astype(np.float32)
y_all = dataset["label"].values.astype(np.float32)
w_all = dataset["sample_weight"].values.astype(np.float32)
n = len(dataset)
step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio)
aucs = []
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_raw = X_all[:tr_end]
y_tr = y_all[:tr_end]
w_tr = w_all[:tr_end]
X_val_raw = X_all[tr_end:val_end]
y_val = y_all[tr_end:val_end]
pos_idx = np.where(y_tr == 1)[0]
neg_idx = np.where(y_tr == 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)
bal_idx = np.sort(np.concatenate([pos_idx, neg_idx]))
X_tr_bal = X_tr_raw[bal_idx]
y_tr_bal = y_tr[bal_idx]
w_tr_bal = w_tr[bal_idx]
# 폴드별 정규화 (학습 데이터 기준으로 계산, 검증에도 동일 적용)
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
# 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(
input_dim=len(FEATURE_COLS),
hidden_dim=128,
lr=1e-3,
epochs=100,
batch_size=256,
)
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)
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
aucs.append(auc)
print(
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f}"
)
print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}")
print(f" 폴드별: {[round(a, 4) for a in aucs]}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--data", default="data/combined_15m.parquet")
parser.add_argument(
"--decay", type=float, default=2.0,
help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)",
)
parser.add_argument("--wf", action="store_true", help="Walk-Forward 검증 실행")
parser.add_argument("--wf-splits", type=int, default=5, help="Walk-Forward 폴드 수")
args = parser.parse_args()
if args.wf:
walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits)
else:
train_mlx(args.data, time_weight_decay=args.decay)
if __name__ == "__main__":
main()

452
scripts/train_model.py Normal file
View File

@@ -0,0 +1,452 @@
"""
과거 캔들 데이터로 LightGBM 필터 모델을 학습하고 저장한다.
사용법: python scripts/train_model.py --data data/xrpusdt_1m.parquet
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import json
import math
from datetime import datetime
from multiprocessing import Pool, cpu_count
from pathlib import Path
import joblib
import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score, classification_report, precision_recall_curve
from src.indicators import Indicators
from src.ml_features import build_features, FEATURE_COLS
from src.label_builder import build_labels
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
def _cgroup_cpu_count() -> int:
"""cgroup v1/v2 쿼터를 읽어 실제 할당된 CPU 수를 반환한다.
LXC/컨테이너 환경에서 cpu_count()가 호스트 전체 코어를 반환하는 문제를 방지한다.
쿼터를 읽을 수 없으면 cpu_count()를 그대로 사용한다.
"""
# cgroup v2
try:
quota_path = Path("/sys/fs/cgroup/cpu.max")
if quota_path.exists():
parts = quota_path.read_text().split()
if parts[0] != "max":
quota = int(parts[0])
period = int(parts[1])
return max(1, math.floor(quota / period))
except Exception:
pass
# cgroup v1
try:
quota = int(Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").read_text())
period = int(Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read_text())
if quota > 0:
return max(1, math.floor(quota / period))
except Exception:
pass
return cpu_count()
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")
PREV_MODEL_PATH = Path("models/lgbm_filter_prev.pkl")
LOG_PATH = Path("models/training_log.json")
def _process_index(args: tuple) -> dict | None:
"""단일 인덱스에 대해 피처+레이블을 계산한다. Pool worker 함수."""
i, df_values, df_columns = args
df = pd.DataFrame(df_values, columns=df_columns)
window = df.iloc[i - 60: i + 1].copy()
ind = Indicators(window)
df_ind = ind.calculate_all()
if df_ind.iloc[-1].isna().any():
return None
signal = ind.get_signal(df_ind)
if signal == "HOLD":
return None
entry_price = float(df_ind["close"].iloc[-1])
atr = float(df_ind["atr"].iloc[-1])
if atr <= 0:
return None
stop_loss = entry_price - atr * ATR_SL_MULT if signal == "LONG" else entry_price + atr * ATR_SL_MULT
take_profit = entry_price + atr * ATR_TP_MULT if signal == "LONG" else entry_price - atr * ATR_TP_MULT
future = df.iloc[i + 1: i + 1 + LOOKAHEAD]
label = build_labels(
future_closes=future["close"].tolist(),
future_highs=future["high"].tolist(),
future_lows=future["low"].tolist(),
take_profit=take_profit,
stop_loss=stop_loss,
side=signal,
)
if label is None:
return None
features = build_features(df_ind, signal)
row = features.to_dict()
row["label"] = label
return row
def generate_dataset(df: pd.DataFrame, n_jobs: int | None = None) -> pd.DataFrame:
"""신호 발생 시점마다 피처와 레이블을 병렬로 생성한다."""
total = len(df)
indices = range(60, total - LOOKAHEAD)
# M4 mini: 10코어(P4+E6). 너무 많은 worker는 IPC 오버헤드를 늘리므로 8로 제한
workers = n_jobs or min(max(1, _cgroup_cpu_count() - 1), 8)
print(f" 병렬 처리: {workers}코어 사용 (총 {len(indices):,}개 인덱스)")
# DataFrame을 numpy로 변환해서 worker 간 전달 비용 최소화
df_values = df.values
df_columns = list(df.columns)
task_args = [(i, df_values, df_columns) for i in indices]
rows = []
errors = []
# chunksize를 크게 잡아 IPC 직렬화 횟수를 줄임
chunk = max(100, len(task_args) // workers)
with Pool(processes=workers) as pool:
for idx, result in enumerate(pool.imap(_process_index, task_args, chunksize=chunk)):
if isinstance(result, dict):
rows.append(result)
elif result is not None:
errors.append(result)
if (idx + 1) % 10000 == 0:
print(f" 진행: {idx + 1:,}/{len(task_args):,} | 샘플: {len(rows):,}")
if errors:
print(f" [경고] worker 오류 {len(errors)}건: {errors[0]}")
if not rows:
print(" [오류] 생성된 샘플이 없습니다. worker 예외 여부를 확인합니다...")
# 단일 프로세스로 첫 번째 인덱스를 직접 실행해서 예외 확인
try:
test_result = _process_index(task_args[0])
print(f" 단일 실행 결과: {test_result}")
except Exception as e:
import traceback
print(f" 단일 실행 예외:\n{traceback.format_exc()}")
return pd.DataFrame(rows)
ACTIVE_PARAMS_PATH = Path("models/active_lgbm_params.json")
def _load_lgbm_params(tuned_params_path: str | None) -> tuple[dict, float]:
"""기본 LightGBM 파라미터를 반환하고, 튜닝 JSON이 주어지면 덮어쓴다.
우선순위:
1. --tuned-params 명시적 인자
2. models/active_lgbm_params.json (Optuna가 자동 갱신)
3. 코드 내 하드코딩 기본값 (fallback)
"""
lgbm_params: dict = {
"n_estimators": 434,
"learning_rate": 0.123659,
"max_depth": 6,
"num_leaves": 14,
"min_child_samples": 10,
"subsample": 0.929062,
"colsample_bytree": 0.946330,
"reg_alpha": 0.573971,
"reg_lambda": 0.000157,
}
weight_scale = 1.783105
# 명시적 인자가 없으면 active 파일 자동 탐색
resolved_path = tuned_params_path or (
str(ACTIVE_PARAMS_PATH) if ACTIVE_PARAMS_PATH.exists() else None
)
if resolved_path:
with open(resolved_path, "r", encoding="utf-8") as f:
tune_data = json.load(f)
best_params = dict(tune_data["best_trial"]["params"])
weight_scale = float(best_params.pop("weight_scale", 1.0))
lgbm_params.update(best_params)
source = "명시적 인자" if tuned_params_path else "active 파일 자동 로드"
print(f"\n[Optuna] 튜닝 파라미터 로드 ({source}): {resolved_path}")
print(f"[Optuna] 적용 파라미터: {lgbm_params}")
print(f"[Optuna] weight_scale: {weight_scale}\n")
else:
print("[Optuna] active 파일 없음 → 코드 내 기본 파라미터 사용\n")
return lgbm_params, weight_scale
def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None):
print(f"데이터 로드: {data_path}")
df_raw = pd.read_parquet(data_path)
print(f"캔들 수: {len(df_raw)}, 컬럼: {list(df_raw.columns)}")
# 병합 데이터셋 여부 판별
btc_df = None
eth_df = None
base_cols = ["open", "high", "low", "close", "volume"]
if "close_btc" in df_raw.columns:
btc_df = df_raw[[c + "_btc" for c in base_cols]].copy()
btc_df.columns = base_cols
print("BTC 피처 활성화")
if "close_eth" in df_raw.columns:
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
eth_df.columns = base_cols
print("ETH 피처 활성화")
df = df_raw[base_cols].copy()
print("데이터셋 생성 중...")
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
negative_ratio=5,
)
if dataset.empty or "label" not in dataset.columns:
raise ValueError(f"데이터셋 생성 실패: 샘플 0개. 위 오류 메시지를 확인하세요.")
print(f"학습 샘플: {len(dataset)}개 (양성={dataset['label'].sum():.0f}, 음성={(dataset['label']==0).sum():.0f})")
if len(dataset) < 200:
raise ValueError(f"학습 샘플 부족: {len(dataset)}개 (최소 200 필요)")
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
print(f"사용 피처: {len(actual_feature_cols)}{actual_feature_cols}")
X = dataset[actual_feature_cols]
y = dataset["label"]
w = dataset["sample_weight"].values
source = dataset["source"].values if "source" in dataset.columns else np.full(len(X), "signal")
split = int(len(X) * 0.8)
X_train, X_val = X.iloc[:split], X.iloc[split:]
y_train, y_val = y.iloc[:split], y.iloc[split:]
# 튜닝 파라미터 로드 (없으면 기본값 사용)
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
w_train = (w[:split] * weight_scale).astype(np.float32)
# --- 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼 ---
source_train = source[:split]
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
X_train = X_train.iloc[balanced_idx]
y_train = y_train.iloc[balanced_idx]
w_train = w_train[balanced_idx]
sig_count = (source_train[balanced_idx] == "signal").sum()
hold_count = (source_train[balanced_idx] == "hold_negative").sum()
print(f"\n계층적 샘플링 후 학습 데이터: {len(X_train)}"
f"(Signal={sig_count}, HOLD={hold_count}, "
f"양성={int(y_train.sum())}, 음성={int((y_train==0).sum())})")
print(f"검증 데이터: {len(X_val)}개 (양성={int(y_val.sum())}, 음성={int((y_val==0).sum())})")
# ---------------------------------------------------------------
model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1)
model.fit(
X_train, y_train,
sample_weight=w_train,
eval_set=[(X_val, y_val)],
eval_metric="auc",
callbacks=[
lgb.early_stopping(80, first_metric_only=True, verbose=False),
lgb.log_evaluation(50),
],
)
val_proba = model.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, val_proba)
# 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
# precision_recall_curve의 마지막 원소는 (1.0, 0.0)이므로 제외
precisions, recalls = precisions[:-1], recalls[:-1]
MIN_RECALL = 0.15
valid_idx = np.where(recalls >= MIN_RECALL)[0]
if len(valid_idx) > 0:
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
best_thr = float(thresholds[best_idx])
best_prec = float(precisions[best_idx])
best_rec = float(recalls[best_idx])
else:
best_thr, best_prec, best_rec = 0.50, 0.0, 0.0
print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용")
print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} "
f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})")
print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0))
if MODEL_PATH.exists():
import shutil
shutil.copy(MODEL_PATH, PREV_MODEL_PATH)
print(f"기존 모델 백업: {PREV_MODEL_PATH}")
MODEL_PATH.parent.mkdir(exist_ok=True)
joblib.dump(model, MODEL_PATH)
print(f"모델 저장: {MODEL_PATH}")
log = []
if LOG_PATH.exists():
with open(LOG_PATH) as f:
log = json.load(f)
log_entry: dict = {
"date": datetime.now().isoformat(),
"backend": "lgbm",
"auc": round(auc, 4),
"best_threshold": round(best_thr, 4),
"best_precision": round(best_prec, 3),
"best_recall": round(best_rec, 3),
"samples": len(dataset),
"features": len(actual_feature_cols),
"time_weight_decay": time_weight_decay,
"model_path": str(MODEL_PATH),
"tuned_params_path": tuned_params_path,
"lgbm_params": lgbm_params,
"weight_scale": weight_scale,
}
log.append(log_entry)
with open(LOG_PATH, "w") as f:
json.dump(log, f, indent=2)
return auc
def walk_forward_auc(
data_path: str,
time_weight_decay: float = 2.0,
n_splits: int = 5,
train_ratio: float = 0.6,
tuned_params_path: str | None = None,
) -> None:
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복.
시계열 순서를 지키면서 매 폴드마다 학습 구간을 늘려가며 검증한다.
실제 미래 예측력의 평균 AUC를 측정하는 데 사용한다.
"""
import warnings
print(f"\n=== Walk-Forward 검증 ({n_splits}폴드, decay={time_weight_decay}) ===")
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,
negative_ratio=5,
)
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
X = dataset[actual_feature_cols].values
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)
step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio)
aucs = []
fold_metrics = []
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]
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
aucs.append(auc)
# 폴드별 최적 임계값 (recall >= 0.15 조건부 precision 최대화)
MIN_RECALL = 0.15
precs, recs, thrs = precision_recall_curve(y_val, proba)
precs, recs = precs[:-1], recs[:-1]
valid_idx = np.where(recs >= MIN_RECALL)[0]
if len(valid_idx) > 0:
best_i = valid_idx[np.argmax(precs[valid_idx])]
f_thr, f_prec, f_rec = float(thrs[best_i]), float(precs[best_i]), float(recs[best_i])
else:
f_thr, f_prec, f_rec = 0.50, 0.0, 0.0
fold_metrics.append({"auc": auc, "precision": f_prec, "recall": f_rec, "threshold": f_thr})
print(
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f} | "
f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}"
)
mean_prec = np.mean([m["precision"] for m in fold_metrics])
mean_rec = np.mean([m["recall"] for m in fold_metrics])
mean_thr = np.mean([m["threshold"] for m in fold_metrics])
print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}")
print(f" 평균 Precision: {mean_prec:.3f} | 평균 Recall: {mean_rec:.3f} | 평균 Threshold: {mean_thr:.4f}")
print(f" 폴드별: {[round(a, 4) for a in aucs]}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--data", default="data/combined_15m.parquet")
parser.add_argument(
"--decay", type=float, default=2.0,
help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)",
)
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(
"--tuned-params", type=str, default=None,
help="Optuna 튜닝 결과 JSON 경로 (지정 시 기본 파라미터를 덮어씀)",
)
args = parser.parse_args()
if args.wf:
walk_forward_auc(
args.data,
time_weight_decay=args.decay,
n_splits=args.wf_splits,
tuned_params_path=args.tuned_params,
)
else:
train(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params)
if __name__ == "__main__":
main()

625
scripts/tune_hyperparams.py Executable file
View File

@@ -0,0 +1,625 @@
#!/usr/bin/env python3
"""
Optuna를 사용한 LightGBM 하이퍼파라미터 자동 탐색.
사용법:
python scripts/tune_hyperparams.py # 기본 (50 trials, 5폴드)
python scripts/tune_hyperparams.py --trials 10 --folds 3 # 빠른 테스트
python scripts/tune_hyperparams.py --data data/combined_15m.parquet --trials 100
python scripts/tune_hyperparams.py --no-baseline # 베이스라인 측정 건너뜀
python scripts/tune_hyperparams.py --min-recall 0.4 # 최소 재현율 제약 조정
결과:
- 콘솔: Best Params + Walk-Forward 리포트
- JSON: models/tune_results_YYYYMMDD_HHMMSS.json
"""
import sys
import warnings
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import json
import time
from datetime import datetime
import numpy as np
import pandas as pd
import lightgbm as lgb
import optuna
from optuna.samplers import TPESampler
from optuna.pruners import MedianPruner
from sklearn.metrics import roc_auc_score, precision_recall_curve
from src.ml_features import FEATURE_COLS
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
# ──────────────────────────────────────────────
# 데이터 로드 및 데이터셋 생성 (1회 캐싱)
# ──────────────────────────────────────────────
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
study 시작 전 1회만 호출하여 모든 trial이 공유한다.
"""
print(f"데이터 로드: {data_path}")
df_raw = pd.read_parquet(data_path)
print(f"캔들 수: {len(df_raw):,}, 컬럼: {list(df_raw.columns)}")
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
print("BTC 피처 활성화")
if "close_eth" in df_raw.columns:
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
eth_df.columns = base_cols
print("ETH 피처 활성화")
df = df_raw[base_cols].copy()
print("\n데이터셋 생성 중 (1회만 실행)...")
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0, negative_ratio=5)
if dataset.empty or "label" not in dataset.columns:
raise ValueError("데이터셋 생성 실패: 샘플 0개")
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
X = dataset[actual_feature_cols].values.astype(np.float32)
y = dataset["label"].values.astype(np.int8)
w = dataset["sample_weight"].values.astype(np.float32)
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
pos = int(y.sum())
neg = int((y == 0).sum())
print(f"데이터셋 완성: {len(dataset):,}개 샘플 (양성={pos}, 음성={neg})")
print(f"사용 피처: {len(actual_feature_cols)}\n")
return X, y, w, source
# ──────────────────────────────────────────────
# Precision 헬퍼
# ──────────────────────────────────────────────
def _find_best_precision_at_recall(
y_true: np.ndarray,
proba: np.ndarray,
min_recall: float = 0.35,
) -> tuple[float, float, float]:
"""
precision_recall_curve에서 recall >= min_recall 조건을 만족하는
최대 precision과 해당 threshold를 반환한다.
Returns:
(best_precision, best_recall, best_threshold)
조건 불만족 시 (0.0, 0.0, 0.50)
"""
precisions, recalls, thresholds = precision_recall_curve(y_true, proba)
precisions, recalls = precisions[:-1], recalls[:-1]
valid_idx = np.where(recalls >= min_recall)[0]
if len(valid_idx) > 0:
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
return (
float(precisions[best_idx]),
float(recalls[best_idx]),
float(thresholds[best_idx]),
)
return (0.0, 0.0, 0.50)
# ──────────────────────────────────────────────
# Walk-Forward 교차검증
# ──────────────────────────────────────────────
def _walk_forward_cv(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
source: np.ndarray,
params: dict,
n_splits: int,
train_ratio: float,
min_recall: float = 0.35,
trial: "optuna.Trial | None" = None,
) -> tuple[float, dict]:
"""
Walk-Forward 교차검증으로 precision 기반 복합 점수를 반환한다.
Score = mean_precision + mean_auc * 0.001 (AUC는 tiebreaker)
trial이 제공되면 각 폴드 후 Optuna에 중간 값을 보고하여 Pruning을 활성화한다.
Returns:
(mean_score, details) where details contains per-fold metrics.
"""
n = len(X)
step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio)
fold_aucs: list[float] = []
fold_precisions: list[float] = []
fold_recalls: list[float] = []
fold_thresholds: list[float] = []
fold_n_pos: list[int] = []
scores_so_far: list[float] = []
for fold_idx in range(n_splits):
tr_end = train_end_start + fold_idx * 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]
# 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼
source_tr = source[:tr_end]
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
n_pos = int(y_val.sum())
if len(bal_idx) < 20 or len(np.unique(y_val)) < 2:
fold_aucs.append(0.5)
fold_precisions.append(0.0)
fold_recalls.append(0.0)
fold_thresholds.append(0.50)
fold_n_pos.append(n_pos)
continue
model = lgb.LGBMClassifier(**params, random_state=42, verbose=-1)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
model.fit(X_tr[bal_idx], y_tr[bal_idx], sample_weight=w_tr[bal_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(float(auc))
# Precision at recall-constrained threshold
if n_pos >= 3:
prec, rec, thr = _find_best_precision_at_recall(y_val, proba, min_recall)
else:
prec, rec, thr = 0.0, 0.0, 0.50
fold_precisions.append(prec)
fold_recalls.append(rec)
fold_thresholds.append(thr)
fold_n_pos.append(n_pos)
# Pruning: 양성 충분한 fold의 score만 보고
score = prec + auc * 0.001
scores_so_far.append(score)
if trial is not None and n_pos >= 3:
valid_scores = [s for s, np_ in zip(scores_so_far, fold_n_pos) if np_ >= 3]
if valid_scores:
trial.report(float(np.mean(valid_scores)), step=fold_idx)
if trial.should_prune():
raise optuna.TrialPruned()
# 양성 충분한 fold만으로 precision 평균 계산
valid_precs = [p for p, np_ in zip(fold_precisions, fold_n_pos) if np_ >= 3]
mean_auc = float(np.mean(fold_aucs)) if fold_aucs else 0.5
mean_prec = float(np.mean(valid_precs)) if valid_precs else 0.0
valid_recs = [r for r, np_ in zip(fold_recalls, fold_n_pos) if np_ >= 3]
mean_rec = float(np.mean(valid_recs)) if valid_recs else 0.0
mean_score = mean_prec + mean_auc * 0.001
details = {
"fold_aucs": fold_aucs,
"fold_precisions": fold_precisions,
"fold_recalls": fold_recalls,
"fold_thresholds": fold_thresholds,
"fold_n_pos": fold_n_pos,
"mean_auc": mean_auc,
"mean_precision": mean_prec,
"mean_recall": mean_rec,
}
return mean_score, details
# ──────────────────────────────────────────────
# Optuna 목적 함수
# ──────────────────────────────────────────────
def make_objective(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
source: np.ndarray,
n_splits: int,
train_ratio: float,
min_recall: float = 0.35,
):
"""클로저로 데이터셋을 캡처한 목적 함수를 반환한다."""
def objective(trial: optuna.Trial) -> float:
# ── 하이퍼파라미터 샘플링 ──
n_estimators = trial.suggest_int("n_estimators", 100, 600)
learning_rate = trial.suggest_float("learning_rate", 0.01, 0.2, log=True)
max_depth = trial.suggest_int("max_depth", 2, 7)
# 핵심 제약: num_leaves <= 2^max_depth - 1 (leaf-wise 과적합 방지)
# 360개 수준의 소규모 데이터셋에서 num_leaves가 크면 암기 발생
max_leaves_upper = min(31, 2 ** max_depth - 1)
num_leaves = trial.suggest_int("num_leaves", 7, max(7, max_leaves_upper))
min_child_samples = trial.suggest_int("min_child_samples", 10, 50)
subsample = trial.suggest_float("subsample", 0.5, 1.0)
colsample_bytree = trial.suggest_float("colsample_bytree", 0.5, 1.0)
reg_alpha = trial.suggest_float("reg_alpha", 1e-4, 1.0, log=True)
reg_lambda = trial.suggest_float("reg_lambda", 1e-4, 1.0, log=True)
# weight_scale: 데이터셋을 1회 캐싱하는 구조이므로
# time_weight_decay 효과를 sample_weight 스케일로 근사한다.
weight_scale = trial.suggest_float("weight_scale", 0.5, 2.0)
w_scaled = (w * weight_scale).astype(np.float32)
params = {
"n_estimators": n_estimators,
"learning_rate": learning_rate,
"max_depth": max_depth,
"num_leaves": num_leaves,
"min_child_samples": min_child_samples,
"subsample": subsample,
"colsample_bytree": colsample_bytree,
"reg_alpha": reg_alpha,
"reg_lambda": reg_lambda,
}
mean_score, details = _walk_forward_cv(
X, y, w_scaled, source, params,
n_splits=n_splits,
train_ratio=train_ratio,
min_recall=min_recall,
trial=trial,
)
# 폴드별 상세 메트릭을 user_attrs에 저장 (결과 리포트용)
trial.set_user_attr("fold_aucs", details["fold_aucs"])
trial.set_user_attr("fold_precisions", details["fold_precisions"])
trial.set_user_attr("fold_recalls", details["fold_recalls"])
trial.set_user_attr("fold_thresholds", details["fold_thresholds"])
trial.set_user_attr("fold_n_pos", details["fold_n_pos"])
trial.set_user_attr("mean_auc", details["mean_auc"])
trial.set_user_attr("mean_precision", details["mean_precision"])
trial.set_user_attr("mean_recall", details["mean_recall"])
return mean_score
return objective
# ──────────────────────────────────────────────
# 베이스라인 측정 (현재 고정 파라미터)
# ──────────────────────────────────────────────
def measure_baseline(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
source: np.ndarray,
n_splits: int,
train_ratio: float,
min_recall: float = 0.35,
) -> tuple[float, dict]:
"""현재 실전 파라미터(active 파일 또는 하드코딩 기본값)로 베이스라인을 측정한다."""
active_path = Path("models/active_lgbm_params.json")
if active_path.exists():
with open(active_path, "r", encoding="utf-8") as f:
tune_data = json.load(f)
best_params = dict(tune_data["best_trial"]["params"])
best_params.pop("weight_scale", None)
baseline_params = best_params
print(f"베이스라인 측정 중 (active 파일: {active_path})...")
else:
baseline_params = {
"n_estimators": 434,
"learning_rate": 0.123659,
"max_depth": 6,
"num_leaves": 14,
"min_child_samples": 10,
"subsample": 0.929062,
"colsample_bytree": 0.946330,
"reg_alpha": 0.573971,
"reg_lambda": 0.000157,
}
print("베이스라인 측정 중 (active 파일 없음 → 코드 내 기본 파라미터)...")
return _walk_forward_cv(
X, y, w, source, baseline_params,
n_splits=n_splits, train_ratio=train_ratio,
min_recall=min_recall,
)
# ──────────────────────────────────────────────
# 결과 출력 및 저장
# ──────────────────────────────────────────────
def print_report(
study: optuna.Study,
baseline_score: float,
baseline_details: dict,
elapsed_sec: float,
output_path: Path,
min_recall: float,
) -> None:
"""콘솔에 최종 리포트를 출력한다."""
best = study.best_trial
best_score = best.value
best_prec = best.user_attrs.get("mean_precision", 0.0)
best_auc = best.user_attrs.get("mean_auc", 0.0)
best_rec = best.user_attrs.get("mean_recall", 0.0)
baseline_prec = baseline_details.get("mean_precision", 0.0)
baseline_auc = baseline_details.get("mean_auc", 0.0)
prec_improvement = best_prec - baseline_prec
prec_improvement_pct = (prec_improvement / baseline_prec * 100) if baseline_prec > 0 else 0.0
elapsed_min = int(elapsed_sec // 60)
elapsed_s = int(elapsed_sec % 60)
sep = "=" * 64
dash = "-" * 64
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
pruned = [t for t in study.trials if t.state == optuna.trial.TrialState.PRUNED]
print(f"\n{sep}")
print(f" Optuna 튜닝 완료 | {len(study.trials)} trials "
f"(완료={len(completed)}, 조기종료={len(pruned)}) | "
f"소요: {elapsed_min}{elapsed_s}")
print(sep)
print(f" 최적화 지표: Precision (recall >= {min_recall} 제약)")
print(f" Best Prec : {best_prec:.4f} (Trial #{best.number})")
print(f" Best AUC : {best_auc:.4f}")
print(f" Best Recall: {best_rec:.4f}")
if baseline_score > 0:
sign = "+" if prec_improvement >= 0 else ""
print(dash)
print(f" Baseline : Prec={baseline_prec:.4f}, AUC={baseline_auc:.4f}")
print(f" 개선폭 : Precision {sign}{prec_improvement:.4f} ({sign}{prec_improvement_pct:.1f}%)")
print(dash)
print(" Best Parameters:")
for k, v in best.params.items():
if isinstance(v, float):
print(f" {k:<22}: {v:.6f}")
else:
print(f" {k:<22}: {v}")
print(dash)
# 폴드별 상세
fold_aucs = best.user_attrs.get("fold_aucs", [])
fold_precs = best.user_attrs.get("fold_precisions", [])
fold_recs = best.user_attrs.get("fold_recalls", [])
fold_thrs = best.user_attrs.get("fold_thresholds", [])
fold_npos = best.user_attrs.get("fold_n_pos", [])
print(" Walk-Forward 폴드별 상세 (Best Trial):")
for i, (auc, prec, rec, thr, npos) in enumerate(
zip(fold_aucs, fold_precs, fold_recs, fold_thrs, fold_npos), 1
):
print(f" 폴드 {i}: AUC={auc:.4f} Prec={prec:.3f} Rec={rec:.3f} Thr={thr:.3f} (양성={npos})")
if fold_precs:
valid_precs = [p for p, np_ in zip(fold_precs, fold_npos) if np_ >= 3]
if valid_precs:
arr_p = np.array(valid_precs)
print(f" 평균 Precision: {arr_p.mean():.4f} ± {arr_p.std():.4f}")
if fold_aucs:
arr_a = np.array(fold_aucs)
print(f" 평균 AUC: {arr_a.mean():.4f} ± {arr_a.std():.4f}")
# 베이스라인 폴드별
bl_folds = baseline_details.get("fold_aucs", [])
bl_precs = baseline_details.get("fold_precisions", [])
bl_recs = baseline_details.get("fold_recalls", [])
bl_thrs = baseline_details.get("fold_thresholds", [])
bl_npos = baseline_details.get("fold_n_pos", [])
if bl_folds:
print(dash)
print(" Baseline 폴드별 상세:")
for i, (auc, prec, rec, thr, npos) in enumerate(
zip(bl_folds, bl_precs, bl_recs, bl_thrs, bl_npos), 1
):
print(f" 폴드 {i}: AUC={auc:.4f} Prec={prec:.3f} Rec={rec:.3f} Thr={thr:.3f} (양성={npos})")
print(dash)
print(f" 결과 저장: {output_path}")
print(f" 다음 단계: python scripts/train_model.py (파라미터 수동 반영 후)")
print(sep)
def save_results(
study: optuna.Study,
baseline_score: float,
baseline_details: dict,
elapsed_sec: float,
data_path: str,
min_recall: float,
) -> Path:
"""결과를 JSON 파일로 저장하고 경로를 반환한다."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = Path(f"models/tune_results_{timestamp}.json")
output_path.parent.mkdir(exist_ok=True)
best = study.best_trial
all_trials = []
for t in study.trials:
if t.state == optuna.trial.TrialState.COMPLETE:
all_trials.append({
"number": t.number,
"score": round(t.value, 6),
"auc": round(t.user_attrs.get("mean_auc", 0.0), 6),
"precision": round(t.user_attrs.get("mean_precision", 0.0), 6),
"recall": round(t.user_attrs.get("mean_recall", 0.0), 6),
"fold_aucs": [round(a, 6) for a in t.user_attrs.get("fold_aucs", [])],
"fold_precisions": [round(p, 6) for p in t.user_attrs.get("fold_precisions", [])],
"params": {
k: (round(v, 6) if isinstance(v, float) else v)
for k, v in t.params.items()
},
})
result = {
"timestamp": datetime.now().isoformat(),
"data_path": data_path,
"min_recall_constraint": min_recall,
"n_trials_total": len(study.trials),
"n_trials_complete": len(all_trials),
"elapsed_sec": round(elapsed_sec, 1),
"baseline": {
"score": round(baseline_score, 6),
"auc": round(baseline_details.get("mean_auc", 0.0), 6),
"precision": round(baseline_details.get("mean_precision", 0.0), 6),
"recall": round(baseline_details.get("mean_recall", 0.0), 6),
"fold_aucs": [round(a, 6) for a in baseline_details.get("fold_aucs", [])],
"fold_precisions": [round(p, 6) for p in baseline_details.get("fold_precisions", [])],
"fold_recalls": [round(r, 6) for r in baseline_details.get("fold_recalls", [])],
"fold_thresholds": [round(t, 6) for t in baseline_details.get("fold_thresholds", [])],
},
"best_trial": {
"number": best.number,
"score": round(best.value, 6),
"auc": round(best.user_attrs.get("mean_auc", 0.0), 6),
"precision": round(best.user_attrs.get("mean_precision", 0.0), 6),
"recall": round(best.user_attrs.get("mean_recall", 0.0), 6),
"fold_aucs": [round(a, 6) for a in best.user_attrs.get("fold_aucs", [])],
"fold_precisions": [round(p, 6) for p in best.user_attrs.get("fold_precisions", [])],
"fold_recalls": [round(r, 6) for r in best.user_attrs.get("fold_recalls", [])],
"fold_thresholds": [round(t, 6) for t in best.user_attrs.get("fold_thresholds", [])],
"fold_n_pos": best.user_attrs.get("fold_n_pos", []),
"params": {
k: (round(v, 6) if isinstance(v, float) else v)
for k, v in best.params.items()
},
},
"all_trials": all_trials,
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
return output_path
# ──────────────────────────────────────────────
# 메인
# ──────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Optuna LightGBM 하이퍼파라미터 튜닝")
parser.add_argument("--data", default="data/combined_15m.parquet", help="학습 데이터 경로")
parser.add_argument("--trials", type=int, default=50, help="Optuna trial 수 (기본: 50)")
parser.add_argument("--folds", type=int, default=5, help="Walk-Forward 폴드 수 (기본: 5)")
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("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀")
args = parser.parse_args()
# 1. 데이터셋 로드 (1회)
X, y, w, source = load_dataset(args.data)
# 2. 베이스라인 측정
if args.no_baseline:
baseline_score, baseline_details = 0.0, {}
print("베이스라인 측정 건너뜀 (--no-baseline)\n")
else:
baseline_score, baseline_details = measure_baseline(
X, y, w, source, args.folds, args.train_ratio, args.min_recall,
)
bl_prec = baseline_details.get("mean_precision", 0.0)
bl_auc = baseline_details.get("mean_auc", 0.0)
bl_rec = baseline_details.get("mean_recall", 0.0)
print(
f"베이스라인: Prec={bl_prec:.4f}, AUC={bl_auc:.4f}, Recall={bl_rec:.4f} "
f"(recall >= {args.min_recall} 제약)\n"
)
# 3. Optuna study 실행
optuna.logging.set_verbosity(optuna.logging.WARNING)
sampler = TPESampler(seed=42)
pruner = MedianPruner(n_startup_trials=5, n_warmup_steps=3)
study = optuna.create_study(
direction="maximize",
sampler=sampler,
pruner=pruner,
study_name="lgbm_wf_precision",
)
objective = make_objective(
X, y, w, source,
n_splits=args.folds,
train_ratio=args.train_ratio,
min_recall=args.min_recall,
)
print(f"Optuna 탐색 시작: {args.trials} trials, {args.folds}폴드 Walk-Forward")
print(f"최적화 지표: Precision (recall >= {args.min_recall} 제약)")
print("(trial 완료마다 진행 상황 출력)\n")
start_time = time.time()
def _progress_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial) -> None:
if trial.state == optuna.trial.TrialState.COMPLETE:
best_so_far = study.best_value
prec = trial.user_attrs.get("mean_precision", 0.0)
auc = trial.user_attrs.get("mean_auc", 0.0)
print(
f" Trial #{trial.number:3d} | Prec={prec:.4f} AUC={auc:.4f} "
f"| Best={best_so_far:.4f} "
f"| leaves={trial.params.get('num_leaves', '?')} "
f"depth={trial.params.get('max_depth', '?')}"
)
elif trial.state == optuna.trial.TrialState.PRUNED:
print(f" Trial #{trial.number:3d} | PRUNED (조기 종료)")
study.optimize(
objective,
n_trials=args.trials,
callbacks=[_progress_callback],
show_progress_bar=False,
)
elapsed = time.time() - start_time
# 4. 결과 저장 및 출력
output_path = save_results(
study, baseline_score, baseline_details, elapsed, args.data, args.min_recall,
)
print_report(
study, baseline_score, baseline_details, elapsed, output_path, args.min_recall,
)
# 5. 성능 개선 시 active 파일 자동 갱신
import shutil
active_path = Path("models/active_lgbm_params.json")
if not args.no_baseline and study.best_value > baseline_score:
shutil.copy(output_path, active_path)
best_prec = study.best_trial.user_attrs.get("mean_precision", 0.0)
bl_prec = baseline_details.get("mean_precision", 0.0)
improvement = best_prec - bl_prec
print(f"[MLOps] Precision +{improvement:.4f} 개선 → {active_path} 자동 갱신 완료")
print(f"[MLOps] 다음 train_model.py 실행 시 새 파라미터가 자동 적용됩니다.\n")
elif args.no_baseline:
print("[MLOps] --no-baseline 모드: 성능 비교 없이 active 파일 유지\n")
else:
best_prec = study.best_trial.user_attrs.get("mean_precision", 0.0)
bl_prec = baseline_details.get("mean_precision", 0.0)
print(
f"[MLOps] 성능 개선 없음 (Prec={best_prec:.4f} ≤ Baseline={bl_prec:.4f}) "
f"→ active 파일 유지\n"
)
if __name__ == "__main__":
main()

View File

@@ -1,11 +1,15 @@
import asyncio import asyncio
import pandas as pd
from loguru import logger from loguru import logger
from src.config import Config from src.config import Config
from src.exchange import BinanceFuturesClient from src.exchange import BinanceFuturesClient
from src.indicators import Indicators from src.indicators import Indicators
from src.data_stream import KlineStream from src.data_stream import MultiSymbolStream
from src.notifier import DiscordNotifier from src.notifier import DiscordNotifier
from src.risk_manager import RiskManager from src.risk_manager import RiskManager
from src.ml_filter import MLFilter
from src.ml_features import build_features
from src.user_data_stream import UserDataStream
class TradingBot: class TradingBot:
@@ -14,17 +18,24 @@ class TradingBot:
self.exchange = BinanceFuturesClient(config) self.exchange = BinanceFuturesClient(config)
self.notifier = DiscordNotifier(config.discord_webhook_url) self.notifier = DiscordNotifier(config.discord_webhook_url)
self.risk = RiskManager(config) self.risk = RiskManager(config)
self.ml_filter = MLFilter()
self.current_trade_side: str | None = None # "LONG" | "SHORT" self.current_trade_side: str | None = None # "LONG" | "SHORT"
self.stream = KlineStream( self._entry_price: float | None = None
symbol=config.symbol, self._entry_quantity: float | None = None
interval="1m", self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
self.stream = MultiSymbolStream(
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
interval="15m",
on_candle=self._on_candle_closed, on_candle=self._on_candle_closed,
) )
def _on_candle_closed(self, candle: dict): async def _on_candle_closed(self, candle: dict):
df = self.stream.get_dataframe() xrp_df = self.stream.get_dataframe(self.config.symbol)
if df is not None: btc_df = self.stream.get_dataframe("BTCUSDT")
asyncio.create_task(self.process_candle(df)) eth_df = self.stream.get_dataframe("ETHUSDT")
if xrp_df is not None:
await self.process_candle(xrp_df, btc_df=btc_df, eth_df=eth_df)
async def _recover_position(self) -> None: async def _recover_position(self) -> None:
"""재시작 시 바이낸스에서 현재 포지션을 조회하여 상태 복구.""" """재시작 시 바이낸스에서 현재 포지션을 조회하여 상태 복구."""
@@ -32,6 +43,8 @@ class TradingBot:
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_quantity = abs(amt)
entry = float(position["entryPrice"]) entry = float(position["entryPrice"])
logger.info( logger.info(
f"기존 포지션 복구: {self.current_trade_side} | " f"기존 포지션 복구: {self.current_trade_side} | "
@@ -44,38 +57,85 @@ class TradingBot:
else: else:
logger.info("기존 포지션 없음 - 신규 진입 대기") logger.info("기존 포지션 없음 - 신규 진입 대기")
async def process_candle(self, df): async def _fetch_market_microstructure(self) -> tuple[float, float]:
"""OI 변화율과 펀딩비를 실시간으로 조회한다. 실패 시 0.0으로 폴백."""
oi_val, fr_val = await asyncio.gather(
self.exchange.get_open_interest(),
self.exchange.get_funding_rate(),
return_exceptions=True,
)
# None(API 실패) 또는 Exception이면 _calc_oi_change를 호출하지 않고 0.0 반환
if isinstance(oi_val, (int, float)) and oi_val > 0:
oi_change = self._calc_oi_change(float(oi_val))
else:
oi_change = 0.0
fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0
logger.debug(f"OI={oi_val}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}")
return oi_change, fr_float
def _calc_oi_change(self, current_oi: float) -> float:
"""이전 OI 대비 변화율을 계산한다. 첫 캔들은 0.0 반환."""
if self._prev_oi is None or self._prev_oi == 0.0:
self._prev_oi = current_oi
return 0.0
change = (current_oi - self._prev_oi) / self._prev_oi
self._prev_oi = current_oi
return change
async def process_candle(self, df, btc_df=None, eth_df=None):
self.ml_filter.check_and_reload()
# 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백)
oi_change, funding_rate = await self._fetch_market_microstructure()
if not self.risk.is_trading_allowed(): if not self.risk.is_trading_allowed():
logger.warning("리스크 한도 초과 - 거래 중단") logger.warning("리스크 한도 초과 - 거래 중단")
return return
ind = Indicators(df) ind = Indicators(df)
df_with_indicators = ind.calculate_all() df_with_indicators = ind.calculate_all()
signal = ind.get_signal(df_with_indicators) raw_signal = ind.get_signal(df_with_indicators)
current_price = df_with_indicators["close"].iloc[-1] current_price = df_with_indicators["close"].iloc[-1]
logger.info(f"신호: {signal} | 현재가: {current_price:.4f} USDT") logger.info(f"신호: {raw_signal} | 현재가: {current_price:.4f} USDT")
position = await self.exchange.get_position() position = await self.exchange.get_position()
if position is None and signal != "HOLD": if position is None and raw_signal != "HOLD":
self.current_trade_side = None self.current_trade_side = None
if not self.risk.can_open_new_position(): if not self.risk.can_open_new_position():
logger.info("최대 포지션 수 도달") logger.info("최대 포지션 수 도달")
return return
signal = raw_signal
features = build_features(
df_with_indicators, signal,
btc_df=btc_df, eth_df=eth_df,
oi_change=oi_change, funding_rate=funding_rate,
)
if self.ml_filter.is_model_loaded():
if not self.ml_filter.should_enter(features):
logger.info(f"ML 필터 차단: {signal} 신호 무시")
return
await self._open_position(signal, df_with_indicators) await self._open_position(signal, df_with_indicators)
elif position is not None: elif position is not None:
pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT" pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
if (pos_side == "LONG" and signal == "SHORT") or \ if (pos_side == "LONG" and raw_signal == "SHORT") or \
(pos_side == "SHORT" and signal == "LONG"): (pos_side == "SHORT" and raw_signal == "LONG"):
await self._close_position(position) await self._close_and_reenter(
position, raw_signal, df_with_indicators,
btc_df=btc_df, eth_df=eth_df,
oi_change=oi_change, funding_rate=funding_rate,
)
async def _open_position(self, signal: str, df): async def _open_position(self, signal: str, df):
balance = await self.exchange.get_balance() balance = await self.exchange.get_balance()
price = df["close"].iloc[-1] price = df["close"].iloc[-1]
margin_ratio = self.risk.get_dynamic_margin_ratio(balance)
quantity = self.exchange.calculate_quantity( quantity = self.exchange.calculate_quantity(
balance=balance, price=price, leverage=self.config.leverage balance=balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio
) )
logger.info(f"포지션 크기: 잔고={balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
stop_loss, take_profit = Indicators(df).get_atr_stop(df, signal, price) stop_loss, take_profit = Indicators(df).get_atr_stop(df, signal, price)
notional = quantity * price notional = quantity * price
@@ -92,12 +152,14 @@ class TradingBot:
last_row = df.iloc[-1] last_row = df.iloc[-1]
signal_snapshot = { signal_snapshot = {
"rsi": float(last_row.get("rsi", 0)), "rsi": float(last_row["rsi"]) if "rsi" in last_row.index and pd.notna(last_row["rsi"]) else 0.0,
"macd_hist": float(last_row.get("macd_hist", 0)), "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.get("atr", 0)), "atr": float(last_row["atr"]) if "atr" in last_row.index and pd.notna(last_row["atr"]) else 0.0,
} }
self.current_trade_side = signal self.current_trade_side = signal
self._entry_price = price
self._entry_quantity = quantity
self.notifier.notify_open( self.notifier.notify_open(
symbol=self.config.symbol, symbol=self.config.symbol,
side=signal, side=signal,
@@ -129,31 +191,111 @@ class TradingBot:
reduce_only=True, reduce_only=True,
) )
async def _close_position(self, position: dict): def _calc_estimated_pnl(self, exit_price: float) -> float:
amt = abs(float(position["positionAmt"])) """진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY" if self._entry_price is None or self._entry_quantity is None or self.current_trade_side is None:
pos_side = "LONG" if side == "SELL" else "SHORT" return 0.0
await self.exchange.cancel_all_orders() if self.current_trade_side == "LONG":
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True) return (exit_price - self._entry_price) * self._entry_quantity
return (self._entry_price - exit_price) * self._entry_quantity
entry = float(position["entryPrice"]) async def _on_position_closed(
mark = float(position["markPrice"]) self,
pnl = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt net_pnl: float,
close_reason: str,
exit_price: float,
) -> None:
"""User Data Stream에서 청산 감지 시 호출되는 콜백."""
estimated_pnl = self._calc_estimated_pnl(exit_price)
diff = net_pnl - estimated_pnl
self.risk.record_pnl(net_pnl)
self.notifier.notify_close( self.notifier.notify_close(
symbol=self.config.symbol, symbol=self.config.symbol,
side=pos_side, side=self.current_trade_side or "UNKNOWN",
exit_price=mark, close_reason=close_reason,
pnl=pnl, exit_price=exit_price,
estimated_pnl=estimated_pnl,
net_pnl=net_pnl,
diff=diff,
) )
self.risk.record_pnl(pnl)
logger.success(
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
)
# _close_and_reenter 중이면 신규 포지션 상태를 덮어쓰지 않는다
if self._is_reentering:
return
# Flat 상태로 초기화
self.current_trade_side = None self.current_trade_side = None
logger.success(f"포지션 청산: PnL={pnl:.4f} USDT") self._entry_price = None
self._entry_quantity = None
async def _close_position(self, position: dict):
"""포지션 청산 주문만 실행한다. PnL 기록/알림은 _on_position_closed 콜백이 담당."""
amt = abs(float(position["positionAmt"]))
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
await self.exchange.cancel_all_orders()
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)
logger.info(f"청산 주문 전송 완료 (side={side}, qty={amt})")
async def _close_and_reenter(
self,
position: dict,
signal: str,
df,
btc_df=None,
eth_df=None,
oi_change: float = 0.0,
funding_rate: float = 0.0,
) -> None:
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
# 재진입 플래그: User Data Stream 콜백이 신규 포지션 상태를 초기화하지 않도록 보호
self._is_reentering = True
try:
await self._close_position(position)
if not self.risk.can_open_new_position():
logger.info("최대 포지션 수 도달 — 재진입 건너뜀")
return
if self.ml_filter.is_model_loaded():
features = build_features(
df, signal,
btc_df=btc_df, eth_df=eth_df,
oi_change=oi_change, funding_rate=funding_rate,
)
if not self.ml_filter.should_enter(features):
logger.info(f"ML 필터 차단: {signal} 재진입 무시")
return
await self._open_position(signal, df)
finally:
self._is_reentering = False
async def run(self): async def run(self):
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x") logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
await self._recover_position() await self._recover_position()
await self.stream.start( balance = await self.exchange.get_balance()
self.risk.set_base_balance(balance)
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
user_stream = UserDataStream(
symbol=self.config.symbol,
on_order_filled=self._on_position_closed,
)
await asyncio.gather(
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,
),
user_stream.start(
api_key=self.config.api_key,
api_secret=self.config.api_secret,
),
) )

View File

@@ -11,17 +11,21 @@ class Config:
api_secret: str = "" api_secret: str = ""
symbol: str = "XRPUSDT" symbol: str = "XRPUSDT"
leverage: int = 10 leverage: int = 10
risk_per_trade: float = 0.02
max_positions: int = 3 max_positions: int = 3
stop_loss_pct: float = 0.015 # 1.5% stop_loss_pct: float = 0.015 # 1.5%
take_profit_pct: float = 0.045 # 4.5% (3:1 RR) take_profit_pct: float = 0.045 # 4.5% (3:1 RR)
trailing_stop_pct: float = 0.01 # 1% trailing_stop_pct: float = 0.01 # 1%
discord_webhook_url: str = "" discord_webhook_url: str = ""
margin_max_ratio: float = 0.50
margin_min_ratio: float = 0.20
margin_decay_rate: float = 0.0006
def __post_init__(self): def __post_init__(self):
self.api_key = os.getenv("BINANCE_API_KEY", "") self.api_key = os.getenv("BINANCE_API_KEY", "")
self.api_secret = os.getenv("BINANCE_API_SECRET", "") self.api_secret = os.getenv("BINANCE_API_SECRET", "")
self.symbol = os.getenv("SYMBOL", "XRPUSDT") self.symbol = os.getenv("SYMBOL", "XRPUSDT")
self.leverage = int(os.getenv("LEVERAGE", "10")) self.leverage = int(os.getenv("LEVERAGE", "10"))
self.risk_per_trade = float(os.getenv("RISK_PER_TRADE", "0.02"))
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "") self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
self.margin_max_ratio = float(os.getenv("MARGIN_MAX_RATIO", "0.50"))
self.margin_min_ratio = float(os.getenv("MARGIN_MIN_RATIO", "0.20"))
self.margin_decay_rate = float(os.getenv("MARGIN_DECAY_RATE", "0.0006"))

View File

@@ -5,12 +5,21 @@ import pandas as pd
from binance import AsyncClient, BinanceSocketManager from binance import AsyncClient, BinanceSocketManager
from loguru import logger from loguru import logger
# 15분봉 기준 EMA50 안정화에 필요한 최소 캔들 수.
# EMA50=50, StochRSI(14,14,3,3)=44, MACD(12,26,9)=33 중 최댓값에 여유분 추가.
_MIN_CANDLES_FOR_SIGNAL = 100
# 초기 구동 시 REST API로 가져올 과거 캔들 수.
# 15분봉 200개 = 50시간치 — EMA50(12.5h) 대비 4배 여유.
_PRELOAD_LIMIT = 200
class KlineStream: class KlineStream:
def __init__( def __init__(
self, self,
symbol: str, symbol: str,
interval: str = "1m", interval: str = "15m",
buffer_size: int = 200, buffer_size: int = 200,
on_candle: Callable = None, on_candle: Callable = None,
): ):
@@ -31,21 +40,21 @@ class KlineStream:
"is_closed": k["x"], "is_closed": k["x"],
} }
def handle_message(self, msg: dict): async def handle_message(self, msg: dict):
candle = self.parse_kline(msg) candle = self.parse_kline(msg)
if candle["is_closed"]: if candle["is_closed"]:
self.buffer.append(candle) self.buffer.append(candle)
if self.on_candle: if self.on_candle:
self.on_candle(candle) await self.on_candle(candle)
def get_dataframe(self) -> pd.DataFrame | None: def get_dataframe(self) -> pd.DataFrame | None:
if len(self.buffer) < 50: if len(self.buffer) < _MIN_CANDLES_FOR_SIGNAL:
return None return None
df = pd.DataFrame(list(self.buffer)) df = pd.DataFrame(list(self.buffer))
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 = 200): async def _preload_history(self, client: AsyncClient, limit: int = _PRELOAD_LIMIT):
"""REST API로 과거 캔들 데이터를 버퍼에 미리 채운다.""" """REST API로 과거 캔들 데이터를 버퍼에 미리 채운다."""
logger.info(f"과거 캔들 {limit}개 로드 중...") logger.info(f"과거 캔들 {limit}개 로드 중...")
klines = await client.futures_klines( klines = await client.futures_klines(
@@ -81,6 +90,108 @@ class KlineStream:
) as stream: ) as stream:
while True: while True:
msg = await stream.recv() msg = await stream.recv()
self.handle_message(msg) await self.handle_message(msg)
finally:
await client.close_connection()
class MultiSymbolStream:
"""
바이낸스 Combined WebSocket으로 여러 심볼의 캔들을 단일 연결로 수신한다.
XRP 캔들이 닫힐 때 on_candle 콜백을 호출한다.
"""
def __init__(
self,
symbols: list[str],
interval: str = "15m",
buffer_size: int = 200,
on_candle: Callable = None,
):
self.symbols = [s.lower() for s in symbols]
self.interval = interval
self.on_candle = on_candle
self.buffers: dict[str, deque] = {
s: deque(maxlen=buffer_size) for s in self.symbols
}
# 첫 번째 심볼이 주 심볼 (XRP)
self.primary_symbol = self.symbols[0]
def parse_kline(self, msg: dict) -> dict:
k = msg["k"]
return {
"timestamp": k["t"],
"open": float(k["o"]),
"high": float(k["h"]),
"low": float(k["l"]),
"close": float(k["c"]),
"volume": float(k["v"]),
"is_closed": k["x"],
}
async def handle_message(self, msg: dict):
# Combined stream 메시지는 {"stream": "...", "data": {...}} 형태
if "stream" in msg:
data = msg["data"]
else:
data = msg
if data.get("e") != "kline":
return
symbol = data["s"].lower()
candle = self.parse_kline(data)
if candle["is_closed"] and symbol in self.buffers:
self.buffers[symbol].append(candle)
if symbol == self.primary_symbol and self.on_candle:
await self.on_candle(candle)
def get_dataframe(self, symbol: str) -> pd.DataFrame | None:
key = symbol.lower()
buf = self.buffers.get(key)
if buf is None or len(buf) < _MIN_CANDLES_FOR_SIGNAL:
return None
df = pd.DataFrame(list(buf))
df.set_index("timestamp", inplace=True)
return df
async def _preload_history(self, client: AsyncClient, limit: int = _PRELOAD_LIMIT):
"""REST API로 모든 심볼의 과거 캔들을 버퍼에 미리 채운다."""
for symbol in self.symbols:
logger.info(f"{symbol.upper()} 과거 캔들 {limit}개 로드 중...")
klines = await client.futures_klines(
symbol=symbol.upper(),
interval=self.interval,
limit=limit,
)
for k in klines[:-1]:
self.buffers[symbol].append({
"timestamp": k[0],
"open": float(k[1]),
"high": float(k[2]),
"low": float(k[3]),
"close": float(k[4]),
"volume": float(k[5]),
"is_closed": True,
})
logger.info(f"{symbol.upper()} {len(self.buffers[symbol])}개 로드 완료")
async def start(self, api_key: str, api_secret: str):
client = await AsyncClient.create(
api_key=api_key,
api_secret=api_secret,
)
await self._preload_history(client)
bm = BinanceSocketManager(client)
streams = [
f"{s}@kline_{self.interval}" for s in self.symbols
]
logger.info(f"Combined WebSocket 시작: {streams}")
try:
async with bm.futures_multiplex_socket(streams) as stream:
while True:
msg = await stream.recv()
await self.handle_message(msg)
finally: finally:
await client.close_connection() await client.close_connection()

491
src/dataset_builder.py Normal file
View File

@@ -0,0 +1,491 @@
"""
전체 시계열을 1회 계산하는 벡터화 데이터셋 빌더.
pandas_ta를 130,000번 반복 호출하는 기존 방식 대신
전체 배열에 1번만 적용해 10~30배 속도를 낸다.
봇 실시간 경로(indicators.py, ml_features.py)는 변경하지 않는다.
"""
import numpy as np
import pandas as pd
import pandas_ta as ta
from src.ml_features import FEATURE_COLS
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.0
WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분)
def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""전체 시계열에 기술 지표를 1회 계산한다."""
d = df.copy()
close = d["close"]
high = d["high"]
low = d["low"]
volume = d["volume"]
d["rsi"] = ta.rsi(close, length=14)
macd = ta.macd(close, fast=12, slow=26, signal=9)
d["macd"] = macd["MACD_12_26_9"]
d["macd_signal"] = macd["MACDs_12_26_9"]
d["macd_hist"] = macd["MACDh_12_26_9"]
bb = ta.bbands(close, length=20, std=2)
d["bb_upper"] = bb["BBU_20_2.0_2.0"]
d["bb_lower"] = bb["BBL_20_2.0_2.0"]
d["ema9"] = ta.ema(close, length=9)
d["ema21"] = ta.ema(close, length=21)
d["ema50"] = ta.ema(close, length=50)
d["atr"] = ta.atr(high, low, close, length=14)
d["vol_ma20"] = ta.sma(volume, length=20)
stoch = ta.stochrsi(close, length=14)
d["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
d["stoch_d"] = stoch["STOCHRSId_14_14_3_3"]
# ADX (14) — 횡보장 필터
adx_df = ta.adx(high, low, close, length=14)
d["adx"] = adx_df["ADX_14"]
return d
def _calc_signals(d: pd.DataFrame) -> np.ndarray:
"""
indicators.py get_signal() 로직을 numpy 배열 연산으로 재현한다.
반환: signal_arr — 각 행에 대해 "LONG" | "SHORT" | "HOLD"
"""
n = len(d)
rsi = d["rsi"].values
macd = d["macd"].values
macd_sig = d["macd_signal"].values
close = d["close"].values
bb_upper = d["bb_upper"].values
bb_lower = d["bb_lower"].values
ema9 = d["ema9"].values
ema21 = d["ema21"].values
ema50 = d["ema50"].values
stoch_k = d["stoch_k"].values
stoch_d = d["stoch_d"].values
volume = d["volume"].values
vol_ma20 = d["vol_ma20"].values
# MACD 크로스: 전 캔들과 비교 (shift(1))
prev_macd = np.roll(macd, 1); prev_macd[0] = np.nan
prev_macd_sig = np.roll(macd_sig, 1); prev_macd_sig[0] = np.nan
long_score = np.zeros(n, dtype=np.float32)
short_score = np.zeros(n, dtype=np.float32)
# 1. RSI
long_score += (rsi < 35).astype(np.float32)
short_score += (rsi > 65).astype(np.float32)
# 2. MACD 크로스 (가중치 2)
macd_cross_up = (prev_macd < prev_macd_sig) & (macd > macd_sig)
macd_cross_down = (prev_macd > prev_macd_sig) & (macd < macd_sig)
long_score += macd_cross_up.astype(np.float32) * 2
short_score += macd_cross_down.astype(np.float32) * 2
# 3. 볼린저 밴드
long_score += (close < bb_lower).astype(np.float32)
short_score += (close > bb_upper).astype(np.float32)
# 4. EMA 정배열/역배열
long_score += ((ema9 > ema21) & (ema21 > ema50)).astype(np.float32)
short_score += ((ema9 < ema21) & (ema21 < ema50)).astype(np.float32)
# 5. Stochastic RSI
long_score += ((stoch_k < 20) & (stoch_k > stoch_d)).astype(np.float32)
short_score += ((stoch_k > 80) & (stoch_k < stoch_d)).astype(np.float32)
# 6. 거래량 급증
vol_surge = volume > vol_ma20 * 1.5
long_enter = (long_score >= 3) & (vol_surge | (long_score >= 4))
short_enter = (short_score >= 3) & (vol_surge | (short_score >= 4))
signal_arr = np.full(n, "HOLD", dtype=object)
signal_arr[long_enter] = "LONG"
signal_arr[short_enter] = "SHORT"
# 둘 다 해당하면 HOLD (충돌 방지)
signal_arr[long_enter & short_enter] = "HOLD"
# ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단
if "adx" in d.columns:
adx = d["adx"].values
low_adx = (~np.isnan(adx)) & (adx < 25)
signal_arr[low_adx] = "HOLD"
return signal_arr
def _rolling_zscore(arr: np.ndarray, window: int = 288) -> np.ndarray:
"""rolling window z-score 정규화. nan은 전파된다(nan-safe).
15분봉 기준 3일(288캔들) 윈도우. min_periods=1로 초반 데이터도 활용."""
s = pd.Series(arr.astype(np.float64))
r = s.rolling(window=window, min_periods=1)
mean = r.mean() # pandas rolling은 nan을 자동으로 건너뜀
std = r.std(ddof=0)
std = std.where(std >= 1e-8, other=1e-8)
z = (s - mean) / std
return z.values.astype(np.float32)
def _calc_features_vectorized(
d: pd.DataFrame,
signal_arr: np.ndarray,
btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None,
) -> pd.DataFrame:
"""
신호 발생 인덱스에서 ml_features.py build_features() 로직을
pandas 벡터 연산으로 재현한다.
"""
close = d["close"]
bb_upper = d["bb_upper"]
bb_lower = d["bb_lower"]
ema9 = d["ema9"]
ema21 = d["ema21"]
ema50 = d["ema50"]
atr = d["atr"]
volume = d["volume"]
vol_ma20 = d["vol_ma20"]
rsi = d["rsi"]
macd_hist = d["macd_hist"]
stoch_k = d["stoch_k"]
stoch_d = d["stoch_d"]
macd = d["macd"]
macd_sig = d["macd_signal"]
bb_range = bb_upper - bb_lower
bb_pct = (close - bb_lower) / (bb_range + 1e-8)
ema_align = np.where(
(ema9 > ema21) & (ema21 > ema50), 1,
np.where(
(ema9 < ema21) & (ema21 < ema50), -1, 0
)
).astype(np.float32)
atr_pct = atr / (close + 1e-8)
vol_ratio = volume / (vol_ma20 + 1e-8)
ret_1 = close.pct_change(1).fillna(0).values
ret_3 = close.pct_change(3).fillna(0).values
ret_5 = close.pct_change(5).fillna(0).values
# 절대값 피처를 rolling z-score로 정규화 (레짐 변화에 강하게)
atr_pct_z = _rolling_zscore(atr_pct)
vol_ratio_z = _rolling_zscore(vol_ratio)
ret_1_z = _rolling_zscore(ret_1)
ret_3_z = _rolling_zscore(ret_3)
ret_5_z = _rolling_zscore(ret_5)
prev_macd = macd.shift(1).fillna(0).values
prev_macd_sig = macd_sig.shift(1).fillna(0).values
# signal_strength: 신호 방향별로 각 조건 점수 합산
is_long = (signal_arr == "LONG")
is_short = (signal_arr == "SHORT")
strength = np.zeros(len(d), dtype=np.float32)
# LONG 조건
strength += is_long * (rsi.values < 35).astype(np.float32)
strength += is_long * ((prev_macd < prev_macd_sig) & (macd.values > macd_sig.values)).astype(np.float32) * 2
strength += is_long * (close.values < bb_lower.values).astype(np.float32)
strength += is_long * (ema_align == 1).astype(np.float32)
strength += is_long * ((stoch_k.values < 20) & (stoch_k.values > stoch_d.values)).astype(np.float32)
# SHORT 조건
strength += is_short * (rsi.values > 65).astype(np.float32)
strength += is_short * ((prev_macd > prev_macd_sig) & (macd.values < macd_sig.values)).astype(np.float32) * 2
strength += is_short * (close.values > bb_upper.values).astype(np.float32)
strength += is_short * (ema_align == -1).astype(np.float32)
strength += is_short * ((stoch_k.values > 80) & (stoch_k.values < stoch_d.values)).astype(np.float32)
side = np.where(signal_arr == "LONG", 1.0, 0.0).astype(np.float32)
result = pd.DataFrame({
"rsi": rsi.values.astype(np.float32),
"macd_hist": macd_hist.values.astype(np.float32),
"bb_pct": bb_pct.astype(np.float32),
"ema_align": ema_align,
"stoch_k": stoch_k.values.astype(np.float32),
"stoch_d": stoch_d.values.astype(np.float32),
"atr_pct": atr_pct_z,
"vol_ratio": vol_ratio_z,
"ret_1": ret_1_z,
"ret_3": ret_3_z,
"ret_5": ret_5_z,
"signal_strength": strength,
"side": side,
"_signal": signal_arr, # 레이블 계산용 임시 컬럼
}, index=d.index)
# BTC/ETH 피처 계산 (제공된 경우)
if btc_df is not None and eth_df is not None:
btc_ret_1 = btc_df["close"].pct_change(1).fillna(0).values
btc_ret_3 = btc_df["close"].pct_change(3).fillna(0).values
btc_ret_5 = btc_df["close"].pct_change(5).fillna(0).values
eth_ret_1 = eth_df["close"].pct_change(1).fillna(0).values
eth_ret_3 = eth_df["close"].pct_change(3).fillna(0).values
eth_ret_5 = eth_df["close"].pct_change(5).fillna(0).values
def _align(arr: np.ndarray, target_len: int) -> np.ndarray:
if len(arr) >= target_len:
return arr[-target_len:]
return np.concatenate([np.zeros(target_len - len(arr)), arr])
n = len(d)
btc_r1 = _align(btc_ret_1, n).astype(np.float32)
btc_r3 = _align(btc_ret_3, n).astype(np.float32)
btc_r5 = _align(btc_ret_5, n).astype(np.float32)
eth_r1 = _align(eth_ret_1, n).astype(np.float32)
eth_r3 = _align(eth_ret_3, n).astype(np.float32)
eth_r5 = _align(eth_ret_5, n).astype(np.float32)
xrp_r1 = ret_1.astype(np.float32)
xrp_btc_rs_raw = np.divide(
xrp_r1, btc_r1,
out=np.zeros_like(xrp_r1),
where=(btc_r1 != 0),
).astype(np.float32)
xrp_eth_rs_raw = np.divide(
xrp_r1, eth_r1,
out=np.zeros_like(xrp_r1),
where=(eth_r1 != 0),
).astype(np.float32)
extra = pd.DataFrame({
"btc_ret_1": _rolling_zscore(btc_r1),
"btc_ret_3": _rolling_zscore(btc_r3),
"btc_ret_5": _rolling_zscore(btc_r5),
"eth_ret_1": _rolling_zscore(eth_r1),
"eth_ret_3": _rolling_zscore(eth_r3),
"eth_ret_5": _rolling_zscore(eth_r5),
"xrp_btc_rs": _rolling_zscore(xrp_btc_rs_raw),
"xrp_eth_rs": _rolling_zscore(xrp_eth_rs_raw),
}, index=d.index)
result = pd.concat([result, extra], axis=1)
# OI 변화율 / 펀딩비 피처
# 컬럼 없으면 전체 nan, 있으면 0.0 구간(데이터 미제공 구간)을 nan으로 마스킹
# LightGBM은 nan을 자체 처리; MLX는 fit()에서 nanmean/nanstd + nan_to_num 처리
if "oi_change" in d.columns:
oi_raw = np.where(d["oi_change"].values == 0.0, np.nan, d["oi_change"].values)
else:
oi_raw = np.full(len(d), np.nan)
if "funding_rate" in d.columns:
fr_raw = np.where(d["funding_rate"].values == 0.0, np.nan, d["funding_rate"].values)
else:
fr_raw = np.full(len(d), np.nan)
result["oi_change"] = _rolling_zscore(oi_raw.astype(np.float64))
result["funding_rate"] = _rolling_zscore(fr_raw.astype(np.float64))
return result
def _calc_labels_vectorized(
d: pd.DataFrame,
feat: pd.DataFrame,
sig_idx: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
"""
label_builder.py build_labels() 로직을 numpy 2D 배열로 벡터화한다.
각 신호 인덱스 i에 대해 future[i+1 : i+1+LOOKAHEAD] 구간의
high/low 배열을 (N × LOOKAHEAD) 행렬로 만들어 argmax로 처리한다.
"""
n_total = len(d)
highs = d["high"].values
lows = d["low"].values
closes = d["close"].values
atrs = d["atr"].values
labels = []
valid_mask = []
for idx in sig_idx:
signal = feat.at[d.index[idx], "_signal"]
entry = closes[idx]
atr = atrs[idx]
if atr <= 0:
valid_mask.append(False)
continue
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
end = min(idx + 1 + LOOKAHEAD, n_total)
fut_high = highs[idx + 1 : end]
fut_low = lows[idx + 1 : end]
label = 0 # 미도달(타임아웃) 시 실패로 간주
for h, l in zip(fut_high, fut_low):
if signal == "LONG":
if l <= sl:
label = 0
break
if h >= tp:
label = 1
break
else: # SHORT
if h >= sl:
label = 0
break
if l <= tp:
label = 1
break
labels.append(label)
valid_mask.append(True)
return np.array(labels, dtype=np.int8), np.array(valid_mask, dtype=bool)
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,
) -> pd.DataFrame:
"""
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
기존 generate_dataset()의 drop-in 대체제.
btc_df, eth_df가 제공되면 21개 피처로 확장한다.
time_weight_decay: 지수 감쇠 강도. 0이면 균등 가중치.
양수일수록 최신 샘플에 더 높은 가중치를 부여한다.
예) 2.0 → 최신 샘플이 가장 오래된 샘플보다 e^2 ≈ 7.4배 높은 가중치.
결과 DataFrame에 'sample_weight' 컬럼으로 포함된다.
negative_ratio: 시그널 샘플 대비 HOLD negative 샘플 비율.
0이면 기존 동작 (시그널만). 5면 시그널의 5배만큼 HOLD 샘플 추가.
"""
print(" [1/3] 전체 시계열 지표 계산 (1회)...")
d = _calc_indicators(df)
print(" [2/3] 신호 마스킹 및 피처 추출...")
signal_arr = _calc_signals(d)
feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
# 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만
OPTIONAL_COLS = {"oi_change", "funding_rate"}
available_cols_for_nan_check = [
c for c in FEATURE_COLS
if c in feat_all.columns and c not in OPTIONAL_COLS
]
base_valid = (
(~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) &
(np.arange(len(d)) >= WARMUP) &
(np.arange(len(d)) < len(d) - LOOKAHEAD)
)
# --- 시그널 캔들 (기존 로직) ---
sig_valid = base_valid & (signal_arr != "HOLD")
sig_idx = np.where(sig_valid)[0]
print(f" 신호 발생 인덱스: {len(sig_idx):,}")
print(" [3/3] 레이블 계산...")
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx)
final_sig_idx = sig_idx[valid_mask]
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns]
feat_signal = feat_all.iloc[final_sig_idx][available_feature_cols].copy()
feat_signal["label"] = labels
feat_signal["source"] = "signal"
# --- HOLD negative 캔들 ---
if negative_ratio > 0 and len(final_sig_idx) > 0:
hold_valid = base_valid & (signal_arr == "HOLD")
hold_candidates = np.where(hold_valid)[0]
n_neg = min(len(hold_candidates), len(final_sig_idx) * negative_ratio)
if n_neg > 0:
rng = np.random.default_rng(42)
hold_idx = rng.choice(hold_candidates, size=n_neg, replace=False)
hold_idx = np.sort(hold_idx)
feat_hold = feat_all.iloc[hold_idx][available_feature_cols].copy()
feat_hold["label"] = 0
feat_hold["source"] = "hold_negative"
# HOLD 캔들은 시그널이 없으므로 side를 랜덤 할당 (50:50)
sides = rng.integers(0, 2, size=len(feat_hold)).astype(np.float32)
feat_hold["side"] = sides
print(f" HOLD negative 추가: {len(feat_hold):,}"
f"(비율 1:{negative_ratio})")
feat_final = pd.concat([feat_signal, feat_hold], ignore_index=True)
# 시간 순서 복원 (원본 인덱스 기반 정렬)
original_order = np.concatenate([final_sig_idx, hold_idx])
sort_order = np.argsort(original_order)
feat_final = feat_final.iloc[sort_order].reset_index(drop=True)
else:
feat_final = feat_signal.reset_index(drop=True)
else:
feat_final = feat_signal.reset_index(drop=True)
# 시간 가중치
n = len(feat_final)
if time_weight_decay > 0 and n > 1:
weights = np.exp(time_weight_decay * np.linspace(0.0, 1.0, n)).astype(np.float32)
weights /= weights.mean()
print(f" 시간 가중치 적용 (decay={time_weight_decay}): "
f"min={weights.min():.3f}, max={weights.max():.3f}")
else:
weights = np.ones(n, dtype=np.float32)
feat_final["sample_weight"] = weights
total_sig = (feat_final["source"] == "signal").sum() if "source" in feat_final.columns else len(feat_final)
total_hold = (feat_final["source"] == "hold_negative").sum() if "source" in feat_final.columns else 0
print(f" 최종 데이터셋: {n:,}개 (시그널={total_sig:,}, HOLD={total_hold:,})")
return feat_final
def stratified_undersample(
y: np.ndarray,
source: np.ndarray,
seed: int = 42,
) -> np.ndarray:
"""Signal 샘플 전수 유지 + HOLD negative만 양성 수 만큼 샘플링.
Args:
y: 라벨 배열 (0 or 1)
source: 소스 배열 ("signal" or "hold_negative")
seed: 랜덤 시드
Returns:
정렬된 인덱스 배열 (학습에 사용할 행 인덱스)
"""
pos_idx = np.where(y == 1)[0] # Signal Win
sig_neg_idx = np.where((y == 0) & (source == "signal"))[0] # Signal Loss
hold_neg_idx = np.where(source == "hold_negative")[0] # HOLD negative
# HOLD negative에서 양성 수 만큼만 샘플링
n_hold = min(len(hold_neg_idx), len(pos_idx))
rng = np.random.default_rng(seed)
if n_hold > 0:
hold_sampled = rng.choice(hold_neg_idx, size=n_hold, replace=False)
else:
hold_sampled = np.array([], dtype=np.intp)
return np.sort(np.concatenate([pos_idx, sig_neg_idx, hold_sampled]))

View File

@@ -15,14 +15,12 @@ class BinanceFuturesClient:
MIN_NOTIONAL = 5.0 # 바이낸스 선물 최소 명목금액 (USDT) MIN_NOTIONAL = 5.0 # 바이낸스 선물 최소 명목금액 (USDT)
def calculate_quantity(self, balance: float, price: float, leverage: int) -> float: def calculate_quantity(self, balance: float, price: float, leverage: int, margin_ratio: float) -> float:
"""리스크 기반 포지션 크기 계산 (최소 명목금액 $5 보장)""" """동적 증거금 비율 기반 포지션 크기 계산 (최소 명목금액 $5 보장)"""
risk_amount = balance * self.config.risk_per_trade notional = balance * margin_ratio * leverage
notional = risk_amount * 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
# XRP는 소수점 1자리, 단 최소 명목금액 충족 여부 재확인
qty_rounded = round(quantity, 1) qty_rounded = round(quantity, 1)
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 = round(self.MIN_NOTIONAL / price + 0.05, 1)
@@ -47,6 +45,8 @@ class BinanceFuturesClient:
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,6 +57,16 @@ class BinanceFuturesClient:
reduce_only: bool = False, reduce_only: bool = False,
) -> dict: ) -> dict:
loop = asyncio.get_event_loop() 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.config.symbol, symbol=self.config.symbol,
side=side, side=side,
@@ -77,6 +87,34 @@ class BinanceFuturesClient:
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.config.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() loop = asyncio.get_event_loop()
positions = await loop.run_in_executor( positions = await loop.run_in_executor(
@@ -91,10 +129,74 @@ class BinanceFuturesClient:
return None return None
async def cancel_all_orders(self): async def cancel_all_orders(self):
"""일반 오픈 주문과 Algo 오픈 주문을 모두 취소한다."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor( await loop.run_in_executor(
None, None,
lambda: self.client.futures_cancel_all_open_orders( lambda: self.client.futures_cancel_all_open_orders(
symbol=self.config.symbol symbol=self.config.symbol
), ),
) )
try:
await loop.run_in_executor(
None,
lambda: self.client.futures_cancel_all_algo_open_orders(
symbol=self.config.symbol
),
)
except Exception as e:
logger.warning(f"Algo 주문 전체 취소 실패 (무시): {e}")
async def get_open_interest(self) -> float | None:
"""현재 미결제약정(OI)을 조회한다. 오류 시 None 반환."""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.client.futures_open_interest(symbol=self.config.symbol),
)
return float(result["openInterest"])
except Exception as e:
logger.warning(f"OI 조회 실패 (무시): {e}")
return None
async def get_funding_rate(self) -> float | None:
"""현재 펀딩비를 조회한다. 오류 시 None 반환."""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.client.futures_mark_price(symbol=self.config.symbol),
)
return float(result["lastFundingRate"])
except Exception as e:
logger.warning(f"펀딩비 조회 실패 (무시): {e}")
return None
async def create_listen_key(self) -> str:
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
loop = asyncio.get_event_loop()
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:
"""PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)"""
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: self.client.futures_stream_keepalive(listenKey=listen_key),
)
async def delete_listen_key(self, listen_key: str) -> None:
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(
None,
lambda: self.client.futures_stream_close(listenKey=listen_key),
)
except Exception as e:
logger.warning(f"listenKey 삭제 실패 (무시): {e}")

View File

@@ -43,6 +43,10 @@ class Indicators:
df["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"] df["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
df["stoch_d"] = stoch["STOCHRSId_14_14_3_3"] df["stoch_d"] = stoch["STOCHRSId_14_14_3_3"]
# ADX (14) — 횡보장 필터
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
df["adx"] = adx_df["ADX_14"]
# 거래량 이동평균 # 거래량 이동평균
df["vol_ma20"] = ta.sma(df["volume"], length=20) df["vol_ma20"] = ta.sma(df["volume"], length=20)
@@ -56,6 +60,12 @@ class Indicators:
last = df.iloc[-1] last = df.iloc[-1]
prev = df.iloc[-2] prev = df.iloc[-2]
# ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단
adx = last.get("adx", None)
if adx is not None and not pd.isna(adx) and adx < 25:
logger.debug(f"ADX 필터: {adx:.1f} < 25 — HOLD")
return "HOLD"
long_signals = 0 long_signals = 0
short_signals = 0 short_signals = 0

25
src/label_builder.py Normal file
View File

@@ -0,0 +1,25 @@
from typing import Optional
def build_labels(
future_closes: list[float],
future_highs: list[float],
future_lows: list[float],
take_profit: float,
stop_loss: float,
side: str,
) -> Optional[int]:
for high, low in zip(future_highs, future_lows):
if side == "LONG":
# 보수적 접근: 손절(SL)을 먼저 체크
if low <= stop_loss:
return 0
if high >= take_profit:
return 1
else: # SHORT
# 보수적 접근: 손절(SL)을 먼저 체크
if high >= stop_loss:
return 0
if low <= take_profit:
return 1
return None

137
src/ml_features.py Normal file
View File

@@ -0,0 +1,137 @@
import pandas as pd
import numpy as np
FEATURE_COLS = [
"rsi", "macd_hist", "bb_pct", "ema_align",
"stoch_k", "stoch_d", "atr_pct", "vol_ratio",
"ret_1", "ret_3", "ret_5", "signal_strength", "side",
"btc_ret_1", "btc_ret_3", "btc_ret_5",
"eth_ret_1", "eth_ret_3", "eth_ret_5",
"xrp_btc_rs", "xrp_eth_rs",
# 시장 미시구조: OI 변화율(z-score), 펀딩비(z-score)
# parquet에 oi_change/funding_rate 컬럼이 없으면 dataset_builder에서 0으로 채움
"oi_change", "funding_rate",
]
def _calc_ret(closes: pd.Series, n: int) -> float:
"""n캔들 전 대비 수익률. 데이터 부족 시 0.0."""
if len(closes) < n + 1:
return 0.0
prev = closes.iloc[-(n + 1)]
return (closes.iloc[-1] - prev) / prev if prev != 0 else 0.0
def _calc_rs(xrp_ret: float, other_ret: float) -> float:
"""상대강도 = xrp_ret / other_ret. 분모 0이면 0.0."""
if other_ret == 0.0:
return 0.0
return xrp_ret / other_ret
def build_features(
df: pd.DataFrame,
signal: str,
btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None,
oi_change: float | None = None,
funding_rate: float | None = None,
) -> pd.Series:
"""
기술 지표가 계산된 DataFrame의 마지막 행에서 ML 피처를 추출한다.
btc_df, eth_df가 제공되면 23개 피처를, 없으면 15개 피처를 반환한다.
signal: "LONG" | "SHORT"
oi_change, funding_rate: 실제 값이 제공되면 사용, 없으면 0.0으로 채운다.
"""
last = df.iloc[-1]
close = last["close"]
bb_upper = last.get("bb_upper", close)
bb_lower = last.get("bb_lower", close)
bb_range = bb_upper - bb_lower
bb_pct = (close - bb_lower) / bb_range if bb_range > 0 else 0.5
ema9 = last.get("ema9", close)
ema21 = last.get("ema21", close)
ema50 = last.get("ema50", close)
if ema9 > ema21 > ema50:
ema_align = 1
elif ema9 < ema21 < ema50:
ema_align = -1
else:
ema_align = 0
atr = last.get("atr", 0)
atr_pct = atr / close if close > 0 else 0
vol_ma20 = last.get("vol_ma20", last.get("volume", 1))
vol_ratio = last["volume"] / vol_ma20 if vol_ma20 > 0 else 1.0
closes = df["close"]
ret_1 = _calc_ret(closes, 1)
ret_3 = _calc_ret(closes, 3)
ret_5 = _calc_ret(closes, 5)
prev = df.iloc[-2] if len(df) >= 2 else last
strength = 0
rsi = last.get("rsi", 50)
macd = last.get("macd", 0)
macd_sig = last.get("macd_signal", 0)
prev_macd = prev.get("macd", 0)
prev_macd_sig = prev.get("macd_signal", 0)
stoch_k = last.get("stoch_k", 50)
stoch_d = last.get("stoch_d", 50)
if signal == "LONG":
if rsi < 35: strength += 1
if prev_macd < prev_macd_sig and macd > macd_sig: strength += 2
if close < last.get("bb_lower", close): strength += 1
if ema_align == 1: strength += 1
if stoch_k < 20 and stoch_k > stoch_d: strength += 1
else:
if rsi > 65: strength += 1
if prev_macd > prev_macd_sig and macd < macd_sig: strength += 2
if close > last.get("bb_upper", close): strength += 1
if ema_align == -1: strength += 1
if stoch_k > 80 and stoch_k < stoch_d: strength += 1
base = {
"rsi": float(rsi),
"macd_hist": float(last.get("macd_hist", 0)),
"bb_pct": float(bb_pct),
"ema_align": float(ema_align),
"stoch_k": float(stoch_k),
"stoch_d": float(last.get("stoch_d", 50)),
"atr_pct": float(atr_pct),
"vol_ratio": float(vol_ratio),
"ret_1": float(ret_1),
"ret_3": float(ret_3),
"ret_5": float(ret_5),
"signal_strength": float(strength),
"side": 1.0 if signal == "LONG" else 0.0,
}
if btc_df is not None and eth_df is not None:
btc_ret_1 = _calc_ret(btc_df["close"], 1)
btc_ret_3 = _calc_ret(btc_df["close"], 3)
btc_ret_5 = _calc_ret(btc_df["close"], 5)
eth_ret_1 = _calc_ret(eth_df["close"], 1)
eth_ret_3 = _calc_ret(eth_df["close"], 3)
eth_ret_5 = _calc_ret(eth_df["close"], 5)
base.update({
"btc_ret_1": float(btc_ret_1),
"btc_ret_3": float(btc_ret_3),
"btc_ret_5": float(btc_ret_5),
"eth_ret_1": float(eth_ret_1),
"eth_ret_3": float(eth_ret_3),
"eth_ret_5": float(eth_ret_5),
"xrp_btc_rs": float(_calc_rs(ret_1, btc_ret_1)),
"xrp_eth_rs": float(_calc_rs(ret_1, eth_ret_1)),
})
# 실시간에서 실제 값이 제공되면 사용, 없으면 0으로 채운다
base["oi_change"] = float(oi_change) if oi_change is not None else 0.0
base["funding_rate"] = float(funding_rate) if funding_rate is not None else 0.0
return pd.Series(base)

163
src/ml_filter.py Normal file
View File

@@ -0,0 +1,163 @@
import os
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from loguru import logger
from src.ml_features import FEATURE_COLS
ONNX_MODEL_PATH = Path("models/mlx_filter.weights.onnx")
LGBM_MODEL_PATH = Path("models/lgbm_filter.pkl")
def _mtime(path: Path) -> float:
"""파일이 없으면 0.0 반환."""
try:
return path.stat().st_mtime
except FileNotFoundError:
return 0.0
class MLFilter:
"""
ML 필터. ONNX(MLX 신경망) 우선 로드, 없으면 LightGBM으로 폴백한다.
둘 다 없으면 항상 진입을 허용한다.
우선순위: ONNX > LightGBM > 폴백(항상 허용)
check_and_reload()를 주기적으로 호출하면 모델 파일 변경 시 자동 리로드된다.
"""
def __init__(
self,
onnx_path: str = str(ONNX_MODEL_PATH),
lgbm_path: str = str(LGBM_MODEL_PATH),
threshold: float = 0.60,
):
self._disabled = os.environ.get("NO_ML_FILTER", "").lower() in ("1", "true", "yes")
self._onnx_path = Path(onnx_path)
self._lgbm_path = Path(lgbm_path)
self._threshold = threshold
self._onnx_session = None
self._lgbm_model = None
self._loaded_onnx_mtime: float = 0.0
self._loaded_lgbm_mtime: float = 0.0
if self._disabled:
logger.info("ML 필터 비활성화 모드 (NO_ML_FILTER=true) → 모든 신호 허용")
else:
self._try_load()
def _try_load(self):
# 로드 여부와 무관하게 두 파일의 현재 mtime을 항상 기록한다.
# 이렇게 해야 로드하지 않은 쪽 파일이 나중에 변경됐을 때만 리로드가 트리거된다.
self._loaded_onnx_mtime = _mtime(self._onnx_path)
self._loaded_lgbm_mtime = _mtime(self._lgbm_path)
# ONNX 우선 시도
if self._onnx_path.exists():
try:
import onnxruntime as ort
sess_opts = ort.SessionOptions()
sess_opts.intra_op_num_threads = 1
sess_opts.inter_op_num_threads = 1
self._onnx_session = ort.InferenceSession(
str(self._onnx_path),
sess_options=sess_opts,
providers=["CPUExecutionProvider"],
)
self._lgbm_model = None
logger.info(
f"ML 필터 로드: ONNX ({self._onnx_path}) "
f"| 임계값={self._threshold}"
)
return
except Exception as e:
logger.warning(f"ONNX 모델 로드 실패: {e}")
self._onnx_session = None
# LightGBM 폴백
if self._lgbm_path.exists():
try:
self._lgbm_model = joblib.load(self._lgbm_path)
logger.info(
f"ML 필터 로드: LightGBM ({self._lgbm_path}) "
f"| 임계값={self._threshold}"
)
except Exception as e:
logger.warning(f"LightGBM 모델 로드 실패: {e}")
self._lgbm_model = None
else:
logger.warning("ML 필터: 모델 파일 없음 → 모든 신호 허용 (폴백)")
def is_model_loaded(self) -> bool:
return self._onnx_session is not None or self._lgbm_model is not None
@property
def active_backend(self) -> str:
if self._onnx_session is not None:
return "ONNX"
if self._lgbm_model is not None:
return "LightGBM"
return "폴백(없음)"
def check_and_reload(self) -> bool:
"""
모델 파일의 mtime을 확인해 변경됐으면 리로드한다.
실제로 리로드가 일어났으면 True 반환.
"""
if self._disabled: return False
onnx_changed = _mtime(self._onnx_path) != self._loaded_onnx_mtime
lgbm_changed = _mtime(self._lgbm_path) != self._loaded_lgbm_mtime
if onnx_changed or lgbm_changed:
changed_files = []
if onnx_changed:
changed_files.append(str(self._onnx_path))
if lgbm_changed:
changed_files.append(str(self._lgbm_path))
logger.info(f"ML 필터: 모델 파일 변경 감지 → 리로드 ({', '.join(changed_files)})")
self._onnx_session = None
self._lgbm_model = None
self._try_load()
logger.info(f"ML 필터 핫리로드 완료: 백엔드={self.active_backend}")
return True
return False
def should_enter(self, features: pd.Series) -> bool:
"""
확률 >= threshold 이면 True (진입 허용).
NO_ML_FILTER=true 이거나 모델 없으면 True 반환 (폴백).
"""
if self._disabled:
logger.debug("ML 필터 비활성화 모드 → 진입 허용")
return True
if not self.is_model_loaded():
return True
try:
if self._onnx_session is not None:
input_name = self._onnx_session.get_inputs()[0].name
X = features[FEATURE_COLS].values.astype(np.float32).reshape(1, -1)
proba = float(self._onnx_session.run(None, {input_name: X})[0][0])
else:
X = features.to_frame().T
proba = float(self._lgbm_model.predict_proba(X)[0][1])
logger.debug(
f"ML 필터 [{self.active_backend}] 확률: {proba:.3f} "
f"(임계값: {self._threshold})"
)
return bool(proba >= self._threshold)
except Exception as e:
logger.warning(f"ML 필터 예측 오류 (폴백 허용): {e}")
return True
def reload_model(self):
"""외부에서 강제 리로드할 때 사용 (하위 호환)."""
prev_backend = self.active_backend
self._onnx_session = None
self._lgbm_model = None
self._try_load()
logger.info(
f"ML 필터 강제 리로드 완료: {prev_backend}{self.active_backend}"
)

240
src/mlx_filter.py Normal file
View File

@@ -0,0 +1,240 @@
"""
Apple MLX 기반 경량 신경망 필터.
M4의 통합 GPU를 자동으로 활용한다.
학습 후 ONNX로 export해 Linux 서버에서 onnxruntime으로 추론한다.
"""
import numpy as np
import pandas as pd
import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as optim
from pathlib import Path
from src.ml_features import FEATURE_COLS
def _export_onnx(
weights_npz: Path,
meta_npz: Path,
onnx_path: Path,
) -> None:
"""
MLX 가중치(.npz)를 읽어 ONNX 그래프로 변환한다.
네트워크 구조: fc1(ReLU) → dropout(추론 시 비활성) → fc2(ReLU) → fc3 → sigmoid
"""
import onnx
from onnx import helper, TensorProto, numpy_helper
meta = np.load(meta_npz)
mean: np.ndarray = meta["mean"].astype(np.float32)
std: np.ndarray = meta["std"].astype(np.float32)
input_dim = int(meta["input_dim"])
hidden_dim = int(meta["hidden_dim"])
w = np.load(weights_npz)
# MLX save_weights 키 패턴: fc1.weight, fc1.bias, ...
fc1_w = w["fc1.weight"].astype(np.float32) # (hidden, input)
fc1_b = w["fc1.bias"].astype(np.float32)
fc2_w = w["fc2.weight"].astype(np.float32) # (hidden//2, hidden)
fc2_b = w["fc2.bias"].astype(np.float32)
fc3_w = w["fc3.weight"].astype(np.float32) # (1, hidden//2)
fc3_b = w["fc3.bias"].astype(np.float32)
def _t(name: str, arr: np.ndarray) -> onnx.TensorProto:
return numpy_helper.from_array(arr, name=name)
initializers = [
_t("mean", mean),
_t("std", std),
_t("fc1_w", fc1_w),
_t("fc1_b", fc1_b),
_t("fc2_w", fc2_w),
_t("fc2_b", fc2_b),
_t("fc3_w", fc3_w),
_t("fc3_b", fc3_b),
]
nodes = [
# 정규화: (x - mean) / std
helper.make_node("Sub", ["X", "mean"], ["x_sub"]),
helper.make_node("Div", ["x_sub", "std"], ["x_norm"]),
# fc1: x_norm @ fc1_w.T + fc1_b
helper.make_node("Gemm", ["x_norm", "fc1_w", "fc1_b"], ["fc1_out"],
transB=1),
helper.make_node("Relu", ["fc1_out"], ["relu1"]),
# fc2: relu1 @ fc2_w.T + fc2_b
helper.make_node("Gemm", ["relu1", "fc2_w", "fc2_b"], ["fc2_out"],
transB=1),
helper.make_node("Relu", ["fc2_out"], ["relu2"]),
# fc3: relu2 @ fc3_w.T + fc3_b → (N, 1)
helper.make_node("Gemm", ["relu2", "fc3_w", "fc3_b"], ["logits"],
transB=1),
# sigmoid → (N, 1)
helper.make_node("Sigmoid", ["logits"], ["proba_2d"]),
# squeeze: (N, 1) → (N,) — axis=-1 로 마지막 차원만 제거
helper.make_node("Squeeze", ["proba_2d", "squeeze_axes"], ["proba"]),
]
squeeze_axes = numpy_helper.from_array(
np.array([-1], dtype=np.int64), name="squeeze_axes"
)
initializers.append(squeeze_axes)
graph = helper.make_graph(
nodes,
"mlx_filter",
inputs=[helper.make_tensor_value_info("X", TensorProto.FLOAT, [None, input_dim])],
outputs=[helper.make_tensor_value_info("proba", TensorProto.FLOAT, [-1])],
initializer=initializers,
)
model_proto = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 17)])
model_proto.ir_version = 8
onnx.checker.check_model(model_proto)
onnx_path.parent.mkdir(exist_ok=True)
onnx.save(model_proto, str(onnx_path))
print(f" ONNX export 완료: {onnx_path}")
class _Net(nn.Module):
"""3층 MLP 이진 분류기."""
def __init__(self, input_dim: int, hidden_dim: int):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim // 2)
self.fc3 = nn.Linear(hidden_dim // 2, 1)
self.dropout = nn.Dropout(p=0.2)
def __call__(self, x: mx.array) -> mx.array:
x = nn.relu(self.fc1(x))
x = self.dropout(x)
x = nn.relu(self.fc2(x))
return self.fc3(x).squeeze(-1)
class MLXFilter:
"""
scikit-learn 호환 인터페이스를 제공하는 MLX 신경망 필터.
M4 통합 GPU(Metal)를 자동으로 사용한다.
"""
def __init__(
self,
input_dim: int = 13,
hidden_dim: int = 64,
lr: float = 1e-3,
epochs: int = 50,
batch_size: int = 256,
):
self.input_dim = input_dim
self.hidden_dim = hidden_dim
self.lr = lr
self.epochs = epochs
self.batch_size = batch_size
self._model = _Net(input_dim, hidden_dim)
self._mean: np.ndarray | None = None
self._std: np.ndarray | None = None
self._trained = False
def fit(
self,
X: pd.DataFrame,
y: pd.Series,
sample_weight: np.ndarray | None = None,
) -> "MLXFilter":
X_np = X[FEATURE_COLS].values.astype(np.float32)
y_np = y.values.astype(np.float32)
# nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치
# (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
mean_vals = np.nanmean(X_np, axis=0)
self._mean = np.nan_to_num(mean_vals, nan=0.0) # 전체-NaN 컬럼 → 평균 0.0
std_vals = np.nanstd(X_np, axis=0)
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0
X_np = (X_np - self._mean) / self._std
X_np = np.nan_to_num(X_np, nan=0.0)
w_np = sample_weight.astype(np.float32) if sample_weight is not None else None
optimizer = optim.Adam(learning_rate=self.lr)
def loss_fn(
model: _Net, x: mx.array, y: mx.array, w: mx.array | None
) -> mx.array:
logits = model(x)
per_sample = nn.losses.binary_cross_entropy(
logits, y, with_logits=True, reduction="none"
)
if w is not None:
return (per_sample * w).sum() / w.sum()
return per_sample.mean()
loss_and_grad = nn.value_and_grad(self._model, loss_fn)
n = len(X_np)
for epoch in range(self.epochs):
idx = np.random.permutation(n)
epoch_loss = 0.0
steps = 0
for start in range(0, n, self.batch_size):
batch_idx = idx[start : start + self.batch_size]
x_batch = mx.array(X_np[batch_idx])
y_batch = mx.array(y_np[batch_idx])
w_batch = mx.array(w_np[batch_idx]) if w_np is not None else None
loss, grads = loss_and_grad(self._model, x_batch, y_batch, w_batch)
optimizer.update(self._model, grads)
mx.eval(self._model.parameters(), optimizer.state)
epoch_loss += loss.item()
steps += 1
if (epoch + 1) % 10 == 0:
print(f" Epoch {epoch + 1}/{self.epochs} loss={epoch_loss / steps:.4f}")
self._trained = True
return self
def predict_proba(self, X: pd.DataFrame) -> np.ndarray:
X_np = X[FEATURE_COLS].values.astype(np.float32)
if self._trained and self._mean is not None:
X_np = (X_np - self._mean) / self._std
X_np = np.nan_to_num(X_np, nan=0.0)
x = mx.array(X_np)
self._model.eval()
logits = self._model(x)
proba = mx.sigmoid(logits)
mx.eval(proba)
self._model.train()
return np.array(proba)
def save(self, path: str | Path) -> None:
path = Path(path)
path.parent.mkdir(exist_ok=True)
weights_path = path.with_suffix(".npz")
self._model.save_weights(str(weights_path))
meta_path = path.with_suffix(".meta.npz")
np.savez(
meta_path,
mean=self._mean,
std=self._std,
input_dim=np.array(self.input_dim),
hidden_dim=np.array(self.hidden_dim),
)
# ONNX export: Linux 서버에서 onnxruntime으로 추론하기 위해 변환
try:
onnx_path = path.with_suffix(".onnx")
_export_onnx(weights_path, meta_path, onnx_path)
except ImportError:
print(" [경고] onnx 패키지 없음 → ONNX export 생략 (pip install onnx)")
@classmethod
def load(cls, path: str | Path) -> "MLXFilter":
path = Path(path)
meta = np.load(path.with_suffix(".meta.npz"))
obj = cls(
input_dim=int(meta["input_dim"]),
hidden_dim=int(meta["hidden_dim"]),
)
obj._mean = meta["mean"]
obj._std = meta["std"]
obj._model.load_weights(str(path.with_suffix(".npz")))
obj._trained = True
return obj

View File

@@ -49,13 +49,20 @@ class DiscordNotifier:
self, self,
symbol: str, symbol: str,
side: str, side: str,
close_reason: str,
exit_price: float, exit_price: float,
pnl: float, estimated_pnl: float,
net_pnl: float,
diff: float,
) -> None: ) -> None:
emoji = "" if pnl >= 0 else "" emoji_map = {"TP": "", "SL": "", "MANUAL": "🔶"}
emoji = emoji_map.get(close_reason, "🔶")
msg = ( msg = (
f"{emoji} **[{symbol}] {side} 청산**\n" f"{emoji} **[{symbol}] {side} {close_reason} 청산**\n"
f"청산가: `{exit_price:.4f}` | PnL: `{pnl:+.4f} USDT`" f"청산가: `{exit_price:.4f}`\n"
f"예상 수익: `{estimated_pnl:+.4f} USDT`\n"
f"실제 순수익: `{net_pnl:+.4f} USDT`\n"
f"차이(슬리피지+수수료): `{diff:+.4f} USDT`"
) )
self._send(msg) self._send(msg)

View File

@@ -34,3 +34,14 @@ class RiskManager:
"""매일 자정 초기화""" """매일 자정 초기화"""
self.daily_pnl = 0.0 self.daily_pnl = 0.0
logger.info("일일 PnL 초기화") logger.info("일일 PnL 초기화")
def set_base_balance(self, balance: float) -> None:
"""봇 시작 시 기준 잔고 설정 (동적 비율 계산 기준점)"""
self.initial_balance = balance
def get_dynamic_margin_ratio(self, balance: float) -> float:
"""잔고에 따라 선형 감소하는 증거금 비율 반환"""
ratio = self.config.margin_max_ratio - (
(balance - self.initial_balance) * self.config.margin_decay_rate
)
return max(self.config.margin_min_ratio, min(self.config.margin_max_ratio, ratio))

114
src/user_data_stream.py Normal file
View File

@@ -0,0 +1,114 @@
import asyncio
from typing import Callable
from binance import AsyncClient, BinanceSocketManager
from loguru import logger
_RECONNECT_DELAY = 5 # 재연결 대기 초
_CLOSE_ORDER_TYPES = {"TAKE_PROFIT_MARKET", "STOP_MARKET"}
class UserDataStream:
"""
Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다.
- python-binance BinanceSocketManager의 내장 keepalive 활용
- 네트워크 단절 시 무한 재연결 루프
- ORDER_TRADE_UPDATE 이벤트에서 지정 심볼의 청산 주문만 필터링하여 콜백 호출
"""
def __init__(
self,
symbol: str, # 감시할 심볼 (예: "XRPUSDT")
on_order_filled: Callable, # bot._on_position_closed 콜백
):
self._symbol = symbol.upper()
self._on_order_filled = on_order_filled
async def start(self, api_key: str, api_secret: str) -> None:
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
client = await AsyncClient.create(
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:
"""연결 → 재연결 무한 루프. BinanceSocketManager가 listenKey keepalive를 내부 처리한다."""
while True:
try:
async with bm.futures_user_socket() as stream:
logger.info(f"User Data Stream 연결 완료 (심볼 필터: {self._symbol})")
while True:
msg = await stream.recv()
if isinstance(msg, dict) and msg.get("e") == "error":
logger.warning(
f"웹소켓 내부 에러 수신: {msg.get('m', msg)}"
f"재연결을 위해 연결 종료"
)
break
await self._handle_message(msg)
except asyncio.CancelledError:
logger.info("User Data Stream 정상 종료")
raise
except Exception as e:
logger.warning(
f"User Data Stream 끊김: {e}"
f"{_RECONNECT_DELAY}초 후 재연결"
)
await asyncio.sleep(_RECONNECT_DELAY)
async def _handle_message(self, msg: dict) -> None:
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
if msg.get("e") != "ORDER_TRADE_UPDATE":
return
order = msg.get("o", {})
# 심볼 필터링: 봇이 관리하는 심볼만 처리
if order.get("s", "") != self._symbol:
return
# x: Execution Type, X: Order Status
if order.get("x") != "TRADE" or order.get("X") != "FILLED":
return
order_type = order.get("o", "")
is_reduce = order.get("R", False)
realized_pnl = float(order.get("rp", "0"))
# 청산 주문 판별: reduceOnly이거나, TP/SL 타입이거나, rp != 0
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0
if not is_close:
return
commission = abs(float(order.get("n", "0")))
net_pnl = realized_pnl - commission
exit_price = float(order.get("ap", "0"))
if order_type == "TAKE_PROFIT_MARKET":
close_reason = "TP"
elif order_type == "STOP_MARKET":
close_reason = "SL"
else:
close_reason = "MANUAL"
logger.info(
f"청산 감지({close_reason}): exit={exit_price:.4f}, "
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
f"net_pnl={net_pnl:+.4f}"
)
await self._on_order_filled(
net_pnl=net_pnl,
close_reason=close_reason,
exit_price=exit_price,
)

View File

@@ -17,6 +17,7 @@ def config():
"RISK_PER_TRADE": "0.02", "RISK_PER_TRADE": "0.02",
"NOTION_TOKEN": "secret_test", "NOTION_TOKEN": "secret_test",
"NOTION_DATABASE_ID": "db_test", "NOTION_DATABASE_ID": "db_test",
"DISCORD_WEBHOOK_URL": "",
}) })
return Config() return Config()
@@ -35,12 +36,23 @@ def sample_df():
}) })
def test_bot_uses_multi_symbol_stream(config):
from src.data_stream import MultiSymbolStream
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
assert isinstance(bot.stream, MultiSymbolStream)
def test_bot_stream_has_btc_eth_buffers(config):
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
assert "btcusdt" in bot.stream.buffers
assert "ethusdt" in bot.stream.buffers
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bot_processes_signal(config, sample_df): async def test_bot_processes_signal(config, sample_df):
with patch("src.bot.BinanceFuturesClient") as MockExchange, \ with patch("src.bot.BinanceFuturesClient") as MockExchange:
patch("src.bot.TradeRepository") as MockRepo:
MockExchange.return_value = AsyncMock() MockExchange.return_value = AsyncMock()
MockRepo.return_value = MagicMock()
bot = TradingBot(config) bot = TradingBot(config)
bot.exchange = AsyncMock() bot.exchange = AsyncMock()
@@ -48,8 +60,8 @@ async def test_bot_processes_signal(config, sample_df):
bot.exchange.get_position = AsyncMock(return_value=None) bot.exchange.get_position = AsyncMock(return_value=None)
bot.exchange.place_order = AsyncMock(return_value={"orderId": "123"}) bot.exchange.place_order = AsyncMock(return_value={"orderId": "123"})
bot.exchange.set_leverage = AsyncMock(return_value={}) bot.exchange.set_leverage = AsyncMock(return_value={})
bot.db = MagicMock() bot.exchange.calculate_quantity = MagicMock(return_value=100.0)
bot.db.save_trade = MagicMock(return_value={"id": "trade1"}) bot.exchange.MIN_NOTIONAL = 5.0
with patch("src.bot.Indicators") as MockInd: with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock() mock_ind = MagicMock()
@@ -58,3 +70,179 @@ async def test_bot_processes_signal(config, sample_df):
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)
@pytest.mark.asyncio
async def test_close_and_reenter_calls_open_when_ml_passes(config, sample_df):
"""반대 시그널 + ML 필터 통과 시 청산 후 재진입해야 한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot._close_position = AsyncMock()
bot._open_position = AsyncMock()
bot.risk = MagicMock()
bot.risk.can_open_new_position.return_value = True
bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = True
bot.ml_filter.should_enter.return_value = True
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df)
bot._close_position.assert_awaited_once_with(position)
bot._open_position.assert_awaited_once_with("SHORT", sample_df)
@pytest.mark.asyncio
async def test_close_and_reenter_skips_open_when_ml_blocks(config, sample_df):
"""ML 필터 차단 시 청산만 하고 재진입하지 않아야 한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot._close_position = AsyncMock()
bot._open_position = AsyncMock()
bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = True
bot.ml_filter.should_enter.return_value = False
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df)
bot._close_position.assert_awaited_once_with(position)
bot._open_position.assert_not_called()
@pytest.mark.asyncio
async def test_close_and_reenter_skips_open_when_max_positions_reached(config, sample_df):
"""최대 포지션 수 도달 시 청산만 하고 재진입하지 않아야 한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot._close_position = AsyncMock()
bot._open_position = AsyncMock()
bot.risk = MagicMock()
bot.risk.can_open_new_position.return_value = False
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
await bot._close_and_reenter(position, "SHORT", sample_df)
bot._close_position.assert_awaited_once_with(position)
bot._open_position.assert_not_called()
@pytest.mark.asyncio
async def test_process_candle_calls_close_and_reenter_on_reverse_signal(config, sample_df):
"""반대 시그널 시 process_candle이 _close_and_reenter를 호출해야 한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot.exchange = AsyncMock()
bot.exchange.get_position = AsyncMock(return_value={
"positionAmt": "100",
"entryPrice": "0.5",
"markPrice": "0.52",
})
bot._close_and_reenter = AsyncMock()
bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = False
bot.ml_filter.should_enter.return_value = True
with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "SHORT" # 현재 LONG 포지션에 반대 시그널
MockInd.return_value = mock_ind
await bot.process_candle(sample_df)
bot._close_and_reenter.assert_awaited_once()
call_args = bot._close_and_reenter.call_args
assert call_args.args[1] == "SHORT"
@pytest.mark.asyncio
async def test_process_candle_passes_raw_signal_to_close_and_reenter_even_if_ml_loaded(config, sample_df):
"""포지션 보유 시 ML 필터가 로드되어 있어도 process_candle은 raw signal을 _close_and_reenter에 전달한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot.exchange = AsyncMock()
bot.exchange.get_position = AsyncMock(return_value={
"positionAmt": "100",
"entryPrice": "0.5",
"markPrice": "0.52",
})
bot._close_and_reenter = AsyncMock()
bot.ml_filter = MagicMock()
bot.ml_filter.is_model_loaded.return_value = True # 모델 로드됨
bot.ml_filter.should_enter.return_value = False # ML이 차단하더라도
with patch("src.bot.Indicators") as MockInd:
mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "SHORT"
MockInd.return_value = mock_ind
await bot.process_candle(sample_df)
# ML 필터가 차단해도 _close_and_reenter는 호출되어야 한다 (ML 재평가는 내부에서)
bot._close_and_reenter.assert_awaited_once()
call_args = bot._close_and_reenter.call_args
assert call_args.args[1] == "SHORT"
# process_candle에서 ml_filter.should_enter가 호출되지 않아야 한다
bot.ml_filter.should_enter.assert_not_called()
@pytest.mark.asyncio
async def test_process_candle_fetches_oi_and_funding(config, sample_df):
"""process_candle()이 OI와 펀딩비를 조회하고 build_features에 전달하는지 확인."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot.exchange = AsyncMock()
bot.exchange.get_balance = AsyncMock(return_value=1000.0)
bot.exchange.get_position = AsyncMock(return_value=None)
bot.exchange.place_order = AsyncMock(return_value={"orderId": "1"})
bot.exchange.set_leverage = AsyncMock()
bot.exchange.get_open_interest = AsyncMock(return_value=5000000.0)
bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001)
# 신호를 LONG으로 강제해 build_features가 반드시 호출되도록 함
with patch("src.bot.Indicators") as mock_ind_cls:
mock_ind = MagicMock()
mock_ind.calculate_all.return_value = sample_df
mock_ind.get_signal.return_value = "LONG"
mock_ind_cls.return_value = mock_ind
with patch("src.bot.build_features") as mock_build:
from src.ml_features import FEATURE_COLS
mock_build.return_value = pd.Series({col: 0.0 for col in FEATURE_COLS})
bot.ml_filter.is_model_loaded = MagicMock(return_value=False)
# _open_position은 이 테스트의 관심사가 아니므로 mock 처리
bot._open_position = AsyncMock()
await bot.process_candle(sample_df)
assert mock_build.called
call_kwargs = mock_build.call_args.kwargs
assert "oi_change" in call_kwargs
assert "funding_rate" in call_kwargs
def test_calc_oi_change_first_candle_returns_zero(config):
"""첫 캔들은 0.0을 반환하고 _prev_oi를 설정한다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
assert bot._calc_oi_change(5000000.0) == 0.0
assert bot._prev_oi == 5000000.0
def test_calc_oi_change_api_failure_does_not_corrupt_state(config):
"""API 실패 시 _fetch_market_microstructure가 _calc_oi_change를 호출하지 않아 상태가 오염되지 않는다."""
with patch("src.bot.BinanceFuturesClient"):
bot = TradingBot(config)
bot._prev_oi = 5000000.0
# API 실패 시 _fetch_market_microstructure는 oi_val > 0 체크로 _calc_oi_change를 건너뜀
# _calc_oi_change(0.0)을 직접 호출하면 _prev_oi가 0.0으로 오염되는 이전 버그를 재현
# 수정 후에는 _fetch_market_microstructure에서 0.0을 직접 반환하므로 이 경로가 없음
# 대신 _calc_oi_change가 정상 값에서만 호출되는지 확인
result = bot._calc_oi_change(5100000.0)
assert abs(result - 0.02) < 1e-6 # (5100000 - 5000000) / 5000000 = 0.02
assert bot._prev_oi == 5100000.0

View File

@@ -6,16 +6,16 @@ from src.config import Config
def test_config_loads_symbol(): def test_config_loads_symbol():
os.environ["SYMBOL"] = "XRPUSDT" os.environ["SYMBOL"] = "XRPUSDT"
os.environ["LEVERAGE"] = "10" os.environ["LEVERAGE"] = "10"
os.environ["RISK_PER_TRADE"] = "0.02"
cfg = Config() cfg = Config()
assert cfg.symbol == "XRPUSDT" assert cfg.symbol == "XRPUSDT"
assert cfg.leverage == 10 assert cfg.leverage == 10
assert cfg.risk_per_trade == 0.02
def test_config_notion_keys(): def test_config_dynamic_margin_params():
os.environ["NOTION_TOKEN"] = "secret_test" os.environ["MARGIN_MAX_RATIO"] = "0.50"
os.environ["NOTION_DATABASE_ID"] = "db_test_id" os.environ["MARGIN_MIN_RATIO"] = "0.20"
os.environ["MARGIN_DECAY_RATE"] = "0.0006"
cfg = Config() cfg = Config()
assert cfg.notion_token == "secret_test" assert cfg.margin_max_ratio == 0.50
assert cfg.notion_database_id == "db_test_id" assert cfg.margin_min_ratio == 0.20
assert cfg.margin_decay_rate == 0.0006

View File

@@ -2,6 +2,44 @@ import pytest
import asyncio import asyncio
from unittest.mock import AsyncMock, patch, MagicMock from unittest.mock import AsyncMock, patch, MagicMock
from src.data_stream import KlineStream from src.data_stream import KlineStream
from src.data_stream import MultiSymbolStream
def test_multi_symbol_stream_has_three_buffers():
stream = MultiSymbolStream(
symbols=["XRPUSDT", "BTCUSDT", "ETHUSDT"],
interval="1m",
)
assert "xrpusdt" in stream.buffers
assert "btcusdt" in stream.buffers
assert "ethusdt" in stream.buffers
def test_multi_symbol_stream_get_dataframe_returns_none_when_empty():
stream = MultiSymbolStream(
symbols=["XRPUSDT", "BTCUSDT", "ETHUSDT"],
interval="1m",
)
assert stream.get_dataframe("XRPUSDT") is None
def test_multi_symbol_stream_get_dataframe_returns_df_when_full():
import pandas as pd
from src.data_stream import _MIN_CANDLES_FOR_SIGNAL
stream = MultiSymbolStream(
symbols=["XRPUSDT", "BTCUSDT", "ETHUSDT"],
interval="1m",
buffer_size=200,
)
candle = {
"timestamp": 1000, "open": 1.0, "high": 1.1,
"low": 0.9, "close": 1.05, "volume": 100.0, "is_closed": True,
}
for i in range(_MIN_CANDLES_FOR_SIGNAL):
c = candle.copy()
c["timestamp"] = 1000 + i
stream.buffers["xrpusdt"].append(c)
df = stream.get_dataframe("XRPUSDT")
assert df is not None
assert len(df) == _MIN_CANDLES_FOR_SIGNAL
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -25,11 +63,11 @@ async def test_kline_stream_parses_message():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_callback_called_on_closed_candle(): async def test_callback_called_on_closed_candle():
received = [] callback = AsyncMock()
stream = KlineStream( stream = KlineStream(
symbol="XRPUSDT", symbol="XRPUSDT",
interval="1m", interval="1m",
on_candle=lambda c: received.append(c), on_candle=callback,
) )
raw_msg = { raw_msg = {
"k": { "k": {
@@ -42,8 +80,8 @@ async def test_callback_called_on_closed_candle():
"x": True, "x": True,
} }
} }
stream.handle_message(raw_msg) await stream.handle_message(raw_msg)
assert len(received) == 1 assert callback.call_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -1,42 +0,0 @@
import pytest
from unittest.mock import MagicMock, patch
from src.database import TradeRepository
@pytest.fixture
def mock_repo():
with patch("src.database.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
repo = TradeRepository(token="secret_test", database_id="db_test")
repo.client = mock_client
yield repo
def test_save_trade(mock_repo):
mock_repo.client.pages.create.return_value = {
"id": "abc123",
"properties": {},
}
result = mock_repo.save_trade(
symbol="XRPUSDT",
side="LONG",
entry_price=0.5,
quantity=400.0,
leverage=10,
signal_data={"rsi": 32, "macd_hist": 0.001},
)
assert result["id"] == "abc123"
def test_close_trade(mock_repo):
mock_repo.client.pages.update.return_value = {
"id": "abc123",
"properties": {
"Status": {"select": {"name": "CLOSED"}},
},
}
result = mock_repo.close_trade(
trade_id="abc123", exit_price=0.55, pnl=20.0
)
assert result["id"] == "abc123"

View File

@@ -0,0 +1,268 @@
import numpy as np
import pandas as pd
import pytest
from src.dataset_builder import generate_dataset_vectorized
@pytest.fixture
def sample_df():
"""최소 200행 이상의 OHLCV 더미 데이터."""
rng = np.random.default_rng(42)
n = 500
close = 2.0 + np.cumsum(rng.normal(0, 0.01, n))
close = np.clip(close, 0.01, None)
high = close * (1 + rng.uniform(0, 0.005, n))
low = close * (1 - rng.uniform(0, 0.005, n))
return pd.DataFrame({
"open": close,
"high": high,
"low": low,
"close": close,
"volume": rng.uniform(1e6, 5e6, n),
})
def test_returns_dataframe(sample_df):
"""결과가 DataFrame이어야 한다."""
result = generate_dataset_vectorized(sample_df)
assert isinstance(result, pd.DataFrame)
def test_has_required_columns(sample_df):
"""기본 13개 피처 + label 컬럼이 모두 있어야 한다."""
BASE_FEATURE_COLS = [
"rsi", "macd_hist", "bb_pct", "ema_align",
"stoch_k", "stoch_d", "atr_pct", "vol_ratio",
"ret_1", "ret_3", "ret_5", "signal_strength", "side",
]
result = generate_dataset_vectorized(sample_df)
if len(result) > 0:
assert "label" in result.columns
for col in BASE_FEATURE_COLS:
assert col in result.columns, f"컬럼 없음: {col}"
def test_label_is_binary(sample_df):
"""label은 0 또는 1만 있어야 한다."""
result = generate_dataset_vectorized(sample_df)
if len(result) > 0:
assert set(result["label"].unique()).issubset({0, 1})
def test_generate_dataset_vectorized_with_btc_eth_has_21_feature_cols():
"""BTC/ETH DataFrame을 전달하면 결과 컬럼이 21개 피처 + label이어야 한다."""
import pandas as pd
import numpy as np
from src.dataset_builder import generate_dataset_vectorized
from src.ml_features import FEATURE_COLS
np.random.seed(42)
n = 500
closes = np.cumprod(1 + np.random.randn(n) * 0.001) * 1.0
xrp_df = pd.DataFrame({
"open": closes * 0.999, "high": closes * 1.005,
"low": closes * 0.995, "close": closes,
"volume": np.random.rand(n) * 1000 + 500,
})
btc_df = xrp_df.copy() * 50000
eth_df = xrp_df.copy() * 3000
result = generate_dataset_vectorized(xrp_df, btc_df=btc_df, eth_df=eth_df)
if not result.empty:
assert set(FEATURE_COLS).issubset(set(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():
"""bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
# close를 모두 같은 값으로 → bb_range=0 유발
df = pd.DataFrame({
"open": np.ones(n),
"high": np.ones(n),
"low": np.ones(n),
"close": np.ones(n),
"volume": np.ones(n),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
numeric_cols = feat.select_dtypes(include=[np.number]).columns
assert not feat[numeric_cols].isin([np.inf, -np.inf]).any().any(), \
"inf 값이 있으면 안 됨"
def test_oi_nan_masking_no_column():
"""oi_change 컬럼이 없으면 전체가 nan이어야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
np.random.seed(0)
df = pd.DataFrame({
"open": np.random.uniform(1, 2, n),
"high": np.random.uniform(2, 3, n),
"low": np.random.uniform(0.5, 1, n),
"close": np.random.uniform(1, 2, n),
"volume": np.random.uniform(1000, 5000, n),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
assert feat["oi_change"].isna().all(), "oi_change 컬럼 없을 때 전부 nan이어야 함"
def test_oi_nan_masking_with_zeros():
"""oi_change 컬럼이 있어도 0.0 구간은 nan으로 마스킹되어야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
np.random.seed(0)
df = pd.DataFrame({
"open": np.random.uniform(1, 2, n),
"high": np.random.uniform(2, 3, n),
"low": np.random.uniform(0.5, 1, n),
"close": np.random.uniform(1, 2, n),
"volume": np.random.uniform(1000, 5000, n),
"oi_change": np.concatenate([np.zeros(50), np.random.uniform(-0.1, 0.1, 50)]),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
assert feat["oi_change"].iloc[50:].notna().any(), "실제 OI 값 구간에 유한값이 있어야 함"
def test_rs_zero_denominator():
"""btc_r1=0일 때 RS가 inf/nan이 아닌 0.0이어야 한다 (np.divide 방식 검증)."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 500
np.random.seed(7)
# XRP close: 약간의 변동
xrp_close = np.cumprod(1 + np.random.randn(n) * 0.001) * 1.0
xrp_df = pd.DataFrame({
"open": xrp_close * 0.999,
"high": xrp_close * 1.005,
"low": xrp_close * 0.995,
"close": xrp_close,
"volume": np.random.rand(n) * 1000 + 500,
})
# BTC close: 완전히 고정 → btc_r1 = 0.0
btc_close = np.ones(n) * 50000.0
btc_df = pd.DataFrame({
"open": btc_close,
"high": btc_close,
"low": btc_close,
"close": btc_close,
"volume": np.random.rand(n) * 1000 + 500,
})
# ETH close: 약간의 변동 (eth_df 없으면 BTC 피처 자체가 계산 안 됨)
eth_close = np.cumprod(1 + np.random.randn(n) * 0.001) * 3000.0
eth_df = pd.DataFrame({
"open": eth_close * 0.999,
"high": eth_close * 1.005,
"low": eth_close * 0.995,
"close": eth_close,
"volume": np.random.rand(n) * 1000 + 500,
})
# _calc_features_vectorized를 직접 호출해 BTC/ETH 피처를 포함한 전체 피처를 검증
d = _calc_indicators(xrp_df)
signal_arr = _calc_signals(d)
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 not feat["xrp_btc_rs"].isin([np.inf, -np.inf]).any(), \
"xrp_btc_rs에 inf가 있으면 안 됨"
assert not feat["xrp_btc_rs"].isna().all(), \
"xrp_btc_rs가 전부 nan이면 안 됨"
@pytest.fixture
def signal_producing_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 # 30봉마다 거래량 급증
return pd.DataFrame({
"open": close, "high": high, "low": low,
"close": close, "volume": volume,
})
def test_hold_negative_labels_are_all_zero(signal_producing_df):
"""HOLD negative 샘플의 label은 전부 0이어야 한다."""
result = generate_dataset_vectorized(signal_producing_df, negative_ratio=3)
assert len(result) > 0, "시그널이 발생하지 않아 테스트 불가"
assert "source" in result.columns
hold_neg = result[result["source"] == "hold_negative"]
assert len(hold_neg) > 0, "HOLD negative 샘플이 0개"
assert (hold_neg["label"] == 0).all(), \
f"HOLD negative 중 label != 0인 샘플 존재: {hold_neg['label'].value_counts().to_dict()}"
def test_signal_samples_preserved_after_sampling(signal_producing_df):
"""계층적 샘플링 후 source='signal' 샘플이 하나도 버려지지 않아야 한다."""
result_signal_only = generate_dataset_vectorized(signal_producing_df, negative_ratio=0)
result_with_hold = generate_dataset_vectorized(signal_producing_df, negative_ratio=3)
assert len(result_signal_only) > 0, "시그널이 발생하지 않아 테스트 불가"
assert "source" in result_with_hold.columns
signal_count = (result_with_hold["source"] == "signal").sum()
assert signal_count == len(result_signal_only), \
f"Signal 샘플 손실: 원본={len(result_signal_only)}, 유지={signal_count}"
def test_stratified_undersample_preserves_signal():
"""stratified_undersample은 signal 샘플을 전수 유지해야 한다."""
from src.dataset_builder import stratified_undersample
y = np.array([1, 0, 0, 0, 0, 0, 0, 0, 1, 0])
source = np.array(["signal", "signal", "signal", "hold_negative",
"hold_negative", "hold_negative", "hold_negative",
"hold_negative", "signal", "signal"])
idx = stratified_undersample(y, source, seed=42)
# signal 인덱스: 0, 1, 2, 8, 9 → 전부 포함
signal_indices = np.where(source == "signal")[0]
for si in signal_indices:
assert si in idx, f"signal 인덱스 {si}가 누락됨"

View File

@@ -12,11 +12,34 @@ def config():
"BINANCE_API_SECRET": "test_secret", "BINANCE_API_SECRET": "test_secret",
"SYMBOL": "XRPUSDT", "SYMBOL": "XRPUSDT",
"LEVERAGE": "10", "LEVERAGE": "10",
"RISK_PER_TRADE": "0.02",
}) })
return Config() return Config()
@pytest.fixture
def client():
config = Config()
config.leverage = 10
c = BinanceFuturesClient.__new__(BinanceFuturesClient)
c.config = config
return c
@pytest.fixture
def exchange():
os.environ.update({
"BINANCE_API_KEY": "test_key",
"BINANCE_API_SECRET": "test_secret",
"SYMBOL": "XRPUSDT",
"LEVERAGE": "10",
})
config = Config()
c = BinanceFuturesClient.__new__(BinanceFuturesClient)
c.config = config
c.client = MagicMock()
return c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_leverage(config): async def test_set_leverage(config):
with patch("src.exchange.Client") as MockClient: with patch("src.exchange.Client") as MockClient:
@@ -28,11 +51,65 @@ async def test_set_leverage(config):
assert result is not None assert result is not None
def test_calculate_quantity(config): def test_calculate_quantity_basic(client):
with patch("src.exchange.Client") as MockClient: """잔고 22, 비율 50%, 레버리지 10배 → 명목금액 110, XRP 가격 2.5 → 수량 44.0"""
MockClient.return_value = MagicMock() qty = client.calculate_quantity(balance=22.0, price=2.5, leverage=10, margin_ratio=0.50)
client = BinanceFuturesClient(config) # 명목금액 = 22 * 0.5 * 10 = 110, 수량 = 110 / 2.5 = 44.0
# 잔고 1000 USDT, 리스크 2%, 레버리지 10, 가격 0.5 assert qty == pytest.approx(44.0, abs=0.1)
qty = client.calculate_quantity(balance=1000.0, price=0.5, leverage=10)
# 1000 * 0.02 * 10 / 0.5 = 400
assert qty == pytest.approx(400.0, rel=0.01) def test_calculate_quantity_min_notional(client):
"""명목금액이 최소(5 USDT) 미만이면 최소값으로 올림"""
qty = client.calculate_quantity(balance=1.0, price=2.5, leverage=1, margin_ratio=0.01)
# 명목금액 = 1 * 0.01 * 1 = 0.01 < 5 → 최소 5 USDT
assert qty * 2.5 >= 5.0
def test_calculate_quantity_zero_balance(client):
"""잔고 0이면 최소 명목금액 기반 수량 반환"""
qty = client.calculate_quantity(balance=0.0, price=2.5, leverage=10, margin_ratio=0.50)
assert qty > 0
@pytest.mark.asyncio
async def test_get_open_interest(exchange):
"""get_open_interest()가 float을 반환하는지 확인."""
exchange.client.futures_open_interest = MagicMock(
return_value={"openInterest": "123456.789"}
)
result = await exchange.get_open_interest()
assert isinstance(result, float)
assert result == pytest.approx(123456.789)
@pytest.mark.asyncio
async def test_get_funding_rate(exchange):
"""get_funding_rate()가 float을 반환하는지 확인."""
exchange.client.futures_mark_price = MagicMock(
return_value={"lastFundingRate": "0.0001"}
)
result = await exchange.get_funding_rate()
assert isinstance(result, float)
assert result == pytest.approx(0.0001)
@pytest.mark.asyncio
async def test_get_open_interest_error_returns_none(exchange):
"""API 오류 시 None 반환 확인."""
from binance.exceptions import BinanceAPIException
exchange.client.futures_open_interest = MagicMock(
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
)
result = await exchange.get_open_interest()
assert result is None
@pytest.mark.asyncio
async def test_get_funding_rate_error_returns_none(exchange):
"""API 오류 시 None 반환 확인."""
from binance.exceptions import BinanceAPIException
exchange.client.futures_mark_price = MagicMock(
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
)
result = await exchange.get_funding_rate()
assert result is None

131
tests/test_fetch_history.py Normal file
View File

@@ -0,0 +1,131 @@
"""fetch_history.py의 upsert_parquet() 함수 테스트."""
import pandas as pd
import numpy as np
import pytest
from pathlib import Path
def _make_parquet(tmp_path: Path, rows: dict) -> Path:
"""테스트용 parquet 파일 생성 헬퍼."""
df = pd.DataFrame(rows)
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
df = df.set_index("timestamp")
path = tmp_path / "test.parquet"
df.to_parquet(path)
return path
def test_upsert_fills_zero_oi_with_real_value(tmp_path):
"""기존 행의 oi_change=0.0이 신규 데이터의 실제 값으로 덮어써진다."""
from scripts.fetch_history import upsert_parquet
existing_path = _make_parquet(tmp_path, {
"timestamp": ["2026-01-01 00:00", "2026-01-01 00:15"],
"close": [1.0, 1.1],
"oi_change": [0.0, 0.0],
"funding_rate": [0.0, 0.0],
})
new_df = pd.DataFrame({
"close": [1.0, 1.1],
"oi_change": [0.05, 0.03],
"funding_rate": [0.0001, 0.0001],
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
new_df.index.name = "timestamp"
result = upsert_parquet(existing_path, new_df)
assert result.loc["2026-01-01 00:00+00:00", "oi_change"] == pytest.approx(0.05)
assert result.loc["2026-01-01 00:15+00:00", "oi_change"] == pytest.approx(0.03)
def test_upsert_appends_new_rows(tmp_path):
"""신규 타임스탬프 행이 기존 데이터 아래에 추가된다."""
from scripts.fetch_history import upsert_parquet
existing_path = _make_parquet(tmp_path, {
"timestamp": ["2026-01-01 00:00"],
"close": [1.0],
"oi_change": [0.05],
"funding_rate": [0.0001],
})
new_df = pd.DataFrame({
"close": [1.1],
"oi_change": [0.03],
"funding_rate": [0.0002],
}, index=pd.to_datetime(["2026-01-01 00:15"], utc=True))
new_df.index.name = "timestamp"
result = upsert_parquet(existing_path, new_df)
assert len(result) == 2
assert pd.Timestamp("2026-01-01 00:15", tz="UTC") in result.index
def test_upsert_keeps_nonzero_existing_oi(tmp_path):
"""기존 행의 oi_change가 이미 0이 아니면 덮어쓰지 않는다."""
from scripts.fetch_history import upsert_parquet
existing_path = _make_parquet(tmp_path, {
"timestamp": ["2026-01-01 00:00"],
"close": [1.0],
"oi_change": [0.07], # 이미 실제 값 존재
"funding_rate": [0.0003],
})
new_df = pd.DataFrame({
"close": [1.0],
"oi_change": [0.05], # 다른 값으로 덮어쓰려 해도
"funding_rate": [0.0001],
}, index=pd.to_datetime(["2026-01-01 00:00"], utc=True))
new_df.index.name = "timestamp"
result = upsert_parquet(existing_path, new_df)
# 기존 값(0.07)이 유지되어야 한다
assert result.iloc[0]["oi_change"] == pytest.approx(0.07)
def test_upsert_no_existing_file_returns_new_df(tmp_path):
"""기존 parquet 파일이 없으면 신규 데이터를 그대로 반환한다."""
from scripts.fetch_history import upsert_parquet
nonexistent_path = tmp_path / "nonexistent.parquet"
new_df = pd.DataFrame({
"close": [1.0, 1.1],
"oi_change": [0.05, 0.03],
"funding_rate": [0.0001, 0.0001],
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
new_df.index.name = "timestamp"
result = upsert_parquet(nonexistent_path, new_df)
assert len(result) == 2
assert result.iloc[0]["oi_change"] == pytest.approx(0.05)
def test_upsert_result_is_sorted_by_timestamp(tmp_path):
"""결과 DataFrame이 timestamp 기준 오름차순 정렬되어 있다."""
from scripts.fetch_history import upsert_parquet
existing_path = _make_parquet(tmp_path, {
"timestamp": ["2026-01-01 00:15"],
"close": [1.1],
"oi_change": [0.0],
"funding_rate": [0.0],
})
new_df = pd.DataFrame({
"close": [1.0, 1.1, 1.2],
"oi_change": [0.05, 0.03, 0.02],
"funding_rate": [0.0001, 0.0001, 0.0002],
}, index=pd.to_datetime(
["2026-01-01 00:00", "2026-01-01 00:15", "2026-01-01 00:30"], utc=True
))
new_df.index.name = "timestamp"
result = upsert_parquet(existing_path, new_df)
assert result.index.is_monotonic_increasing
assert len(result) == 3

View File

@@ -45,6 +45,42 @@ def test_bollinger_bands(sample_df):
assert (valid["bb_upper"] >= valid["bb_lower"]).all() assert (valid["bb_upper"] >= valid["bb_lower"]).all()
def test_adx_column_exists(sample_df):
"""calculate_all()이 adx 컬럼을 생성하는지 확인."""
ind = Indicators(sample_df)
df = ind.calculate_all()
assert "adx" in df.columns
valid = df["adx"].dropna()
assert (valid >= 0).all()
def test_adx_filter_blocks_low_adx(sample_df):
"""ADX < 25일 때 가중치와 무관하게 HOLD를 반환해야 한다."""
ind = Indicators(sample_df)
df = ind.calculate_all()
# 강한 LONG 신호가 나오도록 지표 조작
df.loc[df.index[-1], "rsi"] = 20 # RSI 과매도 → +1
df.loc[df.index[-2], "macd"] = -1 # MACD 골든크로스 → +2
df.loc[df.index[-2], "macd_signal"] = 0
df.loc[df.index[-1], "macd"] = 1
df.loc[df.index[-1], "macd_signal"] = 0
df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 2 # 거래량 서지
# ADX를 강제로 낮은 값으로 설정
df["adx"] = 15.0
signal = ind.get_signal(df)
assert signal == "HOLD"
def test_adx_nan_falls_through(sample_df):
"""ADX가 NaN(초기 캔들)이면 기존 가중치 로직으로 폴백해야 한다."""
ind = Indicators(sample_df)
df = ind.calculate_all()
df["adx"] = float("nan")
signal = ind.get_signal(df)
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
assert signal in ("LONG", "SHORT", "HOLD")
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()

View File

@@ -0,0 +1,73 @@
import pandas as pd
import numpy as np
import pytest
from src.label_builder import build_labels
def make_signal_df():
"""
신호 발생 시점 이후 가격이 TP에 도달하는 시나리오
entry=100, TP=103, SL=98.5
"""
future_closes = [100.5, 101.0, 101.8, 102.5, 103.1, 103.5]
future_highs = [c + 0.3 for c in future_closes]
future_lows = [c - 0.3 for c in future_closes]
return future_closes, future_highs, future_lows
def test_label_tp_reached():
closes, highs, lows = make_signal_df()
label = build_labels(
future_closes=closes,
future_highs=highs,
future_lows=lows,
take_profit=103.0,
stop_loss=98.5,
side="LONG",
)
assert label == 1, "TP 먼저 도달해야 레이블 1"
def test_label_sl_reached():
future_closes = [99.5, 99.0, 98.8, 98.4, 98.0]
future_highs = [c + 0.3 for c in future_closes]
future_lows = [c - 0.3 for c in future_closes]
label = build_labels(
future_closes=future_closes,
future_highs=future_highs,
future_lows=future_lows,
take_profit=103.0,
stop_loss=98.5,
side="LONG",
)
assert label == 0, "SL 먼저 도달해야 레이블 0"
def test_label_neither_reached_returns_none():
future_closes = [100.1, 100.2, 100.3]
future_highs = [c + 0.1 for c in future_closes]
future_lows = [c - 0.1 for c in future_closes]
label = build_labels(
future_closes=future_closes,
future_highs=future_highs,
future_lows=future_lows,
take_profit=103.0,
stop_loss=98.5,
side="LONG",
)
assert label is None, "미결 시 None 반환"
def test_label_short_tp():
future_closes = [99.5, 99.0, 98.0, 97.0]
future_highs = [c + 0.3 for c in future_closes]
future_lows = [c - 0.3 for c in future_closes]
label = build_labels(
future_closes=future_closes,
future_highs=future_highs,
future_lows=future_lows,
take_profit=97.0,
stop_loss=101.5,
side="SHORT",
)
assert label == 1

140
tests/test_ml_features.py Normal file
View File

@@ -0,0 +1,140 @@
import pandas as pd
import numpy as np
import pytest
from src.ml_features import build_features, FEATURE_COLS
def _make_df(n=10, base_price=1.0):
"""테스트용 더미 캔들 DataFrame 생성."""
closes = [base_price * (1 + i * 0.001) for i in range(n)]
return pd.DataFrame({
"close": closes, "high": [c * 1.01 for c in closes],
"low": [c * 0.99 for c in closes],
"volume": [1000.0] * n,
"rsi": [50.0] * n, "macd": [0.0] * n, "macd_signal": [0.0] * n,
"macd_hist": [0.0] * n, "bb_upper": [c * 1.02 for c in closes],
"bb_lower": [c * 0.98 for c in closes], "ema9": closes,
"ema21": closes, "ema50": closes, "atr": [0.01] * n,
"stoch_k": [50.0] * n, "stoch_d": [50.0] * n,
"vol_ma20": [1000.0] * n,
})
def test_build_features_with_btc_eth_has_21_features():
xrp_df = _make_df(10, base_price=1.0)
btc_df = _make_df(10, base_price=50000.0)
eth_df = _make_df(10, base_price=3000.0)
features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df)
assert len(features) == 23
def test_build_features_without_btc_eth_has_13_features():
xrp_df = _make_df(10, base_price=1.0)
features = build_features(xrp_df, "LONG")
assert len(features) == 15
def test_build_features_btc_ret_1_correct():
xrp_df = _make_df(10, base_price=1.0)
btc_df = _make_df(10, base_price=50000.0)
eth_df = _make_df(10, base_price=3000.0)
features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df)
btc_closes = btc_df["close"]
expected_btc_ret_1 = (btc_closes.iloc[-1] - btc_closes.iloc[-2]) / btc_closes.iloc[-2]
assert abs(features["btc_ret_1"] - expected_btc_ret_1) < 1e-6
def test_build_features_rs_zero_when_btc_ret_zero():
xrp_df = _make_df(10, base_price=1.0)
btc_df = _make_df(10, base_price=50000.0)
btc_df["close"] = 50000.0 # 모든 캔들 동일
eth_df = _make_df(10, base_price=3000.0)
features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df)
assert features["xrp_btc_rs"] == 0.0
def test_feature_cols_has_23_items():
from src.ml_features import FEATURE_COLS
assert len(FEATURE_COLS) == 23
def make_df(n=100):
"""테스트용 최소 DataFrame 생성"""
np.random.seed(42)
close = 100 + np.cumsum(np.random.randn(n) * 0.5)
df = pd.DataFrame({
"open": close * 0.999,
"high": close * 1.002,
"low": close * 0.998,
"close": close,
"volume": np.random.uniform(1000, 5000, n),
})
return df
def test_build_features_returns_series():
from src.indicators import Indicators
df = make_df(100)
ind = Indicators(df)
df_ind = ind.calculate_all()
features = build_features(df_ind, signal="LONG")
assert isinstance(features, pd.Series)
BASE_FEATURE_COLS = [
"rsi", "macd_hist", "bb_pct", "ema_align",
"stoch_k", "stoch_d", "atr_pct", "vol_ratio",
"ret_1", "ret_3", "ret_5", "signal_strength", "side",
]
def test_build_features_has_all_cols():
from src.indicators import Indicators
df = make_df(100)
ind = Indicators(df)
df_ind = ind.calculate_all()
features = build_features(df_ind, signal="LONG")
for col in BASE_FEATURE_COLS:
assert col in features.index, f"피처 누락: {col}"
def test_build_features_no_nan():
from src.indicators import Indicators
df = make_df(100)
ind = Indicators(df)
df_ind = ind.calculate_all()
features = build_features(df_ind, signal="LONG")
assert not features.isna().any(), f"NaN 존재: {features[features.isna()]}"
def test_side_encoding():
from src.indicators import Indicators
df = make_df(100)
ind = Indicators(df)
df_ind = ind.calculate_all()
long_feat = build_features(df_ind, signal="LONG")
short_feat = build_features(df_ind, signal="SHORT")
assert long_feat["side"] == 1
assert short_feat["side"] == 0
@pytest.fixture
def sample_df_with_indicators():
from src.indicators import Indicators
df = make_df(100)
ind = Indicators(df)
return ind.calculate_all()
def test_build_features_uses_provided_oi_funding(sample_df_with_indicators):
"""oi_change, funding_rate 파라미터가 제공되면 실제 값이 피처에 반영된다."""
feat = build_features(
sample_df_with_indicators,
signal="LONG",
oi_change=0.05,
funding_rate=0.0002,
)
assert feat["oi_change"] == pytest.approx(0.05)
assert feat["funding_rate"] == pytest.approx(0.0002)
def test_build_features_defaults_to_zero_when_not_provided(sample_df_with_indicators):
"""oi_change, funding_rate 파라미터 미제공 시 0.0으로 채워진다."""
feat = build_features(sample_df_with_indicators, signal="LONG")
assert feat["oi_change"] == pytest.approx(0.0)
assert feat["funding_rate"] == pytest.approx(0.0)

71
tests/test_ml_filter.py Normal file
View File

@@ -0,0 +1,71 @@
import pandas as pd
import numpy as np
import pytest
from unittest.mock import MagicMock, patch
from pathlib import Path
from src.ml_filter import MLFilter
from src.ml_features import FEATURE_COLS
def make_features(side="LONG") -> pd.Series:
return pd.Series({col: 0.5 for col in FEATURE_COLS} | {"side": 1.0 if side == "LONG" else 0.0})
def test_no_model_file_is_not_loaded(tmp_path):
f = MLFilter(
onnx_path=str(tmp_path / "nonexistent.onnx"),
lgbm_path=str(tmp_path / "nonexistent.pkl"),
)
assert not f.is_model_loaded()
def test_no_model_should_enter_returns_true(tmp_path):
"""모델 없으면 항상 진입 허용 (폴백)"""
f = MLFilter(
onnx_path=str(tmp_path / "nonexistent.onnx"),
lgbm_path=str(tmp_path / "nonexistent.pkl"),
)
features = make_features()
assert f.should_enter(features) is True
def test_should_enter_above_threshold():
"""확률 >= 0.60 이면 True"""
f = MLFilter(threshold=0.60)
mock_model = MagicMock()
mock_model.predict_proba.return_value = np.array([[0.35, 0.65]])
f._lgbm_model = mock_model
features = make_features()
assert f.should_enter(features) is True
def test_should_enter_below_threshold():
"""확률 < 0.60 이면 False"""
f = MLFilter(threshold=0.60)
mock_model = MagicMock()
mock_model.predict_proba.return_value = np.array([[0.55, 0.45]])
f._lgbm_model = mock_model
features = make_features()
assert f.should_enter(features) is False
def test_reload_model(tmp_path):
"""reload_model 호출 후 모델 로드 상태 변경"""
import joblib
# 모델 파일이 없는 상태에서 시작
f = MLFilter(
onnx_path=str(tmp_path / "nonexistent.onnx"),
lgbm_path=str(tmp_path / "lgbm_filter.pkl"),
)
assert not f.is_model_loaded()
# _lgbm_model을 직접 주입해서 is_model_loaded가 True인지 확인
mock_model = MagicMock()
f._lgbm_model = mock_model
assert f.is_model_loaded()
# reload_model은 항상 _lgbm_model/_onnx_session을 초기화 후 재로드한다.
# 파일이 없으면 None으로 리셋되어 폴백 상태가 된다.
f.reload_model()
assert not f.is_model_loaded() # 파일 없으므로 폴백 상태

102
tests/test_mlx_filter.py Normal file
View File

@@ -0,0 +1,102 @@
"""
MLXFilter 단위 테스트.
Apple Silicon GPU(Metal)가 없는 환경에서는 스킵한다.
"""
import numpy as np
import pandas as pd
import pytest
mlx = pytest.importorskip("mlx.core", reason="MLX 미설치")
def _make_X(n: int = 4) -> pd.DataFrame:
from src.ml_features import FEATURE_COLS
rng = np.random.default_rng(0)
return pd.DataFrame(
rng.uniform(-1.0, 1.0, (n, len(FEATURE_COLS))).astype(np.float32),
columns=FEATURE_COLS,
)
def test_mlx_gpu_device():
"""MLX가 GPU 디바이스를 기본으로 사용해야 한다."""
import mlx.core as mx
device = mx.default_device()
assert "gpu" in str(device)
def test_mlx_filter_predict_shape_untrained():
"""학습 전에도 predict_proba가 (N,) 형태를 반환해야 한다."""
from src.mlx_filter import MLXFilter
from src.ml_features import FEATURE_COLS
X = _make_X(4)
model = MLXFilter(input_dim=len(FEATURE_COLS), hidden_dim=32)
proba = model.predict_proba(X)
assert proba.shape == (4,)
assert np.all((proba >= 0.0) & (proba <= 1.0))
def test_mlx_filter_fit_and_predict():
"""학습 후 predict_proba가 유효한 확률값을 반환해야 한다."""
from src.mlx_filter import MLXFilter
from src.ml_features import FEATURE_COLS
n = 100
X = _make_X(n)
y = pd.Series(np.random.randint(0, 2, n))
model = MLXFilter(input_dim=len(FEATURE_COLS), hidden_dim=32, epochs=5, batch_size=32)
model.fit(X, y)
proba = model.predict_proba(X)
assert proba.shape == (n,)
assert np.all((proba >= 0.0) & (proba <= 1.0))
def test_fit_with_nan_features():
"""oi_change 피처에 nan이 포함된 경우 학습이 정상 완료되어야 한다."""
import numpy as np
import pandas as pd
from src.mlx_filter import MLXFilter
from src.ml_features import FEATURE_COLS
n = 300
np.random.seed(42)
X = pd.DataFrame(
np.random.randn(n, len(FEATURE_COLS)).astype(np.float32),
columns=FEATURE_COLS,
)
# oi_change 앞 절반을 nan으로
X["oi_change"] = np.where(np.arange(n) < n // 2, np.nan, X["oi_change"])
y = pd.Series((np.random.rand(n) > 0.5).astype(np.float32))
model = MLXFilter(input_dim=len(FEATURE_COLS), hidden_dim=32, epochs=3)
model.fit(X, y) # nan 있어도 예외 없이 완료되어야 함
proba = model.predict_proba(X)
assert not np.any(np.isnan(proba)), "예측 확률에 nan이 없어야 함"
assert proba.min() >= 0.0 and proba.max() <= 1.0
def test_mlx_filter_save_load(tmp_path):
"""저장 후 로드한 모델이 동일한 예측값을 반환해야 한다."""
from src.mlx_filter import MLXFilter
from src.ml_features import FEATURE_COLS
n = 50
X = _make_X(n)
y = pd.Series(np.random.randint(0, 2, n))
model = MLXFilter(input_dim=len(FEATURE_COLS), hidden_dim=32, epochs=3, batch_size=32)
model.fit(X, y)
proba_before = model.predict_proba(X)
save_path = tmp_path / "mlx_filter.weights"
model.save(save_path)
loaded = MLXFilter.load(save_path)
proba_after = loaded.predict_proba(X)
np.testing.assert_allclose(proba_before, proba_after, atol=1e-5)

View File

@@ -11,7 +11,6 @@ def config():
"BINANCE_API_SECRET": "s", "BINANCE_API_SECRET": "s",
"SYMBOL": "XRPUSDT", "SYMBOL": "XRPUSDT",
"LEVERAGE": "10", "LEVERAGE": "10",
"RISK_PER_TRADE": "0.02",
}) })
return Config() return Config()
@@ -34,3 +33,51 @@ def test_position_size_capped(config):
rm = RiskManager(config, max_daily_loss_pct=0.05) rm = RiskManager(config, max_daily_loss_pct=0.05)
rm.open_positions = ["pos1", "pos2", "pos3"] rm.open_positions = ["pos1", "pos2", "pos3"]
assert rm.can_open_new_position() is False assert rm.can_open_new_position() is False
# --- 동적 증거금 비율 테스트 ---
@pytest.fixture
def dynamic_config():
c = Config()
c.margin_max_ratio = 0.50
c.margin_min_ratio = 0.20
c.margin_decay_rate = 0.0006
return c
@pytest.fixture
def risk(dynamic_config):
r = RiskManager(dynamic_config)
r.set_base_balance(22.0)
return r
def test_set_base_balance(risk):
assert risk.initial_balance == 22.0
def test_ratio_at_base_balance(risk):
"""기준 잔고에서 최대 비율(50%) 반환"""
ratio = risk.get_dynamic_margin_ratio(22.0)
assert ratio == pytest.approx(0.50, abs=1e-6)
def test_ratio_decreases_as_balance_grows(risk):
"""잔고가 늘수록 비율 감소"""
ratio_100 = risk.get_dynamic_margin_ratio(100.0)
ratio_300 = risk.get_dynamic_margin_ratio(300.0)
assert ratio_100 < 0.50
assert ratio_300 < ratio_100
def test_ratio_clamped_at_min(risk):
"""잔고가 매우 커도 최소 비율(20%) 이하로 내려가지 않음"""
ratio = risk.get_dynamic_margin_ratio(10000.0)
assert ratio == pytest.approx(0.20, abs=1e-6)
def test_ratio_clamped_at_max(risk):
"""잔고가 기준보다 작아도 최대 비율(50%) 초과하지 않음"""
ratio = risk.get_dynamic_margin_ratio(5.0)
assert ratio == pytest.approx(0.50, abs=1e-6)