feat(vol_breakout): 15m 현물 롱 라이브·모니터·cron 운영 추가

vol_breakout 멀티종목 tick, vol_live HTML 모니터, 마감 봉만 저장하는 캔들 다운로드,
텔레그램 체결 알림, cron/watch 감시 스크립트 및 테스트를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-06-29 08:31:14 +09:00
parent 8413bbd536
commit 72de8d534e
58 changed files with 6008 additions and 1286 deletions

View File

@@ -17,6 +17,8 @@ COIN_TELEGRAM_CHAT_ID=
# --- 거래 대상 --- # --- 거래 대상 ---
SYMBOL=BTC SYMBOL=BTC
COIN_NAME=비트코인 COIN_NAME=비트코인
# 캔들 수집 대상 (쉼표 구분). 비우면 SYMBOL만 수집
DOWNLOAD_SYMBOLS=TRX,NEAR,WLD
# --- 공통: 캔들 DB (현물·선물 공유) --- # --- 공통: 캔들 DB (현물·선물 공유) ---
DB_PATH=data/common/coins.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_TICK_STALE_MIN=12
OPS_WATCH_LOOKBACK_MIN=30 OPS_WATCH_LOOKBACK_MIN=30
OPS_WATCH_AUTO_REMEDIATE=true 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_WATCH_LOOP_START_CMD=
# OPS_PERSIST_SIGNAL_CACHE=false # OPS_PERSIST_SIGNAL_CACHE=false
OPS_STATE_JSON=data/spot/operations/fractal_ops_state.json 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 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 # 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} # 폴더 구조: data|docs / {common, spot}
# common — coins.db 등 공유 리소스 # common — coins.db 등 공유 리소스
# spot — 현물 GT·기법·분석·운영 # spot — 현물 GT·기법·분석·운영

781
README.md
View File

@@ -1,262 +1,459 @@
# Bithumb # Bithumb
빗썸 KRW 마켓 암호화폐 캔들 수집 및 **현물** 매매 전략 파이프라인. 빗썸 KRW 마켓 암호화폐 캔들 수집 및 **현물(spot)** 매매 전략 파이프라인.
- **기본 축:** 3분봉 현물 BTC, 최근 **10년** 캔들 (`DOWNLOAD_DAYS=3650`) - **기본 축:** 3분봉 BTC, 최근 **10년** 캔들 (`DOWNLOAD_DAYS=3650`)
- **데이터·문서 분류:** `common` (공유) · `spot` (현물) - **데이터·문서:** `data/common` (공유 DB) · `data/spot` / `docs/spot` (현물)
- **현재 운영 전략:** `fractal_swing` + MTF off — paper/live tick 운영 구현 완료 - **현재 live 운영:** `fractal_swing`, MTF off, ledger + exchange reconcile + 5분 watch
## 주요 기능
- 빗썸 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
```
--- ---
## 설계 개요 ## 목차
### 파이프라인 단계 1. [파이프라인 개요](#파이프라인-개요)
2. [단계별 요약 (0~3단계)](#단계별-요약-03단계)
3. [수익률 정리](#수익률-정리)
4. [3단계 live 운영](#3단계-live-운영)
5. [설치·실행](#설치실행)
6. [폴더 구조](#폴더-구조)
7. [환경 변수](#환경-변수)
8. [소스 모듈](#소스-모듈)
9. [39종 인과 기법](#39종-인과-기법)
10. [변경 이력](#변경-이력)
---
## 파이프라인 개요
```mermaid
flowchart LR
A[common<br/>캔들 수집] --> 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 0단계** | GT v3 사후 최적 타점 (정답지) | 사용 (연구용) | 불가 |
| **spot 1단계** | GT 타점 완벽 추종 sim 상한선 | GT 자체가 사후 | 불가 | | **spot 1단계** | GT 타점 완벽 추종 sim 상한선 | GT 자체가 사후 | 불가 |
| **spot 2단계** | 39종 인과 기법 평가·MTF 규칙 | 미사용 | 불가 | | **spot 2단계** | 39종 인과 기법 평가·MTF 규칙 | 미사용 | 불가 |
| **spot 3단계** | paper/live tick 운영 | 미사용 | **가능** | | **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 ```mermaid
flowchart TD flowchart TD
subgraph tick["3_run_operations.py tick (권장 180초)"] subgraph loop["3_run_operations.py --loop 180"]
A[sync_ops_candles<br/>전 TF 증분 INSERT] --> B[generate_raw_signals<br/>캐시 + tail 800봉 갱신] A[sync_ops_candles] --> B[generate_raw_signals<br/>force_tail_refresh]
B --> C[filter_signals_for_ops<br/>MTF·TrendGate 선택] B --> C[filter_signals_for_ops]
C --> D[OperationsRunner<br/>bar 단위 클러스터 체결] C --> D[exchange reconcile]
D --> E{paper / live} D --> E[stale backlog 정산]
E -->|paper| F[PaperExecutor<br/>모델 슬리피지 체결] E --> F[ledger pending 체결]
E -->|live| G[LiveExecutor<br/>빗썸 시장가] F --> G[state.json + report]
F --> H[TelegramNotifier<br/>체결 알림]
G --> H
H --> I[state.json + ops_report.json]
end end
J[3_run_filtered_backtest.py] --> K[simulate_gt_signals_pnl<br/>동일 체결 규칙 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` | ### live 시작
|------|---------------------|-----------|-------------|
| **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` 주석 참고 |
상세 해석: [`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 ```text
Bithumb/ bithumb/
├── src/bithumb/ ├── src/bithumb/
│ ├── api/ # 빗썸 Public·Private REST │ ├── api/ # Public·Private REST
│ ├── data/ # 캔들 수집·DB·로더 │ ├── data/ # 캔들 수집·DB
│ ├── ground_truth/ # GT 타점·sim·차트 │ ├── ground_truth/ # GT·sim·pnl 엔진
│ ├── techniques/ # 39종 인과 기법 │ ├── techniques/ # 39종 인과 기법
│ ├── mtf/ # MTF 피처·필터·규칙 │ ├── mtf/ # MTF 필터·규칙
│ ├── evaluation/ # 2단계 리포트·인과 sim │ ├── evaluation/ # 2단계 리포트
│ ├── operations/ # 3단계 운영 (runner·executor·sync·backtest) │ ├── operations/ # runner·executor·ledger·watch·reconcile
│ └── notifications/ # 텔레그램 체결 알림 │ └── notifications/ # 텔레그램
├── scripts/ # 단계별 CLI ├── scripts/ # 단계별 CLI·shell
├── data/ ├── data/
│ ├── common/coins.db # 공유 캔들 OHLCV │ ├── common/coins.db
── spot/ ── spot/
├── ground_truth/ # 0단계 GT JSON ├── ground_truth/
├── techniques/ # 2단계 기법 결과 (fractal_swing.json 등) ├── techniques/
├── mtf/ # mtf_rules_v3.json ├── mtf/
└── operations/ # fractal_ops_state.json └── operations/ # state·sizing·lock·pid
└── docs/ └── docs/
├── live/ # 운영 백테스트 매매 차트 (index.html) ├── live/ # 운영 백테스트 차트
└── spot/ └── spot/
├── 0_ground_truth/ # GT 차트 HTML ├── 0_ground_truth/
├── 1_simulation/ # 1단계 sim 차트 ├── 1_simulation/
├── 2_analysis/ # 2단계 리포트·설계 가이드 ├── 2_analysis/
└── 3_operations/ # 운영·백테스트 JSON 리포트 └── 3_operations/
```
테이블명: `{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 키 필요)
``` ```
--- ---
## 단계별 상세 ## 환경 변수
### common — 캔들 수집 전체: `.env.example`. 카테고리별 요약.
| 항목 | 내용 | ### 공통·GT
|------|------|
| DB | `data/common/coins.db` (`DB_PATH`) |
| 증분 갱신 | DB 최신 시각 이후만 API 조회·INSERT |
| 전체 재수집 | `--full` |
| TF | `DOWNLOAD_INTERVALS` (기본 11개) |
운영 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 | — |
| 티어 | 신호 | ### composite_v3 대안 프로필
|------|------|
| 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` 주석 참고:
```env ```env
OPS_TECHNIQUE_ID=composite_v3 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/runner.py` | tick·ledger·stale·watchdog |
| `operations/candle_sync.py` | 전 TF 증분 캔들 sync | | `operations/exchange_reconcile.py` | 거래소 체결 ↔ 원장 |
| `operations/signal_pipeline.py` | 신호 생성·캐시·tail 갱신·MTF 필터 | | `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/executor.py` | paper/live 체결 |
| `operations/execution.py` | 슬리피지 `fill_price` | | `operations/trade_engine.py` | 사이징·포트폴리오 |
| `operations/trade_engine.py` | 매수·매도 사이징·포트폴리오 | | `operations/backtest.py` | 3년 sim |
| `operations/backtest.py` | 운영 조건 3년 sim | | `ground_truth/pnl.py` | sim 엔진 (2·3단계 공용) |
| `operations/chart.py` | `docs/live` 백테스트 차트 | | `api/bithumb_private.py` | 잔고·주문·done 조회 |
| `operations/state_store.py` | 운영 상태 JSON | | `notifications/telegram.py` | 체결·오류·WATCH |
| `ground_truth/pnl.py` | sim 엔진 (백테스트·2단계 공용) |
| `api/bithumb_private.py` | live 잔고·시장가 주문 |
| `notifications/telegram.py` | 체결 텔레그램 알림 |
--- ---
## 현물 2단계 인과 기법 (39종) ## 39종 인과 기법
`src/bithumb/techniques/` — 단일 33 + 복합 6, 미래 데이터 미사용. `src/bithumb/techniques/` — 단일 33 + 복합 6, **미래 데이터 미사용**.
| ID | 기법 | 유형 | | ID | 기법 | 유형 |
|----|------|------| |----|------|------|
| `zigzag_causal` | 인과 ZigZag | 스윙 B/S | | `fractal_swing` | 프랙탈 스윙 | 스윙 (**live**) |
| `minor_swing` | 소형 스윙 하이브리드 | 하이브리드 | | `zigzag_causal` | 인과 ZigZag | 스윙 |
| `local_extrema` | 국소 극값 | 스윙 B/S | | `pivot_swing` | 피벗 스윙 | 스윙 |
| `pivot_swing` | 피벗 스윙 | 스윙 B/S | | `minor_swing` | 소형 스윙 | 하이브리드 |
| `fractal_swing` | 프랙탈 스윙 | 스윙 B/S | | `local_extrema` | 국소 극값 | 스윙 |
| `swing_failure` | 스윙 실패 | 스윙 B/S | | `composite_v3` | v3 통합 | 복합 |
| `donchian` | 돈치안 채널 | 스윙 B/S | | … | (전체 39종) | `techniques/registry.py` 참고 |
| `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·백테스트·텔레그램) | **구현됨** |
--- ---
## 변경 이력 ## 변경 이력
- 2026-06-13: 프로젝트명 **Bithumb**으로 변경, 선물(futures) 파이프라인 제거 - **2026-06-14:** ledger pending, exchange reconcile, max_age backlog, watch 5분 감시·조치, ops.tick.lock, README 전면 갱신
- 2026-06-13: 텔레그램 매수·매도 체결 알림 (`notifications/telegram.py`) - **2026-06-13:** fractal_swing live — 슬리피지·sync·tail·텔레그램; ops_default sim **+1,873,140%**
- 2026-06-13: `docs/live/` 운영 백테스트 매매 차트 (`3_render_live_chart.py`) - **2026-06-13:** 프로젝트명 Bithumb, 선물 파이프라인 제거
- 2026-06-13: fractal_swing live 운영 — 슬리피지·일 체결 상한·전 TF 증분 sync·신호 tail 갱신 - **2026-06-12:** data/docs common·spot 구조, 2단계 39종 완료, 3단계 초기 (composite_v3)
- 2026-06-13: 운영 백테스트 **+1,873,140%** (3년, 슬리피지 0.05%, 일 100회) 검증 - **2026-06-08:** GT v1/v2/v3
- 2026-06-12: `data/`·`docs/` common/spot 구조 재편 - **2026-06-07:** 캔들 수집 모듈
- 2026-06-12: 3단계 운영 파이프라인 초기 구현 (composite_v3 + MTF paper/live)
- 2026-06-12: 2단계 인과 기법 분석 파이프라인 완료
- 2026-06-08: Ground Truth v1/v2/v3
- 2026-06-07: 캔들 수집 모듈 초기 구현

View File

@@ -1,7 +1,7 @@
{ {
"generated_at": "2026-06-13 18:04:03", "generated_at": "2026-06-13 18:04:03",
"technique_id": "fractal_swing", "technique_id": "fractal_swing",
"symbol": "BTC", "symbol": "TRX",
"default_buy_cash_pct": 1.0, "default_buy_cash_pct": 1.0,
"default_sell_coin_pct": 1.0, "default_sell_coin_pct": 1.0,
"by_cluster": { "by_cluster": {

View File

@@ -1,39 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Bithumb Live — 운영 백테스트 차트</title>
<style>
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 32px; color: #333; background: #f5f5f5; }
h1 { font-size: 22px; margin-bottom: 8px; }
.meta { color: #666; font-size: 14px; margin-bottom: 20px; line-height: 1.6; }
.card { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 20px 24px; max-width: 720px; }
.stat { font-size: 28px; font-weight: bold; color: #2e7d32; margin: 8px 0 16px; }
a { color: #1565c0; text-decoration: none; font-size: 16px; }
a:hover { text-decoration: underline; }
ul { margin: 12px 0 0; padding-left: 20px; font-size: 14px; color: #555; }
</style>
</head>
<body>
<h1>Bithumb Live — 운영 백테스트</h1>
<p class="meta">
BTC · 프랙탈 스윙 (fractal_swing)<br>
sim 기간: 최근 1095일 ·
슬리피지 0.05% ·
일 체결 상한 100 ·
MTF off<br>학습 비율: 매수 100% · 매도 100% (클러스터별 규칙 적용)
</p>
<div class="card">
<div>3년 수익률 (운영 규칙 sim)</div>
<div class="stat">+1885460.27%</div>
<p>
<a href="fractal_swing_ops_chart.html">매수·매도 타점 차트 열기</a>
</p>
<ul>
<li>매수 53,519 / 매도 53,444 체결</li>
<li>초기 200,000원 → 최종 3,771,120,549원</li>
<li>차트: B=매수 S=매도 마커, 이전/다음 타점 탐색, 기간 줌</li>
</ul>
</div>
</body>
</html>

View File

@@ -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 보완, 접근 방식 비교 정리 |

View File

@@ -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단계 체크리스트 |

View File

@@ -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 필터 백테스트, 운영 스크립트 |

View File

@@ -16,7 +16,7 @@ if str(SRC) not in sys.path:
from dataclasses import replace 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.candle_store import CandleStore
from bithumb.data.downloader import CandleDownloader from bithumb.data.downloader import CandleDownloader
from bithumb.data.intervals import INTERVAL_1MIN, estimate_download_requests, interval_label 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: def main() -> int:
"""CLI 진입점.""" """CLI 진입점."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -54,6 +106,12 @@ def main() -> int:
default=None, default=None,
help="(고급) 쉼표 구분 인터벌만 수집. 기본: .env DOWNLOAD_INTERVALS 전체", help="(고급) 쉼표 구분 인터벌만 수집. 기본: .env DOWNLOAD_INTERVALS 전체",
) )
parser.add_argument(
"--symbols",
type=str,
default=None,
help="쉼표 구분 심볼 (기본: .env DOWNLOAD_SYMBOLS 또는 SYMBOL)",
)
parser.add_argument( parser.add_argument(
"--include-1min", "--include-1min",
action="store_true", action="store_true",
@@ -63,8 +121,10 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
_configure_logging(args.verbose) _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: if args.intervals:
settings = replace( settings = replace(
settings, settings,
@@ -83,58 +143,40 @@ def main() -> int:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.info( log.info(
"대상=%s DB=%s mode=%s days=%s intervals=%s", "대상=%s DB=%s mode=%s days=%s intervals=%s",
settings.market, ",".join(f"KRW-{s}" for s in symbols),
settings.db_path, settings.db_path,
mode_label, mode_label,
days, days,
settings.download_intervals, 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) store = CandleStore(settings.db_path)
exit_code = 0
try: try:
for interval in settings.download_intervals: for symbol in symbols:
if args.full: symbol_settings = replace(
est = estimate_download_requests(interval, days, batch_size=settings.candle_count) settings,
log.info( symbol=symbol,
"예상 API 요청: %s%s회 (풀 다운, sleep %.2fs)", coin_name=resolve_coin_name(symbol),
interval_label(interval),
est,
settings.request_sleep_sec,
) )
else: print(f"\n=== {symbol} ({symbol_settings.coin_name}) ===")
_, _, db_max = store.get_range(settings.symbol, interval) _log_interval_estimates(
if db_max is None: store=store,
est = estimate_download_requests(interval, days, batch_size=settings.candle_count) symbol=symbol,
log.info( intervals=symbol_settings.download_intervals,
"예상 API 요청: %s%s회 (DB 없음 → 풀 다운)", days=days,
interval_label(interval), full=args.full,
est, batch_size=symbol_settings.candle_count,
) sleep_sec=symbol_settings.request_sleep_sec,
else: log=log,
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) downloader = CandleDownloader(symbol_settings)
results = downloader.download_all(store, days=days, full=args.full) results = downloader.download_all(store, days=days, full=args.full)
print(f"\n=== 수집 완료 ({mode_label}) ===") print(f"\n--- {symbol} 수집 완료 ({mode_label}) ---")
for result in results: for result in results:
count, min_dt, max_dt = store.get_range(settings.symbol, result.interval_min) 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 "-" 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 "-" max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-"
if result.mode == "uptodate": if result.mode == "uptodate":
@@ -143,16 +185,17 @@ def main() -> int:
flag = "OK" flag = "OK"
else: else:
flag = "PARTIAL" flag = "PARTIAL"
exit_code = 1
label = interval_label(result.interval_min) label = interval_label(result.interval_min)
print( print(
f"[{flag}] {label} ({result.interval_min}) mode={result.mode} | " f"[{flag}] {symbol} {label} ({result.interval_min}) mode={result.mode} | "
f"requests={result.requests} upsert={result.saved_rows} " f"requests={result.requests} upsert={result.saved_rows} "
f"db_rows={count} range={min_s} ~ {max_s}" f"db_rows={count} range={min_s} ~ {max_s}"
) )
finally: finally:
store.close() store.close()
return 0 return exit_code
if __name__ == "__main__": if __name__ == "__main__":

15
scripts/00_run_download_cron.sh Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ def _write_index_html(
<body> <body>
<h1>Bithumb Live — 운영 백테스트</h1> <h1>Bithumb Live — 운영 백테스트</h1>
<p class="meta"> <p class="meta">
{report.get("symbol", "BTC")} · {report.get("technique_name", "")} ({report.get("technique_id", "")})<br> {report.get("symbol", "TRX")} · {report.get("technique_name", "")} ({report.get("technique_id", "")})<br>
sim 기간: 최근 {report.get("sim_lookback_days", 1095)}일 · sim 기간: 최근 {report.get("sim_lookback_days", 1095)}일 ·
슬리피지 {report.get("slippage_rate", 0) * 100:.2f}% · 슬리피지 {report.get("slippage_rate", 0) * 100:.2f}% ·
일 체결 상한 {report.get("daily_max_trades", "-")} · 일 체결 상한 {report.get("daily_max_trades", "-")} ·

View File

@@ -4,7 +4,9 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import atexit
import logging import logging
import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@@ -18,6 +20,20 @@ from bithumb.config import load_settings
from bithumb.operations.runner import OperationsRunner 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: def _configure_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig( logging.basicConfig(
@@ -66,7 +82,12 @@ def main() -> int:
runner = OperationsRunner(settings) runner = OperationsRunner(settings)
sync = not args.no_sync 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: while True:
loop_started = time.monotonic()
try: try:
report = runner.tick(sync_candles=sync) report = runner.tick(sync_candles=sync)
except Exception as exc: except Exception as exc:
@@ -81,7 +102,9 @@ def main() -> int:
) )
if args.loop <= 0: if args.loop <= 0:
break break
time.sleep(args.loop) elapsed = time.monotonic() - loop_started
sleep_sec = max(0.0, float(args.loop) - elapsed)
time.sleep(sleep_sec)
continue continue
port = report.get("portfolio") or {} port = report.get("portfolio") or {}
@@ -100,10 +123,18 @@ def main() -> int:
f"코인 {port.get('coin_qty', 0):.8f} {settings.symbol}" f"코인 {port.get('coin_qty', 0):.8f} {settings.symbol}"
) )
print(f"리포트: {settings.ops_report_json}") 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: if args.loop <= 0:
break 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 return 0

View File

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

View File

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

315
scripts/3_run_vol_monitor.py Executable file
View File

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

View File

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

View File

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

10
scripts/3_run_watch_cron.sh Executable file
View File

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

View File

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

103
scripts/3_watch_ops.py Normal file
View File

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

67
scripts/_cron_env.sh Executable file
View File

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

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.bithumb.vol-monitor</string>
<key>ProgramArguments</key>
<array>
<string>__PYTHON__</string>
<string>__ROOT__/scripts/3_run_vol_monitor.py</string>
</array>
<key>WorkingDirectory</key>
<string>__ROOT__</string>
<key>EnvironmentVariables</key>
<dict>
<key>PYTHONPATH</key>
<string>src</string>
<key>VOL_MONITOR_PORT</key>
<string>8766</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>__LOG__</string>
<key>StandardErrorPath</key>
<string>__LOG__</string>
</dict>
</plist>

View File

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

88
scripts/install_crontab.sh Executable file
View File

@@ -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 <<EOF
Usage: $(basename "$0") [--apply|--show|--remove]
--show 등록될 cron 블록만 출력 (기본)
--apply crontab에 BITHUMB 블록 병합 후 설치
--remove crontab에서 BITHUMB 블록 제거
환경 변수:
BITHUMB_PYTHON cron에서 사용할 python3 (예: ~/opt/anaconda3/envs/coin/bin/python3)
로그:
data/common/download_cron.log
data/spot/operations/vol_breakout_cron.log
data/spot/operations/vol_monitor_cron.log
EOF
}
render_block() {
{
echo "$MARKER_BEGIN"
sed "s|/Users/dsyoon/workspace/bithumb|${ROOT}|g" "$EXAMPLE" \
| grep -v '^#' | grep -v '^[[:space:]]*$'
echo "$MARKER_END"
}
}
strip_block() {
awk -v b="$MARKER_BEGIN" -v e="$MARKER_END" '
$0 == b { skip=1; next }
$0 == e { skip=0; next }
skip { next }
/^# BITHUMB/ { next }
/bithumb\/scripts\/(00_run_download_cron|3_run_vol_breakout_cron|3_run_vol_monitor_cron|3_run_watch_cron)\.sh/ { next }
{ print }
'
}
ensure_dirs() {
mkdir -p \
"${ROOT}/data/common" \
"${ROOT}/data/spot/operations" \
"${ROOT}/docs/spot/3_operations"
}
ACTION="${1:---show}"
case "$ACTION" in
--show)
ensure_dirs
render_block
;;
--apply)
ensure_dirs
chmod +x "${ROOT}/scripts/"*.sh 2>/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

View File

@@ -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 <<EOF
Usage: $(basename "$0") [--install|--uninstall|--status]
--install LaunchAgent 등록 + 즉시 기동 (로그인·크래시 시 자동 재시작)
--uninstall LaunchAgent 제거
--status 실행 상태 확인
접속: http://127.0.0.1:8766/vol_live_monitor.html
EOF
}
resolve_python() {
# shellcheck source=scripts/_cron_env.sh
source "${ROOT}/scripts/_cron_env.sh"
resolve_bithumb_python
}
render_plist() {
local python_bin="$1"
mkdir -p "${ROOT}/data/spot/operations"
sed \
-e "s|__ROOT__|${ROOT}|g" \
-e "s|__PYTHON__|${python_bin}|g" \
-e "s|__LOG__|${LOG}|g" \
"$PLIST_SRC"
}
cmd="${1:---status}"
case "$cmd" in
--install)
PYTHON="$(resolve_python)" || exit 1
mkdir -p "${HOME}/Library/LaunchAgents"
render_plist "$PYTHON" > "$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

View File

@@ -5,7 +5,9 @@ from __future__ import annotations
import logging import logging
import math import math
import time import time
from datetime import datetime
from typing import Any from typing import Any
from urllib.parse import urlencode
import requests import requests
@@ -77,7 +79,11 @@ class BithumbPrivateClient:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""인증 요청을 수행한다.""" """인증 요청을 수행한다."""
url = f"{self.base_url}{path}" 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( headers = auth_headers(
self.access_key, self.access_key,
self.secret_key, self.secret_key,
@@ -182,3 +188,78 @@ class BithumbPrivateClient:
"ord_type": "market", "ord_type": "market",
} }
return self._request("POST", "/v1/orders", params=params) 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

View File

@@ -21,6 +21,31 @@ def _resolve_project_path(raw: str) -> Path:
return 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]: def _parse_int_list(raw: str) -> list[int]:
"""쉼표 구분 정수 목록을 파싱한다. """쉼표 구분 정수 목록을 파싱한다.
@@ -45,6 +70,7 @@ class Settings:
candle_count: int candle_count: int
download_intervals: list[int] download_intervals: list[int]
download_days: int download_days: int
download_symbols: list[str]
db_path: Path db_path: Path
request_sleep_sec: float request_sleep_sec: float
request_retries: int request_retries: int
@@ -112,6 +138,43 @@ class Settings:
ops_signal_tail_bars: int ops_signal_tail_bars: int
ops_persist_signal_cache: bool ops_persist_signal_cache: bool
ops_catchup_bars: int 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_bot_token: str
telegram_chat_id: str telegram_chat_id: str
ops_telegram_enabled: bool ops_telegram_enabled: bool
@@ -148,13 +211,19 @@ def load_settings(env_path: Path | None = None) -> Settings:
if not tech_dir.is_absolute(): if not tech_dir.is_absolute():
tech_dir = _PROJECT_ROOT / tech_dir 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( return Settings(
symbol=os.getenv("SYMBOL", "BTC").upper(), symbol=symbol,
coin_name=os.getenv("COIN_NAME", "비트코인"), coin_name=os.getenv("COIN_NAME", resolve_coin_name(symbol)),
api_url=os.getenv("BITHUMB_API_URL", "https://api.bithumb.com").rstrip("/"), api_url=os.getenv("BITHUMB_API_URL", "https://api.bithumb.com").rstrip("/"),
candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")), candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")),
download_intervals=intervals, download_intervals=intervals,
download_days=int(os.getenv("DOWNLOAD_DAYS", "3650")), download_days=int(os.getenv("DOWNLOAD_DAYS", "3650")),
download_symbols=download_symbols,
db_path=db_path, db_path=db_path,
request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")), request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")),
request_retries=int(os.getenv("API_REQUEST_RETRIES", "3")), 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_signal_tail_bars=int(os.getenv("OPS_SIGNAL_TAIL_BARS", "800")),
ops_persist_signal_cache=os.getenv("OPS_PERSIST_SIGNAL_CACHE", "false").strip().lower() ops_persist_signal_cache=os.getenv("OPS_PERSIST_SIGNAL_CACHE", "false").strip().lower()
in ("1", "true", "yes", "on"), 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_bot_token=os.getenv("COIN_TELEGRAM_BOT_TOKEN", "").strip(),
telegram_chat_id=os.getenv("COIN_TELEGRAM_CHAT_ID", "").strip(), telegram_chat_id=os.getenv("COIN_TELEGRAM_CHAT_ID", "").strip(),
ops_telegram_enabled=_parse_ops_telegram_enabled( ops_telegram_enabled=_parse_ops_telegram_enabled(

View File

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

View File

@@ -20,7 +20,7 @@ def load_candles(
Args: Args:
db_path: SQLite 경로. db_path: SQLite 경로.
symbol: 코인 심볼 (예: BTC). symbol: 코인 심볼 (예: TRX).
interval_min: 분 단위 인터벌 코드. interval_min: 분 단위 인터벌 코드.
lookback_days: 최근 N일만 사용. None이면 전체. lookback_days: 최근 N일만 사용. None이면 전체.

View File

@@ -9,6 +9,7 @@ from pathlib import Path
import pandas as pd import pandas as pd
from bithumb.api.bithumb import parse_kst_datetime from bithumb.api.bithumb import parse_kst_datetime
from bithumb.data.candle_bars import is_closed_candle, only_closed_candles
class CandleStore: class CandleStore:
@@ -90,6 +91,31 @@ class CandleStore:
return int(row[0]), parse_kst_datetime(str(row[1])), parse_kst_datetime(str(row[2])) 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: def read_dataframe(self, symbol: str, interval_min: int) -> pd.DataFrame:
"""캔들을 pandas DataFrame으로 읽는다. """캔들을 pandas DataFrame으로 읽는다.
@@ -154,6 +180,11 @@ class CandleStore:
Returns: Returns:
저장(시도) 행 수. 저장(시도) 행 수.
""" """
if not rows:
return 0
rows = only_closed_candles(rows, interval_min)
if not rows: if not rows:
return 0 return 0
@@ -211,6 +242,11 @@ class CandleStore:
Returns: Returns:
실제 INSERT된 행 수. 실제 INSERT된 행 수.
""" """
if not rows:
return 0
rows = only_closed_candles(rows, interval_min)
if not rows: if not rows:
return 0 return 0

View File

@@ -9,6 +9,7 @@ from typing import Any
from bithumb.api.bithumb import BithumbCandleClient, parse_kst_datetime from bithumb.api.bithumb import BithumbCandleClient, parse_kst_datetime
from bithumb.config import Settings from bithumb.config import Settings
from bithumb.data.candle_bars import only_closed_candles
from bithumb.data.candle_store import CandleStore from bithumb.data.candle_store import CandleStore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -106,15 +107,9 @@ class CandleDownloader:
stop_at = target_from stop_at = target_from
else: else:
mode = "incremental" mode = "incremental"
if db_max >= datetime.now() - timedelta(minutes=max(interval_min, 1)): store.delete_incomplete_tail(symbol, interval_min)
return DownloadResult( _, _, db_max = store.get_range(symbol, interval_min)
interval_min=interval_min, stop_at = db_max - timedelta(minutes=interval_min) if db_max else target_from
mode="uptodate",
requests=0,
saved_rows=0,
reached_target=True,
)
stop_at = db_max - timedelta(minutes=interval_min)
to_kst: datetime | None = None to_kst: datetime | None = None
requests = 0 requests = 0
@@ -132,28 +127,30 @@ class CandleDownloader:
if not candles: if not candles:
break break
rows = _candle_rows_from_api(candles) all_rows = _candle_rows_from_api(candles)
if not rows: if not all_rows:
break break
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: if mode == "incremental" and db_max is not None:
inserted = store.insert_new_rows( refresh_from = db_max - timedelta(minutes=interval_min * 48)
symbol, rows = [
self.settings.coin_name, r for r in rows
interval_min, if parse_kst_datetime(str(r[0])) >= refresh_from
rows, ]
after=db_max, if rows:
) saved_rows += store.upsert_rows(
else:
inserted = store.upsert_rows(
symbol, symbol,
self.settings.coin_name, self.settings.coin_name,
interval_min, interval_min,
rows, rows,
) )
saved_rows += inserted
batch_oldest = min(parse_kst_datetime(r[0]) for r in rows) if mode == "incremental":
break
if oldest_seen is None or batch_oldest < oldest_seen: if oldest_seen is None or batch_oldest < oldest_seen:
oldest_seen = batch_oldest oldest_seen = batch_oldest

View File

@@ -11,7 +11,7 @@ from bithumb.evaluation.gt_align import GT_SIGNAL_TYPES
Operator = Literal["<=", ">="] Operator = Literal["<=", ">="]
# BTC 가격 스케일에 민감한 지표는 자동 규칙에서 제외 # 고가 코인 가격 스케일에 민감한 지표는 자동 규칙에서 제외
_EXCLUDED_AUTO_FEATURES: frozenset[str] = frozenset({"macd_hist", "zigzag_leg_pct", "close"}) _EXCLUDED_AUTO_FEATURES: frozenset[str] = frozenset({"macd_hist", "zigzag_leg_pct", "close"})
# 자동 규칙에 사용할 안정 피처 # 자동 규칙에 사용할 안정 피처

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime
from typing import Any from typing import Any
import requests import requests
@@ -131,6 +132,50 @@ class TelegramNotifier:
return self.send_message("\n".join(lines)) 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( def notify_trade_failure(
self, self,
*, *,

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import json
import logging import logging
import time import time
import traceback import traceback
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -14,8 +14,10 @@ from bithumb.config import Settings
from bithumb.ground_truth.pnl import _cluster_signals from bithumb.ground_truth.pnl import _cluster_signals
from bithumb.notifications.telegram import create_telegram_notifier from bithumb.notifications.telegram import create_telegram_notifier
from bithumb.operations.candle_sync import sync_ops_candles 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.executor import LiveExecutor, create_executor
from bithumb.operations.live_bootstrap import sync_portfolio_from_exchange 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 ( from bithumb.operations.signal_pipeline import (
filter_signals_for_ops, filter_signals_for_ops,
generate_raw_signals, generate_raw_signals,
@@ -75,6 +77,241 @@ def _reconcile_processed_cursor(
state["last_processed_bar_index"] = max_bar_at_or_before 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( def _pending_signals_for_ops(
kept: list[dict[str, Any]], kept: list[dict[str, Any]],
*, *,
@@ -205,6 +442,7 @@ class OperationsRunner:
def __init__(self, settings: Settings) -> None: def __init__(self, settings: Settings) -> None:
self.settings = settings self.settings = settings
self._runner_started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.executor = create_executor(settings) self.executor = create_executor(settings)
self.telegram = create_telegram_notifier( self.telegram = create_telegram_notifier(
settings.telegram_bot_token, settings.telegram_bot_token,
@@ -229,8 +467,33 @@ class OperationsRunner:
logger.exception("live 초기 잔고 동기화 실패") logger.exception("live 초기 잔고 동기화 실패")
self._notify_ops_error("init_sync", exc) 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 리포트 반환.""" """신호 확인 및 체결 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: try:
return self._tick_impl(sync_candles=sync_candles) return self._tick_impl(sync_candles=sync_candles)
except Exception as exc: except Exception as exc:
@@ -240,6 +503,15 @@ class OperationsRunner:
def _tick_impl(self, *, sync_candles: bool | None = None) -> dict[str, Any]: def _tick_impl(self, *, sync_candles: bool | None = None) -> dict[str, Any]:
"""tick 본체.""" """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 do_sync = sync_candles if sync_candles is not None else self.settings.ops_sync_candles
candle_sync_results: list[Any] = [] candle_sync_results: list[Any] = []
if do_sync: if do_sync:
@@ -247,7 +519,16 @@ class OperationsRunner:
df = load_ops_candles(self.settings) df = load_ops_candles(self.settings)
latest_bar = int(len(df) - 1) 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"]) filtered = filter_signals_for_ops(self.settings, gen["raw_signals"])
all_kept = filtered["kept"] all_kept = filtered["kept"]
@@ -268,22 +549,66 @@ class OperationsRunner:
logger.exception("tick 잔고 동기화 실패") logger.exception("tick 잔고 동기화 실패")
self._notify_ops_error("portfolio_sync", exc) self._notify_ops_error("portfolio_sync", exc)
last_bar = int(self.state.get("last_processed_bar_index", -1)) trade_history = self.state.get("trade_history") or []
last_dt = self.state.get("last_processed_datetime") live_since = (
pending_signals = _pending_signals_for_ops( 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, all_kept,
last_processed_datetime=last_dt, trade_history,
last_bar_index=last_bar, 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,
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, latest_bar_index=latest_bar,
lookback_days=self.settings.ops_ledger_lookback_days,
) )
catchup_signals = _catchup_signals_for_ops( catchup_signals = _catchup_signals_for_ops(
all_kept, all_kept,
latest_bar_index=latest_bar, latest_bar_index=latest_bar,
catchup_bars=self.settings.ops_catchup_bars, 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} 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) target_bars = _pending_bar_indices(pending_signals)
executions: list[dict[str, Any]] = [] executions: list[dict[str, Any]] = []
@@ -302,6 +627,24 @@ class OperationsRunner:
(k for k in all_kept if k["datetime"] == sig["datetime"]), (k for k in all_kept if k["datetime"] == sig["datetime"]),
sig, sig,
) )
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", "")),
order_krw=0.0,
order_coin=0.0,
fee_krw=0.0,
price=float(full_sig.get("price", 0)),
skip_reason=expire_reason,
expected_skip=True,
)
else:
try: try:
trade = self.executor.execute_signal( trade = self.executor.execute_signal(
full_sig, full_sig,
@@ -334,7 +677,8 @@ class OperationsRunner:
"signal_type": full_sig.get("signal_type"), "signal_type": full_sig.get("signal_type"),
"price": full_sig["price"], "price": full_sig["price"],
"bar_index": bar_idx, "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(), "trade": trade.to_dict(),
"mtf_filter": full_sig.get("mtf_filter"), "mtf_filter": full_sig.get("mtf_filter"),
} }
@@ -361,11 +705,31 @@ class OperationsRunner:
datetime_str=str(full_sig["datetime"]), datetime_str=str(full_sig["datetime"]),
reason=trade.skip_reason, reason=trade.skip_reason,
) )
if bar_idx > last_bar:
last_bar = bar_idx if executions:
self.state["last_processed_bar_index"] = bar_idx self.state.setdefault("trade_history", []).extend(executions)
if bar_signals: trade_history = self.state.get("trade_history") or []
self.state["last_processed_datetime"] = bar_signals[-1]["datetime"] _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 = { pipeline = {
"technique_id": gen["technique_id"], "technique_id": gen["technique_id"],
@@ -373,16 +737,16 @@ class OperationsRunner:
"kept_count": len(all_kept), "kept_count": len(all_kept),
"rejected_count": len(filtered["rejected"]), "rejected_count": len(filtered["rejected"]),
"latest_bar_index": latest_bar, "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_signal_count": len(catchup_signals),
"catchup_bars": self.settings.ops_catchup_bars, "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 = { report = {
"generated_at": now, "generated_at": now,
"mode": self.settings.ops_mode, "mode": self.settings.ops_mode,
@@ -390,6 +754,7 @@ class OperationsRunner:
"slippage_rate": self.settings.ops_slippage_rate, "slippage_rate": self.settings.ops_slippage_rate,
"daily_max_trades": self.settings.ops_daily_max_trades, "daily_max_trades": self.settings.ops_daily_max_trades,
"signal_refresh": gen.get("signal_refresh"), "signal_refresh": gen.get("signal_refresh"),
"force_tail_refresh": force_tail,
"candle_sync": [ "candle_sync": [
{ {
"interval_min": r.interval_min, "interval_min": r.interval_min,
@@ -402,20 +767,79 @@ class OperationsRunner:
"raw_signals": pipeline["raw_count"], "raw_signals": pipeline["raw_count"],
"filtered_signals": pipeline["kept_count"], "filtered_signals": pipeline["kept_count"],
"pending_bars": target_bars, "pending_bars": target_bars,
"ledger_pending_count": pipeline["ledger_pending_count"],
"pending_signal_count": pipeline["pending_signal_count"], "pending_signal_count": pipeline["pending_signal_count"],
"catchup_signal_count": pipeline["catchup_signal_count"], "catchup_signal_count": pipeline["catchup_signal_count"],
"catchup_bars": pipeline["catchup_bars"], "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"], "latest_bar_candidates": pipeline["raw_count"],
"executions": executions, "executions": executions,
"portfolio": self.state["portfolio"], "portfolio": self.state["portfolio"],
"trades_today_count": self.state["trades_today_count"], "trades_today_count": self.state["trades_today_count"],
"last_processed_bar_index": self.state["last_processed_bar_index"], "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) save_state(self.settings.ops_state_json, self.state)
self._save_report(report) 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 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( def _notify_ops_error(
self, self,
stage: str, stage: str,
@@ -427,6 +851,8 @@ class OperationsRunner:
if not self.telegram.is_active: if not self.telegram.is_active:
return return
tb_tail = traceback.format_exc(limit=4).strip() 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] detail_parts = [p for p in (context, tb_tail) if p]
self.telegram.notify_ops_error( self.telegram.notify_ops_error(
mode=self.settings.ops_mode, mode=self.settings.ops_mode,

View File

@@ -47,6 +47,27 @@ def _offset_signal_bars(signals: list[dict[str, Any]], offset: int) -> list[dict
return shifted 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( def _merge_tail_signals(
cached_signals: list[dict[str, Any]], cached_signals: list[dict[str, Any]],
tail_signals: list[dict[str, Any]], tail_signals: list[dict[str, Any]],
@@ -187,6 +208,7 @@ def generate_raw_signals(
df: pd.DataFrame | None = None, df: pd.DataFrame | None = None,
*, *,
use_cache: bool = True, use_cache: bool = True,
force_tail_refresh: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""기법 신호를 생성한다 (MTF 필터 전). """기법 신호를 생성한다 (MTF 필터 전).
@@ -209,13 +231,21 @@ def generate_raw_signals(
cached = _load_technique_cached(cache_path) cached = _load_technique_cached(cache_path)
raw_signals = list(cached.signals) raw_signals = list(cached.signals)
max_cached_bar = _max_signal_bar_index(raw_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) tail_bars = max(settings.ops_signal_tail_bars, 200)
offset = max(0, len(df) - tail_bars) 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() tail_df = df.iloc[offset:].copy()
technique = get_technique(settings.ops_technique_id) technique = get_technique(settings.ops_technique_id)
params = build_technique_params(settings) params = build_technique_params(settings)
tail_result = run_technique(technique, tail_df, params, gt_result=None) tail_result = run_technique(technique, tail_df, params, gt_result=None)
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 = _merge_tail_signals(
raw_signals, raw_signals,
tail_result.signals, tail_result.signals,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"<b>KRW</b> {summary.get('cash_krw', 0):,.0f}",
f"<b>총평가</b> {summary.get('total_equity_krw', 0):,.0f}",
f"<b>모드</b> {summary.get('mode', '-')}",
]
for sym, row in (summary.get("symbols") or {}).items():
pos = "" if row.get("in_position") else "플랫"
lines.append(
f"<b>{sym}</b> {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"),
}

View File

@@ -0,0 +1,317 @@
"""Bithumb vol_breakout 멀티 종목 라이브 모니터 HTML 셸."""
from __future__ import annotations
import json
from pathlib import Path
_MONITOR_HTML = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>[Bithumb] 라이브 모니터</title>
<script src="https://unpkg.com/lightweight-charts@3.8.0/dist/lightweight-charts.standalone.production.js"></script>
<style>
body {{ margin:0; font-family:"Malgun Gothic",Arial,sans-serif; background:#fff; color:#222; }}
header {{ padding:10px 14px; border-bottom:1px solid #e5e5e5; }}
.titleRow {{ display:flex; align-items:center; gap:10px; margin-bottom:6px; }}
h1 {{ font-size:15px; margin:0; }}
#btnUpdate {{
font-size:12px; padding:3px 12px; border:1px solid #ccc; border-radius:4px;
background:#f5f5f5; color:#333; cursor:pointer;
}}
#btnUpdate:hover {{ background:#eee; }}
#btnUpdate:disabled {{ opacity:0.55; cursor:wait; }}
#meta {{ font-size:12px; color:#555; margin-bottom:6px; }}
table.summary {{ border-collapse:collapse; font-size:12px; width:100%; margin-top:6px; }}
table.summary th, table.summary td {{ border:1px solid #ddd; padding:5px 10px; text-align:center; white-space:nowrap; }}
table.summary th {{ background:#f5f5f5; color:#666; font-weight:normal; font-size:11px; }}
.tabs {{ padding:8px 14px; display:flex; gap:8px; flex-wrap:wrap; border-bottom:1px solid #eee; }}
.tab {{
padding:4px 14px; border:1px solid #ccc; border-radius:4px; background:#fafafa;
cursor:pointer; font-size:13px;
}}
.tab.active {{ background:#333; color:#fff; border-color:#333; }}
#priceChart {{ width:100%; height:44vh; border-bottom:1px solid #eee; }}
#equityChart {{ width:100%; height:32vh; }}
#err {{ display:none; padding:10px 14px; color:#b91c1c; font-size:13px; }}
#reloadHint {{ font-size:12px; color:#888; padding:4px 14px; }}
</style>
</head>
<body>
<header>
<div class="titleRow">
<h1>Bithumb vol_breakout (15m spot long)</h1>
<button id="btnUpdate" type="button" title="거래소 잔고 즉시 갱신">Update</button>
</div>
<div id="meta">로딩 중…</div>
<div id="summary"></div>
</header>
<div id="err"></div>
<div class="tabs" id="tabs"></div>
<div id="reloadHint"></div>
<div id="priceChart"></div>
<div id="equityChart"></div>
<script>
const JSON_URL = {json_url};
const REFRESH_MS = {refresh_ms};
const BALANCE_POLL_MS = {balance_poll_ms};
let payload = null;
let activeSym = null;
let priceChart = null;
let eqChart = null;
let candleSeries = null;
let stratSeries = null;
let bhSeries = null;
let chartSyncLock = false;
function fmtKrw(v) {{
if (v == null || isNaN(v)) return '-';
return Math.round(v).toLocaleString('ko-KR');
}}
function renderSummary(data) {{
const s = data.summary || {{}};
const syms = data.ops_symbols || Object.keys(s.symbols || {{}});
let html = '<table class="summary"><thead><tr>';
html += '<th>KRW</th><th>총평가</th><th>모드</th>';
for (const sym of syms) html += `<th>${{sym}}</th>`;
html += '</tr></thead><tbody><tr>';
html += `<td>${{fmtKrw(s.cash_krw)}}</td>`;
html += `<td>${{fmtKrw(s.total_equity_krw)}}</td>`;
html += `<td>${{s.mode || '-'}}</td>`;
for (const sym of syms) {{
const row = (s.symbols || {{}})[sym] || {{}};
const pos = row.in_position ? '' : '플랫';
html += `<td>${{pos}} · ${{Number(row.coin_qty||0).toFixed(4)}} · sig ${{row.last_signal||0}}</td>`;
}}
html += '</tr></tbody></table>';
document.getElementById('summary').innerHTML = html;
}}
function renderTabs(symbols) {{
const el = document.getElementById('tabs');
el.innerHTML = '';
for (const sym of symbols) {{
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tab' + (sym === activeSym ? ' active' : '');
btn.textContent = sym;
btn.onclick = () => {{ activeSym = sym; renderTabs(symbols); drawChart(); }};
el.appendChild(btn);
}}
}}
function chartOpts() {{
return {{
layout: {{ background: {{ color: '#fff' }}, textColor: '#333' }},
grid: {{ vertLines: {{ color: '#f0f0f0' }}, horzLines: {{ color: '#f0f0f0' }} }},
timeScale: {{ timeVisible: true, secondsVisible: false }},
rightPriceScale: {{ borderColor: '#d0d0d0' }},
}};
}}
function resizeCharts() {{
const pairs = [['priceChart', priceChart], ['equityChart', eqChart]];
for (const [id, chart] of pairs) {{
if (!chart) continue;
const el = document.getElementById(id);
if (el && el.clientWidth > 0 && el.clientHeight > 0) {{
chart.resize(el.clientWidth, el.clientHeight);
}}
}}
}}
function resampleSeriesToCandles(series, candles) {{
if (!candles.length) return series || [];
if (!series || !series.length) return [];
const map = new Map(series.map(p => [p.time, p.value]));
let last = series[0].value;
const out = [];
for (const c of candles) {{
if (map.has(c.time)) last = map.get(c.time);
out.push({{ time: c.time, value: last }});
}}
return out;
}}
function setupChartSync() {{
if (!priceChart || !eqChart) return;
priceChart.timeScale().subscribeVisibleLogicalRangeChange(() => {{
if (chartSyncLock) return;
chartSyncLock = true;
const range = priceChart.timeScale().getVisibleLogicalRange();
if (range) eqChart.timeScale().setVisibleLogicalRange(range);
requestAnimationFrame(() => {{ chartSyncLock = false; }});
}});
eqChart.timeScale().subscribeVisibleLogicalRangeChange(() => {{
if (chartSyncLock) return;
chartSyncLock = true;
const range = eqChart.timeScale().getVisibleLogicalRange();
if (range) priceChart.timeScale().setVisibleLogicalRange(range);
requestAnimationFrame(() => {{ chartSyncLock = false; }});
}});
}}
function initChart() {{
const opts = chartOpts();
priceChart = LightweightCharts.createChart(document.getElementById('priceChart'), opts);
candleSeries = priceChart.addCandlestickSeries({{
upColor: '#c62828', downColor: '#1565c0',
borderUpColor: '#c62828', borderDownColor: '#1565c0',
wickUpColor: '#c62828', wickDownColor: '#1565c0',
}});
eqChart = LightweightCharts.createChart(document.getElementById('equityChart'), opts);
stratSeries = eqChart.addLineSeries({{ color: '#c0392b', lineWidth: 2, title: '전략' }});
bhSeries = eqChart.addLineSeries({{ color: '#888888', lineWidth: 1, lineStyle: 2, title: 'B&H 1/3' }});
setupChartSync();
window.addEventListener('resize', resizeCharts);
}}
function enhanceMarkers(markers) {{
return (markers || []).map(m => {{
const side = String(m.text || '');
const label = (side === 'B' || side === 'buy') ? '매수'
: (side === 'S' || side === 'sell') ? '매도' : side;
return {{ ...m, text: label, size: m.size || 2 }};
}});
}}
function drawChart() {{
if (!payload || !activeSym || !candleSeries) return;
const block = (payload.symbols || {{}})[activeSym] || {{}};
const candles = block.candles_15m || [];
const markers = enhanceMarkers(block.markers || []);
candleSeries.setData(candles);
candleSeries.setMarkers(markers);
priceChart.timeScale().fitContent();
const eq = payload.equity || {{}};
const strategy = resampleSeriesToCandles(eq.strategy || [], candles);
const buyhold = resampleSeriesToCandles(eq.buyhold || [], candles);
if (stratSeries) stratSeries.setData(strategy);
if (bhSeries) bhSeries.setData(buyhold);
if (eqChart) eqChart.timeScale().fitContent();
const s = payload.summary || {{}};
const row = (s.symbols || {{}})[activeSym] || {{}};
const stratPct = Number(s.strategy_return_pct || 0);
const bhPct = Number(s.buyhold_return_pct || 0);
document.getElementById('reloadHint').textContent =
`${{activeSym}} · next 15m ${{row.next_15m || '-'}}` +
(row.seconds_until_15m != null ? ` (${{row.seconds_until_15m}}s)` : '') +
` · 전략 ${{stratPct >= 0 ? '+' : ''}}${{stratPct.toFixed(2)}}%` +
` · B&H ${{bhPct >= 0 ? '+' : ''}}${{bhPct.toFixed(2)}}%` +
(s.equity_start_at ? ` · 기준 ${{s.equity_start_at}} seed ${{fmtKrw(s.equity_seed_krw)}}` : '');
resizeCharts();
}}
async function loadData() {{
try {{
const r = await fetch(JSON_URL + (JSON_URL.includes('?') ? '&' : '?') + 't=' + Date.now(), {{ cache: 'no-store' }});
if (!r.ok) throw new Error('HTTP ' + r.status);
payload = await r.json();
document.getElementById('err').style.display = 'none';
document.getElementById('meta').textContent =
'갱신: ' + (payload.updated_at || '-') +
(payload.last_tick && payload.last_tick.last_run_at ? ' · tick ' + payload.last_tick.last_run_at : '') +
(payload.summary ? (
' · 전략 ' + (Number(payload.summary.strategy_return_pct||0)).toFixed(2) + '%' +
' · B&H ' + (Number(payload.summary.buyhold_return_pct||0)).toFixed(2) + '%'
) : '');
renderSummary(payload);
const syms = payload.ops_symbols || Object.keys(payload.symbols || {{}});
if (!activeSym && syms.length) activeSym = syms[0];
renderTabs(syms);
drawChart();
}} catch (e) {{
const errEl = document.getElementById('err');
errEl.style.display = 'block';
if (payload) {{
errEl.textContent = 'JSON 갱신 실패(이전 데이터 유지): ' + e.message +
' — 터미널에서 python scripts/3_run_vol_monitor.py 가 실행 중인지 확인하세요.';
}} else {{
errEl.textContent = 'JSON 로드 실패: ' + e.message +
' — http://127.0.0.1:8766/vol_live_monitor.html 로 접속하고 서버를 실행했는지 확인하세요.';
}}
}}
}}
async function refreshBalance() {{
const btn = document.getElementById('btnUpdate');
btn.disabled = true;
try {{
const r = await fetch('/api/balance?t=' + Date.now(), {{ cache: 'no-store' }});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'balance fail');
await loadData();
}} catch (e) {{
document.getElementById('err').style.display = 'block';
document.getElementById('err').textContent = '잔고 갱신 실패: ' + e.message;
}} finally {{
btn.disabled = false;
}}
}}
async function pollBalance() {{
try {{
const r = await fetch('/api/balance?t=' + Date.now(), {{ cache: 'no-store' }});
if (!r.ok) return;
const j = await r.json();
if (j.ok && payload && payload.summary) {{
payload.summary.cash_krw = j.cash_krw;
if (j.positions) {{
for (const [sym, qty] of Object.entries(j.positions)) {{
if (payload.summary.symbols && payload.summary.symbols[sym]) {{
payload.summary.symbols[sym].coin_qty = qty;
}}
}}
}}
renderSummary(payload);
}}
}} catch (_) {{}}
}}
if (location.protocol === 'file:') {{
document.getElementById('meta').textContent = 'HTTP 서버 필요 — python scripts/3_run_vol_monitor.py 실행';
document.getElementById('btnUpdate').disabled = true;
}} else {{
try {{
if (typeof LightweightCharts === 'undefined') throw new Error('lightweight-charts CDN 로드 실패');
initChart();
}} catch (e) {{
document.getElementById('err').style.display = 'block';
document.getElementById('err').textContent = '차트 라이브러리 로드 실패: ' + e.message;
}}
document.getElementById('btnUpdate').addEventListener('click', refreshBalance);
loadData();
setInterval(loadData, REFRESH_MS);
setInterval(pollBalance, BALANCE_POLL_MS);
}}
</script>
</body>
</html>
"""
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

View File

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

View File

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

73
tests/test_candle_bars.py Normal file
View File

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

View File

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

143
tests/test_ops_ledger.py Normal file
View File

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

View File

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

View File

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

View File

@@ -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"<script>(.*)</script>", 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)

42
tests/test_watch_ops.py Normal file
View File

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

View File

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