feat(spot): fractal_swing live 운영 — 슬리피지·증분 sync·신호 tail 갱신
운영 백테스트(+1,873,140%)과 live/paper 체결 규칙을 맞추고, 캔들 증분 sync· tail 신호 갱신·일일 체결 상한·슬리피지를 반영한다. docs/live 차트 생성 스크립트와 .env.example·README를 갱신한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
22
.env.example
22
.env.example
@@ -77,16 +77,22 @@ CAUSAL_SIM_REPORT_HTML=docs/spot/2_analysis/causal_sim_report.html
|
|||||||
|
|
||||||
# --- 현물 3단계: 운영 (기본 paper) ---
|
# --- 현물 3단계: 운영 (기본 paper) ---
|
||||||
OPS_MODE=paper
|
OPS_MODE=paper
|
||||||
OPS_TECHNIQUE_ID=composite_v3
|
OPS_TECHNIQUE_ID=fractal_swing
|
||||||
# OPS_MIN_SCORE=3.5
|
OPS_MTF_ENABLED=false
|
||||||
OPS_MTF_ENABLED=true
|
OPS_TREND_GATE_ENABLED=false
|
||||||
OPS_TREND_GATE_ENABLED=true
|
OPS_DAILY_MAX_TRADES=100
|
||||||
OPS_DAILY_MAX_TRADES=20
|
|
||||||
OPS_MIN_ORDER_KRW=5000
|
OPS_MIN_ORDER_KRW=5000
|
||||||
|
OPS_SLIPPAGE_RATE=0.0005
|
||||||
|
OPS_ORDER_INTERVAL_SEC=0.35
|
||||||
OPS_SYNC_CANDLES=true
|
OPS_SYNC_CANDLES=true
|
||||||
OPS_STATE_JSON=data/spot/operations/ops_state.json
|
# 비우면 DOWNLOAD_INTERVALS 전체 증분 sync
|
||||||
OPS_REPORT_JSON=docs/spot/3_operations/ops_report.json
|
# OPS_SYNC_INTERVALS=
|
||||||
OPS_FILTERED_BACKTEST_JSON=docs/spot/3_operations/filtered_backtest_report.json
|
OPS_SIGNAL_TAIL_BARS=800
|
||||||
|
# OPS_PERSIST_SIGNAL_CACHE=false
|
||||||
|
OPS_STATE_JSON=data/spot/operations/fractal_ops_state.json
|
||||||
|
OPS_REPORT_JSON=docs/spot/3_operations/fractal_ops_report.json
|
||||||
|
OPS_FILTERED_BACKTEST_JSON=docs/spot/3_operations/fractal_filtered_backtest_report.json
|
||||||
|
# composite_v3 운영 시: OPS_TECHNIQUE_ID=composite_v3, OPS_MIN_SCORE=2.5, OPS_MTF_ENABLED=true
|
||||||
|
|
||||||
# 폴더 구조: data|docs / {common, spot, futures}
|
# 폴더 구조: data|docs / {common, spot, futures}
|
||||||
# common — coins.db 등 공유 리소스
|
# common — coins.db 등 공유 리소스
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -20,6 +20,9 @@
|
|||||||
!/docs/spot/3_operations/
|
!/docs/spot/3_operations/
|
||||||
/docs/spot/3_operations/**
|
/docs/spot/3_operations/**
|
||||||
!docs/spot/3_operations/stage3_design_guide.md
|
!docs/spot/3_operations/stage3_design_guide.md
|
||||||
|
!/docs/live/
|
||||||
|
/docs/live/**
|
||||||
|
!docs/live/index.html
|
||||||
logs/
|
logs/
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
|||||||
86
README.md
86
README.md
@@ -51,19 +51,21 @@ DeepCoin/
|
|||||||
│ ├── spot/ # 현물 전용 데이터
|
│ ├── spot/ # 현물 전용 데이터
|
||||||
│ │ ├── ground_truth/ # 0단계 GT JSON
|
│ │ ├── ground_truth/ # 0단계 GT JSON
|
||||||
│ │ ├── techniques/ # 2단계 기법 결과
|
│ │ ├── techniques/ # 2단계 기법 결과
|
||||||
│ │ └── mtf/ # 2단계 MTF 규칙
|
│ │ ├── mtf/ # 2단계 MTF 규칙
|
||||||
|
│ │ └── operations/ # 3단계 운영 상태
|
||||||
│ └── futures/ # 선물 전용 데이터
|
│ └── futures/ # 선물 전용 데이터
|
||||||
│ ├── ground_truth/ # 0단계 선물 GT JSON
|
│ ├── ground_truth/ # 0단계 선물 GT JSON
|
||||||
│ ├── techniques/ # (예정) 2단계
|
│ ├── techniques/ # (예정) 2단계
|
||||||
│ └── mtf/ # (예정) 2단계
|
│ └── mtf/ # (예정) 2단계
|
||||||
│
|
│
|
||||||
└── docs/
|
└── docs/
|
||||||
|
├── live/ # live 운영 백테스트 매매 차트
|
||||||
├── common/ # 공통 문서 (예정)
|
├── common/ # 공통 문서 (예정)
|
||||||
├── spot/ # 현물 리포트·차트
|
├── spot/ # 현물 리포트·차트
|
||||||
│ ├── 0_ground_truth/ # 0단계 GT 차트
|
│ ├── 0_ground_truth/ # 0단계 GT 차트
|
||||||
│ ├── 1_simulation/ # 1단계 sim 차트
|
│ ├── 1_simulation/ # 1단계 sim 차트
|
||||||
│ ├── 2_analysis/ # 2단계 분석 리포트
|
│ ├── 2_analysis/ # 2단계 분석 리포트
|
||||||
│ └── 3_operations/ # 3단계 운영 (예정)
|
│ └── 3_operations/ # 3단계 운영 리포트·백테스트
|
||||||
└── futures/ # 선물 리포트·차트
|
└── futures/ # 선물 리포트·차트
|
||||||
├── 0_ground_truth/ # 0단계 선물 GT 차트
|
├── 0_ground_truth/ # 0단계 선물 GT 차트
|
||||||
├── 1_simulation/ # (예정) 1단계
|
├── 1_simulation/ # (예정) 1단계
|
||||||
@@ -125,10 +127,11 @@ 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단계: 운영 (기본 paper) ─────────────────────────────
|
# ── spot 3단계: fractal_swing 운영 ───────────────────────────
|
||||||
bash scripts/3_run_stage3_all.sh
|
bash scripts/3_run_fractal_ops.sh # 백테스트 + paper loop
|
||||||
# python scripts/3_run_operations.py --mode paper
|
python scripts/3_run_fractal_realistic_backtest.py
|
||||||
# python scripts/3_run_operations.py --mode live # API 키 필요
|
python scripts/3_run_filtered_backtest.py # 운영 조건 3년 sim
|
||||||
|
python scripts/3_run_operations.py --loop 180 --mode live # live (API 키 필요)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -178,22 +181,62 @@ 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단계 — fractal_swing live 운영
|
||||||
|
|
||||||
설계·운영 가이드: [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md)
|
설계 가이드: [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md)
|
||||||
|
|
||||||
`composite_v3` + MTF 필터 + 고TF 게이트. **기본 `OPS_MODE=paper`**.
|
**운영 전략:** `fractal_swing` + MTF off. 백테스트(운영 조건) **3년 +1,873,140%** (초기 20만원 → 약 37.5억, 슬리피지 0.05%·일 100회 상한 반영).
|
||||||
|
|
||||||
| 순서 | 스크립트 | 산출물 |
|
| 순서 | 스크립트 | 산출물 |
|
||||||
|------|----------|--------|
|
|------|----------|--------|
|
||||||
| 3-1 | `3_run_filtered_backtest.py` | `filtered_backtest_report.json` |
|
| 백테스트 | `3_run_filtered_backtest.py` | `fractal_filtered_backtest_report.json` |
|
||||||
| 3-2 | `3_run_operations.py` | `ops_report.json`, `ops_state.json` |
|
| **매매 차트** | `3_render_live_chart.py` | `docs/live/index.html`, `fractal_swing_ops_chart.html` |
|
||||||
| 일괄 | `3_run_stage3_all.sh` | 위 전체 |
|
| 시나리오 | `3_run_fractal_realistic_backtest.py` | `fractal_realistic_backtest.json` |
|
||||||
|
| 운영 | `3_run_operations.py --loop 180` | `fractal_ops_report.json`, `fractal_ops_state.json` |
|
||||||
|
| 일괄 | `3_run_fractal_ops.sh` | 백테스트 + paper loop |
|
||||||
|
|
||||||
| 모드 | 설명 |
|
#### 백테스트 vs live 코드 정합 (라이브 직전 확인)
|
||||||
|------|------|
|
|
||||||
| `paper` | 모의 체결 (권장) |
|
| 항목 | 백테스트 | live/paper 코드 | 상태 |
|
||||||
| `live` | 빗썸 시장가 주문 (`BITHUMB_ACCESS_KEY` 필요) |
|
|------|----------|-----------------|------|
|
||||||
|
| 기법 | `fractal_swing` | `OPS_TECHNIQUE_ID` | 일치 |
|
||||||
|
| MTF | off | `OPS_MTF_ENABLED=false` | 일치 |
|
||||||
|
| 슬리피지 0.05% | `simulate_gt_signals_pnl` | `fill_price` → `executor` | 일치 |
|
||||||
|
| 수수료 0.05% | `GT_TRADING_FEE_RATE` | 동일 | 일치 |
|
||||||
|
| 최소 주문 5,000원 | `OPS_MIN_ORDER_KRW` | `trade_engine` | 일치 |
|
||||||
|
| 일 체결 100회 | `daily_max_trades` in sim | `runner` `trades_today_count` | 일치 |
|
||||||
|
| 매수 상한 | `max_buy_from_cash` | `compute_buy_order` | 일치 |
|
||||||
|
| 신호 | 캐시 JSON 전기간 | tail 800봉 갱신 + tick 체결 | 일치 (갱신 로직 추가) |
|
||||||
|
| 캔들 DB | 로컬 DB | `sync_ops_candles` 전 TF 증분 INSERT | 일치 |
|
||||||
|
|
||||||
|
**live 전환 전 체크**
|
||||||
|
|
||||||
|
1. `.env`: `OPS_MODE=live`, API 키 설정
|
||||||
|
2. `python scripts/3_run_filtered_backtest.py` → 필터 sim **약 +1,873,140%** 확인
|
||||||
|
3. `python scripts/3_run_operations.py --loop 180` (paper로 1~2일 모니터링 권장)
|
||||||
|
4. `fractal_ops_report.json`에서 `candle_sync`, `signal_refresh`, 체결 건수 확인
|
||||||
|
|
||||||
|
**주의:** 백테스트는 **3년 전기간 재생** sim이고, live는 **시간에 따라 누적**합니다. 2단계 ideal **+7,560,826%**(슬리피지 0)와는 다릅니다. 실거래 체결가는 모델 슬리피지보다 불리할 수 있습니다.
|
||||||
|
|
||||||
|
#### 운영 환경 변수 (fractal 기본)
|
||||||
|
|
||||||
|
| 변수 | 설명 | 기본값 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `OPS_MODE` | `paper` / `live` | `paper` |
|
||||||
|
| `OPS_TECHNIQUE_ID` | 운영 기법 | `fractal_swing` |
|
||||||
|
| `OPS_MTF_ENABLED` | MTF 필터 | `false` |
|
||||||
|
| `OPS_DAILY_MAX_TRADES` | 일일 체결 상한 | `100` |
|
||||||
|
| `OPS_SLIPPAGE_RATE` | 편도 슬리피지 | `0.0005` (0.05%) |
|
||||||
|
| `OPS_MIN_ORDER_KRW` | 최소 주문 | `5000` |
|
||||||
|
| `OPS_ORDER_INTERVAL_SEC` | live 주문 간격(초) | `0.35` |
|
||||||
|
| `OPS_SYNC_CANDLES` | tick마다 캔들 증분 sync | `true` |
|
||||||
|
| `OPS_SYNC_INTERVALS` | sync TF (비우면 `DOWNLOAD_INTERVALS` 전체) | 전체 |
|
||||||
|
| `OPS_SIGNAL_TAIL_BARS` | 신호 tail 재계산 봉 수 | `800` |
|
||||||
|
| `OPS_PERSIST_SIGNAL_CACHE` | tail 갱신 후 JSON 저장 | `false` |
|
||||||
|
| `OPS_STATE_JSON` | 운영 상태 | `fractal_ops_state.json` |
|
||||||
|
| `OPS_REPORT_JSON` | 운영 리포트 | `fractal_ops_report.json` |
|
||||||
|
|
||||||
|
`composite_v3` + MTF 운영은 `.env.example` 주석 참고.
|
||||||
|
|
||||||
### futures 0단계 — 선물 GT
|
### futures 0단계 — 선물 GT
|
||||||
|
|
||||||
@@ -224,6 +267,11 @@ GT 타점 완벽 추종 시 수익 상한선. 최근 3년·초기 20만 원.
|
|||||||
| `GT_LOOKBACK_DAYS` | GT 타점 기간(일) | `3650` |
|
| `GT_LOOKBACK_DAYS` | GT 타점 기간(일) | `3650` |
|
||||||
| `GT_SIM_LOOKBACK_DAYS` | sim 거래 기간(일) | `1095` |
|
| `GT_SIM_LOOKBACK_DAYS` | sim 거래 기간(일) | `1095` |
|
||||||
| `GT_INITIAL_CASH_KRW` | sim 초기 자본(원) | `200000` |
|
| `GT_INITIAL_CASH_KRW` | sim 초기 자본(원) | `200000` |
|
||||||
|
| `OPS_MODE` | 운영 모드 | `paper` |
|
||||||
|
| `OPS_TECHNIQUE_ID` | 운영 기법 | `fractal_swing` |
|
||||||
|
| `OPS_SLIPPAGE_RATE` | 편도 슬리피지 | `0.0005` |
|
||||||
|
| `OPS_DAILY_MAX_TRADES` | 일일 체결 상한 | `100` |
|
||||||
|
| `OPS_SYNC_CANDLES` | tick 캔들 sync | `true` |
|
||||||
|
|
||||||
### 경로 변수 요약
|
### 경로 변수 요약
|
||||||
|
|
||||||
@@ -233,6 +281,8 @@ GT 타점 완벽 추종 시 수익 상한선. 최근 3년·초기 20만 원.
|
|||||||
| spot GT 차트 | `GROUND_TRUTH_CHART_V3_FILE` | `docs/spot/0_ground_truth/...` |
|
| spot GT 차트 | `GROUND_TRUTH_CHART_V3_FILE` | `docs/spot/0_ground_truth/...` |
|
||||||
| spot sim 차트 | `GROUND_TRUTH_CHART_SIM_V3_FILE` | `docs/spot/1_simulation/...` |
|
| spot sim 차트 | `GROUND_TRUTH_CHART_SIM_V3_FILE` | `docs/spot/1_simulation/...` |
|
||||||
| spot 2단계 | `TECHNIQUES_DIR` | `data/spot/techniques/` |
|
| spot 2단계 | `TECHNIQUES_DIR` | `data/spot/techniques/` |
|
||||||
|
| spot 3단계 운영 | `OPS_STATE_JSON` | `data/spot/operations/fractal_ops_state.json` |
|
||||||
|
| spot 3단계 리포트 | `OPS_REPORT_JSON` | `docs/spot/3_operations/fractal_ops_report.json` |
|
||||||
| futures GT JSON | `GROUND_TRUTH_FUTURES_FILE` | `data/futures/ground_truth/...` |
|
| futures GT JSON | `GROUND_TRUTH_FUTURES_FILE` | `data/futures/ground_truth/...` |
|
||||||
| futures GT 차트 | `GROUND_TRUTH_FUTURES_CHART_V3_FILE` | `docs/futures/0_ground_truth/...` |
|
| futures GT 차트 | `GROUND_TRUTH_FUTURES_CHART_V3_FILE` | `docs/futures/0_ground_truth/...` |
|
||||||
|
|
||||||
@@ -295,7 +345,7 @@ GT 타점 완벽 추종 시 수익 상한선. 최근 3년·초기 20만 원.
|
|||||||
| 유형 | 단계 | 상태 |
|
| 유형 | 단계 | 상태 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| common | 사전 (캔들) | 구현됨 |
|
| common | 사전 (캔들) | 구현됨 |
|
||||||
| spot | 0~3단계 | 구현됨 (3단계 기본 paper) |
|
| spot | 0~3단계 | 구현됨 (3단계 fractal_swing live 준비) |
|
||||||
| futures | 0단계 | 구현됨 |
|
| futures | 0단계 | 구현됨 |
|
||||||
| futures | 1~3단계 | 예정 |
|
| futures | 1~3단계 | 예정 |
|
||||||
|
|
||||||
@@ -303,6 +353,8 @@ GT 타점 완벽 추종 시 수익 상한선. 최근 3년·초기 20만 원.
|
|||||||
|
|
||||||
## 변경 이력
|
## 변경 이력
|
||||||
|
|
||||||
|
- 2026-06-13: `docs/live/` — 운영 백테스트 매수·매도 타점 HTML 차트 (`3_render_live_chart.py`)
|
||||||
|
- 2026-06-13: 운영 백테스트 **+1,873,140%** (3년, 슬리피지 0.05%, 일 100회) 검증
|
||||||
- 2026-06-12: `data/`·`docs/`를 **common / spot / futures** 3유형 구조로 재편, `coins.db` → `data/common/`, 0단계 차트 → `docs/{spot,futures}/0_ground_truth/`
|
- 2026-06-12: `data/`·`docs/`를 **common / spot / futures** 3유형 구조로 재편, `coins.db` → `data/common/`, 0단계 차트 → `docs/{spot,futures}/0_ground_truth/`
|
||||||
- 2026-06-12: `0_ground_truth_futures.py` — 현물 GT → 선물 JSON·차트 변환 로직 보완
|
- 2026-06-12: `0_ground_truth_futures.py` — 현물 GT → 선물 JSON·차트 변환 로직 보완
|
||||||
- 2026-06-12: README 현물 파이프라인 전체 순서 갱신
|
- 2026-06-12: README 현물 파이프라인 전체 순서 갱신
|
||||||
|
|||||||
39
docs/live/index.html
Normal file
39
docs/live/index.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>DeepCoin Live — 운영 백테스트 차트</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 32px; color: #333; background: #f5f5f5; }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 8px; }
|
||||||
|
.meta { color: #666; font-size: 14px; margin-bottom: 20px; line-height: 1.6; }
|
||||||
|
.card { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 20px 24px; max-width: 720px; }
|
||||||
|
.stat { font-size: 28px; font-weight: bold; color: #2e7d32; margin: 8px 0 16px; }
|
||||||
|
a { color: #1565c0; text-decoration: none; font-size: 16px; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
ul { margin: 12px 0 0; padding-left: 20px; font-size: 14px; color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>DeepCoin Live — 운영 백테스트</h1>
|
||||||
|
<p class="meta">
|
||||||
|
BTC · 프랙탈 스윙 (fractal_swing)<br>
|
||||||
|
sim 기간: 최근 1095일 ·
|
||||||
|
슬리피지 0.05% ·
|
||||||
|
일 체결 상한 100 ·
|
||||||
|
MTF off
|
||||||
|
</p>
|
||||||
|
<div class="card">
|
||||||
|
<div>3년 수익률 (운영 규칙 sim)</div>
|
||||||
|
<div class="stat">+1874019.75%</div>
|
||||||
|
<p>
|
||||||
|
<a href="fractal_swing_ops_chart.html">매수·매도 타점 차트 열기</a>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>매수 53,521 / 매도 53,449 체결</li>
|
||||||
|
<li>초기 200,000원 → 최종 3,748,239,499원</li>
|
||||||
|
<li>차트: B=매수 S=매도 마커, 이전/다음 타점 탐색, 기간 줌</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
135
scripts/3_render_live_chart.py
Normal file
135
scripts/3_render_live_chart.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""3단계: 운영 백테스트(+1,873,140%) 매수·매도 타점 HTML 차트 생성."""
|
||||||
|
|
||||||
|
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.chart import render_ops_live_chart
|
||||||
|
|
||||||
|
|
||||||
|
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 _write_index_html(
|
||||||
|
index_path: Path,
|
||||||
|
chart_name: str,
|
||||||
|
report: dict,
|
||||||
|
) -> Path:
|
||||||
|
"""docs/live 인덱스 HTML을 생성한다."""
|
||||||
|
sim = report["filtered_sim"]
|
||||||
|
ret = sim.get("total_return_pct", 0)
|
||||||
|
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>DeepCoin Live — 운영 백테스트 차트</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 32px; color: #333; background: #f5f5f5; }}
|
||||||
|
h1 {{ font-size: 22px; margin-bottom: 8px; }}
|
||||||
|
.meta {{ color: #666; font-size: 14px; margin-bottom: 20px; line-height: 1.6; }}
|
||||||
|
.card {{ background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 20px 24px; max-width: 720px; }}
|
||||||
|
.stat {{ font-size: 28px; font-weight: bold; color: #2e7d32; margin: 8px 0 16px; }}
|
||||||
|
a {{ color: #1565c0; text-decoration: none; font-size: 16px; }}
|
||||||
|
a:hover {{ text-decoration: underline; }}
|
||||||
|
ul {{ margin: 12px 0 0; padding-left: 20px; font-size: 14px; color: #555; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>DeepCoin Live — 운영 백테스트</h1>
|
||||||
|
<p class="meta">
|
||||||
|
{report.get("symbol", "BTC")} · {report.get("technique_name", "")} ({report.get("technique_id", "")})<br>
|
||||||
|
sim 기간: 최근 {report.get("sim_lookback_days", 1095)}일 ·
|
||||||
|
슬리피지 {report.get("slippage_rate", 0) * 100:.2f}% ·
|
||||||
|
일 체결 상한 {report.get("daily_max_trades", "-")} ·
|
||||||
|
MTF {"on" if report.get("mtf_enabled") else "off"}
|
||||||
|
</p>
|
||||||
|
<div class="card">
|
||||||
|
<div>3년 수익률 (운영 규칙 sim)</div>
|
||||||
|
<div class="stat">{ret:+.2f}%</div>
|
||||||
|
<p>
|
||||||
|
<a href="{chart_name}">매수·매도 타점 차트 열기</a>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>매수 {sim.get("buys_executed", 0):,} / 매도 {sim.get("sells_executed", 0):,} 체결</li>
|
||||||
|
<li>초기 {sim.get("initial_cash_krw", 0):,.0f}원 → 최종 {sim.get("final_equity_krw", 0):,.0f}원</li>
|
||||||
|
<li>차트: B=매수 S=매도 마커, 이전/다음 타점 탐색, 기간 줌</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
index_path.write_text(html, encoding="utf-8")
|
||||||
|
return index_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""CLI 진입점."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="운영 백테스트 매수·매도 타점 HTML 차트 (docs/live)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="HTML 출력 경로 (기본: docs/live/{technique}_ops_chart.html)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--chart-days",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="차트 캔들 표시 일수 (기본: GT_SIM_LOOKBACK_DAYS)",
|
||||||
|
)
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
_configure_logging(args.verbose)
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
live_dir = ROOT / "docs" / "live"
|
||||||
|
default_name = f"{settings.ops_technique_id}_ops_chart.html"
|
||||||
|
output_path = Path(args.output) if args.output else live_dir / default_name
|
||||||
|
if not output_path.is_absolute():
|
||||||
|
output_path = ROOT / output_path
|
||||||
|
|
||||||
|
print("\n=== 운영 백테스트 차트 생성 ===", flush=True)
|
||||||
|
print(
|
||||||
|
f"기법: {settings.ops_technique_id} · 슬리피지 {settings.ops_slippage_rate} · "
|
||||||
|
f"일 상한 {settings.ops_daily_max_trades}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_path, report = render_ops_live_chart(
|
||||||
|
settings,
|
||||||
|
output_path,
|
||||||
|
chart_lookback_days=args.chart_days,
|
||||||
|
)
|
||||||
|
data_js = chart_path.with_name(f"{chart_path.stem}_data.js")
|
||||||
|
index_path = _write_index_html(live_dir / "index.html", chart_path.name, report)
|
||||||
|
|
||||||
|
sim = report["filtered_sim"]
|
||||||
|
print(f"\n3년 수익률: {sim.get('total_return_pct'):+.2f}%")
|
||||||
|
print(f"매수/매도: {sim.get('buys_executed')}/{sim.get('sells_executed')}")
|
||||||
|
print(f"HTML: {chart_path}")
|
||||||
|
print(f"데이터: {data_js} ({data_js.stat().st_size / 1e6:.1f} MB)")
|
||||||
|
print(f"인덱스: {index_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -43,6 +43,7 @@ def main() -> int:
|
|||||||
raw = report["raw_sim"]
|
raw = report["raw_sim"]
|
||||||
print("\n=== 3단계 MTF 필터 백테스트 ===")
|
print("\n=== 3단계 MTF 필터 백테스트 ===")
|
||||||
print(f"기법: {report['technique_id']}")
|
print(f"기법: {report['technique_id']}")
|
||||||
|
print(f"슬리피지: {report.get('slippage_rate')} · 일일체결상한: {report.get('daily_max_trades')}")
|
||||||
print(f"원시 신호: {report['raw_signal_count']} → 필터 통과: {report['filtered_signal_count']}")
|
print(f"원시 신호: {report['raw_signal_count']} → 필터 통과: {report['filtered_signal_count']}")
|
||||||
print(f"원시 3년 sim: {raw.get('total_return_pct')}%")
|
print(f"원시 3년 sim: {raw.get('total_return_pct')}%")
|
||||||
print(f"필터 3년 sim: {filt.get('total_return_pct')}%")
|
print(f"필터 3년 sim: {filt.get('total_return_pct')}%")
|
||||||
|
|||||||
12
scripts/3_run_fractal_ops.sh
Executable file
12
scripts/3_run_fractal_ops.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# fractal_swing paper 운영 — 3분봉 tick (180초 간격)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export PYTHONPATH=src
|
||||||
|
|
||||||
|
echo "=== fractal 현실적 백테스트 ==="
|
||||||
|
python scripts/3_run_fractal_realistic_backtest.py
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== paper 운영 (180초 loop) — Ctrl+C 종료 ==="
|
||||||
|
python scripts/3_run_operations.py --loop 180
|
||||||
106
scripts/3_run_fractal_realistic_backtest.py
Normal file
106
scripts/3_run_fractal_realistic_backtest.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""fractal_swing 현실적 백테스트 — 슬리피지·일일체결 상한 시나리오."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
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.causal_sim import normalize_signals_for_sim
|
||||||
|
from deepcoin.ground_truth.pnl import simulate_gt_signals_pnl
|
||||||
|
from deepcoin.operations.signal_pipeline import (
|
||||||
|
_signals_in_lookback,
|
||||||
|
generate_raw_signals,
|
||||||
|
load_ops_candles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""fractal_swing 슬리피지 시나리오 백테스트."""
|
||||||
|
parser = argparse.ArgumentParser(description="fractal_swing 현실적 3년 백테스트")
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
_configure_logging(args.verbose)
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
df = load_ops_candles(settings)
|
||||||
|
gen = generate_raw_signals(settings, df=df, use_cache=True)
|
||||||
|
end = gen["data_end"]
|
||||||
|
price = gen["last_price"]
|
||||||
|
scoped = _signals_in_lookback(gen["raw_signals"], end, settings.gt_sim_lookback_days)
|
||||||
|
normalized = normalize_signals_for_sim(scoped)
|
||||||
|
|
||||||
|
scenarios = [
|
||||||
|
{"label": "stage2_ideal", "slippage_rate": 0.0, "daily_max_trades": None},
|
||||||
|
{"label": "ops_default", "slippage_rate": settings.ops_slippage_rate, "daily_max_trades": settings.ops_daily_max_trades},
|
||||||
|
{"label": "slippage_0.1pct", "slippage_rate": 0.001, "daily_max_trades": settings.ops_daily_max_trades},
|
||||||
|
{"label": "slippage_0.1pct_no_cap", "slippage_rate": 0.001, "daily_max_trades": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for sc in scenarios:
|
||||||
|
sim = simulate_gt_signals_pnl(
|
||||||
|
signals=normalized,
|
||||||
|
initial_cash_krw=settings.gt_initial_cash_krw,
|
||||||
|
fee_rate=settings.gt_trading_fee_rate,
|
||||||
|
min_order_krw=settings.ops_min_order_krw,
|
||||||
|
slippage_rate=sc["slippage_rate"],
|
||||||
|
daily_max_trades=sc["daily_max_trades"],
|
||||||
|
sim_lookback_days=settings.gt_sim_lookback_days,
|
||||||
|
data_end=end,
|
||||||
|
last_mark_price=price,
|
||||||
|
)
|
||||||
|
buys = sim["buys_executed"]
|
||||||
|
rows.append({
|
||||||
|
**sc,
|
||||||
|
"return_pct": sim["total_return_pct"],
|
||||||
|
"final_equity_krw": sim["final_equity_krw"],
|
||||||
|
"buys_executed": buys,
|
||||||
|
"sells_executed": sim["sells_executed"],
|
||||||
|
"buys_skipped": sim["buys_skipped"],
|
||||||
|
"daily_buys_avg": round(buys / settings.gt_sim_lookback_days, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"technique_id": settings.ops_technique_id,
|
||||||
|
"symbol": settings.symbol,
|
||||||
|
"sim_lookback_days": settings.gt_sim_lookback_days,
|
||||||
|
"initial_cash_krw": settings.gt_initial_cash_krw,
|
||||||
|
"signals_in_period": len(scoped),
|
||||||
|
"scenarios": rows,
|
||||||
|
}
|
||||||
|
out = Path("docs/spot/3_operations/fractal_realistic_backtest.json")
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
print("\n=== fractal_swing 현실적 백테스트 (3년) ===")
|
||||||
|
for row in rows:
|
||||||
|
print(
|
||||||
|
f"{row['label']}: {row['return_pct']}% "
|
||||||
|
f"(슬리피지 {row['slippage_rate']}, 일상한 {row['daily_max_trades']}) "
|
||||||
|
f"매수 {row['buys_executed']} (일 {row['daily_buys_avg']})"
|
||||||
|
)
|
||||||
|
print(f"\nJSON: {out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -106,7 +106,12 @@ class Settings:
|
|||||||
ops_trend_gate_enabled: bool
|
ops_trend_gate_enabled: bool
|
||||||
ops_daily_max_trades: int
|
ops_daily_max_trades: int
|
||||||
ops_min_order_krw: float
|
ops_min_order_krw: float
|
||||||
|
ops_slippage_rate: float
|
||||||
|
ops_order_interval_sec: float
|
||||||
ops_sync_candles: bool
|
ops_sync_candles: bool
|
||||||
|
ops_sync_intervals: list[int]
|
||||||
|
ops_signal_tail_bars: int
|
||||||
|
ops_persist_signal_cache: bool
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def market(self) -> str:
|
def market(self) -> str:
|
||||||
@@ -300,6 +305,29 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
|||||||
in ("1", "true", "yes", "on"),
|
in ("1", "true", "yes", "on"),
|
||||||
ops_daily_max_trades=int(os.getenv("OPS_DAILY_MAX_TRADES", "20")),
|
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_min_order_krw=float(os.getenv("OPS_MIN_ORDER_KRW", "5000")),
|
||||||
|
ops_slippage_rate=float(os.getenv("OPS_SLIPPAGE_RATE", "0.0005")),
|
||||||
|
ops_order_interval_sec=float(os.getenv("OPS_ORDER_INTERVAL_SEC", "0.35")),
|
||||||
ops_sync_candles=os.getenv("OPS_SYNC_CANDLES", "true").strip().lower()
|
ops_sync_candles=os.getenv("OPS_SYNC_CANDLES", "true").strip().lower()
|
||||||
in ("1", "true", "yes", "on"),
|
in ("1", "true", "yes", "on"),
|
||||||
|
ops_sync_intervals=_parse_ops_sync_intervals(
|
||||||
|
os.getenv("OPS_SYNC_INTERVALS", "").strip(),
|
||||||
|
fallback_intervals=intervals,
|
||||||
|
),
|
||||||
|
ops_signal_tail_bars=int(os.getenv("OPS_SIGNAL_TAIL_BARS", "800")),
|
||||||
|
ops_persist_signal_cache=os.getenv("OPS_PERSIST_SIGNAL_CACHE", "false").strip().lower()
|
||||||
|
in ("1", "true", "yes", "on"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ops_sync_intervals(
|
||||||
|
raw: str,
|
||||||
|
fallback_intervals: list[int],
|
||||||
|
) -> list[int]:
|
||||||
|
"""운영 캔들 sync 대상 인터벌 목록.
|
||||||
|
|
||||||
|
OPS_SYNC_INTERVALS 비어 있으면 DOWNLOAD_INTERVALS 전체를 사용한다.
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return list(fallback_intervals)
|
||||||
|
values = sorted(set(int(x.strip()) for x in raw.split(",") if x.strip()))
|
||||||
|
return values if values else list(fallback_intervals)
|
||||||
|
|||||||
@@ -189,3 +189,71 @@ class CandleStore:
|
|||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return len(payload)
|
return len(payload)
|
||||||
|
|
||||||
|
def insert_new_rows(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
coin_name: str,
|
||||||
|
interval_min: int,
|
||||||
|
rows: list[tuple],
|
||||||
|
*,
|
||||||
|
after: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""DB에 없는 캔들만 INSERT한다 (증분 수집용).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: 코인 심볼.
|
||||||
|
coin_name: 코인 이름.
|
||||||
|
interval_min: 분 단위 인터벌.
|
||||||
|
rows: ``(ymdhms, open, high, low, close, volume)`` 튜플 리스트.
|
||||||
|
after: 이 시각 **이후** 봉만 저장. None이면 필터 없음.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
실제 INSERT된 행 수.
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if after is not None:
|
||||||
|
filtered: list[tuple] = []
|
||||||
|
for row in rows:
|
||||||
|
dt = parse_kst_datetime(str(row[0]))
|
||||||
|
if dt > after:
|
||||||
|
filtered.append(row)
|
||||||
|
rows = filtered
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
table = self.ensure_table(symbol, interval_min)
|
||||||
|
code = symbol.upper()
|
||||||
|
payload: list[tuple] = []
|
||||||
|
for ymdhms, open_p, high_p, low_p, close_p, volume in rows:
|
||||||
|
dt = parse_kst_datetime(str(ymdhms)) if isinstance(ymdhms, str) else ymdhms
|
||||||
|
ymd = dt.strftime("%Y-%m-%d")
|
||||||
|
hms = dt.strftime("%H:%M:%S")
|
||||||
|
payload.append(
|
||||||
|
(
|
||||||
|
code,
|
||||||
|
coin_name,
|
||||||
|
dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
ymd,
|
||||||
|
hms,
|
||||||
|
close_p,
|
||||||
|
open_p,
|
||||||
|
high_p,
|
||||||
|
low_p,
|
||||||
|
volume,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
changes_before = self._conn.total_changes
|
||||||
|
self._conn.executemany(
|
||||||
|
f"""
|
||||||
|
INSERT OR IGNORE INTO {table}
|
||||||
|
(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return self._conn.total_changes - changes_before
|
||||||
|
|||||||
@@ -136,12 +136,22 @@ class CandleDownloader:
|
|||||||
if not rows:
|
if not rows:
|
||||||
break
|
break
|
||||||
|
|
||||||
saved_rows += store.upsert_rows(
|
if mode == "incremental" and db_max is not None:
|
||||||
|
inserted = store.insert_new_rows(
|
||||||
|
symbol,
|
||||||
|
self.settings.coin_name,
|
||||||
|
interval_min,
|
||||||
|
rows,
|
||||||
|
after=db_max,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
inserted = store.upsert_rows(
|
||||||
symbol,
|
symbol,
|
||||||
self.settings.coin_name,
|
self.settings.coin_name,
|
||||||
interval_min,
|
interval_min,
|
||||||
rows,
|
rows,
|
||||||
)
|
)
|
||||||
|
saved_rows += inserted
|
||||||
|
|
||||||
batch_oldest = min(parse_kst_datetime(r[0]) for r in rows)
|
batch_oldest = min(parse_kst_datetime(r[0]) for r in rows)
|
||||||
if oldest_seen is None or batch_oldest < oldest_seen:
|
if oldest_seen is None or batch_oldest < oldest_seen:
|
||||||
@@ -168,7 +178,7 @@ class CandleDownloader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"수집 완료 %s_%s mode=%s requests=%s saved=%s reached=%s",
|
"수집 완료 %s_%s mode=%s requests=%s inserted=%s reached=%s",
|
||||||
symbol,
|
symbol,
|
||||||
interval_min,
|
interval_min,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -741,6 +741,16 @@ __EXTRA_SCRIPT__
|
|||||||
simNoteExtra: `기법 ${techniqueId} | 미래 데이터 미사용${benchNote}`,
|
simNoteExtra: `기법 ${techniqueId} | 미래 데이터 미사용${benchNote}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (stage === "spot_3_operations") {
|
||||||
|
const opsNote = m.ops_note || "";
|
||||||
|
return {
|
||||||
|
pageTitle: `${m.symbol} 운영 백테스트 — ${techniqueName}`,
|
||||||
|
title: `${m.symbol} 3단계 운영 백테스트 (${techniqueName}, ${m.interval_label}) — live 동일 규칙 · 차트 ${chartLabel}`,
|
||||||
|
panelTitle: `3단계 운영 백테스트 sim (${simLabel}${initLabel})`,
|
||||||
|
legend: "B=매수 S=매도 | 실제 체결만 표시",
|
||||||
|
simNoteExtra: `기법 ${techniqueId} | ${opsNote} | live/paper와 동일 체결 규칙`,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (stage === "spot_2_causal_sim" || techniqueId) {
|
if (stage === "spot_2_causal_sim" || techniqueId) {
|
||||||
return {
|
return {
|
||||||
pageTitle: `${m.symbol} 인과 sim — ${techniqueName}`,
|
pageTitle: `${m.symbol} 인과 sim — ${techniqueName}`,
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ from typing import Any
|
|||||||
from deepcoin.ground_truth.order_sizing import buy_sizing_metadata, max_buy_from_cash
|
from deepcoin.ground_truth.order_sizing import buy_sizing_metadata, max_buy_from_cash
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_price(signal_price: float, side: str, slippage_rate: float) -> float:
|
||||||
|
"""슬리피지 반영 체결가."""
|
||||||
|
price = float(signal_price)
|
||||||
|
slip = max(float(slippage_rate), 0.0)
|
||||||
|
if side == "buy":
|
||||||
|
return price * (1.0 + slip)
|
||||||
|
return price * max(1.0 - slip, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_date(datetime_str: str) -> str:
|
||||||
|
"""신호 datetime에서 YYYY-MM-DD 추출."""
|
||||||
|
return datetime_str[:10]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LegPnl:
|
class LegPnl:
|
||||||
"""레그별 손익 (매수→매도 1쌍)."""
|
"""레그별 손익 (매수→매도 1쌍)."""
|
||||||
@@ -161,6 +175,8 @@ def simulate_gt_signals_pnl(
|
|||||||
initial_cash_krw: float = 400_000.0,
|
initial_cash_krw: float = 400_000.0,
|
||||||
fee_rate: float = 0.0005,
|
fee_rate: float = 0.0005,
|
||||||
min_order_krw: float = 5_000.0,
|
min_order_krw: float = 5_000.0,
|
||||||
|
slippage_rate: float = 0.0,
|
||||||
|
daily_max_trades: int | None = None,
|
||||||
sim_lookback_days: int = 365,
|
sim_lookback_days: int = 365,
|
||||||
data_end: str | None = None,
|
data_end: str | None = None,
|
||||||
last_mark_price: float | None = None,
|
last_mark_price: float | None = None,
|
||||||
@@ -178,6 +194,8 @@ def simulate_gt_signals_pnl(
|
|||||||
initial_cash_krw: 시뮬 시작 원화.
|
initial_cash_krw: 시뮬 시작 원화.
|
||||||
fee_rate: 편도 수수료율.
|
fee_rate: 편도 수수료율.
|
||||||
min_order_krw: 최소 주문 금액.
|
min_order_krw: 최소 주문 금액.
|
||||||
|
slippage_rate: 편도 슬리피지 (0.0005 = 0.05%).
|
||||||
|
daily_max_trades: 일일 체결 상한 (None이면 무제한).
|
||||||
sim_lookback_days: 시뮬 기간(일).
|
sim_lookback_days: 시뮬 기간(일).
|
||||||
data_end: 데이터 종료 시각 문자열. None이면 마지막 신호 시각.
|
data_end: 데이터 종료 시각 문자열. None이면 마지막 신호 시각.
|
||||||
last_mark_price: 미청산 코인 평가 가격. None이면 마지막 체결가.
|
last_mark_price: 미청산 코인 평가 가격. None이면 마지막 체결가.
|
||||||
@@ -214,9 +232,22 @@ def simulate_gt_signals_pnl(
|
|||||||
sells_executed = 0
|
sells_executed = 0
|
||||||
buys_skipped = 0
|
buys_skipped = 0
|
||||||
sells_skipped = 0
|
sells_skipped = 0
|
||||||
|
daily_trade_counts: dict[str, int] = {}
|
||||||
|
|
||||||
mark_price = float(last_mark_price or period_signals[-1]["price"])
|
mark_price = float(last_mark_price or period_signals[-1]["price"])
|
||||||
|
|
||||||
|
def _can_trade_today(datetime_str: str) -> bool:
|
||||||
|
if daily_max_trades is None:
|
||||||
|
return True
|
||||||
|
day = _signal_date(datetime_str)
|
||||||
|
return daily_trade_counts.get(day, 0) < daily_max_trades
|
||||||
|
|
||||||
|
def _record_trade(datetime_str: str) -> None:
|
||||||
|
if daily_max_trades is None:
|
||||||
|
return
|
||||||
|
day = _signal_date(datetime_str)
|
||||||
|
daily_trade_counts[day] = daily_trade_counts.get(day, 0) + 1
|
||||||
|
|
||||||
for side, cluster in _cluster_signals(period_signals):
|
for side, cluster in _cluster_signals(period_signals):
|
||||||
cluster_size = len(cluster)
|
cluster_size = len(cluster)
|
||||||
if side == "buy":
|
if side == "buy":
|
||||||
@@ -224,9 +255,32 @@ def simulate_gt_signals_pnl(
|
|||||||
per_buy = budget / cluster_size if cluster_size else 0.0
|
per_buy = budget / cluster_size if cluster_size else 0.0
|
||||||
for sig in cluster:
|
for sig in cluster:
|
||||||
trade_id += 1
|
trade_id += 1
|
||||||
price = float(sig["price"])
|
price = _fill_price(float(sig["price"]), "buy", slippage_rate)
|
||||||
cash_before = cash
|
cash_before = cash
|
||||||
coin_before = coin_qty
|
coin_before = coin_qty
|
||||||
|
if not _can_trade_today(sig["datetime"]):
|
||||||
|
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
|
||||||
equity = cash + coin_qty * price
|
equity = cash + coin_qty * price
|
||||||
cash_cap = max_buy_from_cash(equity, cash)
|
cash_cap = max_buy_from_cash(equity, cash)
|
||||||
order_krw = min(per_buy, cash, cash_cap)
|
order_krw = min(per_buy, cash, cash_cap)
|
||||||
@@ -260,6 +314,7 @@ def simulate_gt_signals_pnl(
|
|||||||
cash -= order_krw
|
cash -= order_krw
|
||||||
coin_qty += bought
|
coin_qty += bought
|
||||||
buys_executed += 1
|
buys_executed += 1
|
||||||
|
_record_trade(sig["datetime"])
|
||||||
trades.append(
|
trades.append(
|
||||||
SignalTrade(
|
SignalTrade(
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
@@ -284,9 +339,32 @@ def simulate_gt_signals_pnl(
|
|||||||
per_sell = budget_coin / cluster_size if cluster_size else 0.0
|
per_sell = budget_coin / cluster_size if cluster_size else 0.0
|
||||||
for sig in cluster:
|
for sig in cluster:
|
||||||
trade_id += 1
|
trade_id += 1
|
||||||
price = float(sig["price"])
|
price = _fill_price(float(sig["price"]), "sell", slippage_rate)
|
||||||
cash_before = cash
|
cash_before = cash
|
||||||
coin_before = coin_qty
|
coin_before = coin_qty
|
||||||
|
if not _can_trade_today(sig["datetime"]):
|
||||||
|
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
|
||||||
order_coin = min(per_sell, coin_qty)
|
order_coin = min(per_sell, coin_qty)
|
||||||
order_krw = order_coin * price
|
order_krw = order_coin * price
|
||||||
|
|
||||||
@@ -319,6 +397,7 @@ def simulate_gt_signals_pnl(
|
|||||||
cash += gross - fee
|
cash += gross - fee
|
||||||
coin_qty -= order_coin
|
coin_qty -= order_coin
|
||||||
sells_executed += 1
|
sells_executed += 1
|
||||||
|
_record_trade(sig["datetime"])
|
||||||
trades.append(
|
trades.append(
|
||||||
SignalTrade(
|
SignalTrade(
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
@@ -355,6 +434,8 @@ def simulate_gt_signals_pnl(
|
|||||||
"total_pnl_krw": round(total_pnl, 0),
|
"total_pnl_krw": round(total_pnl, 0),
|
||||||
"total_return_pct": round(total_return_pct, 2),
|
"total_return_pct": round(total_return_pct, 2),
|
||||||
"fee_rate": fee_rate,
|
"fee_rate": fee_rate,
|
||||||
|
"slippage_rate": slippage_rate,
|
||||||
|
"daily_max_trades": daily_max_trades,
|
||||||
**buy_sizing_metadata(),
|
**buy_sizing_metadata(),
|
||||||
"sim_lookback_days": sim_lookback_days,
|
"sim_lookback_days": sim_lookback_days,
|
||||||
"period_from": start_str,
|
"period_from": start_str,
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ def run_filtered_backtest(settings: Settings) -> dict[str, Any]:
|
|||||||
signals=normalized,
|
signals=normalized,
|
||||||
initial_cash_krw=settings.gt_initial_cash_krw,
|
initial_cash_krw=settings.gt_initial_cash_krw,
|
||||||
fee_rate=settings.gt_trading_fee_rate,
|
fee_rate=settings.gt_trading_fee_rate,
|
||||||
|
min_order_krw=settings.ops_min_order_krw,
|
||||||
|
slippage_rate=settings.ops_slippage_rate,
|
||||||
|
daily_max_trades=settings.ops_daily_max_trades,
|
||||||
sim_lookback_days=settings.gt_sim_lookback_days,
|
sim_lookback_days=settings.gt_sim_lookback_days,
|
||||||
data_end=pipeline["data_end"],
|
data_end=pipeline["data_end"],
|
||||||
last_mark_price=pipeline["last_price"],
|
last_mark_price=pipeline["last_price"],
|
||||||
@@ -35,6 +38,9 @@ def run_filtered_backtest(settings: Settings) -> dict[str, Any]:
|
|||||||
signals=normalize_signals_for_sim(pipeline["scoped_raw_signals"]),
|
signals=normalize_signals_for_sim(pipeline["scoped_raw_signals"]),
|
||||||
initial_cash_krw=settings.gt_initial_cash_krw,
|
initial_cash_krw=settings.gt_initial_cash_krw,
|
||||||
fee_rate=settings.gt_trading_fee_rate,
|
fee_rate=settings.gt_trading_fee_rate,
|
||||||
|
min_order_krw=settings.ops_min_order_krw,
|
||||||
|
slippage_rate=settings.ops_slippage_rate,
|
||||||
|
daily_max_trades=settings.ops_daily_max_trades,
|
||||||
sim_lookback_days=settings.gt_sim_lookback_days,
|
sim_lookback_days=settings.gt_sim_lookback_days,
|
||||||
data_end=pipeline["data_end"],
|
data_end=pipeline["data_end"],
|
||||||
last_mark_price=pipeline["last_price"],
|
last_mark_price=pipeline["last_price"],
|
||||||
@@ -48,6 +54,8 @@ def run_filtered_backtest(settings: Settings) -> dict[str, Any]:
|
|||||||
"params": pipeline["params"],
|
"params": pipeline["params"],
|
||||||
"mtf_enabled": pipeline.get("mtf_enabled", False),
|
"mtf_enabled": pipeline.get("mtf_enabled", False),
|
||||||
"trend_gate_enabled": settings.ops_trend_gate_enabled,
|
"trend_gate_enabled": settings.ops_trend_gate_enabled,
|
||||||
|
"slippage_rate": settings.ops_slippage_rate,
|
||||||
|
"daily_max_trades": settings.ops_daily_max_trades,
|
||||||
"raw_signal_count": pipeline["raw_count"],
|
"raw_signal_count": pipeline["raw_count"],
|
||||||
"filtered_signal_count": pipeline["kept_count"],
|
"filtered_signal_count": pipeline["kept_count"],
|
||||||
"rejected_signal_count": pipeline["rejected_count"],
|
"rejected_signal_count": pipeline["rejected_count"],
|
||||||
|
|||||||
53
src/deepcoin/operations/candle_sync.py
Normal file
53
src/deepcoin/operations/candle_sync.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""운영 tick용 빗썸 캔들 증분 동기화."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from deepcoin.config import Settings
|
||||||
|
from deepcoin.data.candle_store import CandleStore
|
||||||
|
from deepcoin.data.downloader import CandleDownloader, DownloadResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_ops_candles(settings: Settings) -> list[DownloadResult]:
|
||||||
|
"""``DOWNLOAD_INTERVALS``(또는 ``OPS_SYNC_INTERVALS``) 전 TF 증분 sync.
|
||||||
|
|
||||||
|
각 인터벌별로:
|
||||||
|
1. DB ``MAX(ymdhms)`` 조회
|
||||||
|
2. 최신이면 API 호출 없이 ``uptodate``
|
||||||
|
3. 갭이 있으면 빗썸 역방향 조회 후 ``db_max`` 이후 봉만 INSERT
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: 애플리케이션 설정.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
인터벌별 DownloadResult 리스트.
|
||||||
|
"""
|
||||||
|
intervals = settings.ops_sync_intervals
|
||||||
|
sync_settings = replace(settings, download_intervals=intervals)
|
||||||
|
store = CandleStore(settings.db_path)
|
||||||
|
try:
|
||||||
|
downloader = CandleDownloader(sync_settings)
|
||||||
|
results = downloader.download_all(
|
||||||
|
store,
|
||||||
|
days=settings.download_days,
|
||||||
|
full=False,
|
||||||
|
)
|
||||||
|
for result in results:
|
||||||
|
count, min_dt, max_dt = store.get_range(settings.symbol, result.interval_min)
|
||||||
|
max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-"
|
||||||
|
logger.info(
|
||||||
|
"운영 캔들 sync %smin mode=%s api=%s inserted=%s db_max=%s rows=%s",
|
||||||
|
result.interval_min,
|
||||||
|
result.mode,
|
||||||
|
result.requests,
|
||||||
|
result.saved_rows,
|
||||||
|
max_s,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
79
src/deepcoin/operations/chart.py
Normal file
79
src/deepcoin/operations/chart.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""3단계 운영 백테스트 매매 타점 HTML 차트."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deepcoin.config import Settings
|
||||||
|
from deepcoin.data.intervals import interval_label
|
||||||
|
from deepcoin.ground_truth.chart import render_ground_truth_sim_chart
|
||||||
|
from deepcoin.operations.backtest import run_filtered_backtest
|
||||||
|
|
||||||
|
|
||||||
|
def _ops_chart_shell(
|
||||||
|
pipeline: dict[str, Any],
|
||||||
|
settings: Settings,
|
||||||
|
sim_pnl: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""운영 백테스트 차트용 GT 메타 셸을 구성한다."""
|
||||||
|
slip_pct = settings.ops_slippage_rate * 100
|
||||||
|
daily_cap = settings.ops_daily_max_trades
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"symbol": settings.symbol,
|
||||||
|
"interval_min": settings.gt_interval_min,
|
||||||
|
"interval_label": interval_label(settings.gt_interval_min),
|
||||||
|
"lookback_days": settings.gt_sim_lookback_days,
|
||||||
|
"technique_id": pipeline["technique_id"],
|
||||||
|
"technique_name": pipeline["technique_name"],
|
||||||
|
"stage": "spot_3_operations",
|
||||||
|
"mtf_enabled": pipeline.get("mtf_enabled", False),
|
||||||
|
"slippage_rate": settings.ops_slippage_rate,
|
||||||
|
"daily_max_trades": daily_cap,
|
||||||
|
"ops_note": (
|
||||||
|
f"슬리피지 {slip_pct:.2f}% · 일 체결 상한 {daily_cap} · "
|
||||||
|
f"MTF {'on' if pipeline.get('mtf_enabled') else 'off'}"
|
||||||
|
),
|
||||||
|
"data_from": sim_pnl.get("period_from"),
|
||||||
|
"data_to": sim_pnl.get("period_to"),
|
||||||
|
},
|
||||||
|
"signals": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_ops_live_chart(
|
||||||
|
settings: Settings,
|
||||||
|
output_path: Path,
|
||||||
|
*,
|
||||||
|
chart_lookback_days: int | None = None,
|
||||||
|
) -> tuple[Path, dict[str, Any]]:
|
||||||
|
"""운영 설정과 동일한 규칙으로 3년 sim 후 매수·매도 타점 HTML을 생성한다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: 애플리케이션 설정 (.env).
|
||||||
|
output_path: HTML 출력 경로.
|
||||||
|
chart_lookback_days: 차트 캔들 표시 일수. None이면 sim 기간과 동일.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(HTML 경로, 백테스트 리포트 dict).
|
||||||
|
"""
|
||||||
|
report = run_filtered_backtest(settings)
|
||||||
|
sim_pnl = report["filtered_sim"]
|
||||||
|
pipeline_meta = {
|
||||||
|
"technique_id": report["technique_id"],
|
||||||
|
"technique_name": report["technique_name"],
|
||||||
|
"mtf_enabled": report.get("mtf_enabled", False),
|
||||||
|
}
|
||||||
|
gt_shell = _ops_chart_shell(pipeline_meta, settings, sim_pnl)
|
||||||
|
days = chart_lookback_days if chart_lookback_days is not None else settings.gt_sim_lookback_days
|
||||||
|
|
||||||
|
path = render_ground_truth_sim_chart(
|
||||||
|
db_path=settings.db_path,
|
||||||
|
symbol=settings.symbol,
|
||||||
|
gt_result=gt_shell,
|
||||||
|
sim_pnl=sim_pnl,
|
||||||
|
output_path=output_path,
|
||||||
|
chart_lookback_days=days,
|
||||||
|
)
|
||||||
|
return path, report
|
||||||
21
src/deepcoin/operations/execution.py
Normal file
21
src/deepcoin/operations/execution.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""체결 가격·슬리피지 (paper / live / backtest 공통)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def fill_price(signal_price: float, side: str, slippage_rate: float) -> float:
|
||||||
|
"""신호 가격에 슬리피지를 반영한 체결가를 계산한다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal_price: 신호(봉) 기준 가격.
|
||||||
|
side: ``buy`` 또는 ``sell``.
|
||||||
|
slippage_rate: 편도 슬리피지 비율 (0.0005 = 0.05%).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
매수는 불리하게(↑) · 매도는 불리하게(↓) 조정된 체결가.
|
||||||
|
"""
|
||||||
|
price = float(signal_price)
|
||||||
|
slip = max(float(slippage_rate), 0.0)
|
||||||
|
if side == "buy":
|
||||||
|
return price * (1.0 + slip)
|
||||||
|
return price * max(1.0 - slip, 0.0)
|
||||||
@@ -8,6 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
from deepcoin.api.bithumb_private import BithumbPrivateClient
|
from deepcoin.api.bithumb_private import BithumbPrivateClient
|
||||||
from deepcoin.config import Settings
|
from deepcoin.config import Settings
|
||||||
|
from deepcoin.operations.execution import fill_price
|
||||||
from deepcoin.operations.trade_engine import (
|
from deepcoin.operations.trade_engine import (
|
||||||
TradeResult,
|
TradeResult,
|
||||||
apply_trade_to_portfolio,
|
apply_trade_to_portfolio,
|
||||||
@@ -46,12 +47,16 @@ class PaperExecutor(OrderExecutor):
|
|||||||
cluster_size: int = 1,
|
cluster_size: int = 1,
|
||||||
) -> TradeResult:
|
) -> TradeResult:
|
||||||
"""paper 모드 체결."""
|
"""paper 모드 체결."""
|
||||||
price = float(signal["price"])
|
side = str(signal["side"])
|
||||||
|
price = fill_price(
|
||||||
|
float(signal["price"]),
|
||||||
|
side,
|
||||||
|
self.settings.ops_slippage_rate,
|
||||||
|
)
|
||||||
fee_rate = self.settings.gt_trading_fee_rate
|
fee_rate = self.settings.gt_trading_fee_rate
|
||||||
min_order = self.settings.ops_min_order_krw
|
min_order = self.settings.ops_min_order_krw
|
||||||
cash = float(portfolio.get("cash_krw", 0))
|
cash = float(portfolio.get("cash_krw", 0))
|
||||||
coin = float(portfolio.get("coin_qty", 0))
|
coin = float(portfolio.get("coin_qty", 0))
|
||||||
side = str(signal["side"])
|
|
||||||
|
|
||||||
if side == "buy":
|
if side == "buy":
|
||||||
trade = compute_buy_order(
|
trade = compute_buy_order(
|
||||||
@@ -99,12 +104,16 @@ class LiveExecutor(OrderExecutor):
|
|||||||
) -> TradeResult:
|
) -> TradeResult:
|
||||||
"""live 모드 시장가 주문."""
|
"""live 모드 시장가 주문."""
|
||||||
self._sync_portfolio(portfolio)
|
self._sync_portfolio(portfolio)
|
||||||
price = float(signal["price"])
|
side = str(signal["side"])
|
||||||
|
price = fill_price(
|
||||||
|
float(signal["price"]),
|
||||||
|
side,
|
||||||
|
self.settings.ops_slippage_rate,
|
||||||
|
)
|
||||||
fee_rate = self.settings.gt_trading_fee_rate
|
fee_rate = self.settings.gt_trading_fee_rate
|
||||||
min_order = self.settings.ops_min_order_krw
|
min_order = self.settings.ops_min_order_krw
|
||||||
cash = float(portfolio.get("cash_krw", 0))
|
cash = float(portfolio.get("cash_krw", 0))
|
||||||
coin = float(portfolio.get("coin_qty", 0))
|
coin = float(portfolio.get("coin_qty", 0))
|
||||||
side = str(signal["side"])
|
|
||||||
|
|
||||||
if side == "buy":
|
if side == "buy":
|
||||||
trade = compute_buy_order(
|
trade = compute_buy_order(
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import time
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deepcoin.config import Settings
|
from deepcoin.config import Settings
|
||||||
from deepcoin.ground_truth.pnl import _cluster_signals
|
from deepcoin.ground_truth.pnl import _cluster_signals
|
||||||
|
from deepcoin.operations.candle_sync import sync_ops_candles
|
||||||
from deepcoin.operations.executor import create_executor
|
from deepcoin.operations.executor import create_executor
|
||||||
from deepcoin.operations.signal_pipeline import (
|
from deepcoin.operations.signal_pipeline import (
|
||||||
filter_signals_for_ops,
|
filter_signals_for_ops,
|
||||||
@@ -23,25 +23,15 @@ from deepcoin.operations.state_store import load_state, reset_daily_trade_count,
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _project_root() -> Path:
|
def sync_candles_if_enabled(settings: Settings) -> list[Any]:
|
||||||
return Path(__file__).resolve().parents[3]
|
"""빗썸 캔들을 증분 조회하여 DB에 새 봉만 INSERT한다."""
|
||||||
|
|
||||||
|
|
||||||
def sync_candles_if_enabled(settings: Settings) -> bool:
|
|
||||||
"""증분 캔들 수집 스크립트를 실행한다."""
|
|
||||||
if not settings.ops_sync_candles:
|
if not settings.ops_sync_candles:
|
||||||
return False
|
return []
|
||||||
script = _project_root() / "scripts" / "00_download.py"
|
logger.info(
|
||||||
if not script.exists():
|
"캔들 증분 동기화 (intervals=%s)...",
|
||||||
logger.warning("캔들 동기화 스크립트 없음: %s", script)
|
settings.ops_sync_intervals,
|
||||||
return False
|
|
||||||
logger.info("캔들 증분 동기화 실행...")
|
|
||||||
subprocess.run(
|
|
||||||
[sys.executable, str(script)],
|
|
||||||
cwd=str(_project_root()),
|
|
||||||
check=False,
|
|
||||||
)
|
)
|
||||||
return True
|
return sync_ops_candles(settings)
|
||||||
|
|
||||||
|
|
||||||
def _signals_on_bar(signals: list[dict[str, Any]], bar_index: int) -> list[dict[str, Any]]:
|
def _signals_on_bar(signals: list[dict[str, Any]], bar_index: int) -> list[dict[str, Any]]:
|
||||||
@@ -98,8 +88,10 @@ class OperationsRunner:
|
|||||||
|
|
||||||
def tick(self, *, sync_candles: bool | None = None) -> dict[str, Any]:
|
def tick(self, *, sync_candles: bool | None = None) -> dict[str, Any]:
|
||||||
"""신호 확인 및 체결 1회."""
|
"""신호 확인 및 체결 1회."""
|
||||||
if sync_candles if sync_candles is not None else self.settings.ops_sync_candles:
|
do_sync = sync_candles if sync_candles is not None else self.settings.ops_sync_candles
|
||||||
sync_candles_if_enabled(self.settings)
|
candle_sync_results: list[Any] = []
|
||||||
|
if do_sync:
|
||||||
|
candle_sync_results = sync_candles_if_enabled(self.settings)
|
||||||
|
|
||||||
df = load_ops_candles(self.settings)
|
df = load_ops_candles(self.settings)
|
||||||
latest_bar = int(len(df) - 1)
|
latest_bar = int(len(df) - 1)
|
||||||
@@ -147,6 +139,11 @@ class OperationsRunner:
|
|||||||
executions.append(record)
|
executions.append(record)
|
||||||
if trade.executed:
|
if trade.executed:
|
||||||
self.state["trades_today_count"] += 1
|
self.state["trades_today_count"] += 1
|
||||||
|
if (
|
||||||
|
self.settings.ops_mode == "live"
|
||||||
|
and self.settings.ops_order_interval_sec > 0
|
||||||
|
):
|
||||||
|
time.sleep(self.settings.ops_order_interval_sec)
|
||||||
if bar_idx > last_bar:
|
if bar_idx > last_bar:
|
||||||
last_bar = bar_idx
|
last_bar = bar_idx
|
||||||
self.state["last_processed_bar_index"] = bar_idx
|
self.state["last_processed_bar_index"] = bar_idx
|
||||||
@@ -170,6 +167,18 @@ class OperationsRunner:
|
|||||||
"generated_at": now,
|
"generated_at": now,
|
||||||
"mode": self.settings.ops_mode,
|
"mode": self.settings.ops_mode,
|
||||||
"technique_id": pipeline["technique_id"],
|
"technique_id": pipeline["technique_id"],
|
||||||
|
"slippage_rate": self.settings.ops_slippage_rate,
|
||||||
|
"daily_max_trades": self.settings.ops_daily_max_trades,
|
||||||
|
"signal_refresh": gen.get("signal_refresh"),
|
||||||
|
"candle_sync": [
|
||||||
|
{
|
||||||
|
"interval_min": r.interval_min,
|
||||||
|
"mode": r.mode,
|
||||||
|
"api_requests": r.requests,
|
||||||
|
"inserted_rows": r.saved_rows,
|
||||||
|
}
|
||||||
|
for r in candle_sync_results
|
||||||
|
],
|
||||||
"raw_signals": pipeline["raw_count"],
|
"raw_signals": pipeline["raw_count"],
|
||||||
"filtered_signals": pipeline["kept_count"],
|
"filtered_signals": pipeline["kept_count"],
|
||||||
"pending_bars": target_bars,
|
"pending_bars": target_bars,
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -15,9 +18,75 @@ from deepcoin.mtf.rules import load_or_derive_mtf_rules
|
|||||||
from deepcoin.mtf.store import MultiTimeframeStore
|
from deepcoin.mtf.store import MultiTimeframeStore
|
||||||
from deepcoin.mtf.trend_gate import HtfTrendGate
|
from deepcoin.mtf.trend_gate import HtfTrendGate
|
||||||
from deepcoin.operations.signal_type import enrich_signal_types
|
from deepcoin.operations.signal_type import enrich_signal_types
|
||||||
from deepcoin.techniques.base import TechniqueParams
|
from deepcoin.techniques.base import TechniqueParams, TechniqueResult
|
||||||
from deepcoin.techniques.registry import get_technique
|
from deepcoin.techniques.registry import get_technique
|
||||||
from deepcoin.techniques.runner import load_technique_result, run_technique
|
from deepcoin.techniques.runner import load_technique_result, run_technique, save_technique_result
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_technique_file_cache: dict[str, tuple[float, TechniqueResult]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _max_signal_bar_index(signals: list[dict[str, Any]]) -> int:
|
||||||
|
"""신호 목록의 최대 bar_index."""
|
||||||
|
if not signals:
|
||||||
|
return -1
|
||||||
|
return max(int(s.get("bar_index", 0)) for s in signals)
|
||||||
|
|
||||||
|
|
||||||
|
def _offset_signal_bars(signals: list[dict[str, Any]], offset: int) -> list[dict[str, Any]]:
|
||||||
|
"""tail 구간 실행 결과의 bar_index를 전역 인덱스로 변환한다."""
|
||||||
|
shifted: list[dict[str, Any]] = []
|
||||||
|
for signal in signals:
|
||||||
|
row = dict(signal)
|
||||||
|
row["bar_index"] = int(row.get("bar_index", 0)) + offset
|
||||||
|
pivot = row.get("pivot_bar_index")
|
||||||
|
if pivot is not None:
|
||||||
|
row["pivot_bar_index"] = int(pivot) + offset
|
||||||
|
shifted.append(row)
|
||||||
|
return shifted
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_tail_signals(
|
||||||
|
cached_signals: list[dict[str, Any]],
|
||||||
|
tail_signals: list[dict[str, Any]],
|
||||||
|
offset: int,
|
||||||
|
after_bar: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""캐시 신호 + tail 신규 구간만 병합 (datetime·side 중복 제거)."""
|
||||||
|
seen = {(s.get("datetime"), s.get("side")) for s in cached_signals}
|
||||||
|
merged = list(cached_signals)
|
||||||
|
for signal in _offset_signal_bars(tail_signals, offset):
|
||||||
|
if int(signal.get("bar_index", 0)) <= after_bar:
|
||||||
|
continue
|
||||||
|
key = (signal.get("datetime"), signal.get("side"))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
merged.append(signal)
|
||||||
|
seen.add(key)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _load_technique_cached(cache_path: Path) -> TechniqueResult:
|
||||||
|
"""대용량 기법 JSON을 mtime 기준으로 메모리 캐시한다 (fractal_swing 등)."""
|
||||||
|
path = Path(cache_path)
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
key = str(path)
|
||||||
|
cached = _technique_file_cache.get(key)
|
||||||
|
if cached and cached[0] == mtime:
|
||||||
|
return cached[1]
|
||||||
|
result = load_technique_result(path)
|
||||||
|
_technique_file_cache.clear()
|
||||||
|
_technique_file_cache[key] = (mtime, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _store_technique_memory(cache_path: Path, result: TechniqueResult) -> None:
|
||||||
|
"""메모리 캐시를 갱신한다 (tick 간 신호 연속성)."""
|
||||||
|
key = str(cache_path)
|
||||||
|
mtime = cache_path.stat().st_mtime if cache_path.exists() else 0.0
|
||||||
|
_technique_file_cache.clear()
|
||||||
|
_technique_file_cache[key] = (mtime, result)
|
||||||
|
|
||||||
|
|
||||||
def _parse_dt(value: str) -> datetime:
|
def _parse_dt(value: str) -> datetime:
|
||||||
@@ -36,6 +105,36 @@ def _signals_in_lookback(
|
|||||||
return [s for s in signals if _parse_dt(s["datetime"]) >= start_dt]
|
return [s for s in signals if _parse_dt(s["datetime"]) >= start_dt]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_composite_min_score(
|
||||||
|
signals: list[dict[str, Any]],
|
||||||
|
min_score: float,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""composite_v3 reason의 score= 값으로 min_score 이상 신호만 남긴다."""
|
||||||
|
kept: list[dict[str, Any]] = []
|
||||||
|
for signal in signals:
|
||||||
|
match = re.search(r"score=([\d.]+)", signal.get("reason", ""))
|
||||||
|
if match:
|
||||||
|
if float(match.group(1)) >= min_score:
|
||||||
|
kept.append(signal)
|
||||||
|
else:
|
||||||
|
kept.append(signal)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_ops_min_score(
|
||||||
|
technique_id: str,
|
||||||
|
signals: list[dict[str, Any]],
|
||||||
|
cached_min_score: float | None,
|
||||||
|
ops_min_score: float | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""캐시 신호에 OPS_MIN_SCORE가 캐시보다 높으면 추가 필터를 적용한다."""
|
||||||
|
if technique_id != "composite_v3" or ops_min_score is None:
|
||||||
|
return signals
|
||||||
|
if cached_min_score is not None and ops_min_score <= cached_min_score:
|
||||||
|
return signals
|
||||||
|
return _filter_composite_min_score(signals, ops_min_score)
|
||||||
|
|
||||||
|
|
||||||
def build_technique_params(settings: Settings) -> TechniqueParams:
|
def build_technique_params(settings: Settings) -> TechniqueParams:
|
||||||
"""운영용 기법 파라미터를 구성한다."""
|
"""운영용 기법 파라미터를 구성한다."""
|
||||||
extra: dict[str, Any] = {}
|
extra: dict[str, Any] = {}
|
||||||
@@ -80,21 +179,77 @@ def generate_raw_signals(
|
|||||||
last_price = float(df["close"].iloc[-1])
|
last_price = float(df["close"].iloc[-1])
|
||||||
|
|
||||||
cache_path = settings.techniques_dir / f"{settings.ops_technique_id}.json"
|
cache_path = settings.techniques_dir / f"{settings.ops_technique_id}.json"
|
||||||
|
latest_bar = len(df) - 1
|
||||||
|
from_cache = False
|
||||||
|
refreshed_tail = False
|
||||||
|
|
||||||
if use_cache and cache_path.exists():
|
if use_cache and cache_path.exists():
|
||||||
cached = load_technique_result(cache_path)
|
cached = _load_technique_cached(cache_path)
|
||||||
|
raw_signals = list(cached.signals)
|
||||||
|
max_cached_bar = _max_signal_bar_index(raw_signals)
|
||||||
|
if latest_bar > max_cached_bar:
|
||||||
|
tail_bars = max(settings.ops_signal_tail_bars, 200)
|
||||||
|
offset = max(0, len(df) - tail_bars)
|
||||||
|
tail_df = df.iloc[offset:].copy()
|
||||||
|
technique = get_technique(settings.ops_technique_id)
|
||||||
|
params = build_technique_params(settings)
|
||||||
|
tail_result = run_technique(technique, tail_df, params, gt_result=None)
|
||||||
|
raw_signals = _merge_tail_signals(
|
||||||
|
raw_signals,
|
||||||
|
tail_result.signals,
|
||||||
|
offset,
|
||||||
|
max_cached_bar,
|
||||||
|
)
|
||||||
|
refreshed_tail = True
|
||||||
|
updated = TechniqueResult(
|
||||||
|
technique_id=cached.technique_id,
|
||||||
|
technique_name=cached.technique_name,
|
||||||
|
category=cached.category,
|
||||||
|
causal=cached.causal,
|
||||||
|
description=cached.description,
|
||||||
|
params=cached.params,
|
||||||
|
signals=raw_signals,
|
||||||
|
legs=cached.legs,
|
||||||
|
summary=cached.summary,
|
||||||
|
pnl=cached.pnl,
|
||||||
|
alignment=cached.alignment,
|
||||||
|
)
|
||||||
|
_store_technique_memory(cache_path, updated)
|
||||||
|
if settings.ops_persist_signal_cache:
|
||||||
|
save_technique_result(updated, settings.techniques_dir)
|
||||||
|
logger.info(
|
||||||
|
"신호 tail 갱신 %s: bar %s→%s signals %s→%s",
|
||||||
|
settings.ops_technique_id,
|
||||||
|
max_cached_bar,
|
||||||
|
latest_bar,
|
||||||
|
len(cached.signals),
|
||||||
|
len(raw_signals),
|
||||||
|
)
|
||||||
|
raw_signals = _apply_ops_min_score(
|
||||||
|
cached.technique_id,
|
||||||
|
raw_signals,
|
||||||
|
float(cached.params.get("min_score")) if cached.params.get("min_score") is not None else None,
|
||||||
|
settings.ops_min_score,
|
||||||
|
)
|
||||||
|
params = dict(cached.params)
|
||||||
|
if settings.ops_min_score is not None:
|
||||||
|
params["min_score"] = settings.ops_min_score
|
||||||
|
from_cache = not refreshed_tail
|
||||||
return {
|
return {
|
||||||
"technique_id": cached.technique_id,
|
"technique_id": cached.technique_id,
|
||||||
"technique_name": cached.technique_name,
|
"technique_name": cached.technique_name,
|
||||||
"params": cached.params,
|
"params": params,
|
||||||
"raw_signals": cached.signals,
|
"raw_signals": raw_signals,
|
||||||
"data_end": data_end,
|
"data_end": data_end,
|
||||||
"last_price": last_price,
|
"last_price": last_price,
|
||||||
"from_cache": True,
|
"from_cache": from_cache,
|
||||||
|
"signal_refresh": "tail" if refreshed_tail else "cache",
|
||||||
|
"latest_bar_index": latest_bar,
|
||||||
}
|
}
|
||||||
|
|
||||||
technique = get_technique(settings.ops_technique_id)
|
technique = get_technique(settings.ops_technique_id)
|
||||||
params = build_technique_params(settings)
|
params_obj = build_technique_params(settings)
|
||||||
result = run_technique(technique, df, params, gt_result=None)
|
result = run_technique(technique, df, params_obj, gt_result=None)
|
||||||
return {
|
return {
|
||||||
"technique_id": result.technique_id,
|
"technique_id": result.technique_id,
|
||||||
"technique_name": result.technique_name,
|
"technique_name": result.technique_name,
|
||||||
@@ -103,6 +258,8 @@ def generate_raw_signals(
|
|||||||
"data_end": data_end,
|
"data_end": data_end,
|
||||||
"last_price": last_price,
|
"last_price": last_price,
|
||||||
"from_cache": False,
|
"from_cache": False,
|
||||||
|
"signal_refresh": "full",
|
||||||
|
"latest_bar_index": latest_bar,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user