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:
21in7
2026-03-02 23:34:45 +09:00
parent 99fa508db7
commit 8e56301d52
3 changed files with 108 additions and 49 deletions

View File

@@ -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 전용) |

View File

@@ -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

View File

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