diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1ae12e1..3a90cfd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,29 +24,29 @@ CoinTrader는 **Binance Futures 자동매매 봇**입니다. 기술 지표 신 ```mermaid flowchart TD subgraph 외부["외부 데이터 소스 (Binance)"] - WS1["Combined WebSocket\nXRP/BTC/ETH 15분봉 캔들"] - WS2["User Data Stream WebSocket\nORDER_TRADE_UPDATE 이벤트"] - REST["REST API\nOI·펀딩비·잔고·포지션 조회"] + WS1["Combined WebSocket
XRP/BTC/ETH 15분봉 캔들"] + WS2["User Data Stream WebSocket
ORDER_TRADE_UPDATE 이벤트"] + REST["REST API
OI·펀딩비·잔고·포지션 조회"] end subgraph 실시간봇["실시간 봇 (bot.py — asyncio)"] - DS["data_stream.py\nMultiSymbolStream\n캔들 버퍼 (deque 200개)"] - IND["indicators.py\n기술 지표 계산\nRSI·MACD·BB·EMA·StochRSI·ATR"] - MF["ml_features.py\n23개 피처 추출\n(XRP 13 + BTC/ETH 8 + OI/FR 2)"] - ML["ml_filter.py\nMLFilter\nONNX 우선 / LightGBM 폴백\n확률 ≥ 0.60 시 진입 허용"] - RM["risk_manager.py\nRiskManager\n일일 손실 5% 한도\n동적 증거금 비율"] - EX["exchange.py\nBinanceFuturesClient\n주문·레버리지·잔고 API"] - UDS["user_data_stream.py\nUserDataStream\nTP/SL 즉시 감지"] - NT["notifier.py\nDiscordNotifier\n진입·청산·오류 알림"] + DS["data_stream.py
MultiSymbolStream
캔들 버퍼 (deque 200개)"] + IND["indicators.py
기술 지표 계산
RSI·MACD·BB·EMA·StochRSI·ATR·ADX"] + MF["ml_features.py
23개 피처 추출
(XRP 13 + BTC/ETH 8 + OI/FR 2)"] + ML["ml_filter.py
MLFilter
ONNX 우선 / LightGBM 폴백
확률 ≥ 0.60 시 진입 허용"] + RM["risk_manager.py
RiskManager
일일 손실 5% 한도
동적 증거금 비율"] + EX["exchange.py
BinanceFuturesClient
주문·레버리지·잔고 API"] + UDS["user_data_stream.py
UserDataStream
TP/SL 즉시 감지"] + NT["notifier.py
DiscordNotifier
진입·청산·오류 알림"] end subgraph mlops["MLOps 파이프라인 (맥미니 — 수동/크론)"] - FH["fetch_history.py\n과거 캔들 + OI/펀딩비\nParquet Upsert"] - DB["dataset_builder.py\n벡터화 데이터셋 생성\n레이블: ATR SL/TP 6시간 룩어헤드"] - TM["train_model.py\nLightGBM 학습\nWalk-Forward 5폴드 검증"] - TN["tune_hyperparams.py\nOptuna 50 trials\nTPE + MedianPruner"] - AP["active_lgbm_params.json\nActive Config 패턴\n승인된 파라미터 저장"] - DM["deploy_model.sh\nrsync → LXC 서버\n봇 핫리로드 트리거"] + FH["fetch_history.py
과거 캔들 + OI/펀딩비
Parquet Upsert"] + DB["dataset_builder.py
벡터화 데이터셋 생성
레이블: ATR SL/TP 6시간 룩어헤드"] + TM["train_model.py
LightGBM 학습
Walk-Forward 5폴드 검증"] + TN["tune_hyperparams.py
Optuna 50 trials
TPE + MedianPruner"] + AP["active_lgbm_params.json
Active Config 패턴
승인된 파라미터 저장"] + DM["deploy_model.sh
rsync → LXC 서버
봇 핫리로드 트리거"] end WS1 -->|캔들 마감 이벤트| DS @@ -152,12 +152,16 @@ Combined WebSocket | 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 @@ -313,13 +317,13 @@ RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400 ```mermaid flowchart LR - A["주말 수동 트리거\ntune_hyperparams.py\n(Optuna 50 trials, ~30분)"] - B["결과 검토\ntune_results_YYYYMMDD.json\nBest AUC vs Baseline 비교"] - C{"개선폭 충분?\n(AUC +0.01 이상\n폴드 분산 낮음)"} - D["active_lgbm_params.json\n업데이트\n(Active Config 패턴)"] - E["새벽 2시 크론탭\ntrain_and_deploy.sh\n(데이터 수집 → 학습 → 배포)"] - F["LXC 서버\nlgbm_filter.pkl 교체"] - G["봇 핫리로드\n다음 캔들 mtime 감지\n→ 자동 리로드"] + A["주말 수동 트리거
tune_hyperparams.py
(Optuna 50 trials, ~30분)"] + B["결과 검토
tune_results_YYYYMMDD.json
Best AUC vs Baseline 비교"] + C{"개선폭 충분?
(AUC +0.01 이상
폴드 분산 낮음)"} + D["active_lgbm_params.json
업데이트
(Active Config 패턴)"] + E["새벽 2시 크론탭
train_and_deploy.sh
(데이터 수집 → 학습 → 배포)"] + F["LXC 서버
lgbm_filter.pkl 교체"] + G["봇 핫리로드
다음 캔들 mtime 감지
→ 자동 리로드"] A --> B B --> C @@ -465,7 +469,7 @@ sequenceDiagram BOT->>RM: is_trading_allowed() [일일 손실 한도 확인] BOT->>IND: calculate_all(xrp_df) [지표 계산] - IND-->>BOT: df_with_indicators (RSI, MACD, BB, EMA, StochRSI, ATR) + IND-->>BOT: df_with_indicators (RSI, MACD, BB, EMA, StochRSI, ATR, ADX) BOT->>IND: get_signal(df) [신호 생성] IND-->>BOT: "LONG" | "SHORT" | "HOLD" @@ -543,7 +547,7 @@ sequenceDiagram ### 5.1 테스트 파일 구성 -`tests/` 폴더에 14개 테스트 파일, 총 **80개 이상의 테스트 케이스**가 작성되어 있습니다. +`tests/` 폴더에 13개 테스트 파일, 총 **83개의 테스트 케이스**가 작성되어 있습니다. ```bash pytest tests/ -v # 전체 실행 @@ -554,13 +558,13 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행 | 테스트 파일 | 대상 모듈 | 테스트 케이스 | 주요 검증 항목 | |------------|----------|:------------:|--------------| -| `test_bot.py` | `src/bot.py` | 10 | 반대 시그널 재진입 흐름, ML 차단 시 재진입 스킵, OI/펀딩비 피처 전달, OI 변화율 계산 | -| `test_indicators.py` | `src/indicators.py` | 4 | RSI 범위(0~100), MACD 컬럼 존재, 볼린저 밴드 상하단 대소관계, 신호 반환값 유효성 | +| `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` | 5 | 3심볼 버퍼 존재, 빈 버퍼 None 반환, 캔들 파싱, 마감 캔들 콜백 호출, 프리로드 200개 | +| `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 피처 처리, 저장/로드 후 동일 예측 | @@ -579,6 +583,7 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행 |------|:----------:|:--------------:|------| | 기술 지표 계산 (RSI/MACD/BB/EMA/StochRSI) | ✅ | ✅ | `test_indicators` + `test_ml_features` | | 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` | +| ADX 횡보장 필터 (ADX < 25 차단) | ✅ | — | `test_indicators` (ADX 컬럼·차단·NaN 3개) | | ML 피처 추출 (23개) | ✅ | — | `test_ml_features` | | ML 필터 추론 (임계값 판단) | ✅ | — | `test_ml_filter` | | MLX 신경망 학습/저장/로드 | ✅ | — | `test_mlx_filter` (Apple Silicon 전용) | diff --git a/src/dataset_builder.py b/src/dataset_builder.py index 9b63761..ecaa23b 100644 --- a/src/dataset_builder.py +++ b/src/dataset_builder.py @@ -362,16 +362,13 @@ def generate_dataset_vectorized( 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) @@ -381,41 +378,77 @@ def generate_dataset_vectorized( feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df) # 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만 - # oi_change/funding_rate는 선택적 피처(컬럼 없으면 전체 nan)이므로 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 ] - valid_rows = ( - (signal_arr != "HOLD") & + 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_idx = np.where(valid_rows)[0] + + # --- 시그널 캔들 (기존 로직) --- + 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_idx = sig_idx[valid_mask] - # btc_df/eth_df 제공 여부에 따라 실제 존재하는 피처 컬럼만 선택 + final_sig_idx = sig_idx[valid_mask] available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns] - feat_final = feat_all.iloc[final_idx][available_feature_cols].copy() - feat_final["label"] = labels + 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() # 평균 1로 정규화해 학습률 스케일 유지 + 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 = feat_final.reset_index(drop=True) 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 diff --git a/tests/test_dataset_builder.py b/tests/test_dataset_builder.py index 54dbb07..4156c89 100644 --- a/tests/test_dataset_builder.py +++ b/tests/test_dataset_builder.py @@ -70,7 +70,7 @@ def test_generate_dataset_vectorized_with_btc_eth_has_21_feature_cols(): 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 + assert "label" in result.columns def test_matches_original_generate_dataset(sample_df): @@ -208,3 +208,24 @@ def test_rs_zero_denominator(): "xrp_btc_rs에 inf가 있으면 안 됨" assert not feat["xrp_btc_rs"].isna().all(), \ "xrp_btc_rs가 전부 nan이면 안 됨" + + +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' 샘플이 하나도 버려지지 않아야 한다.""" + 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}"