refactor: Git에서 데이터 제거, 설정·코드만 유지

파이프라인 산출물(data/, docs/)을 Git 추적에서 제외하고
히스토리를 단일 커밋으로 재구성해 저장소 용량을 경량화한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 10:01:43 +09:00
commit 741c949470
92 changed files with 12230 additions and 0 deletions

76
.env.example Normal file
View File

@@ -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

96
.gitignore vendored Normal file
View File

@@ -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

314
README.md Normal file
View File

@@ -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: 캔들 수집 모듈 초기 구현

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
python-dotenv==1.0.1
requests==2.32.3
pandas==2.2.3

9
scripts/00_download.py Normal file
View File

@@ -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__")

View File

@@ -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())

167
scripts/0_ground_truth.py Normal file
View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

169
scripts/2_run_causal_sim.py Normal file
View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

23
scripts/2_run_stage2_all.sh Executable file
View File

@@ -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"

158
scripts/2_run_techniques.py Normal file
View File

@@ -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())

3
src/deepcoin/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""DeepCoin — 빗썸 암호화폐 데이터 수집·분석."""
__version__ = "0.1.0"

View File

@@ -0,0 +1 @@
"""외부 API 클라이언트."""

134
src/deepcoin/api/bithumb.py Normal file
View File

@@ -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 []

262
src/deepcoin/config.py Normal file
View File

@@ -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",
)
),
)

View File

@@ -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",
]

View File

@@ -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"""
<tr class="benchmark">
<td>—</td>
<td>{benchmark.get('label', '1단계 GT sim')}</td>
<td>benchmark</td>
<td>{benchmark.get('final_equity_krw', 0):,.0f}</td>
<td><strong>{benchmark.get('total_return_pct', 0):+.2f}%</strong></td>
<td>{benchmark.get('buys_executed', 0)} / {benchmark.get('sells_executed', 0)}</td>
<td>—</td>
<td>—</td>
</tr>"""
table_rows = bench_row
for idx, row in enumerate(rows, start=1):
chart = row.get("chart_file", "")
table_rows += f"""
<tr>
<td>{idx}</td>
<td><a href="{chart}">{row['technique_name']}</a></td>
<td>{row['category']}</td>
<td>{row.get('final_equity_krw', 0):,.0f}</td>
<td>{row.get('sim_return_pct', 0):+.2f}%</td>
<td>{row.get('buys_executed', 0)} / {row.get('sells_executed', 0)}</td>
<td>{row.get('gt_align_score', 0)*100:.1f}</td>
<td><a href="{chart}">차트</a></td>
</tr>"""
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>DeepCoin 2단계 — 인과 sim</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; color: #333; background: #f5f5f5; }}
h1 {{ font-size: 20px; margin-bottom: 8px; }}
.meta {{ color: #666; margin-bottom: 16px; font-size: 14px; }}
table {{ border-collapse: collapse; width: 100%; background: #fff; font-size: 13px; }}
th, td {{ border: 1px solid #ddd; padding: 8px 10px; text-align: right; }}
th {{ background: #eee; text-align: center; }}
td:nth-child(1), td:nth-child(2), td:nth-child(3) {{ text-align: left; }}
tr.benchmark {{ background: #fff8e1; }}
a {{ color: #1565c0; text-decoration: none; }}
</style>
</head>
<body>
<h1>현물 2단계 — 인과 기법 sim</h1>
<p class="meta">
{report.get('symbol', '')} |
거래 기간: {report.get('sim_period_from', '')} ~ {report.get('sim_period_to', '')} |
생성: {report.get('generated_at', '')}
</p>
<p class="meta">{report.get('description', '')}</p>
<table>
<thead>
<tr>
<th>#</th>
<th>기법</th>
<th>카테고리</th>
<th>최종 평가(원)</th>
<th>수익률</th>
<th>체결(매수/매도)</th>
<th>GT정합</th>
<th>차트</th>
</tr>
</thead>
<tbody>
{table_rows}
</tbody>
</table>
</body>
</html>"""
html_path.write_text(html, encoding="utf-8")
return html_path

View File

@@ -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

View File

@@ -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"<tr>"
f"<td>{row.get('signal_label', '')}</td>"
f"<td>{row.get('timeframe', '')}</td>"
f"<td>{row.get('feature', '')}</td>"
f"<td>{row.get('cohens_d', '')}</td>"
f"<td>{row.get('positive_mean', '')}</td>"
f"<td>{row.get('negative_mean', '')}</td>"
f"<td>{row.get('positive_count', '')}</td>"
f"</tr>"
)
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"<li><b>{tf_label}</b>: {items}</li>")
type_sections += (
f"<section><h3>{label} ({signal_type}) — {count}건</h3>"
f"<ul>{''.join(tf_bits) or '<li>데이터 없음</li>'}</ul></section>"
)
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>MTF GT v3 상관 분석</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; color: #333; background: #fafafa; }}
h1, h2, h3 {{ color: #222; }}
.meta {{ background: #fff; border: 1px solid #ddd; padding: 16px; border-radius: 4px; margin-bottom: 20px; }}
table {{ border-collapse: collapse; width: 100%; background: #fff; margin: 12px 0 24px; }}
th, td {{ border: 1px solid #eee; padding: 8px 10px; text-align: left; font-size: 13px; }}
th {{ background: #f0f0f0; }}
section {{ background: #fff; border: 1px solid #ddd; padding: 16px; margin-bottom: 16px; border-radius: 4px; }}
</style>
</head>
<body>
<h1>GT v3 · 멀티 TF 상태 상관 분석</h1>
<div class="meta">
<p><b>생성</b>: {report.get("generated_at", "")}</p>
<p><b>구간</b>: {analysis.get("period_from", "")} ~ {analysis.get("period_to", "")}
({analysis.get("lookback_days", "")}일)</p>
<p><b>GT 신호</b>: {gt.get("signals_in_period", 0)}건 · 스냅샷 {gt.get("snapshots_extracted", 0)}
· 음성 샘플 {analysis.get("negative_sample_count", 0)}건</p>
<p><b>TF</b>: {", ".join(t["label"] for t in analysis.get("timeframes", []))}</p>
<p>양성=GT v3 타점 시각의 인과 MTF 스냅샷 / 음성=동일 구간 랜덤 3분봉(±{analysis.get("negative_exclude_bars")}봉 제외)</p>
</div>
<h2>전역 피처 중요도 (|Cohen&apos;s d| 상위)</h2>
<table>
<thead>
<tr>
<th>신호 유형</th><th>TF</th><th>피처</th><th>Cohen d</th>
<th>GT 평균</th><th>음성 평균</th><th>GT n</th>
</tr>
</thead>
<tbody>{rank_rows or "<tr><td colspan='7'>없음</td></tr>"}</tbody>
</table>
<h2>신호 유형별 TF 요약</h2>
{type_sections or "<p>없음</p>"}
<p style="font-size:12px;color:#666;">상세 수치는 JSON 리포트 참조.</p>
</body>
</html>
"""
html_path.write_text(html, encoding="utf-8")
return html_path

View File

@@ -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"""
<tr>
<td>{idx}</td>
<td>{row['technique_name']}</td>
<td>{row['category']}</td>
<td>{'Y' if row['causal'] else 'N'}</td>
<td>{row['leg_count']}</td>
<td>{row['tech_return_pct']:+.1f}%</td>
<td>{row['buy_recall']*100:.1f}%</td>
<td>{row['sell_recall']*100:.1f}%</td>
<td>{row['leg_recall']*100:.1f}%</td>
<td>{row['return_capture_ratio']*100:.1f}%</td>
<td><strong>{row['score']*100:.1f}</strong></td>
</tr>"""
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>DeepCoin 2단계 — 인과 GT 정합</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; color: #333; background: #f5f5f5; }}
h1 {{ font-size: 20px; margin-bottom: 8px; }}
.meta {{ color: #666; margin-bottom: 20px; font-size: 14px; }}
table {{ border-collapse: collapse; width: 100%; background: #fff; }}
th, td {{ border: 1px solid #ddd; padding: 8px 10px; text-align: center; font-size: 13px; }}
th {{ background: #e8e8e8; }}
tr:nth-child(even) {{ background: #fafafa; }}
</style>
</head>
<body>
<h1>DeepCoin 2단계 — 인과 기법 Ground Truth 정합</h1>
<div class="meta">
생성: {report.get('generated_at', '')} |
{report.get('symbol', '')} |
GT: {gt.get('leg_count', 0)}레그, {gt.get('return_pct', 0):+.1f}% |
기간: 최근 {gt.get('lookback_days', 365)}
</div>
<table>
<thead>
<tr>
<th>순위</th><th>기법</th><th>유형</th><th>인과</th><th>레그</th>
<th>수익률</th><th>매수 Recall</th><th>매도 Recall</th><th>레그 Recall</th>
<th>수익 포착</th><th>종합 Score</th>
</tr>
</thead>
<tbody>{table_rows}
</tbody>
</table>
</body>
</html>"""
with html_path.open("w", encoding="utf-8") as fp:
fp.write(html)
return html_path

View File

@@ -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"<tr><td>{label}</td><td>{signal_type}</td><td>{count}</td></tr>"
best_table = ""
for row in best_rows:
primary = ", ".join(row.get("primary_techniques", []))
best_table += f"""
<tr>
<td>{row['signal_label']}</td>
<td>{row['gt_count']}</td>
<td>{primary}</td>
<td><strong>{row['best_technique_name']}</strong></td>
<td>{row['best_recall']*100:.1f}%</td>
<td>{row['best_avg_offset']:.1f}봉</td>
</tr>"""
tech_headers = "".join(
f"<th>{label.split('(')[0].strip()}</th>"
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"<td>{st['recall']*100:.0f}%{star}</td>"
else:
cells += "<td>-</td>"
tech_rows += f"""
<tr>
<td>{tech['technique_name']}</td>
<td>{tech['overall_score']*100:.1f}</td>
{cells}
</tr>"""
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>DeepCoin — v3 신호 유형별 GT 정합</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; color: #333; background: #f5f5f5; }}
h1 {{ font-size: 20px; margin-bottom: 4px; }}
h2 {{ font-size: 16px; margin-top: 28px; margin-bottom: 8px; }}
.meta {{ color: #666; margin-bottom: 20px; font-size: 14px; }}
table {{ border-collapse: collapse; width: 100%; background: #fff; margin-bottom: 16px; }}
th, td {{ border: 1px solid #ddd; padding: 8px 10px; text-align: center; font-size: 13px; }}
th {{ background: #e8e8e8; }}
tr:nth-child(even) {{ background: #fafafa; }}
.note {{ font-size: 12px; color: #666; }}
</style>
</head>
<body>
<h1>DeepCoin — v3 신호 유형별 Ground Truth 정합</h1>
<div class="meta">
생성: {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)}
</div>
<h2>GT 신호 유형 분포</h2>
<table>
<thead><tr><th>라벨</th><th>signal_type</th><th>건수</th></tr></thead>
<tbody>{count_cells}</tbody>
</table>
<h2>신호 유형별 최고 Recall 기법</h2>
<table>
<thead>
<tr><th>신호 유형</th><th>GT 건수</th><th>1차 기법</th><th>최고 기법</th><th>Recall</th><th>평균 오차</th></tr>
</thead>
<tbody>{best_table}</tbody>
</table>
<h2>기법 × 신호 유형 Recall 매트릭스</h2>
<p class="note">* = 해당 신호 유형 1차 정합 대상 기법</p>
<table>
<thead>
<tr><th>기법</th><th>종합 Score</th>{tech_headers}</tr>
</thead>
<tbody>{tech_rows}</tbody>
</table>
</body>
</html>"""
with html_path.open("w", encoding="utf-8") as fp:
fp.write(html)
return html_path

View File

@@ -0,0 +1 @@
"""Ground Truth — 사후 벤치마크 매수·매도 타점."""

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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 = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Futures Ground Truth Chart</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="__DATA_JS_NAME__"></script>
<style>
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
h1 { margin: 0 0 6px; font-size: 20px; }
.meta { font-size: 13px; color: #666; }
.legend { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; }
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
.toolbar-group:last-of-type { border-right: none; }
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
.toolbar button:hover { background: #f0f4f8; }
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
.toolbar button.home:hover { background: #1b5e20; }
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
#status { font-size: 12px; color: #888; margin-left: auto; }
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
#detail-wrap { margin: 0 24px 12px; display: none; }
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
__EXTRA_STYLES__
</style>
</head>
<body>
<header>
<h1 id="title">Futures Ground Truth Chart</h1>
<div class="meta" id="meta"></div>
<div class="legend" id="legend"></div>
</header>
__EXTRA_BODY__
<div class="toolbar">
<div class="toolbar-group">
<button id="btn-home" class="home" title="전체 화면으로 복귀">홈</button>
<button id="btn-prev-leg" title="이전 롱 레그">◀ 이전</button>
<button id="btn-next-leg" title="다음 롱 레그">다음 ▶</button>
<span class="leg-info" id="leg-info">타점 - / -</span>
</div>
<div class="toolbar-group">
<button id="btn-all" class="btn-period active">전체</button>
<button id="btn-365d" class="btn-period">1년</button>
<button id="btn-30d" class="btn-period">30일</button>
<button id="btn-7d" class="btn-period">7일</button>
<button id="btn-3d" class="btn-period">3일</button>
</div>
<div class="toolbar-group">
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
<button id="btn-toggle-detail" title="상세 캔들 패널">상세 패널</button>
</div>
<span id="status">데이터 로딩 중…</span>
</div>
<div id="overview"></div>
<div id="detail-wrap">
<h2 id="detail-title">상세 캔들</h2>
<div id="detail"></div>
</div>
<script>
let DATA = null;
let overviewPlot = null;
let detailChart = null;
let detailSeries = null;
let currentMode = "overview";
let currentLegIdx = 0;
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
const COLORS = {
longOpen: "#1565c0",
longClose: "#64b5f6",
shortOpen: "#c62828",
shortClose: "#ff8a65",
};
function fmtPrice(v) {
return Math.round(v).toLocaleString("ko-KR");
}
function measureTextWidth(text, font) {
if (!axisMeasureCtx) {
const c = document.createElement("canvas");
axisMeasureCtx = c.getContext("2d");
}
axisMeasureCtx.font = font;
return axisMeasureCtx.measureText(text).width;
}
function yAxisLabelWidth() {
const vals = DATA.close;
if (!vals || !vals.length) return 88;
const samples = new Set([vals[0], vals[vals.length - 1]]);
let lo = vals[0], hi = vals[0];
for (let i = 1; i < vals.length; i++) {
if (vals[i] < lo) lo = vals[i];
if (vals[i] > hi) hi = vals[i];
}
samples.add(lo);
samples.add(hi);
samples.add((lo + hi) / 2);
let maxW = 0;
samples.forEach(v => {
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
});
return Math.ceil(maxW) + 20;
}
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
if (signalType === "breakout") return "^";
if (signalType === "div_bull" || signalType === "div_bear") return "d";
return "";
}
function markerLabel(kind, m) {
const id = m.marker_id;
const sfx = markerSuffix(m.signal_type);
if (kind === "longOpen") return "L↑" + id + sfx;
if (kind === "longClose") return "L↓" + id + sfx;
if (kind === "shortOpen") return "S↓" + id + sfx;
return "S↑" + id + sfx;
}
function markerChartPrice(m) {
if (m.chart_price != null) return m.chart_price;
let lo = 0;
let hi = DATA.times.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (DATA.times[mid] < m.time) lo = mid + 1;
else hi = mid;
}
return DATA.close[lo];
}
function drawMarkerLabel(ctx, label, x, labelY, color) {
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
ctx.fillStyle = color;
ctx.fillText(label, lx, labelY);
ctx.textBaseline = "alphabetic";
}
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawTriangleOnLine(ctx, x, lineY, up, color) {
ctx.fillStyle = color;
ctx.beginPath();
if (up) {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY + ARROW_HEIGHT);
} else {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY - ARROW_HEIGHT);
}
ctx.closePath();
ctx.fill();
}
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const x = u.valToPos(m.time, "x", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const arrowUp = visualUp(up);
const stackIdx = m.stack_index || 0;
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
const anchorY = lineY + stackShift;
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
const labelY = arrowUp
? anchorY + ARROW_HEIGHT + LABEL_GAP
: anchorY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
function drawFuturesMarkers(u) {
if (!showMarkers) return;
drawSimStartMarker(u, DATA.sim_start_marker);
(DATA.long_open_markers || []).forEach(m =>
drawOneMarker(u, m, COLORS.longOpen, true, markerLabel("longOpen", m)));
(DATA.long_close_markers || []).forEach(m =>
drawOneMarker(u, m, COLORS.longClose, false, markerLabel("longClose", m)));
(DATA.short_open_markers || []).forEach(m =>
drawOneMarker(u, m, COLORS.shortOpen, false, markerLabel("shortOpen", m)));
(DATA.short_close_markers || []).forEach(m =>
drawOneMarker(u, m, COLORS.shortClose, true, markerLabel("shortClose", m)));
}
function drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
const arrowUp = visualUp(false);
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = arrowUp
? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
function updateLegInfo() {
const total = (DATA.long_open_markers || []).length;
const el = document.getElementById("leg-info");
if (!total) { el.textContent = "타점 없음"; return; }
el.textContent = `롱 레그 ${currentLegIdx + 1} / ${total}`;
}
function overviewXRange() {
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
const s = overviewPlot.scales.x;
return { min: s.min, max: s.max };
}
function setOverviewXRange(min, max) {
const t0 = DATA.times[0];
const t1 = DATA.times[DATA.times.length - 1];
overviewPlot.setScale("x", {
min: Math.max(t0, min),
max: Math.min(t1, max),
});
}
function fitOverview() {
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
}
function zoomOverview(factor) {
const { min, max } = overviewXRange();
const mid = (min + max) / 2;
const half = Math.max((max - min) * factor / 2, 3600);
setOverviewXRange(mid - half, mid + half);
}
function buildOverview(keepRange) {
const prev = keepRange ? overviewXRange() : null;
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
const yAxisW = yAxisLabelWidth();
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [40, 10, 40, 10],
scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [
{ gap: 6 },
{
side: 3,
size: yAxisW,
gap: 10,
font: AXIS_FONT,
values: (u, vals) => vals.map(v => fmtPrice(v)),
},
],
series: [{}, { label: "종가", stroke: "#1565c0", width: 1 }],
cursor: { drag: { x: true, y: false, setScale: true } },
hooks: { draw: [(u) => drawFuturesMarkers(u)] },
};
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
if (prev && keepRange) setOverviewXRange(prev.min, prev.max);
else fitOverview();
}
function sliceLastDays(days) {
const cutoff = DATA.times[DATA.times.length - 1] - days * 86400;
let start = 0;
for (let i = DATA.times.length - 1; i >= 0; i--) {
if (DATA.times[i] < cutoff) { start = i + 1; break; }
}
return { start, end: DATA.times.length };
}
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
const priceAxisW = yAxisLabelWidth();
detailChart = LightweightCharts.createChart(wrap, {
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
rightPriceScale: { visible: false },
leftPriceScale: {
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
height: 360,
});
detailSeries = detailChart.addCandlestickSeries({
priceScaleId: "left",
upColor: "#c62828", downColor: "#1565c0",
borderUpColor: "#c62828", borderDownColor: "#1565c0",
wickUpColor: "#c62828", wickDownColor: "#1565c0",
});
const candles = [];
for (let i = startIdx; i < end; i++) {
candles.push({
time: DATA.times[i],
open: DATA.open[i], high: DATA.high[i],
low: DATA.low[i], close: DATA.close[i],
});
}
detailSeries.setData(candles);
const t0 = DATA.times[startIdx];
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
const kindUp = {
longOpen: true,
longClose: false,
shortOpen: false,
shortClose: true,
};
const pending = [];
[
[DATA.long_open_markers, "longOpen"],
[DATA.long_close_markers, "longClose"],
[DATA.short_open_markers, "shortOpen"],
[DATA.short_close_markers, "shortClose"],
].forEach(([list, kind]) => {
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
});
});
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
pending.forEach(({ m, kind }) => {
const arrowUp = visualUp(kindUp[kind]);
markers.push({
time: m.time,
position: arrowUp ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: arrowUp ? "arrowUp" : "arrowDown",
size: 10,
text: markerLabel(kind, m),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
detailChart.timeScale().fitContent();
}
function setActive(btnId) {
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
const el = document.getElementById(btnId);
if (el) el.classList.add("active");
}
function goHome() {
currentMode = "overview";
setActive("btn-all");
document.getElementById("overview").style.display = "block";
if (!overviewPlot) buildOverview(false);
else fitOverview();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
}
function nearestLongCloseAfter(openTime) {
let best = null;
for (const s of DATA.long_close_markers || []) {
if (s.time >= openTime && (!best || s.time < best.time)) best = s;
}
return best || (DATA.long_close_markers || [])[(DATA.long_close_markers || []).length - 1];
}
function jumpToLeg(idx) {
const opens = DATA.long_open_markers || [];
const total = opens.length;
if (!total) return;
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
updateLegInfo();
const lo = opens[currentLegIdx];
const lc = nearestLongCloseAfter(lo.time);
const span = lc ? Math.max(lc.time - lo.time, 86400) : 86400 * 3;
const pad = span * 0.4;
const vmin = lo.time - pad;
const vmax = (lc ? lc.time : lo.time) + pad;
currentMode = "overview";
setActive("btn-all");
document.getElementById("overview").style.display = "block";
if (!overviewPlot) buildOverview(false);
setOverviewXRange(vmin, vmax);
let start = 0;
for (let i = 0; i < DATA.times.length; i++) {
if (DATA.times[i] >= vmin) { start = i; break; }
}
let end = DATA.times.length;
for (let i = DATA.times.length - 1; i >= 0; i--) {
if (DATA.times[i] <= vmax) { end = i + 1; break; }
}
document.getElementById("detail-title").textContent =
`L↑${lo.marker_id} 상방 매수 — ${new Date(lo.time * 1000).toLocaleString("ko-KR")}`;
buildDetailCandles(start, end);
const closeText = lc ? ` → 상방 매도 ${fmtPrice(lc.price)}` : "";
document.getElementById("status").textContent =
`L↑${lo.marker_id} ${fmtPrice(lo.price)}${closeText}`;
}
function showPeriod(days, btnId, label) {
currentMode = "detail";
setActive(btnId);
detailVisible = true;
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
const { start } = sliceLastDays(days);
document.getElementById("detail-title").textContent =
`${label} 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
buildDetailCandles(start);
document.getElementById("overview").style.display = "block";
if (!overviewPlot) buildOverview(false);
const t0 = DATA.times[start];
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
document.getElementById("status").textContent = `${label} 구간 표시`;
}
function applyZoom(factor) {
if (currentMode === "detail" && detailChart) {
const ts = detailChart.timeScale();
const r = ts.getVisibleLogicalRange();
if (!r) return;
const mid = (r.from + r.to) / 2;
const half = Math.max((r.to - r.from) * factor / 2, 10);
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
} else if (overviewPlot) {
zoomOverview(factor);
}
}
function applyFit() {
if (currentMode === "detail" && detailChart) {
detailChart.timeScale().fitContent();
} else if (overviewPlot) {
fitOverview();
}
}
function toggleYFlip() {
yAxisInverted = !yAxisInverted;
const btn = document.getElementById("btn-flip-y");
btn.classList.toggle("active", yAxisInverted);
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
}
__EXTRA_SCRIPT__
function renderLegend() {
const items = [
[COLORS.longOpen, "L↑ 상방 매수 (롱 진입)"],
[COLORS.longClose, "L↓ 상방 매도 (롱 청산)"],
[COLORS.shortOpen, "S↓ 하방 매수 (숏 진입)"],
[COLORS.shortClose, "S↑ 하방 매도 (숏 청산)"],
];
document.getElementById("legend").innerHTML = items.map(([color, text]) =>
`<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${text}</span>`
).join("");
}
function init() {
DATA = window.CHART_DATA;
if (!DATA) throw new Error("차트 데이터 JS 없음");
const m = DATA.meta;
const chartDays = m.chart_lookback_days || m.lookback_days;
const gtDays = m.gt_lookback_days || m.lookback_days;
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
const simMode = !!DATA.sim_pnl;
const simSuffix = simMode ? (m.sim_stage_suffix || " · 1단계 sim") : "";
document.getElementById("title").textContent =
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}${simSuffix}`;
if (simMode) {
const panelTitle = document.getElementById("sim-panel-title");
if (panelTitle) {
const simDays = DATA.sim_pnl?.sim_lookback_days || m.sim_lookback_days || 1095;
const simLabel = simDays >= 365
? `최근 ${Math.round(simDays / 365)}년`
: `최근 ${simDays}일`;
const initCash = DATA.sim_pnl?.initial_cash_krw || 0;
const initLabel = initCash ? `${Math.round(initCash).toLocaleString()}원` : "";
panelTitle.textContent =
(m.sim_stage_title || "1단계 선물 sim (GT 사후 타점)") +
` (${simLabel}${initLabel ? ` · 초기 ${initLabel}` : ""})`;
}
}
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
const chartFrom = m.chart_data_from || m.data_from;
const chartTo = m.chart_data_to || m.data_to;
const lo = (DATA.long_open_markers || []).length;
const lc = (DATA.long_close_markers || []).length;
const so = (DATA.short_open_markers || []).length;
const sc = (DATA.short_close_markers || []).length;
const markerRange = simMode && m.sim_period_from
? `체결 L↑${lo}/L↓${lc} · S↓${so}/S↑${sc} · ${m.sim_period_from.slice(0, 16)} ~ ${(m.sim_period_to || chartTo).slice(0, 16)}`
: `GT ${gtLabel} | 현물 GT 타점 기반`;
const legendExtra = simMode ? " | ▼보라=거래시작" : "";
document.getElementById("meta").textContent =
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | ` +
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | ${markerRange}${legendExtra}`;
renderLegend();
if (simMode) renderSimPanel();
updateLegInfo();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
buildOverview(false);
document.getElementById("btn-home").onclick = goHome;
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
document.getElementById("btn-all").onclick = goHome;
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart);
};
document.getElementById("btn-toggle-detail").onclick = () => {
detailVisible = !detailVisible;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
if (detailVisible && !detailChart) {
const { start } = sliceLastDays(7);
buildDetailCandles(start);
}
};
document.getElementById("overview").addEventListener("dblclick", () => {
if (currentMode === "overview") fitOverview();
});
window.addEventListener("resize", () => {
if (overviewPlot) buildOverview(true);
});
}
try { init(); } catch (err) {
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
}
</script>
</body>
</html>"""
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__", "")
)

View File

@@ -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),
}

View File

@@ -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},
],
}

View File

@@ -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": [],
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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"))
),
}

161
src/deepcoin/mtf/filter.py Normal file
View File

@@ -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

View File

@@ -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,
}

255
src/deepcoin/mtf/rules.py Normal file
View File

@@ -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

86
src/deepcoin/mtf/store.py Normal file
View File

@@ -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)

View File

@@ -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"

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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",
)