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:
36
.env.example
36
.env.example
@@ -17,6 +17,8 @@ COIN_TELEGRAM_CHAT_ID=
|
||||
# --- 거래 대상 ---
|
||||
SYMBOL=BTC
|
||||
COIN_NAME=비트코인
|
||||
# 캔들 수집 대상 (쉼표 구분). 비우면 SYMBOL만 수집
|
||||
DOWNLOAD_SYMBOLS=TRX,NEAR,WLD
|
||||
|
||||
# --- 공통: 캔들 DB (현물·선물 공유) ---
|
||||
DB_PATH=data/common/coins.db
|
||||
@@ -118,7 +120,9 @@ OPS_WATCH_SIGNAL_GRACE_MIN=5
|
||||
OPS_WATCH_TICK_STALE_MIN=12
|
||||
OPS_WATCH_LOOKBACK_MIN=30
|
||||
OPS_WATCH_AUTO_REMEDIATE=true
|
||||
OPS_WATCH_AUTO_RESTART=true
|
||||
# vol_breakout(cron 1분) 사용 시 fractal loop 재시작 끄기
|
||||
OPS_WATCH_AUTO_RESTART=false
|
||||
# fractal loop 커스텀 기동 (OPS_WATCH_AUTO_RESTART=true 일 때)
|
||||
# OPS_WATCH_LOOP_START_CMD=
|
||||
# OPS_PERSIST_SIGNAL_CACHE=false
|
||||
OPS_STATE_JSON=data/spot/operations/fractal_ops_state.json
|
||||
@@ -128,6 +132,36 @@ OPS_FILTERED_BACKTEST_JSON=docs/spot/3_operations/fractal_filtered_backtest_repo
|
||||
OPS_SIZING_RULES_JSON=data/spot/operations/sizing_rules.json
|
||||
# composite_v3 운영 시: OPS_TECHNIQUE_ID=composite_v3, OPS_MIN_SCORE=2.5, OPS_MTF_ENABLED=true
|
||||
|
||||
# --- vol_breakout 현물 롱 (Binance 15m ATR 이식, BTC 제외) ---
|
||||
# fractal_swing(3분)과 별도 프로세스 — 동시 live 시 KRW 경합 주의
|
||||
OPS_SYMBOLS=TRX,NEAR,WLD
|
||||
VOL_STATE_JSON=data/spot/operations/vol_breakout_state.json
|
||||
VOL_LOOKBACK=14
|
||||
VOL_ATR_MULT=2.0
|
||||
VOL_LOOKBACK_DAYS=60
|
||||
# VOL_BUY_SPLIT=0: 미보유 슬롯 수로 동적 (3→1/3, 2→1/2, 1→100%)
|
||||
VOL_BUY_SPLIT=0
|
||||
VOL_WALLET_PCT=0.30
|
||||
VOL_MAX_BARS_PER_TICK=5
|
||||
# 순수 15m flip (trail/stop off). 청산은 하향 돌파(-1) flip만
|
||||
VOL_EXIT_ENABLED=false
|
||||
# trail/stop 사용 시 VOL_EXIT_ENABLED=true 및 아래 값
|
||||
# VOL_TRAIL_ACTIVATE_PCT=1.5
|
||||
# VOL_TRAIL_PCT=0.0025
|
||||
# VOL_TRAIL_ATR_MULT=0.5
|
||||
# VOL_STOP_PCT=-12.0
|
||||
VOL_TICK_LOCK_PATH=data/spot/operations/vol.tick.lock
|
||||
# touch data/spot/operations/vol.kill → 신규 매수 차단
|
||||
# VOL_KILL_SWITCH_PATH=data/spot/operations/vol.kill
|
||||
|
||||
# 라이브 모니터 (Binance vol_live_monitor 유사, 기본 포트 8766)
|
||||
VOL_MONITOR_JSON=docs/spot/3_operations/vol_live_chart.json
|
||||
VOL_MONITOR_HTML=docs/spot/3_operations/vol_live_monitor.html
|
||||
VOL_MONITOR_DAYS=14
|
||||
|
||||
# cron (vol_breakout): bash scripts/install_crontab.sh --apply
|
||||
# BITHUMB_PYTHON=/Users/dsyoon/opt/anaconda3/envs/coin/bin/python3
|
||||
|
||||
# 폴더 구조: data|docs / {common, spot}
|
||||
# common — coins.db 등 공유 리소스
|
||||
# spot — 현물 GT·기법·분석·운영
|
||||
|
||||
783
README.md
783
README.md
@@ -1,262 +1,459 @@
|
||||
# Bithumb
|
||||
|
||||
빗썸 KRW 마켓 암호화폐 캔들 수집 및 **현물** 매매 전략 파이프라인.
|
||||
빗썸 KRW 마켓 암호화폐 캔들 수집 및 **현물(spot)** 매매 전략 파이프라인.
|
||||
|
||||
- **기본 축:** 3분봉 현물 BTC, 최근 **10년** 캔들 (`DOWNLOAD_DAYS=3650`)
|
||||
- **데이터·문서 분류:** `common` (공유) · `spot` (현물)
|
||||
- **현재 운영 전략:** `fractal_swing` + MTF off — paper/live tick 운영 구현 완료
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- 빗썸 Public API(v1) 분·일·주·월봉 캔들 수집 (11개 TF, 1분봉 포함)
|
||||
- SQLite 공유 DB (`data/common/coins.db`) — 현물·MTF 공용
|
||||
- Ground Truth(GT) 벤치마크 → 39종 인과 기법 분석 → **실거래 운영(paper/live)**
|
||||
- 운영 tick: 캔들 증분 sync, 신호 tail 갱신, 슬리피지·일 체결 상한, 텔레그램 체결 알림
|
||||
|
||||
## 요구사항
|
||||
|
||||
- Python 3.10+
|
||||
- Conda 환경 `ncue` 또는 `xavis`
|
||||
|
||||
## 설치
|
||||
|
||||
```bash
|
||||
cd Bithumb
|
||||
conda activate ncue # 또는 xavis
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # API 키·텔레그램 등 로컬 설정
|
||||
```
|
||||
|
||||
`.env` 핵심값 (현물 3분봉·10년·fractal 운영):
|
||||
|
||||
```env
|
||||
SYMBOL=BTC
|
||||
DB_PATH=data/common/coins.db
|
||||
DOWNLOAD_DAYS=3650
|
||||
GT_INTERVAL_MIN=3
|
||||
GT_LOOKBACK_DAYS=3650
|
||||
GT_INITIAL_CASH_KRW=200000
|
||||
GT_SIM_LOOKBACK_DAYS=1095
|
||||
OPS_TECHNIQUE_ID=fractal_swing
|
||||
OPS_MTF_ENABLED=false
|
||||
OPS_SLIPPAGE_RATE=0.0005
|
||||
OPS_DAILY_MAX_TRADES=100
|
||||
```
|
||||
- **기본 축:** 3분봉 BTC, 최근 **10년** 캔들 (`DOWNLOAD_DAYS=3650`)
|
||||
- **데이터·문서:** `data/common` (공유 DB) · `data/spot` / `docs/spot` (현물)
|
||||
- **현재 live 운영:** `fractal_swing`, MTF off, ledger + exchange reconcile + 5분 watch
|
||||
|
||||
---
|
||||
|
||||
## 설계 개요
|
||||
## 목차
|
||||
|
||||
### 파이프라인 단계
|
||||
1. [파이프라인 개요](#파이프라인-개요)
|
||||
2. [단계별 요약 (0~3단계)](#단계별-요약-03단계)
|
||||
3. [수익률 정리](#수익률-정리)
|
||||
4. [3단계 live 운영](#3단계-live-운영)
|
||||
5. [설치·실행](#설치실행)
|
||||
6. [폴더 구조](#폴더-구조)
|
||||
7. [환경 변수](#환경-변수)
|
||||
8. [소스 모듈](#소스-모듈)
|
||||
9. [39종 인과 기법](#39종-인과-기법)
|
||||
10. [변경 이력](#변경-이력)
|
||||
|
||||
---
|
||||
|
||||
## 파이프라인 개요
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[common<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 1단계** | GT 타점 완벽 추종 sim 상한선 | GT 자체가 사후 | 불가 |
|
||||
| **spot 2단계** | 39종 인과 기법 평가·MTF 규칙 | 미사용 | 불가 |
|
||||
| **spot 3단계** | paper/live tick 운영 | 미사용 | **가능** |
|
||||
|
||||
### 현물 3단계 운영 아키텍처 (fractal_swing)
|
||||
**핵심 원칙**
|
||||
|
||||
- 0~1단계는 **연구·벤치마크** (사후 GT 포함).
|
||||
- 2단계는 **인과 기법**만으로 GT 재현도·sim을 비교.
|
||||
- 3단계는 2단계에서 선택한 기법을 **실시간 tick**으로 체결. 백테스트와 **동일 sim 엔진**(`ground_truth/pnl.py`) 사용.
|
||||
|
||||
---
|
||||
|
||||
## 단계별 요약 (0~3단계)
|
||||
|
||||
### common — 캔들 수집
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 스크립트 | `scripts/00_download.py` (별칭 `00_download_candles.py`) |
|
||||
| DB | `data/common/coins.db` |
|
||||
| TF | 11개 (1,3,5,10,15,30,60,240,1440,10080,43200분) |
|
||||
| 모드 | 증분(기본) / `--full` 전체 재수집 |
|
||||
| 테이블 | `{SYMBOL}_{분}` (예: `BTC_3`, `BTC_1440`) |
|
||||
|
||||
3단계 tick에서는 `sync_ops_candles()`가 in-process로 동일 DB에 증분 INSERT (`OPS_SYNC_CANDLES=true`).
|
||||
|
||||
---
|
||||
|
||||
### spot 0단계 — Ground Truth (GT) 타점
|
||||
|
||||
**목적:** 10년 3분봉에서 **사후적으로** 도출한 “이론적 최적” 매수·매도 타점. 이후 단계의 **정답지·벤치마크**.
|
||||
|
||||
| 스크립트 | `scripts/0_ground_truth.py` |
|
||||
|----------|----------------------------|
|
||||
| 기간 | `GT_LOOKBACK_DAYS=3650` (10년) |
|
||||
| 봉 | `GT_INTERVAL_MIN=3` |
|
||||
|
||||
**GT v3 신호 체계**
|
||||
|
||||
| 코드 | 유형 | 10년 GT 건수(대략) |
|
||||
|------|------|-------------------|
|
||||
| B | 스윙 저점 매수 | 944 |
|
||||
| B* | 눌림목 | 406 |
|
||||
| B^ | 돌파 | 122 |
|
||||
| Bd | 상승 다이버전스 | 115 |
|
||||
| S | 스윙 고점 매도 | 944 |
|
||||
| Sd | 하락 다이버전스 | 144 |
|
||||
|
||||
**티어**
|
||||
|
||||
| 티어 | 포함 신호 |
|
||||
|------|-----------|
|
||||
| v1 | B / S |
|
||||
| v2 | + B* |
|
||||
| v3 | + B^ / Bd / Sd |
|
||||
|
||||
**산출물**
|
||||
|
||||
- `data/spot/ground_truth/ground_truth_trades_v{1,2,3}.json`
|
||||
- `docs/spot/0_ground_truth/ground_truth_chart_v*.html`
|
||||
|
||||
---
|
||||
|
||||
### spot 1단계 — GT sim (수익 상한선)
|
||||
|
||||
**목적:** GT v3 타점을 **그대로** sim했을 때 3년 수익률. “이론적 상한” 벤치마크.
|
||||
|
||||
| 스크립트 | `scripts/1_ground_truth_sim.py` |
|
||||
|----------|--------------------------------|
|
||||
| sim 기간 | `GT_SIM_LOOKBACK_DAYS=1095` (3년) |
|
||||
| 초기 자본 | `GT_INITIAL_CASH_KRW=200,000` |
|
||||
| 엔진 | `simulate_gt_signals_pnl` (슬리피지 없음) |
|
||||
|
||||
**산출물:** `docs/spot/1_simulation/ground_truth_chart_sim_v*.html`
|
||||
|
||||
**참고:** GT는 사후 타점이므로 **실거래 불가**. 1단계 수익률은 “최적 타점을 보수적으로 따라갔을 때”의 기준선.
|
||||
|
||||
---
|
||||
|
||||
### spot 2단계 — 인과 기법 분석 (39종)
|
||||
|
||||
**목적:** 미래 데이터 **없이** GT v3를 얼마나 재현하는지 39종 기법을 평가. 3단계 전략 선택 근거.
|
||||
|
||||
| 순서 | 스크립트 | 산출물 |
|
||||
|------|----------|--------|
|
||||
| 2-1 | `2_run_techniques.py` | `data/spot/techniques/*.json`, `comparison_report.html` |
|
||||
| 2-2 | `2_run_causal_sim.py` | `causal_sim_report.html` |
|
||||
| 2-3 | `2_run_signal_type_align.py` | `signal_type_report.html` |
|
||||
| 2-4 | `2_run_mtf_analysis.py` | `mtf_rules_v3.json`, `mtf_correlation_report.html` |
|
||||
| 일괄 | `bash scripts/2_run_stage2_all.sh` | 위 전체 |
|
||||
|
||||
**GT 정합 score 상위 (10년, ±480봉 허용)**
|
||||
|
||||
| 순위 | 기법 | score | 비고 |
|
||||
|------|------|-------|------|
|
||||
| 1 | **fractal_swing** | 0.914 | buy/sell recall 100%, **현재 live 운영** |
|
||||
| 2 | pivot_swing | 0.911 | |
|
||||
| 3 | minor_swing | 0.864 | |
|
||||
| 4 | local_extrema | 0.839 | |
|
||||
| 5 | zigzag_causal | 0.776 | 스윙 특화, 저빈도 |
|
||||
| … | composite_v3 | 0.546 | leg recall 22.9% |
|
||||
|
||||
**문서**
|
||||
|
||||
- 설계: [`docs/spot/2_analysis/stage2_design_guide.md`](docs/spot/2_analysis/stage2_design_guide.md)
|
||||
- 결과 해석: [`docs/spot/2_analysis/stage2_final_summary.md`](docs/spot/2_analysis/stage2_final_summary.md)
|
||||
|
||||
---
|
||||
|
||||
### spot 3단계 — paper / live 운영
|
||||
|
||||
**목적:** 선택 기법(`fractal_swing`)을 3분봉 tick으로 paper 또는 live 체결.
|
||||
|
||||
| 스크립트 | 역할 |
|
||||
|----------|------|
|
||||
| `3_run_filtered_backtest.py` | 운영 조건 3년 sim |
|
||||
| `3_run_fractal_realistic_backtest.py` | 슬리피지·일 상한 시나리오 |
|
||||
| `3_render_live_chart.py` | `docs/live/` 매매 차트 |
|
||||
| `3_run_operations.py` | paper/live tick (loop 180초) |
|
||||
| `3_preflight_live.py` / `3_init_live_state.py` | live 사전 점검·상태 초기화 |
|
||||
| `3_reconcile_signals.py` | backlog inspect / execute |
|
||||
| `3_audit_ops_safety.py` | 놓침·중복 종합 점검 |
|
||||
| `3_watch_ops.py` | read-only 감시 + 불일치 조치 |
|
||||
| `3_run_watch_cron.sh` | cron 5분용 래퍼 |
|
||||
| `bash scripts/3_run_fractal_live.sh` | preflight → init → live loop |
|
||||
|
||||
**설계:** [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md)
|
||||
(초版은 composite_v3 중심 — **현재 `.env` 기본은 fractal_swing**)
|
||||
|
||||
---
|
||||
|
||||
## 수익률 정리
|
||||
|
||||
**공통 조건:** BTC · 3분봉 · sim 기간 **최근 3년(1095일)** · 초기 자본 **200,000원** · 편도 수수료 **0.05%** (`GT_TRADING_FEE_RATE`)
|
||||
|
||||
> sim 수익률은 **과거 데이터 재생** 결과입니다. live는 슬리피지·체결 지연·유동성·운영 오류로 **달라질 수 있으며**, 특히 고빈도 전략은 슬리피지에 극도로 민감합니다.
|
||||
|
||||
### 1단계 — GT v3 벤치마크
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 3년 수익률 | **+94,154%** |
|
||||
| 최종 평가 | 약 1.89억 원 |
|
||||
| 매수/매도 체결 | 239 / 151 |
|
||||
| 의미 | 사후 최적 타점을 보수적으로 sim |
|
||||
|
||||
### 2단계 — 인과 sim (슬리피지 0, 일 상한 없음)
|
||||
|
||||
| 기법 | 3년 수익률 | 최종 평가(약) | 매수/매도 체결 | 일평균 매수 |
|
||||
|------|-----------|--------------|---------------|------------|
|
||||
| **fractal_swing** | **+7,560,826%** | 151억 | 56,893 / 56,892 | **~52회** |
|
||||
| pivot_swing | +4,687,495% | 94억 | 12,656 / 12,658 | ~12회 |
|
||||
| minor_swing | +286,537% | 5.7억 | 831 / 887 | ~0.8회 |
|
||||
| zigzag_causal | +92,711% | 1.86억 | 97 / 97 | ~0.09회 |
|
||||
| composite_v3 | **-97.5%** | ~5,000원 | 1,885 / 1,237 | — |
|
||||
| composite_v3 + MTF (3단계 필터) | +3.37% | — | — | — |
|
||||
|
||||
출처: `docs/spot/2_analysis/stage2_final_summary.md`, `stage2_parity_sweep.json`
|
||||
|
||||
### 3단계 — fractal_swing 운영 백테스트 (슬리피지 반영)
|
||||
|
||||
| 시나리오 | 슬리피지 | 일 체결 상한 | 3년 수익률 | 매수 체결 | 비고 |
|
||||
|----------|---------|-------------|-----------|----------|------|
|
||||
| **stage2 ideal** | 0% | 없음 | **+7,560,826%** | 56,893 | 2단계와 동일 |
|
||||
| **ops_default** | 0.05% | 100 | **+1,873,140%** | 53,589 | sizing_rules 100% (cluster1) |
|
||||
| **ops + sizing 튜닝** | 0.05% | 10,000 | **+2,307,905%** | 56,773 | cluster 100% (`sizing_rules.json`) |
|
||||
| slippage 0.1% | 0.1% | 100 | **-97.5%** | 3,964 | **실거래 리스크** |
|
||||
| slippage 0.1% (상한 없음) | 0.1% | 없음 | -97.5% | 3,962 | 동일 |
|
||||
|
||||
출처: `fractal_realistic_backtest.json`, `fractal_filtered_backtest_report.json` (2026-06-14)
|
||||
|
||||
**해석**
|
||||
|
||||
- 2단계 ideal 대비 ops_default는 약 **24.8%** 수준 (`fractal_ops_vs_stage2.json`) — 슬리피지·일 상한·분할 매매 반영.
|
||||
- 슬리피지 **0.05% → 0.1%**만 올려도 sim은 **-97.5%**로 붕괴 → live에서 체결가·수수료 관리가 핵심.
|
||||
- `1_tune_order_sizing.py`로 cluster별 100% sizing 튜닝 시 sim **+2,307,905%** (`sizing_rules.json` + `.env` `OPS_*_PCT=1.0`).
|
||||
|
||||
### live vs 백테스트
|
||||
|
||||
| 구분 | 백테스트 | live |
|
||||
|------|---------|------|
|
||||
| 실행 | 3년 일괄 재생 | 180초 tick 누적 |
|
||||
| 체결가 | 모델 슬리피지 | 빗썸 시장가 + 실제 스프레드 |
|
||||
| 신호 | 캐시+tail | 동일 파이프라인 + ledger |
|
||||
| 기대 | sim 수치 | sim **이하**가 정상 |
|
||||
|
||||
---
|
||||
|
||||
## 3단계 live 운영
|
||||
|
||||
### tick 아키텍처
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph tick["3_run_operations.py tick (권장 180초)"]
|
||||
A[sync_ops_candles<br/>전 TF 증분 INSERT] --> B[generate_raw_signals<br/>캐시 + tail 800봉 갱신]
|
||||
B --> C[filter_signals_for_ops<br/>MTF·TrendGate 선택]
|
||||
C --> D[OperationsRunner<br/>bar 단위 클러스터 체결]
|
||||
D --> E{paper / live}
|
||||
E -->|paper| F[PaperExecutor<br/>모델 슬리피지 체결]
|
||||
E -->|live| G[LiveExecutor<br/>빗썸 시장가]
|
||||
F --> H[TelegramNotifier<br/>체결 알림]
|
||||
G --> H
|
||||
H --> I[state.json + ops_report.json]
|
||||
subgraph loop["3_run_operations.py --loop 180"]
|
||||
A[sync_ops_candles] --> B[generate_raw_signals<br/>force_tail_refresh]
|
||||
B --> C[filter_signals_for_ops]
|
||||
C --> D[exchange reconcile]
|
||||
D --> E[stale backlog 정산]
|
||||
E --> F[ledger pending 체결]
|
||||
F --> G[state.json + report]
|
||||
end
|
||||
J[3_run_filtered_backtest.py] --> K[simulate_gt_signals_pnl<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` |
|
||||
|------|---------------------|-----------|-------------|
|
||||
| **fractal_swing** (MTF off) | **+1,873,140%** (슬리피지 0.05%, 일 100회) | 일 ~50회 매수 | **기본값** |
|
||||
| fractal_swing ideal (2단계) | +7,560,826% (슬리피지 0, 상한 없음) | 일 ~52회 | 연구용 |
|
||||
| composite_v3 + MTF on | +3.37% | 낮음 | `.env.example` 주석 참고 |
|
||||
### live 시작
|
||||
|
||||
상세 해석: [`docs/spot/2_analysis/stage2_final_summary.md`](docs/spot/2_analysis/stage2_final_summary.md)
|
||||
```bash
|
||||
conda activate ncue # 또는 xavis
|
||||
export PYTHONPATH=src
|
||||
|
||||
# 1) 백테스트 확인
|
||||
python scripts/3_run_filtered_backtest.py
|
||||
|
||||
# 2) live (preflight + init + loop)
|
||||
bash scripts/3_run_fractal_live.sh
|
||||
|
||||
# 또는 직접
|
||||
python scripts/3_run_operations.py --mode live --loop 180
|
||||
```
|
||||
|
||||
### watch cron (5분)
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
# 추가:
|
||||
*/5 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_watch_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/watch_cron.log 2>&1
|
||||
```
|
||||
|
||||
### vol_breakout cron (다운로드 + tick + 모니터)
|
||||
|
||||
TRX/NEAR/WLD 15m flip 운영용. 한 번에 등록:
|
||||
|
||||
```bash
|
||||
bash scripts/install_crontab.sh --apply
|
||||
crontab -l # 확인
|
||||
```
|
||||
|
||||
| cron | 주기 | 스크립트 | 로그 |
|
||||
|------|------|----------|------|
|
||||
| 캔들 증분 | 1분 | `00_run_download_cron.sh` | `data/common/download_cron.log` |
|
||||
| vol tick | 1분 | `3_run_vol_breakout_cron.sh` | `data/spot/operations/vol_breakout_cron.log` |
|
||||
| 모니터 JSON | 5분 | `3_run_vol_monitor_cron.sh` | `data/spot/operations/vol_monitor_cron.log` |
|
||||
|
||||
- hung 프로세스: 다운로드 20분·vol tick 10분 초과 시 자동 종료 후 lock 정리 (`scripts/_cron_env.sh`)
|
||||
- Python: `coin` / `ncue` conda 우선. 다른 환경이면 `.env` 또는 crontab에 `BITHUMB_PYTHON=...` 설정
|
||||
- 모니터 UI (8766):
|
||||
```bash
|
||||
bash scripts/install_vol_monitor_launchd.sh --install # 권장 — 로그인·재부팅 후 자동 기동
|
||||
bash scripts/3_run_vol_monitor_serve.sh # 수동 1회 기동
|
||||
bash scripts/install_vol_monitor_launchd.sh --status # 상태 확인
|
||||
```
|
||||
→ http://127.0.0.1:8766/vol_live_monitor.html
|
||||
Binance 모니터는 **8765** — 포트가 다릅니다.
|
||||
HTTP 서버(8766)는 **별도 터미널에서 수동 실행**:
|
||||
```bash
|
||||
python scripts/3_run_vol_monitor.py
|
||||
```
|
||||
|
||||
점검:
|
||||
|
||||
```bash
|
||||
tail -f data/common/download_cron.log
|
||||
tail -f data/spot/operations/vol_breakout_cron.log
|
||||
bash scripts/00_run_download_cron.sh # 수동 1회
|
||||
bash scripts/3_run_vol_breakout_cron.sh
|
||||
```
|
||||
|
||||
### fractal watch 점검
|
||||
|
||||
```bash
|
||||
python scripts/3_audit_ops_safety.py # PASS 목표
|
||||
python scripts/3_reconcile_signals.py --dry-run # pending 0 목표
|
||||
python scripts/3_watch_ops.py --inspect-only
|
||||
```
|
||||
|
||||
### live 체크리스트
|
||||
|
||||
1. `.env`: `OPS_MODE=live`, API 키, `OPS_EXCHANGE_RECONCILE=true`
|
||||
2. `3_run_filtered_backtest.py` 수익률 확인
|
||||
3. paper 1~2일 또는 소액 live 모니터링
|
||||
4. `fractal_ops_report.json` — tick duration, `ledger_pending_count`, `exchange_reconciled_count`
|
||||
5. 텔레그램 체결·WATCH 알림 확인
|
||||
|
||||
---
|
||||
|
||||
## 설치·실행
|
||||
|
||||
### 요구사항
|
||||
|
||||
- Python 3.10+
|
||||
- Conda `ncue` 또는 `xavis`
|
||||
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
cd bithumb
|
||||
conda activate ncue
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # API 키·텔레그램 등
|
||||
```
|
||||
|
||||
### 전체 파이프라인 (최초 1회)
|
||||
|
||||
```bash
|
||||
export PYTHONPATH=src
|
||||
|
||||
python scripts/00_download.py --full
|
||||
python scripts/0_ground_truth.py --interval 3 --days 3650 --tier all
|
||||
python scripts/1_ground_truth_sim.py --tier all
|
||||
bash scripts/2_run_stage2_all.sh
|
||||
python scripts/3_run_filtered_backtest.py
|
||||
python scripts/3_render_live_chart.py
|
||||
```
|
||||
|
||||
### fractal 운영 (일상)
|
||||
|
||||
```bash
|
||||
bash scripts/3_run_fractal_ops.sh # backtest + paper loop
|
||||
python scripts/3_run_operations.py --loop 180 --mode live
|
||||
bash scripts/3_run_watch_cron.sh # 감시 1회 (또는 cron)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
Bithumb/
|
||||
bithumb/
|
||||
├── src/bithumb/
|
||||
│ ├── api/ # 빗썸 Public·Private REST
|
||||
│ ├── data/ # 캔들 수집·DB·로더
|
||||
│ ├── ground_truth/ # GT 타점·sim·차트
|
||||
│ ├── techniques/ # 39종 인과 기법
|
||||
│ ├── mtf/ # MTF 피처·필터·규칙
|
||||
│ ├── evaluation/ # 2단계 리포트·인과 sim
|
||||
│ ├── operations/ # 3단계 운영 (runner·executor·sync·backtest)
|
||||
│ └── notifications/ # 텔레그램 체결 알림
|
||||
├── scripts/ # 단계별 CLI
|
||||
│
|
||||
│ ├── api/ # Public·Private REST
|
||||
│ ├── data/ # 캔들 수집·DB
|
||||
│ ├── ground_truth/ # GT·sim·pnl 엔진
|
||||
│ ├── techniques/ # 39종 인과 기법
|
||||
│ ├── mtf/ # MTF 필터·규칙
|
||||
│ ├── evaluation/ # 2단계 리포트
|
||||
│ ├── operations/ # runner·executor·ledger·watch·reconcile
|
||||
│ └── notifications/ # 텔레그램
|
||||
├── scripts/ # 단계별 CLI·shell
|
||||
├── data/
|
||||
│ ├── common/coins.db # 공유 캔들 OHLCV
|
||||
│ ├── spot/
|
||||
│ │ ├── ground_truth/ # 0단계 GT JSON
|
||||
│ │ ├── techniques/ # 2단계 기법 결과 (fractal_swing.json 등)
|
||||
│ │ ├── mtf/ # mtf_rules_v3.json
|
||||
│ │ └── operations/ # fractal_ops_state.json
|
||||
│
|
||||
│ ├── common/coins.db
|
||||
│ └── spot/
|
||||
│ ├── ground_truth/
|
||||
│ ├── techniques/
|
||||
│ ├── mtf/
|
||||
│ └── operations/ # state·sizing·lock·pid
|
||||
└── docs/
|
||||
├── live/ # 운영 백테스트 매매 차트 (index.html)
|
||||
├── live/ # 운영 백테스트 차트
|
||||
└── spot/
|
||||
├── 0_ground_truth/ # GT 차트 HTML
|
||||
├── 1_simulation/ # 1단계 sim 차트
|
||||
├── 2_analysis/ # 2단계 리포트·설계 가이드
|
||||
└── 3_operations/ # 운영·백테스트 JSON 리포트
|
||||
```
|
||||
|
||||
테이블명: `{SYMBOL}_{인터벌분}` (예: `BTC_3`, `BTC_1440`). 인터벌: 분봉=분 숫자, 일=`1440`, 주=`10080`, 월=`43200`.
|
||||
|
||||
---
|
||||
|
||||
## 파이프라인 실행 순서
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[00_download] --> B[0_ground_truth]
|
||||
B --> C[1_ground_truth_sim]
|
||||
C --> D[2_run_stage2_all]
|
||||
D --> E[3_run_operations]
|
||||
```
|
||||
|
||||
| 순서 | 단계 | 스크립트 | 산출물 |
|
||||
|------|------|----------|--------|
|
||||
| 0 | common | `00_download.py` | `data/common/coins.db` |
|
||||
| 1 | spot 0단계 | `0_ground_truth.py` | `data/spot/ground_truth/`, `docs/spot/0_ground_truth/` |
|
||||
| 2 | spot 1단계 | `1_ground_truth_sim.py` | `docs/spot/1_simulation/` |
|
||||
| 3 | spot 2단계 | `2_run_*.py`, `2_run_stage2_all.sh` | `data/spot/techniques/`, `docs/spot/2_analysis/` |
|
||||
| 4 | spot 3단계 | `3_run_*.py`, `3_run_fractal_ops.sh` | `data/spot/operations/`, `docs/spot/3_operations/` |
|
||||
|
||||
### 권장 명령
|
||||
|
||||
```bash
|
||||
conda activate ncue
|
||||
export PYTHONPATH=src
|
||||
|
||||
# common
|
||||
python scripts/00_download.py # 증분 갱신
|
||||
python scripts/00_download.py --full # 최초·재구축
|
||||
|
||||
# spot 0~2단계 (분석·기법 캐시 생성)
|
||||
python scripts/0_ground_truth.py --interval 3 --days 3650 --tier all
|
||||
python scripts/1_ground_truth_sim.py --tier all
|
||||
bash scripts/2_run_stage2_all.sh
|
||||
|
||||
# spot 3단계 — fractal_swing 운영
|
||||
python scripts/3_run_filtered_backtest.py # 운영 조건 3년 sim 검증
|
||||
python scripts/3_render_live_chart.py # docs/live 매매 차트
|
||||
python scripts/3_run_fractal_realistic_backtest.py # 슬리피지 시나리오
|
||||
bash scripts/3_run_fractal_ops.sh # 백테스트 + paper 180초 loop
|
||||
python scripts/3_run_operations.py --loop 180 --mode live # live (API 키 필요)
|
||||
├── 0_ground_truth/
|
||||
├── 1_simulation/
|
||||
├── 2_analysis/
|
||||
└── 3_operations/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 단계별 상세
|
||||
## 환경 변수
|
||||
|
||||
### common — 캔들 수집
|
||||
전체: `.env.example`. 카테고리별 요약.
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| DB | `data/common/coins.db` (`DB_PATH`) |
|
||||
| 증분 갱신 | DB 최신 시각 이후만 API 조회·INSERT |
|
||||
| 전체 재수집 | `--full` |
|
||||
| TF | `DOWNLOAD_INTERVALS` (기본 11개) |
|
||||
### 공통·GT
|
||||
|
||||
운영 tick에서는 `sync_ops_candles()`가 subprocess 대신 **in-process** 증분 sync를 수행합니다 (`OPS_SYNC_CANDLES=true`).
|
||||
| 변수 | 설명 | 기본 |
|
||||
|------|------|------|
|
||||
| `SYMBOL` | 코인 | `BTC` |
|
||||
| `DB_PATH` | 캔들 DB | `data/common/coins.db` |
|
||||
| `DOWNLOAD_DAYS` | 수집·GT 기간(일) | `3650` |
|
||||
| `GT_INTERVAL_MIN` | GT·운영 봉(분) | `3` |
|
||||
| `GT_SIM_LOOKBACK_DAYS` | sim·백테스트(일) | `1095` |
|
||||
| `GT_INITIAL_CASH_KRW` | sim 초기 자본 | `200000` |
|
||||
| `GT_TRADING_FEE_RATE` | 편도 수수료 | `0.0005` |
|
||||
|
||||
### spot 0단계 — GT 타점
|
||||
### 3단계 운영 (fractal live)
|
||||
|
||||
사후 최적 매매 타점. 실거래 불가, 이후 단계의 벤치마크.
|
||||
| 변수 | 설명 | 기본 |
|
||||
|------|------|------|
|
||||
| `OPS_MODE` | `paper` / `live` | `paper` |
|
||||
| `OPS_TECHNIQUE_ID` | 기법 | `fractal_swing` |
|
||||
| `OPS_MTF_ENABLED` | MTF 필터 | `false` |
|
||||
| `OPS_SLIPPAGE_RATE` | 편도 슬리피지 | `0.0005` |
|
||||
| `OPS_DAILY_MAX_TRADES` | 일 체결 상한 | `100` (live `.env`는 10000) |
|
||||
| `OPS_CATCHUP_BARS` | catchup 봉 | `480` |
|
||||
| `OPS_LEDGER_LOOKBACK_DAYS` | ledger 스캔 | `1` |
|
||||
| `OPS_LEDGER_EXECUTE_MAX_AGE_MINUTES` | backlog API 허용 | `45` |
|
||||
| `OPS_LIVE_FORCE_TAIL_REFRESH` | live tail 재계산 | `true` |
|
||||
| `OPS_EXCHANGE_RECONCILE` | 거래소 대조 | `true` |
|
||||
| `OPS_TICK_LOCK_PATH` | tick flock | `ops.tick.lock` |
|
||||
| `OPS_LOOP_PID_FILE` | loop PID | `ops_loop.pid` |
|
||||
| `OPS_WATCH_SIGNAL_GRACE_MIN` | 감시 grace | `5` |
|
||||
| `OPS_WATCH_TICK_STALE_MIN` | tick stale → 재시작 | `12` |
|
||||
| `OPS_WATCH_AUTO_REMEDIATE` | 조치 tick | `true` |
|
||||
| `OPS_WATCH_AUTO_RESTART` | loop 재시작 | `true` |
|
||||
| `COIN_TELEGRAM_*` | 체결·WATCH 알림 | — |
|
||||
| `BITHUMB_ACCESS_KEY` / `SECRET` | live API | — |
|
||||
|
||||
| 티어 | 신호 |
|
||||
|------|------|
|
||||
| v1 | 스윙 B/S |
|
||||
| v2 | + 눌림목 B* |
|
||||
| v3 | + 돌파 B^ + 다이버전스 Bd/Sd |
|
||||
|
||||
산출: `data/spot/ground_truth/ground_truth_trades_v{1,2,3}.json`, `docs/spot/0_ground_truth/ground_truth_chart_v*.html`
|
||||
|
||||
### spot 1단계 — GT sim
|
||||
|
||||
GT 타점 완벽 추종 시 3년 수익 상한선. 초기 20만 원, `GT_SIM_LOOKBACK_DAYS=1095`.
|
||||
|
||||
산출: `docs/spot/1_simulation/ground_truth_chart_sim_v*.html`
|
||||
|
||||
### spot 2단계 — 인과 기법 분석
|
||||
|
||||
39종 기법의 GT 정합·3년 sim·신호 유형·MTF 상관 분석.
|
||||
|
||||
| 스크립트 | 산출물 |
|
||||
|----------|--------|
|
||||
| `2_run_techniques.py` | `data/spot/techniques/`, `comparison_report.html` |
|
||||
| `2_run_causal_sim.py` | `causal_sim_report.html`, `technique_chart_sim_*.html` |
|
||||
| `2_run_signal_type_align.py` | `signal_type_report.html` |
|
||||
| `2_run_mtf_analysis.py` | `mtf_rules_v3.json`, `mtf_correlation_report.html` |
|
||||
|
||||
설계: [`docs/spot/2_analysis/stage2_design_guide.md`](docs/spot/2_analysis/stage2_design_guide.md)
|
||||
결과 정리: [`docs/spot/2_analysis/stage2_final_summary.md`](docs/spot/2_analysis/stage2_final_summary.md)
|
||||
|
||||
### spot 3단계 — fractal_swing live 운영
|
||||
|
||||
설계 가이드: [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md)
|
||||
(가이드 초版은 composite_v3 중심 — **현재 운영 기본값은 fractal_swing**)
|
||||
|
||||
#### 백테스트 실적 (BTC, 3년, 초기 20만원)
|
||||
|
||||
| 조건 | 수익률 | 매수 체결 | 비고 |
|
||||
|------|--------|-----------|------|
|
||||
| 운영 백테스트 | **+1,873,140%** | ~53,500 | 슬리피지 0.05%, 일 100회, MTF off |
|
||||
| 2단계 ideal | +7,560,826% | ~56,893 | 슬리피지 0, 상한 없음 |
|
||||
|
||||
#### 스크립트·산출물
|
||||
|
||||
| 스크립트 | 산출물 |
|
||||
|----------|--------|
|
||||
| `3_run_filtered_backtest.py` | `fractal_filtered_backtest_report.json` |
|
||||
| `3_render_live_chart.py` | `docs/live/index.html`, `fractal_swing_ops_chart.html` |
|
||||
| `3_run_fractal_realistic_backtest.py` | `fractal_realistic_backtest.json` |
|
||||
| `3_run_operations.py` | `fractal_ops_report.json`, `fractal_ops_state.json` |
|
||||
| `3_run_fractal_ops.sh` | 백테스트 + paper 180초 loop |
|
||||
|
||||
#### 운영 tick 동작
|
||||
|
||||
1. **캔들 sync** — `OPS_SYNC_INTERVALS` 비우면 `DOWNLOAD_INTERVALS` 전체 TF, `db_max` 이후만 INSERT
|
||||
2. **신호** — 2단계 캐시 JSON 로드; DB 최신 봉 > 캐시 max bar 시 **tail 800봉** fractal 재계산·병합 (`OPS_SIGNAL_TAIL_BARS`)
|
||||
3. **필터** — tick당 **최신 봉** 신호만 MTF 평가 (`OPS_MTF_ENABLED=false` 시 스킵)
|
||||
4. **체결** — bar 단위 클러스터 분할, 일 `OPS_DAILY_MAX_TRADES` 상한
|
||||
5. **알림** — 체결 성공 시 텔레그램; live 실패 시 사유 포함 알림
|
||||
6. **저장** — `OPS_STATE_JSON`, `OPS_REPORT_JSON`
|
||||
|
||||
#### live 전환 체크리스트
|
||||
|
||||
1. `python scripts/3_run_filtered_backtest.py` → **약 +1,873,140%** 확인
|
||||
2. `.env`: `OPS_MODE=live`, `BITHUMB_ACCESS_KEY` / `BITHUMB_SECRET_KEY`
|
||||
3. `python scripts/3_run_operations.py --loop 180` (paper 1~2일 모니터링 권장)
|
||||
4. `fractal_ops_report.json` — `candle_sync`, `signal_refresh`, 체결 건수 확인
|
||||
5. 텔레그램 체결 알림 동작 확인 (`COIN_TELEGRAM_*`)
|
||||
|
||||
**주의:** 백테스트는 3년 **일괄 재생** sim, live는 **tick 누적**. 실거래 체결가는 모델 슬리피지보다 불리할 수 있습니다.
|
||||
|
||||
#### composite_v3 + MTF (대안 운영 프로필)
|
||||
|
||||
`.env.example` 주석 참고:
|
||||
### composite_v3 대안 프로필
|
||||
|
||||
```env
|
||||
OPS_TECHNIQUE_ID=composite_v3
|
||||
@@ -268,148 +465,46 @@ OPS_DAILY_MAX_TRADES=20
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수
|
||||
|
||||
전체 목록: `.env.example`. 주요 항목만 정리합니다.
|
||||
|
||||
### 공통·GT
|
||||
|
||||
| 변수 | 설명 | 기본값 |
|
||||
|------|------|--------|
|
||||
| `SYMBOL` | 코인 심볼 | `BTC` |
|
||||
| `DB_PATH` | 캔들 DB | `data/common/coins.db` |
|
||||
| `DOWNLOAD_DAYS` | 수집·GT 기간(일) | `3650` |
|
||||
| `DOWNLOAD_INTERVALS` | 수집 TF 목록 | 11개 TF |
|
||||
| `GT_INTERVAL_MIN` | GT·기법·운영 기준 봉(분) | `3` |
|
||||
| `GT_LOOKBACK_DAYS` | GT·기법 lookback | `3650` |
|
||||
| `GT_SIM_LOOKBACK_DAYS` | sim·백테스트 기간 | `1095` (3년) |
|
||||
| `GT_INITIAL_CASH_KRW` | sim·paper 초기 자본 | `200000` |
|
||||
| `GT_TRADING_FEE_RATE` | 편도 수수료 | `0.0005` |
|
||||
|
||||
### spot 3단계 운영 (fractal 기본)
|
||||
|
||||
| 변수 | 설명 | 기본값 |
|
||||
|------|------|--------|
|
||||
| `OPS_MODE` | `paper` / `live` | `paper` |
|
||||
| `OPS_TECHNIQUE_ID` | 운영 기법 | `fractal_swing` |
|
||||
| `OPS_MTF_ENABLED` | MTF 필터 | `false` |
|
||||
| `OPS_TREND_GATE_ENABLED` | 고TF trend gate | `false` |
|
||||
| `OPS_DAILY_MAX_TRADES` | 일일 체결 상한 | `100` |
|
||||
| `OPS_MIN_ORDER_KRW` | 최소 주문(원) | `5000` |
|
||||
| `OPS_SLIPPAGE_RATE` | 편도 슬리피지 | `0.0005` (0.05%) |
|
||||
| `OPS_ORDER_INTERVAL_SEC` | live 주문 간격(초) | `0.35` |
|
||||
| `OPS_SYNC_CANDLES` | tick 캔들 증분 sync | `true` |
|
||||
| `OPS_SYNC_INTERVALS` | sync TF (비우면 전체) | 전체 |
|
||||
| `OPS_SIGNAL_TAIL_BARS` | 신호 tail 재계산 봉 | `800` |
|
||||
| `OPS_PERSIST_SIGNAL_CACHE` | tail 후 JSON 저장 | `false` |
|
||||
| `OPS_STATE_JSON` | 운영 상태 | `fractal_ops_state.json` |
|
||||
| `OPS_REPORT_JSON` | tick 리포트 | `fractal_ops_report.json` |
|
||||
| `OPS_FILTERED_BACKTEST_JSON` | 백테스트 리포트 | `fractal_filtered_backtest_report.json` |
|
||||
| `COIN_TELEGRAM_BOT_TOKEN` | 텔레그램 Bot | (비우면 알림 off) |
|
||||
| `COIN_TELEGRAM_CHAT_ID` | 텔레그램 chat ID | |
|
||||
| `OPS_TELEGRAM_ENABLED` | 체결 알림 | 토큰·chat_id 있으면 자동 on |
|
||||
| `BITHUMB_ACCESS_KEY` | live API | — |
|
||||
| `BITHUMB_SECRET_KEY` | live API | — |
|
||||
|
||||
### 경로 변수 요약
|
||||
|
||||
| 용도 | 변수 | 기본 경로 |
|
||||
|------|------|-----------|
|
||||
| spot GT | `GROUND_TRUTH_FILE` | `data/spot/ground_truth/...` |
|
||||
| spot 기법 | `TECHNIQUES_DIR` | `data/spot/techniques/` |
|
||||
| spot MTF | `MTF_RULES_JSON` | `data/spot/mtf/mtf_rules_v3.json` |
|
||||
| spot 운영 상태 | `OPS_STATE_JSON` | `data/spot/operations/fractal_ops_state.json` |
|
||||
| spot 운영 리포트 | `OPS_REPORT_JSON` | `docs/spot/3_operations/fractal_ops_report.json` |
|
||||
| live 차트 | `3_render_live_chart.py` | `docs/live/` |
|
||||
|
||||
---
|
||||
|
||||
## 소스 모듈 (spot 3단계)
|
||||
## 소스 모듈
|
||||
|
||||
| 모듈 | 역할 |
|
||||
|------|------|
|
||||
| `operations/runner.py` | tick 오케스트레이션 |
|
||||
| `operations/candle_sync.py` | 전 TF 증분 캔들 sync |
|
||||
| `operations/signal_pipeline.py` | 신호 생성·캐시·tail 갱신·MTF 필터 |
|
||||
| `operations/runner.py` | tick·ledger·stale·watchdog |
|
||||
| `operations/exchange_reconcile.py` | 거래소 체결 ↔ 원장 |
|
||||
| `operations/watch_ops.py` | 5분 감시·조치 |
|
||||
| `operations/ops_lock.py` | flock |
|
||||
| `operations/candle_sync.py` | 증분 캔들 sync |
|
||||
| `operations/signal_pipeline.py` | 신호 tail·MTF |
|
||||
| `operations/executor.py` | paper/live 체결 |
|
||||
| `operations/execution.py` | 슬리피지 `fill_price` |
|
||||
| `operations/trade_engine.py` | 매수·매도 사이징·포트폴리오 |
|
||||
| `operations/backtest.py` | 운영 조건 3년 sim |
|
||||
| `operations/chart.py` | `docs/live` 백테스트 차트 |
|
||||
| `operations/state_store.py` | 운영 상태 JSON |
|
||||
| `ground_truth/pnl.py` | sim 엔진 (백테스트·2단계 공용) |
|
||||
| `api/bithumb_private.py` | live 잔고·시장가 주문 |
|
||||
| `notifications/telegram.py` | 체결 텔레그램 알림 |
|
||||
| `operations/trade_engine.py` | 사이징·포트폴리오 |
|
||||
| `operations/backtest.py` | 3년 sim |
|
||||
| `ground_truth/pnl.py` | sim 엔진 (2·3단계 공용) |
|
||||
| `api/bithumb_private.py` | 잔고·주문·done 조회 |
|
||||
| `notifications/telegram.py` | 체결·오류·WATCH |
|
||||
|
||||
---
|
||||
|
||||
## 현물 2단계 인과 기법 (39종)
|
||||
## 39종 인과 기법
|
||||
|
||||
`src/bithumb/techniques/` — 단일 33 + 복합 6, 미래 데이터 미사용.
|
||||
`src/bithumb/techniques/` — 단일 33 + 복합 6, **미래 데이터 미사용**.
|
||||
|
||||
| ID | 기법 | 유형 |
|
||||
|----|------|------|
|
||||
| `zigzag_causal` | 인과 ZigZag | 스윙 B/S |
|
||||
| `minor_swing` | 소형 스윙 하이브리드 | 하이브리드 |
|
||||
| `local_extrema` | 국소 극값 | 스윙 B/S |
|
||||
| `pivot_swing` | 피벗 스윙 | 스윙 B/S |
|
||||
| `fractal_swing` | 프랙탈 스윙 | 스윙 B/S |
|
||||
| `swing_failure` | 스윙 실패 | 스윙 B/S |
|
||||
| `donchian` | 돈치안 채널 | 스윙 B/S |
|
||||
| `ema_pullback` | EMA 눌림목 | 눌림목 B* |
|
||||
| `fib_pullback` | 피보나치 눌림목 | 눌림목 B* |
|
||||
| `support_bounce` | 지지·저항 반등 | 눌림목 B* |
|
||||
| `keltner_breakout` | Keltner 돌파 | 돌파 B^ |
|
||||
| `range_breakout` | 레인지 돌파 | 돌파 B^ |
|
||||
| `volume_breakout` | 거래량 돌파 | 돌파 B^ |
|
||||
| `bb_squeeze_breakout` | BB 스퀴즈 돌파 | 돌파 B^ |
|
||||
| `rsi_divergence` | RSI 다이버전스 | Bd/Sd |
|
||||
| `macd_divergence` | MACD 다이버전스 | Bd/Sd |
|
||||
| `obv_divergence` | OBV 다이버전스 | Bd/Sd |
|
||||
| `bb_reversal` | 볼린저 역추세 | 지표 |
|
||||
| `ma_cross` | EMA 크로스 | 지표 |
|
||||
| `rsi_swing` | RSI 스윙 | 지표 |
|
||||
| `macd_cross` | MACD 크로스 | 지표 |
|
||||
| `supertrend` | Supertrend | 추세 |
|
||||
| `adx_trend` | ADX 추세 | 추세 |
|
||||
| `ichimoku_trend` | 일목 추세 | 추세 |
|
||||
| `parabolic_sar` | Parabolic SAR | 추세 |
|
||||
| `stochastic_cross` | Stochastic 크로스 | 모멘텀 |
|
||||
| `cci_extreme` | CCI 극값 | 모멘텀 |
|
||||
| `roc_reversal` | ROC 반전 | 모멘텀 |
|
||||
| `keltner_reversal` | Keltner 역추세 | 변동성 |
|
||||
| `atr_channel` | ATR 채널 | 변동성 |
|
||||
| `pivot_points` | 피벗 포인트 | 구조 |
|
||||
| `support_resistance` | 구조적 지지·저항 | 구조 |
|
||||
| `volume_spike` | 거래량 스파이크 | 거래량 |
|
||||
| `composite_swing` | 스윙 복합 | 복합 |
|
||||
| `composite_pullback` | 눌림목 복합 | 복합 |
|
||||
| `composite_breakout` | 돌파 복합 | 복합 |
|
||||
| `composite_divergence` | 다이버전스 복합 | 복합 |
|
||||
| `composite_v3` | v3 통합 스코어링 | 복합 |
|
||||
| `composite_full` | 전체 통합 복합 | 복합 |
|
||||
|
||||
---
|
||||
|
||||
## 구현 현황
|
||||
|
||||
| 유형 | 단계 | 상태 |
|
||||
|------|------|------|
|
||||
| common | 캔들 수집·증분 sync | 구현됨 |
|
||||
| spot | 0~2단계 (GT·기법·MTF) | 구현됨 |
|
||||
| spot | 3단계 (fractal paper/live·백테스트·텔레그램) | **구현됨** |
|
||||
| `fractal_swing` | 프랙탈 스윙 | 스윙 (**live**) |
|
||||
| `zigzag_causal` | 인과 ZigZag | 스윙 |
|
||||
| `pivot_swing` | 피벗 스윙 | 스윙 |
|
||||
| `minor_swing` | 소형 스윙 | 하이브리드 |
|
||||
| `local_extrema` | 국소 극값 | 스윙 |
|
||||
| `composite_v3` | v3 통합 | 복합 |
|
||||
| … | (전체 39종) | `techniques/registry.py` 참고 |
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
- 2026-06-13: 프로젝트명 **Bithumb**으로 변경, 선물(futures) 파이프라인 제거
|
||||
- 2026-06-13: 텔레그램 매수·매도 체결 알림 (`notifications/telegram.py`)
|
||||
- 2026-06-13: `docs/live/` 운영 백테스트 매매 차트 (`3_render_live_chart.py`)
|
||||
- 2026-06-13: fractal_swing live 운영 — 슬리피지·일 체결 상한·전 TF 증분 sync·신호 tail 갱신
|
||||
- 2026-06-13: 운영 백테스트 **+1,873,140%** (3년, 슬리피지 0.05%, 일 100회) 검증
|
||||
- 2026-06-12: `data/`·`docs/` common/spot 구조 재편
|
||||
- 2026-06-12: 3단계 운영 파이프라인 초기 구현 (composite_v3 + MTF paper/live)
|
||||
- 2026-06-12: 2단계 인과 기법 분석 파이프라인 완료
|
||||
- 2026-06-08: Ground Truth v1/v2/v3
|
||||
- 2026-06-07: 캔들 수집 모듈 초기 구현
|
||||
- **2026-06-14:** ledger pending, exchange reconcile, max_age backlog, watch 5분 감시·조치, ops.tick.lock, README 전면 갱신
|
||||
- **2026-06-13:** fractal_swing live — 슬리피지·sync·tail·텔레그램; ops_default sim **+1,873,140%**
|
||||
- **2026-06-13:** 프로젝트명 Bithumb, 선물 파이프라인 제거
|
||||
- **2026-06-12:** data/docs common·spot 구조, 2단계 39종 완료, 3단계 초기 (composite_v3)
|
||||
- **2026-06-08:** GT v1/v2/v3
|
||||
- **2026-06-07:** 캔들 수집 모듈
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"generated_at": "2026-06-13 18:04:03",
|
||||
"technique_id": "fractal_swing",
|
||||
"symbol": "BTC",
|
||||
"symbol": "TRX",
|
||||
"default_buy_cash_pct": 1.0,
|
||||
"default_sell_coin_pct": 1.0,
|
||||
"by_cluster": {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 보완, 접근 방식 비교 정리 |
|
||||
@@ -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단계 체크리스트 |
|
||||
@@ -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 필터 백테스트, 운영 스크립트 |
|
||||
@@ -16,7 +16,7 @@ if str(SRC) not in sys.path:
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from bithumb.config import load_settings
|
||||
from bithumb.config import load_settings, resolve_coin_name
|
||||
from bithumb.data.candle_store import CandleStore
|
||||
from bithumb.data.downloader import CandleDownloader
|
||||
from bithumb.data.intervals import INTERVAL_1MIN, estimate_download_requests, interval_label
|
||||
@@ -32,6 +32,58 @@ def _configure_logging(verbose: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _parse_symbols(raw: str | None, defaults: list[str]) -> list[str]:
|
||||
"""CLI --symbols 또는 기본 DOWNLOAD_SYMBOLS 목록."""
|
||||
if raw:
|
||||
return [part.strip().upper() for part in raw.split(",") if part.strip()]
|
||||
return list(defaults)
|
||||
|
||||
|
||||
def _log_interval_estimates(
|
||||
*,
|
||||
store: CandleStore,
|
||||
symbol: str,
|
||||
intervals: list[int],
|
||||
days: int,
|
||||
full: bool,
|
||||
batch_size: int,
|
||||
sleep_sec: float,
|
||||
log: logging.Logger,
|
||||
) -> None:
|
||||
"""인터벌별 예상 API 요청 수를 로깅한다."""
|
||||
for interval in intervals:
|
||||
if full:
|
||||
est = estimate_download_requests(interval, days, batch_size=batch_size)
|
||||
log.info(
|
||||
"예상 API 요청: %s %s ≈ %s회 (풀 다운, sleep %.2fs)",
|
||||
symbol,
|
||||
interval_label(interval),
|
||||
est,
|
||||
sleep_sec,
|
||||
)
|
||||
continue
|
||||
_, _, db_max = store.get_range(symbol, interval)
|
||||
if db_max is None:
|
||||
est = estimate_download_requests(interval, days, batch_size=batch_size)
|
||||
log.info(
|
||||
"예상 API 요청: %s %s ≈ %s회 (DB 없음 → 풀 다운)",
|
||||
symbol,
|
||||
interval_label(interval),
|
||||
est,
|
||||
)
|
||||
else:
|
||||
gap_days = max(1, (datetime.now() - db_max).days + 1)
|
||||
est = estimate_download_requests(interval, gap_days, batch_size=batch_size)
|
||||
log.info(
|
||||
"예상 API 요청: %s %s ≈ %s회 (증분, DB=%s, 갭≈%s일)",
|
||||
symbol,
|
||||
interval_label(interval),
|
||||
est,
|
||||
db_max.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
gap_days,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI 진입점."""
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -54,6 +106,12 @@ def main() -> int:
|
||||
default=None,
|
||||
help="(고급) 쉼표 구분 인터벌만 수집. 기본: .env DOWNLOAD_INTERVALS 전체",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--symbols",
|
||||
type=str,
|
||||
default=None,
|
||||
help="쉼표 구분 심볼 (기본: .env DOWNLOAD_SYMBOLS 또는 SYMBOL)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-1min",
|
||||
action="store_true",
|
||||
@@ -63,8 +121,10 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
|
||||
_configure_logging(args.verbose)
|
||||
settings = load_settings()
|
||||
base_settings = load_settings()
|
||||
symbols = _parse_symbols(args.symbols, base_settings.download_symbols)
|
||||
|
||||
settings = base_settings
|
||||
if args.intervals:
|
||||
settings = replace(
|
||||
settings,
|
||||
@@ -83,76 +143,59 @@ def main() -> int:
|
||||
log = logging.getLogger(__name__)
|
||||
log.info(
|
||||
"대상=%s DB=%s mode=%s days=%s intervals=%s",
|
||||
settings.market,
|
||||
",".join(f"KRW-{s}" for s in symbols),
|
||||
settings.db_path,
|
||||
mode_label,
|
||||
days,
|
||||
settings.download_intervals,
|
||||
)
|
||||
for interval in settings.download_intervals:
|
||||
est = estimate_download_requests(interval, days, batch_size=settings.candle_count)
|
||||
log.info(
|
||||
"예상 API 요청: %s ≈ %s회 (sleep %.2fs)",
|
||||
interval_label(interval),
|
||||
est,
|
||||
settings.request_sleep_sec,
|
||||
)
|
||||
|
||||
store = CandleStore(settings.db_path)
|
||||
exit_code = 0
|
||||
try:
|
||||
for interval in settings.download_intervals:
|
||||
if args.full:
|
||||
est = estimate_download_requests(interval, days, batch_size=settings.candle_count)
|
||||
log.info(
|
||||
"예상 API 요청: %s ≈ %s회 (풀 다운, sleep %.2fs)",
|
||||
interval_label(interval),
|
||||
est,
|
||||
settings.request_sleep_sec,
|
||||
)
|
||||
else:
|
||||
_, _, db_max = store.get_range(settings.symbol, interval)
|
||||
if db_max is None:
|
||||
est = estimate_download_requests(interval, days, batch_size=settings.candle_count)
|
||||
log.info(
|
||||
"예상 API 요청: %s ≈ %s회 (DB 없음 → 풀 다운)",
|
||||
interval_label(interval),
|
||||
est,
|
||||
)
|
||||
else:
|
||||
gap_days = max(1, (datetime.now() - db_max).days + 1)
|
||||
est = estimate_download_requests(interval, gap_days, batch_size=settings.candle_count)
|
||||
log.info(
|
||||
"예상 API 요청: %s ≈ %s회 (증분, DB=%s, 갭≈%s일)",
|
||||
interval_label(interval),
|
||||
est,
|
||||
db_max.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
gap_days,
|
||||
)
|
||||
|
||||
downloader = CandleDownloader(settings)
|
||||
results = downloader.download_all(store, days=days, full=args.full)
|
||||
|
||||
print(f"\n=== 수집 완료 ({mode_label}) ===")
|
||||
for result in results:
|
||||
count, min_dt, max_dt = store.get_range(settings.symbol, result.interval_min)
|
||||
min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-"
|
||||
max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-"
|
||||
if result.mode == "uptodate":
|
||||
flag = "UPTODATE"
|
||||
elif result.reached_target:
|
||||
flag = "OK"
|
||||
else:
|
||||
flag = "PARTIAL"
|
||||
label = interval_label(result.interval_min)
|
||||
print(
|
||||
f"[{flag}] {label} ({result.interval_min}) mode={result.mode} | "
|
||||
f"requests={result.requests} upsert={result.saved_rows} "
|
||||
f"db_rows={count} range={min_s} ~ {max_s}"
|
||||
for symbol in symbols:
|
||||
symbol_settings = replace(
|
||||
settings,
|
||||
symbol=symbol,
|
||||
coin_name=resolve_coin_name(symbol),
|
||||
)
|
||||
print(f"\n=== {symbol} ({symbol_settings.coin_name}) ===")
|
||||
_log_interval_estimates(
|
||||
store=store,
|
||||
symbol=symbol,
|
||||
intervals=symbol_settings.download_intervals,
|
||||
days=days,
|
||||
full=args.full,
|
||||
batch_size=symbol_settings.candle_count,
|
||||
sleep_sec=symbol_settings.request_sleep_sec,
|
||||
log=log,
|
||||
)
|
||||
|
||||
downloader = CandleDownloader(symbol_settings)
|
||||
results = downloader.download_all(store, days=days, full=args.full)
|
||||
|
||||
print(f"\n--- {symbol} 수집 완료 ({mode_label}) ---")
|
||||
for result in results:
|
||||
count, min_dt, max_dt = store.get_range(symbol, result.interval_min)
|
||||
min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-"
|
||||
max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-"
|
||||
if result.mode == "uptodate":
|
||||
flag = "UPTODATE"
|
||||
elif result.reached_target:
|
||||
flag = "OK"
|
||||
else:
|
||||
flag = "PARTIAL"
|
||||
exit_code = 1
|
||||
label = interval_label(result.interval_min)
|
||||
print(
|
||||
f"[{flag}] {symbol} {label} ({result.interval_min}) mode={result.mode} | "
|
||||
f"requests={result.requests} upsert={result.saved_rows} "
|
||||
f"db_rows={count} range={min_s} ~ {max_s}"
|
||||
)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
return 0
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
15
scripts/00_run_download_cron.sh
Executable file
15
scripts/00_run_download_cron.sh
Executable 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 "$@"
|
||||
229
scripts/3_audit_ops_safety.py
Normal file
229
scripts/3_audit_ops_safety.py
Normal 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())
|
||||
14
scripts/3_ensure_vol_monitor_serve.sh
Executable file
14
scripts/3_ensure_vol_monitor_serve.sh
Executable 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"
|
||||
78
scripts/3_reconcile_signals.py
Normal file
78
scripts/3_reconcile_signals.py
Normal 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())
|
||||
13
scripts/3_refresh_vol_monitor.py
Normal file
13
scripts/3_refresh_vol_monitor.py
Normal 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__")
|
||||
@@ -63,7 +63,7 @@ def _write_index_html(
|
||||
<body>
|
||||
<h1>Bithumb Live — 운영 백테스트</h1>
|
||||
<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)}일 ·
|
||||
슬리피지 {report.get("slippage_rate", 0) * 100:.2f}% ·
|
||||
일 체결 상한 {report.get("daily_max_trades", "-")} ·
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -18,6 +20,20 @@ from bithumb.config import load_settings
|
||||
from bithumb.operations.runner import OperationsRunner
|
||||
|
||||
|
||||
def _write_loop_pid(path: Path) -> None:
|
||||
"""loop PID 파일 기록 (watch 재시작용)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(f"{os.getpid()}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _remove_loop_pid(path: Path) -> None:
|
||||
"""loop 종료 시 PID 파일 삭제."""
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _configure_logging(verbose: bool) -> None:
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
logging.basicConfig(
|
||||
@@ -66,7 +82,12 @@ def main() -> int:
|
||||
runner = OperationsRunner(settings)
|
||||
sync = not args.no_sync
|
||||
|
||||
if args.loop > 0 and settings.ops_loop_pid_file is not None:
|
||||
_write_loop_pid(settings.ops_loop_pid_file)
|
||||
atexit.register(_remove_loop_pid, settings.ops_loop_pid_file)
|
||||
|
||||
while True:
|
||||
loop_started = time.monotonic()
|
||||
try:
|
||||
report = runner.tick(sync_candles=sync)
|
||||
except Exception as exc:
|
||||
@@ -81,7 +102,9 @@ def main() -> int:
|
||||
)
|
||||
if args.loop <= 0:
|
||||
break
|
||||
time.sleep(args.loop)
|
||||
elapsed = time.monotonic() - loop_started
|
||||
sleep_sec = max(0.0, float(args.loop) - elapsed)
|
||||
time.sleep(sleep_sec)
|
||||
continue
|
||||
|
||||
port = report.get("portfolio") or {}
|
||||
@@ -100,10 +123,18 @@ def main() -> int:
|
||||
f"코인 {port.get('coin_qty', 0):.8f} {settings.symbol}"
|
||||
)
|
||||
print(f"리포트: {settings.ops_report_json}")
|
||||
if report.get("ledger_pending_count") is not None:
|
||||
print(
|
||||
f"ledger pending: {report.get('ledger_pending_count')} · "
|
||||
f"backlog dropped: {report.get('backlog_dropped_count', 0)} · "
|
||||
f"tick: {report.get('last_tick_duration_sec')}s"
|
||||
)
|
||||
|
||||
if args.loop <= 0:
|
||||
break
|
||||
time.sleep(args.loop)
|
||||
elapsed = time.monotonic() - loop_started
|
||||
sleep_sec = max(0.0, float(args.loop) - elapsed)
|
||||
time.sleep(sleep_sec)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
87
scripts/3_run_vol_breakout.py
Normal file
87
scripts/3_run_vol_breakout.py
Normal 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())
|
||||
14
scripts/3_run_vol_breakout_cron.sh
Executable file
14
scripts/3_run_vol_breakout_cron.sh
Executable 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
315
scripts/3_run_vol_monitor.py
Executable 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())
|
||||
14
scripts/3_run_vol_monitor_cron.sh
Executable file
14
scripts/3_run_vol_monitor_cron.sh
Executable 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 "$@"
|
||||
55
scripts/3_run_vol_monitor_serve.sh
Executable file
55
scripts/3_run_vol_monitor_serve.sh
Executable 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
10
scripts/3_run_watch_cron.sh
Executable 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 "$@"
|
||||
13
scripts/3_serve_vol_monitor.py
Normal file
13
scripts/3_serve_vol_monitor.py
Normal 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
103
scripts/3_watch_ops.py
Normal 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
67
scripts/_cron_env.sh
Executable 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
|
||||
}
|
||||
30
scripts/com.bithumb.vol-monitor.plist.template
Normal file
30
scripts/com.bithumb.vol-monitor.plist.template
Normal 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>
|
||||
17
scripts/crontab.bithumb.example
Normal file
17
scripts/crontab.bithumb.example
Normal 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
88
scripts/install_crontab.sh
Executable 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
|
||||
82
scripts/install_vol_monitor_launchd.sh
Executable file
82
scripts/install_vol_monitor_launchd.sh
Executable 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
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
@@ -77,7 +79,11 @@ class BithumbPrivateClient:
|
||||
) -> dict[str, Any]:
|
||||
"""인증 요청을 수행한다."""
|
||||
url = f"{self.base_url}{path}"
|
||||
body = dumps_params(params) if params else None
|
||||
body: str | None = None
|
||||
if params and method.upper() == "GET":
|
||||
url = f"{url}?{urlencode(params, doseq=True)}"
|
||||
elif params:
|
||||
body = dumps_params(params)
|
||||
headers = auth_headers(
|
||||
self.access_key,
|
||||
self.secret_key,
|
||||
@@ -182,3 +188,78 @@ class BithumbPrivateClient:
|
||||
"ord_type": "market",
|
||||
}
|
||||
return self._request("POST", "/v1/orders", params=params)
|
||||
|
||||
def get_orders(
|
||||
self,
|
||||
market: str,
|
||||
*,
|
||||
state: str | None = None,
|
||||
limit: int = 100,
|
||||
page: int = 1,
|
||||
order_by: str = "desc",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""주문 목록 조회 (GET /v1/orders)."""
|
||||
params: dict[str, Any] = {
|
||||
"market": market,
|
||||
"limit": int(limit),
|
||||
"page": int(page),
|
||||
"order_by": order_by,
|
||||
}
|
||||
if state:
|
||||
params["state"] = state
|
||||
payload = self._request("GET", "/v1/orders", params=params)
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def fetch_filled_orders_since(
|
||||
self,
|
||||
market: str,
|
||||
since: datetime,
|
||||
*,
|
||||
max_pages: int = 20,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""since 이후 체결 완료(done) 주문을 수집한다."""
|
||||
filled: list[dict[str, Any]] = []
|
||||
since_naive = since.replace(tzinfo=None)
|
||||
for page in range(1, max_pages + 1):
|
||||
orders = self.get_orders(
|
||||
market,
|
||||
state="done",
|
||||
limit=100,
|
||||
page=page,
|
||||
order_by="desc",
|
||||
)
|
||||
if not orders:
|
||||
break
|
||||
stop = False
|
||||
for order in orders:
|
||||
created = parse_exchange_datetime(str(order.get("created_at", "")))
|
||||
if created is None:
|
||||
continue
|
||||
if created.replace(tzinfo=None) < since_naive:
|
||||
stop = True
|
||||
break
|
||||
if float(order.get("executed_volume") or 0) <= 0:
|
||||
continue
|
||||
filled.append(order)
|
||||
if stop or len(orders) < 100:
|
||||
break
|
||||
return filled
|
||||
|
||||
|
||||
def parse_exchange_datetime(value: str) -> datetime | None:
|
||||
"""빗썸 주문 created_at 파싱."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
pass
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(value, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
@@ -21,6 +21,31 @@ def _resolve_project_path(raw: str) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _resolve_project_path_optional(raw: str) -> Path | None:
|
||||
"""비어 있으면 None, 아니면 프로젝트 루트 기준 절대 경로."""
|
||||
if not raw or not raw.strip():
|
||||
return None
|
||||
return _resolve_project_path(raw)
|
||||
|
||||
|
||||
def _parse_symbol_list(raw: str) -> list[str]:
|
||||
"""쉼표 구분 심볼 목록을 파싱한다."""
|
||||
if not raw or not raw.strip():
|
||||
return []
|
||||
return [part.strip().upper() for part in raw.split(",") if part.strip()]
|
||||
|
||||
|
||||
def resolve_coin_name(symbol: str) -> str:
|
||||
"""심볼에 대응하는 한글 코인명을 반환한다."""
|
||||
names = {
|
||||
"BTC": "비트코인",
|
||||
"TRX": "트론",
|
||||
"NEAR": "니어프로토콜",
|
||||
"WLD": "월드코인",
|
||||
}
|
||||
return names.get(symbol.upper(), symbol.upper())
|
||||
|
||||
|
||||
def _parse_int_list(raw: str) -> list[int]:
|
||||
"""쉼표 구분 정수 목록을 파싱한다.
|
||||
|
||||
@@ -45,6 +70,7 @@ class Settings:
|
||||
candle_count: int
|
||||
download_intervals: list[int]
|
||||
download_days: int
|
||||
download_symbols: list[str]
|
||||
db_path: Path
|
||||
request_sleep_sec: float
|
||||
request_retries: int
|
||||
@@ -112,6 +138,43 @@ class Settings:
|
||||
ops_signal_tail_bars: int
|
||||
ops_persist_signal_cache: bool
|
||||
ops_catchup_bars: int
|
||||
ops_ledger_lookback_days: int
|
||||
ops_ledger_execute_max_age_minutes: int
|
||||
ops_max_backlog_per_tick: int
|
||||
ops_live_force_tail_refresh: bool
|
||||
ops_watchdog_stale_sec: int
|
||||
ops_tick_warn_sec: int
|
||||
ops_log_json: Path | None
|
||||
ops_exchange_reconcile: bool
|
||||
ops_exchange_order_lookback_hours: int
|
||||
ops_exchange_match_window_min: int
|
||||
ops_tick_lock_path: Path | None
|
||||
ops_loop_pid_file: Path | None
|
||||
ops_watch_signal_grace_min: int
|
||||
ops_watch_tick_stale_min: int
|
||||
ops_watch_lookback_min: int
|
||||
ops_watch_auto_remediate: bool
|
||||
ops_watch_auto_restart: bool
|
||||
ops_watch_loop_start_cmd: str
|
||||
# vol_breakout 현물 롱 (Binance 이식)
|
||||
ops_symbols: list[str]
|
||||
vol_state_json: Path
|
||||
vol_lookback: int
|
||||
vol_atr_mult: float
|
||||
vol_lookback_days: int
|
||||
vol_wallet_pct: float
|
||||
vol_buy_split: int
|
||||
vol_max_bars_per_tick: int
|
||||
vol_exit_enabled: bool
|
||||
vol_trail_activate_pct: float
|
||||
vol_trail_pct: float
|
||||
vol_trail_atr_mult: float
|
||||
vol_stop_pct: float
|
||||
vol_tick_lock_path: Path | None
|
||||
vol_kill_switch_path: Path | None
|
||||
vol_monitor_json: Path
|
||||
vol_monitor_html: Path
|
||||
vol_monitor_days: int
|
||||
telegram_bot_token: str
|
||||
telegram_chat_id: str
|
||||
ops_telegram_enabled: bool
|
||||
@@ -148,13 +211,19 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
||||
if not tech_dir.is_absolute():
|
||||
tech_dir = _PROJECT_ROOT / tech_dir
|
||||
|
||||
symbol = os.getenv("SYMBOL", "BTC").upper()
|
||||
download_symbols = _parse_symbol_list(os.getenv("DOWNLOAD_SYMBOLS", ""))
|
||||
if not download_symbols:
|
||||
download_symbols = [symbol]
|
||||
|
||||
return Settings(
|
||||
symbol=os.getenv("SYMBOL", "BTC").upper(),
|
||||
coin_name=os.getenv("COIN_NAME", "비트코인"),
|
||||
symbol=symbol,
|
||||
coin_name=os.getenv("COIN_NAME", resolve_coin_name(symbol)),
|
||||
api_url=os.getenv("BITHUMB_API_URL", "https://api.bithumb.com").rstrip("/"),
|
||||
candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")),
|
||||
download_intervals=intervals,
|
||||
download_days=int(os.getenv("DOWNLOAD_DAYS", "3650")),
|
||||
download_symbols=download_symbols,
|
||||
db_path=db_path,
|
||||
request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")),
|
||||
request_retries=int(os.getenv("API_REQUEST_RETRIES", "3")),
|
||||
@@ -297,7 +366,84 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
||||
ops_signal_tail_bars=int(os.getenv("OPS_SIGNAL_TAIL_BARS", "800")),
|
||||
ops_persist_signal_cache=os.getenv("OPS_PERSIST_SIGNAL_CACHE", "false").strip().lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
ops_catchup_bars=int(os.getenv("OPS_CATCHUP_BARS", "10")),
|
||||
ops_catchup_bars=int(os.getenv("OPS_CATCHUP_BARS", "480")),
|
||||
ops_ledger_lookback_days=int(os.getenv("OPS_LEDGER_LOOKBACK_DAYS", "3")),
|
||||
ops_ledger_execute_max_age_minutes=int(
|
||||
os.getenv("OPS_LEDGER_EXECUTE_MAX_AGE_MINUTES", "45")
|
||||
),
|
||||
ops_max_backlog_per_tick=int(os.getenv("OPS_MAX_BACKLOG_PER_TICK", "20")),
|
||||
ops_live_force_tail_refresh=os.getenv(
|
||||
"OPS_LIVE_FORCE_TAIL_REFRESH", "true"
|
||||
).strip().lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
ops_watchdog_stale_sec=int(os.getenv("OPS_WATCHDOG_STALE_SEC", "600")),
|
||||
ops_tick_warn_sec=int(os.getenv("OPS_TICK_WARN_SEC", "120")),
|
||||
ops_log_json=_resolve_project_path_optional(
|
||||
os.getenv("OPS_LOG_JSON", "data/spot/operations/live_run.log")
|
||||
),
|
||||
ops_exchange_reconcile=os.getenv("OPS_EXCHANGE_RECONCILE", "true").strip().lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
ops_exchange_order_lookback_hours=int(
|
||||
os.getenv("OPS_EXCHANGE_ORDER_LOOKBACK_HOURS", "48")
|
||||
),
|
||||
ops_exchange_match_window_min=int(
|
||||
os.getenv("OPS_EXCHANGE_MATCH_WINDOW_MIN", "720")
|
||||
),
|
||||
ops_tick_lock_path=_resolve_project_path_optional(
|
||||
os.getenv("OPS_TICK_LOCK_PATH", "data/spot/operations/ops.tick.lock")
|
||||
),
|
||||
ops_loop_pid_file=_resolve_project_path_optional(
|
||||
os.getenv("OPS_LOOP_PID_FILE", "data/spot/operations/ops_loop.pid")
|
||||
),
|
||||
ops_watch_signal_grace_min=int(os.getenv("OPS_WATCH_SIGNAL_GRACE_MIN", "5")),
|
||||
ops_watch_tick_stale_min=int(os.getenv("OPS_WATCH_TICK_STALE_MIN", "12")),
|
||||
ops_watch_lookback_min=int(os.getenv("OPS_WATCH_LOOKBACK_MIN", "30")),
|
||||
ops_watch_auto_remediate=os.getenv("OPS_WATCH_AUTO_REMEDIATE", "true")
|
||||
.strip()
|
||||
.lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
ops_watch_auto_restart=os.getenv("OPS_WATCH_AUTO_RESTART", "true")
|
||||
.strip()
|
||||
.lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
ops_watch_loop_start_cmd=os.getenv("OPS_WATCH_LOOP_START_CMD", "").strip(),
|
||||
ops_symbols=_parse_symbol_list(os.getenv("OPS_SYMBOLS", "")) or [
|
||||
s for s in download_symbols if s != "BTC"
|
||||
],
|
||||
vol_state_json=_resolve_project_path(
|
||||
os.getenv("VOL_STATE_JSON", "data/spot/operations/vol_breakout_state.json")
|
||||
),
|
||||
vol_lookback=int(os.getenv("VOL_LOOKBACK", "14")),
|
||||
vol_atr_mult=float(os.getenv("VOL_ATR_MULT", "2.0")),
|
||||
vol_lookback_days=int(os.getenv("VOL_LOOKBACK_DAYS", "60")),
|
||||
vol_wallet_pct=float(os.getenv("VOL_WALLET_PCT", "0.30")),
|
||||
vol_buy_split=int(os.getenv("VOL_BUY_SPLIT", "0")),
|
||||
vol_max_bars_per_tick=int(os.getenv("VOL_MAX_BARS_PER_TICK", "5")),
|
||||
vol_exit_enabled=os.getenv("VOL_EXIT_ENABLED", "false").strip().lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
vol_trail_activate_pct=float(os.getenv("VOL_TRAIL_ACTIVATE_PCT", "1.5")),
|
||||
vol_trail_pct=float(os.getenv("VOL_TRAIL_PCT", "0.0025")),
|
||||
vol_trail_atr_mult=float(os.getenv("VOL_TRAIL_ATR_MULT", "0.5")),
|
||||
vol_stop_pct=float(os.getenv("VOL_STOP_PCT", "-12.0")),
|
||||
vol_tick_lock_path=_resolve_project_path_optional(
|
||||
os.getenv("VOL_TICK_LOCK_PATH", "data/spot/operations/vol.tick.lock")
|
||||
),
|
||||
vol_kill_switch_path=_resolve_project_path_optional(
|
||||
os.getenv("VOL_KILL_SWITCH_PATH", "data/spot/operations/vol.kill")
|
||||
),
|
||||
vol_monitor_json=_resolve_project_path(
|
||||
os.getenv(
|
||||
"VOL_MONITOR_JSON",
|
||||
"docs/spot/3_operations/vol_live_chart.json",
|
||||
)
|
||||
),
|
||||
vol_monitor_html=_resolve_project_path(
|
||||
os.getenv(
|
||||
"VOL_MONITOR_HTML",
|
||||
"docs/spot/3_operations/vol_live_monitor.html",
|
||||
)
|
||||
),
|
||||
vol_monitor_days=int(os.getenv("VOL_MONITOR_DAYS", "14")),
|
||||
telegram_bot_token=os.getenv("COIN_TELEGRAM_BOT_TOKEN", "").strip(),
|
||||
telegram_chat_id=os.getenv("COIN_TELEGRAM_CHAT_ID", "").strip(),
|
||||
ops_telegram_enabled=_parse_ops_telegram_enabled(
|
||||
|
||||
59
src/bithumb/data/candle_bars.py
Normal file
59
src/bithumb/data/candle_bars.py
Normal 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)
|
||||
@@ -20,7 +20,7 @@ def load_candles(
|
||||
|
||||
Args:
|
||||
db_path: SQLite 경로.
|
||||
symbol: 코인 심볼 (예: BTC).
|
||||
symbol: 코인 심볼 (예: TRX).
|
||||
interval_min: 분 단위 인터벌 코드.
|
||||
lookback_days: 최근 N일만 사용. None이면 전체.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
import pandas as pd
|
||||
|
||||
from bithumb.api.bithumb import parse_kst_datetime
|
||||
from bithumb.data.candle_bars import is_closed_candle, only_closed_candles
|
||||
|
||||
|
||||
class CandleStore:
|
||||
@@ -90,6 +91,31 @@ class CandleStore:
|
||||
|
||||
return int(row[0]), parse_kst_datetime(str(row[1])), parse_kst_datetime(str(row[2]))
|
||||
|
||||
def delete_incomplete_tail(
|
||||
self,
|
||||
symbol: str,
|
||||
interval_min: int,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> int:
|
||||
"""DB 최신 봉이 미마감이면 삭제한다 (조기 저장 잔재 정리).
|
||||
|
||||
Returns:
|
||||
삭제된 행 수.
|
||||
"""
|
||||
_, _, db_max = self.get_range(symbol, interval_min)
|
||||
if db_max is None:
|
||||
return 0
|
||||
if is_closed_candle(db_max, interval_min, now=now):
|
||||
return 0
|
||||
table = self.table_name(symbol, interval_min)
|
||||
cur = self._conn.execute(
|
||||
f"DELETE FROM {table} WHERE CODE = ? AND ymdhms = ?",
|
||||
(symbol.upper(), db_max.strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
def read_dataframe(self, symbol: str, interval_min: int) -> pd.DataFrame:
|
||||
"""캔들을 pandas DataFrame으로 읽는다.
|
||||
|
||||
@@ -154,6 +180,11 @@ class CandleStore:
|
||||
Returns:
|
||||
저장(시도) 행 수.
|
||||
"""
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
rows = only_closed_candles(rows, interval_min)
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
@@ -211,6 +242,11 @@ class CandleStore:
|
||||
Returns:
|
||||
실제 INSERT된 행 수.
|
||||
"""
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
rows = only_closed_candles(rows, interval_min)
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
|
||||
from bithumb.api.bithumb import BithumbCandleClient, parse_kst_datetime
|
||||
from bithumb.config import Settings
|
||||
from bithumb.data.candle_bars import only_closed_candles
|
||||
from bithumb.data.candle_store import CandleStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -106,15 +107,9 @@ class CandleDownloader:
|
||||
stop_at = target_from
|
||||
else:
|
||||
mode = "incremental"
|
||||
if db_max >= datetime.now() - timedelta(minutes=max(interval_min, 1)):
|
||||
return DownloadResult(
|
||||
interval_min=interval_min,
|
||||
mode="uptodate",
|
||||
requests=0,
|
||||
saved_rows=0,
|
||||
reached_target=True,
|
||||
)
|
||||
stop_at = db_max - timedelta(minutes=interval_min)
|
||||
store.delete_incomplete_tail(symbol, interval_min)
|
||||
_, _, db_max = store.get_range(symbol, interval_min)
|
||||
stop_at = db_max - timedelta(minutes=interval_min) if db_max else target_from
|
||||
|
||||
to_kst: datetime | None = None
|
||||
requests = 0
|
||||
@@ -132,28 +127,30 @@ class CandleDownloader:
|
||||
if not candles:
|
||||
break
|
||||
|
||||
rows = _candle_rows_from_api(candles)
|
||||
if not rows:
|
||||
all_rows = _candle_rows_from_api(candles)
|
||||
if not all_rows:
|
||||
break
|
||||
|
||||
if mode == "incremental" and db_max is not None:
|
||||
inserted = store.insert_new_rows(
|
||||
symbol,
|
||||
self.settings.coin_name,
|
||||
interval_min,
|
||||
rows,
|
||||
after=db_max,
|
||||
)
|
||||
else:
|
||||
inserted = store.upsert_rows(
|
||||
symbol,
|
||||
self.settings.coin_name,
|
||||
interval_min,
|
||||
rows,
|
||||
)
|
||||
saved_rows += inserted
|
||||
batch_oldest = min(parse_kst_datetime(r[0]) for r in all_rows)
|
||||
rows = only_closed_candles(all_rows, interval_min)
|
||||
if rows:
|
||||
if mode == "incremental" and db_max is not None:
|
||||
refresh_from = db_max - timedelta(minutes=interval_min * 48)
|
||||
rows = [
|
||||
r for r in rows
|
||||
if parse_kst_datetime(str(r[0])) >= refresh_from
|
||||
]
|
||||
if rows:
|
||||
saved_rows += store.upsert_rows(
|
||||
symbol,
|
||||
self.settings.coin_name,
|
||||
interval_min,
|
||||
rows,
|
||||
)
|
||||
|
||||
if mode == "incremental":
|
||||
break
|
||||
|
||||
batch_oldest = min(parse_kst_datetime(r[0]) for r in rows)
|
||||
if oldest_seen is None or batch_oldest < oldest_seen:
|
||||
oldest_seen = batch_oldest
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from bithumb.evaluation.gt_align import GT_SIGNAL_TYPES
|
||||
|
||||
Operator = Literal["<=", ">="]
|
||||
|
||||
# BTC 가격 스케일에 민감한 지표는 자동 규칙에서 제외
|
||||
# 고가 코인 가격 스케일에 민감한 지표는 자동 규칙에서 제외
|
||||
_EXCLUDED_AUTO_FEATURES: frozenset[str] = frozenset({"macd_hist", "zigzag_leg_pct", "close"})
|
||||
|
||||
# 자동 규칙에 사용할 안정 피처
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
@@ -131,6 +132,50 @@ class TelegramNotifier:
|
||||
|
||||
return self.send_message("\n".join(lines))
|
||||
|
||||
def notify_vol_breakout_trade(
|
||||
self,
|
||||
*,
|
||||
mode: str,
|
||||
symbol: str,
|
||||
side: str,
|
||||
price: float,
|
||||
order_krw: float,
|
||||
order_coin: float,
|
||||
equity_krw: float,
|
||||
reason: str = "signal_vol_breakout",
|
||||
ts: str | None = None,
|
||||
pnl_krw: float | None = None,
|
||||
pnl_pct: float | None = None,
|
||||
) -> bool:
|
||||
"""vol_breakout 현물 롱 체결 알림 (Binance live_engine 형식)."""
|
||||
mode_txt = "실거래" if mode == "live" else "페이퍼"
|
||||
market = f"{symbol.upper()}KRW"
|
||||
time_txt = (ts or datetime.now().strftime("%Y-%m-%d %H:%M:%S"))[:19]
|
||||
|
||||
if side == "buy":
|
||||
text = (
|
||||
f"[{mode_txt}] 롱 진입(매수)\n"
|
||||
f"{market} @ {price:,.2f}\n"
|
||||
f"수량 {order_coin:.6f} (≈{order_krw:,.0f}원)\n"
|
||||
f"사유 {reason}\n"
|
||||
f"시각 {time_txt}"
|
||||
)
|
||||
else:
|
||||
pnl_line = ""
|
||||
if pnl_krw is not None and pnl_pct is not None:
|
||||
pnl_line = (
|
||||
f"손익 {pnl_krw:+,.0f}원 ({pnl_pct:+.3f}%)\n"
|
||||
)
|
||||
text = (
|
||||
f"[{mode_txt}] 롱 청산(매도)\n"
|
||||
f"{market} @ {price:,.2f}\n"
|
||||
f"{pnl_line}"
|
||||
f"자본 {equity_krw:,.0f}원\n"
|
||||
f"사유 {reason}\n"
|
||||
f"시각 {time_txt}"
|
||||
)
|
||||
return self.send_message(text)
|
||||
|
||||
def notify_trade_failure(
|
||||
self,
|
||||
*,
|
||||
|
||||
244
src/bithumb/operations/exchange_reconcile.py
Normal file
244
src/bithumb/operations/exchange_reconcile.py
Normal 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
|
||||
97
src/bithumb/operations/multi_portfolio.py
Normal file
97
src/bithumb/operations/multi_portfolio.py
Normal 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"),
|
||||
}
|
||||
48
src/bithumb/operations/ops_lock.py
Normal file
48
src/bithumb/operations/ops_lock.py
Normal 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()
|
||||
76
src/bithumb/operations/reconcile.py
Normal file
76
src/bithumb/operations/reconcile.py
Normal 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,
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -14,8 +14,10 @@ from bithumb.config import Settings
|
||||
from bithumb.ground_truth.pnl import _cluster_signals
|
||||
from bithumb.notifications.telegram import create_telegram_notifier
|
||||
from bithumb.operations.candle_sync import sync_ops_candles
|
||||
from bithumb.operations.exchange_reconcile import reconcile_exchange_fills
|
||||
from bithumb.operations.executor import LiveExecutor, create_executor
|
||||
from bithumb.operations.live_bootstrap import sync_portfolio_from_exchange
|
||||
from bithumb.operations.ops_lock import ops_tick_lock
|
||||
from bithumb.operations.signal_pipeline import (
|
||||
filter_signals_for_ops,
|
||||
generate_raw_signals,
|
||||
@@ -75,6 +77,241 @@ def _reconcile_processed_cursor(
|
||||
state["last_processed_bar_index"] = max_bar_at_or_before
|
||||
|
||||
|
||||
def _is_settled(trade: dict[str, Any]) -> bool:
|
||||
"""trade_history에 완료(체결·의도 스킵)로 기록됐는지."""
|
||||
return bool(trade.get("executed")) or bool(trade.get("expected_skip"))
|
||||
|
||||
|
||||
def _record_status_rank(record: dict[str, Any]) -> int:
|
||||
"""history 레코드 우선순위 (executed > expected_skip > 미정산)."""
|
||||
trade = record.get("trade") or {}
|
||||
if trade.get("executed"):
|
||||
return 3
|
||||
if trade.get("expected_skip"):
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def _history_index(
|
||||
trade_history: list[dict[str, Any]],
|
||||
) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
"""(datetime, side) → 대표 history 레코드 (executed 우선)."""
|
||||
idx: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for record in trade_history:
|
||||
key = (str(record.get("datetime")), str(record.get("side")))
|
||||
existing = idx.get(key)
|
||||
if existing is None or _record_status_rank(record) >= _record_status_rank(
|
||||
existing
|
||||
):
|
||||
idx[key] = record
|
||||
return idx
|
||||
|
||||
|
||||
def _is_signal_api_executable(
|
||||
sig: dict[str, Any],
|
||||
*,
|
||||
max_age_minutes: int,
|
||||
live_since: str | None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""live API 체결 가능 여부 (만료·live 이전 backlog는 스킵).
|
||||
|
||||
Returns:
|
||||
(executable, skip_reason) — skip_reason은 executable=False일 때만 사용.
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now()
|
||||
dt_str = str(sig["datetime"])
|
||||
if live_since and dt_str < live_since:
|
||||
return False, "live 시작 이전 backlog (체결 생략)"
|
||||
if max_age_minutes > 0:
|
||||
sig_dt = _parse_signal_dt(dt_str)
|
||||
age_sec = (now - sig_dt).total_seconds()
|
||||
if age_sec > max_age_minutes * 60:
|
||||
return False, (
|
||||
f"backlog 만료 (신호 {max_age_minutes}분 초과, 체결 생략)"
|
||||
)
|
||||
return True, ""
|
||||
|
||||
|
||||
def _settle_expired_backlog(
|
||||
all_kept: list[dict[str, Any]],
|
||||
trade_history: list[dict[str, Any]],
|
||||
*,
|
||||
max_age_minutes: int,
|
||||
live_since: str | None,
|
||||
lookback_days: int = 3,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""API 없이 만료 backlog를 expected_skip으로 일괄 정산한다."""
|
||||
idx = _history_index(trade_history)
|
||||
scan_start = _ledger_scan_start_datetime(trade_history, lookback_days)
|
||||
now = datetime.now()
|
||||
settled: list[dict[str, Any]] = []
|
||||
for sig in all_kept:
|
||||
dt_str = str(sig.get("datetime", ""))
|
||||
if not dt_str or dt_str < scan_start:
|
||||
continue
|
||||
key = _signal_key(sig)
|
||||
record = idx.get(key)
|
||||
if record is not None and _is_settled(record.get("trade") or {}):
|
||||
continue
|
||||
executable, skip_reason = _is_signal_api_executable(
|
||||
sig,
|
||||
max_age_minutes=max_age_minutes,
|
||||
live_since=live_since,
|
||||
now=now,
|
||||
)
|
||||
if executable:
|
||||
continue
|
||||
settled.append(
|
||||
{
|
||||
"datetime": sig["datetime"],
|
||||
"side": sig["side"],
|
||||
"signal_type": sig.get("signal_type"),
|
||||
"price": sig["price"],
|
||||
"bar_index": int(sig.get("bar_index", -1)),
|
||||
"ledger": True,
|
||||
"backlog_expired": True,
|
||||
"trade": TradeResult(
|
||||
executed=False,
|
||||
side=str(sig["side"]),
|
||||
order_krw=0.0,
|
||||
order_coin=0.0,
|
||||
fee_krw=0.0,
|
||||
price=float(sig.get("price", 0)),
|
||||
skip_reason=skip_reason,
|
||||
expected_skip=True,
|
||||
).to_dict(),
|
||||
"mtf_filter": sig.get("mtf_filter"),
|
||||
}
|
||||
)
|
||||
if settled:
|
||||
logger.info(
|
||||
"backlog 만료 정산 %d건 (max_age=%d분 live_since=%s)",
|
||||
len(settled),
|
||||
max_age_minutes,
|
||||
live_since,
|
||||
)
|
||||
return settled
|
||||
|
||||
|
||||
def _ledger_scan_start_datetime(
|
||||
trade_history: list[dict[str, Any]],
|
||||
lookback_days: int,
|
||||
) -> str:
|
||||
"""ledger pending 스캔 시작 시각 (lookback vs 미정산 최소 시각)."""
|
||||
now = datetime.now()
|
||||
lookback_start = (now - timedelta(days=max(lookback_days, 0))).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
oldest_unsettled: str | None = None
|
||||
for record in trade_history:
|
||||
trade = record.get("trade") or {}
|
||||
if _is_settled(trade):
|
||||
continue
|
||||
dt = str(record.get("datetime", ""))
|
||||
if not dt:
|
||||
continue
|
||||
if oldest_unsettled is None or dt < oldest_unsettled:
|
||||
oldest_unsettled = dt
|
||||
if oldest_unsettled is not None and oldest_unsettled < lookback_start:
|
||||
return oldest_unsettled
|
||||
return lookback_start
|
||||
|
||||
|
||||
def _ledger_pending_signals(
|
||||
kept: list[dict[str, Any]],
|
||||
trade_history: list[dict[str, Any]],
|
||||
*,
|
||||
latest_bar_index: int,
|
||||
lookback_days: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""trade_history 원장 기준 미정산 신호 (datetime·side).
|
||||
|
||||
last_processed_datetime과 무관하게 history에 settled 기록이 없으면 pending.
|
||||
"""
|
||||
idx = _history_index(trade_history)
|
||||
scan_start = _ledger_scan_start_datetime(trade_history, lookback_days)
|
||||
pending: list[dict[str, Any]] = []
|
||||
for sig in kept:
|
||||
bar_idx = int(sig.get("bar_index", -1))
|
||||
if bar_idx > latest_bar_index:
|
||||
continue
|
||||
dt_str = str(sig["datetime"])
|
||||
if dt_str < scan_start:
|
||||
continue
|
||||
key = _signal_key(sig)
|
||||
record = idx.get(key)
|
||||
if record is not None and _is_settled(record.get("trade") or {}):
|
||||
continue
|
||||
pending.append(sig)
|
||||
pending.sort(
|
||||
key=lambda s: (_parse_signal_dt(str(s["datetime"])), str(s.get("side", "")))
|
||||
)
|
||||
return pending
|
||||
|
||||
|
||||
def _advance_cursor_from_ledger(
|
||||
state: dict[str, Any],
|
||||
all_kept: list[dict[str, Any]],
|
||||
trade_history: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""settled history 기준으로 커서를 텔레메트리용으로 갱신한다."""
|
||||
idx = _history_index(trade_history)
|
||||
max_dt: str | None = state.get("last_processed_datetime")
|
||||
max_bar = int(state.get("last_processed_bar_index", -1))
|
||||
for sig in all_kept:
|
||||
key = _signal_key(sig)
|
||||
record = idx.get(key)
|
||||
if record is None or not _is_settled(record.get("trade") or {}):
|
||||
continue
|
||||
dt = str(sig["datetime"])
|
||||
bar = int(sig.get("bar_index", -1))
|
||||
if max_dt is None or dt > max_dt:
|
||||
max_dt = dt
|
||||
if bar > max_bar:
|
||||
max_bar = bar
|
||||
if max_dt is not None:
|
||||
state["last_processed_datetime"] = max_dt
|
||||
state["last_processed_bar_index"] = max_bar
|
||||
|
||||
|
||||
def _apply_backlog_limit(
|
||||
pending_signals: list[dict[str, Any]],
|
||||
max_backlog: int,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""tick당 처리 상한 적용. (truncated, dropped_count)."""
|
||||
if max_backlog <= 0 or len(pending_signals) <= max_backlog:
|
||||
return pending_signals, 0
|
||||
return pending_signals[:max_backlog], len(pending_signals) - max_backlog
|
||||
|
||||
|
||||
def _backlog_summary(pending_signals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""backlog 리포트용 요약."""
|
||||
if not pending_signals:
|
||||
return {
|
||||
"backlog_signal_count": 0,
|
||||
"backlog_oldest_datetime": None,
|
||||
}
|
||||
return {
|
||||
"backlog_signal_count": len(pending_signals),
|
||||
"backlog_oldest_datetime": str(pending_signals[0]["datetime"]),
|
||||
}
|
||||
|
||||
|
||||
def _append_ops_log(settings: Settings, message: str) -> None:
|
||||
"""운영 tick 로그 append."""
|
||||
path = settings.ops_log_json
|
||||
if path is None:
|
||||
return
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as fp:
|
||||
fp.write(message.rstrip() + "\n")
|
||||
except OSError:
|
||||
logger.exception("ops_log append 실패: %s", path)
|
||||
|
||||
|
||||
def _pending_signals_for_ops(
|
||||
kept: list[dict[str, Any]],
|
||||
*,
|
||||
@@ -205,6 +442,7 @@ class OperationsRunner:
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
self._runner_started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.executor = create_executor(settings)
|
||||
self.telegram = create_telegram_notifier(
|
||||
settings.telegram_bot_token,
|
||||
@@ -229,8 +467,33 @@ class OperationsRunner:
|
||||
logger.exception("live 초기 잔고 동기화 실패")
|
||||
self._notify_ops_error("init_sync", exc)
|
||||
|
||||
def tick(self, *, sync_candles: bool | None = None) -> dict[str, Any]:
|
||||
def tick(
|
||||
self,
|
||||
*,
|
||||
sync_candles: bool | None = None,
|
||||
skip_lock: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""신호 확인 및 체결 1회. 예외 발생 시 텔레그램 알림 후 error 리포트 반환."""
|
||||
if skip_lock:
|
||||
return self._tick_with_error_handling(sync_candles=sync_candles)
|
||||
lock_path = self.settings.ops_tick_lock_path
|
||||
if lock_path is None:
|
||||
return self._tick_with_error_handling(sync_candles=sync_candles)
|
||||
with ops_tick_lock(lock_path, blocking=True) as acquired:
|
||||
if not acquired:
|
||||
logger.warning("tick lock 획득 실패 — tick 생략")
|
||||
return self._build_error_report(
|
||||
RuntimeError("tick lock busy"),
|
||||
stage="tick_lock",
|
||||
)
|
||||
return self._tick_with_error_handling(sync_candles=sync_candles)
|
||||
|
||||
def _tick_with_error_handling(
|
||||
self,
|
||||
*,
|
||||
sync_candles: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""tick 본체 + 예외 처리."""
|
||||
try:
|
||||
return self._tick_impl(sync_candles=sync_candles)
|
||||
except Exception as exc:
|
||||
@@ -240,6 +503,15 @@ class OperationsRunner:
|
||||
|
||||
def _tick_impl(self, *, sync_candles: bool | None = None) -> dict[str, Any]:
|
||||
"""tick 본체."""
|
||||
tick_started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
tick_mono_start = time.monotonic()
|
||||
self.state["last_tick_started_at"] = tick_started_at
|
||||
self._check_watchdog_stale(tick_started_at)
|
||||
_append_ops_log(
|
||||
self.settings,
|
||||
f"{tick_started_at} [INFO] tick 시작 mode={self.settings.ops_mode}",
|
||||
)
|
||||
|
||||
do_sync = sync_candles if sync_candles is not None else self.settings.ops_sync_candles
|
||||
candle_sync_results: list[Any] = []
|
||||
if do_sync:
|
||||
@@ -247,7 +519,16 @@ class OperationsRunner:
|
||||
|
||||
df = load_ops_candles(self.settings)
|
||||
latest_bar = int(len(df) - 1)
|
||||
gen = generate_raw_signals(self.settings, df=df, use_cache=True)
|
||||
force_tail = (
|
||||
self.settings.ops_mode == "live"
|
||||
and self.settings.ops_live_force_tail_refresh
|
||||
)
|
||||
gen = generate_raw_signals(
|
||||
self.settings,
|
||||
df=df,
|
||||
use_cache=True,
|
||||
force_tail_refresh=force_tail,
|
||||
)
|
||||
|
||||
filtered = filter_signals_for_ops(self.settings, gen["raw_signals"])
|
||||
all_kept = filtered["kept"]
|
||||
@@ -268,22 +549,66 @@ class OperationsRunner:
|
||||
logger.exception("tick 잔고 동기화 실패")
|
||||
self._notify_ops_error("portfolio_sync", exc)
|
||||
|
||||
last_bar = int(self.state.get("last_processed_bar_index", -1))
|
||||
last_dt = self.state.get("last_processed_datetime")
|
||||
pending_signals = _pending_signals_for_ops(
|
||||
trade_history = self.state.get("trade_history") or []
|
||||
live_since = (
|
||||
str(self.state["live_initialized_at"])
|
||||
if self.settings.ops_mode == "live" and self.state.get("live_initialized_at")
|
||||
else None
|
||||
)
|
||||
max_age = self.settings.ops_ledger_execute_max_age_minutes
|
||||
trade_history = self.state.get("trade_history") or []
|
||||
|
||||
exchange_reconciled: list[dict[str, Any]] = []
|
||||
if (
|
||||
self.settings.ops_mode == "live"
|
||||
and self.settings.ops_exchange_reconcile
|
||||
and isinstance(self.executor, LiveExecutor)
|
||||
):
|
||||
exchange_reconciled = reconcile_exchange_fills(
|
||||
all_kept,
|
||||
trade_history,
|
||||
client=self.executor.client,
|
||||
market=self.settings.market,
|
||||
lookback_hours=self.settings.ops_exchange_order_lookback_hours,
|
||||
match_window_min=self.settings.ops_exchange_match_window_min,
|
||||
lookback_days=self.settings.ops_ledger_lookback_days,
|
||||
)
|
||||
if exchange_reconciled:
|
||||
self.state.setdefault("trade_history", []).extend(exchange_reconciled)
|
||||
trade_history = self.state.get("trade_history") or []
|
||||
|
||||
stale_settled = _settle_expired_backlog(
|
||||
all_kept,
|
||||
last_processed_datetime=last_dt,
|
||||
last_bar_index=last_bar,
|
||||
trade_history,
|
||||
max_age_minutes=max_age,
|
||||
live_since=live_since,
|
||||
lookback_days=self.settings.ops_ledger_lookback_days,
|
||||
)
|
||||
if stale_settled:
|
||||
self.state.setdefault("trade_history", []).extend(stale_settled)
|
||||
trade_history = self.state.get("trade_history") or []
|
||||
|
||||
ledger_pending = _ledger_pending_signals(
|
||||
all_kept,
|
||||
trade_history,
|
||||
latest_bar_index=latest_bar,
|
||||
lookback_days=self.settings.ops_ledger_lookback_days,
|
||||
)
|
||||
catchup_signals = _catchup_signals_for_ops(
|
||||
all_kept,
|
||||
latest_bar_index=latest_bar,
|
||||
catchup_bars=self.settings.ops_catchup_bars,
|
||||
trade_history=self.state.get("trade_history") or [],
|
||||
trade_history=trade_history,
|
||||
)
|
||||
catchup_keys = {_signal_key(s) for s in catchup_signals}
|
||||
pending_signals = _merge_pending_signals(pending_signals, catchup_signals)
|
||||
ledger_keys = {_signal_key(s) for s in ledger_pending}
|
||||
pending_signals = _merge_pending_signals(ledger_pending, catchup_signals)
|
||||
backlog_before_limit = len(pending_signals)
|
||||
pending_signals, backlog_dropped = _apply_backlog_limit(
|
||||
pending_signals,
|
||||
self.settings.ops_max_backlog_per_tick,
|
||||
)
|
||||
backlog_meta = _backlog_summary(pending_signals)
|
||||
target_bars = _pending_bar_indices(pending_signals)
|
||||
|
||||
executions: list[dict[str, Any]] = []
|
||||
@@ -302,23 +627,13 @@ class OperationsRunner:
|
||||
(k for k in all_kept if k["datetime"] == sig["datetime"]),
|
||||
sig,
|
||||
)
|
||||
try:
|
||||
trade = self.executor.execute_signal(
|
||||
full_sig,
|
||||
self.state["portfolio"],
|
||||
cluster_size=cluster_size,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"체결 실행 예외 side=%s datetime=%s",
|
||||
full_sig.get("side"),
|
||||
full_sig.get("datetime"),
|
||||
)
|
||||
self._notify_ops_error(
|
||||
"execute",
|
||||
exc,
|
||||
context=f"side={full_sig.get('side')} dt={full_sig.get('datetime')}",
|
||||
)
|
||||
sig_key = _signal_key(full_sig)
|
||||
executable, expire_reason = _is_signal_api_executable(
|
||||
full_sig,
|
||||
max_age_minutes=max_age,
|
||||
live_since=live_since,
|
||||
)
|
||||
if not executable:
|
||||
trade = TradeResult(
|
||||
executed=False,
|
||||
side=str(full_sig.get("side", "")),
|
||||
@@ -326,15 +641,44 @@ class OperationsRunner:
|
||||
order_coin=0.0,
|
||||
fee_krw=0.0,
|
||||
price=float(full_sig.get("price", 0)),
|
||||
skip_reason=str(exc),
|
||||
skip_reason=expire_reason,
|
||||
expected_skip=True,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
trade = self.executor.execute_signal(
|
||||
full_sig,
|
||||
self.state["portfolio"],
|
||||
cluster_size=cluster_size,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"체결 실행 예외 side=%s datetime=%s",
|
||||
full_sig.get("side"),
|
||||
full_sig.get("datetime"),
|
||||
)
|
||||
self._notify_ops_error(
|
||||
"execute",
|
||||
exc,
|
||||
context=f"side={full_sig.get('side')} dt={full_sig.get('datetime')}",
|
||||
)
|
||||
trade = TradeResult(
|
||||
executed=False,
|
||||
side=str(full_sig.get("side", "")),
|
||||
order_krw=0.0,
|
||||
order_coin=0.0,
|
||||
fee_krw=0.0,
|
||||
price=float(full_sig.get("price", 0)),
|
||||
skip_reason=str(exc),
|
||||
)
|
||||
record = {
|
||||
"datetime": full_sig["datetime"],
|
||||
"side": full_sig["side"],
|
||||
"signal_type": full_sig.get("signal_type"),
|
||||
"price": full_sig["price"],
|
||||
"bar_index": bar_idx,
|
||||
"catchup": _signal_key(full_sig) in catchup_keys,
|
||||
"ledger": sig_key in ledger_keys,
|
||||
"catchup": sig_key in catchup_keys,
|
||||
"trade": trade.to_dict(),
|
||||
"mtf_filter": full_sig.get("mtf_filter"),
|
||||
}
|
||||
@@ -361,11 +705,31 @@ class OperationsRunner:
|
||||
datetime_str=str(full_sig["datetime"]),
|
||||
reason=trade.skip_reason,
|
||||
)
|
||||
if bar_idx > last_bar:
|
||||
last_bar = bar_idx
|
||||
self.state["last_processed_bar_index"] = bar_idx
|
||||
if bar_signals:
|
||||
self.state["last_processed_datetime"] = bar_signals[-1]["datetime"]
|
||||
|
||||
if executions:
|
||||
self.state.setdefault("trade_history", []).extend(executions)
|
||||
trade_history = self.state.get("trade_history") or []
|
||||
_advance_cursor_from_ledger(self.state, all_kept, trade_history)
|
||||
|
||||
tick_duration = time.monotonic() - tick_mono_start
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.state["last_run_at"] = now
|
||||
self.state["last_tick_completed_at"] = now
|
||||
self.state["last_tick_duration_sec"] = round(tick_duration, 2)
|
||||
if (
|
||||
self.settings.ops_tick_warn_sec > 0
|
||||
and tick_duration > self.settings.ops_tick_warn_sec
|
||||
):
|
||||
logger.warning(
|
||||
"tick 소요 %.1fs (경고 임계 %ds)",
|
||||
tick_duration,
|
||||
self.settings.ops_tick_warn_sec,
|
||||
)
|
||||
self._notify_ops_error(
|
||||
"tick_slow",
|
||||
RuntimeError(f"tick {tick_duration:.1f}s"),
|
||||
context=f"threshold={self.settings.ops_tick_warn_sec}s",
|
||||
)
|
||||
|
||||
pipeline = {
|
||||
"technique_id": gen["technique_id"],
|
||||
@@ -373,16 +737,16 @@ class OperationsRunner:
|
||||
"kept_count": len(all_kept),
|
||||
"rejected_count": len(filtered["rejected"]),
|
||||
"latest_bar_index": latest_bar,
|
||||
"pending_signal_count": len(pending_signals),
|
||||
"ledger_pending_count": len(ledger_pending),
|
||||
"pending_signal_count": backlog_before_limit,
|
||||
"catchup_signal_count": len(catchup_signals),
|
||||
"catchup_bars": self.settings.ops_catchup_bars,
|
||||
"backlog_dropped_count": backlog_dropped,
|
||||
"backlog_stale_settled_count": len(stale_settled),
|
||||
"exchange_reconciled_count": len(exchange_reconciled),
|
||||
**backlog_meta,
|
||||
}
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.state["last_run_at"] = now
|
||||
if executions:
|
||||
self.state.setdefault("trade_history", []).extend(executions)
|
||||
|
||||
report = {
|
||||
"generated_at": now,
|
||||
"mode": self.settings.ops_mode,
|
||||
@@ -390,6 +754,7 @@ class OperationsRunner:
|
||||
"slippage_rate": self.settings.ops_slippage_rate,
|
||||
"daily_max_trades": self.settings.ops_daily_max_trades,
|
||||
"signal_refresh": gen.get("signal_refresh"),
|
||||
"force_tail_refresh": force_tail,
|
||||
"candle_sync": [
|
||||
{
|
||||
"interval_min": r.interval_min,
|
||||
@@ -402,20 +767,79 @@ class OperationsRunner:
|
||||
"raw_signals": pipeline["raw_count"],
|
||||
"filtered_signals": pipeline["kept_count"],
|
||||
"pending_bars": target_bars,
|
||||
"ledger_pending_count": pipeline["ledger_pending_count"],
|
||||
"pending_signal_count": pipeline["pending_signal_count"],
|
||||
"catchup_signal_count": pipeline["catchup_signal_count"],
|
||||
"catchup_bars": pipeline["catchup_bars"],
|
||||
"backlog_signal_count": pipeline["backlog_signal_count"],
|
||||
"backlog_oldest_datetime": pipeline["backlog_oldest_datetime"],
|
||||
"backlog_dropped_count": pipeline["backlog_dropped_count"],
|
||||
"backlog_stale_settled_count": pipeline["backlog_stale_settled_count"],
|
||||
"exchange_reconciled_count": pipeline["exchange_reconciled_count"],
|
||||
"latest_bar_candidates": pipeline["raw_count"],
|
||||
"executions": executions,
|
||||
"portfolio": self.state["portfolio"],
|
||||
"trades_today_count": self.state["trades_today_count"],
|
||||
"last_processed_bar_index": self.state["last_processed_bar_index"],
|
||||
"last_processed_datetime": self.state.get("last_processed_datetime"),
|
||||
"last_tick_started_at": tick_started_at,
|
||||
"last_tick_completed_at": now,
|
||||
"last_tick_duration_sec": round(tick_duration, 2),
|
||||
}
|
||||
|
||||
save_state(self.settings.ops_state_json, self.state)
|
||||
self._save_report(report)
|
||||
_append_ops_log(
|
||||
self.settings,
|
||||
(
|
||||
f"{now} [INFO] tick 완료 duration={tick_duration:.1f}s "
|
||||
f"ledger_pending={len(ledger_pending)} "
|
||||
f"processed={len(executions)} "
|
||||
f"backlog_dropped={backlog_dropped}"
|
||||
),
|
||||
)
|
||||
return report
|
||||
|
||||
def _check_watchdog_stale(self, tick_started_at: str) -> None:
|
||||
"""이전 tick 완료 시각이 너무 오래됐으면 알린다.
|
||||
|
||||
프로세스 재시작 직후에는 state에 남은 이전 last_completed를 무시한다.
|
||||
"""
|
||||
stale_sec = self.settings.ops_watchdog_stale_sec
|
||||
if stale_sec <= 0:
|
||||
return
|
||||
last_completed = self.state.get("last_tick_completed_at") or self.state.get(
|
||||
"last_run_at"
|
||||
)
|
||||
if not last_completed:
|
||||
return
|
||||
try:
|
||||
tick_dt = datetime.strptime(tick_started_at, "%Y-%m-%d %H:%M:%S")
|
||||
last_dt = datetime.strptime(str(last_completed), "%Y-%m-%d %H:%M:%S")
|
||||
runner_dt = datetime.strptime(self._runner_started_at, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return
|
||||
if last_dt < runner_dt:
|
||||
logger.info(
|
||||
"watchdog skip (restart): last_completed=%s runner_started=%s",
|
||||
last_completed,
|
||||
self._runner_started_at,
|
||||
)
|
||||
return
|
||||
delta = (tick_dt - last_dt).total_seconds()
|
||||
if delta <= stale_sec:
|
||||
return
|
||||
logger.warning(
|
||||
"watchdog stale: last completed %.0fs ago (threshold %ds)",
|
||||
delta,
|
||||
stale_sec,
|
||||
)
|
||||
self._notify_ops_error(
|
||||
"watchdog_stale",
|
||||
RuntimeError(f"no tick completion for {delta:.0f}s"),
|
||||
context=f"last_completed={last_completed}",
|
||||
)
|
||||
|
||||
def _notify_ops_error(
|
||||
self,
|
||||
stage: str,
|
||||
@@ -427,6 +851,8 @@ class OperationsRunner:
|
||||
if not self.telegram.is_active:
|
||||
return
|
||||
tb_tail = traceback.format_exc(limit=4).strip()
|
||||
if tb_tail in ("NoneType: None", "None"):
|
||||
tb_tail = ""
|
||||
detail_parts = [p for p in (context, tb_tail) if p]
|
||||
self.telegram.notify_ops_error(
|
||||
mode=self.settings.ops_mode,
|
||||
|
||||
@@ -47,6 +47,27 @@ def _offset_signal_bars(signals: list[dict[str, Any]], offset: int) -> list[dict
|
||||
return shifted
|
||||
|
||||
|
||||
def _replace_tail_signals(
|
||||
cached_signals: list[dict[str, Any]],
|
||||
tail_signals: list[dict[str, Any]],
|
||||
offset: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""tail 구간 신호를 재계산 결과로 교체한다 (live force refresh용).
|
||||
|
||||
bar_index >= offset 인 캐시 신호를 제거하고 tail 재계산 신호로 대체한다.
|
||||
"""
|
||||
base = [s for s in cached_signals if int(s.get("bar_index", 0)) < offset]
|
||||
seen = {(s.get("datetime"), s.get("side")) for s in base}
|
||||
merged = list(base)
|
||||
for signal in _offset_signal_bars(tail_signals, offset):
|
||||
key = (signal.get("datetime"), signal.get("side"))
|
||||
if key in seen:
|
||||
continue
|
||||
merged.append(signal)
|
||||
seen.add(key)
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_tail_signals(
|
||||
cached_signals: list[dict[str, Any]],
|
||||
tail_signals: list[dict[str, Any]],
|
||||
@@ -187,6 +208,7 @@ def generate_raw_signals(
|
||||
df: pd.DataFrame | None = None,
|
||||
*,
|
||||
use_cache: bool = True,
|
||||
force_tail_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""기법 신호를 생성한다 (MTF 필터 전).
|
||||
|
||||
@@ -209,19 +231,27 @@ def generate_raw_signals(
|
||||
cached = _load_technique_cached(cache_path)
|
||||
raw_signals = list(cached.signals)
|
||||
max_cached_bar = _max_signal_bar_index(raw_signals)
|
||||
if latest_bar > max_cached_bar:
|
||||
tail_bars = max(settings.ops_signal_tail_bars, 200)
|
||||
offset = max(0, len(df) - tail_bars)
|
||||
tail_bars = max(settings.ops_signal_tail_bars, 200)
|
||||
offset = max(0, len(df) - tail_bars)
|
||||
needs_tail = latest_bar > max_cached_bar or force_tail_refresh
|
||||
if needs_tail:
|
||||
tail_df = df.iloc[offset:].copy()
|
||||
technique = get_technique(settings.ops_technique_id)
|
||||
params = build_technique_params(settings)
|
||||
tail_result = run_technique(technique, tail_df, params, gt_result=None)
|
||||
raw_signals = _merge_tail_signals(
|
||||
raw_signals,
|
||||
tail_result.signals,
|
||||
offset,
|
||||
max_cached_bar,
|
||||
)
|
||||
if force_tail_refresh and latest_bar <= max_cached_bar:
|
||||
raw_signals = _replace_tail_signals(
|
||||
raw_signals,
|
||||
tail_result.signals,
|
||||
offset,
|
||||
)
|
||||
else:
|
||||
raw_signals = _merge_tail_signals(
|
||||
raw_signals,
|
||||
tail_result.signals,
|
||||
offset,
|
||||
max_cached_bar,
|
||||
)
|
||||
refreshed_tail = True
|
||||
updated = TechniqueResult(
|
||||
technique_id=cached.technique_id,
|
||||
|
||||
150
src/bithumb/operations/symbol_executor.py
Normal file
150
src/bithumb/operations/symbol_executor.py
Normal 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
|
||||
386
src/bithumb/operations/vol_breakout_engine.py
Normal file
386
src/bithumb/operations/vol_breakout_engine.py
Normal 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)
|
||||
102
src/bithumb/operations/vol_breakout_exit.py
Normal file
102
src/bithumb/operations/vol_breakout_exit.py
Normal 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
|
||||
201
src/bithumb/operations/vol_breakout_runner.py
Normal file
201
src/bithumb/operations/vol_breakout_runner.py
Normal 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
|
||||
428
src/bithumb/operations/vol_live_monitor.py
Normal file
428
src/bithumb/operations/vol_live_monitor.py
Normal 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"),
|
||||
}
|
||||
317
src/bithumb/operations/vol_monitor_chart.py
Normal file
317
src/bithumb/operations/vol_monitor_chart.py
Normal 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
|
||||
507
src/bithumb/operations/watch_ops.py
Normal file
507
src/bithumb/operations/watch_ops.py
Normal 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
|
||||
96
src/bithumb/simulation/vol_breakout.py
Normal file
96
src/bithumb/simulation/vol_breakout.py
Normal 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
73
tests/test_candle_bars.py
Normal 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()
|
||||
77
tests/test_exchange_reconcile.py
Normal file
77
tests/test_exchange_reconcile.py
Normal 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
143
tests/test_ops_ledger.py
Normal 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
|
||||
46
tests/test_vol_breakout.py
Normal file
46
tests/test_vol_breakout.py
Normal 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
|
||||
66
tests/test_vol_breakout_telegram.py
Normal file
66
tests/test_vol_breakout_telegram.py
Normal 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
|
||||
78
tests/test_vol_live_monitor.py
Normal file
78
tests/test_vol_live_monitor.py
Normal 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
42
tests/test_watch_ops.py
Normal 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 == []
|
||||
72
tests/test_watch_vol_ops.py
Normal file
72
tests/test_watch_vol_ops.py
Normal 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
|
||||
Reference in New Issue
Block a user