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>
This commit is contained in:
@@ -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<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\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<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\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<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
|
||||
@@ -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["주말 수동 트리거<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
|
||||
@@ -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 전용) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user