Compare commits
12 Commits
7a1abc7b72
...
4c09d63505
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c09d63505 | ||
|
|
0fca14a1c2 | ||
|
|
2f5227222b | ||
|
|
10b1ecd273 | ||
|
|
016b13a8f1 | ||
|
|
3c3c7fd56b | ||
|
|
aa52047f14 | ||
|
|
b57b00051a | ||
|
|
3f4e7910fd | ||
|
|
dfd4990ae5 | ||
|
|
4669d08cb4 | ||
|
|
2b315ad6d7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ models/*.pkl
|
||||
data/*.parquet
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
.cursor/
|
||||
17
README.md
17
README.md
@@ -10,7 +10,9 @@ Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / M
|
||||
- **ML 필터 (ONNX 우선 / LightGBM 폴백)**: 기술 지표 신호를 한 번 더 검증하여 오진입 차단. 우선순위: ONNX > LightGBM > 폴백(항상 허용)
|
||||
- **모델 핫리로드**: 캔들마다 모델 파일 mtime을 감지해 변경 시 자동 리로드 (봇 재시작 불필요)
|
||||
- **멀티심볼 스트림**: XRP/BTC/ETH 3개 심볼을 단일 Combined WebSocket으로 수신, BTC·ETH 상관관계 피처 활용
|
||||
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (실시간 미수집 항목은 0으로 채움)
|
||||
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (캔들 마감 시 실시간 조회, 실패 시 0으로 폴백)
|
||||
- **점진적 OI 데이터 축적 (Upsert)**: 바이낸스 OI 히스토리 API는 최근 30일치만 제공. `fetch_history.py` 실행 시 기존 parquet의 `oi_change/funding_rate=0` 구간을 신규 값으로 채워 학습 데이터 품질을 점진적으로 개선
|
||||
- **실시간 OI/펀딩비 조회**: 캔들 마감마다 `get_open_interest()` / `get_funding_rate()`를 비동기 병렬 조회하여 ML 피처에 전달. 이전 캔들 대비 OI 변화율로 변환하여 train-serve skew 해소
|
||||
- **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산 (1.5× / 3.0× ATR)
|
||||
- **Algo Order API 지원**: 계정 설정에 따라 STOP_MARKET/TAKE_PROFIT_MARKET 주문을 `/fapi/v1/algoOrder` 엔드포인트로 자동 전송 (오류 코드 -4120 대응)
|
||||
- **동적 증거금 비율**: 잔고 증가에 따라 선형 감소 (최대 50% → 최소 20%)
|
||||
@@ -42,7 +44,7 @@ cointrader/
|
||||
│ ├── notifier.py # Discord 웹훅 알림
|
||||
│ └── logger_setup.py # Loguru 로거 설정
|
||||
├── scripts/
|
||||
│ ├── fetch_history.py # 과거 데이터 수집 (XRP/BTC/ETH + OI/펀딩비)
|
||||
│ ├── fetch_history.py # 과거 데이터 수집 (XRP/BTC/ETH + OI/펀딩비, Upsert 지원)
|
||||
│ ├── train_model.py # LightGBM 모델 학습 (CPU)
|
||||
│ ├── train_mlx_model.py # MLX 신경망 학습 (Apple Silicon GPU)
|
||||
│ ├── train_and_deploy.sh # 전체 파이프라인 (수집 → 학습 → LXC 배포)
|
||||
@@ -108,6 +110,8 @@ docker compose logs -f cointrader
|
||||
|
||||
맥미니에서 데이터 수집 → 학습 → LXC 배포까지 한 번에 실행합니다.
|
||||
|
||||
> **자동 분기**: `data/combined_15m.parquet`가 없으면 1년치(365일) 전체 수집, 있으면 35일치 Upsert로 자동 전환합니다. 서버 이전이나 데이터 유실 시에도 사람의 개입 없이 자동 복구됩니다.
|
||||
|
||||
```bash
|
||||
# LightGBM + Walk-Forward 5폴드 (기본값)
|
||||
bash scripts/train_and_deploy.sh
|
||||
@@ -126,12 +130,21 @@ bash scripts/train_and_deploy.sh lgbm 0
|
||||
|
||||
```bash
|
||||
# 1. 과거 데이터 수집 (XRP/BTC/ETH 3심볼, 15분봉, 1년치 + OI/펀딩비)
|
||||
# 기본값: Upsert 활성화 — 기존 parquet의 oi_change/funding_rate=0 구간을 실제 값으로 채움
|
||||
python scripts/fetch_history.py \
|
||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||
--interval 15m \
|
||||
--days 365 \
|
||||
--output data/combined_15m.parquet
|
||||
|
||||
# 기존 파일을 완전히 덮어쓰려면 --no-upsert 플래그 사용
|
||||
python scripts/fetch_history.py \
|
||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||
--interval 15m \
|
||||
--days 365 \
|
||||
--output data/combined_15m.parquet \
|
||||
--no-upsert
|
||||
|
||||
# 2-A. LightGBM 모델 학습 (CPU)
|
||||
python scripts/train_model.py --data data/combined_15m.parquet
|
||||
|
||||
|
||||
394
docs/plans/2026-03-02-oi-funding-accumulation.md
Normal file
394
docs/plans/2026-03-02-oi-funding-accumulation.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# OI/펀딩비 누적 저장 (접근법 B) 구현 계획
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** `fetch_history.py`의 데이터 수집 방식을 덮어쓰기(Overwrite)에서 Upsert(병합)로 변경해, 매일 실행할 때마다 기존 parquet의 OI/펀딩비 0.0 구간이 실제 값으로 채워지며 고품질 데이터가 무한히 누적되도록 한다.
|
||||
|
||||
**Architecture:**
|
||||
- `fetch_history.py`에 `--upsert` 플래그 추가 (기본값 True). 기존 parquet이 있으면 로드 후 신규 데이터와 timestamp 기준 병합(Upsert). 없으면 기존처럼 새로 생성.
|
||||
- Upsert 규칙: 기존 행의 `oi_change` / `funding_rate`가 0.0이면 신규 값으로 덮어씀. 신규 행은 그냥 추가. 중복 제거 후 시간순 정렬.
|
||||
- `train_and_deploy.sh`의 `--days` 인자를 35일로 조정 (30일 API 한도 + 5일 버퍼).
|
||||
- LXC 운영서버는 모델 파일만 받으므로 변경 없음. 맥미니의 `data/` 폴더에만 누적.
|
||||
|
||||
**Tech Stack:** pandas, parquet (pyarrow), pytest
|
||||
|
||||
---
|
||||
|
||||
## Task 1: fetch_history.py — upsert_parquet() 함수 추가 및 --upsert 플래그
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/fetch_history.py`
|
||||
- Test: `tests/test_fetch_history.py` (신규 생성)
|
||||
|
||||
### Step 1: 실패 테스트 작성
|
||||
|
||||
`tests/test_fetch_history.py` 파일을 새로 만든다.
|
||||
|
||||
```python
|
||||
"""fetch_history.py의 upsert_parquet() 함수 테스트."""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _make_parquet(tmp_path: Path, rows: dict) -> Path:
|
||||
"""테스트용 parquet 파일 생성 헬퍼."""
|
||||
df = pd.DataFrame(rows)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
|
||||
df = df.set_index("timestamp")
|
||||
path = tmp_path / "test.parquet"
|
||||
df.to_parquet(path)
|
||||
return path
|
||||
|
||||
|
||||
def test_upsert_fills_zero_oi_with_real_value(tmp_path):
|
||||
"""기존 행의 oi_change=0.0이 신규 데이터의 실제 값으로 덮어써진다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
existing_path = _make_parquet(tmp_path, {
|
||||
"timestamp": ["2026-01-01 00:00", "2026-01-01 00:15"],
|
||||
"close": [1.0, 1.1],
|
||||
"oi_change": [0.0, 0.0],
|
||||
"funding_rate": [0.0, 0.0],
|
||||
})
|
||||
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.0, 1.1],
|
||||
"oi_change": [0.05, 0.03],
|
||||
"funding_rate": [0.0001, 0.0001],
|
||||
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(existing_path, new_df)
|
||||
|
||||
assert result.loc["2026-01-01 00:00+00:00", "oi_change"] == pytest.approx(0.05)
|
||||
assert result.loc["2026-01-01 00:15+00:00", "oi_change"] == pytest.approx(0.03)
|
||||
|
||||
|
||||
def test_upsert_appends_new_rows(tmp_path):
|
||||
"""신규 타임스탬프 행이 기존 데이터 아래에 추가된다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
existing_path = _make_parquet(tmp_path, {
|
||||
"timestamp": ["2026-01-01 00:00"],
|
||||
"close": [1.0],
|
||||
"oi_change": [0.05],
|
||||
"funding_rate": [0.0001],
|
||||
})
|
||||
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.1],
|
||||
"oi_change": [0.03],
|
||||
"funding_rate": [0.0002],
|
||||
}, index=pd.to_datetime(["2026-01-01 00:15"], utc=True))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(existing_path, new_df)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "2026-01-01 00:15+00:00" in result.index.astype(str).tolist() or \
|
||||
pd.Timestamp("2026-01-01 00:15", tz="UTC") in result.index
|
||||
|
||||
|
||||
def test_upsert_keeps_nonzero_existing_oi(tmp_path):
|
||||
"""기존 행의 oi_change가 이미 0이 아니면 덮어쓰지 않는다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
existing_path = _make_parquet(tmp_path, {
|
||||
"timestamp": ["2026-01-01 00:00"],
|
||||
"close": [1.0],
|
||||
"oi_change": [0.07], # 이미 실제 값 존재
|
||||
"funding_rate": [0.0003],
|
||||
})
|
||||
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.0],
|
||||
"oi_change": [0.05], # 다른 값으로 덮어쓰려 해도
|
||||
"funding_rate": [0.0001],
|
||||
}, index=pd.to_datetime(["2026-01-01 00:00"], utc=True))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(existing_path, new_df)
|
||||
|
||||
# 기존 값(0.07)이 유지되어야 한다
|
||||
assert result.iloc[0]["oi_change"] == pytest.approx(0.07)
|
||||
|
||||
|
||||
def test_upsert_no_existing_file_returns_new_df(tmp_path):
|
||||
"""기존 parquet 파일이 없으면 신규 데이터를 그대로 반환한다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
nonexistent_path = tmp_path / "nonexistent.parquet"
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.0, 1.1],
|
||||
"oi_change": [0.05, 0.03],
|
||||
"funding_rate": [0.0001, 0.0001],
|
||||
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(nonexistent_path, new_df)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result.iloc[0]["oi_change"] == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_upsert_result_is_sorted_by_timestamp(tmp_path):
|
||||
"""결과 DataFrame이 timestamp 기준 오름차순 정렬되어 있다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
existing_path = _make_parquet(tmp_path, {
|
||||
"timestamp": ["2026-01-01 00:15"],
|
||||
"close": [1.1],
|
||||
"oi_change": [0.0],
|
||||
"funding_rate": [0.0],
|
||||
})
|
||||
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.0, 1.1, 1.2],
|
||||
"oi_change": [0.05, 0.03, 0.02],
|
||||
"funding_rate": [0.0001, 0.0001, 0.0002],
|
||||
}, index=pd.to_datetime(
|
||||
["2026-01-01 00:00", "2026-01-01 00:15", "2026-01-01 00:30"], utc=True
|
||||
))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(existing_path, new_df)
|
||||
|
||||
assert result.index.is_monotonic_increasing
|
||||
assert len(result) == 3
|
||||
```
|
||||
|
||||
### Step 2: 테스트 실패 확인
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_fetch_history.py -v
|
||||
```
|
||||
|
||||
Expected: `FAILED` — `ImportError: cannot import name 'upsert_parquet' from 'scripts.fetch_history'`
|
||||
|
||||
### Step 3: fetch_history.py에 upsert_parquet() 함수 구현
|
||||
|
||||
`scripts/fetch_history.py`의 `main()` 함수 바로 위에 추가한다.
|
||||
|
||||
```python
|
||||
def upsert_parquet(path: Path | str, new_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
기존 parquet 파일에 신규 데이터를 Upsert(병합)한다.
|
||||
|
||||
규칙:
|
||||
- 기존 행의 oi_change / funding_rate가 0.0이면 신규 값으로 덮어씀
|
||||
- 기존 행의 oi_change / funding_rate가 이미 0이 아니면 유지
|
||||
- 신규 타임스탬프 행은 그냥 추가
|
||||
- 결과는 timestamp 기준 오름차순 정렬, 중복 제거
|
||||
|
||||
Args:
|
||||
path: 기존 parquet 경로 (없으면 new_df 그대로 반환)
|
||||
new_df: 새로 수집한 DataFrame (timestamp index)
|
||||
|
||||
Returns:
|
||||
병합된 DataFrame
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return new_df.sort_index()
|
||||
|
||||
existing = pd.read_parquet(path)
|
||||
|
||||
# timestamp index 통일 (tz-aware UTC)
|
||||
if existing.index.tz is None:
|
||||
existing.index = existing.index.tz_localize("UTC")
|
||||
if new_df.index.tz is None:
|
||||
new_df.index = new_df.index.tz_localize("UTC")
|
||||
|
||||
# 기존 데이터에서 oi_change / funding_rate가 0.0인 행만 신규 값으로 업데이트
|
||||
UPSERT_COLS = ["oi_change", "funding_rate"]
|
||||
overlap_idx = existing.index.intersection(new_df.index)
|
||||
|
||||
for col in UPSERT_COLS:
|
||||
if col not in existing.columns or col not in new_df.columns:
|
||||
continue
|
||||
# 겹치는 행 중 기존 값이 0.0인 경우에만 신규 값으로 교체
|
||||
zero_mask = existing.loc[overlap_idx, col] == 0.0
|
||||
update_idx = overlap_idx[zero_mask]
|
||||
if len(update_idx) > 0:
|
||||
existing.loc[update_idx, col] = new_df.loc[update_idx, col]
|
||||
|
||||
# 신규 타임스탬프 행 추가 (기존에 없는 것만)
|
||||
new_only_idx = new_df.index.difference(existing.index)
|
||||
if len(new_only_idx) > 0:
|
||||
existing = pd.concat([existing, new_df.loc[new_only_idx]])
|
||||
|
||||
return existing.sort_index()
|
||||
```
|
||||
|
||||
### Step 4: main()에 --upsert 플래그 추가 및 저장 로직 수정
|
||||
|
||||
`main()` 함수의 `parser` 정의 부분에 인자 추가:
|
||||
|
||||
```python
|
||||
parser.add_argument(
|
||||
"--no-upsert", action="store_true",
|
||||
help="기존 parquet을 Upsert하지 않고 새로 덮어씀 (기본: Upsert 활성화)",
|
||||
)
|
||||
```
|
||||
|
||||
그리고 단일 심볼 저장 부분:
|
||||
```python
|
||||
# 기존:
|
||||
df.to_parquet(args.output)
|
||||
|
||||
# 변경:
|
||||
if not args.no_upsert:
|
||||
df = upsert_parquet(args.output, df)
|
||||
df.to_parquet(args.output)
|
||||
```
|
||||
|
||||
멀티 심볼 저장 부분도 동일하게:
|
||||
```python
|
||||
# 기존:
|
||||
merged.to_parquet(output)
|
||||
|
||||
# 변경:
|
||||
if not args.no_upsert:
|
||||
merged = upsert_parquet(output, merged)
|
||||
merged.to_parquet(output)
|
||||
```
|
||||
|
||||
### Step 5: 테스트 통과 확인
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_fetch_history.py -v
|
||||
```
|
||||
|
||||
Expected: 전체 PASS
|
||||
|
||||
### Step 6: 커밋
|
||||
|
||||
```bash
|
||||
git add scripts/fetch_history.py tests/test_fetch_history.py
|
||||
git commit -m "feat: add upsert_parquet to accumulate OI/funding data incrementally"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: train_and_deploy.sh — 데이터 수집 일수 35일로 조정
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/train_and_deploy.sh`
|
||||
|
||||
### Step 1: 현재 상태 확인
|
||||
|
||||
`scripts/train_and_deploy.sh`에서 `--days 365` 부분을 찾는다.
|
||||
|
||||
### Step 2: 수정
|
||||
|
||||
`train_and_deploy.sh`에서 `fetch_history.py` 호출 부분을 수정한다.
|
||||
|
||||
기존:
|
||||
```bash
|
||||
python scripts/fetch_history.py \
|
||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||
--interval 15m \
|
||||
--days 365 \
|
||||
--output data/combined_15m.parquet
|
||||
```
|
||||
|
||||
변경:
|
||||
```bash
|
||||
# OI/펀딩비 API 제한(30일) + 버퍼 5일 = 35일치 신규 수집 후 기존 parquet에 Upsert
|
||||
python scripts/fetch_history.py \
|
||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||
--interval 15m \
|
||||
--days 35 \
|
||||
--output data/combined_15m.parquet
|
||||
```
|
||||
|
||||
**이유**: 매일 실행 시 35일치만 새로 가져와 기존 누적 parquet에 Upsert한다.
|
||||
- 최초 실행 시(`data/combined_15m.parquet` 없음): 35일치로 시작
|
||||
- 이후 매일: 35일치 신규 데이터로 기존 파일의 0.0 구간을 채우고 최신 행 추가
|
||||
- 시간이 지날수록 OI/펀딩비 실제 값이 있는 구간이 1달 → 2달 → ... 로 늘어남
|
||||
|
||||
**주의**: 최초 실행 시 캔들 데이터도 35일치만 있으므로, 첫 실행은 수동으로
|
||||
`--days 365 --no-upsert`로 전체 캔들을 먼저 수집하는 것을 권장한다.
|
||||
README에 이 내용을 추가한다.
|
||||
|
||||
### Step 3: 커밋
|
||||
|
||||
```bash
|
||||
git add scripts/train_and_deploy.sh
|
||||
git commit -m "feat: fetch 35 days for daily upsert instead of overwriting 365 days"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 전체 테스트 통과 확인 및 README 업데이트
|
||||
|
||||
### Step 1: 전체 테스트 실행
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/ --ignore=tests/test_mlx_filter.py --ignore=tests/test_database.py -v
|
||||
```
|
||||
|
||||
Expected: 전체 PASS
|
||||
|
||||
### Step 2: README.md 업데이트
|
||||
|
||||
**"ML 모델 학습" 섹션의 "전체 파이프라인 (권장)" 부분 아래에 아래 내용을 추가한다:**
|
||||
|
||||
```markdown
|
||||
### 최초 실행 (캔들 전체 수집)
|
||||
|
||||
처음 실행하거나 `data/combined_15m.parquet`가 없을 때는 전체 캔들을 먼저 수집한다.
|
||||
이후 매일 크론탭이 `train_and_deploy.sh`를 실행하면 35일치 신규 데이터가 자동으로 Upsert된다.
|
||||
|
||||
```bash
|
||||
# 최초 1회: 1년치 캔들 전체 수집 (OI/펀딩비는 최근 30일만 실제 값, 나머지 0.0)
|
||||
python scripts/fetch_history.py \
|
||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||
--interval 15m \
|
||||
--days 365 \
|
||||
--no-upsert \
|
||||
--output data/combined_15m.parquet
|
||||
|
||||
# 이후 매일 자동 실행 (크론탭 또는 train_and_deploy.sh):
|
||||
# 35일치 신규 데이터를 기존 파일에 Upsert → OI/펀딩비 0.0 구간이 야금야금 채워짐
|
||||
bash scripts/train_and_deploy.sh
|
||||
```
|
||||
```
|
||||
|
||||
**"주요 기능" 섹션에 아래 항목 추가:**
|
||||
|
||||
```markdown
|
||||
- **OI/펀딩비 누적 학습**: 매일 35일치 신규 데이터를 기존 parquet에 Upsert. 시간이 지날수록 실제 OI/펀딩비 값이 있는 학습 구간이 1달 → 2달 → 반년으로 늘어남
|
||||
```
|
||||
|
||||
### Step 3: 최종 커밋
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: document OI/funding incremental accumulation strategy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 후 검증 포인트
|
||||
|
||||
1. `data/combined_15m.parquet`에서 날짜별 `oi_change` 값 분포 확인:
|
||||
```python
|
||||
import pandas as pd
|
||||
df = pd.read_parquet("data/combined_15m.parquet")
|
||||
print(df["oi_change"].describe())
|
||||
print((df["oi_change"] == 0.0).sum(), "개 행이 아직 0.0")
|
||||
```
|
||||
2. 매일 실행 후 0.0 행 수가 줄어드는지 확인
|
||||
3. 모델 학습 시 `oi_change` / `funding_rate` 피처의 non-zero 비율이 증가하는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 메모 (LXC 운영서버 관련)
|
||||
|
||||
- **LXC 운영서버(10.1.10.24)**: 변경 없음. 모델 파일(`*.pkl` / `*.onnx`)만 받음
|
||||
- **맥미니**: `data/combined_15m.parquet`를 누적 보관. 매일 35일치 Upsert 후 학습
|
||||
- **데이터 흐름**: 맥미니 parquet 누적 → 학습 → 모델 → LXC 배포
|
||||
- **봇 실시간 OI/펀딩비**: 접근법 A(Task 1~4)에서 이미 구현됨. LXC 봇이 캔들마다 REST API로 실시간 수집
|
||||
399
docs/plans/2026-03-02-realtime-oi-funding-features.md
Normal file
399
docs/plans/2026-03-02-realtime-oi-funding-features.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# 실시간 OI/펀딩비 피처 수집 구현 계획
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 실시간 봇에서 캔들 마감 시 바이낸스 REST API로 현재 OI와 펀딩비를 수집해 ML 피처에 실제 값을 넣어 학습-추론 불일치(train-serve skew)를 해소한다.
|
||||
|
||||
**Architecture:**
|
||||
- `exchange.py`에 `get_open_interest()`, `get_funding_rate()` 메서드 추가 (REST 호출)
|
||||
- `bot.py`의 `process_candle()`에서 캔들 마감 시 두 값을 조회하고 `build_features()` 호출 시 전달
|
||||
- `ml_features.py`의 `build_features()`가 `oi_change`, `funding_rate` 파라미터를 받아 실제 값으로 채우도록 수정
|
||||
|
||||
**Tech Stack:** python-binance AsyncClient, aiohttp (이미 사용 중), pytest-asyncio
|
||||
|
||||
---
|
||||
|
||||
## Task 1: exchange.py — OI / 펀딩비 조회 메서드 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/exchange.py`
|
||||
- Test: `tests/test_exchange.py`
|
||||
|
||||
### Step 1: 실패 테스트 작성
|
||||
|
||||
`tests/test_exchange.py` 파일에 아래 테스트를 추가한다.
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_open_interest(exchange):
|
||||
"""get_open_interest()가 float을 반환하는지 확인."""
|
||||
exchange.client.futures_open_interest = MagicMock(
|
||||
return_value={"openInterest": "123456.789"}
|
||||
)
|
||||
result = await exchange.get_open_interest()
|
||||
assert isinstance(result, float)
|
||||
assert result == pytest.approx(123456.789)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_funding_rate(exchange):
|
||||
"""get_funding_rate()가 float을 반환하는지 확인."""
|
||||
exchange.client.futures_mark_price = MagicMock(
|
||||
return_value={"lastFundingRate": "0.0001"}
|
||||
)
|
||||
result = await exchange.get_funding_rate()
|
||||
assert isinstance(result, float)
|
||||
assert result == pytest.approx(0.0001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_open_interest_error_returns_none(exchange):
|
||||
"""API 오류 시 None 반환 확인."""
|
||||
from binance.exceptions import BinanceAPIException
|
||||
exchange.client.futures_open_interest = MagicMock(
|
||||
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
|
||||
)
|
||||
result = await exchange.get_open_interest()
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_funding_rate_error_returns_none(exchange):
|
||||
"""API 오류 시 None 반환 확인."""
|
||||
from binance.exceptions import BinanceAPIException
|
||||
exchange.client.futures_mark_price = MagicMock(
|
||||
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
|
||||
)
|
||||
result = await exchange.get_funding_rate()
|
||||
assert result is None
|
||||
```
|
||||
|
||||
### Step 2: 테스트 실패 확인
|
||||
|
||||
```bash
|
||||
pytest tests/test_exchange.py::test_get_open_interest tests/test_exchange.py::test_get_funding_rate -v
|
||||
```
|
||||
|
||||
Expected: `FAILED` — `AttributeError: 'BinanceFuturesClient' object has no attribute 'get_open_interest'`
|
||||
|
||||
### Step 3: exchange.py에 메서드 구현
|
||||
|
||||
`src/exchange.py`의 `cancel_all_orders()` 메서드 아래에 추가한다.
|
||||
|
||||
```python
|
||||
async def get_open_interest(self) -> float | None:
|
||||
"""현재 미결제약정(OI)을 조회한다. 오류 시 None 반환."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.client.futures_open_interest(symbol=self.config.symbol),
|
||||
)
|
||||
return float(result["openInterest"])
|
||||
except Exception as e:
|
||||
logger.warning(f"OI 조회 실패 (무시): {e}")
|
||||
return None
|
||||
|
||||
async def get_funding_rate(self) -> float | None:
|
||||
"""현재 펀딩비를 조회한다. 오류 시 None 반환."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.client.futures_mark_price(symbol=self.config.symbol),
|
||||
)
|
||||
return float(result["lastFundingRate"])
|
||||
except Exception as e:
|
||||
logger.warning(f"펀딩비 조회 실패 (무시): {e}")
|
||||
return None
|
||||
```
|
||||
|
||||
### Step 4: 테스트 통과 확인
|
||||
|
||||
```bash
|
||||
pytest tests/test_exchange.py -v
|
||||
```
|
||||
|
||||
Expected: 기존 테스트 포함 전체 PASS
|
||||
|
||||
### Step 5: 커밋
|
||||
|
||||
```bash
|
||||
git add src/exchange.py tests/test_exchange.py
|
||||
git commit -m "feat: add get_open_interest and get_funding_rate to BinanceFuturesClient"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: ml_features.py — build_features()에 oi/funding 파라미터 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ml_features.py`
|
||||
- Test: `tests/test_ml_features.py`
|
||||
|
||||
### Step 1: 실패 테스트 작성
|
||||
|
||||
`tests/test_ml_features.py`에 아래 테스트를 추가한다.
|
||||
|
||||
```python
|
||||
def test_build_features_uses_provided_oi_funding(sample_df_with_indicators):
|
||||
"""oi_change, funding_rate 파라미터가 제공되면 실제 값이 피처에 반영된다."""
|
||||
from src.ml_features import build_features
|
||||
feat = build_features(
|
||||
sample_df_with_indicators,
|
||||
signal="LONG",
|
||||
oi_change=0.05,
|
||||
funding_rate=0.0002,
|
||||
)
|
||||
assert feat["oi_change"] == pytest.approx(0.05)
|
||||
assert feat["funding_rate"] == pytest.approx(0.0002)
|
||||
|
||||
|
||||
def test_build_features_defaults_to_zero_when_not_provided(sample_df_with_indicators):
|
||||
"""oi_change, funding_rate 파라미터 미제공 시 0.0으로 채워진다."""
|
||||
from src.ml_features import build_features
|
||||
feat = build_features(sample_df_with_indicators, signal="LONG")
|
||||
assert feat["oi_change"] == pytest.approx(0.0)
|
||||
assert feat["funding_rate"] == pytest.approx(0.0)
|
||||
```
|
||||
|
||||
### Step 2: 테스트 실패 확인
|
||||
|
||||
```bash
|
||||
pytest tests/test_ml_features.py::test_build_features_uses_provided_oi_funding -v
|
||||
```
|
||||
|
||||
Expected: `FAILED` — `TypeError: build_features() got an unexpected keyword argument 'oi_change'`
|
||||
|
||||
### Step 3: ml_features.py 수정
|
||||
|
||||
`build_features()` 시그니처와 마지막 부분을 수정한다.
|
||||
|
||||
```python
|
||||
def build_features(
|
||||
df: pd.DataFrame,
|
||||
signal: str,
|
||||
btc_df: pd.DataFrame | None = None,
|
||||
eth_df: pd.DataFrame | None = None,
|
||||
oi_change: float | None = None,
|
||||
funding_rate: float | None = None,
|
||||
) -> pd.Series:
|
||||
```
|
||||
|
||||
그리고 함수 끝의 `setdefault` 부분을 아래로 교체한다.
|
||||
|
||||
```python
|
||||
# 실시간에서 실제 값이 제공되면 사용, 없으면 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
|
||||
|
||||
return pd.Series(base)
|
||||
```
|
||||
|
||||
기존 코드:
|
||||
```python
|
||||
# 실시간에서는 OI/펀딩비를 수집하지 않으므로 0으로 채워 학습 피처(23개)와 일치시킨다
|
||||
base.setdefault("oi_change", 0.0)
|
||||
base.setdefault("funding_rate", 0.0)
|
||||
|
||||
return pd.Series(base)
|
||||
```
|
||||
|
||||
### Step 4: 테스트 통과 확인
|
||||
|
||||
```bash
|
||||
pytest tests/test_ml_features.py -v
|
||||
```
|
||||
|
||||
Expected: 전체 PASS
|
||||
|
||||
### Step 5: 커밋
|
||||
|
||||
```bash
|
||||
git add src/ml_features.py tests/test_ml_features.py
|
||||
git commit -m "feat: build_features accepts oi_change and funding_rate params"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: bot.py — 캔들 마감 시 OI/펀딩비 조회 후 피처에 전달
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot.py`
|
||||
- Test: `tests/test_bot.py`
|
||||
|
||||
### Step 1: 실패 테스트 작성
|
||||
|
||||
`tests/test_bot.py`에 아래 테스트를 추가한다.
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_candle_fetches_oi_and_funding(config, sample_df):
|
||||
"""process_candle()이 OI와 펀딩비를 조회하고 build_features에 전달하는지 확인."""
|
||||
with patch("src.bot.BinanceFuturesClient"):
|
||||
bot = TradingBot(config)
|
||||
|
||||
bot.exchange = AsyncMock()
|
||||
bot.exchange.get_balance = AsyncMock(return_value=1000.0)
|
||||
bot.exchange.get_position = AsyncMock(return_value=None)
|
||||
bot.exchange.place_order = AsyncMock(return_value={"orderId": "1"})
|
||||
bot.exchange.set_leverage = AsyncMock()
|
||||
bot.exchange.get_open_interest = AsyncMock(return_value=5000000.0)
|
||||
bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001)
|
||||
|
||||
with patch("src.bot.build_features") as mock_build:
|
||||
mock_build.return_value = pd.Series({col: 0.0 for col in __import__("src.ml_features", fromlist=["FEATURE_COLS"]).FEATURE_COLS})
|
||||
# ML 필터는 비활성화
|
||||
bot.ml_filter.is_model_loaded = MagicMock(return_value=False)
|
||||
await bot.process_candle(sample_df)
|
||||
|
||||
# build_features가 oi_change, funding_rate 키워드 인자와 함께 호출됐는지 확인
|
||||
assert mock_build.called
|
||||
call_kwargs = mock_build.call_args.kwargs
|
||||
assert "oi_change" in call_kwargs
|
||||
assert "funding_rate" in call_kwargs
|
||||
```
|
||||
|
||||
### Step 2: 테스트 실패 확인
|
||||
|
||||
```bash
|
||||
pytest tests/test_bot.py::test_process_candle_fetches_oi_and_funding -v
|
||||
```
|
||||
|
||||
Expected: `FAILED` — `AssertionError: assert 'oi_change' in {}`
|
||||
|
||||
### Step 3: bot.py 수정
|
||||
|
||||
`process_candle()` 메서드에서 OI/펀딩비를 조회하고 `build_features()`에 전달한다.
|
||||
|
||||
`process_candle()` 메서드 시작 부분에 OI/펀딩비 조회를 추가한다:
|
||||
|
||||
```python
|
||||
async def process_candle(self, df, btc_df=None, eth_df=None):
|
||||
self.ml_filter.check_and_reload()
|
||||
|
||||
if not self.risk.is_trading_allowed():
|
||||
logger.warning("리스크 한도 초과 - 거래 중단")
|
||||
return
|
||||
|
||||
# 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백)
|
||||
oi_change, funding_rate = await self._fetch_market_microstructure()
|
||||
|
||||
ind = Indicators(df)
|
||||
df_with_indicators = ind.calculate_all()
|
||||
raw_signal = ind.get_signal(df_with_indicators)
|
||||
# ... (이하 동일)
|
||||
```
|
||||
|
||||
그리고 `build_features()` 호출 부분 두 곳을 모두 수정한다:
|
||||
|
||||
```python
|
||||
features = build_features(
|
||||
df_with_indicators, signal,
|
||||
btc_df=btc_df, eth_df=eth_df,
|
||||
oi_change=oi_change, funding_rate=funding_rate,
|
||||
)
|
||||
```
|
||||
|
||||
`_fetch_market_microstructure()` 메서드를 추가한다:
|
||||
|
||||
```python
|
||||
async def _fetch_market_microstructure(self) -> tuple[float, float]:
|
||||
"""OI 변화율과 펀딩비를 실시간으로 조회한다. 실패 시 0.0으로 폴백."""
|
||||
oi_val, fr_val = await asyncio.gather(
|
||||
self.exchange.get_open_interest(),
|
||||
self.exchange.get_funding_rate(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
oi_float = float(oi_val) if isinstance(oi_val, (int, float)) else 0.0
|
||||
fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0
|
||||
|
||||
# OI는 절대값이므로 이전 값 대비 변화율로 변환
|
||||
oi_change = self._calc_oi_change(oi_float)
|
||||
logger.debug(f"OI={oi_float:.0f}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}")
|
||||
return oi_change, fr_float
|
||||
```
|
||||
|
||||
`_calc_oi_change()` 메서드와 `_prev_oi` 상태를 추가한다:
|
||||
|
||||
`__init__()` 에 추가:
|
||||
```python
|
||||
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
||||
```
|
||||
|
||||
메서드 추가:
|
||||
```python
|
||||
def _calc_oi_change(self, current_oi: float) -> float:
|
||||
"""이전 OI 대비 변화율을 계산한다. 첫 캔들은 0.0 반환."""
|
||||
if self._prev_oi is None or self._prev_oi == 0.0:
|
||||
self._prev_oi = current_oi
|
||||
return 0.0
|
||||
change = (current_oi - self._prev_oi) / self._prev_oi
|
||||
self._prev_oi = current_oi
|
||||
return change
|
||||
```
|
||||
|
||||
### Step 4: 테스트 통과 확인
|
||||
|
||||
```bash
|
||||
pytest tests/test_bot.py -v
|
||||
```
|
||||
|
||||
Expected: 전체 PASS
|
||||
|
||||
### Step 5: 커밋
|
||||
|
||||
```bash
|
||||
git add src/bot.py tests/test_bot.py
|
||||
git commit -m "feat: fetch realtime OI and funding rate on candle close for ML features"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 전체 테스트 통과 확인 및 README 업데이트
|
||||
|
||||
### Step 1: 전체 테스트 실행
|
||||
|
||||
```bash
|
||||
bash scripts/run_tests.sh
|
||||
```
|
||||
|
||||
Expected: 전체 PASS (새 테스트 포함)
|
||||
|
||||
### Step 2: README.md 업데이트
|
||||
|
||||
`README.md`의 "주요 기능" 섹션에서 ML 피처 설명을 수정한다.
|
||||
|
||||
기존:
|
||||
```
|
||||
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (실시간 미수집 항목은 0으로 채움)
|
||||
```
|
||||
|
||||
변경:
|
||||
```
|
||||
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (캔들 마감 시 REST API로 실시간 수집)
|
||||
```
|
||||
|
||||
### Step 3: 최종 커밋
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: update README to reflect realtime OI/funding rate collection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 후 검증 포인트
|
||||
|
||||
1. 봇 실행 로그에서 `OI=xxx, OI변화율=xxx, 펀딩비=xxx` 라인이 15분마다 출력되는지 확인
|
||||
2. API 오류(네트워크 단절 등) 시 `WARNING: OI 조회 실패 (무시)` 로그 후 0.0으로 폴백해 봇이 정상 동작하는지 확인
|
||||
3. `build_features()` 호출 시 `oi_change`, `funding_rate`가 실제 값으로 채워지는지 로그 확인
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계: 접근법 B (OI/펀딩비 누적 저장)
|
||||
|
||||
A 완료 후 진행할 계획:
|
||||
- `scripts/fetch_history.py` 실행 시 기존 parquet에 새 30일치를 **append(중복 제거)** 방식으로 저장
|
||||
- 시간이 지날수록 OI/펀딩비 학습 데이터가 누적되어 모델 품질 향상
|
||||
- 별도 플랜 문서로 작성 예정
|
||||
@@ -259,6 +259,61 @@ async def _fetch_oi_and_funding(
|
||||
return _merge_oi_funding(candles, oi_df, funding_df)
|
||||
|
||||
|
||||
def upsert_parquet(path: "Path | str", new_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
기존 parquet 파일에 신규 데이터를 Upsert(병합)한다.
|
||||
|
||||
규칙:
|
||||
- 기존 행의 oi_change / funding_rate가 0.0이면 신규 값으로 덮어씀
|
||||
- 기존 행의 oi_change / funding_rate가 이미 0이 아니면 유지
|
||||
- 신규 타임스탬프 행은 그냥 추가
|
||||
- 결과는 timestamp 기준 오름차순 정렬, 중복 제거
|
||||
|
||||
Args:
|
||||
path: 기존 parquet 경로 (없으면 new_df 그대로 반환)
|
||||
new_df: 새로 수집한 DataFrame (timestamp index)
|
||||
|
||||
Returns:
|
||||
병합된 DataFrame
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return new_df.sort_index()
|
||||
|
||||
existing = pd.read_parquet(path)
|
||||
|
||||
# timestamp index 통일 (tz-aware UTC)
|
||||
if existing.index.tz is None:
|
||||
existing.index = existing.index.tz_localize("UTC")
|
||||
if new_df.index.tz is None:
|
||||
new_df.index = new_df.index.tz_localize("UTC")
|
||||
|
||||
# 기존 데이터에서 oi_change / funding_rate가 0.0인 행만 신규 값으로 업데이트
|
||||
UPSERT_COLS = ["oi_change", "funding_rate"]
|
||||
overlap_idx = existing.index.intersection(new_df.index)
|
||||
|
||||
for col in UPSERT_COLS:
|
||||
if col not in existing.columns or col not in new_df.columns:
|
||||
continue
|
||||
# 겹치는 행 중 기존 값이 0.0인 경우에만 신규 값으로 교체
|
||||
zero_mask = existing.loc[overlap_idx, col] == 0.0
|
||||
update_idx = overlap_idx[zero_mask]
|
||||
if len(update_idx) > 0:
|
||||
existing.loc[update_idx, col] = new_df.loc[update_idx, col]
|
||||
|
||||
# 신규 타임스탬프 행 추가 (기존에 없는 것만)
|
||||
new_only_idx = new_df.index.difference(existing.index)
|
||||
if len(new_only_idx) > 0:
|
||||
existing = pd.concat([existing, new_df.loc[new_only_idx]])
|
||||
|
||||
# 컬럼 불일치(기존 parquet에 oi_change/funding_rate 없음)로 생긴 NaN을 0으로 채움
|
||||
for col in UPSERT_COLS:
|
||||
if col in existing.columns:
|
||||
existing[col] = existing[col].fillna(0.0)
|
||||
|
||||
return existing.sort_index()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="바이낸스 선물 과거 캔들 수집. 단일 심볼 또는 멀티 심볼 병합 저장."
|
||||
@@ -272,6 +327,10 @@ def main():
|
||||
"--no-oi", action="store_true",
|
||||
help="OI/펀딩비 수집을 건너뜀 (캔들 데이터만 저장)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-upsert", action="store_true",
|
||||
help="기존 parquet을 Upsert하지 않고 새로 덮어씀 (기본: Upsert 활성화)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# 하위 호환: --symbol 단독 사용 시 symbols로 통합
|
||||
@@ -283,8 +342,10 @@ def main():
|
||||
if not args.no_oi:
|
||||
print(f"\n[OI/펀딩비] {args.symbols[0]} 수집 중...")
|
||||
df = asyncio.run(_fetch_oi_and_funding(args.symbols[0], args.days, df))
|
||||
if not args.no_upsert:
|
||||
df = upsert_parquet(args.output, df)
|
||||
df.to_parquet(args.output)
|
||||
print(f"저장 완료: {args.output} ({len(df):,}행, {len(df.columns)}컬럼)")
|
||||
print(f"{'Upsert' if not args.no_upsert else '저장'} 완료: {args.output} ({len(df):,}행, {len(df.columns)}컬럼)")
|
||||
else:
|
||||
# 멀티 심볼: 단일 클라이언트로 순차 수집 후 타임스탬프 기준 inner join 병합
|
||||
dfs = asyncio.run(fetch_klines_all(args.symbols, args.interval, args.days))
|
||||
@@ -304,8 +365,10 @@ def main():
|
||||
merged = asyncio.run(_fetch_oi_and_funding(primary, args.days, merged))
|
||||
|
||||
output = args.output.replace("xrpusdt", "combined")
|
||||
if not args.no_upsert:
|
||||
merged = upsert_parquet(output, merged)
|
||||
merged.to_parquet(output)
|
||||
print(f"\n병합 저장 완료: {output} ({len(merged):,}행, {len(merged.columns)}컬럼)")
|
||||
print(f"\n{'Upsert' if not args.no_upsert else '병합 저장'} 완료: {output} ({len(merged):,}행, {len(merged.columns)}컬럼)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -27,12 +27,27 @@ WF_SPLITS="${2:-5}" # 두 번째 인자: Walk-Forward 폴드 수 (0이면 건
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼, 1년치 + OI/펀딩비) ==="
|
||||
mkdir -p data
|
||||
|
||||
PARQUET_FILE="data/combined_15m.parquet"
|
||||
|
||||
echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼 + OI/펀딩비) ==="
|
||||
if [ ! -f "$PARQUET_FILE" ]; then
|
||||
echo " [최초 실행] 기존 데이터 없음 → 1년치(365일) 전체 수집 (--no-upsert)"
|
||||
FETCH_DAYS=365
|
||||
UPSERT_FLAG="--no-upsert"
|
||||
else
|
||||
echo " [일반 실행] 기존 데이터 존재 → 35일치 Upsert (OI/펀딩비 0.0 구간 보충)"
|
||||
FETCH_DAYS=35
|
||||
UPSERT_FLAG=""
|
||||
fi
|
||||
|
||||
python scripts/fetch_history.py \
|
||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||
--interval 15m \
|
||||
--days 365 \
|
||||
--output data/combined_15m.parquet
|
||||
--days "$FETCH_DAYS" \
|
||||
$UPSERT_FLAG \
|
||||
--output "$PARQUET_FILE"
|
||||
|
||||
echo ""
|
||||
echo "=== [2/3] 모델 학습 (23개 피처: XRP 13 + BTC/ETH 8 + OI/펀딩비 2) ==="
|
||||
|
||||
48
src/bot.py
48
src/bot.py
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import pandas as pd
|
||||
from loguru import logger
|
||||
from src.config import Config
|
||||
@@ -18,6 +19,7 @@ class TradingBot:
|
||||
self.risk = RiskManager(config)
|
||||
self.ml_filter = MLFilter()
|
||||
self.current_trade_side: str | None = None # "LONG" | "SHORT"
|
||||
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
||||
self.stream = MultiSymbolStream(
|
||||
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
|
||||
interval="15m",
|
||||
@@ -49,9 +51,37 @@ class TradingBot:
|
||||
else:
|
||||
logger.info("기존 포지션 없음 - 신규 진입 대기")
|
||||
|
||||
async def _fetch_market_microstructure(self) -> tuple[float, float]:
|
||||
"""OI 변화율과 펀딩비를 실시간으로 조회한다. 실패 시 0.0으로 폴백."""
|
||||
oi_val, fr_val = await asyncio.gather(
|
||||
self.exchange.get_open_interest(),
|
||||
self.exchange.get_funding_rate(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
# None(API 실패) 또는 Exception이면 _calc_oi_change를 호출하지 않고 0.0 반환
|
||||
if isinstance(oi_val, (int, float)) and oi_val > 0:
|
||||
oi_change = self._calc_oi_change(float(oi_val))
|
||||
else:
|
||||
oi_change = 0.0
|
||||
fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0
|
||||
logger.debug(f"OI={oi_val}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}")
|
||||
return oi_change, fr_float
|
||||
|
||||
def _calc_oi_change(self, current_oi: float) -> float:
|
||||
"""이전 OI 대비 변화율을 계산한다. 첫 캔들은 0.0 반환."""
|
||||
if self._prev_oi is None or self._prev_oi == 0.0:
|
||||
self._prev_oi = current_oi
|
||||
return 0.0
|
||||
change = (current_oi - self._prev_oi) / self._prev_oi
|
||||
self._prev_oi = current_oi
|
||||
return change
|
||||
|
||||
async def process_candle(self, df, btc_df=None, eth_df=None):
|
||||
self.ml_filter.check_and_reload()
|
||||
|
||||
# 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백)
|
||||
oi_change, funding_rate = await self._fetch_market_microstructure()
|
||||
|
||||
if not self.risk.is_trading_allowed():
|
||||
logger.warning("리스크 한도 초과 - 거래 중단")
|
||||
return
|
||||
@@ -71,8 +101,12 @@ class TradingBot:
|
||||
logger.info("최대 포지션 수 도달")
|
||||
return
|
||||
signal = raw_signal
|
||||
features = build_features(
|
||||
df_with_indicators, signal,
|
||||
btc_df=btc_df, eth_df=eth_df,
|
||||
oi_change=oi_change, funding_rate=funding_rate,
|
||||
)
|
||||
if self.ml_filter.is_model_loaded():
|
||||
features = build_features(df_with_indicators, signal, btc_df=btc_df, eth_df=eth_df)
|
||||
if not self.ml_filter.should_enter(features):
|
||||
logger.info(f"ML 필터 차단: {signal} 신호 무시")
|
||||
return
|
||||
@@ -83,7 +117,9 @@ class TradingBot:
|
||||
if (pos_side == "LONG" and raw_signal == "SHORT") or \
|
||||
(pos_side == "SHORT" and raw_signal == "LONG"):
|
||||
await self._close_and_reenter(
|
||||
position, raw_signal, df_with_indicators, btc_df=btc_df, eth_df=eth_df
|
||||
position, raw_signal, df_with_indicators,
|
||||
btc_df=btc_df, eth_df=eth_df,
|
||||
oi_change=oi_change, funding_rate=funding_rate,
|
||||
)
|
||||
|
||||
async def _open_position(self, signal: str, df):
|
||||
@@ -175,6 +211,8 @@ class TradingBot:
|
||||
df,
|
||||
btc_df=None,
|
||||
eth_df=None,
|
||||
oi_change: float = 0.0,
|
||||
funding_rate: float = 0.0,
|
||||
) -> None:
|
||||
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
|
||||
await self._close_position(position)
|
||||
@@ -184,7 +222,11 @@ class TradingBot:
|
||||
return
|
||||
|
||||
if self.ml_filter.is_model_loaded():
|
||||
features = build_features(df, signal, btc_df=btc_df, eth_df=eth_df)
|
||||
features = build_features(
|
||||
df, signal,
|
||||
btc_df=btc_df, eth_df=eth_df,
|
||||
oi_change=oi_change, funding_rate=funding_rate,
|
||||
)
|
||||
if not self.ml_filter.should_enter(features):
|
||||
logger.info(f"ML 필터 차단: {signal} 재진입 무시")
|
||||
return
|
||||
|
||||
@@ -146,3 +146,29 @@ class BinanceFuturesClient:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Algo 주문 전체 취소 실패 (무시): {e}")
|
||||
|
||||
async def get_open_interest(self) -> float | None:
|
||||
"""현재 미결제약정(OI)을 조회한다. 오류 시 None 반환."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.client.futures_open_interest(symbol=self.config.symbol),
|
||||
)
|
||||
return float(result["openInterest"])
|
||||
except Exception as e:
|
||||
logger.warning(f"OI 조회 실패 (무시): {e}")
|
||||
return None
|
||||
|
||||
async def get_funding_rate(self) -> float | None:
|
||||
"""현재 펀딩비를 조회한다. 오류 시 None 반환."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.client.futures_mark_price(symbol=self.config.symbol),
|
||||
)
|
||||
return float(result["lastFundingRate"])
|
||||
except Exception as e:
|
||||
logger.warning(f"펀딩비 조회 실패 (무시): {e}")
|
||||
return None
|
||||
|
||||
@@ -34,11 +34,14 @@ def build_features(
|
||||
signal: str,
|
||||
btc_df: pd.DataFrame | None = None,
|
||||
eth_df: pd.DataFrame | None = None,
|
||||
oi_change: float | None = None,
|
||||
funding_rate: float | None = None,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
기술 지표가 계산된 DataFrame의 마지막 행에서 ML 피처를 추출한다.
|
||||
btc_df, eth_df가 제공되면 21개 피처를, 없으면 13개 피처를 반환한다.
|
||||
btc_df, eth_df가 제공되면 23개 피처를, 없으면 15개 피처를 반환한다.
|
||||
signal: "LONG" | "SHORT"
|
||||
oi_change, funding_rate: 실제 값이 제공되면 사용, 없으면 0.0으로 채운다.
|
||||
"""
|
||||
last = df.iloc[-1]
|
||||
close = last["close"]
|
||||
@@ -127,8 +130,8 @@ def build_features(
|
||||
"xrp_eth_rs": float(_calc_rs(ret_1, eth_ret_1)),
|
||||
})
|
||||
|
||||
# 실시간에서는 OI/펀딩비를 수집하지 않으므로 0으로 채워 학습 피처(23개)와 일치시킨다
|
||||
base.setdefault("oi_change", 0.0)
|
||||
base.setdefault("funding_rate", 0.0)
|
||||
# 실시간에서 실제 값이 제공되면 사용, 없으면 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
|
||||
|
||||
return pd.Series(base)
|
||||
|
||||
@@ -188,3 +188,60 @@ async def test_process_candle_passes_raw_signal_to_close_and_reenter_even_if_ml_
|
||||
assert call_args.args[1] == "SHORT"
|
||||
# process_candle에서 ml_filter.should_enter가 호출되지 않아야 한다
|
||||
bot.ml_filter.should_enter.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_candle_fetches_oi_and_funding(config, sample_df):
|
||||
"""process_candle()이 OI와 펀딩비를 조회하고 build_features에 전달하는지 확인."""
|
||||
with patch("src.bot.BinanceFuturesClient"):
|
||||
bot = TradingBot(config)
|
||||
|
||||
bot.exchange = AsyncMock()
|
||||
bot.exchange.get_balance = AsyncMock(return_value=1000.0)
|
||||
bot.exchange.get_position = AsyncMock(return_value=None)
|
||||
bot.exchange.place_order = AsyncMock(return_value={"orderId": "1"})
|
||||
bot.exchange.set_leverage = AsyncMock()
|
||||
bot.exchange.get_open_interest = AsyncMock(return_value=5000000.0)
|
||||
bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001)
|
||||
|
||||
# 신호를 LONG으로 강제해 build_features가 반드시 호출되도록 함
|
||||
with patch("src.bot.Indicators") as mock_ind_cls:
|
||||
mock_ind = MagicMock()
|
||||
mock_ind.calculate_all.return_value = sample_df
|
||||
mock_ind.get_signal.return_value = "LONG"
|
||||
mock_ind_cls.return_value = mock_ind
|
||||
|
||||
with patch("src.bot.build_features") as mock_build:
|
||||
from src.ml_features import FEATURE_COLS
|
||||
mock_build.return_value = pd.Series({col: 0.0 for col in FEATURE_COLS})
|
||||
bot.ml_filter.is_model_loaded = MagicMock(return_value=False)
|
||||
# _open_position은 이 테스트의 관심사가 아니므로 mock 처리
|
||||
bot._open_position = AsyncMock()
|
||||
await bot.process_candle(sample_df)
|
||||
|
||||
assert mock_build.called
|
||||
call_kwargs = mock_build.call_args.kwargs
|
||||
assert "oi_change" in call_kwargs
|
||||
assert "funding_rate" in call_kwargs
|
||||
|
||||
|
||||
def test_calc_oi_change_first_candle_returns_zero(config):
|
||||
"""첫 캔들은 0.0을 반환하고 _prev_oi를 설정한다."""
|
||||
with patch("src.bot.BinanceFuturesClient"):
|
||||
bot = TradingBot(config)
|
||||
assert bot._calc_oi_change(5000000.0) == 0.0
|
||||
assert bot._prev_oi == 5000000.0
|
||||
|
||||
|
||||
def test_calc_oi_change_api_failure_does_not_corrupt_state(config):
|
||||
"""API 실패 시 _fetch_market_microstructure가 _calc_oi_change를 호출하지 않아 상태가 오염되지 않는다."""
|
||||
with patch("src.bot.BinanceFuturesClient"):
|
||||
bot = TradingBot(config)
|
||||
bot._prev_oi = 5000000.0
|
||||
# API 실패 시 _fetch_market_microstructure는 oi_val > 0 체크로 _calc_oi_change를 건너뜀
|
||||
# _calc_oi_change(0.0)을 직접 호출하면 _prev_oi가 0.0으로 오염되는 이전 버그를 재현
|
||||
# 수정 후에는 _fetch_market_microstructure에서 0.0을 직접 반환하므로 이 경로가 없음
|
||||
# 대신 _calc_oi_change가 정상 값에서만 호출되는지 확인
|
||||
result = bot._calc_oi_change(5100000.0)
|
||||
assert abs(result - 0.02) < 1e-6 # (5100000 - 5000000) / 5000000 = 0.02
|
||||
assert bot._prev_oi == 5100000.0
|
||||
|
||||
@@ -25,6 +25,21 @@ def client():
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exchange():
|
||||
os.environ.update({
|
||||
"BINANCE_API_KEY": "test_key",
|
||||
"BINANCE_API_SECRET": "test_secret",
|
||||
"SYMBOL": "XRPUSDT",
|
||||
"LEVERAGE": "10",
|
||||
})
|
||||
config = Config()
|
||||
c = BinanceFuturesClient.__new__(BinanceFuturesClient)
|
||||
c.config = config
|
||||
c.client = MagicMock()
|
||||
return c
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_leverage(config):
|
||||
with patch("src.exchange.Client") as MockClient:
|
||||
@@ -54,3 +69,47 @@ def test_calculate_quantity_zero_balance(client):
|
||||
"""잔고 0이면 최소 명목금액 기반 수량 반환"""
|
||||
qty = client.calculate_quantity(balance=0.0, price=2.5, leverage=10, margin_ratio=0.50)
|
||||
assert qty > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_open_interest(exchange):
|
||||
"""get_open_interest()가 float을 반환하는지 확인."""
|
||||
exchange.client.futures_open_interest = MagicMock(
|
||||
return_value={"openInterest": "123456.789"}
|
||||
)
|
||||
result = await exchange.get_open_interest()
|
||||
assert isinstance(result, float)
|
||||
assert result == pytest.approx(123456.789)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_funding_rate(exchange):
|
||||
"""get_funding_rate()가 float을 반환하는지 확인."""
|
||||
exchange.client.futures_mark_price = MagicMock(
|
||||
return_value={"lastFundingRate": "0.0001"}
|
||||
)
|
||||
result = await exchange.get_funding_rate()
|
||||
assert isinstance(result, float)
|
||||
assert result == pytest.approx(0.0001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_open_interest_error_returns_none(exchange):
|
||||
"""API 오류 시 None 반환 확인."""
|
||||
from binance.exceptions import BinanceAPIException
|
||||
exchange.client.futures_open_interest = MagicMock(
|
||||
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
|
||||
)
|
||||
result = await exchange.get_open_interest()
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_funding_rate_error_returns_none(exchange):
|
||||
"""API 오류 시 None 반환 확인."""
|
||||
from binance.exceptions import BinanceAPIException
|
||||
exchange.client.futures_mark_price = MagicMock(
|
||||
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
|
||||
)
|
||||
result = await exchange.get_funding_rate()
|
||||
assert result is None
|
||||
|
||||
131
tests/test_fetch_history.py
Normal file
131
tests/test_fetch_history.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""fetch_history.py의 upsert_parquet() 함수 테스트."""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _make_parquet(tmp_path: Path, rows: dict) -> Path:
|
||||
"""테스트용 parquet 파일 생성 헬퍼."""
|
||||
df = pd.DataFrame(rows)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
|
||||
df = df.set_index("timestamp")
|
||||
path = tmp_path / "test.parquet"
|
||||
df.to_parquet(path)
|
||||
return path
|
||||
|
||||
|
||||
def test_upsert_fills_zero_oi_with_real_value(tmp_path):
|
||||
"""기존 행의 oi_change=0.0이 신규 데이터의 실제 값으로 덮어써진다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
existing_path = _make_parquet(tmp_path, {
|
||||
"timestamp": ["2026-01-01 00:00", "2026-01-01 00:15"],
|
||||
"close": [1.0, 1.1],
|
||||
"oi_change": [0.0, 0.0],
|
||||
"funding_rate": [0.0, 0.0],
|
||||
})
|
||||
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.0, 1.1],
|
||||
"oi_change": [0.05, 0.03],
|
||||
"funding_rate": [0.0001, 0.0001],
|
||||
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(existing_path, new_df)
|
||||
|
||||
assert result.loc["2026-01-01 00:00+00:00", "oi_change"] == pytest.approx(0.05)
|
||||
assert result.loc["2026-01-01 00:15+00:00", "oi_change"] == pytest.approx(0.03)
|
||||
|
||||
|
||||
def test_upsert_appends_new_rows(tmp_path):
|
||||
"""신규 타임스탬프 행이 기존 데이터 아래에 추가된다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
existing_path = _make_parquet(tmp_path, {
|
||||
"timestamp": ["2026-01-01 00:00"],
|
||||
"close": [1.0],
|
||||
"oi_change": [0.05],
|
||||
"funding_rate": [0.0001],
|
||||
})
|
||||
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.1],
|
||||
"oi_change": [0.03],
|
||||
"funding_rate": [0.0002],
|
||||
}, index=pd.to_datetime(["2026-01-01 00:15"], utc=True))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(existing_path, new_df)
|
||||
|
||||
assert len(result) == 2
|
||||
assert pd.Timestamp("2026-01-01 00:15", tz="UTC") in result.index
|
||||
|
||||
|
||||
def test_upsert_keeps_nonzero_existing_oi(tmp_path):
|
||||
"""기존 행의 oi_change가 이미 0이 아니면 덮어쓰지 않는다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
existing_path = _make_parquet(tmp_path, {
|
||||
"timestamp": ["2026-01-01 00:00"],
|
||||
"close": [1.0],
|
||||
"oi_change": [0.07], # 이미 실제 값 존재
|
||||
"funding_rate": [0.0003],
|
||||
})
|
||||
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.0],
|
||||
"oi_change": [0.05], # 다른 값으로 덮어쓰려 해도
|
||||
"funding_rate": [0.0001],
|
||||
}, index=pd.to_datetime(["2026-01-01 00:00"], utc=True))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(existing_path, new_df)
|
||||
|
||||
# 기존 값(0.07)이 유지되어야 한다
|
||||
assert result.iloc[0]["oi_change"] == pytest.approx(0.07)
|
||||
|
||||
|
||||
def test_upsert_no_existing_file_returns_new_df(tmp_path):
|
||||
"""기존 parquet 파일이 없으면 신규 데이터를 그대로 반환한다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
nonexistent_path = tmp_path / "nonexistent.parquet"
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.0, 1.1],
|
||||
"oi_change": [0.05, 0.03],
|
||||
"funding_rate": [0.0001, 0.0001],
|
||||
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(nonexistent_path, new_df)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result.iloc[0]["oi_change"] == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_upsert_result_is_sorted_by_timestamp(tmp_path):
|
||||
"""결과 DataFrame이 timestamp 기준 오름차순 정렬되어 있다."""
|
||||
from scripts.fetch_history import upsert_parquet
|
||||
|
||||
existing_path = _make_parquet(tmp_path, {
|
||||
"timestamp": ["2026-01-01 00:15"],
|
||||
"close": [1.1],
|
||||
"oi_change": [0.0],
|
||||
"funding_rate": [0.0],
|
||||
})
|
||||
|
||||
new_df = pd.DataFrame({
|
||||
"close": [1.0, 1.1, 1.2],
|
||||
"oi_change": [0.05, 0.03, 0.02],
|
||||
"funding_rate": [0.0001, 0.0001, 0.0002],
|
||||
}, index=pd.to_datetime(
|
||||
["2026-01-01 00:00", "2026-01-01 00:15", "2026-01-01 00:30"], utc=True
|
||||
))
|
||||
new_df.index.name = "timestamp"
|
||||
|
||||
result = upsert_parquet(existing_path, new_df)
|
||||
|
||||
assert result.index.is_monotonic_increasing
|
||||
assert len(result) == 3
|
||||
@@ -25,12 +25,12 @@ def test_build_features_with_btc_eth_has_21_features():
|
||||
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) == 21
|
||||
assert len(features) == 23
|
||||
|
||||
def test_build_features_without_btc_eth_has_13_features():
|
||||
xrp_df = _make_df(10, base_price=1.0)
|
||||
features = build_features(xrp_df, "LONG")
|
||||
assert len(features) == 13
|
||||
assert len(features) == 15
|
||||
|
||||
def test_build_features_btc_ret_1_correct():
|
||||
xrp_df = _make_df(10, base_price=1.0)
|
||||
@@ -111,3 +111,30 @@ def test_side_encoding():
|
||||
short_feat = build_features(df_ind, signal="SHORT")
|
||||
assert long_feat["side"] == 1
|
||||
assert short_feat["side"] == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_df_with_indicators():
|
||||
from src.indicators import Indicators
|
||||
df = make_df(100)
|
||||
ind = Indicators(df)
|
||||
return ind.calculate_all()
|
||||
|
||||
|
||||
def test_build_features_uses_provided_oi_funding(sample_df_with_indicators):
|
||||
"""oi_change, funding_rate 파라미터가 제공되면 실제 값이 피처에 반영된다."""
|
||||
feat = build_features(
|
||||
sample_df_with_indicators,
|
||||
signal="LONG",
|
||||
oi_change=0.05,
|
||||
funding_rate=0.0002,
|
||||
)
|
||||
assert feat["oi_change"] == pytest.approx(0.05)
|
||||
assert feat["funding_rate"] == pytest.approx(0.0002)
|
||||
|
||||
|
||||
def test_build_features_defaults_to_zero_when_not_provided(sample_df_with_indicators):
|
||||
"""oi_change, funding_rate 파라미터 미제공 시 0.0으로 채워진다."""
|
||||
feat = build_features(sample_df_with_indicators, signal="LONG")
|
||||
assert feat["oi_change"] == pytest.approx(0.0)
|
||||
assert feat["funding_rate"] == pytest.approx(0.0)
|
||||
|
||||
Reference in New Issue
Block a user