diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a90cfd..124948b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -547,7 +547,7 @@ sequenceDiagram ### 5.1 테스트 파일 구성 -`tests/` 폴더에 13개 테스트 파일, 총 **83개의 테스트 케이스**가 작성되어 있습니다. +`tests/` 폴더에 12개 테스트 파일, 총 **81개의 테스트 케이스**가 작성되어 있습니다. ```bash pytest tests/ -v # 전체 실행 @@ -570,10 +570,8 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행 | `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_database.py` | `src/database.py` | 2 | 거래 저장, 거래 청산 업데이트 (Notion API Mock) | -> `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다. -> `test_database.py`는 현재 미사용 모듈(`src/database.py`)을 대상으로 하며, 실제 운영 경로와 무관합니다. +> `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다. ### 5.3 커버리지 매트릭스 @@ -581,21 +579,21 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행 | 기능 | 단위 테스트 | 통합 수준 테스트 | 비고 | |------|:----------:|:--------------:|------| -| 기술 지표 계산 (RSI/MACD/BB/EMA/StochRSI) | ✅ | ✅ | `test_indicators` + `test_ml_features` | +| 기술 지표 계산 (RSI/MACD/BB/EMA/StochRSI/ADX) | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` | | 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` | -| ADX 횡보장 필터 (ADX < 25 차단) | ✅ | — | `test_indicators` (ADX 컬럼·차단·NaN 3개) | -| ML 피처 추출 (23개) | ✅ | — | `test_ml_features` | +| 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` | +| 레이블 생성 (SL/TP 룩어헤드) | ✅ | ✅ | `test_label_builder` + `test_dataset_builder` (전체 파이프라인 실제 호출) | | 벡터화 데이터셋 빌더 | ✅ | ✅ | `test_dataset_builder` | | 동적 증거금 비율 계산 | ✅ | — | `test_risk_manager` | | 일일 손실 한도 제어 | ✅ | — | `test_risk_manager` | | 포지션 수량 계산 | ✅ | — | `test_exchange` | -| OI/펀딩비 API 조회 (정상/오류) | ✅ | — | `test_exchange` | +| OI/펀딩비 API 조회 (정상/오류) | ✅ | ✅ | `test_exchange` + `test_bot` (`process_candle` → OI/펀딩비 → `build_features` 전달) | | 반대 시그널 재진입 흐름 | ✅ | ✅ | `test_bot` | -| ML 차단 시 재진입 스킵 | ✅ | — | `test_bot` | -| OI 변화율 계산 (API 실패 폴백) | ✅ | — | `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 의존) | diff --git a/docs/plans/2026-03-02-hold-negative-sampling-design.md b/docs/plans/2026-03-02-hold-negative-sampling-design.md new file mode 100644 index 0000000..febd7ff --- /dev/null +++ b/docs/plans/2026-03-02-hold-negative-sampling-design.md @@ -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개 테스트 케이스 추가 | diff --git a/docs/plans/2026-03-02-hold-negative-sampling-plan.md b/docs/plans/2026-03-02-hold-negative-sampling-plan.md new file mode 100644 index 0000000..fa6f45b --- /dev/null +++ b/docs/plans/2026-03-02-hold-negative-sampling-plan.md @@ -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" +``` diff --git a/models/training_log.json b/models/training_log.json index bb82546..96244dd 100644 --- a/models/training_log.json +++ b/models/training_log.json @@ -326,5 +326,30 @@ "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 } ] \ No newline at end of file diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index ca7e997..ea00917 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -21,6 +21,5 @@ fi cd "$PROJECT_ROOT" python -m pytest tests/ \ - --ignore=tests/test_database.py \ -v \ "$@" diff --git a/tests/test_bot.py b/tests/test_bot.py index f2334f5..a60769b 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -17,6 +17,7 @@ def config(): "RISK_PER_TRADE": "0.02", "NOTION_TOKEN": "secret_test", "NOTION_DATABASE_ID": "db_test", + "DISCORD_WEBHOOK_URL": "", }) return Config() diff --git a/tests/test_database.py b/tests/test_database.py deleted file mode 100644 index 1b29d90..0000000 --- a/tests/test_database.py +++ /dev/null @@ -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"