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:
xavis
2026-06-13 08:26:11 +09:00
parent 58802bdc5f
commit 2783826a03
20 changed files with 954 additions and 67 deletions

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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

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

View File

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

View File

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

View File

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