diff --git a/.env.example b/.env.example
index 7c576f5..25667e3 100644
--- a/.env.example
+++ b/.env.example
@@ -17,6 +17,8 @@ COIN_TELEGRAM_CHAT_ID=
# --- 거래 대상 ---
SYMBOL=BTC
COIN_NAME=비트코인
+# 캔들 수집 대상 (쉼표 구분). 비우면 SYMBOL만 수집
+DOWNLOAD_SYMBOLS=TRX,NEAR,WLD
# --- 공통: 캔들 DB (현물·선물 공유) ---
DB_PATH=data/common/coins.db
@@ -118,7 +120,9 @@ OPS_WATCH_SIGNAL_GRACE_MIN=5
OPS_WATCH_TICK_STALE_MIN=12
OPS_WATCH_LOOKBACK_MIN=30
OPS_WATCH_AUTO_REMEDIATE=true
-OPS_WATCH_AUTO_RESTART=true
+# vol_breakout(cron 1분) 사용 시 fractal loop 재시작 끄기
+OPS_WATCH_AUTO_RESTART=false
+# fractal loop 커스텀 기동 (OPS_WATCH_AUTO_RESTART=true 일 때)
# OPS_WATCH_LOOP_START_CMD=
# OPS_PERSIST_SIGNAL_CACHE=false
OPS_STATE_JSON=data/spot/operations/fractal_ops_state.json
@@ -128,6 +132,36 @@ OPS_FILTERED_BACKTEST_JSON=docs/spot/3_operations/fractal_filtered_backtest_repo
OPS_SIZING_RULES_JSON=data/spot/operations/sizing_rules.json
# composite_v3 운영 시: OPS_TECHNIQUE_ID=composite_v3, OPS_MIN_SCORE=2.5, OPS_MTF_ENABLED=true
+# --- vol_breakout 현물 롱 (Binance 15m ATR 이식, BTC 제외) ---
+# fractal_swing(3분)과 별도 프로세스 — 동시 live 시 KRW 경합 주의
+OPS_SYMBOLS=TRX,NEAR,WLD
+VOL_STATE_JSON=data/spot/operations/vol_breakout_state.json
+VOL_LOOKBACK=14
+VOL_ATR_MULT=2.0
+VOL_LOOKBACK_DAYS=60
+# VOL_BUY_SPLIT=0: 미보유 슬롯 수로 동적 (3→1/3, 2→1/2, 1→100%)
+VOL_BUY_SPLIT=0
+VOL_WALLET_PCT=0.30
+VOL_MAX_BARS_PER_TICK=5
+# 순수 15m flip (trail/stop off). 청산은 하향 돌파(-1) flip만
+VOL_EXIT_ENABLED=false
+# trail/stop 사용 시 VOL_EXIT_ENABLED=true 및 아래 값
+# VOL_TRAIL_ACTIVATE_PCT=1.5
+# VOL_TRAIL_PCT=0.0025
+# VOL_TRAIL_ATR_MULT=0.5
+# VOL_STOP_PCT=-12.0
+VOL_TICK_LOCK_PATH=data/spot/operations/vol.tick.lock
+# touch data/spot/operations/vol.kill → 신규 매수 차단
+# VOL_KILL_SWITCH_PATH=data/spot/operations/vol.kill
+
+# 라이브 모니터 (Binance vol_live_monitor 유사, 기본 포트 8766)
+VOL_MONITOR_JSON=docs/spot/3_operations/vol_live_chart.json
+VOL_MONITOR_HTML=docs/spot/3_operations/vol_live_monitor.html
+VOL_MONITOR_DAYS=14
+
+# cron (vol_breakout): bash scripts/install_crontab.sh --apply
+# BITHUMB_PYTHON=/Users/dsyoon/opt/anaconda3/envs/coin/bin/python3
+
# 폴더 구조: data|docs / {common, spot}
# common — coins.db 등 공유 리소스
# spot — 현물 GT·기법·분석·운영
diff --git a/README.md b/README.md
index 4ad9690..1d594ca 100644
--- a/README.md
+++ b/README.md
@@ -1,262 +1,459 @@
# Bithumb
-빗썸 KRW 마켓 암호화폐 캔들 수집 및 **현물** 매매 전략 파이프라인.
+빗썸 KRW 마켓 암호화폐 캔들 수집 및 **현물(spot)** 매매 전략 파이프라인.
-- **기본 축:** 3분봉 현물 BTC, 최근 **10년** 캔들 (`DOWNLOAD_DAYS=3650`)
-- **데이터·문서 분류:** `common` (공유) · `spot` (현물)
-- **현재 운영 전략:** `fractal_swing` + MTF off — paper/live tick 운영 구현 완료
-
-## 주요 기능
-
-- 빗썸 Public API(v1) 분·일·주·월봉 캔들 수집 (11개 TF, 1분봉 포함)
-- SQLite 공유 DB (`data/common/coins.db`) — 현물·MTF 공용
-- Ground Truth(GT) 벤치마크 → 39종 인과 기법 분석 → **실거래 운영(paper/live)**
-- 운영 tick: 캔들 증분 sync, 신호 tail 갱신, 슬리피지·일 체결 상한, 텔레그램 체결 알림
-
-## 요구사항
-
-- Python 3.10+
-- Conda 환경 `ncue` 또는 `xavis`
-
-## 설치
-
-```bash
-cd Bithumb
-conda activate ncue # 또는 xavis
-pip install -r requirements.txt
-cp .env.example .env # API 키·텔레그램 등 로컬 설정
-```
-
-`.env` 핵심값 (현물 3분봉·10년·fractal 운영):
-
-```env
-SYMBOL=BTC
-DB_PATH=data/common/coins.db
-DOWNLOAD_DAYS=3650
-GT_INTERVAL_MIN=3
-GT_LOOKBACK_DAYS=3650
-GT_INITIAL_CASH_KRW=200000
-GT_SIM_LOOKBACK_DAYS=1095
-OPS_TECHNIQUE_ID=fractal_swing
-OPS_MTF_ENABLED=false
-OPS_SLIPPAGE_RATE=0.0005
-OPS_DAILY_MAX_TRADES=100
-```
+- **기본 축:** 3분봉 BTC, 최근 **10년** 캔들 (`DOWNLOAD_DAYS=3650`)
+- **데이터·문서:** `data/common` (공유 DB) · `data/spot` / `docs/spot` (현물)
+- **현재 live 운영:** `fractal_swing`, MTF off, ledger + exchange reconcile + 5분 watch
---
-## 설계 개요
+## 목차
-### 파이프라인 단계
+1. [파이프라인 개요](#파이프라인-개요)
+2. [단계별 요약 (0~3단계)](#단계별-요약-03단계)
+3. [수익률 정리](#수익률-정리)
+4. [3단계 live 운영](#3단계-live-운영)
+5. [설치·실행](#설치실행)
+6. [폴더 구조](#폴더-구조)
+7. [환경 변수](#환경-변수)
+8. [소스 모듈](#소스-모듈)
+9. [39종 인과 기법](#39종-인과-기법)
+10. [변경 이력](#변경-이력)
+
+---
+
+## 파이프라인 개요
+
+```mermaid
+flowchart LR
+ A[common
캔들 수집] --> B[0단계 GT]
+ B --> C[1단계 GT sim]
+ C --> D[2단계 39종 기법]
+ D --> E[3단계 paper/live]
+ F[watch 5분] -.->|감시·조치| E
+```
| 단계 | 목적 | 미래 데이터 | 실거래 |
|------|------|-------------|--------|
-| **common** | 캔들 DB 구축 | — | — |
+| **common** | SQLite 캔들 DB 구축·증분 갱신 | — | — |
| **spot 0단계** | GT v3 사후 최적 타점 (정답지) | 사용 (연구용) | 불가 |
| **spot 1단계** | GT 타점 완벽 추종 sim 상한선 | GT 자체가 사후 | 불가 |
| **spot 2단계** | 39종 인과 기법 평가·MTF 규칙 | 미사용 | 불가 |
| **spot 3단계** | paper/live tick 운영 | 미사용 | **가능** |
-### 현물 3단계 운영 아키텍처 (fractal_swing)
+**핵심 원칙**
+
+- 0~1단계는 **연구·벤치마크** (사후 GT 포함).
+- 2단계는 **인과 기법**만으로 GT 재현도·sim을 비교.
+- 3단계는 2단계에서 선택한 기법을 **실시간 tick**으로 체결. 백테스트와 **동일 sim 엔진**(`ground_truth/pnl.py`) 사용.
+
+---
+
+## 단계별 요약 (0~3단계)
+
+### common — 캔들 수집
+
+| 항목 | 내용 |
+|------|------|
+| 스크립트 | `scripts/00_download.py` (별칭 `00_download_candles.py`) |
+| DB | `data/common/coins.db` |
+| TF | 11개 (1,3,5,10,15,30,60,240,1440,10080,43200분) |
+| 모드 | 증분(기본) / `--full` 전체 재수집 |
+| 테이블 | `{SYMBOL}_{분}` (예: `BTC_3`, `BTC_1440`) |
+
+3단계 tick에서는 `sync_ops_candles()`가 in-process로 동일 DB에 증분 INSERT (`OPS_SYNC_CANDLES=true`).
+
+---
+
+### spot 0단계 — Ground Truth (GT) 타점
+
+**목적:** 10년 3분봉에서 **사후적으로** 도출한 “이론적 최적” 매수·매도 타점. 이후 단계의 **정답지·벤치마크**.
+
+| 스크립트 | `scripts/0_ground_truth.py` |
+|----------|----------------------------|
+| 기간 | `GT_LOOKBACK_DAYS=3650` (10년) |
+| 봉 | `GT_INTERVAL_MIN=3` |
+
+**GT v3 신호 체계**
+
+| 코드 | 유형 | 10년 GT 건수(대략) |
+|------|------|-------------------|
+| B | 스윙 저점 매수 | 944 |
+| B* | 눌림목 | 406 |
+| B^ | 돌파 | 122 |
+| Bd | 상승 다이버전스 | 115 |
+| S | 스윙 고점 매도 | 944 |
+| Sd | 하락 다이버전스 | 144 |
+
+**티어**
+
+| 티어 | 포함 신호 |
+|------|-----------|
+| v1 | B / S |
+| v2 | + B* |
+| v3 | + B^ / Bd / Sd |
+
+**산출물**
+
+- `data/spot/ground_truth/ground_truth_trades_v{1,2,3}.json`
+- `docs/spot/0_ground_truth/ground_truth_chart_v*.html`
+
+---
+
+### spot 1단계 — GT sim (수익 상한선)
+
+**목적:** GT v3 타점을 **그대로** sim했을 때 3년 수익률. “이론적 상한” 벤치마크.
+
+| 스크립트 | `scripts/1_ground_truth_sim.py` |
+|----------|--------------------------------|
+| sim 기간 | `GT_SIM_LOOKBACK_DAYS=1095` (3년) |
+| 초기 자본 | `GT_INITIAL_CASH_KRW=200,000` |
+| 엔진 | `simulate_gt_signals_pnl` (슬리피지 없음) |
+
+**산출물:** `docs/spot/1_simulation/ground_truth_chart_sim_v*.html`
+
+**참고:** GT는 사후 타점이므로 **실거래 불가**. 1단계 수익률은 “최적 타점을 보수적으로 따라갔을 때”의 기준선.
+
+---
+
+### spot 2단계 — 인과 기법 분석 (39종)
+
+**목적:** 미래 데이터 **없이** GT v3를 얼마나 재현하는지 39종 기법을 평가. 3단계 전략 선택 근거.
+
+| 순서 | 스크립트 | 산출물 |
+|------|----------|--------|
+| 2-1 | `2_run_techniques.py` | `data/spot/techniques/*.json`, `comparison_report.html` |
+| 2-2 | `2_run_causal_sim.py` | `causal_sim_report.html` |
+| 2-3 | `2_run_signal_type_align.py` | `signal_type_report.html` |
+| 2-4 | `2_run_mtf_analysis.py` | `mtf_rules_v3.json`, `mtf_correlation_report.html` |
+| 일괄 | `bash scripts/2_run_stage2_all.sh` | 위 전체 |
+
+**GT 정합 score 상위 (10년, ±480봉 허용)**
+
+| 순위 | 기법 | score | 비고 |
+|------|------|-------|------|
+| 1 | **fractal_swing** | 0.914 | buy/sell recall 100%, **현재 live 운영** |
+| 2 | pivot_swing | 0.911 | |
+| 3 | minor_swing | 0.864 | |
+| 4 | local_extrema | 0.839 | |
+| 5 | zigzag_causal | 0.776 | 스윙 특화, 저빈도 |
+| … | composite_v3 | 0.546 | leg recall 22.9% |
+
+**문서**
+
+- 설계: [`docs/spot/2_analysis/stage2_design_guide.md`](docs/spot/2_analysis/stage2_design_guide.md)
+- 결과 해석: [`docs/spot/2_analysis/stage2_final_summary.md`](docs/spot/2_analysis/stage2_final_summary.md)
+
+---
+
+### spot 3단계 — paper / live 운영
+
+**목적:** 선택 기법(`fractal_swing`)을 3분봉 tick으로 paper 또는 live 체결.
+
+| 스크립트 | 역할 |
+|----------|------|
+| `3_run_filtered_backtest.py` | 운영 조건 3년 sim |
+| `3_run_fractal_realistic_backtest.py` | 슬리피지·일 상한 시나리오 |
+| `3_render_live_chart.py` | `docs/live/` 매매 차트 |
+| `3_run_operations.py` | paper/live tick (loop 180초) |
+| `3_preflight_live.py` / `3_init_live_state.py` | live 사전 점검·상태 초기화 |
+| `3_reconcile_signals.py` | backlog inspect / execute |
+| `3_audit_ops_safety.py` | 놓침·중복 종합 점검 |
+| `3_watch_ops.py` | read-only 감시 + 불일치 조치 |
+| `3_run_watch_cron.sh` | cron 5분용 래퍼 |
+| `bash scripts/3_run_fractal_live.sh` | preflight → init → live loop |
+
+**설계:** [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md)
+(초版은 composite_v3 중심 — **현재 `.env` 기본은 fractal_swing**)
+
+---
+
+## 수익률 정리
+
+**공통 조건:** BTC · 3분봉 · sim 기간 **최근 3년(1095일)** · 초기 자본 **200,000원** · 편도 수수료 **0.05%** (`GT_TRADING_FEE_RATE`)
+
+> sim 수익률은 **과거 데이터 재생** 결과입니다. live는 슬리피지·체결 지연·유동성·운영 오류로 **달라질 수 있으며**, 특히 고빈도 전략은 슬리피지에 극도로 민감합니다.
+
+### 1단계 — GT v3 벤치마크
+
+| 항목 | 값 |
+|------|-----|
+| 3년 수익률 | **+94,154%** |
+| 최종 평가 | 약 1.89억 원 |
+| 매수/매도 체결 | 239 / 151 |
+| 의미 | 사후 최적 타점을 보수적으로 sim |
+
+### 2단계 — 인과 sim (슬리피지 0, 일 상한 없음)
+
+| 기법 | 3년 수익률 | 최종 평가(약) | 매수/매도 체결 | 일평균 매수 |
+|------|-----------|--------------|---------------|------------|
+| **fractal_swing** | **+7,560,826%** | 151억 | 56,893 / 56,892 | **~52회** |
+| pivot_swing | +4,687,495% | 94억 | 12,656 / 12,658 | ~12회 |
+| minor_swing | +286,537% | 5.7억 | 831 / 887 | ~0.8회 |
+| zigzag_causal | +92,711% | 1.86억 | 97 / 97 | ~0.09회 |
+| composite_v3 | **-97.5%** | ~5,000원 | 1,885 / 1,237 | — |
+| composite_v3 + MTF (3단계 필터) | +3.37% | — | — | — |
+
+출처: `docs/spot/2_analysis/stage2_final_summary.md`, `stage2_parity_sweep.json`
+
+### 3단계 — fractal_swing 운영 백테스트 (슬리피지 반영)
+
+| 시나리오 | 슬리피지 | 일 체결 상한 | 3년 수익률 | 매수 체결 | 비고 |
+|----------|---------|-------------|-----------|----------|------|
+| **stage2 ideal** | 0% | 없음 | **+7,560,826%** | 56,893 | 2단계와 동일 |
+| **ops_default** | 0.05% | 100 | **+1,873,140%** | 53,589 | sizing_rules 100% (cluster1) |
+| **ops + sizing 튜닝** | 0.05% | 10,000 | **+2,307,905%** | 56,773 | cluster 100% (`sizing_rules.json`) |
+| slippage 0.1% | 0.1% | 100 | **-97.5%** | 3,964 | **실거래 리스크** |
+| slippage 0.1% (상한 없음) | 0.1% | 없음 | -97.5% | 3,962 | 동일 |
+
+출처: `fractal_realistic_backtest.json`, `fractal_filtered_backtest_report.json` (2026-06-14)
+
+**해석**
+
+- 2단계 ideal 대비 ops_default는 약 **24.8%** 수준 (`fractal_ops_vs_stage2.json`) — 슬리피지·일 상한·분할 매매 반영.
+- 슬리피지 **0.05% → 0.1%**만 올려도 sim은 **-97.5%**로 붕괴 → live에서 체결가·수수료 관리가 핵심.
+- `1_tune_order_sizing.py`로 cluster별 100% sizing 튜닝 시 sim **+2,307,905%** (`sizing_rules.json` + `.env` `OPS_*_PCT=1.0`).
+
+### live vs 백테스트
+
+| 구분 | 백테스트 | live |
+|------|---------|------|
+| 실행 | 3년 일괄 재생 | 180초 tick 누적 |
+| 체결가 | 모델 슬리피지 | 빗썸 시장가 + 실제 스프레드 |
+| 신호 | 캐시+tail | 동일 파이프라인 + ledger |
+| 기대 | sim 수치 | sim **이하**가 정상 |
+
+---
+
+## 3단계 live 운영
+
+### tick 아키텍처
```mermaid
flowchart TD
- subgraph tick["3_run_operations.py tick (권장 180초)"]
- A[sync_ops_candles
전 TF 증분 INSERT] --> B[generate_raw_signals
캐시 + tail 800봉 갱신]
- B --> C[filter_signals_for_ops
MTF·TrendGate 선택]
- C --> D[OperationsRunner
bar 단위 클러스터 체결]
- D --> E{paper / live}
- E -->|paper| F[PaperExecutor
모델 슬리피지 체결]
- E -->|live| G[LiveExecutor
빗썸 시장가]
- F --> H[TelegramNotifier
체결 알림]
- G --> H
- H --> I[state.json + ops_report.json]
+ subgraph loop["3_run_operations.py --loop 180"]
+ A[sync_ops_candles] --> B[generate_raw_signals
force_tail_refresh]
+ B --> C[filter_signals_for_ops]
+ C --> D[exchange reconcile]
+ D --> E[stale backlog 정산]
+ E --> F[ledger pending 체결]
+ F --> G[state.json + report]
end
- J[3_run_filtered_backtest.py] --> K[simulate_gt_signals_pnl
동일 체결 규칙 3년 sim]
+ subgraph watch["3_watch_ops.py (cron 5분)"]
+ H[불일치 감지] --> I{조치}
+ I -->|lock 획득| J[remediation tick]
+ I -->|tick stale| K[loop 재시작]
+ I --> T[텔레그램]
+ end
+ L[ops.tick.lock] --- loop
+ L --- watch
```
-**백테스트 vs live 정합:** 슬리피지·수수료·일 체결 상한·매수 상한(`max_buy_from_cash`)·클러스터 분할이 `pnl.py` ↔ `trade_engine` ↔ `executor`에서 동일 규칙을 사용합니다.
+### 신호 누락·중복 방어 (2026-06-14)
-### 운영 전략 비교 (2단계 결론 반영)
+| 기능 | 설명 |
+|------|------|
+| **ledger pending** | `trade_history` 기준 미정산 신호 추적 (커서와 분리) |
+| **force_tail_refresh** | live에서 tail 800봉 신호 재계산 |
+| **catchup 480봉** | 최근 구간 재시도 |
+| **max_age 45분** | 과거 backlog 현재가 재체결 차단 (수수료 churn 방지) |
+| **exchange reconcile** | 거래소 done 주문 ↔ 신호 대조, **재주문 없이** 원장 반영 |
+| **ops.tick.lock** | loop·watch tick 동시 실행 방지 |
+| **watch 5분** | 불일치 시 remediation tick 또는 loop 재시작 + 텔레그램 |
-| 전략 | 3년 sim (운영 조건) | 체결 빈도 | 현재 `.env` |
-|------|---------------------|-----------|-------------|
-| **fractal_swing** (MTF off) | **+1,873,140%** (슬리피지 0.05%, 일 100회) | 일 ~50회 매수 | **기본값** |
-| fractal_swing ideal (2단계) | +7,560,826% (슬리피지 0, 상한 없음) | 일 ~52회 | 연구용 |
-| composite_v3 + MTF on | +3.37% | 낮음 | `.env.example` 주석 참고 |
+### live 시작
-상세 해석: [`docs/spot/2_analysis/stage2_final_summary.md`](docs/spot/2_analysis/stage2_final_summary.md)
+```bash
+conda activate ncue # 또는 xavis
+export PYTHONPATH=src
+
+# 1) 백테스트 확인
+python scripts/3_run_filtered_backtest.py
+
+# 2) live (preflight + init + loop)
+bash scripts/3_run_fractal_live.sh
+
+# 또는 직접
+python scripts/3_run_operations.py --mode live --loop 180
+```
+
+### watch cron (5분)
+
+```bash
+crontab -e
+# 추가:
+*/5 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_watch_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/watch_cron.log 2>&1
+```
+
+### vol_breakout cron (다운로드 + tick + 모니터)
+
+TRX/NEAR/WLD 15m flip 운영용. 한 번에 등록:
+
+```bash
+bash scripts/install_crontab.sh --apply
+crontab -l # 확인
+```
+
+| cron | 주기 | 스크립트 | 로그 |
+|------|------|----------|------|
+| 캔들 증분 | 1분 | `00_run_download_cron.sh` | `data/common/download_cron.log` |
+| vol tick | 1분 | `3_run_vol_breakout_cron.sh` | `data/spot/operations/vol_breakout_cron.log` |
+| 모니터 JSON | 5분 | `3_run_vol_monitor_cron.sh` | `data/spot/operations/vol_monitor_cron.log` |
+
+- hung 프로세스: 다운로드 20분·vol tick 10분 초과 시 자동 종료 후 lock 정리 (`scripts/_cron_env.sh`)
+- Python: `coin` / `ncue` conda 우선. 다른 환경이면 `.env` 또는 crontab에 `BITHUMB_PYTHON=...` 설정
+- 모니터 UI (8766):
+ ```bash
+ bash scripts/install_vol_monitor_launchd.sh --install # 권장 — 로그인·재부팅 후 자동 기동
+ bash scripts/3_run_vol_monitor_serve.sh # 수동 1회 기동
+ bash scripts/install_vol_monitor_launchd.sh --status # 상태 확인
+ ```
+ → http://127.0.0.1:8766/vol_live_monitor.html
+ Binance 모니터는 **8765** — 포트가 다릅니다.
+ HTTP 서버(8766)는 **별도 터미널에서 수동 실행**:
+ ```bash
+ python scripts/3_run_vol_monitor.py
+ ```
+
+점검:
+
+```bash
+tail -f data/common/download_cron.log
+tail -f data/spot/operations/vol_breakout_cron.log
+bash scripts/00_run_download_cron.sh # 수동 1회
+bash scripts/3_run_vol_breakout_cron.sh
+```
+
+### fractal watch 점검
+
+```bash
+python scripts/3_audit_ops_safety.py # PASS 목표
+python scripts/3_reconcile_signals.py --dry-run # pending 0 목표
+python scripts/3_watch_ops.py --inspect-only
+```
+
+### live 체크리스트
+
+1. `.env`: `OPS_MODE=live`, API 키, `OPS_EXCHANGE_RECONCILE=true`
+2. `3_run_filtered_backtest.py` 수익률 확인
+3. paper 1~2일 또는 소액 live 모니터링
+4. `fractal_ops_report.json` — tick duration, `ledger_pending_count`, `exchange_reconciled_count`
+5. 텔레그램 체결·WATCH 알림 확인
+
+---
+
+## 설치·실행
+
+### 요구사항
+
+- Python 3.10+
+- Conda `ncue` 또는 `xavis`
+
+### 설치
+
+```bash
+cd bithumb
+conda activate ncue
+pip install -r requirements.txt
+cp .env.example .env # API 키·텔레그램 등
+```
+
+### 전체 파이프라인 (최초 1회)
+
+```bash
+export PYTHONPATH=src
+
+python scripts/00_download.py --full
+python scripts/0_ground_truth.py --interval 3 --days 3650 --tier all
+python scripts/1_ground_truth_sim.py --tier all
+bash scripts/2_run_stage2_all.sh
+python scripts/3_run_filtered_backtest.py
+python scripts/3_render_live_chart.py
+```
+
+### fractal 운영 (일상)
+
+```bash
+bash scripts/3_run_fractal_ops.sh # backtest + paper loop
+python scripts/3_run_operations.py --loop 180 --mode live
+bash scripts/3_run_watch_cron.sh # 감시 1회 (또는 cron)
+```
---
## 폴더 구조
```text
-Bithumb/
+bithumb/
├── src/bithumb/
-│ ├── api/ # 빗썸 Public·Private REST
-│ ├── data/ # 캔들 수집·DB·로더
-│ ├── ground_truth/ # GT 타점·sim·차트
-│ ├── techniques/ # 39종 인과 기법
-│ ├── mtf/ # MTF 피처·필터·규칙
-│ ├── evaluation/ # 2단계 리포트·인과 sim
-│ ├── operations/ # 3단계 운영 (runner·executor·sync·backtest)
-│ └── notifications/ # 텔레그램 체결 알림
-├── scripts/ # 단계별 CLI
-│
+│ ├── api/ # Public·Private REST
+│ ├── data/ # 캔들 수집·DB
+│ ├── ground_truth/ # GT·sim·pnl 엔진
+│ ├── techniques/ # 39종 인과 기법
+│ ├── mtf/ # MTF 필터·규칙
+│ ├── evaluation/ # 2단계 리포트
+│ ├── operations/ # runner·executor·ledger·watch·reconcile
+│ └── notifications/ # 텔레그램
+├── scripts/ # 단계별 CLI·shell
├── data/
-│ ├── common/coins.db # 공유 캔들 OHLCV
-│ ├── spot/
-│ │ ├── ground_truth/ # 0단계 GT JSON
-│ │ ├── techniques/ # 2단계 기법 결과 (fractal_swing.json 등)
-│ │ ├── mtf/ # mtf_rules_v3.json
-│ │ └── operations/ # fractal_ops_state.json
-│
+│ ├── common/coins.db
+│ └── spot/
+│ ├── ground_truth/
+│ ├── techniques/
+│ ├── mtf/
+│ └── operations/ # state·sizing·lock·pid
└── docs/
- ├── live/ # 운영 백테스트 매매 차트 (index.html)
+ ├── live/ # 운영 백테스트 차트
└── spot/
- ├── 0_ground_truth/ # GT 차트 HTML
- ├── 1_simulation/ # 1단계 sim 차트
- ├── 2_analysis/ # 2단계 리포트·설계 가이드
- └── 3_operations/ # 운영·백테스트 JSON 리포트
-```
-
-테이블명: `{SYMBOL}_{인터벌분}` (예: `BTC_3`, `BTC_1440`). 인터벌: 분봉=분 숫자, 일=`1440`, 주=`10080`, 월=`43200`.
-
----
-
-## 파이프라인 실행 순서
-
-```mermaid
-flowchart LR
- A[00_download] --> B[0_ground_truth]
- B --> C[1_ground_truth_sim]
- C --> D[2_run_stage2_all]
- D --> E[3_run_operations]
-```
-
-| 순서 | 단계 | 스크립트 | 산출물 |
-|------|------|----------|--------|
-| 0 | common | `00_download.py` | `data/common/coins.db` |
-| 1 | spot 0단계 | `0_ground_truth.py` | `data/spot/ground_truth/`, `docs/spot/0_ground_truth/` |
-| 2 | spot 1단계 | `1_ground_truth_sim.py` | `docs/spot/1_simulation/` |
-| 3 | spot 2단계 | `2_run_*.py`, `2_run_stage2_all.sh` | `data/spot/techniques/`, `docs/spot/2_analysis/` |
-| 4 | spot 3단계 | `3_run_*.py`, `3_run_fractal_ops.sh` | `data/spot/operations/`, `docs/spot/3_operations/` |
-
-### 권장 명령
-
-```bash
-conda activate ncue
-export PYTHONPATH=src
-
-# common
-python scripts/00_download.py # 증분 갱신
-python scripts/00_download.py --full # 최초·재구축
-
-# spot 0~2단계 (분석·기법 캐시 생성)
-python scripts/0_ground_truth.py --interval 3 --days 3650 --tier all
-python scripts/1_ground_truth_sim.py --tier all
-bash scripts/2_run_stage2_all.sh
-
-# spot 3단계 — fractal_swing 운영
-python scripts/3_run_filtered_backtest.py # 운영 조건 3년 sim 검증
-python scripts/3_render_live_chart.py # docs/live 매매 차트
-python scripts/3_run_fractal_realistic_backtest.py # 슬리피지 시나리오
-bash scripts/3_run_fractal_ops.sh # 백테스트 + paper 180초 loop
-python scripts/3_run_operations.py --loop 180 --mode live # live (API 키 필요)
+ ├── 0_ground_truth/
+ ├── 1_simulation/
+ ├── 2_analysis/
+ └── 3_operations/
```
---
-## 단계별 상세
+## 환경 변수
-### common — 캔들 수집
+전체: `.env.example`. 카테고리별 요약.
-| 항목 | 내용 |
-|------|------|
-| DB | `data/common/coins.db` (`DB_PATH`) |
-| 증분 갱신 | DB 최신 시각 이후만 API 조회·INSERT |
-| 전체 재수집 | `--full` |
-| TF | `DOWNLOAD_INTERVALS` (기본 11개) |
+### 공통·GT
-운영 tick에서는 `sync_ops_candles()`가 subprocess 대신 **in-process** 증분 sync를 수행합니다 (`OPS_SYNC_CANDLES=true`).
+| 변수 | 설명 | 기본 |
+|------|------|------|
+| `SYMBOL` | 코인 | `BTC` |
+| `DB_PATH` | 캔들 DB | `data/common/coins.db` |
+| `DOWNLOAD_DAYS` | 수집·GT 기간(일) | `3650` |
+| `GT_INTERVAL_MIN` | GT·운영 봉(분) | `3` |
+| `GT_SIM_LOOKBACK_DAYS` | sim·백테스트(일) | `1095` |
+| `GT_INITIAL_CASH_KRW` | sim 초기 자본 | `200000` |
+| `GT_TRADING_FEE_RATE` | 편도 수수료 | `0.0005` |
-### spot 0단계 — GT 타점
+### 3단계 운영 (fractal live)
-사후 최적 매매 타점. 실거래 불가, 이후 단계의 벤치마크.
+| 변수 | 설명 | 기본 |
+|------|------|------|
+| `OPS_MODE` | `paper` / `live` | `paper` |
+| `OPS_TECHNIQUE_ID` | 기법 | `fractal_swing` |
+| `OPS_MTF_ENABLED` | MTF 필터 | `false` |
+| `OPS_SLIPPAGE_RATE` | 편도 슬리피지 | `0.0005` |
+| `OPS_DAILY_MAX_TRADES` | 일 체결 상한 | `100` (live `.env`는 10000) |
+| `OPS_CATCHUP_BARS` | catchup 봉 | `480` |
+| `OPS_LEDGER_LOOKBACK_DAYS` | ledger 스캔 | `1` |
+| `OPS_LEDGER_EXECUTE_MAX_AGE_MINUTES` | backlog API 허용 | `45` |
+| `OPS_LIVE_FORCE_TAIL_REFRESH` | live tail 재계산 | `true` |
+| `OPS_EXCHANGE_RECONCILE` | 거래소 대조 | `true` |
+| `OPS_TICK_LOCK_PATH` | tick flock | `ops.tick.lock` |
+| `OPS_LOOP_PID_FILE` | loop PID | `ops_loop.pid` |
+| `OPS_WATCH_SIGNAL_GRACE_MIN` | 감시 grace | `5` |
+| `OPS_WATCH_TICK_STALE_MIN` | tick stale → 재시작 | `12` |
+| `OPS_WATCH_AUTO_REMEDIATE` | 조치 tick | `true` |
+| `OPS_WATCH_AUTO_RESTART` | loop 재시작 | `true` |
+| `COIN_TELEGRAM_*` | 체결·WATCH 알림 | — |
+| `BITHUMB_ACCESS_KEY` / `SECRET` | live API | — |
-| 티어 | 신호 |
-|------|------|
-| v1 | 스윙 B/S |
-| v2 | + 눌림목 B* |
-| v3 | + 돌파 B^ + 다이버전스 Bd/Sd |
-
-산출: `data/spot/ground_truth/ground_truth_trades_v{1,2,3}.json`, `docs/spot/0_ground_truth/ground_truth_chart_v*.html`
-
-### spot 1단계 — GT sim
-
-GT 타점 완벽 추종 시 3년 수익 상한선. 초기 20만 원, `GT_SIM_LOOKBACK_DAYS=1095`.
-
-산출: `docs/spot/1_simulation/ground_truth_chart_sim_v*.html`
-
-### spot 2단계 — 인과 기법 분석
-
-39종 기법의 GT 정합·3년 sim·신호 유형·MTF 상관 분석.
-
-| 스크립트 | 산출물 |
-|----------|--------|
-| `2_run_techniques.py` | `data/spot/techniques/`, `comparison_report.html` |
-| `2_run_causal_sim.py` | `causal_sim_report.html`, `technique_chart_sim_*.html` |
-| `2_run_signal_type_align.py` | `signal_type_report.html` |
-| `2_run_mtf_analysis.py` | `mtf_rules_v3.json`, `mtf_correlation_report.html` |
-
-설계: [`docs/spot/2_analysis/stage2_design_guide.md`](docs/spot/2_analysis/stage2_design_guide.md)
-결과 정리: [`docs/spot/2_analysis/stage2_final_summary.md`](docs/spot/2_analysis/stage2_final_summary.md)
-
-### spot 3단계 — fractal_swing live 운영
-
-설계 가이드: [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md)
-(가이드 초版은 composite_v3 중심 — **현재 운영 기본값은 fractal_swing**)
-
-#### 백테스트 실적 (BTC, 3년, 초기 20만원)
-
-| 조건 | 수익률 | 매수 체결 | 비고 |
-|------|--------|-----------|------|
-| 운영 백테스트 | **+1,873,140%** | ~53,500 | 슬리피지 0.05%, 일 100회, MTF off |
-| 2단계 ideal | +7,560,826% | ~56,893 | 슬리피지 0, 상한 없음 |
-
-#### 스크립트·산출물
-
-| 스크립트 | 산출물 |
-|----------|--------|
-| `3_run_filtered_backtest.py` | `fractal_filtered_backtest_report.json` |
-| `3_render_live_chart.py` | `docs/live/index.html`, `fractal_swing_ops_chart.html` |
-| `3_run_fractal_realistic_backtest.py` | `fractal_realistic_backtest.json` |
-| `3_run_operations.py` | `fractal_ops_report.json`, `fractal_ops_state.json` |
-| `3_run_fractal_ops.sh` | 백테스트 + paper 180초 loop |
-
-#### 운영 tick 동작
-
-1. **캔들 sync** — `OPS_SYNC_INTERVALS` 비우면 `DOWNLOAD_INTERVALS` 전체 TF, `db_max` 이후만 INSERT
-2. **신호** — 2단계 캐시 JSON 로드; DB 최신 봉 > 캐시 max bar 시 **tail 800봉** fractal 재계산·병합 (`OPS_SIGNAL_TAIL_BARS`)
-3. **필터** — tick당 **최신 봉** 신호만 MTF 평가 (`OPS_MTF_ENABLED=false` 시 스킵)
-4. **체결** — bar 단위 클러스터 분할, 일 `OPS_DAILY_MAX_TRADES` 상한
-5. **알림** — 체결 성공 시 텔레그램; live 실패 시 사유 포함 알림
-6. **저장** — `OPS_STATE_JSON`, `OPS_REPORT_JSON`
-
-#### live 전환 체크리스트
-
-1. `python scripts/3_run_filtered_backtest.py` → **약 +1,873,140%** 확인
-2. `.env`: `OPS_MODE=live`, `BITHUMB_ACCESS_KEY` / `BITHUMB_SECRET_KEY`
-3. `python scripts/3_run_operations.py --loop 180` (paper 1~2일 모니터링 권장)
-4. `fractal_ops_report.json` — `candle_sync`, `signal_refresh`, 체결 건수 확인
-5. 텔레그램 체결 알림 동작 확인 (`COIN_TELEGRAM_*`)
-
-**주의:** 백테스트는 3년 **일괄 재생** sim, live는 **tick 누적**. 실거래 체결가는 모델 슬리피지보다 불리할 수 있습니다.
-
-#### composite_v3 + MTF (대안 운영 프로필)
-
-`.env.example` 주석 참고:
+### composite_v3 대안 프로필
```env
OPS_TECHNIQUE_ID=composite_v3
@@ -268,148 +465,46 @@ OPS_DAILY_MAX_TRADES=20
---
-## 환경 변수
-
-전체 목록: `.env.example`. 주요 항목만 정리합니다.
-
-### 공통·GT
-
-| 변수 | 설명 | 기본값 |
-|------|------|--------|
-| `SYMBOL` | 코인 심볼 | `BTC` |
-| `DB_PATH` | 캔들 DB | `data/common/coins.db` |
-| `DOWNLOAD_DAYS` | 수집·GT 기간(일) | `3650` |
-| `DOWNLOAD_INTERVALS` | 수집 TF 목록 | 11개 TF |
-| `GT_INTERVAL_MIN` | GT·기법·운영 기준 봉(분) | `3` |
-| `GT_LOOKBACK_DAYS` | GT·기법 lookback | `3650` |
-| `GT_SIM_LOOKBACK_DAYS` | sim·백테스트 기간 | `1095` (3년) |
-| `GT_INITIAL_CASH_KRW` | sim·paper 초기 자본 | `200000` |
-| `GT_TRADING_FEE_RATE` | 편도 수수료 | `0.0005` |
-
-### spot 3단계 운영 (fractal 기본)
-
-| 변수 | 설명 | 기본값 |
-|------|------|--------|
-| `OPS_MODE` | `paper` / `live` | `paper` |
-| `OPS_TECHNIQUE_ID` | 운영 기법 | `fractal_swing` |
-| `OPS_MTF_ENABLED` | MTF 필터 | `false` |
-| `OPS_TREND_GATE_ENABLED` | 고TF trend gate | `false` |
-| `OPS_DAILY_MAX_TRADES` | 일일 체결 상한 | `100` |
-| `OPS_MIN_ORDER_KRW` | 최소 주문(원) | `5000` |
-| `OPS_SLIPPAGE_RATE` | 편도 슬리피지 | `0.0005` (0.05%) |
-| `OPS_ORDER_INTERVAL_SEC` | live 주문 간격(초) | `0.35` |
-| `OPS_SYNC_CANDLES` | tick 캔들 증분 sync | `true` |
-| `OPS_SYNC_INTERVALS` | sync TF (비우면 전체) | 전체 |
-| `OPS_SIGNAL_TAIL_BARS` | 신호 tail 재계산 봉 | `800` |
-| `OPS_PERSIST_SIGNAL_CACHE` | tail 후 JSON 저장 | `false` |
-| `OPS_STATE_JSON` | 운영 상태 | `fractal_ops_state.json` |
-| `OPS_REPORT_JSON` | tick 리포트 | `fractal_ops_report.json` |
-| `OPS_FILTERED_BACKTEST_JSON` | 백테스트 리포트 | `fractal_filtered_backtest_report.json` |
-| `COIN_TELEGRAM_BOT_TOKEN` | 텔레그램 Bot | (비우면 알림 off) |
-| `COIN_TELEGRAM_CHAT_ID` | 텔레그램 chat ID | |
-| `OPS_TELEGRAM_ENABLED` | 체결 알림 | 토큰·chat_id 있으면 자동 on |
-| `BITHUMB_ACCESS_KEY` | live API | — |
-| `BITHUMB_SECRET_KEY` | live API | — |
-
-### 경로 변수 요약
-
-| 용도 | 변수 | 기본 경로 |
-|------|------|-----------|
-| spot GT | `GROUND_TRUTH_FILE` | `data/spot/ground_truth/...` |
-| spot 기법 | `TECHNIQUES_DIR` | `data/spot/techniques/` |
-| spot MTF | `MTF_RULES_JSON` | `data/spot/mtf/mtf_rules_v3.json` |
-| spot 운영 상태 | `OPS_STATE_JSON` | `data/spot/operations/fractal_ops_state.json` |
-| spot 운영 리포트 | `OPS_REPORT_JSON` | `docs/spot/3_operations/fractal_ops_report.json` |
-| live 차트 | `3_render_live_chart.py` | `docs/live/` |
-
----
-
-## 소스 모듈 (spot 3단계)
+## 소스 모듈
| 모듈 | 역할 |
|------|------|
-| `operations/runner.py` | tick 오케스트레이션 |
-| `operations/candle_sync.py` | 전 TF 증분 캔들 sync |
-| `operations/signal_pipeline.py` | 신호 생성·캐시·tail 갱신·MTF 필터 |
+| `operations/runner.py` | tick·ledger·stale·watchdog |
+| `operations/exchange_reconcile.py` | 거래소 체결 ↔ 원장 |
+| `operations/watch_ops.py` | 5분 감시·조치 |
+| `operations/ops_lock.py` | flock |
+| `operations/candle_sync.py` | 증분 캔들 sync |
+| `operations/signal_pipeline.py` | 신호 tail·MTF |
| `operations/executor.py` | paper/live 체결 |
-| `operations/execution.py` | 슬리피지 `fill_price` |
-| `operations/trade_engine.py` | 매수·매도 사이징·포트폴리오 |
-| `operations/backtest.py` | 운영 조건 3년 sim |
-| `operations/chart.py` | `docs/live` 백테스트 차트 |
-| `operations/state_store.py` | 운영 상태 JSON |
-| `ground_truth/pnl.py` | sim 엔진 (백테스트·2단계 공용) |
-| `api/bithumb_private.py` | live 잔고·시장가 주문 |
-| `notifications/telegram.py` | 체결 텔레그램 알림 |
+| `operations/trade_engine.py` | 사이징·포트폴리오 |
+| `operations/backtest.py` | 3년 sim |
+| `ground_truth/pnl.py` | sim 엔진 (2·3단계 공용) |
+| `api/bithumb_private.py` | 잔고·주문·done 조회 |
+| `notifications/telegram.py` | 체결·오류·WATCH |
---
-## 현물 2단계 인과 기법 (39종)
+## 39종 인과 기법
-`src/bithumb/techniques/` — 단일 33 + 복합 6, 미래 데이터 미사용.
+`src/bithumb/techniques/` — 단일 33 + 복합 6, **미래 데이터 미사용**.
| ID | 기법 | 유형 |
|----|------|------|
-| `zigzag_causal` | 인과 ZigZag | 스윙 B/S |
-| `minor_swing` | 소형 스윙 하이브리드 | 하이브리드 |
-| `local_extrema` | 국소 극값 | 스윙 B/S |
-| `pivot_swing` | 피벗 스윙 | 스윙 B/S |
-| `fractal_swing` | 프랙탈 스윙 | 스윙 B/S |
-| `swing_failure` | 스윙 실패 | 스윙 B/S |
-| `donchian` | 돈치안 채널 | 스윙 B/S |
-| `ema_pullback` | EMA 눌림목 | 눌림목 B* |
-| `fib_pullback` | 피보나치 눌림목 | 눌림목 B* |
-| `support_bounce` | 지지·저항 반등 | 눌림목 B* |
-| `keltner_breakout` | Keltner 돌파 | 돌파 B^ |
-| `range_breakout` | 레인지 돌파 | 돌파 B^ |
-| `volume_breakout` | 거래량 돌파 | 돌파 B^ |
-| `bb_squeeze_breakout` | BB 스퀴즈 돌파 | 돌파 B^ |
-| `rsi_divergence` | RSI 다이버전스 | Bd/Sd |
-| `macd_divergence` | MACD 다이버전스 | Bd/Sd |
-| `obv_divergence` | OBV 다이버전스 | Bd/Sd |
-| `bb_reversal` | 볼린저 역추세 | 지표 |
-| `ma_cross` | EMA 크로스 | 지표 |
-| `rsi_swing` | RSI 스윙 | 지표 |
-| `macd_cross` | MACD 크로스 | 지표 |
-| `supertrend` | Supertrend | 추세 |
-| `adx_trend` | ADX 추세 | 추세 |
-| `ichimoku_trend` | 일목 추세 | 추세 |
-| `parabolic_sar` | Parabolic SAR | 추세 |
-| `stochastic_cross` | Stochastic 크로스 | 모멘텀 |
-| `cci_extreme` | CCI 극값 | 모멘텀 |
-| `roc_reversal` | ROC 반전 | 모멘텀 |
-| `keltner_reversal` | Keltner 역추세 | 변동성 |
-| `atr_channel` | ATR 채널 | 변동성 |
-| `pivot_points` | 피벗 포인트 | 구조 |
-| `support_resistance` | 구조적 지지·저항 | 구조 |
-| `volume_spike` | 거래량 스파이크 | 거래량 |
-| `composite_swing` | 스윙 복합 | 복합 |
-| `composite_pullback` | 눌림목 복합 | 복합 |
-| `composite_breakout` | 돌파 복합 | 복합 |
-| `composite_divergence` | 다이버전스 복합 | 복합 |
-| `composite_v3` | v3 통합 스코어링 | 복합 |
-| `composite_full` | 전체 통합 복합 | 복합 |
-
----
-
-## 구현 현황
-
-| 유형 | 단계 | 상태 |
-|------|------|------|
-| common | 캔들 수집·증분 sync | 구현됨 |
-| spot | 0~2단계 (GT·기법·MTF) | 구현됨 |
-| spot | 3단계 (fractal paper/live·백테스트·텔레그램) | **구현됨** |
+| `fractal_swing` | 프랙탈 스윙 | 스윙 (**live**) |
+| `zigzag_causal` | 인과 ZigZag | 스윙 |
+| `pivot_swing` | 피벗 스윙 | 스윙 |
+| `minor_swing` | 소형 스윙 | 하이브리드 |
+| `local_extrema` | 국소 극값 | 스윙 |
+| `composite_v3` | v3 통합 | 복합 |
+| … | (전체 39종) | `techniques/registry.py` 참고 |
---
## 변경 이력
-- 2026-06-13: 프로젝트명 **Bithumb**으로 변경, 선물(futures) 파이프라인 제거
-- 2026-06-13: 텔레그램 매수·매도 체결 알림 (`notifications/telegram.py`)
-- 2026-06-13: `docs/live/` 운영 백테스트 매매 차트 (`3_render_live_chart.py`)
-- 2026-06-13: fractal_swing live 운영 — 슬리피지·일 체결 상한·전 TF 증분 sync·신호 tail 갱신
-- 2026-06-13: 운영 백테스트 **+1,873,140%** (3년, 슬리피지 0.05%, 일 100회) 검증
-- 2026-06-12: `data/`·`docs/` common/spot 구조 재편
-- 2026-06-12: 3단계 운영 파이프라인 초기 구현 (composite_v3 + MTF paper/live)
-- 2026-06-12: 2단계 인과 기법 분석 파이프라인 완료
-- 2026-06-08: Ground Truth v1/v2/v3
-- 2026-06-07: 캔들 수집 모듈 초기 구현
+- **2026-06-14:** ledger pending, exchange reconcile, max_age backlog, watch 5분 감시·조치, ops.tick.lock, README 전면 갱신
+- **2026-06-13:** fractal_swing live — 슬리피지·sync·tail·텔레그램; ops_default sim **+1,873,140%**
+- **2026-06-13:** 프로젝트명 Bithumb, 선물 파이프라인 제거
+- **2026-06-12:** data/docs common·spot 구조, 2단계 39종 완료, 3단계 초기 (composite_v3)
+- **2026-06-08:** GT v1/v2/v3
+- **2026-06-07:** 캔들 수집 모듈
diff --git a/data/spot/operations/sizing_rules.json b/data/spot/operations/sizing_rules.json
index 547fc96..9cd5185 100644
--- a/data/spot/operations/sizing_rules.json
+++ b/data/spot/operations/sizing_rules.json
@@ -1,7 +1,7 @@
{
"generated_at": "2026-06-13 18:04:03",
"technique_id": "fractal_swing",
- "symbol": "BTC",
+ "symbol": "TRX",
"default_buy_cash_pct": 1.0,
"default_sell_coin_pct": 1.0,
"by_cluster": {
diff --git a/docs/live/index.html b/docs/live/index.html
deleted file mode 100644
index 65d2421..0000000
--- a/docs/live/index.html
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
- Bithumb Live — 운영 백테스트 차트
-
-
-
- Bithumb Live — 운영 백테스트
-
- BTC · 프랙탈 스윙 (fractal_swing)
- sim 기간: 최근 1095일 ·
- 슬리피지 0.05% ·
- 일 체결 상한 100 ·
- MTF off
학습 비율: 매수 100% · 매도 100% (클러스터별 규칙 적용)
-
-
-
3년 수익률 (운영 규칙 sim)
-
+1885460.27%
-
- 매수·매도 타점 차트 열기
-
-
- - 매수 53,519 / 매도 53,444 체결
- - 초기 200,000원 → 최종 3,771,120,549원
- - 차트: B=매수 S=매도 마커, 이전/다음 타점 탐색, 기간 줌
-
-
-
-
\ No newline at end of file
diff --git a/docs/spot/2_analysis/stage2_design_guide.md b/docs/spot/2_analysis/stage2_design_guide.md
deleted file mode 100644
index cbf63a5..0000000
--- a/docs/spot/2_analysis/stage2_design_guide.md
+++ /dev/null
@@ -1,315 +0,0 @@
-# 현물 2단계 설계 가이드
-
-> Bithumb 현물 파이프라인 2단계(인과 기법 분석)의 목적, 구조, 설계 근거를 정리한 문서입니다.
-> 작성 기준: 2026-06-12 · 기본 TF: 3분봉 · GT: v3
-
----
-
-## Plan (계획)
-
-### 목적
-
-현물 2단계는 **0단계 Ground Truth(GT) 타점을 인과적으로 재현할 수 있는 매매 기법을 검증·순위화**하고, 3단계 실거래(`composite_v3` + MTF 필터)에 넘길 전략을 확정하는 단계입니다.
-
-| 단계 | 핵심 질문 | 미래 데이터 |
-|------|-----------|-------------|
-| 0단계 | 10년 3분봉에서 이론상 최적 매수/매도는 어디였나? | 사용 (사후 분석, 실거래 불가) |
-| 1단계 | GT를 완벽히 따라가면 수익 상한은? | GT 자체가 사후 타점 |
-| **2단계** | **과거 데이터만으로 GT 타점을 얼마나 맞출 수 있나?** | **미사용 (인과)** |
-| 3단계 (예정) | 검증된 전략을 실거래에 연결 | 실시간 인과 |
-
-### GT v3 신호 유형 (정답지)
-
-0단계 GT v3는 3분봉 `bar_index` 기준으로 아래 6종 신호를 포함합니다.
-
-| 코드 | 유형 | 의미 |
-|------|------|------|
-| B | swing_low | 스윙 매수 |
-| B* | pullback | 눌림목 매수 |
-| B^ | breakout | 돌파 매수 |
-| Bd | div_bull | 상승 다이버전스 매수 |
-| S | swing_high | 스윙 매도 |
-| Sd | div_bear | 하락 다이버전스 매도 |
-
-### 설계 원칙: 3분 = 타이밍, 상위 TF = 맥락
-
-2단계는 **두 레이어**로 나뉩니다.
-
-| 레이어 | 타임프레임 | 역할 |
-|--------|------------|------|
-| **기법 (2-1 ~ 2-3)** | 3분봉 | **언제** 사고팔지 — GT 타점 재현 |
-| **MTF (2-4, 3단계)** | 1분 ~ 월봉 | **그때 해도 되는지** — 과매수·추세·변동성 보조 |
-
-3분봉만 사용하는 것은 상위 TF를 무시한다는 뜻이 **아닙니다**. GT 정답지가 3분봉에 정의되어 있으므로 기법 평가는 동일 TF에서 수행하고, 일/주/월봉 맥락은 MTF 레이어에서 붙입니다.
-
-### 2단계가 하지 **않는** 것
-
-아래는 2단계의 목적과 맞지 않거나, 별도 설계가 필요한 접근입니다.
-
-- 39개 기법을 1분 ~ 월봉 **모든 TF**에 각각 실행
-- **모든 봉**에 시장 상태 라벨을 붙여 매수/매도를 처음부터 탐색
-- GT 없이 상태 분류만으로 전략을 발견
-
----
-
-## Do (실행)
-
-### 파이프라인 실행
-
-```bash
-cd Bithumb
-export PYTHONPATH=src
-bash scripts/2_run_stage2_all.sh
-```
-
-### 4단계 세부
-
-| 순서 | 스크립트 | 입력 | 산출물 |
-|------|----------|------|--------|
-| 2-1 | `2_run_techniques.py` | 3분봉 3650일, GT v3 | `data/spot/techniques/*.json`, `comparison_report.html` |
-| 2-2 | `2_run_causal_sim.py` | 2-1 결과 | `causal_sim_report.html`, 기법별 sim 차트 |
-| 2-3 | `2_run_signal_type_align.py` | 2-1 결과 (캐시) | `signal_type_report.html` |
-| 2-4 | `2_run_mtf_analysis.py` | GT v3, 1분~월봉 피처 | `mtf_correlation_report.html`, `data/spot/mtf/mtf_rules_v3.json` |
-
-### 2-1. 기법 실행
-
-**처리 흐름**
-
-1. `coins.db`에서 BTC **3분봉** 3650일 로드 (`GT_INTERVAL_MIN=3`)
-2. 등록된 **39개 인과 기법** 각각 `generate_signals()` 실행
-3. 조건 충족 봉에서만 `buy` / `sell` **이벤트** 생성 (모든 봉에 상태 라벨 X)
-4. 신호를 매수·매도 **레그(leg)** 로 묶고 수익률 계산
-5. GT v3 타점과 **정합(alignment)** 평가 — 기본 허용 오차 ±480봉 (약 24시간)
-
-**정합 지표**
-
-- recall, precision, F1
-- leg recall (매수·매도 쌍 단위)
-- 종합 **score** (터미널 `score=91.1` 등)
-
-**39개 기법 구성**
-
-- 단일 기법 33종: 스윙, 눌림목, 돌파, 다이버전스, 지표, 추세, 모멘텀, 변동성, 구조, 거래량
-- 복합 기법 6종: `composite_swing`, `composite_pullback`, `composite_breakout`, `composite_divergence`, **`composite_v3`**, `composite_full`
-
-`composite_v3`는 v3 GT 6종 신호를 가중 투표로 재현하는 **3단계 실거래 후보 전략**입니다.
-
-### 2-2. 인과 sim
-
-- 2-1에서 저장한 기법 신호를 **최근 3년** (`GT_SIM_LOOKBACK_DAYS=1095`) 구간으로 sim
-- 1단계 GT sim(정답지 수익 상한)과 비교
-- 기법별 HTML sim 차트 생성
-
-### 2-3. 신호 유형별 정합
-
-- GT v3의 B / B* / B^ / Bd / S / Sd **유형별 recall** 분석
-- 예: `ema_pullback`은 B*(눌림목)에 강한가, `rsi_divergence`는 Bd/Sd에 강한가
-- 유형별 최고 recall 기법 리포트
-
-### 2-4. MTF 상관 분석
-
-39기법을 TF마다 다시 돌리지 **않습니다**. 대신:
-
-1. GT v3 **매수/매도 시점**(양성 샘플)에서 1분 ~ 월봉 피처 스냅샷 추출
-2. GT가 아닌 **랜덤 3분봉 시점**(음성 샘플, 기본 2000건)에서 동일 추출
-3. 양성 vs 음성 피처 차이(Cohen's d) 분석
-4. 신호 유형별 MTF **필터 규칙** 도출 → `mtf_rules_v3.json`
-
-**사용 TF** (`DEFAULT_DOWNLOAD_INTERVALS`)
-
-1, 3, 5, 10, 15, 30, 60, 240분, 일(1440), 주(10080), 월(43200)
-
-**TF별 피처** (`src/bithumb/mtf/features.py`)
-
-| 피처 | 용도 |
-|------|------|
-| `rsi14` | 과매수 / 과매도 |
-| `bb_position` | 볼린저 밴드 내 위치 |
-| `close_vs_ema60_pct` | EMA60 대비 이격 (추세·과열) |
-| `ema60_slope_5_pct` | EMA60 기울기 |
-| `trend_bias` | bullish / bearish |
-| `atr_pct` | 변동성 |
-| `zigzag_direction`, `zigzag_leg_pct` | 인과 ZigZag 상태 |
-
-모든 피처는 **인과적** — 해당 시점에 확정된 봉만 사용, 미래 데이터·미완성 상위 TF 봉 미사용.
-
-### 3단계 연동 (예정)
-
-```
-3분 composite_v3 신호 발생
- → MtfFeatureExtractor: 그 시점 1분~월봉 스냅샷
- → HtfTrendGate: 60분·일봉 EMA 이격으로 극단 구간 차단
- → MtfSignalFilter: 신호 유형별 MTF 규칙 N개 이상 충족
- → 통과 신호만 실거래
-```
-
-**고TF 추세 게이트** (`HtfTrendGate`) 기본값
-
-| 조건 | 동작 |
-|------|------|
-| 일봉 EMA60 대비 -25% 이하 | 매수 차단 (과매도·칼날) |
-| 60분 EMA60 대비 -15% 이하 | 매수 차단 |
-| 일봉 EMA60 대비 +35% 이상 | 매도 차단 (과열) |
-| 60분 EMA60 대비 +20% 이상 | 매도 차단 |
-
-### 데이터 흐름
-
-```mermaid
-flowchart TD
- GT["0단계 GT v3\n(3분봉 정답 타점)"]
- Candles["3분봉 3650일\n(coins.db)"]
-
- subgraph step21 ["2-1 기법 실행"]
- T["39개 인과 기법"]
- T --> Sig["매수/매도 신호"]
- Sig --> Align["GT 정합 score"]
- end
-
- subgraph step22 ["2-2 sim"]
- Sim["최근 3년 수익률"]
- end
-
- subgraph step23 ["2-3 신호유형"]
- Type["B/B*/B^/Bd/S/Sd별 recall"]
- end
-
- subgraph step24 ["2-4 MTF"]
- MTF["1분~월봉 피처"]
- MTF --> Rules["mtf_rules_v3.json"]
- end
-
- subgraph step3 ["3단계 (예정)"]
- Filter["composite_v3 + MtfSignalFilter"]
- end
-
- GT --> Align
- Candles --> step21
- Align --> step22
- Align --> step23
- GT --> step24
- MTF --> Rules
- Rules --> Filter
- Sig --> Filter
-```
-
----
-
-## Check (검토)
-
-### 접근 방식 비교
-
-#### A. 현재 방식 (채택)
-
-3분봉 × 39기법 GT 정합 + 4단계 MTF 상관 분석
-
-#### B. 대안: 모든 기법 × 모든 TF × 모든 봉 상태
-
-| 구분 | A: 현재 2단계 | B: 전 TF·전 봉 상태 | C: 절충 (선택 실험) |
-|------|---------------|---------------------|---------------------|
-| **품질 (Q)** | GT와 동일 TF·bar_index로 평가 **명확** | TF마다 신호 의미 상이, **평가 기준 혼란** | 핵심 기법만 상위 TF 추가 검증 |
-| **비용 (C)** | 39기법 × 1 TF ≈ 1~2시간 | 39 × 11 TF ≈ 10~20시간+, 봉별 상태 저장 부담 큼 | +2~3 TF 시 +30~50% |
-| **일정 (D)** | 파이프라인·3단계 **구현 완료** | 정합·스키마·리포트 **전면 재설계** | 기존 유지 + 실험 브랜치 |
-| **리스크 (R)** | 단일 TF 편향 (MTF로 보완) | 차원 폭발 → **과적합** | 실험 범위 제한으로 통제 |
-| **장점 (S)** | 인과·실거래·GT 정합 **일관** | TF별 탐색적 발견 | A 명확성 + B 인사이트 일부 |
-| **단점 (W)** | 2-1 단독으론 상위 TF 안 보임 | GT(3분)와 **축 불일치** | 실험 설계 부담 |
-| **비고** | **현 단계 최적** | 2단계 **대체안 부적합** | 장기 개선 1순위 |
-
-**결론: A(현재 방식) 유지. 필요 시 C로 확장.**
-
-### 3분봉만으로 상위 TF 과매수/과매도 판단이 어렵지 않은가?
-
-**맞습니다.** 3분봉 신호만 따르면:
-
-- 일봉 RSI 과매수 구간 3분 눌림목 매수 → 역추세 진입
-- 주봉 하락 추세 3분 돌파 매수 → 가짜 돌파
-- 월봉 과매도 3분 매도 → 바닥 청산
-
-등의 문제가 발생합니다. Bithumb은 이를 **MTF 레이어**로 보완합니다.
-
-| 상황 | MTF 해석 | 의도 |
-|------|----------|------|
-| 3분 B* + 주봉 RSI 과매도 + 일봉 trend bullish | MTF 규칙 다수 충족 | 적극 매수 (3단계 확장) |
-| 3분 B* + 일봉 RSI 과매수 | 게이트/규칙 미충족 | 보류 |
-| 3분 S + 월봉 과열 + 60분 기울기 꺾임 | 매도 규칙 충족 | 적극 매도 (3단계 확장) |
-
-현재 구현은 **차단(필터)** 중심이며, MTF 점수 기반 **포지션 크기 조절**은 3단계에서 확장 가능합니다.
-
-### KPI · 확인 방법
-
-| KPI | 확인 위치 | 기준 예시 |
-|-----|-----------|-----------|
-| GT 정합 score | `comparison_report.html` | composite_v3 상위권 |
-| leg recall | `data/spot/techniques/*.json` | 70% 이상 (기법별 상이) |
-| sim 수익률 (3년) | `causal_sim_report.html` | 1단계 GT sim 대비 합리적 |
-| 신호 유형 recall | `signal_type_report.html` | B/B*/B^/Bd/S/Sd 유형별 1위 기법 |
-| MTF Cohen's d | `mtf_correlation_report.html` | \|d\| ≥ 1.2 규칙 후보 |
-| MTF 규칙 통과율 | 3단계 `MtfSignalFilter` | kept/rejected 비율 |
-
-### 주요 환경 변수
-
-| 변수 | 설명 | 기본값 |
-|------|------|--------|
-| `GT_INTERVAL_MIN` | GT·기법 기준 인터벌(분) | `3` |
-| `GT_LOOKBACK_DAYS` | GT·기법 기간(일) | `3650` |
-| `GT_SIM_LOOKBACK_DAYS` | sim·MTF 분석 구간(일) | `1095` |
-| `GT_ALIGN_TOLERANCE_BARS` | GT 정합 허용 봉 수 | `480` |
-| `TECHNIQUES_DIR` | 기법 결과 JSON | `data/spot/techniques/` |
-| `MTF_RULES_JSON` | MTF 규칙 | `data/spot/mtf/mtf_rules_v3.json` |
-
----
-
-## Act (개선)
-
-### 현재 한계
-
-| 항목 | 상태 | 비고 |
-|------|------|------|
-| 2-1 ~ 2-3 | MTF 미적용 | 의도적 — GT 정합 단계 |
-| `HtfTrendGate` | 60분·일봉만 | 주·월봉 RSI/BB 게이트 추가 가능 |
-| MTF 필터 | 통과/실패 이진 | 점수 기반 사이즈 조절 (3단계) |
-| 2-4 → rules JSON | `derive_rules_from_report()` 존재 | 2-4 스크립트 자동 저장 연동 검토 |
-
-### 권장 개선 (절충 C)
-
-2단계 골격은 유지하고, 아래만 **별도 실험**으로 진행합니다.
-
-1. **TF별 핵심 기법**: 스윙 5종 + `composite_v3`를 60분·일봉에 추가 실행 — 상위 TF 스윙과 3분 GT 정합 비교
-2. **MTF 4단계 강화**: 음성 샘플 수, 피처, `min_cohens_d` 조정
-3. **주·월봉 게이트**: `HtfTrendGate`에 RSI/BB 기반 조건 추가
-4. **레짐 라벨 모듈**: EMA/ADX 등 봉별 장세 라벨 — 39기법 전 TF 재실행 없이 보조
-
-### 하지 말아야 할 것
-
-- 39기법 × 11 TF × 175만 3분봉 상태를 2단계 **기본 산출물**로 포함
-- GT(3분)와 다른 TF 신호를 **동일 tolerance**로 무조건 비교
-
-### 실행 체크리스트
-
-- [ ] 0단계 GT v3 생성 완료 (`0_ground_truth.py --tier all`)
-- [ ] 1단계 GT sim 완료 (벤치마크 참조용)
-- [ ] `bash scripts/2_run_stage2_all.sh` 완료
-- [ ] `comparison_report.html` — 기법 순위 확인
-- [ ] `signal_type_report.html` — 유형별 강점 기법 확인
-- [ ] `mtf_correlation_report.html` — 일/주/월 RSI·EMA 패턴 확인
-- [x] `mtf_rules_v3.json` — 3단계 필터 규칙 반영 (`data/spot/mtf/mtf_rules_v3.json`)
-
----
-
-## 참고: 관련 소스
-
-| 모듈 | 경로 |
-|------|------|
-| 기법 실행 | `scripts/2_run_techniques.py`, `src/bithumb/techniques/runner.py` |
-| GT 정합 | `src/bithumb/evaluation/gt_align.py` |
-| MTF 피처 | `src/bithumb/mtf/features.py`, `extractor.py`, `store.py` |
-| MTF 규칙 | `src/bithumb/mtf/rules.py` |
-| MTF 필터 | `src/bithumb/mtf/filter.py`, `trend_gate.py` |
-| 통합 기법 | `src/bithumb/techniques/composite_v3.py` |
-
----
-
-## 변경 이력
-
-| 날짜 | 내용 |
-|------|------|
-| 2026-06-12 | 초版 작성 — 2단계 목적, 4단계 구조, MTF 보완, 접근 방식 비교 정리 |
diff --git a/docs/spot/2_analysis/stage2_final_summary.md b/docs/spot/2_analysis/stage2_final_summary.md
deleted file mode 100644
index ed85302..0000000
--- a/docs/spot/2_analysis/stage2_final_summary.md
+++ /dev/null
@@ -1,290 +0,0 @@
-# 현물 2단계 최종 정리 — 결과 해석 및 운영 권고
-
-> Bithumb 현물 파이프라인 2단계(인과 기법 분석) 완료 후 종합 정리 문서
-> 작성 기준: 2026-06-12 · 데이터: BTC · 3분봉 · GT v3 · 분석 기간 3650일 · sim 기간 최근 3년(1095일)
-
----
-
-## 요약 (Executive Summary)
-
-| 질문 | 결론 |
-|------|------|
-| **sim 1위 `fractal_swing`을 바로 실거래에 써도 되나?** | **아니요.** 연구·벤치마크용으로는 유효하나, 체결 빈도·비용·슬리피지를 고려하면 실거래 부적합 |
-| **2단계에서 무엇을 얻었나?** | GT v3 타점을 **인과적으로** 재현하는 기법 39종의 **정합 순위**, **3년 sim 비교**, **신호 유형별 강점**, **MTF 상관 패턴** |
-| **운영 후보는?** | 원안 **`composite_v3` + MTF 필터(3단계)** — 단, 현재 sim 기준 composite_v3는 **-97.5%**로 **3단계 튜닝 전 실거래 금지** |
-| **당장 관찰용으로 쓸 만한 기법** | `zigzag_causal`(3년 sim +92,711%, 체결 97회), `minor_swing`(+286,537%, 831회) — **모의·소액 검증 후** 판단 |
-
----
-
-## Plan (계획) — 2단계가 무엇을 했는가
-
-### 목적
-
-0단계에서 도출한 **Ground Truth v3(사후 최적 타점)** 을, **미래 데이터 없이** 인과 규칙만으로 얼마나 재현할 수 있는지 검증하고, 3단계 실거래 전략의 근거를 마련하는 단계입니다.
-
-| 단계 | 역할 | 미래 데이터 |
-|------|------|-------------|
-| 0단계 | 10년 3분봉 이론적 최적 매수/매도(GT v3) | 사용 (연구용) |
-| 1단계 | GT 타점을 그대로 sim했을 때 3년 수익 **벤치마크** | GT 자체가 사후 타점 |
-| **2단계** | **39개 인과 기법**으로 GT 재현도·sim·MTF 분석 | **미사용** |
-| 3단계 (예정) | 검증된 전략 + MTF 필터 실거래 연결 | 실시간 인과 |
-
-### GT v3 신호 체계 (정답지)
-
-| 코드 | 유형 | 10년 GT 건수(매수/매도) |
-|------|------|-------------------------|
-| B | swing_low (스윙 매수) | 944 |
-| B* | pullback (눌림목) | 406 |
-| B^ | breakout (돌파) | 122 |
-| Bd | div_bull (상승 다이버전스) | 115 |
-| S | swing_high (스윙 매도) | 944 |
-| Sd | div_bear (하락 다이버전스) | 144 |
-
-### 실행 구조 (2-1 ~ 2-4)
-
-| 순서 | 내용 | 주요 산출물 |
-|------|------|-------------|
-| 2-1 | 39개 기법 신호 생성 + GT 정합 score | `data/spot/techniques/*.json`, `comparison_report.html` |
-| 2-2 | 동일 sim 엔진으로 최근 3년 수익률 비교 | `causal_sim_report.html`, `causal_sim_chart_best_technique.html` |
-| 2-3 | B/B*/B^/Bd/S/Sd 유형별 recall | `signal_type_report.html` |
-| 2-4 | GT 시점 vs 랜덤 시점 MTF 피처 상관 | `mtf_correlation_report.html` |
-
-설계 상세는 [`stage2_design_guide.md`](stage2_design_guide.md)를 참고하세요.
-
----
-
-## Do (실행) — 핵심 결과
-
-### 1. GT 정합 score 상위 (2-1, 10년 전체)
-
-정합 허용 오차: **±480봉(약 24시간)**. score는 recall·precision·leg recall 등을 종합한 0~1 지표입니다.
-
-| 순위 | 기법 | score | buy/sell recall | leg recall | 비고 |
-|------|------|-------|-----------------|------------|------|
-| 1 | **fractal_swing** | **0.914** | 100% / 100% | 75.3% | 프랙탈(span=2) 극저점·극고점 |
-| 2 | **pivot_swing** | **0.911** | 100% / 100% | 74.6% | 피벗 기반 스윙 |
-| 3 | **minor_swing** | **0.864** | 87.9% / 95.0% | 73.2% | 소형 스윙 하이브리드 |
-| 4 | local_extrema | 0.839 | 86.3% / 90.7% | 70.6% | 국소 극값 |
-| 5 | **zigzag_causal** | **0.776** | 59.9% / 86.8% | 74.2% | 인과 ZigZag — **스윙(B/S)에 특화** |
-| … | composite_v3 | 0.546 | 97.5% / 89.0% | **22.9%** | 3단계 운영 후보이나 leg 정합 낮음 |
-
-**해석:** 상위 3종은 모두 **스윙 타이밍** 기법입니다. `composite_v3`는 개별 신호 recall은 높지만 **매수·매도 쌍(leg) 정합이 22.9%**에 그쳐, “타점은 근처에 있으나 한 사이클로 묶기 어렵다”는 특성이 있습니다.
-
-### 2. 3년 인과 sim (2-2) — 1단계 GT v3 벤치마크 대비
-
-- **sim 기간:** 2023-06-12 ~ 2026-06-11 (1095일)
-- **초기 자본:** 200,000원
-- **엔진:** 1단계와 동일 `simulate_gt_signals_pnl` (클러스터 분할, 매수 상한, 수수료 반영, 슬리피지 미반영)
-
-#### 1단계 벤치마크
-
-| 항목 | 1단계 GT v3 sim |
-|------|-----------------|
-| 3년 수익률 | **+94,154%** |
-| 최종 평가 | 약 1.89억 원 |
-| 체결 | 매수 239 / 매도 151 |
-| 기간 내 신호 | 390건 |
-
-#### 2단계 sim 상위
-
-| 순위 | 기법 | 3년 sim 수익률 | 최종 평가 | 매수/매도 체결 | GT 정합 |
-|------|------|----------------|-----------|----------------|---------|
-| 1 | fractal_swing | **+7,560,826%** | 약 151억 | **56,893 / 56,892** | 0.914 |
-| 2 | pivot_swing | +4,687,495% | 약 94억 | 12,656 / 12,658 | 0.911 |
-| 3 | minor_swing | +286,537% | 약 5.7억 | 831 / 887 | 0.864 |
-| 4 | keltner_reversal | +203,632% | 약 4.1억 | 26,554 / 24,839 | 0.723 |
-| … | **zigzag_causal** | **+92,711%** | 약 1.86억 | **97 / 97** | 0.776 |
-| … | **composite_v3** | **-97.5%** | 약 5,000원 | 1,885 / 1,237 (스킵 다수) | 0.546 |
-
-**일평균 체결 빈도(매수 기준, 3년):**
-
-| 기법 | 연간 약 | 일평균 약 |
-|------|---------|-----------|
-| fractal_swing | 18,964회 | **52회/일** |
-| pivot_swing | 4,219회 | 12회/일 |
-| minor_swing | 277회 | 0.8회/일 |
-| zigzag_causal | 32회 | **0.09회/일** |
-| GT v3 (1단계) | 80회 | 0.2회/일 |
-
-### 3. sim 1위가 GT 벤치마크보다 높은 이유 (핵심 메커니즘)
-
-동일 sim 엔진임에도 수익률이 역전되는 이유는 **전략 품질이 아니라 체결 구조** 때문입니다.
-
-| 요인 | GT v3 (1단계 sim) | fractal_swing (sim 1위) |
-|------|-------------------|-------------------------|
-| 신호 수 (3년) | 390 | **113,786** |
-| 클러스터 | 평균 1.62신호/클러스터, 다중 매수 분할 | **99.7%가 1신호=1체결** |
-| 매매 패턴 | 드문 타점, 보수적 분할·상한 | **매수·매도 거의 매 스윙마다 교대** |
-| 수수료 (3년 sim) | 상대적으로 적음 | **약 65억 원** (이상적 체결 가정) |
-| sim의 의미 | “최적 타점을 보수적으로 따라감” | “초고빈도 복리 + 상승장 + 슬리피지 없음” |
-
-**정리:** fractal의 높은 sim 수익률은 **GT 재현 우수성의 증거가 아니라**, tolerance(±24시간) 안에서 **모든 미세 스윙이 GT에 걸리는 구조**와 **과매매 복리**가 합쳐진 **백테스트 착시**에 가깝습니다.
-
-### 4. 신호 유형별 정합 (2-3) — v3 6종 커버리지
-
-#### fractal_swing — tolerance 내 “전 유형 100% recall”
-
-±480봉 허용 시 스윙·눌림·돌파·다이버전스 **모든 GT 유형에 recall 100%**로 집계됩니다. 이는 각 유형을 **정확히 구분해 맞춘다**는 뜻이 아니라, **3분봉 미세 스윙이 24시간 안에 GT 타점과 겹친다**는 통계적 결과입니다.
-
-#### zigzag_causal — 스윙 특화, 복합 유형 약함
-
-| GT 유형 | zigzag recall | 해석 |
-|---------|---------------|------|
-| B (swing_low) | **100%** | 스윙 매수에 최적 |
-| S (swing_high) | **100%** | 스윙 매도에 최적 |
-| B* (pullback) | 38.4% | 눌림목은 별도 기법 필요 |
-| B^ (breakout) | 15.6% | 돌파 약함 |
-| Bd (div_bull) | 4.4% | 다이버전스 거의 미포착 |
-| Sd (div_bear) | 2.8% | 다이버전스 거의 미포착 |
-
-→ v3 **6종 전체**를 한 기법으로 운영하려면 **스윙 + 눌림 + 돌파 + 다이버전스**를 조합한 `composite_v3` 또는 유형별 전문 기법 조합이 필요합니다.
-
-#### composite_v3 (3단계 설계 후보)
-
-- 10년 정합 score **0.546** (39종 중 하위권)
-- leg recall **22.9%** — 신호는 많지만 **한 사이클(매수→매도) 단위 정합 낮음**
-- 3년 sim **-97.5%**, buys_skipped **6,090건** — 신호 과다 + 자본·상한·클러스터 규칙과 충돌
-
-### 5. MTF 상관 분석 (2-4)
-
-GT v3 **매수/매도 시점(양성)** vs **랜덤 3분봉(음성)** 에서 1분~월봉 피처를 비교했습니다.
-
-**대표 패턴 (|Cohen's d| 큰 항목 예시):**
-
-| 상황 | TF | 피처 | 방향 |
-|------|-----|------|------|
-| 스윙 매도(S) | 15분 | RSI14 | GT 시점이 랜덤보다 RSI 높음 (과매수 근처) |
-| 눌림목(B*) | 3분 | close_vs_ema60_pct | GT 눌림이 EMA60 대비 더 아래 |
-| 스윙 매수(B) | 30분 | ema60_slope | GT 매수 시 기울기가 상대적으로 완만/하락 |
-
-**의미:** 3분 신호만으로는 부족한 **상위 TF 과열·추세** 정보가 GT 타점과 통계적으로 연관됩니다. 3단계 `HtfTrendGate` + `MtfSignalFilter`로 보완하는 설계가 타당합니다.
-
-**현재 상태:** `mtf_correlation_report.html`·`.json`은 생성됨. `data/spot/mtf/mtf_rules_v3.json`은 **아직 자동 저장 미연동** — 3단계 전 규칙 JSON 확정·연동 필요.
-
----
-
-## Check (검토) — “가장 좋은 기법”을 운영에 써도 되는가?
-
-### 결론: **fractal_swing 단독 실거래는 권장하지 않습니다**
-
-| 검토 항목 | fractal_swing | 실거래 적합성 |
-|-----------|---------------|---------------|
-| 체결 빈도 | 일 **약 52회** 매수 | 거래소 API·수수료·세금·운영 부담 과다 |
-| 슬리피지 | sim **미반영** | 고빈도일수록 체결 가격 악화 누적 |
-| 수수료 | 3년 sim만 **약 65억** 가정 | 실제로도 수익 잠식 극심 |
-| 신호 의미 | 미세 스윙 전부 | 노이즈·휩소에 취약 |
-| GT 정합 | score 최상 | **±24h tolerance** 효과 — “정확한 v3 타입 재현”과 다름 |
-| 3단계 설계 | 원안은 composite_v3 + MTF | fractal은 **연구 1위**, **운영 1안 아님** |
-
-**가능한 예외 (제한적):**
-
-- **연구·모니터링:** 차트·알림으로 “스윙 후보” 참고용
-- **극소액 실험:** 일 체결 상한(예: 1~2회/일), 포지션 캡, 슬리피지 가정 추가 후 **모의거래 3개월 이상**
-
-### 운영 후보 비교 (QCD)
-
-| 구분 | A. fractal_swing 단독 | B. composite_v3 + MTF (원안) | C. zigzag + 유형별 보조 + MTF | D. minor_swing + MTF |
-|------|----------------------|------------------------------|------------------------------|----------------------|
-| **Q (품질)** | 스윙 타이밍만, 유형 혼동 | v3 6종 통합 의도 | B/S 강함, B*/B^/Bd 약 — 보조 기법 필요 | 스윙+하이브리드, 균형 |
-| **C (비용)** | 수수료·API 호출 **최대** | 신호 필터 후 감소 예정 | **낮음** (연 ~32매수) | **중간** (연 ~277매수) |
-| **D (일정)** | 즉시 가능하나 **리스크 최대** | 3단계 구현·튜닝 필요 | MTF·모의 1~2개월 | MTF·모의 1~2개월 |
-| **R (리스크)** | 과매매·슬리피지·장애 **극高** | 현재 sim -97.5% — **튜닝 전 위험** | 신호 적어 기회 손실 | 중간 빈도 휩소 |
-| **S (장점)** | GT 정합 1위, 구현 단순 | v3 철학과 일치, 확장성 | **GT sim과 유사 체결(97회)**, 해석 용이 | 정합 3위, sim 양호 |
-| **W (단점)** | 실거래 **비현실** | leg recall 낮음, 과다 스킵 | 6종 중 4종 약함 | pivot/fractal 대비 정합 낮음 |
-| **비고** | **운영 부적합** | **장기 정석**(튜닝 후) | **단기 모의 1순위** | 모의 2순위 |
-
-**최종 의견:**
-- **지금 당장 실거래:** 위 네 안 모두 **완료 전제 미충족**. 최소 **3단계 MTF 필터 + 모의거래 + composite/조합 튜닝** 후 소액.
-- **2단계 성과의 올바른 사용:** sim 1위 기법을 “운영 전략”으로 고르지 말고, **정합·유형·체결 빈도·MTF**를 함께 보고 **3단계 설계 입력**으로 사용.
-- **단기 모의 우선순위:** **C (zigzag_causal + MTF)** → **D (minor_swing + MTF)**.
-- **중장기 운영 정석:** **B (composite_v3 + MTF)** — 임계값·스킵 로직·leg 정합 개선 후.
-
-### KPI 달성 여부 (2단계 관점)
-
-| KPI | 목표(가이드) | 결과 | 판정 |
-|-----|--------------|------|------|
-| 인과 기법 39종 실행 | 완료 | 완료 | 달성 |
-| GT 정합 ranking | 상위 기법 식별 | fractal/pivot/minor | 달성 |
-| 1단계 sim 대비 3년 비교 | 해석 가능한 보고 | causal_sim_report | 달성 (단, sim만으로 운영 선정 금지) |
-| 유형별 recall | B~Sd별 1위 기법 | signal_type_report | 달성 |
-| MTF Cohen's d | 필터 규칙 후보 | mtf_correlation_report | 달성 |
-| mtf_rules_v3.json | 3단계 입력 | `data/spot/mtf/mtf_rules_v3.json` (6유형×4규칙) | 달성 |
-
----
-
-## Act (개선) — 3단계로 넘기기 전 체크리스트
-
-### 하지 말아야 할 것
-
-1. **causal_sim_report 수익률 1위 = 실거래 전략**으로 간주
-2. **fractal_swing 풀오토** (일 50회+ 체결)
-3. **composite_v3 미튜닝 실거래** (현재 3년 sim -97.5%)
-4. **0단계 전기간 GT 수익**과 2단계 3년 sim **직접 비교** (sim 규칙이 다름)
-
-### 권장 다음 단계
-
-| 순서 | 작업 | 목적 |
-|------|------|------|
-| 1 | `mtf_rules_v3.json` 생성·`MtfSignalFilter` 연동 | 3단계 필터 기반 확보 |
-| 2 | `composite_v3` 임계값·스킵 원인 분석 (buys_skipped 6,090) | leg 정합·자본 효율 개선 |
-| 3 | **zigzag_causal** + MTF **모의거래** (슬리피지 0.05~0.1% 가정) | 현실적 체결 검증 |
-| 4 | 유형별 보조: B*→ema_pullback, B^→donchian, Bd→rsi_divergence 등 **composite 재가중** | v3 6종 커버리지 |
-| 5 | sim에 **슬리피지·일 최대 체결 횟수** 옵션 추가 | 고빈도 기법 과대평가 방지 |
-| 6 | 소액 실거래 전 **최소 3개월 paper trading** KPI | MDD, 승률, 실체결률 |
-
-### 관련 산출물 위치
-
-| 문서/데이터 | 경로 |
-|-------------|------|
-| 기법 정합 순위 | `docs/spot/2_analysis/comparison_report.html` |
-| 3년 sim 순위 | `docs/spot/2_analysis/causal_sim_report.html` |
-| 1위 vs 1단계 GT 비교 차트 | `docs/spot/2_analysis/causal_sim_chart_best_technique.html` |
-| 신호 유형별 recall | `docs/spot/2_analysis/signal_type_report.html` |
-| MTF 상관 | `docs/spot/2_analysis/mtf_correlation_report.html` |
-| 기법별 sim 차트 (39종) | `docs/spot/2_analysis/technique_chart_sim_*.html` |
-| 기법 JSON | `data/spot/techniques/*.json` |
-| 2단계 설계 가이드 | `docs/spot/2_analysis/stage2_design_guide.md` |
-
----
-
-## 부록 A — 지표 읽는 법
-
-### GT 정합 score vs sim 수익률
-
-- **score:** “GT 타점 **근처**에 인과 신호가 있었는가” (위치 품질)
-- **sim 수익률:** “그 신호를 **현재 체결 규칙**으로 얼마나 자주·얼마나 크게 탔는가” (빈도·복리)
-
-두 지표는 **상관이 없을 수 있음**. fractal이 대표 사례.
-
-### 1단계 GT sim이 “상한”이 아닌 이유
-
-- 클러스터 **분할 매수** (v3 평균 매수 클러스터 ~2신호)
-- **매수 상한** (총평가 1억/10억/100억 구간별 10%/5%/1%)
-- 3년 구간 **신호 390건**에 한정
-
-0단계 `simulate_gt_pnl`(레그당 전액 in/out)과는 다른 척도입니다.
-
-### fractal이 모든 signal_type recall 100%인 이유
-
-정합 tolerance **±480봉(24시간)**. 3분봉 fractal은 하루에도 수십 개 스윙을 내므로, GT의 스윙·눌림·돌파·다이버전스 타점 대부분이 **시간 창 안에至少 하나의 fractal 신호**와 매칭됩니다. **유형 분류 정확도**와 혼동하지 말 것.
-
----
-
-## 부록 B — 3년 sim 참고 표 (체결 빈도별)
-
-| 기법 | sim 수익률 | 매수 체결 | GT 정합 | 운영 관점 |
-|------|-------------|-----------|---------|-----------|
-| fractal_swing | +7,560,826% | 56,893 | 0.914 | 연구용, 실거래 비권장 |
-| pivot_swing | +4,687,495% | 12,656 | 0.911 | 고빈도, 실거래 비권장 |
-| minor_swing | +286,537% | 831 | 0.864 | 모의 후보 |
-| zigzag_causal | +92,711% | 97 | 0.776 | **모의 1순위** (GT sim과 유사 빈도) |
-| GT v3 (1단계) | +94,154% | 239 | — | 벤치마크 |
-| composite_v3 | -97.5% | 1,885 | 0.546 | 3단계 튜닝 필수 |
-
----
-
-## 변경 이력
-
-| 날짜 | 내용 |
-|------|------|
-| 2026-06-12 | 2단계 완료 후 최종 정리 — 운영 권고, sim 역전 해석, 3단계 체크리스트 |
diff --git a/docs/spot/3_operations/stage3_design_guide.md b/docs/spot/3_operations/stage3_design_guide.md
deleted file mode 100644
index f85248c..0000000
--- a/docs/spot/3_operations/stage3_design_guide.md
+++ /dev/null
@@ -1,150 +0,0 @@
-# 현물 3단계 설계 가이드 — 실거래 운영
-
-> 2단계 검증 전략(`composite_v3` + MTF 필터)을 빗썸 현물에 연결하는 운영 단계
-> 작성 기준: 2026-06-12 · 기본 모드: **paper**
-
----
-
-## Plan (계획)
-
-### 목적
-
-2단계에서 도출한 **인과 기법 + MTF 필터**를 실시간(또는 모의) 운영 파이프라인에 연결하고, **live 전환 전** 필터 효과를 백테스트로 재검증한다.
-
-### 파이프라인
-
-```
-캔들 증분 동기화 (00_download.py)
- ↓
-composite_v3 신호 생성 (3분봉)
- ↓
-signal_type 추론 (기여 기법 → B/B*/B^/Bd/S/Sd)
- ↓
-HtfTrendGate (60분·일봉 극단 차단)
- ↓
-MtfSignalFilter (mtf_rules_v3.json)
- ↓
-paper / live 체결 (구간별 매수 상한 동일)
-```
-
-### 운영 모드
-
-| 모드 | 설명 | 기본값 |
-|------|------|--------|
-| **paper** | DB 캔들·신호 가격 기준 모의 체결 | **권장·기본** |
-| **live** | 빗썸 Private API 시장가 주문 | API 키 필요, 신중히 사용 |
-
----
-
-## Do (실행)
-
-### 스크립트
-
-| 순서 | 스크립트 | 역할 |
-|------|----------|------|
-| 3-1 | `3_run_filtered_backtest.py` | MTF 필터 전/후 3년 sim 비교 |
-| 3-2 | `3_run_operations.py` | paper/live 1회 tick (신호·체결) |
-| 일괄 | `3_run_stage3_all.sh` | 3-1 + 3-2 paper |
-
-```bash
-cd Bithumb
-export PYTHONPATH=src
-
-# MTF 필터 백테스트
-python scripts/3_run_filtered_backtest.py
-
-# paper 운영 1회
-python scripts/3_run_operations.py
-
-# 일괄
-bash scripts/3_run_stage3_all.sh
-```
-
-### 주요 환경 변수
-
-| 변수 | 설명 | 기본값 |
-|------|------|--------|
-| `OPS_MODE` | `paper` / `live` | `paper` |
-| `OPS_TECHNIQUE_ID` | 운영 기법 | `composite_v3` |
-| `OPS_MIN_SCORE` | composite 최소 점수 (선택) | 기법 기본 2.5 |
-| `OPS_MTF_ENABLED` | MTF 필터 | `true` |
-| `OPS_TREND_GATE_ENABLED` | 고TF 게이트 | `true` |
-| `OPS_DAILY_MAX_TRADES` | 일일 체결 상한 | `20` |
-| `OPS_MIN_ORDER_KRW` | 최소 주문 원화 | `5000` |
-| `OPS_STATE_JSON` | 운영 상태 | `data/spot/operations/ops_state.json` |
-| `BITHUMB_ACCESS_KEY` | live API (선택) | — |
-| `BITHUMB_SECRET_KEY` | live API (선택) | — |
-
-### 산출물
-
-| 파일 | 내용 |
-|------|------|
-| `docs/spot/3_operations/filtered_backtest_report.json` | 필터 전/후 sim |
-| `docs/spot/3_operations/ops_report.json` | 최근 tick 리포트 |
-| `data/spot/operations/ops_state.json` | 포트폴리오·체결 이력 |
-
-### 소스 모듈
-
-| 모듈 | 경로 |
-|------|------|
-| 신호 파이프라인 | `src/bithumb/operations/signal_pipeline.py` |
-| signal_type 추론 | `src/bithumb/operations/signal_type.py` |
-| 체결 엔진 | `src/bithumb/operations/trade_engine.py` |
-| paper/live | `src/bithumb/operations/executor.py` |
-| 러너 | `src/bithumb/operations/runner.py` |
-| 빗썸 Private | `src/bithumb/api/bithumb_private.py` |
-
----
-
-## Check (검토)
-
-### 초기 백테스트 결과 (BTC · 3년 · composite_v3)
-
-| 구분 | 신호 수 | 3년 sim 수익률 |
-|------|---------|----------------|
-| MTF 필터 **전** | 12,262 | **-97.5%** |
-| MTF 필터 **후** | 1,215 | **+3.37%** |
-
-MTF 필터가 composite_v3의 과다 신호·역추세 진입을 상당 부분 걸러냅니다. live 전환 전 paper 운영으로 추가 검증이 필요합니다.
-
-### live 전환 전 체크리스트
-
-- [ ] `3_run_filtered_backtest.py` — 필터 후 sim이 raw 대비 개선되는지 확인
-- [ ] 최소 1주일 **paper** 운영 (`--loop 180` 등)
-- [ ] `OPS_DAILY_MAX_TRADES`·`OPS_MIN_SCORE` 튜닝
-- [ ] 빗썸 API 키 **출금 비활성**·IP 제한 설정
-- [ ] 소액으로 live 테스트
-
-### 2단계 대비 3단계 차이
-
-| 항목 | 2단계 | 3단계 |
-|------|-------|-------|
-| 목적 | 기법 평가·순위 | **운영 연결** |
-| 신호 | 39종 개별 | **composite_v3 + MTF** |
-| 체결 | 일괄 sim | **tick 단위 paper/live** |
-| MTF | 분석·규칙 | **실시간 필터** |
-
----
-
-## Act (개선)
-
-### 권장 튜닝
-
-1. `OPS_MIN_SCORE` 상향 (예: 3.5~4.0) — 신호 과다·스킵 감소
-2. `OPS_DAILY_MAX_TRADES` 하향 — 과매매 방지
-3. 텔레그램 알림 연동 (선택)
-4. 슬리피지 가정 paper 백테스트 확장
-
-### 하지 말아야 할 것
-
-- 백테스트 미검증 상태에서 **live 풀오토**
-- 2단계 sim 1위(`fractal_swing`) 그대로 운영
-- API 키를 Git에 커밋
-
----
-
-## 변경 이력
-
-| 날짜 | 내용 |
-|------|------|
-| 2026-06-12 | 3단계 초版 — paper/live 파이프라인, MTF 필터 백테스트, 운영 스크립트 |
diff --git a/scripts/00_download_candles.py b/scripts/00_download_candles.py
index 89a4d6b..fa52217 100644
--- a/scripts/00_download_candles.py
+++ b/scripts/00_download_candles.py
@@ -16,7 +16,7 @@ if str(SRC) not in sys.path:
from dataclasses import replace
-from bithumb.config import load_settings
+from bithumb.config import load_settings, resolve_coin_name
from bithumb.data.candle_store import CandleStore
from bithumb.data.downloader import CandleDownloader
from bithumb.data.intervals import INTERVAL_1MIN, estimate_download_requests, interval_label
@@ -32,6 +32,58 @@ def _configure_logging(verbose: bool) -> None:
)
+def _parse_symbols(raw: str | None, defaults: list[str]) -> list[str]:
+ """CLI --symbols 또는 기본 DOWNLOAD_SYMBOLS 목록."""
+ if raw:
+ return [part.strip().upper() for part in raw.split(",") if part.strip()]
+ return list(defaults)
+
+
+def _log_interval_estimates(
+ *,
+ store: CandleStore,
+ symbol: str,
+ intervals: list[int],
+ days: int,
+ full: bool,
+ batch_size: int,
+ sleep_sec: float,
+ log: logging.Logger,
+) -> None:
+ """인터벌별 예상 API 요청 수를 로깅한다."""
+ for interval in intervals:
+ if full:
+ est = estimate_download_requests(interval, days, batch_size=batch_size)
+ log.info(
+ "예상 API 요청: %s %s ≈ %s회 (풀 다운, sleep %.2fs)",
+ symbol,
+ interval_label(interval),
+ est,
+ sleep_sec,
+ )
+ continue
+ _, _, db_max = store.get_range(symbol, interval)
+ if db_max is None:
+ est = estimate_download_requests(interval, days, batch_size=batch_size)
+ log.info(
+ "예상 API 요청: %s %s ≈ %s회 (DB 없음 → 풀 다운)",
+ symbol,
+ interval_label(interval),
+ est,
+ )
+ else:
+ gap_days = max(1, (datetime.now() - db_max).days + 1)
+ est = estimate_download_requests(interval, gap_days, batch_size=batch_size)
+ log.info(
+ "예상 API 요청: %s %s ≈ %s회 (증분, DB=%s, 갭≈%s일)",
+ symbol,
+ interval_label(interval),
+ est,
+ db_max.strftime("%Y-%m-%d %H:%M:%S"),
+ gap_days,
+ )
+
+
def main() -> int:
"""CLI 진입점."""
parser = argparse.ArgumentParser(
@@ -54,6 +106,12 @@ def main() -> int:
default=None,
help="(고급) 쉼표 구분 인터벌만 수집. 기본: .env DOWNLOAD_INTERVALS 전체",
)
+ parser.add_argument(
+ "--symbols",
+ type=str,
+ default=None,
+ help="쉼표 구분 심볼 (기본: .env DOWNLOAD_SYMBOLS 또는 SYMBOL)",
+ )
parser.add_argument(
"--include-1min",
action="store_true",
@@ -63,8 +121,10 @@ def main() -> int:
args = parser.parse_args()
_configure_logging(args.verbose)
- settings = load_settings()
+ base_settings = load_settings()
+ symbols = _parse_symbols(args.symbols, base_settings.download_symbols)
+ settings = base_settings
if args.intervals:
settings = replace(
settings,
@@ -83,76 +143,59 @@ def main() -> int:
log = logging.getLogger(__name__)
log.info(
"대상=%s DB=%s mode=%s days=%s intervals=%s",
- settings.market,
+ ",".join(f"KRW-{s}" for s in symbols),
settings.db_path,
mode_label,
days,
settings.download_intervals,
)
- for interval in settings.download_intervals:
- est = estimate_download_requests(interval, days, batch_size=settings.candle_count)
- log.info(
- "예상 API 요청: %s ≈ %s회 (sleep %.2fs)",
- interval_label(interval),
- est,
- settings.request_sleep_sec,
- )
store = CandleStore(settings.db_path)
+ exit_code = 0
try:
- for interval in settings.download_intervals:
- if args.full:
- est = estimate_download_requests(interval, days, batch_size=settings.candle_count)
- log.info(
- "예상 API 요청: %s ≈ %s회 (풀 다운, sleep %.2fs)",
- interval_label(interval),
- est,
- settings.request_sleep_sec,
- )
- else:
- _, _, db_max = store.get_range(settings.symbol, interval)
- if db_max is None:
- est = estimate_download_requests(interval, days, batch_size=settings.candle_count)
- log.info(
- "예상 API 요청: %s ≈ %s회 (DB 없음 → 풀 다운)",
- interval_label(interval),
- est,
- )
- else:
- gap_days = max(1, (datetime.now() - db_max).days + 1)
- est = estimate_download_requests(interval, gap_days, batch_size=settings.candle_count)
- log.info(
- "예상 API 요청: %s ≈ %s회 (증분, DB=%s, 갭≈%s일)",
- interval_label(interval),
- est,
- db_max.strftime("%Y-%m-%d %H:%M:%S"),
- gap_days,
- )
-
- downloader = CandleDownloader(settings)
- results = downloader.download_all(store, days=days, full=args.full)
-
- print(f"\n=== 수집 완료 ({mode_label}) ===")
- for result in results:
- count, min_dt, max_dt = store.get_range(settings.symbol, result.interval_min)
- min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-"
- max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-"
- if result.mode == "uptodate":
- flag = "UPTODATE"
- elif result.reached_target:
- flag = "OK"
- else:
- flag = "PARTIAL"
- label = interval_label(result.interval_min)
- print(
- f"[{flag}] {label} ({result.interval_min}) mode={result.mode} | "
- f"requests={result.requests} upsert={result.saved_rows} "
- f"db_rows={count} range={min_s} ~ {max_s}"
+ for symbol in symbols:
+ symbol_settings = replace(
+ settings,
+ symbol=symbol,
+ coin_name=resolve_coin_name(symbol),
)
+ print(f"\n=== {symbol} ({symbol_settings.coin_name}) ===")
+ _log_interval_estimates(
+ store=store,
+ symbol=symbol,
+ intervals=symbol_settings.download_intervals,
+ days=days,
+ full=args.full,
+ batch_size=symbol_settings.candle_count,
+ sleep_sec=symbol_settings.request_sleep_sec,
+ log=log,
+ )
+
+ downloader = CandleDownloader(symbol_settings)
+ results = downloader.download_all(store, days=days, full=args.full)
+
+ print(f"\n--- {symbol} 수집 완료 ({mode_label}) ---")
+ for result in results:
+ count, min_dt, max_dt = store.get_range(symbol, result.interval_min)
+ min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-"
+ max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-"
+ if result.mode == "uptodate":
+ flag = "UPTODATE"
+ elif result.reached_target:
+ flag = "OK"
+ else:
+ flag = "PARTIAL"
+ exit_code = 1
+ label = interval_label(result.interval_min)
+ print(
+ f"[{flag}] {symbol} {label} ({result.interval_min}) mode={result.mode} | "
+ f"requests={result.requests} upsert={result.saved_rows} "
+ f"db_rows={count} range={min_s} ~ {max_s}"
+ )
finally:
store.close()
- return 0
+ return exit_code
if __name__ == "__main__":
diff --git a/scripts/00_run_download_cron.sh b/scripts/00_run_download_cron.sh
new file mode 100755
index 0000000..3f4c9ce
--- /dev/null
+++ b/scripts/00_run_download_cron.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Bithumb 캔들 증분 수집 (cron 1분, DOWNLOAD_SYMBOLS 전체)
+set -euo pipefail
+# shellcheck source=scripts/_cron_env.sh
+source "$(dirname "$0")/_cron_env.sh"
+
+ensure_cron_log_dir "data/common"
+LOCKDIR="data/common/download.lock.d"
+# 3종목×11 TF 증분 — 20분 초과 시 hung 으로 간주
+if ! acquire_cron_lock "$LOCKDIR" "scripts/00_download.py" 1200; then
+ exit 0
+fi
+
+PYTHON="$(resolve_bithumb_python)" || exit 1
+"$PYTHON" scripts/00_download.py "$@"
diff --git a/scripts/3_audit_ops_safety.py b/scripts/3_audit_ops_safety.py
new file mode 100644
index 0000000..73e5ee9
--- /dev/null
+++ b/scripts/3_audit_ops_safety.py
@@ -0,0 +1,229 @@
+#!/usr/bin/env python3
+"""운영 신호·체결·거래소 주문 최종 점검 (놓침/중복)."""
+
+from __future__ import annotations
+
+import json
+import sys
+from collections import Counter
+from datetime import datetime, timedelta
+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 bithumb.api.bithumb_private import BithumbPrivateClient
+from bithumb.config import load_settings
+from bithumb.operations.exchange_reconcile import (
+ _known_order_uuids,
+ _match_orders_to_signals,
+ _unsettled_signals_for_reconcile,
+ reconcile_exchange_fills,
+)
+from bithumb.operations.runner import (
+ _history_index,
+ _is_settled,
+ _is_signal_api_executable,
+ _ledger_pending_signals,
+ _settle_expired_backlog,
+)
+from bithumb.operations.signal_pipeline import (
+ filter_signals_for_ops,
+ generate_raw_signals,
+ load_ops_candles,
+)
+from bithumb.operations.state_store import load_state
+
+
+def _parse_dt(value: str) -> datetime:
+ return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
+
+
+def main() -> int:
+ settings = load_settings()
+ state = load_state(
+ settings.ops_state_json,
+ initial_cash_krw=settings.gt_initial_cash_krw,
+ )
+ df = load_ops_candles(settings)
+ latest_bar = len(df) - 1
+ gen = generate_raw_signals(
+ settings,
+ df=df,
+ use_cache=True,
+ force_tail_refresh=(
+ settings.ops_mode == "live" and settings.ops_live_force_tail_refresh
+ ),
+ )
+ all_kept = filter_signals_for_ops(settings, gen["raw_signals"])["kept"]
+ trade_history = list(state.get("trade_history") or [])
+ live_since = (
+ str(state["live_initialized_at"])
+ if settings.ops_mode == "live" and state.get("live_initialized_at")
+ else None
+ )
+ max_age = settings.ops_ledger_execute_max_age_minutes
+ now = datetime.now()
+
+ # tick과 동일 순서 시뮬레이션 (state 파일은 쓰지 않음)
+ sim_history = list(trade_history)
+ exchange_reconciled: list[dict] = []
+ stale_settled: list[dict] = []
+
+ if settings.ops_mode == "live" and settings.ops_exchange_reconcile:
+ client = BithumbPrivateClient(
+ access_key=settings.bithumb_access_key,
+ secret_key=settings.bithumb_secret_key,
+ base_url=settings.api_url,
+ sleep_sec=settings.request_sleep_sec,
+ retries=settings.request_retries,
+ )
+ exchange_reconciled = reconcile_exchange_fills(
+ all_kept,
+ sim_history,
+ client=client,
+ market=settings.market,
+ lookback_hours=settings.ops_exchange_order_lookback_hours,
+ match_window_min=settings.ops_exchange_match_window_min,
+ lookback_days=settings.ops_ledger_lookback_days,
+ )
+ sim_history.extend(exchange_reconciled)
+
+ stale_settled = _settle_expired_backlog(
+ all_kept,
+ sim_history,
+ max_age_minutes=max_age,
+ live_since=live_since,
+ lookback_days=settings.ops_ledger_lookback_days,
+ )
+ sim_history.extend(stale_settled)
+
+ ledger_pending = _ledger_pending_signals(
+ all_kept,
+ sim_history,
+ latest_bar_index=latest_bar,
+ lookback_days=settings.ops_ledger_lookback_days,
+ )
+
+ executable_pending: list[dict] = []
+ expired_pending: list[dict] = []
+ for sig in ledger_pending:
+ ok, reason = _is_signal_api_executable(
+ sig,
+ max_age_minutes=max_age,
+ live_since=live_since,
+ now=now,
+ )
+ if ok:
+ executable_pending.append(sig)
+ else:
+ expired_pending.append({**sig, "skip_reason": reason})
+
+ # 중복 uuid
+ uuids = []
+ for rec in sim_history:
+ trade = rec.get("trade") or {}
+ if trade.get("executed"):
+ resp = trade.get("api_response")
+ if isinstance(resp, dict) and resp.get("uuid"):
+ uuids.append(str(resp["uuid"]))
+ uuid_counts = Counter(uuids)
+ dup_uuids = {u: c for u, c in uuid_counts.items() if c > 1}
+
+ # history 내 동일 신호 executed 중복
+ idx = _history_index(sim_history)
+ executed_keys = [
+ key
+ for key, rec in idx.items()
+ if (rec.get("trade") or {}).get("executed")
+ ]
+ raw_executed_count = sum(
+ 1
+ for rec in sim_history
+ if (rec.get("trade") or {}).get("executed")
+ )
+
+ # 거래소 주문 vs 미정산 (reconcile 후에도 남는 orphan)
+ orphan_orders = 0
+ unmatched_unsettled = len(
+ _unsettled_signals_for_reconcile(
+ all_kept,
+ sim_history,
+ lookback_days=settings.ops_ledger_lookback_days,
+ )
+ )
+ if settings.ops_mode == "live":
+ client = BithumbPrivateClient(
+ access_key=settings.bithumb_access_key,
+ secret_key=settings.bithumb_secret_key,
+ base_url=settings.api_url,
+ sleep_sec=settings.request_sleep_sec,
+ retries=settings.request_retries,
+ )
+ since = now - timedelta(hours=settings.ops_exchange_order_lookback_hours)
+ orders = client.fetch_filled_orders_since(settings.market, since)
+ known = _known_order_uuids(sim_history)
+ unsettled = _unsettled_signals_for_reconcile(
+ all_kept,
+ sim_history,
+ lookback_days=settings.ops_ledger_lookback_days,
+ )
+ matches = _match_orders_to_signals(
+ orders,
+ unsettled,
+ match_window_min=settings.ops_exchange_match_window_min,
+ known_uuids=set(known),
+ )
+ orphan_orders = len(orders) - len(matches) - len(
+ [u for u in known if u in {str(o.get("uuid")) for o in orders}]
+ )
+
+ print("=== 최종 점검 (놓침/중복) ===")
+ print(f"mode: {settings.ops_mode}")
+ print(f"now: {now.strftime('%Y-%m-%d %H:%M:%S')}")
+ print(f"kept_signals: {len(all_kept)} | latest_bar: {latest_bar}")
+ print(f"trade_history (raw): {len(trade_history)}")
+ print()
+ print("[tick 시뮬레이션 — exchange → stale 순]")
+ print(f" exchange_reconciled (would add): {len(exchange_reconciled)}")
+ print(f" stale_settled (would add): {len(stale_settled)}")
+ print(f" ledger_pending (after sim): {len(ledger_pending)}")
+ print(f" executable_pending (API 체결 대상): {len(executable_pending)}")
+ print(f" expired_still_pending (버그 의심): {len(expired_pending)}")
+ print()
+ print("[중복]")
+ print(f" executed records (raw): {raw_executed_count}")
+ print(f" unique executed keys: {len(executed_keys)}")
+ print(f" duplicate uuid in history: {len(dup_uuids)}")
+ if dup_uuids:
+ for u, c in list(dup_uuids.items())[:5]:
+ print(f" uuid={u} count={c}")
+ print()
+ print("[놓침 위험]")
+ print(f" unsettled after sim: {unmatched_unsettled}")
+ print(f" orphan exchange orders (approx): {max(orphan_orders, 0)}")
+ if executable_pending:
+ print(" executable_pending 목록:")
+ for sig in executable_pending[:15]:
+ print(f" {sig['datetime']} {sig['side']}")
+ if len(executable_pending) > 15:
+ print(f" ... 외 {len(executable_pending) - 15}건")
+ if expired_pending:
+ print(" expired_still_pending (stale 미적용 의심):")
+ for sig in expired_pending[:10]:
+ print(f" {sig['datetime']} {sig['side']} — {sig.get('skip_reason')}")
+
+ ok = (
+ len(dup_uuids) == 0
+ and len(expired_pending) == 0
+ and len(executable_pending) <= settings.ops_max_backlog_per_tick
+ )
+ print()
+ print("RESULT:", "PASS" if ok else "REVIEW_NEEDED")
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/3_ensure_vol_monitor_serve.sh b/scripts/3_ensure_vol_monitor_serve.sh
new file mode 100755
index 0000000..95d942c
--- /dev/null
+++ b/scripts/3_ensure_vol_monitor_serve.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# vol_live 모니터 HTTP 서버 — 8766 미수신 시 기동 (cron/수동)
+set -euo pipefail
+# shellcheck source=scripts/_cron_env.sh
+source "$(dirname "$0")/_cron_env.sh"
+
+PORT="${VOL_MONITOR_PORT:-8766}"
+export VOL_MONITOR_PORT="$PORT"
+
+if curl -sf -o /dev/null --connect-timeout 2 "http://127.0.0.1:${PORT}/vol_live_monitor.html"; then
+ exit 0
+fi
+
+exec bash "${CRON_PROJECT_ROOT}/scripts/3_run_vol_monitor_serve.sh"
diff --git a/scripts/3_reconcile_signals.py b/scripts/3_reconcile_signals.py
new file mode 100644
index 0000000..da76d35
--- /dev/null
+++ b/scripts/3_reconcile_signals.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""ledger backlog 신호 조회·일괄 처리 (dry-run / execute)."""
+
+from __future__ import annotations
+
+import argparse
+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 bithumb.config import load_settings
+from bithumb.operations.reconcile import inspect_ops_backlog
+from bithumb.operations.runner import OperationsRunner
+
+
+def main() -> int:
+ """CLI 진입점."""
+ parser = argparse.ArgumentParser(description="운영 backlog 신호 reconcile")
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="pending 목록만 출력 (기본)",
+ )
+ parser.add_argument(
+ "--execute",
+ action="store_true",
+ help="OperationsRunner tick 1회로 backlog 처리",
+ )
+ parser.add_argument(
+ "--no-sync",
+ action="store_true",
+ help="execute 시 캔들 sync 생략",
+ )
+ args = parser.parse_args()
+
+ settings = load_settings()
+ info = inspect_ops_backlog(settings)
+ merged = info["merged_pending"]
+ summary = info["summary"]
+
+ print("=== backlog inspect ===")
+ print(f"signal_refresh: {info.get('signal_refresh')}")
+ print(f"force_tail_refresh: {info.get('force_tail_refresh')}")
+ print(f"ledger_pending: {len(info['ledger_pending'])}")
+ print(f"catchup_pending: {len(info['catchup_pending'])}")
+ print(f"merged_pending: {len(merged)}")
+ print(f"backlog_signal_count: {summary['backlog_signal_count']}")
+ print(f"backlog_oldest: {summary['backlog_oldest_datetime']}")
+ print(f"backlog_dropped (per tick limit): {info['backlog_dropped']}")
+
+ for sig in merged[:50]:
+ print(f" {sig['datetime']} {sig['side']} bar={sig.get('bar_index')}")
+ if len(merged) > 50:
+ print(f" ... 외 {len(merged) - 50}건")
+
+ if args.execute:
+ from bithumb.operations.runner import OperationsRunner as OpsRunner
+
+ if settings.ops_mode == "live":
+ print("\n경고: live execute — 실제 주문이 발생할 수 있습니다.")
+ runner = OpsRunner(settings)
+ report = runner.tick(sync_candles=not args.no_sync)
+ print(f"\nexecute 완료: 체결 {len(report.get('executions', []))}건")
+ print(f"ledger_pending_count: {report.get('ledger_pending_count')}")
+ print(f"backlog_dropped_count: {report.get('backlog_dropped_count')}")
+ return 0
+
+ if not args.dry_run and not args.execute:
+ print("\n(--dry-run 기본, --execute 로 처리)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/3_refresh_vol_monitor.py b/scripts/3_refresh_vol_monitor.py
new file mode 100644
index 0000000..bd32739
--- /dev/null
+++ b/scripts/3_refresh_vol_monitor.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+"""vol_live 모니터 JSON 갱신 — 3_run_vol_monitor.py --refresh-only 래퍼."""
+
+from __future__ import annotations
+
+import runpy
+import sys
+from pathlib import Path
+
+if __name__ == "__main__":
+ target = Path(__file__).resolve().parent / "3_run_vol_monitor.py"
+ sys.argv = [str(target), "--refresh-only", *sys.argv[1:]]
+ runpy.run_path(str(target), run_name="__main__")
diff --git a/scripts/3_render_live_chart.py b/scripts/3_render_live_chart.py
index 93e196f..edfcd0a 100644
--- a/scripts/3_render_live_chart.py
+++ b/scripts/3_render_live_chart.py
@@ -63,7 +63,7 @@ def _write_index_html(
Bithumb Live — 운영 백테스트
- {report.get("symbol", "BTC")} · {report.get("technique_name", "")} ({report.get("technique_id", "")})
+ {report.get("symbol", "TRX")} · {report.get("technique_name", "")} ({report.get("technique_id", "")})
sim 기간: 최근 {report.get("sim_lookback_days", 1095)}일 ·
슬리피지 {report.get("slippage_rate", 0) * 100:.2f}% ·
일 체결 상한 {report.get("daily_max_trades", "-")} ·
diff --git a/scripts/3_run_operations.py b/scripts/3_run_operations.py
index 2cb4f97..cc95f93 100644
--- a/scripts/3_run_operations.py
+++ b/scripts/3_run_operations.py
@@ -4,7 +4,9 @@
from __future__ import annotations
import argparse
+import atexit
import logging
+import os
import sys
import time
from pathlib import Path
@@ -18,6 +20,20 @@ from bithumb.config import load_settings
from bithumb.operations.runner import OperationsRunner
+def _write_loop_pid(path: Path) -> None:
+ """loop PID 파일 기록 (watch 재시작용)."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(f"{os.getpid()}\n", encoding="utf-8")
+
+
+def _remove_loop_pid(path: Path) -> None:
+ """loop 종료 시 PID 파일 삭제."""
+ try:
+ path.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+
def _configure_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
@@ -66,7 +82,12 @@ def main() -> int:
runner = OperationsRunner(settings)
sync = not args.no_sync
+ if args.loop > 0 and settings.ops_loop_pid_file is not None:
+ _write_loop_pid(settings.ops_loop_pid_file)
+ atexit.register(_remove_loop_pid, settings.ops_loop_pid_file)
+
while True:
+ loop_started = time.monotonic()
try:
report = runner.tick(sync_candles=sync)
except Exception as exc:
@@ -81,7 +102,9 @@ def main() -> int:
)
if args.loop <= 0:
break
- time.sleep(args.loop)
+ elapsed = time.monotonic() - loop_started
+ sleep_sec = max(0.0, float(args.loop) - elapsed)
+ time.sleep(sleep_sec)
continue
port = report.get("portfolio") or {}
@@ -100,10 +123,18 @@ def main() -> int:
f"코인 {port.get('coin_qty', 0):.8f} {settings.symbol}"
)
print(f"리포트: {settings.ops_report_json}")
+ if report.get("ledger_pending_count") is not None:
+ print(
+ f"ledger pending: {report.get('ledger_pending_count')} · "
+ f"backlog dropped: {report.get('backlog_dropped_count', 0)} · "
+ f"tick: {report.get('last_tick_duration_sec')}s"
+ )
if args.loop <= 0:
break
- time.sleep(args.loop)
+ elapsed = time.monotonic() - loop_started
+ sleep_sec = max(0.0, float(args.loop) - elapsed)
+ time.sleep(sleep_sec)
return 0
diff --git a/scripts/3_run_vol_breakout.py b/scripts/3_run_vol_breakout.py
new file mode 100644
index 0000000..d51c2c2
--- /dev/null
+++ b/scripts/3_run_vol_breakout.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""vol_breakout 현물 롱 — TRX/NEAR/WLD 멀티 tick (Binance 15m ATR 이식)."""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
+
+from bithumb.config import load_settings
+from bithumb.operations.vol_breakout_runner import VolBreakoutRunner
+
+
+def _configure_logging(verbose: bool) -> None:
+ level = logging.DEBUG if verbose else logging.INFO
+ logging.basicConfig(
+ level=level,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+
+
+def main() -> int:
+ """CLI."""
+ parser = argparse.ArgumentParser(description="Bithumb vol_breakout 현물 롱 tick")
+ parser.add_argument("--mode", choices=("paper", "live"), default=None)
+ parser.add_argument("--loop", type=int, default=0, metavar="SEC")
+ parser.add_argument("-v", "--verbose", action="store_true")
+ args = parser.parse_args()
+ _configure_logging(args.verbose)
+
+ if args.mode:
+ import os
+ os.environ["OPS_MODE"] = args.mode
+
+ settings = load_settings()
+ if not settings.ops_symbols:
+ print("OPS_SYMBOLS 또는 DOWNLOAD_SYMBOLS(BTC 제외)가 필요합니다.", file=sys.stderr)
+ return 1
+
+ if settings.ops_mode == "live":
+ if not settings.bithumb_access_key or not settings.bithumb_secret_key:
+ print("live: BITHUMB_ACCESS_KEY / BITHUMB_SECRET_KEY 필요", file=sys.stderr)
+ return 1
+ print("경고: live — 실제 주문 가능")
+
+ print(
+ f"vol_breakout {settings.ops_mode} | symbols={settings.ops_symbols} | "
+ f"lookback={settings.vol_lookback} atr={settings.vol_atr_mult} "
+ f"buy_split={settings.vol_buy_split or settings.vol_wallet_pct} "
+ f"exit={settings.vol_exit_enabled}"
+ )
+
+ def _once() -> dict:
+ runner = VolBreakoutRunner(settings)
+ report = runner.tick()
+ for row in report.get("results") or []:
+ print(
+ f" {row.get('symbol')}: fills={row.get('fills')} "
+ f"note={row.get('note')}"
+ )
+ return report
+
+ if args.loop <= 0:
+ _once()
+ return 0
+
+ while True:
+ try:
+ _once()
+ except KeyboardInterrupt:
+ print("\n종료")
+ return 0
+ except Exception:
+ logging.exception("vol loop tick failed")
+ time.sleep(args.loop)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/3_run_vol_breakout_cron.sh b/scripts/3_run_vol_breakout_cron.sh
new file mode 100755
index 0000000..fa12b30
--- /dev/null
+++ b/scripts/3_run_vol_breakout_cron.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# vol_breakout 현물 롱 tick (cron 1분)
+set -euo pipefail
+# shellcheck source=scripts/_cron_env.sh
+source "$(dirname "$0")/_cron_env.sh"
+
+ensure_cron_log_dir "data/spot/operations"
+LOCKDIR="data/spot/operations/vol.tick.lock.d"
+if ! acquire_cron_lock "$LOCKDIR" "scripts/3_run_vol_breakout.py" 600; then
+ exit 0
+fi
+
+PYTHON="$(resolve_bithumb_python)" || exit 1
+"$PYTHON" scripts/3_run_vol_breakout.py "$@"
diff --git a/scripts/3_run_vol_monitor.py b/scripts/3_run_vol_monitor.py
new file mode 100755
index 0000000..6a9c984
--- /dev/null
+++ b/scripts/3_run_vol_monitor.py
@@ -0,0 +1,315 @@
+#!/usr/bin/env python3
+"""vol_live 모니터 — JSON/HTML 갱신 + HTTP 서버 (통합).
+
+기본 (인자 없음): 전체 갱신(--full) 후 서버 기동
+ python scripts/3_run_vol_monitor.py
+
+갱신만:
+ python scripts/3_run_vol_monitor.py --refresh-only
+
+서버만:
+ python scripts/3_run_vol_monitor.py --serve-only
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import os
+import sys
+import threading
+import time
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import urlparse
+
+_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(_ROOT / "src"))
+
+from bithumb.config import load_settings # noqa: E402
+from bithumb.operations.vol_breakout_engine import load_vol_state # noqa: E402
+from bithumb.operations.vol_live_monitor import ( # noqa: E402
+ fetch_live_balance_snapshot,
+ patch_vol_monitor_balance,
+ write_vol_monitor,
+)
+
+logger = logging.getLogger("vol_monitor")
+_refresh_lock = threading.Lock()
+_balance_lock = threading.Lock()
+_CLIENT_GONE = (BrokenPipeError, ConnectionResetError)
+
+
+def _client_gone(exc: BaseException) -> bool:
+ """브라우저가 응답 전 연결을 끊은 경우."""
+ return isinstance(exc, _CLIENT_GONE)
+
+
+def refresh_vol_live_monitor(*, write_html: bool = True) -> dict:
+ """state + DB 캔들 기준 전체 JSON/HTML 갱신."""
+ settings = load_settings()
+ state = load_vol_state(settings.vol_state_json)
+ if settings.ops_mode == "live":
+ try:
+ bal = fetch_live_balance_snapshot(settings)
+ snap = state.setdefault("portfolio_snapshot", {})
+ snap["cash_krw"] = bal.get("cash_krw", snap.get("cash_krw"))
+ positions = snap.setdefault("positions", {})
+ for sym, qty in (bal.get("positions") or {}).items():
+ positions[sym] = qty
+ except Exception as exc: # noqa: BLE001
+ logger.warning("live balance sync skipped: %s", exc)
+
+ json_path, html_path = write_vol_monitor(settings, state)
+ logger.debug("monitor written: %s", json_path)
+ if write_html:
+ logger.debug("html: %s", html_path)
+ return {"ok": True, "json": str(json_path), "html": str(html_path)}
+
+
+def refresh_vol_live_balance() -> dict:
+ """거래소 잔고만 JSON summary 패치."""
+ settings = load_settings()
+ bal = fetch_live_balance_snapshot(settings)
+ return patch_vol_monitor_balance(settings.vol_monitor_json, bal)
+
+
+def fetch_live_balance() -> dict:
+ """서버 /api/balance용."""
+ settings = load_settings()
+ if settings.ops_mode != "live":
+ state = load_vol_state(settings.vol_state_json)
+ snap = state.get("portfolio_snapshot") or {}
+ return {
+ "ok": True,
+ "cash_krw": snap.get("cash_krw", 0),
+ "positions": snap.get("positions") or {},
+ "mode": settings.ops_mode,
+ }
+ return fetch_live_balance_snapshot(settings)
+
+
+def _out_dir() -> Path:
+ return load_settings().vol_monitor_html.parent
+
+
+def _api_refresh() -> dict:
+ out_dir = _out_dir()
+ json_path = out_dir / "vol_live_chart.json"
+ with _refresh_lock:
+ if not json_path.is_file():
+ return refresh_vol_live_monitor(write_html=False)
+ return refresh_vol_live_balance()
+
+
+def _api_balance() -> dict:
+ with _balance_lock:
+ return fetch_live_balance()
+
+
+class MonitorHandler(SimpleHTTPRequestHandler):
+ """vol_live 정적 파일 + /api/chart · /api/balance · /api/refresh."""
+
+ _static_dir: str | None = None
+ _access_log: bool = False
+
+ def __init__(self, *args, **kwargs) -> None:
+ if MonitorHandler._static_dir is None:
+ MonitorHandler._static_dir = str(_out_dir())
+ super().__init__(*args, directory=MonitorHandler._static_dir, **kwargs)
+
+ def _chart_json_path(self) -> Path:
+ return Path(self.directory) / "vol_live_chart.json"
+
+ def _serve_chart_json(self) -> None:
+ path = self._chart_json_path()
+ if not path.is_file():
+ self.send_error(404, "chart json not found")
+ return
+ body: bytes | None = None
+ for attempt in range(3):
+ try:
+ body = path.read_bytes()
+ json.loads(body.decode("utf-8"))
+ break
+ except (json.JSONDecodeError, OSError):
+ if attempt >= 2:
+ self.send_error(503, "chart json temporarily unavailable")
+ return
+ time.sleep(0.05)
+ if body is None:
+ self.send_error(503, "chart json unavailable")
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store, must-revalidate")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ try:
+ self.wfile.write(body)
+ except _CLIENT_GONE:
+ logger.debug("client disconnected during chart json")
+
+ def log_message(self, fmt: str, *args) -> None:
+ """HTTP 접근 로그 — 기본 off (--verbose 시에만 출력)."""
+ if not MonitorHandler._access_log:
+ return
+ logger.info("%s - %s", self.address_string(), fmt % args)
+
+ def log_error(self, fmt: str, *args) -> None:
+ """5xx 등 서버 오류만 기록 (favicon 404 제외)."""
+ msg = fmt % args
+ if "404" in msg and "File not found" in msg:
+ return
+ logger.warning("%s - %s", self.address_string(), msg)
+
+ def _send_json(self, payload: dict, *, status: int = 200) -> None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ try:
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store, must-revalidate")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ except _CLIENT_GONE:
+ logger.debug("client disconnected before response sent")
+
+ def _handle_refresh(self) -> None:
+ try:
+ self._send_json(_api_refresh())
+ except _CLIENT_GONE:
+ pass
+ except Exception as exc: # noqa: BLE001
+ if not _client_gone(exc):
+ self._send_json({"ok": False, "error": str(exc)}, status=500)
+
+ def _handle_balance(self) -> None:
+ try:
+ self._send_json(_api_balance())
+ except _CLIENT_GONE:
+ pass
+ except Exception as exc: # noqa: BLE001
+ if not _client_gone(exc):
+ self._send_json({"ok": False, "error": str(exc)}, status=500)
+
+ def end_headers(self) -> None:
+ if self.path.endswith(".json"):
+ self.send_header("Cache-Control", "no-store, must-revalidate")
+ super().end_headers()
+
+ def _request_path(self) -> str:
+ return urlparse(self.path).path.rstrip("/")
+
+ def do_POST(self) -> None:
+ path = self._request_path()
+ if path == "/api/refresh":
+ self._handle_refresh()
+ return
+ self.send_error(404, "not found")
+
+ def do_GET(self) -> None:
+ path = self._request_path()
+ if path == "/api/refresh":
+ self._handle_refresh()
+ return
+ if path == "/api/balance":
+ self._handle_balance()
+ return
+ if path == "/api/chart":
+ self._serve_chart_json()
+ return
+ super().do_GET()
+
+
+def run_serve(*, access_log: bool = False, quiet: bool = True) -> int:
+ """HTTP 서버 기동 (블로킹)."""
+ from dotenv import load_dotenv
+
+ MonitorHandler._access_log = access_log
+ load_dotenv(_ROOT / ".env", override=False)
+ port = int(os.environ.get("VOL_MONITOR_PORT", "8766"))
+ out = _out_dir()
+ out.mkdir(parents=True, exist_ok=True)
+ url = f"http://127.0.0.1:{port}/vol_live_monitor.html"
+ if quiet and not access_log:
+ print(f"vol monitor {url} (Ctrl+C 종료)", flush=True)
+ else:
+ logger.info("모니터: %s", url)
+ logger.info("출력 디렉터리: %s", out)
+ try:
+ server = ThreadingHTTPServer(("127.0.0.1", port), MonitorHandler)
+ except OSError as exc:
+ if exc.errno == 48:
+ logger.error(
+ "포트 %s 이미 사용 중 — lsof -iTCP:%s -sTCP:LISTEN 후 종료",
+ port,
+ port,
+ )
+ else:
+ logger.error("서버 bind 실패: %s", exc)
+ return 1
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ logger.info("종료")
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ """CLI — 기본: 갱신 + 서버."""
+ parser = argparse.ArgumentParser(
+ description="Bithumb vol_live 모니터 (갱신 + HTTP 서버)",
+ )
+ mode = parser.add_mutually_exclusive_group()
+ mode.add_argument(
+ "--refresh-only",
+ action="store_true",
+ help="JSON/HTML 갱신만 (서버 미기동)",
+ )
+ mode.add_argument(
+ "--serve-only",
+ action="store_true",
+ help="HTTP 서버만 (갱신 생략)",
+ )
+ parser.add_argument(
+ "--balance-only",
+ action="store_true",
+ help="--refresh-only 와 함께: 잔고 summary만 패치",
+ )
+ parser.add_argument(
+ "-v",
+ "--verbose",
+ action="store_true",
+ help="HTTP 접근·갱신 상세 로그 출력",
+ )
+ args = parser.parse_args(argv)
+
+ serve_mode = not args.refresh_only
+ quiet_serve = serve_mode and not args.verbose
+ log_level = logging.INFO if (args.verbose or args.refresh_only) else logging.WARNING
+ logging.basicConfig(
+ level=log_level,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+
+ if not args.serve_only:
+ if args.balance_only:
+ out = refresh_vol_live_balance()
+ else:
+ out = refresh_vol_live_monitor()
+ if args.verbose or args.refresh_only:
+ logger.info("refresh done: %s", out)
+ if not out.get("ok"):
+ return 1
+
+ if args.refresh_only:
+ return 0
+
+ return run_serve(access_log=args.verbose, quiet=quiet_serve)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/3_run_vol_monitor_cron.sh b/scripts/3_run_vol_monitor_cron.sh
new file mode 100755
index 0000000..0d1aa25
--- /dev/null
+++ b/scripts/3_run_vol_monitor_cron.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# vol_live 모니터 JSON/HTML 갱신 (cron 5분 — tick 실패 시 백업용)
+set -euo pipefail
+# shellcheck source=scripts/_cron_env.sh
+source "$(dirname "$0")/_cron_env.sh"
+
+ensure_cron_log_dir "docs/spot/3_operations"
+LOCKDIR="data/spot/operations/vol.monitor.lock.d"
+if ! acquire_cron_lock "$LOCKDIR" "scripts/3_run_vol_monitor.py" 300; then
+ exit 0
+fi
+
+PYTHON="$(resolve_bithumb_python)" || exit 1
+"$PYTHON" scripts/3_run_vol_monitor.py --refresh-only "$@"
diff --git a/scripts/3_run_vol_monitor_serve.sh b/scripts/3_run_vol_monitor_serve.sh
new file mode 100755
index 0000000..249ac19
--- /dev/null
+++ b/scripts/3_run_vol_monitor_serve.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# vol_live 모니터 HTTP 서버 (포트 기본 8766 — Binance 8765와 분리)
+set -euo pipefail
+# shellcheck source=scripts/_cron_env.sh
+source "$(dirname "$0")/_cron_env.sh"
+
+PIDFILE="${CRON_PROJECT_ROOT}/data/spot/operations/vol_monitor.pid"
+PORT="${VOL_MONITOR_PORT:-8766}"
+export VOL_MONITOR_PORT="$PORT"
+
+if [ "${1:-}" = "--stop" ]; then
+ if [ -f "$PIDFILE" ]; then
+ pid="$(cat "$PIDFILE")"
+ if kill -0 "$pid" 2>/dev/null; then
+ kill "$pid"
+ echo "stopped pid $pid"
+ fi
+ rm -f "$PIDFILE"
+ else
+ echo "pid file 없음"
+ fi
+ exit 0
+fi
+
+if [ -f "$PIDFILE" ]; then
+ old_pid="$(cat "$PIDFILE")"
+ if kill -0 "$old_pid" 2>/dev/null; then
+ if lsof -iTCP:"$PORT" -sTCP:LISTEN -p "$old_pid" >/dev/null 2>&1; then
+ echo "이미 실행 중: pid $old_pid → http://127.0.0.1:${PORT}/vol_live_monitor.html"
+ exit 0
+ fi
+ kill "$old_pid" 2>/dev/null || true
+ fi
+ rm -f "$PIDFILE"
+fi
+
+ensure_cron_log_dir "data/spot/operations"
+LOG="${CRON_PROJECT_ROOT}/data/spot/operations/vol_monitor_serve.log"
+PYTHON="$(resolve_bithumb_python)" || exit 1
+
+nohup "$PYTHON" "${CRON_PROJECT_ROOT}/scripts/3_run_vol_monitor.py" --serve-only >> "$LOG" 2>&1 &
+pid=$!
+disown "$pid" 2>/dev/null || true
+echo "$pid" > "$PIDFILE"
+sleep 2
+
+if curl -sf -o /dev/null "http://127.0.0.1:${PORT}/vol_live_monitor.html"; then
+ echo "모니터 시작: http://127.0.0.1:${PORT}/vol_live_monitor.html"
+ echo "로그: $LOG"
+ echo "종료: bash scripts/3_run_vol_monitor_serve.sh --stop"
+else
+ echo "서버 기동 실패 — 로그 확인: $LOG" >&2
+ tail -20 "$LOG" 2>/dev/null || true
+ exit 1
+fi
diff --git a/scripts/3_run_watch_cron.sh b/scripts/3_run_watch_cron.sh
new file mode 100755
index 0000000..d54bb6a
--- /dev/null
+++ b/scripts/3_run_watch_cron.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+# read-only 감시 (cron 5분) — watch 프로세스 중복 방지
+set -euo pipefail
+# shellcheck source=scripts/_cron_env.sh
+source "$(dirname "$0")/_cron_env.sh"
+
+ensure_cron_log_dir "data/spot/operations"
+
+PYTHON="$(resolve_bithumb_python)" || exit 1
+"$PYTHON" scripts/3_watch_ops.py "$@"
diff --git a/scripts/3_serve_vol_monitor.py b/scripts/3_serve_vol_monitor.py
new file mode 100644
index 0000000..9970817
--- /dev/null
+++ b/scripts/3_serve_vol_monitor.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+"""vol_live 모니터 HTTP 서버 — 3_run_vol_monitor.py --serve-only 래퍼."""
+
+from __future__ import annotations
+
+import runpy
+import sys
+from pathlib import Path
+
+if __name__ == "__main__":
+ target = Path(__file__).resolve().parent / "3_run_vol_monitor.py"
+ sys.argv = [str(target), "--serve-only", *sys.argv[1:]]
+ runpy.run_path(str(target), run_name="__main__")
diff --git a/scripts/3_watch_ops.py b/scripts/3_watch_ops.py
new file mode 100644
index 0000000..78662bc
--- /dev/null
+++ b/scripts/3_watch_ops.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""read-only 감시 + 불일치 시 조치 tick / loop 재시작."""
+
+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 bithumb.config import load_settings
+from bithumb.operations.ops_lock import ops_tick_lock
+from bithumb.operations.watch_ops import (
+ inspect_ops_watch,
+ inspect_vol_watch,
+ is_vol_breakout_ops,
+ remediate_ops_watch,
+ remediate_vol_watch,
+)
+
+
+def _configure_logging(verbose: bool) -> None:
+ level = logging.DEBUG if verbose else logging.INFO
+ logging.basicConfig(
+ level=level,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+
+
+def main() -> int:
+ """CLI 진입점."""
+ parser = argparse.ArgumentParser(description="운영 read-only 감시 + 조치")
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="점검·알림만 (tick/재시작 없음)",
+ )
+ parser.add_argument(
+ "--inspect-only",
+ action="store_true",
+ help="stdout 출력만 (텔레그램·조치 없음)",
+ )
+ parser.add_argument("-v", "--verbose", action="store_true")
+ args = parser.parse_args()
+ _configure_logging(args.verbose)
+
+ settings = load_settings()
+
+ watch_lock = settings.ops_tick_lock_path
+ if watch_lock is not None:
+ watch_lock = watch_lock.parent / "ops.watch.lock"
+ if watch_lock is not None:
+ with ops_tick_lock(watch_lock, blocking=False) as acquired:
+ if not acquired:
+ print("watch already running — skip")
+ return 0
+ return _run_watch(settings, args)
+ return _run_watch(settings, args)
+
+
+def _run_watch(settings, args) -> int:
+ vol_mode = is_vol_breakout_ops(settings)
+ report = inspect_vol_watch(settings) if vol_mode else inspect_ops_watch(settings)
+
+ print("=== ops watch ===")
+ if vol_mode:
+ print("mode: vol_breakout")
+ print(f"checked_at: {report.checked_at}")
+ print(f"ledger_pending: {report.ledger_pending}")
+ print(f"executable_pending: {report.executable_pending}")
+ print(f"tick_age_sec: {report.tick_age_sec}")
+ print(f"loop_running: {report.loop_running}")
+ for issue in report.issues:
+ print(f" [{issue.severity}] {issue.kind}: {issue.message}")
+
+ if args.inspect_only:
+ return 1 if report.issues else 0
+
+ if not report.issues:
+ print("OK — 조치 없음")
+ return 0
+
+ result = (
+ remediate_vol_watch(settings, report, dry_run=args.dry_run)
+ if vol_mode
+ else remediate_ops_watch(settings, report, dry_run=args.dry_run)
+ )
+ print("\n=== remediation ===")
+ for action in result.actions:
+ print(f" action: {action}")
+ for msg in result.messages:
+ print(f" {msg}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/_cron_env.sh b/scripts/_cron_env.sh
new file mode 100755
index 0000000..2d12c03
--- /dev/null
+++ b/scripts/_cron_env.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# cron 래퍼 공통 — 프로젝트 루트, PYTHONPATH, 인터프리터 탐색.
+# shellcheck disable=SC2034
+set -euo pipefail
+
+_CRON_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CRON_PROJECT_ROOT="$(cd "${_CRON_SCRIPT_DIR}/.." && pwd)"
+cd "${CRON_PROJECT_ROOT}"
+export PYTHONPATH=src
+
+# 로그 디렉터리 (cron 리다이렉트 전에 mkdir -p 가능)
+ensure_cron_log_dir() {
+ local dir="$1"
+ mkdir -p "$dir"
+}
+
+# python-dotenv + pandas 등 프로젝트 의존성이 있는 python3
+resolve_bithumb_python() {
+ local candidate=""
+ for candidate in \
+ "${BITHUMB_PYTHON:-}" \
+ "${HOME}/opt/anaconda3/envs/coin/bin/python3" \
+ "${HOME}/opt/anaconda3/envs/ncue/bin/python3" \
+ "${HOME}/miniconda3/envs/xavis/bin/python3" \
+ "$(command -v python3 2>/dev/null || true)"; do
+ if [ -n "$candidate" ] && [ -x "$candidate" ] \
+ && "$candidate" -c "import dotenv" 2>/dev/null; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] python-dotenv 가능한 python3를 찾지 못함 (BITHUMB_PYTHON 설정)" >&2
+ return 1
+}
+
+# mkdir 기반 잠금 — stale/hung 프로세스 정리 후 획득
+# 사용: acquire_cron_lock LOCKDIR pgrep_pattern MAX_AGE_SEC
+acquire_cron_lock() {
+ local lockdir="$1"
+ local pgrep_pattern="$2"
+ local max_age_sec="${3:-900}"
+ local now_ts pid elapsed lock_mtime
+
+ now_ts="$(date +%s)"
+ if [ -d "$lockdir" ]; then
+ lock_mtime="$(stat -f %m "$lockdir" 2>/dev/null || stat -c %Y "$lockdir" 2>/dev/null || echo 0)"
+ elapsed=$((now_ts - lock_mtime))
+ if pgrep -f "$pgrep_pattern" >/dev/null 2>&1; then
+ if [ "$elapsed" -gt "$max_age_sec" ]; then
+ echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] hung ${pgrep_pattern} (${elapsed}s) — 종료 후 lock 정리" >&2
+ pkill -f "$pgrep_pattern" 2>/dev/null || true
+ sleep 1
+ fi
+ fi
+ if ! pgrep -f "$pgrep_pattern" >/dev/null 2>&1; then
+ echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] stale lock 정리: ${lockdir}" >&2
+ rmdir "$lockdir" 2>/dev/null || true
+ fi
+ fi
+
+ if ! mkdir "$lockdir" 2>/dev/null; then
+ echo "$(date '+%Y-%m-%d %H:%M:%S') [SKIP] ${pgrep_pattern} 실행 중 (lock ${lockdir})" >&2
+ return 1
+ fi
+ trap 'rmdir "'"$lockdir"'" 2>/dev/null || true' EXIT INT TERM
+ return 0
+}
diff --git a/scripts/com.bithumb.vol-monitor.plist.template b/scripts/com.bithumb.vol-monitor.plist.template
new file mode 100644
index 0000000..65ab9c0
--- /dev/null
+++ b/scripts/com.bithumb.vol-monitor.plist.template
@@ -0,0 +1,30 @@
+
+
+
+
+ Label
+ com.bithumb.vol-monitor
+ ProgramArguments
+
+ __PYTHON__
+ __ROOT__/scripts/3_run_vol_monitor.py
+
+ WorkingDirectory
+ __ROOT__
+ EnvironmentVariables
+
+ PYTHONPATH
+ src
+ VOL_MONITOR_PORT
+ 8766
+
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ __LOG__
+ StandardErrorPath
+ __LOG__
+
+
diff --git a/scripts/crontab.bithumb.example b/scripts/crontab.bithumb.example
new file mode 100644
index 0000000..d792228
--- /dev/null
+++ b/scripts/crontab.bithumb.example
@@ -0,0 +1,17 @@
+# Bithumb vol_breakout 운영 cron (install_crontab.sh 로 등록)
+# 프로젝트: /Users/dsyoon/workspace/bithumb
+# Python: BITHUMB_PYTHON 또는 coin/ncue conda (scripts/_cron_env.sh)
+
+# 캔들 증분 (TRX,NEAR,WLD × DOWNLOAD_INTERVALS) — 매 1분
+* * * * * /Users/dsyoon/workspace/bithumb/scripts/00_run_download_cron.sh >> /Users/dsyoon/workspace/bithumb/data/common/download_cron.log 2>&1
+
+# vol_breakout 15m flip tick — 매 1분
+* * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_vol_breakout_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/vol_breakout_cron.log 2>&1
+
+# vol_live 모니터 JSON/HTML 백업 갱신 — 5분마다
+*/5 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_vol_monitor_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/vol_monitor_cron.log 2>&1
+
+# 모니터 HTTP 서버(8766) — 터미널: python scripts/3_run_vol_monitor.py
+
+# (선택) fractal 운영 감시 — vol 전용이면 주석 유지
+# */5 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_watch_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/watch_cron.log 2>&1
diff --git a/scripts/install_crontab.sh b/scripts/install_crontab.sh
new file mode 100755
index 0000000..93993c9
--- /dev/null
+++ b/scripts/install_crontab.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Bithumb cron 등록 — 기존 crontab에 BITHUMB 블록 병합/갱신
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+MARKER_BEGIN="# BITHUMB vol_breakout cron (begin)"
+MARKER_END="# BITHUMB vol_breakout cron (end)"
+EXAMPLE="${ROOT}/scripts/crontab.bithumb.example"
+
+usage() {
+ cat </dev/null || true
+ tmp="$(mktemp)"
+ crontab -l 2>/dev/null | strip_block > "$tmp" || true
+ render_block >> "$tmp"
+ crontab "$tmp"
+ rm -f "$tmp"
+ echo "crontab installed. 확인: crontab -l"
+ ;;
+ --remove)
+ tmp="$(mktemp)"
+ if crontab -l 2>/dev/null | strip_block > "$tmp"; then
+ crontab "$tmp"
+ echo "BITHUMB cron block removed."
+ else
+ echo "crontab empty or not found."
+ fi
+ rm -f "$tmp"
+ ;;
+ -h|--help)
+ usage
+ ;;
+ *)
+ usage >&2
+ exit 1
+ ;;
+esac
diff --git a/scripts/install_vol_monitor_launchd.sh b/scripts/install_vol_monitor_launchd.sh
new file mode 100755
index 0000000..b89a806
--- /dev/null
+++ b/scripts/install_vol_monitor_launchd.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# macOS LaunchAgent — vol_live 모니터(8766) 로그인 시 자동 기동·유지
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+LABEL="com.bithumb.vol-monitor"
+PLIST_SRC="${ROOT}/scripts/com.bithumb.vol-monitor.plist.template"
+PLIST_DST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
+LOG="${ROOT}/data/spot/operations/vol_monitor_serve.log"
+
+usage() {
+ cat < "$PLIST_DST"
+ launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
+ launchctl bootstrap "gui/$(id -u)" "$PLIST_DST"
+ launchctl enable "gui/$(id -u)/${LABEL}" 2>/dev/null || true
+ launchctl kickstart -k "gui/$(id -u)/${LABEL}" 2>/dev/null || true
+ sleep 2
+ if curl -sf -o /dev/null --connect-timeout 3 "http://127.0.0.1:8766/vol_live_monitor.html"; then
+ echo "LaunchAgent 설치 완료 — http://127.0.0.1:8766/vol_live_monitor.html"
+ else
+ echo "LaunchAgent 등록됨. 접속 안 되면 로그 확인: $LOG" >&2
+ tail -15 "$LOG" 2>/dev/null || true
+ fi
+ ;;
+ --uninstall)
+ launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
+ rm -f "$PLIST_DST"
+ echo "LaunchAgent 제거됨"
+ ;;
+ --status)
+ if launchctl print "gui/$(id -u)/${LABEL}" >/dev/null 2>&1; then
+ echo "LaunchAgent: 등록됨"
+ launchctl print "gui/$(id -u)/${LABEL}" 2>/dev/null | grep -E "state =|pid =|last exit" || true
+ else
+ echo "LaunchAgent: 미등록"
+ fi
+ if curl -sf -o /dev/null --connect-timeout 2 "http://127.0.0.1:8766/vol_live_monitor.html"; then
+ echo "HTTP 8766: 응답 OK"
+ else
+ echo "HTTP 8766: 연결 불가 (서버 미기동)"
+ fi
+ ;;
+ -h|--help)
+ usage
+ ;;
+ *)
+ usage >&2
+ exit 1
+ ;;
+esac
diff --git a/src/bithumb/api/bithumb_private.py b/src/bithumb/api/bithumb_private.py
index 9775fe5..1d00b16 100644
--- a/src/bithumb/api/bithumb_private.py
+++ b/src/bithumb/api/bithumb_private.py
@@ -5,7 +5,9 @@ from __future__ import annotations
import logging
import math
import time
+from datetime import datetime
from typing import Any
+from urllib.parse import urlencode
import requests
@@ -77,7 +79,11 @@ class BithumbPrivateClient:
) -> dict[str, Any]:
"""인증 요청을 수행한다."""
url = f"{self.base_url}{path}"
- body = dumps_params(params) if params else None
+ body: str | None = None
+ if params and method.upper() == "GET":
+ url = f"{url}?{urlencode(params, doseq=True)}"
+ elif params:
+ body = dumps_params(params)
headers = auth_headers(
self.access_key,
self.secret_key,
@@ -182,3 +188,78 @@ class BithumbPrivateClient:
"ord_type": "market",
}
return self._request("POST", "/v1/orders", params=params)
+
+ def get_orders(
+ self,
+ market: str,
+ *,
+ state: str | None = None,
+ limit: int = 100,
+ page: int = 1,
+ order_by: str = "desc",
+ ) -> list[dict[str, Any]]:
+ """주문 목록 조회 (GET /v1/orders)."""
+ params: dict[str, Any] = {
+ "market": market,
+ "limit": int(limit),
+ "page": int(page),
+ "order_by": order_by,
+ }
+ if state:
+ params["state"] = state
+ payload = self._request("GET", "/v1/orders", params=params)
+ if isinstance(payload, list):
+ return payload
+ data = payload.get("data") if isinstance(payload, dict) else None
+ return data if isinstance(data, list) else []
+
+ def fetch_filled_orders_since(
+ self,
+ market: str,
+ since: datetime,
+ *,
+ max_pages: int = 20,
+ ) -> list[dict[str, Any]]:
+ """since 이후 체결 완료(done) 주문을 수집한다."""
+ filled: list[dict[str, Any]] = []
+ since_naive = since.replace(tzinfo=None)
+ for page in range(1, max_pages + 1):
+ orders = self.get_orders(
+ market,
+ state="done",
+ limit=100,
+ page=page,
+ order_by="desc",
+ )
+ if not orders:
+ break
+ stop = False
+ for order in orders:
+ created = parse_exchange_datetime(str(order.get("created_at", "")))
+ if created is None:
+ continue
+ if created.replace(tzinfo=None) < since_naive:
+ stop = True
+ break
+ if float(order.get("executed_volume") or 0) <= 0:
+ continue
+ filled.append(order)
+ if stop or len(orders) < 100:
+ break
+ return filled
+
+
+def parse_exchange_datetime(value: str) -> datetime | None:
+ """빗썸 주문 created_at 파싱."""
+ if not value:
+ return None
+ try:
+ return datetime.fromisoformat(value)
+ except ValueError:
+ pass
+ for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S"):
+ try:
+ return datetime.strptime(value, fmt)
+ except ValueError:
+ continue
+ return None
diff --git a/src/bithumb/config.py b/src/bithumb/config.py
index c0f7f6c..2ad2cb9 100644
--- a/src/bithumb/config.py
+++ b/src/bithumb/config.py
@@ -21,6 +21,31 @@ def _resolve_project_path(raw: str) -> Path:
return path
+def _resolve_project_path_optional(raw: str) -> Path | None:
+ """비어 있으면 None, 아니면 프로젝트 루트 기준 절대 경로."""
+ if not raw or not raw.strip():
+ return None
+ return _resolve_project_path(raw)
+
+
+def _parse_symbol_list(raw: str) -> list[str]:
+ """쉼표 구분 심볼 목록을 파싱한다."""
+ if not raw or not raw.strip():
+ return []
+ return [part.strip().upper() for part in raw.split(",") if part.strip()]
+
+
+def resolve_coin_name(symbol: str) -> str:
+ """심볼에 대응하는 한글 코인명을 반환한다."""
+ names = {
+ "BTC": "비트코인",
+ "TRX": "트론",
+ "NEAR": "니어프로토콜",
+ "WLD": "월드코인",
+ }
+ return names.get(symbol.upper(), symbol.upper())
+
+
def _parse_int_list(raw: str) -> list[int]:
"""쉼표 구분 정수 목록을 파싱한다.
@@ -45,6 +70,7 @@ class Settings:
candle_count: int
download_intervals: list[int]
download_days: int
+ download_symbols: list[str]
db_path: Path
request_sleep_sec: float
request_retries: int
@@ -112,6 +138,43 @@ class Settings:
ops_signal_tail_bars: int
ops_persist_signal_cache: bool
ops_catchup_bars: int
+ ops_ledger_lookback_days: int
+ ops_ledger_execute_max_age_minutes: int
+ ops_max_backlog_per_tick: int
+ ops_live_force_tail_refresh: bool
+ ops_watchdog_stale_sec: int
+ ops_tick_warn_sec: int
+ ops_log_json: Path | None
+ ops_exchange_reconcile: bool
+ ops_exchange_order_lookback_hours: int
+ ops_exchange_match_window_min: int
+ ops_tick_lock_path: Path | None
+ ops_loop_pid_file: Path | None
+ ops_watch_signal_grace_min: int
+ ops_watch_tick_stale_min: int
+ ops_watch_lookback_min: int
+ ops_watch_auto_remediate: bool
+ ops_watch_auto_restart: bool
+ ops_watch_loop_start_cmd: str
+ # vol_breakout 현물 롱 (Binance 이식)
+ ops_symbols: list[str]
+ vol_state_json: Path
+ vol_lookback: int
+ vol_atr_mult: float
+ vol_lookback_days: int
+ vol_wallet_pct: float
+ vol_buy_split: int
+ vol_max_bars_per_tick: int
+ vol_exit_enabled: bool
+ vol_trail_activate_pct: float
+ vol_trail_pct: float
+ vol_trail_atr_mult: float
+ vol_stop_pct: float
+ vol_tick_lock_path: Path | None
+ vol_kill_switch_path: Path | None
+ vol_monitor_json: Path
+ vol_monitor_html: Path
+ vol_monitor_days: int
telegram_bot_token: str
telegram_chat_id: str
ops_telegram_enabled: bool
@@ -148,13 +211,19 @@ def load_settings(env_path: Path | None = None) -> Settings:
if not tech_dir.is_absolute():
tech_dir = _PROJECT_ROOT / tech_dir
+ symbol = os.getenv("SYMBOL", "BTC").upper()
+ download_symbols = _parse_symbol_list(os.getenv("DOWNLOAD_SYMBOLS", ""))
+ if not download_symbols:
+ download_symbols = [symbol]
+
return Settings(
- symbol=os.getenv("SYMBOL", "BTC").upper(),
- coin_name=os.getenv("COIN_NAME", "비트코인"),
+ symbol=symbol,
+ coin_name=os.getenv("COIN_NAME", resolve_coin_name(symbol)),
api_url=os.getenv("BITHUMB_API_URL", "https://api.bithumb.com").rstrip("/"),
candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")),
download_intervals=intervals,
download_days=int(os.getenv("DOWNLOAD_DAYS", "3650")),
+ download_symbols=download_symbols,
db_path=db_path,
request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")),
request_retries=int(os.getenv("API_REQUEST_RETRIES", "3")),
@@ -297,7 +366,84 @@ def load_settings(env_path: Path | None = None) -> Settings:
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"),
- ops_catchup_bars=int(os.getenv("OPS_CATCHUP_BARS", "10")),
+ ops_catchup_bars=int(os.getenv("OPS_CATCHUP_BARS", "480")),
+ ops_ledger_lookback_days=int(os.getenv("OPS_LEDGER_LOOKBACK_DAYS", "3")),
+ ops_ledger_execute_max_age_minutes=int(
+ os.getenv("OPS_LEDGER_EXECUTE_MAX_AGE_MINUTES", "45")
+ ),
+ ops_max_backlog_per_tick=int(os.getenv("OPS_MAX_BACKLOG_PER_TICK", "20")),
+ ops_live_force_tail_refresh=os.getenv(
+ "OPS_LIVE_FORCE_TAIL_REFRESH", "true"
+ ).strip().lower()
+ in ("1", "true", "yes", "on"),
+ ops_watchdog_stale_sec=int(os.getenv("OPS_WATCHDOG_STALE_SEC", "600")),
+ ops_tick_warn_sec=int(os.getenv("OPS_TICK_WARN_SEC", "120")),
+ ops_log_json=_resolve_project_path_optional(
+ os.getenv("OPS_LOG_JSON", "data/spot/operations/live_run.log")
+ ),
+ ops_exchange_reconcile=os.getenv("OPS_EXCHANGE_RECONCILE", "true").strip().lower()
+ in ("1", "true", "yes", "on"),
+ ops_exchange_order_lookback_hours=int(
+ os.getenv("OPS_EXCHANGE_ORDER_LOOKBACK_HOURS", "48")
+ ),
+ ops_exchange_match_window_min=int(
+ os.getenv("OPS_EXCHANGE_MATCH_WINDOW_MIN", "720")
+ ),
+ ops_tick_lock_path=_resolve_project_path_optional(
+ os.getenv("OPS_TICK_LOCK_PATH", "data/spot/operations/ops.tick.lock")
+ ),
+ ops_loop_pid_file=_resolve_project_path_optional(
+ os.getenv("OPS_LOOP_PID_FILE", "data/spot/operations/ops_loop.pid")
+ ),
+ ops_watch_signal_grace_min=int(os.getenv("OPS_WATCH_SIGNAL_GRACE_MIN", "5")),
+ ops_watch_tick_stale_min=int(os.getenv("OPS_WATCH_TICK_STALE_MIN", "12")),
+ ops_watch_lookback_min=int(os.getenv("OPS_WATCH_LOOKBACK_MIN", "30")),
+ ops_watch_auto_remediate=os.getenv("OPS_WATCH_AUTO_REMEDIATE", "true")
+ .strip()
+ .lower()
+ in ("1", "true", "yes", "on"),
+ ops_watch_auto_restart=os.getenv("OPS_WATCH_AUTO_RESTART", "true")
+ .strip()
+ .lower()
+ in ("1", "true", "yes", "on"),
+ ops_watch_loop_start_cmd=os.getenv("OPS_WATCH_LOOP_START_CMD", "").strip(),
+ ops_symbols=_parse_symbol_list(os.getenv("OPS_SYMBOLS", "")) or [
+ s for s in download_symbols if s != "BTC"
+ ],
+ vol_state_json=_resolve_project_path(
+ os.getenv("VOL_STATE_JSON", "data/spot/operations/vol_breakout_state.json")
+ ),
+ vol_lookback=int(os.getenv("VOL_LOOKBACK", "14")),
+ vol_atr_mult=float(os.getenv("VOL_ATR_MULT", "2.0")),
+ vol_lookback_days=int(os.getenv("VOL_LOOKBACK_DAYS", "60")),
+ vol_wallet_pct=float(os.getenv("VOL_WALLET_PCT", "0.30")),
+ vol_buy_split=int(os.getenv("VOL_BUY_SPLIT", "0")),
+ vol_max_bars_per_tick=int(os.getenv("VOL_MAX_BARS_PER_TICK", "5")),
+ vol_exit_enabled=os.getenv("VOL_EXIT_ENABLED", "false").strip().lower()
+ in ("1", "true", "yes", "on"),
+ vol_trail_activate_pct=float(os.getenv("VOL_TRAIL_ACTIVATE_PCT", "1.5")),
+ vol_trail_pct=float(os.getenv("VOL_TRAIL_PCT", "0.0025")),
+ vol_trail_atr_mult=float(os.getenv("VOL_TRAIL_ATR_MULT", "0.5")),
+ vol_stop_pct=float(os.getenv("VOL_STOP_PCT", "-12.0")),
+ vol_tick_lock_path=_resolve_project_path_optional(
+ os.getenv("VOL_TICK_LOCK_PATH", "data/spot/operations/vol.tick.lock")
+ ),
+ vol_kill_switch_path=_resolve_project_path_optional(
+ os.getenv("VOL_KILL_SWITCH_PATH", "data/spot/operations/vol.kill")
+ ),
+ vol_monitor_json=_resolve_project_path(
+ os.getenv(
+ "VOL_MONITOR_JSON",
+ "docs/spot/3_operations/vol_live_chart.json",
+ )
+ ),
+ vol_monitor_html=_resolve_project_path(
+ os.getenv(
+ "VOL_MONITOR_HTML",
+ "docs/spot/3_operations/vol_live_monitor.html",
+ )
+ ),
+ vol_monitor_days=int(os.getenv("VOL_MONITOR_DAYS", "14")),
telegram_bot_token=os.getenv("COIN_TELEGRAM_BOT_TOKEN", "").strip(),
telegram_chat_id=os.getenv("COIN_TELEGRAM_CHAT_ID", "").strip(),
ops_telegram_enabled=_parse_ops_telegram_enabled(
diff --git a/src/bithumb/data/candle_bars.py b/src/bithumb/data/candle_bars.py
new file mode 100644
index 0000000..3be5320
--- /dev/null
+++ b/src/bithumb/data/candle_bars.py
@@ -0,0 +1,59 @@
+"""캔들 봉 마감 판별."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from bithumb.api.bithumb import parse_kst_datetime
+
+
+def bar_close_time(bar_open: datetime, interval_min: int) -> datetime:
+ """봉 시작 시각 기준 마감 시각(KST naive)을 반환한다."""
+ return bar_open + timedelta(minutes=interval_min)
+
+
+def is_closed_candle(
+ bar_open: datetime,
+ interval_min: int,
+ *,
+ now: datetime | None = None,
+) -> bool:
+ """해당 봉이 마감되었는지 여부."""
+ ref = now or datetime.now()
+ return ref >= bar_close_time(bar_open, interval_min)
+
+
+def only_closed_candles(
+ rows: list[tuple],
+ interval_min: int,
+ *,
+ now: datetime | None = None,
+) -> list[tuple]:
+ """미마감 봉을 제외한 OHLCV 행만 반환한다.
+
+ Args:
+ rows: ``(ymdhms, open, high, low, close, volume)`` 튜플 리스트.
+ interval_min: 분 단위 인터벌.
+ now: 기준 시각(KST). None이면 ``datetime.now()``.
+
+ Returns:
+ 마감된 봉만 포함한 리스트(입력 순서 유지).
+ """
+ if not rows:
+ return []
+ ref = now or datetime.now()
+ closed: list[tuple] = []
+ for row in rows:
+ bar_open = parse_kst_datetime(str(row[0]))
+ if is_closed_candle(bar_open, interval_min, now=ref):
+ closed.append(row)
+ return closed
+
+
+def last_closed_bar_open(now: datetime, interval_min: int) -> datetime:
+ """기준 시각에서 가장 최근 마감된 봉의 시작 시각."""
+ minute = (now.minute // interval_min) * interval_min
+ current_start = now.replace(minute=minute, second=0, microsecond=0)
+ if is_closed_candle(current_start, interval_min, now=now):
+ return current_start
+ return current_start - timedelta(minutes=interval_min)
diff --git a/src/bithumb/data/candle_loader.py b/src/bithumb/data/candle_loader.py
index fd92189..ff3ec41 100644
--- a/src/bithumb/data/candle_loader.py
+++ b/src/bithumb/data/candle_loader.py
@@ -20,7 +20,7 @@ def load_candles(
Args:
db_path: SQLite 경로.
- symbol: 코인 심볼 (예: BTC).
+ symbol: 코인 심볼 (예: TRX).
interval_min: 분 단위 인터벌 코드.
lookback_days: 최근 N일만 사용. None이면 전체.
diff --git a/src/bithumb/data/candle_store.py b/src/bithumb/data/candle_store.py
index fdbbbb0..30a329e 100644
--- a/src/bithumb/data/candle_store.py
+++ b/src/bithumb/data/candle_store.py
@@ -9,6 +9,7 @@ from pathlib import Path
import pandas as pd
from bithumb.api.bithumb import parse_kst_datetime
+from bithumb.data.candle_bars import is_closed_candle, only_closed_candles
class CandleStore:
@@ -90,6 +91,31 @@ class CandleStore:
return int(row[0]), parse_kst_datetime(str(row[1])), parse_kst_datetime(str(row[2]))
+ def delete_incomplete_tail(
+ self,
+ symbol: str,
+ interval_min: int,
+ *,
+ now: datetime | None = None,
+ ) -> int:
+ """DB 최신 봉이 미마감이면 삭제한다 (조기 저장 잔재 정리).
+
+ Returns:
+ 삭제된 행 수.
+ """
+ _, _, db_max = self.get_range(symbol, interval_min)
+ if db_max is None:
+ return 0
+ if is_closed_candle(db_max, interval_min, now=now):
+ return 0
+ table = self.table_name(symbol, interval_min)
+ cur = self._conn.execute(
+ f"DELETE FROM {table} WHERE CODE = ? AND ymdhms = ?",
+ (symbol.upper(), db_max.strftime("%Y-%m-%d %H:%M:%S")),
+ )
+ self._conn.commit()
+ return cur.rowcount
+
def read_dataframe(self, symbol: str, interval_min: int) -> pd.DataFrame:
"""캔들을 pandas DataFrame으로 읽는다.
@@ -154,6 +180,11 @@ class CandleStore:
Returns:
저장(시도) 행 수.
"""
+ if not rows:
+ return 0
+
+ rows = only_closed_candles(rows, interval_min)
+
if not rows:
return 0
@@ -211,6 +242,11 @@ class CandleStore:
Returns:
실제 INSERT된 행 수.
"""
+ if not rows:
+ return 0
+
+ rows = only_closed_candles(rows, interval_min)
+
if not rows:
return 0
diff --git a/src/bithumb/data/downloader.py b/src/bithumb/data/downloader.py
index c41b661..70e6767 100644
--- a/src/bithumb/data/downloader.py
+++ b/src/bithumb/data/downloader.py
@@ -9,6 +9,7 @@ from typing import Any
from bithumb.api.bithumb import BithumbCandleClient, parse_kst_datetime
from bithumb.config import Settings
+from bithumb.data.candle_bars import only_closed_candles
from bithumb.data.candle_store import CandleStore
logger = logging.getLogger(__name__)
@@ -106,15 +107,9 @@ class CandleDownloader:
stop_at = target_from
else:
mode = "incremental"
- if db_max >= datetime.now() - timedelta(minutes=max(interval_min, 1)):
- return DownloadResult(
- interval_min=interval_min,
- mode="uptodate",
- requests=0,
- saved_rows=0,
- reached_target=True,
- )
- stop_at = db_max - timedelta(minutes=interval_min)
+ store.delete_incomplete_tail(symbol, interval_min)
+ _, _, db_max = store.get_range(symbol, interval_min)
+ stop_at = db_max - timedelta(minutes=interval_min) if db_max else target_from
to_kst: datetime | None = None
requests = 0
@@ -132,28 +127,30 @@ class CandleDownloader:
if not candles:
break
- rows = _candle_rows_from_api(candles)
- if not rows:
+ all_rows = _candle_rows_from_api(candles)
+ if not all_rows:
break
- 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,
- self.settings.coin_name,
- interval_min,
- rows,
- )
- saved_rows += inserted
+ batch_oldest = min(parse_kst_datetime(r[0]) for r in all_rows)
+ rows = only_closed_candles(all_rows, interval_min)
+ if rows:
+ if mode == "incremental" and db_max is not None:
+ refresh_from = db_max - timedelta(minutes=interval_min * 48)
+ rows = [
+ r for r in rows
+ if parse_kst_datetime(str(r[0])) >= refresh_from
+ ]
+ if rows:
+ saved_rows += store.upsert_rows(
+ symbol,
+ self.settings.coin_name,
+ interval_min,
+ rows,
+ )
+
+ if mode == "incremental":
+ break
- batch_oldest = min(parse_kst_datetime(r[0]) for r in rows)
if oldest_seen is None or batch_oldest < oldest_seen:
oldest_seen = batch_oldest
diff --git a/src/bithumb/mtf/rules.py b/src/bithumb/mtf/rules.py
index 30714bd..ec788da 100644
--- a/src/bithumb/mtf/rules.py
+++ b/src/bithumb/mtf/rules.py
@@ -11,7 +11,7 @@ from bithumb.evaluation.gt_align import GT_SIGNAL_TYPES
Operator = Literal["<=", ">="]
-# BTC 가격 스케일에 민감한 지표는 자동 규칙에서 제외
+# 고가 코인 가격 스케일에 민감한 지표는 자동 규칙에서 제외
_EXCLUDED_AUTO_FEATURES: frozenset[str] = frozenset({"macd_hist", "zigzag_leg_pct", "close"})
# 자동 규칙에 사용할 안정 피처
diff --git a/src/bithumb/notifications/telegram.py b/src/bithumb/notifications/telegram.py
index a7c6c31..2c98512 100644
--- a/src/bithumb/notifications/telegram.py
+++ b/src/bithumb/notifications/telegram.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from datetime import datetime
from typing import Any
import requests
@@ -131,6 +132,50 @@ class TelegramNotifier:
return self.send_message("\n".join(lines))
+ def notify_vol_breakout_trade(
+ self,
+ *,
+ mode: str,
+ symbol: str,
+ side: str,
+ price: float,
+ order_krw: float,
+ order_coin: float,
+ equity_krw: float,
+ reason: str = "signal_vol_breakout",
+ ts: str | None = None,
+ pnl_krw: float | None = None,
+ pnl_pct: float | None = None,
+ ) -> bool:
+ """vol_breakout 현물 롱 체결 알림 (Binance live_engine 형식)."""
+ mode_txt = "실거래" if mode == "live" else "페이퍼"
+ market = f"{symbol.upper()}KRW"
+ time_txt = (ts or datetime.now().strftime("%Y-%m-%d %H:%M:%S"))[:19]
+
+ if side == "buy":
+ text = (
+ f"[{mode_txt}] 롱 진입(매수)\n"
+ f"{market} @ {price:,.2f}\n"
+ f"수량 {order_coin:.6f} (≈{order_krw:,.0f}원)\n"
+ f"사유 {reason}\n"
+ f"시각 {time_txt}"
+ )
+ else:
+ pnl_line = ""
+ if pnl_krw is not None and pnl_pct is not None:
+ pnl_line = (
+ f"손익 {pnl_krw:+,.0f}원 ({pnl_pct:+.3f}%)\n"
+ )
+ text = (
+ f"[{mode_txt}] 롱 청산(매도)\n"
+ f"{market} @ {price:,.2f}\n"
+ f"{pnl_line}"
+ f"자본 {equity_krw:,.0f}원\n"
+ f"사유 {reason}\n"
+ f"시각 {time_txt}"
+ )
+ return self.send_message(text)
+
def notify_trade_failure(
self,
*,
diff --git a/src/bithumb/operations/exchange_reconcile.py b/src/bithumb/operations/exchange_reconcile.py
new file mode 100644
index 0000000..4f5b2b8
--- /dev/null
+++ b/src/bithumb/operations/exchange_reconcile.py
@@ -0,0 +1,244 @@
+"""거래소 체결 내역과 신호 원장 대조 — 이미 체결된 신호 재주문 방지."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta
+from typing import Any
+
+from bithumb.api.bithumb_private import BithumbPrivateClient, parse_exchange_datetime
+from bithumb.operations.trade_engine import TradeResult
+
+logger = logging.getLogger(__name__)
+
+
+def _ledger_helpers() -> tuple[Any, ...]:
+ """runner와의 순환 import 방지."""
+ from bithumb.operations.runner import (
+ _history_index,
+ _is_settled,
+ _ledger_scan_start_datetime,
+ _parse_signal_dt,
+ _signal_key,
+ )
+
+ return (
+ _history_index,
+ _is_settled,
+ _ledger_scan_start_datetime,
+ _parse_signal_dt,
+ _signal_key,
+ )
+
+
+def _order_side(order: dict[str, Any]) -> str | None:
+ """주문 side → 신호 side (bid→buy, ask→sell)."""
+ raw = str(order.get("side", "")).lower()
+ if raw == "bid":
+ return "buy"
+ if raw == "ask":
+ return "sell"
+ return None
+
+
+def _known_order_uuids(trade_history: list[dict[str, Any]]) -> set[str]:
+ """trade_history에 이미 기록된 주문 uuid."""
+ uuids: set[str] = set()
+ for record in trade_history:
+ trade = record.get("trade") or {}
+ resp = trade.get("api_response")
+ if isinstance(resp, dict):
+ uid = resp.get("uuid")
+ if uid:
+ uuids.add(str(uid))
+ return uuids
+
+
+def _unsettled_signals_for_reconcile(
+ kept: list[dict[str, Any]],
+ trade_history: list[dict[str, Any]],
+ *,
+ lookback_days: int,
+) -> list[dict[str, Any]]:
+ """거래소 대조 대상 미정산 신호."""
+ (
+ _history_index,
+ _is_settled,
+ _ledger_scan_start_datetime,
+ _parse_signal_dt,
+ _signal_key,
+ ) = _ledger_helpers()
+ idx = _history_index(trade_history)
+ scan_start = _ledger_scan_start_datetime(trade_history, lookback_days)
+ pending: list[dict[str, Any]] = []
+ for sig in kept:
+ dt_str = str(sig.get("datetime", ""))
+ if not dt_str or dt_str < scan_start:
+ continue
+ key = _signal_key(sig)
+ record = idx.get(key)
+ if record is not None and _is_settled(record.get("trade") or {}):
+ continue
+ pending.append(sig)
+ pending.sort(key=lambda s: (_parse_signal_dt(str(s["datetime"])), str(s.get("side", ""))))
+ return pending
+
+
+def _match_orders_to_signals(
+ orders: list[dict[str, Any]],
+ signals: list[dict[str, Any]],
+ *,
+ match_window_min: int,
+ known_uuids: set[str],
+) -> list[tuple[dict[str, Any], dict[str, Any]]]:
+ """미사용 체결 주문과 미정산 신호를 side·시간 기준으로 1:1 매칭."""
+ _, _, _, _parse_signal_dt, _ = _ledger_helpers()
+ if not orders or not signals:
+ return []
+
+ window = timedelta(minutes=max(match_window_min, 1))
+ available_signals = list(signals)
+ matches: list[tuple[dict[str, Any], dict[str, Any]]] = []
+
+ sorted_orders = sorted(
+ orders,
+ key=lambda o: parse_exchange_datetime(str(o.get("created_at", ""))) or datetime.min,
+ )
+
+ for order in sorted_orders:
+ uid = str(order.get("uuid", ""))
+ if uid and uid in known_uuids:
+ continue
+ order_side = _order_side(order)
+ if order_side is None:
+ continue
+ if float(order.get("executed_volume") or 0) <= 0:
+ continue
+ order_dt = parse_exchange_datetime(str(order.get("created_at", "")))
+ if order_dt is None:
+ continue
+ order_dt = order_dt.replace(tzinfo=None)
+
+ best_idx: int | None = None
+ best_delta: timedelta | None = None
+ for idx, sig in enumerate(available_signals):
+ if str(sig.get("side")) != order_side:
+ continue
+ sig_dt = _parse_signal_dt(str(sig["datetime"]))
+ if order_dt < sig_dt:
+ continue
+ delta = order_dt - sig_dt
+ if delta > window:
+ continue
+ if best_delta is None or delta < best_delta:
+ best_delta = delta
+ best_idx = idx
+
+ if best_idx is None:
+ continue
+ sig = available_signals.pop(best_idx)
+ matches.append((sig, order))
+ if uid:
+ known_uuids.add(uid)
+
+ return matches
+
+
+def _trade_from_exchange_order(
+ signal: dict[str, Any],
+ order: dict[str, Any],
+) -> TradeResult:
+ """거래소 주문 dict → TradeResult (재주문 없음)."""
+ side = str(signal["side"])
+ executed_volume = float(order.get("executed_volume") or 0)
+ executed_funds = float(order.get("executed_funds") or 0)
+ if side == "buy":
+ order_krw = executed_funds if executed_funds > 0 else float(signal.get("price", 0))
+ order_coin = executed_volume
+ else:
+ order_krw = executed_funds if executed_funds > 0 else executed_volume * float(
+ signal.get("price", 0)
+ )
+ order_coin = executed_volume
+ fill_price = (
+ executed_funds / executed_volume
+ if executed_volume > 0 and executed_funds > 0
+ else float(signal.get("price", 0))
+ )
+ return TradeResult(
+ executed=True,
+ side=side,
+ order_krw=order_krw,
+ order_coin=order_coin,
+ fee_krw=0.0,
+ price=fill_price,
+ skip_reason="",
+ expected_skip=False,
+ api_response=order,
+ )
+
+
+def reconcile_exchange_fills(
+ kept: list[dict[str, Any]],
+ trade_history: list[dict[str, Any]],
+ *,
+ client: BithumbPrivateClient,
+ market: str,
+ lookback_hours: int,
+ match_window_min: int,
+ lookback_days: int,
+) -> list[dict[str, Any]]:
+ """거래소 done 주문과 미정산 신호를 대조해 정산 레코드를 반환한다.
+
+ API 재주문 없이 trade_history에 executed=True 기록만 추가한다.
+ """
+ unsettled = _unsettled_signals_for_reconcile(
+ kept,
+ trade_history,
+ lookback_days=lookback_days,
+ )
+ if not unsettled:
+ return []
+
+ since = datetime.now() - timedelta(hours=max(lookback_hours, 1))
+ try:
+ orders = client.fetch_filled_orders_since(market, since)
+ except Exception:
+ logger.exception("거래소 주문 조회 실패 — exchange reconcile 생략")
+ return []
+
+ known_uuids = _known_order_uuids(trade_history)
+ matches = _match_orders_to_signals(
+ orders,
+ unsettled,
+ match_window_min=match_window_min,
+ known_uuids=known_uuids,
+ )
+ if not matches:
+ return []
+
+ reconciled: list[dict[str, Any]] = []
+ for sig, order in matches:
+ trade = _trade_from_exchange_order(sig, order)
+ reconciled.append(
+ {
+ "datetime": sig["datetime"],
+ "side": sig["side"],
+ "signal_type": sig.get("signal_type"),
+ "price": sig.get("price"),
+ "bar_index": int(sig.get("bar_index", -1)),
+ "ledger": True,
+ "exchange_reconciled": True,
+ "trade": trade.to_dict(),
+ "mtf_filter": sig.get("mtf_filter"),
+ }
+ )
+
+ logger.info(
+ "exchange reconcile %d건 (orders=%d unsettled=%d window=%d분)",
+ len(reconciled),
+ len(orders),
+ len(unsettled),
+ match_window_min,
+ )
+ return reconciled
diff --git a/src/bithumb/operations/multi_portfolio.py b/src/bithumb/operations/multi_portfolio.py
new file mode 100644
index 0000000..119a9f9
--- /dev/null
+++ b/src/bithumb/operations/multi_portfolio.py
@@ -0,0 +1,97 @@
+"""멀티 코인 현물 포트폴리오 — 공유 KRW + 종목별 coin_qty."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from bithumb.api.bithumb_private import BithumbPrivateClient
+
+
+def empty_multi_portfolio(
+ symbols: list[str],
+ *,
+ cash_krw: float = 0.0,
+ mode: str = "paper",
+) -> dict[str, Any]:
+ """빈 멀티 포트폴리오."""
+ return {
+ "cash_krw": float(cash_krw),
+ "mode": mode,
+ "positions": {
+ sym.upper(): {"coin_qty": 0.0, "entry_price": 0.0}
+ for sym in symbols
+ },
+ }
+
+
+def get_coin_qty(portfolio: dict[str, Any], symbol: str) -> float:
+ """종목 보유 수량."""
+ pos = portfolio.get("positions") or {}
+ return float((pos.get(symbol.upper()) or {}).get("coin_qty") or 0.0)
+
+
+def set_coin_qty(portfolio: dict[str, Any], symbol: str, qty: float) -> None:
+ """종목 보유 수량 설정."""
+ portfolio.setdefault("positions", {})
+ sym = symbol.upper()
+ portfolio["positions"].setdefault(sym, {"coin_qty": 0.0, "entry_price": 0.0})
+ portfolio["positions"][sym]["coin_qty"] = float(qty)
+
+
+def in_long_position(portfolio: dict[str, Any], symbol: str, *, dust_krw: float = 5000.0, price: float = 0.0) -> bool:
+ """최소 주문 가치 이상 보유 시 롱 포지션."""
+ qty = get_coin_qty(portfolio, symbol)
+ if qty <= 0:
+ return False
+ if price > 0:
+ return qty * price >= dust_krw
+ return qty > 0
+
+
+def count_empty_buy_slots(
+ portfolio: dict[str, Any],
+ symbols: list[str],
+ *,
+ min_order_krw: float = 5000.0,
+ prices: dict[str, float] | None = None,
+) -> int:
+ """매수 가능 빈 슬롯 수 (미보유 종목).
+
+ 3종목 중 2개 보유 → 1 (100% 매수). 1개 보유 → 2 (1/2 매수).
+ """
+ prices = prices or {}
+ empty = 0
+ for sym in symbols:
+ sym_u = sym.upper()
+ px = float(prices.get(sym_u) or prices.get(sym) or 0.0)
+ if not in_long_position(portfolio, sym_u, dust_krw=min_order_krw, price=px):
+ empty += 1
+ return max(empty, 1)
+
+
+def sync_multi_portfolio_from_exchange(
+ portfolio: dict[str, Any],
+ client: BithumbPrivateClient,
+ symbols: list[str],
+) -> None:
+ """거래소 KRW·코인 잔고 동기화."""
+ krw_avail, _ = client.get_balance("KRW")
+ portfolio["cash_krw"] = float(krw_avail)
+ portfolio.setdefault("positions", {})
+ for sym in symbols:
+ sym_u = sym.upper()
+ coin_avail, _ = client.get_balance(sym_u)
+ portfolio["positions"].setdefault(sym_u, {"coin_qty": 0.0, "entry_price": 0.0})
+ portfolio["positions"][sym_u]["coin_qty"] = float(coin_avail)
+
+
+def single_coin_portfolio_view(
+ portfolio: dict[str, Any],
+ symbol: str,
+) -> dict[str, Any]:
+ """SymbolExecutor 호환 단일 코인 뷰."""
+ return {
+ "cash_krw": float(portfolio.get("cash_krw") or 0.0),
+ "coin_qty": get_coin_qty(portfolio, symbol),
+ "mode": portfolio.get("mode", "paper"),
+ }
diff --git a/src/bithumb/operations/ops_lock.py b/src/bithumb/operations/ops_lock.py
new file mode 100644
index 0000000..5ab1e14
--- /dev/null
+++ b/src/bithumb/operations/ops_lock.py
@@ -0,0 +1,48 @@
+"""운영 tick 단일 실행 flock (loop·watch 공유)."""
+
+from __future__ import annotations
+
+import fcntl
+import logging
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Iterator
+
+logger = logging.getLogger(__name__)
+
+
+@contextmanager
+def ops_tick_lock(path: Path, *, blocking: bool = True) -> Iterator[bool]:
+ """tick 체결 lock.
+
+ Args:
+ path: lock 파일 경로.
+ blocking: False면 획득 실패 시 즉시 False yield.
+
+ Yields:
+ lock 획득 성공 여부.
+ """
+ path.parent.mkdir(parents=True, exist_ok=True)
+ handle = path.open("a+", encoding="utf-8")
+ acquired = False
+ try:
+ flags = fcntl.LOCK_EX
+ if not blocking:
+ flags |= fcntl.LOCK_NB
+ fcntl.flock(handle.fileno(), flags)
+ acquired = True
+ handle.seek(0)
+ handle.truncate()
+ handle.write("locked\n")
+ handle.flush()
+ yield True
+ except BlockingIOError:
+ logger.info("ops tick lock busy: %s", path)
+ yield False
+ finally:
+ if acquired:
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
+ except OSError:
+ pass
+ handle.close()
diff --git a/src/bithumb/operations/reconcile.py b/src/bithumb/operations/reconcile.py
new file mode 100644
index 0000000..ccbe6f9
--- /dev/null
+++ b/src/bithumb/operations/reconcile.py
@@ -0,0 +1,76 @@
+"""운영 backlog 신호 조회·일괄 처리."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from bithumb.config import Settings
+from bithumb.operations.runner import (
+ _apply_backlog_limit,
+ _backlog_summary,
+ _catchup_signals_for_ops,
+ _ledger_pending_signals,
+ _merge_pending_signals,
+ _reconcile_processed_cursor,
+)
+from bithumb.operations.signal_pipeline import (
+ filter_signals_for_ops,
+ generate_raw_signals,
+ load_ops_candles,
+)
+from bithumb.operations.state_store import load_state
+
+
+def inspect_ops_backlog(settings: Settings) -> dict[str, Any]:
+ """현재 state 기준 ledger backlog를 조회한다 (체결 없음).
+
+ Returns:
+ ledger_pending, merged_pending, summary dict.
+ """
+ state = load_state(
+ settings.ops_state_json,
+ initial_cash_krw=settings.gt_initial_cash_krw,
+ )
+ df = load_ops_candles(settings)
+ latest_bar = len(df) - 1
+ force_tail = (
+ settings.ops_mode == "live" and settings.ops_live_force_tail_refresh
+ )
+ gen = generate_raw_signals(
+ settings,
+ df=df,
+ use_cache=True,
+ force_tail_refresh=force_tail,
+ )
+ filtered = filter_signals_for_ops(settings, gen["raw_signals"])
+ all_kept = filtered["kept"]
+ _reconcile_processed_cursor(state, gen["raw_signals"])
+ trade_history = state.get("trade_history") or []
+
+ ledger_pending = _ledger_pending_signals(
+ all_kept,
+ trade_history,
+ latest_bar_index=latest_bar,
+ lookback_days=settings.ops_ledger_lookback_days,
+ )
+ catchup = _catchup_signals_for_ops(
+ all_kept,
+ latest_bar_index=latest_bar,
+ catchup_bars=settings.ops_catchup_bars,
+ trade_history=trade_history,
+ )
+ merged = _merge_pending_signals(ledger_pending, catchup)
+ limited, dropped = _apply_backlog_limit(merged, settings.ops_max_backlog_per_tick)
+
+ return {
+ "state": state,
+ "latest_bar_index": latest_bar,
+ "ledger_pending": ledger_pending,
+ "catchup_pending": catchup,
+ "merged_pending": merged,
+ "limited_pending": limited,
+ "backlog_dropped": dropped,
+ "summary": _backlog_summary(merged),
+ "signal_refresh": gen.get("signal_refresh"),
+ "force_tail_refresh": force_tail,
+ }
diff --git a/src/bithumb/operations/runner.py b/src/bithumb/operations/runner.py
index 1149140..c9a541d 100644
--- a/src/bithumb/operations/runner.py
+++ b/src/bithumb/operations/runner.py
@@ -6,7 +6,7 @@ import json
import logging
import time
import traceback
-from datetime import datetime
+from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
@@ -14,8 +14,10 @@ from bithumb.config import Settings
from bithumb.ground_truth.pnl import _cluster_signals
from bithumb.notifications.telegram import create_telegram_notifier
from bithumb.operations.candle_sync import sync_ops_candles
+from bithumb.operations.exchange_reconcile import reconcile_exchange_fills
from bithumb.operations.executor import LiveExecutor, create_executor
from bithumb.operations.live_bootstrap import sync_portfolio_from_exchange
+from bithumb.operations.ops_lock import ops_tick_lock
from bithumb.operations.signal_pipeline import (
filter_signals_for_ops,
generate_raw_signals,
@@ -75,6 +77,241 @@ def _reconcile_processed_cursor(
state["last_processed_bar_index"] = max_bar_at_or_before
+def _is_settled(trade: dict[str, Any]) -> bool:
+ """trade_history에 완료(체결·의도 스킵)로 기록됐는지."""
+ return bool(trade.get("executed")) or bool(trade.get("expected_skip"))
+
+
+def _record_status_rank(record: dict[str, Any]) -> int:
+ """history 레코드 우선순위 (executed > expected_skip > 미정산)."""
+ trade = record.get("trade") or {}
+ if trade.get("executed"):
+ return 3
+ if trade.get("expected_skip"):
+ return 2
+ return 1
+
+
+def _history_index(
+ trade_history: list[dict[str, Any]],
+) -> dict[tuple[str, str], dict[str, Any]]:
+ """(datetime, side) → 대표 history 레코드 (executed 우선)."""
+ idx: dict[tuple[str, str], dict[str, Any]] = {}
+ for record in trade_history:
+ key = (str(record.get("datetime")), str(record.get("side")))
+ existing = idx.get(key)
+ if existing is None or _record_status_rank(record) >= _record_status_rank(
+ existing
+ ):
+ idx[key] = record
+ return idx
+
+
+def _is_signal_api_executable(
+ sig: dict[str, Any],
+ *,
+ max_age_minutes: int,
+ live_since: str | None,
+ now: datetime | None = None,
+) -> tuple[bool, str]:
+ """live API 체결 가능 여부 (만료·live 이전 backlog는 스킵).
+
+ Returns:
+ (executable, skip_reason) — skip_reason은 executable=False일 때만 사용.
+ """
+ if now is None:
+ now = datetime.now()
+ dt_str = str(sig["datetime"])
+ if live_since and dt_str < live_since:
+ return False, "live 시작 이전 backlog (체결 생략)"
+ if max_age_minutes > 0:
+ sig_dt = _parse_signal_dt(dt_str)
+ age_sec = (now - sig_dt).total_seconds()
+ if age_sec > max_age_minutes * 60:
+ return False, (
+ f"backlog 만료 (신호 {max_age_minutes}분 초과, 체결 생략)"
+ )
+ return True, ""
+
+
+def _settle_expired_backlog(
+ all_kept: list[dict[str, Any]],
+ trade_history: list[dict[str, Any]],
+ *,
+ max_age_minutes: int,
+ live_since: str | None,
+ lookback_days: int = 3,
+) -> list[dict[str, Any]]:
+ """API 없이 만료 backlog를 expected_skip으로 일괄 정산한다."""
+ idx = _history_index(trade_history)
+ scan_start = _ledger_scan_start_datetime(trade_history, lookback_days)
+ now = datetime.now()
+ settled: list[dict[str, Any]] = []
+ for sig in all_kept:
+ dt_str = str(sig.get("datetime", ""))
+ if not dt_str or dt_str < scan_start:
+ continue
+ key = _signal_key(sig)
+ record = idx.get(key)
+ if record is not None and _is_settled(record.get("trade") or {}):
+ continue
+ executable, skip_reason = _is_signal_api_executable(
+ sig,
+ max_age_minutes=max_age_minutes,
+ live_since=live_since,
+ now=now,
+ )
+ if executable:
+ continue
+ settled.append(
+ {
+ "datetime": sig["datetime"],
+ "side": sig["side"],
+ "signal_type": sig.get("signal_type"),
+ "price": sig["price"],
+ "bar_index": int(sig.get("bar_index", -1)),
+ "ledger": True,
+ "backlog_expired": True,
+ "trade": TradeResult(
+ executed=False,
+ side=str(sig["side"]),
+ order_krw=0.0,
+ order_coin=0.0,
+ fee_krw=0.0,
+ price=float(sig.get("price", 0)),
+ skip_reason=skip_reason,
+ expected_skip=True,
+ ).to_dict(),
+ "mtf_filter": sig.get("mtf_filter"),
+ }
+ )
+ if settled:
+ logger.info(
+ "backlog 만료 정산 %d건 (max_age=%d분 live_since=%s)",
+ len(settled),
+ max_age_minutes,
+ live_since,
+ )
+ return settled
+
+
+def _ledger_scan_start_datetime(
+ trade_history: list[dict[str, Any]],
+ lookback_days: int,
+) -> str:
+ """ledger pending 스캔 시작 시각 (lookback vs 미정산 최소 시각)."""
+ now = datetime.now()
+ lookback_start = (now - timedelta(days=max(lookback_days, 0))).strftime(
+ "%Y-%m-%d %H:%M:%S"
+ )
+ oldest_unsettled: str | None = None
+ for record in trade_history:
+ trade = record.get("trade") or {}
+ if _is_settled(trade):
+ continue
+ dt = str(record.get("datetime", ""))
+ if not dt:
+ continue
+ if oldest_unsettled is None or dt < oldest_unsettled:
+ oldest_unsettled = dt
+ if oldest_unsettled is not None and oldest_unsettled < lookback_start:
+ return oldest_unsettled
+ return lookback_start
+
+
+def _ledger_pending_signals(
+ kept: list[dict[str, Any]],
+ trade_history: list[dict[str, Any]],
+ *,
+ latest_bar_index: int,
+ lookback_days: int,
+) -> list[dict[str, Any]]:
+ """trade_history 원장 기준 미정산 신호 (datetime·side).
+
+ last_processed_datetime과 무관하게 history에 settled 기록이 없으면 pending.
+ """
+ idx = _history_index(trade_history)
+ scan_start = _ledger_scan_start_datetime(trade_history, lookback_days)
+ pending: list[dict[str, Any]] = []
+ for sig in kept:
+ bar_idx = int(sig.get("bar_index", -1))
+ if bar_idx > latest_bar_index:
+ continue
+ dt_str = str(sig["datetime"])
+ if dt_str < scan_start:
+ continue
+ key = _signal_key(sig)
+ record = idx.get(key)
+ if record is not None and _is_settled(record.get("trade") or {}):
+ continue
+ pending.append(sig)
+ pending.sort(
+ key=lambda s: (_parse_signal_dt(str(s["datetime"])), str(s.get("side", "")))
+ )
+ return pending
+
+
+def _advance_cursor_from_ledger(
+ state: dict[str, Any],
+ all_kept: list[dict[str, Any]],
+ trade_history: list[dict[str, Any]],
+) -> None:
+ """settled history 기준으로 커서를 텔레메트리용으로 갱신한다."""
+ idx = _history_index(trade_history)
+ max_dt: str | None = state.get("last_processed_datetime")
+ max_bar = int(state.get("last_processed_bar_index", -1))
+ for sig in all_kept:
+ key = _signal_key(sig)
+ record = idx.get(key)
+ if record is None or not _is_settled(record.get("trade") or {}):
+ continue
+ dt = str(sig["datetime"])
+ bar = int(sig.get("bar_index", -1))
+ if max_dt is None or dt > max_dt:
+ max_dt = dt
+ if bar > max_bar:
+ max_bar = bar
+ if max_dt is not None:
+ state["last_processed_datetime"] = max_dt
+ state["last_processed_bar_index"] = max_bar
+
+
+def _apply_backlog_limit(
+ pending_signals: list[dict[str, Any]],
+ max_backlog: int,
+) -> tuple[list[dict[str, Any]], int]:
+ """tick당 처리 상한 적용. (truncated, dropped_count)."""
+ if max_backlog <= 0 or len(pending_signals) <= max_backlog:
+ return pending_signals, 0
+ return pending_signals[:max_backlog], len(pending_signals) - max_backlog
+
+
+def _backlog_summary(pending_signals: list[dict[str, Any]]) -> dict[str, Any]:
+ """backlog 리포트용 요약."""
+ if not pending_signals:
+ return {
+ "backlog_signal_count": 0,
+ "backlog_oldest_datetime": None,
+ }
+ return {
+ "backlog_signal_count": len(pending_signals),
+ "backlog_oldest_datetime": str(pending_signals[0]["datetime"]),
+ }
+
+
+def _append_ops_log(settings: Settings, message: str) -> None:
+ """운영 tick 로그 append."""
+ path = settings.ops_log_json
+ if path is None:
+ return
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("a", encoding="utf-8") as fp:
+ fp.write(message.rstrip() + "\n")
+ except OSError:
+ logger.exception("ops_log append 실패: %s", path)
+
+
def _pending_signals_for_ops(
kept: list[dict[str, Any]],
*,
@@ -205,6 +442,7 @@ class OperationsRunner:
def __init__(self, settings: Settings) -> None:
self.settings = settings
+ self._runner_started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.executor = create_executor(settings)
self.telegram = create_telegram_notifier(
settings.telegram_bot_token,
@@ -229,8 +467,33 @@ class OperationsRunner:
logger.exception("live 초기 잔고 동기화 실패")
self._notify_ops_error("init_sync", exc)
- def tick(self, *, sync_candles: bool | None = None) -> dict[str, Any]:
+ def tick(
+ self,
+ *,
+ sync_candles: bool | None = None,
+ skip_lock: bool = False,
+ ) -> dict[str, Any]:
"""신호 확인 및 체결 1회. 예외 발생 시 텔레그램 알림 후 error 리포트 반환."""
+ if skip_lock:
+ return self._tick_with_error_handling(sync_candles=sync_candles)
+ lock_path = self.settings.ops_tick_lock_path
+ if lock_path is None:
+ return self._tick_with_error_handling(sync_candles=sync_candles)
+ with ops_tick_lock(lock_path, blocking=True) as acquired:
+ if not acquired:
+ logger.warning("tick lock 획득 실패 — tick 생략")
+ return self._build_error_report(
+ RuntimeError("tick lock busy"),
+ stage="tick_lock",
+ )
+ return self._tick_with_error_handling(sync_candles=sync_candles)
+
+ def _tick_with_error_handling(
+ self,
+ *,
+ sync_candles: bool | None = None,
+ ) -> dict[str, Any]:
+ """tick 본체 + 예외 처리."""
try:
return self._tick_impl(sync_candles=sync_candles)
except Exception as exc:
@@ -240,6 +503,15 @@ class OperationsRunner:
def _tick_impl(self, *, sync_candles: bool | None = None) -> dict[str, Any]:
"""tick 본체."""
+ tick_started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ tick_mono_start = time.monotonic()
+ self.state["last_tick_started_at"] = tick_started_at
+ self._check_watchdog_stale(tick_started_at)
+ _append_ops_log(
+ self.settings,
+ f"{tick_started_at} [INFO] tick 시작 mode={self.settings.ops_mode}",
+ )
+
do_sync = sync_candles if sync_candles is not None else self.settings.ops_sync_candles
candle_sync_results: list[Any] = []
if do_sync:
@@ -247,7 +519,16 @@ class OperationsRunner:
df = load_ops_candles(self.settings)
latest_bar = int(len(df) - 1)
- gen = generate_raw_signals(self.settings, df=df, use_cache=True)
+ force_tail = (
+ self.settings.ops_mode == "live"
+ and self.settings.ops_live_force_tail_refresh
+ )
+ gen = generate_raw_signals(
+ self.settings,
+ df=df,
+ use_cache=True,
+ force_tail_refresh=force_tail,
+ )
filtered = filter_signals_for_ops(self.settings, gen["raw_signals"])
all_kept = filtered["kept"]
@@ -268,22 +549,66 @@ class OperationsRunner:
logger.exception("tick 잔고 동기화 실패")
self._notify_ops_error("portfolio_sync", exc)
- last_bar = int(self.state.get("last_processed_bar_index", -1))
- last_dt = self.state.get("last_processed_datetime")
- pending_signals = _pending_signals_for_ops(
+ trade_history = self.state.get("trade_history") or []
+ live_since = (
+ str(self.state["live_initialized_at"])
+ if self.settings.ops_mode == "live" and self.state.get("live_initialized_at")
+ else None
+ )
+ max_age = self.settings.ops_ledger_execute_max_age_minutes
+ trade_history = self.state.get("trade_history") or []
+
+ exchange_reconciled: list[dict[str, Any]] = []
+ if (
+ self.settings.ops_mode == "live"
+ and self.settings.ops_exchange_reconcile
+ and isinstance(self.executor, LiveExecutor)
+ ):
+ exchange_reconciled = reconcile_exchange_fills(
+ all_kept,
+ trade_history,
+ client=self.executor.client,
+ market=self.settings.market,
+ lookback_hours=self.settings.ops_exchange_order_lookback_hours,
+ match_window_min=self.settings.ops_exchange_match_window_min,
+ lookback_days=self.settings.ops_ledger_lookback_days,
+ )
+ if exchange_reconciled:
+ self.state.setdefault("trade_history", []).extend(exchange_reconciled)
+ trade_history = self.state.get("trade_history") or []
+
+ stale_settled = _settle_expired_backlog(
all_kept,
- last_processed_datetime=last_dt,
- last_bar_index=last_bar,
+ trade_history,
+ max_age_minutes=max_age,
+ live_since=live_since,
+ lookback_days=self.settings.ops_ledger_lookback_days,
+ )
+ if stale_settled:
+ self.state.setdefault("trade_history", []).extend(stale_settled)
+ trade_history = self.state.get("trade_history") or []
+
+ ledger_pending = _ledger_pending_signals(
+ all_kept,
+ trade_history,
latest_bar_index=latest_bar,
+ lookback_days=self.settings.ops_ledger_lookback_days,
)
catchup_signals = _catchup_signals_for_ops(
all_kept,
latest_bar_index=latest_bar,
catchup_bars=self.settings.ops_catchup_bars,
- trade_history=self.state.get("trade_history") or [],
+ trade_history=trade_history,
)
catchup_keys = {_signal_key(s) for s in catchup_signals}
- pending_signals = _merge_pending_signals(pending_signals, catchup_signals)
+ ledger_keys = {_signal_key(s) for s in ledger_pending}
+ pending_signals = _merge_pending_signals(ledger_pending, catchup_signals)
+ backlog_before_limit = len(pending_signals)
+ pending_signals, backlog_dropped = _apply_backlog_limit(
+ pending_signals,
+ self.settings.ops_max_backlog_per_tick,
+ )
+ backlog_meta = _backlog_summary(pending_signals)
target_bars = _pending_bar_indices(pending_signals)
executions: list[dict[str, Any]] = []
@@ -302,23 +627,13 @@ class OperationsRunner:
(k for k in all_kept if k["datetime"] == sig["datetime"]),
sig,
)
- try:
- trade = self.executor.execute_signal(
- full_sig,
- self.state["portfolio"],
- cluster_size=cluster_size,
- )
- except Exception as exc:
- logger.exception(
- "체결 실행 예외 side=%s datetime=%s",
- full_sig.get("side"),
- full_sig.get("datetime"),
- )
- self._notify_ops_error(
- "execute",
- exc,
- context=f"side={full_sig.get('side')} dt={full_sig.get('datetime')}",
- )
+ sig_key = _signal_key(full_sig)
+ executable, expire_reason = _is_signal_api_executable(
+ full_sig,
+ max_age_minutes=max_age,
+ live_since=live_since,
+ )
+ if not executable:
trade = TradeResult(
executed=False,
side=str(full_sig.get("side", "")),
@@ -326,15 +641,44 @@ class OperationsRunner:
order_coin=0.0,
fee_krw=0.0,
price=float(full_sig.get("price", 0)),
- skip_reason=str(exc),
+ skip_reason=expire_reason,
+ expected_skip=True,
)
+ else:
+ try:
+ trade = self.executor.execute_signal(
+ full_sig,
+ self.state["portfolio"],
+ cluster_size=cluster_size,
+ )
+ except Exception as exc:
+ logger.exception(
+ "체결 실행 예외 side=%s datetime=%s",
+ full_sig.get("side"),
+ full_sig.get("datetime"),
+ )
+ self._notify_ops_error(
+ "execute",
+ exc,
+ context=f"side={full_sig.get('side')} dt={full_sig.get('datetime')}",
+ )
+ trade = TradeResult(
+ executed=False,
+ side=str(full_sig.get("side", "")),
+ order_krw=0.0,
+ order_coin=0.0,
+ fee_krw=0.0,
+ price=float(full_sig.get("price", 0)),
+ skip_reason=str(exc),
+ )
record = {
"datetime": full_sig["datetime"],
"side": full_sig["side"],
"signal_type": full_sig.get("signal_type"),
"price": full_sig["price"],
"bar_index": bar_idx,
- "catchup": _signal_key(full_sig) in catchup_keys,
+ "ledger": sig_key in ledger_keys,
+ "catchup": sig_key in catchup_keys,
"trade": trade.to_dict(),
"mtf_filter": full_sig.get("mtf_filter"),
}
@@ -361,11 +705,31 @@ class OperationsRunner:
datetime_str=str(full_sig["datetime"]),
reason=trade.skip_reason,
)
- if bar_idx > last_bar:
- last_bar = bar_idx
- self.state["last_processed_bar_index"] = bar_idx
- if bar_signals:
- self.state["last_processed_datetime"] = bar_signals[-1]["datetime"]
+
+ if executions:
+ self.state.setdefault("trade_history", []).extend(executions)
+ trade_history = self.state.get("trade_history") or []
+ _advance_cursor_from_ledger(self.state, all_kept, trade_history)
+
+ tick_duration = time.monotonic() - tick_mono_start
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ self.state["last_run_at"] = now
+ self.state["last_tick_completed_at"] = now
+ self.state["last_tick_duration_sec"] = round(tick_duration, 2)
+ if (
+ self.settings.ops_tick_warn_sec > 0
+ and tick_duration > self.settings.ops_tick_warn_sec
+ ):
+ logger.warning(
+ "tick 소요 %.1fs (경고 임계 %ds)",
+ tick_duration,
+ self.settings.ops_tick_warn_sec,
+ )
+ self._notify_ops_error(
+ "tick_slow",
+ RuntimeError(f"tick {tick_duration:.1f}s"),
+ context=f"threshold={self.settings.ops_tick_warn_sec}s",
+ )
pipeline = {
"technique_id": gen["technique_id"],
@@ -373,16 +737,16 @@ class OperationsRunner:
"kept_count": len(all_kept),
"rejected_count": len(filtered["rejected"]),
"latest_bar_index": latest_bar,
- "pending_signal_count": len(pending_signals),
+ "ledger_pending_count": len(ledger_pending),
+ "pending_signal_count": backlog_before_limit,
"catchup_signal_count": len(catchup_signals),
"catchup_bars": self.settings.ops_catchup_bars,
+ "backlog_dropped_count": backlog_dropped,
+ "backlog_stale_settled_count": len(stale_settled),
+ "exchange_reconciled_count": len(exchange_reconciled),
+ **backlog_meta,
}
- now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- self.state["last_run_at"] = now
- if executions:
- self.state.setdefault("trade_history", []).extend(executions)
-
report = {
"generated_at": now,
"mode": self.settings.ops_mode,
@@ -390,6 +754,7 @@ class OperationsRunner:
"slippage_rate": self.settings.ops_slippage_rate,
"daily_max_trades": self.settings.ops_daily_max_trades,
"signal_refresh": gen.get("signal_refresh"),
+ "force_tail_refresh": force_tail,
"candle_sync": [
{
"interval_min": r.interval_min,
@@ -402,20 +767,79 @@ class OperationsRunner:
"raw_signals": pipeline["raw_count"],
"filtered_signals": pipeline["kept_count"],
"pending_bars": target_bars,
+ "ledger_pending_count": pipeline["ledger_pending_count"],
"pending_signal_count": pipeline["pending_signal_count"],
"catchup_signal_count": pipeline["catchup_signal_count"],
"catchup_bars": pipeline["catchup_bars"],
+ "backlog_signal_count": pipeline["backlog_signal_count"],
+ "backlog_oldest_datetime": pipeline["backlog_oldest_datetime"],
+ "backlog_dropped_count": pipeline["backlog_dropped_count"],
+ "backlog_stale_settled_count": pipeline["backlog_stale_settled_count"],
+ "exchange_reconciled_count": pipeline["exchange_reconciled_count"],
"latest_bar_candidates": pipeline["raw_count"],
"executions": executions,
"portfolio": self.state["portfolio"],
"trades_today_count": self.state["trades_today_count"],
"last_processed_bar_index": self.state["last_processed_bar_index"],
+ "last_processed_datetime": self.state.get("last_processed_datetime"),
+ "last_tick_started_at": tick_started_at,
+ "last_tick_completed_at": now,
+ "last_tick_duration_sec": round(tick_duration, 2),
}
save_state(self.settings.ops_state_json, self.state)
self._save_report(report)
+ _append_ops_log(
+ self.settings,
+ (
+ f"{now} [INFO] tick 완료 duration={tick_duration:.1f}s "
+ f"ledger_pending={len(ledger_pending)} "
+ f"processed={len(executions)} "
+ f"backlog_dropped={backlog_dropped}"
+ ),
+ )
return report
+ def _check_watchdog_stale(self, tick_started_at: str) -> None:
+ """이전 tick 완료 시각이 너무 오래됐으면 알린다.
+
+ 프로세스 재시작 직후에는 state에 남은 이전 last_completed를 무시한다.
+ """
+ stale_sec = self.settings.ops_watchdog_stale_sec
+ if stale_sec <= 0:
+ return
+ last_completed = self.state.get("last_tick_completed_at") or self.state.get(
+ "last_run_at"
+ )
+ if not last_completed:
+ return
+ try:
+ tick_dt = datetime.strptime(tick_started_at, "%Y-%m-%d %H:%M:%S")
+ last_dt = datetime.strptime(str(last_completed), "%Y-%m-%d %H:%M:%S")
+ runner_dt = datetime.strptime(self._runner_started_at, "%Y-%m-%d %H:%M:%S")
+ except ValueError:
+ return
+ if last_dt < runner_dt:
+ logger.info(
+ "watchdog skip (restart): last_completed=%s runner_started=%s",
+ last_completed,
+ self._runner_started_at,
+ )
+ return
+ delta = (tick_dt - last_dt).total_seconds()
+ if delta <= stale_sec:
+ return
+ logger.warning(
+ "watchdog stale: last completed %.0fs ago (threshold %ds)",
+ delta,
+ stale_sec,
+ )
+ self._notify_ops_error(
+ "watchdog_stale",
+ RuntimeError(f"no tick completion for {delta:.0f}s"),
+ context=f"last_completed={last_completed}",
+ )
+
def _notify_ops_error(
self,
stage: str,
@@ -427,6 +851,8 @@ class OperationsRunner:
if not self.telegram.is_active:
return
tb_tail = traceback.format_exc(limit=4).strip()
+ if tb_tail in ("NoneType: None", "None"):
+ tb_tail = ""
detail_parts = [p for p in (context, tb_tail) if p]
self.telegram.notify_ops_error(
mode=self.settings.ops_mode,
diff --git a/src/bithumb/operations/signal_pipeline.py b/src/bithumb/operations/signal_pipeline.py
index 6edbc63..1740770 100644
--- a/src/bithumb/operations/signal_pipeline.py
+++ b/src/bithumb/operations/signal_pipeline.py
@@ -47,6 +47,27 @@ def _offset_signal_bars(signals: list[dict[str, Any]], offset: int) -> list[dict
return shifted
+def _replace_tail_signals(
+ cached_signals: list[dict[str, Any]],
+ tail_signals: list[dict[str, Any]],
+ offset: int,
+) -> list[dict[str, Any]]:
+ """tail 구간 신호를 재계산 결과로 교체한다 (live force refresh용).
+
+ bar_index >= offset 인 캐시 신호를 제거하고 tail 재계산 신호로 대체한다.
+ """
+ base = [s for s in cached_signals if int(s.get("bar_index", 0)) < offset]
+ seen = {(s.get("datetime"), s.get("side")) for s in base}
+ merged = list(base)
+ for signal in _offset_signal_bars(tail_signals, offset):
+ key = (signal.get("datetime"), signal.get("side"))
+ if key in seen:
+ continue
+ merged.append(signal)
+ seen.add(key)
+ return merged
+
+
def _merge_tail_signals(
cached_signals: list[dict[str, Any]],
tail_signals: list[dict[str, Any]],
@@ -187,6 +208,7 @@ def generate_raw_signals(
df: pd.DataFrame | None = None,
*,
use_cache: bool = True,
+ force_tail_refresh: bool = False,
) -> dict[str, Any]:
"""기법 신호를 생성한다 (MTF 필터 전).
@@ -209,19 +231,27 @@ def generate_raw_signals(
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_bars = max(settings.ops_signal_tail_bars, 200)
+ offset = max(0, len(df) - tail_bars)
+ needs_tail = latest_bar > max_cached_bar or force_tail_refresh
+ if needs_tail:
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,
- )
+ if force_tail_refresh and latest_bar <= max_cached_bar:
+ raw_signals = _replace_tail_signals(
+ raw_signals,
+ tail_result.signals,
+ offset,
+ )
+ else:
+ raw_signals = _merge_tail_signals(
+ raw_signals,
+ tail_result.signals,
+ offset,
+ max_cached_bar,
+ )
refreshed_tail = True
updated = TechniqueResult(
technique_id=cached.technique_id,
diff --git a/src/bithumb/operations/symbol_executor.py b/src/bithumb/operations/symbol_executor.py
new file mode 100644
index 0000000..685dc87
--- /dev/null
+++ b/src/bithumb/operations/symbol_executor.py
@@ -0,0 +1,150 @@
+"""종목별 paper/live 체결 — 멀티 포트폴리오 뷰."""
+
+from __future__ import annotations
+
+import logging
+import math
+from typing import Any
+
+from bithumb.api.bithumb_private import BithumbPrivateClient
+from bithumb.config import Settings
+from bithumb.operations.execution import fill_price
+from bithumb.operations.trade_engine import (
+ TradeResult,
+ apply_trade_to_portfolio,
+ compute_buy_order,
+ compute_sell_order,
+ spendable_cash_for_exchange_buy,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class SymbolExecutor:
+ """단일 종목 buy/sell (멀티 포트폴리오 공유 KRW)."""
+
+ def __init__(
+ self,
+ settings: Settings,
+ symbol: str,
+ *,
+ client: BithumbPrivateClient | None = None,
+ ) -> None:
+ self.settings = settings
+ self.symbol = symbol.upper()
+ self.market = f"KRW-{self.symbol}"
+ self._client = client
+
+ def _fee_rate(self) -> float:
+ return self.settings.gt_trading_fee_rate
+
+ def _slippage(self) -> float:
+ return self.settings.ops_slippage_rate
+
+ def _buy_cash_budget(self, cash: float, *, empty_slots: int = 1) -> float:
+ """매수 KRW — 수수료 lock·안전버퍼 후 빈 슬롯 수로 나눔."""
+ spendable_total = spendable_cash_for_exchange_buy(
+ max(float(cash), 0.0),
+ self.settings.ops_exchange_fee_lock_rate,
+ self.settings.ops_buy_safety_buffer_krw,
+ )
+ fixed_split = int(self.settings.vol_buy_split)
+ if fixed_split > 0:
+ return math.floor(spendable_total / fixed_split)
+ slots = max(int(empty_slots), 1)
+ return math.floor(spendable_total / slots)
+
+ def execute_buy(
+ self,
+ portfolio_view: dict[str, Any],
+ *,
+ price: float,
+ empty_slots: int = 1,
+ ) -> TradeResult:
+ """롱 진입 매수."""
+ px = fill_price(price, "buy", self._slippage())
+ cash = float(portfolio_view.get("cash_krw") or 0.0)
+ coin = float(portfolio_view.get("coin_qty") or 0.0)
+ spendable = self._buy_cash_budget(cash, empty_slots=empty_slots)
+
+ trade = compute_buy_order(
+ cash_krw=spendable,
+ coin_qty=coin,
+ price=px,
+ fee_rate=self._fee_rate(),
+ min_order_krw=self.settings.ops_min_order_krw,
+ cluster_size=1,
+ buy_cash_pct=1.0,
+ sizing_rules=None,
+ )
+ if not trade.executed:
+ return trade
+
+ if self.settings.ops_mode == "live" and self._client is not None:
+ try:
+ resp = self._client.market_buy_krw(self.market, trade.order_krw)
+ trade.api_response = resp
+ krw_avail, _ = self._client.get_balance("KRW")
+ coin_avail, _ = self._client.get_balance(self.symbol)
+ portfolio_view["cash_krw"] = krw_avail
+ portfolio_view["coin_qty"] = coin_avail
+ return trade
+ except Exception as exc:
+ logger.exception("live buy failed %s", self.symbol)
+ return TradeResult(
+ executed=False,
+ side="buy",
+ order_krw=trade.order_krw,
+ order_coin=trade.order_coin,
+ fee_krw=0.0,
+ price=px,
+ skip_reason=str(exc),
+ )
+ apply_trade_to_portfolio(portfolio_view, trade)
+ return trade
+
+ def execute_sell(
+ self,
+ portfolio_view: dict[str, Any],
+ *,
+ price: float,
+ sell_pct: float = 1.0,
+ ) -> TradeResult:
+ """롱 청산 매도."""
+ px = fill_price(price, "sell", self._slippage())
+ coin = float(portfolio_view.get("coin_qty") or 0.0)
+
+ trade = compute_sell_order(
+ coin_qty=coin,
+ price=px,
+ fee_rate=self._fee_rate(),
+ min_order_krw=self.settings.ops_min_order_krw,
+ cluster_size=1,
+ sell_coin_pct=sell_pct,
+ sizing_rules=None,
+ )
+ if not trade.executed:
+ return trade
+
+ if self.settings.ops_mode == "live" and self._client is not None:
+ try:
+ resp = self._client.market_sell_volume(self.market, trade.order_coin)
+ trade.api_response = resp
+ krw_avail, _ = self._client.get_balance("KRW")
+ coin_avail, _ = self._client.get_balance(self.symbol)
+ portfolio_view["cash_krw"] = krw_avail
+ portfolio_view["coin_qty"] = coin_avail
+ return trade
+ except Exception as exc:
+ logger.exception("live sell failed %s", self.symbol)
+ return TradeResult(
+ executed=False,
+ side="sell",
+ order_krw=trade.order_krw,
+ order_coin=trade.order_coin,
+ fee_krw=0.0,
+ price=px,
+ skip_reason=str(exc),
+ )
+ apply_trade_to_portfolio(portfolio_view, trade)
+ return trade
diff --git a/src/bithumb/operations/vol_breakout_engine.py b/src/bithumb/operations/vol_breakout_engine.py
new file mode 100644
index 0000000..046a5c2
--- /dev/null
+++ b/src/bithumb/operations/vol_breakout_engine.py
@@ -0,0 +1,386 @@
+"""15m vol_breakout 현물 롱 — 종목별 엔진 (Binance vol_breakout_live 이식)."""
+
+from __future__ import annotations
+
+import json
+import logging
+import time
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+from bithumb.config import Settings
+from bithumb.data.candle_loader import load_candles
+from bithumb.operations.multi_portfolio import (
+ count_empty_buy_slots,
+ in_long_position,
+ single_coin_portfolio_view,
+ sync_multi_portfolio_from_exchange,
+)
+from bithumb.operations.symbol_executor import SymbolExecutor
+from bithumb.operations.vol_breakout_exit import (
+ DEFAULT_SPOT_LONG_EXIT_RULES,
+ SpotLongExitRules,
+ evaluate_spot_long_exit,
+ latest_atr,
+ reset_exit_trail_state,
+ sync_exit_state_after_open,
+)
+from bithumb.simulation.vol_breakout import (
+ baseline_15m_signal_at,
+ drop_incomplete_base_bar,
+ pending_baseline_bar_indices,
+ spot_long_action,
+)
+
+logger = logging.getLogger(__name__)
+
+INTERVAL_MIN = 15
+
+
+@dataclass
+class SymbolTickResult:
+ """종목 1틱 결과."""
+
+ symbol: str
+ fills: int = 0
+ note: str = ""
+ last_price: float = 0.0
+ in_position: bool = False
+ trade_records: list[dict[str, Any]] = field(default_factory=list)
+
+
+def _default_sym_state() -> dict[str, Any]:
+ return {
+ "initialized": False,
+ "last_confirm_time": None,
+ "last_signal": 0,
+ "pending_bar_time": None,
+ "exit_trail_active": False,
+ "exit_peak_price": 0.0,
+ "entry_price": 0.0,
+ }
+
+
+class VolBreakoutSymbolEngine:
+ """단일 종목 15m vol_breakout 현물 롱."""
+
+ STRATEGY = "vol_breakout_15m_spot_long"
+
+ def __init__(
+ self,
+ settings: Settings,
+ symbol: str,
+ executor: SymbolExecutor,
+ ) -> None:
+ self.settings = settings
+ self.symbol = symbol.upper()
+ self.executor = executor
+
+ def _load_closed_15m(self) -> pd.DataFrame:
+ df = load_candles(
+ self.settings.db_path,
+ self.symbol,
+ INTERVAL_MIN,
+ lookback_days=self.settings.vol_lookback_days,
+ )
+ if df.empty:
+ return df
+ return drop_incomplete_base_bar(df, INTERVAL_MIN)
+
+ def _sym_state(self, root: dict[str, Any]) -> dict[str, Any]:
+ root.setdefault("symbols", {})
+ st = root["symbols"].setdefault(self.symbol, _default_sym_state())
+ for k, v in _default_sym_state().items():
+ st.setdefault(k, v if not isinstance(v, dict) else dict(v))
+ return st
+
+ def _current_price(self, df: pd.DataFrame) -> float:
+ if df.empty:
+ return 0.0
+ return float(df["close"].iloc[-1])
+
+ def _merge_portfolio_view(
+ self,
+ portfolio: dict[str, Any],
+ trade_view: dict[str, Any],
+ ) -> None:
+ """체결 후 멀티 포트폴리오 반영."""
+ portfolio["cash_krw"] = float(trade_view.get("cash_krw") or 0.0)
+ portfolio.setdefault("positions", {})
+ sym = self.symbol
+ portfolio["positions"].setdefault(sym, {"coin_qty": 0.0, "entry_price": 0.0})
+ portfolio["positions"][sym]["coin_qty"] = float(trade_view.get("coin_qty") or 0.0)
+
+ def run_exit_tick(
+ self,
+ portfolio: dict[str, Any],
+ root_state: dict[str, Any],
+ *,
+ client=None,
+ ) -> SymbolTickResult:
+ """trail/stop 청산."""
+ if not self.settings.vol_exit_enabled:
+ return SymbolTickResult(symbol=self.symbol, note="exit_disabled")
+
+ if self.settings.ops_mode == "live" and client is not None:
+ sync_multi_portfolio_from_exchange(portfolio, client, [self.symbol])
+
+ df = self._load_closed_15m()
+ price = self._current_price(df)
+ sym_st = self._sym_state(root_state)
+ view = single_coin_portfolio_view(portfolio, self.symbol)
+
+ if not in_long_position(portfolio, self.symbol, price=price):
+ reset_exit_trail_state(sym_st)
+ return SymbolTickResult(symbol=self.symbol, last_price=price, note="exit_flat")
+
+ entry = float(sym_st.get("entry_price") or 0.0)
+ if entry <= 0:
+ entry = price
+ atr = latest_atr(df, self.settings.vol_lookback)
+ rules = self._exit_rules()
+ reason = evaluate_spot_long_exit(
+ sym_st,
+ entry=entry,
+ mark=price,
+ atr=atr,
+ rules=rules,
+ )
+ if reason is None:
+ return SymbolTickResult(
+ symbol=self.symbol,
+ last_price=price,
+ in_position=True,
+ note="exit_hold",
+ )
+
+ entry_px = float(sym_st.get("entry_price") or 0.0) or entry
+ trade = self.executor.execute_sell(view, price=price, sell_pct=1.0)
+ self._merge_portfolio_view(portfolio, view)
+ if trade.executed:
+ reset_exit_trail_state(sym_st)
+ sym_st["entry_price"] = 0.0
+ sym_st["last_signal"] = 0
+ record: dict[str, Any] = {
+ "symbol": self.symbol,
+ "side": "sell",
+ "ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "price": float(trade.price),
+ "order_krw": float(trade.order_krw),
+ "order_coin": float(trade.order_coin),
+ "reason": reason,
+ }
+ if entry_px > 0:
+ cost_krw = entry_px * float(trade.order_coin)
+ pnl_krw = float(trade.order_krw) - cost_krw
+ record["entry_price"] = entry_px
+ record["pnl_krw"] = pnl_krw
+ record["pnl_pct"] = (
+ (pnl_krw / cost_krw * 100.0) if cost_krw > 0 else 0.0
+ )
+ return SymbolTickResult(
+ symbol=self.symbol,
+ fills=1,
+ last_price=price,
+ note=f"exit_{reason}",
+ trade_records=[record],
+ )
+ return SymbolTickResult(
+ symbol=self.symbol,
+ last_price=price,
+ in_position=True,
+ note=f"exit_{reason}_fail",
+ )
+
+ def run_signal_tick(
+ self,
+ portfolio: dict[str, Any],
+ root_state: dict[str, Any],
+ *,
+ client=None,
+ block_entry: bool = False,
+ ) -> SymbolTickResult:
+ """15m 마감 신호 처리 (현물 롱 전용)."""
+ if self.settings.ops_mode == "live" and client is not None:
+ sync_multi_portfolio_from_exchange(portfolio, client, [self.symbol])
+
+ df = self._load_closed_15m()
+ if df.empty:
+ return SymbolTickResult(symbol=self.symbol, note="no_candles")
+
+ price = self._current_price(df)
+ sym_st = self._sym_state(root_state)
+ view = single_coin_portfolio_view(portfolio, self.symbol)
+ holding = in_long_position(portfolio, self.symbol, price=price)
+
+ if not sym_st.get("initialized"):
+ last_key = str(df["datetime"].iloc[-1])[:19]
+ sym_st["initialized"] = True
+ sym_st["last_confirm_time"] = last_key
+ return SymbolTickResult(
+ symbol=self.symbol,
+ last_price=price,
+ note=f"init {last_key}",
+ )
+
+ pending = pending_baseline_bar_indices(df, sym_st.get("last_confirm_time"))
+ if not pending:
+ return SymbolTickResult(
+ symbol=self.symbol,
+ last_price=price,
+ in_position=holding,
+ note="no_new_bar",
+ )
+
+ batch = pending[: max(1, self.settings.vol_max_bars_per_tick)]
+ fills = 0
+ last_note = ""
+ trade_records: list[dict[str, Any]] = []
+
+ for bar_idx in batch:
+ bar_key = str(df["datetime"].iloc[bar_idx])[:19]
+ raw_sig = baseline_15m_signal_at(
+ df,
+ bar_idx,
+ lookback=self.settings.vol_lookback,
+ atr_mult=self.settings.vol_atr_mult,
+ )
+ sym_st["last_signal"] = int(raw_sig)
+
+ if raw_sig == 0:
+ sym_st["last_confirm_time"] = bar_key
+ last_note = f"neutral {bar_key}"
+ continue
+
+ holding = in_long_position(portfolio, self.symbol, price=price)
+ action = spot_long_action(raw_sig, holding)
+ if action is None:
+ sym_st["last_confirm_time"] = bar_key
+ last_note = f"skip_long_only sig={raw_sig} {bar_key}"
+ continue
+
+ if action == "buy" and block_entry:
+ last_note = f"buy_blocked {bar_key}"
+ return SymbolTickResult(
+ symbol=self.symbol,
+ fills=fills,
+ last_price=price,
+ note=last_note,
+ )
+
+ entry_px = float(sym_st.get("entry_price") or 0.0)
+ if action == "buy":
+ empty_slots = count_empty_buy_slots(
+ portfolio,
+ self.settings.ops_symbols,
+ min_order_krw=self.settings.ops_min_order_krw,
+ prices={self.symbol: price},
+ )
+ trade = self.executor.execute_buy(
+ view,
+ price=price,
+ empty_slots=empty_slots,
+ )
+ else:
+ trade = self.executor.execute_sell(view, price=price, sell_pct=1.0)
+
+ self._merge_portfolio_view(portfolio, view)
+ if not trade.executed:
+ last_note = f"{action}_fail {bar_key}: {trade.skip_reason}"
+ return SymbolTickResult(
+ symbol=self.symbol,
+ fills=fills,
+ last_price=price,
+ note=last_note,
+ )
+
+ fills += 1
+ sym_st["last_confirm_time"] = bar_key
+ record: dict[str, Any] = {
+ "symbol": self.symbol,
+ "side": action,
+ "ts": bar_key,
+ "price": float(trade.price),
+ "order_krw": float(trade.order_krw),
+ "order_coin": float(trade.order_coin),
+ }
+ if action == "sell" and entry_px > 0:
+ cost_krw = entry_px * float(trade.order_coin)
+ pnl_krw = float(trade.order_krw) - cost_krw
+ record["entry_price"] = entry_px
+ record["pnl_krw"] = pnl_krw
+ record["pnl_pct"] = (
+ (pnl_krw / cost_krw * 100.0) if cost_krw > 0 else 0.0
+ )
+ trade_records.append(record)
+ if action == "buy":
+ sym_st["entry_price"] = float(trade.price)
+ if self.settings.vol_exit_enabled:
+ sync_exit_state_after_open(sym_st, trade.price)
+ else:
+ sym_st["entry_price"] = 0.0
+ reset_exit_trail_state(sym_st)
+
+ if (
+ self.settings.ops_mode == "live"
+ and self.settings.ops_order_interval_sec > 0
+ ):
+ time.sleep(self.settings.ops_order_interval_sec)
+
+ last_note = f"{action}_ok {bar_key} sig={raw_sig}"
+
+ return SymbolTickResult(
+ symbol=self.symbol,
+ fills=fills,
+ last_price=price,
+ in_position=in_long_position(portfolio, self.symbol, price=price),
+ note=last_note or "done",
+ trade_records=trade_records,
+ )
+
+ def run_tick(
+ self,
+ portfolio: dict[str, Any],
+ root_state: dict[str, Any],
+ *,
+ client=None,
+ block_entry: bool = False,
+ ) -> SymbolTickResult:
+ """순수 flip: exit 비활성 시 15m 신호만. 활성 시 exit → signal."""
+ if self.settings.vol_exit_enabled:
+ exit_res = self.run_exit_tick(portfolio, root_state, client=client)
+ if exit_res.fills > 0:
+ return exit_res
+ return self.run_signal_tick(
+ portfolio,
+ root_state,
+ client=client,
+ block_entry=block_entry,
+ )
+
+ def _exit_rules(self) -> SpotLongExitRules:
+ return SpotLongExitRules(
+ trail_activate_pct=self.settings.vol_trail_activate_pct,
+ trail_pct=self.settings.vol_trail_pct,
+ trail_atr_mult=self.settings.vol_trail_atr_mult,
+ stop_pct=self.settings.vol_stop_pct,
+ )
+
+
+def load_vol_state(path: Path) -> dict[str, Any]:
+ """vol_breakout 상태 JSON."""
+ if not path.exists():
+ return {"strategy": VolBreakoutSymbolEngine.STRATEGY, "symbols": {}}
+ with path.open(encoding="utf-8") as f:
+ return json.load(f)
+
+
+def save_vol_state(path: Path, state: dict[str, Any]) -> None:
+ """vol_breakout 상태 저장."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", encoding="utf-8") as f:
+ json.dump(state, f, ensure_ascii=False, indent=2)
diff --git a/src/bithumb/operations/vol_breakout_exit.py b/src/bithumb/operations/vol_breakout_exit.py
new file mode 100644
index 0000000..3770fba
--- /dev/null
+++ b/src/bithumb/operations/vol_breakout_exit.py
@@ -0,0 +1,102 @@
+"""vol_breakout 현물 롱 intrabar 청산 — trail + stop (레버리지 1x)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Literal
+
+import numpy as np
+import pandas as pd
+
+from bithumb.simulation.vol_breakout import compute_atr
+
+ExitReason = Literal["trail", "stop"]
+
+
+@dataclass(frozen=True)
+class SpotLongExitRules:
+ """현물 롱 청산 규칙 (수익률 %, 레버리지 없음)."""
+
+ trail_activate_pct: float = 1.5
+ trail_pct: float = 0.0025
+ trail_atr_mult: float = 0.5
+ stop_pct: float = -12.0
+
+
+DEFAULT_SPOT_LONG_EXIT_RULES = SpotLongExitRules()
+
+
+def latest_atr(df15: pd.DataFrame, lookback: int) -> float:
+ """마감 15m 기준 최신 ATR."""
+ if df15.empty or len(df15) < lookback + 1:
+ return 0.0
+ c = df15["close"].to_numpy(float)
+ h = df15["high"].to_numpy(float)
+ l = df15["low"].to_numpy(float)
+ atr = compute_atr(h, l, c, lookback)
+ val = float(atr[-1])
+ return val if not np.isnan(val) else 0.0
+
+
+def long_return_pct(entry: float, mark: float) -> float:
+ """롱 수익률(%)."""
+ if entry <= 0 or mark <= 0:
+ return 0.0
+ return (mark / entry - 1.0) * 100.0
+
+
+def _trail_distance(peak: float, atr: float, rules: SpotLongExitRules) -> float:
+ pct_dist = peak * rules.trail_pct
+ atr_dist = rules.trail_atr_mult * atr if rules.trail_atr_mult > 0 and atr > 0 else 0.0
+ return max(pct_dist, atr_dist)
+
+
+def reset_exit_trail_state(sym_state: dict[str, Any]) -> None:
+ """trail 추적 초기화."""
+ sym_state["exit_trail_active"] = False
+ sym_state["exit_peak_price"] = 0.0
+
+
+def sync_exit_state_after_open(sym_state: dict[str, Any], entry_price: float) -> None:
+ """진입 직후 trail 상태."""
+ sym_state["exit_trail_active"] = False
+ sym_state["exit_peak_price"] = float(entry_price)
+
+
+def evaluate_spot_long_exit(
+ sym_state: dict[str, Any],
+ *,
+ entry: float,
+ mark: float,
+ atr: float,
+ rules: SpotLongExitRules = DEFAULT_SPOT_LONG_EXIT_RULES,
+) -> ExitReason | None:
+ """롱 보유 중 trail/stop 필요 여부."""
+ if entry <= 0 or mark <= 0:
+ reset_exit_trail_state(sym_state)
+ return None
+
+ ret = long_return_pct(entry, mark)
+ if ret <= rules.stop_pct:
+ return "stop"
+
+ trail_active = bool(sym_state.get("exit_trail_active"))
+ peak = float(sym_state.get("exit_peak_price") or entry)
+ if peak <= 0:
+ peak = entry
+
+ if not trail_active and ret >= rules.trail_activate_pct:
+ trail_active = True
+ peak = mark
+
+ if trail_active:
+ dist = _trail_distance(peak, atr, rules)
+ peak = max(peak, mark)
+ if mark <= peak - dist:
+ sym_state["exit_trail_active"] = trail_active
+ sym_state["exit_peak_price"] = peak
+ return "trail"
+
+ sym_state["exit_trail_active"] = trail_active
+ sym_state["exit_peak_price"] = peak
+ return None
diff --git a/src/bithumb/operations/vol_breakout_runner.py b/src/bithumb/operations/vol_breakout_runner.py
new file mode 100644
index 0000000..93ebc59
--- /dev/null
+++ b/src/bithumb/operations/vol_breakout_runner.py
@@ -0,0 +1,201 @@
+"""멀티 종목 vol_breakout 현물 롱 러너."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from bithumb.api.bithumb_private import BithumbPrivateClient
+from bithumb.config import Settings
+from bithumb.notifications.telegram import create_telegram_notifier
+from bithumb.operations.multi_portfolio import (
+ empty_multi_portfolio,
+ sync_multi_portfolio_from_exchange,
+)
+from bithumb.operations.ops_lock import ops_tick_lock
+from bithumb.operations.symbol_executor import SymbolExecutor
+from bithumb.operations.vol_breakout_engine import (
+ VolBreakoutSymbolEngine,
+ load_vol_state,
+ save_vol_state,
+)
+from bithumb.operations.vol_live_monitor import append_trade_record, write_vol_monitor
+
+logger = logging.getLogger(__name__)
+
+
+def _portfolio_equity_krw(
+ portfolio: dict[str, Any],
+ symbols: list[str],
+ prices: dict[str, float],
+) -> float:
+ """현금 + 보유 코인 시가 평가."""
+ total = float(portfolio.get("cash_krw") or 0.0)
+ positions = portfolio.get("positions") or {}
+ for sym in symbols:
+ qty = float((positions.get(sym) or {}).get("coin_qty") or 0.0)
+ px = float(prices.get(sym) or 0.0)
+ total += qty * px
+ return total
+
+
+class VolBreakoutRunner:
+ """TRX/NEAR/WLD 등 멀티 종목 vol_breakout tick."""
+
+ def __init__(self, settings: Settings) -> None:
+ self.settings = settings
+ self.symbols = list(settings.ops_symbols)
+ self.state = load_vol_state(settings.vol_state_json)
+ self.state.setdefault("strategy", VolBreakoutSymbolEngine.STRATEGY)
+ self.portfolio = empty_multi_portfolio(
+ self.symbols,
+ mode=settings.ops_mode,
+ )
+ self._client: BithumbPrivateClient | None = None
+ if settings.ops_mode == "live":
+ self._client = BithumbPrivateClient(
+ access_key=settings.bithumb_access_key,
+ secret_key=settings.bithumb_secret_key,
+ base_url=settings.api_url,
+ sleep_sec=settings.request_sleep_sec,
+ retries=settings.request_retries,
+ )
+ sync_multi_portfolio_from_exchange(
+ self.portfolio,
+ self._client,
+ self.symbols,
+ )
+ self.telegram = create_telegram_notifier(
+ settings.telegram_bot_token,
+ settings.telegram_chat_id,
+ enabled=settings.ops_telegram_enabled,
+ )
+ self._engines = {
+ sym: VolBreakoutSymbolEngine(
+ settings,
+ sym,
+ SymbolExecutor(settings, sym, client=self._client),
+ )
+ for sym in self.symbols
+ }
+
+ def _kill_switch_active(self) -> bool:
+ path = self.settings.vol_kill_switch_path
+ return path is not None and path.exists()
+
+ def tick(self, *, skip_lock: bool = False) -> dict[str, Any]:
+ """전 종목 1회 tick."""
+ lock_path = self.settings.vol_tick_lock_path
+ if lock_path and not skip_lock:
+ with ops_tick_lock(lock_path, blocking=False) as acquired:
+ if not acquired:
+ return {"ok": False, "note": "lock_busy"}
+ return self._tick_impl()
+ return self._tick_impl()
+
+ def _tick_impl(self) -> dict[str, Any]:
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ block_entry = self._kill_switch_active()
+ results: list[dict[str, Any]] = []
+ total_fills = 0
+
+ if self._client is not None:
+ sync_multi_portfolio_from_exchange(
+ self.portfolio,
+ self._client,
+ self.symbols,
+ )
+
+ mark_prices: dict[str, float] = {}
+ for sym, engine in self._engines.items():
+ try:
+ df = engine._load_closed_15m()
+ if not df.empty:
+ mark_prices[sym] = engine._current_price(df)
+ except Exception:
+ logger.debug("mark price snapshot failed %s", sym)
+
+ for sym in self.symbols:
+ engine = self._engines[sym]
+ try:
+ res = engine.run_tick(
+ self.portfolio,
+ self.state,
+ client=self._client,
+ block_entry=block_entry,
+ )
+ except Exception as exc:
+ logger.exception("vol tick failed %s", sym)
+ res_note = str(exc)
+ results.append({"symbol": sym, "error": res_note})
+ continue
+
+ total_fills += res.fills
+ if res.last_price > 0:
+ mark_prices[sym] = res.last_price
+ for rec in res.trade_records:
+ append_trade_record(self.state, rec)
+ if self.telegram.is_active:
+ equity = _portfolio_equity_krw(
+ self.portfolio,
+ self.symbols,
+ mark_prices,
+ )
+ reason = str(rec.get("reason") or "signal_vol_breakout")
+ self.telegram.notify_vol_breakout_trade(
+ mode=self.settings.ops_mode,
+ symbol=sym,
+ side=str(rec["side"]),
+ price=float(rec["price"]),
+ order_krw=float(rec["order_krw"]),
+ order_coin=float(rec["order_coin"]),
+ equity_krw=equity,
+ reason=reason,
+ ts=str(rec.get("ts") or now),
+ pnl_krw=(
+ float(rec["pnl_krw"])
+ if rec.get("pnl_krw") is not None
+ else None
+ ),
+ pnl_pct=(
+ float(rec["pnl_pct"])
+ if rec.get("pnl_pct") is not None
+ else None
+ ),
+ )
+ row = {
+ "symbol": sym,
+ "fills": res.fills,
+ "note": res.note,
+ "last_price": res.last_price,
+ "in_position": res.in_position,
+ }
+ results.append(row)
+
+ self.state["last_run_at"] = now
+ self.state["portfolio_snapshot"] = {
+ "cash_krw": round(float(self.portfolio.get("cash_krw") or 0), 0),
+ "positions": {
+ s: round(float(self.portfolio["positions"].get(s, {}).get("coin_qty") or 0), 8)
+ for s in self.symbols
+ },
+ }
+ save_vol_state(self.settings.vol_state_json, self.state)
+
+ tick_report = {
+ "ok": True,
+ "mode": self.settings.ops_mode,
+ "symbols": self.symbols,
+ "fills": total_fills,
+ "kill_switch": block_entry,
+ "results": results,
+ "last_run_at": now,
+ }
+ try:
+ write_vol_monitor(self.settings, self.state, tick_report=tick_report)
+ except Exception:
+ logger.exception("vol monitor refresh failed")
+
+ return tick_report
diff --git a/src/bithumb/operations/vol_live_monitor.py b/src/bithumb/operations/vol_live_monitor.py
new file mode 100644
index 0000000..379fd0e
--- /dev/null
+++ b/src/bithumb/operations/vol_live_monitor.py
@@ -0,0 +1,428 @@
+"""vol_breakout 멀티 종목 모니터 JSON/HTML (Binance vol_live_monitor 유사)."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+from bithumb.config import Settings, resolve_coin_name
+from bithumb.data.candle_loader import load_candles
+from bithumb.operations.multi_portfolio import in_long_position
+from bithumb.operations.vol_monitor_chart import write_vol_monitor_html
+from bithumb.simulation.vol_breakout import drop_incomplete_base_bar
+
+SEOUL = "Asia/Seoul"
+INTERVAL_MIN = 15
+
+
+def _epoch_kst(ts: pd.Timestamp | str) -> int:
+ """KST naive/aware → unix epoch."""
+ t = pd.Timestamp(ts)
+ if t.tzinfo is None:
+ t = t.tz_localize(SEOUL)
+ else:
+ t = t.tz_convert(SEOUL)
+ return int(t.timestamp())
+
+
+def _next_15m_close(df15: pd.DataFrame) -> tuple[str, int | None]:
+ """다음 15m 마감 시각(KST 문자열)과 남은 초."""
+ if df15.empty:
+ return "-", None
+ last_open = pd.Timestamp(df15["datetime"].iloc[-1])
+ next_close = last_open + pd.Timedelta(minutes=INTERVAL_MIN)
+ now = pd.Timestamp.now(tz=SEOUL).tz_localize(None)
+ sec = int(max((next_close - now).total_seconds(), 0))
+ return str(next_close)[:19], sec
+
+
+def _candles_payload(df: pd.DataFrame, *, days: float) -> list[dict[str, float | int]]:
+ """lightweight-charts용 OHLC."""
+ if df.empty:
+ return []
+ d = df.copy()
+ d["datetime"] = pd.to_datetime(d["datetime"])
+ cutoff = d["datetime"].max() - pd.Timedelta(days=max(days, 1))
+ d = d[d["datetime"] >= cutoff]
+ rows: list[dict[str, float | int]] = []
+ for _, row in d.iterrows():
+ rows.append({
+ "time": _epoch_kst(row["datetime"]),
+ "open": float(row["open"]),
+ "high": float(row["high"]),
+ "low": float(row["low"]),
+ "close": float(row["close"]),
+ })
+ return rows
+
+
+def _trade_markers(trades: list[dict[str, Any]], symbol: str) -> list[dict[str, Any]]:
+ """체결 마커."""
+ out: list[dict[str, Any]] = []
+ sym = symbol.upper()
+ for t in trades:
+ if str(t.get("symbol", "")).upper() != sym:
+ continue
+ side = str(t.get("side", ""))
+ ts = t.get("ts")
+ if not ts:
+ continue
+ if side == "buy":
+ out.append({
+ "time": _epoch_kst(ts),
+ "position": "belowBar",
+ "color": "#16a34a",
+ "shape": "arrowUp",
+ "text": "매수",
+ "size": 2,
+ })
+ elif side == "sell":
+ out.append({
+ "time": _epoch_kst(ts),
+ "position": "aboveBar",
+ "color": "#dc2626",
+ "shape": "arrowDown",
+ "text": "매도",
+ "size": 2,
+ })
+ return out
+
+
+def _merged_close_panel(
+ symbol_dfs: dict[str, pd.DataFrame],
+ symbols: list[str],
+ *,
+ days: float,
+) -> pd.DataFrame:
+ """종목별 15m close를 datetime 기준 병합(ffill)."""
+ panel: pd.DataFrame | None = None
+ for sym in symbols:
+ df = symbol_dfs.get(sym, pd.DataFrame())
+ if df.empty:
+ continue
+ d = df.copy()
+ d["datetime"] = pd.to_datetime(d["datetime"])
+ cutoff = d["datetime"].max() - pd.Timedelta(days=max(days, 1))
+ d = d[d["datetime"] >= cutoff][["datetime", "close"]].rename(columns={"close": sym.upper()})
+ panel = d if panel is None else panel.merge(d, on="datetime", how="outer")
+ if panel is None or panel.empty:
+ return pd.DataFrame()
+ sym_cols = [s.upper() for s in symbols if s.upper() in panel.columns]
+ panel = panel.sort_values("datetime").ffill().dropna(subset=sym_cols, how="any")
+ return panel.reset_index(drop=True)
+
+
+def _dedupe_time_series(curve: list[dict[str, float | int]]) -> list[dict[str, float | int]]:
+ """동일 time 중복 제거(마지막 값 유지)."""
+ curve.sort(key=lambda x: int(x["time"]))
+ out: list[dict[str, float | int]] = []
+ for pt in curve:
+ if out and out[-1]["time"] == pt["time"]:
+ out[-1] = pt
+ else:
+ out.append(pt)
+ return out
+
+
+def build_multi_buyhold_series(
+ panel: pd.DataFrame,
+ symbols: list[str],
+ seed_krw: float,
+) -> list[dict[str, float | int]]:
+ """윈도우 시작 시 seed를 1/N씩 나눠 매수 후 보유(B&H) 수익률(%)."""
+ if panel.empty or seed_krw <= 0:
+ return []
+ syms = [s.upper() for s in symbols]
+ first = panel.iloc[0]
+ slot = seed_krw / max(len(syms), 1)
+ qty: dict[str, float] = {}
+ for sym in syms:
+ px = float(first[sym])
+ if px <= 0:
+ return []
+ qty[sym] = slot / px
+ curve: list[dict[str, float | int]] = []
+ for _, row in panel.iterrows():
+ value = sum(qty[sym] * float(row[sym]) for sym in syms)
+ curve.append({
+ "time": _epoch_kst(row["datetime"]),
+ "value": round((value / seed_krw - 1.0) * 100.0, 4),
+ })
+ return curve
+
+
+def build_spot_strategy_equity_series(
+ panel: pd.DataFrame,
+ symbols: list[str],
+ trades: list[dict[str, Any]],
+ *,
+ seed_krw: float,
+ current_equity: float,
+ window_start: pd.Timestamp,
+) -> list[dict[str, float | int]]:
+ """체결 replay + 15m 종가 MTM 포트폴리오 수익률(%)."""
+ init = max(float(seed_krw), 1e-9)
+ start = pd.Timestamp(window_start)
+ syms = [s.upper() for s in symbols]
+ sym_set = set(syms)
+
+ sorted_trades = sorted(
+ [
+ t for t in trades
+ if t.get("ts") and str(t.get("symbol", "")).upper() in sym_set
+ ],
+ key=lambda x: str(x["ts"]),
+ )
+
+ cash = init
+ qty = {sym: 0.0 for sym in syms}
+ curve: list[dict[str, float | int]] = [{
+ "time": _epoch_kst(start),
+ "value": 0.0,
+ }]
+ trade_idx = 0
+ bar_delta = pd.Timedelta(minutes=INTERVAL_MIN)
+
+ def _portfolio_value(row: pd.Series) -> float:
+ return cash + sum(qty[sym] * float(row[sym]) for sym in syms)
+
+ for _, row in panel.iterrows():
+ bar_open = pd.Timestamp(row["datetime"])
+ if bar_open < start:
+ continue
+ bar_close = bar_open + bar_delta
+
+ while trade_idx < len(sorted_trades):
+ tr = sorted_trades[trade_idx]
+ tr_ts = pd.Timestamp(str(tr["ts"]))
+ if tr_ts > bar_close:
+ break
+ sym = str(tr["symbol"]).upper()
+ side = str(tr.get("side", ""))
+ order_krw = float(tr.get("order_krw") or 0.0)
+ order_coin = float(tr.get("order_coin") or 0.0)
+ price = float(tr.get("price") or 0.0)
+ if side == "buy" and order_krw > 0:
+ cash -= order_krw
+ qty[sym] = qty.get(sym, 0.0) + order_coin
+ elif side == "sell" and order_coin > 0:
+ proceeds = order_krw if order_krw > 0 else order_coin * price
+ cash += proceeds
+ qty[sym] = max(qty.get(sym, 0.0) - order_coin, 0.0)
+ eq = _portfolio_value(row)
+ curve.append({
+ "time": _epoch_kst(tr_ts),
+ "value": round((eq / init - 1.0) * 100.0, 4),
+ })
+ trade_idx += 1
+
+ eq = _portfolio_value(row)
+ curve.append({
+ "time": _epoch_kst(bar_open),
+ "value": round((eq / init - 1.0) * 100.0, 4),
+ })
+
+ if curve:
+ curve[-1] = {
+ "time": curve[-1]["time"],
+ "value": round((float(current_equity) / init - 1.0) * 100.0, 4),
+ }
+ return _dedupe_time_series(curve)
+
+
+def _summary_html(summary: dict[str, Any]) -> str:
+ """상단 요약 HTML."""
+ lines = [
+ f"KRW {summary.get('cash_krw', 0):,.0f}",
+ f"총평가 {summary.get('total_equity_krw', 0):,.0f}",
+ f"모드 {summary.get('mode', '-')}",
+ ]
+ for sym, row in (summary.get("symbols") or {}).items():
+ pos = "롱" if row.get("in_position") else "플랫"
+ lines.append(
+ f"{sym} {pos} · qty {row.get('coin_qty', 0):.6f} · "
+ f"sig {row.get('last_signal', 0)} · next 15m {row.get('next_15m', '-')}"
+ )
+ return " · ".join(lines)
+
+
+def build_vol_monitor_payload(
+ settings: Settings,
+ state: dict[str, Any],
+ *,
+ tick_report: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """모니터 JSON 페이로드."""
+ symbols = list(settings.ops_symbols)
+ days = float(settings.vol_monitor_days or 14)
+ sym_state = state.get("symbols") or {}
+ trades = list(state.get("trades") or [])
+ snap = state.get("portfolio_snapshot") or {}
+ cash = float(snap.get("cash_krw") or 0.0)
+
+ symbol_blocks: dict[str, Any] = {}
+ symbol_summary: dict[str, Any] = {}
+ symbol_dfs: dict[str, pd.DataFrame] = {}
+ total_equity = cash
+
+ for sym in symbols:
+ df = load_candles(settings.db_path, sym, INTERVAL_MIN, lookback_days=int(days) + 5)
+ df_closed = drop_incomplete_base_bar(df, INTERVAL_MIN) if not df.empty else df
+ symbol_dfs[sym.upper()] = df_closed
+ next_15m, sec_until = _next_15m_close(df_closed)
+ st = sym_state.get(sym.upper()) or sym_state.get(sym) or {}
+ qty = float((snap.get("positions") or {}).get(sym, 0) or 0.0)
+ price = float(df_closed["close"].iloc[-1]) if not df_closed.empty else 0.0
+ holding = in_long_position(
+ {"positions": {sym: {"coin_qty": qty}}},
+ sym,
+ dust_krw=settings.ops_min_order_krw,
+ price=price,
+ )
+ if price > 0:
+ total_equity += qty * price
+ symbol_summary[sym] = {
+ "name": resolve_coin_name(sym),
+ "in_position": holding,
+ "coin_qty": qty,
+ "last_price": price,
+ "last_signal": st.get("last_signal", 0),
+ "last_confirm_time": st.get("last_confirm_time"),
+ "next_15m": next_15m,
+ "seconds_until_15m": sec_until,
+ }
+ symbol_blocks[sym] = {
+ "candles_15m": _candles_payload(df_closed, days=days),
+ "markers": _trade_markers(trades, sym),
+ }
+
+ panel = _merged_close_panel(symbol_dfs, symbols, days=days)
+ seed_krw = max(total_equity, 1.0)
+ equity_strategy: list[dict[str, float | int]] = []
+ equity_buyhold: list[dict[str, float | int]] = []
+ if not panel.empty:
+ window_start = pd.Timestamp(panel["datetime"].iloc[0])
+ equity_strategy = build_spot_strategy_equity_series(
+ panel,
+ symbols,
+ trades,
+ seed_krw=seed_krw,
+ current_equity=total_equity,
+ window_start=window_start,
+ )
+ equity_buyhold = build_multi_buyhold_series(panel, symbols, seed_krw)
+
+ summary = {
+ "mode": settings.ops_mode,
+ "cash_krw": round(cash, 0),
+ "total_equity_krw": round(total_equity, 0),
+ "equity_seed_krw": round(seed_krw, 0),
+ "equity_start_at": str(panel["datetime"].iloc[0])[:19] if not panel.empty else None,
+ "strategy_return_pct": equity_strategy[-1]["value"] if equity_strategy else 0.0,
+ "buyhold_return_pct": equity_buyhold[-1]["value"] if equity_buyhold else 0.0,
+ "strategy": state.get("strategy", "vol_breakout_15m_spot_long"),
+ "symbols": symbol_summary,
+ }
+
+ return {
+ "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "summary": summary,
+ "summary_html": _summary_html(summary),
+ "symbols": symbol_blocks,
+ "trades": trades[-100:],
+ "last_tick": tick_report or {},
+ "ops_symbols": symbols,
+ "equity": {
+ "strategy": equity_strategy,
+ "buyhold": equity_buyhold,
+ "seed_krw": round(seed_krw, 0),
+ "label_strategy": "vol_breakout",
+ "label_buyhold": "B&H 1/3×3",
+ },
+ }
+
+
+def write_vol_monitor(
+ settings: Settings,
+ state: dict[str, Any],
+ *,
+ tick_report: dict[str, Any] | None = None,
+) -> tuple[Path, Path]:
+ """JSON + HTML 갱신."""
+ json_path = settings.vol_monitor_json
+ html_path = settings.vol_monitor_html
+ json_path.parent.mkdir(parents=True, exist_ok=True)
+ payload = build_vol_monitor_payload(settings, state, tick_report=tick_report)
+ _atomic_write_text(
+ json_path,
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
+ )
+ write_vol_monitor_html(html_path)
+ return json_path, html_path
+
+
+def _atomic_write_text(path: Path, content: str) -> None:
+ """원자적 텍스트 기록 — fetch 중 깨진 JSON 방지."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(content, encoding="utf-8")
+ tmp.replace(path)
+
+
+def append_trade_record(state: dict[str, Any], record: dict[str, Any], *, max_trades: int = 500) -> None:
+ """체결 기록 append (차트 마커용)."""
+ state.setdefault("trades", [])
+ state["trades"].append(record)
+ if len(state["trades"]) > max_trades:
+ state["trades"] = state["trades"][-max_trades:]
+
+
+def patch_vol_monitor_balance(json_path: Path, balance: dict[str, Any]) -> dict[str, Any]:
+ """기존 JSON summary 잔고만 패치."""
+ if not json_path.is_file():
+ return {"ok": False, "error": "json_missing"}
+ payload = json.loads(json_path.read_text(encoding="utf-8"))
+ summary = payload.setdefault("summary", {})
+ summary["cash_krw"] = balance.get("cash_krw", summary.get("cash_krw"))
+ positions = balance.get("positions") or {}
+ sym_map = summary.setdefault("symbols", {})
+ for sym, qty in positions.items():
+ row = sym_map.setdefault(sym, {})
+ row["coin_qty"] = float(qty)
+ payload["balance_updated_at"] = balance.get("updated_at")
+ _atomic_write_text(
+ json_path,
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
+ )
+ return {"ok": True, "cash_krw": summary.get("cash_krw"), "positions": positions}
+
+
+def fetch_live_balance_snapshot(settings: Settings) -> dict[str, Any]:
+ """거래소 KRW·코인 잔고 스냅샷."""
+ from bithumb.api.bithumb_private import BithumbPrivateClient
+
+ client = BithumbPrivateClient(
+ access_key=settings.bithumb_access_key,
+ secret_key=settings.bithumb_secret_key,
+ base_url=settings.api_url,
+ sleep_sec=settings.request_sleep_sec,
+ retries=settings.request_retries,
+ )
+ krw, _ = client.get_balance("KRW")
+ positions: dict[str, float] = {}
+ total = float(krw)
+ for sym in settings.ops_symbols:
+ qty, _ = client.get_balance(sym)
+ positions[sym] = float(qty)
+ if qty > 0:
+ pass # price optional for total
+ return {
+ "ok": True,
+ "cash_krw": round(float(krw), 0),
+ "positions": positions,
+ "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ }
diff --git a/src/bithumb/operations/vol_monitor_chart.py b/src/bithumb/operations/vol_monitor_chart.py
new file mode 100644
index 0000000..a4f8700
--- /dev/null
+++ b/src/bithumb/operations/vol_monitor_chart.py
@@ -0,0 +1,317 @@
+"""Bithumb vol_breakout 멀티 종목 라이브 모니터 HTML 셸."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+_MONITOR_HTML = """
+
+
+
+
+[Bithumb] 라이브 모니터
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+def write_vol_monitor_html(
+ out_path: Path,
+ *,
+ json_filename: str = "/api/chart",
+ refresh_ms: int = 60_000,
+ balance_poll_ms: int = 5_000,
+) -> Path:
+ """JSON fetch형 멀티 종목 모니터 HTML 기록."""
+ json_url = json.dumps(json_filename)
+ html = (
+ _MONITOR_HTML.replace("{json_url}", json_url)
+ .replace("{refresh_ms}", str(int(refresh_ms)))
+ .replace("{balance_poll_ms}", str(int(balance_poll_ms)))
+ .replace("{{", "{")
+ .replace("}}", "}")
+ )
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(html, encoding="utf-8")
+ return out_path
diff --git a/src/bithumb/operations/watch_ops.py b/src/bithumb/operations/watch_ops.py
new file mode 100644
index 0000000..6ff1ea3
--- /dev/null
+++ b/src/bithumb/operations/watch_ops.py
@@ -0,0 +1,507 @@
+"""read-only 감시 + 불일치 시 조치 (tick·loop 재시작)."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import signal
+import subprocess
+import sys
+import time
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any
+
+from bithumb.config import Settings
+from bithumb.notifications.telegram import TelegramNotifier, create_telegram_notifier
+from bithumb.operations.exchange_reconcile import (
+ _known_order_uuids,
+ _match_orders_to_signals,
+)
+from bithumb.api.bithumb_private import BithumbPrivateClient
+from bithumb.operations.ops_lock import ops_tick_lock
+from bithumb.operations.reconcile import inspect_ops_backlog
+from bithumb.operations.runner import (
+ OperationsRunner,
+ _history_index,
+ _is_settled,
+ _is_signal_api_executable,
+ _parse_signal_dt,
+)
+from bithumb.operations.state_store import load_state
+from bithumb.operations.vol_breakout_engine import load_vol_state
+
+logger = logging.getLogger(__name__)
+
+VOL_BREAKOUT_STRATEGY = "vol_breakout_15m_spot_long"
+
+
+@dataclass
+class WatchIssue:
+ """감시에서 감지한 불일치."""
+
+ kind: str
+ severity: str
+ message: str
+ signal: dict[str, Any] | None = None
+
+
+@dataclass
+class WatchReport:
+ """감시 1회 결과."""
+
+ checked_at: str
+ issues: list[WatchIssue] = field(default_factory=list)
+ ledger_pending: int = 0
+ executable_pending: int = 0
+ tick_age_sec: float | None = None
+ loop_running: bool = False
+
+
+@dataclass
+class RemediationResult:
+ """조치 결과."""
+
+ actions: list[str] = field(default_factory=list)
+ messages: list[str] = field(default_factory=list)
+ tick_report: dict[str, Any] | None = None
+
+
+def _parse_state_dt(value: str | None) -> datetime | None:
+ if not value:
+ return None
+ try:
+ return datetime.strptime(str(value), "%Y-%m-%d %H:%M:%S")
+ except ValueError:
+ return None
+
+
+def is_vol_breakout_ops(settings: Settings) -> bool:
+ """vol_breakout state가 있으면 fractal watch 대신 vol 감시."""
+ path = settings.vol_state_json
+ if path is None or not path.exists():
+ return False
+ try:
+ state = load_vol_state(path)
+ except (json.JSONDecodeError, OSError):
+ return False
+ strategy = str(state.get("strategy") or "")
+ return strategy == VOL_BREAKOUT_STRATEGY or bool(state.get("symbols"))
+
+
+def _resolve_ops_python(root: Path) -> str:
+ """watch loop 기동용 python (cron과 동일 conda 우선)."""
+ env_py = os.environ.get("BITHUMB_PYTHON", "").strip()
+ if env_py and Path(env_py).is_file():
+ return env_py
+ for candidate in (
+ Path.home() / "opt/anaconda3/envs/coin/bin/python3",
+ Path.home() / "opt/anaconda3/envs/ncue/bin/python3",
+ Path.home() / "miniconda3/envs/xavis/bin/python3",
+ ):
+ if candidate.is_file():
+ return str(candidate)
+ return sys.executable
+
+
+def inspect_vol_watch(settings: Settings) -> WatchReport:
+ """vol_breakout cron tick 감시 (fractal backlog 미사용)."""
+ now = datetime.now()
+ state = load_vol_state(settings.vol_state_json)
+ last_completed = _parse_state_dt(state.get("last_run_at"))
+ tick_age_sec = (now - last_completed).total_seconds() if last_completed else None
+
+ issues: list[WatchIssue] = []
+ stale_sec = settings.ops_watch_tick_stale_min * 60
+ if tick_age_sec is not None and tick_age_sec > stale_sec:
+ issues.append(
+ WatchIssue(
+ kind="tick_stale",
+ severity="critical",
+ message=(
+ f"vol tick {int(tick_age_sec // 60)}분 전 "
+ f"(임계 {settings.ops_watch_tick_stale_min}분)"
+ ),
+ )
+ )
+
+ return WatchReport(
+ checked_at=now.strftime("%Y-%m-%d %H:%M:%S"),
+ issues=issues,
+ ledger_pending=0,
+ executable_pending=0,
+ tick_age_sec=tick_age_sec,
+ loop_running=True,
+ )
+
+
+def remediate_vol_watch(
+ settings: Settings,
+ report: WatchReport,
+ *,
+ dry_run: bool = False,
+) -> RemediationResult:
+ """vol_breakout tick_stale 시 1회 tick (fractal loop 재시작 없음)."""
+ result = RemediationResult()
+ telegram = create_telegram_notifier(
+ settings.telegram_bot_token,
+ settings.telegram_chat_id,
+ enabled=settings.ops_telegram_enabled,
+ )
+
+ if not report.issues:
+ logger.info("vol watch OK — 조치 없음")
+ return result
+
+ issue_lines = [f"- [{i.severity}] {i.message}" for i in report.issues]
+ result.messages.extend(issue_lines)
+
+ if dry_run or not settings.ops_watch_auto_remediate:
+ _notify_watch(
+ telegram,
+ settings,
+ title="vol 불일치 (dry-run)",
+ lines=issue_lines + ["조치: 없음 (dry-run 또는 auto off)"],
+ )
+ result.actions.append("dry_run")
+ return result
+
+ needs_tick = any(i.kind == "tick_stale" for i in report.issues)
+ if needs_tick:
+ from bithumb.operations.vol_breakout_runner import VolBreakoutRunner
+
+ runner = VolBreakoutRunner(settings)
+ tick_report = runner.tick()
+ result.tick_report = tick_report
+ result.actions.append("vol_remediation_tick")
+ result.messages.append(
+ f"vol tick 완료: fills={tick_report.get('fills', 0)} "
+ f"ok={tick_report.get('ok')}"
+ )
+
+ _notify_watch(
+ telegram,
+ settings,
+ title="vol 조치 완료" if result.actions else "vol 불일치 감지",
+ lines=result.messages,
+ )
+ return result
+
+
+def _is_loop_process_alive(pid_file: Path | None) -> bool:
+ """loop PID 파일 기준 프로세스 생존 여부."""
+ if pid_file is None or not pid_file.exists():
+ return False
+ try:
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
+ except ValueError:
+ return False
+ try:
+ os.kill(pid, 0)
+ return True
+ except OSError:
+ return False
+
+
+def _stop_loop_process(pid_file: Path | None) -> bool:
+ """loop 프로세스 종료 (SIGTERM → SIGKILL)."""
+ if pid_file is None or not pid_file.exists():
+ return False
+ try:
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
+ except ValueError:
+ return False
+ for sig in (signal.SIGTERM, signal.SIGKILL):
+ try:
+ os.kill(pid, sig)
+ except ProcessLookupError:
+ return True
+ except OSError:
+ return False
+ time.sleep(3.0)
+ if not _is_loop_process_alive(pid_file):
+ return True
+ return not _is_loop_process_alive(pid_file)
+
+
+def _start_loop_process(settings: Settings) -> int | None:
+ """loop 프로세스 백그라운드 기동."""
+ cmd = settings.ops_watch_loop_start_cmd
+ if not cmd:
+ root = Path(__file__).resolve().parents[3]
+ python = _resolve_ops_python(root)
+ script = root / "scripts" / "3_run_operations.py"
+ log_path = settings.ops_log_json or root / "data/spot/operations/live_run.log"
+ cmd = (
+ f"cd {root} && export PYTHONPATH=src && "
+ f"nohup {python} {script} --mode live --loop 180 "
+ f">> {log_path} 2>&1 & echo $!"
+ )
+ proc = subprocess.run(
+ cmd,
+ shell=True,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ if proc.returncode != 0:
+ logger.error("loop 시작 실패: %s", proc.stderr[:500])
+ return None
+ tail = (proc.stdout or "").strip().splitlines()
+ if not tail:
+ return None
+ try:
+ return int(tail[-1].strip())
+ except ValueError:
+ return None
+
+
+def inspect_ops_watch(settings: Settings) -> WatchReport:
+ """최근 신호·history·tick 시각을 점검한다 (체결 없음)."""
+ now = datetime.now()
+ info = inspect_ops_backlog(settings)
+ state = info["state"]
+ merged = info["merged_pending"]
+ trade_history = state.get("trade_history") or []
+ idx = _history_index(trade_history)
+
+ last_completed = _parse_state_dt(
+ state.get("last_tick_completed_at") or state.get("last_run_at")
+ )
+ tick_age_sec = (now - last_completed).total_seconds() if last_completed else None
+
+ grace = timedelta(minutes=max(settings.ops_watch_signal_grace_min, 0))
+ max_age = settings.ops_ledger_execute_max_age_minutes
+ live_since = (
+ str(state["live_initialized_at"])
+ if settings.ops_mode == "live" and state.get("live_initialized_at")
+ else None
+ )
+
+ lookback_start = now - timedelta(minutes=max(settings.ops_watch_lookback_min, 1))
+ issues: list[WatchIssue] = []
+ executable_pending = 0
+
+ for sig in merged:
+ sig_dt = _parse_signal_dt(str(sig["datetime"]))
+ if sig_dt < lookback_start:
+ continue
+ age = now - sig_dt
+ if age < grace:
+ continue
+
+ executable, skip_reason = _is_signal_api_executable(
+ sig,
+ max_age_minutes=max_age,
+ live_since=live_since,
+ now=now,
+ )
+ key = (str(sig["datetime"]), str(sig.get("side")))
+ rec = idx.get(key)
+ trade = (rec or {}).get("trade") or {}
+
+ if not executable:
+ continue
+
+ executable_pending += 1
+ if rec is None:
+ issues.append(
+ WatchIssue(
+ kind="miss_pending",
+ severity="critical",
+ message=(
+ f"신호 미기록·미체결 {sig['datetime']} {sig['side']} "
+ f"(경과 {int(age.total_seconds() // 60)}분)"
+ ),
+ signal=sig,
+ )
+ )
+ elif not _is_settled(trade):
+ issues.append(
+ WatchIssue(
+ kind="failed_retry",
+ severity="warning",
+ message=(
+ f"체결 실패·미정산 {sig['datetime']} {sig['side']} "
+ f"사유={(trade.get('skip_reason') or '')[:60]}"
+ ),
+ signal=sig,
+ )
+ )
+
+ stale_sec = settings.ops_watch_tick_stale_min * 60
+ if tick_age_sec is not None and tick_age_sec > stale_sec:
+ issues.append(
+ WatchIssue(
+ kind="tick_stale",
+ severity="critical",
+ message=(
+ f"loop tick {int(tick_age_sec // 60)}분 전 "
+ f"(임계 {settings.ops_watch_tick_stale_min}분)"
+ ),
+ )
+ )
+
+ if settings.ops_mode == "live" and settings.ops_exchange_reconcile:
+ try:
+ client = BithumbPrivateClient(
+ access_key=settings.bithumb_access_key,
+ secret_key=settings.bithumb_secret_key,
+ base_url=settings.api_url,
+ sleep_sec=settings.request_sleep_sec,
+ retries=settings.request_retries,
+ )
+ since = now - timedelta(hours=settings.ops_exchange_order_lookback_hours)
+ orders = client.fetch_filled_orders_since(settings.market, since)
+ known = _known_order_uuids(trade_history)
+ recent_pending = [
+ s
+ for s in merged
+ if _parse_signal_dt(str(s["datetime"])) >= lookback_start
+ ]
+ matches = _match_orders_to_signals(
+ orders,
+ recent_pending,
+ match_window_min=settings.ops_exchange_match_window_min,
+ known_uuids=set(known),
+ )
+ for sig, order in matches:
+ key = (str(sig["datetime"]), str(sig.get("side")))
+ rec = idx.get(key)
+ if rec is not None and _is_settled((rec.get("trade") or {})):
+ continue
+ issues.append(
+ WatchIssue(
+ kind="exchange_unreconciled",
+ severity="warning",
+ message=(
+ f"거래소 체결·원장 불일치 {sig['datetime']} {sig['side']} "
+ f"uuid={order.get('uuid')}"
+ ),
+ signal=sig,
+ )
+ )
+ except Exception:
+ logger.exception("watch 거래소 조회 실패")
+
+ loop_running = _is_loop_process_alive(settings.ops_loop_pid_file)
+ return WatchReport(
+ checked_at=now.strftime("%Y-%m-%d %H:%M:%S"),
+ issues=issues,
+ ledger_pending=len(merged),
+ executable_pending=executable_pending,
+ tick_age_sec=tick_age_sec,
+ loop_running=loop_running,
+ )
+
+
+def _notify_watch(
+ telegram: TelegramNotifier,
+ settings: Settings,
+ *,
+ title: str,
+ lines: list[str],
+) -> None:
+ """감시·조치 알림."""
+ if not telegram.is_active:
+ return
+ mode_label = "LIVE" if settings.ops_mode == "live" else "PAPER"
+ body = "\n".join([f"[Bithumb WATCH] {title} ({mode_label})", *lines])
+ telegram.send_message(body)
+
+
+def remediate_ops_watch(
+ settings: Settings,
+ report: WatchReport,
+ *,
+ dry_run: bool = False,
+) -> RemediationResult:
+ """불일치 유형별 조치 (lock 공유 tick / loop 재시작)."""
+ result = RemediationResult()
+ telegram = create_telegram_notifier(
+ settings.telegram_bot_token,
+ settings.telegram_chat_id,
+ enabled=settings.ops_telegram_enabled,
+ )
+
+ if not report.issues:
+ logger.info("watch OK — 조치 없음")
+ return result
+
+ actionable_kinds = {
+ "miss_pending",
+ "failed_retry",
+ "exchange_unreconciled",
+ }
+ needs_tick = any(i.kind in actionable_kinds for i in report.issues)
+ needs_restart = any(i.kind == "tick_stale" for i in report.issues)
+
+ issue_lines = [f"- [{i.severity}] {i.message}" for i in report.issues]
+ result.messages.extend(issue_lines)
+
+ if dry_run or not settings.ops_watch_auto_remediate:
+ _notify_watch(
+ telegram,
+ settings,
+ title="불일치 감지 (dry-run)",
+ lines=issue_lines + ["조치: 없음 (dry-run 또는 auto off)"],
+ )
+ result.actions.append("dry_run")
+ return result
+
+ restarted = False
+ if needs_restart and settings.ops_watch_auto_restart and not is_vol_breakout_ops(settings):
+ if report.loop_running and settings.ops_loop_pid_file:
+ stopped = _stop_loop_process(settings.ops_loop_pid_file)
+ result.actions.append("stop_loop" if stopped else "stop_loop_failed")
+ pid = _start_loop_process(settings)
+ if pid and settings.ops_loop_pid_file:
+ settings.ops_loop_pid_file.parent.mkdir(parents=True, exist_ok=True)
+ settings.ops_loop_pid_file.write_text(f"{pid}\n", encoding="utf-8")
+ restarted = True
+ result.actions.append(f"start_loop pid={pid}")
+ result.messages.append(f"loop 재시작 pid={pid}")
+
+ if needs_tick and settings.ops_tick_lock_path:
+ with ops_tick_lock(settings.ops_tick_lock_path, blocking=False) as acquired:
+ if not acquired:
+ result.actions.append("tick_skipped_lock_busy")
+ result.messages.append("tick lock busy — loop 처리 중, 다음 주기 재확인")
+ if not restarted:
+ _notify_watch(
+ telegram,
+ settings,
+ title="불일치 (lock busy)",
+ lines=result.messages,
+ )
+ return result
+ runner = OperationsRunner(settings)
+ tick_report = runner.tick(sync_candles=True, skip_lock=True)
+ result.tick_report = tick_report
+ result.actions.append("remediation_tick")
+ exec_count = len(tick_report.get("executions") or [])
+ reconciled = tick_report.get("exchange_reconciled_count", 0)
+ result.messages.append(
+ f"조치 tick 완료: 체결 {exec_count}건, exchange_reconcile {reconciled}건"
+ )
+ for rec in tick_report.get("executions") or []:
+ t = rec.get("trade") or {}
+ if t.get("executed"):
+ result.messages.append(
+ f" 체결: {rec.get('datetime')} {rec.get('side')}"
+ )
+ elif t.get("expected_skip"):
+ result.messages.append(
+ f" skip: {rec.get('datetime')} {rec.get('side')} "
+ f"{(t.get('skip_reason') or '')[:50]}"
+ )
+ elif needs_tick:
+ runner = OperationsRunner(settings)
+ result.tick_report = runner.tick(sync_candles=True)
+ result.actions.append("remediation_tick")
+
+ title = "조치 완료" if result.actions else "불일치 감지"
+ _notify_watch(telegram, settings, title=title, lines=result.messages)
+ return result
diff --git a/src/bithumb/simulation/vol_breakout.py b/src/bithumb/simulation/vol_breakout.py
new file mode 100644
index 0000000..1d21fa2
--- /dev/null
+++ b/src/bithumb/simulation/vol_breakout.py
@@ -0,0 +1,96 @@
+"""15m ATR vol_breakout — Binance causal_hunt 규칙 현물용 이식."""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+
+def compute_atr(
+ high: np.ndarray,
+ low: np.ndarray,
+ close: np.ndarray,
+ lookback: int,
+) -> np.ndarray:
+ """TR 롤링 평균 ATR."""
+ tr = np.maximum(
+ high - low,
+ np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1))),
+ )
+ tr[0] = high[0] - low[0]
+ return pd.Series(tr).rolling(lookback, min_periods=lookback).mean().to_numpy()
+
+
+def floor_period_ts(ts: pd.Timestamp, interval_min: int) -> pd.Timestamp:
+ """봉 시작 시각."""
+ ts = pd.Timestamp(ts)
+ minute = (ts.minute // interval_min) * interval_min
+ return ts.replace(minute=minute, second=0, microsecond=0, nanosecond=0)
+
+
+def drop_incomplete_base_bar(df: pd.DataFrame, interval_min: int) -> pd.DataFrame:
+ """미마감 마지막 봉 제거."""
+ if df.empty:
+ return df
+ out = df.copy()
+ out["datetime"] = pd.to_datetime(out["datetime"])
+ last_start = floor_period_ts(out["datetime"].iloc[-1], interval_min)
+ last_end = last_start + pd.Timedelta(minutes=interval_min)
+ now = out["datetime"].iloc[-1]
+ if pd.Timestamp(now) < last_end:
+ out = out[out["datetime"] < last_start].reset_index(drop=True)
+ return out
+
+
+def baseline_15m_signal_at(
+ df15_closed: pd.DataFrame,
+ bar_idx: int,
+ *,
+ lookback: int,
+ atr_mult: float,
+) -> int:
+ """지정 마감 15m 봉 vol_breakout 신호 (-1/0/+1)."""
+ if bar_idx < lookback + 1 or bar_idx >= len(df15_closed):
+ return 0
+ c = df15_closed["close"].to_numpy(float)
+ h = df15_closed["high"].to_numpy(float)
+ l = df15_closed["low"].to_numpy(float)
+ atr = compute_atr(h, l, c, lookback)
+ i = bar_idx
+ if np.isnan(atr[i - 1]):
+ return 0
+ ref = c[i - 1]
+ thr = atr[i - 1] * atr_mult
+ if c[i] > ref + thr:
+ return 1
+ if c[i] < ref - thr:
+ return -1
+ return 0
+
+
+def pending_baseline_bar_indices(
+ df15_closed: pd.DataFrame,
+ last_confirm_time: str | None,
+) -> list[int]:
+ """last_confirm_time 이후 미처리 마감 15m 봉 인덱스."""
+ if df15_closed.empty:
+ return []
+ dts = pd.to_datetime(df15_closed["datetime"])
+ if not last_confirm_time:
+ return [len(dts) - 1]
+ cutoff = pd.Timestamp(str(last_confirm_time)[:19])
+ return [i for i, t in enumerate(dts) if pd.Timestamp(t) > cutoff]
+
+
+def spot_long_action(raw_sig: int, in_position: bool) -> str | None:
+ """선물 flip 신호를 현물 롱 전용 buy/sell로 변환.
+
+ +1: flat → buy
+ -1: long → sell (청산)
+ 그 외: None
+ """
+ if raw_sig == 1 and not in_position:
+ return "buy"
+ if raw_sig == -1 and in_position:
+ return "sell"
+ return None
diff --git a/tests/test_candle_bars.py b/tests/test_candle_bars.py
new file mode 100644
index 0000000..758bae3
--- /dev/null
+++ b/tests/test_candle_bars.py
@@ -0,0 +1,73 @@
+"""캔들 봉 마감·다운로드 필터."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from bithumb.data.candle_bars import (
+ is_closed_candle,
+ last_closed_bar_open,
+ only_closed_candles,
+)
+
+
+def test_is_closed_candle_15m() -> None:
+ """15분봉은 마감 시각 이후에만 closed."""
+ bar_open = datetime(2026, 6, 27, 16, 15, 0)
+ assert not is_closed_candle(
+ bar_open, 15, now=datetime(2026, 6, 27, 16, 29, 59)
+ )
+ assert is_closed_candle(
+ bar_open, 15, now=datetime(2026, 6, 27, 16, 30, 0)
+ )
+
+
+def test_only_closed_candles_filters_forming_bar() -> None:
+ """API 배치에서 진행 중 봉은 제외."""
+ rows = [
+ ("2026-06-27 16:00:00", 100.0, 110.0, 95.0, 105.0, 1.0),
+ ("2026-06-27 16:15:00", 105.0, 105.0, 105.0, 105.0, 0.1),
+ ]
+ now = datetime(2026, 6, 27, 16, 20, 0)
+ closed = only_closed_candles(rows, 15, now=now)
+ assert len(closed) == 1
+ assert closed[0][0] == "2026-06-27 16:00:00"
+
+
+def test_last_closed_bar_open() -> None:
+ """진행 중인 16:15~16:30 봉 기준 최근 마감은 16:00."""
+ now = datetime(2026, 6, 27, 16, 20, 0)
+ assert last_closed_bar_open(now, 15) == datetime(2026, 6, 27, 16, 0, 0)
+
+ now = datetime(2026, 6, 27, 16, 30, 0)
+ assert last_closed_bar_open(now, 15) == datetime(2026, 6, 27, 16, 15, 0)
+
+
+def test_only_closed_candles_keeps_all_historical() -> None:
+ """과거 봉은 모두 마감으로 간주."""
+ rows = [
+ ("2026-06-26 20:00:00", 1.0, 2.0, 0.5, 1.5, 10.0),
+ ("2026-06-26 20:15:00", 1.5, 2.5, 1.0, 2.0, 8.0),
+ ]
+ now = datetime(2026, 6, 27, 22, 0, 0)
+ assert len(only_closed_candles(rows, 15, now=now)) == 2
+
+
+def test_delete_incomplete_tail(tmp_path) -> None:
+ """미마감 최신 봉이 DB에서 제거된다."""
+ from bithumb.data.candle_store import CandleStore
+
+ db = tmp_path / "t.db"
+ store = CandleStore(db)
+ rows = [
+ ("2026-06-27 16:00:00", 100.0, 110.0, 95.0, 105.0, 1.0),
+ ("2026-06-27 16:15:00", 105.0, 105.0, 105.0, 105.0, 0.1),
+ ]
+ store.upsert_rows("NEAR", "NEAR", 15, rows)
+ deleted = store.delete_incomplete_tail(
+ "NEAR", 15, now=datetime(2026, 6, 27, 16, 20, 0)
+ )
+ assert deleted == 1
+ _, _, db_max = store.get_range("NEAR", 15)
+ assert db_max == datetime(2026, 6, 27, 16, 0, 0)
+ store.close()
diff --git a/tests/test_exchange_reconcile.py b/tests/test_exchange_reconcile.py
new file mode 100644
index 0000000..185223f
--- /dev/null
+++ b/tests/test_exchange_reconcile.py
@@ -0,0 +1,77 @@
+"""exchange reconcile 단위 테스트."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from bithumb.operations.exchange_reconcile import (
+ _match_orders_to_signals,
+ _trade_from_exchange_order,
+)
+
+
+def test_match_orders_to_signals_by_side_and_time() -> None:
+ """같은 side·시간 창 안에서 주문-신호 1:1 매칭."""
+ sig_dt = datetime(2026, 6, 14, 10, 48, 0)
+ signals = [
+ {"datetime": sig_dt.strftime("%Y-%m-%d %H:%M:%S"), "side": "buy", "bar_index": 1},
+ ]
+ order_dt = sig_dt + timedelta(minutes=30)
+ orders = [
+ {
+ "uuid": "order-1",
+ "side": "bid",
+ "created_at": order_dt.isoformat(),
+ "executed_volume": "0.001",
+ "executed_funds": "100000",
+ },
+ ]
+ matches = _match_orders_to_signals(
+ orders,
+ signals,
+ match_window_min=720,
+ known_uuids=set(),
+ )
+ assert len(matches) == 1
+ assert matches[0][0]["side"] == "buy"
+ assert matches[0][1]["uuid"] == "order-1"
+
+
+def test_match_skips_known_uuid() -> None:
+ """이미 history에 있는 uuid는 재매칭하지 않는다."""
+ sig_dt = datetime(2026, 6, 14, 11, 0, 0)
+ signals = [
+ {"datetime": sig_dt.strftime("%Y-%m-%d %H:%M:%S"), "side": "sell", "bar_index": 2},
+ ]
+ orders = [
+ {
+ "uuid": "already-used",
+ "side": "ask",
+ "created_at": (sig_dt + timedelta(minutes=5)).isoformat(),
+ "executed_volume": "0.001",
+ "executed_funds": "100000",
+ },
+ ]
+ matches = _match_orders_to_signals(
+ orders,
+ signals,
+ match_window_min=720,
+ known_uuids={"already-used"},
+ )
+ assert matches == []
+
+
+def test_trade_from_exchange_order_buy() -> None:
+ """매수 체결 → executed TradeResult."""
+ sig = {"datetime": "2026-06-14 10:48:00", "side": "buy", "price": 140_000_000.0}
+ order = {
+ "uuid": "x",
+ "side": "bid",
+ "executed_volume": "0.002",
+ "executed_funds": "280000",
+ }
+ trade = _trade_from_exchange_order(sig, order)
+ assert trade.executed is True
+ assert trade.side == "buy"
+ assert trade.order_coin == 0.002
+ assert trade.api_response == order
diff --git a/tests/test_ops_ledger.py b/tests/test_ops_ledger.py
new file mode 100644
index 0000000..90edbe3
--- /dev/null
+++ b/tests/test_ops_ledger.py
@@ -0,0 +1,143 @@
+"""ledger pending 단위 테스트."""
+
+from __future__ import annotations
+
+from bithumb.operations.runner import (
+ _advance_cursor_from_ledger,
+ _apply_backlog_limit,
+ _is_settled,
+ _is_signal_api_executable,
+ _ledger_pending_signals,
+ _merge_pending_signals,
+ _settle_expired_backlog,
+)
+from datetime import datetime
+
+
+def test_is_settled_executed_or_expected_skip() -> None:
+ """executed 또는 expected_skip이면 settled."""
+ assert _is_settled({"executed": True, "expected_skip": False})
+ assert _is_settled({"executed": False, "expected_skip": True})
+ assert not _is_settled({"executed": False, "expected_skip": False})
+
+
+def test_ledger_pending_skips_settled_and_includes_missing() -> None:
+ """history settled 신호는 제외, 미기록 신호는 pending."""
+ kept = [
+ {"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 10},
+ {"datetime": "2026-06-14 14:15:00", "side": "sell", "bar_index": 11},
+ {"datetime": "2026-06-14 14:51:00", "side": "sell", "bar_index": 12},
+ ]
+ history = [
+ {
+ "datetime": "2026-06-14 14:09:00",
+ "side": "buy",
+ "trade": {"executed": True, "expected_skip": False},
+ },
+ {
+ "datetime": "2026-06-14 14:51:00",
+ "side": "sell",
+ "trade": {"executed": False, "expected_skip": True},
+ },
+ ]
+ pending = _ledger_pending_signals(
+ kept,
+ history,
+ latest_bar_index=12,
+ lookback_days=3,
+ )
+ assert len(pending) == 1
+ assert pending[0]["datetime"] == "2026-06-14 14:15:00"
+
+
+def test_ledger_pending_includes_failed_api() -> None:
+ """API 실패(expected_skip false)는 재시도 대상."""
+ kept = [
+ {"datetime": "2026-06-14 15:00:00", "side": "buy", "bar_index": 20},
+ ]
+ history = [
+ {
+ "datetime": "2026-06-14 15:00:00",
+ "side": "buy",
+ "trade": {"executed": False, "expected_skip": False},
+ },
+ ]
+ pending = _ledger_pending_signals(
+ kept,
+ history,
+ latest_bar_index=20,
+ lookback_days=3,
+ )
+ assert len(pending) == 1
+
+
+def test_apply_backlog_limit() -> None:
+ """tick당 backlog 상한."""
+ signals = [
+ {"datetime": f"2026-06-14 10:{i:02d}:00", "side": "buy", "bar_index": i}
+ for i in range(5)
+ ]
+ limited, dropped = _apply_backlog_limit(signals, 2)
+ assert len(limited) == 2
+ assert dropped == 3
+
+
+def test_advance_cursor_from_ledger() -> None:
+ """settled 신호까지만 커서 전진."""
+ kept = [
+ {"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 10},
+ {"datetime": "2026-06-14 14:15:00", "side": "sell", "bar_index": 11},
+ ]
+ history = [
+ {
+ "datetime": "2026-06-14 14:09:00",
+ "side": "buy",
+ "trade": {"executed": True},
+ },
+ ]
+ state: dict = {
+ "last_processed_datetime": "2026-06-14 13:00:00",
+ "last_processed_bar_index": 5,
+ }
+ _advance_cursor_from_ledger(state, kept, history)
+ assert state["last_processed_datetime"] == "2026-06-14 14:09:00"
+ assert state["last_processed_bar_index"] == 10
+
+
+def test_settle_expired_backlog() -> None:
+ """만료 backlog는 API 없이 expected_skip 정산."""
+ from datetime import timedelta
+
+ now = datetime.now()
+ old_dt = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
+ kept = [{"datetime": old_dt, "side": "buy", "bar_index": 1, "price": 1.0}]
+ settled = _settle_expired_backlog(
+ kept,
+ [],
+ max_age_minutes=45,
+ live_since=None,
+ )
+ assert len(settled) == 1
+ assert settled[0]["trade"]["expected_skip"] is True
+ assert "backlog 만료" in settled[0]["trade"]["skip_reason"]
+
+
+def test_is_signal_api_executable_recent() -> None:
+ """최근 신호는 API 체결 가능."""
+ from datetime import timedelta
+
+ recent = (datetime.now() - timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
+ ok, _ = _is_signal_api_executable(
+ {"datetime": recent, "side": "buy"},
+ max_age_minutes=45,
+ live_since=None,
+ )
+ assert ok
+
+
+def test_merge_pending_dedupes() -> None:
+ """ledger·catchup 병합 시 datetime·side 중복 제거."""
+ a = [{"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 1}]
+ b = [{"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 1}]
+ merged = _merge_pending_signals(a, b)
+ assert len(merged) == 1
diff --git a/tests/test_vol_breakout.py b/tests/test_vol_breakout.py
new file mode 100644
index 0000000..8b61fbf
--- /dev/null
+++ b/tests/test_vol_breakout.py
@@ -0,0 +1,46 @@
+"""vol_breakout 현물 롱 단위 테스트."""
+
+from __future__ import annotations
+
+import pandas as pd
+
+from bithumb.operations.multi_portfolio import count_empty_buy_slots, empty_multi_portfolio
+from bithumb.simulation.vol_breakout import (
+ baseline_15m_signal_at,
+ spot_long_action,
+)
+
+
+def test_spot_long_action_buy_only_when_flat() -> None:
+ assert spot_long_action(1, False) == "buy"
+ assert spot_long_action(1, True) is None
+
+
+def test_spot_long_action_sell_only_when_long() -> None:
+ assert spot_long_action(-1, True) == "sell"
+ assert spot_long_action(-1, False) is None
+
+
+def test_baseline_signal_breakout() -> None:
+ n = 30
+ closes = [100.0] * n
+ closes[-1] = 120.0
+ df = pd.DataFrame({
+ "datetime": pd.date_range("2026-01-01", periods=n, freq="15min"),
+ "open": closes,
+ "high": [c + 1 for c in closes],
+ "low": [c - 1 for c in closes],
+ "close": closes,
+ "volume": [1.0] * n,
+ })
+ sig = baseline_15m_signal_at(df, n - 1, lookback=5, atr_mult=0.01)
+ assert sig == 1
+
+
+def test_empty_buy_slots_dynamic_split() -> None:
+ pf = empty_multi_portfolio(["TRX", "NEAR", "WLD"], cash_krw=900_000)
+ assert count_empty_buy_slots(pf, ["TRX", "NEAR", "WLD"]) == 3
+ pf["positions"]["TRX"]["coin_qty"] = 100.0
+ assert count_empty_buy_slots(pf, ["TRX", "NEAR", "WLD"]) == 2
+ pf["positions"]["NEAR"]["coin_qty"] = 10.0
+ assert count_empty_buy_slots(pf, ["TRX", "NEAR", "WLD"]) == 1
diff --git a/tests/test_vol_breakout_telegram.py b/tests/test_vol_breakout_telegram.py
new file mode 100644
index 0000000..d6504ff
--- /dev/null
+++ b/tests/test_vol_breakout_telegram.py
@@ -0,0 +1,66 @@
+"""vol_breakout 텔레그램 알림 포맷."""
+
+from bithumb.notifications.telegram import TelegramNotifier
+
+
+def test_notify_vol_breakout_buy_message(monkeypatch) -> None:
+ """매수 알림이 Binance 스타일 형식인지 확인."""
+ sent: list[str] = []
+
+ def _capture(text: str) -> bool:
+ sent.append(text)
+ return True
+
+ n = TelegramNotifier("token", "123", enabled=True)
+ monkeypatch.setattr(n, "send_message", _capture)
+
+ n.notify_vol_breakout_trade(
+ mode="live",
+ symbol="TRX",
+ side="buy",
+ price=489.24,
+ order_krw=1_187_000,
+ order_coin=2.426929,
+ equity_krw=500_000,
+ ts="2026-06-27 19:30:05",
+ )
+
+ assert len(sent) == 1
+ text = sent[0]
+ assert "[실거래] 롱 진입(매수)" in text
+ assert "TRXKRW @ 489.24" in text
+ assert "수량 2.426929" in text
+ assert "≈1,187,000원" in text
+ assert "사유 signal_vol_breakout" in text
+ assert "시각 2026-06-27 19:30:05" in text
+
+
+def test_notify_vol_breakout_sell_message(monkeypatch) -> None:
+ """매도 알림에 손익·자본이 포함되는지 확인."""
+ sent: list[str] = []
+
+ def _capture(text: str) -> bool:
+ sent.append(text)
+ return True
+
+ n = TelegramNotifier("token", "123", enabled=True)
+ monkeypatch.setattr(n, "send_message", _capture)
+
+ n.notify_vol_breakout_trade(
+ mode="live",
+ symbol="TRX",
+ side="sell",
+ price=486.76,
+ order_krw=1_180_000,
+ order_coin=2.426929,
+ equity_krw=499_691,
+ pnl_krw=-7_000,
+ pnl_pct=-0.590,
+ ts="2026-06-27 19:45:05",
+ )
+
+ text = sent[0]
+ assert "[실거래] 롱 청산(매도)" in text
+ assert "TRXKRW @ 486.76" in text
+ assert "손익 -7,000원 (-0.590%)" in text
+ assert "자본 499,691원" in text
diff --git a/tests/test_vol_live_monitor.py b/tests/test_vol_live_monitor.py
new file mode 100644
index 0000000..ea3cdda
--- /dev/null
+++ b/tests/test_vol_live_monitor.py
@@ -0,0 +1,78 @@
+"""vol_live 모니터 에쿼티·B&H 테스트."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pandas as pd
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+
+from bithumb.operations.vol_live_monitor import (
+ build_multi_buyhold_series,
+ build_spot_strategy_equity_series,
+)
+
+
+def test_multi_buyhold_thirds() -> None:
+ """3종목 1/3씩 B&H — 한 종목만 10% 상승 시 포트폴리오 +3.33% 근사."""
+ panel = pd.DataFrame({
+ "datetime": pd.to_datetime(["2026-06-01 10:00:00", "2026-06-01 10:15:00"]),
+ "TRX": [100.0, 110.0],
+ "NEAR": [200.0, 200.0],
+ "WLD": [300.0, 300.0],
+ })
+ bh = build_multi_buyhold_series(panel, ["TRX", "NEAR", "WLD"], seed_krw=300_000.0)
+ assert bh[0]["value"] == 0.0
+ assert bh[1]["value"] == pytest.approx(3.3333, rel=1e-3)
+
+
+def test_strategy_replay_buy_sell() -> None:
+ panel = pd.DataFrame({
+ "datetime": pd.to_datetime(["2026-06-01 10:00:00", "2026-06-01 10:15:00"]),
+ "TRX": [100.0, 110.0],
+ "NEAR": [200.0, 200.0],
+ "WLD": [300.0, 300.0],
+ })
+ trades = [
+ {
+ "symbol": "TRX",
+ "side": "buy",
+ "ts": "2026-06-01 10:05:00",
+ "order_krw": 100_000.0,
+ "order_coin": 1000.0,
+ "price": 100.0,
+ },
+ ]
+ curve = build_spot_strategy_equity_series(
+ panel,
+ ["TRX", "NEAR", "WLD"],
+ trades,
+ seed_krw=300_000.0,
+ current_equity=310_000.0,
+ window_start=pd.Timestamp("2026-06-01 10:00:00"),
+ )
+ assert curve[0]["value"] == 0.0
+ assert curve[-1]["value"] == pytest.approx(3.3333, rel=1e-2)
+
+
+def test_write_vol_monitor_html_no_format_error(tmp_path: Path) -> None:
+ import re
+
+ from bithumb.operations.vol_monitor_chart import write_vol_monitor_html
+
+ out = tmp_path / "vol_live_monitor.html"
+ write_vol_monitor_html(out)
+ text = out.read_text(encoding="utf-8")
+ assert "/api/chart" in text
+ assert "equityChart" in text
+ js = re.search(r"", text, re.S)
+ assert js is not None
+ js_path = tmp_path / "monitor.js"
+ js_path.write_text(js.group(1), encoding="utf-8")
+ import subprocess
+
+ subprocess.run(["node", "--check", str(js_path)], check=True, capture_output=True)
diff --git a/tests/test_watch_ops.py b/tests/test_watch_ops.py
new file mode 100644
index 0000000..38d7145
--- /dev/null
+++ b/tests/test_watch_ops.py
@@ -0,0 +1,42 @@
+"""watch_ops 단위 테스트."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from bithumb.operations.watch_ops import WatchIssue, WatchReport, remediate_ops_watch
+
+
+class _FakeSettings:
+ ops_mode = "live"
+ ops_telegram_enabled = False
+ telegram_bot_token = ""
+ telegram_chat_id = ""
+ ops_watch_auto_remediate = True
+ ops_watch_auto_restart = False
+ ops_tick_lock_path = None
+ ops_loop_pid_file = None
+
+
+def test_remediate_dry_run_no_actions() -> None:
+ """dry-run은 tick/재시작 없음."""
+ report = WatchReport(
+ checked_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ issues=[
+ WatchIssue(
+ kind="miss_pending",
+ severity="critical",
+ message="test",
+ )
+ ],
+ )
+ result = remediate_ops_watch(_FakeSettings(), report, dry_run=True)
+ assert "dry_run" in result.actions
+ assert result.tick_report is None
+
+
+def test_remediate_ok_when_no_issues() -> None:
+ """이슈 없으면 조치 없음."""
+ report = WatchReport(checked_at="2026-06-14 20:00:00")
+ result = remediate_ops_watch(_FakeSettings(), report, dry_run=False)
+ assert result.actions == []
diff --git a/tests/test_watch_vol_ops.py b/tests/test_watch_vol_ops.py
new file mode 100644
index 0000000..6def450
--- /dev/null
+++ b/tests/test_watch_vol_ops.py
@@ -0,0 +1,72 @@
+"""watch_ops vol_breakout 분기."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+from types import SimpleNamespace
+
+from bithumb.operations.watch_ops import (
+ WatchIssue,
+ WatchReport,
+ inspect_vol_watch,
+ is_vol_breakout_ops,
+ remediate_vol_watch,
+)
+
+
+def test_is_vol_breakout_ops(tmp_path: Path) -> None:
+ """vol state 파일이 있으면 vol 감시 모드."""
+ state_path = tmp_path / "vol_breakout_state.json"
+ state_path.write_text(
+ json.dumps({"strategy": "vol_breakout_15m_spot_long", "symbols": {}}),
+ encoding="utf-8",
+ )
+ settings = SimpleNamespace(vol_state_json=state_path)
+ assert is_vol_breakout_ops(settings) is True
+
+
+def test_is_vol_breakout_ops_missing_file(tmp_path: Path) -> None:
+ """state 없으면 fractal 감시."""
+ settings = SimpleNamespace(vol_state_json=tmp_path / "missing.json")
+ assert is_vol_breakout_ops(settings) is False
+
+
+def test_inspect_vol_watch_recent_tick(tmp_path: Path) -> None:
+ """최근 tick이면 이슈 없음."""
+ state_path = tmp_path / "vol.json"
+ state_path.write_text(
+ json.dumps({
+ "strategy": "vol_breakout_15m_spot_long",
+ "symbols": {},
+ "last_run_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ }),
+ encoding="utf-8",
+ )
+ settings = SimpleNamespace(
+ vol_state_json=state_path,
+ ops_watch_tick_stale_min=12,
+ )
+ report = inspect_vol_watch(settings)
+ assert report.issues == []
+
+
+def test_remediate_vol_dry_run() -> None:
+ """vol dry-run은 tick 없음."""
+ report = WatchReport(
+ checked_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ issues=[
+ WatchIssue(kind="tick_stale", severity="critical", message="stale"),
+ ],
+ )
+ settings = SimpleNamespace(
+ ops_telegram_enabled=False,
+ telegram_bot_token="",
+ telegram_chat_id="",
+ ops_watch_auto_remediate=True,
+ ops_mode="live",
+ )
+ result = remediate_vol_watch(settings, report, dry_run=True)
+ assert "dry_run" in result.actions
+ assert result.tick_report is None