From 0b18a0b80dd66f1dfa6472e067512eda5a53332c Mon Sep 17 00:00:00 2001 From: 21in7 Date: Tue, 3 Mar 2026 21:11:04 +0900 Subject: [PATCH 1/4] feat: add ADX as 24th ML feature for trend strength learning Migrate ADX from hard filter (ADX < 25 blocks entry) to ML feature so the model can learn optimal ADX thresholds from data. Updates FEATURE_COLS, build_features(), and corresponding tests from 23 to 24 features. Co-Authored-By: Claude Opus 4.6 --- src/ml_features.py | 4 +++- tests/test_ml_features.py | 13 +++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/ml_features.py b/src/ml_features.py index 8a73960..f7a6224 100644 --- a/src/ml_features.py +++ b/src/ml_features.py @@ -11,6 +11,7 @@ FEATURE_COLS = [ # 시장 미시구조: OI 변화율(z-score), 펀딩비(z-score) # parquet에 oi_change/funding_rate 컬럼이 없으면 dataset_builder에서 0으로 채움 "oi_change", "funding_rate", + "adx", ] @@ -39,7 +40,7 @@ def build_features( ) -> pd.Series: """ 기술 지표가 계산된 DataFrame의 마지막 행에서 ML 피처를 추출한다. - btc_df, eth_df가 제공되면 23개 피처를, 없으면 15개 피처를 반환한다. + btc_df, eth_df가 제공되면 24개 피처를, 없으면 16개 피처를 반환한다. signal: "LONG" | "SHORT" oi_change, funding_rate: 실제 값이 제공되면 사용, 없으면 0.0으로 채운다. """ @@ -133,5 +134,6 @@ def build_features( # 실시간에서 실제 값이 제공되면 사용, 없으면 0으로 채운다 base["oi_change"] = float(oi_change) if oi_change is not None else 0.0 base["funding_rate"] = float(funding_rate) if funding_rate is not None else 0.0 + base["adx"] = float(last.get("adx", 0)) return pd.Series(base) diff --git a/tests/test_ml_features.py b/tests/test_ml_features.py index 7a05b3e..0803aeb 100644 --- a/tests/test_ml_features.py +++ b/tests/test_ml_features.py @@ -17,20 +17,21 @@ def _make_df(n=10, base_price=1.0): "ema21": closes, "ema50": closes, "atr": [0.01] * n, "stoch_k": [50.0] * n, "stoch_d": [50.0] * n, "vol_ma20": [1000.0] * n, + "adx": [20.0] * n, }) -def test_build_features_with_btc_eth_has_21_features(): +def test_build_features_with_btc_eth_has_24_features(): xrp_df = _make_df(10, base_price=1.0) btc_df = _make_df(10, base_price=50000.0) eth_df = _make_df(10, base_price=3000.0) features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df) - assert len(features) == 23 + assert len(features) == 24 -def test_build_features_without_btc_eth_has_13_features(): +def test_build_features_without_btc_eth_has_16_features(): xrp_df = _make_df(10, base_price=1.0) features = build_features(xrp_df, "LONG") - assert len(features) == 15 + assert len(features) == 16 def test_build_features_btc_ret_1_correct(): xrp_df = _make_df(10, base_price=1.0) @@ -49,9 +50,9 @@ def test_build_features_rs_zero_when_btc_ret_zero(): features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df) assert features["xrp_btc_rs"] == 0.0 -def test_feature_cols_has_23_items(): +def test_feature_cols_has_24_items(): from src.ml_features import FEATURE_COLS - assert len(FEATURE_COLS) == 23 + assert len(FEATURE_COLS) == 24 def make_df(n=100): From 0aeb15ecfb2034bc8bcfe64c6800d9ebbb6a305c Mon Sep 17 00:00:00 2001 From: 21in7 Date: Tue, 3 Mar 2026 21:14:50 +0900 Subject: [PATCH 2/4] feat: remove ADX hard filter, delegate to ML Co-Authored-By: Claude Opus 4.6 --- src/indicators.py | 7 +++---- tests/test_indicators.py | 14 +++++++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/indicators.py b/src/indicators.py index 4b34fc2..c3b5c70 100644 --- a/src/indicators.py +++ b/src/indicators.py @@ -60,11 +60,10 @@ class Indicators: last = df.iloc[-1] prev = df.iloc[-2] - # ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단 + # ADX 로깅 (ML 피처로 위임, 하드필터 제거) adx = last.get("adx", None) - if adx is not None and not pd.isna(adx) and adx < 25: - logger.debug(f"ADX 필터: {adx:.1f} < 25 — HOLD") - return "HOLD" + if adx is not None and not pd.isna(adx): + logger.debug(f"ADX: {adx:.1f}") long_signals = 0 short_signals = 0 diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 1135677..8dbad7e 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -54,21 +54,21 @@ def test_adx_column_exists(sample_df): assert (valid >= 0).all() -def test_adx_filter_blocks_low_adx(sample_df): - """ADX < 25일 때 가중치와 무관하게 HOLD를 반환해야 한다.""" +def test_adx_low_does_not_block_signal(sample_df): + """ADX < 25여도 시그널이 차단되지 않는다 (ML에 위임).""" ind = Indicators(sample_df) df = ind.calculate_all() # 강한 LONG 신호가 나오도록 지표 조작 - df.loc[df.index[-1], "rsi"] = 20 # RSI 과매도 → +1 - df.loc[df.index[-2], "macd"] = -1 # MACD 골든크로스 → +2 + df.loc[df.index[-1], "rsi"] = 20 + df.loc[df.index[-2], "macd"] = -1 df.loc[df.index[-2], "macd_signal"] = 0 df.loc[df.index[-1], "macd"] = 1 df.loc[df.index[-1], "macd_signal"] = 0 - df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 2 # 거래량 서지 - # ADX를 강제로 낮은 값으로 설정 + df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 2 df["adx"] = 15.0 signal = ind.get_signal(df) - assert signal == "HOLD" + # ADX 낮아도 지표 조건 충족 시 LONG 반환 (ML이 최종 판단) + assert signal == "LONG" def test_adx_nan_falls_through(sample_df): From 9c6f5dbd76987325a8ae7efb911172ca008f4d4a Mon Sep 17 00:00:00 2001 From: 21in7 Date: Tue, 3 Mar 2026 21:17:49 +0900 Subject: [PATCH 3/4] feat: remove ADX hard filter from dataset builder, add ADX as ML feature Co-Authored-By: Claude Opus 4.6 --- src/dataset_builder.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/dataset_builder.py b/src/dataset_builder.py index 37dc4cb..39c1ee0 100644 --- a/src/dataset_builder.py +++ b/src/dataset_builder.py @@ -116,12 +116,6 @@ def _calc_signals(d: pd.DataFrame) -> np.ndarray: # 둘 다 해당하면 HOLD (충돌 방지) signal_arr[long_enter & short_enter] = "HOLD" - # ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단 - if "adx" in d.columns: - adx = d["adx"].values - low_adx = (~np.isnan(adx)) & (adx < 25) - signal_arr[low_adx] = "HOLD" - return signal_arr @@ -212,6 +206,10 @@ def _calc_features_vectorized( side = np.where(signal_arr == "LONG", 1.0, 0.0).astype(np.float32) + # ADX (ML 피처로 제공 — rolling z-score 정규화) + adx_raw = d["adx"].values.astype(np.float64) if "adx" in d.columns else np.zeros(len(d), dtype=np.float64) + adx_z = _rolling_zscore(adx_raw) + result = pd.DataFrame({ "rsi": rsi.values.astype(np.float32), "macd_hist": macd_hist.values.astype(np.float32), @@ -226,6 +224,7 @@ def _calc_features_vectorized( "ret_5": ret_5_z, "signal_strength": strength, "side": side, + "adx": adx_z, "_signal": signal_arr, # 레이블 계산용 임시 컬럼 }, index=d.index) From c39097bf70441816b010e97327662f86c009c7f1 Mon Sep 17 00:00:00 2001 From: 21in7 Date: Tue, 3 Mar 2026 21:18:22 +0900 Subject: [PATCH 4/4] docs: add ADX ML migration design/plan and position monitor logging docs Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 +- ...6-03-03-adx-ml-feature-migration-design.md | 49 ++++ ...026-03-03-adx-ml-feature-migration-plan.md | 227 ++++++++++++++++++ .../2026-03-03-position-monitor-logging.md | 35 +++ 4 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-03-03-adx-ml-feature-migration-design.md create mode 100644 docs/plans/2026-03-03-adx-ml-feature-migration-plan.md create mode 100644 docs/plans/2026-03-03-position-monitor-logging.md diff --git a/CLAUDE.md b/CLAUDE.md index ff8ce02..3b995da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ bash scripts/deploy_model.sh **5-layer data flow on each 15m candle close:** 1. `src/data_stream.py` — Combined WebSocket for XRP/BTC/ETH, deque buffers (200 candles each) 2. `src/indicators.py` — RSI, MACD, BB, EMA, StochRSI, ATR; weighted signal aggregation → LONG/SHORT/HOLD -3. `src/ml_filter.py` + `src/ml_features.py` — 23-feature extraction, ONNX priority > LightGBM fallback, threshold ≥ 0.60 +3. `src/ml_filter.py` + `src/ml_features.py` — 24-feature extraction (ADX 포함), ONNX priority > LightGBM fallback, threshold ≥ 0.55 4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%) 5. `src/user_data_stream.py` + `src/notifier.py` — Real-time TP/SL detection via WebSocket, Discord webhooks @@ -113,4 +113,6 @@ All design documents and implementation plans are stored in `docs/plans/` with t | 2026-03-02 | `user-data-stream-tp-sl-detection` (design + plan) | Completed | | 2026-03-02 | `adx-filter-design` | Completed | | 2026-03-02 | `hold-negative-sampling` (design + plan) | Completed | +| 2026-03-03 | `position-monitor-logging` | Completed | +| 2026-03-03 | `adx-ml-feature-migration` (design + plan) | Completed | | 2026-03-03 | `optuna-precision-objective-plan` | Pending | diff --git a/docs/plans/2026-03-03-adx-ml-feature-migration-design.md b/docs/plans/2026-03-03-adx-ml-feature-migration-design.md new file mode 100644 index 0000000..cecf7f8 --- /dev/null +++ b/docs/plans/2026-03-03-adx-ml-feature-migration-design.md @@ -0,0 +1,49 @@ +# ADX ML 피처 마이그레이션 설계 + +**Goal:** ADX 하드필터(< 25)를 제거하고, ADX를 ML 피처로 추가하여 횡보장 판단을 ML 모델에 위임한다. + +**Background:** 운영 로그 분석 결과, ADX < 25 하드필터가 하루 종일 시그널을 차단하여 ML 필터가 평가할 기회 자체가 없었음. ADX 10~24 구간에서도 수익 가능한 패턴이 존재할 수 있으나, 현재 구조에서는 ML이 이를 학습할 수 없음. + +**Tech Stack:** LightGBM, pandas-ta (기존 사용 중) + +--- + +## 변경 사항 + +### 1. ML 피처에 ADX 추가 (23 → 24 피처) + +- `src/ml_features.py`: `FEATURE_COLS`에 `"adx"` 추가 +- `build_features()`: ADX 값 추출 로직 추가 + +### 2. 데이터셋 빌더에서 ADX 하드필터 제거 + +- `src/dataset_builder.py`: `_calc_signals()`에서 ADX < 25 → HOLD 강제 로직 제거 +- ADX 낮은 구간의 시그널도 학습 데이터에 포함됨 + +### 3. indicators.py ADX 하드필터 제거 + +- `src/indicators.py`: `get_signal()`에서 ADX < 25 early-return 제거 +- ADX 값은 항상 로그에 남김 (대시보드 표시용) + +### 4. ADX 로깅 개선 + +- ADX ≥ 25일 때도 로그 출력 → 대시보드에서 ADX 차트 끊김 해소 + +### 5. 테스트 업데이트 + +- ADX 하드필터 관련 기존 테스트 수정/제거 +- ML 피처에 ADX 포함 확인 테스트 추가 + +## 데이터 흐름 (변경 후) + +``` +캔들 → get_signal() → 지표 가중치 기반 LONG/SHORT/HOLD (ADX 필터 없음) + → ADX 값 항상 로그 출력 + → signal != HOLD → build_features() [24 피처, ADX 포함] + → ML 필터 (threshold ≥ 0.55) → 진입 판단 +``` + +## 주의 사항 + +- 기존 학습된 모델(23 피처)은 24 피처 입력과 호환 안 됨 → **재학습 필수** +- 재학습 전까지 봇 운영 불가 → 배포 시 `train_and_deploy.sh` 먼저 실행 diff --git a/docs/plans/2026-03-03-adx-ml-feature-migration-plan.md b/docs/plans/2026-03-03-adx-ml-feature-migration-plan.md new file mode 100644 index 0000000..86af52f --- /dev/null +++ b/docs/plans/2026-03-03-adx-ml-feature-migration-plan.md @@ -0,0 +1,227 @@ +# ADX ML 피처 마이그레이션 구현 계획 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** ADX 하드필터를 제거하고 ADX를 24번째 ML 피처로 추가하여, 횡보장 판단을 ML 모델에 위임한다. ADX 값을 항상 로그에 남겨 대시보드 끊김도 해소한다. + +**Architecture:** `indicators.py`에서 ADX < 25 early-return 삭제, `ml_features.py`에 ADX 피처 추가 (23 → 24개), `dataset_builder.py`에서 ADX 하드필터 삭제 + ADX 피처 추출 추가. 기존 모델과 호환 안 되므로 재학습 필수. + +**Tech Stack:** LightGBM, pandas-ta, pytest + +--- + +### Task 1: ML 피처 테스트 업데이트 (24개 피처) + +**Files:** +- Modify: `tests/test_ml_features.py:52-54` + +**Step 1: Update the test** + +`test_feature_cols_has_23_items`를 24개로 변경: + +```python +def test_feature_cols_has_24_items(): + from src.ml_features import FEATURE_COLS + assert len(FEATURE_COLS) == 24 +``` + +`test_build_features_with_btc_eth_has_21_features`의 assert도 변경: + +```python +def test_build_features_with_btc_eth_has_24_features(): + xrp_df = _make_df(10, base_price=1.0) + btc_df = _make_df(10, base_price=50000.0) + eth_df = _make_df(10, base_price=3000.0) + features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df) + assert len(features) == 24 +``` + +`test_build_features_without_btc_eth_has_13_features`도 변경: + +```python +def test_build_features_without_btc_eth_has_16_features(): + xrp_df = _make_df(10, base_price=1.0) + features = build_features(xrp_df, "LONG") + assert len(features) == 16 +``` + +`_make_df`에 `"adx": [20.0] * n` 컬럼 추가. + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_ml_features.py::test_feature_cols_has_24_items -v` +Expected: FAIL — 현재 23개 + +--- + +### Task 2: FEATURE_COLS에 ADX 추가 + build_features() 수정 + +**Files:** +- Modify: `src/ml_features.py:4-14` (FEATURE_COLS), `src/ml_features.py:98-112` (base dict) + +**Step 3: Add ADX to FEATURE_COLS** + +```python +FEATURE_COLS = [ + "rsi", "macd_hist", "bb_pct", "ema_align", + "stoch_k", "stoch_d", "atr_pct", "vol_ratio", + "ret_1", "ret_3", "ret_5", "signal_strength", "side", + "btc_ret_1", "btc_ret_3", "btc_ret_5", + "eth_ret_1", "eth_ret_3", "eth_ret_5", + "xrp_btc_rs", "xrp_eth_rs", + "oi_change", "funding_rate", + "adx", +] +``` + +**Step 4: Add ADX extraction in build_features()** + +`base` dict 생성 부분 (line 112 이후)에 추가: + +```python + base["adx"] = float(last.get("adx", 0)) +``` + +docstring의 "23개 피처"를 "24개 피처"로 변경. + +**Step 5: Run tests to verify they pass** + +Run: `pytest tests/test_ml_features.py -v` +Expected: ALL PASS + +**Step 6: Commit** + +```bash +git add src/ml_features.py tests/test_ml_features.py +git commit -m "feat: add ADX as 24th ML feature" +``` + +--- + +### Task 3: indicators.py ADX 하드필터 제거 + 항상 로깅 + +**Files:** +- Modify: `src/indicators.py:63-67` + +**Step 7: Replace ADX hard filter with always-log** + +`get_signal()` 메서드에서 기존 ADX 필터 코드: + +```python + # ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단 + adx = last.get("adx", None) + if adx is not None and not pd.isna(adx) and adx < 25: + logger.debug(f"ADX 필터: {adx:.1f} < 25 — HOLD") + return "HOLD" +``` + +를 다음으로 교체: + +```python + # ADX 로깅 (ML 피처로 위임, 하드필터 제거) + adx = last.get("adx", None) + if adx is not None and not pd.isna(adx): + logger.debug(f"ADX: {adx:.1f}") +``` + +**Step 8: Run ADX-related tests** + +Run: `pytest tests/test_indicators.py -k "adx" -v` +Expected: `test_adx_column_exists` PASS, `test_adx_nan_falls_through` PASS, `test_adx_filter_blocks_low_adx` FAIL (필터 제거됨) + +--- + +### Task 4: ADX 필터 테스트 업데이트 + +**Files:** +- Modify: `tests/test_indicators.py:57-71` + +**Step 9: Replace block test with pass-through test** + +`test_adx_filter_blocks_low_adx`를 제거하고 새 테스트로 교체: + +```python +def test_adx_low_does_not_block_signal(sample_df): + """ADX < 25여도 시그널이 차단되지 않는다 (ML에 위임).""" + ind = Indicators(sample_df) + df = ind.calculate_all() + # 강한 LONG 신호가 나오도록 지표 조작 + df.loc[df.index[-1], "rsi"] = 20 + df.loc[df.index[-2], "macd"] = -1 + df.loc[df.index[-2], "macd_signal"] = 0 + df.loc[df.index[-1], "macd"] = 1 + df.loc[df.index[-1], "macd_signal"] = 0 + df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 2 + df["adx"] = 15.0 + signal = ind.get_signal(df) + # ADX 낮아도 지표 조건 충족 시 LONG 반환 (ML이 최종 판단) + assert signal == "LONG" +``` + +**Step 10: Run all indicator tests** + +Run: `pytest tests/test_indicators.py -v` +Expected: ALL PASS + +**Step 11: Commit** + +```bash +git add src/indicators.py tests/test_indicators.py +git commit -m "feat: remove ADX hard filter, delegate to ML" +``` + +--- + +### Task 5: dataset_builder.py ADX 하드필터 제거 + ADX 피처 추가 + +**Files:** +- Modify: `src/dataset_builder.py:119-123` (ADX 필터 삭제), `src/dataset_builder.py:215-230` (ADX 피처 추가) + +**Step 12: Remove ADX hard filter in _calc_signals()** + +`_calc_signals()` 함수에서 다음 코드 삭제 (lines 119-123): + +```python + # ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단 + if "adx" in d.columns: + adx = d["adx"].values + low_adx = (~np.isnan(adx)) & (adx < 25) + signal_arr[low_adx] = "HOLD" +``` + +**Step 13: Add ADX feature to _calc_features_vectorized()** + +`_calc_features_vectorized()` 함수의 `result` DataFrame 생성 부분에 `"adx"` 추가: + +```python + # ADX (ML 피처로 제공 — rolling z-score 정규화) + adx_raw = d["adx"].values.astype(np.float64) if "adx" in d.columns else np.zeros(len(d), dtype=np.float64) + adx_z = _rolling_zscore(adx_raw) +``` + +`result` DataFrame에 `"adx": adx_z,` 추가 (side 다음에). + +**Step 14: Run full test suite** + +Run: `pytest tests/ -v --tb=short` +Expected: ALL PASS + +**Step 15: Commit** + +```bash +git add src/dataset_builder.py +git commit -m "feat: remove ADX hard filter from dataset builder, add ADX as ML feature" +``` + +--- + +### Task 6: 전체 테스트 + 최종 검증 + +**Step 16: Run full test suite** + +Run: `bash scripts/run_tests.sh` +Expected: ALL PASS + +**Step 17: Final commit if needed** + +주의: 기존 모델(23 피처)은 24 피처 입력과 호환 안 됨. 배포 전 반드시 `bash scripts/train_and_deploy.sh` 실행하여 재학습 필요. diff --git a/docs/plans/2026-03-03-position-monitor-logging.md b/docs/plans/2026-03-03-position-monitor-logging.md new file mode 100644 index 0000000..55e823a --- /dev/null +++ b/docs/plans/2026-03-03-position-monitor-logging.md @@ -0,0 +1,35 @@ +# 포지션 모니터 로깅 (실시간 가격 추적) + +**Goal:** 포지션 보유 중 5분마다 현재가 기준 미실현 손익을 로그로 출력하여, 봇 운영 중 포지션 상태를 실시간 모니터링할 수 있게 한다. + +**Status:** Completed + +--- + +## 변경 사항 + +### 1. MultiSymbolStream에 latest_price 속성 추가 + +- `src/data_stream.py`: `self.latest_price: float | None = None` 초기화 +- `handle_message()`에서 **모든 kline 메시지** (미확정 캔들 포함)에 대해 primary symbol(XRPUSDT)의 close 가격으로 업데이트 +- 기존에는 확정 캔들만 처리했으나, 실시간 가격 추적을 위해 미확정 캔들도 반영 +- BTC/ETH 등 비주 심볼은 latest_price 갱신 안 함 + +### 2. _position_monitor() 코루틴 추가 + +- `src/bot.py`: `_MONITOR_INTERVAL = 300` (5분) 클래스 상수 정의 +- `async def _position_monitor()` 무한 루프: 5분마다 실행 +- 포지션 없으면(`current_trade_side is None`) skip +- 포지션 있으면: `_calc_estimated_pnl(price)`로 미실현 PnL 계산, 퍼센트 산출 후 INFO 로그 출력 +- `asyncio.gather()`에 추가하여 기존 user_data_stream, candle processing과 병렬 실행 + +### 3. 테스트 + +- `tests/test_bot.py`: 포지션 보유 시 PnL 로깅 확인, 포지션 없을 때 정상 skip 확인 (2 cases) +- `tests/test_data_stream.py`: 미확정 캔들로 latest_price 갱신, 비주 심볼은 무시 확인 (1 case) + +## 설계 결정 + +- WebSocket 스트림 재사용 (추가 API 연결 불필요) +- `_MONITOR_INTERVAL`은 클래스 상수로 정의 (테스트에서 0으로 오버라이드 가능) +- 가격/진입가/수량 중 하나라도 None이면 graceful skip