diff --git a/.gitignore b/.gitignore index b0fe990..7c0d95e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ venv/ models/*.pkl data/*.parquet .worktrees/ -.DS_Store \ No newline at end of file +.DS_Store +.cursor/ \ No newline at end of file diff --git a/docs/plans/2026-03-02-oi-funding-accumulation.md b/docs/plans/2026-03-02-oi-funding-accumulation.md new file mode 100644 index 0000000..a9db0c5 --- /dev/null +++ b/docs/plans/2026-03-02-oi-funding-accumulation.md @@ -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로 실시간 수집 diff --git a/docs/plans/2026-03-02-realtime-oi-funding-features.md b/docs/plans/2026-03-02-realtime-oi-funding-features.md new file mode 100644 index 0000000..1c8af96 --- /dev/null +++ b/docs/plans/2026-03-02-realtime-oi-funding-features.md @@ -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/펀딩비 학습 데이터가 누적되어 모델 품질 향상 +- 별도 플랜 문서로 작성 예정