feat(spot): 3단계 운영 파이프라인 — composite_v3 + MTF paper/live
MTF 필터 백테스트, paper/live 체결, 빗썸 Private API 연동 및 운영 스크립트·설계 문서를 추가해 2단계 전략을 실거래 단계에 연결한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
13
.env.example
13
.env.example
@@ -75,6 +75,19 @@ 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_JSON=docs/spot/2_analysis/causal_sim_report.json
|
||||||
CAUSAL_SIM_REPORT_HTML=docs/spot/2_analysis/causal_sim_report.html
|
CAUSAL_SIM_REPORT_HTML=docs/spot/2_analysis/causal_sim_report.html
|
||||||
|
|
||||||
|
# --- 현물 3단계: 운영 (기본 paper) ---
|
||||||
|
OPS_MODE=paper
|
||||||
|
OPS_TECHNIQUE_ID=composite_v3
|
||||||
|
# OPS_MIN_SCORE=3.5
|
||||||
|
OPS_MTF_ENABLED=true
|
||||||
|
OPS_TREND_GATE_ENABLED=true
|
||||||
|
OPS_DAILY_MAX_TRADES=20
|
||||||
|
OPS_MIN_ORDER_KRW=5000
|
||||||
|
OPS_SYNC_CANDLES=true
|
||||||
|
OPS_STATE_JSON=data/spot/operations/ops_state.json
|
||||||
|
OPS_REPORT_JSON=docs/spot/3_operations/ops_report.json
|
||||||
|
OPS_FILTERED_BACKTEST_JSON=docs/spot/3_operations/filtered_backtest_report.json
|
||||||
|
|
||||||
# 폴더 구조: data|docs / {common, spot, futures}
|
# 폴더 구조: data|docs / {common, spot, futures}
|
||||||
# common — coins.db 등 공유 리소스
|
# common — coins.db 등 공유 리소스
|
||||||
# spot — 현물 GT·기법·분석·운영
|
# spot — 현물 GT·기법·분석·운영
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,6 +17,9 @@
|
|||||||
/docs/spot/2_analysis/**
|
/docs/spot/2_analysis/**
|
||||||
!docs/spot/2_analysis/stage2_design_guide.md
|
!docs/spot/2_analysis/stage2_design_guide.md
|
||||||
!docs/spot/2_analysis/stage2_final_summary.md
|
!docs/spot/2_analysis/stage2_final_summary.md
|
||||||
|
!/docs/spot/3_operations/
|
||||||
|
/docs/spot/3_operations/**
|
||||||
|
!docs/spot/3_operations/stage3_design_guide.md
|
||||||
logs/
|
logs/
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -100,7 +100,7 @@ flowchart TD
|
|||||||
| 1 | **0단계** | spot | `0_ground_truth.py` | `data/spot/ground_truth/`, `docs/spot/0_ground_truth/` |
|
| 1 | **0단계** | spot | `0_ground_truth.py` | `data/spot/ground_truth/`, `docs/spot/0_ground_truth/` |
|
||||||
| 2 | **1단계** | spot | `1_ground_truth_sim.py` | `docs/spot/1_simulation/` |
|
| 2 | **1단계** | spot | `1_ground_truth_sim.py` | `docs/spot/1_simulation/` |
|
||||||
| 3 | **2단계** | spot | `2_run_*.py`, `2_run_stage2_all.sh` | `data/spot/techniques/`, `docs/spot/2_analysis/` |
|
| 3 | **2단계** | spot | `2_run_*.py`, `2_run_stage2_all.sh` | `data/spot/techniques/`, `docs/spot/2_analysis/` |
|
||||||
| 4 | **3단계** | spot | (예정) | `docs/spot/3_operations/` |
|
| 4 | **3단계** | spot | `3_run_*.py`, `3_run_stage3_all.sh` | `data/spot/operations/`, `docs/spot/3_operations/` |
|
||||||
| — | **0단계** | futures | `0_ground_truth_futures.py` | `data/futures/ground_truth/`, `docs/futures/0_ground_truth/` |
|
| — | **0단계** | futures | `0_ground_truth_futures.py` | `data/futures/ground_truth/`, `docs/futures/0_ground_truth/` |
|
||||||
|
|
||||||
### 권장 실행 명령 (현물 + 선물 0단계)
|
### 권장 실행 명령 (현물 + 선물 0단계)
|
||||||
@@ -125,8 +125,10 @@ bash scripts/2_run_stage2_all.sh
|
|||||||
# ── futures 0단계: 선물 GT (현물 GT 기반) ───────────────────
|
# ── futures 0단계: 선물 GT (현물 GT 기반) ───────────────────
|
||||||
python scripts/0_ground_truth_futures.py --tier all
|
python scripts/0_ground_truth_futures.py --tier all
|
||||||
|
|
||||||
# ── spot 3단계: 실거래 운영 (구현 예정) ───────────────────────
|
# ── spot 3단계: 운영 (기본 paper) ─────────────────────────────
|
||||||
# python scripts/3_execute_live.py
|
bash scripts/3_run_stage3_all.sh
|
||||||
|
# python scripts/3_run_operations.py --mode paper
|
||||||
|
# python scripts/3_run_operations.py --mode live # API 키 필요
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -176,14 +178,22 @@ GT 타점 완벽 추종 시 수익 상한선. 최근 3년·초기 20만 원.
|
|||||||
| 2-3 | `2_run_signal_type_align.py` | `docs/spot/2_analysis/signal_type_report.html` |
|
| 2-3 | `2_run_signal_type_align.py` | `docs/spot/2_analysis/signal_type_report.html` |
|
||||||
| 2-4 | `2_run_mtf_analysis.py` | `data/spot/mtf/mtf_rules_v3.json`, `docs/spot/2_analysis/mtf_correlation_report.html` |
|
| 2-4 | `2_run_mtf_analysis.py` | `data/spot/mtf/mtf_rules_v3.json`, `docs/spot/2_analysis/mtf_correlation_report.html` |
|
||||||
|
|
||||||
### spot 3단계 — 실거래 운영 (예정)
|
### spot 3단계 — 실거래 운영
|
||||||
|
|
||||||
2단계 검증 전략(`composite_v3` + MTF)을 빗썸 현물 API에 연결.
|
설계·운영 가이드: [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md)
|
||||||
|
|
||||||
| 항목 | 내용 |
|
`composite_v3` + MTF 필터 + 고TF 게이트. **기본 `OPS_MODE=paper`**.
|
||||||
|
|
||||||
|
| 순서 | 스크립트 | 산출물 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 3-1 | `3_run_filtered_backtest.py` | `filtered_backtest_report.json` |
|
||||||
|
| 3-2 | `3_run_operations.py` | `ops_report.json`, `ops_state.json` |
|
||||||
|
| 일괄 | `3_run_stage3_all.sh` | 위 전체 |
|
||||||
|
|
||||||
|
| 모드 | 설명 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 캔들 동기화 | `00_download.py` 증분 갱신 |
|
| `paper` | 모의 체결 (권장) |
|
||||||
| 산출물 | `docs/spot/3_operations/` (예정) |
|
| `live` | 빗썸 시장가 주문 (`BITHUMB_ACCESS_KEY` 필요) |
|
||||||
|
|
||||||
### futures 0단계 — 선물 GT
|
### futures 0단계 — 선물 GT
|
||||||
|
|
||||||
@@ -285,8 +295,7 @@ GT 타점 완벽 추종 시 수익 상한선. 최근 3년·초기 20만 원.
|
|||||||
| 유형 | 단계 | 상태 |
|
| 유형 | 단계 | 상태 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| common | 사전 (캔들) | 구현됨 |
|
| common | 사전 (캔들) | 구현됨 |
|
||||||
| spot | 0~2단계 | 구현됨 |
|
| spot | 0~3단계 | 구현됨 (3단계 기본 paper) |
|
||||||
| spot | 3단계 (운영) | 예정 |
|
|
||||||
| futures | 0단계 | 구현됨 |
|
| futures | 0단계 | 구현됨 |
|
||||||
| futures | 1~3단계 | 예정 |
|
| futures | 1~3단계 | 예정 |
|
||||||
|
|
||||||
|
|||||||
150
docs/spot/3_operations/stage3_design_guide.md
Normal file
150
docs/spot/3_operations/stage3_design_guide.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# 현물 3단계 설계 가이드 — 실거래 운영
|
||||||
|
|
||||||
|
> 2단계 검증 전략(`composite_v3` + MTF 필터)을 빗썸 현물에 연결하는 운영 단계
|
||||||
|
> 작성 기준: 2026-06-12 · 기본 모드: **paper**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan (계획)
|
||||||
|
|
||||||
|
### 목적
|
||||||
|
|
||||||
|
2단계에서 도출한 **인과 기법 + MTF 필터**를 실시간(또는 모의) 운영 파이프라인에 연결하고, **live 전환 전** 필터 효과를 백테스트로 재검증한다.
|
||||||
|
|
||||||
|
### 파이프라인
|
||||||
|
|
||||||
|
```
|
||||||
|
캔들 증분 동기화 (00_download.py)
|
||||||
|
↓
|
||||||
|
composite_v3 신호 생성 (3분봉)
|
||||||
|
↓
|
||||||
|
signal_type 추론 (기여 기법 → B/B*/B^/Bd/S/Sd)
|
||||||
|
↓
|
||||||
|
HtfTrendGate (60분·일봉 극단 차단)
|
||||||
|
↓
|
||||||
|
MtfSignalFilter (mtf_rules_v3.json)
|
||||||
|
↓
|
||||||
|
paper / live 체결 (구간별 매수 상한 동일)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 운영 모드
|
||||||
|
|
||||||
|
| 모드 | 설명 | 기본값 |
|
||||||
|
|------|------|--------|
|
||||||
|
| **paper** | DB 캔들·신호 가격 기준 모의 체결 | **권장·기본** |
|
||||||
|
| **live** | 빗썸 Private API 시장가 주문 | API 키 필요, 신중히 사용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Do (실행)
|
||||||
|
|
||||||
|
### 스크립트
|
||||||
|
|
||||||
|
| 순서 | 스크립트 | 역할 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 3-1 | `3_run_filtered_backtest.py` | MTF 필터 전/후 3년 sim 비교 |
|
||||||
|
| 3-2 | `3_run_operations.py` | paper/live 1회 tick (신호·체결) |
|
||||||
|
| 일괄 | `3_run_stage3_all.sh` | 3-1 + 3-2 paper |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd DeepCoin
|
||||||
|
export PYTHONPATH=src
|
||||||
|
|
||||||
|
# MTF 필터 백테스트
|
||||||
|
python scripts/3_run_filtered_backtest.py
|
||||||
|
|
||||||
|
# paper 운영 1회
|
||||||
|
python scripts/3_run_operations.py
|
||||||
|
|
||||||
|
# 일괄
|
||||||
|
bash scripts/3_run_stage3_all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 환경 변수
|
||||||
|
|
||||||
|
| 변수 | 설명 | 기본값 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `OPS_MODE` | `paper` / `live` | `paper` |
|
||||||
|
| `OPS_TECHNIQUE_ID` | 운영 기법 | `composite_v3` |
|
||||||
|
| `OPS_MIN_SCORE` | composite 최소 점수 (선택) | 기법 기본 2.5 |
|
||||||
|
| `OPS_MTF_ENABLED` | MTF 필터 | `true` |
|
||||||
|
| `OPS_TREND_GATE_ENABLED` | 고TF 게이트 | `true` |
|
||||||
|
| `OPS_DAILY_MAX_TRADES` | 일일 체결 상한 | `20` |
|
||||||
|
| `OPS_MIN_ORDER_KRW` | 최소 주문 원화 | `5000` |
|
||||||
|
| `OPS_STATE_JSON` | 운영 상태 | `data/spot/operations/ops_state.json` |
|
||||||
|
| `BITHUMB_ACCESS_KEY` | live API (선택) | — |
|
||||||
|
| `BITHUMB_SECRET_KEY` | live API (선택) | — |
|
||||||
|
|
||||||
|
### 산출물
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| `docs/spot/3_operations/filtered_backtest_report.json` | 필터 전/후 sim |
|
||||||
|
| `docs/spot/3_operations/ops_report.json` | 최근 tick 리포트 |
|
||||||
|
| `data/spot/operations/ops_state.json` | 포트폴리오·체결 이력 |
|
||||||
|
|
||||||
|
### 소스 모듈
|
||||||
|
|
||||||
|
| 모듈 | 경로 |
|
||||||
|
|------|------|
|
||||||
|
| 신호 파이프라인 | `src/deepcoin/operations/signal_pipeline.py` |
|
||||||
|
| signal_type 추론 | `src/deepcoin/operations/signal_type.py` |
|
||||||
|
| 체결 엔진 | `src/deepcoin/operations/trade_engine.py` |
|
||||||
|
| paper/live | `src/deepcoin/operations/executor.py` |
|
||||||
|
| 러너 | `src/deepcoin/operations/runner.py` |
|
||||||
|
| 빗썸 Private | `src/deepcoin/api/bithumb_private.py` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Check (검토)
|
||||||
|
|
||||||
|
### 초기 백테스트 결과 (BTC · 3년 · composite_v3)
|
||||||
|
|
||||||
|
| 구분 | 신호 수 | 3년 sim 수익률 |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| MTF 필터 **전** | 12,262 | **-97.5%** |
|
||||||
|
| MTF 필터 **후** | 1,215 | **+3.37%** |
|
||||||
|
|
||||||
|
MTF 필터가 composite_v3의 과다 신호·역추세 진입을 상당 부분 걸러냅니다. live 전환 전 paper 운영으로 추가 검증이 필요합니다.
|
||||||
|
|
||||||
|
### live 전환 전 체크리스트
|
||||||
|
|
||||||
|
- [ ] `3_run_filtered_backtest.py` — 필터 후 sim이 raw 대비 개선되는지 확인
|
||||||
|
- [ ] 최소 1주일 **paper** 운영 (`--loop 180` 등)
|
||||||
|
- [ ] `OPS_DAILY_MAX_TRADES`·`OPS_MIN_SCORE` 튜닝
|
||||||
|
- [ ] 빗썸 API 키 **출금 비활성**·IP 제한 설정
|
||||||
|
- [ ] 소액으로 live 테스트
|
||||||
|
|
||||||
|
### 2단계 대비 3단계 차이
|
||||||
|
|
||||||
|
| 항목 | 2단계 | 3단계 |
|
||||||
|
|------|-------|-------|
|
||||||
|
| 목적 | 기법 평가·순위 | **운영 연결** |
|
||||||
|
| 신호 | 39종 개별 | **composite_v3 + MTF** |
|
||||||
|
| 체결 | 일괄 sim | **tick 단위 paper/live** |
|
||||||
|
| MTF | 분석·규칙 | **실시간 필터** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Act (개선)
|
||||||
|
|
||||||
|
### 권장 튜닝
|
||||||
|
|
||||||
|
1. `OPS_MIN_SCORE` 상향 (예: 3.5~4.0) — 신호 과다·스킵 감소
|
||||||
|
2. `OPS_DAILY_MAX_TRADES` 하향 — 과매매 방지
|
||||||
|
3. 텔레그램 알림 연동 (선택)
|
||||||
|
4. 슬리피지 가정 paper 백테스트 확장
|
||||||
|
|
||||||
|
### 하지 말아야 할 것
|
||||||
|
|
||||||
|
- 백테스트 미검증 상태에서 **live 풀오토**
|
||||||
|
- 2단계 sim 1위(`fractal_swing`) 그대로 운영
|
||||||
|
- API 키를 Git에 커밋
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
| 날짜 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-06-12 | 3단계 초版 — paper/live 파이프라인, MTF 필터 백테스트, 운영 스크립트 |
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
pandas==2.2.3
|
pandas==2.2.3
|
||||||
|
PyJWT==2.10.1
|
||||||
|
|||||||
55
scripts/3_run_filtered_backtest.py
Normal file
55
scripts/3_run_filtered_backtest.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""3단계: MTF 필터 적용 composite_v3 백테스트 (최근 3년 sim)."""
|
||||||
|
|
||||||
|
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.operations.backtest import run_filtered_backtest, save_backtest_report
|
||||||
|
|
||||||
|
|
||||||
|
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="3단계: composite_v3 + MTF 필터 3년 백테스트",
|
||||||
|
)
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
_configure_logging(args.verbose)
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
report = run_filtered_backtest(settings)
|
||||||
|
path = save_backtest_report(report, settings.ops_filtered_backtest_json)
|
||||||
|
|
||||||
|
filt = report["filtered_sim"]
|
||||||
|
raw = report["raw_sim"]
|
||||||
|
print("\n=== 3단계 MTF 필터 백테스트 ===")
|
||||||
|
print(f"기법: {report['technique_id']}")
|
||||||
|
print(f"원시 신호: {report['raw_signal_count']} → 필터 통과: {report['filtered_signal_count']}")
|
||||||
|
print(f"원시 3년 sim: {raw.get('total_return_pct')}%")
|
||||||
|
print(f"필터 3년 sim: {filt.get('total_return_pct')}%")
|
||||||
|
print(f"개선: {report['improvement_return_pct']}%p")
|
||||||
|
print(f"\nJSON: {path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
94
scripts/3_run_operations.py
Normal file
94
scripts/3_run_operations.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""3단계: paper/live 운영 1회 tick (신호 확인·체결)."""
|
||||||
|
|
||||||
|
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.operations.runner import OperationsRunner
|
||||||
|
|
||||||
|
|
||||||
|
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="3단계: DeepCoin 운영 tick")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=("paper", "live"),
|
||||||
|
default=None,
|
||||||
|
help="OPS_MODE 덮어쓰기 (기본 .env)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-sync",
|
||||||
|
action="store_true",
|
||||||
|
help="캔들 증분 동기화 생략",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--loop",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
metavar="SEC",
|
||||||
|
help="N초마다 반복 실행 (0=1회)",
|
||||||
|
)
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
_configure_logging(args.verbose)
|
||||||
|
|
||||||
|
if args.mode:
|
||||||
|
import os
|
||||||
|
os.environ["OPS_MODE"] = args.mode
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
if settings.ops_mode == "live":
|
||||||
|
if not settings.bithumb_access_key or not settings.bithumb_secret_key:
|
||||||
|
print("live 모드에는 BITHUMB_ACCESS_KEY / BITHUMB_SECRET_KEY 가 필요합니다.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("경고: live 모드 — 실제 주문이 발생할 수 있습니다.")
|
||||||
|
|
||||||
|
runner = OperationsRunner(settings)
|
||||||
|
sync = not args.no_sync
|
||||||
|
|
||||||
|
while True:
|
||||||
|
report = runner.tick(sync_candles=sync)
|
||||||
|
port = report["portfolio"]
|
||||||
|
print("\n=== 3단계 운영 tick ===")
|
||||||
|
print(f"모드: {report['mode']}")
|
||||||
|
print(
|
||||||
|
f"최신 봉 후보: {report.get('latest_bar_candidates', 0)} · "
|
||||||
|
f"필터 통과: {report['filtered_signals']} · "
|
||||||
|
f"처리 bar: {report.get('pending_bars', [])}"
|
||||||
|
)
|
||||||
|
print(f"이번 체결: {len(report['executions'])}건")
|
||||||
|
print(
|
||||||
|
f"포트폴리오: 현금 {port['cash_krw']:,.0f}원 · "
|
||||||
|
f"코인 {port['coin_qty']:.8f} {settings.symbol}"
|
||||||
|
)
|
||||||
|
print(f"리포트: {settings.ops_report_json}")
|
||||||
|
|
||||||
|
if args.loop <= 0:
|
||||||
|
break
|
||||||
|
time.sleep(args.loop)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
15
scripts/3_run_stage3_all.sh
Executable file
15
scripts/3_run_stage3_all.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 3단계 일괄: MTF 필터 백테스트 → paper 운영 1회 tick
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export PYTHONPATH=src
|
||||||
|
|
||||||
|
echo "=== 3-1 MTF 필터 백테스트 ==="
|
||||||
|
python scripts/3_run_filtered_backtest.py
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 3-2 paper 운영 tick ==="
|
||||||
|
python scripts/3_run_operations.py --no-sync
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "완료. 리포트: docs/spot/3_operations/"
|
||||||
60
src/deepcoin/api/bithumb_auth.py
Normal file
60
src/deepcoin/api/bithumb_auth.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""빗썸 Private API JWT 인증."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
|
||||||
|
def build_jwt_token(
|
||||||
|
access_key: str,
|
||||||
|
secret_key: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""빗썸 v2.1 JWT Bearer 토큰을 생성한다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_key: API Access Key.
|
||||||
|
secret_key: API Secret Key.
|
||||||
|
params: POST/DELETE body 또는 query 파라미터. 있으면 query_hash 포함.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JWT 문자열 (Bearer 접두사 없음).
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"access_key": access_key,
|
||||||
|
"nonce": str(uuid.uuid4()),
|
||||||
|
"timestamp": round(time.time() * 1000),
|
||||||
|
}
|
||||||
|
if params:
|
||||||
|
query = urlencode(params, doseq=True).encode()
|
||||||
|
payload["query_hash"] = hashlib.sha512(query).hexdigest()
|
||||||
|
payload["query_hash_alg"] = "SHA512"
|
||||||
|
return jwt.encode(payload, secret_key, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers(
|
||||||
|
access_key: str,
|
||||||
|
secret_key: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Authorization 헤더 dict를 반환한다."""
|
||||||
|
token = build_jwt_token(access_key, secret_key, params=params)
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dumps_params(params: dict[str, Any]) -> str:
|
||||||
|
"""주문 body JSON 직렬화 (키 순서 유지)."""
|
||||||
|
return json.dumps(params, separators=(",", ":"))
|
||||||
143
src/deepcoin/api/bithumb_private.py
Normal file
143
src/deepcoin/api/bithumb_private.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""빗썸 Private REST API — 잔고·주문."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from deepcoin.api.bithumb_auth import auth_headers, dumps_params
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BithumbPrivateClient:
|
||||||
|
"""빗썸 v2.1 Private API 클라이언트."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
access_key: str,
|
||||||
|
secret_key: str,
|
||||||
|
base_url: str = "https://api.bithumb.com",
|
||||||
|
sleep_sec: float = 0.35,
|
||||||
|
retries: int = 3,
|
||||||
|
) -> None:
|
||||||
|
"""클라이언트를 초기화한다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_key: API Access Key.
|
||||||
|
secret_key: API Secret Key.
|
||||||
|
base_url: API 베이스 URL.
|
||||||
|
sleep_sec: 연속 요청 간 대기(초).
|
||||||
|
retries: 실패 시 재시도 횟수.
|
||||||
|
"""
|
||||||
|
if not access_key or not secret_key:
|
||||||
|
raise ValueError("BITHUMB_ACCESS_KEY / BITHUMB_SECRET_KEY 가 필요합니다.")
|
||||||
|
self.access_key = access_key
|
||||||
|
self.secret_key = secret_key
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.sleep_sec = sleep_sec
|
||||||
|
self.retries = retries
|
||||||
|
self._session = requests.Session()
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""인증 요청을 수행한다."""
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
body = dumps_params(params) if params else None
|
||||||
|
headers = auth_headers(
|
||||||
|
self.access_key,
|
||||||
|
self.secret_key,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(1, self.retries + 1):
|
||||||
|
try:
|
||||||
|
response = self._session.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
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()
|
||||||
|
time.sleep(self.sleep_sec)
|
||||||
|
if not isinstance(payload, (dict, list)):
|
||||||
|
raise ValueError(f"Unexpected response: {type(payload)}")
|
||||||
|
return payload if isinstance(payload, dict) else {"data": payload}
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
wait = self.sleep_sec * attempt * 2
|
||||||
|
logger.warning("Private API failed (%s/%s): %s", attempt, self.retries, exc)
|
||||||
|
time.sleep(wait)
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_accounts(self) -> list[dict[str, Any]]:
|
||||||
|
"""전체 계좌(잔고)를 조회한다."""
|
||||||
|
payload = self._request("GET", "/v1/accounts")
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return payload
|
||||||
|
return payload.get("data", payload) if isinstance(payload.get("data"), list) else []
|
||||||
|
|
||||||
|
def get_balance(self, currency: str) -> tuple[float, float]:
|
||||||
|
"""통화별 잔고를 반환한다.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(available, locked) 수량 또는 원화.
|
||||||
|
"""
|
||||||
|
currency = currency.upper()
|
||||||
|
for row in self.get_accounts():
|
||||||
|
if str(row.get("currency", "")).upper() == currency:
|
||||||
|
return float(row.get("balance", 0)), float(row.get("locked", 0))
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
def market_buy_krw(self, market: str, krw_amount: float) -> dict[str, Any]:
|
||||||
|
"""시장가 매수 (원화 금액).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: 예) KRW-BTC.
|
||||||
|
krw_amount: 매수 원화 금액.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
주문 응답 dict.
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"market": market,
|
||||||
|
"side": "bid",
|
||||||
|
"price": str(int(krw_amount)),
|
||||||
|
"ord_type": "price",
|
||||||
|
}
|
||||||
|
return self._request("POST", "/v1/orders", params=params)
|
||||||
|
|
||||||
|
def market_sell_volume(self, market: str, volume: float) -> dict[str, Any]:
|
||||||
|
"""시장가 매도 (코인 수량).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: 예) KRW-BTC.
|
||||||
|
volume: 매도 수량.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
주문 응답 dict.
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"market": market,
|
||||||
|
"side": "ask",
|
||||||
|
"volume": f"{volume:.8f}".rstrip("0").rstrip("."),
|
||||||
|
"ord_type": "market",
|
||||||
|
}
|
||||||
|
return self._request("POST", "/v1/orders", params=params)
|
||||||
@@ -93,6 +93,20 @@ class Settings:
|
|||||||
mtf_rules_json: Path
|
mtf_rules_json: Path
|
||||||
causal_sim_report_json: Path
|
causal_sim_report_json: Path
|
||||||
causal_sim_report_html: Path
|
causal_sim_report_html: Path
|
||||||
|
# 현물 3단계: 운영 (paper / live)
|
||||||
|
bithumb_access_key: str
|
||||||
|
bithumb_secret_key: str
|
||||||
|
ops_mode: str
|
||||||
|
ops_technique_id: str
|
||||||
|
ops_min_score: float | None
|
||||||
|
ops_state_json: Path
|
||||||
|
ops_report_json: Path
|
||||||
|
ops_filtered_backtest_json: Path
|
||||||
|
ops_mtf_enabled: bool
|
||||||
|
ops_trend_gate_enabled: bool
|
||||||
|
ops_daily_max_trades: int
|
||||||
|
ops_min_order_krw: float
|
||||||
|
ops_sync_candles: bool
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def market(self) -> str:
|
def market(self) -> str:
|
||||||
@@ -259,4 +273,33 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
|||||||
"docs/spot/2_analysis/causal_sim_report.html",
|
"docs/spot/2_analysis/causal_sim_report.html",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
bithumb_access_key=os.getenv("BITHUMB_ACCESS_KEY", "").strip(),
|
||||||
|
bithumb_secret_key=os.getenv("BITHUMB_SECRET_KEY", "").strip(),
|
||||||
|
ops_mode=os.getenv("OPS_MODE", "paper").strip().lower(),
|
||||||
|
ops_technique_id=os.getenv("OPS_TECHNIQUE_ID", "composite_v3").strip(),
|
||||||
|
ops_min_score=(
|
||||||
|
float(os.getenv("OPS_MIN_SCORE"))
|
||||||
|
if os.getenv("OPS_MIN_SCORE", "").strip()
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
ops_state_json=_resolve_project_path(
|
||||||
|
os.getenv("OPS_STATE_JSON", "data/spot/operations/ops_state.json")
|
||||||
|
),
|
||||||
|
ops_report_json=_resolve_project_path(
|
||||||
|
os.getenv("OPS_REPORT_JSON", "docs/spot/3_operations/ops_report.json")
|
||||||
|
),
|
||||||
|
ops_filtered_backtest_json=_resolve_project_path(
|
||||||
|
os.getenv(
|
||||||
|
"OPS_FILTERED_BACKTEST_JSON",
|
||||||
|
"docs/spot/3_operations/filtered_backtest_report.json",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ops_mtf_enabled=os.getenv("OPS_MTF_ENABLED", "true").strip().lower()
|
||||||
|
in ("1", "true", "yes", "on"),
|
||||||
|
ops_trend_gate_enabled=os.getenv("OPS_TREND_GATE_ENABLED", "true").strip().lower()
|
||||||
|
in ("1", "true", "yes", "on"),
|
||||||
|
ops_daily_max_trades=int(os.getenv("OPS_DAILY_MAX_TRADES", "20")),
|
||||||
|
ops_min_order_krw=float(os.getenv("OPS_MIN_ORDER_KRW", "5000")),
|
||||||
|
ops_sync_candles=os.getenv("OPS_SYNC_CANDLES", "true").strip().lower()
|
||||||
|
in ("1", "true", "yes", "on"),
|
||||||
)
|
)
|
||||||
|
|||||||
12
src/deepcoin/operations/__init__.py
Normal file
12
src/deepcoin/operations/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""현물 3단계 운영 — composite_v3 + MTF."""
|
||||||
|
|
||||||
|
from deepcoin.operations.backtest import run_filtered_backtest, save_backtest_report
|
||||||
|
from deepcoin.operations.runner import OperationsRunner
|
||||||
|
from deepcoin.operations.signal_pipeline import run_signal_pipeline
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OperationsRunner",
|
||||||
|
"run_filtered_backtest",
|
||||||
|
"run_signal_pipeline",
|
||||||
|
"save_backtest_report",
|
||||||
|
]
|
||||||
69
src/deepcoin/operations/backtest.py
Normal file
69
src/deepcoin/operations/backtest.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""3단계: MTF 필터 적용 composite 백테스트."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deepcoin.config import Settings
|
||||||
|
from deepcoin.evaluation.causal_sim import normalize_signals_for_sim
|
||||||
|
from deepcoin.ground_truth.pnl import simulate_gt_signals_pnl
|
||||||
|
from deepcoin.operations.signal_pipeline import run_signal_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def run_filtered_backtest(settings: Settings) -> dict[str, Any]:
|
||||||
|
"""MTF 필터 통과 신호로 3년 sim을 실행한다."""
|
||||||
|
pipeline = run_signal_pipeline(
|
||||||
|
settings,
|
||||||
|
use_cache=True,
|
||||||
|
mtf_lookback_days=settings.gt_sim_lookback_days,
|
||||||
|
)
|
||||||
|
kept = pipeline["kept"]
|
||||||
|
normalized = normalize_signals_for_sim(kept)
|
||||||
|
sim = simulate_gt_signals_pnl(
|
||||||
|
signals=normalized,
|
||||||
|
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=pipeline["data_end"],
|
||||||
|
last_mark_price=pipeline["last_price"],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_sim = simulate_gt_signals_pnl(
|
||||||
|
signals=normalize_signals_for_sim(pipeline["scoped_raw_signals"]),
|
||||||
|
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=pipeline["data_end"],
|
||||||
|
last_mark_price=pipeline["last_price"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"symbol": settings.symbol,
|
||||||
|
"technique_id": pipeline["technique_id"],
|
||||||
|
"technique_name": pipeline["technique_name"],
|
||||||
|
"params": pipeline["params"],
|
||||||
|
"mtf_enabled": pipeline.get("mtf_enabled", False),
|
||||||
|
"trend_gate_enabled": settings.ops_trend_gate_enabled,
|
||||||
|
"raw_signal_count": pipeline["raw_count"],
|
||||||
|
"filtered_signal_count": pipeline["kept_count"],
|
||||||
|
"rejected_signal_count": pipeline["rejected_count"],
|
||||||
|
"sim_lookback_days": settings.gt_sim_lookback_days,
|
||||||
|
"filtered_sim": sim,
|
||||||
|
"raw_sim": raw_sim,
|
||||||
|
"improvement_return_pct": round(
|
||||||
|
(sim.get("total_return_pct") or 0) - (raw_sim.get("total_return_pct") or 0),
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_backtest_report(report: dict[str, Any], path: Path) -> Path:
|
||||||
|
"""백테스트 리포트 JSON 저장."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as fp:
|
||||||
|
json.dump(report, fp, ensure_ascii=False, indent=2)
|
||||||
|
return path
|
||||||
175
src/deepcoin/operations/executor.py
Normal file
175
src/deepcoin/operations/executor.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""paper / live 주문 실행."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deepcoin.api.bithumb_private import BithumbPrivateClient
|
||||||
|
from deepcoin.config import Settings
|
||||||
|
from deepcoin.operations.trade_engine import (
|
||||||
|
TradeResult,
|
||||||
|
apply_trade_to_portfolio,
|
||||||
|
compute_buy_order,
|
||||||
|
compute_sell_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderExecutor(ABC):
|
||||||
|
"""주문 실행 인터페이스."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def execute_signal(
|
||||||
|
self,
|
||||||
|
signal: dict[str, Any],
|
||||||
|
portfolio: dict[str, Any],
|
||||||
|
*,
|
||||||
|
cluster_size: int = 1,
|
||||||
|
) -> TradeResult:
|
||||||
|
"""신호 1건을 체결한다."""
|
||||||
|
|
||||||
|
|
||||||
|
class PaperExecutor(OrderExecutor):
|
||||||
|
"""모의 체결 — 신호 가격 기준 즉시 fill."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
def execute_signal(
|
||||||
|
self,
|
||||||
|
signal: dict[str, Any],
|
||||||
|
portfolio: dict[str, Any],
|
||||||
|
*,
|
||||||
|
cluster_size: int = 1,
|
||||||
|
) -> TradeResult:
|
||||||
|
"""paper 모드 체결."""
|
||||||
|
price = float(signal["price"])
|
||||||
|
fee_rate = self.settings.gt_trading_fee_rate
|
||||||
|
min_order = self.settings.ops_min_order_krw
|
||||||
|
cash = float(portfolio.get("cash_krw", 0))
|
||||||
|
coin = float(portfolio.get("coin_qty", 0))
|
||||||
|
side = str(signal["side"])
|
||||||
|
|
||||||
|
if side == "buy":
|
||||||
|
trade = compute_buy_order(
|
||||||
|
cash_krw=cash,
|
||||||
|
coin_qty=coin,
|
||||||
|
price=price,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
min_order_krw=min_order,
|
||||||
|
cluster_size=cluster_size,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
trade = compute_sell_order(
|
||||||
|
coin_qty=coin,
|
||||||
|
price=price,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
min_order_krw=min_order,
|
||||||
|
cluster_size=cluster_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
if trade.executed:
|
||||||
|
apply_trade_to_portfolio(portfolio, trade)
|
||||||
|
return trade
|
||||||
|
|
||||||
|
|
||||||
|
class LiveExecutor(OrderExecutor):
|
||||||
|
"""실거래 체결 — 빗썸 시장가 주문."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings, client: BithumbPrivateClient) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def _sync_portfolio(self, portfolio: dict[str, Any]) -> None:
|
||||||
|
"""거래소 잔고로 포트폴리오를 동기화한다."""
|
||||||
|
krw_avail, _ = self.client.get_balance("KRW")
|
||||||
|
coin_avail, _ = self.client.get_balance(self.settings.symbol)
|
||||||
|
portfolio["cash_krw"] = krw_avail
|
||||||
|
portfolio["coin_qty"] = coin_avail
|
||||||
|
|
||||||
|
def execute_signal(
|
||||||
|
self,
|
||||||
|
signal: dict[str, Any],
|
||||||
|
portfolio: dict[str, Any],
|
||||||
|
*,
|
||||||
|
cluster_size: int = 1,
|
||||||
|
) -> TradeResult:
|
||||||
|
"""live 모드 시장가 주문."""
|
||||||
|
self._sync_portfolio(portfolio)
|
||||||
|
price = float(signal["price"])
|
||||||
|
fee_rate = self.settings.gt_trading_fee_rate
|
||||||
|
min_order = self.settings.ops_min_order_krw
|
||||||
|
cash = float(portfolio.get("cash_krw", 0))
|
||||||
|
coin = float(portfolio.get("coin_qty", 0))
|
||||||
|
side = str(signal["side"])
|
||||||
|
|
||||||
|
if side == "buy":
|
||||||
|
trade = compute_buy_order(
|
||||||
|
cash_krw=cash,
|
||||||
|
coin_qty=coin,
|
||||||
|
price=price,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
min_order_krw=min_order,
|
||||||
|
cluster_size=cluster_size,
|
||||||
|
)
|
||||||
|
if not trade.executed:
|
||||||
|
return trade
|
||||||
|
try:
|
||||||
|
resp = self.client.market_buy_krw(self.settings.market, trade.order_krw)
|
||||||
|
trade.api_response = resp
|
||||||
|
self._sync_portfolio(portfolio)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("live buy failed")
|
||||||
|
return TradeResult(
|
||||||
|
executed=False,
|
||||||
|
side="buy",
|
||||||
|
order_krw=0.0,
|
||||||
|
order_coin=0.0,
|
||||||
|
fee_krw=0.0,
|
||||||
|
price=price,
|
||||||
|
skip_reason=str(exc),
|
||||||
|
)
|
||||||
|
return trade
|
||||||
|
|
||||||
|
trade = compute_sell_order(
|
||||||
|
coin_qty=coin,
|
||||||
|
price=price,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
min_order_krw=min_order,
|
||||||
|
cluster_size=cluster_size,
|
||||||
|
)
|
||||||
|
if not trade.executed:
|
||||||
|
return trade
|
||||||
|
try:
|
||||||
|
resp = self.client.market_sell_volume(self.settings.market, trade.order_coin)
|
||||||
|
trade.api_response = resp
|
||||||
|
self._sync_portfolio(portfolio)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("live sell failed")
|
||||||
|
return TradeResult(
|
||||||
|
executed=False,
|
||||||
|
side="sell",
|
||||||
|
order_krw=0.0,
|
||||||
|
order_coin=0.0,
|
||||||
|
fee_krw=0.0,
|
||||||
|
price=price,
|
||||||
|
skip_reason=str(exc),
|
||||||
|
)
|
||||||
|
return trade
|
||||||
|
|
||||||
|
|
||||||
|
def create_executor(settings: Settings) -> OrderExecutor:
|
||||||
|
"""설정에 맞는 Executor를 생성한다."""
|
||||||
|
if settings.ops_mode == "live":
|
||||||
|
client = BithumbPrivateClient(
|
||||||
|
access_key=settings.bithumb_access_key,
|
||||||
|
secret_key=settings.bithumb_secret_key,
|
||||||
|
base_url=settings.api_url,
|
||||||
|
sleep_sec=settings.request_sleep_sec,
|
||||||
|
retries=settings.request_retries,
|
||||||
|
)
|
||||||
|
return LiveExecutor(settings, client)
|
||||||
|
return PaperExecutor(settings)
|
||||||
192
src/deepcoin/operations/runner.py
Normal file
192
src/deepcoin/operations/runner.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""3단계 운영 러너 — 캔들 동기화·신호·체결."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deepcoin.config import Settings
|
||||||
|
from deepcoin.ground_truth.pnl import _cluster_signals
|
||||||
|
from deepcoin.operations.executor import create_executor
|
||||||
|
from deepcoin.operations.signal_pipeline import (
|
||||||
|
filter_signals_for_ops,
|
||||||
|
generate_raw_signals,
|
||||||
|
load_ops_candles,
|
||||||
|
)
|
||||||
|
from deepcoin.operations.state_store import load_state, reset_daily_trade_count, save_state
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
def sync_candles_if_enabled(settings: Settings) -> bool:
|
||||||
|
"""증분 캔들 수집 스크립트를 실행한다."""
|
||||||
|
if not settings.ops_sync_candles:
|
||||||
|
return False
|
||||||
|
script = _project_root() / "scripts" / "00_download.py"
|
||||||
|
if not script.exists():
|
||||||
|
logger.warning("캔들 동기화 스크립트 없음: %s", script)
|
||||||
|
return False
|
||||||
|
logger.info("캔들 증분 동기화 실행...")
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, str(script)],
|
||||||
|
cwd=str(_project_root()),
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _signals_on_bar(signals: list[dict[str, Any]], bar_index: int) -> list[dict[str, Any]]:
|
||||||
|
"""특정 bar_index 신호만 반환."""
|
||||||
|
return [s for s in signals if int(s.get("bar_index", -1)) == bar_index]
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_bar_indices(
|
||||||
|
kept: list[dict[str, Any]],
|
||||||
|
last_bar_index: int,
|
||||||
|
latest_bar_index: int,
|
||||||
|
) -> list[int]:
|
||||||
|
"""체결 대상 bar_index 목록 (최초 실행은 최신 봉만)."""
|
||||||
|
if last_bar_index < 0:
|
||||||
|
return [latest_bar_index] if any(
|
||||||
|
int(s.get("bar_index", -1)) == latest_bar_index for s in kept
|
||||||
|
) else []
|
||||||
|
return sorted(
|
||||||
|
{
|
||||||
|
int(s.get("bar_index", -1))
|
||||||
|
for s in kept
|
||||||
|
if int(s.get("bar_index", -1)) > last_bar_index
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cluster_pending(pending: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]:
|
||||||
|
"""연속 동일 side 신호를 클러스터로 묶는다."""
|
||||||
|
normalized = [
|
||||||
|
{
|
||||||
|
"side": s["side"],
|
||||||
|
"datetime": s["datetime"],
|
||||||
|
"price": s["price"],
|
||||||
|
"bar_index": s.get("bar_index", 0),
|
||||||
|
"signal_type": s.get("signal_type", ""),
|
||||||
|
"marker_id": s.get("marker_id"),
|
||||||
|
}
|
||||||
|
for s in pending
|
||||||
|
]
|
||||||
|
return _cluster_signals(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationsRunner:
|
||||||
|
"""3단계 운영 1회 tick 실행."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.executor = create_executor(settings)
|
||||||
|
self.state = load_state(
|
||||||
|
settings.ops_state_json,
|
||||||
|
initial_cash_krw=settings.gt_initial_cash_krw,
|
||||||
|
)
|
||||||
|
self.state["portfolio"]["mode"] = settings.ops_mode
|
||||||
|
|
||||||
|
def tick(self, *, sync_candles: bool | None = None) -> dict[str, Any]:
|
||||||
|
"""신호 확인 및 체결 1회."""
|
||||||
|
if sync_candles if sync_candles is not None else self.settings.ops_sync_candles:
|
||||||
|
sync_candles_if_enabled(self.settings)
|
||||||
|
|
||||||
|
df = load_ops_candles(self.settings)
|
||||||
|
latest_bar = int(len(df) - 1)
|
||||||
|
gen = generate_raw_signals(self.settings, df=df, use_cache=True)
|
||||||
|
|
||||||
|
# tick: 최신 봉 후보만 MTF 평가 (전기간 MTF는 백테스트 전용)
|
||||||
|
bar_candidates = _signals_on_bar(gen["raw_signals"], latest_bar)
|
||||||
|
filtered = filter_signals_for_ops(self.settings, bar_candidates)
|
||||||
|
kept = filtered["kept"]
|
||||||
|
|
||||||
|
reset_daily_trade_count(self.state)
|
||||||
|
last_bar = int(self.state.get("last_processed_bar_index", -1))
|
||||||
|
target_bars = _pending_bar_indices(kept, last_bar, latest_bar)
|
||||||
|
|
||||||
|
executions: list[dict[str, Any]] = []
|
||||||
|
max_daily = self.settings.ops_daily_max_trades
|
||||||
|
|
||||||
|
for bar_idx in target_bars:
|
||||||
|
bar_signals = _signals_on_bar(kept, bar_idx)
|
||||||
|
clusters = _cluster_pending(bar_signals)
|
||||||
|
for side, cluster in clusters:
|
||||||
|
if self.state["trades_today_count"] >= max_daily:
|
||||||
|
logger.warning("일일 체결 상한(%d) 도달 — 중단", max_daily)
|
||||||
|
break
|
||||||
|
cluster_size = len(cluster)
|
||||||
|
for sig in cluster:
|
||||||
|
full_sig = next(
|
||||||
|
(k for k in kept if k["datetime"] == sig["datetime"]),
|
||||||
|
sig,
|
||||||
|
)
|
||||||
|
trade = self.executor.execute_signal(
|
||||||
|
full_sig,
|
||||||
|
self.state["portfolio"],
|
||||||
|
cluster_size=cluster_size,
|
||||||
|
)
|
||||||
|
record = {
|
||||||
|
"datetime": full_sig["datetime"],
|
||||||
|
"side": full_sig["side"],
|
||||||
|
"signal_type": full_sig.get("signal_type"),
|
||||||
|
"price": full_sig["price"],
|
||||||
|
"bar_index": bar_idx,
|
||||||
|
"trade": trade.to_dict(),
|
||||||
|
"mtf_filter": full_sig.get("mtf_filter"),
|
||||||
|
}
|
||||||
|
executions.append(record)
|
||||||
|
if trade.executed:
|
||||||
|
self.state["trades_today_count"] += 1
|
||||||
|
if bar_idx > last_bar:
|
||||||
|
last_bar = bar_idx
|
||||||
|
self.state["last_processed_bar_index"] = bar_idx
|
||||||
|
if bar_signals:
|
||||||
|
self.state["last_processed_datetime"] = bar_signals[-1]["datetime"]
|
||||||
|
|
||||||
|
pipeline = {
|
||||||
|
"technique_id": gen["technique_id"],
|
||||||
|
"raw_count": len(bar_candidates),
|
||||||
|
"kept_count": len(kept),
|
||||||
|
"rejected_count": len(filtered["rejected"]),
|
||||||
|
"latest_bar_index": latest_bar,
|
||||||
|
}
|
||||||
|
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
self.state["last_run_at"] = now
|
||||||
|
if executions:
|
||||||
|
self.state.setdefault("trade_history", []).extend(executions)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"generated_at": now,
|
||||||
|
"mode": self.settings.ops_mode,
|
||||||
|
"technique_id": pipeline["technique_id"],
|
||||||
|
"raw_signals": pipeline["raw_count"],
|
||||||
|
"filtered_signals": pipeline["kept_count"],
|
||||||
|
"pending_bars": target_bars,
|
||||||
|
"latest_bar_candidates": pipeline["raw_count"],
|
||||||
|
"executions": executions,
|
||||||
|
"portfolio": self.state["portfolio"],
|
||||||
|
"trades_today_count": self.state["trades_today_count"],
|
||||||
|
"last_processed_bar_index": self.state["last_processed_bar_index"],
|
||||||
|
}
|
||||||
|
|
||||||
|
save_state(self.settings.ops_state_json, self.state)
|
||||||
|
self._save_report(report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
def _save_report(self, report: dict[str, Any]) -> None:
|
||||||
|
"""최신 운영 리포트 저장."""
|
||||||
|
path = self.settings.ops_report_json
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as fp:
|
||||||
|
json.dump(report, fp, ensure_ascii=False, indent=2)
|
||||||
186
src/deepcoin/operations/signal_pipeline.py
Normal file
186
src/deepcoin/operations/signal_pipeline.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""3단계: composite_v3 신호 생성 + MTF 필터."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from deepcoin.config import Settings
|
||||||
|
from deepcoin.data.candle_loader import load_candles
|
||||||
|
from deepcoin.mtf.extractor import MtfFeatureExtractor
|
||||||
|
from deepcoin.mtf.filter import MtfSignalFilter
|
||||||
|
from deepcoin.mtf.rules import load_or_derive_mtf_rules
|
||||||
|
from deepcoin.mtf.store import MultiTimeframeStore
|
||||||
|
from deepcoin.mtf.trend_gate import HtfTrendGate
|
||||||
|
from deepcoin.operations.signal_type import enrich_signal_types
|
||||||
|
from deepcoin.techniques.base import TechniqueParams
|
||||||
|
from deepcoin.techniques.registry import get_technique
|
||||||
|
from deepcoin.techniques.runner import load_technique_result, run_technique
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(value: str) -> datetime:
|
||||||
|
"""신호 datetime 파싱."""
|
||||||
|
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _signals_in_lookback(
|
||||||
|
signals: list[dict[str, Any]],
|
||||||
|
data_end: str,
|
||||||
|
lookback_days: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""data_end 기준 lookback_days 이내 신호만 반환."""
|
||||||
|
end_dt = _parse_dt(data_end)
|
||||||
|
start_dt = end_dt - timedelta(days=lookback_days)
|
||||||
|
return [s for s in signals if _parse_dt(s["datetime"]) >= start_dt]
|
||||||
|
|
||||||
|
|
||||||
|
def build_technique_params(settings: Settings) -> TechniqueParams:
|
||||||
|
"""운영용 기법 파라미터를 구성한다."""
|
||||||
|
extra: dict[str, Any] = {}
|
||||||
|
if settings.ops_min_score is not None:
|
||||||
|
extra["min_score"] = settings.ops_min_score
|
||||||
|
return 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=extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_ops_candles(settings: Settings) -> pd.DataFrame:
|
||||||
|
"""운영·백테스트용 캔들을 로드한다."""
|
||||||
|
return load_candles(
|
||||||
|
settings.db_path,
|
||||||
|
settings.symbol,
|
||||||
|
settings.gt_interval_min,
|
||||||
|
settings.gt_lookback_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_raw_signals(
|
||||||
|
settings: Settings,
|
||||||
|
df: pd.DataFrame | None = None,
|
||||||
|
*,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""기법 신호를 생성한다 (MTF 필터 전).
|
||||||
|
|
||||||
|
2단계 캐시 JSON이 있으면 재계산 없이 로드한다.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
technique_id, raw_signals, result 메타 dict.
|
||||||
|
"""
|
||||||
|
if df is None:
|
||||||
|
df = load_ops_candles(settings)
|
||||||
|
data_end = str(df["datetime"].iloc[-1])
|
||||||
|
last_price = float(df["close"].iloc[-1])
|
||||||
|
|
||||||
|
cache_path = settings.techniques_dir / f"{settings.ops_technique_id}.json"
|
||||||
|
if use_cache and cache_path.exists():
|
||||||
|
cached = load_technique_result(cache_path)
|
||||||
|
return {
|
||||||
|
"technique_id": cached.technique_id,
|
||||||
|
"technique_name": cached.technique_name,
|
||||||
|
"params": cached.params,
|
||||||
|
"raw_signals": cached.signals,
|
||||||
|
"data_end": data_end,
|
||||||
|
"last_price": last_price,
|
||||||
|
"from_cache": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
technique = get_technique(settings.ops_technique_id)
|
||||||
|
params = build_technique_params(settings)
|
||||||
|
result = run_technique(technique, df, params, gt_result=None)
|
||||||
|
return {
|
||||||
|
"technique_id": result.technique_id,
|
||||||
|
"technique_name": result.technique_name,
|
||||||
|
"params": result.params,
|
||||||
|
"raw_signals": result.signals,
|
||||||
|
"data_end": data_end,
|
||||||
|
"last_price": last_price,
|
||||||
|
"from_cache": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_mtf_filter(settings: Settings) -> MtfSignalFilter | None:
|
||||||
|
"""MTF 필터 인스턴스를 생성한다. 비활성 시 None."""
|
||||||
|
if not settings.ops_mtf_enabled:
|
||||||
|
return None
|
||||||
|
rule_set = load_or_derive_mtf_rules(
|
||||||
|
settings.mtf_rules_json,
|
||||||
|
settings.mtf_report_json,
|
||||||
|
)
|
||||||
|
store = MultiTimeframeStore(
|
||||||
|
db_path=settings.db_path,
|
||||||
|
symbol=settings.symbol,
|
||||||
|
lookback_days=settings.gt_sim_lookback_days + 120,
|
||||||
|
zigzag_reversal_pct=settings.gt_zigzag_reversal_pct,
|
||||||
|
)
|
||||||
|
extractor = MtfFeatureExtractor(
|
||||||
|
store=store,
|
||||||
|
base_interval_min=settings.gt_interval_min,
|
||||||
|
)
|
||||||
|
trend_gate = HtfTrendGate(enabled=settings.ops_trend_gate_enabled)
|
||||||
|
return MtfSignalFilter(extractor, rule_set, trend_gate=trend_gate)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_signals_for_ops(
|
||||||
|
settings: Settings,
|
||||||
|
raw_signals: list[dict[str, Any]],
|
||||||
|
mtf_filter: MtfSignalFilter | None = None,
|
||||||
|
*,
|
||||||
|
data_end: str | None = None,
|
||||||
|
mtf_lookback_days: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""신호 유형 보강 후 MTF 필터를 적용한다."""
|
||||||
|
scoped = raw_signals
|
||||||
|
if data_end and mtf_lookback_days:
|
||||||
|
scoped = _signals_in_lookback(raw_signals, data_end, mtf_lookback_days)
|
||||||
|
typed = enrich_signal_types(scoped)
|
||||||
|
if mtf_filter is None:
|
||||||
|
mtf_filter = build_mtf_filter(settings)
|
||||||
|
if mtf_filter is None:
|
||||||
|
return {
|
||||||
|
"kept": typed,
|
||||||
|
"rejected": [],
|
||||||
|
"mtf_enabled": False,
|
||||||
|
}
|
||||||
|
kept, rejected = mtf_filter.filter_signals(typed)
|
||||||
|
return {
|
||||||
|
"kept": kept,
|
||||||
|
"rejected": rejected,
|
||||||
|
"mtf_enabled": True,
|
||||||
|
"min_rules_pass": mtf_filter.rule_set.min_rules_pass,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_signal_pipeline(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
use_cache: bool = True,
|
||||||
|
mtf_lookback_days: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""캔들 로드 → 기법 신호 → MTF 필터까지 일괄 실행."""
|
||||||
|
lookback = mtf_lookback_days or settings.gt_sim_lookback_days
|
||||||
|
gen = generate_raw_signals(settings, use_cache=use_cache)
|
||||||
|
filtered = filter_signals_for_ops(
|
||||||
|
settings,
|
||||||
|
gen["raw_signals"],
|
||||||
|
data_end=gen["data_end"],
|
||||||
|
mtf_lookback_days=lookback,
|
||||||
|
)
|
||||||
|
scoped_raw = _signals_in_lookback(gen["raw_signals"], gen["data_end"], lookback)
|
||||||
|
return {
|
||||||
|
**gen,
|
||||||
|
**filtered,
|
||||||
|
"scoped_raw_signals": scoped_raw,
|
||||||
|
"raw_count": len(scoped_raw),
|
||||||
|
"raw_count_total": len(gen["raw_signals"]),
|
||||||
|
"kept_count": len(filtered["kept"]),
|
||||||
|
"rejected_count": len(filtered["rejected"]),
|
||||||
|
"mtf_lookback_days": lookback,
|
||||||
|
}
|
||||||
69
src/deepcoin/operations/signal_type.py
Normal file
69
src/deepcoin/operations/signal_type.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""composite_v3 신호의 GT signal_type 추론."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_PULLBACK_SOURCES = frozenset(
|
||||||
|
{"ema_pullback", "fib_pullback", "support_bounce", "bb_reversal"}
|
||||||
|
)
|
||||||
|
_BREAKOUT_SOURCES = frozenset(
|
||||||
|
{"donchian", "range_breakout", "keltner_breakout"}
|
||||||
|
)
|
||||||
|
_DIV_SOURCES = frozenset(
|
||||||
|
{"rsi_divergence", "macd_divergence", "obv_divergence", "rsi_swing"}
|
||||||
|
)
|
||||||
|
_SWING_SOURCES = frozenset(
|
||||||
|
{
|
||||||
|
"zigzag_causal",
|
||||||
|
"minor_swing",
|
||||||
|
"pivot_swing",
|
||||||
|
"fractal_swing",
|
||||||
|
"local_extrema",
|
||||||
|
"macd_cross",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_SOURCE_PATTERN = re.compile(r"\[([^\]]+)\]")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_composite_sources(reason: str) -> set[str]:
|
||||||
|
"""composite reason 문자열에서 기법 ID 목록을 추출한다."""
|
||||||
|
match = _SOURCE_PATTERN.search(reason or "")
|
||||||
|
if not match:
|
||||||
|
return set()
|
||||||
|
return {part.strip() for part in match.group(1).split(",") if part.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_signal_type(side: str, sources: set[str]) -> str:
|
||||||
|
"""기여 기법 집합으로 GT signal_type을 추론한다.
|
||||||
|
|
||||||
|
우선순위: 다이버전스 > 돌파 > 눌림목 > 스윙.
|
||||||
|
"""
|
||||||
|
if side == "buy":
|
||||||
|
if sources & _DIV_SOURCES:
|
||||||
|
return "div_bull"
|
||||||
|
if sources & _BREAKOUT_SOURCES:
|
||||||
|
return "breakout"
|
||||||
|
if sources & _PULLBACK_SOURCES:
|
||||||
|
return "pullback"
|
||||||
|
if sources & _SWING_SOURCES:
|
||||||
|
return "swing_low"
|
||||||
|
return "swing_low"
|
||||||
|
if sources & _DIV_SOURCES:
|
||||||
|
return "div_bear"
|
||||||
|
return "swing_high"
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_signal_types(signals: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""신호 dict에 signal_type·sources 필드를 채운다."""
|
||||||
|
enriched: list[dict[str, Any]] = []
|
||||||
|
for sig in signals:
|
||||||
|
copy = dict(sig)
|
||||||
|
if not copy.get("signal_type"):
|
||||||
|
sources = parse_composite_sources(str(copy.get("reason", "")))
|
||||||
|
copy["sources"] = sorted(sources)
|
||||||
|
copy["signal_type"] = infer_signal_type(str(copy["side"]), sources)
|
||||||
|
enriched.append(copy)
|
||||||
|
return enriched
|
||||||
59
src/deepcoin/operations/state_store.py
Normal file
59
src/deepcoin/operations/state_store.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""운영 상태 JSON 저장·로드."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _today_str() -> str:
|
||||||
|
"""오늘 날짜 문자열 (KST naive)."""
|
||||||
|
return datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def default_state(initial_cash_krw: float = 200_000.0) -> dict[str, Any]:
|
||||||
|
"""빈 운영 상태 dict."""
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"last_run_at": None,
|
||||||
|
"last_processed_datetime": None,
|
||||||
|
"last_processed_bar_index": -1,
|
||||||
|
"trades_today_date": _today_str(),
|
||||||
|
"trades_today_count": 0,
|
||||||
|
"portfolio": {
|
||||||
|
"cash_krw": float(initial_cash_krw),
|
||||||
|
"coin_qty": 0.0,
|
||||||
|
"mode": "paper",
|
||||||
|
},
|
||||||
|
"trade_history": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path: Path, *, initial_cash_krw: float = 200_000.0) -> dict[str, Any]:
|
||||||
|
"""운영 상태를 로드한다. 없으면 기본값 생성."""
|
||||||
|
if not path.exists():
|
||||||
|
return default_state(initial_cash_krw)
|
||||||
|
with path.open(encoding="utf-8") as fp:
|
||||||
|
state = json.load(fp)
|
||||||
|
if "portfolio" not in state:
|
||||||
|
state["portfolio"] = default_state(initial_cash_krw)["portfolio"]
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(path: Path, state: dict[str, Any]) -> Path:
|
||||||
|
"""운영 상태를 저장한다."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as fp:
|
||||||
|
json.dump(state, fp, ensure_ascii=False, indent=2)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def reset_daily_trade_count(state: dict[str, Any]) -> None:
|
||||||
|
"""날짜가 바뀌면 일일 체결 카운터를 초기화한다."""
|
||||||
|
today = _today_str()
|
||||||
|
if state.get("trades_today_date") != today:
|
||||||
|
state["trades_today_date"] = today
|
||||||
|
state["trades_today_count"] = 0
|
||||||
127
src/deepcoin/operations/trade_engine.py
Normal file
127
src/deepcoin/operations/trade_engine.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""매수·매도 체결 로직 (paper / live 공통 사이징)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deepcoin.ground_truth.order_sizing import max_buy_from_cash
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TradeResult:
|
||||||
|
"""단일 체결 결과."""
|
||||||
|
|
||||||
|
executed: bool
|
||||||
|
side: str
|
||||||
|
order_krw: float
|
||||||
|
order_coin: float
|
||||||
|
fee_krw: float
|
||||||
|
price: float
|
||||||
|
skip_reason: str = ""
|
||||||
|
api_response: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""JSON 직렬화 dict."""
|
||||||
|
return {
|
||||||
|
"executed": self.executed,
|
||||||
|
"side": self.side,
|
||||||
|
"order_krw": round(self.order_krw, 0),
|
||||||
|
"order_coin": round(self.order_coin, 8),
|
||||||
|
"fee_krw": round(self.fee_krw, 2),
|
||||||
|
"price": self.price,
|
||||||
|
"skip_reason": self.skip_reason,
|
||||||
|
"api_response": self.api_response,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_buy_order(
|
||||||
|
*,
|
||||||
|
cash_krw: float,
|
||||||
|
coin_qty: float,
|
||||||
|
price: float,
|
||||||
|
fee_rate: float,
|
||||||
|
min_order_krw: float,
|
||||||
|
cluster_size: int = 1,
|
||||||
|
) -> TradeResult:
|
||||||
|
"""매수 주문 금액·수량을 계산한다."""
|
||||||
|
cash = max(float(cash_krw), 0.0)
|
||||||
|
equity = cash + float(coin_qty) * price
|
||||||
|
cash_cap = max_buy_from_cash(equity, cash)
|
||||||
|
per_buy = cash / cluster_size if cluster_size > 0 else cash
|
||||||
|
order_krw = min(per_buy, cash, cash_cap)
|
||||||
|
|
||||||
|
if order_krw < min_order_krw:
|
||||||
|
return TradeResult(
|
||||||
|
executed=False,
|
||||||
|
side="buy",
|
||||||
|
order_krw=0.0,
|
||||||
|
order_coin=0.0,
|
||||||
|
fee_krw=0.0,
|
||||||
|
price=price,
|
||||||
|
skip_reason="원화 부족 또는 최소 주문 미만",
|
||||||
|
)
|
||||||
|
|
||||||
|
fee = order_krw * fee_rate
|
||||||
|
bought = (order_krw - fee) / price
|
||||||
|
return TradeResult(
|
||||||
|
executed=True,
|
||||||
|
side="buy",
|
||||||
|
order_krw=order_krw,
|
||||||
|
order_coin=bought,
|
||||||
|
fee_krw=fee,
|
||||||
|
price=price,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_sell_order(
|
||||||
|
*,
|
||||||
|
coin_qty: float,
|
||||||
|
price: float,
|
||||||
|
fee_rate: float,
|
||||||
|
min_order_krw: float,
|
||||||
|
cluster_size: int = 1,
|
||||||
|
) -> TradeResult:
|
||||||
|
"""매도 주문 수량을 계산한다."""
|
||||||
|
qty = max(float(coin_qty), 0.0)
|
||||||
|
per_sell = qty / cluster_size if cluster_size > 0 else qty
|
||||||
|
order_coin = per_sell
|
||||||
|
order_krw = order_coin * price
|
||||||
|
|
||||||
|
if order_coin <= 0 or order_krw < min_order_krw:
|
||||||
|
return TradeResult(
|
||||||
|
executed=False,
|
||||||
|
side="sell",
|
||||||
|
order_krw=0.0,
|
||||||
|
order_coin=0.0,
|
||||||
|
fee_krw=0.0,
|
||||||
|
price=price,
|
||||||
|
skip_reason="코인 부족 또는 최소 주문 미만",
|
||||||
|
)
|
||||||
|
|
||||||
|
fee = order_krw * fee_rate
|
||||||
|
return TradeResult(
|
||||||
|
executed=True,
|
||||||
|
side="sell",
|
||||||
|
order_krw=order_krw,
|
||||||
|
order_coin=order_coin,
|
||||||
|
fee_krw=fee,
|
||||||
|
price=price,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_trade_to_portfolio(
|
||||||
|
portfolio: dict[str, Any],
|
||||||
|
trade: TradeResult,
|
||||||
|
) -> None:
|
||||||
|
"""체결 결과를 포트폴리오에 반영한다."""
|
||||||
|
cash = float(portfolio.get("cash_krw", 0))
|
||||||
|
coin = float(portfolio.get("coin_qty", 0))
|
||||||
|
if not trade.executed:
|
||||||
|
return
|
||||||
|
if trade.side == "buy":
|
||||||
|
portfolio["cash_krw"] = cash - trade.order_krw
|
||||||
|
portfolio["coin_qty"] = coin + trade.order_coin
|
||||||
|
else:
|
||||||
|
portfolio["cash_krw"] = cash + trade.order_krw - trade.fee_krw
|
||||||
|
portfolio["coin_qty"] = coin - trade.order_coin
|
||||||
Reference in New Issue
Block a user