diff --git a/.env.example b/.env.example index 7c576f5..25667e3 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ COIN_TELEGRAM_CHAT_ID= # --- 거래 대상 --- SYMBOL=BTC COIN_NAME=비트코인 +# 캔들 수집 대상 (쉼표 구분). 비우면 SYMBOL만 수집 +DOWNLOAD_SYMBOLS=TRX,NEAR,WLD # --- 공통: 캔들 DB (현물·선물 공유) --- DB_PATH=data/common/coins.db @@ -118,7 +120,9 @@ OPS_WATCH_SIGNAL_GRACE_MIN=5 OPS_WATCH_TICK_STALE_MIN=12 OPS_WATCH_LOOKBACK_MIN=30 OPS_WATCH_AUTO_REMEDIATE=true -OPS_WATCH_AUTO_RESTART=true +# vol_breakout(cron 1분) 사용 시 fractal loop 재시작 끄기 +OPS_WATCH_AUTO_RESTART=false +# fractal loop 커스텀 기동 (OPS_WATCH_AUTO_RESTART=true 일 때) # OPS_WATCH_LOOP_START_CMD= # OPS_PERSIST_SIGNAL_CACHE=false OPS_STATE_JSON=data/spot/operations/fractal_ops_state.json @@ -128,6 +132,36 @@ OPS_FILTERED_BACKTEST_JSON=docs/spot/3_operations/fractal_filtered_backtest_repo OPS_SIZING_RULES_JSON=data/spot/operations/sizing_rules.json # composite_v3 운영 시: OPS_TECHNIQUE_ID=composite_v3, OPS_MIN_SCORE=2.5, OPS_MTF_ENABLED=true +# --- vol_breakout 현물 롱 (Binance 15m ATR 이식, BTC 제외) --- +# fractal_swing(3분)과 별도 프로세스 — 동시 live 시 KRW 경합 주의 +OPS_SYMBOLS=TRX,NEAR,WLD +VOL_STATE_JSON=data/spot/operations/vol_breakout_state.json +VOL_LOOKBACK=14 +VOL_ATR_MULT=2.0 +VOL_LOOKBACK_DAYS=60 +# VOL_BUY_SPLIT=0: 미보유 슬롯 수로 동적 (3→1/3, 2→1/2, 1→100%) +VOL_BUY_SPLIT=0 +VOL_WALLET_PCT=0.30 +VOL_MAX_BARS_PER_TICK=5 +# 순수 15m flip (trail/stop off). 청산은 하향 돌파(-1) flip만 +VOL_EXIT_ENABLED=false +# trail/stop 사용 시 VOL_EXIT_ENABLED=true 및 아래 값 +# VOL_TRAIL_ACTIVATE_PCT=1.5 +# VOL_TRAIL_PCT=0.0025 +# VOL_TRAIL_ATR_MULT=0.5 +# VOL_STOP_PCT=-12.0 +VOL_TICK_LOCK_PATH=data/spot/operations/vol.tick.lock +# touch data/spot/operations/vol.kill → 신규 매수 차단 +# VOL_KILL_SWITCH_PATH=data/spot/operations/vol.kill + +# 라이브 모니터 (Binance vol_live_monitor 유사, 기본 포트 8766) +VOL_MONITOR_JSON=docs/spot/3_operations/vol_live_chart.json +VOL_MONITOR_HTML=docs/spot/3_operations/vol_live_monitor.html +VOL_MONITOR_DAYS=14 + +# cron (vol_breakout): bash scripts/install_crontab.sh --apply +# BITHUMB_PYTHON=/Users/dsyoon/opt/anaconda3/envs/coin/bin/python3 + # 폴더 구조: data|docs / {common, spot} # common — coins.db 등 공유 리소스 # spot — 현물 GT·기법·분석·운영 diff --git a/README.md b/README.md index 4ad9690..1d594ca 100644 --- a/README.md +++ b/README.md @@ -1,262 +1,459 @@ # Bithumb -빗썸 KRW 마켓 암호화폐 캔들 수집 및 **현물** 매매 전략 파이프라인. +빗썸 KRW 마켓 암호화폐 캔들 수집 및 **현물(spot)** 매매 전략 파이프라인. -- **기본 축:** 3분봉 현물 BTC, 최근 **10년** 캔들 (`DOWNLOAD_DAYS=3650`) -- **데이터·문서 분류:** `common` (공유) · `spot` (현물) -- **현재 운영 전략:** `fractal_swing` + MTF off — paper/live tick 운영 구현 완료 - -## 주요 기능 - -- 빗썸 Public API(v1) 분·일·주·월봉 캔들 수집 (11개 TF, 1분봉 포함) -- SQLite 공유 DB (`data/common/coins.db`) — 현물·MTF 공용 -- Ground Truth(GT) 벤치마크 → 39종 인과 기법 분석 → **실거래 운영(paper/live)** -- 운영 tick: 캔들 증분 sync, 신호 tail 갱신, 슬리피지·일 체결 상한, 텔레그램 체결 알림 - -## 요구사항 - -- Python 3.10+ -- Conda 환경 `ncue` 또는 `xavis` - -## 설치 - -```bash -cd Bithumb -conda activate ncue # 또는 xavis -pip install -r requirements.txt -cp .env.example .env # API 키·텔레그램 등 로컬 설정 -``` - -`.env` 핵심값 (현물 3분봉·10년·fractal 운영): - -```env -SYMBOL=BTC -DB_PATH=data/common/coins.db -DOWNLOAD_DAYS=3650 -GT_INTERVAL_MIN=3 -GT_LOOKBACK_DAYS=3650 -GT_INITIAL_CASH_KRW=200000 -GT_SIM_LOOKBACK_DAYS=1095 -OPS_TECHNIQUE_ID=fractal_swing -OPS_MTF_ENABLED=false -OPS_SLIPPAGE_RATE=0.0005 -OPS_DAILY_MAX_TRADES=100 -``` +- **기본 축:** 3분봉 BTC, 최근 **10년** 캔들 (`DOWNLOAD_DAYS=3650`) +- **데이터·문서:** `data/common` (공유 DB) · `data/spot` / `docs/spot` (현물) +- **현재 live 운영:** `fractal_swing`, MTF off, ledger + exchange reconcile + 5분 watch --- -## 설계 개요 +## 목차 -### 파이프라인 단계 +1. [파이프라인 개요](#파이프라인-개요) +2. [단계별 요약 (0~3단계)](#단계별-요약-03단계) +3. [수익률 정리](#수익률-정리) +4. [3단계 live 운영](#3단계-live-운영) +5. [설치·실행](#설치실행) +6. [폴더 구조](#폴더-구조) +7. [환경 변수](#환경-변수) +8. [소스 모듈](#소스-모듈) +9. [39종 인과 기법](#39종-인과-기법) +10. [변경 이력](#변경-이력) + +--- + +## 파이프라인 개요 + +```mermaid +flowchart LR + A[common
캔들 수집] --> B[0단계 GT] + B --> C[1단계 GT sim] + C --> D[2단계 39종 기법] + D --> E[3단계 paper/live] + F[watch 5분] -.->|감시·조치| E +``` | 단계 | 목적 | 미래 데이터 | 실거래 | |------|------|-------------|--------| -| **common** | 캔들 DB 구축 | — | — | +| **common** | SQLite 캔들 DB 구축·증분 갱신 | — | — | | **spot 0단계** | GT v3 사후 최적 타점 (정답지) | 사용 (연구용) | 불가 | | **spot 1단계** | GT 타점 완벽 추종 sim 상한선 | GT 자체가 사후 | 불가 | | **spot 2단계** | 39종 인과 기법 평가·MTF 규칙 | 미사용 | 불가 | | **spot 3단계** | paper/live tick 운영 | 미사용 | **가능** | -### 현물 3단계 운영 아키텍처 (fractal_swing) +**핵심 원칙** + +- 0~1단계는 **연구·벤치마크** (사후 GT 포함). +- 2단계는 **인과 기법**만으로 GT 재현도·sim을 비교. +- 3단계는 2단계에서 선택한 기법을 **실시간 tick**으로 체결. 백테스트와 **동일 sim 엔진**(`ground_truth/pnl.py`) 사용. + +--- + +## 단계별 요약 (0~3단계) + +### common — 캔들 수집 + +| 항목 | 내용 | +|------|------| +| 스크립트 | `scripts/00_download.py` (별칭 `00_download_candles.py`) | +| DB | `data/common/coins.db` | +| TF | 11개 (1,3,5,10,15,30,60,240,1440,10080,43200분) | +| 모드 | 증분(기본) / `--full` 전체 재수집 | +| 테이블 | `{SYMBOL}_{분}` (예: `BTC_3`, `BTC_1440`) | + +3단계 tick에서는 `sync_ops_candles()`가 in-process로 동일 DB에 증분 INSERT (`OPS_SYNC_CANDLES=true`). + +--- + +### spot 0단계 — Ground Truth (GT) 타점 + +**목적:** 10년 3분봉에서 **사후적으로** 도출한 “이론적 최적” 매수·매도 타점. 이후 단계의 **정답지·벤치마크**. + +| 스크립트 | `scripts/0_ground_truth.py` | +|----------|----------------------------| +| 기간 | `GT_LOOKBACK_DAYS=3650` (10년) | +| 봉 | `GT_INTERVAL_MIN=3` | + +**GT v3 신호 체계** + +| 코드 | 유형 | 10년 GT 건수(대략) | +|------|------|-------------------| +| B | 스윙 저점 매수 | 944 | +| B* | 눌림목 | 406 | +| B^ | 돌파 | 122 | +| Bd | 상승 다이버전스 | 115 | +| S | 스윙 고점 매도 | 944 | +| Sd | 하락 다이버전스 | 144 | + +**티어** + +| 티어 | 포함 신호 | +|------|-----------| +| v1 | B / S | +| v2 | + B* | +| v3 | + B^ / Bd / Sd | + +**산출물** + +- `data/spot/ground_truth/ground_truth_trades_v{1,2,3}.json` +- `docs/spot/0_ground_truth/ground_truth_chart_v*.html` + +--- + +### spot 1단계 — GT sim (수익 상한선) + +**목적:** GT v3 타점을 **그대로** sim했을 때 3년 수익률. “이론적 상한” 벤치마크. + +| 스크립트 | `scripts/1_ground_truth_sim.py` | +|----------|--------------------------------| +| sim 기간 | `GT_SIM_LOOKBACK_DAYS=1095` (3년) | +| 초기 자본 | `GT_INITIAL_CASH_KRW=200,000` | +| 엔진 | `simulate_gt_signals_pnl` (슬리피지 없음) | + +**산출물:** `docs/spot/1_simulation/ground_truth_chart_sim_v*.html` + +**참고:** GT는 사후 타점이므로 **실거래 불가**. 1단계 수익률은 “최적 타점을 보수적으로 따라갔을 때”의 기준선. + +--- + +### spot 2단계 — 인과 기법 분석 (39종) + +**목적:** 미래 데이터 **없이** GT v3를 얼마나 재현하는지 39종 기법을 평가. 3단계 전략 선택 근거. + +| 순서 | 스크립트 | 산출물 | +|------|----------|--------| +| 2-1 | `2_run_techniques.py` | `data/spot/techniques/*.json`, `comparison_report.html` | +| 2-2 | `2_run_causal_sim.py` | `causal_sim_report.html` | +| 2-3 | `2_run_signal_type_align.py` | `signal_type_report.html` | +| 2-4 | `2_run_mtf_analysis.py` | `mtf_rules_v3.json`, `mtf_correlation_report.html` | +| 일괄 | `bash scripts/2_run_stage2_all.sh` | 위 전체 | + +**GT 정합 score 상위 (10년, ±480봉 허용)** + +| 순위 | 기법 | score | 비고 | +|------|------|-------|------| +| 1 | **fractal_swing** | 0.914 | buy/sell recall 100%, **현재 live 운영** | +| 2 | pivot_swing | 0.911 | | +| 3 | minor_swing | 0.864 | | +| 4 | local_extrema | 0.839 | | +| 5 | zigzag_causal | 0.776 | 스윙 특화, 저빈도 | +| … | composite_v3 | 0.546 | leg recall 22.9% | + +**문서** + +- 설계: [`docs/spot/2_analysis/stage2_design_guide.md`](docs/spot/2_analysis/stage2_design_guide.md) +- 결과 해석: [`docs/spot/2_analysis/stage2_final_summary.md`](docs/spot/2_analysis/stage2_final_summary.md) + +--- + +### spot 3단계 — paper / live 운영 + +**목적:** 선택 기법(`fractal_swing`)을 3분봉 tick으로 paper 또는 live 체결. + +| 스크립트 | 역할 | +|----------|------| +| `3_run_filtered_backtest.py` | 운영 조건 3년 sim | +| `3_run_fractal_realistic_backtest.py` | 슬리피지·일 상한 시나리오 | +| `3_render_live_chart.py` | `docs/live/` 매매 차트 | +| `3_run_operations.py` | paper/live tick (loop 180초) | +| `3_preflight_live.py` / `3_init_live_state.py` | live 사전 점검·상태 초기화 | +| `3_reconcile_signals.py` | backlog inspect / execute | +| `3_audit_ops_safety.py` | 놓침·중복 종합 점검 | +| `3_watch_ops.py` | read-only 감시 + 불일치 조치 | +| `3_run_watch_cron.sh` | cron 5분용 래퍼 | +| `bash scripts/3_run_fractal_live.sh` | preflight → init → live loop | + +**설계:** [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md) +(초版은 composite_v3 중심 — **현재 `.env` 기본은 fractal_swing**) + +--- + +## 수익률 정리 + +**공통 조건:** BTC · 3분봉 · sim 기간 **최근 3년(1095일)** · 초기 자본 **200,000원** · 편도 수수료 **0.05%** (`GT_TRADING_FEE_RATE`) + +> sim 수익률은 **과거 데이터 재생** 결과입니다. live는 슬리피지·체결 지연·유동성·운영 오류로 **달라질 수 있으며**, 특히 고빈도 전략은 슬리피지에 극도로 민감합니다. + +### 1단계 — GT v3 벤치마크 + +| 항목 | 값 | +|------|-----| +| 3년 수익률 | **+94,154%** | +| 최종 평가 | 약 1.89억 원 | +| 매수/매도 체결 | 239 / 151 | +| 의미 | 사후 최적 타점을 보수적으로 sim | + +### 2단계 — 인과 sim (슬리피지 0, 일 상한 없음) + +| 기법 | 3년 수익률 | 최종 평가(약) | 매수/매도 체결 | 일평균 매수 | +|------|-----------|--------------|---------------|------------| +| **fractal_swing** | **+7,560,826%** | 151억 | 56,893 / 56,892 | **~52회** | +| pivot_swing | +4,687,495% | 94억 | 12,656 / 12,658 | ~12회 | +| minor_swing | +286,537% | 5.7억 | 831 / 887 | ~0.8회 | +| zigzag_causal | +92,711% | 1.86억 | 97 / 97 | ~0.09회 | +| composite_v3 | **-97.5%** | ~5,000원 | 1,885 / 1,237 | — | +| composite_v3 + MTF (3단계 필터) | +3.37% | — | — | — | + +출처: `docs/spot/2_analysis/stage2_final_summary.md`, `stage2_parity_sweep.json` + +### 3단계 — fractal_swing 운영 백테스트 (슬리피지 반영) + +| 시나리오 | 슬리피지 | 일 체결 상한 | 3년 수익률 | 매수 체결 | 비고 | +|----------|---------|-------------|-----------|----------|------| +| **stage2 ideal** | 0% | 없음 | **+7,560,826%** | 56,893 | 2단계와 동일 | +| **ops_default** | 0.05% | 100 | **+1,873,140%** | 53,589 | sizing_rules 100% (cluster1) | +| **ops + sizing 튜닝** | 0.05% | 10,000 | **+2,307,905%** | 56,773 | cluster 100% (`sizing_rules.json`) | +| slippage 0.1% | 0.1% | 100 | **-97.5%** | 3,964 | **실거래 리스크** | +| slippage 0.1% (상한 없음) | 0.1% | 없음 | -97.5% | 3,962 | 동일 | + +출처: `fractal_realistic_backtest.json`, `fractal_filtered_backtest_report.json` (2026-06-14) + +**해석** + +- 2단계 ideal 대비 ops_default는 약 **24.8%** 수준 (`fractal_ops_vs_stage2.json`) — 슬리피지·일 상한·분할 매매 반영. +- 슬리피지 **0.05% → 0.1%**만 올려도 sim은 **-97.5%**로 붕괴 → live에서 체결가·수수료 관리가 핵심. +- `1_tune_order_sizing.py`로 cluster별 100% sizing 튜닝 시 sim **+2,307,905%** (`sizing_rules.json` + `.env` `OPS_*_PCT=1.0`). + +### live vs 백테스트 + +| 구분 | 백테스트 | live | +|------|---------|------| +| 실행 | 3년 일괄 재생 | 180초 tick 누적 | +| 체결가 | 모델 슬리피지 | 빗썸 시장가 + 실제 스프레드 | +| 신호 | 캐시+tail | 동일 파이프라인 + ledger | +| 기대 | sim 수치 | sim **이하**가 정상 | + +--- + +## 3단계 live 운영 + +### tick 아키텍처 ```mermaid flowchart TD - subgraph tick["3_run_operations.py tick (권장 180초)"] - A[sync_ops_candles
전 TF 증분 INSERT] --> B[generate_raw_signals
캐시 + tail 800봉 갱신] - B --> C[filter_signals_for_ops
MTF·TrendGate 선택] - C --> D[OperationsRunner
bar 단위 클러스터 체결] - D --> E{paper / live} - E -->|paper| F[PaperExecutor
모델 슬리피지 체결] - E -->|live| G[LiveExecutor
빗썸 시장가] - F --> H[TelegramNotifier
체결 알림] - G --> H - H --> I[state.json + ops_report.json] + subgraph loop["3_run_operations.py --loop 180"] + A[sync_ops_candles] --> B[generate_raw_signals
force_tail_refresh] + B --> C[filter_signals_for_ops] + C --> D[exchange reconcile] + D --> E[stale backlog 정산] + E --> F[ledger pending 체결] + F --> G[state.json + report] end - J[3_run_filtered_backtest.py] --> K[simulate_gt_signals_pnl
동일 체결 규칙 3년 sim] + subgraph watch["3_watch_ops.py (cron 5분)"] + H[불일치 감지] --> I{조치} + I -->|lock 획득| J[remediation tick] + I -->|tick stale| K[loop 재시작] + I --> T[텔레그램] + end + L[ops.tick.lock] --- loop + L --- watch ``` -**백테스트 vs live 정합:** 슬리피지·수수료·일 체결 상한·매수 상한(`max_buy_from_cash`)·클러스터 분할이 `pnl.py` ↔ `trade_engine` ↔ `executor`에서 동일 규칙을 사용합니다. +### 신호 누락·중복 방어 (2026-06-14) -### 운영 전략 비교 (2단계 결론 반영) +| 기능 | 설명 | +|------|------| +| **ledger pending** | `trade_history` 기준 미정산 신호 추적 (커서와 분리) | +| **force_tail_refresh** | live에서 tail 800봉 신호 재계산 | +| **catchup 480봉** | 최근 구간 재시도 | +| **max_age 45분** | 과거 backlog 현재가 재체결 차단 (수수료 churn 방지) | +| **exchange reconcile** | 거래소 done 주문 ↔ 신호 대조, **재주문 없이** 원장 반영 | +| **ops.tick.lock** | loop·watch tick 동시 실행 방지 | +| **watch 5분** | 불일치 시 remediation tick 또는 loop 재시작 + 텔레그램 | -| 전략 | 3년 sim (운영 조건) | 체결 빈도 | 현재 `.env` | -|------|---------------------|-----------|-------------| -| **fractal_swing** (MTF off) | **+1,873,140%** (슬리피지 0.05%, 일 100회) | 일 ~50회 매수 | **기본값** | -| fractal_swing ideal (2단계) | +7,560,826% (슬리피지 0, 상한 없음) | 일 ~52회 | 연구용 | -| composite_v3 + MTF on | +3.37% | 낮음 | `.env.example` 주석 참고 | +### live 시작 -상세 해석: [`docs/spot/2_analysis/stage2_final_summary.md`](docs/spot/2_analysis/stage2_final_summary.md) +```bash +conda activate ncue # 또는 xavis +export PYTHONPATH=src + +# 1) 백테스트 확인 +python scripts/3_run_filtered_backtest.py + +# 2) live (preflight + init + loop) +bash scripts/3_run_fractal_live.sh + +# 또는 직접 +python scripts/3_run_operations.py --mode live --loop 180 +``` + +### watch cron (5분) + +```bash +crontab -e +# 추가: +*/5 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_watch_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/watch_cron.log 2>&1 +``` + +### vol_breakout cron (다운로드 + tick + 모니터) + +TRX/NEAR/WLD 15m flip 운영용. 한 번에 등록: + +```bash +bash scripts/install_crontab.sh --apply +crontab -l # 확인 +``` + +| cron | 주기 | 스크립트 | 로그 | +|------|------|----------|------| +| 캔들 증분 | 1분 | `00_run_download_cron.sh` | `data/common/download_cron.log` | +| vol tick | 1분 | `3_run_vol_breakout_cron.sh` | `data/spot/operations/vol_breakout_cron.log` | +| 모니터 JSON | 5분 | `3_run_vol_monitor_cron.sh` | `data/spot/operations/vol_monitor_cron.log` | + +- hung 프로세스: 다운로드 20분·vol tick 10분 초과 시 자동 종료 후 lock 정리 (`scripts/_cron_env.sh`) +- Python: `coin` / `ncue` conda 우선. 다른 환경이면 `.env` 또는 crontab에 `BITHUMB_PYTHON=...` 설정 +- 모니터 UI (8766): + ```bash + bash scripts/install_vol_monitor_launchd.sh --install # 권장 — 로그인·재부팅 후 자동 기동 + bash scripts/3_run_vol_monitor_serve.sh # 수동 1회 기동 + bash scripts/install_vol_monitor_launchd.sh --status # 상태 확인 + ``` + → http://127.0.0.1:8766/vol_live_monitor.html + Binance 모니터는 **8765** — 포트가 다릅니다. + HTTP 서버(8766)는 **별도 터미널에서 수동 실행**: + ```bash + python scripts/3_run_vol_monitor.py + ``` + +점검: + +```bash +tail -f data/common/download_cron.log +tail -f data/spot/operations/vol_breakout_cron.log +bash scripts/00_run_download_cron.sh # 수동 1회 +bash scripts/3_run_vol_breakout_cron.sh +``` + +### fractal watch 점검 + +```bash +python scripts/3_audit_ops_safety.py # PASS 목표 +python scripts/3_reconcile_signals.py --dry-run # pending 0 목표 +python scripts/3_watch_ops.py --inspect-only +``` + +### live 체크리스트 + +1. `.env`: `OPS_MODE=live`, API 키, `OPS_EXCHANGE_RECONCILE=true` +2. `3_run_filtered_backtest.py` 수익률 확인 +3. paper 1~2일 또는 소액 live 모니터링 +4. `fractal_ops_report.json` — tick duration, `ledger_pending_count`, `exchange_reconciled_count` +5. 텔레그램 체결·WATCH 알림 확인 + +--- + +## 설치·실행 + +### 요구사항 + +- Python 3.10+ +- Conda `ncue` 또는 `xavis` + +### 설치 + +```bash +cd bithumb +conda activate ncue +pip install -r requirements.txt +cp .env.example .env # API 키·텔레그램 등 +``` + +### 전체 파이프라인 (최초 1회) + +```bash +export PYTHONPATH=src + +python scripts/00_download.py --full +python scripts/0_ground_truth.py --interval 3 --days 3650 --tier all +python scripts/1_ground_truth_sim.py --tier all +bash scripts/2_run_stage2_all.sh +python scripts/3_run_filtered_backtest.py +python scripts/3_render_live_chart.py +``` + +### fractal 운영 (일상) + +```bash +bash scripts/3_run_fractal_ops.sh # backtest + paper loop +python scripts/3_run_operations.py --loop 180 --mode live +bash scripts/3_run_watch_cron.sh # 감시 1회 (또는 cron) +``` --- ## 폴더 구조 ```text -Bithumb/ +bithumb/ ├── src/bithumb/ -│ ├── api/ # 빗썸 Public·Private REST -│ ├── data/ # 캔들 수집·DB·로더 -│ ├── ground_truth/ # GT 타점·sim·차트 -│ ├── techniques/ # 39종 인과 기법 -│ ├── mtf/ # MTF 피처·필터·규칙 -│ ├── evaluation/ # 2단계 리포트·인과 sim -│ ├── operations/ # 3단계 운영 (runner·executor·sync·backtest) -│ └── notifications/ # 텔레그램 체결 알림 -├── scripts/ # 단계별 CLI -│ +│ ├── api/ # Public·Private REST +│ ├── data/ # 캔들 수집·DB +│ ├── ground_truth/ # GT·sim·pnl 엔진 +│ ├── techniques/ # 39종 인과 기법 +│ ├── mtf/ # MTF 필터·규칙 +│ ├── evaluation/ # 2단계 리포트 +│ ├── operations/ # runner·executor·ledger·watch·reconcile +│ └── notifications/ # 텔레그램 +├── scripts/ # 단계별 CLI·shell ├── data/ -│ ├── common/coins.db # 공유 캔들 OHLCV -│ ├── spot/ -│ │ ├── ground_truth/ # 0단계 GT JSON -│ │ ├── techniques/ # 2단계 기법 결과 (fractal_swing.json 등) -│ │ ├── mtf/ # mtf_rules_v3.json -│ │ └── operations/ # fractal_ops_state.json -│ +│ ├── common/coins.db +│ └── spot/ +│ ├── ground_truth/ +│ ├── techniques/ +│ ├── mtf/ +│ └── operations/ # state·sizing·lock·pid └── docs/ - ├── live/ # 운영 백테스트 매매 차트 (index.html) + ├── live/ # 운영 백테스트 차트 └── spot/ - ├── 0_ground_truth/ # GT 차트 HTML - ├── 1_simulation/ # 1단계 sim 차트 - ├── 2_analysis/ # 2단계 리포트·설계 가이드 - └── 3_operations/ # 운영·백테스트 JSON 리포트 -``` - -테이블명: `{SYMBOL}_{인터벌분}` (예: `BTC_3`, `BTC_1440`). 인터벌: 분봉=분 숫자, 일=`1440`, 주=`10080`, 월=`43200`. - ---- - -## 파이프라인 실행 순서 - -```mermaid -flowchart LR - A[00_download] --> B[0_ground_truth] - B --> C[1_ground_truth_sim] - C --> D[2_run_stage2_all] - D --> E[3_run_operations] -``` - -| 순서 | 단계 | 스크립트 | 산출물 | -|------|------|----------|--------| -| 0 | common | `00_download.py` | `data/common/coins.db` | -| 1 | spot 0단계 | `0_ground_truth.py` | `data/spot/ground_truth/`, `docs/spot/0_ground_truth/` | -| 2 | spot 1단계 | `1_ground_truth_sim.py` | `docs/spot/1_simulation/` | -| 3 | spot 2단계 | `2_run_*.py`, `2_run_stage2_all.sh` | `data/spot/techniques/`, `docs/spot/2_analysis/` | -| 4 | spot 3단계 | `3_run_*.py`, `3_run_fractal_ops.sh` | `data/spot/operations/`, `docs/spot/3_operations/` | - -### 권장 명령 - -```bash -conda activate ncue -export PYTHONPATH=src - -# common -python scripts/00_download.py # 증분 갱신 -python scripts/00_download.py --full # 최초·재구축 - -# spot 0~2단계 (분석·기법 캐시 생성) -python scripts/0_ground_truth.py --interval 3 --days 3650 --tier all -python scripts/1_ground_truth_sim.py --tier all -bash scripts/2_run_stage2_all.sh - -# spot 3단계 — fractal_swing 운영 -python scripts/3_run_filtered_backtest.py # 운영 조건 3년 sim 검증 -python scripts/3_render_live_chart.py # docs/live 매매 차트 -python scripts/3_run_fractal_realistic_backtest.py # 슬리피지 시나리오 -bash scripts/3_run_fractal_ops.sh # 백테스트 + paper 180초 loop -python scripts/3_run_operations.py --loop 180 --mode live # live (API 키 필요) + ├── 0_ground_truth/ + ├── 1_simulation/ + ├── 2_analysis/ + └── 3_operations/ ``` --- -## 단계별 상세 +## 환경 변수 -### common — 캔들 수집 +전체: `.env.example`. 카테고리별 요약. -| 항목 | 내용 | -|------|------| -| DB | `data/common/coins.db` (`DB_PATH`) | -| 증분 갱신 | DB 최신 시각 이후만 API 조회·INSERT | -| 전체 재수집 | `--full` | -| TF | `DOWNLOAD_INTERVALS` (기본 11개) | +### 공통·GT -운영 tick에서는 `sync_ops_candles()`가 subprocess 대신 **in-process** 증분 sync를 수행합니다 (`OPS_SYNC_CANDLES=true`). +| 변수 | 설명 | 기본 | +|------|------|------| +| `SYMBOL` | 코인 | `BTC` | +| `DB_PATH` | 캔들 DB | `data/common/coins.db` | +| `DOWNLOAD_DAYS` | 수집·GT 기간(일) | `3650` | +| `GT_INTERVAL_MIN` | GT·운영 봉(분) | `3` | +| `GT_SIM_LOOKBACK_DAYS` | sim·백테스트(일) | `1095` | +| `GT_INITIAL_CASH_KRW` | sim 초기 자본 | `200000` | +| `GT_TRADING_FEE_RATE` | 편도 수수료 | `0.0005` | -### spot 0단계 — GT 타점 +### 3단계 운영 (fractal live) -사후 최적 매매 타점. 실거래 불가, 이후 단계의 벤치마크. +| 변수 | 설명 | 기본 | +|------|------|------| +| `OPS_MODE` | `paper` / `live` | `paper` | +| `OPS_TECHNIQUE_ID` | 기법 | `fractal_swing` | +| `OPS_MTF_ENABLED` | MTF 필터 | `false` | +| `OPS_SLIPPAGE_RATE` | 편도 슬리피지 | `0.0005` | +| `OPS_DAILY_MAX_TRADES` | 일 체결 상한 | `100` (live `.env`는 10000) | +| `OPS_CATCHUP_BARS` | catchup 봉 | `480` | +| `OPS_LEDGER_LOOKBACK_DAYS` | ledger 스캔 | `1` | +| `OPS_LEDGER_EXECUTE_MAX_AGE_MINUTES` | backlog API 허용 | `45` | +| `OPS_LIVE_FORCE_TAIL_REFRESH` | live tail 재계산 | `true` | +| `OPS_EXCHANGE_RECONCILE` | 거래소 대조 | `true` | +| `OPS_TICK_LOCK_PATH` | tick flock | `ops.tick.lock` | +| `OPS_LOOP_PID_FILE` | loop PID | `ops_loop.pid` | +| `OPS_WATCH_SIGNAL_GRACE_MIN` | 감시 grace | `5` | +| `OPS_WATCH_TICK_STALE_MIN` | tick stale → 재시작 | `12` | +| `OPS_WATCH_AUTO_REMEDIATE` | 조치 tick | `true` | +| `OPS_WATCH_AUTO_RESTART` | loop 재시작 | `true` | +| `COIN_TELEGRAM_*` | 체결·WATCH 알림 | — | +| `BITHUMB_ACCESS_KEY` / `SECRET` | live API | — | -| 티어 | 신호 | -|------|------| -| v1 | 스윙 B/S | -| v2 | + 눌림목 B* | -| v3 | + 돌파 B^ + 다이버전스 Bd/Sd | - -산출: `data/spot/ground_truth/ground_truth_trades_v{1,2,3}.json`, `docs/spot/0_ground_truth/ground_truth_chart_v*.html` - -### spot 1단계 — GT sim - -GT 타점 완벽 추종 시 3년 수익 상한선. 초기 20만 원, `GT_SIM_LOOKBACK_DAYS=1095`. - -산출: `docs/spot/1_simulation/ground_truth_chart_sim_v*.html` - -### spot 2단계 — 인과 기법 분석 - -39종 기법의 GT 정합·3년 sim·신호 유형·MTF 상관 분석. - -| 스크립트 | 산출물 | -|----------|--------| -| `2_run_techniques.py` | `data/spot/techniques/`, `comparison_report.html` | -| `2_run_causal_sim.py` | `causal_sim_report.html`, `technique_chart_sim_*.html` | -| `2_run_signal_type_align.py` | `signal_type_report.html` | -| `2_run_mtf_analysis.py` | `mtf_rules_v3.json`, `mtf_correlation_report.html` | - -설계: [`docs/spot/2_analysis/stage2_design_guide.md`](docs/spot/2_analysis/stage2_design_guide.md) -결과 정리: [`docs/spot/2_analysis/stage2_final_summary.md`](docs/spot/2_analysis/stage2_final_summary.md) - -### spot 3단계 — fractal_swing live 운영 - -설계 가이드: [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md) -(가이드 초版은 composite_v3 중심 — **현재 운영 기본값은 fractal_swing**) - -#### 백테스트 실적 (BTC, 3년, 초기 20만원) - -| 조건 | 수익률 | 매수 체결 | 비고 | -|------|--------|-----------|------| -| 운영 백테스트 | **+1,873,140%** | ~53,500 | 슬리피지 0.05%, 일 100회, MTF off | -| 2단계 ideal | +7,560,826% | ~56,893 | 슬리피지 0, 상한 없음 | - -#### 스크립트·산출물 - -| 스크립트 | 산출물 | -|----------|--------| -| `3_run_filtered_backtest.py` | `fractal_filtered_backtest_report.json` | -| `3_render_live_chart.py` | `docs/live/index.html`, `fractal_swing_ops_chart.html` | -| `3_run_fractal_realistic_backtest.py` | `fractal_realistic_backtest.json` | -| `3_run_operations.py` | `fractal_ops_report.json`, `fractal_ops_state.json` | -| `3_run_fractal_ops.sh` | 백테스트 + paper 180초 loop | - -#### 운영 tick 동작 - -1. **캔들 sync** — `OPS_SYNC_INTERVALS` 비우면 `DOWNLOAD_INTERVALS` 전체 TF, `db_max` 이후만 INSERT -2. **신호** — 2단계 캐시 JSON 로드; DB 최신 봉 > 캐시 max bar 시 **tail 800봉** fractal 재계산·병합 (`OPS_SIGNAL_TAIL_BARS`) -3. **필터** — tick당 **최신 봉** 신호만 MTF 평가 (`OPS_MTF_ENABLED=false` 시 스킵) -4. **체결** — bar 단위 클러스터 분할, 일 `OPS_DAILY_MAX_TRADES` 상한 -5. **알림** — 체결 성공 시 텔레그램; live 실패 시 사유 포함 알림 -6. **저장** — `OPS_STATE_JSON`, `OPS_REPORT_JSON` - -#### live 전환 체크리스트 - -1. `python scripts/3_run_filtered_backtest.py` → **약 +1,873,140%** 확인 -2. `.env`: `OPS_MODE=live`, `BITHUMB_ACCESS_KEY` / `BITHUMB_SECRET_KEY` -3. `python scripts/3_run_operations.py --loop 180` (paper 1~2일 모니터링 권장) -4. `fractal_ops_report.json` — `candle_sync`, `signal_refresh`, 체결 건수 확인 -5. 텔레그램 체결 알림 동작 확인 (`COIN_TELEGRAM_*`) - -**주의:** 백테스트는 3년 **일괄 재생** sim, live는 **tick 누적**. 실거래 체결가는 모델 슬리피지보다 불리할 수 있습니다. - -#### composite_v3 + MTF (대안 운영 프로필) - -`.env.example` 주석 참고: +### composite_v3 대안 프로필 ```env OPS_TECHNIQUE_ID=composite_v3 @@ -268,148 +465,46 @@ OPS_DAILY_MAX_TRADES=20 --- -## 환경 변수 - -전체 목록: `.env.example`. 주요 항목만 정리합니다. - -### 공통·GT - -| 변수 | 설명 | 기본값 | -|------|------|--------| -| `SYMBOL` | 코인 심볼 | `BTC` | -| `DB_PATH` | 캔들 DB | `data/common/coins.db` | -| `DOWNLOAD_DAYS` | 수집·GT 기간(일) | `3650` | -| `DOWNLOAD_INTERVALS` | 수집 TF 목록 | 11개 TF | -| `GT_INTERVAL_MIN` | GT·기법·운영 기준 봉(분) | `3` | -| `GT_LOOKBACK_DAYS` | GT·기법 lookback | `3650` | -| `GT_SIM_LOOKBACK_DAYS` | sim·백테스트 기간 | `1095` (3년) | -| `GT_INITIAL_CASH_KRW` | sim·paper 초기 자본 | `200000` | -| `GT_TRADING_FEE_RATE` | 편도 수수료 | `0.0005` | - -### spot 3단계 운영 (fractal 기본) - -| 변수 | 설명 | 기본값 | -|------|------|--------| -| `OPS_MODE` | `paper` / `live` | `paper` | -| `OPS_TECHNIQUE_ID` | 운영 기법 | `fractal_swing` | -| `OPS_MTF_ENABLED` | MTF 필터 | `false` | -| `OPS_TREND_GATE_ENABLED` | 고TF trend gate | `false` | -| `OPS_DAILY_MAX_TRADES` | 일일 체결 상한 | `100` | -| `OPS_MIN_ORDER_KRW` | 최소 주문(원) | `5000` | -| `OPS_SLIPPAGE_RATE` | 편도 슬리피지 | `0.0005` (0.05%) | -| `OPS_ORDER_INTERVAL_SEC` | live 주문 간격(초) | `0.35` | -| `OPS_SYNC_CANDLES` | tick 캔들 증분 sync | `true` | -| `OPS_SYNC_INTERVALS` | sync TF (비우면 전체) | 전체 | -| `OPS_SIGNAL_TAIL_BARS` | 신호 tail 재계산 봉 | `800` | -| `OPS_PERSIST_SIGNAL_CACHE` | tail 후 JSON 저장 | `false` | -| `OPS_STATE_JSON` | 운영 상태 | `fractal_ops_state.json` | -| `OPS_REPORT_JSON` | tick 리포트 | `fractal_ops_report.json` | -| `OPS_FILTERED_BACKTEST_JSON` | 백테스트 리포트 | `fractal_filtered_backtest_report.json` | -| `COIN_TELEGRAM_BOT_TOKEN` | 텔레그램 Bot | (비우면 알림 off) | -| `COIN_TELEGRAM_CHAT_ID` | 텔레그램 chat ID | | -| `OPS_TELEGRAM_ENABLED` | 체결 알림 | 토큰·chat_id 있으면 자동 on | -| `BITHUMB_ACCESS_KEY` | live API | — | -| `BITHUMB_SECRET_KEY` | live API | — | - -### 경로 변수 요약 - -| 용도 | 변수 | 기본 경로 | -|------|------|-----------| -| spot GT | `GROUND_TRUTH_FILE` | `data/spot/ground_truth/...` | -| spot 기법 | `TECHNIQUES_DIR` | `data/spot/techniques/` | -| spot MTF | `MTF_RULES_JSON` | `data/spot/mtf/mtf_rules_v3.json` | -| spot 운영 상태 | `OPS_STATE_JSON` | `data/spot/operations/fractal_ops_state.json` | -| spot 운영 리포트 | `OPS_REPORT_JSON` | `docs/spot/3_operations/fractal_ops_report.json` | -| live 차트 | `3_render_live_chart.py` | `docs/live/` | - ---- - -## 소스 모듈 (spot 3단계) +## 소스 모듈 | 모듈 | 역할 | |------|------| -| `operations/runner.py` | tick 오케스트레이션 | -| `operations/candle_sync.py` | 전 TF 증분 캔들 sync | -| `operations/signal_pipeline.py` | 신호 생성·캐시·tail 갱신·MTF 필터 | +| `operations/runner.py` | tick·ledger·stale·watchdog | +| `operations/exchange_reconcile.py` | 거래소 체결 ↔ 원장 | +| `operations/watch_ops.py` | 5분 감시·조치 | +| `operations/ops_lock.py` | flock | +| `operations/candle_sync.py` | 증분 캔들 sync | +| `operations/signal_pipeline.py` | 신호 tail·MTF | | `operations/executor.py` | paper/live 체결 | -| `operations/execution.py` | 슬리피지 `fill_price` | -| `operations/trade_engine.py` | 매수·매도 사이징·포트폴리오 | -| `operations/backtest.py` | 운영 조건 3년 sim | -| `operations/chart.py` | `docs/live` 백테스트 차트 | -| `operations/state_store.py` | 운영 상태 JSON | -| `ground_truth/pnl.py` | sim 엔진 (백테스트·2단계 공용) | -| `api/bithumb_private.py` | live 잔고·시장가 주문 | -| `notifications/telegram.py` | 체결 텔레그램 알림 | +| `operations/trade_engine.py` | 사이징·포트폴리오 | +| `operations/backtest.py` | 3년 sim | +| `ground_truth/pnl.py` | sim 엔진 (2·3단계 공용) | +| `api/bithumb_private.py` | 잔고·주문·done 조회 | +| `notifications/telegram.py` | 체결·오류·WATCH | --- -## 현물 2단계 인과 기법 (39종) +## 39종 인과 기법 -`src/bithumb/techniques/` — 단일 33 + 복합 6, 미래 데이터 미사용. +`src/bithumb/techniques/` — 단일 33 + 복합 6, **미래 데이터 미사용**. | ID | 기법 | 유형 | |----|------|------| -| `zigzag_causal` | 인과 ZigZag | 스윙 B/S | -| `minor_swing` | 소형 스윙 하이브리드 | 하이브리드 | -| `local_extrema` | 국소 극값 | 스윙 B/S | -| `pivot_swing` | 피벗 스윙 | 스윙 B/S | -| `fractal_swing` | 프랙탈 스윙 | 스윙 B/S | -| `swing_failure` | 스윙 실패 | 스윙 B/S | -| `donchian` | 돈치안 채널 | 스윙 B/S | -| `ema_pullback` | EMA 눌림목 | 눌림목 B* | -| `fib_pullback` | 피보나치 눌림목 | 눌림목 B* | -| `support_bounce` | 지지·저항 반등 | 눌림목 B* | -| `keltner_breakout` | Keltner 돌파 | 돌파 B^ | -| `range_breakout` | 레인지 돌파 | 돌파 B^ | -| `volume_breakout` | 거래량 돌파 | 돌파 B^ | -| `bb_squeeze_breakout` | BB 스퀴즈 돌파 | 돌파 B^ | -| `rsi_divergence` | RSI 다이버전스 | Bd/Sd | -| `macd_divergence` | MACD 다이버전스 | Bd/Sd | -| `obv_divergence` | OBV 다이버전스 | Bd/Sd | -| `bb_reversal` | 볼린저 역추세 | 지표 | -| `ma_cross` | EMA 크로스 | 지표 | -| `rsi_swing` | RSI 스윙 | 지표 | -| `macd_cross` | MACD 크로스 | 지표 | -| `supertrend` | Supertrend | 추세 | -| `adx_trend` | ADX 추세 | 추세 | -| `ichimoku_trend` | 일목 추세 | 추세 | -| `parabolic_sar` | Parabolic SAR | 추세 | -| `stochastic_cross` | Stochastic 크로스 | 모멘텀 | -| `cci_extreme` | CCI 극값 | 모멘텀 | -| `roc_reversal` | ROC 반전 | 모멘텀 | -| `keltner_reversal` | Keltner 역추세 | 변동성 | -| `atr_channel` | ATR 채널 | 변동성 | -| `pivot_points` | 피벗 포인트 | 구조 | -| `support_resistance` | 구조적 지지·저항 | 구조 | -| `volume_spike` | 거래량 스파이크 | 거래량 | -| `composite_swing` | 스윙 복합 | 복합 | -| `composite_pullback` | 눌림목 복합 | 복합 | -| `composite_breakout` | 돌파 복합 | 복합 | -| `composite_divergence` | 다이버전스 복합 | 복합 | -| `composite_v3` | v3 통합 스코어링 | 복합 | -| `composite_full` | 전체 통합 복합 | 복합 | - ---- - -## 구현 현황 - -| 유형 | 단계 | 상태 | -|------|------|------| -| common | 캔들 수집·증분 sync | 구현됨 | -| spot | 0~2단계 (GT·기법·MTF) | 구현됨 | -| spot | 3단계 (fractal paper/live·백테스트·텔레그램) | **구현됨** | +| `fractal_swing` | 프랙탈 스윙 | 스윙 (**live**) | +| `zigzag_causal` | 인과 ZigZag | 스윙 | +| `pivot_swing` | 피벗 스윙 | 스윙 | +| `minor_swing` | 소형 스윙 | 하이브리드 | +| `local_extrema` | 국소 극값 | 스윙 | +| `composite_v3` | v3 통합 | 복합 | +| … | (전체 39종) | `techniques/registry.py` 참고 | --- ## 변경 이력 -- 2026-06-13: 프로젝트명 **Bithumb**으로 변경, 선물(futures) 파이프라인 제거 -- 2026-06-13: 텔레그램 매수·매도 체결 알림 (`notifications/telegram.py`) -- 2026-06-13: `docs/live/` 운영 백테스트 매매 차트 (`3_render_live_chart.py`) -- 2026-06-13: fractal_swing live 운영 — 슬리피지·일 체결 상한·전 TF 증분 sync·신호 tail 갱신 -- 2026-06-13: 운영 백테스트 **+1,873,140%** (3년, 슬리피지 0.05%, 일 100회) 검증 -- 2026-06-12: `data/`·`docs/` common/spot 구조 재편 -- 2026-06-12: 3단계 운영 파이프라인 초기 구현 (composite_v3 + MTF paper/live) -- 2026-06-12: 2단계 인과 기법 분석 파이프라인 완료 -- 2026-06-08: Ground Truth v1/v2/v3 -- 2026-06-07: 캔들 수집 모듈 초기 구현 +- **2026-06-14:** ledger pending, exchange reconcile, max_age backlog, watch 5분 감시·조치, ops.tick.lock, README 전면 갱신 +- **2026-06-13:** fractal_swing live — 슬리피지·sync·tail·텔레그램; ops_default sim **+1,873,140%** +- **2026-06-13:** 프로젝트명 Bithumb, 선물 파이프라인 제거 +- **2026-06-12:** data/docs common·spot 구조, 2단계 39종 완료, 3단계 초기 (composite_v3) +- **2026-06-08:** GT v1/v2/v3 +- **2026-06-07:** 캔들 수집 모듈 diff --git a/data/spot/operations/sizing_rules.json b/data/spot/operations/sizing_rules.json index 547fc96..9cd5185 100644 --- a/data/spot/operations/sizing_rules.json +++ b/data/spot/operations/sizing_rules.json @@ -1,7 +1,7 @@ { "generated_at": "2026-06-13 18:04:03", "technique_id": "fractal_swing", - "symbol": "BTC", + "symbol": "TRX", "default_buy_cash_pct": 1.0, "default_sell_coin_pct": 1.0, "by_cluster": { diff --git a/docs/live/index.html b/docs/live/index.html deleted file mode 100644 index 65d2421..0000000 --- a/docs/live/index.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - Bithumb Live — 운영 백테스트 차트 - - - -

Bithumb Live — 운영 백테스트

-

- BTC · 프랙탈 스윙 (fractal_swing)
- sim 기간: 최근 1095일 · - 슬리피지 0.05% · - 일 체결 상한 100 · - MTF off
학습 비율: 매수 100% · 매도 100% (클러스터별 규칙 적용) -

-
-
3년 수익률 (운영 규칙 sim)
-
+1885460.27%
-

- 매수·매도 타점 차트 열기 -

- -
- - \ No newline at end of file diff --git a/docs/spot/2_analysis/stage2_design_guide.md b/docs/spot/2_analysis/stage2_design_guide.md deleted file mode 100644 index cbf63a5..0000000 --- a/docs/spot/2_analysis/stage2_design_guide.md +++ /dev/null @@ -1,315 +0,0 @@ -# 현물 2단계 설계 가이드 - -> Bithumb 현물 파이프라인 2단계(인과 기법 분석)의 목적, 구조, 설계 근거를 정리한 문서입니다. -> 작성 기준: 2026-06-12 · 기본 TF: 3분봉 · GT: v3 - ---- - -## Plan (계획) - -### 목적 - -현물 2단계는 **0단계 Ground Truth(GT) 타점을 인과적으로 재현할 수 있는 매매 기법을 검증·순위화**하고, 3단계 실거래(`composite_v3` + MTF 필터)에 넘길 전략을 확정하는 단계입니다. - -| 단계 | 핵심 질문 | 미래 데이터 | -|------|-----------|-------------| -| 0단계 | 10년 3분봉에서 이론상 최적 매수/매도는 어디였나? | 사용 (사후 분석, 실거래 불가) | -| 1단계 | GT를 완벽히 따라가면 수익 상한은? | GT 자체가 사후 타점 | -| **2단계** | **과거 데이터만으로 GT 타점을 얼마나 맞출 수 있나?** | **미사용 (인과)** | -| 3단계 (예정) | 검증된 전략을 실거래에 연결 | 실시간 인과 | - -### GT v3 신호 유형 (정답지) - -0단계 GT v3는 3분봉 `bar_index` 기준으로 아래 6종 신호를 포함합니다. - -| 코드 | 유형 | 의미 | -|------|------|------| -| B | swing_low | 스윙 매수 | -| B* | pullback | 눌림목 매수 | -| B^ | breakout | 돌파 매수 | -| Bd | div_bull | 상승 다이버전스 매수 | -| S | swing_high | 스윙 매도 | -| Sd | div_bear | 하락 다이버전스 매도 | - -### 설계 원칙: 3분 = 타이밍, 상위 TF = 맥락 - -2단계는 **두 레이어**로 나뉩니다. - -| 레이어 | 타임프레임 | 역할 | -|--------|------------|------| -| **기법 (2-1 ~ 2-3)** | 3분봉 | **언제** 사고팔지 — GT 타점 재현 | -| **MTF (2-4, 3단계)** | 1분 ~ 월봉 | **그때 해도 되는지** — 과매수·추세·변동성 보조 | - -3분봉만 사용하는 것은 상위 TF를 무시한다는 뜻이 **아닙니다**. GT 정답지가 3분봉에 정의되어 있으므로 기법 평가는 동일 TF에서 수행하고, 일/주/월봉 맥락은 MTF 레이어에서 붙입니다. - -### 2단계가 하지 **않는** 것 - -아래는 2단계의 목적과 맞지 않거나, 별도 설계가 필요한 접근입니다. - -- 39개 기법을 1분 ~ 월봉 **모든 TF**에 각각 실행 -- **모든 봉**에 시장 상태 라벨을 붙여 매수/매도를 처음부터 탐색 -- GT 없이 상태 분류만으로 전략을 발견 - ---- - -## Do (실행) - -### 파이프라인 실행 - -```bash -cd Bithumb -export PYTHONPATH=src -bash scripts/2_run_stage2_all.sh -``` - -### 4단계 세부 - -| 순서 | 스크립트 | 입력 | 산출물 | -|------|----------|------|--------| -| 2-1 | `2_run_techniques.py` | 3분봉 3650일, GT v3 | `data/spot/techniques/*.json`, `comparison_report.html` | -| 2-2 | `2_run_causal_sim.py` | 2-1 결과 | `causal_sim_report.html`, 기법별 sim 차트 | -| 2-3 | `2_run_signal_type_align.py` | 2-1 결과 (캐시) | `signal_type_report.html` | -| 2-4 | `2_run_mtf_analysis.py` | GT v3, 1분~월봉 피처 | `mtf_correlation_report.html`, `data/spot/mtf/mtf_rules_v3.json` | - -### 2-1. 기법 실행 - -**처리 흐름** - -1. `coins.db`에서 BTC **3분봉** 3650일 로드 (`GT_INTERVAL_MIN=3`) -2. 등록된 **39개 인과 기법** 각각 `generate_signals()` 실행 -3. 조건 충족 봉에서만 `buy` / `sell` **이벤트** 생성 (모든 봉에 상태 라벨 X) -4. 신호를 매수·매도 **레그(leg)** 로 묶고 수익률 계산 -5. GT v3 타점과 **정합(alignment)** 평가 — 기본 허용 오차 ±480봉 (약 24시간) - -**정합 지표** - -- recall, precision, F1 -- leg recall (매수·매도 쌍 단위) -- 종합 **score** (터미널 `score=91.1` 등) - -**39개 기법 구성** - -- 단일 기법 33종: 스윙, 눌림목, 돌파, 다이버전스, 지표, 추세, 모멘텀, 변동성, 구조, 거래량 -- 복합 기법 6종: `composite_swing`, `composite_pullback`, `composite_breakout`, `composite_divergence`, **`composite_v3`**, `composite_full` - -`composite_v3`는 v3 GT 6종 신호를 가중 투표로 재현하는 **3단계 실거래 후보 전략**입니다. - -### 2-2. 인과 sim - -- 2-1에서 저장한 기법 신호를 **최근 3년** (`GT_SIM_LOOKBACK_DAYS=1095`) 구간으로 sim -- 1단계 GT sim(정답지 수익 상한)과 비교 -- 기법별 HTML sim 차트 생성 - -### 2-3. 신호 유형별 정합 - -- GT v3의 B / B* / B^ / Bd / S / Sd **유형별 recall** 분석 -- 예: `ema_pullback`은 B*(눌림목)에 강한가, `rsi_divergence`는 Bd/Sd에 강한가 -- 유형별 최고 recall 기법 리포트 - -### 2-4. MTF 상관 분석 - -39기법을 TF마다 다시 돌리지 **않습니다**. 대신: - -1. GT v3 **매수/매도 시점**(양성 샘플)에서 1분 ~ 월봉 피처 스냅샷 추출 -2. GT가 아닌 **랜덤 3분봉 시점**(음성 샘플, 기본 2000건)에서 동일 추출 -3. 양성 vs 음성 피처 차이(Cohen's d) 분석 -4. 신호 유형별 MTF **필터 규칙** 도출 → `mtf_rules_v3.json` - -**사용 TF** (`DEFAULT_DOWNLOAD_INTERVALS`) - -1, 3, 5, 10, 15, 30, 60, 240분, 일(1440), 주(10080), 월(43200) - -**TF별 피처** (`src/bithumb/mtf/features.py`) - -| 피처 | 용도 | -|------|------| -| `rsi14` | 과매수 / 과매도 | -| `bb_position` | 볼린저 밴드 내 위치 | -| `close_vs_ema60_pct` | EMA60 대비 이격 (추세·과열) | -| `ema60_slope_5_pct` | EMA60 기울기 | -| `trend_bias` | bullish / bearish | -| `atr_pct` | 변동성 | -| `zigzag_direction`, `zigzag_leg_pct` | 인과 ZigZag 상태 | - -모든 피처는 **인과적** — 해당 시점에 확정된 봉만 사용, 미래 데이터·미완성 상위 TF 봉 미사용. - -### 3단계 연동 (예정) - -``` -3분 composite_v3 신호 발생 - → MtfFeatureExtractor: 그 시점 1분~월봉 스냅샷 - → HtfTrendGate: 60분·일봉 EMA 이격으로 극단 구간 차단 - → MtfSignalFilter: 신호 유형별 MTF 규칙 N개 이상 충족 - → 통과 신호만 실거래 -``` - -**고TF 추세 게이트** (`HtfTrendGate`) 기본값 - -| 조건 | 동작 | -|------|------| -| 일봉 EMA60 대비 -25% 이하 | 매수 차단 (과매도·칼날) | -| 60분 EMA60 대비 -15% 이하 | 매수 차단 | -| 일봉 EMA60 대비 +35% 이상 | 매도 차단 (과열) | -| 60분 EMA60 대비 +20% 이상 | 매도 차단 | - -### 데이터 흐름 - -```mermaid -flowchart TD - GT["0단계 GT v3\n(3분봉 정답 타점)"] - Candles["3분봉 3650일\n(coins.db)"] - - subgraph step21 ["2-1 기법 실행"] - T["39개 인과 기법"] - T --> Sig["매수/매도 신호"] - Sig --> Align["GT 정합 score"] - end - - subgraph step22 ["2-2 sim"] - Sim["최근 3년 수익률"] - end - - subgraph step23 ["2-3 신호유형"] - Type["B/B*/B^/Bd/S/Sd별 recall"] - end - - subgraph step24 ["2-4 MTF"] - MTF["1분~월봉 피처"] - MTF --> Rules["mtf_rules_v3.json"] - end - - subgraph step3 ["3단계 (예정)"] - Filter["composite_v3 + MtfSignalFilter"] - end - - GT --> Align - Candles --> step21 - Align --> step22 - Align --> step23 - GT --> step24 - MTF --> Rules - Rules --> Filter - Sig --> Filter -``` - ---- - -## Check (검토) - -### 접근 방식 비교 - -#### A. 현재 방식 (채택) - -3분봉 × 39기법 GT 정합 + 4단계 MTF 상관 분석 - -#### B. 대안: 모든 기법 × 모든 TF × 모든 봉 상태 - -| 구분 | A: 현재 2단계 | B: 전 TF·전 봉 상태 | C: 절충 (선택 실험) | -|------|---------------|---------------------|---------------------| -| **품질 (Q)** | GT와 동일 TF·bar_index로 평가 **명확** | TF마다 신호 의미 상이, **평가 기준 혼란** | 핵심 기법만 상위 TF 추가 검증 | -| **비용 (C)** | 39기법 × 1 TF ≈ 1~2시간 | 39 × 11 TF ≈ 10~20시간+, 봉별 상태 저장 부담 큼 | +2~3 TF 시 +30~50% | -| **일정 (D)** | 파이프라인·3단계 **구현 완료** | 정합·스키마·리포트 **전면 재설계** | 기존 유지 + 실험 브랜치 | -| **리스크 (R)** | 단일 TF 편향 (MTF로 보완) | 차원 폭발 → **과적합** | 실험 범위 제한으로 통제 | -| **장점 (S)** | 인과·실거래·GT 정합 **일관** | TF별 탐색적 발견 | A 명확성 + B 인사이트 일부 | -| **단점 (W)** | 2-1 단독으론 상위 TF 안 보임 | GT(3분)와 **축 불일치** | 실험 설계 부담 | -| **비고** | **현 단계 최적** | 2단계 **대체안 부적합** | 장기 개선 1순위 | - -**결론: A(현재 방식) 유지. 필요 시 C로 확장.** - -### 3분봉만으로 상위 TF 과매수/과매도 판단이 어렵지 않은가? - -**맞습니다.** 3분봉 신호만 따르면: - -- 일봉 RSI 과매수 구간 3분 눌림목 매수 → 역추세 진입 -- 주봉 하락 추세 3분 돌파 매수 → 가짜 돌파 -- 월봉 과매도 3분 매도 → 바닥 청산 - -등의 문제가 발생합니다. Bithumb은 이를 **MTF 레이어**로 보완합니다. - -| 상황 | MTF 해석 | 의도 | -|------|----------|------| -| 3분 B* + 주봉 RSI 과매도 + 일봉 trend bullish | MTF 규칙 다수 충족 | 적극 매수 (3단계 확장) | -| 3분 B* + 일봉 RSI 과매수 | 게이트/규칙 미충족 | 보류 | -| 3분 S + 월봉 과열 + 60분 기울기 꺾임 | 매도 규칙 충족 | 적극 매도 (3단계 확장) | - -현재 구현은 **차단(필터)** 중심이며, MTF 점수 기반 **포지션 크기 조절**은 3단계에서 확장 가능합니다. - -### KPI · 확인 방법 - -| KPI | 확인 위치 | 기준 예시 | -|-----|-----------|-----------| -| GT 정합 score | `comparison_report.html` | composite_v3 상위권 | -| leg recall | `data/spot/techniques/*.json` | 70% 이상 (기법별 상이) | -| sim 수익률 (3년) | `causal_sim_report.html` | 1단계 GT sim 대비 합리적 | -| 신호 유형 recall | `signal_type_report.html` | B/B*/B^/Bd/S/Sd 유형별 1위 기법 | -| MTF Cohen's d | `mtf_correlation_report.html` | \|d\| ≥ 1.2 규칙 후보 | -| MTF 규칙 통과율 | 3단계 `MtfSignalFilter` | kept/rejected 비율 | - -### 주요 환경 변수 - -| 변수 | 설명 | 기본값 | -|------|------|--------| -| `GT_INTERVAL_MIN` | GT·기법 기준 인터벌(분) | `3` | -| `GT_LOOKBACK_DAYS` | GT·기법 기간(일) | `3650` | -| `GT_SIM_LOOKBACK_DAYS` | sim·MTF 분석 구간(일) | `1095` | -| `GT_ALIGN_TOLERANCE_BARS` | GT 정합 허용 봉 수 | `480` | -| `TECHNIQUES_DIR` | 기법 결과 JSON | `data/spot/techniques/` | -| `MTF_RULES_JSON` | MTF 규칙 | `data/spot/mtf/mtf_rules_v3.json` | - ---- - -## Act (개선) - -### 현재 한계 - -| 항목 | 상태 | 비고 | -|------|------|------| -| 2-1 ~ 2-3 | MTF 미적용 | 의도적 — GT 정합 단계 | -| `HtfTrendGate` | 60분·일봉만 | 주·월봉 RSI/BB 게이트 추가 가능 | -| MTF 필터 | 통과/실패 이진 | 점수 기반 사이즈 조절 (3단계) | -| 2-4 → rules JSON | `derive_rules_from_report()` 존재 | 2-4 스크립트 자동 저장 연동 검토 | - -### 권장 개선 (절충 C) - -2단계 골격은 유지하고, 아래만 **별도 실험**으로 진행합니다. - -1. **TF별 핵심 기법**: 스윙 5종 + `composite_v3`를 60분·일봉에 추가 실행 — 상위 TF 스윙과 3분 GT 정합 비교 -2. **MTF 4단계 강화**: 음성 샘플 수, 피처, `min_cohens_d` 조정 -3. **주·월봉 게이트**: `HtfTrendGate`에 RSI/BB 기반 조건 추가 -4. **레짐 라벨 모듈**: EMA/ADX 등 봉별 장세 라벨 — 39기법 전 TF 재실행 없이 보조 - -### 하지 말아야 할 것 - -- 39기법 × 11 TF × 175만 3분봉 상태를 2단계 **기본 산출물**로 포함 -- GT(3분)와 다른 TF 신호를 **동일 tolerance**로 무조건 비교 - -### 실행 체크리스트 - -- [ ] 0단계 GT v3 생성 완료 (`0_ground_truth.py --tier all`) -- [ ] 1단계 GT sim 완료 (벤치마크 참조용) -- [ ] `bash scripts/2_run_stage2_all.sh` 완료 -- [ ] `comparison_report.html` — 기법 순위 확인 -- [ ] `signal_type_report.html` — 유형별 강점 기법 확인 -- [ ] `mtf_correlation_report.html` — 일/주/월 RSI·EMA 패턴 확인 -- [x] `mtf_rules_v3.json` — 3단계 필터 규칙 반영 (`data/spot/mtf/mtf_rules_v3.json`) - ---- - -## 참고: 관련 소스 - -| 모듈 | 경로 | -|------|------| -| 기법 실행 | `scripts/2_run_techniques.py`, `src/bithumb/techniques/runner.py` | -| GT 정합 | `src/bithumb/evaluation/gt_align.py` | -| MTF 피처 | `src/bithumb/mtf/features.py`, `extractor.py`, `store.py` | -| MTF 규칙 | `src/bithumb/mtf/rules.py` | -| MTF 필터 | `src/bithumb/mtf/filter.py`, `trend_gate.py` | -| 통합 기법 | `src/bithumb/techniques/composite_v3.py` | - ---- - -## 변경 이력 - -| 날짜 | 내용 | -|------|------| -| 2026-06-12 | 초版 작성 — 2단계 목적, 4단계 구조, MTF 보완, 접근 방식 비교 정리 | diff --git a/docs/spot/2_analysis/stage2_final_summary.md b/docs/spot/2_analysis/stage2_final_summary.md deleted file mode 100644 index ed85302..0000000 --- a/docs/spot/2_analysis/stage2_final_summary.md +++ /dev/null @@ -1,290 +0,0 @@ -# 현물 2단계 최종 정리 — 결과 해석 및 운영 권고 - -> Bithumb 현물 파이프라인 2단계(인과 기법 분석) 완료 후 종합 정리 문서 -> 작성 기준: 2026-06-12 · 데이터: BTC · 3분봉 · GT v3 · 분석 기간 3650일 · sim 기간 최근 3년(1095일) - ---- - -## 요약 (Executive Summary) - -| 질문 | 결론 | -|------|------| -| **sim 1위 `fractal_swing`을 바로 실거래에 써도 되나?** | **아니요.** 연구·벤치마크용으로는 유효하나, 체결 빈도·비용·슬리피지를 고려하면 실거래 부적합 | -| **2단계에서 무엇을 얻었나?** | GT v3 타점을 **인과적으로** 재현하는 기법 39종의 **정합 순위**, **3년 sim 비교**, **신호 유형별 강점**, **MTF 상관 패턴** | -| **운영 후보는?** | 원안 **`composite_v3` + MTF 필터(3단계)** — 단, 현재 sim 기준 composite_v3는 **-97.5%**로 **3단계 튜닝 전 실거래 금지** | -| **당장 관찰용으로 쓸 만한 기법** | `zigzag_causal`(3년 sim +92,711%, 체결 97회), `minor_swing`(+286,537%, 831회) — **모의·소액 검증 후** 판단 | - ---- - -## Plan (계획) — 2단계가 무엇을 했는가 - -### 목적 - -0단계에서 도출한 **Ground Truth v3(사후 최적 타점)** 을, **미래 데이터 없이** 인과 규칙만으로 얼마나 재현할 수 있는지 검증하고, 3단계 실거래 전략의 근거를 마련하는 단계입니다. - -| 단계 | 역할 | 미래 데이터 | -|------|------|-------------| -| 0단계 | 10년 3분봉 이론적 최적 매수/매도(GT v3) | 사용 (연구용) | -| 1단계 | GT 타점을 그대로 sim했을 때 3년 수익 **벤치마크** | GT 자체가 사후 타점 | -| **2단계** | **39개 인과 기법**으로 GT 재현도·sim·MTF 분석 | **미사용** | -| 3단계 (예정) | 검증된 전략 + MTF 필터 실거래 연결 | 실시간 인과 | - -### GT v3 신호 체계 (정답지) - -| 코드 | 유형 | 10년 GT 건수(매수/매도) | -|------|------|-------------------------| -| B | swing_low (스윙 매수) | 944 | -| B* | pullback (눌림목) | 406 | -| B^ | breakout (돌파) | 122 | -| Bd | div_bull (상승 다이버전스) | 115 | -| S | swing_high (스윙 매도) | 944 | -| Sd | div_bear (하락 다이버전스) | 144 | - -### 실행 구조 (2-1 ~ 2-4) - -| 순서 | 내용 | 주요 산출물 | -|------|------|-------------| -| 2-1 | 39개 기법 신호 생성 + GT 정합 score | `data/spot/techniques/*.json`, `comparison_report.html` | -| 2-2 | 동일 sim 엔진으로 최근 3년 수익률 비교 | `causal_sim_report.html`, `causal_sim_chart_best_technique.html` | -| 2-3 | B/B*/B^/Bd/S/Sd 유형별 recall | `signal_type_report.html` | -| 2-4 | GT 시점 vs 랜덤 시점 MTF 피처 상관 | `mtf_correlation_report.html` | - -설계 상세는 [`stage2_design_guide.md`](stage2_design_guide.md)를 참고하세요. - ---- - -## Do (실행) — 핵심 결과 - -### 1. GT 정합 score 상위 (2-1, 10년 전체) - -정합 허용 오차: **±480봉(약 24시간)**. score는 recall·precision·leg recall 등을 종합한 0~1 지표입니다. - -| 순위 | 기법 | score | buy/sell recall | leg recall | 비고 | -|------|------|-------|-----------------|------------|------| -| 1 | **fractal_swing** | **0.914** | 100% / 100% | 75.3% | 프랙탈(span=2) 극저점·극고점 | -| 2 | **pivot_swing** | **0.911** | 100% / 100% | 74.6% | 피벗 기반 스윙 | -| 3 | **minor_swing** | **0.864** | 87.9% / 95.0% | 73.2% | 소형 스윙 하이브리드 | -| 4 | local_extrema | 0.839 | 86.3% / 90.7% | 70.6% | 국소 극값 | -| 5 | **zigzag_causal** | **0.776** | 59.9% / 86.8% | 74.2% | 인과 ZigZag — **스윙(B/S)에 특화** | -| … | composite_v3 | 0.546 | 97.5% / 89.0% | **22.9%** | 3단계 운영 후보이나 leg 정합 낮음 | - -**해석:** 상위 3종은 모두 **스윙 타이밍** 기법입니다. `composite_v3`는 개별 신호 recall은 높지만 **매수·매도 쌍(leg) 정합이 22.9%**에 그쳐, “타점은 근처에 있으나 한 사이클로 묶기 어렵다”는 특성이 있습니다. - -### 2. 3년 인과 sim (2-2) — 1단계 GT v3 벤치마크 대비 - -- **sim 기간:** 2023-06-12 ~ 2026-06-11 (1095일) -- **초기 자본:** 200,000원 -- **엔진:** 1단계와 동일 `simulate_gt_signals_pnl` (클러스터 분할, 매수 상한, 수수료 반영, 슬리피지 미반영) - -#### 1단계 벤치마크 - -| 항목 | 1단계 GT v3 sim | -|------|-----------------| -| 3년 수익률 | **+94,154%** | -| 최종 평가 | 약 1.89억 원 | -| 체결 | 매수 239 / 매도 151 | -| 기간 내 신호 | 390건 | - -#### 2단계 sim 상위 - -| 순위 | 기법 | 3년 sim 수익률 | 최종 평가 | 매수/매도 체결 | GT 정합 | -|------|------|----------------|-----------|----------------|---------| -| 1 | fractal_swing | **+7,560,826%** | 약 151억 | **56,893 / 56,892** | 0.914 | -| 2 | pivot_swing | +4,687,495% | 약 94억 | 12,656 / 12,658 | 0.911 | -| 3 | minor_swing | +286,537% | 약 5.7억 | 831 / 887 | 0.864 | -| 4 | keltner_reversal | +203,632% | 약 4.1억 | 26,554 / 24,839 | 0.723 | -| … | **zigzag_causal** | **+92,711%** | 약 1.86억 | **97 / 97** | 0.776 | -| … | **composite_v3** | **-97.5%** | 약 5,000원 | 1,885 / 1,237 (스킵 다수) | 0.546 | - -**일평균 체결 빈도(매수 기준, 3년):** - -| 기법 | 연간 약 | 일평균 약 | -|------|---------|-----------| -| fractal_swing | 18,964회 | **52회/일** | -| pivot_swing | 4,219회 | 12회/일 | -| minor_swing | 277회 | 0.8회/일 | -| zigzag_causal | 32회 | **0.09회/일** | -| GT v3 (1단계) | 80회 | 0.2회/일 | - -### 3. sim 1위가 GT 벤치마크보다 높은 이유 (핵심 메커니즘) - -동일 sim 엔진임에도 수익률이 역전되는 이유는 **전략 품질이 아니라 체결 구조** 때문입니다. - -| 요인 | GT v3 (1단계 sim) | fractal_swing (sim 1위) | -|------|-------------------|-------------------------| -| 신호 수 (3년) | 390 | **113,786** | -| 클러스터 | 평균 1.62신호/클러스터, 다중 매수 분할 | **99.7%가 1신호=1체결** | -| 매매 패턴 | 드문 타점, 보수적 분할·상한 | **매수·매도 거의 매 스윙마다 교대** | -| 수수료 (3년 sim) | 상대적으로 적음 | **약 65억 원** (이상적 체결 가정) | -| sim의 의미 | “최적 타점을 보수적으로 따라감” | “초고빈도 복리 + 상승장 + 슬리피지 없음” | - -**정리:** fractal의 높은 sim 수익률은 **GT 재현 우수성의 증거가 아니라**, tolerance(±24시간) 안에서 **모든 미세 스윙이 GT에 걸리는 구조**와 **과매매 복리**가 합쳐진 **백테스트 착시**에 가깝습니다. - -### 4. 신호 유형별 정합 (2-3) — v3 6종 커버리지 - -#### fractal_swing — tolerance 내 “전 유형 100% recall” - -±480봉 허용 시 스윙·눌림·돌파·다이버전스 **모든 GT 유형에 recall 100%**로 집계됩니다. 이는 각 유형을 **정확히 구분해 맞춘다**는 뜻이 아니라, **3분봉 미세 스윙이 24시간 안에 GT 타점과 겹친다**는 통계적 결과입니다. - -#### zigzag_causal — 스윙 특화, 복합 유형 약함 - -| GT 유형 | zigzag recall | 해석 | -|---------|---------------|------| -| B (swing_low) | **100%** | 스윙 매수에 최적 | -| S (swing_high) | **100%** | 스윙 매도에 최적 | -| B* (pullback) | 38.4% | 눌림목은 별도 기법 필요 | -| B^ (breakout) | 15.6% | 돌파 약함 | -| Bd (div_bull) | 4.4% | 다이버전스 거의 미포착 | -| Sd (div_bear) | 2.8% | 다이버전스 거의 미포착 | - -→ v3 **6종 전체**를 한 기법으로 운영하려면 **스윙 + 눌림 + 돌파 + 다이버전스**를 조합한 `composite_v3` 또는 유형별 전문 기법 조합이 필요합니다. - -#### composite_v3 (3단계 설계 후보) - -- 10년 정합 score **0.546** (39종 중 하위권) -- leg recall **22.9%** — 신호는 많지만 **한 사이클(매수→매도) 단위 정합 낮음** -- 3년 sim **-97.5%**, buys_skipped **6,090건** — 신호 과다 + 자본·상한·클러스터 규칙과 충돌 - -### 5. MTF 상관 분석 (2-4) - -GT v3 **매수/매도 시점(양성)** vs **랜덤 3분봉(음성)** 에서 1분~월봉 피처를 비교했습니다. - -**대표 패턴 (|Cohen's d| 큰 항목 예시):** - -| 상황 | TF | 피처 | 방향 | -|------|-----|------|------| -| 스윙 매도(S) | 15분 | RSI14 | GT 시점이 랜덤보다 RSI 높음 (과매수 근처) | -| 눌림목(B*) | 3분 | close_vs_ema60_pct | GT 눌림이 EMA60 대비 더 아래 | -| 스윙 매수(B) | 30분 | ema60_slope | GT 매수 시 기울기가 상대적으로 완만/하락 | - -**의미:** 3분 신호만으로는 부족한 **상위 TF 과열·추세** 정보가 GT 타점과 통계적으로 연관됩니다. 3단계 `HtfTrendGate` + `MtfSignalFilter`로 보완하는 설계가 타당합니다. - -**현재 상태:** `mtf_correlation_report.html`·`.json`은 생성됨. `data/spot/mtf/mtf_rules_v3.json`은 **아직 자동 저장 미연동** — 3단계 전 규칙 JSON 확정·연동 필요. - ---- - -## Check (검토) — “가장 좋은 기법”을 운영에 써도 되는가? - -### 결론: **fractal_swing 단독 실거래는 권장하지 않습니다** - -| 검토 항목 | fractal_swing | 실거래 적합성 | -|-----------|---------------|---------------| -| 체결 빈도 | 일 **약 52회** 매수 | 거래소 API·수수료·세금·운영 부담 과다 | -| 슬리피지 | sim **미반영** | 고빈도일수록 체결 가격 악화 누적 | -| 수수료 | 3년 sim만 **약 65억** 가정 | 실제로도 수익 잠식 극심 | -| 신호 의미 | 미세 스윙 전부 | 노이즈·휩소에 취약 | -| GT 정합 | score 최상 | **±24h tolerance** 효과 — “정확한 v3 타입 재현”과 다름 | -| 3단계 설계 | 원안은 composite_v3 + MTF | fractal은 **연구 1위**, **운영 1안 아님** | - -**가능한 예외 (제한적):** - -- **연구·모니터링:** 차트·알림으로 “스윙 후보” 참고용 -- **극소액 실험:** 일 체결 상한(예: 1~2회/일), 포지션 캡, 슬리피지 가정 추가 후 **모의거래 3개월 이상** - -### 운영 후보 비교 (QCD) - -| 구분 | A. fractal_swing 단독 | B. composite_v3 + MTF (원안) | C. zigzag + 유형별 보조 + MTF | D. minor_swing + MTF | -|------|----------------------|------------------------------|------------------------------|----------------------| -| **Q (품질)** | 스윙 타이밍만, 유형 혼동 | v3 6종 통합 의도 | B/S 강함, B*/B^/Bd 약 — 보조 기법 필요 | 스윙+하이브리드, 균형 | -| **C (비용)** | 수수료·API 호출 **최대** | 신호 필터 후 감소 예정 | **낮음** (연 ~32매수) | **중간** (연 ~277매수) | -| **D (일정)** | 즉시 가능하나 **리스크 최대** | 3단계 구현·튜닝 필요 | MTF·모의 1~2개월 | MTF·모의 1~2개월 | -| **R (리스크)** | 과매매·슬리피지·장애 **극高** | 현재 sim -97.5% — **튜닝 전 위험** | 신호 적어 기회 손실 | 중간 빈도 휩소 | -| **S (장점)** | GT 정합 1위, 구현 단순 | v3 철학과 일치, 확장성 | **GT sim과 유사 체결(97회)**, 해석 용이 | 정합 3위, sim 양호 | -| **W (단점)** | 실거래 **비현실** | leg recall 낮음, 과다 스킵 | 6종 중 4종 약함 | pivot/fractal 대비 정합 낮음 | -| **비고** | **운영 부적합** | **장기 정석**(튜닝 후) | **단기 모의 1순위** | 모의 2순위 | - -**최종 의견:** -- **지금 당장 실거래:** 위 네 안 모두 **완료 전제 미충족**. 최소 **3단계 MTF 필터 + 모의거래 + composite/조합 튜닝** 후 소액. -- **2단계 성과의 올바른 사용:** sim 1위 기법을 “운영 전략”으로 고르지 말고, **정합·유형·체결 빈도·MTF**를 함께 보고 **3단계 설계 입력**으로 사용. -- **단기 모의 우선순위:** **C (zigzag_causal + MTF)** → **D (minor_swing + MTF)**. -- **중장기 운영 정석:** **B (composite_v3 + MTF)** — 임계값·스킵 로직·leg 정합 개선 후. - -### KPI 달성 여부 (2단계 관점) - -| KPI | 목표(가이드) | 결과 | 판정 | -|-----|--------------|------|------| -| 인과 기법 39종 실행 | 완료 | 완료 | 달성 | -| GT 정합 ranking | 상위 기법 식별 | fractal/pivot/minor | 달성 | -| 1단계 sim 대비 3년 비교 | 해석 가능한 보고 | causal_sim_report | 달성 (단, sim만으로 운영 선정 금지) | -| 유형별 recall | B~Sd별 1위 기법 | signal_type_report | 달성 | -| MTF Cohen's d | 필터 규칙 후보 | mtf_correlation_report | 달성 | -| mtf_rules_v3.json | 3단계 입력 | `data/spot/mtf/mtf_rules_v3.json` (6유형×4규칙) | 달성 | - ---- - -## Act (개선) — 3단계로 넘기기 전 체크리스트 - -### 하지 말아야 할 것 - -1. **causal_sim_report 수익률 1위 = 실거래 전략**으로 간주 -2. **fractal_swing 풀오토** (일 50회+ 체결) -3. **composite_v3 미튜닝 실거래** (현재 3년 sim -97.5%) -4. **0단계 전기간 GT 수익**과 2단계 3년 sim **직접 비교** (sim 규칙이 다름) - -### 권장 다음 단계 - -| 순서 | 작업 | 목적 | -|------|------|------| -| 1 | `mtf_rules_v3.json` 생성·`MtfSignalFilter` 연동 | 3단계 필터 기반 확보 | -| 2 | `composite_v3` 임계값·스킵 원인 분석 (buys_skipped 6,090) | leg 정합·자본 효율 개선 | -| 3 | **zigzag_causal** + MTF **모의거래** (슬리피지 0.05~0.1% 가정) | 현실적 체결 검증 | -| 4 | 유형별 보조: B*→ema_pullback, B^→donchian, Bd→rsi_divergence 등 **composite 재가중** | v3 6종 커버리지 | -| 5 | sim에 **슬리피지·일 최대 체결 횟수** 옵션 추가 | 고빈도 기법 과대평가 방지 | -| 6 | 소액 실거래 전 **최소 3개월 paper trading** KPI | MDD, 승률, 실체결률 | - -### 관련 산출물 위치 - -| 문서/데이터 | 경로 | -|-------------|------| -| 기법 정합 순위 | `docs/spot/2_analysis/comparison_report.html` | -| 3년 sim 순위 | `docs/spot/2_analysis/causal_sim_report.html` | -| 1위 vs 1단계 GT 비교 차트 | `docs/spot/2_analysis/causal_sim_chart_best_technique.html` | -| 신호 유형별 recall | `docs/spot/2_analysis/signal_type_report.html` | -| MTF 상관 | `docs/spot/2_analysis/mtf_correlation_report.html` | -| 기법별 sim 차트 (39종) | `docs/spot/2_analysis/technique_chart_sim_*.html` | -| 기법 JSON | `data/spot/techniques/*.json` | -| 2단계 설계 가이드 | `docs/spot/2_analysis/stage2_design_guide.md` | - ---- - -## 부록 A — 지표 읽는 법 - -### GT 정합 score vs sim 수익률 - -- **score:** “GT 타점 **근처**에 인과 신호가 있었는가” (위치 품질) -- **sim 수익률:** “그 신호를 **현재 체결 규칙**으로 얼마나 자주·얼마나 크게 탔는가” (빈도·복리) - -두 지표는 **상관이 없을 수 있음**. fractal이 대표 사례. - -### 1단계 GT sim이 “상한”이 아닌 이유 - -- 클러스터 **분할 매수** (v3 평균 매수 클러스터 ~2신호) -- **매수 상한** (총평가 1억/10억/100억 구간별 10%/5%/1%) -- 3년 구간 **신호 390건**에 한정 - -0단계 `simulate_gt_pnl`(레그당 전액 in/out)과는 다른 척도입니다. - -### fractal이 모든 signal_type recall 100%인 이유 - -정합 tolerance **±480봉(24시간)**. 3분봉 fractal은 하루에도 수십 개 스윙을 내므로, GT의 스윙·눌림·돌파·다이버전스 타점 대부분이 **시간 창 안에至少 하나의 fractal 신호**와 매칭됩니다. **유형 분류 정확도**와 혼동하지 말 것. - ---- - -## 부록 B — 3년 sim 참고 표 (체결 빈도별) - -| 기법 | sim 수익률 | 매수 체결 | GT 정합 | 운영 관점 | -|------|-------------|-----------|---------|-----------| -| fractal_swing | +7,560,826% | 56,893 | 0.914 | 연구용, 실거래 비권장 | -| pivot_swing | +4,687,495% | 12,656 | 0.911 | 고빈도, 실거래 비권장 | -| minor_swing | +286,537% | 831 | 0.864 | 모의 후보 | -| zigzag_causal | +92,711% | 97 | 0.776 | **모의 1순위** (GT sim과 유사 빈도) | -| GT v3 (1단계) | +94,154% | 239 | — | 벤치마크 | -| composite_v3 | -97.5% | 1,885 | 0.546 | 3단계 튜닝 필수 | - ---- - -## 변경 이력 - -| 날짜 | 내용 | -|------|------| -| 2026-06-12 | 2단계 완료 후 최종 정리 — 운영 권고, sim 역전 해석, 3단계 체크리스트 | diff --git a/docs/spot/3_operations/stage3_design_guide.md b/docs/spot/3_operations/stage3_design_guide.md deleted file mode 100644 index f85248c..0000000 --- a/docs/spot/3_operations/stage3_design_guide.md +++ /dev/null @@ -1,150 +0,0 @@ -# 현물 3단계 설계 가이드 — 실거래 운영 - -> 2단계 검증 전략(`composite_v3` + MTF 필터)을 빗썸 현물에 연결하는 운영 단계 -> 작성 기준: 2026-06-12 · 기본 모드: **paper** - ---- - -## Plan (계획) - -### 목적 - -2단계에서 도출한 **인과 기법 + MTF 필터**를 실시간(또는 모의) 운영 파이프라인에 연결하고, **live 전환 전** 필터 효과를 백테스트로 재검증한다. - -### 파이프라인 - -``` -캔들 증분 동기화 (00_download.py) - ↓ -composite_v3 신호 생성 (3분봉) - ↓ -signal_type 추론 (기여 기법 → B/B*/B^/Bd/S/Sd) - ↓ -HtfTrendGate (60분·일봉 극단 차단) - ↓ -MtfSignalFilter (mtf_rules_v3.json) - ↓ -paper / live 체결 (구간별 매수 상한 동일) -``` - -### 운영 모드 - -| 모드 | 설명 | 기본값 | -|------|------|--------| -| **paper** | DB 캔들·신호 가격 기준 모의 체결 | **권장·기본** | -| **live** | 빗썸 Private API 시장가 주문 | API 키 필요, 신중히 사용 | - ---- - -## Do (실행) - -### 스크립트 - -| 순서 | 스크립트 | 역할 | -|------|----------|------| -| 3-1 | `3_run_filtered_backtest.py` | MTF 필터 전/후 3년 sim 비교 | -| 3-2 | `3_run_operations.py` | paper/live 1회 tick (신호·체결) | -| 일괄 | `3_run_stage3_all.sh` | 3-1 + 3-2 paper | - -```bash -cd Bithumb -export PYTHONPATH=src - -# MTF 필터 백테스트 -python scripts/3_run_filtered_backtest.py - -# paper 운영 1회 -python scripts/3_run_operations.py - -# 일괄 -bash scripts/3_run_stage3_all.sh -``` - -### 주요 환경 변수 - -| 변수 | 설명 | 기본값 | -|------|------|--------| -| `OPS_MODE` | `paper` / `live` | `paper` | -| `OPS_TECHNIQUE_ID` | 운영 기법 | `composite_v3` | -| `OPS_MIN_SCORE` | composite 최소 점수 (선택) | 기법 기본 2.5 | -| `OPS_MTF_ENABLED` | MTF 필터 | `true` | -| `OPS_TREND_GATE_ENABLED` | 고TF 게이트 | `true` | -| `OPS_DAILY_MAX_TRADES` | 일일 체결 상한 | `20` | -| `OPS_MIN_ORDER_KRW` | 최소 주문 원화 | `5000` | -| `OPS_STATE_JSON` | 운영 상태 | `data/spot/operations/ops_state.json` | -| `BITHUMB_ACCESS_KEY` | live API (선택) | — | -| `BITHUMB_SECRET_KEY` | live API (선택) | — | - -### 산출물 - -| 파일 | 내용 | -|------|------| -| `docs/spot/3_operations/filtered_backtest_report.json` | 필터 전/후 sim | -| `docs/spot/3_operations/ops_report.json` | 최근 tick 리포트 | -| `data/spot/operations/ops_state.json` | 포트폴리오·체결 이력 | - -### 소스 모듈 - -| 모듈 | 경로 | -|------|------| -| 신호 파이프라인 | `src/bithumb/operations/signal_pipeline.py` | -| signal_type 추론 | `src/bithumb/operations/signal_type.py` | -| 체결 엔진 | `src/bithumb/operations/trade_engine.py` | -| paper/live | `src/bithumb/operations/executor.py` | -| 러너 | `src/bithumb/operations/runner.py` | -| 빗썸 Private | `src/bithumb/api/bithumb_private.py` | - ---- - -## Check (검토) - -### 초기 백테스트 결과 (BTC · 3년 · composite_v3) - -| 구분 | 신호 수 | 3년 sim 수익률 | -|------|---------|----------------| -| MTF 필터 **전** | 12,262 | **-97.5%** | -| MTF 필터 **후** | 1,215 | **+3.37%** | - -MTF 필터가 composite_v3의 과다 신호·역추세 진입을 상당 부분 걸러냅니다. live 전환 전 paper 운영으로 추가 검증이 필요합니다. - -### live 전환 전 체크리스트 - -- [ ] `3_run_filtered_backtest.py` — 필터 후 sim이 raw 대비 개선되는지 확인 -- [ ] 최소 1주일 **paper** 운영 (`--loop 180` 등) -- [ ] `OPS_DAILY_MAX_TRADES`·`OPS_MIN_SCORE` 튜닝 -- [ ] 빗썸 API 키 **출금 비활성**·IP 제한 설정 -- [ ] 소액으로 live 테스트 - -### 2단계 대비 3단계 차이 - -| 항목 | 2단계 | 3단계 | -|------|-------|-------| -| 목적 | 기법 평가·순위 | **운영 연결** | -| 신호 | 39종 개별 | **composite_v3 + MTF** | -| 체결 | 일괄 sim | **tick 단위 paper/live** | -| MTF | 분석·규칙 | **실시간 필터** | - ---- - -## Act (개선) - -### 권장 튜닝 - -1. `OPS_MIN_SCORE` 상향 (예: 3.5~4.0) — 신호 과다·스킵 감소 -2. `OPS_DAILY_MAX_TRADES` 하향 — 과매매 방지 -3. 텔레그램 알림 연동 (선택) -4. 슬리피지 가정 paper 백테스트 확장 - -### 하지 말아야 할 것 - -- 백테스트 미검증 상태에서 **live 풀오토** -- 2단계 sim 1위(`fractal_swing`) 그대로 운영 -- API 키를 Git에 커밋 - ---- - -## 변경 이력 - -| 날짜 | 내용 | -|------|------| -| 2026-06-12 | 3단계 초版 — paper/live 파이프라인, MTF 필터 백테스트, 운영 스크립트 | diff --git a/scripts/00_download_candles.py b/scripts/00_download_candles.py index 89a4d6b..fa52217 100644 --- a/scripts/00_download_candles.py +++ b/scripts/00_download_candles.py @@ -16,7 +16,7 @@ if str(SRC) not in sys.path: from dataclasses import replace -from bithumb.config import load_settings +from bithumb.config import load_settings, resolve_coin_name from bithumb.data.candle_store import CandleStore from bithumb.data.downloader import CandleDownloader from bithumb.data.intervals import INTERVAL_1MIN, estimate_download_requests, interval_label @@ -32,6 +32,58 @@ def _configure_logging(verbose: bool) -> None: ) +def _parse_symbols(raw: str | None, defaults: list[str]) -> list[str]: + """CLI --symbols 또는 기본 DOWNLOAD_SYMBOLS 목록.""" + if raw: + return [part.strip().upper() for part in raw.split(",") if part.strip()] + return list(defaults) + + +def _log_interval_estimates( + *, + store: CandleStore, + symbol: str, + intervals: list[int], + days: int, + full: bool, + batch_size: int, + sleep_sec: float, + log: logging.Logger, +) -> None: + """인터벌별 예상 API 요청 수를 로깅한다.""" + for interval in intervals: + if full: + est = estimate_download_requests(interval, days, batch_size=batch_size) + log.info( + "예상 API 요청: %s %s ≈ %s회 (풀 다운, sleep %.2fs)", + symbol, + interval_label(interval), + est, + sleep_sec, + ) + continue + _, _, db_max = store.get_range(symbol, interval) + if db_max is None: + est = estimate_download_requests(interval, days, batch_size=batch_size) + log.info( + "예상 API 요청: %s %s ≈ %s회 (DB 없음 → 풀 다운)", + symbol, + interval_label(interval), + est, + ) + else: + gap_days = max(1, (datetime.now() - db_max).days + 1) + est = estimate_download_requests(interval, gap_days, batch_size=batch_size) + log.info( + "예상 API 요청: %s %s ≈ %s회 (증분, DB=%s, 갭≈%s일)", + symbol, + interval_label(interval), + est, + db_max.strftime("%Y-%m-%d %H:%M:%S"), + gap_days, + ) + + def main() -> int: """CLI 진입점.""" parser = argparse.ArgumentParser( @@ -54,6 +106,12 @@ def main() -> int: default=None, help="(고급) 쉼표 구분 인터벌만 수집. 기본: .env DOWNLOAD_INTERVALS 전체", ) + parser.add_argument( + "--symbols", + type=str, + default=None, + help="쉼표 구분 심볼 (기본: .env DOWNLOAD_SYMBOLS 또는 SYMBOL)", + ) parser.add_argument( "--include-1min", action="store_true", @@ -63,8 +121,10 @@ def main() -> int: args = parser.parse_args() _configure_logging(args.verbose) - settings = load_settings() + base_settings = load_settings() + symbols = _parse_symbols(args.symbols, base_settings.download_symbols) + settings = base_settings if args.intervals: settings = replace( settings, @@ -83,76 +143,59 @@ def main() -> int: log = logging.getLogger(__name__) log.info( "대상=%s DB=%s mode=%s days=%s intervals=%s", - settings.market, + ",".join(f"KRW-{s}" for s in symbols), settings.db_path, mode_label, days, settings.download_intervals, ) - for interval in settings.download_intervals: - est = estimate_download_requests(interval, days, batch_size=settings.candle_count) - log.info( - "예상 API 요청: %s ≈ %s회 (sleep %.2fs)", - interval_label(interval), - est, - settings.request_sleep_sec, - ) store = CandleStore(settings.db_path) + exit_code = 0 try: - for interval in settings.download_intervals: - if args.full: - est = estimate_download_requests(interval, days, batch_size=settings.candle_count) - log.info( - "예상 API 요청: %s ≈ %s회 (풀 다운, sleep %.2fs)", - interval_label(interval), - est, - settings.request_sleep_sec, - ) - else: - _, _, db_max = store.get_range(settings.symbol, interval) - if db_max is None: - est = estimate_download_requests(interval, days, batch_size=settings.candle_count) - log.info( - "예상 API 요청: %s ≈ %s회 (DB 없음 → 풀 다운)", - interval_label(interval), - est, - ) - else: - gap_days = max(1, (datetime.now() - db_max).days + 1) - est = estimate_download_requests(interval, gap_days, batch_size=settings.candle_count) - log.info( - "예상 API 요청: %s ≈ %s회 (증분, DB=%s, 갭≈%s일)", - interval_label(interval), - est, - db_max.strftime("%Y-%m-%d %H:%M:%S"), - gap_days, - ) - - downloader = CandleDownloader(settings) - results = downloader.download_all(store, days=days, full=args.full) - - print(f"\n=== 수집 완료 ({mode_label}) ===") - for result in results: - count, min_dt, max_dt = store.get_range(settings.symbol, result.interval_min) - min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-" - max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-" - if result.mode == "uptodate": - flag = "UPTODATE" - elif result.reached_target: - flag = "OK" - else: - flag = "PARTIAL" - label = interval_label(result.interval_min) - print( - f"[{flag}] {label} ({result.interval_min}) mode={result.mode} | " - f"requests={result.requests} upsert={result.saved_rows} " - f"db_rows={count} range={min_s} ~ {max_s}" + for symbol in symbols: + symbol_settings = replace( + settings, + symbol=symbol, + coin_name=resolve_coin_name(symbol), ) + print(f"\n=== {symbol} ({symbol_settings.coin_name}) ===") + _log_interval_estimates( + store=store, + symbol=symbol, + intervals=symbol_settings.download_intervals, + days=days, + full=args.full, + batch_size=symbol_settings.candle_count, + sleep_sec=symbol_settings.request_sleep_sec, + log=log, + ) + + downloader = CandleDownloader(symbol_settings) + results = downloader.download_all(store, days=days, full=args.full) + + print(f"\n--- {symbol} 수집 완료 ({mode_label}) ---") + for result in results: + count, min_dt, max_dt = store.get_range(symbol, result.interval_min) + min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-" + max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-" + if result.mode == "uptodate": + flag = "UPTODATE" + elif result.reached_target: + flag = "OK" + else: + flag = "PARTIAL" + exit_code = 1 + label = interval_label(result.interval_min) + print( + f"[{flag}] {symbol} {label} ({result.interval_min}) mode={result.mode} | " + f"requests={result.requests} upsert={result.saved_rows} " + f"db_rows={count} range={min_s} ~ {max_s}" + ) finally: store.close() - return 0 + return exit_code if __name__ == "__main__": diff --git a/scripts/00_run_download_cron.sh b/scripts/00_run_download_cron.sh new file mode 100755 index 0000000..3f4c9ce --- /dev/null +++ b/scripts/00_run_download_cron.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Bithumb 캔들 증분 수집 (cron 1분, DOWNLOAD_SYMBOLS 전체) +set -euo pipefail +# shellcheck source=scripts/_cron_env.sh +source "$(dirname "$0")/_cron_env.sh" + +ensure_cron_log_dir "data/common" +LOCKDIR="data/common/download.lock.d" +# 3종목×11 TF 증분 — 20분 초과 시 hung 으로 간주 +if ! acquire_cron_lock "$LOCKDIR" "scripts/00_download.py" 1200; then + exit 0 +fi + +PYTHON="$(resolve_bithumb_python)" || exit 1 +"$PYTHON" scripts/00_download.py "$@" diff --git a/scripts/3_audit_ops_safety.py b/scripts/3_audit_ops_safety.py new file mode 100644 index 0000000..73e5ee9 --- /dev/null +++ b/scripts/3_audit_ops_safety.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""운영 신호·체결·거래소 주문 최종 점검 (놓침/중복).""" + +from __future__ import annotations + +import json +import sys +from collections import Counter +from datetime import datetime, timedelta +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from bithumb.api.bithumb_private import BithumbPrivateClient +from bithumb.config import load_settings +from bithumb.operations.exchange_reconcile import ( + _known_order_uuids, + _match_orders_to_signals, + _unsettled_signals_for_reconcile, + reconcile_exchange_fills, +) +from bithumb.operations.runner import ( + _history_index, + _is_settled, + _is_signal_api_executable, + _ledger_pending_signals, + _settle_expired_backlog, +) +from bithumb.operations.signal_pipeline import ( + filter_signals_for_ops, + generate_raw_signals, + load_ops_candles, +) +from bithumb.operations.state_store import load_state + + +def _parse_dt(value: str) -> datetime: + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + + +def main() -> int: + settings = load_settings() + state = load_state( + settings.ops_state_json, + initial_cash_krw=settings.gt_initial_cash_krw, + ) + df = load_ops_candles(settings) + latest_bar = len(df) - 1 + gen = generate_raw_signals( + settings, + df=df, + use_cache=True, + force_tail_refresh=( + settings.ops_mode == "live" and settings.ops_live_force_tail_refresh + ), + ) + all_kept = filter_signals_for_ops(settings, gen["raw_signals"])["kept"] + trade_history = list(state.get("trade_history") or []) + live_since = ( + str(state["live_initialized_at"]) + if settings.ops_mode == "live" and state.get("live_initialized_at") + else None + ) + max_age = settings.ops_ledger_execute_max_age_minutes + now = datetime.now() + + # tick과 동일 순서 시뮬레이션 (state 파일은 쓰지 않음) + sim_history = list(trade_history) + exchange_reconciled: list[dict] = [] + stale_settled: list[dict] = [] + + if settings.ops_mode == "live" and settings.ops_exchange_reconcile: + client = BithumbPrivateClient( + access_key=settings.bithumb_access_key, + secret_key=settings.bithumb_secret_key, + base_url=settings.api_url, + sleep_sec=settings.request_sleep_sec, + retries=settings.request_retries, + ) + exchange_reconciled = reconcile_exchange_fills( + all_kept, + sim_history, + client=client, + market=settings.market, + lookback_hours=settings.ops_exchange_order_lookback_hours, + match_window_min=settings.ops_exchange_match_window_min, + lookback_days=settings.ops_ledger_lookback_days, + ) + sim_history.extend(exchange_reconciled) + + stale_settled = _settle_expired_backlog( + all_kept, + sim_history, + max_age_minutes=max_age, + live_since=live_since, + lookback_days=settings.ops_ledger_lookback_days, + ) + sim_history.extend(stale_settled) + + ledger_pending = _ledger_pending_signals( + all_kept, + sim_history, + latest_bar_index=latest_bar, + lookback_days=settings.ops_ledger_lookback_days, + ) + + executable_pending: list[dict] = [] + expired_pending: list[dict] = [] + for sig in ledger_pending: + ok, reason = _is_signal_api_executable( + sig, + max_age_minutes=max_age, + live_since=live_since, + now=now, + ) + if ok: + executable_pending.append(sig) + else: + expired_pending.append({**sig, "skip_reason": reason}) + + # 중복 uuid + uuids = [] + for rec in sim_history: + trade = rec.get("trade") or {} + if trade.get("executed"): + resp = trade.get("api_response") + if isinstance(resp, dict) and resp.get("uuid"): + uuids.append(str(resp["uuid"])) + uuid_counts = Counter(uuids) + dup_uuids = {u: c for u, c in uuid_counts.items() if c > 1} + + # history 내 동일 신호 executed 중복 + idx = _history_index(sim_history) + executed_keys = [ + key + for key, rec in idx.items() + if (rec.get("trade") or {}).get("executed") + ] + raw_executed_count = sum( + 1 + for rec in sim_history + if (rec.get("trade") or {}).get("executed") + ) + + # 거래소 주문 vs 미정산 (reconcile 후에도 남는 orphan) + orphan_orders = 0 + unmatched_unsettled = len( + _unsettled_signals_for_reconcile( + all_kept, + sim_history, + lookback_days=settings.ops_ledger_lookback_days, + ) + ) + if settings.ops_mode == "live": + client = BithumbPrivateClient( + access_key=settings.bithumb_access_key, + secret_key=settings.bithumb_secret_key, + base_url=settings.api_url, + sleep_sec=settings.request_sleep_sec, + retries=settings.request_retries, + ) + since = now - timedelta(hours=settings.ops_exchange_order_lookback_hours) + orders = client.fetch_filled_orders_since(settings.market, since) + known = _known_order_uuids(sim_history) + unsettled = _unsettled_signals_for_reconcile( + all_kept, + sim_history, + lookback_days=settings.ops_ledger_lookback_days, + ) + matches = _match_orders_to_signals( + orders, + unsettled, + match_window_min=settings.ops_exchange_match_window_min, + known_uuids=set(known), + ) + orphan_orders = len(orders) - len(matches) - len( + [u for u in known if u in {str(o.get("uuid")) for o in orders}] + ) + + print("=== 최종 점검 (놓침/중복) ===") + print(f"mode: {settings.ops_mode}") + print(f"now: {now.strftime('%Y-%m-%d %H:%M:%S')}") + print(f"kept_signals: {len(all_kept)} | latest_bar: {latest_bar}") + print(f"trade_history (raw): {len(trade_history)}") + print() + print("[tick 시뮬레이션 — exchange → stale 순]") + print(f" exchange_reconciled (would add): {len(exchange_reconciled)}") + print(f" stale_settled (would add): {len(stale_settled)}") + print(f" ledger_pending (after sim): {len(ledger_pending)}") + print(f" executable_pending (API 체결 대상): {len(executable_pending)}") + print(f" expired_still_pending (버그 의심): {len(expired_pending)}") + print() + print("[중복]") + print(f" executed records (raw): {raw_executed_count}") + print(f" unique executed keys: {len(executed_keys)}") + print(f" duplicate uuid in history: {len(dup_uuids)}") + if dup_uuids: + for u, c in list(dup_uuids.items())[:5]: + print(f" uuid={u} count={c}") + print() + print("[놓침 위험]") + print(f" unsettled after sim: {unmatched_unsettled}") + print(f" orphan exchange orders (approx): {max(orphan_orders, 0)}") + if executable_pending: + print(" executable_pending 목록:") + for sig in executable_pending[:15]: + print(f" {sig['datetime']} {sig['side']}") + if len(executable_pending) > 15: + print(f" ... 외 {len(executable_pending) - 15}건") + if expired_pending: + print(" expired_still_pending (stale 미적용 의심):") + for sig in expired_pending[:10]: + print(f" {sig['datetime']} {sig['side']} — {sig.get('skip_reason')}") + + ok = ( + len(dup_uuids) == 0 + and len(expired_pending) == 0 + and len(executable_pending) <= settings.ops_max_backlog_per_tick + ) + print() + print("RESULT:", "PASS" if ok else "REVIEW_NEEDED") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/3_ensure_vol_monitor_serve.sh b/scripts/3_ensure_vol_monitor_serve.sh new file mode 100755 index 0000000..95d942c --- /dev/null +++ b/scripts/3_ensure_vol_monitor_serve.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# vol_live 모니터 HTTP 서버 — 8766 미수신 시 기동 (cron/수동) +set -euo pipefail +# shellcheck source=scripts/_cron_env.sh +source "$(dirname "$0")/_cron_env.sh" + +PORT="${VOL_MONITOR_PORT:-8766}" +export VOL_MONITOR_PORT="$PORT" + +if curl -sf -o /dev/null --connect-timeout 2 "http://127.0.0.1:${PORT}/vol_live_monitor.html"; then + exit 0 +fi + +exec bash "${CRON_PROJECT_ROOT}/scripts/3_run_vol_monitor_serve.sh" diff --git a/scripts/3_reconcile_signals.py b/scripts/3_reconcile_signals.py new file mode 100644 index 0000000..da76d35 --- /dev/null +++ b/scripts/3_reconcile_signals.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""ledger backlog 신호 조회·일괄 처리 (dry-run / execute).""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from bithumb.config import load_settings +from bithumb.operations.reconcile import inspect_ops_backlog +from bithumb.operations.runner import OperationsRunner + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="운영 backlog 신호 reconcile") + parser.add_argument( + "--dry-run", + action="store_true", + help="pending 목록만 출력 (기본)", + ) + parser.add_argument( + "--execute", + action="store_true", + help="OperationsRunner tick 1회로 backlog 처리", + ) + parser.add_argument( + "--no-sync", + action="store_true", + help="execute 시 캔들 sync 생략", + ) + args = parser.parse_args() + + settings = load_settings() + info = inspect_ops_backlog(settings) + merged = info["merged_pending"] + summary = info["summary"] + + print("=== backlog inspect ===") + print(f"signal_refresh: {info.get('signal_refresh')}") + print(f"force_tail_refresh: {info.get('force_tail_refresh')}") + print(f"ledger_pending: {len(info['ledger_pending'])}") + print(f"catchup_pending: {len(info['catchup_pending'])}") + print(f"merged_pending: {len(merged)}") + print(f"backlog_signal_count: {summary['backlog_signal_count']}") + print(f"backlog_oldest: {summary['backlog_oldest_datetime']}") + print(f"backlog_dropped (per tick limit): {info['backlog_dropped']}") + + for sig in merged[:50]: + print(f" {sig['datetime']} {sig['side']} bar={sig.get('bar_index')}") + if len(merged) > 50: + print(f" ... 외 {len(merged) - 50}건") + + if args.execute: + from bithumb.operations.runner import OperationsRunner as OpsRunner + + if settings.ops_mode == "live": + print("\n경고: live execute — 실제 주문이 발생할 수 있습니다.") + runner = OpsRunner(settings) + report = runner.tick(sync_candles=not args.no_sync) + print(f"\nexecute 완료: 체결 {len(report.get('executions', []))}건") + print(f"ledger_pending_count: {report.get('ledger_pending_count')}") + print(f"backlog_dropped_count: {report.get('backlog_dropped_count')}") + return 0 + + if not args.dry_run and not args.execute: + print("\n(--dry-run 기본, --execute 로 처리)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/3_refresh_vol_monitor.py b/scripts/3_refresh_vol_monitor.py new file mode 100644 index 0000000..bd32739 --- /dev/null +++ b/scripts/3_refresh_vol_monitor.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""vol_live 모니터 JSON 갱신 — 3_run_vol_monitor.py --refresh-only 래퍼.""" + +from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +if __name__ == "__main__": + target = Path(__file__).resolve().parent / "3_run_vol_monitor.py" + sys.argv = [str(target), "--refresh-only", *sys.argv[1:]] + runpy.run_path(str(target), run_name="__main__") diff --git a/scripts/3_render_live_chart.py b/scripts/3_render_live_chart.py index 93e196f..edfcd0a 100644 --- a/scripts/3_render_live_chart.py +++ b/scripts/3_render_live_chart.py @@ -63,7 +63,7 @@ def _write_index_html(

Bithumb Live — 운영 백테스트

- {report.get("symbol", "BTC")} · {report.get("technique_name", "")} ({report.get("technique_id", "")})
+ {report.get("symbol", "TRX")} · {report.get("technique_name", "")} ({report.get("technique_id", "")})
sim 기간: 최근 {report.get("sim_lookback_days", 1095)}일 · 슬리피지 {report.get("slippage_rate", 0) * 100:.2f}% · 일 체결 상한 {report.get("daily_max_trades", "-")} · diff --git a/scripts/3_run_operations.py b/scripts/3_run_operations.py index 2cb4f97..cc95f93 100644 --- a/scripts/3_run_operations.py +++ b/scripts/3_run_operations.py @@ -4,7 +4,9 @@ from __future__ import annotations import argparse +import atexit import logging +import os import sys import time from pathlib import Path @@ -18,6 +20,20 @@ from bithumb.config import load_settings from bithumb.operations.runner import OperationsRunner +def _write_loop_pid(path: Path) -> None: + """loop PID 파일 기록 (watch 재시작용).""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{os.getpid()}\n", encoding="utf-8") + + +def _remove_loop_pid(path: Path) -> None: + """loop 종료 시 PID 파일 삭제.""" + try: + path.unlink(missing_ok=True) + except OSError: + pass + + def _configure_logging(verbose: bool) -> None: level = logging.DEBUG if verbose else logging.INFO logging.basicConfig( @@ -66,7 +82,12 @@ def main() -> int: runner = OperationsRunner(settings) sync = not args.no_sync + if args.loop > 0 and settings.ops_loop_pid_file is not None: + _write_loop_pid(settings.ops_loop_pid_file) + atexit.register(_remove_loop_pid, settings.ops_loop_pid_file) + while True: + loop_started = time.monotonic() try: report = runner.tick(sync_candles=sync) except Exception as exc: @@ -81,7 +102,9 @@ def main() -> int: ) if args.loop <= 0: break - time.sleep(args.loop) + elapsed = time.monotonic() - loop_started + sleep_sec = max(0.0, float(args.loop) - elapsed) + time.sleep(sleep_sec) continue port = report.get("portfolio") or {} @@ -100,10 +123,18 @@ def main() -> int: f"코인 {port.get('coin_qty', 0):.8f} {settings.symbol}" ) print(f"리포트: {settings.ops_report_json}") + if report.get("ledger_pending_count") is not None: + print( + f"ledger pending: {report.get('ledger_pending_count')} · " + f"backlog dropped: {report.get('backlog_dropped_count', 0)} · " + f"tick: {report.get('last_tick_duration_sec')}s" + ) if args.loop <= 0: break - time.sleep(args.loop) + elapsed = time.monotonic() - loop_started + sleep_sec = max(0.0, float(args.loop) - elapsed) + time.sleep(sleep_sec) return 0 diff --git a/scripts/3_run_vol_breakout.py b/scripts/3_run_vol_breakout.py new file mode 100644 index 0000000..d51c2c2 --- /dev/null +++ b/scripts/3_run_vol_breakout.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""vol_breakout 현물 롱 — TRX/NEAR/WLD 멀티 tick (Binance 15m ATR 이식).""" + +from __future__ import annotations + +import argparse +import logging +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from bithumb.config import load_settings +from bithumb.operations.vol_breakout_runner import VolBreakoutRunner + + +def _configure_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def main() -> int: + """CLI.""" + parser = argparse.ArgumentParser(description="Bithumb vol_breakout 현물 롱 tick") + parser.add_argument("--mode", choices=("paper", "live"), default=None) + parser.add_argument("--loop", type=int, default=0, metavar="SEC") + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + _configure_logging(args.verbose) + + if args.mode: + import os + os.environ["OPS_MODE"] = args.mode + + settings = load_settings() + if not settings.ops_symbols: + print("OPS_SYMBOLS 또는 DOWNLOAD_SYMBOLS(BTC 제외)가 필요합니다.", file=sys.stderr) + return 1 + + if settings.ops_mode == "live": + if not settings.bithumb_access_key or not settings.bithumb_secret_key: + print("live: BITHUMB_ACCESS_KEY / BITHUMB_SECRET_KEY 필요", file=sys.stderr) + return 1 + print("경고: live — 실제 주문 가능") + + print( + f"vol_breakout {settings.ops_mode} | symbols={settings.ops_symbols} | " + f"lookback={settings.vol_lookback} atr={settings.vol_atr_mult} " + f"buy_split={settings.vol_buy_split or settings.vol_wallet_pct} " + f"exit={settings.vol_exit_enabled}" + ) + + def _once() -> dict: + runner = VolBreakoutRunner(settings) + report = runner.tick() + for row in report.get("results") or []: + print( + f" {row.get('symbol')}: fills={row.get('fills')} " + f"note={row.get('note')}" + ) + return report + + if args.loop <= 0: + _once() + return 0 + + while True: + try: + _once() + except KeyboardInterrupt: + print("\n종료") + return 0 + except Exception: + logging.exception("vol loop tick failed") + time.sleep(args.loop) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/3_run_vol_breakout_cron.sh b/scripts/3_run_vol_breakout_cron.sh new file mode 100755 index 0000000..fa12b30 --- /dev/null +++ b/scripts/3_run_vol_breakout_cron.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# vol_breakout 현물 롱 tick (cron 1분) +set -euo pipefail +# shellcheck source=scripts/_cron_env.sh +source "$(dirname "$0")/_cron_env.sh" + +ensure_cron_log_dir "data/spot/operations" +LOCKDIR="data/spot/operations/vol.tick.lock.d" +if ! acquire_cron_lock "$LOCKDIR" "scripts/3_run_vol_breakout.py" 600; then + exit 0 +fi + +PYTHON="$(resolve_bithumb_python)" || exit 1 +"$PYTHON" scripts/3_run_vol_breakout.py "$@" diff --git a/scripts/3_run_vol_monitor.py b/scripts/3_run_vol_monitor.py new file mode 100755 index 0000000..6a9c984 --- /dev/null +++ b/scripts/3_run_vol_monitor.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""vol_live 모니터 — JSON/HTML 갱신 + HTTP 서버 (통합). + +기본 (인자 없음): 전체 갱신(--full) 후 서버 기동 + python scripts/3_run_vol_monitor.py + +갱신만: + python scripts/3_run_vol_monitor.py --refresh-only + +서버만: + python scripts/3_run_vol_monitor.py --serve-only +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +import threading +import time +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + +_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_ROOT / "src")) + +from bithumb.config import load_settings # noqa: E402 +from bithumb.operations.vol_breakout_engine import load_vol_state # noqa: E402 +from bithumb.operations.vol_live_monitor import ( # noqa: E402 + fetch_live_balance_snapshot, + patch_vol_monitor_balance, + write_vol_monitor, +) + +logger = logging.getLogger("vol_monitor") +_refresh_lock = threading.Lock() +_balance_lock = threading.Lock() +_CLIENT_GONE = (BrokenPipeError, ConnectionResetError) + + +def _client_gone(exc: BaseException) -> bool: + """브라우저가 응답 전 연결을 끊은 경우.""" + return isinstance(exc, _CLIENT_GONE) + + +def refresh_vol_live_monitor(*, write_html: bool = True) -> dict: + """state + DB 캔들 기준 전체 JSON/HTML 갱신.""" + settings = load_settings() + state = load_vol_state(settings.vol_state_json) + if settings.ops_mode == "live": + try: + bal = fetch_live_balance_snapshot(settings) + snap = state.setdefault("portfolio_snapshot", {}) + snap["cash_krw"] = bal.get("cash_krw", snap.get("cash_krw")) + positions = snap.setdefault("positions", {}) + for sym, qty in (bal.get("positions") or {}).items(): + positions[sym] = qty + except Exception as exc: # noqa: BLE001 + logger.warning("live balance sync skipped: %s", exc) + + json_path, html_path = write_vol_monitor(settings, state) + logger.debug("monitor written: %s", json_path) + if write_html: + logger.debug("html: %s", html_path) + return {"ok": True, "json": str(json_path), "html": str(html_path)} + + +def refresh_vol_live_balance() -> dict: + """거래소 잔고만 JSON summary 패치.""" + settings = load_settings() + bal = fetch_live_balance_snapshot(settings) + return patch_vol_monitor_balance(settings.vol_monitor_json, bal) + + +def fetch_live_balance() -> dict: + """서버 /api/balance용.""" + settings = load_settings() + if settings.ops_mode != "live": + state = load_vol_state(settings.vol_state_json) + snap = state.get("portfolio_snapshot") or {} + return { + "ok": True, + "cash_krw": snap.get("cash_krw", 0), + "positions": snap.get("positions") or {}, + "mode": settings.ops_mode, + } + return fetch_live_balance_snapshot(settings) + + +def _out_dir() -> Path: + return load_settings().vol_monitor_html.parent + + +def _api_refresh() -> dict: + out_dir = _out_dir() + json_path = out_dir / "vol_live_chart.json" + with _refresh_lock: + if not json_path.is_file(): + return refresh_vol_live_monitor(write_html=False) + return refresh_vol_live_balance() + + +def _api_balance() -> dict: + with _balance_lock: + return fetch_live_balance() + + +class MonitorHandler(SimpleHTTPRequestHandler): + """vol_live 정적 파일 + /api/chart · /api/balance · /api/refresh.""" + + _static_dir: str | None = None + _access_log: bool = False + + def __init__(self, *args, **kwargs) -> None: + if MonitorHandler._static_dir is None: + MonitorHandler._static_dir = str(_out_dir()) + super().__init__(*args, directory=MonitorHandler._static_dir, **kwargs) + + def _chart_json_path(self) -> Path: + return Path(self.directory) / "vol_live_chart.json" + + def _serve_chart_json(self) -> None: + path = self._chart_json_path() + if not path.is_file(): + self.send_error(404, "chart json not found") + return + body: bytes | None = None + for attempt in range(3): + try: + body = path.read_bytes() + json.loads(body.decode("utf-8")) + break + except (json.JSONDecodeError, OSError): + if attempt >= 2: + self.send_error(503, "chart json temporarily unavailable") + return + time.sleep(0.05) + if body is None: + self.send_error(503, "chart json unavailable") + return + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store, must-revalidate") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + try: + self.wfile.write(body) + except _CLIENT_GONE: + logger.debug("client disconnected during chart json") + + def log_message(self, fmt: str, *args) -> None: + """HTTP 접근 로그 — 기본 off (--verbose 시에만 출력).""" + if not MonitorHandler._access_log: + return + logger.info("%s - %s", self.address_string(), fmt % args) + + def log_error(self, fmt: str, *args) -> None: + """5xx 등 서버 오류만 기록 (favicon 404 제외).""" + msg = fmt % args + if "404" in msg and "File not found" in msg: + return + logger.warning("%s - %s", self.address_string(), msg) + + def _send_json(self, payload: dict, *, status: int = 200) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + try: + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store, must-revalidate") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except _CLIENT_GONE: + logger.debug("client disconnected before response sent") + + def _handle_refresh(self) -> None: + try: + self._send_json(_api_refresh()) + except _CLIENT_GONE: + pass + except Exception as exc: # noqa: BLE001 + if not _client_gone(exc): + self._send_json({"ok": False, "error": str(exc)}, status=500) + + def _handle_balance(self) -> None: + try: + self._send_json(_api_balance()) + except _CLIENT_GONE: + pass + except Exception as exc: # noqa: BLE001 + if not _client_gone(exc): + self._send_json({"ok": False, "error": str(exc)}, status=500) + + def end_headers(self) -> None: + if self.path.endswith(".json"): + self.send_header("Cache-Control", "no-store, must-revalidate") + super().end_headers() + + def _request_path(self) -> str: + return urlparse(self.path).path.rstrip("/") + + def do_POST(self) -> None: + path = self._request_path() + if path == "/api/refresh": + self._handle_refresh() + return + self.send_error(404, "not found") + + def do_GET(self) -> None: + path = self._request_path() + if path == "/api/refresh": + self._handle_refresh() + return + if path == "/api/balance": + self._handle_balance() + return + if path == "/api/chart": + self._serve_chart_json() + return + super().do_GET() + + +def run_serve(*, access_log: bool = False, quiet: bool = True) -> int: + """HTTP 서버 기동 (블로킹).""" + from dotenv import load_dotenv + + MonitorHandler._access_log = access_log + load_dotenv(_ROOT / ".env", override=False) + port = int(os.environ.get("VOL_MONITOR_PORT", "8766")) + out = _out_dir() + out.mkdir(parents=True, exist_ok=True) + url = f"http://127.0.0.1:{port}/vol_live_monitor.html" + if quiet and not access_log: + print(f"vol monitor {url} (Ctrl+C 종료)", flush=True) + else: + logger.info("모니터: %s", url) + logger.info("출력 디렉터리: %s", out) + try: + server = ThreadingHTTPServer(("127.0.0.1", port), MonitorHandler) + except OSError as exc: + if exc.errno == 48: + logger.error( + "포트 %s 이미 사용 중 — lsof -iTCP:%s -sTCP:LISTEN 후 종료", + port, + port, + ) + else: + logger.error("서버 bind 실패: %s", exc) + return 1 + try: + server.serve_forever() + except KeyboardInterrupt: + logger.info("종료") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """CLI — 기본: 갱신 + 서버.""" + parser = argparse.ArgumentParser( + description="Bithumb vol_live 모니터 (갱신 + HTTP 서버)", + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--refresh-only", + action="store_true", + help="JSON/HTML 갱신만 (서버 미기동)", + ) + mode.add_argument( + "--serve-only", + action="store_true", + help="HTTP 서버만 (갱신 생략)", + ) + parser.add_argument( + "--balance-only", + action="store_true", + help="--refresh-only 와 함께: 잔고 summary만 패치", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="HTTP 접근·갱신 상세 로그 출력", + ) + args = parser.parse_args(argv) + + serve_mode = not args.refresh_only + quiet_serve = serve_mode and not args.verbose + log_level = logging.INFO if (args.verbose or args.refresh_only) else logging.WARNING + logging.basicConfig( + level=log_level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + if not args.serve_only: + if args.balance_only: + out = refresh_vol_live_balance() + else: + out = refresh_vol_live_monitor() + if args.verbose or args.refresh_only: + logger.info("refresh done: %s", out) + if not out.get("ok"): + return 1 + + if args.refresh_only: + return 0 + + return run_serve(access_log=args.verbose, quiet=quiet_serve) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/3_run_vol_monitor_cron.sh b/scripts/3_run_vol_monitor_cron.sh new file mode 100755 index 0000000..0d1aa25 --- /dev/null +++ b/scripts/3_run_vol_monitor_cron.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# vol_live 모니터 JSON/HTML 갱신 (cron 5분 — tick 실패 시 백업용) +set -euo pipefail +# shellcheck source=scripts/_cron_env.sh +source "$(dirname "$0")/_cron_env.sh" + +ensure_cron_log_dir "docs/spot/3_operations" +LOCKDIR="data/spot/operations/vol.monitor.lock.d" +if ! acquire_cron_lock "$LOCKDIR" "scripts/3_run_vol_monitor.py" 300; then + exit 0 +fi + +PYTHON="$(resolve_bithumb_python)" || exit 1 +"$PYTHON" scripts/3_run_vol_monitor.py --refresh-only "$@" diff --git a/scripts/3_run_vol_monitor_serve.sh b/scripts/3_run_vol_monitor_serve.sh new file mode 100755 index 0000000..249ac19 --- /dev/null +++ b/scripts/3_run_vol_monitor_serve.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# vol_live 모니터 HTTP 서버 (포트 기본 8766 — Binance 8765와 분리) +set -euo pipefail +# shellcheck source=scripts/_cron_env.sh +source "$(dirname "$0")/_cron_env.sh" + +PIDFILE="${CRON_PROJECT_ROOT}/data/spot/operations/vol_monitor.pid" +PORT="${VOL_MONITOR_PORT:-8766}" +export VOL_MONITOR_PORT="$PORT" + +if [ "${1:-}" = "--stop" ]; then + if [ -f "$PIDFILE" ]; then + pid="$(cat "$PIDFILE")" + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" + echo "stopped pid $pid" + fi + rm -f "$PIDFILE" + else + echo "pid file 없음" + fi + exit 0 +fi + +if [ -f "$PIDFILE" ]; then + old_pid="$(cat "$PIDFILE")" + if kill -0 "$old_pid" 2>/dev/null; then + if lsof -iTCP:"$PORT" -sTCP:LISTEN -p "$old_pid" >/dev/null 2>&1; then + echo "이미 실행 중: pid $old_pid → http://127.0.0.1:${PORT}/vol_live_monitor.html" + exit 0 + fi + kill "$old_pid" 2>/dev/null || true + fi + rm -f "$PIDFILE" +fi + +ensure_cron_log_dir "data/spot/operations" +LOG="${CRON_PROJECT_ROOT}/data/spot/operations/vol_monitor_serve.log" +PYTHON="$(resolve_bithumb_python)" || exit 1 + +nohup "$PYTHON" "${CRON_PROJECT_ROOT}/scripts/3_run_vol_monitor.py" --serve-only >> "$LOG" 2>&1 & +pid=$! +disown "$pid" 2>/dev/null || true +echo "$pid" > "$PIDFILE" +sleep 2 + +if curl -sf -o /dev/null "http://127.0.0.1:${PORT}/vol_live_monitor.html"; then + echo "모니터 시작: http://127.0.0.1:${PORT}/vol_live_monitor.html" + echo "로그: $LOG" + echo "종료: bash scripts/3_run_vol_monitor_serve.sh --stop" +else + echo "서버 기동 실패 — 로그 확인: $LOG" >&2 + tail -20 "$LOG" 2>/dev/null || true + exit 1 +fi diff --git a/scripts/3_run_watch_cron.sh b/scripts/3_run_watch_cron.sh new file mode 100755 index 0000000..d54bb6a --- /dev/null +++ b/scripts/3_run_watch_cron.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# read-only 감시 (cron 5분) — watch 프로세스 중복 방지 +set -euo pipefail +# shellcheck source=scripts/_cron_env.sh +source "$(dirname "$0")/_cron_env.sh" + +ensure_cron_log_dir "data/spot/operations" + +PYTHON="$(resolve_bithumb_python)" || exit 1 +"$PYTHON" scripts/3_watch_ops.py "$@" diff --git a/scripts/3_serve_vol_monitor.py b/scripts/3_serve_vol_monitor.py new file mode 100644 index 0000000..9970817 --- /dev/null +++ b/scripts/3_serve_vol_monitor.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""vol_live 모니터 HTTP 서버 — 3_run_vol_monitor.py --serve-only 래퍼.""" + +from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +if __name__ == "__main__": + target = Path(__file__).resolve().parent / "3_run_vol_monitor.py" + sys.argv = [str(target), "--serve-only", *sys.argv[1:]] + runpy.run_path(str(target), run_name="__main__") diff --git a/scripts/3_watch_ops.py b/scripts/3_watch_ops.py new file mode 100644 index 0000000..78662bc --- /dev/null +++ b/scripts/3_watch_ops.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""read-only 감시 + 불일치 시 조치 tick / loop 재시작.""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from bithumb.config import load_settings +from bithumb.operations.ops_lock import ops_tick_lock +from bithumb.operations.watch_ops import ( + inspect_ops_watch, + inspect_vol_watch, + is_vol_breakout_ops, + remediate_ops_watch, + remediate_vol_watch, +) + + +def _configure_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="운영 read-only 감시 + 조치") + parser.add_argument( + "--dry-run", + action="store_true", + help="점검·알림만 (tick/재시작 없음)", + ) + parser.add_argument( + "--inspect-only", + action="store_true", + help="stdout 출력만 (텔레그램·조치 없음)", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + _configure_logging(args.verbose) + + settings = load_settings() + + watch_lock = settings.ops_tick_lock_path + if watch_lock is not None: + watch_lock = watch_lock.parent / "ops.watch.lock" + if watch_lock is not None: + with ops_tick_lock(watch_lock, blocking=False) as acquired: + if not acquired: + print("watch already running — skip") + return 0 + return _run_watch(settings, args) + return _run_watch(settings, args) + + +def _run_watch(settings, args) -> int: + vol_mode = is_vol_breakout_ops(settings) + report = inspect_vol_watch(settings) if vol_mode else inspect_ops_watch(settings) + + print("=== ops watch ===") + if vol_mode: + print("mode: vol_breakout") + print(f"checked_at: {report.checked_at}") + print(f"ledger_pending: {report.ledger_pending}") + print(f"executable_pending: {report.executable_pending}") + print(f"tick_age_sec: {report.tick_age_sec}") + print(f"loop_running: {report.loop_running}") + for issue in report.issues: + print(f" [{issue.severity}] {issue.kind}: {issue.message}") + + if args.inspect_only: + return 1 if report.issues else 0 + + if not report.issues: + print("OK — 조치 없음") + return 0 + + result = ( + remediate_vol_watch(settings, report, dry_run=args.dry_run) + if vol_mode + else remediate_ops_watch(settings, report, dry_run=args.dry_run) + ) + print("\n=== remediation ===") + for action in result.actions: + print(f" action: {action}") + for msg in result.messages: + print(f" {msg}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/_cron_env.sh b/scripts/_cron_env.sh new file mode 100755 index 0000000..2d12c03 --- /dev/null +++ b/scripts/_cron_env.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# cron 래퍼 공통 — 프로젝트 루트, PYTHONPATH, 인터프리터 탐색. +# shellcheck disable=SC2034 +set -euo pipefail + +_CRON_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CRON_PROJECT_ROOT="$(cd "${_CRON_SCRIPT_DIR}/.." && pwd)" +cd "${CRON_PROJECT_ROOT}" +export PYTHONPATH=src + +# 로그 디렉터리 (cron 리다이렉트 전에 mkdir -p 가능) +ensure_cron_log_dir() { + local dir="$1" + mkdir -p "$dir" +} + +# python-dotenv + pandas 등 프로젝트 의존성이 있는 python3 +resolve_bithumb_python() { + local candidate="" + for candidate in \ + "${BITHUMB_PYTHON:-}" \ + "${HOME}/opt/anaconda3/envs/coin/bin/python3" \ + "${HOME}/opt/anaconda3/envs/ncue/bin/python3" \ + "${HOME}/miniconda3/envs/xavis/bin/python3" \ + "$(command -v python3 2>/dev/null || true)"; do + if [ -n "$candidate" ] && [ -x "$candidate" ] \ + && "$candidate" -c "import dotenv" 2>/dev/null; then + echo "$candidate" + return 0 + fi + done + echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] python-dotenv 가능한 python3를 찾지 못함 (BITHUMB_PYTHON 설정)" >&2 + return 1 +} + +# mkdir 기반 잠금 — stale/hung 프로세스 정리 후 획득 +# 사용: acquire_cron_lock LOCKDIR pgrep_pattern MAX_AGE_SEC +acquire_cron_lock() { + local lockdir="$1" + local pgrep_pattern="$2" + local max_age_sec="${3:-900}" + local now_ts pid elapsed lock_mtime + + now_ts="$(date +%s)" + if [ -d "$lockdir" ]; then + lock_mtime="$(stat -f %m "$lockdir" 2>/dev/null || stat -c %Y "$lockdir" 2>/dev/null || echo 0)" + elapsed=$((now_ts - lock_mtime)) + if pgrep -f "$pgrep_pattern" >/dev/null 2>&1; then + if [ "$elapsed" -gt "$max_age_sec" ]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] hung ${pgrep_pattern} (${elapsed}s) — 종료 후 lock 정리" >&2 + pkill -f "$pgrep_pattern" 2>/dev/null || true + sleep 1 + fi + fi + if ! pgrep -f "$pgrep_pattern" >/dev/null 2>&1; then + echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] stale lock 정리: ${lockdir}" >&2 + rmdir "$lockdir" 2>/dev/null || true + fi + fi + + if ! mkdir "$lockdir" 2>/dev/null; then + echo "$(date '+%Y-%m-%d %H:%M:%S') [SKIP] ${pgrep_pattern} 실행 중 (lock ${lockdir})" >&2 + return 1 + fi + trap 'rmdir "'"$lockdir"'" 2>/dev/null || true' EXIT INT TERM + return 0 +} diff --git a/scripts/com.bithumb.vol-monitor.plist.template b/scripts/com.bithumb.vol-monitor.plist.template new file mode 100644 index 0000000..65ab9c0 --- /dev/null +++ b/scripts/com.bithumb.vol-monitor.plist.template @@ -0,0 +1,30 @@ + + + + + Label + com.bithumb.vol-monitor + ProgramArguments + + __PYTHON__ + __ROOT__/scripts/3_run_vol_monitor.py + + WorkingDirectory + __ROOT__ + EnvironmentVariables + + PYTHONPATH + src + VOL_MONITOR_PORT + 8766 + + RunAtLoad + + KeepAlive + + StandardOutPath + __LOG__ + StandardErrorPath + __LOG__ + + diff --git a/scripts/crontab.bithumb.example b/scripts/crontab.bithumb.example new file mode 100644 index 0000000..d792228 --- /dev/null +++ b/scripts/crontab.bithumb.example @@ -0,0 +1,17 @@ +# Bithumb vol_breakout 운영 cron (install_crontab.sh 로 등록) +# 프로젝트: /Users/dsyoon/workspace/bithumb +# Python: BITHUMB_PYTHON 또는 coin/ncue conda (scripts/_cron_env.sh) + +# 캔들 증분 (TRX,NEAR,WLD × DOWNLOAD_INTERVALS) — 매 1분 +* * * * * /Users/dsyoon/workspace/bithumb/scripts/00_run_download_cron.sh >> /Users/dsyoon/workspace/bithumb/data/common/download_cron.log 2>&1 + +# vol_breakout 15m flip tick — 매 1분 +* * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_vol_breakout_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/vol_breakout_cron.log 2>&1 + +# vol_live 모니터 JSON/HTML 백업 갱신 — 5분마다 +*/5 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_vol_monitor_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/vol_monitor_cron.log 2>&1 + +# 모니터 HTTP 서버(8766) — 터미널: python scripts/3_run_vol_monitor.py + +# (선택) fractal 운영 감시 — vol 전용이면 주석 유지 +# */5 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_watch_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/watch_cron.log 2>&1 diff --git a/scripts/install_crontab.sh b/scripts/install_crontab.sh new file mode 100755 index 0000000..93993c9 --- /dev/null +++ b/scripts/install_crontab.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Bithumb cron 등록 — 기존 crontab에 BITHUMB 블록 병합/갱신 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MARKER_BEGIN="# BITHUMB vol_breakout cron (begin)" +MARKER_END="# BITHUMB vol_breakout cron (end)" +EXAMPLE="${ROOT}/scripts/crontab.bithumb.example" + +usage() { + cat </dev/null || true + tmp="$(mktemp)" + crontab -l 2>/dev/null | strip_block > "$tmp" || true + render_block >> "$tmp" + crontab "$tmp" + rm -f "$tmp" + echo "crontab installed. 확인: crontab -l" + ;; + --remove) + tmp="$(mktemp)" + if crontab -l 2>/dev/null | strip_block > "$tmp"; then + crontab "$tmp" + echo "BITHUMB cron block removed." + else + echo "crontab empty or not found." + fi + rm -f "$tmp" + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/scripts/install_vol_monitor_launchd.sh b/scripts/install_vol_monitor_launchd.sh new file mode 100755 index 0000000..b89a806 --- /dev/null +++ b/scripts/install_vol_monitor_launchd.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# macOS LaunchAgent — vol_live 모니터(8766) 로그인 시 자동 기동·유지 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LABEL="com.bithumb.vol-monitor" +PLIST_SRC="${ROOT}/scripts/com.bithumb.vol-monitor.plist.template" +PLIST_DST="${HOME}/Library/LaunchAgents/${LABEL}.plist" +LOG="${ROOT}/data/spot/operations/vol_monitor_serve.log" + +usage() { + cat < "$PLIST_DST" + launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$PLIST_DST" + launchctl enable "gui/$(id -u)/${LABEL}" 2>/dev/null || true + launchctl kickstart -k "gui/$(id -u)/${LABEL}" 2>/dev/null || true + sleep 2 + if curl -sf -o /dev/null --connect-timeout 3 "http://127.0.0.1:8766/vol_live_monitor.html"; then + echo "LaunchAgent 설치 완료 — http://127.0.0.1:8766/vol_live_monitor.html" + else + echo "LaunchAgent 등록됨. 접속 안 되면 로그 확인: $LOG" >&2 + tail -15 "$LOG" 2>/dev/null || true + fi + ;; + --uninstall) + launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true + rm -f "$PLIST_DST" + echo "LaunchAgent 제거됨" + ;; + --status) + if launchctl print "gui/$(id -u)/${LABEL}" >/dev/null 2>&1; then + echo "LaunchAgent: 등록됨" + launchctl print "gui/$(id -u)/${LABEL}" 2>/dev/null | grep -E "state =|pid =|last exit" || true + else + echo "LaunchAgent: 미등록" + fi + if curl -sf -o /dev/null --connect-timeout 2 "http://127.0.0.1:8766/vol_live_monitor.html"; then + echo "HTTP 8766: 응답 OK" + else + echo "HTTP 8766: 연결 불가 (서버 미기동)" + fi + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/src/bithumb/api/bithumb_private.py b/src/bithumb/api/bithumb_private.py index 9775fe5..1d00b16 100644 --- a/src/bithumb/api/bithumb_private.py +++ b/src/bithumb/api/bithumb_private.py @@ -5,7 +5,9 @@ from __future__ import annotations import logging import math import time +from datetime import datetime from typing import Any +from urllib.parse import urlencode import requests @@ -77,7 +79,11 @@ class BithumbPrivateClient: ) -> dict[str, Any]: """인증 요청을 수행한다.""" url = f"{self.base_url}{path}" - body = dumps_params(params) if params else None + body: str | None = None + if params and method.upper() == "GET": + url = f"{url}?{urlencode(params, doseq=True)}" + elif params: + body = dumps_params(params) headers = auth_headers( self.access_key, self.secret_key, @@ -182,3 +188,78 @@ class BithumbPrivateClient: "ord_type": "market", } return self._request("POST", "/v1/orders", params=params) + + def get_orders( + self, + market: str, + *, + state: str | None = None, + limit: int = 100, + page: int = 1, + order_by: str = "desc", + ) -> list[dict[str, Any]]: + """주문 목록 조회 (GET /v1/orders).""" + params: dict[str, Any] = { + "market": market, + "limit": int(limit), + "page": int(page), + "order_by": order_by, + } + if state: + params["state"] = state + payload = self._request("GET", "/v1/orders", params=params) + if isinstance(payload, list): + return payload + data = payload.get("data") if isinstance(payload, dict) else None + return data if isinstance(data, list) else [] + + def fetch_filled_orders_since( + self, + market: str, + since: datetime, + *, + max_pages: int = 20, + ) -> list[dict[str, Any]]: + """since 이후 체결 완료(done) 주문을 수집한다.""" + filled: list[dict[str, Any]] = [] + since_naive = since.replace(tzinfo=None) + for page in range(1, max_pages + 1): + orders = self.get_orders( + market, + state="done", + limit=100, + page=page, + order_by="desc", + ) + if not orders: + break + stop = False + for order in orders: + created = parse_exchange_datetime(str(order.get("created_at", ""))) + if created is None: + continue + if created.replace(tzinfo=None) < since_naive: + stop = True + break + if float(order.get("executed_volume") or 0) <= 0: + continue + filled.append(order) + if stop or len(orders) < 100: + break + return filled + + +def parse_exchange_datetime(value: str) -> datetime | None: + """빗썸 주문 created_at 파싱.""" + if not value: + return None + try: + return datetime.fromisoformat(value) + except ValueError: + pass + for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(value, fmt) + except ValueError: + continue + return None diff --git a/src/bithumb/config.py b/src/bithumb/config.py index c0f7f6c..2ad2cb9 100644 --- a/src/bithumb/config.py +++ b/src/bithumb/config.py @@ -21,6 +21,31 @@ def _resolve_project_path(raw: str) -> Path: return path +def _resolve_project_path_optional(raw: str) -> Path | None: + """비어 있으면 None, 아니면 프로젝트 루트 기준 절대 경로.""" + if not raw or not raw.strip(): + return None + return _resolve_project_path(raw) + + +def _parse_symbol_list(raw: str) -> list[str]: + """쉼표 구분 심볼 목록을 파싱한다.""" + if not raw or not raw.strip(): + return [] + return [part.strip().upper() for part in raw.split(",") if part.strip()] + + +def resolve_coin_name(symbol: str) -> str: + """심볼에 대응하는 한글 코인명을 반환한다.""" + names = { + "BTC": "비트코인", + "TRX": "트론", + "NEAR": "니어프로토콜", + "WLD": "월드코인", + } + return names.get(symbol.upper(), symbol.upper()) + + def _parse_int_list(raw: str) -> list[int]: """쉼표 구분 정수 목록을 파싱한다. @@ -45,6 +70,7 @@ class Settings: candle_count: int download_intervals: list[int] download_days: int + download_symbols: list[str] db_path: Path request_sleep_sec: float request_retries: int @@ -112,6 +138,43 @@ class Settings: ops_signal_tail_bars: int ops_persist_signal_cache: bool ops_catchup_bars: int + ops_ledger_lookback_days: int + ops_ledger_execute_max_age_minutes: int + ops_max_backlog_per_tick: int + ops_live_force_tail_refresh: bool + ops_watchdog_stale_sec: int + ops_tick_warn_sec: int + ops_log_json: Path | None + ops_exchange_reconcile: bool + ops_exchange_order_lookback_hours: int + ops_exchange_match_window_min: int + ops_tick_lock_path: Path | None + ops_loop_pid_file: Path | None + ops_watch_signal_grace_min: int + ops_watch_tick_stale_min: int + ops_watch_lookback_min: int + ops_watch_auto_remediate: bool + ops_watch_auto_restart: bool + ops_watch_loop_start_cmd: str + # vol_breakout 현물 롱 (Binance 이식) + ops_symbols: list[str] + vol_state_json: Path + vol_lookback: int + vol_atr_mult: float + vol_lookback_days: int + vol_wallet_pct: float + vol_buy_split: int + vol_max_bars_per_tick: int + vol_exit_enabled: bool + vol_trail_activate_pct: float + vol_trail_pct: float + vol_trail_atr_mult: float + vol_stop_pct: float + vol_tick_lock_path: Path | None + vol_kill_switch_path: Path | None + vol_monitor_json: Path + vol_monitor_html: Path + vol_monitor_days: int telegram_bot_token: str telegram_chat_id: str ops_telegram_enabled: bool @@ -148,13 +211,19 @@ def load_settings(env_path: Path | None = None) -> Settings: if not tech_dir.is_absolute(): tech_dir = _PROJECT_ROOT / tech_dir + symbol = os.getenv("SYMBOL", "BTC").upper() + download_symbols = _parse_symbol_list(os.getenv("DOWNLOAD_SYMBOLS", "")) + if not download_symbols: + download_symbols = [symbol] + return Settings( - symbol=os.getenv("SYMBOL", "BTC").upper(), - coin_name=os.getenv("COIN_NAME", "비트코인"), + symbol=symbol, + coin_name=os.getenv("COIN_NAME", resolve_coin_name(symbol)), api_url=os.getenv("BITHUMB_API_URL", "https://api.bithumb.com").rstrip("/"), candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")), download_intervals=intervals, download_days=int(os.getenv("DOWNLOAD_DAYS", "3650")), + download_symbols=download_symbols, db_path=db_path, request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")), request_retries=int(os.getenv("API_REQUEST_RETRIES", "3")), @@ -297,7 +366,84 @@ def load_settings(env_path: Path | None = None) -> Settings: ops_signal_tail_bars=int(os.getenv("OPS_SIGNAL_TAIL_BARS", "800")), ops_persist_signal_cache=os.getenv("OPS_PERSIST_SIGNAL_CACHE", "false").strip().lower() in ("1", "true", "yes", "on"), - ops_catchup_bars=int(os.getenv("OPS_CATCHUP_BARS", "10")), + ops_catchup_bars=int(os.getenv("OPS_CATCHUP_BARS", "480")), + ops_ledger_lookback_days=int(os.getenv("OPS_LEDGER_LOOKBACK_DAYS", "3")), + ops_ledger_execute_max_age_minutes=int( + os.getenv("OPS_LEDGER_EXECUTE_MAX_AGE_MINUTES", "45") + ), + ops_max_backlog_per_tick=int(os.getenv("OPS_MAX_BACKLOG_PER_TICK", "20")), + ops_live_force_tail_refresh=os.getenv( + "OPS_LIVE_FORCE_TAIL_REFRESH", "true" + ).strip().lower() + in ("1", "true", "yes", "on"), + ops_watchdog_stale_sec=int(os.getenv("OPS_WATCHDOG_STALE_SEC", "600")), + ops_tick_warn_sec=int(os.getenv("OPS_TICK_WARN_SEC", "120")), + ops_log_json=_resolve_project_path_optional( + os.getenv("OPS_LOG_JSON", "data/spot/operations/live_run.log") + ), + ops_exchange_reconcile=os.getenv("OPS_EXCHANGE_RECONCILE", "true").strip().lower() + in ("1", "true", "yes", "on"), + ops_exchange_order_lookback_hours=int( + os.getenv("OPS_EXCHANGE_ORDER_LOOKBACK_HOURS", "48") + ), + ops_exchange_match_window_min=int( + os.getenv("OPS_EXCHANGE_MATCH_WINDOW_MIN", "720") + ), + ops_tick_lock_path=_resolve_project_path_optional( + os.getenv("OPS_TICK_LOCK_PATH", "data/spot/operations/ops.tick.lock") + ), + ops_loop_pid_file=_resolve_project_path_optional( + os.getenv("OPS_LOOP_PID_FILE", "data/spot/operations/ops_loop.pid") + ), + ops_watch_signal_grace_min=int(os.getenv("OPS_WATCH_SIGNAL_GRACE_MIN", "5")), + ops_watch_tick_stale_min=int(os.getenv("OPS_WATCH_TICK_STALE_MIN", "12")), + ops_watch_lookback_min=int(os.getenv("OPS_WATCH_LOOKBACK_MIN", "30")), + ops_watch_auto_remediate=os.getenv("OPS_WATCH_AUTO_REMEDIATE", "true") + .strip() + .lower() + in ("1", "true", "yes", "on"), + ops_watch_auto_restart=os.getenv("OPS_WATCH_AUTO_RESTART", "true") + .strip() + .lower() + in ("1", "true", "yes", "on"), + ops_watch_loop_start_cmd=os.getenv("OPS_WATCH_LOOP_START_CMD", "").strip(), + ops_symbols=_parse_symbol_list(os.getenv("OPS_SYMBOLS", "")) or [ + s for s in download_symbols if s != "BTC" + ], + vol_state_json=_resolve_project_path( + os.getenv("VOL_STATE_JSON", "data/spot/operations/vol_breakout_state.json") + ), + vol_lookback=int(os.getenv("VOL_LOOKBACK", "14")), + vol_atr_mult=float(os.getenv("VOL_ATR_MULT", "2.0")), + vol_lookback_days=int(os.getenv("VOL_LOOKBACK_DAYS", "60")), + vol_wallet_pct=float(os.getenv("VOL_WALLET_PCT", "0.30")), + vol_buy_split=int(os.getenv("VOL_BUY_SPLIT", "0")), + vol_max_bars_per_tick=int(os.getenv("VOL_MAX_BARS_PER_TICK", "5")), + vol_exit_enabled=os.getenv("VOL_EXIT_ENABLED", "false").strip().lower() + in ("1", "true", "yes", "on"), + vol_trail_activate_pct=float(os.getenv("VOL_TRAIL_ACTIVATE_PCT", "1.5")), + vol_trail_pct=float(os.getenv("VOL_TRAIL_PCT", "0.0025")), + vol_trail_atr_mult=float(os.getenv("VOL_TRAIL_ATR_MULT", "0.5")), + vol_stop_pct=float(os.getenv("VOL_STOP_PCT", "-12.0")), + vol_tick_lock_path=_resolve_project_path_optional( + os.getenv("VOL_TICK_LOCK_PATH", "data/spot/operations/vol.tick.lock") + ), + vol_kill_switch_path=_resolve_project_path_optional( + os.getenv("VOL_KILL_SWITCH_PATH", "data/spot/operations/vol.kill") + ), + vol_monitor_json=_resolve_project_path( + os.getenv( + "VOL_MONITOR_JSON", + "docs/spot/3_operations/vol_live_chart.json", + ) + ), + vol_monitor_html=_resolve_project_path( + os.getenv( + "VOL_MONITOR_HTML", + "docs/spot/3_operations/vol_live_monitor.html", + ) + ), + vol_monitor_days=int(os.getenv("VOL_MONITOR_DAYS", "14")), telegram_bot_token=os.getenv("COIN_TELEGRAM_BOT_TOKEN", "").strip(), telegram_chat_id=os.getenv("COIN_TELEGRAM_CHAT_ID", "").strip(), ops_telegram_enabled=_parse_ops_telegram_enabled( diff --git a/src/bithumb/data/candle_bars.py b/src/bithumb/data/candle_bars.py new file mode 100644 index 0000000..3be5320 --- /dev/null +++ b/src/bithumb/data/candle_bars.py @@ -0,0 +1,59 @@ +"""캔들 봉 마감 판별.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from bithumb.api.bithumb import parse_kst_datetime + + +def bar_close_time(bar_open: datetime, interval_min: int) -> datetime: + """봉 시작 시각 기준 마감 시각(KST naive)을 반환한다.""" + return bar_open + timedelta(minutes=interval_min) + + +def is_closed_candle( + bar_open: datetime, + interval_min: int, + *, + now: datetime | None = None, +) -> bool: + """해당 봉이 마감되었는지 여부.""" + ref = now or datetime.now() + return ref >= bar_close_time(bar_open, interval_min) + + +def only_closed_candles( + rows: list[tuple], + interval_min: int, + *, + now: datetime | None = None, +) -> list[tuple]: + """미마감 봉을 제외한 OHLCV 행만 반환한다. + + Args: + rows: ``(ymdhms, open, high, low, close, volume)`` 튜플 리스트. + interval_min: 분 단위 인터벌. + now: 기준 시각(KST). None이면 ``datetime.now()``. + + Returns: + 마감된 봉만 포함한 리스트(입력 순서 유지). + """ + if not rows: + return [] + ref = now or datetime.now() + closed: list[tuple] = [] + for row in rows: + bar_open = parse_kst_datetime(str(row[0])) + if is_closed_candle(bar_open, interval_min, now=ref): + closed.append(row) + return closed + + +def last_closed_bar_open(now: datetime, interval_min: int) -> datetime: + """기준 시각에서 가장 최근 마감된 봉의 시작 시각.""" + minute = (now.minute // interval_min) * interval_min + current_start = now.replace(minute=minute, second=0, microsecond=0) + if is_closed_candle(current_start, interval_min, now=now): + return current_start + return current_start - timedelta(minutes=interval_min) diff --git a/src/bithumb/data/candle_loader.py b/src/bithumb/data/candle_loader.py index fd92189..ff3ec41 100644 --- a/src/bithumb/data/candle_loader.py +++ b/src/bithumb/data/candle_loader.py @@ -20,7 +20,7 @@ def load_candles( Args: db_path: SQLite 경로. - symbol: 코인 심볼 (예: BTC). + symbol: 코인 심볼 (예: TRX). interval_min: 분 단위 인터벌 코드. lookback_days: 최근 N일만 사용. None이면 전체. diff --git a/src/bithumb/data/candle_store.py b/src/bithumb/data/candle_store.py index fdbbbb0..30a329e 100644 --- a/src/bithumb/data/candle_store.py +++ b/src/bithumb/data/candle_store.py @@ -9,6 +9,7 @@ from pathlib import Path import pandas as pd from bithumb.api.bithumb import parse_kst_datetime +from bithumb.data.candle_bars import is_closed_candle, only_closed_candles class CandleStore: @@ -90,6 +91,31 @@ class CandleStore: return int(row[0]), parse_kst_datetime(str(row[1])), parse_kst_datetime(str(row[2])) + def delete_incomplete_tail( + self, + symbol: str, + interval_min: int, + *, + now: datetime | None = None, + ) -> int: + """DB 최신 봉이 미마감이면 삭제한다 (조기 저장 잔재 정리). + + Returns: + 삭제된 행 수. + """ + _, _, db_max = self.get_range(symbol, interval_min) + if db_max is None: + return 0 + if is_closed_candle(db_max, interval_min, now=now): + return 0 + table = self.table_name(symbol, interval_min) + cur = self._conn.execute( + f"DELETE FROM {table} WHERE CODE = ? AND ymdhms = ?", + (symbol.upper(), db_max.strftime("%Y-%m-%d %H:%M:%S")), + ) + self._conn.commit() + return cur.rowcount + def read_dataframe(self, symbol: str, interval_min: int) -> pd.DataFrame: """캔들을 pandas DataFrame으로 읽는다. @@ -154,6 +180,11 @@ class CandleStore: Returns: 저장(시도) 행 수. """ + if not rows: + return 0 + + rows = only_closed_candles(rows, interval_min) + if not rows: return 0 @@ -211,6 +242,11 @@ class CandleStore: Returns: 실제 INSERT된 행 수. """ + if not rows: + return 0 + + rows = only_closed_candles(rows, interval_min) + if not rows: return 0 diff --git a/src/bithumb/data/downloader.py b/src/bithumb/data/downloader.py index c41b661..70e6767 100644 --- a/src/bithumb/data/downloader.py +++ b/src/bithumb/data/downloader.py @@ -9,6 +9,7 @@ from typing import Any from bithumb.api.bithumb import BithumbCandleClient, parse_kst_datetime from bithumb.config import Settings +from bithumb.data.candle_bars import only_closed_candles from bithumb.data.candle_store import CandleStore logger = logging.getLogger(__name__) @@ -106,15 +107,9 @@ class CandleDownloader: stop_at = target_from else: mode = "incremental" - if db_max >= datetime.now() - timedelta(minutes=max(interval_min, 1)): - return DownloadResult( - interval_min=interval_min, - mode="uptodate", - requests=0, - saved_rows=0, - reached_target=True, - ) - stop_at = db_max - timedelta(minutes=interval_min) + store.delete_incomplete_tail(symbol, interval_min) + _, _, db_max = store.get_range(symbol, interval_min) + stop_at = db_max - timedelta(minutes=interval_min) if db_max else target_from to_kst: datetime | None = None requests = 0 @@ -132,28 +127,30 @@ class CandleDownloader: if not candles: break - rows = _candle_rows_from_api(candles) - if not rows: + all_rows = _candle_rows_from_api(candles) + if not all_rows: break - if mode == "incremental" and db_max is not None: - inserted = store.insert_new_rows( - symbol, - self.settings.coin_name, - interval_min, - rows, - after=db_max, - ) - else: - inserted = store.upsert_rows( - symbol, - self.settings.coin_name, - interval_min, - rows, - ) - saved_rows += inserted + batch_oldest = min(parse_kst_datetime(r[0]) for r in all_rows) + rows = only_closed_candles(all_rows, interval_min) + if rows: + if mode == "incremental" and db_max is not None: + refresh_from = db_max - timedelta(minutes=interval_min * 48) + rows = [ + r for r in rows + if parse_kst_datetime(str(r[0])) >= refresh_from + ] + if rows: + saved_rows += store.upsert_rows( + symbol, + self.settings.coin_name, + interval_min, + rows, + ) + + if mode == "incremental": + break - batch_oldest = min(parse_kst_datetime(r[0]) for r in rows) if oldest_seen is None or batch_oldest < oldest_seen: oldest_seen = batch_oldest diff --git a/src/bithumb/mtf/rules.py b/src/bithumb/mtf/rules.py index 30714bd..ec788da 100644 --- a/src/bithumb/mtf/rules.py +++ b/src/bithumb/mtf/rules.py @@ -11,7 +11,7 @@ from bithumb.evaluation.gt_align import GT_SIGNAL_TYPES Operator = Literal["<=", ">="] -# BTC 가격 스케일에 민감한 지표는 자동 규칙에서 제외 +# 고가 코인 가격 스케일에 민감한 지표는 자동 규칙에서 제외 _EXCLUDED_AUTO_FEATURES: frozenset[str] = frozenset({"macd_hist", "zigzag_leg_pct", "close"}) # 자동 규칙에 사용할 안정 피처 diff --git a/src/bithumb/notifications/telegram.py b/src/bithumb/notifications/telegram.py index a7c6c31..2c98512 100644 --- a/src/bithumb/notifications/telegram.py +++ b/src/bithumb/notifications/telegram.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from datetime import datetime from typing import Any import requests @@ -131,6 +132,50 @@ class TelegramNotifier: return self.send_message("\n".join(lines)) + def notify_vol_breakout_trade( + self, + *, + mode: str, + symbol: str, + side: str, + price: float, + order_krw: float, + order_coin: float, + equity_krw: float, + reason: str = "signal_vol_breakout", + ts: str | None = None, + pnl_krw: float | None = None, + pnl_pct: float | None = None, + ) -> bool: + """vol_breakout 현물 롱 체결 알림 (Binance live_engine 형식).""" + mode_txt = "실거래" if mode == "live" else "페이퍼" + market = f"{symbol.upper()}KRW" + time_txt = (ts or datetime.now().strftime("%Y-%m-%d %H:%M:%S"))[:19] + + if side == "buy": + text = ( + f"[{mode_txt}] 롱 진입(매수)\n" + f"{market} @ {price:,.2f}\n" + f"수량 {order_coin:.6f} (≈{order_krw:,.0f}원)\n" + f"사유 {reason}\n" + f"시각 {time_txt}" + ) + else: + pnl_line = "" + if pnl_krw is not None and pnl_pct is not None: + pnl_line = ( + f"손익 {pnl_krw:+,.0f}원 ({pnl_pct:+.3f}%)\n" + ) + text = ( + f"[{mode_txt}] 롱 청산(매도)\n" + f"{market} @ {price:,.2f}\n" + f"{pnl_line}" + f"자본 {equity_krw:,.0f}원\n" + f"사유 {reason}\n" + f"시각 {time_txt}" + ) + return self.send_message(text) + def notify_trade_failure( self, *, diff --git a/src/bithumb/operations/exchange_reconcile.py b/src/bithumb/operations/exchange_reconcile.py new file mode 100644 index 0000000..4f5b2b8 --- /dev/null +++ b/src/bithumb/operations/exchange_reconcile.py @@ -0,0 +1,244 @@ +"""거래소 체결 내역과 신호 원장 대조 — 이미 체결된 신호 재주문 방지.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import Any + +from bithumb.api.bithumb_private import BithumbPrivateClient, parse_exchange_datetime +from bithumb.operations.trade_engine import TradeResult + +logger = logging.getLogger(__name__) + + +def _ledger_helpers() -> tuple[Any, ...]: + """runner와의 순환 import 방지.""" + from bithumb.operations.runner import ( + _history_index, + _is_settled, + _ledger_scan_start_datetime, + _parse_signal_dt, + _signal_key, + ) + + return ( + _history_index, + _is_settled, + _ledger_scan_start_datetime, + _parse_signal_dt, + _signal_key, + ) + + +def _order_side(order: dict[str, Any]) -> str | None: + """주문 side → 신호 side (bid→buy, ask→sell).""" + raw = str(order.get("side", "")).lower() + if raw == "bid": + return "buy" + if raw == "ask": + return "sell" + return None + + +def _known_order_uuids(trade_history: list[dict[str, Any]]) -> set[str]: + """trade_history에 이미 기록된 주문 uuid.""" + uuids: set[str] = set() + for record in trade_history: + trade = record.get("trade") or {} + resp = trade.get("api_response") + if isinstance(resp, dict): + uid = resp.get("uuid") + if uid: + uuids.add(str(uid)) + return uuids + + +def _unsettled_signals_for_reconcile( + kept: list[dict[str, Any]], + trade_history: list[dict[str, Any]], + *, + lookback_days: int, +) -> list[dict[str, Any]]: + """거래소 대조 대상 미정산 신호.""" + ( + _history_index, + _is_settled, + _ledger_scan_start_datetime, + _parse_signal_dt, + _signal_key, + ) = _ledger_helpers() + idx = _history_index(trade_history) + scan_start = _ledger_scan_start_datetime(trade_history, lookback_days) + pending: list[dict[str, Any]] = [] + for sig in kept: + dt_str = str(sig.get("datetime", "")) + if not dt_str or dt_str < scan_start: + continue + key = _signal_key(sig) + record = idx.get(key) + if record is not None and _is_settled(record.get("trade") or {}): + continue + pending.append(sig) + pending.sort(key=lambda s: (_parse_signal_dt(str(s["datetime"])), str(s.get("side", "")))) + return pending + + +def _match_orders_to_signals( + orders: list[dict[str, Any]], + signals: list[dict[str, Any]], + *, + match_window_min: int, + known_uuids: set[str], +) -> list[tuple[dict[str, Any], dict[str, Any]]]: + """미사용 체결 주문과 미정산 신호를 side·시간 기준으로 1:1 매칭.""" + _, _, _, _parse_signal_dt, _ = _ledger_helpers() + if not orders or not signals: + return [] + + window = timedelta(minutes=max(match_window_min, 1)) + available_signals = list(signals) + matches: list[tuple[dict[str, Any], dict[str, Any]]] = [] + + sorted_orders = sorted( + orders, + key=lambda o: parse_exchange_datetime(str(o.get("created_at", ""))) or datetime.min, + ) + + for order in sorted_orders: + uid = str(order.get("uuid", "")) + if uid and uid in known_uuids: + continue + order_side = _order_side(order) + if order_side is None: + continue + if float(order.get("executed_volume") or 0) <= 0: + continue + order_dt = parse_exchange_datetime(str(order.get("created_at", ""))) + if order_dt is None: + continue + order_dt = order_dt.replace(tzinfo=None) + + best_idx: int | None = None + best_delta: timedelta | None = None + for idx, sig in enumerate(available_signals): + if str(sig.get("side")) != order_side: + continue + sig_dt = _parse_signal_dt(str(sig["datetime"])) + if order_dt < sig_dt: + continue + delta = order_dt - sig_dt + if delta > window: + continue + if best_delta is None or delta < best_delta: + best_delta = delta + best_idx = idx + + if best_idx is None: + continue + sig = available_signals.pop(best_idx) + matches.append((sig, order)) + if uid: + known_uuids.add(uid) + + return matches + + +def _trade_from_exchange_order( + signal: dict[str, Any], + order: dict[str, Any], +) -> TradeResult: + """거래소 주문 dict → TradeResult (재주문 없음).""" + side = str(signal["side"]) + executed_volume = float(order.get("executed_volume") or 0) + executed_funds = float(order.get("executed_funds") or 0) + if side == "buy": + order_krw = executed_funds if executed_funds > 0 else float(signal.get("price", 0)) + order_coin = executed_volume + else: + order_krw = executed_funds if executed_funds > 0 else executed_volume * float( + signal.get("price", 0) + ) + order_coin = executed_volume + fill_price = ( + executed_funds / executed_volume + if executed_volume > 0 and executed_funds > 0 + else float(signal.get("price", 0)) + ) + return TradeResult( + executed=True, + side=side, + order_krw=order_krw, + order_coin=order_coin, + fee_krw=0.0, + price=fill_price, + skip_reason="", + expected_skip=False, + api_response=order, + ) + + +def reconcile_exchange_fills( + kept: list[dict[str, Any]], + trade_history: list[dict[str, Any]], + *, + client: BithumbPrivateClient, + market: str, + lookback_hours: int, + match_window_min: int, + lookback_days: int, +) -> list[dict[str, Any]]: + """거래소 done 주문과 미정산 신호를 대조해 정산 레코드를 반환한다. + + API 재주문 없이 trade_history에 executed=True 기록만 추가한다. + """ + unsettled = _unsettled_signals_for_reconcile( + kept, + trade_history, + lookback_days=lookback_days, + ) + if not unsettled: + return [] + + since = datetime.now() - timedelta(hours=max(lookback_hours, 1)) + try: + orders = client.fetch_filled_orders_since(market, since) + except Exception: + logger.exception("거래소 주문 조회 실패 — exchange reconcile 생략") + return [] + + known_uuids = _known_order_uuids(trade_history) + matches = _match_orders_to_signals( + orders, + unsettled, + match_window_min=match_window_min, + known_uuids=known_uuids, + ) + if not matches: + return [] + + reconciled: list[dict[str, Any]] = [] + for sig, order in matches: + trade = _trade_from_exchange_order(sig, order) + reconciled.append( + { + "datetime": sig["datetime"], + "side": sig["side"], + "signal_type": sig.get("signal_type"), + "price": sig.get("price"), + "bar_index": int(sig.get("bar_index", -1)), + "ledger": True, + "exchange_reconciled": True, + "trade": trade.to_dict(), + "mtf_filter": sig.get("mtf_filter"), + } + ) + + logger.info( + "exchange reconcile %d건 (orders=%d unsettled=%d window=%d분)", + len(reconciled), + len(orders), + len(unsettled), + match_window_min, + ) + return reconciled diff --git a/src/bithumb/operations/multi_portfolio.py b/src/bithumb/operations/multi_portfolio.py new file mode 100644 index 0000000..119a9f9 --- /dev/null +++ b/src/bithumb/operations/multi_portfolio.py @@ -0,0 +1,97 @@ +"""멀티 코인 현물 포트폴리오 — 공유 KRW + 종목별 coin_qty.""" + +from __future__ import annotations + +from typing import Any + +from bithumb.api.bithumb_private import BithumbPrivateClient + + +def empty_multi_portfolio( + symbols: list[str], + *, + cash_krw: float = 0.0, + mode: str = "paper", +) -> dict[str, Any]: + """빈 멀티 포트폴리오.""" + return { + "cash_krw": float(cash_krw), + "mode": mode, + "positions": { + sym.upper(): {"coin_qty": 0.0, "entry_price": 0.0} + for sym in symbols + }, + } + + +def get_coin_qty(portfolio: dict[str, Any], symbol: str) -> float: + """종목 보유 수량.""" + pos = portfolio.get("positions") or {} + return float((pos.get(symbol.upper()) or {}).get("coin_qty") or 0.0) + + +def set_coin_qty(portfolio: dict[str, Any], symbol: str, qty: float) -> None: + """종목 보유 수량 설정.""" + portfolio.setdefault("positions", {}) + sym = symbol.upper() + portfolio["positions"].setdefault(sym, {"coin_qty": 0.0, "entry_price": 0.0}) + portfolio["positions"][sym]["coin_qty"] = float(qty) + + +def in_long_position(portfolio: dict[str, Any], symbol: str, *, dust_krw: float = 5000.0, price: float = 0.0) -> bool: + """최소 주문 가치 이상 보유 시 롱 포지션.""" + qty = get_coin_qty(portfolio, symbol) + if qty <= 0: + return False + if price > 0: + return qty * price >= dust_krw + return qty > 0 + + +def count_empty_buy_slots( + portfolio: dict[str, Any], + symbols: list[str], + *, + min_order_krw: float = 5000.0, + prices: dict[str, float] | None = None, +) -> int: + """매수 가능 빈 슬롯 수 (미보유 종목). + + 3종목 중 2개 보유 → 1 (100% 매수). 1개 보유 → 2 (1/2 매수). + """ + prices = prices or {} + empty = 0 + for sym in symbols: + sym_u = sym.upper() + px = float(prices.get(sym_u) or prices.get(sym) or 0.0) + if not in_long_position(portfolio, sym_u, dust_krw=min_order_krw, price=px): + empty += 1 + return max(empty, 1) + + +def sync_multi_portfolio_from_exchange( + portfolio: dict[str, Any], + client: BithumbPrivateClient, + symbols: list[str], +) -> None: + """거래소 KRW·코인 잔고 동기화.""" + krw_avail, _ = client.get_balance("KRW") + portfolio["cash_krw"] = float(krw_avail) + portfolio.setdefault("positions", {}) + for sym in symbols: + sym_u = sym.upper() + coin_avail, _ = client.get_balance(sym_u) + portfolio["positions"].setdefault(sym_u, {"coin_qty": 0.0, "entry_price": 0.0}) + portfolio["positions"][sym_u]["coin_qty"] = float(coin_avail) + + +def single_coin_portfolio_view( + portfolio: dict[str, Any], + symbol: str, +) -> dict[str, Any]: + """SymbolExecutor 호환 단일 코인 뷰.""" + return { + "cash_krw": float(portfolio.get("cash_krw") or 0.0), + "coin_qty": get_coin_qty(portfolio, symbol), + "mode": portfolio.get("mode", "paper"), + } diff --git a/src/bithumb/operations/ops_lock.py b/src/bithumb/operations/ops_lock.py new file mode 100644 index 0000000..5ab1e14 --- /dev/null +++ b/src/bithumb/operations/ops_lock.py @@ -0,0 +1,48 @@ +"""운영 tick 단일 실행 flock (loop·watch 공유).""" + +from __future__ import annotations + +import fcntl +import logging +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +logger = logging.getLogger(__name__) + + +@contextmanager +def ops_tick_lock(path: Path, *, blocking: bool = True) -> Iterator[bool]: + """tick 체결 lock. + + Args: + path: lock 파일 경로. + blocking: False면 획득 실패 시 즉시 False yield. + + Yields: + lock 획득 성공 여부. + """ + path.parent.mkdir(parents=True, exist_ok=True) + handle = path.open("a+", encoding="utf-8") + acquired = False + try: + flags = fcntl.LOCK_EX + if not blocking: + flags |= fcntl.LOCK_NB + fcntl.flock(handle.fileno(), flags) + acquired = True + handle.seek(0) + handle.truncate() + handle.write("locked\n") + handle.flush() + yield True + except BlockingIOError: + logger.info("ops tick lock busy: %s", path) + yield False + finally: + if acquired: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except OSError: + pass + handle.close() diff --git a/src/bithumb/operations/reconcile.py b/src/bithumb/operations/reconcile.py new file mode 100644 index 0000000..ccbe6f9 --- /dev/null +++ b/src/bithumb/operations/reconcile.py @@ -0,0 +1,76 @@ +"""운영 backlog 신호 조회·일괄 처리.""" + +from __future__ import annotations + +from typing import Any + +from bithumb.config import Settings +from bithumb.operations.runner import ( + _apply_backlog_limit, + _backlog_summary, + _catchup_signals_for_ops, + _ledger_pending_signals, + _merge_pending_signals, + _reconcile_processed_cursor, +) +from bithumb.operations.signal_pipeline import ( + filter_signals_for_ops, + generate_raw_signals, + load_ops_candles, +) +from bithumb.operations.state_store import load_state + + +def inspect_ops_backlog(settings: Settings) -> dict[str, Any]: + """현재 state 기준 ledger backlog를 조회한다 (체결 없음). + + Returns: + ledger_pending, merged_pending, summary dict. + """ + state = load_state( + settings.ops_state_json, + initial_cash_krw=settings.gt_initial_cash_krw, + ) + df = load_ops_candles(settings) + latest_bar = len(df) - 1 + force_tail = ( + settings.ops_mode == "live" and settings.ops_live_force_tail_refresh + ) + gen = generate_raw_signals( + settings, + df=df, + use_cache=True, + force_tail_refresh=force_tail, + ) + filtered = filter_signals_for_ops(settings, gen["raw_signals"]) + all_kept = filtered["kept"] + _reconcile_processed_cursor(state, gen["raw_signals"]) + trade_history = state.get("trade_history") or [] + + ledger_pending = _ledger_pending_signals( + all_kept, + trade_history, + latest_bar_index=latest_bar, + lookback_days=settings.ops_ledger_lookback_days, + ) + catchup = _catchup_signals_for_ops( + all_kept, + latest_bar_index=latest_bar, + catchup_bars=settings.ops_catchup_bars, + trade_history=trade_history, + ) + merged = _merge_pending_signals(ledger_pending, catchup) + limited, dropped = _apply_backlog_limit(merged, settings.ops_max_backlog_per_tick) + + return { + "state": state, + "latest_bar_index": latest_bar, + "ledger_pending": ledger_pending, + "catchup_pending": catchup, + "merged_pending": merged, + "limited_pending": limited, + "backlog_dropped": dropped, + "summary": _backlog_summary(merged), + "signal_refresh": gen.get("signal_refresh"), + "force_tail_refresh": force_tail, + } diff --git a/src/bithumb/operations/runner.py b/src/bithumb/operations/runner.py index 1149140..c9a541d 100644 --- a/src/bithumb/operations/runner.py +++ b/src/bithumb/operations/runner.py @@ -6,7 +6,7 @@ import json import logging import time import traceback -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -14,8 +14,10 @@ from bithumb.config import Settings from bithumb.ground_truth.pnl import _cluster_signals from bithumb.notifications.telegram import create_telegram_notifier from bithumb.operations.candle_sync import sync_ops_candles +from bithumb.operations.exchange_reconcile import reconcile_exchange_fills from bithumb.operations.executor import LiveExecutor, create_executor from bithumb.operations.live_bootstrap import sync_portfolio_from_exchange +from bithumb.operations.ops_lock import ops_tick_lock from bithumb.operations.signal_pipeline import ( filter_signals_for_ops, generate_raw_signals, @@ -75,6 +77,241 @@ def _reconcile_processed_cursor( state["last_processed_bar_index"] = max_bar_at_or_before +def _is_settled(trade: dict[str, Any]) -> bool: + """trade_history에 완료(체결·의도 스킵)로 기록됐는지.""" + return bool(trade.get("executed")) or bool(trade.get("expected_skip")) + + +def _record_status_rank(record: dict[str, Any]) -> int: + """history 레코드 우선순위 (executed > expected_skip > 미정산).""" + trade = record.get("trade") or {} + if trade.get("executed"): + return 3 + if trade.get("expected_skip"): + return 2 + return 1 + + +def _history_index( + trade_history: list[dict[str, Any]], +) -> dict[tuple[str, str], dict[str, Any]]: + """(datetime, side) → 대표 history 레코드 (executed 우선).""" + idx: dict[tuple[str, str], dict[str, Any]] = {} + for record in trade_history: + key = (str(record.get("datetime")), str(record.get("side"))) + existing = idx.get(key) + if existing is None or _record_status_rank(record) >= _record_status_rank( + existing + ): + idx[key] = record + return idx + + +def _is_signal_api_executable( + sig: dict[str, Any], + *, + max_age_minutes: int, + live_since: str | None, + now: datetime | None = None, +) -> tuple[bool, str]: + """live API 체결 가능 여부 (만료·live 이전 backlog는 스킵). + + Returns: + (executable, skip_reason) — skip_reason은 executable=False일 때만 사용. + """ + if now is None: + now = datetime.now() + dt_str = str(sig["datetime"]) + if live_since and dt_str < live_since: + return False, "live 시작 이전 backlog (체결 생략)" + if max_age_minutes > 0: + sig_dt = _parse_signal_dt(dt_str) + age_sec = (now - sig_dt).total_seconds() + if age_sec > max_age_minutes * 60: + return False, ( + f"backlog 만료 (신호 {max_age_minutes}분 초과, 체결 생략)" + ) + return True, "" + + +def _settle_expired_backlog( + all_kept: list[dict[str, Any]], + trade_history: list[dict[str, Any]], + *, + max_age_minutes: int, + live_since: str | None, + lookback_days: int = 3, +) -> list[dict[str, Any]]: + """API 없이 만료 backlog를 expected_skip으로 일괄 정산한다.""" + idx = _history_index(trade_history) + scan_start = _ledger_scan_start_datetime(trade_history, lookback_days) + now = datetime.now() + settled: list[dict[str, Any]] = [] + for sig in all_kept: + dt_str = str(sig.get("datetime", "")) + if not dt_str or dt_str < scan_start: + continue + key = _signal_key(sig) + record = idx.get(key) + if record is not None and _is_settled(record.get("trade") or {}): + continue + executable, skip_reason = _is_signal_api_executable( + sig, + max_age_minutes=max_age_minutes, + live_since=live_since, + now=now, + ) + if executable: + continue + settled.append( + { + "datetime": sig["datetime"], + "side": sig["side"], + "signal_type": sig.get("signal_type"), + "price": sig["price"], + "bar_index": int(sig.get("bar_index", -1)), + "ledger": True, + "backlog_expired": True, + "trade": TradeResult( + executed=False, + side=str(sig["side"]), + order_krw=0.0, + order_coin=0.0, + fee_krw=0.0, + price=float(sig.get("price", 0)), + skip_reason=skip_reason, + expected_skip=True, + ).to_dict(), + "mtf_filter": sig.get("mtf_filter"), + } + ) + if settled: + logger.info( + "backlog 만료 정산 %d건 (max_age=%d분 live_since=%s)", + len(settled), + max_age_minutes, + live_since, + ) + return settled + + +def _ledger_scan_start_datetime( + trade_history: list[dict[str, Any]], + lookback_days: int, +) -> str: + """ledger pending 스캔 시작 시각 (lookback vs 미정산 최소 시각).""" + now = datetime.now() + lookback_start = (now - timedelta(days=max(lookback_days, 0))).strftime( + "%Y-%m-%d %H:%M:%S" + ) + oldest_unsettled: str | None = None + for record in trade_history: + trade = record.get("trade") or {} + if _is_settled(trade): + continue + dt = str(record.get("datetime", "")) + if not dt: + continue + if oldest_unsettled is None or dt < oldest_unsettled: + oldest_unsettled = dt + if oldest_unsettled is not None and oldest_unsettled < lookback_start: + return oldest_unsettled + return lookback_start + + +def _ledger_pending_signals( + kept: list[dict[str, Any]], + trade_history: list[dict[str, Any]], + *, + latest_bar_index: int, + lookback_days: int, +) -> list[dict[str, Any]]: + """trade_history 원장 기준 미정산 신호 (datetime·side). + + last_processed_datetime과 무관하게 history에 settled 기록이 없으면 pending. + """ + idx = _history_index(trade_history) + scan_start = _ledger_scan_start_datetime(trade_history, lookback_days) + pending: list[dict[str, Any]] = [] + for sig in kept: + bar_idx = int(sig.get("bar_index", -1)) + if bar_idx > latest_bar_index: + continue + dt_str = str(sig["datetime"]) + if dt_str < scan_start: + continue + key = _signal_key(sig) + record = idx.get(key) + if record is not None and _is_settled(record.get("trade") or {}): + continue + pending.append(sig) + pending.sort( + key=lambda s: (_parse_signal_dt(str(s["datetime"])), str(s.get("side", ""))) + ) + return pending + + +def _advance_cursor_from_ledger( + state: dict[str, Any], + all_kept: list[dict[str, Any]], + trade_history: list[dict[str, Any]], +) -> None: + """settled history 기준으로 커서를 텔레메트리용으로 갱신한다.""" + idx = _history_index(trade_history) + max_dt: str | None = state.get("last_processed_datetime") + max_bar = int(state.get("last_processed_bar_index", -1)) + for sig in all_kept: + key = _signal_key(sig) + record = idx.get(key) + if record is None or not _is_settled(record.get("trade") or {}): + continue + dt = str(sig["datetime"]) + bar = int(sig.get("bar_index", -1)) + if max_dt is None or dt > max_dt: + max_dt = dt + if bar > max_bar: + max_bar = bar + if max_dt is not None: + state["last_processed_datetime"] = max_dt + state["last_processed_bar_index"] = max_bar + + +def _apply_backlog_limit( + pending_signals: list[dict[str, Any]], + max_backlog: int, +) -> tuple[list[dict[str, Any]], int]: + """tick당 처리 상한 적용. (truncated, dropped_count).""" + if max_backlog <= 0 or len(pending_signals) <= max_backlog: + return pending_signals, 0 + return pending_signals[:max_backlog], len(pending_signals) - max_backlog + + +def _backlog_summary(pending_signals: list[dict[str, Any]]) -> dict[str, Any]: + """backlog 리포트용 요약.""" + if not pending_signals: + return { + "backlog_signal_count": 0, + "backlog_oldest_datetime": None, + } + return { + "backlog_signal_count": len(pending_signals), + "backlog_oldest_datetime": str(pending_signals[0]["datetime"]), + } + + +def _append_ops_log(settings: Settings, message: str) -> None: + """운영 tick 로그 append.""" + path = settings.ops_log_json + if path is None: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fp: + fp.write(message.rstrip() + "\n") + except OSError: + logger.exception("ops_log append 실패: %s", path) + + def _pending_signals_for_ops( kept: list[dict[str, Any]], *, @@ -205,6 +442,7 @@ class OperationsRunner: def __init__(self, settings: Settings) -> None: self.settings = settings + self._runner_started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.executor = create_executor(settings) self.telegram = create_telegram_notifier( settings.telegram_bot_token, @@ -229,8 +467,33 @@ class OperationsRunner: logger.exception("live 초기 잔고 동기화 실패") self._notify_ops_error("init_sync", exc) - def tick(self, *, sync_candles: bool | None = None) -> dict[str, Any]: + def tick( + self, + *, + sync_candles: bool | None = None, + skip_lock: bool = False, + ) -> dict[str, Any]: """신호 확인 및 체결 1회. 예외 발생 시 텔레그램 알림 후 error 리포트 반환.""" + if skip_lock: + return self._tick_with_error_handling(sync_candles=sync_candles) + lock_path = self.settings.ops_tick_lock_path + if lock_path is None: + return self._tick_with_error_handling(sync_candles=sync_candles) + with ops_tick_lock(lock_path, blocking=True) as acquired: + if not acquired: + logger.warning("tick lock 획득 실패 — tick 생략") + return self._build_error_report( + RuntimeError("tick lock busy"), + stage="tick_lock", + ) + return self._tick_with_error_handling(sync_candles=sync_candles) + + def _tick_with_error_handling( + self, + *, + sync_candles: bool | None = None, + ) -> dict[str, Any]: + """tick 본체 + 예외 처리.""" try: return self._tick_impl(sync_candles=sync_candles) except Exception as exc: @@ -240,6 +503,15 @@ class OperationsRunner: def _tick_impl(self, *, sync_candles: bool | None = None) -> dict[str, Any]: """tick 본체.""" + tick_started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + tick_mono_start = time.monotonic() + self.state["last_tick_started_at"] = tick_started_at + self._check_watchdog_stale(tick_started_at) + _append_ops_log( + self.settings, + f"{tick_started_at} [INFO] tick 시작 mode={self.settings.ops_mode}", + ) + do_sync = sync_candles if sync_candles is not None else self.settings.ops_sync_candles candle_sync_results: list[Any] = [] if do_sync: @@ -247,7 +519,16 @@ class OperationsRunner: df = load_ops_candles(self.settings) latest_bar = int(len(df) - 1) - gen = generate_raw_signals(self.settings, df=df, use_cache=True) + force_tail = ( + self.settings.ops_mode == "live" + and self.settings.ops_live_force_tail_refresh + ) + gen = generate_raw_signals( + self.settings, + df=df, + use_cache=True, + force_tail_refresh=force_tail, + ) filtered = filter_signals_for_ops(self.settings, gen["raw_signals"]) all_kept = filtered["kept"] @@ -268,22 +549,66 @@ class OperationsRunner: logger.exception("tick 잔고 동기화 실패") self._notify_ops_error("portfolio_sync", exc) - last_bar = int(self.state.get("last_processed_bar_index", -1)) - last_dt = self.state.get("last_processed_datetime") - pending_signals = _pending_signals_for_ops( + trade_history = self.state.get("trade_history") or [] + live_since = ( + str(self.state["live_initialized_at"]) + if self.settings.ops_mode == "live" and self.state.get("live_initialized_at") + else None + ) + max_age = self.settings.ops_ledger_execute_max_age_minutes + trade_history = self.state.get("trade_history") or [] + + exchange_reconciled: list[dict[str, Any]] = [] + if ( + self.settings.ops_mode == "live" + and self.settings.ops_exchange_reconcile + and isinstance(self.executor, LiveExecutor) + ): + exchange_reconciled = reconcile_exchange_fills( + all_kept, + trade_history, + client=self.executor.client, + market=self.settings.market, + lookback_hours=self.settings.ops_exchange_order_lookback_hours, + match_window_min=self.settings.ops_exchange_match_window_min, + lookback_days=self.settings.ops_ledger_lookback_days, + ) + if exchange_reconciled: + self.state.setdefault("trade_history", []).extend(exchange_reconciled) + trade_history = self.state.get("trade_history") or [] + + stale_settled = _settle_expired_backlog( all_kept, - last_processed_datetime=last_dt, - last_bar_index=last_bar, + trade_history, + max_age_minutes=max_age, + live_since=live_since, + lookback_days=self.settings.ops_ledger_lookback_days, + ) + if stale_settled: + self.state.setdefault("trade_history", []).extend(stale_settled) + trade_history = self.state.get("trade_history") or [] + + ledger_pending = _ledger_pending_signals( + all_kept, + trade_history, latest_bar_index=latest_bar, + lookback_days=self.settings.ops_ledger_lookback_days, ) catchup_signals = _catchup_signals_for_ops( all_kept, latest_bar_index=latest_bar, catchup_bars=self.settings.ops_catchup_bars, - trade_history=self.state.get("trade_history") or [], + trade_history=trade_history, ) catchup_keys = {_signal_key(s) for s in catchup_signals} - pending_signals = _merge_pending_signals(pending_signals, catchup_signals) + ledger_keys = {_signal_key(s) for s in ledger_pending} + pending_signals = _merge_pending_signals(ledger_pending, catchup_signals) + backlog_before_limit = len(pending_signals) + pending_signals, backlog_dropped = _apply_backlog_limit( + pending_signals, + self.settings.ops_max_backlog_per_tick, + ) + backlog_meta = _backlog_summary(pending_signals) target_bars = _pending_bar_indices(pending_signals) executions: list[dict[str, Any]] = [] @@ -302,23 +627,13 @@ class OperationsRunner: (k for k in all_kept if k["datetime"] == sig["datetime"]), sig, ) - try: - trade = self.executor.execute_signal( - full_sig, - self.state["portfolio"], - cluster_size=cluster_size, - ) - except Exception as exc: - logger.exception( - "체결 실행 예외 side=%s datetime=%s", - full_sig.get("side"), - full_sig.get("datetime"), - ) - self._notify_ops_error( - "execute", - exc, - context=f"side={full_sig.get('side')} dt={full_sig.get('datetime')}", - ) + sig_key = _signal_key(full_sig) + executable, expire_reason = _is_signal_api_executable( + full_sig, + max_age_minutes=max_age, + live_since=live_since, + ) + if not executable: trade = TradeResult( executed=False, side=str(full_sig.get("side", "")), @@ -326,15 +641,44 @@ class OperationsRunner: order_coin=0.0, fee_krw=0.0, price=float(full_sig.get("price", 0)), - skip_reason=str(exc), + skip_reason=expire_reason, + expected_skip=True, ) + else: + try: + trade = self.executor.execute_signal( + full_sig, + self.state["portfolio"], + cluster_size=cluster_size, + ) + except Exception as exc: + logger.exception( + "체결 실행 예외 side=%s datetime=%s", + full_sig.get("side"), + full_sig.get("datetime"), + ) + self._notify_ops_error( + "execute", + exc, + context=f"side={full_sig.get('side')} dt={full_sig.get('datetime')}", + ) + trade = TradeResult( + executed=False, + side=str(full_sig.get("side", "")), + order_krw=0.0, + order_coin=0.0, + fee_krw=0.0, + price=float(full_sig.get("price", 0)), + skip_reason=str(exc), + ) record = { "datetime": full_sig["datetime"], "side": full_sig["side"], "signal_type": full_sig.get("signal_type"), "price": full_sig["price"], "bar_index": bar_idx, - "catchup": _signal_key(full_sig) in catchup_keys, + "ledger": sig_key in ledger_keys, + "catchup": sig_key in catchup_keys, "trade": trade.to_dict(), "mtf_filter": full_sig.get("mtf_filter"), } @@ -361,11 +705,31 @@ class OperationsRunner: datetime_str=str(full_sig["datetime"]), reason=trade.skip_reason, ) - if bar_idx > last_bar: - last_bar = bar_idx - self.state["last_processed_bar_index"] = bar_idx - if bar_signals: - self.state["last_processed_datetime"] = bar_signals[-1]["datetime"] + + if executions: + self.state.setdefault("trade_history", []).extend(executions) + trade_history = self.state.get("trade_history") or [] + _advance_cursor_from_ledger(self.state, all_kept, trade_history) + + tick_duration = time.monotonic() - tick_mono_start + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.state["last_run_at"] = now + self.state["last_tick_completed_at"] = now + self.state["last_tick_duration_sec"] = round(tick_duration, 2) + if ( + self.settings.ops_tick_warn_sec > 0 + and tick_duration > self.settings.ops_tick_warn_sec + ): + logger.warning( + "tick 소요 %.1fs (경고 임계 %ds)", + tick_duration, + self.settings.ops_tick_warn_sec, + ) + self._notify_ops_error( + "tick_slow", + RuntimeError(f"tick {tick_duration:.1f}s"), + context=f"threshold={self.settings.ops_tick_warn_sec}s", + ) pipeline = { "technique_id": gen["technique_id"], @@ -373,16 +737,16 @@ class OperationsRunner: "kept_count": len(all_kept), "rejected_count": len(filtered["rejected"]), "latest_bar_index": latest_bar, - "pending_signal_count": len(pending_signals), + "ledger_pending_count": len(ledger_pending), + "pending_signal_count": backlog_before_limit, "catchup_signal_count": len(catchup_signals), "catchup_bars": self.settings.ops_catchup_bars, + "backlog_dropped_count": backlog_dropped, + "backlog_stale_settled_count": len(stale_settled), + "exchange_reconciled_count": len(exchange_reconciled), + **backlog_meta, } - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - self.state["last_run_at"] = now - if executions: - self.state.setdefault("trade_history", []).extend(executions) - report = { "generated_at": now, "mode": self.settings.ops_mode, @@ -390,6 +754,7 @@ class OperationsRunner: "slippage_rate": self.settings.ops_slippage_rate, "daily_max_trades": self.settings.ops_daily_max_trades, "signal_refresh": gen.get("signal_refresh"), + "force_tail_refresh": force_tail, "candle_sync": [ { "interval_min": r.interval_min, @@ -402,20 +767,79 @@ class OperationsRunner: "raw_signals": pipeline["raw_count"], "filtered_signals": pipeline["kept_count"], "pending_bars": target_bars, + "ledger_pending_count": pipeline["ledger_pending_count"], "pending_signal_count": pipeline["pending_signal_count"], "catchup_signal_count": pipeline["catchup_signal_count"], "catchup_bars": pipeline["catchup_bars"], + "backlog_signal_count": pipeline["backlog_signal_count"], + "backlog_oldest_datetime": pipeline["backlog_oldest_datetime"], + "backlog_dropped_count": pipeline["backlog_dropped_count"], + "backlog_stale_settled_count": pipeline["backlog_stale_settled_count"], + "exchange_reconciled_count": pipeline["exchange_reconciled_count"], "latest_bar_candidates": pipeline["raw_count"], "executions": executions, "portfolio": self.state["portfolio"], "trades_today_count": self.state["trades_today_count"], "last_processed_bar_index": self.state["last_processed_bar_index"], + "last_processed_datetime": self.state.get("last_processed_datetime"), + "last_tick_started_at": tick_started_at, + "last_tick_completed_at": now, + "last_tick_duration_sec": round(tick_duration, 2), } save_state(self.settings.ops_state_json, self.state) self._save_report(report) + _append_ops_log( + self.settings, + ( + f"{now} [INFO] tick 완료 duration={tick_duration:.1f}s " + f"ledger_pending={len(ledger_pending)} " + f"processed={len(executions)} " + f"backlog_dropped={backlog_dropped}" + ), + ) return report + def _check_watchdog_stale(self, tick_started_at: str) -> None: + """이전 tick 완료 시각이 너무 오래됐으면 알린다. + + 프로세스 재시작 직후에는 state에 남은 이전 last_completed를 무시한다. + """ + stale_sec = self.settings.ops_watchdog_stale_sec + if stale_sec <= 0: + return + last_completed = self.state.get("last_tick_completed_at") or self.state.get( + "last_run_at" + ) + if not last_completed: + return + try: + tick_dt = datetime.strptime(tick_started_at, "%Y-%m-%d %H:%M:%S") + last_dt = datetime.strptime(str(last_completed), "%Y-%m-%d %H:%M:%S") + runner_dt = datetime.strptime(self._runner_started_at, "%Y-%m-%d %H:%M:%S") + except ValueError: + return + if last_dt < runner_dt: + logger.info( + "watchdog skip (restart): last_completed=%s runner_started=%s", + last_completed, + self._runner_started_at, + ) + return + delta = (tick_dt - last_dt).total_seconds() + if delta <= stale_sec: + return + logger.warning( + "watchdog stale: last completed %.0fs ago (threshold %ds)", + delta, + stale_sec, + ) + self._notify_ops_error( + "watchdog_stale", + RuntimeError(f"no tick completion for {delta:.0f}s"), + context=f"last_completed={last_completed}", + ) + def _notify_ops_error( self, stage: str, @@ -427,6 +851,8 @@ class OperationsRunner: if not self.telegram.is_active: return tb_tail = traceback.format_exc(limit=4).strip() + if tb_tail in ("NoneType: None", "None"): + tb_tail = "" detail_parts = [p for p in (context, tb_tail) if p] self.telegram.notify_ops_error( mode=self.settings.ops_mode, diff --git a/src/bithumb/operations/signal_pipeline.py b/src/bithumb/operations/signal_pipeline.py index 6edbc63..1740770 100644 --- a/src/bithumb/operations/signal_pipeline.py +++ b/src/bithumb/operations/signal_pipeline.py @@ -47,6 +47,27 @@ def _offset_signal_bars(signals: list[dict[str, Any]], offset: int) -> list[dict return shifted +def _replace_tail_signals( + cached_signals: list[dict[str, Any]], + tail_signals: list[dict[str, Any]], + offset: int, +) -> list[dict[str, Any]]: + """tail 구간 신호를 재계산 결과로 교체한다 (live force refresh용). + + bar_index >= offset 인 캐시 신호를 제거하고 tail 재계산 신호로 대체한다. + """ + base = [s for s in cached_signals if int(s.get("bar_index", 0)) < offset] + seen = {(s.get("datetime"), s.get("side")) for s in base} + merged = list(base) + for signal in _offset_signal_bars(tail_signals, offset): + key = (signal.get("datetime"), signal.get("side")) + if key in seen: + continue + merged.append(signal) + seen.add(key) + return merged + + def _merge_tail_signals( cached_signals: list[dict[str, Any]], tail_signals: list[dict[str, Any]], @@ -187,6 +208,7 @@ def generate_raw_signals( df: pd.DataFrame | None = None, *, use_cache: bool = True, + force_tail_refresh: bool = False, ) -> dict[str, Any]: """기법 신호를 생성한다 (MTF 필터 전). @@ -209,19 +231,27 @@ def generate_raw_signals( cached = _load_technique_cached(cache_path) raw_signals = list(cached.signals) max_cached_bar = _max_signal_bar_index(raw_signals) - if latest_bar > max_cached_bar: - tail_bars = max(settings.ops_signal_tail_bars, 200) - offset = max(0, len(df) - tail_bars) + tail_bars = max(settings.ops_signal_tail_bars, 200) + offset = max(0, len(df) - tail_bars) + needs_tail = latest_bar > max_cached_bar or force_tail_refresh + if needs_tail: tail_df = df.iloc[offset:].copy() technique = get_technique(settings.ops_technique_id) params = build_technique_params(settings) tail_result = run_technique(technique, tail_df, params, gt_result=None) - raw_signals = _merge_tail_signals( - raw_signals, - tail_result.signals, - offset, - max_cached_bar, - ) + if force_tail_refresh and latest_bar <= max_cached_bar: + raw_signals = _replace_tail_signals( + raw_signals, + tail_result.signals, + offset, + ) + else: + raw_signals = _merge_tail_signals( + raw_signals, + tail_result.signals, + offset, + max_cached_bar, + ) refreshed_tail = True updated = TechniqueResult( technique_id=cached.technique_id, diff --git a/src/bithumb/operations/symbol_executor.py b/src/bithumb/operations/symbol_executor.py new file mode 100644 index 0000000..685dc87 --- /dev/null +++ b/src/bithumb/operations/symbol_executor.py @@ -0,0 +1,150 @@ +"""종목별 paper/live 체결 — 멀티 포트폴리오 뷰.""" + +from __future__ import annotations + +import logging +import math +from typing import Any + +from bithumb.api.bithumb_private import BithumbPrivateClient +from bithumb.config import Settings +from bithumb.operations.execution import fill_price +from bithumb.operations.trade_engine import ( + TradeResult, + apply_trade_to_portfolio, + compute_buy_order, + compute_sell_order, + spendable_cash_for_exchange_buy, +) + +logger = logging.getLogger(__name__) + + +class SymbolExecutor: + """단일 종목 buy/sell (멀티 포트폴리오 공유 KRW).""" + + def __init__( + self, + settings: Settings, + symbol: str, + *, + client: BithumbPrivateClient | None = None, + ) -> None: + self.settings = settings + self.symbol = symbol.upper() + self.market = f"KRW-{self.symbol}" + self._client = client + + def _fee_rate(self) -> float: + return self.settings.gt_trading_fee_rate + + def _slippage(self) -> float: + return self.settings.ops_slippage_rate + + def _buy_cash_budget(self, cash: float, *, empty_slots: int = 1) -> float: + """매수 KRW — 수수료 lock·안전버퍼 후 빈 슬롯 수로 나눔.""" + spendable_total = spendable_cash_for_exchange_buy( + max(float(cash), 0.0), + self.settings.ops_exchange_fee_lock_rate, + self.settings.ops_buy_safety_buffer_krw, + ) + fixed_split = int(self.settings.vol_buy_split) + if fixed_split > 0: + return math.floor(spendable_total / fixed_split) + slots = max(int(empty_slots), 1) + return math.floor(spendable_total / slots) + + def execute_buy( + self, + portfolio_view: dict[str, Any], + *, + price: float, + empty_slots: int = 1, + ) -> TradeResult: + """롱 진입 매수.""" + px = fill_price(price, "buy", self._slippage()) + cash = float(portfolio_view.get("cash_krw") or 0.0) + coin = float(portfolio_view.get("coin_qty") or 0.0) + spendable = self._buy_cash_budget(cash, empty_slots=empty_slots) + + trade = compute_buy_order( + cash_krw=spendable, + coin_qty=coin, + price=px, + fee_rate=self._fee_rate(), + min_order_krw=self.settings.ops_min_order_krw, + cluster_size=1, + buy_cash_pct=1.0, + sizing_rules=None, + ) + if not trade.executed: + return trade + + if self.settings.ops_mode == "live" and self._client is not None: + try: + resp = self._client.market_buy_krw(self.market, trade.order_krw) + trade.api_response = resp + krw_avail, _ = self._client.get_balance("KRW") + coin_avail, _ = self._client.get_balance(self.symbol) + portfolio_view["cash_krw"] = krw_avail + portfolio_view["coin_qty"] = coin_avail + return trade + except Exception as exc: + logger.exception("live buy failed %s", self.symbol) + return TradeResult( + executed=False, + side="buy", + order_krw=trade.order_krw, + order_coin=trade.order_coin, + fee_krw=0.0, + price=px, + skip_reason=str(exc), + ) + apply_trade_to_portfolio(portfolio_view, trade) + return trade + + def execute_sell( + self, + portfolio_view: dict[str, Any], + *, + price: float, + sell_pct: float = 1.0, + ) -> TradeResult: + """롱 청산 매도.""" + px = fill_price(price, "sell", self._slippage()) + coin = float(portfolio_view.get("coin_qty") or 0.0) + + trade = compute_sell_order( + coin_qty=coin, + price=px, + fee_rate=self._fee_rate(), + min_order_krw=self.settings.ops_min_order_krw, + cluster_size=1, + sell_coin_pct=sell_pct, + sizing_rules=None, + ) + if not trade.executed: + return trade + + if self.settings.ops_mode == "live" and self._client is not None: + try: + resp = self._client.market_sell_volume(self.market, trade.order_coin) + trade.api_response = resp + krw_avail, _ = self._client.get_balance("KRW") + coin_avail, _ = self._client.get_balance(self.symbol) + portfolio_view["cash_krw"] = krw_avail + portfolio_view["coin_qty"] = coin_avail + return trade + except Exception as exc: + logger.exception("live sell failed %s", self.symbol) + return TradeResult( + executed=False, + side="sell", + order_krw=trade.order_krw, + order_coin=trade.order_coin, + fee_krw=0.0, + price=px, + skip_reason=str(exc), + ) + apply_trade_to_portfolio(portfolio_view, trade) + return trade diff --git a/src/bithumb/operations/vol_breakout_engine.py b/src/bithumb/operations/vol_breakout_engine.py new file mode 100644 index 0000000..046a5c2 --- /dev/null +++ b/src/bithumb/operations/vol_breakout_engine.py @@ -0,0 +1,386 @@ +"""15m vol_breakout 현물 롱 — 종목별 엔진 (Binance vol_breakout_live 이식).""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import asdict, dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + +import pandas as pd + +from bithumb.config import Settings +from bithumb.data.candle_loader import load_candles +from bithumb.operations.multi_portfolio import ( + count_empty_buy_slots, + in_long_position, + single_coin_portfolio_view, + sync_multi_portfolio_from_exchange, +) +from bithumb.operations.symbol_executor import SymbolExecutor +from bithumb.operations.vol_breakout_exit import ( + DEFAULT_SPOT_LONG_EXIT_RULES, + SpotLongExitRules, + evaluate_spot_long_exit, + latest_atr, + reset_exit_trail_state, + sync_exit_state_after_open, +) +from bithumb.simulation.vol_breakout import ( + baseline_15m_signal_at, + drop_incomplete_base_bar, + pending_baseline_bar_indices, + spot_long_action, +) + +logger = logging.getLogger(__name__) + +INTERVAL_MIN = 15 + + +@dataclass +class SymbolTickResult: + """종목 1틱 결과.""" + + symbol: str + fills: int = 0 + note: str = "" + last_price: float = 0.0 + in_position: bool = False + trade_records: list[dict[str, Any]] = field(default_factory=list) + + +def _default_sym_state() -> dict[str, Any]: + return { + "initialized": False, + "last_confirm_time": None, + "last_signal": 0, + "pending_bar_time": None, + "exit_trail_active": False, + "exit_peak_price": 0.0, + "entry_price": 0.0, + } + + +class VolBreakoutSymbolEngine: + """단일 종목 15m vol_breakout 현물 롱.""" + + STRATEGY = "vol_breakout_15m_spot_long" + + def __init__( + self, + settings: Settings, + symbol: str, + executor: SymbolExecutor, + ) -> None: + self.settings = settings + self.symbol = symbol.upper() + self.executor = executor + + def _load_closed_15m(self) -> pd.DataFrame: + df = load_candles( + self.settings.db_path, + self.symbol, + INTERVAL_MIN, + lookback_days=self.settings.vol_lookback_days, + ) + if df.empty: + return df + return drop_incomplete_base_bar(df, INTERVAL_MIN) + + def _sym_state(self, root: dict[str, Any]) -> dict[str, Any]: + root.setdefault("symbols", {}) + st = root["symbols"].setdefault(self.symbol, _default_sym_state()) + for k, v in _default_sym_state().items(): + st.setdefault(k, v if not isinstance(v, dict) else dict(v)) + return st + + def _current_price(self, df: pd.DataFrame) -> float: + if df.empty: + return 0.0 + return float(df["close"].iloc[-1]) + + def _merge_portfolio_view( + self, + portfolio: dict[str, Any], + trade_view: dict[str, Any], + ) -> None: + """체결 후 멀티 포트폴리오 반영.""" + portfolio["cash_krw"] = float(trade_view.get("cash_krw") or 0.0) + portfolio.setdefault("positions", {}) + sym = self.symbol + portfolio["positions"].setdefault(sym, {"coin_qty": 0.0, "entry_price": 0.0}) + portfolio["positions"][sym]["coin_qty"] = float(trade_view.get("coin_qty") or 0.0) + + def run_exit_tick( + self, + portfolio: dict[str, Any], + root_state: dict[str, Any], + *, + client=None, + ) -> SymbolTickResult: + """trail/stop 청산.""" + if not self.settings.vol_exit_enabled: + return SymbolTickResult(symbol=self.symbol, note="exit_disabled") + + if self.settings.ops_mode == "live" and client is not None: + sync_multi_portfolio_from_exchange(portfolio, client, [self.symbol]) + + df = self._load_closed_15m() + price = self._current_price(df) + sym_st = self._sym_state(root_state) + view = single_coin_portfolio_view(portfolio, self.symbol) + + if not in_long_position(portfolio, self.symbol, price=price): + reset_exit_trail_state(sym_st) + return SymbolTickResult(symbol=self.symbol, last_price=price, note="exit_flat") + + entry = float(sym_st.get("entry_price") or 0.0) + if entry <= 0: + entry = price + atr = latest_atr(df, self.settings.vol_lookback) + rules = self._exit_rules() + reason = evaluate_spot_long_exit( + sym_st, + entry=entry, + mark=price, + atr=atr, + rules=rules, + ) + if reason is None: + return SymbolTickResult( + symbol=self.symbol, + last_price=price, + in_position=True, + note="exit_hold", + ) + + entry_px = float(sym_st.get("entry_price") or 0.0) or entry + trade = self.executor.execute_sell(view, price=price, sell_pct=1.0) + self._merge_portfolio_view(portfolio, view) + if trade.executed: + reset_exit_trail_state(sym_st) + sym_st["entry_price"] = 0.0 + sym_st["last_signal"] = 0 + record: dict[str, Any] = { + "symbol": self.symbol, + "side": "sell", + "ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "price": float(trade.price), + "order_krw": float(trade.order_krw), + "order_coin": float(trade.order_coin), + "reason": reason, + } + if entry_px > 0: + cost_krw = entry_px * float(trade.order_coin) + pnl_krw = float(trade.order_krw) - cost_krw + record["entry_price"] = entry_px + record["pnl_krw"] = pnl_krw + record["pnl_pct"] = ( + (pnl_krw / cost_krw * 100.0) if cost_krw > 0 else 0.0 + ) + return SymbolTickResult( + symbol=self.symbol, + fills=1, + last_price=price, + note=f"exit_{reason}", + trade_records=[record], + ) + return SymbolTickResult( + symbol=self.symbol, + last_price=price, + in_position=True, + note=f"exit_{reason}_fail", + ) + + def run_signal_tick( + self, + portfolio: dict[str, Any], + root_state: dict[str, Any], + *, + client=None, + block_entry: bool = False, + ) -> SymbolTickResult: + """15m 마감 신호 처리 (현물 롱 전용).""" + if self.settings.ops_mode == "live" and client is not None: + sync_multi_portfolio_from_exchange(portfolio, client, [self.symbol]) + + df = self._load_closed_15m() + if df.empty: + return SymbolTickResult(symbol=self.symbol, note="no_candles") + + price = self._current_price(df) + sym_st = self._sym_state(root_state) + view = single_coin_portfolio_view(portfolio, self.symbol) + holding = in_long_position(portfolio, self.symbol, price=price) + + if not sym_st.get("initialized"): + last_key = str(df["datetime"].iloc[-1])[:19] + sym_st["initialized"] = True + sym_st["last_confirm_time"] = last_key + return SymbolTickResult( + symbol=self.symbol, + last_price=price, + note=f"init {last_key}", + ) + + pending = pending_baseline_bar_indices(df, sym_st.get("last_confirm_time")) + if not pending: + return SymbolTickResult( + symbol=self.symbol, + last_price=price, + in_position=holding, + note="no_new_bar", + ) + + batch = pending[: max(1, self.settings.vol_max_bars_per_tick)] + fills = 0 + last_note = "" + trade_records: list[dict[str, Any]] = [] + + for bar_idx in batch: + bar_key = str(df["datetime"].iloc[bar_idx])[:19] + raw_sig = baseline_15m_signal_at( + df, + bar_idx, + lookback=self.settings.vol_lookback, + atr_mult=self.settings.vol_atr_mult, + ) + sym_st["last_signal"] = int(raw_sig) + + if raw_sig == 0: + sym_st["last_confirm_time"] = bar_key + last_note = f"neutral {bar_key}" + continue + + holding = in_long_position(portfolio, self.symbol, price=price) + action = spot_long_action(raw_sig, holding) + if action is None: + sym_st["last_confirm_time"] = bar_key + last_note = f"skip_long_only sig={raw_sig} {bar_key}" + continue + + if action == "buy" and block_entry: + last_note = f"buy_blocked {bar_key}" + return SymbolTickResult( + symbol=self.symbol, + fills=fills, + last_price=price, + note=last_note, + ) + + entry_px = float(sym_st.get("entry_price") or 0.0) + if action == "buy": + empty_slots = count_empty_buy_slots( + portfolio, + self.settings.ops_symbols, + min_order_krw=self.settings.ops_min_order_krw, + prices={self.symbol: price}, + ) + trade = self.executor.execute_buy( + view, + price=price, + empty_slots=empty_slots, + ) + else: + trade = self.executor.execute_sell(view, price=price, sell_pct=1.0) + + self._merge_portfolio_view(portfolio, view) + if not trade.executed: + last_note = f"{action}_fail {bar_key}: {trade.skip_reason}" + return SymbolTickResult( + symbol=self.symbol, + fills=fills, + last_price=price, + note=last_note, + ) + + fills += 1 + sym_st["last_confirm_time"] = bar_key + record: dict[str, Any] = { + "symbol": self.symbol, + "side": action, + "ts": bar_key, + "price": float(trade.price), + "order_krw": float(trade.order_krw), + "order_coin": float(trade.order_coin), + } + if action == "sell" and entry_px > 0: + cost_krw = entry_px * float(trade.order_coin) + pnl_krw = float(trade.order_krw) - cost_krw + record["entry_price"] = entry_px + record["pnl_krw"] = pnl_krw + record["pnl_pct"] = ( + (pnl_krw / cost_krw * 100.0) if cost_krw > 0 else 0.0 + ) + trade_records.append(record) + if action == "buy": + sym_st["entry_price"] = float(trade.price) + if self.settings.vol_exit_enabled: + sync_exit_state_after_open(sym_st, trade.price) + else: + sym_st["entry_price"] = 0.0 + reset_exit_trail_state(sym_st) + + if ( + self.settings.ops_mode == "live" + and self.settings.ops_order_interval_sec > 0 + ): + time.sleep(self.settings.ops_order_interval_sec) + + last_note = f"{action}_ok {bar_key} sig={raw_sig}" + + return SymbolTickResult( + symbol=self.symbol, + fills=fills, + last_price=price, + in_position=in_long_position(portfolio, self.symbol, price=price), + note=last_note or "done", + trade_records=trade_records, + ) + + def run_tick( + self, + portfolio: dict[str, Any], + root_state: dict[str, Any], + *, + client=None, + block_entry: bool = False, + ) -> SymbolTickResult: + """순수 flip: exit 비활성 시 15m 신호만. 활성 시 exit → signal.""" + if self.settings.vol_exit_enabled: + exit_res = self.run_exit_tick(portfolio, root_state, client=client) + if exit_res.fills > 0: + return exit_res + return self.run_signal_tick( + portfolio, + root_state, + client=client, + block_entry=block_entry, + ) + + def _exit_rules(self) -> SpotLongExitRules: + return SpotLongExitRules( + trail_activate_pct=self.settings.vol_trail_activate_pct, + trail_pct=self.settings.vol_trail_pct, + trail_atr_mult=self.settings.vol_trail_atr_mult, + stop_pct=self.settings.vol_stop_pct, + ) + + +def load_vol_state(path: Path) -> dict[str, Any]: + """vol_breakout 상태 JSON.""" + if not path.exists(): + return {"strategy": VolBreakoutSymbolEngine.STRATEGY, "symbols": {}} + with path.open(encoding="utf-8") as f: + return json.load(f) + + +def save_vol_state(path: Path, state: dict[str, Any]) -> None: + """vol_breakout 상태 저장.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) diff --git a/src/bithumb/operations/vol_breakout_exit.py b/src/bithumb/operations/vol_breakout_exit.py new file mode 100644 index 0000000..3770fba --- /dev/null +++ b/src/bithumb/operations/vol_breakout_exit.py @@ -0,0 +1,102 @@ +"""vol_breakout 현물 롱 intrabar 청산 — trail + stop (레버리지 1x).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +import numpy as np +import pandas as pd + +from bithumb.simulation.vol_breakout import compute_atr + +ExitReason = Literal["trail", "stop"] + + +@dataclass(frozen=True) +class SpotLongExitRules: + """현물 롱 청산 규칙 (수익률 %, 레버리지 없음).""" + + trail_activate_pct: float = 1.5 + trail_pct: float = 0.0025 + trail_atr_mult: float = 0.5 + stop_pct: float = -12.0 + + +DEFAULT_SPOT_LONG_EXIT_RULES = SpotLongExitRules() + + +def latest_atr(df15: pd.DataFrame, lookback: int) -> float: + """마감 15m 기준 최신 ATR.""" + if df15.empty or len(df15) < lookback + 1: + return 0.0 + c = df15["close"].to_numpy(float) + h = df15["high"].to_numpy(float) + l = df15["low"].to_numpy(float) + atr = compute_atr(h, l, c, lookback) + val = float(atr[-1]) + return val if not np.isnan(val) else 0.0 + + +def long_return_pct(entry: float, mark: float) -> float: + """롱 수익률(%).""" + if entry <= 0 or mark <= 0: + return 0.0 + return (mark / entry - 1.0) * 100.0 + + +def _trail_distance(peak: float, atr: float, rules: SpotLongExitRules) -> float: + pct_dist = peak * rules.trail_pct + atr_dist = rules.trail_atr_mult * atr if rules.trail_atr_mult > 0 and atr > 0 else 0.0 + return max(pct_dist, atr_dist) + + +def reset_exit_trail_state(sym_state: dict[str, Any]) -> None: + """trail 추적 초기화.""" + sym_state["exit_trail_active"] = False + sym_state["exit_peak_price"] = 0.0 + + +def sync_exit_state_after_open(sym_state: dict[str, Any], entry_price: float) -> None: + """진입 직후 trail 상태.""" + sym_state["exit_trail_active"] = False + sym_state["exit_peak_price"] = float(entry_price) + + +def evaluate_spot_long_exit( + sym_state: dict[str, Any], + *, + entry: float, + mark: float, + atr: float, + rules: SpotLongExitRules = DEFAULT_SPOT_LONG_EXIT_RULES, +) -> ExitReason | None: + """롱 보유 중 trail/stop 필요 여부.""" + if entry <= 0 or mark <= 0: + reset_exit_trail_state(sym_state) + return None + + ret = long_return_pct(entry, mark) + if ret <= rules.stop_pct: + return "stop" + + trail_active = bool(sym_state.get("exit_trail_active")) + peak = float(sym_state.get("exit_peak_price") or entry) + if peak <= 0: + peak = entry + + if not trail_active and ret >= rules.trail_activate_pct: + trail_active = True + peak = mark + + if trail_active: + dist = _trail_distance(peak, atr, rules) + peak = max(peak, mark) + if mark <= peak - dist: + sym_state["exit_trail_active"] = trail_active + sym_state["exit_peak_price"] = peak + return "trail" + + sym_state["exit_trail_active"] = trail_active + sym_state["exit_peak_price"] = peak + return None diff --git a/src/bithumb/operations/vol_breakout_runner.py b/src/bithumb/operations/vol_breakout_runner.py new file mode 100644 index 0000000..93ebc59 --- /dev/null +++ b/src/bithumb/operations/vol_breakout_runner.py @@ -0,0 +1,201 @@ +"""멀티 종목 vol_breakout 현물 롱 러너.""" + +from __future__ import annotations + +import logging +from datetime import datetime +from pathlib import Path +from typing import Any + +from bithumb.api.bithumb_private import BithumbPrivateClient +from bithumb.config import Settings +from bithumb.notifications.telegram import create_telegram_notifier +from bithumb.operations.multi_portfolio import ( + empty_multi_portfolio, + sync_multi_portfolio_from_exchange, +) +from bithumb.operations.ops_lock import ops_tick_lock +from bithumb.operations.symbol_executor import SymbolExecutor +from bithumb.operations.vol_breakout_engine import ( + VolBreakoutSymbolEngine, + load_vol_state, + save_vol_state, +) +from bithumb.operations.vol_live_monitor import append_trade_record, write_vol_monitor + +logger = logging.getLogger(__name__) + + +def _portfolio_equity_krw( + portfolio: dict[str, Any], + symbols: list[str], + prices: dict[str, float], +) -> float: + """현금 + 보유 코인 시가 평가.""" + total = float(portfolio.get("cash_krw") or 0.0) + positions = portfolio.get("positions") or {} + for sym in symbols: + qty = float((positions.get(sym) or {}).get("coin_qty") or 0.0) + px = float(prices.get(sym) or 0.0) + total += qty * px + return total + + +class VolBreakoutRunner: + """TRX/NEAR/WLD 등 멀티 종목 vol_breakout tick.""" + + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.symbols = list(settings.ops_symbols) + self.state = load_vol_state(settings.vol_state_json) + self.state.setdefault("strategy", VolBreakoutSymbolEngine.STRATEGY) + self.portfolio = empty_multi_portfolio( + self.symbols, + mode=settings.ops_mode, + ) + self._client: BithumbPrivateClient | None = None + if settings.ops_mode == "live": + self._client = BithumbPrivateClient( + access_key=settings.bithumb_access_key, + secret_key=settings.bithumb_secret_key, + base_url=settings.api_url, + sleep_sec=settings.request_sleep_sec, + retries=settings.request_retries, + ) + sync_multi_portfolio_from_exchange( + self.portfolio, + self._client, + self.symbols, + ) + self.telegram = create_telegram_notifier( + settings.telegram_bot_token, + settings.telegram_chat_id, + enabled=settings.ops_telegram_enabled, + ) + self._engines = { + sym: VolBreakoutSymbolEngine( + settings, + sym, + SymbolExecutor(settings, sym, client=self._client), + ) + for sym in self.symbols + } + + def _kill_switch_active(self) -> bool: + path = self.settings.vol_kill_switch_path + return path is not None and path.exists() + + def tick(self, *, skip_lock: bool = False) -> dict[str, Any]: + """전 종목 1회 tick.""" + lock_path = self.settings.vol_tick_lock_path + if lock_path and not skip_lock: + with ops_tick_lock(lock_path, blocking=False) as acquired: + if not acquired: + return {"ok": False, "note": "lock_busy"} + return self._tick_impl() + return self._tick_impl() + + def _tick_impl(self) -> dict[str, Any]: + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + block_entry = self._kill_switch_active() + results: list[dict[str, Any]] = [] + total_fills = 0 + + if self._client is not None: + sync_multi_portfolio_from_exchange( + self.portfolio, + self._client, + self.symbols, + ) + + mark_prices: dict[str, float] = {} + for sym, engine in self._engines.items(): + try: + df = engine._load_closed_15m() + if not df.empty: + mark_prices[sym] = engine._current_price(df) + except Exception: + logger.debug("mark price snapshot failed %s", sym) + + for sym in self.symbols: + engine = self._engines[sym] + try: + res = engine.run_tick( + self.portfolio, + self.state, + client=self._client, + block_entry=block_entry, + ) + except Exception as exc: + logger.exception("vol tick failed %s", sym) + res_note = str(exc) + results.append({"symbol": sym, "error": res_note}) + continue + + total_fills += res.fills + if res.last_price > 0: + mark_prices[sym] = res.last_price + for rec in res.trade_records: + append_trade_record(self.state, rec) + if self.telegram.is_active: + equity = _portfolio_equity_krw( + self.portfolio, + self.symbols, + mark_prices, + ) + reason = str(rec.get("reason") or "signal_vol_breakout") + self.telegram.notify_vol_breakout_trade( + mode=self.settings.ops_mode, + symbol=sym, + side=str(rec["side"]), + price=float(rec["price"]), + order_krw=float(rec["order_krw"]), + order_coin=float(rec["order_coin"]), + equity_krw=equity, + reason=reason, + ts=str(rec.get("ts") or now), + pnl_krw=( + float(rec["pnl_krw"]) + if rec.get("pnl_krw") is not None + else None + ), + pnl_pct=( + float(rec["pnl_pct"]) + if rec.get("pnl_pct") is not None + else None + ), + ) + row = { + "symbol": sym, + "fills": res.fills, + "note": res.note, + "last_price": res.last_price, + "in_position": res.in_position, + } + results.append(row) + + self.state["last_run_at"] = now + self.state["portfolio_snapshot"] = { + "cash_krw": round(float(self.portfolio.get("cash_krw") or 0), 0), + "positions": { + s: round(float(self.portfolio["positions"].get(s, {}).get("coin_qty") or 0), 8) + for s in self.symbols + }, + } + save_vol_state(self.settings.vol_state_json, self.state) + + tick_report = { + "ok": True, + "mode": self.settings.ops_mode, + "symbols": self.symbols, + "fills": total_fills, + "kill_switch": block_entry, + "results": results, + "last_run_at": now, + } + try: + write_vol_monitor(self.settings, self.state, tick_report=tick_report) + except Exception: + logger.exception("vol monitor refresh failed") + + return tick_report diff --git a/src/bithumb/operations/vol_live_monitor.py b/src/bithumb/operations/vol_live_monitor.py new file mode 100644 index 0000000..379fd0e --- /dev/null +++ b/src/bithumb/operations/vol_live_monitor.py @@ -0,0 +1,428 @@ +"""vol_breakout 멀티 종목 모니터 JSON/HTML (Binance vol_live_monitor 유사).""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +import pandas as pd + +from bithumb.config import Settings, resolve_coin_name +from bithumb.data.candle_loader import load_candles +from bithumb.operations.multi_portfolio import in_long_position +from bithumb.operations.vol_monitor_chart import write_vol_monitor_html +from bithumb.simulation.vol_breakout import drop_incomplete_base_bar + +SEOUL = "Asia/Seoul" +INTERVAL_MIN = 15 + + +def _epoch_kst(ts: pd.Timestamp | str) -> int: + """KST naive/aware → unix epoch.""" + t = pd.Timestamp(ts) + if t.tzinfo is None: + t = t.tz_localize(SEOUL) + else: + t = t.tz_convert(SEOUL) + return int(t.timestamp()) + + +def _next_15m_close(df15: pd.DataFrame) -> tuple[str, int | None]: + """다음 15m 마감 시각(KST 문자열)과 남은 초.""" + if df15.empty: + return "-", None + last_open = pd.Timestamp(df15["datetime"].iloc[-1]) + next_close = last_open + pd.Timedelta(minutes=INTERVAL_MIN) + now = pd.Timestamp.now(tz=SEOUL).tz_localize(None) + sec = int(max((next_close - now).total_seconds(), 0)) + return str(next_close)[:19], sec + + +def _candles_payload(df: pd.DataFrame, *, days: float) -> list[dict[str, float | int]]: + """lightweight-charts용 OHLC.""" + if df.empty: + return [] + d = df.copy() + d["datetime"] = pd.to_datetime(d["datetime"]) + cutoff = d["datetime"].max() - pd.Timedelta(days=max(days, 1)) + d = d[d["datetime"] >= cutoff] + rows: list[dict[str, float | int]] = [] + for _, row in d.iterrows(): + rows.append({ + "time": _epoch_kst(row["datetime"]), + "open": float(row["open"]), + "high": float(row["high"]), + "low": float(row["low"]), + "close": float(row["close"]), + }) + return rows + + +def _trade_markers(trades: list[dict[str, Any]], symbol: str) -> list[dict[str, Any]]: + """체결 마커.""" + out: list[dict[str, Any]] = [] + sym = symbol.upper() + for t in trades: + if str(t.get("symbol", "")).upper() != sym: + continue + side = str(t.get("side", "")) + ts = t.get("ts") + if not ts: + continue + if side == "buy": + out.append({ + "time": _epoch_kst(ts), + "position": "belowBar", + "color": "#16a34a", + "shape": "arrowUp", + "text": "매수", + "size": 2, + }) + elif side == "sell": + out.append({ + "time": _epoch_kst(ts), + "position": "aboveBar", + "color": "#dc2626", + "shape": "arrowDown", + "text": "매도", + "size": 2, + }) + return out + + +def _merged_close_panel( + symbol_dfs: dict[str, pd.DataFrame], + symbols: list[str], + *, + days: float, +) -> pd.DataFrame: + """종목별 15m close를 datetime 기준 병합(ffill).""" + panel: pd.DataFrame | None = None + for sym in symbols: + df = symbol_dfs.get(sym, pd.DataFrame()) + if df.empty: + continue + d = df.copy() + d["datetime"] = pd.to_datetime(d["datetime"]) + cutoff = d["datetime"].max() - pd.Timedelta(days=max(days, 1)) + d = d[d["datetime"] >= cutoff][["datetime", "close"]].rename(columns={"close": sym.upper()}) + panel = d if panel is None else panel.merge(d, on="datetime", how="outer") + if panel is None or panel.empty: + return pd.DataFrame() + sym_cols = [s.upper() for s in symbols if s.upper() in panel.columns] + panel = panel.sort_values("datetime").ffill().dropna(subset=sym_cols, how="any") + return panel.reset_index(drop=True) + + +def _dedupe_time_series(curve: list[dict[str, float | int]]) -> list[dict[str, float | int]]: + """동일 time 중복 제거(마지막 값 유지).""" + curve.sort(key=lambda x: int(x["time"])) + out: list[dict[str, float | int]] = [] + for pt in curve: + if out and out[-1]["time"] == pt["time"]: + out[-1] = pt + else: + out.append(pt) + return out + + +def build_multi_buyhold_series( + panel: pd.DataFrame, + symbols: list[str], + seed_krw: float, +) -> list[dict[str, float | int]]: + """윈도우 시작 시 seed를 1/N씩 나눠 매수 후 보유(B&H) 수익률(%).""" + if panel.empty or seed_krw <= 0: + return [] + syms = [s.upper() for s in symbols] + first = panel.iloc[0] + slot = seed_krw / max(len(syms), 1) + qty: dict[str, float] = {} + for sym in syms: + px = float(first[sym]) + if px <= 0: + return [] + qty[sym] = slot / px + curve: list[dict[str, float | int]] = [] + for _, row in panel.iterrows(): + value = sum(qty[sym] * float(row[sym]) for sym in syms) + curve.append({ + "time": _epoch_kst(row["datetime"]), + "value": round((value / seed_krw - 1.0) * 100.0, 4), + }) + return curve + + +def build_spot_strategy_equity_series( + panel: pd.DataFrame, + symbols: list[str], + trades: list[dict[str, Any]], + *, + seed_krw: float, + current_equity: float, + window_start: pd.Timestamp, +) -> list[dict[str, float | int]]: + """체결 replay + 15m 종가 MTM 포트폴리오 수익률(%).""" + init = max(float(seed_krw), 1e-9) + start = pd.Timestamp(window_start) + syms = [s.upper() for s in symbols] + sym_set = set(syms) + + sorted_trades = sorted( + [ + t for t in trades + if t.get("ts") and str(t.get("symbol", "")).upper() in sym_set + ], + key=lambda x: str(x["ts"]), + ) + + cash = init + qty = {sym: 0.0 for sym in syms} + curve: list[dict[str, float | int]] = [{ + "time": _epoch_kst(start), + "value": 0.0, + }] + trade_idx = 0 + bar_delta = pd.Timedelta(minutes=INTERVAL_MIN) + + def _portfolio_value(row: pd.Series) -> float: + return cash + sum(qty[sym] * float(row[sym]) for sym in syms) + + for _, row in panel.iterrows(): + bar_open = pd.Timestamp(row["datetime"]) + if bar_open < start: + continue + bar_close = bar_open + bar_delta + + while trade_idx < len(sorted_trades): + tr = sorted_trades[trade_idx] + tr_ts = pd.Timestamp(str(tr["ts"])) + if tr_ts > bar_close: + break + sym = str(tr["symbol"]).upper() + side = str(tr.get("side", "")) + order_krw = float(tr.get("order_krw") or 0.0) + order_coin = float(tr.get("order_coin") or 0.0) + price = float(tr.get("price") or 0.0) + if side == "buy" and order_krw > 0: + cash -= order_krw + qty[sym] = qty.get(sym, 0.0) + order_coin + elif side == "sell" and order_coin > 0: + proceeds = order_krw if order_krw > 0 else order_coin * price + cash += proceeds + qty[sym] = max(qty.get(sym, 0.0) - order_coin, 0.0) + eq = _portfolio_value(row) + curve.append({ + "time": _epoch_kst(tr_ts), + "value": round((eq / init - 1.0) * 100.0, 4), + }) + trade_idx += 1 + + eq = _portfolio_value(row) + curve.append({ + "time": _epoch_kst(bar_open), + "value": round((eq / init - 1.0) * 100.0, 4), + }) + + if curve: + curve[-1] = { + "time": curve[-1]["time"], + "value": round((float(current_equity) / init - 1.0) * 100.0, 4), + } + return _dedupe_time_series(curve) + + +def _summary_html(summary: dict[str, Any]) -> str: + """상단 요약 HTML.""" + lines = [ + f"KRW {summary.get('cash_krw', 0):,.0f}", + f"총평가 {summary.get('total_equity_krw', 0):,.0f}", + f"모드 {summary.get('mode', '-')}", + ] + for sym, row in (summary.get("symbols") or {}).items(): + pos = "롱" if row.get("in_position") else "플랫" + lines.append( + f"{sym} {pos} · qty {row.get('coin_qty', 0):.6f} · " + f"sig {row.get('last_signal', 0)} · next 15m {row.get('next_15m', '-')}" + ) + return " · ".join(lines) + + +def build_vol_monitor_payload( + settings: Settings, + state: dict[str, Any], + *, + tick_report: dict[str, Any] | None = None, +) -> dict[str, Any]: + """모니터 JSON 페이로드.""" + symbols = list(settings.ops_symbols) + days = float(settings.vol_monitor_days or 14) + sym_state = state.get("symbols") or {} + trades = list(state.get("trades") or []) + snap = state.get("portfolio_snapshot") or {} + cash = float(snap.get("cash_krw") or 0.0) + + symbol_blocks: dict[str, Any] = {} + symbol_summary: dict[str, Any] = {} + symbol_dfs: dict[str, pd.DataFrame] = {} + total_equity = cash + + for sym in symbols: + df = load_candles(settings.db_path, sym, INTERVAL_MIN, lookback_days=int(days) + 5) + df_closed = drop_incomplete_base_bar(df, INTERVAL_MIN) if not df.empty else df + symbol_dfs[sym.upper()] = df_closed + next_15m, sec_until = _next_15m_close(df_closed) + st = sym_state.get(sym.upper()) or sym_state.get(sym) or {} + qty = float((snap.get("positions") or {}).get(sym, 0) or 0.0) + price = float(df_closed["close"].iloc[-1]) if not df_closed.empty else 0.0 + holding = in_long_position( + {"positions": {sym: {"coin_qty": qty}}}, + sym, + dust_krw=settings.ops_min_order_krw, + price=price, + ) + if price > 0: + total_equity += qty * price + symbol_summary[sym] = { + "name": resolve_coin_name(sym), + "in_position": holding, + "coin_qty": qty, + "last_price": price, + "last_signal": st.get("last_signal", 0), + "last_confirm_time": st.get("last_confirm_time"), + "next_15m": next_15m, + "seconds_until_15m": sec_until, + } + symbol_blocks[sym] = { + "candles_15m": _candles_payload(df_closed, days=days), + "markers": _trade_markers(trades, sym), + } + + panel = _merged_close_panel(symbol_dfs, symbols, days=days) + seed_krw = max(total_equity, 1.0) + equity_strategy: list[dict[str, float | int]] = [] + equity_buyhold: list[dict[str, float | int]] = [] + if not panel.empty: + window_start = pd.Timestamp(panel["datetime"].iloc[0]) + equity_strategy = build_spot_strategy_equity_series( + panel, + symbols, + trades, + seed_krw=seed_krw, + current_equity=total_equity, + window_start=window_start, + ) + equity_buyhold = build_multi_buyhold_series(panel, symbols, seed_krw) + + summary = { + "mode": settings.ops_mode, + "cash_krw": round(cash, 0), + "total_equity_krw": round(total_equity, 0), + "equity_seed_krw": round(seed_krw, 0), + "equity_start_at": str(panel["datetime"].iloc[0])[:19] if not panel.empty else None, + "strategy_return_pct": equity_strategy[-1]["value"] if equity_strategy else 0.0, + "buyhold_return_pct": equity_buyhold[-1]["value"] if equity_buyhold else 0.0, + "strategy": state.get("strategy", "vol_breakout_15m_spot_long"), + "symbols": symbol_summary, + } + + return { + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "summary": summary, + "summary_html": _summary_html(summary), + "symbols": symbol_blocks, + "trades": trades[-100:], + "last_tick": tick_report or {}, + "ops_symbols": symbols, + "equity": { + "strategy": equity_strategy, + "buyhold": equity_buyhold, + "seed_krw": round(seed_krw, 0), + "label_strategy": "vol_breakout", + "label_buyhold": "B&H 1/3×3", + }, + } + + +def write_vol_monitor( + settings: Settings, + state: dict[str, Any], + *, + tick_report: dict[str, Any] | None = None, +) -> tuple[Path, Path]: + """JSON + HTML 갱신.""" + json_path = settings.vol_monitor_json + html_path = settings.vol_monitor_html + json_path.parent.mkdir(parents=True, exist_ok=True) + payload = build_vol_monitor_payload(settings, state, tick_report=tick_report) + _atomic_write_text( + json_path, + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + ) + write_vol_monitor_html(html_path) + return json_path, html_path + + +def _atomic_write_text(path: Path, content: str) -> None: + """원자적 텍스트 기록 — fetch 중 깨진 JSON 방지.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(content, encoding="utf-8") + tmp.replace(path) + + +def append_trade_record(state: dict[str, Any], record: dict[str, Any], *, max_trades: int = 500) -> None: + """체결 기록 append (차트 마커용).""" + state.setdefault("trades", []) + state["trades"].append(record) + if len(state["trades"]) > max_trades: + state["trades"] = state["trades"][-max_trades:] + + +def patch_vol_monitor_balance(json_path: Path, balance: dict[str, Any]) -> dict[str, Any]: + """기존 JSON summary 잔고만 패치.""" + if not json_path.is_file(): + return {"ok": False, "error": "json_missing"} + payload = json.loads(json_path.read_text(encoding="utf-8")) + summary = payload.setdefault("summary", {}) + summary["cash_krw"] = balance.get("cash_krw", summary.get("cash_krw")) + positions = balance.get("positions") or {} + sym_map = summary.setdefault("symbols", {}) + for sym, qty in positions.items(): + row = sym_map.setdefault(sym, {}) + row["coin_qty"] = float(qty) + payload["balance_updated_at"] = balance.get("updated_at") + _atomic_write_text( + json_path, + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + ) + return {"ok": True, "cash_krw": summary.get("cash_krw"), "positions": positions} + + +def fetch_live_balance_snapshot(settings: Settings) -> dict[str, Any]: + """거래소 KRW·코인 잔고 스냅샷.""" + from bithumb.api.bithumb_private import BithumbPrivateClient + + client = BithumbPrivateClient( + access_key=settings.bithumb_access_key, + secret_key=settings.bithumb_secret_key, + base_url=settings.api_url, + sleep_sec=settings.request_sleep_sec, + retries=settings.request_retries, + ) + krw, _ = client.get_balance("KRW") + positions: dict[str, float] = {} + total = float(krw) + for sym in settings.ops_symbols: + qty, _ = client.get_balance(sym) + positions[sym] = float(qty) + if qty > 0: + pass # price optional for total + return { + "ok": True, + "cash_krw": round(float(krw), 0), + "positions": positions, + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } diff --git a/src/bithumb/operations/vol_monitor_chart.py b/src/bithumb/operations/vol_monitor_chart.py new file mode 100644 index 0000000..a4f8700 --- /dev/null +++ b/src/bithumb/operations/vol_monitor_chart.py @@ -0,0 +1,317 @@ +"""Bithumb vol_breakout 멀티 종목 라이브 모니터 HTML 셸.""" + +from __future__ import annotations + +import json +from pathlib import Path + +_MONITOR_HTML = """ + + + + +[Bithumb] 라이브 모니터 + + + + +

+
+

Bithumb vol_breakout (15m spot long)

+ +
+
로딩 중…
+
+
+
+
+
+
+
+ + + +""" + + +def write_vol_monitor_html( + out_path: Path, + *, + json_filename: str = "/api/chart", + refresh_ms: int = 60_000, + balance_poll_ms: int = 5_000, +) -> Path: + """JSON fetch형 멀티 종목 모니터 HTML 기록.""" + json_url = json.dumps(json_filename) + html = ( + _MONITOR_HTML.replace("{json_url}", json_url) + .replace("{refresh_ms}", str(int(refresh_ms))) + .replace("{balance_poll_ms}", str(int(balance_poll_ms))) + .replace("{{", "{") + .replace("}}", "}") + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(html, encoding="utf-8") + return out_path diff --git a/src/bithumb/operations/watch_ops.py b/src/bithumb/operations/watch_ops.py new file mode 100644 index 0000000..6ff1ea3 --- /dev/null +++ b/src/bithumb/operations/watch_ops.py @@ -0,0 +1,507 @@ +"""read-only 감시 + 불일치 시 조치 (tick·loop 재시작).""" + +from __future__ import annotations + +import json +import logging +import os +import signal +import subprocess +import sys +import time +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from bithumb.config import Settings +from bithumb.notifications.telegram import TelegramNotifier, create_telegram_notifier +from bithumb.operations.exchange_reconcile import ( + _known_order_uuids, + _match_orders_to_signals, +) +from bithumb.api.bithumb_private import BithumbPrivateClient +from bithumb.operations.ops_lock import ops_tick_lock +from bithumb.operations.reconcile import inspect_ops_backlog +from bithumb.operations.runner import ( + OperationsRunner, + _history_index, + _is_settled, + _is_signal_api_executable, + _parse_signal_dt, +) +from bithumb.operations.state_store import load_state +from bithumb.operations.vol_breakout_engine import load_vol_state + +logger = logging.getLogger(__name__) + +VOL_BREAKOUT_STRATEGY = "vol_breakout_15m_spot_long" + + +@dataclass +class WatchIssue: + """감시에서 감지한 불일치.""" + + kind: str + severity: str + message: str + signal: dict[str, Any] | None = None + + +@dataclass +class WatchReport: + """감시 1회 결과.""" + + checked_at: str + issues: list[WatchIssue] = field(default_factory=list) + ledger_pending: int = 0 + executable_pending: int = 0 + tick_age_sec: float | None = None + loop_running: bool = False + + +@dataclass +class RemediationResult: + """조치 결과.""" + + actions: list[str] = field(default_factory=list) + messages: list[str] = field(default_factory=list) + tick_report: dict[str, Any] | None = None + + +def _parse_state_dt(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.strptime(str(value), "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + +def is_vol_breakout_ops(settings: Settings) -> bool: + """vol_breakout state가 있으면 fractal watch 대신 vol 감시.""" + path = settings.vol_state_json + if path is None or not path.exists(): + return False + try: + state = load_vol_state(path) + except (json.JSONDecodeError, OSError): + return False + strategy = str(state.get("strategy") or "") + return strategy == VOL_BREAKOUT_STRATEGY or bool(state.get("symbols")) + + +def _resolve_ops_python(root: Path) -> str: + """watch loop 기동용 python (cron과 동일 conda 우선).""" + env_py = os.environ.get("BITHUMB_PYTHON", "").strip() + if env_py and Path(env_py).is_file(): + return env_py + for candidate in ( + Path.home() / "opt/anaconda3/envs/coin/bin/python3", + Path.home() / "opt/anaconda3/envs/ncue/bin/python3", + Path.home() / "miniconda3/envs/xavis/bin/python3", + ): + if candidate.is_file(): + return str(candidate) + return sys.executable + + +def inspect_vol_watch(settings: Settings) -> WatchReport: + """vol_breakout cron tick 감시 (fractal backlog 미사용).""" + now = datetime.now() + state = load_vol_state(settings.vol_state_json) + last_completed = _parse_state_dt(state.get("last_run_at")) + tick_age_sec = (now - last_completed).total_seconds() if last_completed else None + + issues: list[WatchIssue] = [] + stale_sec = settings.ops_watch_tick_stale_min * 60 + if tick_age_sec is not None and tick_age_sec > stale_sec: + issues.append( + WatchIssue( + kind="tick_stale", + severity="critical", + message=( + f"vol tick {int(tick_age_sec // 60)}분 전 " + f"(임계 {settings.ops_watch_tick_stale_min}분)" + ), + ) + ) + + return WatchReport( + checked_at=now.strftime("%Y-%m-%d %H:%M:%S"), + issues=issues, + ledger_pending=0, + executable_pending=0, + tick_age_sec=tick_age_sec, + loop_running=True, + ) + + +def remediate_vol_watch( + settings: Settings, + report: WatchReport, + *, + dry_run: bool = False, +) -> RemediationResult: + """vol_breakout tick_stale 시 1회 tick (fractal loop 재시작 없음).""" + result = RemediationResult() + telegram = create_telegram_notifier( + settings.telegram_bot_token, + settings.telegram_chat_id, + enabled=settings.ops_telegram_enabled, + ) + + if not report.issues: + logger.info("vol watch OK — 조치 없음") + return result + + issue_lines = [f"- [{i.severity}] {i.message}" for i in report.issues] + result.messages.extend(issue_lines) + + if dry_run or not settings.ops_watch_auto_remediate: + _notify_watch( + telegram, + settings, + title="vol 불일치 (dry-run)", + lines=issue_lines + ["조치: 없음 (dry-run 또는 auto off)"], + ) + result.actions.append("dry_run") + return result + + needs_tick = any(i.kind == "tick_stale" for i in report.issues) + if needs_tick: + from bithumb.operations.vol_breakout_runner import VolBreakoutRunner + + runner = VolBreakoutRunner(settings) + tick_report = runner.tick() + result.tick_report = tick_report + result.actions.append("vol_remediation_tick") + result.messages.append( + f"vol tick 완료: fills={tick_report.get('fills', 0)} " + f"ok={tick_report.get('ok')}" + ) + + _notify_watch( + telegram, + settings, + title="vol 조치 완료" if result.actions else "vol 불일치 감지", + lines=result.messages, + ) + return result + + +def _is_loop_process_alive(pid_file: Path | None) -> bool: + """loop PID 파일 기준 프로세스 생존 여부.""" + if pid_file is None or not pid_file.exists(): + return False + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except ValueError: + return False + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def _stop_loop_process(pid_file: Path | None) -> bool: + """loop 프로세스 종료 (SIGTERM → SIGKILL).""" + if pid_file is None or not pid_file.exists(): + return False + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except ValueError: + return False + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.kill(pid, sig) + except ProcessLookupError: + return True + except OSError: + return False + time.sleep(3.0) + if not _is_loop_process_alive(pid_file): + return True + return not _is_loop_process_alive(pid_file) + + +def _start_loop_process(settings: Settings) -> int | None: + """loop 프로세스 백그라운드 기동.""" + cmd = settings.ops_watch_loop_start_cmd + if not cmd: + root = Path(__file__).resolve().parents[3] + python = _resolve_ops_python(root) + script = root / "scripts" / "3_run_operations.py" + log_path = settings.ops_log_json or root / "data/spot/operations/live_run.log" + cmd = ( + f"cd {root} && export PYTHONPATH=src && " + f"nohup {python} {script} --mode live --loop 180 " + f">> {log_path} 2>&1 & echo $!" + ) + proc = subprocess.run( + cmd, + shell=True, + check=False, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + logger.error("loop 시작 실패: %s", proc.stderr[:500]) + return None + tail = (proc.stdout or "").strip().splitlines() + if not tail: + return None + try: + return int(tail[-1].strip()) + except ValueError: + return None + + +def inspect_ops_watch(settings: Settings) -> WatchReport: + """최근 신호·history·tick 시각을 점검한다 (체결 없음).""" + now = datetime.now() + info = inspect_ops_backlog(settings) + state = info["state"] + merged = info["merged_pending"] + trade_history = state.get("trade_history") or [] + idx = _history_index(trade_history) + + last_completed = _parse_state_dt( + state.get("last_tick_completed_at") or state.get("last_run_at") + ) + tick_age_sec = (now - last_completed).total_seconds() if last_completed else None + + grace = timedelta(minutes=max(settings.ops_watch_signal_grace_min, 0)) + max_age = settings.ops_ledger_execute_max_age_minutes + live_since = ( + str(state["live_initialized_at"]) + if settings.ops_mode == "live" and state.get("live_initialized_at") + else None + ) + + lookback_start = now - timedelta(minutes=max(settings.ops_watch_lookback_min, 1)) + issues: list[WatchIssue] = [] + executable_pending = 0 + + for sig in merged: + sig_dt = _parse_signal_dt(str(sig["datetime"])) + if sig_dt < lookback_start: + continue + age = now - sig_dt + if age < grace: + continue + + executable, skip_reason = _is_signal_api_executable( + sig, + max_age_minutes=max_age, + live_since=live_since, + now=now, + ) + key = (str(sig["datetime"]), str(sig.get("side"))) + rec = idx.get(key) + trade = (rec or {}).get("trade") or {} + + if not executable: + continue + + executable_pending += 1 + if rec is None: + issues.append( + WatchIssue( + kind="miss_pending", + severity="critical", + message=( + f"신호 미기록·미체결 {sig['datetime']} {sig['side']} " + f"(경과 {int(age.total_seconds() // 60)}분)" + ), + signal=sig, + ) + ) + elif not _is_settled(trade): + issues.append( + WatchIssue( + kind="failed_retry", + severity="warning", + message=( + f"체결 실패·미정산 {sig['datetime']} {sig['side']} " + f"사유={(trade.get('skip_reason') or '')[:60]}" + ), + signal=sig, + ) + ) + + stale_sec = settings.ops_watch_tick_stale_min * 60 + if tick_age_sec is not None and tick_age_sec > stale_sec: + issues.append( + WatchIssue( + kind="tick_stale", + severity="critical", + message=( + f"loop tick {int(tick_age_sec // 60)}분 전 " + f"(임계 {settings.ops_watch_tick_stale_min}분)" + ), + ) + ) + + if settings.ops_mode == "live" and settings.ops_exchange_reconcile: + try: + client = BithumbPrivateClient( + access_key=settings.bithumb_access_key, + secret_key=settings.bithumb_secret_key, + base_url=settings.api_url, + sleep_sec=settings.request_sleep_sec, + retries=settings.request_retries, + ) + since = now - timedelta(hours=settings.ops_exchange_order_lookback_hours) + orders = client.fetch_filled_orders_since(settings.market, since) + known = _known_order_uuids(trade_history) + recent_pending = [ + s + for s in merged + if _parse_signal_dt(str(s["datetime"])) >= lookback_start + ] + matches = _match_orders_to_signals( + orders, + recent_pending, + match_window_min=settings.ops_exchange_match_window_min, + known_uuids=set(known), + ) + for sig, order in matches: + key = (str(sig["datetime"]), str(sig.get("side"))) + rec = idx.get(key) + if rec is not None and _is_settled((rec.get("trade") or {})): + continue + issues.append( + WatchIssue( + kind="exchange_unreconciled", + severity="warning", + message=( + f"거래소 체결·원장 불일치 {sig['datetime']} {sig['side']} " + f"uuid={order.get('uuid')}" + ), + signal=sig, + ) + ) + except Exception: + logger.exception("watch 거래소 조회 실패") + + loop_running = _is_loop_process_alive(settings.ops_loop_pid_file) + return WatchReport( + checked_at=now.strftime("%Y-%m-%d %H:%M:%S"), + issues=issues, + ledger_pending=len(merged), + executable_pending=executable_pending, + tick_age_sec=tick_age_sec, + loop_running=loop_running, + ) + + +def _notify_watch( + telegram: TelegramNotifier, + settings: Settings, + *, + title: str, + lines: list[str], +) -> None: + """감시·조치 알림.""" + if not telegram.is_active: + return + mode_label = "LIVE" if settings.ops_mode == "live" else "PAPER" + body = "\n".join([f"[Bithumb WATCH] {title} ({mode_label})", *lines]) + telegram.send_message(body) + + +def remediate_ops_watch( + settings: Settings, + report: WatchReport, + *, + dry_run: bool = False, +) -> RemediationResult: + """불일치 유형별 조치 (lock 공유 tick / loop 재시작).""" + result = RemediationResult() + telegram = create_telegram_notifier( + settings.telegram_bot_token, + settings.telegram_chat_id, + enabled=settings.ops_telegram_enabled, + ) + + if not report.issues: + logger.info("watch OK — 조치 없음") + return result + + actionable_kinds = { + "miss_pending", + "failed_retry", + "exchange_unreconciled", + } + needs_tick = any(i.kind in actionable_kinds for i in report.issues) + needs_restart = any(i.kind == "tick_stale" for i in report.issues) + + issue_lines = [f"- [{i.severity}] {i.message}" for i in report.issues] + result.messages.extend(issue_lines) + + if dry_run or not settings.ops_watch_auto_remediate: + _notify_watch( + telegram, + settings, + title="불일치 감지 (dry-run)", + lines=issue_lines + ["조치: 없음 (dry-run 또는 auto off)"], + ) + result.actions.append("dry_run") + return result + + restarted = False + if needs_restart and settings.ops_watch_auto_restart and not is_vol_breakout_ops(settings): + if report.loop_running and settings.ops_loop_pid_file: + stopped = _stop_loop_process(settings.ops_loop_pid_file) + result.actions.append("stop_loop" if stopped else "stop_loop_failed") + pid = _start_loop_process(settings) + if pid and settings.ops_loop_pid_file: + settings.ops_loop_pid_file.parent.mkdir(parents=True, exist_ok=True) + settings.ops_loop_pid_file.write_text(f"{pid}\n", encoding="utf-8") + restarted = True + result.actions.append(f"start_loop pid={pid}") + result.messages.append(f"loop 재시작 pid={pid}") + + if needs_tick and settings.ops_tick_lock_path: + with ops_tick_lock(settings.ops_tick_lock_path, blocking=False) as acquired: + if not acquired: + result.actions.append("tick_skipped_lock_busy") + result.messages.append("tick lock busy — loop 처리 중, 다음 주기 재확인") + if not restarted: + _notify_watch( + telegram, + settings, + title="불일치 (lock busy)", + lines=result.messages, + ) + return result + runner = OperationsRunner(settings) + tick_report = runner.tick(sync_candles=True, skip_lock=True) + result.tick_report = tick_report + result.actions.append("remediation_tick") + exec_count = len(tick_report.get("executions") or []) + reconciled = tick_report.get("exchange_reconciled_count", 0) + result.messages.append( + f"조치 tick 완료: 체결 {exec_count}건, exchange_reconcile {reconciled}건" + ) + for rec in tick_report.get("executions") or []: + t = rec.get("trade") or {} + if t.get("executed"): + result.messages.append( + f" 체결: {rec.get('datetime')} {rec.get('side')}" + ) + elif t.get("expected_skip"): + result.messages.append( + f" skip: {rec.get('datetime')} {rec.get('side')} " + f"{(t.get('skip_reason') or '')[:50]}" + ) + elif needs_tick: + runner = OperationsRunner(settings) + result.tick_report = runner.tick(sync_candles=True) + result.actions.append("remediation_tick") + + title = "조치 완료" if result.actions else "불일치 감지" + _notify_watch(telegram, settings, title=title, lines=result.messages) + return result diff --git a/src/bithumb/simulation/vol_breakout.py b/src/bithumb/simulation/vol_breakout.py new file mode 100644 index 0000000..1d21fa2 --- /dev/null +++ b/src/bithumb/simulation/vol_breakout.py @@ -0,0 +1,96 @@ +"""15m ATR vol_breakout — Binance causal_hunt 규칙 현물용 이식.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + + +def compute_atr( + high: np.ndarray, + low: np.ndarray, + close: np.ndarray, + lookback: int, +) -> np.ndarray: + """TR 롤링 평균 ATR.""" + tr = np.maximum( + high - low, + np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1))), + ) + tr[0] = high[0] - low[0] + return pd.Series(tr).rolling(lookback, min_periods=lookback).mean().to_numpy() + + +def floor_period_ts(ts: pd.Timestamp, interval_min: int) -> pd.Timestamp: + """봉 시작 시각.""" + ts = pd.Timestamp(ts) + minute = (ts.minute // interval_min) * interval_min + return ts.replace(minute=minute, second=0, microsecond=0, nanosecond=0) + + +def drop_incomplete_base_bar(df: pd.DataFrame, interval_min: int) -> pd.DataFrame: + """미마감 마지막 봉 제거.""" + if df.empty: + return df + out = df.copy() + out["datetime"] = pd.to_datetime(out["datetime"]) + last_start = floor_period_ts(out["datetime"].iloc[-1], interval_min) + last_end = last_start + pd.Timedelta(minutes=interval_min) + now = out["datetime"].iloc[-1] + if pd.Timestamp(now) < last_end: + out = out[out["datetime"] < last_start].reset_index(drop=True) + return out + + +def baseline_15m_signal_at( + df15_closed: pd.DataFrame, + bar_idx: int, + *, + lookback: int, + atr_mult: float, +) -> int: + """지정 마감 15m 봉 vol_breakout 신호 (-1/0/+1).""" + if bar_idx < lookback + 1 or bar_idx >= len(df15_closed): + return 0 + c = df15_closed["close"].to_numpy(float) + h = df15_closed["high"].to_numpy(float) + l = df15_closed["low"].to_numpy(float) + atr = compute_atr(h, l, c, lookback) + i = bar_idx + if np.isnan(atr[i - 1]): + return 0 + ref = c[i - 1] + thr = atr[i - 1] * atr_mult + if c[i] > ref + thr: + return 1 + if c[i] < ref - thr: + return -1 + return 0 + + +def pending_baseline_bar_indices( + df15_closed: pd.DataFrame, + last_confirm_time: str | None, +) -> list[int]: + """last_confirm_time 이후 미처리 마감 15m 봉 인덱스.""" + if df15_closed.empty: + return [] + dts = pd.to_datetime(df15_closed["datetime"]) + if not last_confirm_time: + return [len(dts) - 1] + cutoff = pd.Timestamp(str(last_confirm_time)[:19]) + return [i for i, t in enumerate(dts) if pd.Timestamp(t) > cutoff] + + +def spot_long_action(raw_sig: int, in_position: bool) -> str | None: + """선물 flip 신호를 현물 롱 전용 buy/sell로 변환. + + +1: flat → buy + -1: long → sell (청산) + 그 외: None + """ + if raw_sig == 1 and not in_position: + return "buy" + if raw_sig == -1 and in_position: + return "sell" + return None diff --git a/tests/test_candle_bars.py b/tests/test_candle_bars.py new file mode 100644 index 0000000..758bae3 --- /dev/null +++ b/tests/test_candle_bars.py @@ -0,0 +1,73 @@ +"""캔들 봉 마감·다운로드 필터.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from bithumb.data.candle_bars import ( + is_closed_candle, + last_closed_bar_open, + only_closed_candles, +) + + +def test_is_closed_candle_15m() -> None: + """15분봉은 마감 시각 이후에만 closed.""" + bar_open = datetime(2026, 6, 27, 16, 15, 0) + assert not is_closed_candle( + bar_open, 15, now=datetime(2026, 6, 27, 16, 29, 59) + ) + assert is_closed_candle( + bar_open, 15, now=datetime(2026, 6, 27, 16, 30, 0) + ) + + +def test_only_closed_candles_filters_forming_bar() -> None: + """API 배치에서 진행 중 봉은 제외.""" + rows = [ + ("2026-06-27 16:00:00", 100.0, 110.0, 95.0, 105.0, 1.0), + ("2026-06-27 16:15:00", 105.0, 105.0, 105.0, 105.0, 0.1), + ] + now = datetime(2026, 6, 27, 16, 20, 0) + closed = only_closed_candles(rows, 15, now=now) + assert len(closed) == 1 + assert closed[0][0] == "2026-06-27 16:00:00" + + +def test_last_closed_bar_open() -> None: + """진행 중인 16:15~16:30 봉 기준 최근 마감은 16:00.""" + now = datetime(2026, 6, 27, 16, 20, 0) + assert last_closed_bar_open(now, 15) == datetime(2026, 6, 27, 16, 0, 0) + + now = datetime(2026, 6, 27, 16, 30, 0) + assert last_closed_bar_open(now, 15) == datetime(2026, 6, 27, 16, 15, 0) + + +def test_only_closed_candles_keeps_all_historical() -> None: + """과거 봉은 모두 마감으로 간주.""" + rows = [ + ("2026-06-26 20:00:00", 1.0, 2.0, 0.5, 1.5, 10.0), + ("2026-06-26 20:15:00", 1.5, 2.5, 1.0, 2.0, 8.0), + ] + now = datetime(2026, 6, 27, 22, 0, 0) + assert len(only_closed_candles(rows, 15, now=now)) == 2 + + +def test_delete_incomplete_tail(tmp_path) -> None: + """미마감 최신 봉이 DB에서 제거된다.""" + from bithumb.data.candle_store import CandleStore + + db = tmp_path / "t.db" + store = CandleStore(db) + rows = [ + ("2026-06-27 16:00:00", 100.0, 110.0, 95.0, 105.0, 1.0), + ("2026-06-27 16:15:00", 105.0, 105.0, 105.0, 105.0, 0.1), + ] + store.upsert_rows("NEAR", "NEAR", 15, rows) + deleted = store.delete_incomplete_tail( + "NEAR", 15, now=datetime(2026, 6, 27, 16, 20, 0) + ) + assert deleted == 1 + _, _, db_max = store.get_range("NEAR", 15) + assert db_max == datetime(2026, 6, 27, 16, 0, 0) + store.close() diff --git a/tests/test_exchange_reconcile.py b/tests/test_exchange_reconcile.py new file mode 100644 index 0000000..185223f --- /dev/null +++ b/tests/test_exchange_reconcile.py @@ -0,0 +1,77 @@ +"""exchange reconcile 단위 테스트.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from bithumb.operations.exchange_reconcile import ( + _match_orders_to_signals, + _trade_from_exchange_order, +) + + +def test_match_orders_to_signals_by_side_and_time() -> None: + """같은 side·시간 창 안에서 주문-신호 1:1 매칭.""" + sig_dt = datetime(2026, 6, 14, 10, 48, 0) + signals = [ + {"datetime": sig_dt.strftime("%Y-%m-%d %H:%M:%S"), "side": "buy", "bar_index": 1}, + ] + order_dt = sig_dt + timedelta(minutes=30) + orders = [ + { + "uuid": "order-1", + "side": "bid", + "created_at": order_dt.isoformat(), + "executed_volume": "0.001", + "executed_funds": "100000", + }, + ] + matches = _match_orders_to_signals( + orders, + signals, + match_window_min=720, + known_uuids=set(), + ) + assert len(matches) == 1 + assert matches[0][0]["side"] == "buy" + assert matches[0][1]["uuid"] == "order-1" + + +def test_match_skips_known_uuid() -> None: + """이미 history에 있는 uuid는 재매칭하지 않는다.""" + sig_dt = datetime(2026, 6, 14, 11, 0, 0) + signals = [ + {"datetime": sig_dt.strftime("%Y-%m-%d %H:%M:%S"), "side": "sell", "bar_index": 2}, + ] + orders = [ + { + "uuid": "already-used", + "side": "ask", + "created_at": (sig_dt + timedelta(minutes=5)).isoformat(), + "executed_volume": "0.001", + "executed_funds": "100000", + }, + ] + matches = _match_orders_to_signals( + orders, + signals, + match_window_min=720, + known_uuids={"already-used"}, + ) + assert matches == [] + + +def test_trade_from_exchange_order_buy() -> None: + """매수 체결 → executed TradeResult.""" + sig = {"datetime": "2026-06-14 10:48:00", "side": "buy", "price": 140_000_000.0} + order = { + "uuid": "x", + "side": "bid", + "executed_volume": "0.002", + "executed_funds": "280000", + } + trade = _trade_from_exchange_order(sig, order) + assert trade.executed is True + assert trade.side == "buy" + assert trade.order_coin == 0.002 + assert trade.api_response == order diff --git a/tests/test_ops_ledger.py b/tests/test_ops_ledger.py new file mode 100644 index 0000000..90edbe3 --- /dev/null +++ b/tests/test_ops_ledger.py @@ -0,0 +1,143 @@ +"""ledger pending 단위 테스트.""" + +from __future__ import annotations + +from bithumb.operations.runner import ( + _advance_cursor_from_ledger, + _apply_backlog_limit, + _is_settled, + _is_signal_api_executable, + _ledger_pending_signals, + _merge_pending_signals, + _settle_expired_backlog, +) +from datetime import datetime + + +def test_is_settled_executed_or_expected_skip() -> None: + """executed 또는 expected_skip이면 settled.""" + assert _is_settled({"executed": True, "expected_skip": False}) + assert _is_settled({"executed": False, "expected_skip": True}) + assert not _is_settled({"executed": False, "expected_skip": False}) + + +def test_ledger_pending_skips_settled_and_includes_missing() -> None: + """history settled 신호는 제외, 미기록 신호는 pending.""" + kept = [ + {"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 10}, + {"datetime": "2026-06-14 14:15:00", "side": "sell", "bar_index": 11}, + {"datetime": "2026-06-14 14:51:00", "side": "sell", "bar_index": 12}, + ] + history = [ + { + "datetime": "2026-06-14 14:09:00", + "side": "buy", + "trade": {"executed": True, "expected_skip": False}, + }, + { + "datetime": "2026-06-14 14:51:00", + "side": "sell", + "trade": {"executed": False, "expected_skip": True}, + }, + ] + pending = _ledger_pending_signals( + kept, + history, + latest_bar_index=12, + lookback_days=3, + ) + assert len(pending) == 1 + assert pending[0]["datetime"] == "2026-06-14 14:15:00" + + +def test_ledger_pending_includes_failed_api() -> None: + """API 실패(expected_skip false)는 재시도 대상.""" + kept = [ + {"datetime": "2026-06-14 15:00:00", "side": "buy", "bar_index": 20}, + ] + history = [ + { + "datetime": "2026-06-14 15:00:00", + "side": "buy", + "trade": {"executed": False, "expected_skip": False}, + }, + ] + pending = _ledger_pending_signals( + kept, + history, + latest_bar_index=20, + lookback_days=3, + ) + assert len(pending) == 1 + + +def test_apply_backlog_limit() -> None: + """tick당 backlog 상한.""" + signals = [ + {"datetime": f"2026-06-14 10:{i:02d}:00", "side": "buy", "bar_index": i} + for i in range(5) + ] + limited, dropped = _apply_backlog_limit(signals, 2) + assert len(limited) == 2 + assert dropped == 3 + + +def test_advance_cursor_from_ledger() -> None: + """settled 신호까지만 커서 전진.""" + kept = [ + {"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 10}, + {"datetime": "2026-06-14 14:15:00", "side": "sell", "bar_index": 11}, + ] + history = [ + { + "datetime": "2026-06-14 14:09:00", + "side": "buy", + "trade": {"executed": True}, + }, + ] + state: dict = { + "last_processed_datetime": "2026-06-14 13:00:00", + "last_processed_bar_index": 5, + } + _advance_cursor_from_ledger(state, kept, history) + assert state["last_processed_datetime"] == "2026-06-14 14:09:00" + assert state["last_processed_bar_index"] == 10 + + +def test_settle_expired_backlog() -> None: + """만료 backlog는 API 없이 expected_skip 정산.""" + from datetime import timedelta + + now = datetime.now() + old_dt = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S") + kept = [{"datetime": old_dt, "side": "buy", "bar_index": 1, "price": 1.0}] + settled = _settle_expired_backlog( + kept, + [], + max_age_minutes=45, + live_since=None, + ) + assert len(settled) == 1 + assert settled[0]["trade"]["expected_skip"] is True + assert "backlog 만료" in settled[0]["trade"]["skip_reason"] + + +def test_is_signal_api_executable_recent() -> None: + """최근 신호는 API 체결 가능.""" + from datetime import timedelta + + recent = (datetime.now() - timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S") + ok, _ = _is_signal_api_executable( + {"datetime": recent, "side": "buy"}, + max_age_minutes=45, + live_since=None, + ) + assert ok + + +def test_merge_pending_dedupes() -> None: + """ledger·catchup 병합 시 datetime·side 중복 제거.""" + a = [{"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 1}] + b = [{"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 1}] + merged = _merge_pending_signals(a, b) + assert len(merged) == 1 diff --git a/tests/test_vol_breakout.py b/tests/test_vol_breakout.py new file mode 100644 index 0000000..8b61fbf --- /dev/null +++ b/tests/test_vol_breakout.py @@ -0,0 +1,46 @@ +"""vol_breakout 현물 롱 단위 테스트.""" + +from __future__ import annotations + +import pandas as pd + +from bithumb.operations.multi_portfolio import count_empty_buy_slots, empty_multi_portfolio +from bithumb.simulation.vol_breakout import ( + baseline_15m_signal_at, + spot_long_action, +) + + +def test_spot_long_action_buy_only_when_flat() -> None: + assert spot_long_action(1, False) == "buy" + assert spot_long_action(1, True) is None + + +def test_spot_long_action_sell_only_when_long() -> None: + assert spot_long_action(-1, True) == "sell" + assert spot_long_action(-1, False) is None + + +def test_baseline_signal_breakout() -> None: + n = 30 + closes = [100.0] * n + closes[-1] = 120.0 + df = pd.DataFrame({ + "datetime": pd.date_range("2026-01-01", periods=n, freq="15min"), + "open": closes, + "high": [c + 1 for c in closes], + "low": [c - 1 for c in closes], + "close": closes, + "volume": [1.0] * n, + }) + sig = baseline_15m_signal_at(df, n - 1, lookback=5, atr_mult=0.01) + assert sig == 1 + + +def test_empty_buy_slots_dynamic_split() -> None: + pf = empty_multi_portfolio(["TRX", "NEAR", "WLD"], cash_krw=900_000) + assert count_empty_buy_slots(pf, ["TRX", "NEAR", "WLD"]) == 3 + pf["positions"]["TRX"]["coin_qty"] = 100.0 + assert count_empty_buy_slots(pf, ["TRX", "NEAR", "WLD"]) == 2 + pf["positions"]["NEAR"]["coin_qty"] = 10.0 + assert count_empty_buy_slots(pf, ["TRX", "NEAR", "WLD"]) == 1 diff --git a/tests/test_vol_breakout_telegram.py b/tests/test_vol_breakout_telegram.py new file mode 100644 index 0000000..d6504ff --- /dev/null +++ b/tests/test_vol_breakout_telegram.py @@ -0,0 +1,66 @@ +"""vol_breakout 텔레그램 알림 포맷.""" + +from bithumb.notifications.telegram import TelegramNotifier + + +def test_notify_vol_breakout_buy_message(monkeypatch) -> None: + """매수 알림이 Binance 스타일 형식인지 확인.""" + sent: list[str] = [] + + def _capture(text: str) -> bool: + sent.append(text) + return True + + n = TelegramNotifier("token", "123", enabled=True) + monkeypatch.setattr(n, "send_message", _capture) + + n.notify_vol_breakout_trade( + mode="live", + symbol="TRX", + side="buy", + price=489.24, + order_krw=1_187_000, + order_coin=2.426929, + equity_krw=500_000, + ts="2026-06-27 19:30:05", + ) + + assert len(sent) == 1 + text = sent[0] + assert "[실거래] 롱 진입(매수)" in text + assert "TRXKRW @ 489.24" in text + assert "수량 2.426929" in text + assert "≈1,187,000원" in text + assert "사유 signal_vol_breakout" in text + assert "시각 2026-06-27 19:30:05" in text + + +def test_notify_vol_breakout_sell_message(monkeypatch) -> None: + """매도 알림에 손익·자본이 포함되는지 확인.""" + sent: list[str] = [] + + def _capture(text: str) -> bool: + sent.append(text) + return True + + n = TelegramNotifier("token", "123", enabled=True) + monkeypatch.setattr(n, "send_message", _capture) + + n.notify_vol_breakout_trade( + mode="live", + symbol="TRX", + side="sell", + price=486.76, + order_krw=1_180_000, + order_coin=2.426929, + equity_krw=499_691, + pnl_krw=-7_000, + pnl_pct=-0.590, + ts="2026-06-27 19:45:05", + ) + + text = sent[0] + assert "[실거래] 롱 청산(매도)" in text + assert "TRXKRW @ 486.76" in text + assert "손익 -7,000원 (-0.590%)" in text + assert "자본 499,691원" in text diff --git a/tests/test_vol_live_monitor.py b/tests/test_vol_live_monitor.py new file mode 100644 index 0000000..ea3cdda --- /dev/null +++ b/tests/test_vol_live_monitor.py @@ -0,0 +1,78 @@ +"""vol_live 모니터 에쿼티·B&H 테스트.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pandas as pd +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from bithumb.operations.vol_live_monitor import ( + build_multi_buyhold_series, + build_spot_strategy_equity_series, +) + + +def test_multi_buyhold_thirds() -> None: + """3종목 1/3씩 B&H — 한 종목만 10% 상승 시 포트폴리오 +3.33% 근사.""" + panel = pd.DataFrame({ + "datetime": pd.to_datetime(["2026-06-01 10:00:00", "2026-06-01 10:15:00"]), + "TRX": [100.0, 110.0], + "NEAR": [200.0, 200.0], + "WLD": [300.0, 300.0], + }) + bh = build_multi_buyhold_series(panel, ["TRX", "NEAR", "WLD"], seed_krw=300_000.0) + assert bh[0]["value"] == 0.0 + assert bh[1]["value"] == pytest.approx(3.3333, rel=1e-3) + + +def test_strategy_replay_buy_sell() -> None: + panel = pd.DataFrame({ + "datetime": pd.to_datetime(["2026-06-01 10:00:00", "2026-06-01 10:15:00"]), + "TRX": [100.0, 110.0], + "NEAR": [200.0, 200.0], + "WLD": [300.0, 300.0], + }) + trades = [ + { + "symbol": "TRX", + "side": "buy", + "ts": "2026-06-01 10:05:00", + "order_krw": 100_000.0, + "order_coin": 1000.0, + "price": 100.0, + }, + ] + curve = build_spot_strategy_equity_series( + panel, + ["TRX", "NEAR", "WLD"], + trades, + seed_krw=300_000.0, + current_equity=310_000.0, + window_start=pd.Timestamp("2026-06-01 10:00:00"), + ) + assert curve[0]["value"] == 0.0 + assert curve[-1]["value"] == pytest.approx(3.3333, rel=1e-2) + + +def test_write_vol_monitor_html_no_format_error(tmp_path: Path) -> None: + import re + + from bithumb.operations.vol_monitor_chart import write_vol_monitor_html + + out = tmp_path / "vol_live_monitor.html" + write_vol_monitor_html(out) + text = out.read_text(encoding="utf-8") + assert "/api/chart" in text + assert "equityChart" in text + js = re.search(r"", text, re.S) + assert js is not None + js_path = tmp_path / "monitor.js" + js_path.write_text(js.group(1), encoding="utf-8") + import subprocess + + subprocess.run(["node", "--check", str(js_path)], check=True, capture_output=True) diff --git a/tests/test_watch_ops.py b/tests/test_watch_ops.py new file mode 100644 index 0000000..38d7145 --- /dev/null +++ b/tests/test_watch_ops.py @@ -0,0 +1,42 @@ +"""watch_ops 단위 테스트.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from bithumb.operations.watch_ops import WatchIssue, WatchReport, remediate_ops_watch + + +class _FakeSettings: + ops_mode = "live" + ops_telegram_enabled = False + telegram_bot_token = "" + telegram_chat_id = "" + ops_watch_auto_remediate = True + ops_watch_auto_restart = False + ops_tick_lock_path = None + ops_loop_pid_file = None + + +def test_remediate_dry_run_no_actions() -> None: + """dry-run은 tick/재시작 없음.""" + report = WatchReport( + checked_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + issues=[ + WatchIssue( + kind="miss_pending", + severity="critical", + message="test", + ) + ], + ) + result = remediate_ops_watch(_FakeSettings(), report, dry_run=True) + assert "dry_run" in result.actions + assert result.tick_report is None + + +def test_remediate_ok_when_no_issues() -> None: + """이슈 없으면 조치 없음.""" + report = WatchReport(checked_at="2026-06-14 20:00:00") + result = remediate_ops_watch(_FakeSettings(), report, dry_run=False) + assert result.actions == [] diff --git a/tests/test_watch_vol_ops.py b/tests/test_watch_vol_ops.py new file mode 100644 index 0000000..6def450 --- /dev/null +++ b/tests/test_watch_vol_ops.py @@ -0,0 +1,72 @@ +"""watch_ops vol_breakout 분기.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +from bithumb.operations.watch_ops import ( + WatchIssue, + WatchReport, + inspect_vol_watch, + is_vol_breakout_ops, + remediate_vol_watch, +) + + +def test_is_vol_breakout_ops(tmp_path: Path) -> None: + """vol state 파일이 있으면 vol 감시 모드.""" + state_path = tmp_path / "vol_breakout_state.json" + state_path.write_text( + json.dumps({"strategy": "vol_breakout_15m_spot_long", "symbols": {}}), + encoding="utf-8", + ) + settings = SimpleNamespace(vol_state_json=state_path) + assert is_vol_breakout_ops(settings) is True + + +def test_is_vol_breakout_ops_missing_file(tmp_path: Path) -> None: + """state 없으면 fractal 감시.""" + settings = SimpleNamespace(vol_state_json=tmp_path / "missing.json") + assert is_vol_breakout_ops(settings) is False + + +def test_inspect_vol_watch_recent_tick(tmp_path: Path) -> None: + """최근 tick이면 이슈 없음.""" + state_path = tmp_path / "vol.json" + state_path.write_text( + json.dumps({ + "strategy": "vol_breakout_15m_spot_long", + "symbols": {}, + "last_run_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }), + encoding="utf-8", + ) + settings = SimpleNamespace( + vol_state_json=state_path, + ops_watch_tick_stale_min=12, + ) + report = inspect_vol_watch(settings) + assert report.issues == [] + + +def test_remediate_vol_dry_run() -> None: + """vol dry-run은 tick 없음.""" + report = WatchReport( + checked_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + issues=[ + WatchIssue(kind="tick_stale", severity="critical", message="stale"), + ], + ) + settings = SimpleNamespace( + ops_telegram_enabled=False, + telegram_bot_token="", + telegram_chat_id="", + ops_watch_auto_remediate=True, + ops_mode="live", + ) + result = remediate_vol_watch(settings, report, dry_run=True) + assert "dry_run" in result.actions + assert result.tick_report is None