Compare commits

...

6 Commits

Author SHA1 Message Date
21in7
fce4d536ea feat: implement HOLD negative sampling and stratified undersampling in ML pipeline
Added HOLD candles as negative samples to increase training data from ~535 to ~3,200 samples. Introduced a negative_ratio parameter in generate_dataset_vectorized() for sampling HOLD candles alongside signal candles. Implemented stratified undersampling to ensure signal samples are preserved during training. Updated relevant tests to validate new functionality and maintain compatibility with existing tests.

- Modified dataset_builder.py to include HOLD negative sampling logic
- Updated train_model.py to apply stratified undersampling
- Added tests for new sampling methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:13:42 +09:00
21in7
74966590b5 feat: apply stratified undersampling to hyperparameter tuning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:09:43 +09:00
21in7
6cd54b46d9 feat: apply stratified undersampling to training pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:03:09 +09:00
21in7
0af138d8ee feat: add stratified_undersample helper function
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:58:15 +09:00
21in7
b7ad358a0a fix: make HOLD negative sampling tests non-vacuous
The two HOLD negative tests (test_hold_negative_labels_are_all_zero,
test_signal_samples_preserved_after_sampling) were passing vacuously
because sample_df produces 0 signal candles (ADX ~18, below threshold
25). Added signal_producing_df fixture with higher volatility and volume
surges to reliably generate signals. Removed if-guards so assertions
are mandatory. Also restored the full docstring for
generate_dataset_vectorized() documenting btc_df/eth_df,
time_weight_decay, and negative_ratio parameters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:45:10 +09:00
21in7
8e56301d52 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>
2026-03-02 23:34:45 +09:00
11 changed files with 769 additions and 130 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/` 폴더에 12개 테스트 파일, 총 **81개의 테스트 케이스**가 작성되어 있습니다.
```bash
pytest tests/ -v # 전체 실행
@@ -554,22 +558,20 @@ 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 피처 처리, 저장/로드 후 동일 예측 |
| `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 커버리지 매트릭스
@@ -577,20 +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` |
| 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 의존) |

View File

@@ -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개 테스트 케이스 추가 |

View File

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

View File

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

View File

@@ -21,6 +21,5 @@ fi
cd "$PROJECT_ROOT"
python -m pytest tests/ \
--ignore=tests/test_database.py \
-v \
"$@"

View File

@@ -22,7 +22,7 @@ from sklearn.metrics import roc_auc_score, classification_report
from src.indicators import Indicators
from src.ml_features import build_features, FEATURE_COLS
from src.label_builder import build_labels
from src.dataset_builder import generate_dataset_vectorized
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
def _cgroup_cpu_count() -> int:
"""cgroup v1/v2 쿼터를 읽어 실제 할당된 CPU 수를 반환한다.
@@ -214,7 +214,11 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
df = df_raw[base_cols].copy()
print("데이터셋 생성 중...")
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay)
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
negative_ratio=5,
)
if dataset.empty or "label" not in dataset.columns:
raise ValueError(f"데이터셋 생성 실패: 샘플 0개. 위 오류 메시지를 확인하세요.")
@@ -229,6 +233,7 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
X = dataset[actual_feature_cols]
y = dataset["label"]
w = dataset["sample_weight"].values
source = dataset["source"].values if "source" in dataset.columns else np.full(len(X), "signal")
split = int(len(X) * 0.8)
X_train, X_val = X.iloc[:split], X.iloc[split:]
@@ -238,21 +243,19 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
w_train = (w[:split] * weight_scale).astype(np.float32)
# --- 클래스 불균형 처리: 언더샘플링 (시간 가중치 인덱스 보존) ---
pos_idx = np.where(y_train == 1)[0]
neg_idx = np.where(y_train == 0)[0]
if len(neg_idx) > len(pos_idx):
np.random.seed(42)
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
balanced_idx = np.sort(np.concatenate([pos_idx, neg_idx])) # 시간 순서 유지
# --- 계층적 샘플링: 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]
print(f"\n언더샘플링 후 학습 데이터: {len(X_train)}개 (양성={y_train.sum()}, 음성={(y_train==0).sum()})")
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())})")
# ---------------------------------------------------------------
@@ -354,13 +357,16 @@ def walk_forward_auc(
df = df_raw[base_cols].copy()
dataset = generate_dataset_vectorized(
df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay
df, btc_df=btc_df, eth_df=eth_df,
time_weight_decay=time_weight_decay,
negative_ratio=5,
)
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
X = dataset[actual_feature_cols].values
y = dataset["label"].values
w = dataset["sample_weight"].values
n = len(dataset)
source = dataset["source"].values if "source" in dataset.columns else np.full(n, "signal")
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
w = (w * weight_scale).astype(np.float32)
@@ -378,12 +384,8 @@ def walk_forward_auc(
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
pos_idx = np.where(y_tr == 1)[0]
neg_idx = np.where(y_tr == 0)[0]
if len(neg_idx) > len(pos_idx):
np.random.seed(42)
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
idx = np.sort(np.concatenate([pos_idx, neg_idx]))
source_tr = source[:tr_end]
idx = stratified_undersample(y_tr, source_tr, seed=42)
model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1)
with warnings.catch_warnings():

View File

@@ -31,14 +31,14 @@ from optuna.pruners import MedianPruner
from sklearn.metrics import roc_auc_score
from src.ml_features import FEATURE_COLS
from src.dataset_builder import generate_dataset_vectorized
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
# ──────────────────────────────────────────────
# 데이터 로드 및 데이터셋 생성 (1회 캐싱)
# ──────────────────────────────────────────────
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
study 시작 전 1회만 호출하여 모든 trial이 공유한다.
@@ -63,7 +63,7 @@ def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
df = df_raw[base_cols].copy()
print("\n데이터셋 생성 중 (1회만 실행)...")
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0)
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0, negative_ratio=5)
if dataset.empty or "label" not in dataset.columns:
raise ValueError("데이터셋 생성 실패: 샘플 0개")
@@ -72,13 +72,14 @@ def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
X = dataset[actual_feature_cols].values.astype(np.float32)
y = dataset["label"].values.astype(np.int8)
w = dataset["sample_weight"].values.astype(np.float32)
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
pos = int(y.sum())
neg = int((y == 0).sum())
print(f"데이터셋 완성: {len(dataset):,}개 샘플 (양성={pos}, 음성={neg})")
print(f"사용 피처: {len(actual_feature_cols)}\n")
return X, y, w
return X, y, w, source
# ──────────────────────────────────────────────
@@ -89,6 +90,7 @@ def _walk_forward_cv(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
source: np.ndarray,
params: dict,
n_splits: int,
train_ratio: float,
@@ -113,13 +115,9 @@ def _walk_forward_cv(
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
# 클래스 불균형 처리: 언더샘플링 (시간 순서 유지)
pos_idx = np.where(y_tr == 1)[0]
neg_idx = np.where(y_tr == 0)[0]
if len(neg_idx) > len(pos_idx) and len(pos_idx) > 0:
rng = np.random.default_rng(42)
neg_idx = rng.choice(neg_idx, size=len(pos_idx), replace=False)
bal_idx = np.sort(np.concatenate([pos_idx, neg_idx]))
# 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼
source_tr = source[:tr_end]
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
if len(bal_idx) < 20 or len(np.unique(y_val)) < 2:
fold_aucs.append(0.5)
@@ -152,6 +150,7 @@ def make_objective(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
source: np.ndarray,
n_splits: int,
train_ratio: float,
):
@@ -192,7 +191,7 @@ def make_objective(
}
mean_auc, fold_aucs = _walk_forward_cv(
X, y, w_scaled, params,
X, y, w_scaled, source, params,
n_splits=n_splits,
train_ratio=train_ratio,
trial=trial,
@@ -214,6 +213,7 @@ def measure_baseline(
X: np.ndarray,
y: np.ndarray,
w: np.ndarray,
source: np.ndarray,
n_splits: int,
train_ratio: float,
) -> tuple[float, list[float]]:
@@ -241,7 +241,7 @@ def measure_baseline(
}
print("베이스라인 측정 중 (active 파일 없음 → 코드 내 기본 파라미터)...")
return _walk_forward_cv(X, y, w, baseline_params, n_splits=n_splits, train_ratio=train_ratio)
return _walk_forward_cv(X, y, w, source, baseline_params, n_splits=n_splits, train_ratio=train_ratio)
# ──────────────────────────────────────────────
@@ -377,14 +377,14 @@ def main():
args = parser.parse_args()
# 1. 데이터셋 로드 (1회)
X, y, w = load_dataset(args.data)
X, y, w, source = load_dataset(args.data)
# 2. 베이스라인 측정
if args.no_baseline:
baseline_auc, baseline_folds = 0.0, []
print("베이스라인 측정 건너뜀 (--no-baseline)\n")
else:
baseline_auc, baseline_folds = measure_baseline(X, y, w, args.folds, args.train_ratio)
baseline_auc, baseline_folds = measure_baseline(X, y, w, source, args.folds, args.train_ratio)
print(
f"베이스라인 AUC: {baseline_auc:.4f} "
f"(폴드별: {[round(a, 4) for a in baseline_folds]})\n"
@@ -401,7 +401,7 @@ def main():
study_name="lgbm_wf_auc",
)
objective = make_objective(X, y, w, n_splits=args.folds, train_ratio=args.train_ratio)
objective = make_objective(X, y, w, source, n_splits=args.folds, train_ratio=args.train_ratio)
print(f"Optuna 탐색 시작: {args.trials} trials, {args.folds}폴드 Walk-Forward")
print("(trial 완료마다 진행 상황 출력)\n")

View File

@@ -362,6 +362,7 @@ 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회 계산해 학습 데이터셋을 생성한다.
@@ -372,6 +373,9 @@ def generate_dataset_vectorized(
양수일수록 최신 샘플에 더 높은 가중치를 부여한다.
예) 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 +385,107 @@ 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
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]))

View File

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

View File

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

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,61 @@ def test_rs_zero_denominator():
"xrp_btc_rs에 inf가 있으면 안 됨"
assert not feat["xrp_btc_rs"].isna().all(), \
"xrp_btc_rs가 전부 nan이면 안 됨"
@pytest.fixture
def signal_producing_df():
"""시그널이 반드시 발생하는 더미 데이터. 높은 변동성 + 거래량 급증."""
rng = np.random.default_rng(7)
n = 800
trend = np.linspace(1.5, 3.0, n)
noise = np.cumsum(rng.normal(0, 0.04, n))
close = np.clip(trend + noise, 0.01, None)
high = close * (1 + rng.uniform(0, 0.015, n))
low = close * (1 - rng.uniform(0, 0.015, n))
volume = rng.uniform(1e6, 3e6, n)
volume[::30] *= 3.0 # 30봉마다 거래량 급증
return pd.DataFrame({
"open": close, "high": high, "low": low,
"close": close, "volume": volume,
})
def test_hold_negative_labels_are_all_zero(signal_producing_df):
"""HOLD negative 샘플의 label은 전부 0이어야 한다."""
result = generate_dataset_vectorized(signal_producing_df, negative_ratio=3)
assert len(result) > 0, "시그널이 발생하지 않아 테스트 불가"
assert "source" in result.columns
hold_neg = result[result["source"] == "hold_negative"]
assert len(hold_neg) > 0, "HOLD negative 샘플이 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(signal_producing_df):
"""계층적 샘플링 후 source='signal' 샘플이 하나도 버려지지 않아야 한다."""
result_signal_only = generate_dataset_vectorized(signal_producing_df, negative_ratio=0)
result_with_hold = generate_dataset_vectorized(signal_producing_df, negative_ratio=3)
assert len(result_signal_only) > 0, "시그널이 발생하지 않아 테스트 불가"
assert "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}"
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}가 누락됨"