Compare commits

...

3 Commits

Author SHA1 Message Date
21in7
99fa508db7 feat: add CLAUDE.md and settings.json for project documentation and plugin configuration
Introduced CLAUDE.md to provide comprehensive guidance on the CoinTrader project, including architecture, common commands, testing, and deployment details. Added settings.json to enable the superpowers plugin for Claude. This enhances the project's documentation and configuration management.
2026-03-02 20:01:18 +09:00
21in7
eeb5e9d877 feat: add ADX filter to block sideways market entries
ADX < 25 now returns HOLD in get_signal(), preventing entries during
trendless (sideways) markets. NaN ADX values fall through to existing
weighted signal logic. Also syncs the vectorized dataset builder with
the same ADX filter to keep training data consistent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:55:12 +09:00
21in7
c8a2c36bfb feat: add ADX calculation to indicators
Add ADX (Average Directional Index) with period 14 to calculate_all()
for sideways market filtering. Includes test verifying the adx column
exists and contains non-negative values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:47:18 +09:00
6 changed files with 294 additions and 0 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"superpowers@claude-plugins-official": true
}
}

83
CLAUDE.md Normal file
View File

@@ -0,0 +1,83 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
CoinTrader is a Python asyncio-based automated cryptocurrency trading bot for Binance Futures. It trades XRPUSDT on 15-minute candles, using BTC/ETH as correlation features. The system has 5 layers: Data (WebSocket streams) → Signal (technical indicators) → ML Filter (ONNX/LightGBM) → Execution & Risk → Event/Alert (Discord).
## Common Commands
```bash
# venv
source .venv/bin/activate
# Run the bot
python main.py
# Run full test suite
bash scripts/run_tests.sh
# Run filtered tests
bash scripts/run_tests.sh -k "bot"
# Run pytest directly
pytest tests/ -v --tb=short
# ML training pipeline (LightGBM default)
bash scripts/train_and_deploy.sh
# MLX GPU training (macOS Apple Silicon)
bash scripts/train_and_deploy.sh mlx
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
python scripts/tune_hyperparams.py
# Fetch historical data
python scripts/fetch_history.py --symbols XRPUSDT BTCUSDT ETHUSDT --interval 15m --days 365
# Deploy models to production
bash scripts/deploy_model.sh
```
## Architecture
**Entry point**: `main.py` → creates `Config` (dataclass from env vars) → runs `TradingBot`
**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
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
**Parallel execution**: `user_data_stream` runs independently via `asyncio.gather()` alongside candle processing.
## Key Patterns
- **Async-first**: All I/O operations use `async/await`; parallel tasks via `asyncio.gather()`
- **Reverse signal re-entry**: While holding LONG, if SHORT signal appears → close position, cancel SL/TP, open SHORT. `_is_reentering` flag prevents race conditions with User Data Stream
- **ML hot reload**: `ml_filter.check_and_reload()` compares file mtime on every candle, reloads model without restart
- **Active Config pattern**: Best hyperparams stored in `models/active_lgbm_params.json`, must be manually approved before retraining
- **Graceful degradation**: Missing model → all signals pass; API failure → use fallback values (0.0 for OI/funding)
- **Walk-forward validation**: Time-series CV with undersampling (1:1 class balance, preserving time order)
- **Label generation**: Binary labels based on 24-candle (6h) lookahead — check SL hit first (conservative), then TP
## Testing
- All external APIs (Binance, Discord) are mocked with `unittest.mock.AsyncMock`
- Async tests use `@pytest.mark.asyncio`
- 14 test files, 80+ test cases covering all layers
- Testing is done in actual terminal, not IDE sandbox
## Configuration
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOL` (default XRPUSDT), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `NO_ML_FILTER`.
`src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars.
## Deployment
- **Docker**: `Dockerfile` (Python 3.12-slim) + `docker-compose.yml`
- **CI/CD**: Jenkins pipeline (Gitea → Docker registry → LXC production server)
- Models stored in `models/`, data cache in `data/`, logs in `logs/`

View File

@@ -0,0 +1,150 @@
# ADX 횡보장 필터 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** ADX < 25일 때 get_signal()에서 즉시 HOLD를 반환하여 횡보장 진입을 차단한다.
**Architecture:** `calculate_all()`에서 `pandas_ta.adx()`로 ADX 컬럼을 추가하고, `get_signal()`에서 가중치 계산 전 ADX < 25이면 early-return HOLD. NaN(초기 캔들)은 기존 로직으로 폴백.
**Tech Stack:** pandas-ta (이미 사용 중), pytest
---
### Task 1: ADX 계산 테스트 추가
**Files:**
- Test: `tests/test_indicators.py`
**Step 1: Write the failing test**
```python
def test_adx_column_exists(sample_df):
"""calculate_all()이 adx 컬럼을 생성하는지 확인."""
ind = Indicators(sample_df)
df = ind.calculate_all()
assert "adx" in df.columns
valid = df["adx"].dropna()
assert (valid >= 0).all()
```
`tests/test_indicators.py`에 위 테스트 함수를 추가한다.
**Step 2: Run test to verify it fails**
Run: `pytest tests/test_indicators.py::test_adx_column_exists -v`
Expected: FAIL — `"adx" not in df.columns`
---
### Task 2: calculate_all()에 ADX 계산 추가
**Files:**
- Modify: `src/indicators.py:46-48` (vol_ma20 계산 바로 앞에 추가)
**Step 3: Write minimal implementation**
`calculate_all()`의 Stochastic RSI 계산 뒤, `vol_ma20` 계산 앞에 추가:
```python
# ADX (14) — 횡보장 필터
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
df["adx"] = adx_df["ADX_14"]
```
**Step 4: Run test to verify it passes**
Run: `pytest tests/test_indicators.py::test_adx_column_exists -v`
Expected: PASS
**Step 5: Commit**
```bash
git add src/indicators.py tests/test_indicators.py
git commit -m "feat: add ADX calculation to indicators"
```
---
### Task 3: ADX 필터 테스트 추가 (차단 케이스)
**Files:**
- Test: `tests/test_indicators.py`
**Step 6: Write the failing test**
```python
def test_adx_filter_blocks_low_adx(sample_df):
"""ADX < 25일 때 가중치와 무관하게 HOLD를 반환해야 한다."""
ind = Indicators(sample_df)
df = ind.calculate_all()
# ADX를 강제로 낮은 값으로 설정
df["adx"] = 15.0
signal = ind.get_signal(df)
assert signal == "HOLD"
```
**Step 7: Run test to verify it fails**
Run: `pytest tests/test_indicators.py::test_adx_filter_blocks_low_adx -v`
Expected: FAIL — signal이 LONG 또는 SHORT 반환 (ADX 필터 미구현)
---
### Task 4: ADX 필터 테스트 추가 (NaN 폴백 케이스)
**Files:**
- Test: `tests/test_indicators.py`
**Step 8: Write the failing test**
```python
def test_adx_nan_falls_through(sample_df):
"""ADX가 NaN(초기 캔들)이면 기존 가중치 로직으로 폴백해야 한다."""
ind = Indicators(sample_df)
df = ind.calculate_all()
df["adx"] = float("nan")
signal = ind.get_signal(df)
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
assert signal in ("LONG", "SHORT", "HOLD")
```
**Step 9: Run test to verify it passes (이 테스트는 현재도 통과)**
Run: `pytest tests/test_indicators.py::test_adx_nan_falls_through -v`
Expected: PASS (ADX 컬럼이 무시되므로 기존 로직 그대로)
---
### Task 5: get_signal()에 ADX early-return 구현
**Files:**
- Modify: `src/indicators.py:51-56` (get_signal 메서드 시작부)
**Step 10: Write minimal implementation**
`get_signal()` 메서드의 `last = df.iloc[-1]` 바로 다음에 추가:
```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"
```
**Step 11: Run all ADX-related tests**
Run: `pytest tests/test_indicators.py -k "adx" -v`
Expected: 3 tests PASS
**Step 12: Run full test suite to check for regressions**
Run: `pytest tests/ -v --tb=short`
Expected: All tests PASS
**Step 13: Commit**
```bash
git add src/indicators.py tests/test_indicators.py
git commit -m "feat: add ADX filter to block sideways market entries"
```

View File

@@ -47,6 +47,10 @@ def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
d["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
d["stoch_d"] = stoch["STOCHRSId_14_14_3_3"]
# ADX (14) — 횡보장 필터
adx_df = ta.adx(high, low, close, length=14)
d["adx"] = adx_df["ADX_14"]
return d
@@ -112,6 +116,12 @@ 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

View File

@@ -43,6 +43,10 @@ class Indicators:
df["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
df["stoch_d"] = stoch["STOCHRSId_14_14_3_3"]
# ADX (14) — 횡보장 필터
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
df["adx"] = adx_df["ADX_14"]
# 거래량 이동평균
df["vol_ma20"] = ta.sma(df["volume"], length=20)
@@ -56,6 +60,12 @@ class Indicators:
last = df.iloc[-1]
prev = df.iloc[-2]
# 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"
long_signals = 0
short_signals = 0

View File

@@ -45,6 +45,42 @@ def test_bollinger_bands(sample_df):
assert (valid["bb_upper"] >= valid["bb_lower"]).all()
def test_adx_column_exists(sample_df):
"""calculate_all()이 adx 컬럼을 생성하는지 확인."""
ind = Indicators(sample_df)
df = ind.calculate_all()
assert "adx" in df.columns
valid = df["adx"].dropna()
assert (valid >= 0).all()
def test_adx_filter_blocks_low_adx(sample_df):
"""ADX < 25일 때 가중치와 무관하게 HOLD를 반환해야 한다."""
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[-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["adx"] = 15.0
signal = ind.get_signal(df)
assert signal == "HOLD"
def test_adx_nan_falls_through(sample_df):
"""ADX가 NaN(초기 캔들)이면 기존 가중치 로직으로 폴백해야 한다."""
ind = Indicators(sample_df)
df = ind.calculate_all()
df["adx"] = float("nan")
signal = ind.get_signal(df)
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
assert signal in ("LONG", "SHORT", "HOLD")
def test_signal_returns_direction(sample_df):
ind = Indicators(sample_df)
df = ind.calculate_all()