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}"