commit 741c9494706ee906bacd9e9ecfc01cd87c9c850d Author: dsyoon Date: Fri Jun 12 10:01:43 2026 +0900 refactor: Git에서 데이터 제거, 설정·코드만 유지 파이프라인 산출물(data/, docs/)을 Git 추적에서 제외하고 히스토리를 단일 커밋으로 재구성해 저장소 용량을 경량화한다. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4fa8bcd --- /dev/null +++ b/.env.example @@ -0,0 +1,76 @@ +# DeepCoin — .env.example (비밀값 없음). 복사: cp .env.example .env + +# --- 빗썸 API (캔들 수집은 Public API, 키 선택) --- +BITHUMB_ACCESS_KEY= +BITHUMB_SECRET_KEY= +BITHUMB_API_URL=https://api.bithumb.com +BITHUMB_API_CANDLE_COUNT=200 +BITHUMB_MINUTE_INTERVALS=1,3,5,10,15,30,60,240 +HTS_API_RETRY_SLEEP_SEC=0.5 + +# --- 텔레그램 (선택) --- +COIN_TELEGRAM_BOT_TOKEN= +COIN_TELEGRAM_CHAT_ID= + +# --- 거래 대상 --- +SYMBOL=BTC +COIN_NAME=비트코인 + +# --- 공유: 캔들 DB (현물·선물 유일한 공유 리소스) --- +DB_PATH=coins.db +DOWNLOAD_DAYS=3650 +DOWNLOAD_INTERVALS=1,3,5,10,15,30,60,240,1440,10080,43200 +API_REQUEST_SLEEP_SEC=0.35 +API_REQUEST_RETRIES=3 + +# --- 0단계: GT 타점 (현물·선물 공통 파라미터) --- +GT_INTERVAL_MIN=3 +GT_LOOKBACK_DAYS=3447 +GT_INITIAL_CASH_KRW=200000 +GT_TRADING_FEE_RATE=0.0005 +GT_ZIGZAG_REVERSAL_PCT=5.0 +GT_MIN_LEG_PCT=3.0 +GT_PULLBACK_MIN_PCT=1.5 +GT_PULLBACK_LOCAL_ORDER=10 +GT_BREAKOUT_BUFFER_PCT=0.1 +GT_BREAKOUT_CONSOLIDATION_BARS=200 +GT_BREAKOUT_MIN_RALLY_PCT=2.0 +GT_DIV_LOCAL_ORDER=20 +GT_DIV_MIN_BARS_BETWEEN=1500 +GT_DIV_MIN_RSI_DIFF=5.0 +GT_DIV_MIN_FUTURE_MOVE_PCT=4.0 +GT_SIM_LOOKBACK_DAYS=1095 +GT_ALIGN_TOLERANCE_BARS=480 + +# --- 0단계: 현물 GT --- +GROUND_TRUTH_FILE=data/spot/ground_truth/ground_truth_trades_v3.json +GROUND_TRUTH_V1_FILE=data/spot/ground_truth/ground_truth_trades_v1.json +GROUND_TRUTH_V2_FILE=data/spot/ground_truth/ground_truth_trades_v2.json +GROUND_TRUTH_CHART_V1_FILE=docs/0_ground_truth/spot/ground_truth_chart_v1.html +GROUND_TRUTH_CHART_V2_FILE=docs/0_ground_truth/spot/ground_truth_chart_v2.html +GROUND_TRUTH_CHART_V3_FILE=docs/0_ground_truth/spot/ground_truth_chart_v3.html + +# --- 0단계: 선물 GT (0단계 공통, 차트·JSON 분리) --- +GROUND_TRUTH_FUTURES_FILE=data/futures/ground_truth/ground_truth_trades_v3.json +GROUND_TRUTH_FUTURES_V1_FILE=data/futures/ground_truth/ground_truth_trades_v1.json +GROUND_TRUTH_FUTURES_V2_FILE=data/futures/ground_truth/ground_truth_trades_v2.json +GROUND_TRUTH_FUTURES_CHART_V1_FILE=docs/0_ground_truth/futures/ground_truth_chart_v1.html +GROUND_TRUTH_FUTURES_CHART_V2_FILE=docs/0_ground_truth/futures/ground_truth_chart_v2.html +GROUND_TRUTH_FUTURES_CHART_V3_FILE=docs/0_ground_truth/futures/ground_truth_chart_v3.html + +# --- 현물 1단계: GT sim --- +GROUND_TRUTH_CHART_SIM_V1_FILE=docs/spot/1_simulation/ground_truth_chart_sim_v1.html +GROUND_TRUTH_CHART_SIM_V2_FILE=docs/spot/1_simulation/ground_truth_chart_sim_v2.html +GROUND_TRUTH_CHART_SIM_V3_FILE=docs/spot/1_simulation/ground_truth_chart_sim_v3.html + +# --- 현물 2단계: 인과 기법 GT 정합 --- +TECHNIQUES_DIR=data/spot/techniques +ANALYSIS_REPORT_JSON=docs/spot/2_analysis/comparison_report.json +ANALYSIS_REPORT_HTML=docs/spot/2_analysis/comparison_report.html +SIGNAL_TYPE_REPORT_JSON=docs/spot/2_analysis/signal_type_report.json +SIGNAL_TYPE_REPORT_HTML=docs/spot/2_analysis/signal_type_report.html +MTF_REPORT_JSON=docs/spot/2_analysis/mtf_correlation_report.json +MTF_REPORT_HTML=docs/spot/2_analysis/mtf_correlation_report.html +MTF_RULES_JSON=data/spot/mtf/mtf_rules_v3.json +CAUSAL_SIM_REPORT_JSON=docs/spot/2_analysis/causal_sim_report.json +CAUSAL_SIM_REPORT_HTML=docs/spot/2_analysis/causal_sim_report.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1bc460 --- /dev/null +++ b/.gitignore @@ -0,0 +1,96 @@ +# Local secrets and OS +.env +.DS_Store +.idea + +# Runtime data & generated artifacts (scripts로 재생성) +data/ +docs/ +logs/ +*.db + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff +*.log +local_settings.py + +# Flask stuff +instance/ +.webassets-cache + +# Scrapy stuff +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# virtualenv +.venv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject diff --git a/README.md b/README.md new file mode 100644 index 0000000..88ce9b0 --- /dev/null +++ b/README.md @@ -0,0 +1,314 @@ +# DeepCoin + +빗썸 KRW 마켓 암호화폐 캔들 데이터 수집 및 현물·선물 매매 전략 파이프라인. + +## 주요 기능 + +- 빗썸 Public API(v1) 기반 분·일·주·월봉 캔들 수집 +- SQLite(`coins.db`) 저장 — 테이블명 `{SYMBOL}_{인터벌코드}` (예: `BTC_60`, `BTC_10080`) +- 2017-01-01~ 역방향 페이지네이션 수집 (기본 3650일·10년, **1분봉 포함**) +- Ground Truth 기반 현물·선물 벤치마크 및 인과 기법 분석 + +## 요구사항 + +- Python 3.10+ +- Conda 환경 `ncue` 또는 `xavis` + +## 설치 + +```bash +cd DeepCoin +conda activate ncue +pip install -r requirements.txt +cp .env.example .env # API 키 등 입력 +``` + +## 환경 변수 + +| 변수 | 설명 | 기본값 | +|------|------|--------| +| `SYMBOL` | 코인 심볼 | `BTC` | +| `COIN_NAME` | 코인 이름 | `비트코인` | +| `DB_PATH` | SQLite 경로 | `coins.db` | +| `DOWNLOAD_DAYS` | 전체 수집·차트 일수 (10년) | `3650` | +| `DOWNLOAD_INTERVALS` | 인터벌 코드 목록 (`1`=1분봉 포함) | `1,3,5,10,15,30,60,240,1440,10080,43200` | +| `BITHUMB_API_CANDLE_COUNT` | 요청당 캔들 수 (최대 200) | `200` | +| `API_REQUEST_SLEEP_SEC` | API 호출 간격(초) | `0.35` | + +인터벌 코드: 분봉은 분 단위 숫자, 일봉=`1440`, 주봉=`10080`, 월봉=`43200` + +캔들 조회는 Public API이므로 API 키 없이도 동작합니다. + +--- + +## 파이프라인 + +**0단계**는 현물·선물 공통입니다. 이후 현물·선물은 각각 1~3단계로 독립 진행합니다. + +| 구분 | 단계 | 내용 | 산출물 | 스크립트 | +|------|------|------|--------|----------| +| 사전 | — | 캔들 수집 | `coins.db` | `00_download.py` | + +### 0단계 — GT 타점 (현물·선물 공통) + +| 시장 | 산출물 | 스크립트 | +|------|--------|----------| +| 현물 | `docs/0_ground_truth/spot/` | `0_ground_truth.py` | +| 선물 | `docs/0_ground_truth/futures/` | `0_ground_truth_futures.py` | + +### 현물 단계 + +| 단계 | 내용 | 산출물 | 스크립트 | +|------|------|--------|----------| +| **현물 1단계** | GT sim (벤치마크) 현물 | `docs/spot/1_simulation/` | `1_ground_truth_sim.py` | +| **현물 2단계** | 인과 기법 분석 (현물) | `docs/spot/2_analysis/` | `2_run_techniques.py` 등 | +| **현물 3단계** | 현물 실거래 운영 | (예정) | (예정) | + +### 선물 단계 + +| 단계 | 내용 | 산출물 | 스크립트 | +|------|------|--------|----------| +| **선물 1단계** | GT sim (벤치마크) 선물 | (예정) | (예정) | +| **선물 2단계** | 인과 기법 분석 (선물) | (예정) | (예정) | +| **선물 3단계** | 선물 실거래 운영 | (예정) | (예정) | + +### 단계별 상세 설명 + +#### 사전 — 캔들 수집 + +빗썸 Public API로 분·일·주·월봉(1분봉 포함) OHLCV를 수집해 `coins.db`에 저장합니다. 이후 모든 단계는 이 DB만 참조하며, 현물·선물이 DB를 공유합니다. + +- 기본 동작: DB 최신 시각 이후 **증분 갱신** (`00_download.py`) +- 전체 재수집: `--full` 옵션 (최초 1회·DB 재구축 시) +- 테이블명: `{SYMBOL}_{인터벌코드}` (예: `BTC_3`, `BTC_1440`) + +#### 0단계 — GT 타점 (현물·선물 공통) + +**Ground Truth(GT)** 는 사후적으로 “이상적인 매매 타점”을 정의한 기준 데이터입니다. 미래 캔들을 참조해 ZigZag 스윙·눌림목·돌파·다이버전스 타점을 찾으므로 **실거래에 직접 쓸 수 없고**, 이후 단계(벤치마크·기법 분석)의 **정답지(기준선)** 역할을 합니다. + +| 티어 | 포함 신호 | 설명 | +|------|-----------|------| +| **v1** | 스윙 B/S | ZigZag 스윙 저점 매수·고점 매도만 | +| **v2** | + 눌림목 B* | v1 + 상승 레그 중 눌림목 매수 | +| **v3** | + 돌파 B^ + 다이버전스 Bd/Sd | v2 + 돌파 매수 + RSI 다이버전스 매수·매도 | + +v3 신호 유형 6종: + +| `signal_type` | 라벨 | 의미 | +|---------------|------|------| +| `swing_low` | B | ZigZag 스윙 저점 매수 | +| `pullback` | B* | 상승 추세 눌림목 매수 | +| `breakout` | B^ | 횡보·레인지 상단 돌파 매수 | +| `div_bull` | Bd | RSI 상승 다이버전스 매수 | +| `swing_high` | S | ZigZag 스윙 고점 매도 | +| `div_bear` | Sd | RSI 하락 다이버전스 매도 | + +**현물 0단계** (`0_ground_truth.py`): 3분봉·2017년~ 구간에서 GT를 생성하고, 매수/매도 마커가 표시된 인터랙티브 차트를 만듭니다. + +- 데이터: `data/spot/ground_truth/ground_truth_trades_v{1,2,3}.json` +- 차트: `docs/0_ground_truth/spot/ground_truth_chart_v{1,2,3}.html` + +**선물 0단계** (`0_ground_truth_futures.py`): 동일한 현물 GT 신호를 선물 롱·숏 4색 마커로 변환합니다. 가격·시각은 현물 GT와 동일하며, 해석만 선물 관점으로 매핑합니다. + +| 현물 GT | 선물 마커 | 의미 | +|---------|-----------|------| +| buy (B·B*·B^·Bd) | **L↑** | 롱 진입 (상방 매수) | +| buy | **S↑** | 숏 청산 (하방 매도) | +| sell (S·Sd) | **L↓** | 롱 청산 (상방 매도) | +| sell | **S↓** | 숏 진입 (하방 매수) | + +- 데이터: `data/futures/ground_truth/ground_truth_trades_v{1,2,3}.json` +- 차트: `docs/0_ground_truth/futures/ground_truth_chart_v{1,2,3}.html` + +#### 현물 1단계 — GT sim (벤치마크) 현물 + +0단계 GT 타점을 **그대로** 따라 매매했을 때의 수익을 재현하는 **현물 벤치마크**입니다. “GT를 완벽히 따랐다면 얼마를 벌 수 있었는가”를 측정하며, 이후 인과 기법·실거래 전략이 비교해야 할 **상한선(천장)** 입니다. + +- 기간: 최근 **3년** (`GT_SIM_LOOKBACK_DAYS=1095`) +- 초기 자본: **20만 원** +- 매수 규칙: 총평가 구간별 현금 비율 상한 — 1억↑ 10% · 10억↑ 5% · 100억↑ 1% +- 수수료: 편도 0.05% (`GT_TRADING_FEE_RATE`) +- 산출물: `docs/spot/1_simulation/ground_truth_chart_sim_v{1,2,3}.html` (누적 수익 곡선·체결 마커 포함) + +#### 현물 2단계 — 인과 기법 분석 (현물) + +0단계 GT v3 타점을 **미래 데이터 없이** 재현할 수 있는 인과(causal) 매매 기법 39종을 실행하고, GT와 얼마나 맞는지 정량 평가합니다. 목표는 “실시간으로 쓸 수 있는 기법 조합이 GT 타점을 얼마나 포착하는가”를 파악하는 것입니다. + +| 스크립트 | 분석 내용 | 산출물 | +|----------|-----------|--------| +| `2_run_techniques.py` | 39종 기법 전체 실행 + GT 정합 비교 | `comparison_report.html`, `data/spot/techniques/*.json` | +| `2_run_signal_type_align.py` | B/B*/B^/Bd/S/Sd 유형별 recall | `signal_type_report.html` | +| `2_run_mtf_analysis.py` | GT v3 × 10개 TF 피처 상관 (Cohen's d) | `mtf_correlation_report.html`, `data/spot/mtf/mtf_rules_v3.json` | + +평가 지표: + +- **recall**: GT 신호 대비 기법 신호가 허용 오차(`GT_ALIGN_TOLERANCE_BARS`) 내에 맞춘 비율 +- **score**: recall·정밀도·타점 거리를 종합한 정합 점수 +- **MTF 상관**: 일·주·월 등 상위 TF 지표가 GT 유형별로 어떤 패턴을 보이는지 통계 분석 + +#### 현물 3단계 — 현물 실거래 운영 (예정) + +현물 2단계에서 검증된 인과 기법(또는 `composite_v3` 등 통합 기법)을 빗썸 현물 API에 연결해 **실제 주문·체결·리스크 관리**를 수행하는 단계입니다. + +- 실시간 캔들 수신 및 인과 신호 생성 +- 주문 실행·포지션(보유 코인)·손절/익절 규칙 +- 1단계 GT sim 벤치마크 대비 실거래 성과 모니터링 +- 텔레그램 등 알림·로그·장애 복구 + +#### 선물 1단계 — GT sim (벤치마크) 선물 (예정) + +선물 0단계 GT 타점(L↑/L↓/S↓/S↑)을 그대로 따라 롱·숏 포지션을 운용했을 때의 수익을 재현하는 **선물 벤치마크**입니다. 현물 1단계와 동일한 개념이나, 롱·숏 양방향·레버리지·청산 규칙 등 선물 고유 파라미터를 반영합니다. + +- 0단계 선물 GT 4색 마커 기준 체결 시뮬레이션 +- 현물 1단계와 동일한 초기 자본·기간·수수료 체계 (선물 수수료·슬리피지 별도 정의 예정) +- 산출물 예정: `docs/futures/1_simulation/` + +#### 선물 2단계 — 인과 기법 분석 (선물) (예정) + +현물 2단계와 동일한 인과 기법 39종(또는 선물 특화 변형)을 선물 GT v3 타점과 정합 평가합니다. 롱·숏 각각의 recall, MTF 필터, 신호 유형별 성능을 선물 관점에서 분석합니다. + +- 선물 GT 6종 신호 ↔ 인과 기법 정합 리포트 +- 선물 MTF 상관 분석 및 규칙 추출 +- 산출물 예정: `docs/futures/2_analysis/`, `data/futures/techniques/`, `data/futures/mtf/` + +#### 선물 3단계 — 선물 실거래 운영 (예정) + +선물 2단계에서 검증된 전략을 실제 선물 거래 API에 연결해 롱·숏 포지션을 자동 운용하는 단계입니다. + +- 실시간 인과 신호 → 롱/숏 진입·청산 +- 레버리지·증거금·청산가 모니터링 +- 선물 1단계 sim 벤치마크 대비 실거래 성과 추적 + +### 실행 순서 + +0단계(GT 타점)를 먼저 만든 뒤, 현물 1단계 sim → 현물 2단계 인과 분석 순으로 진행합니다. 선물·현물 3단계는 각 시장의 1·2단계 완료 후 구현 예정입니다. + +```bash +# 사전: 전체 인터벌 증분 갱신 (1분~월봉, DB 최신 이후만) +python scripts/00_download.py + +# 사전: 전체 인터벌 풀 다운 (최초 1회 또는 DB 재구축) +python scripts/00_download.py --full + +# 1분봉만 풀 다운 (수 시간 소요) +python scripts/00_download_candles.py --full --days 3650 --intervals 1 + +# 0단계: Ground Truth 타점 생성 (v1/v2/v3) +python scripts/0_ground_truth.py + +# 0단계: 선물 GT 타점 차트 (롱·숏 4색, 타점만) +python scripts/0_ground_truth_futures.py + +# 현물 1단계: GT sim (최근 3년 · 20만원) +python scripts/1_ground_truth_sim.py --tier all + +# 현물 2단계: 인과 기법 GT 정합 비교 +python scripts/2_run_techniques.py +python scripts/2_run_signal_type_align.py --from-cache +python scripts/2_run_mtf_analysis.py +``` + +### 현물 2단계 인과 기법 목록 (39종) + +`src/deepcoin/techniques/` — 단일 33 + 복합 6, **미래 데이터 미사용**. 카탈로그: `data/spot/techniques_catalog.json` + +| ID | 기법 | 유형 | 설명 | +|----|------|------|------| +| `zigzag_causal` | 인과 ZigZag | 스윙 B/S | 되돌림 % 확정 시 스윙 저점 매수·고점 매도 (GT ZigZag 인과 버전) | +| `minor_swing` | 소형 스윙 하이브리드 | 하이브리드 | 소형 ZigZag(2.5%) + 국소 극값 — GT 중간 눌림목 보완 | +| `local_extrema` | 국소 극값 | 스윙 B/S | 국소 저점 매수·고점 매도 (눌림목 유형 포착) | +| `pivot_swing` | 피벗 스윙 | 스윙 B/S | 피벗 저점 매수·고점 매도 (스윙 B/S) | +| `fractal_swing` | 프랙탈 스윙 | 스윙 B/S | Williams 프랙탈 스윙 저점 매수·고점 매도 | +| `swing_failure` | 스윙 실패 | 스윙 B/S | 스윙 고저점 돌파 실패(페일드 브레이크아웃) 반전 신호 | +| `donchian` | 돈치안 채널 | 스윙 B/S | 돈치안(40) 채널 하단 매수·상단 매도 | +| `ema_pullback` | EMA 눌림목 | 눌림목 B* | EMA(20/60) 눌림목 반등 매수·되돌림 매도 (B*) | +| `fib_pullback` | 피보나치 눌림목 | 눌림목 B* | 피보나치 38.2~61.8% 되돌림 구간 매수·매도 (B*) | +| `support_bounce` | 지지·저항 반등 | 눌림목 B* | N봉 지지·저항 터치 후 반전 (B*) | +| `keltner_breakout` | Keltner 돌파 | 돌파 B^ | Keltner 채널 상·하단 돌파 (B^) | +| `range_breakout` | 레인지 돌파 | 돌파 B^ | N봉 레인지 상·하단 돌파 (B^) | +| `volume_breakout` | 거래량 돌파 | 돌파 B^ | 거래량 스파이크 + 레인지 돌파 (B^) | +| `bb_squeeze_breakout` | BB 스퀴즈 돌파 | 돌파 B^ | 볼린저 밴드 스퀴즈 후 돌파 (B^) | +| `rsi_divergence` | RSI 다이버전스 | 다이버전스 Bd/Sd | RSI 상승(Bd)·하락(Sd) 다이버전스 | +| `macd_divergence` | MACD 다이버전스 | 다이버전스 Bd/Sd | MACD 히스토그램 상승(Bd)·하락(Sd) 다이버전스 | +| `obv_divergence` | OBV 다이버전스 | 다이버전스 Bd/Sd | OBV 상승(Bd)·하락(Sd) 다이버전스 | +| `bb_reversal` | 볼린저 역추세 | 지표 | BB(20,2) 하단 매수·상단 매도 + EMA 추세 필터 | +| `ma_cross` | EMA 크로스 | 지표 | EMA(20/60) 골든크로스 매수·데드크로스 매도 | +| `rsi_swing` | RSI 스윙 | 지표 | RSI(14) 과매도 반등 매수·과매수 하락 매도 | +| `macd_cross` | MACD 크로스 | 지표 | MACD(12,26,9) 시그널선 골든·데드 크로스 | +| `supertrend` | Supertrend | 추세 | Supertrend 상승·하락 전환 신호 | +| `adx_trend` | ADX 추세 | 추세 | ADX(14) 강세 + DI 크로스 추세 신호 | +| `ichimoku_trend` | 일목 추세 | 추세 | 일목 전환선·기준선 크로스 추세 신호 | +| `parabolic_sar` | Parabolic SAR | 추세 | Parabolic SAR 추세 전환 신호 | +| `stochastic_cross` | Stochastic 크로스 | 모멘텀 | Stochastic(14,3) %K/%D 크로스 | +| `cci_extreme` | CCI 극값 | 모멘텀 | CCI(20) 과매도·과매수 반전 | +| `roc_reversal` | ROC 반전 | 모멘텀 | ROC(12) 극값 반전 신호 | +| `keltner_reversal` | Keltner 역추세 | 변동성 | Keltner 채널 하단 매수·상단 매도 | +| `atr_channel` | ATR 채널 | 변동성 | EMA(20) ± ATR(14)×2 채널 반전 | +| `pivot_points` | 피벗 포인트 | 구조 | 롤링 피벗 S1/R1 반전 | +| `support_resistance` | 구조적 지지·저항 | 구조 | 스윙 피벗 기반 S/R 반전 | +| `volume_spike` | 거래량 스파이크 | 거래량 | 거래량 스파이크 후 반전 (클라이맥스) | +| `composite_swing` | 스윙 복합 | 복합 | 스윙 저점·고점 전담 기법 가중 투표 (B/S) | +| `composite_pullback` | 눌림목 복합 | 복합 | 눌림목·역추세 기법 가중 투표 (B*) | +| `composite_breakout` | 돌파 복합 | 복합 | 돌파·모멘텀 기법 가중 투표 (B^) | +| `composite_divergence` | 다이버전스 복합 | 복합 | RSI/MACD/OBV 다이버전스 가중 투표 (Bd/Sd) | +| `composite_v3` | v3 통합 스코어링 | 복합 | v3 GT 6종 신호 유형별 핵심 기법 가중 투표 + EMA(60) 추세 필터 | +| `composite_full` | 전체 통합 복합 | 복합 | 전체 인과 기법 가중 투표 + EMA 추세 필터 | + +--- + +## 디렉터리 구조 + +```text +DeepCoin/ +├── src/deepcoin/ +│ ├── data/ # 캔들 수집·로드 +│ ├── ground_truth/ # 0·1단계 GT·차트 +│ ├── techniques/ # 현물 2단계 인과 기법 (39종) +│ ├── evaluation/ # 현물 2단계 GT 정합 평가 +│ └── mtf/ # 현물 2단계 MTF 분석 +├── scripts/ # 접두사: 00=사전, 0=0단계, 1=현물1, 2=현물2 +│ ├── 00_download.py # 사전 (증분/풀다운 래퍼) +│ ├── 00_download_candles.py # 사전 (캔들 수집 본체) +│ ├── 0_ground_truth.py # 0단계 현물 GT +│ ├── 0_ground_truth_futures.py # 0단계 선물 GT 차트 +│ ├── 1_ground_truth_sim.py # 현물 1단계 sim +│ ├── 2_run_techniques.py # 현물 2단계 +│ ├── 2_run_signal_type_align.py +│ └── 2_run_mtf_analysis.py +├── coins.db # 공유 캔들 DB (현물·선물 유일한 공유 리소스) +├── data/ +│ ├── spot/ # 현물 전용 +│ │ ├── ground_truth/ # GT JSON v1~v3 +│ │ ├── techniques/ # 2단계 기법 실행 결과 +│ │ └── mtf/ # MTF 규칙 +│ └── futures/ # 선물 (0단계 GT만, 1~3단계 추후) +│ └── ground_truth/ +├── docs/ +│ ├── 0_ground_truth/ # 0단계 (현물·선물 공통) +│ │ ├── spot/ +│ │ └── futures/ +│ └── spot/ # 현물 1~2단계 +│ ├── 1_simulation/ +│ └── 2_analysis/ +``` + +현물·선물은 `coins.db`만 공유합니다. 현재 구현 범위는 **0단계(공통) + 현물 1·2단계**이며, 선물 1~3단계·현물 3단계는 추후 구현 예정입니다. + +## 변경 이력 + +- 2026-06-11: `docs/0_ground_truth/{spot,futures}/`로 0단계 차트를 docs 직하로 이동 +- 2026-06-11: `scripts/` 접두사를 파이프라인 단계와 일치 (`00_` 사전, `0_` 0단계, `1_` 현물1, `2_` 현물2) +- 2026-06-11: README 파이프라인 단계별 상세 설명 추가 (0단계 공통 + 현물·선물 1~3단계) +- 2026-06-11: README 파이프라인을 0단계(공통) + 현물·선물 각 1~3단계 체계로 정리 +- 2026-06-11: 선물 1~3단계·`04_run_causal_futures` 제거, 현물 0·1·2단계만 유지 +- 2026-06-11: `docs/spot/`·`docs/futures/` 상위 분리 (`data/`와 동일 구조) +- 2026-06-11: `data/spot/`·`data/futures/` 완전 분리 (`coins.db`만 공유), 선물 GT·MTF·causal 독립 경로 +- 2026-06-11: 2단계 38종 기법 리포트 재생성, 3단계 causal/MTF/walkforward 재검증, README 39종 표 반영 +- 2026-06-11: 캔들 수집 기본 동작을 DB 최신 이후 증분 갱신으로 변경 (`--full`로 전체 재수집) +- 2026-06-10: docs를 단계별 폴더(`0_ground_truth`~`3_causal`)로 재구성, 단계 정의 정렬 (0=GT 타점, 1=sim) +- 2026-06-09: 파이프라인 번호를 0~4단계 체계로 통일 (0=벤치마크, 1=GT+기법, 2=인과분석, 3=시뮬, 4=실거래) +- 2026-06-09: 3단계 인과 선물 전략 (composite_v3 + ATR) + 0단계 벤치마크 비교 +- 2026-06-09: v3 신호 유형별 GT 정합 리포트 + composite_v3 통합 기법 +- 2026-06-09: 선물 GT 차트 futures/gt/ 정리, 마커 UI 개선 +- 2026-06-08: Ground Truth v1/v2/v3 + 매매 기법 8종 +- 2026-06-07: 캔들 수집 모듈 초기 구현 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d0c0716 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-dotenv==1.0.1 +requests==2.32.3 +pandas==2.2.3 diff --git a/scripts/00_download.py b/scripts/00_download.py new file mode 100644 index 0000000..b483f6a --- /dev/null +++ b/scripts/00_download.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +"""사전: 빗썸 캔들 수집 (기본=전체 인터벌 증분, --full=풀 다운).""" + +import runpy +from pathlib import Path + +if __name__ == "__main__": + target = Path(__file__).resolve().parent / "00_download_candles.py" + runpy.run_path(str(target), run_name="__main__") diff --git a/scripts/00_download_candles.py b/scripts/00_download_candles.py new file mode 100644 index 0000000..c4c5daf --- /dev/null +++ b/scripts/00_download_candles.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""사전: 빗썸 캔들 수집 — 기본: 전체 인터벌 증분 갱신, --full: 전체 인터벌 풀 다운.""" + +from __future__ import annotations + +import argparse +import logging +import sys +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from dataclasses import replace + +from deepcoin.config import load_settings +from deepcoin.data.candle_store import CandleStore +from deepcoin.data.downloader import CandleDownloader +from deepcoin.data.intervals import INTERVAL_1MIN, estimate_download_requests, interval_label + + +def _configure_logging(verbose: bool) -> None: + """로깅 레벨을 설정한다.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser( + description="빗썸 캔들 데이터 수집 (DOWNLOAD_INTERVALS 전체, 1분봉 포함)", + ) + parser.add_argument( + "--full", + action="store_true", + help="전체 인터벌을 DOWNLOAD_DAYS 구간만큼 역방향 풀 다운 (최초 1회·재구축)", + ) + parser.add_argument( + "--days", + type=int, + default=None, + help="풀 다운(--full) 또는 DB 비어 있을 때 목표 일수 (기본: DOWNLOAD_DAYS)", + ) + parser.add_argument( + "--intervals", + type=str, + default=None, + help="(고급) 쉼표 구분 인터벌만 수집. 기본: .env DOWNLOAD_INTERVALS 전체", + ) + parser.add_argument( + "--include-1min", + action="store_true", + help="1분봉(1)을 기존 DOWNLOAD_INTERVALS에 추가하여 수집", + ) + parser.add_argument("-v", "--verbose", action="store_true", help="디버그 로그") + args = parser.parse_args() + + _configure_logging(args.verbose) + settings = load_settings() + + if args.intervals: + settings = replace( + settings, + download_intervals=[ + int(x.strip()) for x in args.intervals.split(",") if x.strip() + ], + ) + elif args.include_1min and INTERVAL_1MIN not in settings.download_intervals: + settings = replace( + settings, + download_intervals=sorted({*settings.download_intervals, INTERVAL_1MIN}), + ) + + days = args.days or settings.download_days + mode_label = "full" if args.full else "incremental" + log = logging.getLogger(__name__) + log.info( + "대상=%s DB=%s mode=%s days=%s intervals=%s", + settings.market, + settings.db_path, + mode_label, + days, + settings.download_intervals, + ) + for interval in settings.download_intervals: + est = estimate_download_requests(interval, days, batch_size=settings.candle_count) + log.info( + "예상 API 요청: %s ≈ %s회 (sleep %.2fs)", + interval_label(interval), + est, + settings.request_sleep_sec, + ) + + store = CandleStore(settings.db_path) + try: + for interval in settings.download_intervals: + if args.full: + est = estimate_download_requests(interval, days, batch_size=settings.candle_count) + log.info( + "예상 API 요청: %s ≈ %s회 (풀 다운, sleep %.2fs)", + interval_label(interval), + est, + settings.request_sleep_sec, + ) + else: + _, _, db_max = store.get_range(settings.symbol, interval) + if db_max is None: + est = estimate_download_requests(interval, days, batch_size=settings.candle_count) + log.info( + "예상 API 요청: %s ≈ %s회 (DB 없음 → 풀 다운)", + interval_label(interval), + est, + ) + else: + gap_days = max(1, (datetime.now() - db_max).days + 1) + est = estimate_download_requests(interval, gap_days, batch_size=settings.candle_count) + log.info( + "예상 API 요청: %s ≈ %s회 (증분, DB=%s, 갭≈%s일)", + interval_label(interval), + est, + db_max.strftime("%Y-%m-%d %H:%M:%S"), + gap_days, + ) + + downloader = CandleDownloader(settings) + results = downloader.download_all(store, days=days, full=args.full) + + print(f"\n=== 수집 완료 ({mode_label}) ===") + for result in results: + count, min_dt, max_dt = store.get_range(settings.symbol, result.interval_min) + min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-" + max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-" + if result.mode == "uptodate": + flag = "UPTODATE" + elif result.reached_target: + flag = "OK" + else: + flag = "PARTIAL" + label = interval_label(result.interval_min) + print( + f"[{flag}] {label} ({result.interval_min}) mode={result.mode} | " + f"requests={result.requests} upsert={result.saved_rows} " + f"db_rows={count} range={min_s} ~ {max_s}" + ) + finally: + store.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/0_ground_truth.py b/scripts/0_ground_truth.py new file mode 100644 index 0000000..9bcb374 --- /dev/null +++ b/scripts/0_ground_truth.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""0단계: Ground Truth — 사후 최적 매수·매도 타점 (v1/v2/v3).""" + +from __future__ import annotations + +import argparse +import logging +import sys +from dataclasses import replace +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from deepcoin.config import Settings, load_settings +from deepcoin.data.intervals import interval_label +from deepcoin.ground_truth.chart import render_ground_truth_chart +from deepcoin.ground_truth.ground_truth import GtParams, build_ground_truth, save_ground_truth + +TIER_DESCRIPTIONS = { + "v1": "스윙만 (최소 매수·매도)", + "v2": "스윙 + 눌림목", + "v3": "스윙 + 눌림목 + 돌파 + 다이버전스", +} + + +def _configure_logging(verbose: bool) -> None: + """로깅 레벨을 설정한다.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def _base_params(settings: Settings, args: argparse.Namespace) -> GtParams: + """CLI·환경 설정을 반영한 공통 GT 파라미터.""" + return GtParams( + interval_min=args.interval or settings.gt_interval_min, + lookback_days=args.days or settings.gt_lookback_days, + zigzag_reversal_pct=args.zigzag or settings.gt_zigzag_reversal_pct, + min_leg_pct=args.min_leg or settings.gt_min_leg_pct, + pullback_min_pct=settings.gt_pullback_min_pct, + pullback_local_order=settings.gt_pullback_local_order, + breakout_buffer_pct=settings.gt_breakout_buffer_pct, + breakout_consolidation_bars=settings.gt_breakout_consolidation_bars, + breakout_min_rally_pct=settings.gt_breakout_min_rally_pct, + div_local_order=settings.gt_div_local_order, + div_min_bars_between=settings.gt_div_min_bars_between, + div_min_rsi_diff=settings.gt_div_min_rsi_diff, + div_min_future_move_pct=settings.gt_div_min_future_move_pct, + ) + + +def _tier_targets(settings: Settings, tier_arg: str) -> list[tuple[str, Path, Path]]: + """생성할 티어 목록 (tier, json_path, chart_path).""" + all_tiers: dict[str, tuple[Path, Path]] = { + "v1": (settings.ground_truth_v1_file, settings.ground_truth_chart_v1_file), + "v2": (settings.ground_truth_v2_file, settings.ground_truth_chart_v2_file), + "v3": (settings.ground_truth_file, settings.ground_truth_chart_v3_file), + } + if tier_arg == "all": + return [(t, *paths) for t, paths in all_tiers.items()] + return [(tier_arg, *all_tiers[tier_arg])] + + +def _print_tier_summary( + tier: str, + result: dict[str, Any], + json_path: Path, + chart_path: Path | None, +) -> None: + """티어별 GT 요약을 출력한다.""" + summary = result["summary"] + meta = result["meta"] + pnl = result["pnl"] + + print(f"\n=== Ground Truth {tier.upper()} ({TIER_DESCRIPTIONS[tier]}) ===") + print(f"대상: {meta['symbol']} ({meta['interval_label']})") + print(f"GT·수익 기간: {meta['data_from']} ~ {meta['data_to']} ({meta['bar_count']}봉)") + print(f"피벗: {meta['pivot_count']}개 → 레그: {summary['leg_count']}개") + print( + f"매수 타점: {summary['buy_count']}개 " + f"(눌림 {summary.get('pullback_buy_count', 0)} / 돌파 {summary.get('breakout_buy_count', 0)} " + f"/ 다이버전스 {summary.get('divergence_buy_count', 0)}) " + f"/ 매도: {summary['sell_count']}개 (다이버전스 {summary.get('divergence_sell_count', 0)})" + ) + print(f"레그 수익률 — 평균: {summary['avg_leg_pct']}%, 최대: {summary['max_leg_pct']}%") + + period = "" + if pnl.get("period_from"): + period = f" ({pnl['period_from'][:10]} ~ {pnl['period_to'][:10]})" + print(f"누적 수익{period}: {pnl['final_cash_krw']:,.0f}원 ({pnl['total_return_pct']:+.2f}%)") + print(f"JSON: {json_path}") + if chart_path: + print(f"차트: {chart_path}") + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="0단계: Ground Truth 타점 생성") + parser.add_argument("--interval", type=int, default=None, help="GT 인터벌(분)") + parser.add_argument("--days", type=int, default=None, help="GT·타점 기간(일). 기본 3447(2017~)") + parser.add_argument("--zigzag", type=float, default=None, help="ZigZag 되돌림 %%") + parser.add_argument("--min-leg", type=float, default=None, help="최소 레그 수익률 %%") + parser.add_argument("--no-chart", action="store_true", help="HTML 차트 생략") + parser.add_argument( + "--tier", + choices=("v1", "v2", "v3", "all"), + default="all", + help="생성할 GT 버전 (v1=스윙만, v2=+눌림, v3=전체, all=3종)", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + _configure_logging(args.verbose) + settings = load_settings() + base = _base_params(settings, args) + tiers = _tier_targets(settings, args.tier) + + logging.info( + "GT 생성: %s %s, %s일, ZigZag=%s%%, min_leg=%s%%, 초기=%s원, tier=%s", + settings.symbol, + interval_label(base.interval_min), + base.lookback_days, + base.zigzag_reversal_pct, + base.min_leg_pct, + f"{settings.gt_initial_cash_krw:,.0f}", + args.tier, + ) + + print("\n=== Ground Truth 완료 (0단계) ===") + print(f"차트·타점 표시: 최근 {settings.download_days}일 (2017~)") + + for tier, json_path, chart_path in tiers: + params = replace(base, chart_tier=tier) + result = build_ground_truth( + db_path=settings.db_path, + symbol=settings.symbol, + coin_name=settings.coin_name, + params=params, + initial_cash_krw=settings.gt_initial_cash_krw, + fee_rate=settings.gt_trading_fee_rate, + ) + save_ground_truth(result, json_path) + + rendered: Path | None = None + if not args.no_chart: + rendered = render_ground_truth_chart( + db_path=settings.db_path, + symbol=settings.symbol, + gt_result=result, + output_path=chart_path, + chart_lookback_days=settings.download_days, + ) + + _print_tier_summary(tier, result, json_path, rendered) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/0_ground_truth_futures.py b/scripts/0_ground_truth_futures.py new file mode 100644 index 0000000..b754fee --- /dev/null +++ b/scripts/0_ground_truth_futures.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""0단계: 선물 GT 타점 차트 (현물 GT → 롱·숏 4색 마커).""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from deepcoin.config import Settings, load_settings +from deepcoin.ground_truth.futures_chart import render_futures_ground_truth_chart + +TIER_DESCRIPTIONS = { + "v1": "스윙만 (최소 매수·매도)", + "v2": "스윙 + 눌림목", + "v3": "스윙 + 눌림목 + 돌파 + 다이버전스", +} + + +def _configure_logging(verbose: bool) -> None: + """로깅 레벨을 설정한다.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def _tier_targets(settings: Settings, tier_arg: str) -> list[tuple[str, Path, Path]]: + """생성할 티어 목록 (tier, futures_json_path, futures_chart_path).""" + all_tiers: dict[str, tuple[Path, Path]] = { + "v1": ( + settings.ground_truth_futures_v1_file, + settings.ground_truth_futures_chart_v1_file, + ), + "v2": ( + settings.ground_truth_futures_v2_file, + settings.ground_truth_futures_chart_v2_file, + ), + "v3": ( + settings.ground_truth_futures_file, + settings.ground_truth_futures_chart_v3_file, + ), + } + if tier_arg == "all": + return [(t, *paths) for t, paths in all_tiers.items()] + return [(tier_arg, *all_tiers[tier_arg])] + + +def _load_gt(json_path: Path) -> dict[str, Any]: + """선물 GT JSON을 로드한다.""" + with json_path.open(encoding="utf-8") as fp: + return json.load(fp) + + +def _print_summary(tier: str, gt_result: dict[str, Any], chart_path: Path) -> None: + """티어별 선물 차트 요약을 출력한다.""" + meta = gt_result["meta"] + summary = gt_result["summary"] + print(f"\n=== 선물 GT 차트 {tier.upper()} ({TIER_DESCRIPTIONS[tier]}) ===") + print(f"대상: {meta['symbol']} ({meta['interval_label']})") + print(f"GT 기간: {meta['data_from']} ~ {meta['data_to']}") + print( + f"선물 GT 타점: 매수 {summary['buy_count']} / 매도 {summary['sell_count']} " + f"→ 선물 상방·하방 각 {summary['buy_count']}/{summary['sell_count']} 마커" + ) + print(f"차트: {chart_path}") + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser( + description="선물 GT JSON 기반 Ground Truth 차트 (롱·숏 4색)" + ) + parser.add_argument( + "--tier", + choices=("v1", "v2", "v3", "all"), + default="all", + help="대상 GT 버전", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + _configure_logging(args.verbose) + settings = load_settings() + tiers = _tier_targets(settings, args.tier) + + print("\n=== 선물 Ground Truth 차트 생성 ===") + print("현물 GT 타점 → L↑상방매수 L↓상방매도 S↓하방매수 S↑하방매도") + + for tier, json_path, chart_path in tiers: + if not json_path.exists(): + logging.error("현물 GT JSON 없음: %s — 먼저 0_ground_truth.py 실행", json_path) + return 1 + + gt_result = _load_gt(json_path) + render_futures_ground_truth_chart( + db_path=settings.db_path, + symbol=settings.symbol, + gt_result=gt_result, + output_path=chart_path, + chart_lookback_days=settings.download_days, + ) + _print_summary(tier, gt_result, chart_path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/1_ground_truth_sim.py b/scripts/1_ground_truth_sim.py new file mode 100644 index 0000000..fc68dec --- /dev/null +++ b/scripts/1_ground_truth_sim.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""1단계: Ground Truth 타점 기준 3년 수익 sim + sim 차트 생성.""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from dataclasses import replace +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from deepcoin.config import Settings, load_settings +from deepcoin.data.candle_loader import load_candles +from deepcoin.data.intervals import interval_label +from deepcoin.ground_truth.chart import render_ground_truth_sim_chart +from deepcoin.ground_truth.ground_truth import GtParams, build_ground_truth, save_ground_truth +from deepcoin.ground_truth.pnl import simulate_gt_signals_pnl + +TIER_DESCRIPTIONS = { + "v1": "스윙만 (최소 매수·매도)", + "v2": "스윙 + 눌림목", + "v3": "스윙 + 눌림목 + 돌파 + 다이버전스", +} + + +def _configure_logging(verbose: bool) -> None: + """로깅 레벨을 설정한다.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def _base_params(settings: Settings, args: argparse.Namespace) -> GtParams: + """CLI·환경 설정을 반영한 공통 GT 파라미터.""" + return GtParams( + interval_min=args.interval or settings.gt_interval_min, + lookback_days=args.days or settings.gt_lookback_days, + zigzag_reversal_pct=args.zigzag or settings.gt_zigzag_reversal_pct, + min_leg_pct=args.min_leg or settings.gt_min_leg_pct, + pullback_min_pct=settings.gt_pullback_min_pct, + pullback_local_order=settings.gt_pullback_local_order, + breakout_buffer_pct=settings.gt_breakout_buffer_pct, + breakout_consolidation_bars=settings.gt_breakout_consolidation_bars, + breakout_min_rally_pct=settings.gt_breakout_min_rally_pct, + div_local_order=settings.gt_div_local_order, + div_min_bars_between=settings.gt_div_min_bars_between, + div_min_rsi_diff=settings.gt_div_min_rsi_diff, + div_min_future_move_pct=settings.gt_div_min_future_move_pct, + ) + + +def _tier_targets(settings: Settings) -> list[tuple[str, Path, Path, Path]]: + """티어별 (tier, json, chart, sim_chart) 경로.""" + return [ + ( + "v1", + settings.ground_truth_v1_file, + settings.ground_truth_chart_v1_file, + settings.ground_truth_chart_sim_v1_file, + ), + ( + "v2", + settings.ground_truth_v2_file, + settings.ground_truth_chart_v2_file, + settings.ground_truth_chart_sim_v2_file, + ), + ( + "v3", + settings.ground_truth_file, + settings.ground_truth_chart_v3_file, + settings.ground_truth_chart_sim_v3_file, + ), + ] + + +def _load_or_build_gt( + settings: Settings, + params: GtParams, + json_path: Path, + rebuild: bool, +) -> dict[str, Any]: + """GT JSON을 로드하거나 새로 생성한다.""" + if json_path.exists() and not rebuild: + with json_path.open(encoding="utf-8") as fp: + return json.load(fp) + + result = build_ground_truth( + db_path=settings.db_path, + symbol=settings.symbol, + coin_name=settings.coin_name, + params=params, + initial_cash_krw=settings.gt_initial_cash_krw, + fee_rate=settings.gt_trading_fee_rate, + ) + save_ground_truth(result, json_path) + return result + + +def _print_sim_summary( + tier: str, + sim_pnl: dict[str, Any], + sim_chart_path: Path, +) -> None: + """티어별 시뮬 요약을 출력한다.""" + print(f"\n=== 1단계 sim {tier.upper()} ({TIER_DESCRIPTIONS[tier]}) ===") + print( + f"기간: {sim_pnl['period_from']} ~ {sim_pnl['period_to']} " + f"({sim_pnl['sim_lookback_days']}일)" + ) + print( + f"초기 {sim_pnl['initial_cash_krw']:,.0f}원 → " + f"최종 {sim_pnl['final_equity_krw']:,.0f}원 " + f"({sim_pnl['total_return_pct']:+.2f}%)" + ) + print( + f"현금 {sim_pnl['final_cash_krw']:,.0f}원 + " + f"코인 {sim_pnl['final_coin_qty']:.8f} " + f"(평가 {sim_pnl['final_coin_value_krw']:,.0f}원)" + ) + print( + f"체결 매수 {sim_pnl['buys_executed']} / 매도 {sim_pnl['sells_executed']} | " + f"스킵 매수 {sim_pnl['buys_skipped']} / 매도 {sim_pnl['sells_skipped']}" + ) + print(f"sim 차트: {sim_chart_path}") + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="1단계: Ground Truth 3년 sim") + parser.add_argument("--interval", type=int, default=None, help="GT 인터벌(분)") + parser.add_argument("--days", type=int, default=None, help="GT 타점 기간(일). 기본 730") + parser.add_argument( + "--sim-days", + type=int, + default=None, + help="시뮬 기간(일). 기본 GT_SIM_LOOKBACK_DAYS 또는 365", + ) + parser.add_argument("--zigzag", type=float, default=None, help="ZigZag 되돌림 %%") + parser.add_argument("--min-leg", type=float, default=None, help="최소 레그 수익률 %%") + parser.add_argument( + "--tier", + choices=("v1", "v2", "v3", "all"), + default="all", + help="대상 GT 버전", + ) + parser.add_argument( + "--rebuild-gt", + action="store_true", + help="GT JSON을 다시 생성 (없으면 자동 생성)", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + _configure_logging(args.verbose) + settings = load_settings() + base = _base_params(settings, args) + sim_days = args.sim_days or settings.gt_sim_lookback_days + tiers = _tier_targets(settings) + if args.tier != "all": + tiers = [t for t in tiers if t[0] == args.tier] + + logging.info( + "1단계 sim: %s %s, GT %s일, sim %s일, 초기=%s원", + settings.symbol, + interval_label(base.interval_min), + base.lookback_days, + sim_days, + f"{settings.gt_initial_cash_krw:,.0f}", + ) + + print("\n=== Ground Truth 1단계 sim ===") + print(f"초기 자본: {settings.gt_initial_cash_krw:,.0f}원 | 시뮬 기간: 최근 {sim_days}일") + + for tier, json_path, _chart_path, sim_chart_path in tiers: + params = replace(base, chart_tier=tier) + gt_result = _load_or_build_gt(settings, params, json_path, args.rebuild_gt) + + df = load_candles( + db_path=settings.db_path, + symbol=settings.symbol, + interval_min=params.interval_min, + lookback_days=base.lookback_days, + ) + last_close = float(df["close"].iloc[-1]) + + sim_pnl = simulate_gt_signals_pnl( + signals=gt_result.get("signals") or [], + initial_cash_krw=settings.gt_initial_cash_krw, + fee_rate=settings.gt_trading_fee_rate, + sim_lookback_days=sim_days, + data_end=gt_result["meta"]["data_to"], + last_mark_price=last_close, + ) + gt_result["sim_pnl"] = sim_pnl + save_ground_truth(gt_result, json_path) + + render_ground_truth_sim_chart( + db_path=settings.db_path, + symbol=settings.symbol, + gt_result=gt_result, + sim_pnl=sim_pnl, + output_path=sim_chart_path, + chart_lookback_days=settings.download_days, + ) + _print_sim_summary(tier, sim_pnl, sim_chart_path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/2_run_causal_sim.py b/scripts/2_run_causal_sim.py new file mode 100644 index 0000000..8059d7e --- /dev/null +++ b/scripts/2_run_causal_sim.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""2단계: 인과 기법 sim + 차트 (1단계와 동일 거래 기간·초기 자본).""" + +from __future__ import annotations + +import argparse +import logging +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from deepcoin.config import load_settings +from deepcoin.data.candle_loader import load_candles +from deepcoin.data.intervals import interval_label +from deepcoin.evaluation.causal_sim import ( + build_causal_sim_report, + render_causal_sim_html, + render_technique_sim_chart, + run_technique_causal_sim, + save_causal_sim_report, + technique_sim_chart_path, +) +from deepcoin.techniques.runner import load_ground_truth, load_technique_results + + +def _configure_logging(verbose: bool) -> None: + """로깅 레벨을 설정한다.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def _print_progress(phase: str, current: int, total: int, detail: str) -> None: + """진행률을 stdout에 출력한다.""" + pct = current / total * 100.0 if total else 100.0 + msg = f"[{phase}] {current}/{total} ({pct:.1f}%) — {detail}" + print(msg, flush=True) + logging.info(msg) + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="2단계: 인과 기법 sim + 차트") + parser.add_argument( + "--techniques", + type=str, + default=None, + help="대상 기법 ID (쉼표 구분). 기본: 전체", + ) + parser.add_argument( + "--no-charts", + action="store_true", + help="개별 sim 차트 HTML 생략 (요약 리포트만)", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + _configure_logging(args.verbose) + settings = load_settings() + gt_result = load_ground_truth(settings.ground_truth_file) + gt_meta = gt_result.get("meta", {}) + + technique_ids = None + if args.techniques: + technique_ids = [t.strip() for t in args.techniques.split(",") if t.strip()] + + results = load_technique_results(settings.techniques_dir, technique_ids) + if not results: + logging.error( + "기법 결과 없음: %s — 먼저 2_run_techniques.py 실행", + settings.techniques_dir, + ) + return 1 + + stage1_sim = gt_result.get("sim_pnl") + if not stage1_sim: + logging.warning("GT JSON에 1단계 sim_pnl 없음 — 1_ground_truth_sim.py 권장") + + df = load_candles( + db_path=settings.db_path, + symbol=settings.symbol, + interval_min=settings.gt_interval_min, + lookback_days=settings.gt_lookback_days, + ) + last_close = float(df["close"].iloc[-1]) + data_end = gt_meta.get("data_to") + analysis_dir = settings.causal_sim_report_json.parent + + print("\n=== 2단계 인과 sim ===", flush=True) + print( + f"거래 기간: 최근 {settings.gt_sim_lookback_days}일 | " + f"초기 {settings.gt_initial_cash_krw:,.0f}원 | " + f"기법 {len(results)}개", + flush=True, + ) + + sim_pnls: dict[str, dict] = {} + total = len(results) + t0 = time.monotonic() + + for idx, result in enumerate(results, start=1): + sim_pnl = run_technique_causal_sim( + result, + initial_cash_krw=settings.gt_initial_cash_krw, + fee_rate=settings.gt_trading_fee_rate, + sim_lookback_days=settings.gt_sim_lookback_days, + data_end=data_end, + last_mark_price=last_close, + ) + sim_pnls[result.technique_id] = sim_pnl + _print_progress( + "sim", + idx, + total, + f"{result.technique_id} → {sim_pnl.get('total_return_pct', 0):+.2f}%", + ) + + if not args.no_charts: + chart_path = technique_sim_chart_path(analysis_dir, result.technique_id) + render_technique_sim_chart( + db_path=settings.db_path, + symbol=settings.symbol, + gt_meta=gt_meta, + result=result, + sim_pnl=sim_pnl, + output_path=chart_path, + chart_lookback_days=settings.gt_sim_lookback_days, + ) + _print_progress("chart", idx, total, str(chart_path.name)) + + report = build_causal_sim_report(results, gt_result, settings.symbol, sim_pnls) + report["meta"] = { + "interval_label": interval_label(settings.gt_interval_min), + "initial_cash_krw": settings.gt_initial_cash_krw, + "sim_lookback_days": settings.gt_sim_lookback_days, + } + json_path = save_causal_sim_report(report, settings.causal_sim_report_json) + html_path = render_causal_sim_html(report, settings.causal_sim_report_html) + + elapsed = time.monotonic() - t0 + print(f"\n=== 2단계 인과 sim 완료 ({elapsed/60:.1f}분) ===", flush=True) + if stage1_sim: + print( + f"1단계 벤치마크(v3): {stage1_sim.get('total_return_pct', 0):+.2f}% " + f"({stage1_sim.get('period_from', '')} ~ {stage1_sim.get('period_to', '')})", + flush=True, + ) + top = report["ranking"][:3] + for i, row in enumerate(top, start=1): + print( + f" {i}. {row['technique_name']}: {row['sim_return_pct']:+.2f}% " + f"(GT정합 {row['gt_align_score']*100:.1f})", + flush=True, + ) + print(f"요약 JSON: {json_path}", flush=True) + print(f"요약 HTML: {html_path}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/2_run_mtf_analysis.py b/scripts/2_run_mtf_analysis.py new file mode 100644 index 0000000..3e9702f --- /dev/null +++ b/scripts/2_run_mtf_analysis.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""2단계: GT v3 타점 · 멀티 TF 피처 상관 분석.""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from deepcoin.config import load_settings +from deepcoin.evaluation.mtf_report import ( + build_mtf_correlation_report, + render_mtf_html, + save_mtf_report, +) +from deepcoin.mtf.extractor import MtfFeatureExtractor +from deepcoin.mtf.store import MultiTimeframeStore +from deepcoin.techniques.runner import load_ground_truth + + +def _configure_logging(verbose: bool) -> None: + """로깅 레벨을 설정한다.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def _resolve_gt_path(settings, gt_file: str | None) -> Path: + """GT JSON 경로를 결정한다.""" + if gt_file: + path = Path(gt_file) + if not path.is_absolute(): + path = ROOT / path + return path + return settings.ground_truth_file + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="2단계: GT v3 MTF 피처 상관 분석") + parser.add_argument("--gt-file", type=str, default=None, help="GT JSON (기본 v3)") + parser.add_argument( + "--days", + type=int, + default=None, + help="분석 구간(일). 기본 GT_SIM_LOOKBACK_DAYS", + ) + parser.add_argument( + "--negative-samples", + type=int, + default=2000, + help="음성 샘플 3분봉 수", + ) + parser.add_argument( + "--exclude-bars", + type=int, + default=60, + help="GT 주변 제외 3분봉 수", + ) + parser.add_argument("--seed", type=int, default=42, help="음성 샘플 RNG seed") + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + _configure_logging(args.verbose) + settings = load_settings() + gt_path = _resolve_gt_path(settings, args.gt_file) + lookback_days = args.days or settings.gt_sim_lookback_days + + logging.info( + "MTF 상관 분석: %s, 최근 %d일, 음성 %d건", + gt_path.name, + lookback_days, + args.negative_samples, + ) + + gt_result = load_ground_truth(gt_path) + store = MultiTimeframeStore( + db_path=settings.db_path, + symbol=settings.symbol, + lookback_days=lookback_days + 120, + zigzag_reversal_pct=settings.gt_zigzag_reversal_pct, + ) + extractor = MtfFeatureExtractor( + store=store, + base_interval_min=settings.gt_interval_min, + ) + + report = build_mtf_correlation_report( + gt_result=gt_result, + extractor=extractor, + lookback_days=lookback_days, + negative_sample_count=args.negative_samples, + exclude_bars=args.exclude_bars, + seed=args.seed, + ) + + json_path = save_mtf_report(report, settings.mtf_report_json) + html_path = render_mtf_html(report, settings.mtf_report_html) + + gt = report.get("gt", {}) + top = (report.get("global_feature_ranking") or [])[:5] + print("\n=== GT v3 MTF 상관 분석 ===") + print(f"구간: {report['analysis']['period_from']} ~ {report['analysis']['period_to']}") + print( + f"GT 신호 {gt.get('signals_in_period', 0)}건 · " + f"스냅샷 {gt.get('snapshots_extracted', 0)}건 · " + f"음성 {report['analysis']['negative_sample_count']}건" + ) + print("\n상위 피처 (|Cohen's d|):") + for row in top: + print( + f" {row['signal_label']} | {row['timeframe']} | {row['feature']} | " + f"d={row['cohens_d']}" + ) + print(f"\nJSON: {json_path}") + print(f"HTML: {html_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/2_run_signal_type_align.py b/scripts/2_run_signal_type_align.py new file mode 100644 index 0000000..ddef3ff --- /dev/null +++ b/scripts/2_run_signal_type_align.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""2단계: v3 신호 유형별 인과 기법 정합 (B/B*/B^/Bd/S/Sd).""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from deepcoin.config import load_settings +from deepcoin.data.intervals import interval_label +from deepcoin.evaluation.report import ( + build_comparison_report, + render_comparison_html, + save_comparison_report, +) +from deepcoin.evaluation.signal_type_report import ( + build_signal_type_report, + render_signal_type_html, + save_signal_type_report, +) +from deepcoin.techniques.base import TechniqueParams +from deepcoin.techniques.registry import list_technique_ids +from deepcoin.techniques.runner import ( + load_ground_truth, + load_technique_results, + run_all_techniques, + save_technique_result, +) + + +def _configure_logging(verbose: bool) -> None: + """로깅 레벨을 설정한다.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def _resolve_gt_path(settings, gt_file: str | None) -> Path: + """GT JSON 경로를 결정한다.""" + if gt_file: + path = Path(gt_file) + if not path.is_absolute(): + path = ROOT / path + return path + return settings.ground_truth_file + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="2단계: v3 신호 유형별 인과 정합") + parser.add_argument( + "--gt-file", + type=str, + default=None, + help="Ground Truth JSON 경로 (기본: GROUND_TRUTH_FILE 또는 v3)", + ) + parser.add_argument( + "--techniques", + type=str, + default=None, + help="실행할 기법 ID (쉼표 구분). 기본: 전체 (composite_v3 포함)", + ) + parser.add_argument("--tolerance", type=int, default=None, help="GT 정합 허용 봉 수") + parser.add_argument("--no-comparison", action="store_true", help="종합 비교 리포트 생략") + parser.add_argument( + "--from-cache", + action="store_true", + help="data/spot/techniques/ 저장 JSON에서 로드 (재실행 생략)", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + _configure_logging(args.verbose) + settings = load_settings() + gt_path = _resolve_gt_path(settings, args.gt_file) + + if not gt_path.exists(): + logging.error("Ground Truth 파일 없음: %s — 먼저 0_ground_truth.py 실행", gt_path) + return 1 + + gt_result = load_ground_truth(gt_path) + chart_tier = gt_result.get("meta", {}).get("chart_tier", "unknown") + if chart_tier not in ("v2", "v3"): + logging.warning( + "GT chart_tier=%s — 신호 유형 분석은 v2/v3에서 의미 있습니다.", chart_tier + ) + + technique_ids = None + if args.techniques: + technique_ids = [t.strip() for t in args.techniques.split(",") if t.strip()] + + params = TechniqueParams( + interval_min=settings.gt_interval_min, + lookback_days=settings.gt_lookback_days, + min_leg_pct=settings.gt_min_leg_pct, + initial_cash_krw=settings.gt_initial_cash_krw, + fee_rate=settings.gt_trading_fee_rate, + extra={"reversal_pct": settings.gt_zigzag_reversal_pct}, + ) + + tolerance = args.tolerance or settings.gt_align_tolerance_bars + + logging.info( + "신호 유형 정합: %s %s, %s일, GT=%s, tolerance=%s봉", + settings.symbol, + interval_label(params.interval_min), + params.lookback_days, + gt_path.name, + tolerance, + ) + + results = ( + load_technique_results(settings.techniques_dir, technique_ids) + if args.from_cache + else run_all_techniques( + db_path=settings.db_path, + symbol=settings.symbol, + params=params, + gt_result=gt_result, + tolerance_bars=tolerance, + technique_ids=technique_ids, + on_result=lambda result: save_technique_result(result, settings.techniques_dir), + ) + ) + + if not results: + logging.error("기법 결과 없음 — 먼저 2_run_techniques.py 실행") + return 1 + + for result in results: + align = result.alignment or {} + by_type = align.get("by_signal_type") or {} + type_hits = [] + for signal_type, type_align in by_type.items(): + if type_align.get("gt_count", 0) > 0: + type_hits.append( + f"{signal_type}:{type_align['recall']*100:.0f}%" + ) + type_summary = ", ".join(type_hits[:4]) + if len(type_hits) > 4: + type_summary += "..." + print( + f" [{result.technique_id}] score={align.get('score', 0)*100:.1f} " + f"| {type_summary or 'no types'}" + ) + + signal_report = build_signal_type_report(results, gt_result, settings.symbol) + st_json = save_signal_type_report(signal_report, settings.signal_type_report_json) + st_html = render_signal_type_html(signal_report, settings.signal_type_report_html) + + print(f"\n=== v3 신호 유형별 최고 Recall ===") + for row in signal_report.get("best_by_signal_type", []): + print( + f" {row['signal_label']}: " + f"{row['best_technique_name']} {row['best_recall']*100:.0f}% " + f"({row['best_technique_id']}, GT {row['gt_count']}건)" + ) + print(f"신호 유형 리포트 JSON: {st_json}") + print(f"신호 유형 리포트 HTML: {st_html}") + + if not args.no_comparison: + comparison = build_comparison_report(results, gt_result, settings.symbol) + cmp_json = save_comparison_report(comparison, settings.analysis_report_json) + cmp_html = render_comparison_html(comparison, settings.analysis_report_html) + print(f"\n=== GT 정합 순위 (상위 3) ===") + for idx, row in enumerate(comparison["ranking"][:3], start=1): + print( + f" {idx}. {row['technique_name']}: " + f"score {row['score']*100:.1f}, leg recall {row['leg_recall']*100:.0f}%" + ) + print(f"비교 리포트 JSON: {cmp_json}") + print(f"비교 리포트 HTML: {cmp_html}") + + print(f"\n등록 기법: {', '.join(list_technique_ids())}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/2_run_stage2_all.sh b/scripts/2_run_stage2_all.sh new file mode 100755 index 0000000..0902143 --- /dev/null +++ b/scripts/2_run_stage2_all.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# 현물 2단계 전체: 기법 실행 → 인과 sim → 신호유형 정합 → MTF 분석 +set -euo pipefail +cd "$(dirname "$0")/.." +export PYTHONPATH=src +PY="${PY:-/opt/anaconda3/envs/ncue/bin/python}" +LOG="${LOG:-/tmp/deepcoin_stage2.log}" + +echo "=== 현물 2단계 파이프라인 시작 $(date '+%Y-%m-%d %H:%M:%S') ===" | tee "$LOG" + +echo "[1/4] 기법 실행 (39종)..." | tee -a "$LOG" +"$PY" scripts/2_run_techniques.py 2>&1 | tee -a "$LOG" + +echo "[2/4] 인과 sim + 차트..." | tee -a "$LOG" +"$PY" scripts/2_run_causal_sim.py 2>&1 | tee -a "$LOG" + +echo "[3/4] 신호 유형별 정합..." | tee -a "$LOG" +"$PY" scripts/2_run_signal_type_align.py --from-cache 2>&1 | tee -a "$LOG" + +echo "[4/4] MTF 상관 분석..." | tee -a "$LOG" +"$PY" scripts/2_run_mtf_analysis.py 2>&1 | tee -a "$LOG" + +echo "=== 현물 2단계 완료 $(date '+%Y-%m-%d %H:%M:%S') ===" | tee -a "$LOG" diff --git a/scripts/2_run_techniques.py b/scripts/2_run_techniques.py new file mode 100644 index 0000000..ebdec37 --- /dev/null +++ b/scripts/2_run_techniques.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""2단계: 인과 기법 GT 정합 분석 (과거 데이터만 · 0단계 타점 대비).""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from deepcoin.config import load_settings +from deepcoin.data.intervals import interval_label +from deepcoin.evaluation.report import ( + build_comparison_report, + render_comparison_html, + save_comparison_report, +) +from deepcoin.techniques.base import TechniqueParams +from deepcoin.techniques.registry import list_technique_ids +from deepcoin.techniques.runner import ( + load_ground_truth, + run_all_techniques, + save_technique_result, +) + + +def _configure_logging(verbose: bool) -> None: + """로깅 레벨을 설정한다.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="2단계: 인과 기법 GT 정합 분석") + parser.add_argument( + "--techniques", + type=str, + default=None, + help="실행할 기법 ID (쉼표 구분). 기본: 전체", + ) + parser.add_argument("--tolerance", type=int, default=None, help="GT 정합 허용 봉 수") + parser.add_argument("--no-report", action="store_true", help="비교 리포트 생략") + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + _configure_logging(args.verbose) + settings = load_settings() + + if not settings.ground_truth_file.exists(): + logging.error("Ground Truth 파일 없음: %s — 먼저 0_ground_truth.py 실행", settings.ground_truth_file) + return 1 + + gt_result = load_ground_truth(settings.ground_truth_file) + technique_ids = None + if args.techniques: + technique_ids = [t.strip() for t in args.techniques.split(",") if t.strip()] + + params = TechniqueParams( + interval_min=settings.gt_interval_min, + lookback_days=settings.gt_lookback_days, + min_leg_pct=settings.gt_min_leg_pct, + initial_cash_krw=settings.gt_initial_cash_krw, + fee_rate=settings.gt_trading_fee_rate, + extra={"reversal_pct": settings.gt_zigzag_reversal_pct}, + ) + + tolerance = args.tolerance or settings.gt_align_tolerance_bars + + logging.info( + "기법 실행: %s %s, %s일, tolerance=%s봉", + settings.symbol, + interval_label(params.interval_min), + params.lookback_days, + tolerance, + ) + + technique_count = len(technique_ids) if technique_ids else len(list_technique_ids()) + print( + f"\n=== 2단계 기법 실행 시작 ({technique_count}종) ===", + flush=True, + ) + + completed = 0 + + def _on_result(result) -> None: + nonlocal completed + save_technique_result(result, settings.techniques_dir) + completed += 1 + pct = completed / technique_count * 100.0 if technique_count else 100.0 + align = result.alignment or {} + print( + f"[기법] {completed}/{technique_count} ({pct:.1f}%) — " + f"{result.technique_id} score={align.get('score', 0)*100:.1f}", + flush=True, + ) + + results = run_all_techniques( + db_path=settings.db_path, + symbol=settings.symbol, + params=params, + gt_result=gt_result, + tolerance_bars=tolerance, + technique_ids=technique_ids, + on_result=_on_result, + ) + + saved_paths: list[Path] = [] + for result in results: + path = settings.techniques_dir / f"{result.technique_id}.json" + saved_paths.append(path) + align = result.alignment or {} + legs = align.get("legs", {}) + print( + f" [{result.technique_id}] {result.technique_name}: " + f"레그 {result.summary.get('leg_count', 0)}개, " + f"수익 {result.pnl.get('total_return_pct', 0):+.1f}%, " + f"GT정합 score={align.get('score', 0)*100:.1f} " + f"(leg recall {legs.get('leg_recall', 0)*100:.0f}%)" + ) + + print(f"\n=== 2단계 인과 정합 분석 완료 ({len(results)}개) ===") + print(f"저장: {settings.techniques_dir}/") + for path in saved_paths: + print(f" - {path.name}") + + if not args.no_report: + report = build_comparison_report(results, gt_result, settings.symbol) + json_path = save_comparison_report(report, settings.analysis_report_json) + html_path = render_comparison_html(report, settings.analysis_report_html) + print(f"\n=== GT 정합 순위 (상위 3) ===") + gt_return = report["gt"]["return_pct"] + print(f"GT 벤치마크: {gt_return:+.1f}%") + for idx, row in enumerate(report["ranking"][:3], start=1): + print( + f" {idx}. {row['technique_name']}: " + f"score {row['score']*100:.1f}, " + f"수익 {row['tech_return_pct']:+.1f}%, " + f"leg recall {row['leg_recall']*100:.0f}%" + ) + print(f"리포트 JSON: {json_path}") + print(f"리포트 HTML: {html_path}") + + print(f"\n등록 기법: {', '.join(list_technique_ids())}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/deepcoin/__init__.py b/src/deepcoin/__init__.py new file mode 100644 index 0000000..8f97be6 --- /dev/null +++ b/src/deepcoin/__init__.py @@ -0,0 +1,3 @@ +"""DeepCoin — 빗썸 암호화폐 데이터 수집·분석.""" + +__version__ = "0.1.0" diff --git a/src/deepcoin/api/__init__.py b/src/deepcoin/api/__init__.py new file mode 100644 index 0000000..0d3f7b9 --- /dev/null +++ b/src/deepcoin/api/__init__.py @@ -0,0 +1 @@ +"""외부 API 클라이언트.""" diff --git a/src/deepcoin/api/bithumb.py b/src/deepcoin/api/bithumb.py new file mode 100644 index 0000000..cc3affa --- /dev/null +++ b/src/deepcoin/api/bithumb.py @@ -0,0 +1,134 @@ +"""빗썸 Public REST API — 캔들 조회.""" + +from __future__ import annotations + +import logging +import time +from datetime import datetime +from typing import Any + +import requests + +from deepcoin.data.intervals import INTERVAL_DAILY, INTERVAL_MONTHLY, INTERVAL_WEEKLY + +logger = logging.getLogger(__name__) + +# 인터벌(분) → 빗썸 candles API 경로 세그먼트 +_CALENDAR_PATHS: dict[int, str] = { + INTERVAL_DAILY: "days", + INTERVAL_WEEKLY: "weeks", + INTERVAL_MONTHLY: "months", +} + +KST_FMT = "%Y-%m-%d %H:%M:%S" +KST_FMT_T = "%Y-%m-%dT%H:%M:%S" + + +def parse_kst_datetime(value: str) -> datetime: + """KST 캔들 시각 문자열을 datetime으로 변환한다. + + Args: + value: `yyyy-MM-dd HH:mm:ss` 또는 ISO 형식. + + Returns: + naive datetime (KST 기준). + """ + normalized = value.strip().replace("T", " ") + return datetime.strptime(normalized, KST_FMT) + + +def format_kst_datetime(dt: datetime) -> str: + """datetime을 빗썸 `to` 파라미터 형식으로 포맷한다. + + Args: + dt: KST 기준 시각. + + Returns: + `yyyy-MM-dd HH:mm:ss` 문자열. + """ + return dt.strftime(KST_FMT) + + +class BithumbCandleClient: + """빗썸 캔들 API 클라이언트.""" + + def __init__( + self, + base_url: str = "https://api.bithumb.com", + count: int = 200, + sleep_sec: float = 0.35, + retries: int = 3, + ) -> None: + """클라이언트를 초기화한다. + + Args: + base_url: API 베이스 URL. + count: 요청당 캔들 개수 (최대 200). + sleep_sec: 연속 요청 간 대기(초). + retries: 실패 시 재시도 횟수. + """ + self.base_url = base_url.rstrip("/") + self.count = min(max(count, 1), 200) + self.sleep_sec = sleep_sec + self.retries = retries + self._session = requests.Session() + self._session.headers.update({"accept": "application/json"}) + + def fetch_candles( + self, + market: str, + interval_min: int, + to_kst: datetime | None = None, + ) -> list[dict[str, Any]]: + """캔들 배치를 조회한다 (최신순). + + Args: + market: 거래 페어 (예: KRW-WLD). + interval_min: 분 단위. 1440이면 일봉 API 사용. + to_kst: 조회 기준 시각(KST). 해당 시각 캔들은 제외. + + Returns: + 캔들 dict 리스트. API 오류 시 빈 리스트. + + Raises: + requests.RequestException: 재시도 후에도 네트워크 실패. + """ + if interval_min in _CALENDAR_PATHS: + segment = _CALENDAR_PATHS[interval_min] + url = f"{self.base_url}/v1/candles/{segment}" + params: dict[str, Any] = {"market": market, "count": self.count} + else: + url = f"{self.base_url}/v1/candles/minutes/{interval_min}" + params = {"market": market, "count": self.count} + + if to_kst is not None: + params["to"] = format_kst_datetime(to_kst) + + last_error: Exception | None = None + for attempt in range(1, self.retries + 1): + try: + response = self._session.get(url, params=params, timeout=30) + if response.status_code == 429: + wait = self.sleep_sec * attempt * 3 + logger.warning("Rate limit 429 — %ss 대기 후 재시도", wait) + time.sleep(wait) + continue + response.raise_for_status() + payload = response.json() + if isinstance(payload, dict) and "error" in payload: + logger.error("API error: %s", payload["error"]) + return [] + if not isinstance(payload, list): + logger.error("Unexpected response type: %s", type(payload)) + return [] + time.sleep(self.sleep_sec) + return payload + except requests.RequestException as exc: + last_error = exc + wait = self.sleep_sec * attempt * 2 + logger.warning("Request failed (%s/%s): %s", attempt, self.retries, exc) + time.sleep(wait) + + if last_error is not None: + raise last_error + return [] diff --git a/src/deepcoin/config.py b/src/deepcoin/config.py new file mode 100644 index 0000000..bc24d82 --- /dev/null +++ b/src/deepcoin/config.py @@ -0,0 +1,262 @@ +"""환경 변수 로드 및 애플리케이션 설정.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + +from deepcoin.data.intervals import DEFAULT_DOWNLOAD_INTERVALS + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def _resolve_project_path(raw: str) -> Path: + """프로젝트 루트 기준 상대 경로를 절대 경로로 변환한다.""" + path = Path(raw) + if not path.is_absolute(): + path = _PROJECT_ROOT / path + return path + + +def _parse_int_list(raw: str) -> list[int]: + """쉼표 구분 정수 목록을 파싱한다. + + Args: + raw: 예) "3,5,10,15" + + Returns: + 정수 리스트. 빈 입력이면 빈 리스트. + """ + if not raw or not raw.strip(): + return [] + return [int(part.strip()) for part in raw.split(",") if part.strip()] + + +@dataclass(frozen=True) +class Settings: + """DeepCoin 실행 설정.""" + + symbol: str + coin_name: str + api_url: str + candle_count: int + download_intervals: list[int] + download_days: int + db_path: Path + request_sleep_sec: float + request_retries: int + # 0단계: GT 타점 (현물·선물 공통 파라미터) + gt_interval_min: int + gt_lookback_days: int + gt_zigzag_reversal_pct: float + gt_min_leg_pct: float + gt_pullback_min_pct: float + gt_pullback_local_order: int + gt_breakout_buffer_pct: float + gt_breakout_consolidation_bars: int + gt_breakout_min_rally_pct: float + gt_div_local_order: int + gt_div_min_bars_between: int + gt_div_min_rsi_diff: float + gt_div_min_future_move_pct: float + ground_truth_file: Path + ground_truth_v1_file: Path + ground_truth_v2_file: Path + ground_truth_chart_v1_file: Path + ground_truth_chart_v2_file: Path + ground_truth_chart_v3_file: Path + ground_truth_futures_file: Path + ground_truth_futures_v1_file: Path + ground_truth_futures_v2_file: Path + ground_truth_futures_chart_v1_file: Path + ground_truth_futures_chart_v2_file: Path + ground_truth_futures_chart_v3_file: Path + # 현물 1단계: GT sim + ground_truth_chart_sim_v1_file: Path + ground_truth_chart_sim_v2_file: Path + ground_truth_chart_sim_v3_file: Path + gt_sim_lookback_days: int + gt_initial_cash_krw: float + gt_trading_fee_rate: float + # 현물 2단계: 인과 기법 GT 정합 + techniques_dir: Path + analysis_report_json: Path + analysis_report_html: Path + signal_type_report_json: Path + signal_type_report_html: Path + gt_align_tolerance_bars: int + mtf_report_json: Path + mtf_report_html: Path + mtf_rules_json: Path + causal_sim_report_json: Path + causal_sim_report_html: Path + + @property + def market(self) -> str: + """빗썸 마켓 코드 (예: KRW-BTC).""" + return f"KRW-{self.symbol}" + + +def load_settings(env_path: Path | None = None) -> Settings: + """`.env`를 로드하고 Settings를 반환한다. + + Args: + env_path: `.env` 경로. None이면 프로젝트 루트. + + Returns: + Settings 인스턴스. + """ + path = env_path or (_PROJECT_ROOT / ".env") + load_dotenv(path, override=False) + + default_intervals = ",".join(str(i) for i in DEFAULT_DOWNLOAD_INTERVALS) + intervals_raw = os.getenv("DOWNLOAD_INTERVALS", default_intervals) + intervals = sorted(set(_parse_int_list(intervals_raw))) + + db_raw = os.getenv("DB_PATH", "coins.db") + db_path = Path(db_raw) + if not db_path.is_absolute(): + db_path = _PROJECT_ROOT / db_path + + tech_dir_raw = os.getenv("TECHNIQUES_DIR", "data/spot/techniques") + tech_dir = Path(tech_dir_raw) + if not tech_dir.is_absolute(): + tech_dir = _PROJECT_ROOT / tech_dir + + return Settings( + symbol=os.getenv("SYMBOL", "BTC").upper(), + coin_name=os.getenv("COIN_NAME", "비트코인"), + api_url=os.getenv("BITHUMB_API_URL", "https://api.bithumb.com").rstrip("/"), + candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")), + download_intervals=intervals, + download_days=int(os.getenv("DOWNLOAD_DAYS", "3650")), + db_path=db_path, + request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")), + request_retries=int(os.getenv("API_REQUEST_RETRIES", "3")), + gt_interval_min=int(os.getenv("GT_INTERVAL_MIN", "3")), + gt_lookback_days=int(os.getenv("GT_LOOKBACK_DAYS", "3447")), + gt_zigzag_reversal_pct=float(os.getenv("GT_ZIGZAG_REVERSAL_PCT", "5.0")), + gt_min_leg_pct=float(os.getenv("GT_MIN_LEG_PCT", "3.0")), + gt_pullback_min_pct=float(os.getenv("GT_PULLBACK_MIN_PCT", "1.5")), + gt_pullback_local_order=int(os.getenv("GT_PULLBACK_LOCAL_ORDER", "10")), + gt_breakout_buffer_pct=float(os.getenv("GT_BREAKOUT_BUFFER_PCT", "0.1")), + gt_breakout_consolidation_bars=int(os.getenv("GT_BREAKOUT_CONSOLIDATION_BARS", "200")), + gt_breakout_min_rally_pct=float(os.getenv("GT_BREAKOUT_MIN_RALLY_PCT", "2.0")), + gt_div_local_order=int(os.getenv("GT_DIV_LOCAL_ORDER", "20")), + gt_div_min_bars_between=int(os.getenv("GT_DIV_MIN_BARS_BETWEEN", "1500")), + gt_div_min_rsi_diff=float(os.getenv("GT_DIV_MIN_RSI_DIFF", "5.0")), + gt_div_min_future_move_pct=float(os.getenv("GT_DIV_MIN_FUTURE_MOVE_PCT", "4.0")), + ground_truth_file=_resolve_project_path( + os.getenv("GROUND_TRUTH_FILE", "data/spot/ground_truth/ground_truth_trades_v3.json") + ), + ground_truth_v1_file=_resolve_project_path( + os.getenv("GROUND_TRUTH_V1_FILE", "data/spot/ground_truth/ground_truth_trades_v1.json") + ), + ground_truth_v2_file=_resolve_project_path( + os.getenv("GROUND_TRUTH_V2_FILE", "data/spot/ground_truth/ground_truth_trades_v2.json") + ), + ground_truth_chart_v1_file=_resolve_project_path( + os.getenv("GROUND_TRUTH_CHART_V1_FILE", "docs/0_ground_truth/spot/ground_truth_chart_v1.html") + ), + ground_truth_chart_v2_file=_resolve_project_path( + os.getenv("GROUND_TRUTH_CHART_V2_FILE", "docs/0_ground_truth/spot/ground_truth_chart_v2.html") + ), + ground_truth_chart_v3_file=_resolve_project_path( + os.getenv("GROUND_TRUTH_CHART_V3_FILE", "docs/0_ground_truth/spot/ground_truth_chart_v3.html") + ), + ground_truth_futures_file=_resolve_project_path( + os.getenv( + "GROUND_TRUTH_FUTURES_FILE", + "data/futures/ground_truth/ground_truth_trades_v3.json", + ) + ), + ground_truth_futures_v1_file=_resolve_project_path( + os.getenv( + "GROUND_TRUTH_FUTURES_V1_FILE", + "data/futures/ground_truth/ground_truth_trades_v1.json", + ) + ), + ground_truth_futures_v2_file=_resolve_project_path( + os.getenv( + "GROUND_TRUTH_FUTURES_V2_FILE", + "data/futures/ground_truth/ground_truth_trades_v2.json", + ) + ), + ground_truth_futures_chart_v1_file=_resolve_project_path( + os.getenv( + "GROUND_TRUTH_FUTURES_CHART_V1_FILE", + "docs/0_ground_truth/futures/ground_truth_chart_v1.html", + ) + ), + ground_truth_futures_chart_v2_file=_resolve_project_path( + os.getenv( + "GROUND_TRUTH_FUTURES_CHART_V2_FILE", + "docs/0_ground_truth/futures/ground_truth_chart_v2.html", + ) + ), + ground_truth_futures_chart_v3_file=_resolve_project_path( + os.getenv( + "GROUND_TRUTH_FUTURES_CHART_V3_FILE", + "docs/0_ground_truth/futures/ground_truth_chart_v3.html", + ) + ), + ground_truth_chart_sim_v1_file=_resolve_project_path( + os.getenv( + "GROUND_TRUTH_CHART_SIM_V1_FILE", + "docs/spot/1_simulation/ground_truth_chart_sim_v1.html", + ) + ), + ground_truth_chart_sim_v2_file=_resolve_project_path( + os.getenv( + "GROUND_TRUTH_CHART_SIM_V2_FILE", + "docs/spot/1_simulation/ground_truth_chart_sim_v2.html", + ) + ), + ground_truth_chart_sim_v3_file=_resolve_project_path( + os.getenv( + "GROUND_TRUTH_CHART_SIM_V3_FILE", + "docs/spot/1_simulation/ground_truth_chart_sim_v3.html", + ) + ), + gt_sim_lookback_days=int(os.getenv("GT_SIM_LOOKBACK_DAYS", "1095")), + gt_initial_cash_krw=float(os.getenv("GT_INITIAL_CASH_KRW", "400000")), + gt_trading_fee_rate=float(os.getenv("GT_TRADING_FEE_RATE", "0.0005")), + techniques_dir=tech_dir, + analysis_report_json=_resolve_project_path( + os.getenv("ANALYSIS_REPORT_JSON", "docs/spot/2_analysis/comparison_report.json") + ), + analysis_report_html=_resolve_project_path( + os.getenv("ANALYSIS_REPORT_HTML", "docs/spot/2_analysis/comparison_report.html") + ), + signal_type_report_json=_resolve_project_path( + os.getenv("SIGNAL_TYPE_REPORT_JSON", "docs/spot/2_analysis/signal_type_report.json") + ), + signal_type_report_html=_resolve_project_path( + os.getenv("SIGNAL_TYPE_REPORT_HTML", "docs/spot/2_analysis/signal_type_report.html") + ), + gt_align_tolerance_bars=int(os.getenv("GT_ALIGN_TOLERANCE_BARS", "480")), + mtf_report_json=_resolve_project_path( + os.getenv("MTF_REPORT_JSON", "docs/spot/2_analysis/mtf_correlation_report.json") + ), + mtf_report_html=_resolve_project_path( + os.getenv("MTF_REPORT_HTML", "docs/spot/2_analysis/mtf_correlation_report.html") + ), + mtf_rules_json=_resolve_project_path( + os.getenv("MTF_RULES_JSON", "data/spot/mtf/mtf_rules_v3.json") + ), + causal_sim_report_json=_resolve_project_path( + os.getenv( + "CAUSAL_SIM_REPORT_JSON", + "docs/spot/2_analysis/causal_sim_report.json", + ) + ), + causal_sim_report_html=_resolve_project_path( + os.getenv( + "CAUSAL_SIM_REPORT_HTML", + "docs/spot/2_analysis/causal_sim_report.html", + ) + ), + ) diff --git a/src/deepcoin/evaluation/__init__.py b/src/deepcoin/evaluation/__init__.py new file mode 100644 index 0000000..d9cd28f --- /dev/null +++ b/src/deepcoin/evaluation/__init__.py @@ -0,0 +1,27 @@ +"""Ground Truth 정합 평가.""" + +from deepcoin.evaluation.gt_align import align_with_ground_truth +from deepcoin.evaluation.mtf_report import ( + build_mtf_correlation_report, + render_mtf_html, + save_mtf_report, +) +from deepcoin.evaluation.report import build_comparison_report, render_comparison_html, save_comparison_report +from deepcoin.evaluation.signal_type_report import ( + build_signal_type_report, + render_signal_type_html, + save_signal_type_report, +) + +__all__ = [ + "align_with_ground_truth", + "build_comparison_report", + "render_comparison_html", + "save_comparison_report", + "build_signal_type_report", + "render_signal_type_html", + "save_signal_type_report", + "build_mtf_correlation_report", + "render_mtf_html", + "save_mtf_report", +] diff --git a/src/deepcoin/evaluation/causal_sim.py b/src/deepcoin/evaluation/causal_sim.py new file mode 100644 index 0000000..5c4347b --- /dev/null +++ b/src/deepcoin/evaluation/causal_sim.py @@ -0,0 +1,265 @@ +"""현물 2단계: 인과 기법 시뮬 (1단계와 동일 거래 기간 · 초기 자본).""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +from deepcoin.ground_truth.chart import render_ground_truth_sim_chart +from deepcoin.ground_truth.pnl import simulate_gt_signals_pnl +from deepcoin.techniques.base import TechniqueResult + + +def normalize_signals_for_sim(signals: list[dict[str, Any]]) -> list[dict[str, Any]]: + """기법 신호를 1단계 sim 엔진 형식으로 정규화한다.""" + ordered = sorted(signals, key=lambda s: s["datetime"]) + buy_id = 0 + sell_id = 0 + normalized: list[dict[str, Any]] = [] + for sig in ordered: + side = sig["side"] + if side == "buy": + buy_id += 1 + marker_id = buy_id + default_type = "swing_low" + else: + sell_id += 1 + marker_id = sell_id + default_type = "swing_high" + normalized.append( + { + "side": side, + "datetime": sig["datetime"], + "price": float(sig["price"]), + "bar_index": sig.get("bar_index", 0), + "marker_id": marker_id, + "signal_type": sig.get("signal_type", default_type), + } + ) + return normalized + + +def run_technique_causal_sim( + result: TechniqueResult, + *, + initial_cash_krw: float, + fee_rate: float, + sim_lookback_days: int, + data_end: str, + last_mark_price: float, +) -> dict[str, Any]: + """인과 기법 신호로 1단계와 동일 규칙의 포트폴리오 sim을 실행한다.""" + signals = normalize_signals_for_sim(result.signals) + return simulate_gt_signals_pnl( + signals=signals, + initial_cash_krw=initial_cash_krw, + fee_rate=fee_rate, + sim_lookback_days=sim_lookback_days, + data_end=data_end, + last_mark_price=last_mark_price, + ) + + +def _gt_shell_for_chart( + gt_meta: dict[str, Any], + technique: TechniqueResult, +) -> dict[str, Any]: + """차트 렌더용 최소 GT 구조를 만든다.""" + return { + "meta": { + **gt_meta, + "technique_id": technique.technique_id, + "technique_name": technique.technique_name, + "stage": "spot_2_causal_sim", + }, + "signals": [], + } + + +def technique_sim_chart_path(analysis_dir: Path, technique_id: str) -> Path: + """기법별 sim 차트 HTML 경로.""" + return analysis_dir / f"technique_chart_sim_{technique_id}.html" + + +def render_technique_sim_chart( + *, + db_path: Path, + symbol: str, + gt_meta: dict[str, Any], + result: TechniqueResult, + sim_pnl: dict[str, Any], + output_path: Path, + chart_lookback_days: int, +) -> Path: + """인과 기법 sim 차트 HTML을 생성한다.""" + gt_shell = _gt_shell_for_chart(gt_meta, result) + return render_ground_truth_sim_chart( + db_path=db_path, + symbol=symbol, + gt_result=gt_shell, + sim_pnl=sim_pnl, + output_path=output_path, + chart_lookback_days=chart_lookback_days, + ) + + +def stage1_benchmark_from_gt(gt_result: dict[str, Any]) -> dict[str, Any] | None: + """1단계 GT sim 벤치마크(v3)를 추출한다.""" + sim = gt_result.get("sim_pnl") + if not sim: + return None + return { + "label": "1단계 GT sim (v3, 사후 최적 타점)", + "period_from": sim.get("period_from"), + "period_to": sim.get("period_to"), + "sim_lookback_days": sim.get("sim_lookback_days"), + "initial_cash_krw": sim.get("initial_cash_krw"), + "final_equity_krw": sim.get("final_equity_krw"), + "total_return_pct": sim.get("total_return_pct"), + "buys_executed": sim.get("buys_executed"), + "sells_executed": sim.get("sells_executed"), + } + + +def build_causal_sim_report( + results: list[TechniqueResult], + gt_result: dict[str, Any], + symbol: str, + sim_pnls: dict[str, dict[str, Any]], +) -> dict[str, Any]: + """2단계 인과 sim 요약 리포트를 생성한다.""" + gt_meta = gt_result.get("meta", {}) + benchmark = stage1_benchmark_from_gt(gt_result) + rows: list[dict[str, Any]] = [] + + for result in results: + sim = sim_pnls.get(result.technique_id, {}) + align = result.alignment or {} + rows.append( + { + "technique_id": result.technique_id, + "technique_name": result.technique_name, + "category": result.category, + "causal": result.causal, + "sim_return_pct": sim.get("total_return_pct", 0.0), + "final_equity_krw": sim.get("final_equity_krw", 0.0), + "buys_executed": sim.get("buys_executed", 0), + "sells_executed": sim.get("sells_executed", 0), + "buys_skipped": sim.get("buys_skipped", 0), + "sells_skipped": sim.get("sells_skipped", 0), + "gt_align_score": align.get("score", 0.0), + "chart_file": f"technique_chart_sim_{result.technique_id}.html", + } + ) + + rows.sort(key=lambda r: r["sim_return_pct"], reverse=True) + period_from = benchmark.get("period_from") if benchmark else None + period_to = benchmark.get("period_to") if benchmark else None + + return { + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "symbol": symbol, + "description": ( + "2단계 인과 sim — 1단계와 동일 거래 기간·초기 자본. " + "1단계는 사후 GT 타점, 2단계는 인과 기법 신호로 체결." + ), + "sim_period_from": period_from, + "sim_period_to": period_to, + "sim_lookback_days": gt_meta.get("sim_lookback_days") + or (benchmark or {}).get("sim_lookback_days"), + "stage1_benchmark_v3": benchmark, + "ranking": rows, + } + + +def save_causal_sim_report(report: dict[str, Any], json_path: Path) -> Path: + """인과 sim 리포트 JSON을 저장한다.""" + json_path.parent.mkdir(parents=True, exist_ok=True) + with json_path.open("w", encoding="utf-8") as fp: + json.dump(report, fp, ensure_ascii=False, indent=2) + return json_path + + +def render_causal_sim_html(report: dict[str, Any], html_path: Path) -> Path: + """인과 sim 리포트 HTML을 생성한다.""" + html_path.parent.mkdir(parents=True, exist_ok=True) + benchmark = report.get("stage1_benchmark_v3") or {} + rows = report.get("ranking", []) + + bench_row = "" + if benchmark: + bench_row = f""" + + — + {benchmark.get('label', '1단계 GT sim')} + benchmark + {benchmark.get('final_equity_krw', 0):,.0f} + {benchmark.get('total_return_pct', 0):+.2f}% + {benchmark.get('buys_executed', 0)} / {benchmark.get('sells_executed', 0)} + — + — + """ + + table_rows = bench_row + for idx, row in enumerate(rows, start=1): + chart = row.get("chart_file", "") + table_rows += f""" + + {idx} + {row['technique_name']} + {row['category']} + {row.get('final_equity_krw', 0):,.0f} + {row.get('sim_return_pct', 0):+.2f}% + {row.get('buys_executed', 0)} / {row.get('sells_executed', 0)} + {row.get('gt_align_score', 0)*100:.1f} + 차트 + """ + + html = f""" + + + + DeepCoin 2단계 — 인과 sim + + + +

현물 2단계 — 인과 기법 sim

+

+ {report.get('symbol', '')} | + 거래 기간: {report.get('sim_period_from', '')} ~ {report.get('sim_period_to', '')} | + 생성: {report.get('generated_at', '')} +

+

{report.get('description', '')}

+ + + + + + + + + + + + + + + {table_rows} + +
#기법카테고리최종 평가(원)수익률체결(매수/매도)GT정합차트
+ +""" + html_path.write_text(html, encoding="utf-8") + return html_path diff --git a/src/deepcoin/evaluation/gt_align.py b/src/deepcoin/evaluation/gt_align.py new file mode 100644 index 0000000..0a7a652 --- /dev/null +++ b/src/deepcoin/evaluation/gt_align.py @@ -0,0 +1,381 @@ +"""Ground Truth와 기법 신호·레그 정합 평가.""" + +from __future__ import annotations + +from typing import Any + +# v3 GT 신호 유형 (ground_truth.py signal_type 필드와 동일) +GT_SIGNAL_TYPES: tuple[str, ...] = ( + "swing_low", + "pullback", + "breakout", + "div_bull", + "swing_high", + "div_bear", +) + +SIGNAL_TYPE_LABELS: dict[str, str] = { + "swing_low": "스윙 매수 (B)", + "pullback": "눌림목 (B*)", + "breakout": "돌파 (B^)", + "div_bull": "상승 다이버전스 (Bd)", + "swing_high": "스윙 매도 (S)", + "div_bear": "하락 다이버전스 (Sd)", +} + +SIGNAL_TYPE_SIDE: dict[str, str] = { + "swing_low": "buy", + "pullback": "buy", + "breakout": "buy", + "div_bull": "buy", + "swing_high": "sell", + "div_bear": "sell", +} + +# 신호 유형별 1차 정합 대상 기법 (리포트 하이라이트용) +SIGNAL_TYPE_PRIMARY_TECHNIQUES: dict[str, list[str]] = { + "swing_low": [ + "zigzag_causal", "minor_swing", "pivot_swing", "fractal_swing", + "composite_swing", + ], + "pullback": [ + "ema_pullback", "fib_pullback", "support_bounce", + "local_extrema", "bb_reversal", "composite_pullback", + ], + "breakout": [ + "donchian", "range_breakout", "keltner_breakout", + "bb_squeeze_breakout", "volume_breakout", "composite_breakout", + ], + "div_bull": [ + "rsi_divergence", "macd_divergence", "obv_divergence", + "rsi_swing", "composite_divergence", + ], + "swing_high": [ + "zigzag_causal", "minor_swing", "pivot_swing", "fractal_swing", + "composite_swing", + ], + "div_bear": [ + "rsi_divergence", "macd_divergence", "obv_divergence", + "rsi_swing", "composite_divergence", + ], +} + + +def _bar_distance(a: int, b: int) -> int: + """두 봉 인덱스 간 절대 거리.""" + return abs(a - b) + + +def _match_signal( + gt_bar: int, + candidates: list[dict[str, Any]], + tolerance_bars: int, + used: set[int], +) -> dict[str, Any] | None: + """GT 신호에 가장 가까운 기법 신호를 찾는다.""" + best: dict[str, Any] | None = None + best_dist = tolerance_bars + 1 + best_idx = -1 + + for idx, candidate in enumerate(candidates): + if idx in used: + continue + pivot_bar = candidate.get("pivot_bar_index") + compare_bar = pivot_bar if pivot_bar is not None else candidate["bar_index"] + dist = _bar_distance(gt_bar, compare_bar) + if dist <= tolerance_bars and dist < best_dist: + best = candidate + best_dist = dist + best_idx = idx + + if best is None: + return None + + return { + "matched": True, + "gt_bar_index": gt_bar, + "tech_bar_index": best.get("pivot_bar_index") or best["bar_index"], + "signal_bar_index": best["bar_index"], + "bar_offset": best_dist, + "tech_price": best["price"], + "tech_datetime": best["datetime"], + "candidate_index": best_idx, + } + + +def align_signals( + gt_signals: list[dict[str, Any]], + tech_signals: list[dict[str, Any]], + tolerance_bars: int, + side: str, +) -> dict[str, Any]: + """GT 신호와 기법 신호의 정합률을 계산한다. + + Args: + gt_signals: GT signals 리스트. + tech_signals: 기법 신호 dict 리스트. + tolerance_bars: 허용 봉 오차. + side: buy | sell. + + Returns: + 정합 메트릭 dict. + """ + gt_filtered = [s for s in gt_signals if s["side"] == side] + tech_filtered = [s for s in tech_signals if s["side"] == side] + + used: set[int] = set() + hits: list[dict[str, Any]] = [] + misses: list[dict[str, Any]] = [] + + for gt_sig in gt_filtered: + match = _match_signal(gt_sig["bar_index"], tech_filtered, tolerance_bars, used) + if match: + used.add(match["candidate_index"]) + hits.append({**match, "gt_datetime": gt_sig["datetime"], "gt_price": gt_sig["price"]}) + else: + misses.append( + { + "gt_bar_index": gt_sig["bar_index"], + "gt_datetime": gt_sig["datetime"], + "gt_price": gt_sig["price"], + } + ) + + gt_count = len(gt_filtered) + hit_count = len(hits) + recall = hit_count / gt_count if gt_count else 0.0 + precision = hit_count / len(tech_filtered) if tech_filtered else 0.0 + f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 + avg_offset = sum(h["bar_offset"] for h in hits) / hit_count if hit_count else 0.0 + + return { + "side": side, + "gt_count": gt_count, + "tech_count": len(tech_filtered), + "hit_count": hit_count, + "miss_count": len(misses), + "recall": round(recall, 4), + "precision": round(precision, 4), + "f1": round(f1, 4), + "avg_bar_offset": round(avg_offset, 1), + "hits": hits, + "misses": misses, + } + + +def align_legs( + gt_legs: list[dict[str, Any]], + tech_legs: list[dict[str, Any]], + tolerance_bars: int, +) -> dict[str, Any]: + """GT 레그와 기법 레그의 정합률을 계산한다.""" + captured: list[dict[str, Any]] = [] + missed: list[dict[str, Any]] = [] + used_tech: set[int] = set() + + for gt_leg in gt_legs: + best_leg: dict[str, Any] | None = None + best_score = tolerance_bars * 2 + 1 + best_id = -1 + + for tech_leg in tech_legs: + tid = int(tech_leg["leg_id"]) + if tid in used_tech: + continue + buy_dist = _bar_distance(gt_leg["buy_bar_index"], tech_leg["buy_bar_index"]) + sell_dist = _bar_distance(gt_leg["sell_bar_index"], tech_leg["sell_bar_index"]) + score = buy_dist + sell_dist + if buy_dist <= tolerance_bars and sell_dist <= tolerance_bars and score < best_score: + best_leg = tech_leg + best_score = score + best_id = tid + + if best_leg: + used_tech.add(best_id) + captured.append( + { + "gt_leg_id": gt_leg["leg_id"], + "tech_leg_id": best_leg["leg_id"], + "gt_buy": gt_leg["buy_datetime"], + "tech_buy": best_leg["buy_datetime"], + "gt_sell": gt_leg["sell_datetime"], + "tech_sell": best_leg["sell_datetime"], + "buy_bar_offset": _bar_distance(gt_leg["buy_bar_index"], best_leg["buy_bar_index"]), + "sell_bar_offset": _bar_distance(gt_leg["sell_bar_index"], best_leg["sell_bar_index"]), + "gt_leg_pct": gt_leg["leg_pct"], + "tech_leg_pct": best_leg["leg_pct"], + } + ) + else: + missed.append( + { + "gt_leg_id": gt_leg["leg_id"], + "buy_datetime": gt_leg["buy_datetime"], + "sell_datetime": gt_leg["sell_datetime"], + "leg_pct": gt_leg["leg_pct"], + } + ) + + gt_count = len(gt_legs) + recall = len(captured) / gt_count if gt_count else 0.0 + + return { + "gt_leg_count": gt_count, + "tech_leg_count": len(tech_legs), + "captured_count": len(captured), + "missed_count": len(missed), + "leg_recall": round(recall, 4), + "captured": captured, + "missed": missed, + } + + +def align_with_ground_truth( + gt_result: dict[str, Any], + technique_signals: list[dict[str, Any]], + technique_legs: list[dict[str, Any]], + tolerance_bars: int, +) -> dict[str, Any]: + """GT 대비 기법 정합 결과 전체를 반환한다.""" + gt_signals = gt_result.get("signals", []) + gt_legs = gt_result.get("legs", []) + gt_pnl = gt_result.get("pnl", {}) + + buy_align = align_signals(gt_signals, technique_signals, tolerance_bars, "buy") + sell_align = align_signals(gt_signals, technique_signals, tolerance_bars, "sell") + leg_align = align_legs(gt_legs, technique_legs, tolerance_bars) + + tech_return = 0.0 + if technique_legs: + from deepcoin.ground_truth.pnl import simulate_gt_pnl + + tech_pnl = simulate_gt_pnl( + technique_legs, + initial_cash_krw=gt_pnl.get("initial_cash_krw", 400_000), + fee_rate=gt_pnl.get("fee_rate", 0.0005), + ) + tech_return = tech_pnl["total_return_pct"] + + gt_return = gt_pnl.get("total_return_pct", 0.0) + return_capture = tech_return / gt_return if gt_return else 0.0 + + by_signal_type = align_all_signal_types(gt_signals, technique_signals, tolerance_bars) + + return { + "tolerance_bars": tolerance_bars, + "buy": buy_align, + "sell": sell_align, + "legs": leg_align, + "by_signal_type": by_signal_type, + "gt_return_pct": gt_return, + "tech_return_pct": tech_return, + "return_capture_ratio": round(return_capture, 4), + "score": round( + ( + buy_align["recall"] * 0.25 + + sell_align["recall"] * 0.25 + + leg_align["leg_recall"] * 0.35 + + min(return_capture, 1.0) * 0.15 + ), + 4, + ), + } + + +def align_signal_type( + gt_signals: list[dict[str, Any]], + tech_signals: list[dict[str, Any]], + tolerance_bars: int, + signal_type: str, +) -> dict[str, Any]: + """특정 GT 신호 유형에 대한 기법 정합률을 계산한다. + + Args: + gt_signals: GT signals 리스트 (signal_type 필드 포함). + tech_signals: 기법 신호 dict 리스트. + tolerance_bars: 허용 봉 오차. + signal_type: swing_low | pullback | breakout | div_bull | swing_high | div_bear. + + Returns: + 신호 유형별 정합 메트릭 dict. + """ + side = SIGNAL_TYPE_SIDE.get(signal_type, "buy") + gt_filtered = [ + s for s in gt_signals if s.get("signal_type") == signal_type and s.get("side") == side + ] + result = align_signals(gt_filtered, tech_signals, tolerance_bars, side) + result["signal_type"] = signal_type + result["signal_label"] = SIGNAL_TYPE_LABELS.get(signal_type, signal_type) + result["primary_techniques"] = SIGNAL_TYPE_PRIMARY_TECHNIQUES.get(signal_type, []) + return result + + +def align_all_signal_types( + gt_signals: list[dict[str, Any]], + tech_signals: list[dict[str, Any]], + tolerance_bars: int, +) -> dict[str, dict[str, Any]]: + """모든 GT 신호 유형에 대한 정합 결과를 반환한다.""" + present_types = {s.get("signal_type") for s in gt_signals if s.get("signal_type")} + types_to_run = [t for t in GT_SIGNAL_TYPES if t in present_types] + return { + signal_type: align_signal_type(gt_signals, tech_signals, tolerance_bars, signal_type) + for signal_type in types_to_run + } + + +def summarize_signal_type_matrix( + technique_results: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """기법별·신호유형별 recall 매트릭스 요약을 생성한다. + + Args: + technique_results: technique_id, technique_name, by_signal_type 키를 가진 dict 리스트. + + Returns: + 신호 유형별 최고 recall 기법이 포함된 행 리스트. + """ + rows: list[dict[str, Any]] = [] + for signal_type in GT_SIGNAL_TYPES: + label = SIGNAL_TYPE_LABELS.get(signal_type, signal_type) + primary = SIGNAL_TYPE_PRIMARY_TECHNIQUES.get(signal_type, []) + entries: list[dict[str, Any]] = [] + + for tech in technique_results: + by_type = tech.get("by_signal_type") or {} + align = by_type.get(signal_type) + if not align or align.get("gt_count", 0) == 0: + continue + entries.append( + { + "technique_id": tech["technique_id"], + "technique_name": tech["technique_name"], + "recall": align.get("recall", 0.0), + "hit_count": align.get("hit_count", 0), + "gt_count": align.get("gt_count", 0), + "avg_bar_offset": align.get("avg_bar_offset", 0.0), + "is_primary": tech["technique_id"] in primary, + } + ) + + if not entries: + continue + + entries.sort(key=lambda e: e["recall"], reverse=True) + best = entries[0] + gt_count = best["gt_count"] + rows.append( + { + "signal_type": signal_type, + "signal_label": label, + "side": SIGNAL_TYPE_SIDE.get(signal_type, ""), + "gt_count": gt_count, + "primary_techniques": primary, + "best_technique_id": best["technique_id"], + "best_technique_name": best["technique_name"], + "best_recall": best["recall"], + "best_avg_offset": best["avg_bar_offset"], + "ranking": entries, + } + ) + return rows diff --git a/src/deepcoin/evaluation/mtf_report.py b/src/deepcoin/evaluation/mtf_report.py new file mode 100644 index 0000000..1532a9d --- /dev/null +++ b/src/deepcoin/evaluation/mtf_report.py @@ -0,0 +1,494 @@ +"""GT v3 타점과 MTF 피처 상태 상관 분석 리포트.""" + +from __future__ import annotations + +import json +import math +import random +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +import pandas as pd + +from deepcoin.evaluation.gt_align import SIGNAL_TYPE_LABELS, SIGNAL_TYPE_SIDE +from deepcoin.mtf.extractor import MtfFeatureExtractor, MtfSnapshot +from deepcoin.mtf.features import FEATURE_NAMES + +NUMERIC_FEATURES: tuple[str, ...] = ( + "close_vs_ema60_pct", + "ema60_slope_5_pct", + "rsi14", + "macd_hist", + "bb_position", + "atr_pct", + "zigzag_leg_pct", +) + +CATEGORICAL_FEATURES: tuple[str, ...] = ( + "zigzag_direction", + "trend_bias", +) + + +def _parse_dt(raw: str) -> pd.Timestamp: + """datetime 문자열을 Timestamp로 변환한다.""" + return pd.Timestamp(raw) + + +def filter_signals_in_period( + signals: list[dict[str, Any]], + period_from: str, + period_to: str, +) -> list[dict[str, Any]]: + """기간 내 GT 신호만 필터한다.""" + start = _parse_dt(period_from) + end = _parse_dt(period_to) + out: list[dict[str, Any]] = [] + for sig in signals: + dt = _parse_dt(sig["datetime"]) + if start <= dt <= end: + out.append(sig) + return out + + +def compute_sim_period( + data_end: str, + lookback_days: int, +) -> tuple[str, str]: + """sim lookback 기준 분석 구간 시작·종료를 반환한다.""" + end = _parse_dt(data_end) + start = end - timedelta(days=lookback_days) + return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S") + + +def build_excluded_bar_indices( + signals: list[dict[str, Any]], + exclude_bars: int, +) -> set[int]: + """음성 샘플에서 제외할 3분봉 bar_index 집합.""" + excluded: set[int] = set() + for sig in signals: + center = int(sig["bar_index"]) + for offset in range(-exclude_bars, exclude_bars + 1): + excluded.add(center + offset) + return excluded + + +def sample_negative_datetimes( + base_df: pd.DataFrame, + period_from: str, + period_to: str, + excluded_indices: set[int], + sample_count: int, + seed: int = 42, +) -> list[str]: + """분석 구간에서 GT 주변을 제외한 랜덤 3분봉 시각을 샘플링한다.""" + start = _parse_dt(period_from) + end = _parse_dt(period_to) + mask = (base_df["datetime"] >= start) & (base_df["datetime"] <= end) + candidates: list[str] = [] + for idx, row in base_df.loc[mask].iterrows(): + if int(idx) in excluded_indices: + continue + candidates.append(pd.Timestamp(row["datetime"]).strftime("%Y-%m-%d %H:%M:%S")) + + if not candidates: + return [] + + rng = random.Random(seed) + if len(candidates) <= sample_count: + return candidates + return rng.sample(candidates, sample_count) + + +def _cohens_d(group_a: list[float], group_b: list[float]) -> float | None: + """두 표본 간 Cohen's d 효과 크기.""" + if len(group_a) < 2 or len(group_b) < 2: + return None + mean_a = sum(group_a) / len(group_a) + mean_b = sum(group_b) / len(group_b) + var_a = sum((x - mean_a) ** 2 for x in group_a) / (len(group_a) - 1) + var_b = sum((x - mean_b) ** 2 for x in group_b) / (len(group_b) - 1) + pooled = math.sqrt(((len(group_a) - 1) * var_a + (len(group_b) - 1) * var_b) / (len(group_a) + len(group_b) - 2)) + if pooled == 0: + return None + return (mean_a - mean_b) / pooled + + +def _feature_value(snapshot: MtfSnapshot, tf_label: str, feature: str) -> Any: + """스냅샷에서 TF·피처 값을 꺼낸다.""" + tf = snapshot.timeframes.get(tf_label, {}) + if not tf.get("available"): + return None + return tf.get(feature) + + +def _summarize_numeric( + pos_values: list[float], + neg_values: list[float], +) -> dict[str, Any]: + """수치 피처 양성·음성 분포 요약.""" + if not pos_values: + return {"positive_count": 0, "negative_count": len(neg_values)} + + pos_mean = sum(pos_values) / len(pos_values) + neg_mean = sum(neg_values) / len(neg_values) if neg_values else None + pos_sorted = sorted(pos_values) + med_idx = len(pos_sorted) // 2 + pos_median = pos_sorted[med_idx] + + return { + "positive_count": len(pos_values), + "negative_count": len(neg_values), + "positive_mean": round(pos_mean, 4), + "positive_median": round(pos_median, 4), + "negative_mean": round(neg_mean, 4) if neg_mean is not None else None, + "mean_delta": round(pos_mean - neg_mean, 4) if neg_mean is not None else None, + "cohens_d": round(_cohens_d(pos_values, neg_values) or 0.0, 4) + if neg_values and _cohens_d(pos_values, neg_values) is not None + else None, + } + + +def _summarize_categorical( + pos_values: list[str], + neg_values: list[str], +) -> dict[str, Any]: + """범주형 피처 분포 요약.""" + def _ratio(values: list[str], target: str) -> float | None: + if not values: + return None + return round(sum(1 for v in values if v == target) / len(values), 4) + + pos_counts: dict[str, int] = {} + for v in pos_values: + pos_counts[v] = pos_counts.get(v, 0) + 1 + + neg_counts: dict[str, int] = {} + for v in neg_values: + neg_counts[v] = neg_counts.get(v, 0) + 1 + + return { + "positive_count": len(pos_values), + "negative_count": len(neg_values), + "positive_distribution": pos_counts, + "negative_distribution": neg_counts, + "positive_bullish_ratio": _ratio(pos_values, "bullish") + if pos_values and pos_values[0] in ("bullish", "bearish") + else None, + "negative_bullish_ratio": _ratio(neg_values, "bullish") + if neg_values and neg_values[0] in ("bullish", "bearish") + else None, + "positive_up_ratio": _ratio(pos_values, "up") + if pos_values and pos_values[0] in ("up", "down", "none") + else None, + "negative_up_ratio": _ratio(neg_values, "up") + if neg_values and neg_values[0] in ("up", "down", "none") + else None, + } + + +def analyze_feature_correlations( + labeled_snapshots: list[dict[str, Any]], + negative_snapshots: list[MtfSnapshot], + tf_labels: list[str], +) -> dict[str, Any]: + """신호 유형·TF·피처별 GT vs 음성 비교.""" + neg_list = list(negative_snapshots) + by_type: dict[str, list[MtfSnapshot]] = {} + for item in labeled_snapshots: + st = item["signal_type"] + snap = item.get("snapshot") + if isinstance(snap, MtfSnapshot): + by_type.setdefault(st, []).append(snap) + + type_reports: dict[str, Any] = {} + + for signal_type, pos_snaps in sorted(by_type.items()): + tf_report: dict[str, Any] = {} + + for tf_label in tf_labels: + numeric_rows: dict[str, Any] = {} + for feat in NUMERIC_FEATURES: + pos_vals: list[float] = [] + for snap in pos_snaps: + val = _feature_value(snap, tf_label, feat) + if val is not None and isinstance(val, (int, float)): + pos_vals.append(float(val)) + + neg_vals: list[float] = [] + for snap in neg_list: + val = _feature_value(snap, tf_label, feat) + if val is not None and isinstance(val, (int, float)): + neg_vals.append(float(val)) + + summary = _summarize_numeric(pos_vals, neg_vals) + summary["feature"] = feat + numeric_rows[feat] = summary + + cat_rows: dict[str, Any] = {} + for feat in CATEGORICAL_FEATURES: + pos_vals = [ + str(v) + for snap in pos_snaps + if (v := _feature_value(snap, tf_label, feat)) is not None + ] + neg_vals = [ + str(v) + for snap in neg_list + if (v := _feature_value(snap, tf_label, feat)) is not None + ] + cat_rows[feat] = _summarize_categorical(pos_vals, neg_vals) + + ranked = sorted( + numeric_rows.values(), + key=lambda r: abs(r.get("cohens_d") or 0.0), + reverse=True, + ) + tf_report[tf_label] = { + "numeric": numeric_rows, + "categorical": cat_rows, + "top_numeric_features": ranked[:3], + } + + type_reports[signal_type] = { + "label": SIGNAL_TYPE_LABELS.get(signal_type, signal_type), + "side": SIGNAL_TYPE_SIDE.get(signal_type, ""), + "sample_count": len(pos_snaps), + "timeframes": tf_report, + } + + return type_reports + + +def rank_global_feature_importance( + type_reports: dict[str, Any], +) -> list[dict[str, Any]]: + """신호 유형·TF·피처별 |Cohen's d| 전역 순위.""" + rows: list[dict[str, Any]] = [] + for signal_type, block in type_reports.items(): + for tf_label, tf_data in block.get("timeframes", {}).items(): + for feat, summary in tf_data.get("numeric", {}).items(): + d = summary.get("cohens_d") + if d is None: + continue + rows.append( + { + "signal_type": signal_type, + "signal_label": block.get("label", signal_type), + "side": block.get("side", ""), + "timeframe": tf_label, + "feature": feat, + "cohens_d": d, + "abs_cohens_d": abs(d), + "positive_mean": summary.get("positive_mean"), + "negative_mean": summary.get("negative_mean"), + "mean_delta": summary.get("mean_delta"), + "positive_count": summary.get("positive_count", 0), + } + ) + rows.sort(key=lambda r: r["abs_cohens_d"], reverse=True) + return rows[:50] + + +def build_mtf_correlation_report( + gt_result: dict[str, Any], + extractor: MtfFeatureExtractor, + lookback_days: int, + negative_sample_count: int = 2000, + exclude_bars: int = 60, + seed: int = 42, +) -> dict[str, Any]: + """GT v3 타점 MTF 상관 리포트 본문을 생성한다. + + Args: + gt_result: Ground Truth JSON. + extractor: MTF 피처 추출기. + lookback_days: 분석 구간(일). + negative_sample_count: 음성 샘플 수. + exclude_bars: GT 주변 제외 3분봉 수. + seed: 음성 샘플 RNG seed. + + Returns: + 리포트 dict. + """ + meta = gt_result.get("meta", {}) + signals = gt_result.get("signals") or [] + data_end = meta.get("data_to", "") + period_from, period_to = compute_sim_period(data_end, lookback_days) + period_signals = filter_signals_in_period(signals, period_from, period_to) + + tf_labels = [extractor.store.interval_label(iv) for iv in extractor.intervals] + + labeled: list[dict[str, Any]] = [] + missing = 0 + for sig in period_signals: + snap = extractor.extract_at(sig["datetime"]) + if snap is None: + missing += 1 + continue + labeled.append( + { + "signal_type": sig.get("signal_type", "unknown"), + "side": sig.get("side", ""), + "datetime": sig["datetime"], + "bar_index": sig.get("bar_index"), + "snapshot": snap, + } + ) + + excluded = build_excluded_bar_indices(period_signals, exclude_bars) + neg_dts = sample_negative_datetimes( + extractor.store.base_df, + period_from, + period_to, + excluded, + negative_sample_count, + seed=seed, + ) + neg_snaps = extractor.extract_many(neg_dts) + + type_reports = analyze_feature_correlations(labeled, neg_snaps, tf_labels) + global_rank = rank_global_feature_importance(type_reports) + + type_counts: dict[str, int] = {} + for sig in period_signals: + st = sig.get("signal_type", "unknown") + type_counts[st] = type_counts.get(st, 0) + 1 + + return { + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "symbol": meta.get("symbol", ""), + "chart_tier": meta.get("chart_tier", "v3"), + "analysis": { + "lookback_days": lookback_days, + "period_from": period_from, + "period_to": period_to, + "base_interval_min": extractor.base_interval_min, + "timeframes": [ + {"interval_min": iv, "label": extractor.store.interval_label(iv)} + for iv in extractor.intervals + ], + "feature_names": list(FEATURE_NAMES), + "numeric_features": list(NUMERIC_FEATURES), + "categorical_features": list(CATEGORICAL_FEATURES), + "negative_exclude_bars": exclude_bars, + "negative_sample_requested": negative_sample_count, + "negative_sample_count": len(neg_snaps), + }, + "gt": { + "signals_in_period": len(period_signals), + "snapshots_extracted": len(labeled), + "snapshots_missing": missing, + "signal_type_counts": type_counts, + }, + "signal_type_labels": SIGNAL_TYPE_LABELS, + "by_signal_type": type_reports, + "global_feature_ranking": global_rank, + } + + +def save_mtf_report(report: dict[str, Any], json_path: Path) -> Path: + """MTF 상관 리포트 JSON 저장.""" + json_path.parent.mkdir(parents=True, exist_ok=True) + + def _serialize(obj: Any) -> Any: + if isinstance(obj, MtfSnapshot): + return obj.to_dict() + raise TypeError(f"not serializable: {type(obj)}") + + serializable = json.loads(json.dumps(report, default=_serialize, ensure_ascii=False)) + with json_path.open("w", encoding="utf-8") as fp: + json.dump(serializable, fp, ensure_ascii=False, indent=2) + return json_path + + +def render_mtf_html(report: dict[str, Any], html_path: Path) -> Path: + """MTF 상관 리포트 HTML을 생성한다.""" + html_path.parent.mkdir(parents=True, exist_ok=True) + analysis = report.get("analysis", {}) + gt = report.get("gt", {}) + ranking = report.get("global_feature_ranking", [])[:30] + by_type = report.get("by_signal_type", {}) + + rank_rows = "" + for row in ranking: + rank_rows += ( + f"" + f"{row.get('signal_label', '')}" + f"{row.get('timeframe', '')}" + f"{row.get('feature', '')}" + f"{row.get('cohens_d', '')}" + f"{row.get('positive_mean', '')}" + f"{row.get('negative_mean', '')}" + f"{row.get('positive_count', '')}" + f"" + ) + + type_sections = "" + for signal_type, block in by_type.items(): + label = block.get("label", signal_type) + count = block.get("sample_count", 0) + tf_bits = [] + for tf_label, tf_data in block.get("timeframes", {}).items(): + tops = tf_data.get("top_numeric_features") or [] + if not tops: + continue + items = ", ".join( + f"{t.get('feature')} (d={t.get('cohens_d')})" + for t in tops[:2] + ) + tf_bits.append(f"
  • {tf_label}: {items}
  • ") + type_sections += ( + f"

    {label} ({signal_type}) — {count}건

    " + f"
      {''.join(tf_bits) or '
    • 데이터 없음
    • '}
    " + ) + + html = f""" + + + + MTF GT v3 상관 분석 + + + +

    GT v3 · 멀티 TF 상태 상관 분석

    +
    +

    생성: {report.get("generated_at", "")}

    +

    구간: {analysis.get("period_from", "")} ~ {analysis.get("period_to", "")} + ({analysis.get("lookback_days", "")}일)

    +

    GT 신호: {gt.get("signals_in_period", 0)}건 · 스냅샷 {gt.get("snapshots_extracted", 0)}건 + · 음성 샘플 {analysis.get("negative_sample_count", 0)}건

    +

    TF: {", ".join(t["label"] for t in analysis.get("timeframes", []))}

    +

    양성=GT v3 타점 시각의 인과 MTF 스냅샷 / 음성=동일 구간 랜덤 3분봉(±{analysis.get("negative_exclude_bars")}봉 제외)

    +
    + +

    전역 피처 중요도 (|Cohen's d| 상위)

    + + + + + + + + {rank_rows or ""} +
    신호 유형TF피처Cohen dGT 평균음성 평균GT n
    없음
    + +

    신호 유형별 TF 요약

    + {type_sections or "

    없음

    "} + +

    상세 수치는 JSON 리포트 참조.

    + + +""" + html_path.write_text(html, encoding="utf-8") + return html_path diff --git a/src/deepcoin/evaluation/report.py b/src/deepcoin/evaluation/report.py new file mode 100644 index 0000000..803b4bf --- /dev/null +++ b/src/deepcoin/evaluation/report.py @@ -0,0 +1,132 @@ +"""2단계: 인과 기법 GT 정합 비교 리포트.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +from deepcoin.techniques.base import TechniqueResult + + +def build_comparison_report( + results: list[TechniqueResult], + gt_result: dict[str, Any], + symbol: str, +) -> dict[str, Any]: + """기법 비교 요약 리포트를 생성한다.""" + gt_meta = gt_result.get("meta", {}) + gt_pnl = gt_result.get("pnl", {}) + gt_summary = gt_result.get("summary", {}) + + rows: list[dict[str, Any]] = [] + for result in results: + align = result.alignment or {} + buy = align.get("buy", {}) + sell = align.get("sell", {}) + legs = align.get("legs", {}) + rows.append( + { + "technique_id": result.technique_id, + "technique_name": result.technique_name, + "category": result.category, + "causal": result.causal, + "leg_count": result.summary.get("leg_count", 0), + "tech_return_pct": result.pnl.get("total_return_pct", 0.0), + "buy_recall": buy.get("recall", 0.0), + "sell_recall": sell.get("recall", 0.0), + "leg_recall": legs.get("leg_recall", 0.0), + "return_capture_ratio": align.get("return_capture_ratio", 0.0), + "score": align.get("score", 0.0), + "avg_buy_offset": buy.get("avg_bar_offset", 0.0), + "avg_sell_offset": sell.get("avg_bar_offset", 0.0), + } + ) + + rows.sort(key=lambda r: r["score"], reverse=True) + + return { + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "symbol": symbol, + "gt": { + "leg_count": gt_summary.get("leg_count", 0), + "return_pct": gt_pnl.get("total_return_pct", 0.0), + "interval_label": gt_meta.get("interval_label", ""), + "lookback_days": gt_meta.get("lookback_days", 365), + }, + "ranking": rows, + } + + +def save_comparison_report(report: dict[str, Any], json_path: Path) -> Path: + """비교 리포트 JSON을 저장한다.""" + json_path.parent.mkdir(parents=True, exist_ok=True) + with json_path.open("w", encoding="utf-8") as fp: + json.dump(report, fp, ensure_ascii=False, indent=2) + return json_path + + +def render_comparison_html(report: dict[str, Any], html_path: Path) -> Path: + """비교 리포트 HTML을 생성한다.""" + html_path.parent.mkdir(parents=True, exist_ok=True) + rows = report.get("ranking", []) + gt = report.get("gt", {}) + + table_rows = "" + for idx, row in enumerate(rows, start=1): + table_rows += f""" + + {idx} + {row['technique_name']} + {row['category']} + {'Y' if row['causal'] else 'N'} + {row['leg_count']} + {row['tech_return_pct']:+.1f}% + {row['buy_recall']*100:.1f}% + {row['sell_recall']*100:.1f}% + {row['leg_recall']*100:.1f}% + {row['return_capture_ratio']*100:.1f}% + {row['score']*100:.1f} + """ + + html = f""" + + + + DeepCoin 2단계 — 인과 GT 정합 + + + +

    DeepCoin 2단계 — 인과 기법 Ground Truth 정합

    +
    + 생성: {report.get('generated_at', '')} | + {report.get('symbol', '')} | + GT: {gt.get('leg_count', 0)}레그, {gt.get('return_pct', 0):+.1f}% | + 기간: 최근 {gt.get('lookback_days', 365)}일 +
    + + + + + + + + + {table_rows} + +
    순위기법유형인과레그수익률매수 Recall매도 Recall레그 Recall수익 포착종합 Score
    + +""" + + with html_path.open("w", encoding="utf-8") as fp: + fp.write(html) + return html_path diff --git a/src/deepcoin/evaluation/signal_type_report.py b/src/deepcoin/evaluation/signal_type_report.py new file mode 100644 index 0000000..2211008 --- /dev/null +++ b/src/deepcoin/evaluation/signal_type_report.py @@ -0,0 +1,216 @@ +"""v3 GT 신호 유형별 기법 정합 리포트.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +from deepcoin.evaluation.gt_align import ( + SIGNAL_TYPE_LABELS, + SIGNAL_TYPE_PRIMARY_TECHNIQUES, + summarize_signal_type_matrix, +) +from deepcoin.techniques.base import TechniqueResult + + +def build_signal_type_report( + results: list[TechniqueResult], + gt_result: dict[str, Any], + symbol: str, +) -> dict[str, Any]: + """신호 유형별 GT 정합 리포트를 생성한다. + + Args: + results: 기법 실행 결과 리스트. + gt_result: Ground Truth JSON dict. + symbol: 거래 심볼. + + Returns: + 리포트 dict. + """ + gt_meta = gt_result.get("meta", {}) + gt_summary = gt_result.get("summary", {}) + gt_signals = gt_result.get("signals", []) + + type_counts: dict[str, int] = {} + for sig in gt_signals: + st = sig.get("signal_type", "") + if st: + type_counts[st] = type_counts.get(st, 0) + 1 + + tech_rows: list[dict[str, Any]] = [] + for result in results: + align = result.alignment or {} + by_type = align.get("by_signal_type") or {} + tech_rows.append( + { + "technique_id": result.technique_id, + "technique_name": result.technique_name, + "category": result.category, + "causal": result.causal, + "score": align.get("score", 0.0), + "by_signal_type": by_type, + } + ) + + matrix = summarize_signal_type_matrix(tech_rows) + + technique_detail: list[dict[str, Any]] = [] + for result in results: + align = result.alignment or {} + by_type = align.get("by_signal_type") or {} + type_recalls: dict[str, Any] = {} + for signal_type, type_align in by_type.items(): + type_recalls[signal_type] = { + "label": type_align.get("signal_label", signal_type), + "gt_count": type_align.get("gt_count", 0), + "hit_count": type_align.get("hit_count", 0), + "recall": type_align.get("recall", 0.0), + "avg_bar_offset": type_align.get("avg_bar_offset", 0.0), + "is_primary": result.technique_id + in SIGNAL_TYPE_PRIMARY_TECHNIQUES.get(signal_type, []), + } + technique_detail.append( + { + "technique_id": result.technique_id, + "technique_name": result.technique_name, + "category": result.category, + "overall_score": align.get("score", 0.0), + "signal_types": type_recalls, + } + ) + + technique_detail.sort(key=lambda r: r["overall_score"], reverse=True) + + return { + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "symbol": symbol, + "chart_tier": gt_meta.get("chart_tier", "v3"), + "gt": { + "interval_label": gt_meta.get("interval_label", ""), + "lookback_days": gt_meta.get("lookback_days", 730), + "signal_type_counts": type_counts, + "buy_count": gt_summary.get("buy_count", 0), + "sell_count": gt_summary.get("sell_count", 0), + "leg_count": gt_summary.get("leg_count", 0), + }, + "signal_type_labels": SIGNAL_TYPE_LABELS, + "primary_technique_map": SIGNAL_TYPE_PRIMARY_TECHNIQUES, + "best_by_signal_type": matrix, + "techniques": technique_detail, + } + + +def save_signal_type_report(report: dict[str, Any], json_path: Path) -> Path: + """신호 유형 리포트 JSON을 저장한다.""" + json_path.parent.mkdir(parents=True, exist_ok=True) + with json_path.open("w", encoding="utf-8") as fp: + json.dump(report, fp, ensure_ascii=False, indent=2) + return json_path + + +def render_signal_type_html(report: dict[str, Any], html_path: Path) -> Path: + """신호 유형 리포트 HTML을 생성한다.""" + html_path.parent.mkdir(parents=True, exist_ok=True) + gt = report.get("gt", {}) + counts = gt.get("signal_type_counts", {}) + best_rows = report.get("best_by_signal_type", []) + techniques = report.get("techniques", []) + + count_cells = "" + for signal_type, label in report.get("signal_type_labels", {}).items(): + count = counts.get(signal_type, 0) + if count: + count_cells += f"{label}{signal_type}{count}" + + best_table = "" + for row in best_rows: + primary = ", ".join(row.get("primary_techniques", [])) + best_table += f""" + + {row['signal_label']} + {row['gt_count']} + {primary} + {row['best_technique_name']} + {row['best_recall']*100:.1f}% + {row['best_avg_offset']:.1f}봉 + """ + + tech_headers = "".join( + f"{label.split('(')[0].strip()}" + for label in report.get("signal_type_labels", {}).values() + ) + tech_rows = "" + for tech in techniques: + cells = "" + for signal_type in report.get("signal_type_labels", {}): + st = tech.get("signal_types", {}).get(signal_type) + if st and st.get("gt_count", 0) > 0: + star = "*" if st.get("is_primary") else "" + cells += f"{st['recall']*100:.0f}%{star}" + else: + cells += "-" + tech_rows += f""" + + {tech['technique_name']} + {tech['overall_score']*100:.1f} + {cells} + """ + + html = f""" + + + + DeepCoin — v3 신호 유형별 GT 정합 + + + +

    DeepCoin — v3 신호 유형별 Ground Truth 정합

    +
    + 생성: {report.get('generated_at', '')} | + {report.get('symbol', '')} | + Tier: {report.get('chart_tier', 'v3')} | + 기간: 최근 {gt.get('lookback_days', 730)}일 | + 매수 {gt.get('buy_count', 0)} / 매도 {gt.get('sell_count', 0)} / 레그 {gt.get('leg_count', 0)} +
    + +

    GT 신호 유형 분포

    + + + {count_cells} +
    라벨signal_type건수
    + +

    신호 유형별 최고 Recall 기법

    + + + + + {best_table} +
    신호 유형GT 건수1차 기법최고 기법Recall평균 오차
    + +

    기법 × 신호 유형 Recall 매트릭스

    +

    * = 해당 신호 유형 1차 정합 대상 기법

    + + + {tech_headers} + + {tech_rows} +
    기법종합 Score
    + +""" + + with html_path.open("w", encoding="utf-8") as fp: + fp.write(html) + return html_path diff --git a/src/deepcoin/ground_truth/__init__.py b/src/deepcoin/ground_truth/__init__.py new file mode 100644 index 0000000..c9ee079 --- /dev/null +++ b/src/deepcoin/ground_truth/__init__.py @@ -0,0 +1 @@ +"""Ground Truth — 사후 벤치마크 매수·매도 타점.""" diff --git a/src/deepcoin/ground_truth/breakout.py b/src/deepcoin/ground_truth/breakout.py new file mode 100644 index 0000000..02046df --- /dev/null +++ b/src/deepcoin/ground_truth/breakout.py @@ -0,0 +1,162 @@ +"""돌파 매수 타점 (Ground Truth 보조, 사후 최적).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +import pandas as pd + +from deepcoin.ground_truth.zigzag import Pivot + + +class _LegLike(Protocol): + """레그 최소 인터페이스.""" + + leg_id: int + buy_bar_index: int + sell_bar_index: int + sell_price: float + + +@dataclass(frozen=True) +class BreakoutBuy: + """돌파 매수 타점.""" + + bar_index: int + price: float + datetime: pd.Timestamp + leg_id: int + resistance_price: float + + +def find_breakout_buy_pivots( + df: pd.DataFrame, + legs: list[_LegLike], + pullback_buys: list[Pivot], + breakout_buffer_pct: float = 0.1, + min_bars_after_anchor: int = 10, + consolidation_bars: int = 200, + leg_end_margin_bars: int = 80, + min_rally_to_sell_pct: float = 2.0, +) -> list[BreakoutBuy]: + """각 레그에서 횡보·눌림 후 저항 돌파 시점을 찾는다 (1단계 GT, 미래 허용). + + 눌림목 이후 구간 고점을 저항으로 보고, 종가가 저항을 돌파한 + 첫 봉을 레그당 돌파 매수 1개로 표시한다. + + Args: + df: OHLCV DataFrame. + legs: ZigZag 스윙 레그. + pullback_buys: 눌림목 Pivot (레그별 매칭). + breakout_buffer_pct: 저항 대비 돌파 확인 버퍼(%). + min_bars_after_anchor: 눌림목/구간 시작 후 최소 대기 봉. + consolidation_bars: 횡보 저항 산정 구간(봉). + leg_end_margin_bars: 매도 직전 제외 봉. + min_rally_to_sell_pct: 돌파 후 레그 고점까지 최소 추가 상승(%). + + Returns: + BreakoutBuy 리스트. + """ + if not legs or len(df) < 20: + return [] + + highs = df["high"].astype(float).values + closes = df["close"].astype(float).values + pullback_by_leg = _map_pullback_to_legs(legs, pullback_buys) + breakouts: list[BreakoutBuy] = [] + buffer = breakout_buffer_pct / 100.0 + + for leg in legs: + anchor = pullback_by_leg.get(leg.leg_id) + if anchor is not None: + zone_start = anchor.bar_index + else: + leg_len = leg.sell_bar_index - leg.buy_bar_index + zone_start = max( + leg.buy_bar_index + 120, + leg.sell_bar_index - int(leg_len * 0.35), + ) + + zone_end = min( + zone_start + consolidation_bars, + leg.sell_bar_index - leg_end_margin_bars - 5, + ) + search_start = zone_start + min_bars_after_anchor + search_end = leg.sell_bar_index - leg_end_margin_bars + if zone_end <= zone_start or search_end <= search_start + 5: + continue + + resistance = float(highs[zone_start:zone_end].max()) + if resistance <= 0: + continue + + breakout = _first_breakout_above( + df=df, + highs=highs, + closes=closes, + search_start=max(search_start, zone_end), + search_end=search_end, + resistance=resistance, + buffer=buffer, + sell_price=float(leg.sell_price), + min_rally_pct=min_rally_to_sell_pct, + ) + if breakout is None: + continue + + bar_idx, price, resistance = breakout + breakouts.append( + BreakoutBuy( + bar_index=bar_idx, + price=round(price, 2), + datetime=pd.Timestamp(df.iloc[bar_idx]["datetime"]), + leg_id=leg.leg_id, + resistance_price=round(resistance, 2), + ) + ) + + return breakouts + + +def _map_pullback_to_legs(legs: list[_LegLike], pullback_buys: list[Pivot]) -> dict[int, Pivot]: + """눌림목을 소속 레그에 매핑한다.""" + mapping: dict[int, Pivot] = {} + for pivot in pullback_buys: + for leg in legs: + if leg.buy_bar_index < pivot.bar_index < leg.sell_bar_index: + prev = mapping.get(leg.leg_id) + if prev is None or pivot.bar_index > prev.bar_index: + mapping[leg.leg_id] = pivot + return mapping + + +def _first_breakout_above( + df: pd.DataFrame, + highs, + closes, + search_start: int, + search_end: int, + resistance: float, + buffer: float, + sell_price: float, + min_rally_pct: float, +) -> tuple[int, float, float] | None: + """고정 저항선 상향 돌파 첫 봉을 반환한다.""" + threshold = resistance * (1 + buffer) + + for i in range(search_start, search_end): + close_val = float(closes[i]) + if close_val <= threshold: + continue + + future_max = float(highs[i : search_end + 1].max()) + rally_pct = (future_max - close_val) / close_val * 100.0 + sell_rally_pct = (sell_price - close_val) / close_val * 100.0 + + if rally_pct < min_rally_pct or sell_rally_pct < min_rally_pct: + continue + + return i, close_val, resistance + + return None diff --git a/src/deepcoin/ground_truth/chart.py b/src/deepcoin/ground_truth/chart.py new file mode 100644 index 0000000..7df2545 --- /dev/null +++ b/src/deepcoin/ground_truth/chart.py @@ -0,0 +1,1005 @@ +"""Ground Truth 차트 HTML 생성 (전체 기간 지원).""" + +from __future__ import annotations + +import bisect +import json +from pathlib import Path +from typing import Any + +import pandas as pd + +from deepcoin.data.candle_loader import load_candles + +# 0이면 제한 없이 전체 봉 표시 +DEFAULT_MAX_CANDLES = 0 + + +def _data_js_path(html_path: Path) -> Path: + """HTML과 짝을 이루는 데이터 JS 경로 (file:// 프로토콜 호환). + + 예: ground_truth_chart_v3.html → ground_truth_chart_v3_data.js + """ + return html_path.with_name(f"{html_path.stem}_data.js") + + +def _to_unix_seconds(dt_series: pd.Series) -> list[int]: + """datetime Series를 uPlot/LWC용 unix 초 리스트로 변환한다. + + pandas datetime64[ns/us/ms] 단위 차이에 관계없이 올바른 초 단위를 반환한다. + + Args: + dt_series: datetime 컬럼. + + Returns: + unix epoch 초 리스트. + """ + parsed = pd.to_datetime(dt_series) + seconds = (parsed - pd.Timestamp("1970-01-01")) / pd.Timedelta(seconds=1) + return seconds.astype(int).tolist() + + +def _close_at_timestamp(times: list[int], closes: list[float], ts: int) -> float: + """차트 종가 배열에서 시각에 해당하는 종가를 반환한다. + + Args: + times: unix 초 리스트. + closes: 종가 리스트. + ts: 조회 시각(unix 초). + + Returns: + 해당 봉 종가. 정확히 일치하는 봉이 없으면 가장 가까운 봉 종가. + """ + if not times: + return 0.0 + idx = bisect.bisect_left(times, ts) + if idx >= len(times): + return closes[-1] + if idx > 0 and times[idx] != ts: + if abs(times[idx - 1] - ts) <= abs(times[idx] - ts): + idx -= 1 + return closes[idx] + + +def _enrich_markers_chart_price( + markers: list[dict[str, Any]], + times: list[int], + closes: list[float], +) -> list[dict[str, Any]]: + """마커에 종가 선(chart) 위치용 chart_price를 추가한다.""" + return [ + {**marker, "chart_price": _close_at_timestamp(times, closes, marker["time"])} + for marker in markers + ] + + +def _markers_from_executed_trades( + sim_pnl: dict[str, Any], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """시뮬에서 실제 체결된 매수·매도만 마커로 변환한다.""" + buy_markers: list[dict[str, Any]] = [] + sell_markers: list[dict[str, Any]] = [] + + for trade in sim_pnl.get("trades") or []: + if trade.get("skipped"): + continue + side = trade["side"] + signal_type = trade.get("signal_type") or ( + "swing_low" if side == "buy" else "swing_high" + ) + marker = { + "time": int(pd.Timestamp(trade["datetime"]).timestamp()), + "price": trade["price"], + "marker_id": trade.get("marker_id") or trade.get("trade_id"), + "signal_type": signal_type, + } + if side == "buy": + buy_markers.append(marker) + else: + sell_markers.append(marker) + + return buy_markers, sell_markers + + +def _markers_from_gt_signals( + gt_result: dict[str, Any], + sim_period_from_ts: int | None = None, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """GT 신호에서 마커를 구성한다 (1단계 차트용).""" + buy_markers: list[dict[str, Any]] = [] + sell_markers: list[dict[str, Any]] = [] + + for sig in gt_result.get("signals") or []: + ts = int(pd.Timestamp(sig["datetime"]).timestamp()) + if sim_period_from_ts is not None and ts < sim_period_from_ts: + continue + marker = { + "time": ts, + "price": sig["price"], + "marker_id": sig.get("marker_id", sig.get("leg_id")), + "signal_type": sig.get( + "signal_type", + "swing_low" if sig["side"] == "buy" else "swing_high", + ), + } + if sig["side"] == "buy": + buy_markers.append(marker) + else: + sell_markers.append(marker) + + return buy_markers, sell_markers + + +def _sim_start_marker( + df: pd.DataFrame, + sim_pnl: dict[str, Any], +) -> dict[str, Any] | None: + """1년 시뮬 매매 시작 시점 마커를 구성한다.""" + period_from = sim_pnl.get("period_from") + if not period_from: + return None + + start_ts = pd.Timestamp(period_from) + parsed = pd.to_datetime(df["datetime"]) + idx = int(parsed.searchsorted(start_ts, side="left")) + if idx >= len(df): + idx = len(df) - 1 + row = df.iloc[idx] + dt_str = str(row["datetime"]) + return { + "time": int(pd.Timestamp(dt_str).timestamp()), + "price": float(row["close"]), + "datetime": dt_str, + "label": "거래시작", + } + + +def _build_chart_payload( + df: pd.DataFrame, + gt_result: dict[str, Any], + chart_days: int, + gt_lookback_days: int, + sim_pnl: dict[str, Any] | None = None, +) -> dict[str, Any]: + """차트 HTML용 JSON payload를 구성한다.""" + times = _to_unix_seconds(df["datetime"]) + closes = df["close"].astype(float).tolist() + + if sim_pnl is not None: + buy_markers, sell_markers = _markers_from_executed_trades(sim_pnl) + else: + buy_markers, sell_markers = _markers_from_gt_signals(gt_result) + + buy_markers = _enrich_markers_chart_price(buy_markers, times, closes) + sell_markers = _enrich_markers_chart_price(sell_markers, times, closes) + + chart_meta = { + **gt_result["meta"], + "chart_lookback_days": chart_days, + "gt_lookback_days": gt_lookback_days, + "chart_data_from": str(df["datetime"].min()), + "chart_data_to": str(df["datetime"].max()), + "gt_marker_count": len(buy_markers), + } + if sim_pnl is not None: + chart_meta["sim_period_from"] = sim_pnl.get("period_from") + chart_meta["sim_period_to"] = sim_pnl.get("period_to") + chart_meta["sim_lookback_days"] = sim_pnl.get("sim_lookback_days") + payload: dict[str, Any] = { + "times": times, + "open": df["open"].astype(float).tolist(), + "high": df["high"].astype(float).tolist(), + "low": df["low"].astype(float).tolist(), + "close": closes, + "buy_markers": buy_markers, + "sell_markers": sell_markers, + "meta": chart_meta, + "bar_count": len(df), + } + if sim_pnl is not None: + payload["sim_pnl"] = sim_pnl + start_marker = _sim_start_marker(df, sim_pnl) + if start_marker is not None: + payload["sim_start_marker"] = start_marker + return payload + + +def render_ground_truth_chart( + db_path: Path, + symbol: str, + gt_result: dict[str, Any], + output_path: Path, + chart_lookback_days: int | None = None, + max_candles: int = DEFAULT_MAX_CANDLES, +) -> Path: + """GT 타점이 표시된 HTML 차트를 생성한다. + + 대용량(3분봉 2년 등)은 종가 라인으로 전체 기간을 표시하고, + JSON 데이터는 별도 파일로 분리한다. + + Args: + db_path: SQLite 경로. + symbol: 코인 심볼. + gt_result: build_ground_truth 결과. + output_path: HTML 출력 경로. + chart_lookback_days: 차트에 표시할 일수. None이면 GT lookback과 동일. + max_candles: 0이면 전체, 양수면 최근 N봉만. + + Returns: + HTML 저장 경로. + """ + interval_min = gt_result["meta"]["interval_min"] + gt_lookback_days = gt_result["meta"]["lookback_days"] + chart_days = chart_lookback_days if chart_lookback_days is not None else gt_lookback_days + + df = load_candles(db_path, symbol, interval_min, lookback_days=chart_days) + if max_candles > 0 and len(df) > max_candles: + df = df.iloc[-max_candles:].reset_index(drop=True) + + payload = _build_chart_payload(df, gt_result, chart_days, gt_lookback_days) + + output_path.parent.mkdir(parents=True, exist_ok=True) + data_path = _data_js_path(output_path) + with data_path.open("w", encoding="utf-8") as fp: + fp.write("window.CHART_DATA=") + json.dump(payload, fp, ensure_ascii=False, separators=(",", ":")) + fp.write(";") + + data_js_name = data_path.name + output_path.write_text(_html_template(data_js_name), encoding="utf-8") + return output_path + + +_HTML_TEMPLATE = """ + + + + Ground Truth Chart + + + + + + + +
    +

    Ground Truth Chart

    +
    +
    +__EXTRA_BODY__ +
    +
    + + + + 타점 - / - +
    +
    + + + + + +
    +
    + + + +
    +
    + + +
    + 데이터 로딩 중… +
    +
    +
    +

    상세 캔들

    +
    +
    + + +""" + + +def _html_template(data_js_name: str) -> str: + """1단계 GT 차트 HTML 템플릿을 생성한다.""" + return _build_html_template(data_js_name, sim_mode=False) + + +_SIM_EXTRA_STYLES = """ + .sim-panel { margin: 12px 24px 0; padding: 16px 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px; } + .sim-panel h2 { margin: 0 0 12px; font-size: 16px; } + .sim-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } + .sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; min-width: 0; overflow: hidden; } + .sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; } + .sim-card .value { font-size: 18px; font-weight: bold; line-height: 1.25; max-width: 100%; } + .sim-card .value.positive { color: #2e7d32; } + .sim-card .value.negative { color: #c62828; } + .sim-note { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; } + #trade-table-wrap { margin: 12px 24px 0; background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; } + #trade-table-wrap summary { padding: 10px 16px; cursor: pointer; font-size: 14px; background: #fafafa; border-bottom: 1px solid #eee; } + .trade-table { width: 100%; border-collapse: collapse; font-size: 12px; } + .trade-table th, .trade-table td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: right; } + .trade-table th:first-child, .trade-table td:first-child { text-align: left; } + .trade-table th { background: #f5f5f5; position: sticky; top: 0; } + .trade-table tr.skipped td { color: #999; } + .trade-scroll { max-height: 240px; overflow: auto; } +""" + +_SIM_EXTRA_BODY = """ +
    +

    1단계 수익 sim

    +
    +
    +
    +
    + 체결 내역 (0건) +
    + + + + + + + + +
    시각구분유형가격주문금액수수료현금코인비고
    +
    +
    +""" + +_SIM_EXTRA_SCRIPT = """ + function fmtMoney(v) { + return Math.round(v).toLocaleString("ko-KR") + "원"; + } + + function fmtPct(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "-"; + const sign = n > 0 ? "+" : n < 0 ? "-" : ""; + const formatted = Math.abs(n).toLocaleString("ko-KR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return sign + formatted + "%"; + } + + function fitSimCardValues() { + document.querySelectorAll(".sim-card .value").forEach(el => { + const card = el.closest(".sim-card"); + if (!card) return; + const maxW = Math.max(card.clientWidth - 24, 80); + let size = 18; + el.style.fontSize = size + "px"; + el.style.whiteSpace = "nowrap"; + while (el.scrollWidth > maxW && size > 9) { + size -= 1; + el.style.fontSize = size + "px"; + } + if (el.scrollWidth > maxW) { + el.style.whiteSpace = "normal"; + el.style.wordBreak = "keep-all"; + el.style.overflowWrap = "anywhere"; + el.style.fontSize = "10px"; + } + }); + } + + function renderSimPanel() { + const p = DATA.sim_pnl; + const retClass = p.total_return_pct >= 0 ? "positive" : "negative"; + document.getElementById("sim-grid").innerHTML = [ + ["초기 자본", fmtMoney(p.initial_cash_krw), ""], + ["최종 평가액", fmtMoney(p.final_equity_krw), retClass], + ["손익", fmtMoney(p.total_pnl_krw), retClass], + ["수익률", fmtPct(p.total_return_pct), retClass], + ["현금 잔고", fmtMoney(p.final_cash_krw), ""], + ["보유 코인", p.final_coin_qty.toFixed(8), ""], + ["코인 평가", fmtMoney(p.final_coin_value_krw), ""], + ["매수/매도", `${p.buys_executed}/${p.sells_executed}건`, ""], + ].map(([label, value, cls]) => + `
    ${label}
    ${value}
    ` + ).join(""); + document.getElementById("sim-note").textContent = + `시뮬 기간: ${p.period_from} ~ ${p.period_to} (${p.sim_lookback_days}일) | ` + + `신호 ${p.signals_in_period}건 | 분할매수/매도 클러스터 적용 | ` + + `스킵 매수 ${p.buys_skipped} / 매도 ${p.sells_skipped} | 수수료 ${(p.fee_rate * 100).toFixed(2)}%` + + (p.buy_sizing_rule ? ` | 매수 ${p.buy_sizing_rule}` : ""); + const tbody = document.getElementById("trade-body"); + tbody.innerHTML = ""; + (p.trades || []).forEach(t => { + const tr = document.createElement("tr"); + if (t.skipped) tr.className = "skipped"; + tr.innerHTML = ` + ${t.datetime} + ${t.side === "buy" ? "매수" : "매도"} + ${t.signal_type} + ${fmtPrice(t.price)} + ${t.order_krw ? fmtMoney(t.order_krw) : "-"} + ${t.fee_krw ? fmtMoney(t.fee_krw) : "-"} + ${fmtMoney(t.cash_after)} + ${t.coin_after.toFixed(8)} + ${t.skipped ? (t.skip_reason || "스킵") : "분할 " + t.cluster_size}`; + tbody.appendChild(tr); + }); + document.getElementById("trade-count").textContent = String((p.trades || []).length); + fitSimCardValues(); + } +""" + + +def _build_html_template(data_js_name: str, sim_mode: bool) -> str: + """GT/시뮬 차트 HTML 템플릿을 생성한다.""" + html = _HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name) + if sim_mode: + html = ( + html.replace("__EXTRA_STYLES__", _SIM_EXTRA_STYLES) + .replace("__EXTRA_BODY__", _SIM_EXTRA_BODY) + .replace("__EXTRA_SCRIPT__", _SIM_EXTRA_SCRIPT) + ) + else: + html = ( + html.replace("__EXTRA_STYLES__", "") + .replace("__EXTRA_BODY__", "") + .replace("__EXTRA_SCRIPT__", "") + ) + return html + + +def _sim_html_template(data_js_name: str) -> str: + """2단계 sim 차트 HTML 템플릿을 생성한다.""" + return _build_html_template(data_js_name, sim_mode=True) + + +def render_ground_truth_sim_chart( + db_path: Path, + symbol: str, + gt_result: dict[str, Any], + sim_pnl: dict[str, Any], + output_path: Path, + chart_lookback_days: int | None = None, + max_candles: int = DEFAULT_MAX_CANDLES, +) -> Path: + """GT 타점 + 2단계 시뮬 수익 결과가 표시된 HTML 차트를 생성한다. + + Args: + db_path: SQLite 경로. + symbol: 코인 심볼. + gt_result: build_ground_truth 결과. + sim_pnl: simulate_gt_signals_pnl 결과. + output_path: HTML 출력 경로. + chart_lookback_days: 차트 표시 일수. + max_candles: 0이면 전체. + + Returns: + HTML 저장 경로. + """ + interval_min = gt_result["meta"]["interval_min"] + gt_lookback_days = gt_result["meta"]["lookback_days"] + chart_days = chart_lookback_days if chart_lookback_days is not None else gt_lookback_days + + df = load_candles(db_path, symbol, interval_min, lookback_days=chart_days) + if max_candles > 0 and len(df) > max_candles: + df = df.iloc[-max_candles:].reset_index(drop=True) + + payload = _build_chart_payload( + df, gt_result, chart_days, gt_lookback_days, sim_pnl=sim_pnl + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + data_path = _data_js_path(output_path) + with data_path.open("w", encoding="utf-8") as fp: + fp.write("window.CHART_DATA=") + json.dump(payload, fp, ensure_ascii=False, separators=(",", ":")) + fp.write(";") + + data_js_name = data_path.name + output_path.write_text(_sim_html_template(data_js_name), encoding="utf-8") + return output_path + diff --git a/src/deepcoin/ground_truth/divergence.py b/src/deepcoin/ground_truth/divergence.py new file mode 100644 index 0000000..f170057 --- /dev/null +++ b/src/deepcoin/ground_truth/divergence.py @@ -0,0 +1,303 @@ +"""RSI·MACD 다이버전스 매수·매도 타점 (Ground Truth, 사후 검증).""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from deepcoin.techniques.indicators import macd, rsi + + +@dataclass(frozen=True) +class DivergenceSignal: + """다이버전스 신호.""" + + side: str # buy | sell + bar_index: int + price: float + datetime: pd.Timestamp + indicator: str # rsi | macd_hist + price_prev: float + price_curr: float + ind_prev: float + ind_curr: float + + +def find_divergence_signals( + df: pd.DataFrame, + local_order: int = 15, + min_bars_between: int = 100, + max_pair_lookback_bars: int = 5000, + rsi_period: int = 14, + min_rsi_diff: float = 2.0, + min_macd_hist_diff: float = 0.0, + min_price_move_pct: float = 1.5, + future_bars: int = 2000, + min_future_move_pct: float = 2.0, +) -> tuple[list[DivergenceSignal], list[DivergenceSignal]]: + """가격·지표 다이버전스 매수·매도 후보를 찾는다. + + - 상승 다이버전스(매수): 가격 LL + RSI/MACD HL + - 하락 다이버전스(매도): 가격 HH + RSI/MACD LH + - 미래 데이터로 이후 유의미한 반등·하락을 사후 검증 + + Args: + df: OHLCV DataFrame. + local_order: 국소 극값 반경(봉). + min_bars_between: 연속 다이버전스 최소 간격(봉). + max_pair_lookback_bars: 비교할 이전 극값 최대 거리(봉). + rsi_period: RSI 기간. + min_rsi_diff: RSI 다이버전스 최소 차이(포인트). + min_macd_hist_diff: MACD 히스토그램 최소 차이. + min_price_move_pct: 극값 간 최소 가격 변동(%). + future_bars: 사후 검증 구간(봉). + min_future_move_pct: 사후 최소 가격 변동(%). + + Returns: + (매수 신호, 매도 신호) 리스트. + """ + if len(df) < local_order * 4 + rsi_period: + return [], [] + + close = df["close"].astype(float) + low = df["low"].astype(float) + high = df["high"].astype(float) + rsi_vals = rsi(close, period=rsi_period) + _, _, macd_hist = macd(close) + + lows = _find_local_extrema(df, low, rsi_vals, macd_hist, local_order, "low") + highs = _find_local_extrema(df, high, rsi_vals, macd_hist, local_order, "high") + + bull_rsi = _detect_bullish( + df, lows, rsi_vals, "rsi", min_rsi_diff, min_price_move_pct, + max_pair_lookback_bars, future_bars, min_future_move_pct, high, + ) + bear_rsi = _detect_bearish( + df, highs, rsi_vals, "rsi", min_rsi_diff, min_price_move_pct, + max_pair_lookback_bars, future_bars, min_future_move_pct, low, + ) + + bull_macd: list[DivergenceSignal] = [] + bear_macd: list[DivergenceSignal] = [] + if min_macd_hist_diff > 0: + bull_macd = _detect_bullish( + df, lows, macd_hist, "macd_hist", min_macd_hist_diff, min_price_move_pct, + max_pair_lookback_bars, future_bars, min_future_move_pct, high, + ) + bear_macd = _detect_bearish( + df, highs, macd_hist, "macd_hist", min_macd_hist_diff, min_price_move_pct, + max_pair_lookback_bars, future_bars, min_future_move_pct, low, + ) + + buys = _dedupe_signals( + _merge_same_bar(bull_rsi + bull_macd), min_bars_between, prefer_lower=True + ) + sells = _dedupe_signals( + _merge_same_bar(bear_rsi + bear_macd), min_bars_between, prefer_lower=False + ) + return buys, sells + + +@dataclass +class _ExtremePoint: + """국소 극값.""" + + bar_index: int + price: float + rsi: float + macd_hist: float + datetime: pd.Timestamp + + +def _find_local_extrema( + df: pd.DataFrame, + series: pd.Series, + rsi_vals: pd.Series, + macd_hist: pd.Series, + order: int, + side: str, +) -> list[_ExtremePoint]: + """국소 저점·고점을 수집한다.""" + points: list[_ExtremePoint] = [] + values = series.values + + for i in range(order, len(df) - order): + window = values[i - order : i + order + 1] + val = float(values[i]) + if side == "low" and val > window.min(): + continue + if side == "high" and val < window.max(): + continue + if pd.isna(rsi_vals.iloc[i]) or pd.isna(macd_hist.iloc[i]): + continue + points.append( + _ExtremePoint( + bar_index=i, + price=val, + rsi=float(rsi_vals.iloc[i]), + macd_hist=float(macd_hist.iloc[i]), + datetime=pd.Timestamp(df.iloc[i]["datetime"]), + ) + ) + return points + + +def _detect_bullish( + df: pd.DataFrame, + lows: list[_ExtremePoint], + indicator: pd.Series, + indicator_name: str, + min_ind_diff: float, + min_price_move_pct: float, + max_lookback: int, + future_bars: int, + min_future_move_pct: float, + highs, +) -> list[DivergenceSignal]: + """상승 다이버전스 매수를 탐지한다.""" + signals: list[DivergenceSignal] = [] + if len(lows) < 2: + return signals + + for idx in range(1, len(lows)): + curr = lows[idx] + for prev in reversed(lows[:idx]): + if curr.bar_index - prev.bar_index > max_lookback: + break + if curr.bar_index - prev.bar_index < 20: + continue + + price_move = (prev.price - curr.price) / prev.price * 100.0 + if price_move < min_price_move_pct: + continue + + ind_prev = float(indicator.iloc[prev.bar_index]) + ind_curr = float(indicator.iloc[curr.bar_index]) + if pd.isna(ind_prev) or pd.isna(ind_curr): + continue + if not (curr.price < prev.price and ind_curr > ind_prev + min_ind_diff): + continue + + end = min(len(df), curr.bar_index + future_bars + 1) + future_high = float(highs[curr.bar_index:end].max()) + rally = (future_high - curr.price) / curr.price * 100.0 + if rally < min_future_move_pct: + continue + + signals.append( + DivergenceSignal( + side="buy", + bar_index=curr.bar_index, + price=round(curr.price, 2), + datetime=curr.datetime, + indicator=indicator_name, + price_prev=round(prev.price, 2), + price_curr=round(curr.price, 2), + ind_prev=round(ind_prev, 4), + ind_curr=round(ind_curr, 4), + ) + ) + break + + return signals + + +def _detect_bearish( + df: pd.DataFrame, + highs: list[_ExtremePoint], + indicator: pd.Series, + indicator_name: str, + min_ind_diff: float, + min_price_move_pct: float, + max_lookback: int, + future_bars: int, + min_future_move_pct: float, + lows, +) -> list[DivergenceSignal]: + """하락 다이버전스 매도를 탐지한다.""" + signals: list[DivergenceSignal] = [] + if len(highs) < 2: + return signals + + for idx in range(1, len(highs)): + curr = highs[idx] + for prev in reversed(highs[:idx]): + if curr.bar_index - prev.bar_index > max_lookback: + break + if curr.bar_index - prev.bar_index < 20: + continue + + price_move = (curr.price - prev.price) / prev.price * 100.0 + if price_move < min_price_move_pct: + continue + + ind_prev = float(indicator.iloc[prev.bar_index]) + ind_curr = float(indicator.iloc[curr.bar_index]) + if pd.isna(ind_prev) or pd.isna(ind_curr): + continue + if not (curr.price > prev.price and ind_curr < ind_prev - min_ind_diff): + continue + + end = min(len(df), curr.bar_index + future_bars + 1) + future_low = float(lows[curr.bar_index:end].min()) + drop = (curr.price - future_low) / curr.price * 100.0 + if drop < min_future_move_pct: + continue + + signals.append( + DivergenceSignal( + side="sell", + bar_index=curr.bar_index, + price=round(curr.price, 2), + datetime=curr.datetime, + indicator=indicator_name, + price_prev=round(prev.price, 2), + price_curr=round(curr.price, 2), + ind_prev=round(ind_prev, 4), + ind_curr=round(ind_curr, 4), + ) + ) + break + + return signals + + +def _merge_same_bar(signals: list[DivergenceSignal]) -> list[DivergenceSignal]: + """동일 봉·동일 방향 신호를 하나로 합친다.""" + by_bar: dict[int, DivergenceSignal] = {} + for signal in signals: + prev = by_bar.get(signal.bar_index) + if prev is None: + by_bar[signal.bar_index] = signal + continue + prev_diff = abs(prev.ind_curr - prev.ind_prev) + curr_diff = abs(signal.ind_curr - signal.ind_prev) + if curr_diff > prev_diff: + by_bar[signal.bar_index] = signal + return sorted(by_bar.values(), key=lambda s: s.bar_index) + + +def _dedupe_signals( + signals: list[DivergenceSignal], + min_bars: int, + prefer_lower: bool, +) -> list[DivergenceSignal]: + """근접 신호를 병합한다.""" + if not signals: + return [] + + sorted_signals = sorted(signals, key=lambda s: s.bar_index) + merged: list[DivergenceSignal] = [sorted_signals[0]] + + for signal in sorted_signals[1:]: + last = merged[-1] + if signal.bar_index - last.bar_index < min_bars: + if prefer_lower and signal.price < last.price: + merged[-1] = signal + elif not prefer_lower and signal.price > last.price: + merged[-1] = signal + else: + merged.append(signal) + + return merged diff --git a/src/deepcoin/ground_truth/futures.py b/src/deepcoin/ground_truth/futures.py new file mode 100644 index 0000000..b08e098 --- /dev/null +++ b/src/deepcoin/ground_truth/futures.py @@ -0,0 +1,128 @@ +"""현물 Ground Truth 신호를 선물 롱·숏 타점으로 변환한다.""" + +from __future__ import annotations + +from typing import Any + +import pandas as pd + + +def futures_events_from_gt_signals( + signals: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """현물 GT 신호를 선물 이벤트 스트림으로 변환한다. + + 동일 봉에서 청산 이벤트를 먼저, 진입 이벤트를 나중에 배치한다. + + Args: + signals: GT signals 리스트. + + Returns: + position, action, event_order 등이 포함된 이벤트 리스트. + """ + events: list[dict[str, Any]] = [] + for sig in signals: + side = sig["side"] + signal_type = sig.get( + "signal_type", + "swing_low" if side == "buy" else "swing_high", + ) + base = { + "datetime": sig["datetime"], + "price": sig["price"], + "bar_index": sig.get("bar_index", 0), + "marker_id": sig.get("marker_id", sig.get("leg_id")), + "signal_type": signal_type, + "leg_id": sig.get("leg_id"), + } + if side == "buy": + events.append({**base, "position": "short", "action": "close", "event_order": 0}) + events.append({**base, "position": "long", "action": "open", "event_order": 1}) + else: + events.append({**base, "position": "long", "action": "close", "event_order": 0}) + events.append({**base, "position": "short", "action": "open", "event_order": 1}) + return events + + +def futures_markers_from_gt_signals( + gt_result: dict[str, Any], +) -> dict[str, list[dict[str, Any]]]: + """현물 GT 신호를 선물 4종 마커(상·하방 매수·매도)로 변환한다. + + 현물 GT의 스윙 저점(buy)과 고점(sell)을 동일 가격·시각에 + 롱·숏 양방향 선물 타점으로 매핑한다. + + - buy → 상방 매수(롱 진입), 하방 매도(숏 청산) + - sell → 상방 매도(롱 청산), 하방 매수(숏 진입) + + Args: + gt_result: build_ground_truth 결과 또는 동일 스키마 JSON. + + Returns: + long_open, long_close, short_open, short_close 마커 리스트. + """ + long_open: list[dict[str, Any]] = [] + long_close: list[dict[str, Any]] = [] + short_open: list[dict[str, Any]] = [] + short_close: list[dict[str, Any]] = [] + + for sig in gt_result.get("signals") or []: + side = sig["side"] + signal_type = sig.get( + "signal_type", + "swing_low" if side == "buy" else "swing_high", + ) + marker_id = sig.get("marker_id", sig.get("leg_id")) + base = { + "time": int(pd.Timestamp(sig["datetime"]).timestamp()), + "price": sig["price"], + "marker_id": marker_id, + "signal_type": signal_type, + "leg_id": sig.get("leg_id"), + } + if side == "buy": + long_open.append({**base, "position": "long", "action": "open"}) + short_close.append({**base, "position": "short", "action": "close"}) + else: + long_close.append({**base, "position": "long", "action": "close"}) + short_open.append({**base, "position": "short", "action": "open"}) + + return { + "long_open": long_open, + "long_close": long_close, + "short_open": short_open, + "short_close": short_close, + } + + +def futures_markers_from_executed_trades( + sim_pnl: dict[str, Any], +) -> dict[str, list[dict[str, Any]]]: + """시뮬 체결 내역에서 4종 선물 마커를 구성한다.""" + markers: dict[str, list[dict[str, Any]]] = { + "long_open": [], + "long_close": [], + "short_open": [], + "short_close": [], + } + + for trade in sim_pnl.get("trades") or []: + if trade.get("skipped"): + continue + position = trade["position"] + action = trade["action"] + key = f"{position}_{action}" + if key not in ("long_open", "long_close", "short_open", "short_close"): + continue + markers[key].append( + { + "time": int(pd.Timestamp(trade["datetime"]).timestamp()), + "price": trade["price"], + "marker_id": trade.get("marker_id") or trade.get("trade_id"), + "signal_type": trade.get("signal_type", ""), + "position": position, + "action": action, + } + ) + + return markers diff --git a/src/deepcoin/ground_truth/futures_chart.py b/src/deepcoin/ground_truth/futures_chart.py new file mode 100644 index 0000000..1dcc961 --- /dev/null +++ b/src/deepcoin/ground_truth/futures_chart.py @@ -0,0 +1,763 @@ +"""선물 Ground Truth 차트 HTML 생성 (롱·숏 4색 마커).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from deepcoin.data.candle_loader import load_candles +from deepcoin.ground_truth.chart import ( + DEFAULT_MAX_CANDLES, + _data_js_path, + _enrich_markers_chart_price, + _sim_start_marker, + _to_unix_seconds, +) +from deepcoin.ground_truth.futures import futures_markers_from_gt_signals + + +def _stack_marker_positions( + long_open: list[dict[str, Any]], + long_close: list[dict[str, Any]], + short_open: list[dict[str, Any]], + short_close: list[dict[str, Any]], +) -> None: + """동일 시각에 겹치는 마커에 세로 스택 인덱스를 부여한다. + + 같은 봉의 L↓·S↓ 등이 좌우로 벌어지지 않고 동일 X에서 위아래로 쌓이도록 한다. + """ + from collections import defaultdict + + groups: dict[int, list[dict[str, Any]]] = defaultdict(list) + for markers in (long_open, long_close, short_open, short_close): + for marker in markers: + groups[int(marker["time"])].append(marker) + + kind_rank = { + "long_close": 0, + "short_open": 1, + "long_open": 2, + "short_close": 3, + } + + for markers_at_time in groups.values(): + if len(markers_at_time) <= 1: + for marker in markers_at_time: + marker["stack_index"] = 0 + continue + markers_at_time.sort( + key=lambda m: kind_rank.get(f"{m.get('position')}_{m.get('action')}", 9) + ) + for index, marker in enumerate(markers_at_time): + marker["stack_index"] = index + marker.pop("x_offset_px", None) + +_FUTURES_HTML_TEMPLATE = """ + + + + Futures Ground Truth Chart + + + + + + + +
    +

    Futures Ground Truth Chart

    +
    +
    +
    +__EXTRA_BODY__ +
    +
    + + + + 타점 - / - +
    +
    + + + + + +
    +
    + + + + +
    +
    + + +
    + 데이터 로딩 중… +
    +
    +
    +

    상세 캔들

    +
    +
    + + +""" + + +def _build_futures_chart_payload( + df, + gt_result: dict[str, Any], + chart_days: int, + gt_lookback_days: int, +) -> dict[str, Any]: + """선물 GT 차트용 JSON payload를 구성한다.""" + markers = futures_markers_from_gt_signals(gt_result) + times = _to_unix_seconds(df["datetime"]) + closes = df["close"].astype(float).tolist() + chart_meta = { + **gt_result["meta"], + "market_type": "futures", + "chart_lookback_days": chart_days, + "gt_lookback_days": gt_lookback_days, + "chart_data_from": str(df["datetime"].min()), + "chart_data_to": str(df["datetime"].max()), + } + payload: dict[str, Any] = { + "times": times, + "open": df["open"].astype(float).tolist(), + "high": df["high"].astype(float).tolist(), + "low": df["low"].astype(float).tolist(), + "close": closes, + "long_open_markers": _enrich_markers_chart_price(markers["long_open"], times, closes), + "long_close_markers": _enrich_markers_chart_price(markers["long_close"], times, closes), + "short_open_markers": _enrich_markers_chart_price(markers["short_open"], times, closes), + "short_close_markers": _enrich_markers_chart_price(markers["short_close"], times, closes), + "meta": chart_meta, + "bar_count": len(df), + } + _stack_marker_positions( + payload["long_open_markers"], + payload["long_close_markers"], + payload["short_open_markers"], + payload["short_close_markers"], + ) + return payload + + +def render_futures_ground_truth_chart( + db_path: Path, + symbol: str, + gt_result: dict[str, Any], + output_path: Path, + chart_lookback_days: int | None = None, + max_candles: int = DEFAULT_MAX_CANDLES, +) -> Path: + """현물 GT 타점을 선물 롱·숏 4색 마커로 표시한 HTML 차트를 생성한다. + + Args: + db_path: SQLite 경로. + symbol: 코인 심볼. + gt_result: build_ground_truth 결과 또는 spot GT JSON. + output_path: HTML 출력 경로. + chart_lookback_days: 차트 표시 일수. None이면 GT lookback과 동일. + max_candles: 0이면 전체, 양수면 최근 N봉만. + + Returns: + HTML 저장 경로. + """ + interval_min = gt_result["meta"]["interval_min"] + gt_lookback_days = gt_result["meta"]["lookback_days"] + chart_days = chart_lookback_days if chart_lookback_days is not None else gt_lookback_days + + df = load_candles(db_path, symbol, interval_min, lookback_days=chart_days) + if max_candles > 0 and len(df) > max_candles: + df = df.iloc[-max_candles:].reset_index(drop=True) + + payload = _build_futures_chart_payload(df, gt_result, chart_days, gt_lookback_days) + + output_path.parent.mkdir(parents=True, exist_ok=True) + data_path = _data_js_path(output_path) + with data_path.open("w", encoding="utf-8") as fp: + fp.write("window.CHART_DATA=") + json.dump(payload, fp, ensure_ascii=False, separators=(",", ":")) + fp.write(";") + + data_js_name = data_path.name + html = _futures_html_template(data_js_name) + output_path.write_text(html, encoding="utf-8") + return output_path + + +def _futures_html_template(data_js_name: str) -> str: + """선물 GT 차트 HTML 템플릿을 생성한다.""" + return ( + _FUTURES_HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name) + .replace("__EXTRA_STYLES__", "") + .replace("__EXTRA_BODY__", "") + .replace("__EXTRA_SCRIPT__", "") + ) diff --git a/src/deepcoin/ground_truth/ground_truth.py b/src/deepcoin/ground_truth/ground_truth.py new file mode 100644 index 0000000..f9634c7 --- /dev/null +++ b/src/deepcoin/ground_truth/ground_truth.py @@ -0,0 +1,411 @@ +"""Ground Truth 매수·매도 타점 생성 (1단계 · 0단계 sim 입력).""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +import pandas as pd + +from deepcoin.data.candle_loader import load_candles +from deepcoin.data.intervals import interval_label +from deepcoin.ground_truth.pnl import simulate_gt_pnl +from deepcoin.ground_truth.breakout import find_breakout_buy_pivots +from deepcoin.ground_truth.divergence import find_divergence_signals +from deepcoin.ground_truth.pullback import find_pullback_buy_pivots +from deepcoin.ground_truth.zigzag import Pivot, find_zigzag_pivots + + +@dataclass(frozen=True) +class GtParams: + """Ground Truth 생성 파라미터.""" + + interval_min: int + lookback_days: int + zigzag_reversal_pct: float + min_leg_pct: float + pullback_min_pct: float = 1.5 + pullback_local_order: int = 10 + breakout_buffer_pct: float = 0.1 + breakout_consolidation_bars: int = 200 + breakout_min_rally_pct: float = 2.0 + div_local_order: int = 20 + div_min_bars_between: int = 1500 + div_min_rsi_diff: float = 5.0 + div_min_future_move_pct: float = 4.0 + chart_tier: str = "v3" + + +def _tier_flags(tier: str) -> tuple[bool, bool, bool]: + """차트 버전별 보조 신호 포함 여부 (눌림목, 돌파, 다이버전스). + + v1: ZigZag 스윙만 (레그당 1매수·1매도 최소) + v2: 스윙 + 눌림목 + v3: v2 + 돌파 + 다이버전스 + """ + tier = tier.lower() + if tier == "v1": + return False, False, False + if tier == "v2": + return True, False, False + return True, True, True + + +@dataclass +class GtLeg: + """매수→매도 1레그 (최대 스윙 수익 구간).""" + + leg_id: int + buy_datetime: str + buy_price: float + buy_bar_index: int + sell_datetime: str + sell_price: float + sell_bar_index: int + leg_pct: float + bars_held: int + + +def build_ground_truth( + db_path: Path, + symbol: str, + coin_name: str, + params: GtParams, + initial_cash_krw: float = 400_000.0, + fee_rate: float = 0.0005, +) -> dict[str, Any]: + """최근 1년 구간에서 사후 최적 스윙 레그(1매수·1매도) GT를 생성한다. + + 미래 데이터를 사용해 ZigZag 스윙 저점 매수·고점 매도 쌍을 찾는다. + 1단계 벤치마크: 최대 스윙 수익을 포착하는 타점. + + Args: + db_path: SQLite 경로. + symbol: 코인 심볼. + coin_name: 코인 이름. + params: GT 파라미터. + initial_cash_krw: 수익률 계산 초기 자본 (1년 시작 시점). + fee_rate: 거래 수수료율. + + Returns: + JSON 직렬화 가능한 GT 결과 dict. + """ + df = load_candles( + db_path=db_path, + symbol=symbol, + interval_min=params.interval_min, + lookback_days=params.lookback_days, + ) + + pivots = find_zigzag_pivots(df, reversal_pct=params.zigzag_reversal_pct) + legs = _pivots_to_legs(pivots, min_leg_pct=params.min_leg_pct) + leg_dicts = [asdict(leg) for leg in legs] + include_pullback, include_breakout, include_divergence = _tier_flags(params.chart_tier) + + pullback_buys: list[Pivot] = [] + if include_pullback: + pullback_buys = find_pullback_buy_pivots( + df, + legs=legs, + min_pullback_pct=params.pullback_min_pct, + local_order=params.pullback_local_order, + ) + + breakout_buys = [] + if include_breakout: + breakout_buys = find_breakout_buy_pivots( + df, + legs=legs, + pullback_buys=pullback_buys, + breakout_buffer_pct=params.breakout_buffer_pct, + consolidation_bars=params.breakout_consolidation_bars, + min_rally_to_sell_pct=params.breakout_min_rally_pct, + ) + + div_buys: list = [] + div_sells: list = [] + if include_divergence: + div_buys, div_sells = find_divergence_signals( + df, + local_order=params.div_local_order, + min_bars_between=params.div_min_bars_between, + min_rsi_diff=params.div_min_rsi_diff, + min_future_move_pct=params.div_min_future_move_pct, + ) + + mode_map = { + "v1": "optimal_swing_legs", + "v2": "optimal_swing_legs_with_pullback", + "v3": "optimal_swing_legs_with_pullback_breakout_divergence", + } + mode = mode_map.get(params.chart_tier.lower(), mode_map["v3"]) + + signals = _build_signals(legs, pullback_buys, breakout_buys, div_buys, div_sells) + summary = _summarize(legs, signals) + pnl = simulate_gt_pnl(leg_dicts, initial_cash_krw=initial_cash_krw, fee_rate=fee_rate) + + return { + "meta": { + "symbol": symbol.upper(), + "coin_name": coin_name, + "interval_min": params.interval_min, + "interval_label": interval_label(params.interval_min), + "lookback_days": params.lookback_days, + "chart_tier": params.chart_tier.lower(), + "mode": mode, + "zigzag_reversal_pct": params.zigzag_reversal_pct, + "min_leg_pct": params.min_leg_pct, + "pullback_min_pct": params.pullback_min_pct, + "initial_cash_krw": initial_cash_krw, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "data_from": str(df["datetime"].min()), + "data_to": str(df["datetime"].max()), + "bar_count": len(df), + "pivot_count": len(pivots), + "pullback_buy_count": len(pullback_buys), + "breakout_buy_count": len(breakout_buys), + "breakout_buffer_pct": params.breakout_buffer_pct, + "divergence_buy_count": len(div_buys), + "divergence_sell_count": len(div_sells), + }, + "legs": leg_dicts, + "signals": signals, + "summary": summary, + "pnl": pnl, + } + + +def save_ground_truth(result: dict[str, Any], output_path: Path) -> Path: + """GT 결과를 JSON으로 저장한다.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8") as fp: + json.dump(result, fp, ensure_ascii=False, indent=2) + return output_path + + +def _pivots_to_legs(pivots: list[Pivot], min_leg_pct: float) -> list[GtLeg]: + """스윙 저점→고점을 1매수·1매도 레그로 변환한다.""" + legs: list[GtLeg] = [] + leg_id = 0 + i = 0 + + while i < len(pivots) - 1: + buy_pivot = pivots[i] + sell_pivot = pivots[i + 1] + + if buy_pivot.side != "low" or sell_pivot.side != "high": + i += 1 + continue + if sell_pivot.bar_index <= buy_pivot.bar_index: + i += 1 + continue + + leg_pct = (sell_pivot.price - buy_pivot.price) / buy_pivot.price * 100.0 + if leg_pct < min_leg_pct: + i += 1 + continue + + leg_id += 1 + legs.append( + GtLeg( + leg_id=leg_id, + buy_datetime=buy_pivot.datetime.strftime("%Y-%m-%d %H:%M:%S"), + buy_price=round(buy_pivot.price, 2), + buy_bar_index=buy_pivot.bar_index, + sell_datetime=sell_pivot.datetime.strftime("%Y-%m-%d %H:%M:%S"), + sell_price=round(sell_pivot.price, 2), + sell_bar_index=sell_pivot.bar_index, + leg_pct=round(leg_pct, 2), + bars_held=sell_pivot.bar_index - buy_pivot.bar_index, + ) + ) + i += 2 + + return legs + + +def _build_signals( + legs: list[GtLeg], + pullback_buys: list[Pivot], + breakout_buys: list, + div_buys: list, + div_sells: list, +) -> list[dict[str, Any]]: + """스윙·눌림목·돌파·다이버전스 신호를 통합한다.""" + signals: list[dict[str, Any]] = [] + buy_marker_id = 0 + sell_marker_id = 0 + + existing_buy_bars: set[int] = {leg.buy_bar_index for leg in legs} + existing_sell_bars: set[int] = {leg.sell_bar_index for leg in legs} + nearby_tolerance = 120 + + for leg in legs: + buy_marker_id += 1 + signals.append( + { + "marker_id": buy_marker_id, + "leg_id": leg.leg_id, + "side": "buy", + "signal_type": "swing_low", + "datetime": leg.buy_datetime, + "price": leg.buy_price, + "bar_index": leg.buy_bar_index, + } + ) + sell_marker_id += 1 + existing_sell_bars.add(leg.sell_bar_index) + signals.append( + { + "marker_id": sell_marker_id, + "leg_id": leg.leg_id, + "side": "sell", + "signal_type": "swing_high", + "datetime": leg.sell_datetime, + "price": leg.sell_price, + "bar_index": leg.sell_bar_index, + "leg_pct": leg.leg_pct, + } + ) + + for pivot in pullback_buys: + if _is_near_existing_buy(pivot.bar_index, existing_buy_bars, nearby_tolerance): + continue + buy_marker_id += 1 + existing_buy_bars.add(pivot.bar_index) + signals.append( + { + "marker_id": buy_marker_id, + "leg_id": None, + "side": "buy", + "signal_type": "pullback", + "datetime": pivot.datetime.strftime("%Y-%m-%d %H:%M:%S"), + "price": round(pivot.price, 2), + "bar_index": pivot.bar_index, + } + ) + + for breakout in breakout_buys: + if _is_near_existing_buy(breakout.bar_index, existing_buy_bars, nearby_tolerance): + continue + buy_marker_id += 1 + existing_buy_bars.add(breakout.bar_index) + signals.append( + { + "marker_id": buy_marker_id, + "leg_id": breakout.leg_id, + "side": "buy", + "signal_type": "breakout", + "datetime": breakout.datetime.strftime("%Y-%m-%d %H:%M:%S"), + "price": breakout.price, + "bar_index": breakout.bar_index, + "resistance_price": breakout.resistance_price, + } + ) + + div_tolerance = 400 + for div in div_buys: + if _is_near_bar(div.bar_index, existing_buy_bars, div_tolerance): + continue + buy_marker_id += 1 + existing_buy_bars.add(div.bar_index) + signals.append(_divergence_to_dict(div, buy_marker_id, "div_bull")) + + for div in div_sells: + if _is_near_bar(div.bar_index, existing_sell_bars, div_tolerance): + continue + sell_marker_id += 1 + existing_sell_bars.add(div.bar_index) + signals.append(_divergence_to_dict(div, sell_marker_id, "div_bear")) + + signals.sort(key=lambda s: (s["bar_index"], _signal_sort_key(s))) + return signals + + +def _divergence_to_dict(div, marker_id: int, signal_type: str) -> dict[str, Any]: + """DivergenceSignal을 GT signal dict로 변환한다.""" + return { + "marker_id": marker_id, + "leg_id": None, + "side": div.side, + "signal_type": signal_type, + "datetime": div.datetime.strftime("%Y-%m-%d %H:%M:%S"), + "price": div.price, + "bar_index": div.bar_index, + "indicator": div.indicator, + "price_prev": div.price_prev, + "ind_prev": div.ind_prev, + "ind_curr": div.ind_curr, + } + + +def _signal_sort_key(signal: dict[str, Any]) -> int: + """동일 봉에서 신호 유형 정렬 우선순위.""" + order = { + "swing_low": 0, + "pullback": 1, + "breakout": 2, + "div_bull": 3, + "swing_high": 4, + "div_bear": 5, + } + return order.get(signal.get("signal_type", ""), 9) + + +def _is_near_bar(bar_index: int, existing_bars: set[int], tolerance: int) -> bool: + """기존 타점과 너무 가까우면 보조 신호를 제외한다.""" + for existing in existing_bars: + if abs(bar_index - existing) <= tolerance: + return True + return False + + +def _is_near_existing_buy(bar_index: int, existing_bars: set[int], tolerance: int) -> bool: + """기존 매수와 너무 가까우면 보조 매수를 제외한다.""" + return _is_near_bar(bar_index, existing_bars, tolerance) + + +def _summarize(legs: list[GtLeg], signals: list[dict[str, Any]]) -> dict[str, Any]: + """GT 요약 통계.""" + buy_count = sum(1 for s in signals if s["side"] == "buy") + sell_count = sum(1 for s in signals if s["side"] == "sell") + pullback_count = sum(1 for s in signals if s.get("signal_type") == "pullback") + breakout_count = sum(1 for s in signals if s.get("signal_type") == "breakout") + div_buy_count = sum(1 for s in signals if s.get("signal_type") == "div_bull") + div_sell_count = sum(1 for s in signals if s.get("signal_type") == "div_bear") + + if not legs: + return { + "leg_count": 0, + "buy_count": buy_count, + "sell_count": sell_count, + "pullback_buy_count": pullback_count, + "breakout_buy_count": breakout_count, + "divergence_buy_count": div_buy_count, + "divergence_sell_count": div_sell_count, + "avg_leg_pct": 0.0, + "median_leg_pct": 0.0, + "max_leg_pct": 0.0, + "min_leg_pct": 0.0, + "avg_bars_held": 0.0, + } + + pcts = [leg.leg_pct for leg in legs] + bars = [leg.bars_held for leg in legs] + return { + "leg_count": len(legs), + "buy_count": buy_count, + "sell_count": sell_count, + "pullback_buy_count": pullback_count, + "breakout_buy_count": breakout_count, + "divergence_buy_count": div_buy_count, + "divergence_sell_count": div_sell_count, + "avg_leg_pct": round(sum(pcts) / len(pcts), 2), + "median_leg_pct": round(float(pd.Series(pcts).median()), 2), + "max_leg_pct": round(max(pcts), 2), + "min_leg_pct": round(min(pcts), 2), + "avg_bars_held": round(sum(bars) / len(bars), 1), + } diff --git a/src/deepcoin/ground_truth/order_sizing.py b/src/deepcoin/ground_truth/order_sizing.py new file mode 100644 index 0000000..0e07968 --- /dev/null +++ b/src/deepcoin/ground_truth/order_sizing.py @@ -0,0 +1,59 @@ +"""총평가금액 구간별 매수(현금) 상한.""" + +from __future__ import annotations + +from typing import Any + +# 총평가금액(원) 구간 — 높은 구간이 우선 적용 +EQUITY_TIER_100M = 100_000_000 +EQUITY_TIER_1B = 1_000_000_000 +EQUITY_TIER_10B = 10_000_000_000 + +BUY_SIZING_RULE_LABEL = "총평가 1억↑ 현금 10% · 10억↑ 5% · 100억↑ 1%" + + +def buy_cash_pct(equity_krw: float) -> float | None: + """총평가금액에 따른 1회 매수 허용 현금 비율. + + Args: + equity_krw: 현재 총평가금액(원). + + Returns: + 허용 비율(0~1). 1억 미만이면 None(비율 상한 없음). + """ + if equity_krw >= EQUITY_TIER_10B: + return 0.01 + if equity_krw >= EQUITY_TIER_1B: + return 0.05 + if equity_krw >= EQUITY_TIER_100M: + return 0.10 + return None + + +def max_buy_from_cash(equity_krw: float, cash_krw: float) -> float: + """구간별 규칙을 반영한 1회 매수 최대 금액. + + Args: + equity_krw: 현재 총평가금액(원). + cash_krw: 보유 현금(원). + + Returns: + 매수에 사용 가능한 최대 원화. + """ + cash = max(float(cash_krw), 0.0) + pct = buy_cash_pct(equity_krw) + if pct is None: + return cash + return cash * pct + + +def buy_sizing_metadata() -> dict[str, Any]: + """시뮬 결과·차트에 포함할 매수 상한 메타.""" + return { + "buy_sizing_rule": BUY_SIZING_RULE_LABEL, + "buy_sizing_tiers": [ + {"min_equity_krw": EQUITY_TIER_100M, "max_cash_pct": 0.10}, + {"min_equity_krw": EQUITY_TIER_1B, "max_cash_pct": 0.05}, + {"min_equity_krw": EQUITY_TIER_10B, "max_cash_pct": 0.01}, + ], + } diff --git a/src/deepcoin/ground_truth/pnl.py b/src/deepcoin/ground_truth/pnl.py new file mode 100644 index 0000000..c753de6 --- /dev/null +++ b/src/deepcoin/ground_truth/pnl.py @@ -0,0 +1,400 @@ +"""Ground Truth 기준 초기 자본 누적 수익률 계산.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +from typing import Any + +from deepcoin.ground_truth.order_sizing import buy_sizing_metadata, max_buy_from_cash + + +@dataclass +class LegPnl: + """레그별 손익 (매수→매도 1쌍).""" + + leg_id: int + buy_datetime: str + sell_datetime: str + buy_price: float + sell_price: float + cash_before: float + cash_after: float + leg_return_pct: float + cumulative_return_pct: float + btc_qty: float + + +@dataclass +class SignalTrade: + """신호 1건 실행 기록.""" + + trade_id: int + side: str + signal_type: str + marker_id: int | None + datetime: str + price: float + cash_before: float + cash_after: float + coin_before: float + coin_after: float + order_krw: float + order_coin: float + fee_krw: float + cluster_size: int + skipped: bool + skip_reason: str | None = None + + +def simulate_gt_pnl( + legs: list[dict[str, Any]], + initial_cash_krw: float = 400_000.0, + fee_rate: float = 0.0005, + min_order_krw: float = 5_000.0, +) -> dict[str, Any]: + """GT 레그(1매수·1매도)를 순서대로 실행한 누적 수익률. + + - 초기 현금 전액 매수 → 전량 매도 반복 (복리) + - 1단계 Ground Truth 벤치마크용 + + Args: + legs: GT legs 리스트. + initial_cash_krw: 초기 원화. + fee_rate: 편도 수수료율. + min_order_krw: 최소 주문 금액. + + Returns: + 요약 + 레그별 손익 dict. + """ + cash = float(initial_cash_krw) + leg_pnls: list[LegPnl] = [] + skipped = 0 + + for leg in legs: + buy_price = float(leg["buy_price"]) + sell_price = float(leg["sell_price"]) + cash_before = cash + + if cash < min_order_krw: + skipped += 1 + continue + + buy_fee = cash * fee_rate + btc_bought = (cash - buy_fee) / buy_price + cash = 0.0 + + sell_gross = btc_bought * sell_price + sell_fee = sell_gross * fee_rate + cash = sell_gross - sell_fee + + leg_return = (cash - cash_before) / cash_before * 100.0 + cumulative = (cash - initial_cash_krw) / initial_cash_krw * 100.0 + + leg_pnls.append( + LegPnl( + leg_id=int(leg["leg_id"]), + buy_datetime=leg["buy_datetime"], + sell_datetime=leg["sell_datetime"], + buy_price=buy_price, + sell_price=sell_price, + cash_before=round(cash_before, 0), + cash_after=round(cash, 0), + leg_return_pct=round(leg_return, 2), + cumulative_return_pct=round(cumulative, 2), + btc_qty=round(btc_bought, 8), + ) + ) + + final_cash = cash + total_return_pct = (final_cash - initial_cash_krw) / initial_cash_krw * 100.0 + + period_from = leg_pnls[0].buy_datetime if leg_pnls else None + period_to = leg_pnls[-1].sell_datetime if leg_pnls else None + + return { + "initial_cash_krw": initial_cash_krw, + "final_cash_krw": round(final_cash, 0), + "total_pnl_krw": round(final_cash - initial_cash_krw, 0), + "total_return_pct": round(total_return_pct, 2), + "fee_rate": fee_rate, + "legs_traded": len(leg_pnls), + "legs_skipped": skipped, + "period_from": period_from, + "period_to": period_to, + "leg_pnls": [asdict(x) for x in leg_pnls], + } + + +def _parse_signal_dt(value: str) -> datetime: + """GT signal datetime 문자열을 파싱한다.""" + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + + +def _cluster_signals(signals: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]: + """연속 동일 side 신호를 클러스터로 묶는다.""" + ordered = sorted(signals, key=lambda s: (s["bar_index"], s.get("marker_id", 0))) + clusters: list[tuple[str, list[dict[str, Any]]]] = [] + current_side: str | None = None + current: list[dict[str, Any]] = [] + + for sig in ordered: + side = sig["side"] + if current_side is None: + current_side = side + current = [sig] + continue + if side == current_side: + current.append(sig) + continue + clusters.append((current_side, current)) + current_side = side + current = [sig] + + if current_side and current: + clusters.append((current_side, current)) + return clusters + + +def simulate_gt_signals_pnl( + signals: list[dict[str, Any]], + initial_cash_krw: float = 400_000.0, + fee_rate: float = 0.0005, + min_order_krw: float = 5_000.0, + sim_lookback_days: int = 365, + data_end: str | None = None, + last_mark_price: float | None = None, +) -> dict[str, Any]: + """GT 매수·매도 신호를 시간순 실행한 2단계 포트폴리오 시뮬레이션. + + - 시뮬 기간: data_end 기준 최근 sim_lookback_days + - 연속 매수: 가용 원화를 매수 신호 수로 균등 분할 + - 총평가 1억↑ 현금 10% · 10억↑ 5% · 100억↑ 1% 상한 + - 연속 매도: 보유 코인을 매도 신호 수로 균등 분할 (상한 없음) + - 원화 부족 시 매수 스킵, 코인 없으면 매도 스킵 + + Args: + signals: GT signals 리스트. + initial_cash_krw: 시뮬 시작 원화. + fee_rate: 편도 수수료율. + min_order_krw: 최소 주문 금액. + sim_lookback_days: 시뮬 기간(일). + data_end: 데이터 종료 시각 문자열. None이면 마지막 신호 시각. + last_mark_price: 미청산 코인 평가 가격. None이면 마지막 체결가. + + Returns: + 요약 + 체결/스킵 내역 dict. + """ + if not signals: + return _empty_signal_pnl(initial_cash_krw, fee_rate, sim_lookback_days) + + end_dt = _parse_signal_dt(data_end) if data_end else max( + _parse_signal_dt(s["datetime"]) for s in signals + ) + start_dt = end_dt - timedelta(days=sim_lookback_days) + start_str = start_dt.strftime("%Y-%m-%d %H:%M:%S") + + period_signals = [ + s for s in signals if _parse_signal_dt(s["datetime"]) >= start_dt + ] + if not period_signals: + return _empty_signal_pnl( + initial_cash_krw, + fee_rate, + sim_lookback_days, + period_from=start_str, + period_to=end_dt.strftime("%Y-%m-%d %H:%M:%S"), + ) + + cash = float(initial_cash_krw) + coin_qty = 0.0 + trades: list[SignalTrade] = [] + trade_id = 0 + buys_executed = 0 + sells_executed = 0 + buys_skipped = 0 + sells_skipped = 0 + + mark_price = float(last_mark_price or period_signals[-1]["price"]) + + for side, cluster in _cluster_signals(period_signals): + cluster_size = len(cluster) + if side == "buy": + budget = cash + per_buy = budget / cluster_size if cluster_size else 0.0 + for sig in cluster: + trade_id += 1 + price = float(sig["price"]) + cash_before = cash + coin_before = coin_qty + equity = cash + coin_qty * price + cash_cap = max_buy_from_cash(equity, cash) + order_krw = min(per_buy, cash, cash_cap) + + if order_krw < min_order_krw: + buys_skipped += 1 + trades.append( + SignalTrade( + trade_id=trade_id, + side="buy", + signal_type=str(sig.get("signal_type", "buy")), + marker_id=sig.get("marker_id"), + datetime=sig["datetime"], + price=price, + cash_before=round(cash_before, 0), + cash_after=round(cash, 0), + coin_before=round(coin_before, 8), + coin_after=round(coin_qty, 8), + order_krw=0.0, + order_coin=0.0, + fee_krw=0.0, + cluster_size=cluster_size, + skipped=True, + skip_reason="원화 부족", + ) + ) + continue + + fee = order_krw * fee_rate + bought = (order_krw - fee) / price + cash -= order_krw + coin_qty += bought + buys_executed += 1 + trades.append( + SignalTrade( + trade_id=trade_id, + side="buy", + signal_type=str(sig.get("signal_type", "buy")), + marker_id=sig.get("marker_id"), + datetime=sig["datetime"], + price=price, + cash_before=round(cash_before, 0), + cash_after=round(cash, 0), + coin_before=round(coin_before, 8), + coin_after=round(coin_qty, 8), + order_krw=round(order_krw, 0), + order_coin=round(bought, 8), + fee_krw=round(fee, 0), + cluster_size=cluster_size, + skipped=False, + ) + ) + else: + budget_coin = coin_qty + per_sell = budget_coin / cluster_size if cluster_size else 0.0 + for sig in cluster: + trade_id += 1 + price = float(sig["price"]) + cash_before = cash + coin_before = coin_qty + order_coin = min(per_sell, coin_qty) + order_krw = order_coin * price + + if order_coin <= 0 or order_krw < min_order_krw: + sells_skipped += 1 + trades.append( + SignalTrade( + trade_id=trade_id, + side="sell", + signal_type=str(sig.get("signal_type", "sell")), + marker_id=sig.get("marker_id"), + datetime=sig["datetime"], + price=price, + cash_before=round(cash_before, 0), + cash_after=round(cash, 0), + coin_before=round(coin_before, 8), + coin_after=round(coin_qty, 8), + order_krw=0.0, + order_coin=0.0, + fee_krw=0.0, + cluster_size=cluster_size, + skipped=True, + skip_reason="코인 부족", + ) + ) + continue + + gross = order_coin * price + fee = gross * fee_rate + cash += gross - fee + coin_qty -= order_coin + sells_executed += 1 + trades.append( + SignalTrade( + trade_id=trade_id, + side="sell", + signal_type=str(sig.get("signal_type", "sell")), + marker_id=sig.get("marker_id"), + datetime=sig["datetime"], + price=price, + cash_before=round(cash_before, 0), + cash_after=round(cash, 0), + coin_before=round(coin_before, 8), + coin_after=round(coin_qty, 8), + order_krw=round(gross, 0), + order_coin=round(order_coin, 8), + fee_krw=round(fee, 0), + cluster_size=cluster_size, + skipped=False, + ) + ) + + coin_value = coin_qty * mark_price + final_equity = cash + coin_value + total_pnl = final_equity - initial_cash_krw + total_return_pct = total_pnl / initial_cash_krw * 100.0 + + return { + "mode": "signal_split", + "initial_cash_krw": initial_cash_krw, + "final_cash_krw": round(cash, 0), + "final_coin_qty": round(coin_qty, 8), + "final_mark_price": round(mark_price, 2), + "final_coin_value_krw": round(coin_value, 0), + "final_equity_krw": round(final_equity, 0), + "total_pnl_krw": round(total_pnl, 0), + "total_return_pct": round(total_return_pct, 2), + "fee_rate": fee_rate, + **buy_sizing_metadata(), + "sim_lookback_days": sim_lookback_days, + "period_from": start_str, + "period_to": end_dt.strftime("%Y-%m-%d %H:%M:%S"), + "signals_in_period": len(period_signals), + "buys_executed": buys_executed, + "sells_executed": sells_executed, + "buys_skipped": buys_skipped, + "sells_skipped": sells_skipped, + "trades": [asdict(t) for t in trades], + } + + +def _empty_signal_pnl( + initial_cash_krw: float, + fee_rate: float, + sim_lookback_days: int, + period_from: str | None = None, + period_to: str | None = None, +) -> dict[str, Any]: + """신호가 없을 때의 빈 시뮬 결과.""" + return { + "mode": "signal_split", + "initial_cash_krw": initial_cash_krw, + "final_cash_krw": initial_cash_krw, + "final_coin_qty": 0.0, + "final_mark_price": 0.0, + "final_coin_value_krw": 0.0, + "final_equity_krw": initial_cash_krw, + "total_pnl_krw": 0.0, + "total_return_pct": 0.0, + "fee_rate": fee_rate, + **buy_sizing_metadata(), + "sim_lookback_days": sim_lookback_days, + "period_from": period_from, + "period_to": period_to, + "signals_in_period": 0, + "buys_executed": 0, + "sells_executed": 0, + "buys_skipped": 0, + "sells_skipped": 0, + "trades": [], + } diff --git a/src/deepcoin/ground_truth/pullback.py b/src/deepcoin/ground_truth/pullback.py new file mode 100644 index 0000000..e85a5c6 --- /dev/null +++ b/src/deepcoin/ground_truth/pullback.py @@ -0,0 +1,118 @@ +"""상승 추세 중 눌림목 매수 타점 (Ground Truth 보조).""" + +from __future__ import annotations + +import pandas as pd + +from typing import Protocol + +from deepcoin.ground_truth.zigzag import Pivot + + +class _LegLike(Protocol): + """레그 최소 인터페이스.""" + + buy_bar_index: int + sell_bar_index: int + + +def find_pullback_buy_pivots( + df: pd.DataFrame, + legs: list[_LegLike], + min_pullback_pct: float = 1.5, + local_order: int = 10, + swing_near_bars: int = 120, + leg_end_margin_bars: int = 100, +) -> list[Pivot]: + """각 스윙 레그 구간에서 돌파 직전 최적 눌림목 1개를 찾는다 (1단계 GT). + + 빨간 원 유형(9/27, 7/8 등) — 스윙 매수 후·매도 전 구간의 + 가장 깊은 되돌림 저점을 레그당 1개만 선택한다. + + Args: + df: OHLCV DataFrame. + legs: ZigZag 스윙 레그. + min_pullback_pct: 직전 고점 대비 최소 되돌림(%). + local_order: 국소 저점 판별 반경(봉). + swing_near_bars: 스윙 매수 직후 구간 제외(봉). + leg_end_margin_bars: 매도 직전 구간 제외(봉). + + Returns: + 시간순 Pivot(low) 리스트. + """ + if not legs or len(df) < local_order * 2 + 10: + return [] + + highs = df["high"].astype(float).values + lows = df["low"].astype(float).values + pullbacks: list[Pivot] = [] + + for leg in legs: + full_start = leg.buy_bar_index + swing_near_bars + full_end = leg.sell_bar_index - leg_end_margin_bars + if full_end <= full_start + local_order * 2: + continue + + leg_len = leg.sell_bar_index - leg.buy_bar_index + pre_breakout_start = leg.sell_bar_index - int(leg_len * 0.35) + start = max(full_start, pre_breakout_start) + end = full_end + + best_idx = _best_pullback_in_range( + df, lows, highs, leg.buy_bar_index, start, end, local_order, min_pullback_pct + ) + if best_idx is None: + best_idx = _best_pullback_in_range( + df, lows, highs, leg.buy_bar_index, full_start, full_end, local_order, min_pullback_pct + ) + if best_idx is None: + continue + + pullbacks.append( + Pivot( + bar_index=best_idx, + side="low", + price=float(lows[best_idx]), + datetime=pd.Timestamp(df.iloc[best_idx]["datetime"]), + ) + ) + + return pullbacks + + +def _best_pullback_in_range( + df: pd.DataFrame, + lows, + highs, + buy_bar_index: int, + start: int, + end: int, + local_order: int, + min_pullback_pct: float, +) -> int | None: + """구간 내 최대 되돌림 국소 저점 bar_index를 반환한다.""" + if end <= start + local_order * 2: + return None + + best_idx: int | None = None + best_price = float("inf") + + for i in range(max(start, local_order), min(end, len(df) - local_order)): + low_val = float(lows[i]) + window = lows[i - local_order : i + local_order + 1] + if low_val > float(window.min()): + continue + + region_high = float(highs[buy_bar_index : i + 1].max()) + if region_high <= 0: + continue + + pullback_pct = (region_high - low_val) / region_high * 100.0 + if pullback_pct < min_pullback_pct: + continue + + if low_val < best_price: + best_price = low_val + best_idx = i + + return best_idx diff --git a/src/deepcoin/ground_truth/swing_signals.py b/src/deepcoin/ground_truth/swing_signals.py new file mode 100644 index 0000000..ce09a94 --- /dev/null +++ b/src/deepcoin/ground_truth/swing_signals.py @@ -0,0 +1,173 @@ +"""독립 매수·매도 스윙 타점 탐지 (레그 1:1 강제 없음).""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from deepcoin.ground_truth.zigzag import Pivot, find_zigzag_pivots + + +@dataclass(frozen=True) +class SwingSignal: + """매수 또는 매도 타점.""" + + signal_id: int + side: str # buy | sell + bar_index: int + price: float + datetime: pd.Timestamp + source: str # minor_zigzag | local_extrema + swing_pct: float + + +def find_swing_signals( + df: pd.DataFrame, + minor_reversal_pct: float = 2.5, + local_order: int = 20, + min_swing_pct: float = 2.0, + min_bars_between: int = 30, +) -> tuple[list[SwingSignal], list[SwingSignal]]: + """적절한 매수·매도 타점을 독립적으로 찾는다. + + - 소형 ZigZag(되돌림 2.5% 등): 눌림목·반등 고점 + - 국소 극값: 횡보 후 돌파 전 저점(빨간 원 구간 유형) 보완 + - 매수·매도를 1:1 레그로 묶지 않음 + + Args: + df: OHLCV DataFrame. + minor_reversal_pct: 소형 ZigZag 되돌림 %. + local_order: 국소 극값 탐지 반경(봉). + min_swing_pct: 국소 극값 인정 최소 변동 %. + min_bars_between: 동일 방향 신호 최소 간격(봉). + + Returns: + (매수 신호 리스트, 매도 신호 리스트) — 각각 시간순. + """ + minor_pivots = find_zigzag_pivots(df, reversal_pct=minor_reversal_pct) + local_lows = _find_local_lows(df, order=local_order, min_swing_pct=min_swing_pct) + local_highs = _find_local_highs(df, order=local_order, min_swing_pct=min_swing_pct) + + buy_candidates = [p for p in minor_pivots if p.side == "low"] + sell_candidates = [p for p in minor_pivots if p.side == "high"] + + buy_candidates.extend(local_lows) + sell_candidates.extend(local_highs) + + buys = _to_signals(_dedupe_pivots(buy_candidates, min_bars_between, prefer="low"), "buy") + sells = _to_signals(_dedupe_pivots(sell_candidates, min_bars_between, prefer="high"), "sell") + + return buys, sells + + +def _find_local_lows( + df: pd.DataFrame, + order: int, + min_swing_pct: float, +) -> list[Pivot]: + """최근 고점 대비 되돌림 후 형성된 국소 저점.""" + pivots: list[Pivot] = [] + lookback = order * 4 + + for i in range(order, len(df) - order): + low_val = float(df.iloc[i]["low"]) + window = df["low"].iloc[i - order : i + order + 1] + if low_val > window.min(): + continue + + start = max(0, i - lookback) + recent_high = float(df["high"].iloc[start : i + 1].max()) + if recent_high <= 0: + continue + drop_pct = (recent_high - low_val) / recent_high * 100.0 + if drop_pct < min_swing_pct: + continue + + pivots.append( + Pivot( + bar_index=i, + side="low", + price=low_val, + datetime=pd.Timestamp(df.iloc[i]["datetime"]), + ) + ) + return pivots + + +def _find_local_highs( + df: pd.DataFrame, + order: int, + min_swing_pct: float, +) -> list[Pivot]: + """최근 저점 대비 반등 후 형성된 국소 고점.""" + pivots: list[Pivot] = [] + lookback = order * 4 + + for i in range(order, len(df) - order): + high_val = float(df.iloc[i]["high"]) + window = df["high"].iloc[i - order : i + order + 1] + if high_val < window.max(): + continue + + start = max(0, i - lookback) + recent_low = float(df["low"].iloc[start : i + 1].min()) + if recent_low <= 0: + continue + rise_pct = (high_val - recent_low) / recent_low * 100.0 + if rise_pct < min_swing_pct: + continue + + pivots.append( + Pivot( + bar_index=i, + side="high", + price=high_val, + datetime=pd.Timestamp(df.iloc[i]["datetime"]), + ) + ) + return pivots + + +def _dedupe_pivots( + pivots: list[Pivot], + min_bars: int, + prefer: str, +) -> list[Pivot]: + """가까운 동일 방향 피벗을 병합한다.""" + if not pivots: + return [] + + sorted_pivots = sorted(pivots, key=lambda p: p.bar_index) + merged: list[Pivot] = [sorted_pivots[0]] + + for pivot in sorted_pivots[1:]: + last = merged[-1] + if pivot.bar_index - last.bar_index < min_bars: + if prefer == "low" and pivot.price < last.price: + merged[-1] = pivot + elif prefer == "high" and pivot.price > last.price: + merged[-1] = pivot + else: + merged.append(pivot) + + return merged + + +def _to_signals(pivots: list[Pivot], side: str) -> list[SwingSignal]: + """Pivot 리스트를 SwingSignal로 변환한다.""" + source = "local_extrema" + signals: list[SwingSignal] = [] + for idx, pivot in enumerate(pivots, start=1): + signals.append( + SwingSignal( + signal_id=idx, + side=side, + bar_index=pivot.bar_index, + price=round(pivot.price, 2), + datetime=pivot.datetime, + source=source, + swing_pct=0.0, + ) + ) + return signals diff --git a/src/deepcoin/ground_truth/zigzag.py b/src/deepcoin/ground_truth/zigzag.py new file mode 100644 index 0000000..8370dbb --- /dev/null +++ b/src/deepcoin/ground_truth/zigzag.py @@ -0,0 +1,120 @@ +"""ZigZag 스윙 피벗 탐지 (Ground Truth용, 미래 데이터 허용).""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + + +@dataclass(frozen=True) +class Pivot: + """스윙 고점/저점.""" + + bar_index: int + side: str # "low" | "high" + price: float + datetime: pd.Timestamp + + +def find_zigzag_pivots( + df: pd.DataFrame, + reversal_pct: float = 5.0, +) -> list[Pivot]: + """ZigZag 알고리즘으로 교대 스윙 피벗을 찾는다. + + 가격이 직전 극값 대비 `reversal_pct`% 이상 되돌림되면 피벗을 확정한다. + Ground Truth 라벨링용이므로 전체 시계열(미래 포함)을 사용한다. + + Args: + df: datetime, high, low, close 컬럼 DataFrame. + reversal_pct: 피벗 확정 최소 되돌림 비율(%). + + Returns: + 시간순 피벗 리스트 (low/high 교대). + """ + if len(df) < 3: + return [] + + threshold = reversal_pct / 100.0 + pivots: list[Pivot] = [] + + direction: str | None = None + extreme_idx = 0 + extreme_price = float(df.iloc[0]["close"]) + anchor_price = extreme_price + + for i in range(1, len(df)): + high = float(df.iloc[i]["high"]) + low = float(df.iloc[i]["low"]) + + if direction is None: + if high >= anchor_price * (1 + threshold): + direction = "up" + extreme_idx = i + extreme_price = high + elif low <= anchor_price * (1 - threshold): + direction = "down" + extreme_idx = i + extreme_price = low + continue + + if direction == "up": + if high >= extreme_price: + extreme_price = high + extreme_idx = i + if low <= extreme_price * (1 - threshold): + pivots.append(_make_pivot(df, extreme_idx, "high", extreme_price)) + direction = "down" + extreme_idx = i + extreme_price = low + anchor_price = extreme_price + else: + if low <= extreme_price: + extreme_price = low + extreme_idx = i + if high >= extreme_price * (1 + threshold): + pivots.append(_make_pivot(df, extreme_idx, "low", extreme_price)) + direction = "up" + extreme_idx = i + extreme_price = high + anchor_price = extreme_price + + # 마지막 미확정 극값도 피벗으로 추가 (방향에 따라) + if direction == "up": + pivots.append(_make_pivot(df, extreme_idx, "high", extreme_price)) + elif direction == "down": + pivots.append(_make_pivot(df, extreme_idx, "low", extreme_price)) + + return _normalize_alternating(pivots) + + +def _make_pivot(df: pd.DataFrame, idx: int, side: str, price: float) -> Pivot: + """Pivot 객체를 생성한다.""" + return Pivot( + bar_index=idx, + side=side, + price=price, + datetime=pd.Timestamp(df.iloc[idx]["datetime"]), + ) + + +def _normalize_alternating(pivots: list[Pivot]) -> list[Pivot]: + """인접 동일 side 피벗을 제거하고 low/high 교대를 맞춘다.""" + if not pivots: + return [] + + cleaned: list[Pivot] = [pivots[0]] + for pivot in pivots[1:]: + last = cleaned[-1] + if pivot.side == last.side: + # 같은 방향이면 더 극단적인 가격으로 갱신 + if pivot.side == "high" and pivot.price >= last.price: + cleaned[-1] = pivot + elif pivot.side == "low" and pivot.price <= last.price: + cleaned[-1] = pivot + else: + cleaned.append(pivot) + + # 첫 피벗이 high면 앞 low가 누락된 경우 — GT에서는 허용 + return cleaned diff --git a/src/deepcoin/mtf/__init__.py b/src/deepcoin/mtf/__init__.py new file mode 100644 index 0000000..a02d2fa --- /dev/null +++ b/src/deepcoin/mtf/__init__.py @@ -0,0 +1,23 @@ +"""멀티 타임프레임(MTF) 인과 피처 추출.""" + +from deepcoin.mtf.alignment import as_of_from_signal_bar, last_complete_bar_index +from deepcoin.mtf.extractor import MtfFeatureExtractor, MtfSnapshot +from deepcoin.mtf.filter import MtfSignalFilter, score_mtf_rules +from deepcoin.mtf.rules import MtfRule, MtfRuleSet, derive_rules_from_report, load_mtf_rules, save_mtf_rules +from deepcoin.mtf.store import MTF_INTERVALS, MultiTimeframeStore + +__all__ = [ + "MTF_INTERVALS", + "MultiTimeframeStore", + "MtfFeatureExtractor", + "MtfSnapshot", + "MtfSignalFilter", + "MtfRule", + "MtfRuleSet", + "derive_rules_from_report", + "load_mtf_rules", + "save_mtf_rules", + "score_mtf_rules", + "as_of_from_signal_bar", + "last_complete_bar_index", +] diff --git a/src/deepcoin/mtf/alignment.py b/src/deepcoin/mtf/alignment.py new file mode 100644 index 0000000..1c6b154 --- /dev/null +++ b/src/deepcoin/mtf/alignment.py @@ -0,0 +1,94 @@ +"""MTF 봉 정렬 — 미완성 봉 제외, look-ahead 방지.""" + +from __future__ import annotations + +import pandas as pd + + +def bar_close_datetime(open_dt: pd.Timestamp, interval_min: int) -> pd.Timestamp: + """봉 시가 시각 기준 종가(마감) 시각을 반환한다. + + Args: + open_dt: 봉 시가(오픈) 시각. + interval_min: 분 단위 인터벌 코드. + + Returns: + 봉이 완전히 마감된 시각. + """ + return open_dt + pd.Timedelta(minutes=interval_min) + + +def as_of_from_signal_bar( + signal_open: pd.Timestamp, + base_interval_min: int = 3, +) -> pd.Timestamp: + """3분봉 신호 봉 종가 시점(판단 시각)을 반환한다. + + Args: + signal_open: 신호가 찍힌 기준 봉(3분)의 시가 시각. + base_interval_min: 기준(체결) 인터벌 분. + + Returns: + 해당 봉이 마감된 시각 = signal_open + base_interval_min. + """ + return bar_close_datetime(signal_open, base_interval_min) + + +def is_bar_complete( + open_dt: pd.Timestamp, + interval_min: int, + as_of: pd.Timestamp, +) -> bool: + """as_of 시점에 해당 봉이 완전히 마감되었는지 판별한다.""" + return bar_close_datetime(open_dt, interval_min) <= as_of + + +def last_complete_bar_index( + datetimes: pd.Series, + interval_min: int, + as_of: pd.Timestamp, +) -> int | None: + """as_of 이전(포함)에 마감된 마지막 봉 인덱스를 반환한다. + + Args: + datetimes: 봉 시가 시각 시리즈 (오름차순). + interval_min: 분 단위 인터벌. + as_of: 판단 시각 — 이 시각까지 마감된 봉만 사용. + + Returns: + 마지막 완성 봉 인덱스. 없으면 None. + """ + if datetimes.empty: + return None + + close_times = datetimes + pd.to_timedelta(interval_min, unit="m") + valid = close_times <= as_of + if not valid.any(): + return None + return int(valid.to_numpy().nonzero()[0][-1]) + + +def resolve_bar_index( + datetimes: pd.Series, + interval_min: int, + base_interval_min: int, + signal_open: pd.Timestamp, + as_of: pd.Timestamp, +) -> int | None: + """기준 TF는 신호 봉, 상위 TF는 마감 완료 봉 인덱스를 반환한다. + + Args: + datetimes: 해당 TF 봉 시가 시각. + interval_min: 조회 TF 분 코드. + base_interval_min: 체결 기준 TF (3분). + signal_open: GT/신호 3분봉 시가 시각. + as_of: 판단 시각(3분봉 종가). + + Returns: + 피처 추출에 사용할 bar index. + """ + if interval_min == base_interval_min: + matches = datetimes[datetimes == signal_open] + if not matches.empty: + return int(matches.index[0]) + return last_complete_bar_index(datetimes, interval_min, as_of) diff --git a/src/deepcoin/mtf/extractor.py b/src/deepcoin/mtf/extractor.py new file mode 100644 index 0000000..56891dc --- /dev/null +++ b/src/deepcoin/mtf/extractor.py @@ -0,0 +1,114 @@ +"""시점별 멀티 TF 피처 스냅샷 추출.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pandas as pd + +from deepcoin.mtf.alignment import as_of_from_signal_bar, resolve_bar_index +from deepcoin.mtf.features import snapshot_at_index +from deepcoin.mtf.store import MTF_INTERVALS, MultiTimeframeStore + + +@dataclass +class MtfSnapshot: + """단일 판단 시점의 멀티 TF 피처 묶음.""" + + signal_datetime: str + as_of: str + base_interval_min: int + timeframes: dict[str, dict[str, Any]] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """JSON 직렬화용 dict.""" + return { + "signal_datetime": self.signal_datetime, + "as_of": self.as_of, + "base_interval_min": self.base_interval_min, + "timeframes": self.timeframes, + } + + +class MtfFeatureExtractor: + """GT/신호 시각에서 인과 MTF 피처 스냅샷을 추출한다.""" + + def __init__( + self, + store: MultiTimeframeStore, + base_interval_min: int = 3, + intervals: tuple[int, ...] | None = None, + ) -> None: + """Extractor를 생성한다. + + Args: + store: 로드된 MultiTimeframeStore. + base_interval_min: 체결·신호 기준 TF (3분). + intervals: 추출 TF 목록. None이면 store.intervals. + """ + self.store = store + self.base_interval_min = base_interval_min + self.intervals = intervals or store.intervals + store.load() + + def extract_at(self, signal_datetime: str | pd.Timestamp) -> MtfSnapshot | None: + """신호 시각(3분봉 시가)에서 MTF 스냅샷을 추출한다. + + 미래 데이터·미완성 상위 TF 봉은 사용하지 않는다. + + Args: + signal_datetime: GT signals.datetime (3분봉 시가). + + Returns: + MtfSnapshot. 기준 3분봉이 없으면 None. + """ + signal_open = pd.Timestamp(signal_datetime) + as_of = as_of_from_signal_bar(signal_open, self.base_interval_min) + signal_str = signal_open.strftime("%Y-%m-%d %H:%M:%S") + as_of_str = as_of.strftime("%Y-%m-%d %H:%M:%S") + + base_df = self.store.get_raw(self.base_interval_min) + if signal_open not in base_df["datetime"].values: + return None + + timeframes: dict[str, dict[str, Any]] = {} + + for interval_min in self.intervals: + label = self.store.interval_label(interval_min) + feat_df = self.store.get_features(interval_min) + bar_idx = resolve_bar_index( + feat_df["datetime"], + interval_min, + self.base_interval_min, + signal_open, + as_of, + ) + if bar_idx is None: + timeframes[label] = {"interval_min": interval_min, "available": False} + continue + + snap = snapshot_at_index(feat_df, bar_idx) + snap["interval_min"] = interval_min + snap["interval_label"] = label + snap["available"] = True + timeframes[label] = snap + + return MtfSnapshot( + signal_datetime=signal_str, + as_of=as_of_str, + base_interval_min=self.base_interval_min, + timeframes=timeframes, + ) + + def extract_many( + self, + datetimes: list[str], + ) -> list[MtfSnapshot]: + """여러 시각의 스냅샷을 순서대로 추출한다.""" + results: list[MtfSnapshot] = [] + for dt in datetimes: + snap = self.extract_at(dt) + if snap is not None: + results.append(snap) + return results diff --git a/src/deepcoin/mtf/features.py b/src/deepcoin/mtf/features.py new file mode 100644 index 0000000..1fc3cd7 --- /dev/null +++ b/src/deepcoin/mtf/features.py @@ -0,0 +1,184 @@ +"""TF별 기술적 피처 계산 (인과, 봉 인덱스 기준).""" + +from __future__ import annotations + +from typing import Any + +import pandas as pd + +from deepcoin.techniques.indicators import atr, bollinger_bands, ema, macd, rsi + +FEATURE_NAMES: tuple[str, ...] = ( + "close", + "ema60", + "close_vs_ema60_pct", + "ema60_slope_5_pct", + "rsi14", + "macd_hist", + "bb_position", + "atr_pct", + "zigzag_direction", + "zigzag_leg_pct", + "trend_bias", +) + + +def compute_feature_frame( + df: pd.DataFrame, + reversal_pct: float = 5.0, +) -> pd.DataFrame: + """OHLCV에 MTF 분석용 피처 컬럼을 추가한다. + + Args: + df: datetime, open, high, low, close, volume. + reversal_pct: 인과 ZigZag 되돌림 %. + + Returns: + 피처 컬럼이 추가된 DataFrame (원본 컬럼 유지). + """ + out = df.copy() + close = out["close"].astype(float) + high = out["high"].astype(float) + low = out["low"].astype(float) + + ema60 = ema(close, 60) + out["ema60"] = ema60 + out["close_vs_ema60_pct"] = (close - ema60) / ema60.replace(0, pd.NA) * 100.0 + ema_shift = ema60.shift(5) + out["ema60_slope_5_pct"] = (ema60 - ema_shift) / ema_shift.replace(0, pd.NA) * 100.0 + + out["rsi14"] = rsi(close, 14) + _, _, macd_hist = macd(close) + out["macd_hist"] = macd_hist + + bb_mid, bb_upper, bb_lower = bollinger_bands(close, 20, 2.0) + band_width = (bb_upper - bb_lower).replace(0, pd.NA) + out["bb_position"] = (close - bb_lower) / band_width + + atr14 = atr(high, low, close, 14) + out["atr_pct"] = atr14 / close.replace(0, pd.NA) * 100.0 + + zz_dir, zz_leg = _compute_zigzag_series(out, reversal_pct) + out["zigzag_direction"] = zz_dir + out["zigzag_leg_pct"] = zz_leg + + out["trend_bias"] = pd.Series( + ["bullish" if c > e else "bearish" for c, e in zip(close, ema60, strict=False)], + index=out.index, + dtype="object", + ) + out.loc[ema60.isna(), "trend_bias"] = pd.NA + + return out + + +def _compute_zigzag_series( + df: pd.DataFrame, + reversal_pct: float, +) -> tuple[pd.Series, pd.Series]: + """각 봉 시점의 인과 ZigZag 방향·leg %를 계산한다. + + Args: + df: OHLCV DataFrame. + reversal_pct: 피벗 확정 되돌림 %. + + Returns: + (direction_series, leg_pct_series). direction은 up/down/none. + """ + n = len(df) + directions = ["none"] * n + leg_pcts: list[float | None] = [None] * n + + if n < 2: + return pd.Series(directions, index=df.index), pd.Series(leg_pcts, index=df.index) + + threshold = reversal_pct / 100.0 + direction: str | None = None + extreme_idx = 0 + extreme_price = float(df.iloc[0]["close"]) + pivot_price = extreme_price + + for i in range(n): + high = float(df.iloc[i]["high"]) + low = float(df.iloc[i]["low"]) + + if direction is None: + if high >= extreme_price * (1 + threshold): + direction = "up" + extreme_idx = i + extreme_price = high + pivot_price = extreme_price + elif low <= extreme_price * (1 - threshold): + direction = "down" + extreme_idx = i + extreme_price = low + pivot_price = extreme_price + elif direction == "up": + if high >= extreme_price: + extreme_price = high + extreme_idx = i + if low <= extreme_price * (1 - threshold): + pivot_price = extreme_price + direction = "down" + extreme_idx = i + extreme_price = low + else: + if low <= extreme_price: + extreme_price = low + extreme_idx = i + if high >= extreme_price * (1 + threshold): + pivot_price = extreme_price + direction = "up" + extreme_idx = i + extreme_price = high + + directions[i] = direction or "none" + ref = pivot_price if pivot_price else float(df.iloc[i]["close"]) + price = float(df.iloc[i]["close"]) + if ref: + leg_pcts[i] = round((price - ref) / ref * 100.0, 4) + + return pd.Series(directions, index=df.index), pd.Series(leg_pcts, index=df.index) + + +def snapshot_at_index(feat_df: pd.DataFrame, bar_index: int) -> dict[str, Any]: + """단일 TF·단일 bar_index의 피처 dict를 반환한다. + + Args: + feat_df: compute_feature_frame 출력. + bar_index: 행 인덱스 (0-based). + + Returns: + FEATURE_NAMES 키를 포함한 피처 dict. bar 메타 포함. + """ + if bar_index < 0 or bar_index >= len(feat_df): + return {} + + row = feat_df.iloc[bar_index] + dt = pd.Timestamp(row["datetime"]).strftime("%Y-%m-%d %H:%M:%S") + + def _num(key: str) -> float | None: + val = row.get(key) + if val is None or pd.isna(val): + return None + return round(float(val), 4) + + return { + "bar_index": bar_index, + "bar_datetime": dt, + "close": _num("close"), + "ema60": _num("ema60"), + "close_vs_ema60_pct": _num("close_vs_ema60_pct"), + "ema60_slope_5_pct": _num("ema60_slope_5_pct"), + "rsi14": _num("rsi14"), + "macd_hist": _num("macd_hist"), + "bb_position": _num("bb_position"), + "atr_pct": _num("atr_pct"), + "zigzag_direction": str(row.get("zigzag_direction", "none")), + "zigzag_leg_pct": _num("zigzag_leg_pct"), + "trend_bias": ( + None + if pd.isna(row.get("trend_bias")) + else str(row.get("trend_bias")) + ), + } diff --git a/src/deepcoin/mtf/filter.py b/src/deepcoin/mtf/filter.py new file mode 100644 index 0000000..f5af642 --- /dev/null +++ b/src/deepcoin/mtf/filter.py @@ -0,0 +1,161 @@ +"""MTF 규칙 기반 composite 신호 필터.""" + +from __future__ import annotations + +from typing import Any + +from deepcoin.mtf.trend_gate import HtfTrendGate +from deepcoin.mtf.extractor import MtfFeatureExtractor, MtfSnapshot +from deepcoin.mtf.rules import MtfRule, MtfRuleSet + + +def evaluate_rule(rule: MtfRule, snapshot: MtfSnapshot) -> bool | None: + """단일 규칙을 스냅샷에 적용한다. + + Args: + rule: MTF 규칙. + snapshot: 시점 MTF 스냅샷. + + Returns: + True/False 또는 피처 없음 시 None. + """ + tf = snapshot.timeframes.get(rule.timeframe_label, {}) + if not tf.get("available"): + return None + + value = tf.get(rule.feature) + if value is None: + return None + + numeric = float(value) + if rule.operator == "<=": + return numeric <= rule.threshold + return numeric >= rule.threshold + + +def score_mtf_rules( + signal_type: str, + snapshot: MtfSnapshot, + rule_set: MtfRuleSet, +) -> dict[str, Any]: + """신호 유형 규칙 충족 점수를 계산한다. + + Returns: + passed, total_evaluated, total_rules, details 키를 가진 dict. + """ + rules = rule_set.rules_for(signal_type) + if not rules: + return { + "passed": True, + "total_evaluated": 0, + "total_rules": 0, + "required_pass": rule_set.min_rules_pass, + "details": [], + "reason": "no_rules_for_type", + } + + details: list[dict[str, Any]] = [] + passed = 0 + evaluated = 0 + + for rule in rules: + result = evaluate_rule(rule, snapshot) + detail = { + "timeframe": rule.timeframe_label, + "feature": rule.feature, + "operator": rule.operator, + "threshold": rule.threshold, + "result": result, + } + details.append(detail) + if result is None: + continue + evaluated += 1 + if result: + passed += 1 + + required = min(rule_set.min_rules_pass, len(rules)) + if evaluated == 0: + ok = True + reason = "no_features_available" + elif evaluated < required: + ok = passed == evaluated + reason = "partial_eval_all_pass" + else: + ok = passed >= required + reason = "score_ok" if ok else "score_below_min" + + return { + "passed": ok, + "passed_count": passed, + "total_evaluated": evaluated, + "total_rules": len(rules), + "required_pass": required, + "details": details, + "reason": reason, + } + + +class MtfSignalFilter: + """composite_v3 신호에 MTF 규칙 필터를 적용한다.""" + + def __init__( + self, + extractor: MtfFeatureExtractor, + rule_set: MtfRuleSet, + trend_gate: HtfTrendGate | None = None, + ) -> None: + """Filter 생성.""" + self.extractor = extractor + self.rule_set = rule_set + self.trend_gate = trend_gate + self._cache: dict[str, MtfSnapshot | None] = {} + + def _snapshot(self, signal_datetime: str) -> MtfSnapshot | None: + """스냅샷 캐시 조회.""" + if signal_datetime not in self._cache: + self._cache[signal_datetime] = self.extractor.extract_at(signal_datetime) + return self._cache[signal_datetime] + + def passes(self, signal: dict[str, Any]) -> tuple[bool, dict[str, Any]]: + """단일 GT 스키마 신호가 MTF 규칙을 통과하는지 판별.""" + signal_type = str(signal.get("signal_type", "")) + snap = self._snapshot(str(signal["datetime"])) + if snap is None: + return False, {"reason": "snapshot_missing", "passed": False} + + if self.trend_gate and self.trend_gate.enabled: + snap_dict = snap.to_dict() + side = str(signal.get("side", "")) + if side == "buy": + ok_gate, gate_reason = self.trend_gate.allows_buy(snap_dict) + else: + ok_gate, gate_reason = self.trend_gate.allows_sell(snap_dict) + if not ok_gate: + return False, {"reason": "htf_gate_blocked", "gate": gate_reason, "passed": False} + + score = score_mtf_rules(signal_type, snap, self.rule_set) + return bool(score["passed"]), score + + def filter_signals( + self, + signals: list[dict[str, Any]], + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """신호를 kept / rejected 로 분리한다.""" + kept: list[dict[str, Any]] = [] + rejected: list[dict[str, Any]] = [] + + for sig in sorted(signals, key=lambda s: s["bar_index"]): + ok, meta = self.passes(sig) + sig_copy = dict(sig) + sig_copy["mtf_filter"] = meta + if ok: + kept.append(sig_copy) + else: + rejected.append(sig_copy) + + for idx, sig in enumerate(kept, start=1): + sig["marker_id"] = idx + sig["leg_id"] = idx + + return kept, rejected diff --git a/src/deepcoin/mtf/precompute.py b/src/deepcoin/mtf/precompute.py new file mode 100644 index 0000000..843483b --- /dev/null +++ b/src/deepcoin/mtf/precompute.py @@ -0,0 +1,106 @@ +"""3분봉 타임라인 ↔ 각 TF bar index 사전 정렬 (벡터화).""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from deepcoin.mtf.alignment import as_of_from_signal_bar +from deepcoin.mtf.features import snapshot_at_index +from deepcoin.mtf.store import MultiTimeframeStore + + +def _vectorized_tf_indices( + base_opens: pd.Series, + tf_opens: pd.Series, + base_interval_min: int, + tf_interval_min: int, +) -> np.ndarray: + """3m 각 봉에 대한 TF bar index 배열을 벡터화 계산한다.""" + n = len(base_opens) + result = np.full(n, -1, dtype=np.int64) + + if tf_opens.empty: + return result + + as_ofs = base_opens + pd.to_timedelta(base_interval_min, unit="m") + tf_closes = tf_opens + pd.to_timedelta(tf_interval_min, unit="m") + close_vals = tf_closes.to_numpy(dtype="datetime64[ns]") + as_of_vals = as_ofs.to_numpy(dtype="datetime64[ns]") + open_vals = tf_opens.to_numpy(dtype="datetime64[ns]") + base_open_vals = base_opens.to_numpy(dtype="datetime64[ns]") + + if tf_interval_min == base_interval_min: + pos = np.searchsorted(open_vals, base_open_vals) + valid = (pos < len(open_vals)) & (open_vals[pos] == base_open_vals) + result[valid] = pos[valid] + return result + + pos = np.searchsorted(close_vals, as_of_vals, side="right") - 1 + valid = pos >= 0 + result[valid] = pos[valid] + return result + + +def build_3m_to_tf_bar_indices( + base_df: pd.DataFrame, + store: MultiTimeframeStore, + base_interval_min: int = 3, +) -> dict[int, np.ndarray]: + """각 3m bar index에 대응하는 TF별 feature bar index를 계산한다. + + Args: + base_df: 3m OHLCV (store.base_df와 동일 타임라인). + store: 로드된 MTF store. + base_interval_min: 기준 TF 분. + + Returns: + interval_min → int64 ndarray (-1 = 미사용). + """ + store.load() + base_opens = base_df["datetime"] + mapping: dict[int, np.ndarray] = {} + + for interval_min in store.intervals: + feat_df = store.get_features(interval_min) + mapping[interval_min] = _vectorized_tf_indices( + base_opens, + feat_df["datetime"], + base_interval_min, + interval_min, + ) + + return mapping + + +def snapshot_at_3m_bar( + bar_index: int, + store: MultiTimeframeStore, + tf_bar_indices: dict[int, np.ndarray], + base_interval_min: int = 3, +) -> dict: + """사전 계산된 인덱스로 MTF 스냅샷 dict를 빠르게 조립한다.""" + base_df = store.base_df + signal_open = pd.Timestamp(base_df.iloc[bar_index]["datetime"]) + as_of = as_of_from_signal_bar(signal_open, base_interval_min) + + timeframes: dict[str, dict] = {} + for interval_min in store.intervals: + label = store.interval_label(interval_min) + tf_idx = int(tf_bar_indices[interval_min][bar_index]) + if tf_idx < 0: + timeframes[label] = {"interval_min": interval_min, "available": False} + continue + feat_df = store.get_features(interval_min) + snap = snapshot_at_index(feat_df, tf_idx) + snap["interval_min"] = interval_min + snap["interval_label"] = label + snap["available"] = True + timeframes[label] = snap + + return { + "signal_datetime": signal_open.strftime("%Y-%m-%d %H:%M:%S"), + "as_of": as_of.strftime("%Y-%m-%d %H:%M:%S"), + "base_interval_min": base_interval_min, + "timeframes": timeframes, + } diff --git a/src/deepcoin/mtf/rules.py b/src/deepcoin/mtf/rules.py new file mode 100644 index 0000000..be45326 --- /dev/null +++ b/src/deepcoin/mtf/rules.py @@ -0,0 +1,255 @@ +"""MTF 상관 리포트 기반 규칙 정의·도출.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from deepcoin.evaluation.gt_align import GT_SIGNAL_TYPES + +Operator = Literal["<=", ">="] + +# BTC 가격 스케일에 민감한 지표는 자동 규칙에서 제외 +_EXCLUDED_AUTO_FEATURES: frozenset[str] = frozenset({"macd_hist", "zigzag_leg_pct", "close"}) + +# 자동 규칙에 사용할 안정 피처 +_PREFERRED_FEATURES: tuple[str, ...] = ( + "close_vs_ema60_pct", + "ema60_slope_5_pct", + "rsi14", + "bb_position", + "atr_pct", +) + + +@dataclass(frozen=True) +class MtfRule: + """단일 MTF 조건 (신호 유형별).""" + + signal_type: str + timeframe_label: str + interval_min: int + feature: str + operator: Operator + threshold: float + cohens_d: float + positive_mean: float + negative_mean: float + + def to_dict(self) -> dict[str, Any]: + """JSON 직렬화 dict.""" + return { + "signal_type": self.signal_type, + "timeframe_label": self.timeframe_label, + "interval_min": self.interval_min, + "feature": self.feature, + "operator": self.operator, + "threshold": self.threshold, + "cohens_d": self.cohens_d, + "positive_mean": self.positive_mean, + "negative_mean": self.negative_mean, + } + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> MtfRule: + """dict에서 MtfRule 생성.""" + return cls( + signal_type=str(raw["signal_type"]), + timeframe_label=str(raw["timeframe_label"]), + interval_min=int(raw["interval_min"]), + feature=str(raw["feature"]), + operator=raw["operator"], # type: ignore[arg-type] + threshold=float(raw["threshold"]), + cohens_d=float(raw.get("cohens_d", 0.0)), + positive_mean=float(raw.get("positive_mean", 0.0)), + negative_mean=float(raw.get("negative_mean", 0.0)), + ) + + +@dataclass +class MtfRuleSet: + """신호 유형별 MTF 규칙 묶음.""" + + version: str = "v1" + min_rules_pass: int = 2 + min_cohens_d: float = 1.2 + max_rules_per_type: int = 4 + rules_by_type: dict[str, list[MtfRule]] = field(default_factory=dict) + source_report: str = "" + + def rules_for(self, signal_type: str) -> list[MtfRule]: + """신호 유형에 해당하는 규칙 목록.""" + return self.rules_by_type.get(signal_type, []) + + def to_dict(self) -> dict[str, Any]: + """JSON 직렬화 dict.""" + return { + "version": self.version, + "min_rules_pass": self.min_rules_pass, + "min_cohens_d": self.min_cohens_d, + "max_rules_per_type": self.max_rules_per_type, + "source_report": self.source_report, + "rules_by_type": { + st: [r.to_dict() for r in rules] + for st, rules in self.rules_by_type.items() + }, + } + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> MtfRuleSet: + """dict에서 MtfRuleSet 생성.""" + rules_by_type: dict[str, list[MtfRule]] = {} + for st, items in (raw.get("rules_by_type") or {}).items(): + rules_by_type[st] = [MtfRule.from_dict(item) for item in items] + return cls( + version=str(raw.get("version", "v1")), + min_rules_pass=int(raw.get("min_rules_pass", 2)), + min_cohens_d=float(raw.get("min_cohens_d", 1.2)), + max_rules_per_type=int(raw.get("max_rules_per_type", 4)), + rules_by_type=rules_by_type, + source_report=str(raw.get("source_report", "")), + ) + + +def _label_to_interval(label: str, tf_list: list[dict[str, Any]]) -> int: + """TF 라벨 → interval_min.""" + for item in tf_list: + if item.get("label") == label: + return int(item["interval_min"]) + raise KeyError(f"unknown timeframe label: {label}") + + +def derive_rules_from_report( + report: dict[str, Any], + min_cohens_d: float = 1.2, + max_rules_per_type: int = 4, + min_rules_pass: int = 2, + preferred_features: tuple[str, ...] = _PREFERRED_FEATURES, +) -> MtfRuleSet: + """MTF 상관 리포트에서 신호 유형별 규칙 후보를 도출한다. + + 임계값은 GT(양성) 평균과 음성 평균의 중간값으로 설정한다. + + Args: + report: build_mtf_correlation_report 출력 JSON. + min_cohens_d: |Cohen's d| 최소값. + max_rules_per_type: 유형당 최대 규칙 수. + min_rules_pass: 필터 통과에 필요한 최소 충족 규칙 수. + preferred_features: 우선 사용 피처. + + Returns: + MtfRuleSet. + """ + analysis = report.get("analysis", {}) + tf_list = analysis.get("timeframes") or [] + by_type = report.get("by_signal_type") or {} + + rule_set = MtfRuleSet( + min_rules_pass=min_rules_pass, + min_cohens_d=min_cohens_d, + max_rules_per_type=max_rules_per_type, + source_report=str(report.get("generated_at", "")), + ) + + for signal_type in GT_SIGNAL_TYPES: + block = by_type.get(signal_type) + if not block: + continue + + candidates: list[tuple[float, MtfRule]] = [] + + for tf_label, tf_data in (block.get("timeframes") or {}).items(): + interval_min = _label_to_interval(tf_label, tf_list) + numeric = tf_data.get("numeric") or {} + + for feat_name, summary in numeric.items(): + if feat_name in _EXCLUDED_AUTO_FEATURES: + continue + if feat_name not in preferred_features: + continue + + d = summary.get("cohens_d") + if d is None or abs(float(d)) < min_cohens_d: + continue + + pos_mean = float(summary.get("positive_mean", 0.0)) + neg_mean = float(summary.get("negative_mean", 0.0)) + threshold = round((pos_mean + neg_mean) / 2.0, 4) + operator: Operator = "<=" if pos_mean < neg_mean else ">=" + + rule = MtfRule( + signal_type=signal_type, + timeframe_label=tf_label, + interval_min=interval_min, + feature=feat_name, + operator=operator, + threshold=threshold, + cohens_d=float(d), + positive_mean=pos_mean, + negative_mean=neg_mean, + ) + candidates.append((abs(float(d)), rule)) + + candidates.sort(key=lambda x: x[0], reverse=True) + seen: set[tuple[str, str]] = set() + picked: list[MtfRule] = [] + for _, rule in candidates: + key = (rule.timeframe_label, rule.feature) + if key in seen: + continue + seen.add(key) + picked.append(rule) + if len(picked) >= max_rules_per_type: + break + + if picked: + rule_set.rules_by_type[signal_type] = picked + + return rule_set + + +def save_mtf_rules(rule_set: MtfRuleSet, json_path: Path) -> Path: + """규칙 JSON 저장.""" + json_path.parent.mkdir(parents=True, exist_ok=True) + with json_path.open("w", encoding="utf-8") as fp: + json.dump(rule_set.to_dict(), fp, ensure_ascii=False, indent=2) + return json_path + + +def load_mtf_rules(json_path: Path) -> MtfRuleSet: + """규칙 JSON 로드.""" + with json_path.open(encoding="utf-8") as fp: + return MtfRuleSet.from_dict(json.load(fp)) + + +def load_or_derive_mtf_rules( + rules_path: Path, + report_path: Path, + min_cohens_d: float = 1.2, + max_rules_per_type: int = 4, + min_rules_pass: int = 2, + force_derive: bool = False, +) -> MtfRuleSet: + """규칙 파일이 있으면 로드, 없거나 force면 리포트에서 재도출.""" + if rules_path.exists() and not force_derive: + return load_mtf_rules(rules_path) + + if not report_path.exists(): + raise FileNotFoundError( + f"MTF 규칙/리포트 없음: rules={rules_path}, report={report_path}. " + "먼저 scripts/2_run_mtf_analysis.py 실행" + ) + + with report_path.open(encoding="utf-8") as fp: + report = json.load(fp) + + rule_set = derive_rules_from_report( + report, + min_cohens_d=min_cohens_d, + max_rules_per_type=max_rules_per_type, + min_rules_pass=min_rules_pass, + ) + save_mtf_rules(rule_set, rules_path) + return rule_set diff --git a/src/deepcoin/mtf/store.py b/src/deepcoin/mtf/store.py new file mode 100644 index 0000000..9a5763f --- /dev/null +++ b/src/deepcoin/mtf/store.py @@ -0,0 +1,86 @@ +"""멀티 TF 캔들 로드 및 피처 프레임 캐시.""" + +from __future__ import annotations + +from pathlib import Path + +import pandas as pd + +from deepcoin.data.candle_loader import load_candles +from deepcoin.data.intervals import DEFAULT_DOWNLOAD_INTERVALS, interval_label +from deepcoin.mtf.features import compute_feature_frame + +MTF_INTERVALS: tuple[int, ...] = tuple(DEFAULT_DOWNLOAD_INTERVALS) + + +class MultiTimeframeStore: + """10개 TF 캔들·피처 프레임을 로드·캐시한다.""" + + def __init__( + self, + db_path: Path, + symbol: str, + intervals: tuple[int, ...] | None = None, + lookback_days: int | None = None, + zigzag_reversal_pct: float = 5.0, + ) -> None: + """Store를 초기화한다 (데이터는 load() 호출 시 로드). + + Args: + db_path: SQLite 경로. + symbol: 코인 심볼. + intervals: 사용 TF 목록. None이면 MTF_INTERVALS. + lookback_days: 최근 N일만 로드. None이면 전체. + zigzag_reversal_pct: 인과 ZigZag 되돌림 %. + """ + self.db_path = db_path + self.symbol = symbol.upper() + self.intervals = intervals or MTF_INTERVALS + self.lookback_days = lookback_days + self.zigzag_reversal_pct = zigzag_reversal_pct + self._raw: dict[int, pd.DataFrame] = {} + self._features: dict[int, pd.DataFrame] = {} + self._loaded = False + + def load(self) -> None: + """모든 TF 캔들과 피처 컬럼을 로드한다.""" + if self._loaded: + return + + for interval_min in self.intervals: + df = load_candles( + self.db_path, + self.symbol, + interval_min, + lookback_days=self.lookback_days, + ) + self._raw[interval_min] = df + self._features[interval_min] = compute_feature_frame( + df, + reversal_pct=self.zigzag_reversal_pct, + ) + + self._loaded = True + + def get_raw(self, interval_min: int) -> pd.DataFrame: + """원본 OHLCV DataFrame을 반환한다.""" + self.load() + if interval_min not in self._raw: + raise KeyError(f"interval {interval_min} not loaded") + return self._raw[interval_min] + + def get_features(self, interval_min: int) -> pd.DataFrame: + """피처 컬럼이 추가된 DataFrame을 반환한다.""" + self.load() + if interval_min not in self._features: + raise KeyError(f"interval {interval_min} not loaded") + return self._features[interval_min] + + def interval_label(self, interval_min: int) -> str: + """인터벌 한글 라벨.""" + return interval_label(interval_min) + + @property + def base_df(self) -> pd.DataFrame: + """체결 기준 3분봉 DataFrame.""" + return self.get_raw(3) diff --git a/src/deepcoin/mtf/trend_gate.py b/src/deepcoin/mtf/trend_gate.py new file mode 100644 index 0000000..ea282e5 --- /dev/null +++ b/src/deepcoin/mtf/trend_gate.py @@ -0,0 +1,55 @@ +"""고TF(60분·일봉) 추세 게이트 — 극단 구간 매매 억제.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class HtfTrendGate: + """고TF EMA60 대비 위치로 매수·매도 허용 여부를 판단한다.""" + + enabled: bool = True + buy_block_daily_below_pct: float = -25.0 + buy_block_60m_below_pct: float = -15.0 + sell_block_daily_above_pct: float = 35.0 + sell_block_60m_above_pct: float = 20.0 + + def _tf_value(self, snapshot: dict[str, Any], label: str, feature: str) -> float | None: + """스냅샷 dict에서 TF 피처 값.""" + tf = snapshot.get("timeframes", {}).get(label, {}) + if not tf.get("available"): + return None + val = tf.get(feature) + if val is None: + return None + return float(val) + + def allows_buy(self, snapshot: dict[str, Any]) -> tuple[bool, str]: + """매수 허용 여부.""" + if not self.enabled: + return True, "disabled" + + daily = self._tf_value(snapshot, "일봉", "close_vs_ema60_pct") + h60 = self._tf_value(snapshot, "60분", "close_vs_ema60_pct") + + if daily is not None and daily < self.buy_block_daily_below_pct: + return False, f"daily_ema60={daily:.1f}%<{self.buy_block_daily_below_pct}" + if h60 is not None and h60 < self.buy_block_60m_below_pct: + return False, f"60m_ema60={h60:.1f}%<{self.buy_block_60m_below_pct}" + return True, "ok" + + def allows_sell(self, snapshot: dict[str, Any]) -> tuple[bool, str]: + """매도 허용 여부.""" + if not self.enabled: + return True, "disabled" + + daily = self._tf_value(snapshot, "일봉", "close_vs_ema60_pct") + h60 = self._tf_value(snapshot, "60분", "close_vs_ema60_pct") + + if daily is not None and daily > self.sell_block_daily_above_pct: + return False, f"daily_ema60={daily:.1f}%>{self.sell_block_daily_above_pct}" + if h60 is not None and h60 > self.sell_block_60m_above_pct: + return False, f"60m_ema60={h60:.1f}%>{self.sell_block_60m_above_pct}" + return True, "ok" diff --git a/src/deepcoin/techniques/__init__.py b/src/deepcoin/techniques/__init__.py new file mode 100644 index 0000000..52d9655 --- /dev/null +++ b/src/deepcoin/techniques/__init__.py @@ -0,0 +1,20 @@ +"""2단계: Ground Truth 정합 매매 기법.""" + +from deepcoin.techniques.registry import ( + get_all_techniques, + get_composite_techniques, + get_single_techniques, + list_technique_ids, + techniques_by_category, +) +from deepcoin.techniques.runner import run_all_techniques, run_technique + +__all__ = [ + "get_all_techniques", + "get_single_techniques", + "get_composite_techniques", + "list_technique_ids", + "techniques_by_category", + "run_all_techniques", + "run_technique", +] diff --git a/src/deepcoin/techniques/adx_trend.py b/src/deepcoin/techniques/adx_trend.py new file mode 100644 index 0000000..0c68df6 --- /dev/null +++ b/src/deepcoin/techniques/adx_trend.py @@ -0,0 +1,53 @@ +"""ADX 추세 강도 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import adx + + +class AdxTrendTechnique(BaseTechnique): + """ADX 강세 구간 +DI/-DI 크로스 매수·매도.""" + + technique_id = "adx_trend" + technique_name = "ADX 추세" + category = "trend" + causal = True + description = "ADX(14) 강세 + DI 크로스 추세 신호" + + def default_extra_params(self) -> dict: + return {"period": 14, "adx_threshold": 25.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + period = int(params.extra.get("period", 14)) + adx_threshold = float(params.extra.get("adx_threshold", 25.0)) + + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + adx_line, plus_di, minus_di = adx(high, low, close, period=period) + + signals: list[TechniqueSignal] = [] + + for i in range(period + 2, len(df)): + if pd.isna(adx_line.iloc[i]): + continue + + if float(adx_line.iloc[i]) < adx_threshold: + continue + + prev_plus = float(plus_di.iloc[i - 1]) + prev_minus = float(minus_di.iloc[i - 1]) + curr_plus = float(plus_di.iloc[i]) + curr_minus = float(minus_di.iloc[i]) + c = float(close.iloc[i]) + + if prev_plus <= prev_minus and curr_plus > curr_minus: + signals.append(make_signal(df, i, c, "buy", "adx_bull_trend", confidence=0.67)) + elif prev_plus >= prev_minus and curr_plus < curr_minus: + signals.append(make_signal(df, i, c, "sell", "adx_bear_trend", confidence=0.67)) + + return signals diff --git a/src/deepcoin/techniques/atr_channel.py b/src/deepcoin/techniques/atr_channel.py new file mode 100644 index 0000000..648329c --- /dev/null +++ b/src/deepcoin/techniques/atr_channel.py @@ -0,0 +1,64 @@ +"""ATR 채널 역추세 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import atr, ema + + +class AtrChannelTechnique(BaseTechnique): + """EMA ± ATR 채널 터치 후 반전.""" + + technique_id = "atr_channel" + technique_name = "ATR 채널" + category = "volatility" + causal = True + description = "EMA(20) ± ATR(14)×2 채널 반전" + + def default_extra_params(self) -> dict: + return {"ema_span": 20, "atr_period": 14, "atr_mult": 2.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + ema_span = int(params.extra.get("ema_span", 20)) + atr_period = int(params.extra.get("atr_period", 14)) + atr_mult = float(params.extra.get("atr_mult", 2.0)) + + close = df["close"].astype(float) + low = df["low"].astype(float) + high = df["high"].astype(float) + mid = ema(close, ema_span) + atr_vals = atr(high, low, close, period=atr_period) + upper = mid + atr_mult * atr_vals + lower = mid - atr_mult * atr_vals + + signals: list[TechniqueSignal] = [] + touched_lower = False + touched_upper = False + start = max(ema_span, atr_period) + + for i in range(start, len(df)): + if pd.isna(lower.iloc[i]): + continue + + l = float(low.iloc[i]) + h = float(high.iloc[i]) + c = float(close.iloc[i]) + lo = float(lower.iloc[i]) + u = float(upper.iloc[i]) + + if l <= lo: + touched_lower = True + if touched_lower and c > lo: + signals.append(make_signal(df, i, c, "buy", "atr_channel_lower", confidence=0.68)) + touched_lower = False + + if h >= u: + touched_upper = True + if touched_upper and c < u: + signals.append(make_signal(df, i, c, "sell", "atr_channel_upper", confidence=0.68)) + touched_upper = False + + return signals diff --git a/src/deepcoin/techniques/base.py b/src/deepcoin/techniques/base.py new file mode 100644 index 0000000..8a69319 --- /dev/null +++ b/src/deepcoin/techniques/base.py @@ -0,0 +1,85 @@ +"""매매 기법 공통 인터페이스 및 결과 타입.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass, field +from typing import Any + +import pandas as pd + + +@dataclass +class TechniqueSignal: + """기법이 생성한 매수·매도 신호.""" + + side: str # buy | sell + bar_index: int + price: float + datetime: str + pivot_bar_index: int | None = None + confidence: float = 1.0 + reason: str = "" + + def to_dict(self) -> dict[str, Any]: + """JSON 직렬화용 dict.""" + return asdict(self) + + +@dataclass +class TechniqueParams: + """기법 실행 공통 파라미터.""" + + interval_min: int + lookback_days: int + min_leg_pct: float + initial_cash_krw: float + fee_rate: float + extra: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class TechniqueResult: + """기법 실행 결과 (GT JSON과 호환 구조).""" + + technique_id: str + technique_name: str + category: str + causal: bool + description: str + params: dict[str, Any] + signals: list[dict[str, Any]] + legs: list[dict[str, Any]] + summary: dict[str, Any] + pnl: dict[str, Any] + alignment: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + """JSON 직렬화용 dict.""" + return asdict(self) + + +class BaseTechnique(ABC): + """Ground Truth 정합을 목표로 하는 매매 기법 베이스.""" + + technique_id: str + technique_name: str + category: str + causal: bool + description: str + + @abstractmethod + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + """캔들 DataFrame에서 매수·매도 신호를 생성한다. + + Args: + df: datetime, open, high, low, close, volume 컬럼. + params: 공통 실행 파라미터. + + Returns: + 시간순 신호 리스트. + """ + + def default_extra_params(self) -> dict[str, Any]: + """기법별 기본 추가 파라미터.""" + return {} diff --git a/src/deepcoin/techniques/bb_reversal.py b/src/deepcoin/techniques/bb_reversal.py new file mode 100644 index 0000000..eb04469 --- /dev/null +++ b/src/deepcoin/techniques/bb_reversal.py @@ -0,0 +1,80 @@ +"""볼린저 밴드 역추세 매매.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.indicators import bollinger_bands, ema + + +class BbReversalTechnique(BaseTechnique): + """BB 하단 터치 후 반등 매수, 상단 터치 후 하락 매도.""" + + technique_id = "bb_reversal" + technique_name = "볼린저 역추세" + category = "indicator" + causal = True + description = "BB(20,2) 하단 매수·상단 매도 + EMA 추세 필터" + + def default_extra_params(self) -> dict: + return {"window": 20, "num_std": 2.0, "ema_span": 60} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + window = int(params.extra.get("window", 20)) + num_std = float(params.extra.get("num_std", 2.0)) + ema_span = int(params.extra.get("ema_span", 60)) + + close = df["close"].astype(float) + low = df["low"].astype(float) + high = df["high"].astype(float) + _, upper, lower = bollinger_bands(close, window=window, num_std=num_std) + trend = ema(close, ema_span) + + signals: list[TechniqueSignal] = [] + in_oversold = False + in_overbought = False + + for i in range(window, len(df)): + if pd.isna(lower.iloc[i]) or pd.isna(upper.iloc[i]): + continue + + low_i = float(low.iloc[i]) + high_i = float(high.iloc[i]) + close_i = float(close.iloc[i]) + lower_i = float(lower.iloc[i]) + upper_i = float(upper.iloc[i]) + trend_i = float(trend.iloc[i]) + dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S") + + if low_i <= lower_i: + in_oversold = True + if in_oversold and close_i > lower_i and close_i >= trend_i * 0.98: + signals.append( + TechniqueSignal( + side="buy", + bar_index=i, + price=round(close_i, 2), + datetime=dt, + confidence=0.7, + reason="bb_lower_bounce", + ) + ) + in_oversold = False + + if high_i >= upper_i: + in_overbought = True + if in_overbought and close_i < upper_i: + signals.append( + TechniqueSignal( + side="sell", + bar_index=i, + price=round(close_i, 2), + datetime=dt, + confidence=0.7, + reason="bb_upper_reject", + ) + ) + in_overbought = False + + return signals diff --git a/src/deepcoin/techniques/bb_squeeze_breakout.py b/src/deepcoin/techniques/bb_squeeze_breakout.py new file mode 100644 index 0000000..ca87f61 --- /dev/null +++ b/src/deepcoin/techniques/bb_squeeze_breakout.py @@ -0,0 +1,61 @@ +"""볼린저 스퀴즈 돌파 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import bollinger_bands + + +class BbSqueezeBreakoutTechnique(BaseTechnique): + """BB 폭 축소(스퀴즈) 후 상·하단 돌파.""" + + technique_id = "bb_squeeze_breakout" + technique_name = "BB 스퀴즈 돌파" + category = "breakout" + causal = True + description = "볼린저 밴드 스퀴즈 후 돌파 (B^)" + + def default_extra_params(self) -> dict: + return {"window": 20, "squeeze_pctile": 20, "lookback": 100} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + window = int(params.extra.get("window", 20)) + squeeze_pctile = float(params.extra.get("squeeze_pctile", 20)) + lookback = int(params.extra.get("lookback", 100)) + + close = df["close"].astype(float) + mid, upper, lower = bollinger_bands(close, window=window) + width = (upper - lower) / mid.replace(0, pd.NA) * 100.0 + + signals: list[TechniqueSignal] = [] + in_squeeze = False + + for i in range(window + lookback, len(df)): + if pd.isna(width.iloc[i]): + continue + + hist = width.iloc[i - lookback : i].dropna() + if hist.empty: + continue + + threshold = float(hist.quantile(squeeze_pctile / 100.0)) + w = float(width.iloc[i]) + c = float(close.iloc[i]) + u = float(upper.iloc[i]) + lo = float(lower.iloc[i]) + prev_c = float(close.iloc[i - 1]) + + if w <= threshold: + in_squeeze = True + + if in_squeeze and prev_c <= u and c > u: + signals.append(make_signal(df, i, c, "buy", "bb_squeeze_breakout_up", confidence=0.75)) + in_squeeze = False + elif in_squeeze and prev_c >= lo and c < lo: + signals.append(make_signal(df, i, c, "sell", "bb_squeeze_breakout_down", confidence=0.75)) + in_squeeze = False + + return signals diff --git a/src/deepcoin/techniques/cci_extreme.py b/src/deepcoin/techniques/cci_extreme.py new file mode 100644 index 0000000..f4a34fd --- /dev/null +++ b/src/deepcoin/techniques/cci_extreme.py @@ -0,0 +1,57 @@ +"""CCI 극값 반전 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal, safe_float +from deepcoin.techniques.indicators import cci + + +class CciExtremeTechnique(BaseTechnique): + """CCI -100 이탈 후 복귀 매수, +100 이탈 후 복귀 매도.""" + + technique_id = "cci_extreme" + technique_name = "CCI 극값" + category = "momentum" + causal = True + description = "CCI(20) 과매도·과매수 반전" + + def default_extra_params(self) -> dict: + return {"period": 20, "low_level": -100.0, "high_level": 100.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + period = int(params.extra.get("period", 20)) + low_level = float(params.extra.get("low_level", -100.0)) + high_level = float(params.extra.get("high_level", 100.0)) + + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + cci_vals = cci(high, low, close, period=period) + + signals: list[TechniqueSignal] = [] + was_oversold = False + was_overbought = False + + for i in range(period + 1, len(df)): + curr = safe_float(cci_vals.iloc[i]) + prev = safe_float(cci_vals.iloc[i - 1]) + c = safe_float(close.iloc[i]) + if curr is None or prev is None or c is None: + continue + + if curr < low_level: + was_oversold = True + if was_oversold and prev < low_level <= curr: + signals.append(make_signal(df, i, c, "buy", "cci_oversold_exit", confidence=0.65)) + was_oversold = False + + if curr > high_level: + was_overbought = True + if was_overbought and prev > high_level >= curr: + signals.append(make_signal(df, i, c, "sell", "cci_overbought_exit", confidence=0.65)) + was_overbought = False + + return signals diff --git a/src/deepcoin/techniques/composite_base.py b/src/deepcoin/techniques/composite_base.py new file mode 100644 index 0000000..ac73714 --- /dev/null +++ b/src/deepcoin/techniques/composite_base.py @@ -0,0 +1,126 @@ +"""유형별·전체 복합 기법 공통 로직.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.indicators import ema + + +@dataclass(frozen=True) +class WeightedEvent: + """봉 단위 가중 투표 이벤트.""" + + bar_index: int + side: str + weight: float + source: str + + +def collect_weighted_events( + techniques: list[BaseTechnique], + weights: dict[str, tuple[float, float]], + df: pd.DataFrame, + params: TechniqueParams, +) -> list[WeightedEvent]: + """하위 기법 신호를 가중 이벤트로 수집한다.""" + events: list[WeightedEvent] = [] + for technique in techniques: + merged_extra = {**technique.default_extra_params(), **params.extra} + run_params = TechniqueParams( + interval_min=params.interval_min, + lookback_days=params.lookback_days, + min_leg_pct=params.min_leg_pct, + initial_cash_krw=params.initial_cash_krw, + fee_rate=params.fee_rate, + extra=merged_extra, + ) + raw = technique.generate_signals(df, run_params) + buy_w, sell_w = weights.get(technique.technique_id, (1.0, 1.0)) + for sig in raw: + weight = buy_w if sig.side == "buy" else sell_w + events.append( + WeightedEvent( + bar_index=sig.bar_index, + side=sig.side, + weight=weight, + source=technique.technique_id, + ) + ) + events.sort(key=lambda e: e.bar_index) + return events + + +def cluster_events(events: list[WeightedEvent], merge_bars: int) -> list[list[WeightedEvent]]: + """인접 봉의 이벤트를 클러스터로 묶는다.""" + if not events: + return [] + + clusters: list[list[WeightedEvent]] = [[events[0]]] + for event in events[1:]: + last_bar = max(e.bar_index for e in clusters[-1]) + if event.bar_index - last_bar <= merge_bars: + clusters[-1].append(event) + else: + clusters.append([event]) + return clusters + + +def score_clusters_to_signals( + df: pd.DataFrame, + clusters: list[list[WeightedEvent]], + *, + min_score: float, + trend_span: int = 60, + use_trend_filter: bool = True, +) -> list[TechniqueSignal]: + """클러스터 점수를 TechniqueSignal로 변환한다.""" + close = df["close"].astype(float) + trend = ema(close, trend_span) if use_trend_filter else None + signals: list[TechniqueSignal] = [] + + for cluster in clusters: + buy_score = sum(e.weight for e in cluster if e.side == "buy") + sell_score = sum(e.weight for e in cluster if e.side == "sell") + bar_index = max(e.bar_index for e in cluster) + sources = sorted({e.source for e in cluster}) + + if bar_index >= len(df): + continue + if use_trend_filter and trend is not None and pd.isna(trend.iloc[bar_index]): + continue + + price = float(close.iloc[bar_index]) + dt = pd.Timestamp(df.iloc[bar_index]["datetime"]).strftime("%Y-%m-%d %H:%M:%S") + trend_val = float(trend.iloc[bar_index]) if use_trend_filter and trend is not None else price + + if buy_score >= min_score and buy_score > sell_score: + if not use_trend_filter or price > trend_val: + signals.append( + TechniqueSignal( + side="buy", + bar_index=bar_index, + price=round(price, 2), + datetime=dt, + confidence=round(min(buy_score / 5.0, 1.0), 2), + reason=f"composite_buy score={buy_score:.1f} [{','.join(sources)}]", + ) + ) + elif sell_score >= min_score and sell_score > buy_score: + if not use_trend_filter or price < trend_val: + signals.append( + TechniqueSignal( + side="sell", + bar_index=bar_index, + price=round(price, 2), + datetime=dt, + confidence=round(min(sell_score / 5.0, 1.0), 2), + reason=f"composite_sell score={sell_score:.1f} [{','.join(sources)}]", + ) + ) + + signals.sort(key=lambda s: s.bar_index) + return signals diff --git a/src/deepcoin/techniques/composite_breakout.py b/src/deepcoin/techniques/composite_breakout.py new file mode 100644 index 0000000..6c976db --- /dev/null +++ b/src/deepcoin/techniques/composite_breakout.py @@ -0,0 +1,59 @@ +"""돌파 유형 복합 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.bb_squeeze_breakout import BbSqueezeBreakoutTechnique +from deepcoin.techniques.composite_base import ( + cluster_events, + collect_weighted_events, + score_clusters_to_signals, +) +from deepcoin.techniques.donchian import DonchianTechnique +from deepcoin.techniques.keltner_breakout import KeltnerBreakoutTechnique +from deepcoin.techniques.macd_cross import MacdCrossTechnique +from deepcoin.techniques.range_breakout import RangeBreakoutTechnique +from deepcoin.techniques.volume_breakout import VolumeBreakoutTechnique + +_SUB = [ + DonchianTechnique(), + RangeBreakoutTechnique(), + KeltnerBreakoutTechnique(), + BbSqueezeBreakoutTechnique(), + VolumeBreakoutTechnique(), + MacdCrossTechnique(), +] + +_WEIGHTS: dict[str, tuple[float, float]] = { + "donchian": (2.0, 2.0), + "range_breakout": (2.0, 2.0), + "keltner_breakout": (1.8, 1.8), + "bb_squeeze_breakout": (1.8, 1.8), + "volume_breakout": (1.5, 1.5), + "macd_cross": (1.2, 1.2), +} + + +class CompositeBreakoutTechnique(BaseTechnique): + """돌파 B^ 유형 전담 복합 기법.""" + + technique_id = "composite_breakout" + technique_name = "돌파 복합" + category = "composite" + causal = True + description = "돌파·모멘텀 기법 가중 투표 (B^)" + + def default_extra_params(self) -> dict: + return {"min_score": 1.8, "merge_bars": 3, "trend_ema_span": 60} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + min_score = float(params.extra.get("min_score", 1.8)) + merge_bars = int(params.extra.get("merge_bars", 3)) + trend_span = int(params.extra.get("trend_ema_span", 60)) + events = collect_weighted_events(_SUB, _WEIGHTS, df, params) + clusters = cluster_events(events, merge_bars=merge_bars) + return score_clusters_to_signals( + df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=False, + ) diff --git a/src/deepcoin/techniques/composite_divergence.py b/src/deepcoin/techniques/composite_divergence.py new file mode 100644 index 0000000..0912699 --- /dev/null +++ b/src/deepcoin/techniques/composite_divergence.py @@ -0,0 +1,56 @@ +"""다이버전스 유형 복합 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.composite_base import ( + cluster_events, + collect_weighted_events, + score_clusters_to_signals, +) +from deepcoin.techniques.macd_cross import MacdCrossTechnique +from deepcoin.techniques.macd_divergence import MacdDivergenceTechnique +from deepcoin.techniques.obv_divergence import ObvDivergenceTechnique +from deepcoin.techniques.rsi_divergence import RsiDivergenceTechnique +from deepcoin.techniques.rsi_swing import RsiSwingTechnique + +_SUB = [ + RsiDivergenceTechnique(), + MacdDivergenceTechnique(), + ObvDivergenceTechnique(), + RsiSwingTechnique(), + MacdCrossTechnique(), +] + +_WEIGHTS: dict[str, tuple[float, float]] = { + "rsi_divergence": (2.5, 2.5), + "macd_divergence": (2.5, 2.5), + "obv_divergence": (2.0, 2.0), + "rsi_swing": (1.2, 1.2), + "macd_cross": (1.0, 1.0), +} + + +class CompositeDivergenceTechnique(BaseTechnique): + """다이버전스 Bd/Sd 유형 전담 복합 기법.""" + + technique_id = "composite_divergence" + technique_name = "다이버전스 복합" + category = "composite" + causal = True + description = "RSI/MACD/OBV 다이버전스 가중 투표 (Bd/Sd)" + + def default_extra_params(self) -> dict: + return {"min_score": 2.0, "merge_bars": 5, "trend_ema_span": 60} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + min_score = float(params.extra.get("min_score", 2.0)) + merge_bars = int(params.extra.get("merge_bars", 5)) + trend_span = int(params.extra.get("trend_ema_span", 60)) + events = collect_weighted_events(_SUB, _WEIGHTS, df, params) + clusters = cluster_events(events, merge_bars=merge_bars) + return score_clusters_to_signals( + df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=False, + ) diff --git a/src/deepcoin/techniques/composite_full.py b/src/deepcoin/techniques/composite_full.py new file mode 100644 index 0000000..b6bf021 --- /dev/null +++ b/src/deepcoin/techniques/composite_full.py @@ -0,0 +1,61 @@ +"""전체 기법 통합 복합.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.composite_base import ( + cluster_events, + collect_weighted_events, + score_clusters_to_signals, +) +def _build_full_sub_techniques() -> list[BaseTechnique]: + """복합 제외 단일 기법 목록을 반환한다.""" + from deepcoin.techniques.registry import get_single_techniques + + return get_single_techniques() + + +_CATEGORY_WEIGHT: dict[str, tuple[float, float]] = { + "swing": (2.0, 2.0), + "pullback": (1.8, 1.8), + "breakout": (1.8, 1.8), + "divergence": (2.0, 2.0), + "indicator": (1.2, 1.2), + "trend": (1.0, 1.0), + "momentum": (1.0, 1.0), + "volatility": (1.2, 1.2), + "structure": (1.2, 1.2), + "volume": (1.0, 1.0), + "hybrid": (1.8, 1.8), +} + + +class CompositeFullTechnique(BaseTechnique): + """등록된 모든 단일 기법 가중 투표 통합.""" + + technique_id = "composite_full" + technique_name = "전체 통합 복합" + category = "composite" + causal = True + description = "전체 인과 기법 가중 투표 + EMA 추세 필터" + + def default_extra_params(self) -> dict: + return {"min_score": 4.0, "merge_bars": 3, "trend_ema_span": 60} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + min_score = float(params.extra.get("min_score", 4.0)) + merge_bars = int(params.extra.get("merge_bars", 3)) + trend_span = int(params.extra.get("trend_ema_span", 60)) + + sub = _build_full_sub_techniques() + weights = { + t.technique_id: _CATEGORY_WEIGHT.get(t.category, (1.0, 1.0)) + for t in sub + } + events = collect_weighted_events(sub, weights, df, params) + clusters = cluster_events(events, merge_bars=merge_bars) + return score_clusters_to_signals( + df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=True, + ) diff --git a/src/deepcoin/techniques/composite_pullback.py b/src/deepcoin/techniques/composite_pullback.py new file mode 100644 index 0000000..4c0e286 --- /dev/null +++ b/src/deepcoin/techniques/composite_pullback.py @@ -0,0 +1,59 @@ +"""눌림목 유형 복합 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.bb_reversal import BbReversalTechnique +from deepcoin.techniques.composite_base import ( + cluster_events, + collect_weighted_events, + score_clusters_to_signals, +) +from deepcoin.techniques.ema_pullback import EmaPullbackTechnique +from deepcoin.techniques.fib_pullback import FibPullbackTechnique +from deepcoin.techniques.keltner_reversal import KeltnerReversalTechnique +from deepcoin.techniques.local_extrema import LocalExtremaTechnique +from deepcoin.techniques.support_bounce import SupportBounceTechnique + +_SUB = [ + EmaPullbackTechnique(), + FibPullbackTechnique(), + SupportBounceTechnique(), + BbReversalTechnique(), + LocalExtremaTechnique(), + KeltnerReversalTechnique(), +] + +_WEIGHTS: dict[str, tuple[float, float]] = { + "ema_pullback": (2.0, 2.0), + "fib_pullback": (2.0, 2.0), + "support_bounce": (1.8, 1.8), + "bb_reversal": (1.8, 1.8), + "local_extrema": (1.5, 1.5), + "keltner_reversal": (1.2, 1.2), +} + + +class CompositePullbackTechnique(BaseTechnique): + """눌림목 B* 유형 전담 복합 기법.""" + + technique_id = "composite_pullback" + technique_name = "눌림목 복합" + category = "composite" + causal = True + description = "눌림목·역추세 기법 가중 투표 (B*)" + + def default_extra_params(self) -> dict: + return {"min_score": 2.0, "merge_bars": 3, "trend_ema_span": 60} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + min_score = float(params.extra.get("min_score", 2.0)) + merge_bars = int(params.extra.get("merge_bars", 3)) + trend_span = int(params.extra.get("trend_ema_span", 60)) + events = collect_weighted_events(_SUB, _WEIGHTS, df, params) + clusters = cluster_events(events, merge_bars=merge_bars) + return score_clusters_to_signals( + df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=True, + ) diff --git a/src/deepcoin/techniques/composite_swing.py b/src/deepcoin/techniques/composite_swing.py new file mode 100644 index 0000000..53ba1c2 --- /dev/null +++ b/src/deepcoin/techniques/composite_swing.py @@ -0,0 +1,62 @@ +"""스윙 유형 복합 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.composite_base import ( + cluster_events, + collect_weighted_events, + score_clusters_to_signals, +) +from deepcoin.techniques.donchian import DonchianTechnique +from deepcoin.techniques.fractal_swing import FractalSwingTechnique +from deepcoin.techniques.local_extrema import LocalExtremaTechnique +from deepcoin.techniques.minor_swing import MinorSwingTechnique +from deepcoin.techniques.pivot_swing import PivotSwingTechnique +from deepcoin.techniques.swing_failure import SwingFailureTechnique +from deepcoin.techniques.zigzag_causal import ZigzagCausalTechnique + +_SUB = [ + ZigzagCausalTechnique(), + MinorSwingTechnique(), + PivotSwingTechnique(), + FractalSwingTechnique(), + LocalExtremaTechnique(), + DonchianTechnique(), + SwingFailureTechnique(), +] + +_WEIGHTS: dict[str, tuple[float, float]] = { + "zigzag_causal": (2.5, 2.5), + "minor_swing": (2.0, 2.0), + "pivot_swing": (1.8, 1.8), + "fractal_swing": (1.5, 1.5), + "local_extrema": (1.5, 1.5), + "donchian": (1.2, 1.2), + "swing_failure": (1.0, 1.0), +} + + +class CompositeSwingTechnique(BaseTechnique): + """스윙 B/S 유형 전담 복합 기법.""" + + technique_id = "composite_swing" + technique_name = "스윙 복합" + category = "composite" + causal = True + description = "스윙 저점·고점 전담 기법 가중 투표 (B/S)" + + def default_extra_params(self) -> dict: + return {"min_score": 2.5, "merge_bars": 3, "trend_ema_span": 60} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + min_score = float(params.extra.get("min_score", 2.5)) + merge_bars = int(params.extra.get("merge_bars", 3)) + trend_span = int(params.extra.get("trend_ema_span", 60)) + events = collect_weighted_events(_SUB, _WEIGHTS, df, params) + clusters = cluster_events(events, merge_bars=merge_bars) + return score_clusters_to_signals( + df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=True, + ) diff --git a/src/deepcoin/techniques/composite_v3.py b/src/deepcoin/techniques/composite_v3.py new file mode 100644 index 0000000..ac9b29f --- /dev/null +++ b/src/deepcoin/techniques/composite_v3.py @@ -0,0 +1,97 @@ +"""v3 GT 6종 신호를 가중 투표로 통합하는 복합 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.bb_reversal import BbReversalTechnique +from deepcoin.techniques.composite_base import ( + cluster_events, + collect_weighted_events, + score_clusters_to_signals, +) +from deepcoin.techniques.donchian import DonchianTechnique +from deepcoin.techniques.ema_pullback import EmaPullbackTechnique +from deepcoin.techniques.fib_pullback import FibPullbackTechnique +from deepcoin.techniques.fractal_swing import FractalSwingTechnique +from deepcoin.techniques.keltner_breakout import KeltnerBreakoutTechnique +from deepcoin.techniques.local_extrema import LocalExtremaTechnique +from deepcoin.techniques.macd_cross import MacdCrossTechnique +from deepcoin.techniques.macd_divergence import MacdDivergenceTechnique +from deepcoin.techniques.minor_swing import MinorSwingTechnique +from deepcoin.techniques.obv_divergence import ObvDivergenceTechnique +from deepcoin.techniques.pivot_swing import PivotSwingTechnique +from deepcoin.techniques.range_breakout import RangeBreakoutTechnique +from deepcoin.techniques.rsi_divergence import RsiDivergenceTechnique +from deepcoin.techniques.rsi_swing import RsiSwingTechnique +from deepcoin.techniques.support_bounce import SupportBounceTechnique +from deepcoin.techniques.zigzag_causal import ZigzagCausalTechnique + +_TECHNIQUE_WEIGHTS: dict[str, tuple[float, float]] = { + "zigzag_causal": (2.5, 2.5), + "minor_swing": (2.0, 2.0), + "pivot_swing": (1.8, 1.8), + "fractal_swing": (1.5, 1.5), + "local_extrema": (1.5, 1.5), + "ema_pullback": (2.0, 1.0), + "fib_pullback": (2.0, 1.0), + "support_bounce": (1.5, 1.0), + "bb_reversal": (1.5, 1.5), + "donchian": (1.5, 1.5), + "range_breakout": (1.8, 1.0), + "keltner_breakout": (1.5, 1.0), + "macd_cross": (1.2, 1.2), + "rsi_divergence": (2.0, 2.0), + "macd_divergence": (2.0, 2.0), + "obv_divergence": (1.8, 1.8), + "rsi_swing": (1.5, 1.5), +} + +_SUB_TECHNIQUES: list[BaseTechnique] = [ + ZigzagCausalTechnique(), + MinorSwingTechnique(), + PivotSwingTechnique(), + FractalSwingTechnique(), + LocalExtremaTechnique(), + EmaPullbackTechnique(), + FibPullbackTechnique(), + SupportBounceTechnique(), + BbReversalTechnique(), + DonchianTechnique(), + RangeBreakoutTechnique(), + KeltnerBreakoutTechnique(), + MacdCrossTechnique(), + RsiDivergenceTechnique(), + MacdDivergenceTechnique(), + ObvDivergenceTechnique(), + RsiSwingTechnique(), +] + + +class CompositeV3Technique(BaseTechnique): + """v3 GT 6종 신호를 가중 투표로 재현하는 통합 인과 기법.""" + + technique_id = "composite_v3" + technique_name = "v3 통합 스코어링" + category = "composite" + causal = True + description = "v3 GT 6종 신호 유형별 핵심 기법 가중 투표 + EMA(60) 추세 필터" + + def default_extra_params(self) -> dict: + return { + "min_score": 2.5, + "merge_bars": 3, + "trend_ema_span": 60, + "reversal_pct": 5.0, + } + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + min_score = float(params.extra.get("min_score", 2.5)) + merge_bars = int(params.extra.get("merge_bars", 3)) + trend_span = int(params.extra.get("trend_ema_span", 60)) + events = collect_weighted_events(_SUB_TECHNIQUES, _TECHNIQUE_WEIGHTS, df, params) + clusters = cluster_events(events, merge_bars=merge_bars) + return score_clusters_to_signals( + df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=True, + ) diff --git a/src/deepcoin/techniques/donchian.py b/src/deepcoin/techniques/donchian.py new file mode 100644 index 0000000..772f5a6 --- /dev/null +++ b/src/deepcoin/techniques/donchian.py @@ -0,0 +1,76 @@ +"""돈치안 채널 스윙.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal + + +class DonchianTechnique(BaseTechnique): + """N봉 최저가 터치 후 반등 매수, N봉 최고가 터치 후 하락 매도.""" + + technique_id = "donchian" + technique_name = "돈치안 채널" + category = "swing" + causal = True + description = "돈치안(40) 채널 하단 매수·상단 매도" + + def default_extra_params(self) -> dict: + return {"window": 40} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + window = int(params.extra.get("window", 40)) + low = df["low"].astype(float) + high = df["high"].astype(float) + close = df["close"].astype(float) + + lower_channel = low.rolling(window=window, min_periods=window).min() + upper_channel = high.rolling(window=window, min_periods=window).max() + + signals: list[TechniqueSignal] = [] + touched_lower = False + touched_upper = False + + for i in range(window, len(df)): + if pd.isna(lower_channel.iloc[i]): + continue + + low_i = float(low.iloc[i]) + high_i = float(high.iloc[i]) + close_i = float(close.iloc[i]) + lower_i = float(lower_channel.iloc[i]) + upper_i = float(upper_channel.iloc[i]) + dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S") + + if low_i <= lower_i: + touched_lower = True + if touched_lower and close_i > lower_i * 1.005: + signals.append( + TechniqueSignal( + side="buy", + bar_index=i, + price=round(close_i, 2), + datetime=dt, + confidence=0.65, + reason="donchian_lower_bounce", + ) + ) + touched_lower = False + + if high_i >= upper_i: + touched_upper = True + if touched_upper and close_i < upper_i * 0.995: + signals.append( + TechniqueSignal( + side="sell", + bar_index=i, + price=round(close_i, 2), + datetime=dt, + confidence=0.65, + reason="donchian_upper_reject", + ) + ) + touched_upper = False + + return signals diff --git a/src/deepcoin/techniques/ema_pullback.py b/src/deepcoin/techniques/ema_pullback.py new file mode 100644 index 0000000..8647a19 --- /dev/null +++ b/src/deepcoin/techniques/ema_pullback.py @@ -0,0 +1,62 @@ +"""EMA 눌림목 반등 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import ema + + +class EmaPullbackTechnique(BaseTechnique): + """상승 추세에서 EMA 터치 후 반등 매수, 하락 추세에서 EMA 터치 후 하락 매도.""" + + technique_id = "ema_pullback" + technique_name = "EMA 눌림목" + category = "pullback" + causal = True + description = "EMA(20/60) 눌림목 반등 매수·되돌림 매도 (B*)" + + def default_extra_params(self) -> dict: + return {"fast_span": 20, "slow_span": 60, "touch_pct": 0.5} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + fast_span = int(params.extra.get("fast_span", 20)) + slow_span = int(params.extra.get("slow_span", 60)) + touch_pct = float(params.extra.get("touch_pct", 0.5)) / 100.0 + + close = df["close"].astype(float) + low = df["low"].astype(float) + high = df["high"].astype(float) + ema_fast = ema(close, fast_span) + ema_slow = ema(close, slow_span) + + signals: list[TechniqueSignal] = [] + touched_fast_buy = False + touched_fast_sell = False + + for i in range(slow_span + 1, len(df)): + if pd.isna(ema_fast.iloc[i]) or pd.isna(ema_slow.iloc[i]): + continue + + c = float(close.iloc[i]) + l = float(low.iloc[i]) + h = float(high.iloc[i]) + ef = float(ema_fast.iloc[i]) + es = float(ema_slow.iloc[i]) + prev_c = float(close.iloc[i - 1]) + + if c > es and l <= ef * (1 + touch_pct): + touched_fast_buy = True + if touched_fast_buy and prev_c <= ef and c > ef and c > es: + signals.append(make_signal(df, i, c, "buy", "ema_pullback_buy", confidence=0.74)) + touched_fast_buy = False + + if c < es and h >= ef * (1 - touch_pct): + touched_fast_sell = True + if touched_fast_sell and prev_c >= ef and c < ef and c < es: + signals.append(make_signal(df, i, c, "sell", "ema_pullback_sell", confidence=0.74)) + touched_fast_sell = False + + return signals diff --git a/src/deepcoin/techniques/fib_pullback.py b/src/deepcoin/techniques/fib_pullback.py new file mode 100644 index 0000000..707b10c --- /dev/null +++ b/src/deepcoin/techniques/fib_pullback.py @@ -0,0 +1,106 @@ +"""피보나치 되돌림 눌림목 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import find_confirmed_pivots, make_signal +from deepcoin.techniques.indicators import ema + + +class FibPullbackTechnique(BaseTechnique): + """스윙 고저점 기준 피보나치 38.2~61.8% 구간 터치 후 반전.""" + + technique_id = "fib_pullback" + technique_name = "피보나치 눌림목" + category = "pullback" + causal = True + description = "피보나치 38.2~61.8% 되돌림 구간 매수·매도 (B*)" + + def default_extra_params(self) -> dict: + return {"order": 15, "fib_low": 0.382, "fib_high": 0.618, "trend_span": 60} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + order = int(params.extra.get("order", 15)) + fib_low = float(params.extra.get("fib_low", 0.382)) + fib_high = float(params.extra.get("fib_high", 0.618)) + trend_span = int(params.extra.get("trend_span", 60)) + + low = df["low"].astype(float) + high = df["high"].astype(float) + close = df["close"].astype(float) + trend = ema(close, trend_span) + low_pivots = find_confirmed_pivots(low, order, "low") + high_pivots = find_confirmed_pivots(high, order, "high") + signals: list[TechniqueSignal] = [] + + for i in range(1, len(low_pivots)): + swing_low_idx, swing_low = low_pivots[i] + swing_high_idx, swing_high = _prior_high(high_pivots, swing_low_idx) + if swing_high_idx is None or swing_high <= swing_low: + continue + confirm = swing_low_idx + order + if confirm >= len(df): + continue + leg = swing_high - swing_low + zone_top = swing_high - leg * fib_low + zone_bot = swing_high - leg * fib_high + for j in range(confirm, min(len(df), confirm + order * 4)): + if float(close.iloc[j]) < float(trend.iloc[j]): + break + if zone_bot <= float(low.iloc[j]) <= zone_top and float(close.iloc[j]) > zone_bot: + signals.append( + make_signal( + df, j, float(close.iloc[j]), "buy", + "fib_pullback_buy", pivot_bar_index=swing_low_idx, confidence=0.73, + ) + ) + break + + for i in range(1, len(high_pivots)): + swing_high_idx, swing_high = high_pivots[i] + swing_low_idx, swing_low = _prior_low(low_pivots, swing_high_idx) + if swing_low_idx is None or swing_high <= swing_low: + continue + confirm = swing_high_idx + order + if confirm >= len(df): + continue + leg = swing_high - swing_low + zone_bot = swing_low + leg * fib_low + zone_top = swing_low + leg * fib_high + for j in range(confirm, min(len(df), confirm + order * 4)): + if float(close.iloc[j]) > float(trend.iloc[j]): + break + if zone_bot <= float(high.iloc[j]) <= zone_top and float(close.iloc[j]) < zone_top: + signals.append( + make_signal( + df, j, float(close.iloc[j]), "sell", + "fib_pullback_sell", pivot_bar_index=swing_high_idx, confidence=0.73, + ) + ) + break + + return signals + + +def _prior_high( + pivots: list[tuple[int, float]], before_idx: int, +) -> tuple[int | None, float | None]: + """before_idx 이전 최근 고점 피벗을 반환한다.""" + candidates = [(idx, val) for idx, val in pivots if idx < before_idx] + if not candidates: + return None, None + idx, val = max(candidates, key=lambda x: x[0]) + return idx, val + + +def _prior_low( + pivots: list[tuple[int, float]], before_idx: int, +) -> tuple[int | None, float | None]: + """before_idx 이전 최근 저점 피벗을 반환한다.""" + candidates = [(idx, val) for idx, val in pivots if idx < before_idx] + if not candidates: + return None, None + idx, val = max(candidates, key=lambda x: x[0]) + return idx, val diff --git a/src/deepcoin/techniques/fractal_swing.py b/src/deepcoin/techniques/fractal_swing.py new file mode 100644 index 0000000..6ae0928 --- /dev/null +++ b/src/deepcoin/techniques/fractal_swing.py @@ -0,0 +1,53 @@ +"""Williams 프랙탈 스윙 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import dedupe_signals, find_fractal_pivots, make_signal + + +class FractalSwingTechnique(BaseTechnique): + """프랙탈 저점·고점 확정 시 매수·매도.""" + + technique_id = "fractal_swing" + technique_name = "프랙탈 스윙" + category = "swing" + causal = True + description = "Williams 프랙탈 스윙 저점 매수·고점 매도" + + def default_extra_params(self) -> dict: + return {"span": 2, "min_bars_between": 20} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + span = int(params.extra.get("span", 2)) + min_bars = int(params.extra.get("min_bars_between", 20)) + low = df["low"].astype(float) + high = df["high"].astype(float) + low_fractals, high_fractals = find_fractal_pivots(low, high, span=span) + signals: list[TechniqueSignal] = [] + + for pivot_idx, pivot_val in low_fractals: + confirm_idx = pivot_idx + span + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, pivot_val, "buy", + "fractal_low", pivot_bar_index=pivot_idx, confidence=0.7, + ) + ) + + for pivot_idx, pivot_val in high_fractals: + confirm_idx = pivot_idx + span + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, pivot_val, "sell", + "fractal_high", pivot_bar_index=pivot_idx, confidence=0.7, + ) + ) + + return dedupe_signals(signals, min_bars=min_bars) diff --git a/src/deepcoin/techniques/helpers.py b/src/deepcoin/techniques/helpers.py new file mode 100644 index 0000000..ef0e9b1 --- /dev/null +++ b/src/deepcoin/techniques/helpers.py @@ -0,0 +1,186 @@ +"""기법 공통 유틸리티 (신호 병합·다이버전스·피벗 탐지).""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import TechniqueSignal + + +def safe_float(value: object) -> float | None: + """Series 스칼라를 float로 변환한다. NA/NaN이면 None.""" + if pd.isna(value): + return None + return float(value) + + +def format_datetime(df: pd.DataFrame, bar_index: int) -> str: + """봉 인덱스의 datetime 문자열을 반환한다.""" + return pd.Timestamp(df.iloc[bar_index]["datetime"]).strftime("%Y-%m-%d %H:%M:%S") + + +def dedupe_signals(signals: list[TechniqueSignal], min_bars: int) -> list[TechniqueSignal]: + """동일 방향 근접 신호를 병합한다.""" + if not signals: + return [] + + sorted_signals = sorted(signals, key=lambda s: s.bar_index) + merged: list[TechniqueSignal] = [sorted_signals[0]] + + for signal in sorted_signals[1:]: + last = merged[-1] + if signal.side == last.side and signal.bar_index - last.bar_index < min_bars: + if signal.side == "buy" and signal.price < last.price: + merged[-1] = signal + elif signal.side == "sell" and signal.price > last.price: + merged[-1] = signal + else: + merged.append(signal) + + return sorted(merged, key=lambda s: s.bar_index) + + +def merge_opposite_signals( + signals: list[TechniqueSignal], + min_bars: int, +) -> list[TechniqueSignal]: + """방향 무관 근접 신호 중 confidence 높은 쪽을 유지한다.""" + if not signals: + return [] + + sorted_signals = sorted(signals, key=lambda s: s.bar_index) + merged: list[TechniqueSignal] = [sorted_signals[0]] + + for signal in sorted_signals[1:]: + last = merged[-1] + if signal.bar_index - last.bar_index < min_bars: + if signal.confidence > last.confidence: + merged[-1] = signal + else: + merged.append(signal) + + return sorted(merged, key=lambda s: s.bar_index) + + +def find_confirmed_pivots( + series: pd.Series, + order: int, + mode: str, +) -> list[tuple[int, float]]: + """order봉 지연 확정 피벗 인덱스·값 목록을 반환한다. + + Args: + series: high 또는 low 시리즈. + order: 좌우 비교 봉 수. + mode: low | high. + + Returns: + (pivot_bar_index, pivot_value) 리스트. + """ + pivots: list[tuple[int, float]] = [] + values = series.astype(float) + + for pivot_idx in range(order, len(values) - order): + window = values.iloc[pivot_idx - order : pivot_idx + order + 1] + val = float(values.iloc[pivot_idx]) + if mode == "low" and val <= window.min(): + pivots.append((pivot_idx, val)) + elif mode == "high" and val >= window.max(): + pivots.append((pivot_idx, val)) + + return pivots + + +def find_fractal_pivots( + low: pd.Series, + high: pd.Series, + span: int = 2, +) -> tuple[list[tuple[int, float]], list[tuple[int, float]]]: + """Williams 프랙탈 피벗 (span봉 후 확정)을 반환한다.""" + low_fractals: list[tuple[int, float]] = [] + high_fractals: list[tuple[int, float]] = [] + + for pivot_idx in range(span, len(low) - span): + low_window = low.iloc[pivot_idx - span : pivot_idx + span + 1].astype(float) + high_window = high.iloc[pivot_idx - span : pivot_idx + span + 1].astype(float) + low_val = float(low.iloc[pivot_idx]) + high_val = float(high.iloc[pivot_idx]) + + if low_val == low_window.min(): + low_fractals.append((pivot_idx, low_val)) + if high_val == high_window.max(): + high_fractals.append((pivot_idx, high_val)) + + return low_fractals, high_fractals + + +def detect_bullish_divergence( + price_pivots: list[tuple[int, float]], + indicator: pd.Series, + min_bars_between: int = 10, + max_bars_between: int = 500, +) -> list[tuple[int, int]]: + """가격 저점 하락·지표 저점 상승 다이버전스 (확정봉, 피벗2) 쌍을 반환한다.""" + pairs: list[tuple[int, int]] = [] + for i in range(1, len(price_pivots)): + idx1, p1 = price_pivots[i - 1] + idx2, p2 = price_pivots[i] + gap = idx2 - idx1 + if gap < min_bars_between or gap > max_bars_between: + continue + if p2 >= p1: + continue + ind1 = float(indicator.iloc[idx1]) + ind2 = float(indicator.iloc[idx2]) + if pd.isna(ind1) or pd.isna(ind2): + continue + if ind2 > ind1: + pairs.append((idx2, idx2)) + return pairs + + +def detect_bearish_divergence( + price_pivots: list[tuple[int, float]], + indicator: pd.Series, + min_bars_between: int = 10, + max_bars_between: int = 500, +) -> list[tuple[int, int]]: + """가격 고점 상승·지표 고점 하락 다이버전스 쌍을 반환한다.""" + pairs: list[tuple[int, int]] = [] + for i in range(1, len(price_pivots)): + idx1, p1 = price_pivots[i - 1] + idx2, p2 = price_pivots[i] + gap = idx2 - idx1 + if gap < min_bars_between or gap > max_bars_between: + continue + if p2 <= p1: + continue + ind1 = float(indicator.iloc[idx1]) + ind2 = float(indicator.iloc[idx2]) + if pd.isna(ind1) or pd.isna(ind2): + continue + if ind2 < ind1: + pairs.append((idx2, idx2)) + return pairs + + +def make_signal( + df: pd.DataFrame, + bar_index: int, + price: float, + side: str, + reason: str, + *, + pivot_bar_index: int | None = None, + confidence: float = 0.7, +) -> TechniqueSignal: + """TechniqueSignal을 생성한다.""" + return TechniqueSignal( + side=side, + bar_index=bar_index, + price=round(price, 2), + datetime=format_datetime(df, bar_index), + pivot_bar_index=pivot_bar_index, + confidence=confidence, + reason=reason, + ) diff --git a/src/deepcoin/techniques/ichimoku_trend.py b/src/deepcoin/techniques/ichimoku_trend.py new file mode 100644 index 0000000..f45f6da --- /dev/null +++ b/src/deepcoin/techniques/ichimoku_trend.py @@ -0,0 +1,51 @@ +"""일목 전환·기준선 크로스 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import ichimoku + + +class IchimokuTrendTechnique(BaseTechnique): + """전환선이 기준선 상향·하향 돌파 시 매수·매도.""" + + technique_id = "ichimoku_trend" + technique_name = "일목 추세" + category = "trend" + causal = True + description = "일목 전환선·기준선 크로스 추세 신호" + + def default_extra_params(self) -> dict: + return {"tenkan": 9, "kijun": 26} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + tenkan_span = int(params.extra.get("tenkan", 9)) + kijun_span = int(params.extra.get("kijun", 26)) + + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + tenkan, kijun = ichimoku(high, low, tenkan=tenkan_span, kijun=kijun_span) + + signals: list[TechniqueSignal] = [] + start = kijun_span + 1 + + for i in range(start, len(df)): + if pd.isna(tenkan.iloc[i]) or pd.isna(kijun.iloc[i]): + continue + + prev_t = float(tenkan.iloc[i - 1]) + prev_k = float(kijun.iloc[i - 1]) + curr_t = float(tenkan.iloc[i]) + curr_k = float(kijun.iloc[i]) + c = float(close.iloc[i]) + + if prev_t <= prev_k and curr_t > curr_k: + signals.append(make_signal(df, i, c, "buy", "ichimoku_bull_cross", confidence=0.66)) + elif prev_t >= prev_k and curr_t < curr_k: + signals.append(make_signal(df, i, c, "sell", "ichimoku_bear_cross", confidence=0.66)) + + return signals diff --git a/src/deepcoin/techniques/indicators.py b/src/deepcoin/techniques/indicators.py new file mode 100644 index 0000000..4083ad3 --- /dev/null +++ b/src/deepcoin/techniques/indicators.py @@ -0,0 +1,295 @@ +"""기술적 지표 계산 (인과 신호용).""" + +from __future__ import annotations + +import pandas as pd + + +def ema(series: pd.Series, span: int) -> pd.Series: + """지수이동평균을 계산한다.""" + return series.ewm(span=span, adjust=False).mean() + + +def sma(series: pd.Series, window: int) -> pd.Series: + """단순이동평균을 계산한다.""" + return series.rolling(window=window, min_periods=window).mean() + + +def bollinger_bands( + close: pd.Series, + window: int = 20, + num_std: float = 2.0, +) -> tuple[pd.Series, pd.Series, pd.Series]: + """볼린저 밴드 (중심, 상단, 하단)를 계산한다.""" + mid = sma(close, window) + std = close.rolling(window=window, min_periods=window).std() + upper = mid + num_std * std + lower = mid - num_std * std + return mid, upper, lower + + +def rsi(close: pd.Series, period: int = 14) -> pd.Series: + """RSI(상대강도지수)를 계산한다.""" + delta = close.diff() + gain = delta.clip(lower=0.0) + loss = -delta.clip(upper=0.0) + avg_gain = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() + avg_loss = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() + rs = avg_gain / avg_loss.replace(0, pd.NA) + return 100 - (100 / (1 + rs)) + + +def macd( + close: pd.Series, + fast: int = 12, + slow: int = 26, + signal: int = 9, +) -> tuple[pd.Series, pd.Series, pd.Series]: + """MACD, 시그널, 히스토그램을 계산한다.""" + ema_fast = ema(close, fast) + ema_slow = ema(close, slow) + macd_line = ema_fast - ema_slow + signal_line = ema(macd_line, signal) + hist = macd_line - signal_line + return macd_line, signal_line, hist + + +def atr( + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 14, +) -> pd.Series: + """Average True Range (ATR)를 계산한다. + + Args: + high: 고가 시리즈. + low: 저가 시리즈. + close: 종가 시리즈. + period: ATR 기간. + + Returns: + ATR 시리즈. + """ + prev_close = close.shift(1) + tr = pd.concat( + [ + (high - low).abs(), + (high - prev_close).abs(), + (low - prev_close).abs(), + ], + axis=1, + ).max(axis=1) + return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() + + +def stochastic( + high: pd.Series, + low: pd.Series, + close: pd.Series, + k_period: int = 14, + d_period: int = 3, +) -> tuple[pd.Series, pd.Series]: + """Stochastic %K, %D를 계산한다.""" + import numpy as np + + close_f = close.astype(float) + lowest = low.astype(float).rolling(window=k_period, min_periods=k_period).min() + highest = high.astype(float).rolling(window=k_period, min_periods=k_period).max() + range_hl = highest - lowest + pct_k = pd.Series( + np.where(range_hl > 0, 100.0 * (close_f - lowest) / range_hl, np.nan), + index=close.index, + dtype=float, + ) + pct_d = pct_k.rolling(window=d_period, min_periods=d_period).mean() + return pct_k, pct_d + + +def cci( + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 20, +) -> pd.Series: + """Commodity Channel Index를 계산한다.""" + tp = (high + low + close) / 3.0 + sma_tp = sma(tp, period) + mean_dev = (tp - sma_tp).abs().rolling(window=period, min_periods=period).mean() + return (tp - sma_tp) / (0.015 * mean_dev.replace(0, pd.NA)) + + +def roc(close: pd.Series, period: int = 12) -> pd.Series: + """Rate of Change(%)를 계산한다.""" + prev = close.shift(period) + return (close - prev) / prev.replace(0, pd.NA) * 100.0 + + +def adx( + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 14, +) -> tuple[pd.Series, pd.Series, pd.Series]: + """ADX, +DI, -DI를 계산한다.""" + up_move = high.diff() + down_move = -low.diff() + plus_dm = up_move.where((up_move > down_move) & (up_move > 0), 0.0) + minus_dm = down_move.where((down_move > up_move) & (down_move > 0), 0.0) + atr_vals = atr(high, low, close, period=period) + plus_di = 100 * ema(plus_dm, period) / atr_vals.replace(0, pd.NA) + minus_di = 100 * ema(minus_dm, period) / atr_vals.replace(0, pd.NA) + dx = (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, pd.NA) * 100 + adx_line = ema(dx, period) + return adx_line, plus_di, minus_di + + +def keltner_channels( + high: pd.Series, + low: pd.Series, + close: pd.Series, + ema_span: int = 20, + atr_period: int = 10, + atr_mult: float = 2.0, +) -> tuple[pd.Series, pd.Series, pd.Series]: + """Keltner 채널 (중심, 상단, 하단)을 계산한다.""" + mid = ema(close, ema_span) + atr_vals = atr(high, low, close, period=atr_period) + upper = mid + atr_mult * atr_vals + lower = mid - atr_mult * atr_vals + return mid, upper, lower + + +def obv(close: pd.Series, volume: pd.Series) -> pd.Series: + """On-Balance Volume을 계산한다.""" + direction = close.diff().apply(lambda x: 1 if x > 0 else (-1 if x < 0 else 0)) + return (direction * volume.astype(float)).cumsum() + + +def supertrend( + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 10, + multiplier: float = 3.0, +) -> tuple[pd.Series, pd.Series]: + """Supertrend 라인과 방향(1=상승, -1=하락)을 계산한다.""" + atr_vals = atr(high, low, close, period=period) + hl2 = (high + low) / 2.0 + basic_upper = hl2 + multiplier * atr_vals + basic_lower = hl2 - multiplier * atr_vals + + final_upper = basic_upper.copy() + final_lower = basic_lower.copy() + direction = pd.Series(1, index=close.index, dtype=float) + st_line = pd.Series(index=close.index, dtype=float) + + for i in range(1, len(close)): + if pd.isna(final_upper.iloc[i]) or pd.isna(final_lower.iloc[i]): + continue + + if basic_upper.iloc[i] < final_upper.iloc[i - 1] or close.iloc[i - 1] > final_upper.iloc[i - 1]: + final_upper.iloc[i] = basic_upper.iloc[i] + else: + final_upper.iloc[i] = final_upper.iloc[i - 1] + + if basic_lower.iloc[i] > final_lower.iloc[i - 1] or close.iloc[i - 1] < final_lower.iloc[i - 1]: + final_lower.iloc[i] = basic_lower.iloc[i] + else: + final_lower.iloc[i] = final_lower.iloc[i - 1] + + if direction.iloc[i - 1] == 1: + if close.iloc[i] < final_lower.iloc[i]: + direction.iloc[i] = -1 + else: + direction.iloc[i] = 1 + else: + if close.iloc[i] > final_upper.iloc[i]: + direction.iloc[i] = 1 + else: + direction.iloc[i] = -1 + + st_line.iloc[i] = final_lower.iloc[i] if direction.iloc[i] == 1 else final_upper.iloc[i] + + return st_line, direction + + +def parabolic_sar( + high: pd.Series, + low: pd.Series, + close: pd.Series, + af_step: float = 0.02, + af_max: float = 0.2, +) -> pd.Series: + """Parabolic SAR 근사값을 계산한다.""" + length = len(close) + sar = pd.Series(index=close.index, dtype=float) + if length < 2: + return sar + + bull = True + af = af_step + ep = float(high.iloc[0]) + sar.iloc[0] = float(low.iloc[0]) + + for i in range(1, length): + prev_sar = float(sar.iloc[i - 1]) if not pd.isna(sar.iloc[i - 1]) else float(low.iloc[0]) + curr_sar = prev_sar + af * (ep - prev_sar) + h = float(high.iloc[i]) + low_i = float(low.iloc[i]) + + if bull: + curr_sar = min(curr_sar, float(low.iloc[i - 1]), low_i) + if low_i < curr_sar: + bull = False + curr_sar = ep + ep = low_i + af = af_step + else: + if h > ep: + ep = h + af = min(af + af_step, af_max) + else: + curr_sar = max(curr_sar, float(high.iloc[i - 1]), h) + if h > curr_sar: + bull = True + curr_sar = ep + ep = h + af = af_step + else: + if low_i < ep: + ep = low_i + af = min(af + af_step, af_max) + + sar.iloc[i] = curr_sar + + return sar + + +def ichimoku( + high: pd.Series, + low: pd.Series, + tenkan: int = 9, + kijun: int = 26, +) -> tuple[pd.Series, pd.Series]: + """일목 전환선·기준선을 계산한다 (인과 신호용 간소 버전).""" + tenkan_sen = (high.rolling(tenkan).max() + low.rolling(tenkan).min()) / 2.0 + kijun_sen = (high.rolling(kijun).max() + low.rolling(kijun).min()) / 2.0 + return tenkan_sen, kijun_sen + + +def rolling_pivot_points( + high: pd.Series, + low: pd.Series, + close: pd.Series, + window: int = 60, +) -> tuple[pd.Series, pd.Series, pd.Series]: + """롤링 피벗 P, S1, R1을 계산한다.""" + prev_high = high.shift(1).rolling(window).max() + prev_low = low.shift(1).rolling(window).min() + prev_close = close.shift(1) + pivot = (prev_high + prev_low + prev_close) / 3.0 + s1 = 2 * pivot - prev_high + r1 = 2 * pivot - prev_low + return pivot, s1, r1 + diff --git a/src/deepcoin/techniques/keltner_breakout.py b/src/deepcoin/techniques/keltner_breakout.py new file mode 100644 index 0000000..24240bf --- /dev/null +++ b/src/deepcoin/techniques/keltner_breakout.py @@ -0,0 +1,53 @@ +"""Keltner 채널 돌파 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import keltner_channels + + +class KeltnerBreakoutTechnique(BaseTechnique): + """Keltner 상단 돌파 매수, 하단 이탈 매도.""" + + technique_id = "keltner_breakout" + technique_name = "Keltner 돌파" + category = "breakout" + causal = True + description = "Keltner 채널 상·하단 돌파 (B^)" + + def default_extra_params(self) -> dict: + return {"ema_span": 20, "atr_period": 10, "atr_mult": 2.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + ema_span = int(params.extra.get("ema_span", 20)) + atr_period = int(params.extra.get("atr_period", 10)) + atr_mult = float(params.extra.get("atr_mult", 2.0)) + + close = df["close"].astype(float) + high = df["high"].astype(float) + low = df["low"].astype(float) + _, upper, lower = keltner_channels( + high, low, close, ema_span=ema_span, atr_period=atr_period, atr_mult=atr_mult, + ) + + signals: list[TechniqueSignal] = [] + start = ema_span + atr_period + + for i in range(start + 1, len(df)): + if pd.isna(upper.iloc[i]) or pd.isna(lower.iloc[i]): + continue + + prev_c = float(close.iloc[i - 1]) + c = float(close.iloc[i]) + u = float(upper.iloc[i]) + lo = float(lower.iloc[i]) + + if prev_c <= float(upper.iloc[i - 1]) and c > u: + signals.append(make_signal(df, i, c, "buy", "keltner_breakout_up", confidence=0.72)) + elif prev_c >= float(lower.iloc[i - 1]) and c < lo: + signals.append(make_signal(df, i, c, "sell", "keltner_breakout_down", confidence=0.72)) + + return signals diff --git a/src/deepcoin/techniques/keltner_reversal.py b/src/deepcoin/techniques/keltner_reversal.py new file mode 100644 index 0000000..2a17975 --- /dev/null +++ b/src/deepcoin/techniques/keltner_reversal.py @@ -0,0 +1,63 @@ +"""Keltner 채널 역추세 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import keltner_channels + + +class KeltnerReversalTechnique(BaseTechnique): + """Keltner 하단 터치 후 반등 매수, 상단 터치 후 하락 매도.""" + + technique_id = "keltner_reversal" + technique_name = "Keltner 역추세" + category = "volatility" + causal = True + description = "Keltner 채널 하단 매수·상단 매도" + + def default_extra_params(self) -> dict: + return {"ema_span": 20, "atr_period": 10, "atr_mult": 2.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + ema_span = int(params.extra.get("ema_span", 20)) + atr_period = int(params.extra.get("atr_period", 10)) + atr_mult = float(params.extra.get("atr_mult", 2.0)) + + close = df["close"].astype(float) + low = df["low"].astype(float) + high = df["high"].astype(float) + _, upper, lower = keltner_channels( + high, low, close, ema_span=ema_span, atr_period=atr_period, atr_mult=atr_mult, + ) + + signals: list[TechniqueSignal] = [] + touched_lower = False + touched_upper = False + start = ema_span + atr_period + + for i in range(start, len(df)): + if pd.isna(lower.iloc[i]): + continue + + l = float(low.iloc[i]) + h = float(high.iloc[i]) + c = float(close.iloc[i]) + lo = float(lower.iloc[i]) + u = float(upper.iloc[i]) + + if l <= lo: + touched_lower = True + if touched_lower and c > lo: + signals.append(make_signal(df, i, c, "buy", "keltner_lower_bounce", confidence=0.69)) + touched_lower = False + + if h >= u: + touched_upper = True + if touched_upper and c < u: + signals.append(make_signal(df, i, c, "sell", "keltner_upper_reject", confidence=0.69)) + touched_upper = False + + return signals diff --git a/src/deepcoin/techniques/legs.py b/src/deepcoin/techniques/legs.py new file mode 100644 index 0000000..5adb1b1 --- /dev/null +++ b/src/deepcoin/techniques/legs.py @@ -0,0 +1,130 @@ +"""신호 → 1매수·1매도 레그 변환.""" + +from __future__ import annotations + +from typing import Any + +from deepcoin.techniques.base import TechniqueSignal + + +def signals_to_legs( + signals: list[TechniqueSignal], + min_leg_pct: float = 3.0, +) -> list[dict[str, Any]]: + """시간순 신호를 1매수·1매도 레그로 짝짓는다. + + 포지션 없을 때만 매수, 보유 중일 때만 매도를 인정한다. + + Args: + signals: 시간순 TechniqueSignal 리스트. + min_leg_pct: 최소 레그 수익률(%) 필터. + + Returns: + GT legs와 동일 스키마의 레그 dict 리스트. + """ + sorted_signals = sorted(signals, key=lambda s: s.bar_index) + legs: list[dict[str, Any]] = [] + pending_buy: TechniqueSignal | None = None + leg_id = 0 + + for signal in sorted_signals: + if signal.price <= 0: + continue + + if signal.side == "buy": + if pending_buy is None: + pending_buy = signal + elif signal.price < pending_buy.price: + pending_buy = signal + continue + + if signal.side != "sell" or pending_buy is None: + continue + + if pending_buy.price <= 0: + pending_buy = None + continue + + leg_pct = (signal.price - pending_buy.price) / pending_buy.price * 100.0 + if leg_pct < min_leg_pct: + continue + + leg_id += 1 + legs.append( + { + "leg_id": leg_id, + "buy_datetime": pending_buy.datetime, + "buy_price": round(pending_buy.price, 2), + "buy_bar_index": pending_buy.bar_index, + "sell_datetime": signal.datetime, + "sell_price": round(signal.price, 2), + "sell_bar_index": signal.bar_index, + "leg_pct": round(leg_pct, 2), + "bars_held": signal.bar_index - pending_buy.bar_index, + } + ) + pending_buy = None + + return legs + + +def summarize_legs(legs: list[dict[str, Any]]) -> dict[str, Any]: + """레그 요약 통계를 계산한다.""" + if not legs: + return { + "leg_count": 0, + "buy_count": 0, + "sell_count": 0, + "avg_leg_pct": 0.0, + "median_leg_pct": 0.0, + "max_leg_pct": 0.0, + "min_leg_pct": 0.0, + "avg_bars_held": 0.0, + } + + pcts = [float(leg["leg_pct"]) for leg in legs] + bars = [int(leg["bars_held"]) for leg in legs] + pcts_sorted = sorted(pcts) + mid = len(pcts_sorted) // 2 + median = ( + pcts_sorted[mid] + if len(pcts_sorted) % 2 == 1 + else (pcts_sorted[mid - 1] + pcts_sorted[mid]) / 2 + ) + + return { + "leg_count": len(legs), + "buy_count": len(legs), + "sell_count": len(legs), + "avg_leg_pct": round(sum(pcts) / len(pcts), 2), + "median_leg_pct": round(median, 2), + "max_leg_pct": round(max(pcts), 2), + "min_leg_pct": round(min(pcts), 2), + "avg_bars_held": round(sum(bars) / len(bars), 1), + } + + +def legs_to_signal_dicts(legs: list[dict[str, Any]]) -> list[dict[str, Any]]: + """레그를 GT signals 형식으로 변환한다.""" + signals: list[dict[str, Any]] = [] + for leg in legs: + signals.append( + { + "leg_id": leg["leg_id"], + "side": "buy", + "datetime": leg["buy_datetime"], + "price": leg["buy_price"], + "bar_index": leg["buy_bar_index"], + } + ) + signals.append( + { + "leg_id": leg["leg_id"], + "side": "sell", + "datetime": leg["sell_datetime"], + "price": leg["sell_price"], + "bar_index": leg["sell_bar_index"], + "leg_pct": leg["leg_pct"], + } + ) + return signals diff --git a/src/deepcoin/techniques/local_extrema.py b/src/deepcoin/techniques/local_extrema.py new file mode 100644 index 0000000..5b79d26 --- /dev/null +++ b/src/deepcoin/techniques/local_extrema.py @@ -0,0 +1,125 @@ +"""국소 극값 기반 매수·매도 (눌림목·반등 고점).""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal + + +class LocalExtremaTechnique(BaseTechnique): + """좌우 N봉 대비 국소 저점·고점에서 신호 (인과: 확정 지연 order봉).""" + + technique_id = "local_extrema" + technique_name = "국소 극값" + category = "swing" + causal = True + description = "국소 저점 매수·고점 매도 (눌림목 유형 포착, 9/27 구간 등)" + + def default_extra_params(self) -> dict: + return {"order": 20, "min_swing_pct": 2.0, "min_bars_between": 30} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + order = int(params.extra.get("order", 20)) + min_swing_pct = float(params.extra.get("min_swing_pct", 2.0)) + min_bars = int(params.extra.get("min_bars_between", 30)) + buys = _find_local_low_signals(df, order=order, min_swing_pct=min_swing_pct) + sells = _find_local_high_signals(df, order=order, min_swing_pct=min_swing_pct) + return _dedupe_signals(buys + sells, min_bars=min_bars) + + +def _find_local_low_signals( + df: pd.DataFrame, + order: int, + min_swing_pct: float, +) -> list[TechniqueSignal]: + """국소 저점 매수 신호 (order봉 후 확정).""" + signals: list[TechniqueSignal] = [] + lookback = order * 4 + + for pivot_idx in range(order, len(df) - order): + low_val = float(df.iloc[pivot_idx]["low"]) + window = df["low"].iloc[pivot_idx - order : pivot_idx + order + 1] + if low_val > window.min(): + continue + + start = max(0, pivot_idx - lookback) + recent_high = float(df["high"].iloc[start : pivot_idx + 1].max()) + if recent_high <= 0: + continue + drop_pct = (recent_high - low_val) / recent_high * 100.0 + if drop_pct < min_swing_pct: + continue + + confirm_idx = pivot_idx + order + signals.append( + TechniqueSignal( + side="buy", + bar_index=confirm_idx, + price=round(low_val, 2), + datetime=pd.Timestamp(df.iloc[confirm_idx]["datetime"]).strftime("%Y-%m-%d %H:%M:%S"), + pivot_bar_index=pivot_idx, + confidence=min(1.0, drop_pct / 10.0), + reason="local_low", + ) + ) + return signals + + +def _find_local_high_signals( + df: pd.DataFrame, + order: int, + min_swing_pct: float, +) -> list[TechniqueSignal]: + """국소 고점 매도 신호 (order봉 후 확정).""" + signals: list[TechniqueSignal] = [] + lookback = order * 4 + + for pivot_idx in range(order, len(df) - order): + high_val = float(df.iloc[pivot_idx]["high"]) + window = df["high"].iloc[pivot_idx - order : pivot_idx + order + 1] + if high_val < window.max(): + continue + + start = max(0, pivot_idx - lookback) + recent_low = float(df["low"].iloc[start : pivot_idx + 1].min()) + if recent_low <= 0: + continue + rise_pct = (high_val - recent_low) / recent_low * 100.0 + if rise_pct < min_swing_pct: + continue + + confirm_idx = pivot_idx + order + signals.append( + TechniqueSignal( + side="sell", + bar_index=confirm_idx, + price=round(high_val, 2), + datetime=pd.Timestamp(df.iloc[confirm_idx]["datetime"]).strftime("%Y-%m-%d %H:%M:%S"), + pivot_bar_index=pivot_idx, + confidence=min(1.0, rise_pct / 10.0), + reason="local_high", + ) + ) + return signals + + +def _dedupe_signals(signals: list[TechniqueSignal], min_bars: int) -> list[TechniqueSignal]: + """동일 방향 근접 신호를 병합한다.""" + if not signals: + return [] + + sorted_signals = sorted(signals, key=lambda s: s.bar_index) + merged: list[TechniqueSignal] = [sorted_signals[0]] + + for signal in sorted_signals[1:]: + last = merged[-1] + if signal.side == last.side and signal.bar_index - last.bar_index < min_bars: + if signal.side == "buy" and signal.price < last.price: + merged[-1] = signal + elif signal.side == "sell" and signal.price > last.price: + merged[-1] = signal + else: + merged.append(signal) + + return sorted(merged, key=lambda s: s.bar_index) diff --git a/src/deepcoin/techniques/ma_cross.py b/src/deepcoin/techniques/ma_cross.py new file mode 100644 index 0000000..1c2bb27 --- /dev/null +++ b/src/deepcoin/techniques/ma_cross.py @@ -0,0 +1,68 @@ +"""이동평균 골든·데드 크로스.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.indicators import ema + + +class MaCrossTechnique(BaseTechnique): + """단기 EMA가 장기 EMA를 상향 돌파 시 매수, 하향 돌파 시 매도.""" + + technique_id = "ma_cross" + technique_name = "EMA 크로스" + category = "indicator" + causal = True + description = "EMA(20/60) 골든크로스 매수·데드크로스 매도" + + def default_extra_params(self) -> dict: + return {"fast_span": 20, "slow_span": 60} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + fast_span = int(params.extra.get("fast_span", 20)) + slow_span = int(params.extra.get("slow_span", 60)) + + close = df["close"].astype(float) + fast = ema(close, fast_span) + slow = ema(close, slow_span) + + signals: list[TechniqueSignal] = [] + start = max(fast_span, slow_span) + + for i in range(start + 1, len(df)): + if pd.isna(fast.iloc[i]) or pd.isna(slow.iloc[i]): + continue + + prev_fast = float(fast.iloc[i - 1]) + prev_slow = float(slow.iloc[i - 1]) + curr_fast = float(fast.iloc[i]) + curr_slow = float(slow.iloc[i]) + price = float(close.iloc[i]) + dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S") + + if prev_fast <= prev_slow and curr_fast > curr_slow: + signals.append( + TechniqueSignal( + side="buy", + bar_index=i, + price=round(price, 2), + datetime=dt, + confidence=0.6, + reason="golden_cross", + ) + ) + elif prev_fast >= prev_slow and curr_fast < curr_slow: + signals.append( + TechniqueSignal( + side="sell", + bar_index=i, + price=round(price, 2), + datetime=dt, + confidence=0.6, + reason="death_cross", + ) + ) + + return signals diff --git a/src/deepcoin/techniques/macd_cross.py b/src/deepcoin/techniques/macd_cross.py new file mode 100644 index 0000000..366ae4c --- /dev/null +++ b/src/deepcoin/techniques/macd_cross.py @@ -0,0 +1,68 @@ +"""MACD 시그널선 크로스.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.indicators import macd + + +class MacdCrossTechnique(BaseTechnique): + """MACD가 시그널선을 상향 돌파 시 매수, 하향 돌파 시 매도.""" + + technique_id = "macd_cross" + technique_name = "MACD 크로스" + category = "indicator" + causal = True + description = "MACD(12,26,9) 시그널선 골든·데드 크로스" + + def default_extra_params(self) -> dict: + return {"fast": 12, "slow": 26, "signal": 9} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + fast = int(params.extra.get("fast", 12)) + slow = int(params.extra.get("slow", 26)) + signal_span = int(params.extra.get("signal", 9)) + + close = df["close"].astype(float) + macd_line, signal_line, _ = macd(close, fast=fast, slow=slow, signal=signal_span) + + signals: list[TechniqueSignal] = [] + start = slow + signal_span + + for i in range(start + 1, len(df)): + if pd.isna(macd_line.iloc[i]) or pd.isna(signal_line.iloc[i]): + continue + + prev_macd = float(macd_line.iloc[i - 1]) + prev_sig = float(signal_line.iloc[i - 1]) + curr_macd = float(macd_line.iloc[i]) + curr_sig = float(signal_line.iloc[i]) + price = float(close.iloc[i]) + dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S") + + if prev_macd <= prev_sig and curr_macd > curr_sig: + signals.append( + TechniqueSignal( + side="buy", + bar_index=i, + price=round(price, 2), + datetime=dt, + confidence=0.6, + reason="macd_bull_cross", + ) + ) + elif prev_macd >= prev_sig and curr_macd < curr_sig: + signals.append( + TechniqueSignal( + side="sell", + bar_index=i, + price=round(price, 2), + datetime=dt, + confidence=0.6, + reason="macd_bear_cross", + ) + ) + + return signals diff --git a/src/deepcoin/techniques/macd_divergence.py b/src/deepcoin/techniques/macd_divergence.py new file mode 100644 index 0000000..7fd2416 --- /dev/null +++ b/src/deepcoin/techniques/macd_divergence.py @@ -0,0 +1,76 @@ +"""MACD 다이버전스 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import ( + dedupe_signals, + detect_bearish_divergence, + detect_bullish_divergence, + find_confirmed_pivots, + make_signal, +) +from deepcoin.techniques.indicators import macd + + +class MacdDivergenceTechnique(BaseTechnique): + """MACD 히스토그램 상승·하락 다이버전스.""" + + technique_id = "macd_divergence" + technique_name = "MACD 다이버전스" + category = "divergence" + causal = True + description = "MACD 히스토그램 상승(Bd)·하락(Sd) 다이버전스" + + def default_extra_params(self) -> dict: + return { + "fast": 12, "slow": 26, "signal": 9, + "order": 12, "min_bars_between": 15, "max_bars_between": 400, + } + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + fast = int(params.extra.get("fast", 12)) + slow = int(params.extra.get("slow", 26)) + signal_span = int(params.extra.get("signal", 9)) + order = int(params.extra.get("order", 12)) + min_bars = int(params.extra.get("min_bars_between", 15)) + max_bars = int(params.extra.get("max_bars_between", 400)) + + close = df["close"].astype(float) + low = df["low"].astype(float) + high = df["high"].astype(float) + _, _, hist = macd(close, fast=fast, slow=slow, signal=signal_span) + + low_pivots = find_confirmed_pivots(low, order, "low") + high_pivots = find_confirmed_pivots(high, order, "high") + signals: list[TechniqueSignal] = [] + + for pivot_idx, _ in detect_bullish_divergence( + low_pivots, hist, min_bars_between=min_bars, max_bars_between=max_bars, + ): + confirm_idx = pivot_idx + order + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, float(close.iloc[confirm_idx]), "buy", + "macd_bull_divergence", pivot_bar_index=pivot_idx, confidence=0.77, + ) + ) + + for pivot_idx, _ in detect_bearish_divergence( + high_pivots, hist, min_bars_between=min_bars, max_bars_between=max_bars, + ): + confirm_idx = pivot_idx + order + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, float(close.iloc[confirm_idx]), "sell", + "macd_bear_divergence", pivot_bar_index=pivot_idx, confidence=0.77, + ) + ) + + return dedupe_signals(signals, min_bars=min_bars) diff --git a/src/deepcoin/techniques/minor_swing.py b/src/deepcoin/techniques/minor_swing.py new file mode 100644 index 0000000..c4525e3 --- /dev/null +++ b/src/deepcoin/techniques/minor_swing.py @@ -0,0 +1,67 @@ +"""소형 ZigZag + 국소 극값 하이브리드.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.local_extrema import LocalExtremaTechnique +from deepcoin.techniques.zigzag_causal import find_causal_zigzag_signals + + +class MinorSwingTechnique(BaseTechnique): + """2.5% 인과 ZigZag와 국소 극값을 결합한 하이브리드.""" + + technique_id = "minor_swing" + technique_name = "소형 스윙 하이브리드" + category = "hybrid" + causal = True + description = "소형 ZigZag(2.5%) + 국소 극값 — GT 중간 눌림목 보완" + + def default_extra_params(self) -> dict: + return { + "reversal_pct": 2.5, + "order": 15, + "min_swing_pct": 2.0, + "min_bars_between": 20, + } + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + reversal_pct = float(params.extra.get("reversal_pct", 2.5)) + zz_signals = find_causal_zigzag_signals(df, reversal_pct=reversal_pct) + + local_params = TechniqueParams( + interval_min=params.interval_min, + lookback_days=params.lookback_days, + min_leg_pct=params.min_leg_pct, + initial_cash_krw=params.initial_cash_krw, + fee_rate=params.fee_rate, + extra={ + "order": int(params.extra.get("order", 15)), + "min_swing_pct": float(params.extra.get("min_swing_pct", 2.0)), + "min_bars_between": int(params.extra.get("min_bars_between", 20)), + }, + ) + local_signals = LocalExtremaTechnique().generate_signals(df, local_params) + return _merge_signals(zz_signals + local_signals, min_bars=20) + + +def _merge_signals(signals: list[TechniqueSignal], min_bars: int) -> list[TechniqueSignal]: + """방향별 근접 신호를 병합한다.""" + if not signals: + return [] + + sorted_signals = sorted(signals, key=lambda s: s.bar_index) + merged: list[TechniqueSignal] = [sorted_signals[0]] + + for signal in sorted_signals[1:]: + last = merged[-1] + if signal.side == last.side and signal.bar_index - last.bar_index < min_bars: + if signal.side == "buy" and signal.price < last.price: + merged[-1] = signal + elif signal.side == "sell" and signal.price > last.price: + merged[-1] = signal + else: + merged.append(signal) + + return sorted(merged, key=lambda s: s.bar_index) diff --git a/src/deepcoin/techniques/obv_divergence.py b/src/deepcoin/techniques/obv_divergence.py new file mode 100644 index 0000000..bb9b9f4 --- /dev/null +++ b/src/deepcoin/techniques/obv_divergence.py @@ -0,0 +1,71 @@ +"""OBV 다이버전스 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import ( + dedupe_signals, + detect_bearish_divergence, + detect_bullish_divergence, + find_confirmed_pivots, + make_signal, +) +from deepcoin.techniques.indicators import obv + + +class ObvDivergenceTechnique(BaseTechnique): + """OBV 상승·하락 다이버전스.""" + + technique_id = "obv_divergence" + technique_name = "OBV 다이버전스" + category = "divergence" + causal = True + description = "OBV 상승(Bd)·하락(Sd) 다이버전스" + + def default_extra_params(self) -> dict: + return {"order": 12, "min_bars_between": 15, "max_bars_between": 400} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + order = int(params.extra.get("order", 12)) + min_bars = int(params.extra.get("min_bars_between", 15)) + max_bars = int(params.extra.get("max_bars_between", 400)) + + close = df["close"].astype(float) + low = df["low"].astype(float) + high = df["high"].astype(float) + volume = df["volume"].astype(float) + obv_vals = obv(close, volume) + + low_pivots = find_confirmed_pivots(low, order, "low") + high_pivots = find_confirmed_pivots(high, order, "high") + signals: list[TechniqueSignal] = [] + + for pivot_idx, _ in detect_bullish_divergence( + low_pivots, obv_vals, min_bars_between=min_bars, max_bars_between=max_bars, + ): + confirm_idx = pivot_idx + order + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, float(close.iloc[confirm_idx]), "buy", + "obv_bull_divergence", pivot_bar_index=pivot_idx, confidence=0.76, + ) + ) + + for pivot_idx, _ in detect_bearish_divergence( + high_pivots, obv_vals, min_bars_between=min_bars, max_bars_between=max_bars, + ): + confirm_idx = pivot_idx + order + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, float(close.iloc[confirm_idx]), "sell", + "obv_bear_divergence", pivot_bar_index=pivot_idx, confidence=0.76, + ) + ) + + return dedupe_signals(signals, min_bars=min_bars) diff --git a/src/deepcoin/techniques/parabolic_sar_signal.py b/src/deepcoin/techniques/parabolic_sar_signal.py new file mode 100644 index 0000000..8570988 --- /dev/null +++ b/src/deepcoin/techniques/parabolic_sar_signal.py @@ -0,0 +1,46 @@ +"""Parabolic SAR 추세 전환 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import parabolic_sar + + +class ParabolicSarTechnique(BaseTechnique): + """SAR가 가격 위·아래 전환 시 매도·매수.""" + + technique_id = "parabolic_sar" + technique_name = "Parabolic SAR" + category = "trend" + causal = True + description = "Parabolic SAR 추세 전환 신호" + + def default_extra_params(self) -> dict: + return {"af_step": 0.02, "af_max": 0.2} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + sar = parabolic_sar(high, low, close) + + signals: list[TechniqueSignal] = [] + + for i in range(2, len(df)): + if pd.isna(sar.iloc[i]) or pd.isna(sar.iloc[i - 1]): + continue + + prev_c = float(close.iloc[i - 1]) + c = float(close.iloc[i]) + prev_sar = float(sar.iloc[i - 1]) + curr_sar = float(sar.iloc[i]) + + if prev_c <= prev_sar and c > curr_sar: + signals.append(make_signal(df, i, c, "buy", "psar_bull_flip", confidence=0.65)) + elif prev_c >= prev_sar and c < curr_sar: + signals.append(make_signal(df, i, c, "sell", "psar_bear_flip", confidence=0.65)) + + return signals diff --git a/src/deepcoin/techniques/pivot_points.py b/src/deepcoin/techniques/pivot_points.py new file mode 100644 index 0000000..0d78150 --- /dev/null +++ b/src/deepcoin/techniques/pivot_points.py @@ -0,0 +1,60 @@ +"""롤링 피벗 포인트 반등 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import rolling_pivot_points + + +class PivotPointsTechnique(BaseTechnique): + """S1 지지 반등 매수, R1 저항 거부 매도.""" + + technique_id = "pivot_points" + technique_name = "피벗 포인트" + category = "structure" + causal = True + description = "롤링 피벗 S1/R1 반전" + + def default_extra_params(self) -> dict: + return {"window": 60, "touch_pct": 0.2} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + window = int(params.extra.get("window", 60)) + touch_pct = float(params.extra.get("touch_pct", 0.2)) / 100.0 + + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + _, s1, r1 = rolling_pivot_points(high, low, close, window=window) + + signals: list[TechniqueSignal] = [] + touched_s1 = False + touched_r1 = False + + for i in range(window + 1, len(df)): + if pd.isna(s1.iloc[i]) or pd.isna(r1.iloc[i]): + continue + + c = float(close.iloc[i]) + l = float(low.iloc[i]) + h = float(high.iloc[i]) + s1_i = float(s1.iloc[i]) + r1_i = float(r1.iloc[i]) + prev_c = float(close.iloc[i - 1]) + + if l <= s1_i * (1 + touch_pct): + touched_s1 = True + if touched_s1 and prev_c <= s1_i and c > s1_i: + signals.append(make_signal(df, i, c, "buy", "pivot_s1_bounce", confidence=0.67)) + touched_s1 = False + + if h >= r1_i * (1 - touch_pct): + touched_r1 = True + if touched_r1 and prev_c >= r1_i and c < r1_i: + signals.append(make_signal(df, i, c, "sell", "pivot_r1_reject", confidence=0.67)) + touched_r1 = False + + return signals diff --git a/src/deepcoin/techniques/pivot_swing.py b/src/deepcoin/techniques/pivot_swing.py new file mode 100644 index 0000000..ba5fdba --- /dev/null +++ b/src/deepcoin/techniques/pivot_swing.py @@ -0,0 +1,52 @@ +"""피벗 스윙 고저점 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import dedupe_signals, find_confirmed_pivots, make_signal + + +class PivotSwingTechnique(BaseTechnique): + """좌우 N봉 피벗 저점·고점 확정 시 매수·매도.""" + + technique_id = "pivot_swing" + technique_name = "피벗 스윙" + category = "swing" + causal = True + description = "피벗 저점 매수·고점 매도 (스윙 B/S)" + + def default_extra_params(self) -> dict: + return {"order": 12, "min_bars_between": 24} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + order = int(params.extra.get("order", 12)) + min_bars = int(params.extra.get("min_bars_between", 24)) + low = df["low"].astype(float) + high = df["high"].astype(float) + signals: list[TechniqueSignal] = [] + + for pivot_idx, pivot_val in find_confirmed_pivots(low, order, "low"): + confirm_idx = pivot_idx + order + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, pivot_val, "buy", + "pivot_low", pivot_bar_index=pivot_idx, confidence=0.75, + ) + ) + + for pivot_idx, pivot_val in find_confirmed_pivots(high, order, "high"): + confirm_idx = pivot_idx + order + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, pivot_val, "sell", + "pivot_high", pivot_bar_index=pivot_idx, confidence=0.75, + ) + ) + + return dedupe_signals(signals, min_bars=min_bars) diff --git a/src/deepcoin/techniques/range_breakout.py b/src/deepcoin/techniques/range_breakout.py new file mode 100644 index 0000000..d028ce1 --- /dev/null +++ b/src/deepcoin/techniques/range_breakout.py @@ -0,0 +1,49 @@ +"""레인지 돌파 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal + + +class RangeBreakoutTechnique(BaseTechnique): + """N봉 고가·저가 레인지 상향·하향 돌파.""" + + technique_id = "range_breakout" + technique_name = "레인지 돌파" + category = "breakout" + causal = True + description = "N봉 레인지 상·하단 돌파 (B^)" + + def default_extra_params(self) -> dict: + return {"window": 40, "buffer_pct": 0.1} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + window = int(params.extra.get("window", 40)) + buffer_pct = float(params.extra.get("buffer_pct", 0.1)) / 100.0 + + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + range_high = high.shift(1).rolling(window).max() + range_low = low.shift(1).rolling(window).min() + + signals: list[TechniqueSignal] = [] + + for i in range(window + 1, len(df)): + if pd.isna(range_high.iloc[i]) or pd.isna(range_low.iloc[i]): + continue + + c = float(close.iloc[i]) + prev_c = float(close.iloc[i - 1]) + rh = float(range_high.iloc[i]) + rl = float(range_low.iloc[i]) + + if prev_c <= rh and c > rh * (1 + buffer_pct): + signals.append(make_signal(df, i, c, "buy", "range_breakout_up", confidence=0.73)) + elif prev_c >= rl and c < rl * (1 - buffer_pct): + signals.append(make_signal(df, i, c, "sell", "range_breakout_down", confidence=0.73)) + + return signals diff --git a/src/deepcoin/techniques/registry.py b/src/deepcoin/techniques/registry.py new file mode 100644 index 0000000..c28fc29 --- /dev/null +++ b/src/deepcoin/techniques/registry.py @@ -0,0 +1,139 @@ +"""매매 기법 레지스트리.""" + +from __future__ import annotations + +from deepcoin.techniques.base import BaseTechnique +from deepcoin.techniques.adx_trend import AdxTrendTechnique +from deepcoin.techniques.atr_channel import AtrChannelTechnique +from deepcoin.techniques.bb_reversal import BbReversalTechnique +from deepcoin.techniques.bb_squeeze_breakout import BbSqueezeBreakoutTechnique +from deepcoin.techniques.cci_extreme import CciExtremeTechnique +from deepcoin.techniques.composite_breakout import CompositeBreakoutTechnique +from deepcoin.techniques.composite_divergence import CompositeDivergenceTechnique +from deepcoin.techniques.composite_full import CompositeFullTechnique +from deepcoin.techniques.composite_pullback import CompositePullbackTechnique +from deepcoin.techniques.composite_swing import CompositeSwingTechnique +from deepcoin.techniques.composite_v3 import CompositeV3Technique +from deepcoin.techniques.donchian import DonchianTechnique +from deepcoin.techniques.ema_pullback import EmaPullbackTechnique +from deepcoin.techniques.fib_pullback import FibPullbackTechnique +from deepcoin.techniques.fractal_swing import FractalSwingTechnique +from deepcoin.techniques.ichimoku_trend import IchimokuTrendTechnique +from deepcoin.techniques.keltner_breakout import KeltnerBreakoutTechnique +from deepcoin.techniques.keltner_reversal import KeltnerReversalTechnique +from deepcoin.techniques.local_extrema import LocalExtremaTechnique +from deepcoin.techniques.ma_cross import MaCrossTechnique +from deepcoin.techniques.macd_cross import MacdCrossTechnique +from deepcoin.techniques.macd_divergence import MacdDivergenceTechnique +from deepcoin.techniques.minor_swing import MinorSwingTechnique +from deepcoin.techniques.obv_divergence import ObvDivergenceTechnique +from deepcoin.techniques.parabolic_sar_signal import ParabolicSarTechnique +from deepcoin.techniques.pivot_points import PivotPointsTechnique +from deepcoin.techniques.pivot_swing import PivotSwingTechnique +from deepcoin.techniques.range_breakout import RangeBreakoutTechnique +from deepcoin.techniques.roc_reversal import RocReversalTechnique +from deepcoin.techniques.rsi_divergence import RsiDivergenceTechnique +from deepcoin.techniques.rsi_swing import RsiSwingTechnique +from deepcoin.techniques.stochastic_cross import StochasticCrossTechnique +from deepcoin.techniques.supertrend_signal import SupertrendTechnique +from deepcoin.techniques.support_bounce import SupportBounceTechnique +from deepcoin.techniques.support_resistance import SupportResistanceTechnique +from deepcoin.techniques.swing_failure import SwingFailureTechnique +from deepcoin.techniques.volume_breakout import VolumeBreakoutTechnique +from deepcoin.techniques.volume_spike import VolumeSpikeTechnique +from deepcoin.techniques.zigzag_causal import ZigzagCausalTechnique + +# 카테고리별 단일 기법 (인과, 미래 데이터 미사용) +_SINGLE_TECHNIQUES: list[BaseTechnique] = [ + # swing + ZigzagCausalTechnique(), + MinorSwingTechnique(), + LocalExtremaTechnique(), + PivotSwingTechnique(), + FractalSwingTechnique(), + SwingFailureTechnique(), + DonchianTechnique(), + # pullback + EmaPullbackTechnique(), + FibPullbackTechnique(), + SupportBounceTechnique(), + # breakout + KeltnerBreakoutTechnique(), + RangeBreakoutTechnique(), + VolumeBreakoutTechnique(), + BbSqueezeBreakoutTechnique(), + # divergence + RsiDivergenceTechnique(), + MacdDivergenceTechnique(), + ObvDivergenceTechnique(), + # indicator (기존) + BbReversalTechnique(), + MaCrossTechnique(), + RsiSwingTechnique(), + MacdCrossTechnique(), + # trend + SupertrendTechnique(), + AdxTrendTechnique(), + IchimokuTrendTechnique(), + ParabolicSarTechnique(), + # momentum + StochasticCrossTechnique(), + CciExtremeTechnique(), + RocReversalTechnique(), + # volatility + KeltnerReversalTechnique(), + AtrChannelTechnique(), + # structure + PivotPointsTechnique(), + SupportResistanceTechnique(), + # volume + VolumeSpikeTechnique(), +] + +# 복합 기법 +_COMPOSITE_TECHNIQUES: list[BaseTechnique] = [ + CompositeSwingTechnique(), + CompositePullbackTechnique(), + CompositeBreakoutTechnique(), + CompositeDivergenceTechnique(), + CompositeV3Technique(), + CompositeFullTechnique(), +] + +_ALL_TECHNIQUES: list[BaseTechnique] = _SINGLE_TECHNIQUES + _COMPOSITE_TECHNIQUES + + +def get_single_techniques() -> list[BaseTechnique]: + """복합 제외 단일 기법 목록을 반환한다.""" + return list(_SINGLE_TECHNIQUES) + + +def get_composite_techniques() -> list[BaseTechnique]: + """복합 기법 목록을 반환한다.""" + return list(_COMPOSITE_TECHNIQUES) + + +def get_all_techniques() -> list[BaseTechnique]: + """등록된 모든 매매 기법을 반환한다.""" + return list(_ALL_TECHNIQUES) + + +def get_technique(technique_id: str) -> BaseTechnique | None: + """ID로 기법을 조회한다.""" + for technique in _ALL_TECHNIQUES: + if technique.technique_id == technique_id: + return technique + return None + + +def list_technique_ids() -> list[str]: + """기법 ID 목록을 반환한다.""" + return [t.technique_id for t in _ALL_TECHNIQUES] + + +def techniques_by_category() -> dict[str, list[str]]: + """카테고리별 기법 ID 목록을 반환한다.""" + result: dict[str, list[str]] = {} + for technique in _ALL_TECHNIQUES: + result.setdefault(technique.category, []).append(technique.technique_id) + return result diff --git a/src/deepcoin/techniques/roc_reversal.py b/src/deepcoin/techniques/roc_reversal.py new file mode 100644 index 0000000..d13963a --- /dev/null +++ b/src/deepcoin/techniques/roc_reversal.py @@ -0,0 +1,55 @@ +"""ROC 반전 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal, safe_float +from deepcoin.techniques.indicators import roc + + +class RocReversalTechnique(BaseTechnique): + """ROC 극단 하락 후 반등 매수, 극단 상승 후 하락 매도.""" + + technique_id = "roc_reversal" + technique_name = "ROC 반전" + category = "momentum" + causal = True + description = "ROC(12) 극값 반전 신호" + + def default_extra_params(self) -> dict: + return {"period": 12, "low_pct": -5.0, "high_pct": 5.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + period = int(params.extra.get("period", 12)) + low_pct = float(params.extra.get("low_pct", -5.0)) + high_pct = float(params.extra.get("high_pct", 5.0)) + + close = df["close"].astype(float) + roc_vals = roc(close, period=period) + + signals: list[TechniqueSignal] = [] + was_low = False + was_high = False + + for i in range(period + 2, len(df)): + curr = safe_float(roc_vals.iloc[i]) + prev = safe_float(roc_vals.iloc[i - 1]) + c = safe_float(close.iloc[i]) + if curr is None or prev is None or c is None: + continue + + if curr < low_pct: + was_low = True + if was_low and prev < low_pct <= curr: + signals.append(make_signal(df, i, c, "buy", "roc_bull_reversal", confidence=0.64)) + was_low = False + + if curr > high_pct: + was_high = True + if was_high and prev > high_pct >= curr: + signals.append(make_signal(df, i, c, "sell", "roc_bear_reversal", confidence=0.64)) + was_high = False + + return signals diff --git a/src/deepcoin/techniques/rsi_divergence.py b/src/deepcoin/techniques/rsi_divergence.py new file mode 100644 index 0000000..3def391 --- /dev/null +++ b/src/deepcoin/techniques/rsi_divergence.py @@ -0,0 +1,71 @@ +"""RSI 다이버전스 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import ( + dedupe_signals, + detect_bearish_divergence, + detect_bullish_divergence, + find_confirmed_pivots, + make_signal, +) +from deepcoin.techniques.indicators import rsi + + +class RsiDivergenceTechnique(BaseTechnique): + """RSI 상승·하락 다이버전스 매수·매도.""" + + technique_id = "rsi_divergence" + technique_name = "RSI 다이버전스" + category = "divergence" + causal = True + description = "RSI 상승(Bd)·하락(Sd) 다이버전스" + + def default_extra_params(self) -> dict: + return {"period": 14, "order": 12, "min_bars_between": 15, "max_bars_between": 400} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + period = int(params.extra.get("period", 14)) + order = int(params.extra.get("order", 12)) + min_bars = int(params.extra.get("min_bars_between", 15)) + max_bars = int(params.extra.get("max_bars_between", 400)) + + close = df["close"].astype(float) + low = df["low"].astype(float) + high = df["high"].astype(float) + rsi_vals = rsi(close, period=period) + + low_pivots = find_confirmed_pivots(low, order, "low") + high_pivots = find_confirmed_pivots(high, order, "high") + signals: list[TechniqueSignal] = [] + + for pivot_idx, _ in detect_bullish_divergence( + low_pivots, rsi_vals, min_bars_between=min_bars, max_bars_between=max_bars, + ): + confirm_idx = pivot_idx + order + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, float(close.iloc[confirm_idx]), "buy", + "rsi_bull_divergence", pivot_bar_index=pivot_idx, confidence=0.78, + ) + ) + + for pivot_idx, _ in detect_bearish_divergence( + high_pivots, rsi_vals, min_bars_between=min_bars, max_bars_between=max_bars, + ): + confirm_idx = pivot_idx + order + if confirm_idx >= len(df): + continue + signals.append( + make_signal( + df, confirm_idx, float(close.iloc[confirm_idx]), "sell", + "rsi_bear_divergence", pivot_bar_index=pivot_idx, confidence=0.78, + ) + ) + + return dedupe_signals(signals, min_bars=min_bars) diff --git a/src/deepcoin/techniques/rsi_swing.py b/src/deepcoin/techniques/rsi_swing.py new file mode 100644 index 0000000..acdb552 --- /dev/null +++ b/src/deepcoin/techniques/rsi_swing.py @@ -0,0 +1,74 @@ +"""RSI 과매도·과매수 스윙.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.indicators import rsi + + +class RsiSwingTechnique(BaseTechnique): + """RSI 30 이탈 후 복귀 시 매수, 70 이탈 후 복귀 시 매도.""" + + technique_id = "rsi_swing" + technique_name = "RSI 스윙" + category = "indicator" + causal = True + description = "RSI(14) 과매도 반등 매수·과매수 하락 매도" + + def default_extra_params(self) -> dict: + return {"period": 14, "oversold": 30.0, "overbought": 70.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + period = int(params.extra.get("period", 14)) + oversold = float(params.extra.get("oversold", 30.0)) + overbought = float(params.extra.get("overbought", 70.0)) + + close = df["close"].astype(float) + rsi_vals = rsi(close, period=period) + + signals: list[TechniqueSignal] = [] + was_oversold = False + was_overbought = False + + for i in range(period + 1, len(df)): + if pd.isna(rsi_vals.iloc[i]): + continue + + curr = float(rsi_vals.iloc[i]) + prev = float(rsi_vals.iloc[i - 1]) + price = float(close.iloc[i]) + dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S") + + if curr < oversold: + was_oversold = True + if was_oversold and prev < oversold <= curr: + signals.append( + TechniqueSignal( + side="buy", + bar_index=i, + price=round(price, 2), + datetime=dt, + confidence=0.65, + reason="rsi_oversold_exit", + ) + ) + was_oversold = False + + if curr > overbought: + was_overbought = True + if was_overbought and prev > overbought >= curr: + signals.append( + TechniqueSignal( + side="sell", + bar_index=i, + price=round(price, 2), + datetime=dt, + confidence=0.65, + reason="rsi_overbought_exit", + ) + ) + was_overbought = False + + return signals diff --git a/src/deepcoin/techniques/runner.py b/src/deepcoin/techniques/runner.py new file mode 100644 index 0000000..172b023 --- /dev/null +++ b/src/deepcoin/techniques/runner.py @@ -0,0 +1,206 @@ +"""매매 기법 실행 및 결과 저장.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any + +import pandas as pd + +logger = logging.getLogger(__name__) + +from deepcoin.data.candle_loader import load_candles +from deepcoin.data.intervals import interval_label +from deepcoin.evaluation.gt_align import align_with_ground_truth +from deepcoin.ground_truth.pnl import simulate_gt_pnl +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueResult +from deepcoin.techniques.legs import legs_to_signal_dicts, signals_to_legs, summarize_legs +from deepcoin.techniques.registry import get_all_techniques, get_technique + + +def run_technique( + technique: BaseTechnique, + df: pd.DataFrame, + params: TechniqueParams, + gt_result: dict[str, Any] | None = None, + tolerance_bars: int = 480, +) -> TechniqueResult: + """단일 기법을 실행하고 GT 정합을 계산한다. + + Args: + technique: 실행할 기법. + df: 캔들 DataFrame. + params: 실행 파라미터. + gt_result: Ground Truth JSON dict (정합 평가용). + tolerance_bars: GT 신호 매칭 허용 봉 수. + + Returns: + TechniqueResult. + """ + merged_extra = {**technique.default_extra_params(), **params.extra} + run_params = TechniqueParams( + interval_min=params.interval_min, + lookback_days=params.lookback_days, + min_leg_pct=params.min_leg_pct, + initial_cash_krw=params.initial_cash_krw, + fee_rate=params.fee_rate, + extra=merged_extra, + ) + + raw_signals = technique.generate_signals(df, run_params) + raw_signals = [s for s in raw_signals if s.price > 0] + legs = signals_to_legs(raw_signals, min_leg_pct=run_params.min_leg_pct) + summary = summarize_legs(legs) + pnl = simulate_gt_pnl( + legs, + initial_cash_krw=run_params.initial_cash_krw, + fee_rate=run_params.fee_rate, + ) + + alignment = None + if gt_result is not None: + alignment = align_with_ground_truth( + gt_result=gt_result, + technique_signals=[s.to_dict() for s in raw_signals], + technique_legs=legs, + tolerance_bars=tolerance_bars, + ) + + return TechniqueResult( + technique_id=technique.technique_id, + technique_name=technique.technique_name, + category=technique.category, + causal=technique.causal, + description=technique.description, + params={ + "interval_min": run_params.interval_min, + "lookback_days": run_params.lookback_days, + "min_leg_pct": run_params.min_leg_pct, + "initial_cash_krw": run_params.initial_cash_krw, + "fee_rate": run_params.fee_rate, + **merged_extra, + }, + signals=[s.to_dict() for s in raw_signals], + legs=legs, + summary=summary, + pnl=pnl, + alignment=alignment, + ) + + +def run_all_techniques( + db_path: Path, + symbol: str, + params: TechniqueParams, + gt_result: dict[str, Any] | None = None, + tolerance_bars: int = 480, + technique_ids: list[str] | None = None, + on_result: Callable[[TechniqueResult], None] | None = None, + skip_errors: bool = True, +) -> list[TechniqueResult]: + """등록된 기법을 일괄 실행한다. + + Args: + on_result: 기법 1건 완료 시 호출 (즉시 저장 등). + skip_errors: True면 실패 기법은 건너뛰고 계속 실행. + """ + df = load_candles( + db_path=db_path, + symbol=symbol, + interval_min=params.interval_min, + lookback_days=params.lookback_days, + ) + + techniques = get_all_techniques() + if technique_ids: + techniques = [t for t in techniques if t.technique_id in technique_ids] + + results: list[TechniqueResult] = [] + total = len(techniques) + for idx, technique in enumerate(techniques, start=1): + try: + result = run_technique( + technique=technique, + df=df, + params=params, + gt_result=gt_result, + tolerance_bars=tolerance_bars, + ) + except Exception: + logger.exception("기법 실행 실패: %s", technique.technique_id) + if not skip_errors: + raise + continue + results.append(result) + if on_result is not None: + on_result(result) + pct = idx / total * 100.0 if total else 100.0 + logger.info( + "기법 진행 %d/%d (%.1f%%) — %s", + idx, + total, + pct, + technique.technique_id, + ) + return results + + +def load_technique_result(path: Path) -> TechniqueResult: + """저장된 기법 JSON을 TechniqueResult로 로드한다.""" + with path.open(encoding="utf-8") as fp: + payload = json.load(fp) + return TechniqueResult( + technique_id=payload["technique_id"], + technique_name=payload["technique_name"], + category=payload["category"], + causal=payload["causal"], + description=payload.get("description", ""), + params=payload.get("params", {}), + signals=payload.get("signals", []), + legs=payload.get("legs", []), + summary=payload.get("summary", {}), + pnl=payload.get("pnl", {}), + alignment=payload.get("alignment"), + ) + + +def load_technique_results( + output_dir: Path, + technique_ids: list[str] | None = None, +) -> list[TechniqueResult]: + """저장된 기법 JSON 목록을 로드한다.""" + if technique_ids: + paths = [output_dir / f"{tid}.json" for tid in technique_ids] + else: + paths = sorted(output_dir.glob("*.json")) + + results: list[TechniqueResult] = [] + for path in paths: + if not path.exists(): + continue + results.append(load_technique_result(path)) + return results + + +def save_technique_result(result: TechniqueResult, output_dir: Path) -> Path: + """기법 결과를 JSON으로 저장한다.""" + output_dir.mkdir(parents=True, exist_ok=True) + out_path = output_dir / f"{result.technique_id}.json" + payload = result.to_dict() + payload["meta"] = { + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "interval_label": interval_label(int(result.params["interval_min"])), + } + with out_path.open("w", encoding="utf-8") as fp: + json.dump(payload, fp, ensure_ascii=False, indent=2) + return out_path + + +def load_ground_truth(gt_path: Path) -> dict[str, Any]: + """Ground Truth JSON을 로드한다.""" + with gt_path.open(encoding="utf-8") as fp: + return json.load(fp) diff --git a/src/deepcoin/techniques/stochastic_cross.py b/src/deepcoin/techniques/stochastic_cross.py new file mode 100644 index 0000000..cd9f9fb --- /dev/null +++ b/src/deepcoin/techniques/stochastic_cross.py @@ -0,0 +1,53 @@ +"""Stochastic 크로스 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import stochastic + + +class StochasticCrossTechnique(BaseTechnique): + """Stochastic %K가 %D 상향·하향 돌파 시 매수·매도.""" + + technique_id = "stochastic_cross" + technique_name = "Stochastic 크로스" + category = "momentum" + causal = True + description = "Stochastic(14,3) %K/%D 크로스" + + def default_extra_params(self) -> dict: + return {"k_period": 14, "d_period": 3, "oversold": 20.0, "overbought": 80.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + k_period = int(params.extra.get("k_period", 14)) + d_period = int(params.extra.get("d_period", 3)) + oversold = float(params.extra.get("oversold", 20.0)) + overbought = float(params.extra.get("overbought", 80.0)) + + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + pct_k, pct_d = stochastic(high, low, close, k_period=k_period, d_period=d_period) + + signals: list[TechniqueSignal] = [] + start = k_period + d_period + + for i in range(start + 1, len(df)): + if pd.isna(pct_k.iloc[i]) or pd.isna(pct_d.iloc[i]): + continue + + prev_k = float(pct_k.iloc[i - 1]) + prev_d = float(pct_d.iloc[i - 1]) + curr_k = float(pct_k.iloc[i]) + curr_d = float(pct_d.iloc[i]) + c = float(close.iloc[i]) + + if prev_k <= prev_d and curr_k > curr_d and curr_k < oversold + 15: + signals.append(make_signal(df, i, c, "buy", "stoch_bull_cross", confidence=0.66)) + elif prev_k >= prev_d and curr_k < curr_d and curr_k > overbought - 15: + signals.append(make_signal(df, i, c, "sell", "stoch_bear_cross", confidence=0.66)) + + return signals diff --git a/src/deepcoin/techniques/supertrend_signal.py b/src/deepcoin/techniques/supertrend_signal.py new file mode 100644 index 0000000..c2914c3 --- /dev/null +++ b/src/deepcoin/techniques/supertrend_signal.py @@ -0,0 +1,48 @@ +"""Supertrend 추세 전환 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal +from deepcoin.techniques.indicators import supertrend + + +class SupertrendTechnique(BaseTechnique): + """Supertrend 방향 전환 시 매수·매도.""" + + technique_id = "supertrend" + technique_name = "Supertrend" + category = "trend" + causal = True + description = "Supertrend 상승·하락 전환 신호" + + def default_extra_params(self) -> dict: + return {"period": 10, "multiplier": 3.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + period = int(params.extra.get("period", 10)) + multiplier = float(params.extra.get("multiplier", 3.0)) + + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + _, direction = supertrend(high, low, close, period=period, multiplier=multiplier) + + signals: list[TechniqueSignal] = [] + + for i in range(period + 2, len(df)): + if pd.isna(direction.iloc[i]) or pd.isna(direction.iloc[i - 1]): + continue + + prev_dir = float(direction.iloc[i - 1]) + curr_dir = float(direction.iloc[i]) + c = float(close.iloc[i]) + + if prev_dir < 0 < curr_dir: + signals.append(make_signal(df, i, c, "buy", "supertrend_bull_flip", confidence=0.68)) + elif prev_dir > 0 > curr_dir: + signals.append(make_signal(df, i, c, "sell", "supertrend_bear_flip", confidence=0.68)) + + return signals diff --git a/src/deepcoin/techniques/support_bounce.py b/src/deepcoin/techniques/support_bounce.py new file mode 100644 index 0000000..9cdb532 --- /dev/null +++ b/src/deepcoin/techniques/support_bounce.py @@ -0,0 +1,60 @@ +"""지지·저항 반등 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal + + +class SupportBounceTechnique(BaseTechnique): + """롤링 지지선 터치 후 반등 매수, 저항선 터치 후 하락 매도.""" + + technique_id = "support_bounce" + technique_name = "지지·저항 반등" + category = "pullback" + causal = True + description = "N봉 지지·저항 터치 후 반전 (B*)" + + def default_extra_params(self) -> dict: + return {"window": 80, "touch_pct": 0.3} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + window = int(params.extra.get("window", 80)) + touch_pct = float(params.extra.get("touch_pct", 0.3)) / 100.0 + + low = df["low"].astype(float) + high = df["high"].astype(float) + close = df["close"].astype(float) + support = low.shift(1).rolling(window).min() + resistance = high.shift(1).rolling(window).max() + + signals: list[TechniqueSignal] = [] + touched_support = False + touched_resistance = False + + for i in range(window + 1, len(df)): + if pd.isna(support.iloc[i]) or pd.isna(resistance.iloc[i]): + continue + + c = float(close.iloc[i]) + l = float(low.iloc[i]) + h = float(high.iloc[i]) + sup = float(support.iloc[i]) + res = float(resistance.iloc[i]) + prev_c = float(close.iloc[i - 1]) + + if l <= sup * (1 + touch_pct): + touched_support = True + if touched_support and prev_c <= sup and c > sup: + signals.append(make_signal(df, i, c, "buy", "support_bounce", confidence=0.71)) + touched_support = False + + if h >= res * (1 - touch_pct): + touched_resistance = True + if touched_resistance and prev_c >= res and c < res: + signals.append(make_signal(df, i, c, "sell", "resistance_reject", confidence=0.71)) + touched_resistance = False + + return signals diff --git a/src/deepcoin/techniques/support_resistance.py b/src/deepcoin/techniques/support_resistance.py new file mode 100644 index 0000000..c63cba7 --- /dev/null +++ b/src/deepcoin/techniques/support_resistance.py @@ -0,0 +1,64 @@ +"""스윙 기반 지지·저항 구조 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import dedupe_signals, find_confirmed_pivots, make_signal + + +class SupportResistanceTechnique(BaseTechnique): + """확정 스윙 고저점을 지지·저항으로 활용한 반전.""" + + technique_id = "support_resistance" + technique_name = "구조적 지지·저항" + category = "structure" + causal = True + description = "스윙 피벗 기반 S/R 반전" + + def default_extra_params(self) -> dict: + return {"order": 15, "touch_pct": 0.4, "max_age_bars": 500} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + order = int(params.extra.get("order", 15)) + touch_pct = float(params.extra.get("touch_pct", 0.4)) / 100.0 + max_age = int(params.extra.get("max_age_bars", 500)) + + low = df["low"].astype(float) + high = df["high"].astype(float) + close = df["close"].astype(float) + low_pivots = find_confirmed_pivots(low, order, "low") + high_pivots = find_confirmed_pivots(high, order, "high") + signals: list[TechniqueSignal] = [] + + for i in range(order * 2, len(df)): + c = float(close.iloc[i]) + l = float(low.iloc[i]) + h = float(high.iloc[i]) + + for pivot_idx, pivot_val in reversed(low_pivots): + if pivot_idx >= i or i - pivot_idx > max_age: + continue + if l <= pivot_val * (1 + touch_pct) and c > pivot_val: + signals.append( + make_signal( + df, i, c, "buy", "sr_support_bounce", + pivot_bar_index=pivot_idx, confidence=0.7, + ) + ) + break + + for pivot_idx, pivot_val in reversed(high_pivots): + if pivot_idx >= i or i - pivot_idx > max_age: + continue + if h >= pivot_val * (1 - touch_pct) and c < pivot_val: + signals.append( + make_signal( + df, i, c, "sell", "sr_resistance_reject", + pivot_bar_index=pivot_idx, confidence=0.7, + ) + ) + break + + return dedupe_signals(signals, min_bars=order) diff --git a/src/deepcoin/techniques/swing_failure.py b/src/deepcoin/techniques/swing_failure.py new file mode 100644 index 0000000..1b75a82 --- /dev/null +++ b/src/deepcoin/techniques/swing_failure.py @@ -0,0 +1,67 @@ +"""스윙 실패(페일드) 패턴 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import dedupe_signals, find_confirmed_pivots, make_signal + + +class SwingFailureTechnique(BaseTechnique): + """이전 스윙 고점 돌파 실패 후 매도, 저점 이탈 실패 후 매수.""" + + technique_id = "swing_failure" + technique_name = "스윙 실패" + category = "swing" + causal = True + description = "스윙 고저점 돌파 실패(페일드 브레이크아웃) 반전 신호" + + def default_extra_params(self) -> dict: + return {"order": 10, "min_bars_between": 30} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + order = int(params.extra.get("order", 10)) + min_bars = int(params.extra.get("min_bars_between", 30)) + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + high_pivots = find_confirmed_pivots(high, order, "high") + low_pivots = find_confirmed_pivots(low, order, "low") + signals: list[TechniqueSignal] = [] + + for i in range(1, len(high_pivots)): + prev_idx, prev_high = high_pivots[i - 1] + pivot_idx, pivot_high = high_pivots[i] + if pivot_high <= prev_high: + continue + search_start = pivot_idx + order + search_end = min(len(df), search_start + order * 6) + for j in range(search_start, search_end): + if float(high.iloc[j]) > prev_high and float(close.iloc[j]) < prev_high: + signals.append( + make_signal( + df, j, float(close.iloc[j]), "sell", + "swing_failure_high", pivot_bar_index=pivot_idx, confidence=0.72, + ) + ) + break + + for i in range(1, len(low_pivots)): + prev_idx, prev_low = low_pivots[i - 1] + pivot_idx, pivot_low = low_pivots[i] + if pivot_low >= prev_low: + continue + search_start = pivot_idx + order + search_end = min(len(df), search_start + order * 6) + for j in range(search_start, search_end): + if float(low.iloc[j]) < prev_low and float(close.iloc[j]) > prev_low: + signals.append( + make_signal( + df, j, float(close.iloc[j]), "buy", + "swing_failure_low", pivot_bar_index=pivot_idx, confidence=0.72, + ) + ) + break + + return dedupe_signals(signals, min_bars=min_bars) diff --git a/src/deepcoin/techniques/volume_breakout.py b/src/deepcoin/techniques/volume_breakout.py new file mode 100644 index 0000000..15349b5 --- /dev/null +++ b/src/deepcoin/techniques/volume_breakout.py @@ -0,0 +1,55 @@ +"""거래량 동반 돌파 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal + + +class VolumeBreakoutTechnique(BaseTechnique): + """거래량 급증과 함께 레인지 상·하단 돌파.""" + + technique_id = "volume_breakout" + technique_name = "거래량 돌파" + category = "breakout" + causal = True + description = "거래량 스파이크 + 레인지 돌파 (B^)" + + def default_extra_params(self) -> dict: + return {"window": 40, "vol_mult": 1.8} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + window = int(params.extra.get("window", 40)) + vol_mult = float(params.extra.get("vol_mult", 1.8)) + + high = df["high"].astype(float) + low = df["low"].astype(float) + close = df["close"].astype(float) + volume = df["volume"].astype(float) + range_high = high.shift(1).rolling(window).max() + range_low = low.shift(1).rolling(window).min() + vol_ma = volume.rolling(window).mean() + + signals: list[TechniqueSignal] = [] + + for i in range(window + 1, len(df)): + if pd.isna(range_high.iloc[i]) or pd.isna(vol_ma.iloc[i]): + continue + + c = float(close.iloc[i]) + v = float(volume.iloc[i]) + vma = float(vol_ma.iloc[i]) + if vma <= 0 or v < vma * vol_mult: + continue + + rh = float(range_high.iloc[i]) + rl = float(range_low.iloc[i]) + + if c > rh: + signals.append(make_signal(df, i, c, "buy", "volume_breakout_up", confidence=0.74)) + elif c < rl: + signals.append(make_signal(df, i, c, "sell", "volume_breakout_down", confidence=0.74)) + + return signals diff --git a/src/deepcoin/techniques/volume_spike.py b/src/deepcoin/techniques/volume_spike.py new file mode 100644 index 0000000..4307311 --- /dev/null +++ b/src/deepcoin/techniques/volume_spike.py @@ -0,0 +1,60 @@ +"""거래량 스파이크 반전 기법.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal +from deepcoin.techniques.helpers import make_signal + + +class VolumeSpikeTechnique(BaseTechnique): + """거래량 급증 봉의 방향 반전 신호.""" + + technique_id = "volume_spike" + technique_name = "거래량 스파이크" + category = "volume" + causal = True + description = "거래량 스파이크 후 반전 (클라이맥스)" + + def default_extra_params(self) -> dict: + return {"window": 30, "vol_mult": 2.5, "min_body_pct": 0.5} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + window = int(params.extra.get("window", 30)) + vol_mult = float(params.extra.get("vol_mult", 2.5)) + min_body_pct = float(params.extra.get("min_body_pct", 0.5)) / 100.0 + + open_ = df["open"].astype(float) + close = df["close"].astype(float) + volume = df["volume"].astype(float) + vol_ma = volume.rolling(window).mean() + + signals: list[TechniqueSignal] = [] + + for i in range(window + 1, len(df) - 1): + if pd.isna(vol_ma.iloc[i]): + continue + + v = float(volume.iloc[i]) + vma = float(vol_ma.iloc[i]) + if vma <= 0 or v < vma * vol_mult: + continue + + o = float(open_.iloc[i]) + c = float(close.iloc[i]) + body_pct = abs(c - o) / o if o > 0 else 0 + if body_pct < min_body_pct: + continue + + next_c = float(close.iloc[i + 1]) + if c < o and next_c > c: + signals.append( + make_signal(df, i + 1, next_c, "buy", "volume_climax_buy", confidence=0.63) + ) + elif c > o and next_c < c: + signals.append( + make_signal(df, i + 1, next_c, "sell", "volume_climax_sell", confidence=0.63) + ) + + return signals diff --git a/src/deepcoin/techniques/zigzag_causal.py b/src/deepcoin/techniques/zigzag_causal.py new file mode 100644 index 0000000..352add9 --- /dev/null +++ b/src/deepcoin/techniques/zigzag_causal.py @@ -0,0 +1,112 @@ +"""인과 ZigZag — 피벗 확정 시점에 매수·매도 신호.""" + +from __future__ import annotations + +import pandas as pd + +from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal + + +class ZigzagCausalTechnique(BaseTechnique): + """되돌림 % 확정 시 피벗을 인정하는 인과 ZigZag 기법. + + Ground Truth ZigZag와 동일 임계값을 쓰되, 미래 데이터 없이 + 피벗이 확정된 봉에서만 신호를 발생시킨다. + """ + + technique_id = "zigzag_causal" + technique_name = "인과 ZigZag" + category = "swing" + causal = True + description = "되돌림 % 확정 시 스윙 저점 매수·고점 매도 (GT ZigZag 인과 버전)" + + def default_extra_params(self) -> dict: + return {"reversal_pct": 5.0} + + def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]: + reversal_pct = float(params.extra.get("reversal_pct", 5.0)) + return find_causal_zigzag_signals(df, reversal_pct=reversal_pct) + + +def find_causal_zigzag_signals( + df: pd.DataFrame, + reversal_pct: float = 5.0, +) -> list[TechniqueSignal]: + """인과 ZigZag 신호를 생성한다. + + Args: + df: OHLCV DataFrame. + reversal_pct: 피벗 확정 최소 되돌림(%). + + Returns: + 시간순 신호 리스트. + """ + if len(df) < 3: + return [] + + threshold = reversal_pct / 100.0 + signals: list[TechniqueSignal] = [] + direction: str | None = None + extreme_idx = 0 + extreme_price = float(df.iloc[0]["close"]) + + for i in range(1, len(df)): + high = float(df.iloc[i]["high"]) + low = float(df.iloc[i]["low"]) + + if direction is None: + if high >= extreme_price * (1 + threshold): + direction = "up" + extreme_idx = i + extreme_price = high + elif low <= extreme_price * (1 - threshold): + direction = "down" + extreme_idx = i + extreme_price = low + continue + + if direction == "up": + if high >= extreme_price: + extreme_price = high + extreme_idx = i + if low <= extreme_price * (1 - threshold): + signals.append( + _make_signal(df, i, extreme_idx, extreme_price, "sell", reversal_pct) + ) + direction = "down" + extreme_idx = i + extreme_price = low + else: + if low <= extreme_price: + extreme_price = low + extreme_idx = i + if high >= extreme_price * (1 + threshold): + signals.append( + _make_signal(df, i, extreme_idx, extreme_price, "buy", reversal_pct) + ) + direction = "up" + extreme_idx = i + extreme_price = high + + return signals + + +def _make_signal( + df: pd.DataFrame, + confirm_idx: int, + pivot_idx: int, + pivot_price: float, + side: str, + reversal_pct: float, +) -> TechniqueSignal: + """확정 봉 기준 TechniqueSignal을 생성한다.""" + dt = pd.Timestamp(df.iloc[confirm_idx]["datetime"]).strftime("%Y-%m-%d %H:%M:%S") + return TechniqueSignal( + side=side, + bar_index=confirm_idx, + price=round(pivot_price, 2), + datetime=dt, + pivot_bar_index=pivot_idx, + confidence=min(1.0, reversal_pct / 10.0), + reason=f"zigzag_{side}_confirmed", + )