Compare commits
3 Commits
b8b99da207
...
99fa508db7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99fa508db7 | ||
|
|
eeb5e9d877 | ||
|
|
c8a2c36bfb |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"superpowers@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
83
CLAUDE.md
Normal file
83
CLAUDE.md
Normal 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/`
|
||||||
150
docs/plans/2026-03-02-adx-filter-design.md
Normal file
150
docs/plans/2026-03-02-adx-filter-design.md
Normal 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"
|
||||||
|
```
|
||||||
@@ -47,6 +47,10 @@ def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
|||||||
d["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
|
d["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
|
||||||
d["stoch_d"] = stoch["STOCHRSId_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
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -112,6 +116,12 @@ def _calc_signals(d: pd.DataFrame) -> np.ndarray:
|
|||||||
# 둘 다 해당하면 HOLD (충돌 방지)
|
# 둘 다 해당하면 HOLD (충돌 방지)
|
||||||
signal_arr[long_enter & short_enter] = "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
|
return signal_arr
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ class Indicators:
|
|||||||
df["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
|
df["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
|
||||||
df["stoch_d"] = stoch["STOCHRSId_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)
|
df["vol_ma20"] = ta.sma(df["volume"], length=20)
|
||||||
|
|
||||||
@@ -56,6 +60,12 @@ class Indicators:
|
|||||||
last = df.iloc[-1]
|
last = df.iloc[-1]
|
||||||
prev = df.iloc[-2]
|
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
|
long_signals = 0
|
||||||
short_signals = 0
|
short_signals = 0
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,42 @@ def test_bollinger_bands(sample_df):
|
|||||||
assert (valid["bb_upper"] >= valid["bb_lower"]).all()
|
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):
|
def test_signal_returns_direction(sample_df):
|
||||||
ind = Indicators(sample_df)
|
ind = Indicators(sample_df)
|
||||||
df = ind.calculate_all()
|
df = ind.calculate_all()
|
||||||
|
|||||||
Reference in New Issue
Block a user