refactor: DeepCoin 1·2단계 파이프라인으로 구조 재편

레거시 분석·매칭·운영 코드를 정리하고 src/deepcoin 기반으로 재구성한다.
1단계 GT는 2년 스윙·눌림목·돌파·다이버전스 타점을 차트에 표시하고,
2단계는 8개 매매 기법과 GT 정합 평가 스크립트를 추가한다.
.env와 GT JSON 산출물은 추적에서 제외한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-06-08 23:51:26 +09:00
parent 51f70076fb
commit df3c9aecb9
154 changed files with 4629 additions and 215122 deletions

183
.env
View File

@@ -1,183 +0,0 @@
# DeepCoin 로컬 설정 (Git 제외). 설정 변경은 이 파일만 수정하세요.
# --- 빗썸 API ---
BITHUMB_ACCESS_KEY=d3e9676ef70b91df515eff02f5d0c7009ce390ce455aea
BITHUMB_SECRET_KEY=M2E4ZWQxYmQ3YThhYzE5YjYxNjhmMzBiOWZkMjI0MmVmOTBkZmRkNWMxMDE5N2VmOTVjODQyNTJlNmU1NQ==
BITHUMB_API_URL=https://api.bithumb.com
BITHUMB_API_CANDLE_COUNT=200
BITHUMB_MINUTE_INTERVALS=1,3,5,10,15,30,60,240
HTS_API_RETRY_SLEEP_SEC=0.5
# --- 텔레그램 (선택, 알림 미사용 시 비워도 됨) ---
COIN_TELEGRAM_BOT_TOKEN=6435061393:AAHOh9wB5yGNGUdb3SfCYJrrWTBe7wgConM
COIN_TELEGRAM_CHAT_ID=574661323
# --- 거래 대상 ---
SYMBOL=WLD
COIN_NAME=월드코인
# --- 경로 ---
DB_PATH=data/coins.db
GROUND_TRUTH_FILE=data/ground_truth/ground_truth_trades.json
# --- 타임프레임 (분) ---
DAILY_INTERVAL_MIN=1440
ENTRY_INTERVAL=3
TREND_INTERVAL_1H=60
TREND_INTERVAL_1D=1440
ALL_INTERVALS=3,5,10,15,30,60,240,1440
DOWNLOAD_INTERVALS=3,5,10,15,30,60,240,1440
GENERAL_ANALYSIS_INTERVALS=3,5,10,15,30,60,240,1440
TIMING_INTERVALS=3,5,10,15
TREND_INTERVALS=60,240,1440
INTERVAL_PREFIX=1:m1,3:m3,5:m5,10:m10,15:m15,30:m30,60:m60,240:m240,1440:d1
# --- 볼린저 / RSI ---
BB_PERIOD=20
BB_STD=2
BB_MIN_WIDTH_PCT=0.8
RSI_PERIOD=14
DISPARITY_PERIODS=5,20,60
DISPARITY_OVERBOUGHT=105
DISPARITY_OVERSOLD=95
MACD_FAST=12
MACD_SLOW=26
MACD_SIGNAL=9
STOCH_K_PERIOD=14
STOCH_D_PERIOD=3
STOCH_SMOOTH_K=3
STOCH_OVERSOLD=20
STOCH_OVERBOUGHT=80
TREND_RANGE_MA_GAP_PCT=0.5
# --- MTF 정렬 ---
ALIGN_RSI_OVERSOLD=35
ALIGN_RSI_OVERBOUGHT=65
ALIGN_RSI_CONFLICT_TIMING_LOW=40
ALIGN_RSI_CONFLICT_TIMING_HIGH=65
ALIGN_RSI_CONFLICT_TREND_LOW=40
ALIGN_RSI_CONFLICT_TREND_HIGH=65
ALIGN_BB_POS_LOW=0.2
ALIGN_BB_POS_HIGH=0.8
# --- 다운로드 / DB ---
DOWNLOAD_MONTHS=12
DOWNLOAD_MONTHS_1M=6
INCREMENTAL_OVERLAP_BARS=3
DOWNLOAD_BACKFILL_EXTRA_BARS=200
DOWNLOAD_MIN_INCREMENTAL_BARS=50
DOWNLOAD_DAILY_EXTRA_DAYS=20
CHART_LOOKBACK_DAYS=365
DB_READ_LIMIT_DEFAULT=7000
DB_ROW_WARMUP_BARS=200
DB_ROW_MIN_DAILY_BARS=100
DB_ROW_DAILY_PADDING_DAYS=30
# --- Ground Truth --_MIN_LEG_PCT=8.0
GT_BUY_MIN_SWING_PCT=3.0
GT_BUY_BB_MAX=0.45
GT_BUY_MIN_BARS=24
GT_MAX_BUYS_PER_LEG=12
GT_MAX_SELLS_PER_LEG=2
GT_SELL_SPLIT_GAP_PCT=2.5
GT_MARKER_SIZE_MIN=10
GT_MARKER_SIZE_MAX=32
GT_INITIAL_CASH_KRW=400000
TRADING_FEE_RATE=0.0005
GT_UNLIMITED_CHRONOLOGICAL_DAYS=300
# --- 모니터 ---
MONITOR_LOOP_SLEEP_SEC=180
MONITOR_POOL_WORKERS=12
MONITOR_DEFAULT_INTERVAL=60
MONITOR_API_RETRIES=3
MONITOR_API_BONG_COUNT=3000
MONITOR_SLEEP_AFTER_REQUEST_SEC=0.5
MONITOR_SLEEP_RATE_LIMIT_SEC=5
MONITOR_SLEEP_BETWEEN_CHUNKS_SEC=0.3
MONITOR_API_CHUNK_BARS=200
MONITOR_MA_WINDOWS=5,20,40,120,200,240,720,1440
MONITOR_NORM_WINDOW=20
MONITOR_TELEGRAM_BATCH_SIZE=20
# --- general_analysis ---
GA_COL_PREFIX=ga_
LOOKBACK_BARS=3:120,5:100,10:80,15:60,30:50,60:40,240:30,1440:60
CONTEXT_TAIL_ROWS=3:6000,5:5000,10:4000,15:3000,30:2000,60:1500,240:800,1440:500
GA_DEFAULT_TAIL_EXPORT=200
GA_PATTERN_TOLERANCE_PCT=2.5
GA_VP_BINS=30
GA_VP_VALUE_AREA_PCT=0.70
GA_HV_ROLLING_BARS=20
GA_HV_PERCENTILE_WINDOW=120
GA_HV_ANNUALIZE_SQ.41148133
GA_DIVERGENCE_LOOKBACK=10
GA_SMA_PERIODS=5,20,60,120
GA_EMA_SPANS=12,26
GA_ATR_PERIOD=14
GA_KELTNER_ATR_MULT=2
GA_AO_FAST=5
GA_AO_SLOW=34
GA_LINREG_WINDOW=20
GA_ADX_PERIOD=14
GA_ADX_TREND_THRESHOLD=25
GA_SUPERTREND_ATR_MULT=3
GA_VOL_SPIKE_MULT=1.8
GA_VOL_MA_WINDOW=20
GA_CCI_PERIOD=20
GA_WILLIAMS_PERIOD=14
GA_ROC_PERIOD=10
GA_MFI_PERIOD=14
GA_CMF_PERIOD=20
GA_DONCHIAN_PERIOD=20
GA_BB_SQUEEZE_WINDOW=50
GA_BB_SQUEEZE_QUANTILE=0.2
GA_PIVOT_ORDER=3
GA_PSAR_AF_START=0.02
GA_PSAR_AF_STEP=0.02
GA_PSAR_AF_MAX=0.2
# --- .env.example 누락 키 추가 (2026-06-01) ---
GT_MIN_ORDER_KRW=5000
GT_BUY_PCT_LARGE_LEG=1.0
GT_BUY_PCT_SMALL_LEG=0.05
GT_LARGE_LEG_TOP_PCT=0.2
GT_SIGNAL_CAUSAL=1
SIM_CAUSAL_TIER=1
CAUSAL_GT_PEAK_MODE=local
CAUSAL_GT_MIN_LEG_PCT=5.0
CAUSAL_GT_MIN_BARS_BETWEEN_LEGS=60
CAUSAL_GT_USE_LOCAL_TROUGH=1
CAUSAL_GT_DD_LARGE_PCT=5.0
CAUSAL_GT_DD_MEDIUM_PCT=2.0
GT_BUY_PCT_MEDIUM_LEG=0.25
SIM_TIER_CONVICTION_DD_PCT=10.0
SIM_PRIMARY_SIZING=auto
SIM_HYBRID_MIN_HOLDOUT_PNL_PCT=0.0
SIM_HYBRID_MAX_MDD_PCT=30.0
SIM_OPTION_C_TARGET_PNL_PCT=300.0
SIM_OPTION_C_PHASE2_TARGET_PNL_PCT=1000.0
SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO=0.85
SIM_OPTION_C_MIN_GT_CAPTURE=0.23
SIM_HYBRID_PORTFOLIO_WF_MIN_RATIO=0.5
GT_BUY_WEIGHT_RULE=inverse_price_normalized
GT_SELL_SPLIT_WEIGHTS=0.65,0.35
MATCH_LABEL_MODE=leg_gt
MATCH_HOLDOUT_RATIO=0.15
MATCH_MONITOR_MAX_PER_SIDE=1
SIM_GO_WF_POSITIVE_RATIO=0.5
SIM_FEE_STRESS_MULT=2.0
# === Phase B-1 운영 (설정 변경 후 06_execute_live 반드시 재기동) ===
MATCH_LIVE_CACHE_SEC=180
MONITOR_ALERT_COOLDOWN_MIN=3
MONITOR_ALERT_KRW_AMOUNT=40000
LIVE_TRADING_ENABLED=1
LIVE_ORDER_KRW=40000
LIVE_BUY_PCT_LARGE=1.0
LIVE_BUY_PCT_SMALL=0.05
LIVE_DAILY_KRW_MAX=400000
LIVE_COOLDOWN_MIN=3
LIVE_MAX_TRADES_PER_DAY=15
LIVE_DAILY_LOSS_LIMIT_KRW=40000
LIVE_SLIPPAGE_PCT=0.05

View File

@@ -1,210 +1,50 @@
# DeepCoin — .env.example (비밀값 없음). 복사: cp .env.example .env 후 키만 채우세요. # DeepCoin — .env.example (비밀값 없음). 복사: cp .env.example .env
# Python: conda activate ncue && pip install -r requirements.txt
# --- 빗썸 API --- # --- 빗썸 API (캔들 수집은 Public API, 키 선택) ---
BITHUMB_ACCESS_KEY= BITHUMB_ACCESS_KEY=
BITHUMB_SECRET_KEY= BITHUMB_SECRET_KEY=
BITHUMB_API_URL=https://api.bithumb.com BITHUMB_API_URL=https://api.bithumb.com
BITHUMB_API_CANDLE_COUNT=200 BITHUMB_API_CANDLE_COUNT=200
BITHUMB_MINUTE_INTERVALS=1,3,5,10,15,30,60,240 BITHUMB_MINUTE_INTERVALS=3,5,10,15,30,60,240
HTS_API_RETRY_SLEEP_SEC=0.5 HTS_API_RETRY_SLEEP_SEC=0.5
# --- 텔레그램 (선택, 알림 미사용 시 비워도 됨) --- # --- 텔레그램 (선택) ---
COIN_TELEGRAM_BOT_TOKEN= COIN_TELEGRAM_BOT_TOKEN=
COIN_TELEGRAM_CHAT_ID= COIN_TELEGRAM_CHAT_ID=
# --- 거래 대상 --- # --- 거래 대상 ---
SYMBOL=WLD SYMBOL=BTC
COIN_NAME=월드코인 COIN_NAME=비트코인
# --- 경로 --- # --- 데이터 수집 (최대 2년) ---
DB_PATH=data/coins.db # 인터벌 코드: 분봉=분, 일봉=1440, 주봉=10080, 월봉=43200
GROUND_TRUTH_FILE=data/ground_truth/ground_truth_trades.json DB_PATH=coins.db
DOWNLOAD_DAYS=730
DOWNLOAD_INTERVALS=3,5,10,15,30,60,240,1440,10080,43200
API_REQUEST_SLEEP_SEC=0.35
API_REQUEST_RETRIES=3
# --- 타임프레임 (분) --- # --- Ground Truth (1단계, 2년) — DOWNLOAD_DAYS와 동일 권장 ---
DAILY_INTERVAL_MIN=1440 GT_INTERVAL_MIN=3
ENTRY_INTERVAL=3 GT_LOOKBACK_DAYS=730
TREND_INTERVAL_1H=60
TREND_INTERVAL_1D=1440
ALL_INTERVALS=3,5,10,15,30,60,240,1440
DOWNLOAD_INTERVALS=3,5,10,15,30,60,240,1440
GENERAL_ANALYSIS_INTERVALS=3,5,10,15,30,60,240,1440,10080,43200
TIMING_INTERVALS=3,5,10,15
TREND_INTERVALS=60,240,1440,10080,43200
INTERVAL_PREFIX=3:m3,5:m5,10:m10,15:m15,30:m30,60:m60,240:m240,1440:d1,10080:w1,43200:mo1
# --- 볼린저 / RSI ---
BB_PERIOD=20
BB_STD=2
BB_MIN_WIDTH_PCT=0.8
RSI_PERIOD=14
DISPARITY_PERIODS=5,20,60
DISPARITY_OVERBOUGHT=105
DISPARITY_OVERSOLD=95
MACD_FAST=12
MACD_SLOW=26
MACD_SIGNAL=9
STOCH_K_PERIOD=14
STOCH_D_PERIOD=3
STOCH_SMOOTH_K=3
STOCH_OVERSOLD=20
STOCH_OVERBOUGHT=80
TREND_RANGE_MA_GAP_PCT=0.5
# --- MTF 정렬 ---
ALIGN_RSI_OVERSOLD=35
ALIGN_RSI_OVERBOUGHT=65
ALIGN_RSI_CONFLICT_TIMING_LOW=40
ALIGN_RSI_CONFLICT_TIMING_HIGH=65
ALIGN_RSI_CONFLICT_TREND_LOW=40
ALIGN_RSI_CONFLICT_TREND_HIGH=65
ALIGN_BB_POS_LOW=0.2
ALIGN_BB_POS_HIGH=0.8
# --- 다운로드 / DB ---
DOWNLOAD_MONTHS=12
INCREMENTAL_OVERLAP_BARS=3
DOWNLOAD_BACKFILL_EXTRA_BARS=200
DOWNLOAD_MIN_INCREMENTAL_BARS=50
DOWNLOAD_DAILY_EXTRA_DAYS=20
CHART_LOOKBACK_DAYS=365
DB_READ_LIMIT_DEFAULT=7000
DB_ROW_WARMUP_BARS=200
DB_ROW_MIN_DAILY_BARS=100
DB_ROW_DAILY_PADDING_DAYS=30
# --- Ground Truth ---
GT_MIN_SWING_PCT=4.0
GT_PIVOT_ORDER=20
GT_MIN_BARS_BETWEEN=30
GT_MAX_ROUND_TRIPS=24
GT_SELECTION_MODE=split_buy_peak_sell
GT_MIN_LEG_PCT=8.0
GT_BUY_MIN_SWING_PCT=3.0
GT_BUY_BB_MAX=0.45
GT_BUY_MIN_BARS=24
GT_MAX_BUYS_PER_LEG=12
GT_MAX_SELLS_PER_LEG=2
GT_SELL_SPLIT_GAP_PCT=2.5
GT_MARKER_SIZE_MIN=10
GT_MARKER_SIZE_MAX=32
GT_INITIAL_CASH_KRW=400000 GT_INITIAL_CASH_KRW=400000
TRADING_FEE_RATE=0.0005 GT_TRADING_FEE_RATE=0.0005
GT_UNLIMITED_CHRONOLOGICAL_DAYS=300 GT_ZIGZAG_REVERSAL_PCT=5.0
GT_MIN_LEG_PCT=3.0
GT_PULLBACK_MIN_PCT=1.5
GT_PULLBACK_LOCAL_ORDER=10
GT_BREAKOUT_BUFFER_PCT=0.1
GT_BREAKOUT_CONSOLIDATION_BARS=200
GT_BREAKOUT_MIN_RALLY_PCT=2.0
GT_DIV_LOCAL_ORDER=20
GT_DIV_MIN_BARS_BETWEEN=1500
GT_DIV_MIN_RSI_DIFF=5.0
GT_DIV_MIN_FUTURE_MOVE_PCT=4.0
GROUND_TRUTH_FILE=data/ground_truth/ground_truth_trades.json
GROUND_TRUTH_CHART_FILE=docs/02_ground_truth/ground_truth_chart.html
# --- 모니터 --- # --- 매매 기법 (2단계) ---
MONITOR_LOOP_SLEEP_SEC=180 TECHNIQUES_DIR=data/techniques
MONITOR_POOL_WORKERS=12 ANALYSIS_REPORT_JSON=docs/03_analysis/comparison_report.json
MONITOR_DEFAULT_INTERVAL=60 ANALYSIS_REPORT_HTML=docs/03_analysis/comparison_report.html
MONITOR_API_RETRIES=3 GT_ALIGN_TOLERANCE_BARS=480
MONITOR_API_BONG_COUNT=3000
MONITOR_SLEEP_AFTER_REQUEST_SEC=0.5
MONITOR_SLEEP_RATE_LIMIT_SEC=5
MONITOR_SLEEP_BETWEEN_CHUNKS_SEC=0.3
MONITOR_API_CHUNK_BARS=200
MONITOR_MA_WINDOWS=5,20,40,120,200,240,720,1440
MONITOR_NORM_WINDOW=20
MONITOR_TELEGRAM_BATCH_SIZE=20
# --- general_analysis ---
GA_COL_PREFIX=ga_
LOOKBACK_BARS=3:120,5:100,10:80,15:60,30:50,60:40,240:30,1440:60,10080:12,43200:6
CONTEXT_TAIL_ROWS=3:6000,5:5000,10:4000,15:3000,30:2000,60:1500,240:800,1440:500,10080:120,43200:48
GA_DEFAULT_TAIL_EXPORT=200
GA_PATTERN_TOLERANCE_PCT=2.5
GA_VP_BINS=30
GA_VP_VALUE_AREA_PCT=0.70
GA_HV_ROLLING_BARS=20
GA_HV_PERCENTILE_WINDOW=120
GA_HV_ANNUALIZE_SQRT=339.41148133
GA_DIVERGENCE_LOOKBACK=10
GA_SMA_PERIODS=5,20,60,120
GA_EMA_SPANS=12,26
GA_ATR_PERIOD=14
GA_KELTNER_ATR_MULT=2
GA_AO_FAST=5
GA_AO_SLOW=34
GA_LINREG_WINDOW=20
GA_ADX_PERIOD=14
GA_ADX_TREND_THRESHOLD=25
GA_SUPERTREND_ATR_MULT=3
GA_VOL_SPIKE_MULT=1.8
GA_VOL_MA_WINDOW=20
GA_CCI_PERIOD=20
GA_WILLIAMS_PERIOD=14
GA_ROC_PERIOD=10
GA_MFI_PERIOD=14
GA_CMF_PERIOD=20
GA_DONCHIAN_PERIOD=20
GA_BB_SQUEEZE_WINDOW=50
GA_BB_SQUEEZE_QUANTILE=0.2
GA_PIVOT_ORDER=3
GA_PSAR_AF_START=0.02
GA_PSAR_AF_STEP=0.02
GA_PSAR_AF_MAX=0.2
# --- .env.example 누락 키 추가 (2026-06-01) ---
GT_MIN_ORDER_KRW=5000
GT_BUY_PCT_LARGE_LEG=1.0
GT_BUY_PCT_SMALL_LEG=0.05
GT_LARGE_LEG_TOP_PCT=0.2
GT_SIGNAL_CAUSAL=1
SIM_CAUSAL_TIER=1
CAUSAL_GT_PEAK_MODE=local
CAUSAL_GT_MIN_LEG_PCT=5.0
CAUSAL_GT_MIN_BARS_BETWEEN_LEGS=60
CAUSAL_GT_USE_LOCAL_TROUGH=1
CAUSAL_GT_DD_LARGE_PCT=5.0
CAUSAL_GT_DD_MEDIUM_PCT=2.0
GT_BUY_PCT_MEDIUM_LEG=0.25
SIM_TIER_CONVICTION_DD_PCT=10.0
SIM_PRIMARY_SIZING=auto
SIM_HYBRID_MIN_HOLDOUT_PNL_PCT=0.0
SIM_HYBRID_MAX_MDD_PCT=30.0
SIM_OPTION_C_TARGET_PNL_PCT=300.0
SIM_OPTION_C_PHASE2_TARGET_PNL_PCT=1000.0
SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO=0.85
SIM_OPTION_C_MIN_GT_CAPTURE=0.23
SIM_HYBRID_PORTFOLIO_WF_MIN_RATIO=0.5
GT_BUY_WEIGHT_RULE=inverse_price_normalized
GT_SELL_SPLIT_WEIGHTS=0.65,0.35
MATCH_LABEL_MODE=leg_gt
MATCH_HOLDOUT_RATIO=0.15
MATCH_MONITOR_MAX_PER_SIDE=1
SIM_GO_WF_POSITIVE_RATIO=0.5
SIM_FEE_STRESS_MULT=2.0
# 3분봉(MATCH_PRIMARY_INTERVAL=3) — 규칙·알림 쿨다운 1봉
MONITOR_ALERT_COOLDOWN_MIN=3
MONITOR_ALERT_KRW_AMOUNT=40000
# Phase B-1: 실거래 (06_execute_live.py 만 빗썸 주문 — 05는 알림만)
LIVE_TRADING_ENABLED=1
# LIVE_* 원화 한도: GT_INITIAL_CASH_KRW(40만) — B-1: 일한도 1배, 손실 10%, 1회참고 10%
LIVE_ORDER_KRW=40000
LIVE_BUY_PCT_LARGE=1.0
LIVE_BUY_PCT_SMALL=0.05
LIVE_DAILY_KRW_MAX=400000
LIVE_COOLDOWN_MIN=3
LIVE_MAX_TRADES_PER_DAY=15
LIVE_DAILY_LOSS_LIMIT_KRW=40000
LIVE_SLIPPAGE_PCT=0.05
# 06: 시뮬 sim_causal_hybrid 정합 — fire_outcomes monitor 발화 부트스트랩
LIVE_HYBRID_BOOTSTRAP_FIRES=1
# 07 일일 24h 수익률 텔레그램 (scripts/07_daily_pnl_telegram.py)
DAILY_PNL_REPORT_ENABLED=1
DAILY_PNL_REPORT_HOUR=19
DAILY_PNL_REPORT_MINUTE=0
DAILY_PNL_REPORT_TZ=Asia/Seoul
DAILY_PNL_SNAPSHOT_ON_LIVE=1
DAILY_PNL_SNAPSHOT_RETENTION_DAYS=90
# 05/06 루프 시 봉 DB 증분 · live_eval 캐시(루프 주기와 동일)
MONITOR_PERSIST_CANDLES=1
MATCH_LIVE_CACHE_SEC=180
# 05/06 시작·루프마다 지연 봉 자동 보완 (간격당 허용 지연 = 간격분×OPS_SYNC_MAX_LAG_BARS)
OPS_SYNC_ON_START=1
OPS_SYNC_MAX_LAG_BARS=2
# --- 주·월봉 다운로드 (01_download) ---
DOWNLOAD_INTERVALS_WM=10080,43200
DOWNLOAD_MONTHS_WM=24
WEEK_INTERVAL_MIN=10080
MONTH_INTERVAL_MIN=43200

8
.gitignore vendored
View File

@@ -1,4 +1,6 @@
# ---> Python.env # Local secrets and OS
.env
.DS_Store
.idea .idea
*.db *.db
@@ -97,7 +99,11 @@ ENV/
# 재생성 산출물 (스크립트 실행 시 로컬 생성) # 재생성 산출물 (스크립트 실행 시 로컬 생성)
docs/02_ground_truth/*.html docs/02_ground_truth/*.html
docs/02_ground_truth/ground_truth_chart_data.js
data/ground_truth/ground_truth_trades.json
data/techniques/*.json
docs/03_analysis/*.html docs/03_analysis/*.html
docs/03_analysis/comparison_report.json
docs/03_analysis/latest/ docs/03_analysis/latest/
docs/04_matching/simulation_report.html docs/04_matching/simulation_report.html
docs/04_matching/backtest_summary.html docs/04_matching/backtest_summary.html

190
README.md
View File

@@ -1,101 +1,119 @@
# DeepCoin — WLD MTF · Ground Truth · Simulation · Operations # DeepCoin
빗썸 KRW-WLD. 프로젝트는 **세 축**으로만 설계됩니다. 빗썸 KRW 마켓 암호화폐 캔들 데이터 수집 프로젝트.
| 축 | 역할 | 미래 데이터 | ## 주요 기능
|----|------|-------------|
| **Ground Truth** | 벤치마크 타점·leg·배분 | 허용 (사후 라벨) |
| **Simulation** | 규칙·인과 백테스트·Go/No-Go | 금지 |
| **Operations** | 시뮬과 동일 규칙·hybrid 배분 실주문 | 금지 |
설계 상세: [docs/reference/ARCHITECTURE.md](docs/reference/ARCHITECTURE.md) - 빗썸 Public API(v1) 기반 분·일·주·월봉 캔들 수집
- SQLite(`coins.db`) 저장 — 테이블명 `{SYMBOL}_{인터벌코드}` (예: `BTC_60`, `BTC_10080`)
- 최대 2년(기본 730일) 역방향 페이지네이션 수집
## 파이프라인 (권장 순서) ## 요구사항
- Python 3.10+
- Conda 환경 `ncue` 또는 `xavis`
## 설치
```bash ```bash
conda activate ncue # 또는 xavis cd DeepCoin
conda activate ncue
# 데이터 pip install -r requirements.txt
python scripts/01_download.py cp .env.example .env # API 키 등 입력
# Ground Truth
python scripts/02_ground_truth.py
python scripts/03_analyze_enrich.py
python scripts/03_analyze_trades.py
python scripts/03_gt_mtf_profile.py
python scripts/05_chart_truth.py
# Simulation
python scripts/04_match_rules.py
python scripts/04_simulation_report.py
# Operations (LIVE_TRADING_ENABLED=1)
python scripts/06_verify_live.py
python scripts/check_balance.py
python scripts/06_execute_live.py --once
python scripts/06_execute_live.py
``` ```
선택: `05_run_monitor.py` (알림만), `07_daily_pnl_telegram.py` (매일 24h 수익률), `00_sync_ops.py` (운영 전 봉 동기화) ## 환경 변수
## 디렉터리 | 변수 | 설명 | 기본값 |
|------|------|--------|
| `SYMBOL` | 코인 심볼 | `BTC` |
| `COIN_NAME` | 코인 이름 | `비트코인` |
| `DB_PATH` | SQLite 경로 | `coins.db` |
| `DOWNLOAD_DAYS` | 수집 일수 (최대 2년) | `730` |
| `DOWNLOAD_INTERVALS` | 인터벌 코드 목록 | `3,5,10,15,30,60,240,1440,10080,43200` |
| `BITHUMB_API_CANDLE_COUNT` | 요청당 캔들 수 (최대 200) | `200` |
| `API_REQUEST_SLEEP_SEC` | API 호출 간격(초) | `0.35` |
인터벌 코드: 분봉은 분 단위 숫자, 일봉=`1440`, 주봉=`10080`, 월봉=`43200`
캔들 조회는 Public API이므로 API 키 없이도 동작합니다.
## 5단계 파이프라인
| 단계 | 목적 | 스크립트 |
|------|------|----------|
| 0 | 데이터 수집 | `01_download.py` |
| 1 | Ground Truth (사후 벤치마크 타점) | `02_ground_truth.py` |
| 2 | 매매 기법 개발·GT 정합 비교 | `03_run_techniques.py` |
| 3 | 인과 신호 (과거 데이터만) | (예정) |
| 4 | 시뮬레이션 백테스트 | (예정) |
| 5 | 실거래 운영 | (예정) |
```bash
# 0. 데이터 수집
python scripts/01_download.py
# 1. Ground Truth (매수·매도 벤치마크 타점)
python scripts/02_ground_truth.py
# 2. 매매 기법 실행 및 GT 정합 비교
python scripts/03_run_techniques.py
```
## 2단계 매매 기법 (8종)
Ground Truth 타점에 맞출 수 있는 후보 기법. 모두 **인과 신호** (미래 데이터 미사용).
| ID | 기법 | 유형 | 설명 |
|----|------|------|------|
| `zigzag_causal` | 인과 ZigZag | swing | GT ZigZag 5%의 인과 버전 |
| `minor_swing` | 소형 스윙 하이브리드 | hybrid | ZigZag 2.5% + 국소 극값 |
| `local_extrema` | 국소 극값 | swing | 눌림목·반등 고점 (9/27 유형) |
| `bb_reversal` | 볼린저 역추세 | indicator | BB 하단 매수·상단 매도 |
| `ma_cross` | EMA 크로스 | indicator | EMA(20/60) 골든·데드 크로스 |
| `rsi_swing` | RSI 스윙 | indicator | RSI 과매도·과매수 복귀 |
| `macd_cross` | MACD 크로스 | indicator | MACD 시그널선 크로스 |
| `donchian` | 돈치안 채널 | swing | 채널 하단·상단 반전 |
GT 정합 평가: 매수/매도 recall, 레그 recall, 수익 포착률, 종합 score.
## 실행 (데이터 수집)
```bash
# 전체 인터벌, 최대 2년
python scripts/download_candles.py
# 일·주·월봉만 빠르게
python scripts/download_candles.py --intervals 1440,10080,43200
# 최근 90일, 60분봉만
python scripts/download_candles.py --days 90 --intervals 60
```
## 디렉터리 구조
```text ```text
DeepCoin/ DeepCoin/
├── scripts/ # CLI 진입점 (GT / Sim / Ops) ├── src/deepcoin/
├── deepcoin/ │ ├── api/bithumb.py
│ ├── data/ # 01 │ ├── data/
│ ├── ground_truth/ # GT │ ├── ground_truth/ # 1단계
│ ├── analysis/ # GT 입력 (03) │ ├── techniques/ # 2단계 매매 기법
── matching/ # Sim (04) ── evaluation/ # GT 정합 평가
│ └── ops/ # Ops (05·06) ├── scripts/
├── data/ # coins.db, ground_truth/, ops/ │ ├── 01_download.py
└── docs/ │ ├── 02_ground_truth.py
── reference/ # ARCHITECTURE, SIMULATION, OPERATIONS … ── 03_run_techniques.py
└── 02~05/ # 재생성 산출물 ├── data/ground_truth/
├── data/techniques/
├── docs/02_ground_truth/
├── docs/03_analysis/
└── coins.db
``` ```
## 환경 ## 변경 이력
| 파일 | 용도 | - 2026-06-08: 2단계 매매 기법 8종 + GT 정합 비교 리포트 추가
|------|------| - 2026-06-08: 1단계 Ground Truth (ZigZag 스윙 매수·매도 타점) 추가
| `.env` | API 키·전역 설정 (Git 제외) | - 2026-06-07: BTC 최대 2년 분·일·주·월봉 수집 지원
| `config.py` | `.env` 로드 | - 2026-06-07: 캔들 수집 모듈 초기 구현
`scripts/_bootstrap.py`가 루트 `.env`를 자동 로드합니다.
### 주요 변수
| 변수 | 기본 | 축 |
|------|------|-----|
| `CHART_LOOKBACK_DAYS` | 365 | GT·Sim |
| `GT_INITIAL_CASH_KRW` | 400000 | GT·Sim·Ops 배분 |
| `GENERAL_ANALYSIS_INTERVALS` | 3m~일봉+주·월 | Sim 스캔 |
| `GT_SIGNAL_CAUSAL` | 1 | Sim·Ops (인과) |
| `LIVE_TRADING_ENABLED` | 1 | Ops (`0`이면 06 기동 불가) |
전체: `.env.example`, [docs/05_ops/env.recommended.md](docs/05_ops/env.recommended.md)
## 산출물
| 경로 | 축 |
|------|-----|
| `data/ground_truth/ground_truth_trades.json` | GT |
| `docs/02_ground_truth/wld_ground_truth_chart.html` | GT 차트 (`05_chart_truth.py` 재생성) |
| `docs/04_matching/matched_rules.json` | Sim |
| `docs/04_matching/simulation_report.html` | Sim |
| `data/ops/live_trades.jsonl` | Ops |
## 문서
| 문서 | 내용 |
|------|------|
| [ARCHITECTURE.md](docs/reference/ARCHITECTURE.md) | 3축 설계 (필독) |
| [GROUND_TRUTH.md](docs/reference/GROUND_TRUTH.md) | GT 타점 |
| [SIMULATION.md](docs/reference/SIMULATION.md) | 시뮬·Go/No-Go |
| [OPERATIONS.md](docs/reference/OPERATIONS.md) | 운영 루틴 |
| [LIVE_TRADING.md](docs/reference/LIVE_TRADING.md) | 06 실거래 |
## 면책
실거래 손익은 사용자 책임입니다. 본 저장소는 투자 자문이 아닙니다.

439
config.py
View File

@@ -1,439 +0,0 @@
"""
전역 설정 (WLD). 값은 PROJECT_ROOT/.env → OS 환경 변수 순으로 읽습니다.
"""
from __future__ import annotations
import os
from deepcoin.env_loader import load_project_env
load_project_env(override=True)
def _getenv(key: str, default: str = "") -> str:
return os.getenv(key, default)
def _getenv_int(key: str, default: str) -> int:
return int(_getenv(key, default))
def _getenv_float(key: str, default: str) -> float:
return float(_getenv(key, default))
def _parse_int_tuple(env_key: str, default: str) -> tuple[int, ...]:
raw = _getenv(env_key, default)
return tuple(int(x.strip()) for x in raw.split(",") if x.strip())
def _parse_int_set(env_key: str, default: str) -> frozenset[int]:
return frozenset(_parse_int_tuple(env_key, default))
def _parse_interval_map(env_key: str, default: str) -> dict[int, int]:
"""
'3:120,5:100'{3: 120, 5: 100}.
"""
raw = _getenv(env_key, default)
out: dict[int, int] = {}
for part in raw.split(","):
part = part.strip()
if ":" not in part:
continue
k, v = part.split(":", 1)
out[int(k.strip())] = int(v.strip())
return out
def _parse_str_map(env_key: str, default: str) -> dict[int, str]:
"""'3:m3,1440:d1'{3: 'm3', 1440: 'd1'}."""
raw = _getenv(env_key, default)
out: dict[int, str] = {}
for part in raw.split(","):
part = part.strip()
if ":" not in part:
continue
k, v = part.split(":", 1)
out[int(k.strip())] = v.strip()
return out
# --- API / 알림 ---
BITHUMB_ACCESS_KEY = _getenv("BITHUMB_ACCESS_KEY")
BITHUMB_SECRET_KEY = _getenv("BITHUMB_SECRET_KEY")
BITHUMB_API_URL = _getenv("BITHUMB_API_URL", "https://api.bithumb.com")
BITHUMB_API_CANDLE_COUNT = _getenv_int("BITHUMB_API_CANDLE_COUNT", "200")
BITHUMB_MINUTE_INTERVALS = _parse_int_set(
"BITHUMB_MINUTE_INTERVALS", "3,5,10,15,30,60,240"
)
HTS_API_RETRY_SLEEP_SEC = _getenv_float("HTS_API_RETRY_SLEEP_SEC", "0.5")
COIN_TELEGRAM_BOT_TOKEN = _getenv("COIN_TELEGRAM_BOT_TOKEN")
COIN_TELEGRAM_CHAT_ID = _getenv("COIN_TELEGRAM_CHAT_ID")
# --- 거래 대상 ---
SYMBOL = _getenv("SYMBOL", "WLD")
COIN_NAME = _getenv("COIN_NAME", "월드코인")
KR_COINS: dict[str, str] = {SYMBOL: COIN_NAME}
# --- 타임프레임 (분) ---
DAILY_INTERVAL_MIN = _getenv_int("DAILY_INTERVAL_MIN", "1440")
ENTRY_INTERVAL = _getenv_int("ENTRY_INTERVAL", "3")
TREND_INTERVAL_1H = _getenv_int("TREND_INTERVAL_1H", "60")
TREND_INTERVAL_1D = _getenv_int("TREND_INTERVAL_1D", "1440")
ALL_INTERVALS: tuple[int, ...] = _parse_int_tuple(
"ALL_INTERVALS", "3,5,10,15,30,60,240,1440"
)
# 1분봉은 01_download·ops_sync 미적재 (DOWNLOAD_EXCLUDED_INTERVALS).
DOWNLOAD_INTERVALS: tuple[int, ...] = _parse_int_tuple(
"DOWNLOAD_INTERVALS",
"3,5,10,15,30,60,240,1440",
)
# 주봉(10080)·월봉(43200) — 01_download 별도 적재, 기본 2년(DOWNLOAD_MONTHS_WM)
WEEK_INTERVAL_MIN = _getenv_int("WEEK_INTERVAL_MIN", "10080")
MONTH_INTERVAL_MIN = _getenv_int("MONTH_INTERVAL_MIN", "43200")
DOWNLOAD_INTERVALS_WM: tuple[int, ...] = _parse_int_tuple(
"DOWNLOAD_INTERVALS_WM",
"10080,43200",
)
DOWNLOAD_MONTHS_WM = _getenv_int("DOWNLOAD_MONTHS_WM", "24")
GENERAL_ANALYSIS_INTERVALS: tuple[int, ...] = _parse_int_tuple(
"GENERAL_ANALYSIS_INTERVALS", "3,5,10,15,30,60,240,1440"
)
TIMING_INTERVALS: tuple[int, ...] = _parse_int_tuple(
"TIMING_INTERVALS", "3,5,10,15"
)
TREND_INTERVALS: tuple[int, ...] = _parse_int_tuple(
"TREND_INTERVALS", "60,240,1440"
)
INTERVAL_PREFIX: dict[int, str] = _parse_str_map(
"INTERVAL_PREFIX",
"3:m3,5:m5,10:m10,15:m15,30:m30,60:m60,240:m240,1440:d1,10080:w1,43200:mo1",
)
# --- 볼린저 / RSI ---
BB_PERIOD = _getenv_int("BB_PERIOD", "20")
BB_STD = _getenv_float("BB_STD", "2")
BB_MIN_WIDTH_PCT = _getenv_float("BB_MIN_WIDTH_PCT", "0.8")
RSI_PERIOD = _getenv_int("RSI_PERIOD", "14")
# --- 이격도 ---
DISPARITY_PERIODS: tuple[int, ...] = _parse_int_tuple("DISPARITY_PERIODS", "5,20,60")
DISPARITY_OVERBOUGHT = _getenv_float("DISPARITY_OVERBOUGHT", "105")
DISPARITY_OVERSOLD = _getenv_float("DISPARITY_OVERSOLD", "95")
# --- MACD / Stochastic ---
MACD_FAST = _getenv_int("MACD_FAST", "12")
MACD_SLOW = _getenv_int("MACD_SLOW", "26")
MACD_SIGNAL = _getenv_int("MACD_SIGNAL", "9")
STOCH_K_PERIOD = _getenv_int("STOCH_K_PERIOD", "14")
STOCH_D_PERIOD = _getenv_int("STOCH_D_PERIOD", "3")
STOCH_SMOOTH_K = _getenv_int("STOCH_SMOOTH_K", "3")
STOCH_OVERSOLD = _getenv_float("STOCH_OVERSOLD", "20")
STOCH_OVERBOUGHT = _getenv_float("STOCH_OVERBOUGHT", "80")
# --- 추세 ---
TREND_RANGE_MA_GAP_PCT = _getenv_float("TREND_RANGE_MA_GAP_PCT", "0.5")
# --- MTF 합성·정렬 ---
ALIGN_RSI_OVERSOLD = _getenv_float("ALIGN_RSI_OVERSOLD", "35")
ALIGN_RSI_OVERBOUGHT = _getenv_float("ALIGN_RSI_OVERBOUGHT", "65")
ALIGN_RSI_CONFLICT_TIMING_LOW = _getenv_float("ALIGN_RSI_CONFLICT_TIMING_LOW", "40")
ALIGN_RSI_CONFLICT_TIMING_HIGH = _getenv_float("ALIGN_RSI_CONFLICT_TIMING_HIGH", "65")
ALIGN_RSI_CONFLICT_TREND_LOW = _getenv_float("ALIGN_RSI_CONFLICT_TREND_LOW", "40")
ALIGN_RSI_CONFLICT_TREND_HIGH = _getenv_float("ALIGN_RSI_CONFLICT_TREND_HIGH", "65")
ALIGN_BB_POS_LOW = _getenv_float("ALIGN_BB_POS_LOW", "0.2")
ALIGN_BB_POS_HIGH = _getenv_float("ALIGN_BB_POS_HIGH", "0.8")
# --- 다운로드 / DB ---
DOWNLOAD_MONTHS = _getenv_int("DOWNLOAD_MONTHS", "12")
INCREMENTAL_OVERLAP_BARS = _getenv_int("INCREMENTAL_OVERLAP_BARS", "3")
DOWNLOAD_BACKFILL_EXTRA_BARS = _getenv_int("DOWNLOAD_BACKFILL_EXTRA_BARS", "200")
DOWNLOAD_MIN_INCREMENTAL_BARS = _getenv_int("DOWNLOAD_MIN_INCREMENTAL_BARS", "50")
DOWNLOAD_DAILY_EXTRA_DAYS = _getenv_int("DOWNLOAD_DAILY_EXTRA_DAYS", "20")
# 05/06 시작 시 누락·지연 봉 자동 증분 (01_download와 동일 저장)
OPS_SYNC_ON_START = _getenv("OPS_SYNC_ON_START", "1").strip().lower() in (
"1",
"true",
"yes",
)
OPS_SYNC_MAX_LAG_BARS = _getenv_int("OPS_SYNC_MAX_LAG_BARS", "2")
DB_READ_LIMIT_DEFAULT = _getenv_int("DB_READ_LIMIT_DEFAULT", "7000")
DB_ROW_WARMUP_BARS = _getenv_int("DB_ROW_WARMUP_BARS", "200")
DB_ROW_MIN_DAILY_BARS = _getenv_int("DB_ROW_MIN_DAILY_BARS", "100")
DB_ROW_DAILY_PADDING_DAYS = _getenv_int("DB_ROW_DAILY_PADDING_DAYS", "30")
def _paths():
from deepcoin.paths import (
ANALYSIS_CAPABILITY_HTML,
ANALYSIS_LATEST_DIR,
ANALYSIS_REPORT_HTML,
ANALYSIS_TRADES_CSV,
resolve_db_path,
resolve_ground_truth_file,
)
return (
resolve_db_path(),
resolve_ground_truth_file(),
ANALYSIS_TRADES_CSV,
ANALYSIS_REPORT_HTML,
ANALYSIS_CAPABILITY_HTML,
ANALYSIS_LATEST_DIR,
)
_db, _gt, _a_csv, _a_html, _a_cap, _a_latest = _paths()
DB_PATH = _getenv("DB_PATH", str(_db))
GROUND_TRUTH_PATH = _gt
REPORTS_ANALYSIS_TRADES_CSV = _a_csv
REPORTS_ANALYSIS_REPORT_HTML = _a_html
REPORTS_ANALYSIS_CAPABILITY_HTML = _a_cap
REPORTS_ANALYSIS_LATEST_DIR = _a_latest
GROUND_TRUTH_FILE = _getenv("GROUND_TRUTH_FILE", str(_gt))
# --- 차트 ---
CHART_LOOKBACK_DAYS = _getenv_int("CHART_LOOKBACK_DAYS", "365")
GT_UNLIMITED_CHRONOLOGICAL_DAYS = _getenv_int("GT_UNLIMITED_CHRONOLOGICAL_DAYS", "300")
# --- Ground Truth ---
GT_MIN_SWING_PCT = _getenv_float("GT_MIN_SWING_PCT", "4.0")
GT_PIVOT_ORDER = _getenv_int("GT_PIVOT_ORDER", "20")
GT_MIN_BARS_BETWEEN = _getenv_int("GT_MIN_BARS_BETWEEN", "30")
GT_MAX_ROUND_TRIPS = _getenv_int("GT_MAX_ROUND_TRIPS", "24")
GT_SELECTION_MODE = _getenv("GT_SELECTION_MODE", "split_buy_peak_sell")
GT_MIN_LEG_PCT = _getenv_float("GT_MIN_LEG_PCT", "8.0")
GT_BUY_MIN_SWING_PCT = _getenv_float("GT_BUY_MIN_SWING_PCT", "3.0")
GT_BUY_BB_MAX = _getenv_float("GT_BUY_BB_MAX", "0.45")
GT_BUY_MIN_BARS = _getenv_int("GT_BUY_MIN_BARS", "24")
GT_MAX_BUYS_PER_LEG = _getenv_int("GT_MAX_BUYS_PER_LEG", "12")
GT_MAX_SELLS_PER_LEG = _getenv_int("GT_MAX_SELLS_PER_LEG", "2")
GT_SELL_SPLIT_GAP_PCT = _getenv_float("GT_SELL_SPLIT_GAP_PCT", "2.5")
GT_SELL_SPLIT_WEIGHTS: tuple[float, ...] = tuple(
float(x.strip())
for x in _getenv("GT_SELL_SPLIT_WEIGHTS", "0.65,0.35").split(",")
if x.strip()
) or (0.65, 0.35)
GT_BUY_WEIGHT_RULE = _getenv("GT_BUY_WEIGHT_RULE", "inverse_price_normalized")
GT_MARKER_SIZE_MIN = _getenv_int("GT_MARKER_SIZE_MIN", "10")
GT_MARKER_SIZE_MAX = _getenv_int("GT_MARKER_SIZE_MAX", "32")
GT_INITIAL_CASH_KRW = _getenv_int("GT_INITIAL_CASH_KRW", "400000")
GT_MIN_ORDER_KRW = _getenv_int("GT_MIN_ORDER_KRW", "5000")
GT_BUY_PCT_LARGE_LEG = _getenv_float("GT_BUY_PCT_LARGE_LEG", "1.0")
GT_BUY_PCT_SMALL_LEG = _getenv_float("GT_BUY_PCT_SMALL_LEG", "0.05")
GT_LARGE_LEG_TOP_PCT = _getenv_float("GT_LARGE_LEG_TOP_PCT", "0.2")
# 시뮬·스캔: 1=인과적 신호·tier (미래 데이터 미사용, 운영 정합)
GT_SIGNAL_CAUSAL = _getenv("GT_SIGNAL_CAUSAL", "1").strip().lower() in (
"1",
"true",
"yes",
)
SIM_CAUSAL_TIER = _getenv("SIM_CAUSAL_TIER", "1").strip().lower() in (
"1",
"true",
"yes",
)
# 인과 GT leg 엔진 (Option C +300% 경로)
CAUSAL_GT_PEAK_MODE = _getenv("CAUSAL_GT_PEAK_MODE", "local").strip().lower()
if CAUSAL_GT_PEAK_MODE not in ("local", "zigzag"):
CAUSAL_GT_PEAK_MODE = "local"
CAUSAL_GT_MIN_LEG_PCT = _getenv_float("CAUSAL_GT_MIN_LEG_PCT", "5.0")
CAUSAL_GT_MIN_BARS_BETWEEN_LEGS = _getenv_int("CAUSAL_GT_MIN_BARS_BETWEEN_LEGS", "60")
CAUSAL_GT_USE_LOCAL_TROUGH = _getenv("CAUSAL_GT_USE_LOCAL_TROUGH", "1").strip().lower() in (
"1",
"true",
"yes",
)
CAUSAL_GT_DD_LARGE_PCT = _getenv_float("CAUSAL_GT_DD_LARGE_PCT", "8.0")
CAUSAL_GT_DD_MEDIUM_PCT = _getenv_float("CAUSAL_GT_DD_MEDIUM_PCT", "4.0")
GT_BUY_PCT_MEDIUM_LEG = _getenv_float("GT_BUY_PCT_MEDIUM_LEG", "0.25")
SIM_TIER_CONVICTION_DD_PCT = _getenv_float("SIM_TIER_CONVICTION_DD_PCT", "10.0")
TRADING_FEE_RATE = _getenv_float("TRADING_FEE_RATE", "0.0005")
# --- 모니터 / API 수집 ---
MONITOR_LOOP_SLEEP_SEC = _getenv_int("MONITOR_LOOP_SLEEP_SEC", "180")
MONITOR_POOL_WORKERS = _getenv_int("MONITOR_POOL_WORKERS", "12")
MONITOR_DEFAULT_INTERVAL = _getenv_int("MONITOR_DEFAULT_INTERVAL", "60")
MONITOR_API_RETRIES = _getenv_int("MONITOR_API_RETRIES", "3")
MONITOR_API_BONG_COUNT = _getenv_int("MONITOR_API_BONG_COUNT", "3000")
MONITOR_SLEEP_AFTER_REQUEST_SEC = _getenv_float("MONITOR_SLEEP_AFTER_REQUEST_SEC", "0.5")
MONITOR_SLEEP_RATE_LIMIT_SEC = _getenv_float("MONITOR_SLEEP_RATE_LIMIT_SEC", "5")
MONITOR_SLEEP_BETWEEN_CHUNKS_SEC = _getenv_float("MONITOR_SLEEP_BETWEEN_CHUNKS_SEC", "0.3")
MONITOR_API_CHUNK_BARS = _getenv_int("MONITOR_API_CHUNK_BARS", "200")
MONITOR_MA_WINDOWS: tuple[int, ...] = _parse_int_tuple(
"MONITOR_MA_WINDOWS", "5,20,40,120,200,240,720,1440"
)
MONITOR_NORM_WINDOW = _getenv_int("MONITOR_NORM_WINDOW", "20")
MONITOR_TELEGRAM_BATCH_SIZE = _getenv_int("MONITOR_TELEGRAM_BATCH_SIZE", "20")
# 규칙 알림 참고 금액(매수 시 수량=금액/가격). 매도 시에는 보유 수량 우선.
MONITOR_ALERT_KRW_AMOUNT = _getenv_int("MONITOR_ALERT_KRW_AMOUNT", "40000")
# 05/06·live_eval API 수집 시 coins.db 증분 INSERT (01_download와 동일 append_data)
MONITOR_PERSIST_CANDLES = _getenv("MONITOR_PERSIST_CANDLES", "1").strip().lower() in (
"1",
"true",
"yes",
)
# --- general_analysis ---
GA_COL_PREFIX = _getenv("GA_COL_PREFIX", "ga_")
LOOKBACK_BARS: dict[int, int] = _parse_interval_map(
"LOOKBACK_BARS",
"3:120,5:100,10:80,15:60,30:50,60:40,240:30,1440:60",
)
CONTEXT_TAIL_ROWS: dict[int, int] = _parse_interval_map(
"CONTEXT_TAIL_ROWS",
"3:6000,5:5000,10:4000,15:3000,30:2000,60:1500,240:800,1440:500",
)
GA_DEFAULT_TAIL_EXPORT = _getenv_int("GA_DEFAULT_TAIL_EXPORT", "200")
GA_PATTERN_TOLERANCE_PCT = _getenv_float("GA_PATTERN_TOLERANCE_PCT", "2.5")
GA_VP_BINS = _getenv_int("GA_VP_BINS", "30")
GA_VP_VALUE_AREA_PCT = _getenv_float("GA_VP_VALUE_AREA_PCT", "0.70")
GA_HV_ROLLING_BARS = _getenv_int("GA_HV_ROLLING_BARS", "20")
GA_HV_PERCENTILE_WINDOW = _getenv_int("GA_HV_PERCENTILE_WINDOW", "120")
GA_HV_ANNUALIZE_SQRT = _getenv_float("GA_HV_ANNUALIZE_SQRT", "339.41148133")
GA_DIVERGENCE_LOOKBACK = _getenv_int("GA_DIVERGENCE_LOOKBACK", "10")
GA_SMA_PERIODS: tuple[int, ...] = _parse_int_tuple("GA_SMA_PERIODS", "5,20,60,120")
GA_EMA_SPANS: tuple[int, ...] = _parse_int_tuple("GA_EMA_SPANS", "12,26")
GA_ATR_PERIOD = _getenv_int("GA_ATR_PERIOD", "14")
GA_KELTNER_ATR_MULT = _getenv_float("GA_KELTNER_ATR_MULT", "2")
GA_AO_FAST = _getenv_int("GA_AO_FAST", "5")
GA_AO_SLOW = _getenv_int("GA_AO_SLOW", "34")
GA_LINREG_WINDOW = _getenv_int("GA_LINREG_WINDOW", "20")
GA_ADX_PERIOD = _getenv_int("GA_ADX_PERIOD", "14")
GA_ADX_TREND_THRESHOLD = _getenv_float("GA_ADX_TREND_THRESHOLD", "25")
GA_SUPERTREND_ATR_MULT = _getenv_float("GA_SUPERTREND_ATR_MULT", "3")
GA_VOL_SPIKE_MULT = _getenv_float("GA_VOL_SPIKE_MULT", "1.8")
GA_VOL_MA_WINDOW = _getenv_int("GA_VOL_MA_WINDOW", "20")
GA_CCI_PERIOD = _getenv_int("GA_CCI_PERIOD", "20")
GA_WILLIAMS_PERIOD = _getenv_int("GA_WILLIAMS_PERIOD", "14")
GA_ROC_PERIOD = _getenv_int("GA_ROC_PERIOD", "10")
GA_MFI_PERIOD = _getenv_int("GA_MFI_PERIOD", "14")
GA_CMF_PERIOD = _getenv_int("GA_CMF_PERIOD", "20")
GA_DONCHIAN_PERIOD = _getenv_int("GA_DONCHIAN_PERIOD", "20")
GA_BB_SQUEEZE_WINDOW = _getenv_int("GA_BB_SQUEEZE_WINDOW", "50")
GA_BB_SQUEEZE_QUANTILE = _getenv_float("GA_BB_SQUEEZE_QUANTILE", "0.2")
GA_PIVOT_ORDER = _getenv_int("GA_PIVOT_ORDER", "3")
GA_PSAR_AF_START = _getenv_float("GA_PSAR_AF_START", "0.02")
GA_PSAR_AF_STEP = _getenv_float("GA_PSAR_AF_STEP", "0.02")
GA_PSAR_AF_MAX = _getenv_float("GA_PSAR_AF_MAX", "0.2")
# --- 04 매칭 (GT 프로필 + 전구간 EV) ---
MATCH_PRIMARY_INTERVAL = _getenv_int("MATCH_PRIMARY_INTERVAL", "3")
MATCH_GT_TOLERANCE_MIN = _getenv_int("MATCH_GT_TOLERANCE_MIN", "15")
MATCH_FORWARD_BARS = _getenv_int("MATCH_FORWARD_BARS", "60")
MATCH_FEE_RATE = _getenv_float("MATCH_FEE_RATE", "0.0005")
MATCH_MIN_FIRES = _getenv_int("MATCH_MIN_FIRES", "10")
MATCH_TRAIN_RATIO = _getenv_float("MATCH_TRAIN_RATIO", "0.7")
MATCH_MAX_RULES_PER_SIDE = _getenv_int("MATCH_MAX_RULES_PER_SIDE", "5")
MATCH_PROFILE_QUANTILE_LO = _getenv_float("MATCH_PROFILE_QUANTILE_LO", "0.25")
MATCH_PROFILE_QUANTILE_HI = _getenv_float("MATCH_PROFILE_QUANTILE_HI", "0.75")
MATCH_MIN_EV_VALID = _getenv_float("MATCH_MIN_EV_VALID", "0.0")
MATCH_MIN_PROFIT_FACTOR = _getenv_float("MATCH_MIN_PROFIT_FACTOR", "1.0")
MATCH_MAX_VALID_FIRE_RATE = _getenv_float("MATCH_MAX_VALID_FIRE_RATE", "0.35")
MATCH_BEST_EFFORT_PER_SIDE = _getenv_int("MATCH_BEST_EFFORT_PER_SIDE", "3")
MATCH_INCLUDE_WIDE_RULES = _getenv("MATCH_INCLUDE_WIDE_RULES", "0").strip() in (
"1",
"true",
"True",
"yes",
)
MATCH_PROFILE_TIGHT_LO = _getenv_float("MATCH_PROFILE_TIGHT_LO", "0.35")
MATCH_PROFILE_TIGHT_HI = _getenv_float("MATCH_PROFILE_TIGHT_HI", "0.65")
MATCH_PROFILE_TOP_PER_TF = _getenv_int("MATCH_PROFILE_TOP_PER_TF", "6")
MATCH_PROFILE_TOP_GLOBAL = _getenv_int("MATCH_PROFILE_TOP_GLOBAL", "30")
MATCH_PROFILE_MIN_SEPARATION = _getenv_float("MATCH_PROFILE_MIN_SEPARATION", "0.25")
MATCH_PROFILE_MIN_SAMPLES = _getenv_int("MATCH_PROFILE_MIN_SAMPLES", "10")
MATCH_INCLUDE_MTF_CROSS = _getenv("MATCH_INCLUDE_MTF_CROSS", "1").strip() in (
"1",
"true",
"True",
"yes",
)
MATCH_LIVE_LOOKBACK_DAYS = _getenv_int("MATCH_LIVE_LOOKBACK_DAYS", "14")
MATCH_LIVE_CACHE_SEC = _getenv_int("MATCH_LIVE_CACHE_SEC", "180")
MATCH_LABEL_MODE = _getenv("MATCH_LABEL_MODE", "leg_gt")
MATCH_MAX_HOLD_DAYS = _getenv_int("MATCH_MAX_HOLD_DAYS", "45")
MATCH_INCLUDE_ATOMIC = _getenv("MATCH_INCLUDE_ATOMIC", "0").strip() in (
"1",
"true",
"True",
"yes",
)
MATCH_HOLDOUT_RATIO = _getenv_float("MATCH_HOLDOUT_RATIO", "0.15")
MATCH_MIN_FIRES_HOLDOUT = _getenv_int("MATCH_MIN_FIRES_HOLDOUT", "5")
MATCH_MONITOR_MAX_PER_SIDE = _getenv_int("MATCH_MONITOR_MAX_PER_SIDE", "1")
MONITOR_ALERT_COOLDOWN_MIN = _getenv_int("MONITOR_ALERT_COOLDOWN_MIN", "3")
MATCH_KIND_PRIORITY: tuple[str, ...] = tuple(
x.strip()
for x in _getenv(
"MATCH_KIND_PRIORITY",
"mtf_cross,compound_tight,contrast,compound,atomic,wide",
).split(",")
if x.strip()
)
# --- Simulation: 시뮬레이션 리포트 ---
SIM_WALK_FORWARD_MIN_MONTHS = _getenv_int("SIM_WALK_FORWARD_MIN_MONTHS", "3")
SIM_FEE_STRESS_MULT = _getenv_float("SIM_FEE_STRESS_MULT", "2.0")
SIM_GO_MIN_HOLDOUT_EV = _getenv_float("SIM_GO_MIN_HOLDOUT_EV", "0.0")
SIM_GO_MIN_HOLDOUT_PF = _getenv_float("SIM_GO_MIN_HOLDOUT_PF", "1.0")
SIM_GO_WF_POSITIVE_RATIO = _getenv_float("SIM_GO_WF_POSITIVE_RATIO", "0.5")
# hybrid DD tier 승격·검증 (Option C +300%)
SIM_HYBRID_MIN_HOLDOUT_PNL_PCT = _getenv_float("SIM_HYBRID_MIN_HOLDOUT_PNL_PCT", "0.0")
SIM_HYBRID_MAX_MDD_PCT = _getenv_float("SIM_HYBRID_MAX_MDD_PCT", "30.0")
SIM_OPTION_C_TARGET_PNL_PCT = _getenv_float("SIM_OPTION_C_TARGET_PNL_PCT", "300.0")
SIM_OPTION_C_PHASE2_TARGET_PNL_PCT = _getenv_float("SIM_OPTION_C_PHASE2_TARGET_PNL_PCT", "1000.0")
SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO = _getenv_float("SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO", "0.85")
SIM_OPTION_C_MIN_GT_CAPTURE = _getenv_float("SIM_OPTION_C_MIN_GT_CAPTURE", "0.23")
SIM_HYBRID_PORTFOLIO_WF_MIN_RATIO = _getenv_float("SIM_HYBRID_PORTFOLIO_WF_MIN_RATIO", "0.5")
SIM_PRIMARY_SIZING = _getenv("SIM_PRIMARY_SIZING", "auto").strip().lower()
if SIM_PRIMARY_SIZING not in ("auto", "hybrid", "causal_tier"):
SIM_PRIMARY_SIZING = "auto"
# --- Operations: 실거래 (LIVE=1, 시뮬 Go·monitor_rules 선행) ---
LIVE_TRADING_ENABLED = _getenv("LIVE_TRADING_ENABLED", "1").strip() in (
"1",
"true",
"True",
"yes",
)
LIVE_ORDER_KRW = _getenv_int("LIVE_ORDER_KRW", "40000")
LIVE_BUY_PCT_LARGE = _getenv_float("LIVE_BUY_PCT_LARGE", "1.0")
LIVE_BUY_PCT_SMALL = _getenv_float("LIVE_BUY_PCT_SMALL", "0.05")
# 운영 기본: 일한도=초기자금 1배 (GT_INITIAL_CASH_KRW).
LIVE_DAILY_KRW_MAX = _getenv_int("LIVE_DAILY_KRW_MAX", "400000")
LIVE_COOLDOWN_MIN = _getenv_int("LIVE_COOLDOWN_MIN", "3")
LIVE_MAX_TRADES_PER_DAY = _getenv_int("LIVE_MAX_TRADES_PER_DAY", "15")
LIVE_DAILY_LOSS_LIMIT_KRW = _getenv_int("LIVE_DAILY_LOSS_LIMIT_KRW", "40000")
LIVE_SLIPPAGE_PCT = _getenv_float("LIVE_SLIPPAGE_PCT", "0.05")
# 06: fire_outcomes monitor 발화로 hybrid 이력 부트스트랩 (시뮬 sim_causal_hybrid 정합)
LIVE_HYBRID_BOOTSTRAP_FIRES = _getenv("LIVE_HYBRID_BOOTSTRAP_FIRES", "1").strip() in (
"1",
"true",
"True",
"yes",
)
# --- 일일 수익률 텔레그램 (07_daily_pnl_telegram) ---
DAILY_PNL_REPORT_ENABLED = _getenv("DAILY_PNL_REPORT_ENABLED", "1").strip() in (
"1",
"true",
"True",
"yes",
)
DAILY_PNL_REPORT_HOUR = _getenv_int("DAILY_PNL_REPORT_HOUR", "19")
DAILY_PNL_REPORT_MINUTE = _getenv_int("DAILY_PNL_REPORT_MINUTE", "0")
DAILY_PNL_REPORT_TZ = _getenv("DAILY_PNL_REPORT_TZ", "Asia/Seoul")
# 06 루프마다 스냅샷 적재 → 24시간 전 자산 비교 가능
DAILY_PNL_SNAPSHOT_ON_LIVE = _getenv("DAILY_PNL_SNAPSHOT_ON_LIVE", "1").strip() in (
"1",
"true",
"True",
"yes",
)
DAILY_PNL_SNAPSHOT_RETENTION_DAYS = _getenv_int("DAILY_PNL_SNAPSHOT_RETENTION_DAYS", "90")

File diff suppressed because it is too large Load Diff

View File

View File

@@ -1,14 +0,0 @@
"""
DeepCoin 패키지 — WLD MTF 분석·정답·운영 단계.
"""
from pathlib import Path
import sys
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
from deepcoin.paths import ensure_dirs
ensure_dirs()

View File

@@ -1,153 +0,0 @@
"""
general_analysis MTF 합성·정렬 점수.
"""
from __future__ import annotations
from typing import Any
import pandas as pd
from config import (
ALIGN_BB_POS_HIGH,
ALIGN_BB_POS_LOW,
ALIGN_RSI_CONFLICT_TIMING_HIGH,
ALIGN_RSI_CONFLICT_TIMING_LOW,
ALIGN_RSI_CONFLICT_TREND_HIGH,
ALIGN_RSI_CONFLICT_TREND_LOW,
ALIGN_RSI_OVERBOUGHT,
ALIGN_RSI_OVERSOLD,
)
from deepcoin.analysis.general_analysis_config import TIMING_INTERVALS, TREND_INTERVALS
from deepcoin.analysis.general_analysis_core import ga_col, interval_tf_prefix
def general_analysis_mtf_scores(
prefixed_row: dict[str, Any],
) -> dict[str, float | int | str]:
"""
간격 접두사가 붙은 스냅샷 행에서 MTF 합성 점수 계산.
Args:
prefixed_row: m3_ga_rsi 형태 flat dict.
Returns:
ga_align_* 점수.
"""
rsi_oversold = 0
rsi_overbought = 0
trend_up = 0
trend_down = 0
n_timing = 0
n_trend = 0
conflict = 0
for interval in TIMING_INTERVALS:
p = interval_tf_prefix(interval)
rk = f"{p}_RSI"
if rk in prefixed_row and prefixed_row[rk] is not None:
n_timing += 1
rsi = float(prefixed_row[rk])
if rsi < ALIGN_RSI_OVERSOLD:
rsi_oversold += 1
if rsi > ALIGN_RSI_OVERBOUGHT:
rsi_overbought += 1
for interval in TREND_INTERVALS:
p = interval_tf_prefix(interval)
sk = f"{p}_{ga_col('struct_trend')}"
if sk in prefixed_row:
n_trend += 1
t = prefixed_row[sk]
if t == "up":
trend_up += 1
elif t == "down":
trend_down += 1
m3_rsi = prefixed_row.get("m3_RSI")
d1_rsi = prefixed_row.get("d1_RSI")
if m3_rsi is not None and d1_rsi is not None:
if (
float(m3_rsi) < ALIGN_RSI_CONFLICT_TIMING_LOW
and float(d1_rsi) > ALIGN_RSI_CONFLICT_TREND_HIGH
):
conflict = 1
if (
float(m3_rsi) > ALIGN_RSI_CONFLICT_TIMING_HIGH
and float(d1_rsi) < ALIGN_RSI_CONFLICT_TREND_LOW
):
conflict = 1
timing_buy_align = rsi_oversold / max(len(TIMING_INTERVALS), 1)
timing_sell_align = rsi_overbought / max(len(TIMING_INTERVALS), 1)
return {
"ga_align_rsi_oversold_tf": rsi_oversold,
"ga_align_rsi_overbought_tf": rsi_overbought,
"ga_align_trend_up_tf": trend_up,
"ga_align_trend_down_tf": trend_down,
"ga_align_timing_buy_score": round(timing_buy_align, 3),
"ga_align_timing_sell_score": round(timing_sell_align, 3),
"ga_align_trend_score": round(
(trend_up - trend_down) / max(n_trend, 1), 3
),
"ga_align_mtf_conflict": conflict,
}
def general_analysis_mtf_vote_latest(
frames_enriched: dict[int, pd.DataFrame],
) -> dict[str, float | int | str]:
"""
각 TF 최신 완성봉 지표로 TF 가중 투표·필터 점수 산출.
Args:
frames_enriched: interval → enrich된 DataFrame.
Returns:
ga_vote_* 점수 (접두사 없음, ga_col로 감쌀 것).
"""
votes_buy = 0
votes_sell = 0
trend_ok = 0
n = 0
for interval in TIMING_INTERVALS:
df = frames_enriched.get(interval)
if df is None or df.empty:
continue
row = df.iloc[-1]
n += 1
rsi = row.get("RSI")
if rsi is not None and not pd.isna(rsi):
if float(rsi) < ALIGN_RSI_OVERSOLD:
votes_buy += 1
if float(rsi) > ALIGN_RSI_OVERBOUGHT:
votes_sell += 1
bb_pos = row.get("bb_pos")
if bb_pos is not None and float(bb_pos) < ALIGN_BB_POS_LOW:
votes_buy += 1
if bb_pos is not None and float(bb_pos) > ALIGN_BB_POS_HIGH:
votes_sell += 1
for interval in TREND_INTERVALS:
df = frames_enriched.get(interval)
if df is None or df.empty:
continue
row = df.iloc[-1]
st = row.get(ga_col("struct_trend"), "range")
if st == "up":
trend_ok += 1
elif st == "down":
trend_ok -= 1
return {
"vote_timing_buy": votes_buy,
"vote_timing_sell": votes_sell,
"vote_trend_score": trend_ok,
"vote_tf_used": n,
}
def general_analysis_vote_columns() -> list[str]:
return ["vote_timing_buy", "vote_timing_sell", "vote_trend_score", "vote_tf_used"]

View File

@@ -1,114 +0,0 @@
"""
general_analysis 캔들·차트 변환 (Heikin-Ashi, 복수봉 패턴).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from deepcoin.analysis.general_analysis_core import ga_col
def general_analysis_apply_candles(df: pd.DataFrame) -> pd.DataFrame:
"""
단일·복수 봉 캔들 패턴 및 Heikin-Ashi 컬럼 추가.
Args:
df: OHLCV.
Returns:
ga_* 캔들 컬럼이 추가된 DataFrame.
"""
out = df.copy()
o = out["Open"].astype(float)
h = out["High"].astype(float)
l = out["Low"].astype(float)
c = out["Close"].astype(float)
rng = (h - l).replace(0, np.nan)
body = (c - o).abs()
out[ga_col("body_ratio")] = (body / rng).fillna(0).clip(0, 1)
upper_wick = h - np.maximum(o, c)
lower_wick = np.minimum(o, c) - l
out[ga_col("upper_wick_ratio")] = (upper_wick / rng).fillna(0).clip(0, 1)
out[ga_col("lower_wick_ratio")] = (lower_wick / rng).fillna(0).clip(0, 1)
out[ga_col("bullish")] = (c > o).astype(int)
out[ga_col("bearish")] = (c < o).astype(int)
out[ga_col("hammer")] = (
(out[ga_col("lower_wick_ratio")] > 0.45) & (out[ga_col("body_ratio")] < 0.35)
).astype(int)
out[ga_col("shooting_star")] = (
(out[ga_col("upper_wick_ratio")] > 0.45) & (out[ga_col("body_ratio")] < 0.35)
).astype(int)
out[ga_col("doji")] = (out[ga_col("body_ratio")] < 0.1).astype(int)
prev_o, prev_c = o.shift(1), c.shift(1)
out[ga_col("bullish_engulfing")] = (
(c > o) & (prev_c < prev_o) & (c >= prev_o) & (o <= prev_c)
).astype(int)
out[ga_col("bearish_engulfing")] = (
(c < o) & (prev_c > prev_o) & (c <= prev_o) & (o >= prev_c)
).astype(int)
o2, c2 = o.shift(2), c.shift(2)
mid1 = (o.shift(1) + c.shift(1)) / 2
out[ga_col("morning_star")] = (
(c2 < o2)
& (abs(c.shift(1) - o.shift(1)) < rng.shift(1) * 0.15)
& (c > o)
& (c > mid1)
).astype(int)
out[ga_col("evening_star")] = (
(c2 > o2)
& (abs(c.shift(1) - o.shift(1)) < rng.shift(1) * 0.15)
& (c < o)
& (c < mid1)
).astype(int)
out[ga_col("three_white_soldiers")] = (
(c > o)
& (c.shift(1) > o.shift(1))
& (c.shift(2) > o.shift(2))
& (c > c.shift(1))
& (c.shift(1) > c.shift(2))
).astype(int)
out[ga_col("three_black_crows")] = (
(c < o)
& (c.shift(1) < o.shift(1))
& (c.shift(2) < o.shift(2))
& (c < c.shift(1))
& (c.shift(1) < c.shift(2))
).astype(int)
# Heikin-Ashi
ha_close = (o + h + l + c) / 4
ha_open = ha_close.copy()
ha_open.iloc[0] = (o.iloc[0] + c.iloc[0]) / 2
for i in range(1, len(out)):
ha_open.iloc[i] = (ha_open.iloc[i - 1] + ha_close.iloc[i - 1]) / 2
out[ga_col("ha_close")] = ha_close
out[ga_col("ha_open")] = ha_open
out[ga_col("ha_bull")] = (ha_close > ha_open).astype(int)
out[ga_col("ha_trend_up")] = (
(ha_close > ha_close.shift(1)) & (ha_close.shift(1) > ha_close.shift(2))
).astype(int)
return out
def general_analysis_candle_columns() -> list[str]:
return [
"body_ratio",
"hammer",
"shooting_star",
"doji",
"bullish_engulfing",
"bearish_engulfing",
"morning_star",
"evening_star",
"three_white_soldiers",
"three_black_crows",
"ha_bull",
"ha_trend_up",
]

View File

@@ -1,159 +0,0 @@
"""
general_analysis 차트 유형 (캔들·선·바·Renko·P&F).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from deepcoin.analysis.general_analysis_core import ga_col
def _renko_direction_series(close: pd.Series, brick: pd.Series) -> pd.Series:
"""ATR 기반 브릭 크기로 Renko 방향 (+1/-1/0) 시계열."""
n = len(close)
direction = pd.Series(0, index=close.index, dtype=int)
if n < 2:
return direction
price = float(close.iloc[0])
for i in range(1, n):
b = float(brick.iloc[i]) if not np.isnan(brick.iloc[i]) else float(close.diff().abs().median())
if b < 1e-9:
b = 1e-9
c = float(close.iloc[i])
if c >= price + b:
steps = int((c - price) // b)
direction.iloc[i] = 1
price += steps * b
elif c <= price - b:
steps = int((price - c) // b)
direction.iloc[i] = -1
price -= steps * b
return direction
def general_analysis_apply_chart_bars(df: pd.DataFrame) -> pd.DataFrame:
"""
봉 단위 차트 파생 컬럼 (선 기울기, Renko, P&F).
Args:
df: OHLCV (+ ga_atr_14 권장).
Returns:
ga_chart_* 시계열 컬럼 추가.
"""
out = df.copy()
c = out["Close"].astype(float)
h = out["High"].astype(float)
l = out["Low"].astype(float)
out[ga_col("chart_line_slope_1")] = c.diff()
out[ga_col("chart_bar_range_pct")] = (h - l) / c.replace(0, np.nan) * 100
from config import GA_ATR_PERIOD
brick = (
out[ga_col("atr_14")]
if ga_col("atr_14") in out.columns
else (h - l).rolling(GA_ATR_PERIOD).mean()
)
brick = brick.fillna(c.diff().abs().rolling(20).median()).replace(0, np.nan).bfill()
renko_dir = _renko_direction_series(c, brick)
out[ga_col("chart_renko_dir")] = renko_dir
out[ga_col("chart_renko_up")] = (renko_dir == 1).astype(int)
box = brick.fillna(1.0)
pnf = pd.Series(0, index=out.index, dtype=int)
col = 0
for i in range(1, len(c)):
b = float(box.iloc[i])
move = c.iloc[i] - c.iloc[i - 1]
if move >= b:
col += 1
pnf.iloc[i] = 1
elif move <= -b:
col -= 1
pnf.iloc[i] = -1
out[ga_col("chart_pnf_col")] = pnf
if "Volume" in out.columns:
v = out["Volume"].astype(float)
out[ga_col("chart_vol_spike")] = (v > v.rolling(20).mean() * 1.8).astype(int)
if ga_col("ha_trend_up") in out.columns:
out[ga_col("chart_ha_trend")] = out[ga_col("ha_trend_up")]
elif ga_col("ha_bull") in out.columns:
out[ga_col("chart_ha_trend")] = out[ga_col("ha_bull")]
return out
def general_analysis_chart_metrics(df: pd.DataFrame) -> dict[str, object]:
"""
lookback 구간 차트 요약 (마지막 봉 스냅샷용).
Returns:
ga_chart_* dict.
"""
res: dict[str, object] = {
"chart_type_candle": 1,
"chart_line_slope": 0.0,
"chart_bar_range_pct": 0.0,
"chart_ha_trend": 0,
"chart_renko_brick_up_ratio": 0.5,
"chart_renko_dir": 0,
"chart_pnf_col": 0,
"chart_vol_spike": 0,
}
if df is None or len(df) < 5:
return {ga_col(k): v for k, v in res.items()}
row = df.iloc[-1]
c = df["Close"].astype(float)
res["chart_line_slope"] = float((c.iloc[-1] - c.iloc[0]) / max(len(c) - 1, 1))
res["chart_bar_range_pct"] = float(
(df["High"].iloc[-1] - df["Low"].iloc[-1]) / max(c.iloc[-1], 1e-9) * 100
)
if ga_col("chart_renko_dir") in df.columns:
rd = df[ga_col("chart_renko_dir")].astype(float)
up = (rd == 1).sum()
down = (rd == -1).sum()
res["chart_renko_brick_up_ratio"] = round(up / max(up + down, 1), 3)
res["chart_renko_dir"] = int(rd.iloc[-1])
else:
diff = c.diff().fillna(0)
up = (diff > 0).sum()
down = (diff < 0).sum()
res["chart_renko_brick_up_ratio"] = round(up / max(up + down, 1), 3)
if ga_col("chart_pnf_col") in df.columns:
res["chart_pnf_col"] = int(df[ga_col("chart_pnf_col")].iloc[-1])
if ga_col("chart_ha_trend") in df.columns:
res["chart_ha_trend"] = int(row[ga_col("chart_ha_trend")])
elif ga_col("ha_trend_up") in df.columns:
res["chart_ha_trend"] = int(row[ga_col("ha_trend_up")])
if ga_col("chart_vol_spike") in df.columns:
res["chart_vol_spike"] = int(row[ga_col("chart_vol_spike")])
elif "Volume" in df.columns:
v = df["Volume"].astype(float)
res["chart_vol_spike"] = int(v.iloc[-1] > v.iloc[-20:].mean() * 1.8)
return {ga_col(k): v for k, v in res.items()}
def general_analysis_chart_columns() -> list[str]:
return [
"chart_type_candle",
"chart_line_slope",
"chart_line_slope_1",
"chart_bar_range_pct",
"chart_ha_trend",
"chart_renko_brick_up_ratio",
"chart_renko_dir",
"chart_renko_up",
"chart_pnf_col",
"chart_vol_spike",
]

View File

@@ -1,26 +0,0 @@
"""
general_analysis MTF 설정 (config.py 재노출).
"""
from __future__ import annotations
from config import (
CONTEXT_TAIL_ROWS,
GA_COL_PREFIX,
GENERAL_ANALYSIS_INTERVALS,
GROUND_TRUTH_FILE,
INTERVAL_PREFIX,
LOOKBACK_BARS,
REPORTS_ANALYSIS_CAPABILITY_HTML,
REPORTS_ANALYSIS_LATEST_DIR,
REPORTS_ANALYSIS_REPORT_HTML,
REPORTS_ANALYSIS_TRADES_CSV,
TIMING_INTERVALS,
TREND_INTERVALS,
)
DEFAULT_TRADES_FILE = GROUND_TRUTH_FILE
DEFAULT_OUTPUT_CSV = str(REPORTS_ANALYSIS_TRADES_CSV)
DEFAULT_OUTPUT_HTML = str(REPORTS_ANALYSIS_REPORT_HTML)
DEFAULT_CAPABILITY_HTML = str(REPORTS_ANALYSIS_CAPABILITY_HTML)
DEFAULT_LATEST_DIR = str(REPORTS_ANALYSIS_LATEST_DIR)

View File

@@ -1,98 +0,0 @@
"""
general_analysis lookback 컨텍스트 특징 (패턴·파동·VP·하모닉) 봉별 적용.
"""
from __future__ import annotations
import pandas as pd
from deepcoin.analysis.general_analysis_config import CONTEXT_TAIL_ROWS, LOOKBACK_BARS
from deepcoin.analysis.general_analysis_core import ga_col
from deepcoin.analysis.general_analysis_harmonic import (
general_analysis_harmonic_columns,
general_analysis_harmonic_snapshot,
)
from deepcoin.analysis.general_analysis_patterns import general_analysis_apply_patterns_to_bars
from deepcoin.analysis.general_analysis_volume import (
general_analysis_volume_columns,
general_analysis_volume_snapshot,
)
from deepcoin.analysis.general_analysis_wave import general_analysis_apply_wave_to_bars
def general_analysis_apply_volume_to_bars(
df: pd.DataFrame,
interval: int,
tail_rows: int | None = None,
) -> pd.DataFrame:
"""Volume Profile 컬럼을 최근 봉에 롤링 적용."""
out = df.copy()
for k in general_analysis_volume_columns():
out[ga_col(k)] = 0.0 if k != "vp_in_value_area" else 0
lb = LOOKBACK_BARS.get(interval, 80)
n = len(out)
if n < lb + 1:
return out
if tail_rows is None:
tail_rows = CONTEXT_TAIL_ROWS.get(interval, 5000)
start = max(lb, n - tail_rows)
for i in range(start, n):
snap = general_analysis_volume_snapshot(out.iloc[i - lb : i])
idx = out.index[i]
for k, v in snap.items():
out.at[idx, k] = v
return out
def general_analysis_apply_harmonic_to_bars(
df: pd.DataFrame,
interval: int,
tail_rows: int | None = None,
) -> pd.DataFrame:
"""하모닉 패턴 컬럼 롤링 적용."""
out = df.copy()
for k in general_analysis_harmonic_columns():
default = "none" if k == "harmonic_label" else 0
out[ga_col(k)] = default
lb = LOOKBACK_BARS.get(interval, 80)
n = len(out)
if n < lb + 1:
return out
if tail_rows is None:
tail_rows = CONTEXT_TAIL_ROWS.get(interval, 5000)
start = max(lb, n - tail_rows)
for i in range(start, n):
snap = general_analysis_harmonic_snapshot(out.iloc[i - lb : i])
idx = out.index[i]
for k, v in snap.items():
out.at[idx, k] = v
return out
def general_analysis_apply_context_features(
df: pd.DataFrame,
interval: int,
tail_rows: int | None = None,
) -> pd.DataFrame:
"""
패턴·파동·VP·하모닉 lookback 라벨을 봉 시계열에 병합.
Args:
df: general_analysis_enrich_bars 1단계 결과.
interval: 분봉 간격(분).
tail_rows: 롤링 적용 봉 수 상한.
Returns:
컨텍스트 ga_* 컬럼이 추가된 DataFrame.
"""
out = general_analysis_apply_patterns_to_bars(df, interval, tail_rows)
out = general_analysis_apply_wave_to_bars(out, interval, tail_rows)
out = general_analysis_apply_volume_to_bars(out, interval, tail_rows)
out = general_analysis_apply_harmonic_to_bars(out, interval, tail_rows)
return out

View File

@@ -1,92 +0,0 @@
"""
general_analysis 공통 유틸 (슬라이스·피벗·컬럼 접두사).
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
from deepcoin.analysis.general_analysis_config import GA_COL_PREFIX, LOOKBACK_BARS
def ga_col(name: str) -> str:
"""general_analysis 출력 컬럼명."""
return f"{GA_COL_PREFIX}{name}"
def interval_tf_prefix(interval: int) -> str:
"""간격 접두사 (m3, d1)."""
from deepcoin.analysis.general_analysis_config import INTERVAL_PREFIX
return INTERVAL_PREFIX.get(interval, f"m{interval}")
def prefixed_snapshot(
row: pd.Series,
interval: int,
keys: list[str] | tuple[str, ...],
) -> dict[str, Any]:
"""한 봉의 ga_ 컬럼을 {m3_ga_rsi: ...} 형태로 변환."""
p = interval_tf_prefix(interval)
out: dict[str, Any] = {}
for k in keys:
col = ga_col(k)
if col in row.index:
v = row[col]
out[f"{p}_{col}"] = None if pd.isna(v) else v
return out
def slice_to_timestamp(df: pd.DataFrame, ts: pd.Timestamp) -> pd.DataFrame:
"""타점 시각 이전 완성봉만 (해당 시각 봉 미포함)."""
if df.empty:
return df
if not isinstance(df.index, pd.DatetimeIndex):
df = df.copy()
df.index = pd.to_datetime(df.index)
ts = pd.Timestamp(ts)
if ts.tzinfo is not None and df.index.tz is None:
ts = ts.tz_localize(None)
return df[df.index < ts].copy()
def lookback_slice(df: pd.DataFrame, interval: int, end_ts: pd.Timestamp) -> pd.DataFrame:
"""타점 직전 lookback 구간."""
sliced = slice_to_timestamp(df, end_ts)
n = LOOKBACK_BARS.get(interval, 80)
if len(sliced) > n:
return sliced.iloc[-n:].copy()
return sliced
def find_pivots(
highs: np.ndarray,
lows: np.ndarray,
order: int = 3,
) -> tuple[list[int], list[int]]:
"""국소 고점·저점 인덱스 (양쪽 order 봉보다 극값)."""
peak_idx: list[int] = []
trough_idx: list[int] = []
n = len(highs)
if n < order * 2 + 1:
return peak_idx, trough_idx
for i in range(order, n - order):
if highs[i] >= highs[i - order : i + order + 1].max():
peak_idx.append(i)
if lows[i] <= lows[i - order : i + order + 1].min():
trough_idx.append(i)
return peak_idx, trough_idx
def last_row_dict(df: pd.DataFrame, cols: list[str]) -> dict[str, Any]:
"""마지막 봉의 지정 컬럼 dict."""
if df.empty:
return {ga_col(c): None for c in cols}
row = df.iloc[-1]
return {
ga_col(c): (None if c not in row.index or pd.isna(row[c]) else row[c])
for c in cols
}

View File

@@ -1,148 +0,0 @@
"""
general_analysis 봉 데이터 전구간 enrich + 최신봉·기법 점검 리포트.
python scripts/03_analyze_enrich.py
python scripts/03_analyze_enrich.py --interval 1440
"""
from __future__ import annotations
import argparse
from pathlib import Path
import pandas as pd
from config import CHART_LOOKBACK_DAYS, GA_DEFAULT_TAIL_EXPORT, SYMBOL
from deepcoin.analysis.general_analysis_align import (
general_analysis_mtf_scores,
general_analysis_mtf_vote_latest,
)
from deepcoin.analysis.general_analysis_config import (
DEFAULT_CAPABILITY_HTML,
DEFAULT_LATEST_DIR,
GENERAL_ANALYSIS_INTERVALS,
)
from deepcoin.analysis.general_analysis_core import ga_col, interval_tf_prefix
from deepcoin.analysis.general_analysis_pipeline import general_analysis_enrich_bars
from deepcoin.ops.monitor import Monitor
from deepcoin.data.mtf_bb import load_frames_from_db
def _latest_row_summary(df: pd.DataFrame, prefix: str) -> dict[str, object]:
"""최신 봉의 ga_·핵심 레거시 컬럼 요약."""
if df.empty:
return {}
row = df.iloc[-1]
out: dict[str, object] = {"dt": str(df.index[-1]), "tf": prefix}
for c in df.columns:
if c.startswith("ga_") or c in ("RSI", "bb_pos", "macd_hist", "stoch_k"):
v = row[c]
if pd.isna(v):
continue
out[c] = v
return out
def write_capability_html(
summaries: dict[str, dict[str, object]],
vote: dict[str, object],
path: Path,
) -> None:
"""기법 컬럼 존재 여부·최신값 요약 HTML."""
rows = ""
for tf, snap in summaries.items():
ga_cols = [k for k in snap if str(k).startswith("ga_")]
rows += f"<tr><td>{tf}</td><td>{snap.get('dt','')}</td><td>{len(ga_cols)}</td></tr>"
vote_rows = "".join(f"<li><code>{k}</code>: {v}</li>" for k, v in vote.items())
html = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="utf-8"/>
<title>general_analysis 기법 점검</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f5f5f5; }}
table {{ border-collapse: collapse; width: 100%; background: #fff; }}
th, td {{ border: 1px solid #e2e8f0; padding: 8px; font-size: 0.88rem; }}
th {{ background: #e2e8f0; }}
</style></head><body>
<h1>general_analysis 기법 점검 ({SYMBOL})</h1>
<p>3분~일봉 enrich 완료. 최신 봉 기준 컬럼 수·MTF 투표.</p>
<h2>간격별 ga_ 컬럼 수</h2>
<table><thead><tr><th>TF</th><th>최신 시각</th><th>ga_ 컬럼 수</th></tr></thead>
<tbody>{rows}</tbody></table>
<h2>MTF 투표 (최신 봉)</h2>
<ul>{vote_rows}</ul>
<p>상세 CSV: <code>{DEFAULT_LATEST_DIR}/</code></p>
</body></html>"""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(html, encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="general_analysis 8TF enrich")
parser.add_argument("--interval", type=int, default=0, help="단일 간격만 (0=전체)")
parser.add_argument(
"--tail-export",
type=int,
default=GA_DEFAULT_TAIL_EXPORT,
help="CSV 저장 최근 N봉",
)
args = parser.parse_args()
from deepcoin.paths import ANALYSIS_CAPABILITY_HTML, ANALYSIS_LATEST_DIR
intervals = (
(args.interval,)
if args.interval > 0
else GENERAL_ANALYSIS_INTERVALS
)
print(f"=== general_analysis enrich {SYMBOL} ===")
mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
if not frames:
raise RuntimeError("coins.db 데이터 없음")
enriched: dict[int, pd.DataFrame] = {}
summaries: dict[str, dict[str, object]] = {}
out_dir = ANALYSIS_LATEST_DIR
out_dir.mkdir(parents=True, exist_ok=True)
for iv in intervals:
raw = frames.get(iv)
if raw is None or raw.empty:
print(f" skip {iv}: no data")
continue
p = interval_tf_prefix(iv)
print(f" [{p}] enrich {len(raw)} bars...")
ef = general_analysis_enrich_bars(raw, iv, full_context=True)
enriched[iv] = ef
tail = ef.tail(args.tail_export)
csv_path = out_dir / f"{p}_latest.csv"
tail.to_csv(csv_path, encoding="utf-8-sig")
print(f" -> {csv_path} ({len(tail)} rows x {len(tail.columns)} cols)")
summaries[p] = _latest_row_summary(ef, p)
flat_vote: dict[str, object] = {}
if len(enriched) >= 2:
for k, v in general_analysis_mtf_vote_latest(enriched).items():
flat_vote[ga_col(k)] = v
prefixed = {}
for iv, ef in enriched.items():
p = interval_tf_prefix(iv)
row = ef.iloc[-1]
for c in ("RSI", "bb_pos"):
if c in row.index:
prefixed[f"{p}_{c}"] = row[c]
st = row.get(ga_col("struct_trend"))
if st is not None:
prefixed[f"{p}_{ga_col('struct_trend')}"] = st
flat_vote.update(general_analysis_mtf_scores(prefixed))
write_capability_html(summaries, flat_vote, ANALYSIS_CAPABILITY_HTML)
print(f"점검 리포트: {ANALYSIS_CAPABILITY_HTML}")
print("완료.")
if __name__ == "__main__":
main()

View File

@@ -1,72 +0,0 @@
"""
general_analysis 하모닉 패턴 (Gartley, Bat 근사).
"""
from __future__ import annotations
import numpy as np
from deepcoin.analysis.general_analysis_core import find_pivots, ga_col
def _ratio(a: float, b: float) -> float:
if abs(b) < 1e-12:
return 0.0
return abs(a / b)
def _near(x: float, target: float, tol: float = 0.08) -> bool:
return abs(x - target) <= tol
def general_analysis_harmonic_snapshot(win) -> dict[str, object]:
"""
최근 5개 피벗으로 Gartley·Bat 유사 비율 검사.
Args:
win: OHLCV DataFrame.
Returns:
ga_harmonic_* dict.
"""
res: dict[str, object] = {
"harmonic_gartley": 0,
"harmonic_bat": 0,
"harmonic_label": "none",
}
if win is None or len(win) < 30:
return {ga_col(k): v for k, v in res.items()}
h = win["High"].astype(float).values
l = win["Low"].astype(float).values
peaks, troughs = find_pivots(h, l, order=2)
pivots = sorted([(i, "H", h[i]) for i in peaks] + [(i, "L", l[i]) for i in troughs])
if len(pivots) < 5:
return {ga_col(k): v for k, v in res.items()}
pts = pivots[-5:]
prices = [p[2] for p in pts]
xa = abs(prices[1] - prices[0])
ab = abs(prices[2] - prices[1])
bc = abs(prices[3] - prices[2])
cd = abs(prices[4] - prices[3])
if xa < 1e-9:
return {ga_col(k): v for k, v in res.items()}
r_ab = _ratio(ab, xa)
r_bc = _ratio(bc, ab) if ab > 1e-9 else 0.0
r_cd = _ratio(cd, bc) if bc > 1e-9 else 0.0
if _near(r_ab, 0.618) and 0.35 <= r_bc <= 0.95 and 1.1 <= r_cd <= 1.75:
res["harmonic_gartley"] = 1
res["harmonic_label"] = "gartley"
if _near(r_ab, 0.382) and 0.35 <= r_bc <= 0.95 and 1.5 <= r_cd <= 2.1:
res["harmonic_bat"] = 1
res["harmonic_label"] = "bat"
return {ga_col(k): v for k, v in res.items()}
def general_analysis_harmonic_columns() -> list[str]:
return ["harmonic_gartley", "harmonic_bat", "harmonic_label"]

View File

@@ -1,382 +0,0 @@
"""
general_analysis 확장 기술적 지표 (추세·모멘텀·변동성·거래량).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from config import (
BB_PERIOD,
BB_STD,
GA_ADX_PERIOD,
GA_ADX_TREND_THRESHOLD,
GA_AO_FAST,
GA_AO_SLOW,
GA_ATR_PERIOD,
GA_BB_SQUEEZE_QUANTILE,
GA_BB_SQUEEZE_WINDOW,
GA_CCI_PERIOD,
GA_CMF_PERIOD,
GA_DIVERGENCE_LOOKBACK,
GA_DONCHIAN_PERIOD,
GA_EMA_SPANS,
GA_HV_ANNUALIZE_SQRT,
GA_HV_PERCENTILE_WINDOW,
GA_HV_ROLLING_BARS,
GA_KELTNER_ATR_MULT,
GA_LINREG_WINDOW,
GA_MFI_PERIOD,
GA_PSAR_AF_MAX,
GA_PSAR_AF_START,
GA_PSAR_AF_STEP,
GA_ROC_PERIOD,
GA_SMA_PERIODS,
GA_SUPERTREND_ATR_MULT,
GA_VOL_MA_WINDOW,
GA_WILLIAMS_PERIOD,
)
from deepcoin.analysis.general_analysis_core import ga_col
from deepcoin.common.indicators import apply_bar_indicators
def _ema(series: pd.Series, span: int) -> pd.Series:
return series.ewm(span=span, adjust=False).mean()
def _parabolic_sar(
high: np.ndarray,
low: np.ndarray,
af_start: float = GA_PSAR_AF_START,
af_step: float = GA_PSAR_AF_STEP,
af_max: float = GA_PSAR_AF_MAX,
) -> tuple[np.ndarray, np.ndarray]:
"""
Parabolic SAR 시계열.
Returns:
(sar, bull_flag 0/1)
"""
n = len(high)
sar = np.zeros(n)
bull = np.ones(n, dtype=int)
if n < 2:
return sar, bull
is_bull = True
af = af_start
ep = high[0]
sar[0] = low[0]
for i in range(1, n):
prev_sar = sar[i - 1]
if is_bull:
sar[i] = prev_sar + af * (ep - prev_sar)
sar[i] = min(sar[i], low[i - 1], low[i] if i > 0 else low[i - 1])
if low[i] < sar[i]:
is_bull = False
sar[i] = ep
ep = low[i]
af = af_start
else:
if high[i] > ep:
ep = high[i]
af = min(af + af_step, af_max)
else:
sar[i] = prev_sar + af * (ep - prev_sar)
sar[i] = max(sar[i], high[i - 1], high[i] if i > 0 else high[i - 1])
if high[i] > sar[i]:
is_bull = True
sar[i] = ep
ep = high[i]
af = af_start
else:
if low[i] < ep:
ep = low[i]
af = min(af + af_step, af_max)
bull[i] = int(is_bull)
return sar, bull
def general_analysis_apply_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""
기존 apply_bar_indicators 위에 ga_ 접두사 확장 지표를 추가합니다.
Args:
df: OHLCV.
Returns:
ga_* 컬럼이 추가된 DataFrame.
"""
out = apply_bar_indicators(df.copy())
o = out["Open"].astype(float)
h = out["High"].astype(float)
l = out["Low"].astype(float)
c = out["Close"].astype(float)
v = out["Volume"].astype(float)
# --- 추세: MA ---
sma_fast = GA_SMA_PERIODS[0] if GA_SMA_PERIODS else 5
sma_slow = GA_SMA_PERIODS[1] if len(GA_SMA_PERIODS) > 1 else 20
for p in GA_SMA_PERIODS:
ma = c.rolling(p).mean()
out[ga_col(f"sma_{p}")] = ma
out[ga_col(f"close_vs_sma_{p}_pct")] = (c / ma.replace(0, np.nan) - 1) * 100
ema_fast = GA_EMA_SPANS[0] if GA_EMA_SPANS else 12
ema_slow = GA_EMA_SPANS[1] if len(GA_EMA_SPANS) > 1 else 26
out[ga_col(f"ema_{ema_fast}")] = _ema(c, ema_fast)
out[ga_col(f"ema_{ema_slow}")] = _ema(c, ema_slow)
out[ga_col("golden_cross")] = (
(out[ga_col(f"sma_{sma_fast}")] > out[ga_col(f"sma_{sma_slow}")])
& (out[ga_col(f"sma_{sma_fast}")].shift(1) <= out[ga_col(f"sma_{sma_slow}")].shift(1))
).astype(int)
out[ga_col("death_cross")] = (
(out[ga_col(f"sma_{sma_fast}")] < out[ga_col(f"sma_{sma_slow}")])
& (out[ga_col(f"sma_{sma_fast}")].shift(1) >= out[ga_col(f"sma_{sma_slow}")].shift(1))
).astype(int)
# --- ATR / 변동성 ---
tr = pd.concat(
[
h - l,
(h - c.shift(1)).abs(),
(l - c.shift(1)).abs(),
],
axis=1,
).max(axis=1)
atr = tr.rolling(GA_ATR_PERIOD).mean()
out[ga_col("atr_14")] = atr
out[ga_col("atr_pct")] = atr / c.replace(0, np.nan) * 100
out[ga_col("bb_width_pct")] = out.get("BB_Width", (out["Upper"] - out["Lower"]) / out["MA"] * 100)
bw = out[ga_col("bb_width_pct")].astype(float)
out[ga_col("bb_squeeze")] = (
bw < bw.rolling(GA_BB_SQUEEZE_WINDOW).quantile(GA_BB_SQUEEZE_QUANTILE)
).astype(int)
dc = GA_DONCHIAN_PERIOD
out[ga_col("donchian_high_20")] = h.rolling(dc).max()
out[ga_col("donchian_low_20")] = l.rolling(dc).min()
out[ga_col("donchian_pos")] = (c - out[ga_col("donchian_low_20")]) / (
out[ga_col("donchian_high_20")] - out[ga_col("donchian_low_20")]
).replace(0, np.nan)
# --- 모멘텀: CCI, Williams %R ---
tp = (h + l + c) / 3
cci_period = GA_CCI_PERIOD
sma_tp = tp.rolling(cci_period).mean()
mad = tp.rolling(cci_period).apply(lambda x: np.abs(x - x.mean()).mean(), raw=True)
out[ga_col("cci_20")] = (tp - sma_tp) / (0.015 * mad.replace(0, np.nan))
out[ga_col("cci_oversold")] = (out[ga_col("cci_20")] < -100).astype(int)
out[ga_col("cci_overbought")] = (out[ga_col("cci_20")] > 100).astype(int)
hh = h.rolling(GA_WILLIAMS_PERIOD).max()
ll = l.rolling(GA_WILLIAMS_PERIOD).min()
out[ga_col("williams_r")] = (hh - c) / (hh - ll).replace(0, np.nan) * -100
out[ga_col("williams_oversold")] = (out[ga_col("williams_r")] < -80).astype(int)
out[ga_col("williams_overbought")] = (out[ga_col("williams_r")] > -20).astype(int)
div_lb = GA_DIVERGENCE_LOOKBACK
out[ga_col("roc_10")] = (c / c.shift(GA_ROC_PERIOD).replace(0, np.nan) - 1) * 100
# MFI
raw_mf = tp * v
pos_mf = raw_mf.where(tp > tp.shift(1), 0.0).rolling(GA_MFI_PERIOD).sum()
neg_mf = raw_mf.where(tp < tp.shift(1), 0.0).rolling(GA_MFI_PERIOD).sum()
mfr = pos_mf / neg_mf.replace(0, np.nan)
out[ga_col("mfi_14")] = 100 - (100 / (1 + mfr))
# MACD / RSI / Stoch 다이버전스
if "RSI" in out.columns and "macd_hist" in out.columns:
price_up = (c > c.shift(div_lb)).astype(int)
rsi_up = (out["RSI"] > out["RSI"].shift(div_lb)).astype(int)
macd_up = (out["macd_hist"] > out["macd_hist"].shift(div_lb)).astype(int)
out[ga_col("rsi_bull_div")] = ((price_up == 0) & (rsi_up == 1)).astype(int)
out[ga_col("rsi_bear_div")] = ((price_up == 1) & (rsi_up == 0)).astype(int)
out[ga_col("macd_bull_div")] = ((price_up == 0) & (macd_up == 1)).astype(int)
out[ga_col("macd_bear_div")] = ((price_up == 1) & (macd_up == 0)).astype(int)
if "stoch_k" in out.columns:
price_up = (c > c.shift(div_lb)).astype(int)
st_up = (out["stoch_k"] > out["stoch_k"].shift(div_lb)).astype(int)
out[ga_col("stoch_bull_div")] = ((price_up == 0) & (st_up == 1)).astype(int)
out[ga_col("stoch_bear_div")] = ((price_up == 1) & (st_up == 0)).astype(int)
# 봉 간 변화 (타점 Δ와 동일 정의, 전 구간)
if "RSI" in out.columns:
out[ga_col("rsi_delta_1")] = out["RSI"].diff()
if "macd_hist" in out.columns:
out[ga_col("macd_hist_delta_1")] = out["macd_hist"].diff()
if "stoch_k" in out.columns:
out[ga_col("stoch_k_delta_1")] = out["stoch_k"].diff()
# --- 거래량 ---
vol_ma = v.rolling(GA_VOL_MA_WINDOW).mean()
out[ga_col("vol_ma20")] = vol_ma
out[ga_col("vol_ratio")] = v / vol_ma.replace(0, np.nan)
out[ga_col("obv")] = (np.sign(c.diff().fillna(0)) * v).cumsum()
obv = out[ga_col("obv")].astype(float)
out[ga_col("obv_slope_10")] = obv - obv.shift(div_lb)
out[ga_col("obv_bull_div")] = (
(c < c.shift(div_lb)) & (obv > obv.shift(div_lb))
).astype(int)
out[ga_col("obv_bear_div")] = (
(c > c.shift(div_lb)) & (obv < obv.shift(div_lb))
).astype(int)
# CMF
mfv = ((c - l) - (h - c)) / (h - l).replace(0, np.nan) * v
out[ga_col("cmf_20")] = (
mfv.rolling(GA_CMF_PERIOD).sum() / v.rolling(GA_CMF_PERIOD).sum().replace(0, np.nan)
)
# Accumulation/Distribution Line
clv = ((c - l) - (h - c)) / (h - l).replace(0, np.nan)
out[ga_col("ad_line")] = (clv * v).cumsum()
ad = out[ga_col("ad_line")].astype(float)
out[ga_col("ad_slope_10")] = ad - ad.shift(div_lb)
# VWAP 근사 (누적, 세션 리셋 없음)
cum_vp = (tp * v).cumsum()
cum_v = v.cumsum().replace(0, np.nan)
out[ga_col("vwap")] = cum_vp / cum_v
out[ga_col("close_vs_vwap_pct")] = (c / out[ga_col("vwap")] - 1) * 100
# Keltner Channel
k_mid = _ema(c, BB_PERIOD)
out[ga_col("keltner_mid")] = k_mid
out[ga_col("keltner_upper")] = k_mid + GA_KELTNER_ATR_MULT * atr
out[ga_col("keltner_lower")] = k_mid - GA_KELTNER_ATR_MULT * atr
out[ga_col("keltner_pos")] = (c - out[ga_col("keltner_lower")]) / (
out[ga_col("keltner_upper")] - out[ga_col("keltner_lower")]
).replace(0, np.nan)
# Awesome Oscillator: median price SMA5 - SMA34
mp = (h + l) / 2
out[ga_col("ao")] = mp.rolling(GA_AO_FAST).mean() - mp.rolling(GA_AO_SLOW).mean()
out[ga_col("ao_bull")] = (
(out[ga_col("ao")] > 0) & (out[ga_col("ao")].shift(1) <= 0)
).astype(int)
out[ga_col("ao_bear")] = (
(out[ga_col("ao")] < 0) & (out[ga_col("ao")].shift(1) >= 0)
).astype(int)
# Historical Volatility (로그수익 20봉 표준편차, 연율화 계수 1=봉 단위)
log_ret = np.log(c / c.shift(1).replace(0, np.nan))
hv = log_ret.rolling(GA_HV_ROLLING_BARS).std() * GA_HV_ANNUALIZE_SQRT
out[ga_col("hv_20")] = hv
out[ga_col("hv_percentile")] = hv.rolling(GA_HV_PERCENTILE_WINDOW).apply(
lambda x: float((x[:-1] < x[-1]).mean()) if len(x) > 1 and not np.isnan(x[-1]) else 0.5,
raw=True,
)
# Parabolic SAR
sar, psar_bull = _parabolic_sar(h.values, l.values)
out[ga_col("psar")] = sar
out[ga_col("psar_bull")] = psar_bull
ps = pd.Series(psar_bull, index=out.index)
out[ga_col("psar_flip_bull")] = ((ps == 1) & (ps.shift(1) == 0)).astype(int)
out[ga_col("psar_flip_bear")] = ((ps == 0) & (ps.shift(1) == 1)).astype(int)
# ADX
up_move = h.diff()
down_move = -l.diff()
plus_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0)
minus_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0)
atr_safe = atr.replace(0, np.nan)
plus_di = 100 * pd.Series(plus_dm, index=out.index).rolling(GA_ADX_PERIOD).mean() / atr_safe
minus_di = 100 * pd.Series(minus_dm, index=out.index).rolling(GA_ADX_PERIOD).mean() / atr_safe
dx = (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan) * 100
out[ga_col("adx_14")] = dx.rolling(GA_ADX_PERIOD).mean()
out[ga_col("plus_di")] = plus_di
out[ga_col("minus_di")] = minus_di
out[ga_col("adx_trending")] = (out[ga_col("adx_14")] > GA_ADX_TREND_THRESHOLD).astype(int)
# Supertrend 방향 (ATR 밴드)
hl2 = (h + l) / 2
upper = hl2 + GA_SUPERTREND_ATR_MULT * atr
lower = hl2 - GA_SUPERTREND_ATR_MULT * atr
out[ga_col("supertrend_bull")] = (c > lower).astype(int)
# Linear regression slope 20
def _lin_slope(y: np.ndarray) -> float:
if len(y) < 2:
return 0.0
x = np.arange(len(y))
coef = np.polyfit(x, y, 1)
return float(coef[0])
out[ga_col("linreg_slope_20")] = c.rolling(GA_LINREG_WINDOW).apply(_lin_slope, raw=True)
def _lin_r2(y: np.ndarray) -> float:
if len(y) < 3:
return 0.0
x = np.arange(len(y))
coef = np.polyfit(x, y, 1)
pred = coef[0] * x + coef[1]
ss_res = ((y - pred) ** 2).sum()
ss_tot = ((y - y.mean()) ** 2).sum()
if ss_tot < 1e-12:
return 0.0
return float(1 - ss_res / ss_tot)
out[ga_col("linreg_r2_20")] = c.rolling(GA_LINREG_WINDOW).apply(_lin_r2, raw=True)
return out
def general_analysis_indicator_columns() -> list[str]:
"""스냅샷용 ga_ 지표 컬럼 목록."""
return [
"sma_5",
"sma_20",
"sma_60",
"close_vs_sma_20_pct",
"golden_cross",
"death_cross",
"atr_14",
"atr_pct",
"bb_squeeze",
"donchian_pos",
"cci_20",
"cci_oversold",
"cci_overbought",
"williams_r",
"williams_oversold",
"williams_overbought",
"roc_10",
"mfi_14",
"rsi_bull_div",
"rsi_bear_div",
"macd_bull_div",
"macd_bear_div",
"stoch_bull_div",
"stoch_bear_div",
"rsi_delta_1",
"macd_hist_delta_1",
"stoch_k_delta_1",
"keltner_pos",
"ao",
"ao_bull",
"ao_bear",
"hv_20",
"hv_percentile",
"ad_line",
"ad_slope_10",
"vol_ratio",
"obv_slope_10",
"obv_bull_div",
"obv_bear_div",
"cmf_20",
"close_vs_vwap_pct",
"adx_14",
"adx_trending",
"supertrend_bull",
"linreg_slope_20",
"linreg_r2_20",
"psar",
"psar_bull",
"psar_flip_bull",
"psar_flip_bear",
]

View File

@@ -1,302 +0,0 @@
"""
general_analysis 차트·가격 패턴 (반전·지속·박스).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from config import GA_PATTERN_TOLERANCE_PCT, GA_PIVOT_ORDER
from deepcoin.analysis.general_analysis_core import find_pivots, ga_col, last_row_dict
def _pct_diff(a: float, b: float) -> float:
return abs(a - b) / max(abs(a), abs(b), 1e-9) * 100
def general_analysis_detect_patterns(win: pd.DataFrame) -> dict[str, int | float | str | None]:
"""
lookback 윈도우 마지막 봉 기준 패턴 라벨 (0/1 및 요약).
Args:
win: OHLCV (+ 지표 선택).
Returns:
ga_pattern_* 키 dict (접두사 없음, ga_col로 감쌈).
"""
res: dict[str, int | float | str | None] = {
"pattern_double_top": 0,
"pattern_double_bottom": 0,
"pattern_head_shoulders": 0,
"pattern_inv_head_shoulders": 0,
"pattern_triangle_sym": 0,
"pattern_triangle_asc": 0,
"pattern_triangle_desc": 0,
"pattern_flag_bull": 0,
"pattern_flag_bear": 0,
"pattern_wedge_rising": 0,
"pattern_wedge_falling": 0,
"pattern_rectangle": 0,
"pattern_channel_up": 0,
"pattern_channel_down": 0,
"pattern_measured_move": 0,
"pattern_rounding_top": 0,
"pattern_rounding_bottom": 0,
"pattern_gap_up": 0,
"pattern_gap_down": 0,
"pattern_v_bottom": 0,
"pattern_spike_top": 0,
"pattern_triple_top": 0,
"pattern_triple_bottom": 0,
"pattern_cup_handle": 0,
"pattern_keystone_bull": 0,
"pattern_keystone_bear": 0,
"pattern_island_top": 0,
"pattern_island_bottom": 0,
"pattern_label": "none",
}
if win is None or len(win) < 20:
return res
h = win["High"].astype(float).values
l = win["Low"].astype(float).values
c = win["Close"].astype(float).values
peaks, troughs = find_pivots(h, l, order=GA_PIVOT_ORDER)
tol = GA_PATTERN_TOLERANCE_PCT
if len(peaks) >= 3:
p1, p2, p3 = peaks[-3], peaks[-2], peaks[-1]
if (
_pct_diff(h[p1], h[p2]) < tol
and _pct_diff(h[p2], h[p3]) < tol
and p1 < p2 < p3
):
res["pattern_triple_top"] = 1
res["pattern_label"] = "triple_top"
if len(troughs) >= 3:
t1, t2, t3 = troughs[-3], troughs[-2], troughs[-1]
if (
_pct_diff(l[t1], l[t2]) < tol
and _pct_diff(l[t2], l[t3]) < tol
and t1 < t2 < t3
):
res["pattern_triple_bottom"] = 1
res["pattern_label"] = "triple_bottom"
if len(peaks) >= 2:
p1, p2 = peaks[-2], peaks[-1]
if _pct_diff(h[p1], h[p2]) < tol:
res["pattern_double_top"] = 1
if res["pattern_label"] == "none":
res["pattern_label"] = "double_top"
if len(troughs) >= 2:
t1, t2 = troughs[-2], troughs[-1]
if _pct_diff(l[t1], l[t2]) < tol:
res["pattern_double_bottom"] = 1
res["pattern_label"] = "double_bottom"
if len(peaks) >= 3:
i, j, k = peaks[-3], peaks[-2], peaks[-1]
if h[j] > h[i] and h[j] > h[k] and _pct_diff(h[i], h[k]) < tol * 1.5:
res["pattern_head_shoulders"] = 1
res["pattern_label"] = "head_shoulders"
if len(troughs) >= 3:
i, j, k = troughs[-3], troughs[-2], troughs[-1]
if l[j] < l[i] and l[j] < l[k] and _pct_diff(l[i], l[k]) < tol * 1.5:
res["pattern_inv_head_shoulders"] = 1
res["pattern_label"] = "inv_head_shoulders"
n = len(win)
x = np.arange(n)
high_slope = np.polyfit(x, h, 1)[0]
low_slope = np.polyfit(x, l, 1)[0]
if high_slope < 0 and low_slope > 0:
res["pattern_triangle_sym"] = 1
if res["pattern_label"] == "none":
res["pattern_label"] = "triangle_sym"
if high_slope < 0 and low_slope > 0 and low_slope > abs(high_slope) * 0.5:
res["pattern_triangle_asc"] = 1
if high_slope < 0 and low_slope < 0 and abs(high_slope) > abs(low_slope) * 0.5:
res["pattern_triangle_desc"] = 1
rng_pct = (h.max() - l.min()) / max(c[-1], 1e-9) * 100
if rng_pct < 8 and abs(high_slope) < c[-1] * 0.0001:
res["pattern_rectangle"] = 1
if res["pattern_label"] == "none":
res["pattern_label"] = "rectangle"
leg = max(n // 3, 5)
if n > leg * 2:
first_move = (c[leg] - c[0]) / max(c[0], 1e-9) * 100
channel = (c[-1] - c[-leg]) / max(c[-leg], 1e-9) * 100
if first_move > 5 and abs(channel) < 3:
res["pattern_flag_bull"] = 1
res["pattern_label"] = "flag_bull"
if first_move < -5 and abs(channel) < 3:
res["pattern_flag_bear"] = 1
res["pattern_label"] = "flag_bear"
if high_slope > 0 and low_slope > 0:
res["pattern_wedge_rising"] = 1
if high_slope < 0 and low_slope < 0:
res["pattern_wedge_falling"] = 1
if high_slope > 0 and low_slope > 0:
res["pattern_channel_up"] = 1
if high_slope < 0 and low_slope < 0:
res["pattern_channel_down"] = 1
if len(c) >= 15:
mid = len(c) // 2
first_half = c[:mid].mean()
second_half = c[mid:].mean()
if c[0] > c[mid] * 1.08 and c[-1] > c[mid] * 1.05:
res["pattern_v_bottom"] = 1
res["pattern_label"] = "v_bottom"
if c[0] < c[-1] * 0.92 and c.max() > c[0] * 1.1:
res["pattern_spike_top"] = 1
o = win["Open"].astype(float).values
gap_ups: list[int] = []
gap_downs: list[int] = []
for i in range(1, min(30, n)):
if l[i] > h[i - 1]:
res["pattern_gap_up"] = 1
gap_ups.append(i)
if h[i] < l[i - 1]:
res["pattern_gap_down"] = 1
gap_downs.append(i)
for gi in gap_ups:
for gd in gap_downs:
if gd > gi and h[gi] < l[gd]:
res["pattern_island_top"] = 1
res["pattern_label"] = "island_top"
if gd > gi and l[gi] > h[gd]:
res["pattern_island_bottom"] = 1
res["pattern_label"] = "island_bottom"
# 키리스톤: 상단 수평 + 하단 상승(역키리스톤) 또는 하단 수평 + 상단 하락
if abs(high_slope) < c[-1] * 0.00005 and low_slope > 0:
res["pattern_keystone_bull"] = 1
if res["pattern_label"] == "none":
res["pattern_label"] = "keystone_bull"
if abs(low_slope) < c[-1] * 0.00005 and high_slope < 0:
res["pattern_keystone_bear"] = 1
if res["pattern_label"] == "none":
res["pattern_label"] = "keystone_bear"
# 컵앤핸들: 전반 U자 + 후반 15% 소폭 조정
if n >= 40:
cup_len = int(n * 0.65)
handle_len = max(int(n * 0.15), 5)
cup = c[:cup_len]
handle = c[-handle_len:]
rim = float(max(cup[0], cup[-1]))
bottom = float(cup.min())
depth = rim - bottom
if depth > rim * 0.08 and float(cup[-1]) > bottom + depth * 0.5:
handle_pull = float(handle.max() - handle.min())
if handle_pull < depth * 0.5 and float(c[-1]) >= rim * 0.98:
res["pattern_cup_handle"] = 1
res["pattern_label"] = "cup_handle"
if len(c) >= 30:
ma = pd.Series(c).rolling(10).mean()
if float(ma.iloc[-1]) > float(ma.iloc[-15]) > float(ma.iloc[-30]):
res["pattern_rounding_bottom"] = 1
if float(ma.iloc[-1]) < float(ma.iloc[-15]) < float(ma.iloc[-30]):
res["pattern_rounding_top"] = 1
if len(peaks) >= 2 and len(troughs) >= 2:
leg_h = h[peaks[-1]] - l[troughs[-1]]
if leg_h > 0 and c[-1] >= l[troughs[-1]] + leg_h * 0.9:
res["pattern_measured_move"] = 1
return res
def general_analysis_pattern_snapshot(win: pd.DataFrame) -> dict[str, object]:
"""패턴 dict → ga_pattern_* 컬럼명."""
raw = general_analysis_detect_patterns(win)
return {ga_col(k): v for k, v in raw.items()}
def general_analysis_pattern_columns() -> list[str]:
return [
"pattern_double_top",
"pattern_double_bottom",
"pattern_head_shoulders",
"pattern_inv_head_shoulders",
"pattern_triangle_sym",
"pattern_triangle_asc",
"pattern_triangle_desc",
"pattern_flag_bull",
"pattern_flag_bear",
"pattern_wedge_rising",
"pattern_wedge_falling",
"pattern_rectangle",
"pattern_channel_up",
"pattern_channel_down",
"pattern_measured_move",
"pattern_rounding_top",
"pattern_rounding_bottom",
"pattern_gap_up",
"pattern_gap_down",
"pattern_v_bottom",
"pattern_spike_top",
"pattern_triple_top",
"pattern_triple_bottom",
"pattern_cup_handle",
"pattern_keystone_bull",
"pattern_keystone_bear",
"pattern_island_top",
"pattern_island_bottom",
"pattern_label",
]
def general_analysis_apply_patterns_to_bars(
df: pd.DataFrame,
interval: int,
tail_rows: int | None = None,
) -> pd.DataFrame:
"""
lookback 윈도우 패턴 라벨을 봉별 컬럼으로 채움 (최근 tail_rows만, 성능).
Args:
df: OHLCV (+ 선택적 지표).
interval: 분봉 간격.
tail_rows: None이면 전체(8천봉 이하) 또는 config tail.
Returns:
ga_pattern_* 컬럼이 추가된 DataFrame.
"""
from deepcoin.analysis.general_analysis_config import CONTEXT_TAIL_ROWS, LOOKBACK_BARS
out = df.copy()
lb = LOOKBACK_BARS.get(interval, 80)
keys = [k for k in general_analysis_pattern_columns() if k != "pattern_label"]
for k in keys:
out[ga_col(k)] = 0
out[ga_col("pattern_label")] = "none"
n = len(out)
if n < lb + 1:
return out
if tail_rows is None:
tail_rows = CONTEXT_TAIL_ROWS.get(interval, 5000)
start = max(lb, n - tail_rows)
for i in range(start, n):
win = out.iloc[i - lb : i]
det = general_analysis_detect_patterns(win)
idx = out.index[i]
for k, v in det.items():
out.at[idx, ga_col(k)] = v
return out

View File

@@ -1,154 +0,0 @@
"""
general_analysis 전체 파이프라인 (지표·캔들·한 봉 특징).
"""
from __future__ import annotations
import pandas as pd
from deepcoin.common.candle_features import compute_bar_features
from deepcoin.analysis.general_analysis_candles import (
general_analysis_apply_candles,
general_analysis_candle_columns,
)
from deepcoin.analysis.general_analysis_chart import (
general_analysis_apply_chart_bars,
general_analysis_chart_columns,
general_analysis_chart_metrics,
)
from deepcoin.analysis.general_analysis_context import general_analysis_apply_context_features
from deepcoin.analysis.general_analysis_harmonic import (
general_analysis_harmonic_columns,
general_analysis_harmonic_snapshot,
)
from deepcoin.analysis.general_analysis_volume import (
general_analysis_volume_columns,
general_analysis_volume_snapshot,
)
from deepcoin.analysis.general_analysis_core import ga_col, lookback_slice
from deepcoin.analysis.general_analysis_indicators import (
general_analysis_apply_indicators,
general_analysis_indicator_columns,
)
from deepcoin.analysis.general_analysis_patterns import (
general_analysis_pattern_columns,
general_analysis_pattern_snapshot,
)
from deepcoin.analysis.general_analysis_wave import (
general_analysis_wave_columns,
general_analysis_wave_snapshot,
)
def general_analysis_enrich_bars(
df: pd.DataFrame,
interval: int | None = None,
*,
full_context: bool = True,
) -> pd.DataFrame:
"""
OHLCV → candle_features + ga 지표 + 캔들 + 차트 + (선택) lookback 컨텍스트.
Args:
df: raw OHLCV.
interval: 분봉 간격. full_context=True일 때 필수.
full_context: 패턴·VP·파동·하모닉 롤링 적용.
Returns:
전체 특징 컬럼 DataFrame.
"""
base = compute_bar_features(df)
out = general_analysis_apply_indicators(base)
out = general_analysis_apply_candles(out)
out = general_analysis_apply_chart_bars(out)
if full_context and interval is not None:
out = general_analysis_apply_context_features(out, interval)
return out
def general_analysis_snapshot_at_bar(
enriched: pd.DataFrame,
ts: pd.Timestamp,
interval: int,
) -> dict[str, object]:
"""
타점 시각 직전 완성봉 + lookback 패턴·파동·차트 메타.
Args:
enriched: general_analysis_enrich_bars 결과.
ts: 타점 시각.
interval: 분봉 간격.
Returns:
flat dict (ga_ 키 + legacy bb/rsi where present).
"""
win = lookback_slice(enriched, interval, ts)
snap: dict[str, object] = {}
if win.empty:
return snap
row = win.iloc[-1]
legacy_cols = [
"bb_pos",
"RSI",
"macd_hist",
"stoch_k",
"stoch_d",
"macd_line",
"macd_signal",
"BB_Width",
]
for c in legacy_cols:
if c in row.index and not pd.isna(row[c]):
snap[c] = float(row[c]) if isinstance(row[c], (int, float)) else row[c]
for c in general_analysis_indicator_columns():
col = ga_col(c)
if col in row.index:
v = row[col]
snap[col] = None if pd.isna(v) else v
for c in general_analysis_candle_columns():
col = ga_col(c)
if col in row.index:
snap[col] = int(row[col]) if not pd.isna(row[col]) else 0
pat_cols = [ga_col(c) for c in general_analysis_pattern_columns()]
if pat_cols and pat_cols[0] in enriched.columns:
for col in pat_cols:
if col in row.index:
snap[col] = row[col]
else:
snap.update(general_analysis_pattern_snapshot(win))
wave_cols = [ga_col(c) for c in general_analysis_wave_columns()]
if wave_cols and wave_cols[0] in enriched.columns:
for col in wave_cols:
if col in row.index:
snap[col] = row[col]
else:
snap.update(general_analysis_wave_snapshot(win))
snap.update(general_analysis_volume_snapshot(win))
snap.update(general_analysis_harmonic_snapshot(win))
snap.update(general_analysis_chart_metrics(win))
return snap
def general_analysis_all_snapshot_keys() -> list[str]:
"""CSV 헤더용 전체 키 목록 (간격 접두사 제외)."""
keys = list(legacy_snapshot_keys())
keys += [ga_col(c) for c in general_analysis_indicator_columns()]
keys += [ga_col(c) for c in general_analysis_candle_columns()]
keys += [ga_col(c) for c in general_analysis_pattern_columns()]
keys += [ga_col(c) for c in general_analysis_wave_columns()]
keys += [ga_col(c) for c in general_analysis_chart_columns()]
keys += [ga_col(c) for c in general_analysis_volume_columns()]
keys += [ga_col(c) for c in general_analysis_harmonic_columns()]
return keys
def legacy_snapshot_keys() -> list[str]:
return ["bb_pos", "RSI", "macd_hist", "stoch_k", "stoch_d"]

View File

@@ -1,73 +0,0 @@
"""
general_analysis HTML 요약 리포트.
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from deepcoin.analysis.general_analysis_config import DEFAULT_OUTPUT_CSV, DEFAULT_OUTPUT_HTML
def write_analysis_report(
csv_path: Path | str = DEFAULT_OUTPUT_CSV,
html_path: Path | str = DEFAULT_OUTPUT_HTML,
) -> Path:
"""
스냅샷 CSV를 읽어 모듈별 컬럼 수·샘플 테이블 HTML 생성.
Returns:
HTML 경로.
"""
df = pd.read_csv(csv_path)
html_out = Path(html_path)
html_out.parent.mkdir(parents=True, exist_ok=True)
modules = {
"지표 (ga_)": [c for c in df.columns if "_ga_" in c or c.startswith("ga_")],
"패턴": [c for c in df.columns if "ga_pattern_" in c],
"파동·구조": [c for c in df.columns if "ga_struct_" in c or "ga_elliott" in c or "ga_wyckoff" in c or "ga_fib_" in c],
"차트": [c for c in df.columns if "ga_chart_" in c],
"MTF 합성": [c for c in df.columns if "ga_align_" in c],
"레거시": [c for c in df.columns if c.endswith("_RSI") or c.endswith("_bb_pos")],
}
summary_rows = ""
for name, cols in modules.items():
summary_rows += f"<tr><td>{name}</td><td>{len(cols)}</td></tr>"
sample = df.head(5)[
["dt", "action", "price", "ga_align_timing_buy_score", "ga_align_mtf_conflict", "d1_RSI", "m3_RSI"]
].to_html(index=False, classes="tbl") if "d1_RSI" in df.columns else df.head(3).to_html(index=False)
buy_mean = df[df["action"] == "buy"]["ga_align_timing_buy_score"].mean() if "ga_align_timing_buy_score" in df.columns else 0
sell_mean = df[df["action"] == "sell"]["ga_align_timing_sell_score"].mean() if "ga_align_timing_sell_score" in df.columns else 0
content = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="utf-8"/>
<title>general_analysis 실행 리포트</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f5f5f5; }}
table.tbl {{ border-collapse: collapse; width: 100%; background: #fff; font-size: 0.85rem; }}
th, td {{ border: 1px solid #e2e8f0; padding: 6px 8px; }}
th {{ background: #e2e8f0; }}
</style></head><body>
<h1>general_analysis 실행 리포트</h1>
<p>타점 {len(df)}건 · 컬럼 {len(df.columns)}개 · CSV: {csv_path}</p>
<h2>모듈별 컬럼 수</h2>
<table class="tbl"><thead><tr><th>모듈</th><th>컬럼 수</th></tr></thead>
<tbody>{summary_rows}</tbody></table>
<h2>MTF 합성 평균</h2>
<ul>
<li>매수 타점 timing_buy_score 평균: {buy_mean:.3f}</li>
<li>매도 타점 timing_sell_score 평균: {sell_mean:.3f}</li>
</ul>
<h2>샘플 5건</h2>
{sample}
<p>전체 데이터: <code>{csv_path}</code></p>
</body></html>"""
html_out.write_text(content, encoding="utf-8")
print(f"리포트: {html_out}")
return html_out

View File

@@ -1,89 +0,0 @@
"""
general_analysis 실행 진입점.
python scripts/03_analyze_trades.py
python scripts/03_analyze_trades.py --limit 20 # 테스트용 타점 수 제한
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from config import CHART_LOOKBACK_DAYS, SYMBOL
from deepcoin.analysis.general_analysis_config import (
DEFAULT_OUTPUT_CSV,
DEFAULT_OUTPUT_HTML,
DEFAULT_TRADES_FILE,
)
from deepcoin.analysis.general_analysis_report import write_analysis_report
from deepcoin.analysis.general_analysis_snapshot import export_trade_snapshots
from deepcoin.ops.monitor import Monitor
from deepcoin.data.mtf_bb import load_frames_from_db
def main() -> None:
"""ground truth 타점 MTF general_analysis 스냅샷 생성."""
parser = argparse.ArgumentParser(description="general_analysis MTF 타점 분석")
parser.add_argument("--limit", type=int, default=0, help="타점 수 제한 (0=전체)")
parser.add_argument("--trades", type=str, default=DEFAULT_TRADES_FILE)
parser.add_argument("--csv", type=str, default=DEFAULT_OUTPUT_CSV)
parser.add_argument("--html", type=str, default=DEFAULT_OUTPUT_HTML)
args = parser.parse_args()
from deepcoin.paths import REPORTS_ANALYSIS
trades_path = Path(args.trades)
data = json.loads(trades_path.read_text(encoding="utf-8"))
trades = data.get("trades") or []
if args.limit > 0:
trades = trades[: args.limit]
print(f"테스트 모드: 타점 {args.limit}건만")
import sys
import time
print(f"=== 03b GT 타점 MTF 분석 {SYMBOL} (lookback {CHART_LOOKBACK_DAYS}일) ===")
print(f" 간격: 3,5,10,15,30,60,240,1440분 (1분 제외) · 타점 {len(trades)}")
sys.stdout.flush()
t0 = time.time()
print("[03b] Phase 0: DB에서 8TF OHLCV 로드...")
sys.stdout.flush()
mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
if not frames:
raise RuntimeError("coins.db 데이터 없음")
for iv in (3, 5, 10, 15, 30, 60, 240, 1440):
df = frames.get(iv)
n = len(df) if df is not None else 0
print(f" WLD_{iv}: {n:,}")
print(f"[03b] Phase 0 완료 ({time.time() - t0:.0f}초)")
sys.stdout.flush()
# limit 시 임시 trades 파일
if args.limit > 0:
tmp = REPORTS_ANALYSIS / "_ga_trades_subset.json"
tmp.parent.mkdir(exist_ok=True)
subset = {**data, "trades": trades}
tmp.write_text(json.dumps(subset, ensure_ascii=False), encoding="utf-8")
trades_path = tmp
from deepcoin.analysis.general_analysis_snapshot import build_trade_mtf_snapshots
csv_path = Path(args.csv)
df = build_trade_mtf_snapshots(frames, trades)
csv_path.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(csv_path, index=False, encoding="utf-8-sig")
print(f"저장: {csv_path} ({len(df)}× {len(df.columns)}열)")
write_analysis_report(csv_path, Path(args.html))
from deepcoin.matching.gt_mtf_profile import run_gt_mtf_profile
run_gt_mtf_profile(csv_path)
print("완료.")
if __name__ == "__main__":
main()

View File

@@ -1,130 +0,0 @@
"""
general_analysis ground truth 타점 MTF 스냅샷 생성.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
from typing import Any
import pandas as pd
from deepcoin.analysis.general_analysis_align import general_analysis_mtf_scores
from deepcoin.analysis.general_analysis_config import (
DEFAULT_OUTPUT_CSV,
DEFAULT_TRADES_FILE,
GENERAL_ANALYSIS_INTERVALS,
)
from deepcoin.analysis.general_analysis_core import interval_tf_prefix
from deepcoin.analysis.general_analysis_pipeline import general_analysis_enrich_bars, general_analysis_snapshot_at_bar
from deepcoin.ground_truth.ground_truth import load_ground_truth
def _prefixed_snap(snap: dict[str, Any], interval: int) -> dict[str, Any]:
p = interval_tf_prefix(interval)
return {f"{p}_{k}": v for k, v in snap.items()}
def build_trade_mtf_snapshots(
frames: dict[int, pd.DataFrame],
trades: list[dict[str, Any]],
) -> pd.DataFrame:
"""
모든 타점에 대해 8개 간격 general_analysis 스냅샷.
Args:
frames: interval → OHLCV.
trades: ground_truth trades.
Returns:
wide DataFrame (1 row per trade).
"""
n_trades = len(trades)
enriched: dict[int, pd.DataFrame] = {}
t0 = time.time()
print(
f"[03b] MTF enrich (주·월봉 포함) — {len(GENERAL_ANALYSIS_INTERVALS)}개 간격"
)
sys.stdout.flush()
for step, iv in enumerate(GENERAL_ANALYSIS_INTERVALS, start=1):
raw = frames.get(iv)
if raw is None or raw.empty:
print(f" [{step}/8] {interval_tf_prefix(iv)} SKIP (데이터 없음)")
sys.stdout.flush()
continue
label = interval_tf_prefix(iv)
print(f" [{step}/8] {label} enrich 시작 ({len(raw):,}봉)...")
sys.stdout.flush()
t_iv = time.time()
enriched[iv] = general_analysis_enrich_bars(raw, iv, full_context=True)
print(f" [{step}/8] {label} 완료 — {len(enriched[iv].columns)}열, {time.time() - t_iv:.0f}")
sys.stdout.flush()
print(f"[03b] Phase A 완료 (누적 {time.time() - t0:.0f}초)")
sys.stdout.flush()
print(f"[03b] Phase B: GT 타점 스냅샷 {n_trades}")
sys.stdout.flush()
rows: list[dict[str, Any]] = []
t_b = time.time()
for i, t in enumerate(sorted(trades, key=lambda x: x["dt"])):
ts = pd.Timestamp(t["dt"])
row: dict[str, Any] = {
"trade_idx": i,
"dt": t["dt"],
"action": t["action"],
"price": t["price"],
"weight": t.get("weight", 1.0),
"leg_id": t.get("leg_id", 0),
"memo": t.get("memo", ""),
}
flat: dict[str, Any] = {}
for iv in GENERAL_ANALYSIS_INTERVALS:
ef = enriched.get(iv)
if ef is None:
continue
snap = general_analysis_snapshot_at_bar(ef, ts, iv)
flat.update(_prefixed_snap(snap, iv))
row.update(flat)
row.update(general_analysis_mtf_scores(flat))
rows.append(row)
done = i + 1
if done == 1 or done % 25 == 0 or done == n_trades:
elapsed = time.time() - t_b
rate = done / elapsed if elapsed > 0 else 0
eta = (n_trades - done) / rate if rate > 0 else 0
print(
f" 타점 {done}/{n_trades} "
f"({elapsed:.0f}초 경과, ETA 약 {eta:.0f}초)"
)
sys.stdout.flush()
print(f"[03b] Phase B 완료 ({time.time() - t_b:.0f}초)")
sys.stdout.flush()
return pd.DataFrame(rows)
def export_trade_snapshots(
frames: dict[int, pd.DataFrame],
trades_path: Path | str = DEFAULT_TRADES_FILE,
output_csv: Path | str = DEFAULT_OUTPUT_CSV,
) -> Path:
"""
CSV로 타점 MTF 스냅샷 저장.
Returns:
저장 경로.
"""
data = load_ground_truth(Path(trades_path))
if not data:
raise FileNotFoundError(f"정답 파일 없음: {trades_path}")
trades = data.get("trades") or []
print(f"타점 {len(trades)}× {len(GENERAL_ANALYSIS_INTERVALS)} TF general_analysis")
df = build_trade_mtf_snapshots(frames, trades)
out = Path(output_csv)
out.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(out, index=False, encoding="utf-8-sig")
print(f"저장: {out} ({len(df)}× {len(df.columns)}열)")
return out

View File

@@ -1,92 +0,0 @@
"""
general_analysis Volume Profile (POC, VAH, VAL).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from config import GA_VP_BINS, GA_VP_VALUE_AREA_PCT
from deepcoin.analysis.general_analysis_core import ga_col
def general_analysis_volume_profile(
win: pd.DataFrame,
bins: int | None = None,
value_area_pct: float | None = None,
) -> dict[str, float | int]:
"""
lookback 구간 가격-거래량 분포에서 POC·VAH·VAL 계산.
Args:
win: OHLCV.
bins: 가격 구간 수.
value_area_pct: value area 누적 비율 (기본 70%).
Returns:
ga_vp_* 키 dict (접두사 없음).
"""
res: dict[str, float | int] = {
"vp_poc": 0.0,
"vp_vah": 0.0,
"vp_val": 0.0,
"vp_close_vs_poc_pct": 0.0,
"vp_in_value_area": 0,
}
if bins is None:
bins = GA_VP_BINS
if value_area_pct is None:
value_area_pct = GA_VP_VALUE_AREA_PCT
if win is None or len(win) < 10 or "Volume" not in win.columns:
return res
h = win["High"].astype(float).values
l = win["Low"].astype(float).values
c = win["Close"].astype(float).values
v = win["Volume"].astype(float).values
tp = (h + l + c) / 3.0
lo, hi = float(l.min()), float(h.max())
if hi <= lo:
return res
edges = np.linspace(lo, hi, bins + 1)
hist = np.zeros(bins, dtype=float)
for i in range(len(tp)):
idx = int(np.clip(np.digitize(tp[i], edges) - 1, 0, bins - 1))
hist[idx] += v[i]
if hist.sum() <= 0:
return res
poc_idx = int(np.argmax(hist))
poc = float((edges[poc_idx] + edges[poc_idx + 1]) / 2)
res["vp_poc"] = poc
order = np.argsort(hist)[::-1]
cum = 0.0
selected: list[int] = []
total = hist.sum()
for idx in order:
selected.append(int(idx))
cum += hist[idx]
if cum >= total * value_area_pct:
break
sel_min, sel_max = min(selected), max(selected)
res["vp_val"] = float(edges[sel_min])
res["vp_vah"] = float(edges[sel_max + 1])
res["vp_close_vs_poc_pct"] = float((c[-1] / poc - 1) * 100) if poc else 0.0
res["vp_in_value_area"] = int(res["vp_val"] <= c[-1] <= res["vp_vah"])
return res
def general_analysis_volume_columns() -> list[str]:
return ["vp_poc", "vp_vah", "vp_val", "vp_close_vs_poc_pct", "vp_in_value_area"]
def general_analysis_volume_snapshot(win: pd.DataFrame) -> dict[str, object]:
"""Volume profile → ga_vp_*."""
return {ga_col(k): v for k, v in general_analysis_volume_profile(win).items()}

View File

@@ -1,205 +0,0 @@
"""
general_analysis 파동·시장 구조 (다우, 엘리어트 라이트, 피보나치, Wyckoff 태그).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from deepcoin.analysis.general_analysis_core import find_pivots, ga_col
def _fib_levels(low: float, high: float) -> dict[str, float]:
diff = high - low
return {
"fib_0": low,
"fib_382": low + diff * 0.382,
"fib_500": low + diff * 0.5,
"fib_618": low + diff * 0.618,
"fib_100": high,
"fib_1618": low + diff * 1.618,
}
def general_analysis_wave_snapshot(win: pd.DataFrame) -> dict[str, object]:
"""
lookback 윈도우 마지막 시점 파동·구조 스냅샷.
Args:
win: OHLCV.
Returns:
ga_wave_* / ga_struct_* / ga_fib_* dict.
"""
res: dict[str, object] = {
"struct_trend": "range",
"struct_hh": 0,
"struct_hl": 0,
"struct_lh": 0,
"struct_ll": 0,
"struct_bos_bull": 0,
"struct_bos_bear": 0,
"struct_choch": 0,
"elliott_wave_count": 0,
"elliott_phase": "unknown",
"wyckoff_phase": "unknown",
"fib_near_level": "none",
"ichi_trend": "neutral",
"pitchfork_bias": "neutral",
"pitchfork_dist_pct": 0.0,
"wyckoff_spring": 0,
"wyckoff_utad": 0,
}
if win is None or len(win) < 15:
return {ga_col(k): v for k, v in res.items()}
h = win["High"].astype(float).values
l = win["Low"].astype(float).values
c = win["Close"].astype(float).values
peaks, troughs = find_pivots(h, l, order=2)
# Dow HH/HL/LH/LL
if len(peaks) >= 2 and len(troughs) >= 2:
hh = int(h[peaks[-1]] > h[peaks[-2]])
hl = int(l[troughs[-1]] > l[troughs[-2]])
lh = int(h[peaks[-1]] < h[peaks[-2]])
ll = int(l[troughs[-1]] < l[troughs[-2]])
res["struct_hh"] = hh
res["struct_hl"] = hl
res["struct_lh"] = lh
res["struct_ll"] = ll
if hh and hl:
res["struct_trend"] = "up"
elif lh and ll:
res["struct_trend"] = "down"
if hh and c[-1] > h[peaks[-2]]:
res["struct_bos_bull"] = 1
if ll and c[-1] < l[troughs[-2]]:
res["struct_bos_bear"] = 1
if (hh and ll) or (lh and hl):
res["struct_choch"] = 1
# Elliott lite: pivot count in window
swings = len(peaks) + len(troughs)
res["elliott_wave_count"] = swings
if swings >= 5:
res["elliott_phase"] = "impulse_late"
elif swings >= 3:
res["elliott_phase"] = "corrective"
# Wyckoff lite
vol = win["Volume"].astype(float).values if "Volume" in win.columns else np.ones(len(c))
vol_ma = vol[-20:].mean() if len(vol) >= 20 else vol.mean()
price_range = (h[-20:].max() - l[-20:].min()) / max(c[-1], 1e-9) * 100
if price_range < 6 and vol[-1] < vol_ma * 1.2:
res["wyckoff_phase"] = "accumulation"
elif price_range < 6 and vol[-1] > vol_ma * 1.5 and c[-1] > c[-5]:
res["wyckoff_phase"] = "distribution"
if price_range < 8 and l[-1] < l[-5] and c[-1] > c[-2] and vol[-1] > vol_ma * 1.3:
res["wyckoff_spring"] = 1
if price_range < 8 and h[-1] > h[-5] and c[-1] < c[-2] and vol[-1] > vol_ma * 1.3:
res["wyckoff_utad"] = 1
# Andrews Pitchfork (3피벗 중앙선 대비 종가 위치)
pivots = sorted([(i, h[i]) for i in peaks] + [(i, l[i]) for i in troughs])
if len(pivots) >= 3:
p0, p1, p2 = pivots[-3], pivots[-2], pivots[-1]
y0 = p0[1]
y_mid = (p1[1] + p2[1]) / 2
x0, x2 = p0[0], p2[0]
if x2 != x0:
slope = (y_mid - y0) / (x2 - x0)
y_line = y0 + slope * (len(c) - 1 - x0)
dist_pct = (c[-1] - y_line) / max(c[-1], 1e-9) * 100
res["pitchfork_dist_pct"] = round(float(dist_pct), 3)
if dist_pct > 0.5:
res["pitchfork_bias"] = "above"
elif dist_pct < -0.5:
res["pitchfork_bias"] = "below"
# Fibonacci
hi, lo = float(h.max()), float(l.min())
levels = _fib_levels(lo, hi)
price = float(c[-1])
for name, lvl in levels.items():
if abs(price - lvl) / max(price, 1e-9) * 100 < 1.5:
res["fib_near_level"] = name.replace("fib_", "")
break
if "ichi_cloud_top" in win.columns:
row = win.iloc[-1]
ct = float(row.get("ichi_cloud_top", np.nan))
cb = float(row.get("ichi_cloud_bottom", np.nan))
if not np.isnan(ct) and price > ct:
res["ichi_trend"] = "above_cloud"
elif not np.isnan(cb) and price < cb:
res["ichi_trend"] = "below_cloud"
else:
res["ichi_trend"] = "in_cloud"
return {ga_col(k): v for k, v in res.items()}
def general_analysis_wave_columns() -> list[str]:
return [
"struct_trend",
"struct_hh",
"struct_hl",
"struct_lh",
"struct_ll",
"struct_bos_bull",
"struct_bos_bear",
"struct_choch",
"elliott_wave_count",
"elliott_phase",
"wyckoff_phase",
"fib_near_level",
"ichi_trend",
"pitchfork_bias",
"pitchfork_dist_pct",
"wyckoff_spring",
"wyckoff_utad",
]
def general_analysis_apply_wave_to_bars(
df: pd.DataFrame,
interval: int,
tail_rows: int | None = None,
) -> pd.DataFrame:
"""파동·구조 스냅샷을 최근 봉에 롤링 적용."""
from deepcoin.analysis.general_analysis_config import CONTEXT_TAIL_ROWS, LOOKBACK_BARS
out = df.copy()
lb = LOOKBACK_BARS.get(interval, 80)
for k in general_analysis_wave_columns():
col = ga_col(k)
if k == "struct_trend":
out[col] = "range"
elif k in ("elliott_phase", "wyckoff_phase"):
out[col] = "unknown"
elif k == "fib_near_level":
out[col] = "none"
elif k in ("ichi_trend", "pitchfork_bias"):
out[col] = "neutral"
elif k == "pitchfork_dist_pct":
out[col] = 0.0
else:
out[col] = 0
n = len(out)
if n < lb + 1:
return out
if tail_rows is None:
tail_rows = CONTEXT_TAIL_ROWS.get(interval, 5000)
start = max(lb, n - tail_rows)
for i in range(start, n):
snap = general_analysis_wave_snapshot(out.iloc[i - lb : i])
idx = out.index[i]
for k, v in snap.items():
out.at[idx, k] = v
return out

View File

@@ -1,5 +0,0 @@
"""외부 API 연동 (빗썸 등)."""
from deepcoin.api.bithumb import HTS
__all__ = ["HTS"]

View File

@@ -1,301 +0,0 @@
import pandas as pd
import jwt
import uuid
import time
import requests
import json
import hashlib
from urllib.parse import urlencode
class HTS:
"""빗썸 Open API 래퍼 (시세 조회, 잔고, 주문)."""
bithumb = None
accessKey = ""
secretKey = ""
apiUrl = ""
def __init__(self):
from config import BITHUMB_ACCESS_KEY, BITHUMB_API_URL, BITHUMB_SECRET_KEY
self.bithumb = None
self.accessKey = BITHUMB_ACCESS_KEY
self.secretKey = BITHUMB_SECRET_KEY
self.apiUrl = BITHUMB_API_URL.rstrip("/")
def append(self, stock, df=None, data_1=None):
if df is not None:
for i in range(len(df)):
stock['PRICE'].append(
{
"ymd": df.index[i],
"close": df['close'].iloc[i],
"diff": 0,
"open": df['open'].iloc[i],
"high": df['high'].iloc[i],
"low": df['low'].iloc[i],
"volume": df['volume'].iloc[i],
"avg5": -1, "avg20": -1, "avg60": -1, "avg120": -1, "avg240": -1, "avg480": -1,
"bolingerband_upper": -1, "bolingerband_lower": -1, "bolingerband_middle": -1, "bolingerband_bwi": -1,
"ichimokucloud_changeLine": -1, "ichimokucloud_baseLine": -1, "ichimokucloud_leadingSpan1": -1, "ichimokucloud_leadingSpan2": -1,
"stochastic_fast_k_1": -1, "stochastic_slow_k_1": -1, "stochastic_slow_d_1": -1,
"stochastic_fast_k_2": -1, "stochastic_slow_k_2": -1, "stochastic_slow_d_2": -1,
"stochastic_fast_k_3": -1, "stochastic_slow_k_3": -1, "stochastic_slow_d_3": -1,
"rsi": -1, "rsis": -1,
"macd": -1, "macds": -1, "macdo": -1, "nor_macd": -1, "nor_macds": -1, "nor_macdo": -1,
})
if data_1 is not None:
stock['PRICE'].append(
{
"ymd": data_1.index[-1],
"close": data_1['close'].iloc[-1],
"diff": 0,
"open": data_1['open'].iloc[-1],
"high": data_1['high'].iloc[-1],
"low": data_1['low'].iloc[-1],
"volume": data_1['volume'].iloc[-1],
"avg5": -1, "avg20": -1, "avg60": -1, "avg120": -1, "avg240": -1, "avg480": -1,
"bolingerband_upper": -1, "bolingerband_lower": -1, "bolingerband_middle": -1, "bolingerband_bwi": -1, "bolingerband_nor_bwi": -1,
"envelope_upper": -1, "envelope_lower": -1, "envelope_middle": -1,
"ichimokucloud_changeLine": -1, "ichimokucloud_baseLine": -1, "ichimokucloud_leadingSpan1": -1,
"ichimokucloud_leadingSpan2": -1,
"stochastic_fast_k_1": -1, "stochastic_slow_k_1": -1, "stochastic_slow_d_1": -1,
"stochastic_fast_k_2": -1, "stochastic_slow_k_2": -1, "stochastic_slow_d_2": -1,
"stochastic_fast_k_3": -1, "stochastic_slow_k_3": -1, "stochastic_slow_d_3": -1,
"rsi": -1, "rsis": -1,
"macd": -1, "macds": -1, "macdo": -1, "nor_macd": -1, "nor_macds": -1, "nor_macdo": -1,
})
return
def getCoinRawData(self, ticker_code, minute=None, day=False, week=False, month=False, to=None, endpoint='/v1/candles'):
url = None
if minute == 0:
# 현재가 정보
url = (self.apiUrl + "/v1/ticker?markets=KRW-{}").format(ticker_code)
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
json_data = json.loads(response.text)
df_temp = pd.DataFrame(json_data)
if 'trade_date_kst' not in df_temp or 'trade_time_kst' not in df_temp:
return None
df = pd.DataFrame()
df['datetime'] = pd.to_datetime(df_temp['trade_date_kst'], format='%Y-%m-%dT%H:%M:%S')
df['open'] = df_temp['opening_price']
df['close'] = df_temp['trade_price']
df['high'] = df_temp['high_price']
df['low'] = df_temp['low_price']
df['volume'] = df_temp['trade_volume']
df = df.set_index('datetime')
df = df.astype(float)
df["datetime"] = df.index
else:
# 분봉
if minute is not None and minute in {1, 3, 5, 10, 15, 30, 60, 240}:
if to is None:
url = (self.apiUrl + endpoint + "/minutes/{}?market=KRW-{}&count=3000").format(minute, ticker_code)
else:
url = (self.apiUrl + endpoint + "/minutes/{}?market=KRW-{}&count=3000&to={}").format(minute, ticker_code, to)
if day:
if to is None:
url = (self.apiUrl + endpoint + "/days?market=KRW-{}&count=3000").format(ticker_code)
else:
url = (self.apiUrl + endpoint + "/days?market=KRW-{}&count=3000&to={}").format(ticker_code, to)
if week:
if to is None:
url = (self.apiUrl + endpoint + "/weeks?market=KRW-{}&count=3000").format(ticker_code)
else:
url = (self.apiUrl + endpoint + "/weeks?market=KRW-{}&count=3000&to={}").format(ticker_code, to)
if month:
if to is None:
url = (self.apiUrl + endpoint + "/months?market=KRW-{}&count=3000").format(ticker_code)
else:
url = (self.apiUrl + endpoint + "/months?market=KRW-{}&count=3000&to={}").format(ticker_code, to)
if url is None:
return None
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
json_data = json.loads(response.text)
df_temp = pd.DataFrame(json_data)
if 'candle_date_time_kst' not in df_temp:
return None
df = pd.DataFrame()
#df.columns = ['datetime', 'open', 'close', 'high', 'low', 'volume']
#df['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst'])
df['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst'], format='%Y-%m-%dT%H:%M:%S')
df['open'] = df_temp['opening_price']
df['close'] = df_temp['trade_price']
df['high'] = df_temp['high_price']
df['low'] = df_temp['low_price']
df['volume'] = df_temp['candle_acc_trade_volume']
df = df.set_index('datetime')
df = df.astype(float)
df["datetime"] = df.index
if df is None:
return None
return df
def getTickerList(self):
url = f"{self.apiUrl}/v1/market/all?isDetails=false"
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
tickets = response.json()
return tickets
def getVirtual_asset_warning(self):
url = f"{self.apiUrl}/v1/market/virtual_asset_warning"
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
warning_list = response.json()
return warning_list
# 거래대금이 많은 순으로 코인리스트를 얻는다.
def getTopCoinList(self, interval, top):
return
# 현재 가격 얻어오기
def getCurrentPrice(self, ticker_code, endpoint='/v1/ticker'):
headers = {"accept": "application/json"}
url = (self.apiUrl + endpoint + "?markets=KRW-{}").format(ticker_code)
response = requests.get(url, headers=headers)
ticker_state = response.json()
return ticker_state
# 잔고 가져오기
def getBalances(self, ticker_code=None, endpoint='/v1/accounts'):
payload = {
'access_key': self.accessKey,
'nonce': str(uuid.uuid4()),
'timestamp': round(time.time() * 1000)
}
jwt_token = jwt.encode(payload, self.secretKey)
authorization_token = 'Bearer {}'.format(jwt_token)
headers = {
'Authorization': authorization_token
}
response = requests.get(self.apiUrl + endpoint, headers=headers)
balances = response.json()
"""
[
{'currency': 'P', 'balance': '78290', 'locked': '0', 'avg_buy_price': '0', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'KRW', 'balance': '4218.401653', 'locked': '0', 'avg_buy_price': '0', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'XRP', 'balance': '13069.27647861', 'locked': '0', 'avg_buy_price': '1917', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'ADA', 'balance': '6941.65484013', 'locked': '0', 'avg_buy_price': '1260', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'BSV', 'balance': '0.00005656', 'locked': '0', 'avg_buy_price': '65450', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'SAND', 'balance': '0.00001158', 'locked': '0', 'avg_buy_price': '544.8', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'AVAX', 'balance': '26.43960509', 'locked': '0', 'avg_buy_price': '60882', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'XCORE', 'balance': '0.2119', 'locked': '0', 'avg_buy_price': '0', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'}
]
"""
if ticker_code is None:
return balances
else:
for balance in balances:
if balance['currency'] == ticker_code:
return balance
return None
def order(self, ticker_code, side, ord_type, volume, price=None, endpoint='/v1/orders'):
if ord_type=='limit':
# 지정가 매수 (limit, side=bid) / 매도 (limit, side=ask)
if price is None:
return
requestBody = dict(market='KRW-'+ticker_code, side=side, volume=volume, price=price, ord_type=ord_type)
else:
# 시장가 매수 (price, side=bid) / 매도 (market, side=ask)
if ord_type == 'price':
requestBody = dict(market='KRW-' + ticker_code, side=side, price=price, ord_type=ord_type)
else:
requestBody = dict(market='KRW-' + ticker_code, side=side, volume=volume, ord_type=ord_type)
# Generate access token
query = urlencode(requestBody).encode()
hash = hashlib.sha512()
hash.update(query)
query_hash = hash.hexdigest()
payload = {
'access_key': self.accessKey,
'nonce': str(uuid.uuid4()),
'timestamp': round(time.time() * 1000),
'query_hash': query_hash,
'query_hash_alg': 'SHA512',
}
jwt_token = jwt.encode(payload, self.secretKey)
authorization_token = 'Bearer {}'.format(jwt_token)
headers = {
'Authorization': authorization_token,
'Content-Type': 'application/json'
}
response = requests.post(self.apiUrl + endpoint, data=json.dumps(requestBody), headers=headers)
# handle to success or fail
#print(response.json())
if response.status_code == 200:
return True
return False
# 시장가 매수한다. 2초 뒤 잔고 데이터 리스트 리턴
def buyCoinMarket(self, ticker_code, price, count=None):
if price > 5000:
if price < 50000:
self.order(ticker_code, side='bid', ord_type='price', volume=count, price=price)
buy_price = price
else:
repeat = 10
buy_price = int(price / 1000) * 1000
buy_amount = int(buy_price / repeat)
while repeat > 0:
self.order(ticker_code, side='bid', ord_type='price', volume=count, price=buy_amount)
repeat -= 1
time.sleep(0.5)
else:
buy_price = 0
return buy_price
# 시장가 매도한다. 2초 뒤 잔고 데이터 리스트 리턴
def sellCoinMarket(self, ticker_code, price, count):
return self.order(ticker_code, side='ask', ord_type='market', volume=count, price=price)
# 지정가 매수한다. 2초 뒤 잔고 데이터 리스트 리턴
def buyCoinLimit(self, ticker_code, price, count):
return self.order(ticker_code, side='bid', ord_type='limit', volume=count, price=price)
# 지정가 매도한다. 2초 뒤 잔고 데이터 리스트 리턴
def sellCoinLimit(self, ticker_code, price, count):
return self.order(ticker_code, side='ask', ord_type='limit', volume=count, price=price)
def getOrderBook(self, ticker_code, endpoint='/v1/orderbook'):
"""
필드 설명 타입
market 마켓 코드 String
timestamp 호가 생성 시각 Long
total_ask_size 호가 매도 총 잔량 Double
total_bid_size 호가 매수 총 잔량 Double
orderbook_units 호가 List of Objects
> ask_price 매도호가 Double
> bid_price 매수호가 Double
> ask_size 매도 잔량 Double
> bid_size 매수 잔량 Double
"""
headers = {"accept": "application/json"}
url = (self.apiUrl + endpoint + "?markets=KRW-{}").format(ticker_code)
response = requests.get(url, headers=headers)
# 매도 총 잔량: sum([units['ask_size'] for units in orders[0]['orderbook_units']])
# 매수 총 잔량: sum([units['bid_size'] for units in orders[0]['orderbook_units']])
orders = response.json()
return orders

View File

@@ -1,362 +0,0 @@
"""
모든 봉(3~1440분 등)에 BB·일목 위치·캔들 형태 특징을 계산하고
기준 타임라인(3분)에 맞춰 정렬합니다.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from config import (
ALL_INTERVALS,
BB_MIN_WIDTH_PCT,
DISPARITY_PERIODS,
ENTRY_INTERVAL,
INTERVAL_PREFIX,
STOCH_OVERBOUGHT,
STOCH_OVERSOLD,
)
from deepcoin.common.indicators import apply_bar_indicators, disparity_column
def interval_prefix(interval: int) -> str:
"""컬럼 접두사 (예: m3, d1)."""
return INTERVAL_PREFIX.get(interval, f"m{interval}")
def interval_display(interval: int) -> str:
if interval >= 1440:
return "일봉"
return f"{interval}"
# BB 위치 (밴드 내 %B 구간)
BB_ZONE_FEATURES: tuple[str, ...] = (
"bb_zone_bottom",
"bb_zone_low",
"bb_zone_mid",
"bb_zone_high",
"bb_zone_top",
)
# 일목 위치
ICHI_FEATURES: tuple[str, ...] = (
"ichi_above_cloud",
"ichi_below_cloud",
"ichi_in_cloud",
"ichi_cloud_bull",
"ichi_cloud_bear",
"ichi_tk_bull",
"ichi_tk_bear",
"ichi_price_above_tenkan",
"ichi_price_below_kijun",
"ichi_tk_cross_up",
"ichi_tk_cross_down",
)
# BB 이벤트·캔들 형태
BB_EVENT_FEATURES: tuple[str, ...] = (
"cross_up_lower",
"cross_up_upper",
"cross_down_lower",
"below_lower",
"above_upper",
"inside_band",
"bb_pos_low",
"bb_pos_high",
"squeeze",
)
MACD_STOCH_FEATURES: tuple[str, ...] = (
"macd_hist_positive",
"macd_hist_negative",
"macd_cross_up",
"macd_cross_down",
"stoch_oversold",
"stoch_overbought",
"stoch_cross_up",
"stoch_cross_down",
)
def _disparity_feature_names() -> tuple[str, ...]:
"""기간별 이격도 과매수·과매도 불리언 컬럼명."""
names: list[str] = []
for p in DISPARITY_PERIODS:
names.append(f"disparity_{p}_oversold")
names.append(f"disparity_{p}_overbought")
return tuple(names)
DISPARITY_FEATURES: tuple[str, ...] = _disparity_feature_names()
CANDLE_SHAPE_FEATURES: tuple[str, ...] = (
"body_strong",
"body_weak",
"hammer",
"shooting_star",
"bullish",
"bearish",
)
FEATURE_BOOL_COLS: tuple[str, ...] = (
BB_EVENT_FEATURES
+ BB_ZONE_FEATURES
+ ICHI_FEATURES
+ MACD_STOCH_FEATURES
+ DISPARITY_FEATURES
+ CANDLE_SHAPE_FEATURES
)
def compute_bar_features(df: pd.DataFrame) -> pd.DataFrame:
"""단일 봉 DataFrame에 BB·일목·MACD·스토캐스틱·캔들 위치 특징을 추가합니다."""
out = apply_bar_indicators(df.copy())
if len(out) < 2:
return out
o = out["Open"].astype(float)
h = out["High"].astype(float)
l = out["Low"].astype(float)
c = out["Close"].astype(float)
prev_c = c.shift(1)
upper = out["Upper"].astype(float)
lower = out["Lower"].astype(float)
prev_upper = upper.shift(1)
prev_lower = lower.shift(1)
rng = (h - l).replace(0, np.nan)
body = (c - o).abs()
out["range_pct"] = (rng / c.replace(0, np.nan)) * 100
out["body_ratio"] = (body / rng).fillna(0).clip(0, 1)
out["upper_wick_ratio"] = ((h - np.maximum(o, c)) / rng).fillna(0).clip(0, 1)
out["lower_wick_ratio"] = ((np.minimum(o, c) - l) / rng).fillna(0).clip(0, 1)
out["ret_pct"] = ((c - prev_c) / prev_c.replace(0, np.nan)) * 100
pos = out["bb_pos"].astype(float)
out["bb_zone_bottom"] = (pos < 0.15).astype(int)
out["bb_zone_low"] = ((pos >= 0.15) & (pos < 0.35)).astype(int)
out["bb_zone_mid"] = ((pos >= 0.35) & (pos < 0.65)).astype(int)
out["bb_zone_high"] = ((pos >= 0.65) & (pos < 0.85)).astype(int)
out["bb_zone_top"] = (pos >= 0.85).astype(int)
out["cross_up_lower"] = ((prev_c <= prev_lower) & (c > lower)).astype(int)
out["cross_up_upper"] = ((prev_c < prev_upper) & (c >= upper)).astype(int)
out["cross_down_lower"] = ((prev_c >= prev_lower) & (c < lower)).astype(int)
out["below_lower"] = (c < lower).astype(int)
out["above_upper"] = (c > upper).astype(int)
out["inside_band"] = ((c >= lower) & (c <= upper)).astype(int)
out["bb_pos_low"] = (pos < 0.2).astype(int)
out["bb_pos_high"] = (pos > 0.8).astype(int)
out["squeeze"] = (out["BB_Width"] < BB_MIN_WIDTH_PCT).astype(int)
ct = out["ichi_cloud_top"].astype(float)
cb = out["ichi_cloud_bottom"].astype(float)
ten = out["ichi_tenkan"].astype(float)
kij = out["ichi_kijun"].astype(float)
prev_ten = ten.shift(1)
prev_kij = kij.shift(1)
out["ichi_above_cloud"] = (c > ct).astype(int)
out["ichi_below_cloud"] = (c < cb).astype(int)
out["ichi_in_cloud"] = ((c >= cb) & (c <= ct)).astype(int)
out["ichi_cloud_bull"] = (out["ichi_span_a"] > out["ichi_span_b"]).astype(int)
out["ichi_cloud_bear"] = (out["ichi_span_a"] < out["ichi_span_b"]).astype(int)
out["ichi_tk_bull"] = (ten > kij).astype(int)
out["ichi_tk_bear"] = (ten < kij).astype(int)
out["ichi_price_above_tenkan"] = (c > ten).astype(int)
out["ichi_price_below_kijun"] = (c < kij).astype(int)
out["ichi_tk_cross_up"] = ((prev_ten <= prev_kij) & (ten > kij)).astype(int)
out["ichi_tk_cross_down"] = ((prev_ten >= prev_kij) & (ten < kij)).astype(int)
out["body_strong"] = (out["body_ratio"] > 0.55).astype(int)
out["body_weak"] = (out["body_ratio"] < 0.25).astype(int)
out["hammer"] = ((out["lower_wick_ratio"] > 0.45) & (out["body_ratio"] < 0.35)).astype(int)
out["shooting_star"] = ((out["upper_wick_ratio"] > 0.45) & (out["body_ratio"] < 0.35)).astype(int)
out["bullish"] = (c > o).astype(int)
out["bearish"] = (c < o).astype(int)
if "macd_hist" in out.columns:
mh = out["macd_hist"].astype(float)
prev_mh = mh.shift(1)
ml = out["macd_line"].astype(float)
ms = out["macd_signal"].astype(float)
prev_ml = ml.shift(1)
prev_ms = ms.shift(1)
out["macd_hist_positive"] = (mh > 0).astype(int)
out["macd_hist_negative"] = (mh < 0).astype(int)
out["macd_cross_up"] = ((prev_ml <= prev_ms) & (ml > ms)).astype(int)
out["macd_cross_down"] = ((prev_ml >= prev_ms) & (ml < ms)).astype(int)
if "stoch_k" in out.columns:
sk = out["stoch_k"].astype(float)
sd = out["stoch_d"].astype(float)
prev_sk = sk.shift(1)
prev_sd = sd.shift(1)
out["stoch_oversold"] = (sk <= STOCH_OVERSOLD).astype(int)
out["stoch_overbought"] = (sk >= STOCH_OVERBOUGHT).astype(int)
out["stoch_cross_up"] = ((prev_sk <= prev_sd) & (sk > sd)).astype(int)
out["stoch_cross_down"] = ((prev_sk >= prev_sd) & (sk < sd)).astype(int)
from config import DISPARITY_OVERBOUGHT, DISPARITY_OVERSOLD
for p in DISPARITY_PERIODS:
col = disparity_column(p)
if col not in out.columns:
continue
d = out[col].astype(float)
out[f"disparity_{p}_oversold"] = (d <= DISPARITY_OVERSOLD).astype(int)
out[f"disparity_{p}_overbought"] = (d >= DISPARITY_OVERBOUGHT).astype(int)
return out
def describe_latest_position(df: pd.DataFrame, interval: int) -> dict:
"""한 봉의 최신 BB·일목 위치 요약."""
feat = compute_bar_features(df)
if feat.empty:
return {"interval": interval, "label": interval_display(interval)}
row = feat.iloc[-1]
pos = float(row.get("bb_pos", 0.5))
bb_zone = "mid"
for z in BB_ZONE_FEATURES:
if int(row.get(z, 0)) == 1:
bb_zone = z.replace("bb_zone_", "")
break
ichi_pos = "in_cloud"
if int(row.get("ichi_above_cloud", 0)):
ichi_pos = "above_cloud"
elif int(row.get("ichi_below_cloud", 0)):
ichi_pos = "below_cloud"
snap: dict = {
"interval": interval,
"label": interval_display(interval),
"close": float(row["Close"]),
"bb_pos": round(pos, 3),
"bb_zone": bb_zone,
"bb_state": _bb_event_label(row),
"ichi_position": ichi_pos,
"ichi_tk": "bull" if int(row.get("ichi_tk_bull", 0)) else "bear",
"ichi_cloud": "bull" if int(row.get("ichi_cloud_bull", 0)) else "bear",
}
if "macd_hist" in row.index and pd.notna(row["macd_hist"]):
snap["macd_hist"] = round(float(row["macd_hist"]), 4)
snap["macd_state"] = "bull" if float(row["macd_hist"]) > 0 else "bear"
if "stoch_k" in row.index and pd.notna(row["stoch_k"]):
sk = float(row["stoch_k"])
snap["stoch_k"] = round(sk, 1)
snap["stoch_d"] = round(float(row["stoch_d"]), 1)
if sk <= STOCH_OVERSOLD:
snap["stoch_zone"] = "oversold"
elif sk >= STOCH_OVERBOUGHT:
snap["stoch_zone"] = "overbought"
else:
snap["stoch_zone"] = "mid"
disp_vals: dict[int, float] = {}
for p in DISPARITY_PERIODS:
col = disparity_column(p)
if col in row.index and pd.notna(row[col]):
disp_vals[p] = round(float(row[col]), 2)
if disp_vals:
snap["disparity"] = disp_vals
primary_p = 20 if 20 in DISPARITY_PERIODS else DISPARITY_PERIODS[0]
snap["disparity_primary"] = disp_vals.get(
primary_p, next(iter(disp_vals.values()))
)
return snap
def _bb_event_label(row: pd.Series) -> str:
for name in (
"cross_up_lower",
"cross_up_upper",
"cross_down_lower",
"below_lower",
"above_upper",
"squeeze",
"inside_band",
):
if int(row.get(name, 0)) == 1:
return name
return "neutral"
def _merge_interval_features(
master_index: pd.DatetimeIndex,
feat: pd.DataFrame,
prefix: str,
) -> pd.DataFrame:
"""master_index 길이와 동일한 간격 특징만 반환."""
pick = [c for c in FEATURE_BOOL_COLS if c in feat.columns]
numeric_cols = (
"bb_pos",
"body_ratio",
"lower_wick_ratio",
"ret_pct",
"bb_width_pct",
"macd_line",
"macd_signal",
"macd_hist",
"stoch_k",
"stoch_d",
"RSI",
) + tuple(disparity_column(p) for p in DISPARITY_PERIODS)
extra = [c for c in numeric_cols if c in feat.columns]
if "bb_width_pct" not in feat.columns and "BB_Width" in feat.columns:
feat = feat.copy()
feat["bb_width_pct"] = feat["BB_Width"]
extra.append("bb_width_pct")
sub = feat[pick + extra].copy()
sub.columns = [f"{prefix}_{c}" for c in sub.columns]
left = pd.DataFrame({"ts": master_index})
right = sub.reset_index()
time_col = right.columns[0]
right = right.rename(columns={time_col: "ts"})
merged = pd.merge_asof(
left.sort_values("ts"),
right.sort_values("ts"),
on="ts",
direction="backward",
)
merged.index = master_index
return merged.drop(columns=["ts"])
def build_master_feature_matrix(frames: dict[int, pd.DataFrame]) -> pd.DataFrame:
"""3분 타임라인에 모든 봉의 BB·일목·캔들 특징을 붙인 행렬."""
entry = frames.get(ENTRY_INTERVAL)
if entry is None or entry.empty:
raise ValueError(f"{ENTRY_INTERVAL}분봉(ENTRY_INTERVAL) 데이터가 없습니다.")
entry_feat = compute_bar_features(entry)
entry_feat = entry_feat[~entry_feat.index.duplicated(keep="last")].sort_index()
p0 = interval_prefix(ENTRY_INTERVAL)
ohlc = ["Open", "High", "Low", "Close", "Volume", "Upper", "Lower", "MA"]
master = entry_feat[[c for c in ohlc if c in entry_feat.columns]].copy()
for col in FEATURE_BOOL_COLS:
if col in entry_feat.columns:
master[f"{p0}_{col}"] = entry_feat[col]
for col in ("bb_pos", "body_ratio", "lower_wick_ratio", "ret_pct", "bb_width_pct", "BB_Width"):
if col in entry_feat.columns:
master[f"{p0}_{col}"] = entry_feat[col]
for interval in ALL_INTERVALS:
if interval == ENTRY_INTERVAL:
continue
df = frames.get(interval)
if df is None or df.empty:
continue
feat = compute_bar_features(df)
feat = feat[~feat.index.duplicated(keep="last")].sort_index()
prefix = interval_prefix(interval)
merged = _merge_interval_features(master.index, feat, prefix)
master = pd.concat([master, merged], axis=1)
return master.loc[:, ~master.columns.duplicated()]

View File

@@ -1,370 +0,0 @@
"""
볼린저 밴드·일목·MACD·스토캐스틱·RSI·이격도 계산 (모든 봉 간격 공용).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from config import (
BB_PERIOD,
BB_STD,
DISPARITY_OVERBOUGHT,
DISPARITY_OVERSOLD,
DISPARITY_PERIODS,
MACD_FAST,
MACD_SIGNAL,
MACD_SLOW,
RSI_PERIOD,
STOCH_D_PERIOD,
STOCH_K_PERIOD,
STOCH_OVERBOUGHT,
STOCH_OVERSOLD,
STOCH_SMOOTH_K,
TREND_RANGE_MA_GAP_PCT,
)
Trend = str # "up" | "down" | "range"
def add_bollinger(
df: pd.DataFrame,
period: int = BB_PERIOD,
std_mult: float = BB_STD,
) -> pd.DataFrame:
"""
볼린저 밴드 컬럼을 추가합니다.
Args:
df: OHLCV DataFrame.
period: 중심선 기간.
std_mult: 표준편차 배수.
Returns:
MA, Upper, Lower, STD, bb_pos, BB_Width 가 추가된 DataFrame.
"""
out = df.copy()
if "MA" not in out.columns:
out["MA"] = out["Close"].rolling(period).mean()
if "Upper" not in out.columns or "Lower" not in out.columns:
std = out["Close"].rolling(period).std()
out["STD"] = std
out["Upper"] = out["MA"] + std_mult * std
out["Lower"] = out["MA"] - std_mult * std
ma = out["MA"].replace(0, np.nan)
band = (out["Upper"] - out["Lower"]).replace(0, np.nan)
out["bb_pos"] = ((out["Close"] - out["Lower"]) / band).clip(0, 1)
out["BB_Width"] = band / ma * 100
return out
def add_macd(
df: pd.DataFrame,
fast: int = MACD_FAST,
slow: int = MACD_SLOW,
signal_period: int = MACD_SIGNAL,
) -> pd.DataFrame:
"""
MACD(12,26,9) 라인·시그널·히스토그램을 추가합니다.
Args:
df: OHLCV (Close 필요).
fast: 단기 EMA 기간.
slow: 장기 EMA 기간.
signal_period: 시그널 EMA 기간.
Returns:
macd_line, macd_signal, macd_hist 컬럼이 추가된 DataFrame.
"""
out = df.copy()
close = out["Close"].astype(float)
ema_fast = close.ewm(span=fast, adjust=False).mean()
ema_slow = close.ewm(span=slow, adjust=False).mean()
out["macd_line"] = ema_fast - ema_slow
out["macd_signal"] = out["macd_line"].ewm(span=signal_period, adjust=False).mean()
out["macd_hist"] = out["macd_line"] - out["macd_signal"]
return out
def disparity_column(period: int) -> str:
"""이격도 컬럼명 (예: disparity_20)."""
return f"disparity_{period}"
def add_disparity(
df: pd.DataFrame,
periods: tuple[int, ...] | None = None,
) -> pd.DataFrame:
"""
이격도 = (종가 / SMA(n)) × 100. 100이면 이평선과 동일 위치.
Args:
df: OHLCV (Close 필요).
periods: SMA 기간 목록. None이면 config.DISPARITY_PERIODS.
Returns:
disparity_{n} 컬럼이 추가된 DataFrame.
"""
out = df.copy()
close = out["Close"].astype(float)
for p in periods or DISPARITY_PERIODS:
ma = close.rolling(p).mean()
out[disparity_column(p)] = (close / ma.replace(0, np.nan)) * 100.0
return out
def disparity_zone(value: float | None) -> str:
"""이격도 구간 라벨 (oversold / mid / overbought)."""
if value is None:
return "mid"
if value <= DISPARITY_OVERSOLD:
return "oversold"
if value >= DISPARITY_OVERBOUGHT:
return "overbought"
return "mid"
def add_stochastic(
df: pd.DataFrame,
k_period: int = STOCH_K_PERIOD,
d_period: int = STOCH_D_PERIOD,
smooth_k: int = STOCH_SMOOTH_K,
) -> pd.DataFrame:
"""
스토캐스틱 %%D를 추가합니다 (Slow Stochastic).
Args:
df: OHLCV (High, Low, Close 필요).
k_period: %K lookback.
d_period: %D SMA 기간.
smooth_k: %K SMA 평활 기간.
Returns:
stoch_k, stoch_d 컬럼이 추가된 DataFrame.
"""
out = df.copy()
h = out["High"].astype(float)
l = out["Low"].astype(float)
c = out["Close"].astype(float)
lowest = l.rolling(k_period).min()
highest = h.rolling(k_period).max()
denom = (highest - lowest).replace(0, np.nan)
raw_k = ((c - lowest) / denom) * 100.0
out["stoch_k"] = raw_k.rolling(smooth_k).mean()
out["stoch_d"] = out["stoch_k"].rolling(d_period).mean()
return out
def add_ichimoku(
df: pd.DataFrame,
tenkan: int = 9,
kijun: int = 26,
senkou_b_period: int = 52,
) -> pd.DataFrame:
"""
일목균형표 라인·구름 위치 컬럼 추가 (해당 봉 시점, 미래 데이터 미사용).
Returns:
ichi_tenkan, ichi_kijun, ichi_span_a, ichi_span_b,
ichi_cloud_top, ichi_cloud_bottom
"""
out = df.copy()
h = out["High"].astype(float)
l = out["Low"].astype(float)
c = out["Close"].astype(float)
out["ichi_tenkan"] = (h.rolling(tenkan).max() + l.rolling(tenkan).min()) / 2
out["ichi_kijun"] = (h.rolling(kijun).max() + l.rolling(kijun).min()) / 2
out["ichi_span_a"] = (out["ichi_tenkan"] + out["ichi_kijun"]) / 2
out["ichi_span_b"] = (h.rolling(senkou_b_period).max() + l.rolling(senkou_b_period).min()) / 2
out["ichi_cloud_top"] = np.maximum(out["ichi_span_a"], out["ichi_span_b"])
out["ichi_cloud_bottom"] = np.minimum(out["ichi_span_a"], out["ichi_span_b"])
return out
def prepare_entry_df(data: pd.DataFrame) -> pd.DataFrame:
"""
RSI·거래량 MA·BB 폭 등 보조 컬럼을 추가합니다.
Args:
data: BB(MA/Upper/Lower)가 계산된 OHLCV.
Returns:
RSI 등 컬럼이 추가된 DataFrame.
"""
df = data.copy()
delta = df["Close"].diff()
gain = delta.where(delta > 0, 0.0).rolling(RSI_PERIOD).mean()
loss = (-delta.where(delta < 0, 0.0)).rolling(RSI_PERIOD).mean()
rs = gain / loss.replace(0, np.nan)
df["RSI"] = 100 - (100 / (1 + rs))
df["VolMA5"] = df["Volume"].rolling(5).mean()
if "MA" in df.columns and "Upper" in df.columns and "Lower" in df.columns:
ma = df["MA"].replace(0, np.nan)
df["BB_Width"] = (df["Upper"] - df["Lower"]) / ma * 100
return df
def apply_bar_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""
봉 분석·차트용 표준 지표 일괄 적용 (BB, 일목, RSI, MACD, 스토캐스틱, 이격도).
Args:
df: OHLCV DataFrame (datetime index).
Returns:
모든 지표 컬럼이 붙은 DataFrame.
"""
out = add_bollinger(df)
out = add_ichimoku(out)
out = prepare_entry_df(out)
out = add_disparity(out)
out = add_macd(out)
out = add_stochastic(out)
return out
def latest_indicator_snapshot(df: pd.DataFrame) -> dict[str, float | str | None]:
"""
최신 봉의 BB·RSI·MACD·스토캐스틱 요약 (모니터·로그용).
Args:
df: apply_bar_indicators 적용된 DataFrame.
Returns:
지표명→값 dict.
"""
if df.empty:
return {}
row = df.iloc[-1]
def _f(col: str) -> float | None:
if col not in row.index or pd.isna(row[col]):
return None
return round(float(row[col]), 4)
macd_hist = _f("macd_hist")
stoch_k = _f("stoch_k")
stoch_d = _f("stoch_d")
stoch_zone = "mid"
if stoch_k is not None:
if stoch_k <= STOCH_OVERSOLD:
stoch_zone = "oversold"
elif stoch_k >= STOCH_OVERBOUGHT:
stoch_zone = "overbought"
macd_state = "neutral"
if macd_hist is not None:
macd_state = "bull" if macd_hist > 0 else "bear"
disp: dict[str, float | None] = {}
for p in DISPARITY_PERIODS:
col = disparity_column(p)
disp[col] = _f(col)
primary = disparity_column(DISPARITY_PERIODS[0]) if DISPARITY_PERIODS else None
disp_primary = disp.get(primary) if primary else None
return {
"bb_pos": _f("bb_pos"),
"rsi": _f("RSI"),
"disparity": disp,
"disparity_primary": disp_primary,
"disparity_zone": disparity_zone(disp_primary),
"macd_line": _f("macd_line"),
"macd_signal": _f("macd_signal"),
"macd_hist": macd_hist,
"macd_state": macd_state,
"stoch_k": stoch_k,
"stoch_d": stoch_d,
"stoch_zone": stoch_zone,
}
def _trend_from_ma20(df: pd.DataFrame) -> Trend:
"""
단일 TF에서 종가·MA20·MA40 관계로 추세를 판정합니다.
Args:
df: OHLCV+지표 DataFrame.
Returns:
up | down | range.
"""
if len(df) < 20:
return "range"
close = float(df["Close"].iloc[-1])
ma20_col = "MA20" if "MA20" in df.columns else "MA"
if ma20_col not in df.columns:
return "range"
ma20 = float(df[ma20_col].iloc[-1])
ma40 = float(df["MA40"].iloc[-1]) if "MA40" in df.columns and len(df) >= 40 else ma20
if ma40 == 0:
return "range"
gap = abs(ma20 - ma40) / ma40 * 100
if gap < TREND_RANGE_MA_GAP_PCT:
return "range"
if close > ma20 and ma20 > ma40:
return "up"
if close < ma20 and ma20 < ma40:
return "down"
return "range"
def get_trend(df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> Trend:
"""
일봉·1시간봉 기준 추세(up/down/range)를 반환합니다.
Args:
df_1d: 일봉 OHLCV+지표.
df_1h: 1시간봉 OHLCV+지표.
Returns:
추세 문자열.
"""
if len(df_1d) < 20 or len(df_1h) < 40:
return "range"
d_close = float(df_1d["Close"].iloc[-1])
d_ma20 = float(df_1d["MA20"].iloc[-1])
h_close = float(df_1h["Close"].iloc[-1])
h_ma20 = float(df_1h["MA20"].iloc[-1])
h_ma40 = float(df_1h["MA40"].iloc[-1])
if h_ma40 == 0:
return "range"
ma_gap_pct = abs(h_ma20 - h_ma40) / h_ma40 * 100
if ma_gap_pct < TREND_RANGE_MA_GAP_PCT:
return "range"
if d_close > d_ma20 and h_ma20 > h_ma40 and h_close > h_ma20:
return "up"
if d_close < d_ma20 and h_ma20 < h_ma40 and h_close < h_ma20:
return "down"
return "range"
def get_mtf_trend_summary(
frames: dict[int, pd.DataFrame],
interval_keys: tuple[int, ...],
) -> dict[str, str]:
"""
여러 TF의 종가·이동평균 기준 추세 요약(주·월봉 포함).
Args:
frames: interval → OHLCV+지표.
interval_keys: 요약할 간격(분) 목록.
Returns:
interval_label → up/down/range.
"""
from deepcoin.data.mtf_bb import interval_label
out: dict[str, str] = {}
for iv in interval_keys:
df = frames.get(iv)
if df is None or df.empty:
continue
out[interval_label(iv)] = _trend_from_ma20(df)
return out

View File

@@ -1,93 +0,0 @@
"""
봉 간격 상수·빗썸(Upbit 호환) 캔들 API 경로.
분봉·일봉 외 주봉(10080)·월봉(43200)은 DB 테이블명 `{symbol}_{interval}` 에 그대로 사용합니다.
"""
from __future__ import annotations
from dateutil.relativedelta import relativedelta
from config import DAILY_INTERVAL_MIN, MONTH_INTERVAL_MIN, WEEK_INTERVAL_MIN
WM_INTERVALS: frozenset[int] = frozenset({WEEK_INTERVAL_MIN, MONTH_INTERVAL_MIN})
# 01_download·ops_sync 대상에서 제외 (DB 적재 안 함)
DOWNLOAD_EXCLUDED_INTERVALS: frozenset[int] = frozenset({1})
def is_excluded_from_download(interval: int) -> bool:
"""01_download·ops_sync에서 건너뛸 간격(1분봉 등)."""
return interval in DOWNLOAD_EXCLUDED_INTERVALS
def is_week_or_month(interval: int) -> bool:
"""주봉·월봉 여부."""
return interval in WM_INTERVALS
def candle_api_segment(interval: int) -> str:
"""
REST 경로 세그먼트 (minutes/{n} 제외).
Returns:
'weeks' | 'months' | 'days' | minutes/{interval}
"""
if interval == WEEK_INTERVAL_MIN:
return "weeks"
if interval == MONTH_INTERVAL_MIN:
return "months"
if interval >= DAILY_INTERVAL_MIN:
return "days"
return f"minutes/{interval}"
def interval_display_label(interval: int) -> str:
"""로그·UI용 라벨."""
if interval == WEEK_INTERVAL_MIN:
return "주봉"
if interval == MONTH_INTERVAL_MIN:
return "월봉"
if interval >= DAILY_INTERVAL_MIN:
return "일봉(1440)"
return f"{interval}분봉"
def pagination_step(interval: int, chunk_bars: int) -> relativedelta:
"""
get_coin_more_data 역순 수집 시 `to` 감소 단위.
Args:
interval: 봉 간격(분 표기).
chunk_bars: API 1회 최대 봉 수(겹침 청크).
Returns:
relativedelta.
"""
if interval == WEEK_INTERVAL_MIN:
return relativedelta(weeks=chunk_bars)
if interval == MONTH_INTERVAL_MIN:
return relativedelta(months=chunk_bars)
return relativedelta(minutes=interval * chunk_bars)
def bars_for_months(interval: int, months: int, *, extra_days: int = 0) -> int:
"""
N개월치 예상 봉 수(여유 포함).
Args:
interval: 봉 간격.
months: 보관·적재 개월 수.
extra_days: 일봉 계열 여유 일수.
Returns:
API 요청 목표 봉 수.
"""
if interval == WEEK_INTERVAL_MIN:
return int(months * 30 / 7) + max(extra_days // 7, 5)
if interval == MONTH_INTERVAL_MIN:
return months + max(extra_days // 30, 2)
if interval >= DAILY_INTERVAL_MIN:
return months * 30 + extra_days
bars_per_day = (24 * 60) // interval
return months * 30 * bars_per_day + 200

View File

@@ -1,426 +0,0 @@
"""
WLD 과거 봉을 빗썸 API에서 받아 coins.db에 저장합니다.
- 최초: 최근 N개월 전량 적재
- 이후: DB 마지막 시각 **이후** 봉만 추가 (증분)
"""
from __future__ import annotations
import sqlite3
from datetime import datetime
import pandas as pd
from dateutil.relativedelta import relativedelta
from config import (
BITHUMB_MINUTE_INTERVALS,
COIN_NAME,
DB_PATH,
DOWNLOAD_BACKFILL_EXTRA_BARS,
DOWNLOAD_DAILY_EXTRA_DAYS,
DOWNLOAD_INTERVALS,
DOWNLOAD_INTERVALS_WM,
DOWNLOAD_MIN_INCREMENTAL_BARS,
DOWNLOAD_MONTHS,
DOWNLOAD_MONTHS_WM,
INCREMENTAL_OVERLAP_BARS,
KR_COINS,
SYMBOL,
)
from deepcoin.data.candle_intervals import (
bars_for_months,
interval_display_label,
is_excluded_from_download,
is_week_or_month,
)
from deepcoin.ops.monitor import Monitor
def bong_count_for_months(interval_minutes: int, months: int) -> int:
"""N개월치 봉 개수(여유분 포함)."""
if is_week_or_month(interval_minutes):
return bars_for_months(
interval_minutes, months, extra_days=DOWNLOAD_DAILY_EXTRA_DAYS
)
days = months * 30
from config import DAILY_INTERVAL_MIN
if interval_minutes >= DAILY_INTERVAL_MIN:
return days + DOWNLOAD_DAILY_EXTRA_DAYS
bars_per_day = (24 * 60) // interval_minutes
return days * bars_per_day + DOWNLOAD_BACKFILL_EXTRA_BARS
def bong_count_since(
interval_minutes: int, last_ts: pd.Timestamp, overlap: int = INCREMENTAL_OVERLAP_BARS
) -> int:
"""마지막 저장 시각 이후 필요한 API 봉 수(겹침 포함)."""
now = pd.Timestamp.now()
if last_ts.tzinfo is not None and now.tzinfo is None:
last_ts = last_ts.tz_localize(None)
delta_min = max(0, (now - last_ts).total_seconds() / 60)
bars = int(delta_min / interval_minutes) + overlap + 10
return max(bars, DOWNLOAD_MIN_INCREMENTAL_BARS)
def months_cutoff(months: int) -> pd.Timestamp:
"""N개월 전 시각."""
return pd.Timestamp(datetime.now() - relativedelta(months=months))
def trim_to_recent_months(data: pd.DataFrame, months: int) -> pd.DataFrame:
"""최근 N개월 구간만 남깁니다."""
if data is None or data.empty:
return data
cutoff = months_cutoff(months)
if not isinstance(data.index, pd.DatetimeIndex):
data = data.copy()
data.index = pd.to_datetime(data.index)
return data[data.index >= cutoff].copy()
def interval_label(interval: int) -> str:
"""로그용 간격 라벨."""
return interval_display_label(interval)
def months_for_interval(interval: int, default_months: int) -> int:
"""간격별 DB 보관 개월 수 (주·월봉은 DOWNLOAD_MONTHS_WM)."""
if is_week_or_month(interval):
return DOWNLOAD_MONTHS_WM
return default_months
def all_download_intervals() -> tuple[int, ...]:
"""분봉·일봉 + 주·월봉 간격 목록(1분 제외, 중복 제거, 순서 유지)."""
seen: set[int] = set()
out: list[int] = []
for iv in (*DOWNLOAD_INTERVALS, *DOWNLOAD_INTERVALS_WM):
if is_excluded_from_download(iv) or iv in seen:
continue
seen.add(iv)
out.append(iv)
return tuple(out)
def download_jobs() -> list[tuple[int, str]]:
"""
01_download 대상 간격.
Returns:
(interval_min, 표시명) 리스트.
"""
jobs: list[tuple[int, str]] = []
for iv in all_download_intervals():
if is_excluded_from_download(iv):
continue
if not is_week_or_month(iv) and iv < 1440 and iv not in BITHUMB_MINUTE_INTERVALS:
print(f"경고: {iv}분봉은 빗썸 API 미지원 — 건너뜀")
continue
jobs.append((iv, interval_label(iv)))
return jobs
def ensure_table(cursor, table_name: str) -> None:
cursor.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} "
"(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
"Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
)
cursor.execute(
f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
)
def get_earliest_timestamp(
symbol: str, interval: int, db_path: str = DB_PATH
) -> pd.Timestamp | None:
"""테이블에 저장된 해당 심볼의 가장 오래된 봉 시각."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"SELECT MIN(ymdhms) FROM {table_name} WHERE CODE = ?",
(symbol,),
)
row = cursor.fetchone()
conn.close()
if row and row[0]:
return pd.Timestamp(row[0])
return None
def get_last_timestamp(
symbol: str, interval: int, db_path: str = DB_PATH
) -> pd.Timestamp | None:
"""테이블에 저장된 해당 심볼의 마지막 봉 시각."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"SELECT MAX(ymdhms) FROM {table_name} WHERE CODE = ?",
(symbol,),
)
row = cursor.fetchone()
conn.close()
if row and row[0]:
return pd.Timestamp(row[0])
return None
def get_row_count(symbol: str, interval: int, db_path: str = DB_PATH) -> int:
"""저장된 봉 개수."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"SELECT COUNT(*) FROM {table_name} WHERE CODE = ?",
(symbol,),
)
row = cursor.fetchone()
conn.close()
return int(row[0]) if row else 0
def filter_after_last(
data: pd.DataFrame, last_ts: pd.Timestamp | None
) -> pd.DataFrame:
"""마지막 저장 시각보다 이후(>)인 봉만 반환."""
if data is None or data.empty or last_ts is None:
return data
if not isinstance(data.index, pd.DatetimeIndex):
data = data.copy()
data.index = pd.to_datetime(data.index)
last = pd.Timestamp(last_ts)
return data[data.index > last].copy()
def prune_before_cutoff(
symbol: str, interval: int, months: int, db_path: str = DB_PATH
) -> int:
"""N개월보다 오래된 봉 삭제 (DB 용량 유지)."""
cutoff = months_cutoff(months).strftime("%Y-%m-%d %H:%M:%S")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"DELETE FROM {table_name} WHERE CODE = ? AND ymdhms < ?",
(symbol, cutoff),
)
deleted = cursor.rowcount
conn.commit()
cursor.close()
conn.close()
return deleted
def append_data(
symbol: str,
interval: int,
data: pd.DataFrame,
last_ts: pd.Timestamp | None = None,
db_path: str = DB_PATH,
) -> tuple[int, int]:
"""
마지막 시각 이후 봉만 INSERT합니다. 기존 데이터는 삭제하지 않습니다.
Args:
last_ts: None이면 전체 data 적재, 있으면 index > last_ts 만 적재
Returns:
(추가된 행 수, 스킵된 행 수)
"""
if data is None or data.empty:
return 0, 0
total = len(data)
to_save = data if last_ts is None else filter_after_last(data, last_ts)
skipped = total - len(to_save)
if to_save.empty:
return 0, skipped
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
records = []
for i in range(len(to_save)):
ts = to_save.index[i]
if hasattr(ts, "to_pydatetime"):
ts = ts.to_pydatetime()
ymd = ts.strftime("%Y%m%d")
hms = ts.strftime("%H%M%S")
ymdhms = ts.strftime("%Y-%m-%d %H:%M:%S")
records.append(
(
symbol,
KR_COINS[symbol],
ymdhms,
ymd,
hms,
float(to_save["Open"].iloc[i]),
float(to_save["High"].iloc[i]),
float(to_save["Low"].iloc[i]),
float(to_save["Close"].iloc[i]),
float(to_save["Volume"].iloc[i]),
)
)
cursor.executemany(
f"INSERT INTO {table_name} "
"(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
records,
)
conn.commit()
cursor.close()
conn.close()
return len(records), skipped
def backfill_before_earliest(
monitor: Monitor,
symbol: str,
interval: int,
months: int,
) -> int:
"""
DB 최초 봉보다 오래된 구간을 API로 채웁니다 (1년 적재 시 필요).
Returns:
추가된 행 수.
"""
months = months_for_interval(interval, months)
cutoff = months_cutoff(months)
earliest = get_earliest_timestamp(symbol, interval)
if earliest is None or earliest <= cutoff:
return 0
label = interval_label(interval)
# now부터 역순 수집이므로 cutoff까지 닿으려면 N개월 전체 봉 수가 필요
target = bong_count_for_months(interval, months)
print(
f" [백필] {label}{cutoff.date()} ~ {earliest} "
f"(API 역수집 약 {target}봉)"
)
data = monitor.get_coin_more_data(
symbol, interval, bong_count=target, verbose=True
)
if data is None or data.empty:
print(" -> 백필 API 데이터 없음")
return 0
if not isinstance(data.index, pd.DatetimeIndex):
data.index = pd.to_datetime(data.index)
hist = data[(data.index >= cutoff) & (data.index < earliest)].copy()
if hist.empty:
print(" -> 백필 대상 구간 없음")
return 0
inserted, skipped = append_data(symbol, interval, hist, last_ts=None)
print(f" -> 백필 추가 {inserted}행 (스킵 {skipped})")
return inserted
def download_symbol(
monitor: Monitor,
symbol: str,
interval: int,
months: int,
*,
verbose: bool = True,
skip_backfill: bool = False,
) -> None:
"""
한 간격의 봉을 API로 받아 증분·백필 저장합니다.
Args:
skip_backfill: True면 운영 증분만(백필 생략). 05/06 시작 동기화용.
"""
months = months_for_interval(interval, months)
label = interval_label(interval)
existing = get_row_count(symbol, interval)
if existing > 0 and not skip_backfill:
backfill_before_earliest(monitor, symbol, interval, months)
last_ts = get_last_timestamp(symbol, interval)
if last_ts is None:
target = bong_count_for_months(interval, months)
mode = "초기 적재"
else:
target = min(
bong_count_since(interval, last_ts),
bong_count_for_months(interval, months),
)
mode = f"증분 (마지막 {last_ts.strftime('%Y-%m-%d %H:%M:%S')} 이후)"
print(f"\n[{symbol}] {label}{mode}")
print(f" DB 기존 {existing}행 | API 목표 약 {target}")
data = monitor.get_coin_more_data(
symbol, interval, bong_count=target, verbose=verbose
)
if data is None or data.empty:
print(" -> API 데이터 없음")
return
data = trim_to_recent_months(data, months)
if data.empty:
print(" -> 최근 N개월 필터 후 데이터 없음")
return
inserted, skipped = append_data(symbol, interval, data, last_ts=last_ts)
pruned = prune_before_cutoff(symbol, interval, months)
new_last = get_last_timestamp(symbol, interval)
total = get_row_count(symbol, interval)
print(f" -> API {len(data)}봉 | 추가 {inserted}행 | 스킵(기존) {skipped}")
if pruned > 0:
print(f" -> {months}개월 이전 {pruned}행 정리")
if new_last is not None:
print(f" -> DB 합계 {total}행 | {data.index[0]} ~ {new_last}")
def download(months: int | None = None) -> None:
"""
WLD 다중 분봉·일봉·주봉·월봉을 coins.db에 증분 적재합니다.
간격: DOWNLOAD_INTERVALS + DOWNLOAD_INTERVALS_WM (주·월은 DOWNLOAD_MONTHS_WM, 기본 24=2년)
"""
default_months = months or DOWNLOAD_MONTHS
monitor = Monitor(cooldown_file=None)
jobs = download_jobs()
intervals_str = ", ".join(str(iv) for iv, _ in jobs)
print(f"=== {COIN_NAME} ({SYMBOL}) -> {DB_PATH} (증분 INSERT) ===")
print(
f"보관 분봉·일봉 {default_months}개월 | "
f"주·월봉 {DOWNLOAD_MONTHS_WM}개월 | 간격(분): {intervals_str}"
)
started = datetime.now()
for interval, desc in jobs:
print(f"\n--- {desc} ---")
job_months = months_for_interval(interval, default_months)
try:
download_symbol(monitor, SYMBOL, interval, job_months)
except Exception as e:
print(f"오류 interval={interval}: {e}")
elapsed = datetime.now() - started
print(f"\n완료 (소요: {elapsed})")
if __name__ == "__main__":
download()

View File

@@ -1,56 +0,0 @@
"""
coins.db에서 전 간격 봉 데이터를 로드합니다.
"""
from __future__ import annotations
import pandas as pd
from config import DOWNLOAD_INTERVALS, DOWNLOAD_INTERVALS_WM, SYMBOL
from deepcoin.data.candle_intervals import interval_display_label
def interval_label(interval: int) -> str:
"""봉 간격 표시 라벨."""
return interval_display_label(interval)
def _all_load_intervals() -> tuple[int, ...]:
seen: set[int] = set()
out: list[int] = []
for iv in (*DOWNLOAD_INTERVALS, *DOWNLOAD_INTERVALS_WM):
if iv not in seen:
seen.add(iv)
out.append(iv)
return tuple(out)
def load_frames_from_db(
monitor,
symbol: str,
lookback_days: int | None = None,
) -> dict[int, pd.DataFrame]:
"""
coins.db에서 DOWNLOAD_INTERVALS 전부 로드·지표 계산.
Args:
monitor: Monitor 인스턴스.
symbol: 코인 심볼.
lookback_days: 지정 시 간격별로 해당 일수만큼 DB에서 더 많이 읽습니다.
Returns:
간격(분) → OHLCV+지표 DataFrame.
"""
frames: dict[int, pd.DataFrame] = {}
for iv in _all_load_intervals():
db_max = None
if lookback_days is not None:
db_max = monitor.db_row_limit_for_interval(iv, lookback_days)
df = monitor.get_coin_some_data(symbol, iv, db_max_rows=db_max)
if df is None or df.empty:
print(f" [{interval_label(iv)}] DB/API 데이터 없음 — 스킵")
continue
df = monitor.calculate_technical_indicators(df)
frames[iv] = df
print(f" [{interval_label(iv)}] {len(df)}{df.index[0]} ~ {df.index[-1]}")
return frames

View File

@@ -1,191 +0,0 @@
"""
05/06 운영 시작 전 coins.db 누락 봉을 증분 보완합니다.
마지막 저장 시각이 현재보다 OPS_SYNC_MAX_LAG_BARS 이상 뒤처진 간격만
01_download.download_symbol 과 동일 경로로 API 수집합니다.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
import pandas as pd
from config import (
COIN_NAME,
DB_PATH,
DOWNLOAD_MONTHS,
OPS_SYNC_MAX_LAG_BARS,
OPS_SYNC_ON_START,
SYMBOL,
)
from deepcoin.data.downloader import (
download_jobs,
download_symbol,
get_last_timestamp,
get_row_count,
interval_label,
)
from deepcoin.ops.monitor import Monitor
# 프로세스당 1회 상세 로그(루프마다 ensure 호출 시 스팸 방지)
_logged_fresh_once: bool = False
@dataclass
class OpsSyncResult:
"""운영 전 DB 동기화 결과."""
synced: list[int] = field(default_factory=list)
skipped_fresh: list[int] = field(default_factory=list)
errors: dict[int, str] = field(default_factory=dict)
disabled: bool = False
@property
def ok(self) -> bool:
"""치명적 오류 없이 완료."""
return not self.errors or len(self.synced) > 0 or len(self.skipped_fresh) > 0
def lag_minutes_since_last(
last_ts: pd.Timestamp | None, interval_minutes: int
) -> float | None:
"""
마지막 봉 시각 대비 경과 분.
Returns:
last_ts 없으면 None.
"""
if last_ts is None:
return None
now = pd.Timestamp.now()
last = pd.Timestamp(last_ts)
if last.tzinfo is not None and now.tzinfo is None:
last = last.tz_localize(None)
return max(0.0, (now - last).total_seconds() / 60.0)
def is_interval_stale(
symbol: str,
interval: int,
*,
max_lag_bars: int = OPS_SYNC_MAX_LAG_BARS,
db_path: str = DB_PATH,
) -> tuple[bool, str]:
"""
간격별 DB가 운영에 필요한 최신성을 만족하는지 판단.
Returns:
(stale 여부, 사유 문자열)
"""
rows = get_row_count(symbol, interval, db_path=db_path)
last = get_last_timestamp(symbol, interval, db_path=db_path)
if rows == 0 or last is None:
return True, "데이터 없음"
lag = lag_minutes_since_last(last, interval)
if lag is None:
return True, "마지막 시각 없음"
threshold = float(interval * max_lag_bars)
if lag > threshold:
return True, f"지연 {lag:.0f}분 > 허용 {threshold:.0f}"
return False, f"최신 (마지막 {last.strftime('%Y-%m-%d %H:%M:%S')})"
def list_stale_intervals(
symbol: str = SYMBOL,
*,
max_lag_bars: int = OPS_SYNC_MAX_LAG_BARS,
db_path: str = DB_PATH,
) -> list[tuple[int, str, str]]:
"""
갱신이 필요한 (interval, label, reason) 목록.
Returns:
download_jobs 순서와 동일하게 stale 항목만.
"""
out: list[tuple[int, str, str]] = []
for interval, label in download_jobs():
stale, reason = is_interval_stale(
symbol, interval, max_lag_bars=max_lag_bars, db_path=db_path
)
if stale:
out.append((interval, label, reason))
return out
def ensure_ops_candles(
symbol: str = SYMBOL,
months: int | None = None,
*,
force: bool = False,
verbose_download: bool = False,
) -> OpsSyncResult:
"""
05/06 실행 직전 누락·지연 봉을 coins.db에 증분 적재합니다.
Args:
symbol: 코인 코드.
months: 보관 개월( None 이면 DOWNLOAD_MONTHS ).
force: True면 최신 간격도 전부 재수집.
verbose_download: True면 download_symbol API 진행 로그 출력.
Returns:
OpsSyncResult
"""
global _logged_fresh_once
result = OpsSyncResult()
if not OPS_SYNC_ON_START and not force:
result.disabled = True
print("[ops_sync] OPS_SYNC_ON_START=0 — 건너뜀")
return result
months = months or DOWNLOAD_MONTHS
jobs = download_jobs()
stale = list_stale_intervals(symbol) if not force else [
(iv, lb, "force") for iv, lb in jobs
]
fresh = [
iv for iv, _ in jobs if iv not in {s[0] for s in stale}
]
result.skipped_fresh = fresh
if not stale:
if not _logged_fresh_once or force:
print(
f"[ops_sync] {COIN_NAME} ({symbol}) DB 최신 · "
f"간격 {len(fresh)}개 (증분 다운로드 없음)"
)
for iv in fresh:
last = get_last_timestamp(symbol, iv)
if last is not None:
print(f" [{interval_label(iv)}] ~ {last}")
_logged_fresh_once = True
return result
print(
f"[ops_sync] {COIN_NAME} ({symbol}) 누락 보완 · "
f"갱신 {len(stale)}개 / 최신 {len(fresh)}개 (백필 생략·증분만)"
)
monitor = Monitor(cooldown_file=None)
started = datetime.now()
for interval, label, reason in stale:
print(f"\n[ops_sync] --- {label} --- ({reason})")
try:
download_symbol(
monitor,
symbol,
interval,
months,
verbose=verbose_download,
skip_backfill=True,
)
result.synced.append(interval)
except Exception as exc:
result.errors[interval] = str(exc)
print(f" [ops_sync] 오류 interval={interval}: {exc}")
elapsed = datetime.now() - started
_logged_fresh_once = True
print(f"\n[ops_sync] 완료 (소요: {elapsed}) · 갱신={result.synced} 오류={len(result.errors)}")
return result

View File

@@ -1,87 +0,0 @@
"""
DeepCoin .env 로드 (프로젝트 루트 기준).
config·HTS·스크립트 진입 전에 한 번 호출하면 cwd와 무관하게 동일한 설정을 사용합니다.
"""
from __future__ import annotations
import os
from pathlib import Path
from deepcoin.paths import PROJECT_ROOT
_ENV_LOADED = False
_ENV_LOADER = "none"
ENV_FILE = PROJECT_ROOT / ".env"
def _load_env_fallback(*, override: bool) -> None:
"""
python-dotenv 미설치 시 .env 를 직접 파싱해 os.environ 에 반영합니다.
Args:
override: True면 기존 키를 .env 값으로 덮어씀.
"""
if not ENV_FILE.is_file():
return
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[7:].strip()
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if not key:
continue
if not override and key in os.environ:
continue
os.environ[key] = value
def load_project_env(*, override: bool = False) -> bool:
"""
PROJECT_ROOT/.env 를 로드합니다 (python-dotenv 우선, 없으면 fallback).
Args:
override: True면 기존 OS 환경 변수를 .env 값으로 덮어씀.
Returns:
.env 파일이 존재해 로드했으면 True, 없으면 False.
"""
global _ENV_LOADED, _ENV_LOADER
if _ENV_LOADED and not override:
return ENV_FILE.is_file()
if not ENV_FILE.is_file():
_ENV_LOADED = True
_ENV_LOADER = "missing"
return False
try:
from dotenv import load_dotenv
load_dotenv(ENV_FILE, override=override)
_ENV_LOADER = "dotenv"
except ImportError:
_load_env_fallback(override=override)
_ENV_LOADER = "fallback"
_ENV_LOADED = True
return True
def env_status() -> dict[str, str | bool]:
"""디버그용 .env 상태."""
return {
"project_root": str(PROJECT_ROOT),
"env_file": str(ENV_FILE),
"env_exists": ENV_FILE.is_file(),
"loaded": _ENV_LOADED,
"loader": _ENV_LOADER,
"live_trading_enabled": os.getenv("LIVE_TRADING_ENABLED", ""),
}

View File

@@ -1,173 +0,0 @@
"""
인과 GT leg 엔진 파라미터 그리드 탐색·최적 저장.
"""
from __future__ import annotations
import json
from itertools import product
from pathlib import Path
from typing import Any
from config import (
CHART_LOOKBACK_DAYS,
GT_BUY_BB_MAX,
GT_BUY_MIN_SWING_PCT,
GT_MIN_SWING_PCT,
GT_PIVOT_ORDER,
MATCH_PRIMARY_INTERVAL,
SYMBOL,
)
from deepcoin.data.mtf_bb import load_frames_from_db
from deepcoin.ground_truth.causal_gt_trades import simulate_causal_gt_portfolio
from deepcoin.ground_truth.ground_truth import load_ground_truth
from deepcoin.ops.monitor import Monitor
from deepcoin.paths import MATCHING_CAUSAL_GT_CALIBRATION_JSON, resolve_ground_truth_file
def default_causal_gt_params() -> dict[str, Any]:
"""
인과 GT leg 엔진 기본 파라미터.
Returns:
build_causal_split_buy_peak_sell_trades 키워드 인자.
"""
from config import (
CAUSAL_GT_MIN_BARS_BETWEEN_LEGS,
CAUSAL_GT_MIN_LEG_PCT,
CAUSAL_GT_PEAK_MODE,
CAUSAL_GT_USE_LOCAL_TROUGH,
)
return {
"pivot_order": GT_PIVOT_ORDER,
"buy_swing_pct": GT_BUY_MIN_SWING_PCT,
"sell_swing_pct": GT_MIN_SWING_PCT,
"bb_max": GT_BUY_BB_MAX,
"min_leg_pct": CAUSAL_GT_MIN_LEG_PCT,
"use_local_trough": CAUSAL_GT_USE_LOCAL_TROUGH,
"peak_mode": CAUSAL_GT_PEAK_MODE,
"min_bars_between_legs": CAUSAL_GT_MIN_BARS_BETWEEN_LEGS,
}
def load_causal_gt_params(path: Path | None = None) -> dict[str, Any]:
"""
캘리브레이션 JSON 또는 config 기본값.
Args:
path: JSON 경로. None이면 MATCHING_CAUSAL_GT_CALIBRATION_JSON.
Returns:
best params dict.
"""
p = path or MATCHING_CAUSAL_GT_CALIBRATION_JSON
if p.is_file():
data = json.loads(p.read_text(encoding="utf-8"))
best = data.get("best_params") or data.get("params")
if best:
return dict(best)
return default_causal_gt_params()
def _grid_space() -> dict[str, list[Any]]:
"""탐색 그리드 (로컬 peak 최적화 반영, 조합 ~864)."""
return {
"peak_mode": ["local", "zigzag"],
"pivot_order": [8, 10, 12, 15],
"buy_swing_pct": [2.0, 2.5, 3.0],
"sell_swing_pct": [3.0, 4.0],
"bb_max": [0.55, 0.65, 0.75],
"min_leg_pct": [3.0, 5.0, 8.0],
"min_bars_between_legs": [60, 90],
"use_local_trough": [True, False],
}
def run_causal_gt_calibration(
*,
min_trades: int = 30,
top_n: int = 20,
out_path: Path | None = None,
) -> dict[str, Any]:
"""
그리드 탐색 후 최적 파라미터 JSON 저장.
Args:
min_trades: 최소 체결 수 미만 조합 제외.
top_n: 상위 N개 기록.
out_path: 저장 경로.
Returns:
calibration report dict.
"""
gt = load_ground_truth(resolve_ground_truth_file()) or {}
mark = float((gt.get("summary") or {}).get("mark_price") or 0)
gt_pnl = float(
(gt.get("summary") or {}).get("pnl_pct")
or 0
)
mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
df = frames[MATCH_PRIMARY_INTERVAL].copy()
grid = _grid_space()
keys = list(grid.keys())
results: list[dict[str, Any]] = []
total = 1
for k in keys:
total *= len(grid[k])
print(f"[causal_gt] 그리드 {total} 조합 탐색...")
done = 0
for combo in product(*(grid[k] for k in keys)):
params = dict(zip(keys, combo))
r = simulate_causal_gt_portfolio(df, last_price=mark or None, **params)
tc = int(r.get("trade_count") or 0)
done += 1
if done % 200 == 0:
print(f" ... {done}/{total}")
if tc < min_trades:
continue
pnl = float(r.get("pnl_pct") or 0)
results.append(
{
"pnl_pct": round(pnl, 2),
"trade_count": tc,
"leg_count": r.get("leg_count", 0),
"max_drawdown_pct": r.get("max_drawdown_pct"),
"capture_ratio": round(pnl / gt_pnl, 4) if gt_pnl else 0,
"params": params,
}
)
results.sort(key=lambda x: x["pnl_pct"], reverse=True)
best = results[0] if results else None
report: dict[str, Any] = {
"symbol": SYMBOL,
"interval_min": MATCH_PRIMARY_INTERVAL,
"gt_pnl_pct": gt_pnl,
"grid_combinations": total,
"valid_combinations": len(results),
"min_trades": min_trades,
"best": best,
"best_params": best["params"] if best else default_causal_gt_params(),
"top": results[:top_n],
"target_pnl_pct": 300.0,
"target_met": bool(best and best["pnl_pct"] >= 300.0),
}
out = out_path or MATCHING_CAUSAL_GT_CALIBRATION_JSON
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[causal_gt] 저장: {out}")
if best:
print(
f"[causal_gt] 최적 PnL={best['pnl_pct']}% "
f"trades={best['trade_count']} legs={best['leg_count']} "
f"capture={best.get('capture_ratio', 0):.2%}"
)
else:
print("[causal_gt] 유효 조합 없음")
return report

View File

@@ -1,447 +0,0 @@
"""
Phase 3: monitor 발화 + drawdown/past-leg tier (인과적).
매도는 monitor(sell_mtf_cross) 유지, tier만 drawdown·과거 leg 수익으로 강화합니다.
"""
from __future__ import annotations
from typing import Any
import pandas as pd
from config import (
CAUSAL_GT_DD_LARGE_PCT,
CAUSAL_GT_DD_MEDIUM_PCT,
GT_BUY_PCT_LARGE_LEG,
GT_BUY_PCT_MEDIUM_LEG,
GT_BUY_PCT_SMALL_LEG,
GT_INITIAL_CASH_KRW,
SIM_TIER_CONVICTION_DD_PCT,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.gt_allocation import (
allocate_order_amounts_chronological,
simulate_portfolio_summary,
)
from deepcoin.matching.portfolio_sim import sort_fires_chronological
from deepcoin.matching.position_sizing import enrich_sim_trades_with_gt_weights
def _deduped_ohlc(df: pd.DataFrame) -> pd.DataFrame:
"""
DatetimeIndex 중복 제거·정렬 (drawdown lookup용).
Args:
df: OHLC DataFrame.
Returns:
index unique OHLC.
"""
if df.empty:
return df
out = df.sort_index()
if not out.index.is_unique:
out = out[~out.index.duplicated(keep="last")]
return out
def _close_series_from_df(df: pd.DataFrame) -> pd.Series:
"""
OHLC DataFrame에서 종가 시리즈 추출 (positional index).
Args:
df: Open/Close 또는 open/close 컬럼을 가진 OHLC.
Returns:
float 종가 시리즈.
"""
if df.empty:
return pd.Series(dtype=float)
frame = _deduped_ohlc(df)
for col in ("close", "Close"):
if col in frame.columns:
return frame[col].astype(float).reset_index(drop=True)
raise KeyError("OHLC DataFrame에 close/Close 컬럼이 없습니다.")
def _bar_index_at(df: pd.DataFrame, dt: str) -> int:
"""
시각 dt에 대응하는 bar 위치 (인덱스 중복 시 nearest).
Args:
df: DatetimeIndex OHLC.
dt: ISO 시각 문자열.
Returns:
정수 bar 위치 (0..n-1).
"""
frame = _deduped_ohlc(df)
if frame.empty:
return 0
try:
ts = pd.to_datetime(dt)
except (TypeError, ValueError):
return 0
pos = int(frame.index.get_indexer([ts], method="nearest")[0])
return max(pos, 0)
def _drawdown_pct_at_index(closes: pd.Series, idx: int) -> float:
"""
bar idx 시점 drawdown % (과거 rolling high 대비, 인과적).
Args:
closes: 종가 시리즈.
idx: 봉 위치.
Returns:
drawdown % (0~100).
"""
if idx < 0 or idx >= len(closes):
return 0.0
seg = closes.iloc[: idx + 1].astype(float)
if seg.empty:
return 0.0
peak = float(seg.max())
cur = float(seg.iloc[-1])
if peak <= 0:
return 0.0
return max((peak - cur) / peak * 100.0, 0.0)
def hybrid_tier_scale(
trade: dict[str, Any],
*,
completed_leg_ret: dict[int, float],
enhanced: bool = False,
dd_large_pct: float | None = None,
dd_medium_pct: float | None = None,
) -> float:
"""
과거 leg 수익 tier + drawdown tier (인과적).
Args:
trade: 매수 trade dict (drawdown_pct 포함).
completed_leg_ret: 청산 완료 leg realized return %.
enhanced: True면 medium tier·conviction 플래그 적용.
dd_large_pct: drawdown large tier 임계(%). None이면 config.
dd_medium_pct: drawdown medium tier 임계(%). None이면 config.
Returns:
asset_pct_scale.
"""
from config import GT_LARGE_LEG_TOP_PCT
from deepcoin.matching.position_sizing import (
large_leg_ids_from_past_returns,
)
dd_large = float(dd_large_pct if dd_large_pct is not None else CAUSAL_GT_DD_LARGE_PCT)
dd_medium = float(dd_medium_pct if dd_medium_pct is not None else CAUSAL_GT_DD_MEDIUM_PCT)
lid = int(trade.get("leg_id", 0))
large_past = large_leg_ids_from_past_returns(completed_leg_ret, GT_LARGE_LEG_TOP_PCT)
dd = float(trade.get("drawdown_pct") or 0.0)
if lid in large_past:
if enhanced and dd >= SIM_TIER_CONVICTION_DD_PCT:
trade["conviction_buy"] = True
return float(GT_BUY_PCT_LARGE_LEG)
if dd >= dd_large:
if enhanced:
trade["conviction_buy"] = True
return float(GT_BUY_PCT_LARGE_LEG)
if dd >= dd_medium:
if enhanced and dd >= SIM_TIER_CONVICTION_DD_PCT:
trade["conviction_buy"] = True
return float(GT_BUY_PCT_MEDIUM_LEG) if enhanced else float(GT_BUY_PCT_LARGE_LEG) * 0.5
return float(GT_BUY_PCT_SMALL_LEG)
def _monitor_rows_from_fires(fires: pd.DataFrame) -> list[dict[str, Any]]:
"""monitor 발화 DataFrame → trade dict 리스트."""
rows: list[dict[str, Any]] = []
for _, r in sort_fires_chronological(fires).iterrows():
rows.append(
{
"dt": str(r["dt"]),
"action": r["side"],
"price": float(r["close"]),
"rule_id": r.get("rule_id", ""),
}
)
return rows
def build_monitor_hybrid_sized_trades(
fires: pd.DataFrame,
df: pd.DataFrame,
*,
enhanced: bool = False,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
dd_large_pct: float | None = None,
dd_medium_pct: float | None = None,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""
monitor 발화 → hybrid tier amount_krw 배분 (인과적).
Args:
fires: monitor rule 발화 (buy+sell).
df: 3m OHLC (drawdown 계산).
enhanced: conviction·medium tier 사용.
initial_cash: 시작 현금.
fee_rate: 수수료율.
Returns:
(amount_krw가 채워진 trade dict, alloc_stats).
"""
from deepcoin.ground_truth.ground_truth import load_ground_truth, order_trades_chronological
from deepcoin.paths import resolve_ground_truth_file
if fires.empty:
return [], {"buy_executed": 0, "buy_skipped": 0}
gt_data = load_ground_truth(resolve_ground_truth_file()) or {}
gt_trades = order_trades_chronological(gt_data.get("trades") or [])
enriched = enrich_sim_trades_with_gt_weights(
_monitor_rows_from_fires(fires),
gt_trades,
causal_legs=True,
)
enriched = _attach_drawdown_to_buys(enriched, df)
def scale_fn(t: dict[str, Any], completed_leg_ret: dict[int, float]) -> float:
return hybrid_tier_scale(
t,
completed_leg_ret=completed_leg_ret,
enhanced=enhanced,
dd_large_pct=dd_large_pct,
dd_medium_pct=dd_medium_pct,
)
return allocate_order_amounts_chronological(
enriched,
initial_cash=initial_cash,
fee_rate=fee_rate,
causal_tier=False,
asset_pct_scale_fn=scale_fn,
)
def _simulate_monitor_tier_portfolio(
fires: pd.DataFrame,
df: pd.DataFrame,
*,
enhanced: bool = False,
last_price: float | None = None,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
dd_large_pct: float | None = None,
dd_medium_pct: float | None = None,
) -> dict[str, Any]:
"""
monitor buy+sell + tier 복리 시뮬 (hybrid 또는 enhanced).
Args:
fires: monitor rule 발화 (buy+sell).
df: 3m OHLC (drawdown 계산).
enhanced: conviction·medium tier 사용.
last_price: 미청산 평가가.
initial_cash: 시작 현금.
fee_rate: 수수료율.
dd_large_pct: drawdown large tier 임계(%).
dd_medium_pct: drawdown medium tier 임계(%).
Returns:
portfolio summary dict.
"""
mode = "monitor_tier_enhanced" if enhanced else "monitor_dd_tier"
if fires.empty:
return {"pnl_pct": 0.0, "trade_count": 0, "sizing_mode": mode}
sized, alloc_stats = build_monitor_hybrid_sized_trades(
fires,
df,
enhanced=enhanced,
initial_cash=initial_cash,
fee_rate=fee_rate,
dd_large_pct=dd_large_pct,
dd_medium_pct=dd_medium_pct,
)
mark = last_price
if mark is None and not df.empty:
try:
mark = float(_close_series_from_df(df).iloc[-1])
except KeyError:
mark = None
result = simulate_portfolio_summary(
sized,
initial_cash=initial_cash,
fee_rate=fee_rate,
last_price=mark,
use_amount_krw=True,
)
result["sizing_mode"] = mode
if enhanced:
result["sizing_note"] = (
"monitor buy+sell + past-leg·drawdown tier + conviction (미래 미사용)"
)
else:
result["sizing_note"] = (
"monitor buy+sell + drawdown·past-leg tier (미래 미사용)"
)
result["alloc_stats"] = alloc_stats
result["input_fires"] = int(len(fires))
return result
def _attach_drawdown_to_buys(
trades: list[dict[str, Any]],
df: pd.DataFrame,
) -> list[dict[str, Any]]:
"""
매수 trade에 bar drawdown % 부여 (인과적).
Args:
trades: enrich된 trade dict.
df: 3m OHLC (DatetimeIndex).
Returns:
drawdown_pct가 추가된 trade dict.
"""
if df.empty:
return trades
close_s = _close_series_from_df(df)
out: list[dict[str, Any]] = []
for t in trades:
row = dict(t)
if row.get("action") != "buy":
out.append(row)
continue
bar_idx = _bar_index_at(df, str(row.get("dt", "")))
row["drawdown_pct"] = round(_drawdown_pct_at_index(close_s, bar_idx), 2)
out.append(row)
return out
def simulate_monitor_dd_tier_portfolio(
fires: pd.DataFrame,
df: pd.DataFrame,
*,
last_price: float | None = None,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
dd_large_pct: float | None = None,
dd_medium_pct: float | None = None,
) -> dict[str, Any]:
"""
monitor buy+sell + drawdown/past-leg tier 복리 시뮬.
Args:
fires: monitor rule 발화 (buy+sell).
df: 3m OHLC (drawdown 계산).
last_price: 미청산 평가가.
initial_cash: 시작 현금.
fee_rate: 수수료율.
dd_large_pct: drawdown large tier 임계(%).
dd_medium_pct: drawdown medium tier 임계(%).
Returns:
portfolio summary dict.
"""
return _simulate_monitor_tier_portfolio(
fires,
df,
enhanced=False,
last_price=last_price,
initial_cash=initial_cash,
fee_rate=fee_rate,
dd_large_pct=dd_large_pct,
dd_medium_pct=dd_medium_pct,
)
def simulate_monitor_tier_enhanced_portfolio(
fires: pd.DataFrame,
df: pd.DataFrame,
*,
last_price: float | None = None,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> dict[str, Any]:
"""
Phase 4: monitor + past-leg·drawdown tier + conviction (weight 분할 생략).
Args:
fires: monitor rule 발화 (buy+sell).
df: 3m OHLC (drawdown 계산).
last_price: 미청산 평가가.
initial_cash: 시작 현금.
fee_rate: 수수료율.
Returns:
portfolio summary dict.
"""
return _simulate_monitor_tier_portfolio(
fires,
df,
enhanced=True,
last_price=last_price,
initial_cash=initial_cash,
fee_rate=fee_rate,
)
def simulate_causal_gt_hybrid_portfolio(
buy_fires: pd.DataFrame,
df: pd.DataFrame,
*,
monitor_fires: pd.DataFrame | None = None,
last_price: float | None = None,
cg_params: dict[str, Any] | None = None,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
dd_large_pct: float | None = None,
dd_medium_pct: float | None = None,
) -> dict[str, Any]:
"""
Phase 3 하이브리드: monitor buy+sell + DD tier (권장).
monitor_fires가 있으면 DD tier 경로, 없으면 구 peak-sell 경로(legacy).
Args:
buy_fires: buy 발화 (legacy peak-sell 경로용).
df: 3m OHLCV.
monitor_fires: monitor buy+sell (권장).
last_price: 미청산 평가가.
cg_params: legacy 파라미터.
initial_cash: 시작 현금.
fee_rate: 수수료율.
dd_large_pct: drawdown large tier 임계(%).
dd_medium_pct: drawdown medium tier 임계(%).
Returns:
portfolio summary dict.
"""
if monitor_fires is not None and not monitor_fires.empty:
return simulate_monitor_dd_tier_portfolio(
monitor_fires,
df,
last_price=last_price,
initial_cash=initial_cash,
fee_rate=fee_rate,
dd_large_pct=dd_large_pct,
dd_medium_pct=dd_medium_pct,
)
return {
"pnl_pct": 0.0,
"trade_count": 0,
"note": "monitor_fires required",
"sizing_mode": "causal_gt_hybrid",
}

View File

@@ -1,433 +0,0 @@
"""
인과적 GT leg 타점 생성 — t 시점까지 데이터만 사용.
GT split_buy_peak_sell 과 동일 구조(분할매수·65/35 매도·leg_id)이나
피벗·leg 종료는 gt_signal_causal 확정 신호만 사용합니다.
"""
from __future__ import annotations
from typing import Any, Literal
import pandas as pd
from config import (
GT_BUY_MIN_BARS,
GT_BUY_MIN_SWING_PCT,
GT_MAX_BUYS_PER_LEG,
GT_MAX_SELLS_PER_LEG,
GT_MIN_SWING_PCT,
GT_PIVOT_ORDER,
GT_SELL_SPLIT_GAP_PCT,
)
from deepcoin.ground_truth.gt_model import leg_entry_weights, leg_exit_weights
from deepcoin.ground_truth.gt_signal_causal import enrich_scan_frame_gt_signals_causal
PeakMode = Literal["zigzag", "local"]
def _collect_causal_buy_bars(
frame: pd.DataFrame,
start: pd.Timestamp,
end: pd.Timestamp,
*,
min_bars: int,
max_buys: int,
use_local_trough: bool,
bb_max: float,
) -> list[tuple[pd.Timestamp, float]]:
"""
leg 구간 (start, end) 내 인과적 매수 후보 봉.
Args:
frame: gt_buy_signal 등 포함.
start: 이전 매도 시각(미포함).
end: leg 종료 peak 시각(포함).
min_bars: 분할 매수 최소 간격.
max_buys: leg당 최대 매수.
use_local_trough: True면 gt_trough_local+BB, False면 gt_buy_signal.
bb_max: BB %B 상한.
Returns:
(dt, low_price) 리스트 (시간순).
"""
seg = frame[(frame.index > start) & (frame.index <= end)]
if seg.empty:
return []
if use_local_trough:
bb = pd.to_numeric(seg.get("bb_pos"), errors="coerce")
mask = (seg["gt_trough_local"] == 1) & (bb <= bb_max)
else:
mask = seg["gt_buy_signal"] == 1
cands: list[tuple[pd.Timestamp, float, int]] = []
for ts, row in seg[mask].iterrows():
price = float(row["Low"]) if "Low" in row else float(row.get("close", 0))
if price <= 0:
continue
idx = frame.index.get_loc(ts)
if isinstance(idx, slice):
idx = int(idx.start or 0)
cands.append((ts, price, int(idx)))
cands.sort(key=lambda x: x[0])
filtered: list[tuple[pd.Timestamp, float, int]] = []
for ts, price, idx in cands:
if filtered and idx - filtered[-1][2] < min_bars:
if price < filtered[-1][1]:
filtered[-1] = (ts, price, idx)
continue
filtered.append((ts, price, idx))
if len(filtered) > max_buys:
filtered.sort(key=lambda x: x[1])
filtered = sorted(filtered[:max_buys], key=lambda x: x[0])
return [(ts, price) for ts, price, _ in filtered]
def _causal_sell_points(
frame: pd.DataFrame,
peak_ts: pd.Timestamp,
max_splits: int,
*,
peak_signal_col: str = "gt_peak_zigzag",
) -> list[tuple[pd.Timestamp, float, float]]:
"""
인과적 매도: peak 확정봉 + (선택) 직후 확정 peak 1건 분할.
Args:
frame: OHLC + gt peak 컬럼.
peak_ts: leg 종료 peak 시각.
max_splits: 최대 분할(2).
peak_signal_col: 두 번째 분할 탐색 컬럼.
Returns:
(dt, high_price, weight) 리스트.
"""
if peak_ts not in frame.index:
return []
row = frame.loc[peak_ts]
if isinstance(row, pd.DataFrame):
row = row.iloc[-1]
main_price = float(row["High"]) if "High" in row else float(row.get("close", 0))
weights = leg_exit_weights(max_splits if max_splits >= 2 else 1)
if max_splits < 2 or len(weights) < 2:
return [(peak_ts, main_price, 1.0)]
peak_idx = frame.index.get_loc(peak_ts)
if isinstance(peak_idx, slice):
peak_idx = int(peak_idx.start or 0)
seg = frame.iloc[peak_idx + 1 : peak_idx + 81]
second_ts: pd.Timestamp | None = None
second_price = main_price
for ts, srow in seg.iterrows():
if int(srow.get(peak_signal_col, 0)) != 1:
continue
px = float(srow["High"]) if "High" in srow else float(srow.get("close", 0))
gap = abs(px - main_price) / max(main_price, 1e-9) * 100.0
if gap <= GT_SELL_SPLIT_GAP_PCT:
second_ts = ts
second_price = px
break
if second_ts is None:
return [(peak_ts, main_price, 1.0)]
return [
(peak_ts, main_price, weights[0]),
(second_ts, second_price, weights[1]),
]
def _peak_signal_column(peak_mode: PeakMode) -> str:
"""leg 종료 peak 컬럼명."""
return "gt_peak_local" if peak_mode == "local" else "gt_peak_zigzag"
def _filter_peak_times(
frame: pd.DataFrame,
peak_col: str,
min_bars: int,
) -> list[pd.Timestamp]:
"""
peak 후보를 min_bars 간격으로稀疏화 (인과적, 시간순).
Args:
frame: OHLC frame.
peak_col: peak 신호 컬럼.
min_bars: 최소 봉 간격.
Returns:
peak 타임스탬프 리스트.
"""
peaks = frame.index[frame[peak_col] == 1]
if len(peaks) == 0:
return []
kept: list[pd.Timestamp] = []
last_idx = -min_bars
for ts in peaks:
idx = frame.index.get_loc(ts)
if isinstance(idx, slice):
idx = int(idx.start or 0)
if idx - last_idx >= min_bars:
kept.append(ts)
last_idx = int(idx)
return kept
def _precompute_buy_candidates(
frame: pd.DataFrame,
*,
use_local_trough: bool,
bb_max: float,
) -> list[tuple[int, pd.Timestamp, float]]:
"""
전구간 매수 후보 (bar_idx, ts, price).
Args:
frame: enriched frame.
use_local_trough: local trough vs zigzag buy.
bb_max: BB 상한.
Returns:
(idx, ts, price) 리스트.
"""
if use_local_trough:
bb = pd.to_numeric(frame.get("bb_pos"), errors="coerce")
mask = (frame["gt_trough_local"] == 1) & (bb <= bb_max)
else:
mask = frame["gt_buy_signal"] == 1
out: list[tuple[int, pd.Timestamp, float]] = []
for ts in frame.index[mask]:
row = frame.loc[ts]
if isinstance(row, pd.DataFrame):
row = row.iloc[-1]
price = float(row["Low"]) if "Low" in row else float(row.get("close", 0))
if price <= 0:
continue
idx = frame.index.get_loc(ts)
if isinstance(idx, slice):
idx = int(idx.start or 0)
out.append((int(idx), ts, price))
return out
def _buys_in_range(
candidates: list[tuple[int, pd.Timestamp, float]],
start_idx: int,
end_idx: int,
*,
min_bars: int,
max_buys: int,
) -> list[tuple[pd.Timestamp, float]]:
"""start_idx < bar_idx <= end_idx 구간 매수 후보 (min_bars·max_buys 적용)."""
seg = [(i, ts, p) for i, ts, p in candidates if start_idx < i <= end_idx]
if not seg:
return []
filtered: list[tuple[int, pd.Timestamp, float]] = []
for i, ts, p in seg:
if filtered and i - filtered[-1][0] < min_bars:
if p < filtered[-1][2]:
filtered[-1] = (i, ts, p)
continue
filtered.append((i, ts, p))
if len(filtered) > max_buys:
filtered.sort(key=lambda x: x[2])
filtered = sorted(filtered[:max_buys], key=lambda x: x[0])
return [(ts, p) for _, ts, p in filtered]
def build_causal_split_buy_peak_sell_trades(
df: pd.DataFrame,
*,
pivot_order: int = GT_PIVOT_ORDER,
buy_swing_pct: float = GT_BUY_MIN_SWING_PCT,
sell_swing_pct: float = GT_MIN_SWING_PCT,
bb_max: float = 0.65,
min_leg_pct: float = GT_MIN_SWING_PCT,
buy_min_bars: int = GT_BUY_MIN_BARS,
max_buys: int = GT_MAX_BUYS_PER_LEG,
max_sells: int = GT_MAX_SELLS_PER_LEG,
use_local_trough: bool = True,
peak_mode: PeakMode = "local",
min_bars_between_legs: int = 60,
) -> list[dict[str, Any]]:
"""
인과적 split_buy_peak_sell trade dict 리스트.
Args:
df: 3m OHLCV+bb_pos (DatetimeIndex).
pivot_order: 피벗 확정 지연.
buy_swing_pct: 매수 ZigZag %.
sell_swing_pct: 매도 ZigZag %.
bb_max: BB %B 상한.
min_leg_pct: leg 최소 수익률(%).
buy_min_bars: 분할 매수 간격.
max_buys: leg당 매수 상한.
max_sells: leg당 매도 상한.
use_local_trough: local trough 분할매수 사용.
peak_mode: zigzag | local (leg 종료 peak).
min_bars_between_legs: 연속 leg 종료 최소 간격(봉).
Returns:
{dt, action, price, weight, leg_id} dict 리스트.
"""
frame = enrich_scan_frame_gt_signals_causal(
df,
pivot_order=pivot_order,
buy_swing_pct=buy_swing_pct,
sell_swing_pct=sell_swing_pct,
bb_max=bb_max,
)
peak_col = _peak_signal_column(peak_mode)
if peak_col not in frame.columns:
return []
peak_times = _filter_peak_times(frame, peak_col, min_bars_between_legs)
if not peak_times:
return []
buy_candidates = _precompute_buy_candidates(
frame,
use_local_trough=use_local_trough,
bb_max=bb_max,
)
start_idx = 0
if frame.index.size:
loc = frame.index.get_loc(frame.index[0])
start_idx = int(loc.start or 0) if isinstance(loc, slice) else int(loc)
peak_signal_col = peak_col
trades: list[dict[str, Any]] = []
prev_sell_idx = start_idx
leg_id = 0
leg_trough_price = 0.0
for peak_ts in peak_times:
peak_idx = frame.index.get_loc(peak_ts)
if isinstance(peak_idx, slice):
peak_idx = int(peak_idx.start or 0)
if peak_idx - prev_sell_idx < min_bars_between_legs:
continue
prow = frame.loc[peak_ts]
if isinstance(prow, pd.DataFrame):
prow = prow.iloc[-1]
peak_price = float(prow["High"]) if "High" in prow else float(prow.get("close", 0))
seg = frame.iloc[prev_sell_idx + 1 : peak_idx + 1]
if not seg.empty and "Low" in seg.columns:
leg_trough_price = float(seg["Low"].astype(float).min())
leg_pct = (
(peak_price - leg_trough_price) / max(leg_trough_price, 1e-9) * 100.0
if leg_trough_price > 0
else 0.0
)
if leg_pct < min_leg_pct:
continue
buys = _buys_in_range(
buy_candidates,
prev_sell_idx,
int(peak_idx),
min_bars=buy_min_bars,
max_buys=max_buys,
)
if not buys:
prev_sell_idx = int(peak_idx)
leg_trough_price = peak_price
continue
prices = [p for _, p in buys]
weights = leg_entry_weights(prices)
for (dt, price), w in zip(buys, weights):
trades.append(
{
"dt": dt.strftime("%Y-%m-%d %H:%M:%S"),
"action": "buy",
"price": round(price, 2),
"weight": round(w, 4),
"leg_id": leg_id,
}
)
sell_pts = _causal_sell_points(
frame,
peak_ts,
max_sells,
peak_signal_col=peak_signal_col,
)
for dt, price, w in sell_pts[:max_sells]:
trades.append(
{
"dt": dt.strftime("%Y-%m-%d %H:%M:%S"),
"action": "sell",
"price": round(price, 2),
"weight": round(w, 4),
"leg_id": leg_id,
}
)
prev_sell_idx = int(peak_idx)
leg_trough_price = peak_price
leg_id += 1
return trades
def simulate_causal_gt_portfolio(
df: pd.DataFrame,
*,
last_price: float | None = None,
**build_kw: Any,
) -> dict[str, Any]:
"""
인과 GT 타점 + causal tier 복리 포트폴리오.
Args:
df: 3m OHLCV.
last_price: 미청산 평가 종가.
build_kw: build_causal_split_buy_peak_sell_trades 인자.
Returns:
simulate_portfolio_summary 형식 dict + leg_count, params.
"""
from deepcoin.ground_truth.gt_allocation import (
allocate_order_amounts_chronological,
simulate_portfolio_summary,
)
raw = build_causal_split_buy_peak_sell_trades(df, **build_kw)
if not raw:
return {
"pnl_pct": 0.0,
"trade_count": 0,
"leg_count": 0,
"note": "no trades",
"sizing_mode": "causal_gt_leg_engine",
}
sized, alloc_stats = allocate_order_amounts_chronological(raw, causal_tier=True)
mark = last_price
if mark is None and "close" in df.columns:
mark = float(df["close"].iloc[-1])
result = simulate_portfolio_summary(
sized,
last_price=mark,
use_amount_krw=True,
)
leg_count = len({t.get("leg_id") for t in raw})
result["leg_count"] = leg_count
result["sizing_mode"] = "causal_gt_leg_engine"
result["sizing_note"] = (
"인과 GT leg: split_buy + peak_sell, causal tier 복리 (미래 미사용)"
)
result["causal_gt_params"] = dict(build_kw)
result["alloc_stats"] = alloc_stats
return result

File diff suppressed because it is too large Load Diff

View File

@@ -1,407 +0,0 @@
"""
GT 공통 자본 배분·포트폴리오 시뮬 엔진.
ground_truth.allocate_gt_order_amounts · simulate_truth_portfolio ·
matching/portfolio_sim 이 동일 규칙을 공유합니다.
"""
from __future__ import annotations
from typing import Any, Callable
from config import (
GT_INITIAL_CASH_KRW,
GT_MIN_ORDER_KRW,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.gt_model import remaining_weight_sum
def resolve_sell_qty(
t: dict[str, Any],
qty: float,
price: float,
sell_base_qty: float,
weight: float,
) -> float:
"""
매도 수량: amount_krw 우선, 없으면 sell_base_qty × weight.
Args:
t: trade dict.
qty: 현재 보유 수량.
price: 체결가.
sell_base_qty: leg 첫 매도 시점 보유량.
weight: 매도 비중.
Returns:
매도 수량.
"""
if qty <= 0 or price <= 0:
return 0.0
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
gross_cap = float(ak)
if gross_cap >= qty * price * 0.999:
return qty
return min(qty, gross_cap / price)
return min(sell_base_qty * weight, qty)
def allocate_order_amounts_chronological(
trades: list[dict[str, Any]],
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
min_order_krw: float = GT_MIN_ORDER_KRW,
fee_rate: float = TRADING_FEE_RATE,
large_legs: set[int] | None = None,
asset_pct_scale_fn: Callable[[dict[str, Any]], float] | None = None,
causal_tier: bool = False,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""
시각순·leg 비중·티어 스케일로 amount_krw를 배분합니다.
causal_tier=True: 청산 완료 leg의 realized return 만으로 tier 산정 (인과적).
Args:
trades: trade dict (weight·leg_id·action·price).
initial_cash: 초기 현금.
min_order_krw: 최소 체결 원화.
fee_rate: 수수료율.
large_legs: 대형 leg. None이면 GT trades에서 산출(비인과).
asset_pct_scale_fn: 매수 trade별 tier scale.
causal_tier: 과거 청산 leg 수익률만으로 tier.
Returns:
(amount_krw 채워진 trades, alloc_stats).
"""
from config import GT_BUY_PCT_LARGE_LEG, GT_LARGE_LEG_TOP_PCT
from deepcoin.matching.position_sizing import (
compute_buy_amount_krw,
large_leg_ids_from_past_returns,
leg_asset_pct_scale,
top_leg_ids_by_forward_return,
)
chron = sorted(trades, key=lambda x: x["dt"])
if large_legs is None and not causal_tier:
large_legs = top_leg_ids_by_forward_return(chron)
elif large_legs is None:
large_legs = set()
leg_buy_idxs: dict[int, list[int]] = {}
leg_sell_idxs: dict[int, list[int]] = {}
for i, t in enumerate(chron):
lid = int(t.get("leg_id", 0))
if t["action"] == "buy":
leg_buy_idxs.setdefault(lid, []).append(i)
elif t["action"] == "sell":
leg_sell_idxs.setdefault(lid, []).append(i)
cash = float(initial_cash)
qty = 0.0
qty_by_leg: dict[int, float] = {}
sell_leg: int | None = None
sell_base_qty = 0.0
buy_executed = 0
buy_skipped = 0
sell_executed = 0
sell_skipped = 0
buy_amounts: list[float] = []
large_tier_buys = 0
completed_leg_ret: dict[int, float] = {}
leg_cost_krw: dict[int, float] = {}
leg_proceeds_krw: dict[int, float] = {}
for i, t in enumerate(chron):
price = float(t["price"])
if price <= 0:
continue
leg_id = int(t.get("leg_id", 0))
weight = float(t.get("weight", 1.0))
if t["action"] == "buy":
w_sum = remaining_weight_sum(chron, leg_id, i)
if causal_tier:
large_now = large_leg_ids_from_past_returns(
completed_leg_ret, GT_LARGE_LEG_TOP_PCT
)
scale = leg_asset_pct_scale(leg_id, large_now)
elif asset_pct_scale_fn is not None:
scale = asset_pct_scale_fn(t, completed_leg_ret)
else:
scale = leg_asset_pct_scale(leg_id, large_legs)
amount = compute_buy_amount_krw(
cash,
qty,
price,
weight,
w_sum,
asset_pct_scale=scale,
min_order_krw=min_order_krw,
fee_rate=fee_rate,
ignore_weight_split=bool(t.get("conviction_buy")),
)
if amount <= 0:
t["amount_krw"] = 0
buy_skipped += 1
continue
t["amount_krw"] = amount
fee = amount * fee_rate
cash -= amount + fee
bought_qty = amount / price
qty += bought_qty
qty_by_leg[leg_id] = qty_by_leg.get(leg_id, 0.0) + bought_qty
leg_cost_krw[leg_id] = leg_cost_krw.get(leg_id, 0.0) + amount + fee
buy_executed += 1
buy_amounts.append(amount)
if scale >= float(GT_BUY_PCT_LARGE_LEG) * 0.99:
large_tier_buys += 1
sell_leg = None
elif t["action"] == "sell":
leg_qty = qty_by_leg.get(leg_id, 0.0)
if leg_qty <= 1e-12:
sell_skipped += 1
continue
if sell_leg != leg_id:
sell_leg = leg_id
sell_base_qty = leg_qty
rem_sells = [j for j in leg_sell_idxs.get(leg_id, []) if j >= i]
is_last_leg_sell = bool(rem_sells) and i == rem_sells[-1]
if is_last_leg_sell:
sell_qty = leg_qty
gross = sell_qty * price
else:
gross = sell_base_qty * weight * price
if gross >= min_order_krw:
gross = max(min_order_krw, gross)
gross = min(gross, leg_qty * price)
if gross <= 0:
sell_skipped += 1
continue
sell_qty = leg_qty if is_last_leg_sell else gross / price
t["amount_krw"] = round(gross, 0)
fee = gross * fee_rate
cash += gross - fee
leg_proceeds_krw[leg_id] = leg_proceeds_krw.get(leg_id, 0.0) + (gross - fee)
leg_qty -= sell_qty
qty_by_leg[leg_id] = max(leg_qty, 0.0)
qty = max(qty - sell_qty, 0.0)
if qty < 1e-12:
qty = 0.0
sell_executed += 1
if (causal_tier or asset_pct_scale_fn is not None) and leg_qty <= 1e-12:
cost = leg_cost_krw.pop(leg_id, 0.0)
proceeds = leg_proceeds_krw.pop(leg_id, 0.0)
if cost > 0:
completed_leg_ret[leg_id] = (proceeds - cost) / cost * 100.0
stats: dict[str, Any] = {
"buy_executed": buy_executed,
"buy_skipped": buy_skipped,
"sell_executed": sell_executed,
"sell_skipped": sell_skipped,
"buy_total_krw": round(sum(buy_amounts), 0),
"large_leg_count": large_tier_buys,
"large_tier_buy_count": large_tier_buys,
}
if buy_amounts:
stats["buy_amount_avg_krw"] = round(sum(buy_amounts) / len(buy_amounts), 0)
stats["buy_amount_min_krw"] = round(min(buy_amounts), 0)
stats["buy_amount_max_krw"] = round(max(buy_amounts), 0)
return trades, stats
def simulate_portfolio_steps(
trades: list[dict[str, Any]],
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
use_amount_krw: bool = True,
) -> list[dict[str, Any]]:
"""
체결마다 현금·보유·총평가 스냅샷.
Args:
trades: 시각순 trade dict (amount_krw·weight·leg_id).
initial_cash: 시작 현금.
fee_rate: 수수료율.
use_amount_krw: True면 amount_krw 기준 체결.
Returns:
step dict 리스트.
"""
rows = sorted(trades, key=lambda x: x["dt"])
cash = float(initial_cash)
qty = 0.0
qty_by_leg: dict[int, float] = {}
sell_leg: int | None = None
sell_base_qty = 0.0
leg_budget = 0.0
current_leg: int | None = None
steps: list[dict[str, Any]] = []
for t in rows:
action = t.get("action", t.get("side", ""))
price = float(t["price"])
if price <= 0:
continue
weight = float(t.get("weight", 1.0))
leg_id = int(t.get("leg_id", 0))
if action == "buy":
if use_amount_krw and t.get("amount_krw") is not None and float(t["amount_krw"]) > 0:
amount = min(float(t["amount_krw"]), max(cash / (1.0 + fee_rate), 0.0))
else:
if leg_id != current_leg:
current_leg = leg_id
leg_budget = cash
amount = min(leg_budget * weight, max(cash / (1.0 + fee_rate), 0.0))
if amount <= 0:
continue
fee = amount * fee_rate
cash -= amount + fee
bought = amount / price
qty += bought
qty_by_leg[leg_id] = qty_by_leg.get(leg_id, 0.0) + bought
sell_leg = None
elif action == "sell" and qty > 0:
leg_qty = qty_by_leg.get(leg_id, qty)
if sell_leg != leg_id:
sell_leg = leg_id
sell_base_qty = leg_qty
sell_qty = resolve_sell_qty(t, leg_qty, price, sell_base_qty, weight)
if sell_qty <= 0:
continue
gross = sell_qty * price
fee = gross * fee_rate
cash += gross - fee
leg_qty -= sell_qty
qty_by_leg[leg_id] = max(leg_qty, 0.0)
qty -= sell_qty
if qty < 1e-12:
qty = 0.0
steps.append(
{
"dt": t["dt"],
"action": action,
"price": price,
"weight": weight,
"leg_id": leg_id,
"amount_krw": t.get("amount_krw"),
"cash_krw": round(cash, 0),
"holding_qty": round(qty, 6),
"total_asset_krw": round(cash + qty * price, 0),
}
)
return steps
def compute_drawdown_metrics(steps: list[dict[str, Any]]) -> dict[str, Any]:
"""
equity curve 기준 최대 낙폭·고점 대비 하락.
Args:
steps: simulate_portfolio_steps 결과.
Returns:
max_drawdown_pct, peak_asset_krw, trough_after_peak_krw.
"""
if not steps:
return {
"max_drawdown_pct": 0.0,
"peak_asset_krw": 0.0,
"trough_asset_krw": 0.0,
}
assets = [float(s["total_asset_krw"]) for s in steps]
peak = assets[0]
max_dd = 0.0
peak_at = assets[0]
trough_at = assets[0]
for a in assets:
if a > peak:
peak = a
dd = (peak - a) / peak * 100.0 if peak > 0 else 0.0
if dd > max_dd:
max_dd = dd
peak_at = peak
trough_at = a
return {
"max_drawdown_pct": round(max_dd, 2),
"peak_asset_krw": round(peak_at, 0),
"trough_asset_krw": round(trough_at, 0),
}
def simulate_portfolio_summary(
trades: list[dict[str, Any]],
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
last_price: float | None = None,
use_amount_krw: bool = True,
) -> dict[str, Any]:
"""
포트폴리오 시뮬 요약 + MDD.
Args:
trades: trade dict 리스트.
initial_cash: 시작 현금.
fee_rate: 수수료율.
last_price: 미청산 평가가.
use_amount_krw: amount_krw 체결 사용.
Returns:
pnl·fee·MDD 포함 dict.
"""
steps = simulate_portfolio_steps(
trades,
initial_cash=initial_cash,
fee_rate=fee_rate,
use_amount_krw=use_amount_krw,
)
if not steps:
return {
"initial_cash_krw": round(initial_cash, 0),
"final_asset_krw": round(initial_cash, 0),
"pnl_krw": 0.0,
"pnl_pct": 0.0,
"trade_count": len(trades),
"max_drawdown_pct": 0.0,
}
last_step = steps[-1]
cash = float(last_step["cash_krw"])
qty = float(last_step["holding_qty"])
mark = float(last_price if last_price is not None else last_step["price"])
holding_value = qty * mark
final_asset = cash + holding_value
pnl = final_asset - initial_cash
pnl_pct = pnl / initial_cash * 100.0 if initial_cash else 0.0
fees = 0.0
for t in sorted(trades, key=lambda x: x["dt"]):
ak = float(t.get("amount_krw") or 0)
if ak <= 0:
continue
fees += ak * fee_rate
dd = compute_drawdown_metrics(steps)
return {
"initial_cash_krw": round(initial_cash, 0),
"final_asset_krw": round(final_asset, 0),
"pnl_krw": round(pnl, 0),
"pnl_pct": round(pnl_pct, 2),
"total_fees_krw": round(fees, 0),
"cash_krw": round(cash, 0),
"holding_qty": round(qty, 6),
"holding_value_krw": round(holding_value, 0),
"mark_price": round(mark, 2),
"fee_rate": fee_rate,
"trade_count": len(trades),
**dd,
}

View File

@@ -1,150 +0,0 @@
"""
GT 체결 amount_krw·총자산 비율 분석 — 시뮬 tier·배분율 최적 추정.
"""
from __future__ import annotations
from typing import Any
from config import (
GT_BUY_PCT_LARGE_LEG,
GT_BUY_PCT_SMALL_LEG,
GT_INITIAL_CASH_KRW,
GT_LARGE_LEG_TOP_PCT,
TRADING_FEE_RATE,
)
from deepcoin.matching.position_sizing import (
leg_asset_pct_scale,
optimal_weight_share,
portfolio_totals,
top_leg_ids_by_forward_return,
)
def analyze_gt_buy_allocation(
trades: list[dict[str, Any]],
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> dict[str, Any]:
"""
GT 시각순 체결에서 매수별 (실제투입/총자산) 비율을 분석합니다.
Args:
trades: amount_krw·weight·leg_id가 채워진 GT trade dict.
initial_cash: 시작 현금.
fee_rate: 수수료율.
Returns:
leg tier별·전체 배분 통계 및 권장 pct_large/pct_small.
"""
chron = sorted(trades, key=lambda x: x["dt"])
if not chron:
return {"note": "체결 없음"}
large_legs = top_leg_ids_by_forward_return(chron, GT_LARGE_LEG_TOP_PCT)
cash = float(initial_cash)
qty = 0.0
ratios_large: list[float] = []
ratios_small: list[float] = []
ratios_all: list[float] = []
for i, t in enumerate(chron):
price = float(t["price"])
if price <= 0:
continue
leg_id = int(t.get("leg_id", 0))
action = t.get("action", "")
if action == "buy":
w = float(t.get("weight", 1.0))
rem = sum(
float(chron[j].get("weight", 1.0))
for j in range(i, len(chron))
if int(chron[j].get("leg_id", 0)) == leg_id
and chron[j].get("action") == "buy"
)
opt = optimal_weight_share(w, rem) if rem > 0 else 1.0
total_asset, _, _ = portfolio_totals(cash, qty, price)
amount = float(t.get("amount_krw") or 0)
if total_asset > 0 and amount > 0 and opt > 0:
implied = amount / (total_asset * opt)
ratios_all.append(implied)
if leg_id in large_legs:
ratios_large.append(implied)
else:
ratios_small.append(implied)
if amount > 0:
fee = amount * fee_rate
cash -= amount + fee
qty += amount / price
elif action == "sell" and qty > 0:
gross = float(t.get("amount_krw") or qty * price)
cash += gross * (1.0 - fee_rate)
qty = 0.0
def _stats(vals: list[float]) -> dict[str, float]:
if not vals:
return {}
s = sorted(vals)
n = len(s)
return {
"count": n,
"mean": round(sum(s) / n, 4),
"median": round(s[n // 2], 4),
"p25": round(s[max(0, n // 4)], 4),
"p75": round(s[min(n - 1, 3 * n // 4)], 4),
}
st_all = _stats(ratios_all)
st_large = _stats(ratios_large)
st_small = _stats(ratios_small)
rec_large = st_large.get("median", GT_BUY_PCT_LARGE_LEG)
rec_small = st_small.get("median", GT_BUY_PCT_SMALL_LEG)
if not rec_large or rec_large <= 0:
rec_large = GT_BUY_PCT_LARGE_LEG
if not rec_small or rec_small <= 0:
rec_small = GT_BUY_PCT_SMALL_LEG
return {
"large_leg_ids": sorted(large_legs),
"large_leg_count": len(large_legs),
"config_pct_large": GT_BUY_PCT_LARGE_LEG,
"config_pct_small": GT_BUY_PCT_SMALL_LEG,
"observed_implied_scale": {
"all": st_all,
"large_leg": st_large,
"small_leg": st_small,
},
"recommended_pct_large_leg": round(rec_large, 4),
"recommended_pct_small_leg": round(rec_small, 4),
"note": (
"implied_scale = amount / (pre_buy_total_asset × weight_share); "
"시뮬 tier는 GT 분석 median 사용"
),
}
def gt_tier_scale_from_analysis(
leg_id: int,
large_legs: set[int],
analysis: dict[str, Any] | None = None,
) -> float:
"""
GT 분석 권장값 또는 config tier scale.
Args:
leg_id: leg 번호.
large_legs: 상위 leg.
analysis: analyze_gt_buy_allocation 결과.
Returns:
총자산 대비 매수 스케일 (0~1).
"""
if analysis and analysis.get("observed_implied_scale", {}).get("all"):
if leg_id in large_legs:
return float(analysis.get("recommended_pct_large_leg", GT_BUY_PCT_LARGE_LEG))
return float(analysis.get("recommended_pct_small_leg", GT_BUY_PCT_SMALL_LEG))
return leg_asset_pct_scale(leg_id, large_legs)

View File

@@ -1,417 +0,0 @@
"""
Ground Truth 타점·비중·자본 배분 모델 (일반화 명세).
타점 생성(ground_truth.py), 자본 배분(gt_allocation.py),
시뮬(position_sizing.py)의 공통 언어.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
from config import (
GT_BUY_BB_MAX,
GT_BUY_MIN_BARS,
GT_BUY_MIN_SWING_PCT,
GT_BUY_PCT_LARGE_LEG,
GT_BUY_PCT_SMALL_LEG,
GT_BUY_WEIGHT_RULE,
GT_LARGE_LEG_TOP_PCT,
GT_MAX_BUYS_PER_LEG,
GT_MAX_SELLS_PER_LEG,
GT_MIN_ORDER_KRW,
GT_SELL_SPLIT_GAP_PCT,
GT_SELL_SPLIT_WEIGHTS,
GT_SELECTION_MODE,
)
# --- 매수 비중 규칙 (확장 가능) ---
EntryWeightFn = Callable[[list[float]], list[float]]
def normalize_weights(scores: list[float]) -> list[float]:
"""
비중 점수를 합 1로 정규화합니다.
Args:
scores: raw score 리스트.
Returns:
정규화 weight (합 ≈ 1).
"""
if not scores:
return []
total = sum(scores)
if total <= 0:
n = len(scores)
return [1.0 / n] * n
return [s / total for s in scores]
def compute_buy_weights_inverse_price(prices: list[float]) -> list[float]:
"""
저점 매수 비중: score_i = 1/price_i → 합=1 정규화.
Args:
prices: leg 내 매수 후보 가격.
Returns:
weight 리스트 (합 ≈ 1).
"""
if not prices:
return []
scores = [1.0 / max(p, 1e-9) for p in prices]
return normalize_weights(scores)
def compute_buy_weights_equal(_prices: list[float]) -> list[float]:
"""
균등 분할 매수 비중.
Args:
_prices: leg 내 매수 후보 가격(미사용).
Returns:
weight 리스트 (합 = 1).
"""
n = len(_prices)
if n <= 0:
return []
return [1.0 / n] * n
ENTRY_WEIGHT_RULES: dict[str, EntryWeightFn] = {
"inverse_price_normalized": compute_buy_weights_inverse_price,
"equal": compute_buy_weights_equal,
}
def compute_entry_weights(
prices: list[float],
rule: str | None = None,
) -> list[float]:
"""
매수 타점 비중을 규칙명으로 계산합니다.
Args:
prices: 체결 가격 리스트.
rule: `inverse_price_normalized` | `equal`. None이면 config.
Returns:
leg 내 매수 weight (합 ≈ 1).
"""
key = (rule or GT_BUY_WEIGHT_RULE).strip()
fn = ENTRY_WEIGHT_RULES.get(key, compute_buy_weights_inverse_price)
return fn(prices)
def leg_entry_weights(
prices: list[float],
rule: str | None = None,
) -> list[float]:
"""
leg 내 매수 타점 비중 (compute_entry_weights 별칭).
Args:
prices: 체결 가격 리스트.
rule: 비중 규칙 키.
Returns:
weight 리스트 (합 ≈ 1).
"""
return compute_entry_weights(prices, rule)
def leg_exit_weights(
n_sells: int,
exit_spec: GtExitSpec | None = None,
) -> list[float]:
"""
leg 매도 분할 비중.
Args:
n_sells: 매도 횟수.
exit_spec: 매도 명세.
Returns:
weight 리스트 (합 ≈ 1).
"""
return sell_split_weights(n_sells, exit_spec)
@dataclass(frozen=True)
class GtEntrySpec:
"""
매수 타점(leg 내 분할) 규칙.
Attributes:
pivot_kind: ZigZag 저점(trough).
price_field: 체결가 = 봉 Low.
weight_rule: 매수 비중 규칙 키.
max_per_leg: leg당 최대 매수 횟수.
min_bars_gap: 분할 매수 최소 봉 간격.
"""
pivot_kind: str = "trough"
price_field: str = "Low"
weight_rule: str = GT_BUY_WEIGHT_RULE
max_per_leg: int = GT_MAX_BUYS_PER_LEG
min_bars_gap: int = GT_BUY_MIN_BARS
bb_filter: str = f"bb_pos <= {GT_BUY_BB_MAX}"
@dataclass(frozen=True)
class GtExitSpec:
"""
매도 타점(leg 내 분할) 규칙.
Attributes:
pivot_kind: major swing 고점(peak).
price_field: 체결가 = 봉 High.
split_weights: N회 분할 시 각 매도 비중 (합=1).
split_gap_pct: 2차 고점 인정 최소 괴리(%).
"""
pivot_kind: str = "peak"
price_field: str = "High"
split_weights: tuple[float, ...] = GT_SELL_SPLIT_WEIGHTS
split_gap_pct: float = GT_SELL_SPLIT_GAP_PCT
max_per_leg: int = GT_MAX_SELLS_PER_LEG
@dataclass(frozen=True)
class GtCapitalSpec:
"""
체결 원화(amount_krw) 배분 규칙.
Attributes:
buy_formula: 총자산 × 최적매수율, 상한=가용현금.
optimal_buy_rate: leg 내 남은 weight 비중.
large_leg_top_pct: 수익률 상위 leg 비율.
pct_large: 상위 leg 총자산 배분 스케일.
pct_small: 그 외 leg 스케일.
min_order_krw: 최소 체결 원화.
"""
buy_formula: str = (
"target = total_asset * (weight/remaining_weights) * tier_scale; "
"amount = min(target, available_cash/(1+fee))"
)
optimal_buy_rate: str = "weight / sum(remaining_buy_weights_in_leg)"
large_leg_top_pct: float = GT_LARGE_LEG_TOP_PCT
pct_large: float = GT_BUY_PCT_LARGE_LEG
pct_small: float = GT_BUY_PCT_SMALL_LEG
min_order_krw: float = float(GT_MIN_ORDER_KRW)
sell_formula: str = "sell_base_qty * sell_weight * price (last sell = full leg_qty)"
@dataclass
class GroundTruthModel:
"""
GT 전체 모델 (타점 + 비중 + 자본).
Attributes:
selection_mode: 타점 생성 모드.
entry: 매수 명세.
exit: 매도 명세.
capital: 자본 배분 명세.
leg: leg 정의.
"""
selection_mode: str = GT_SELECTION_MODE
entry: GtEntrySpec = field(default_factory=GtEntrySpec)
exit: GtExitSpec = field(default_factory=GtExitSpec)
capital: GtCapitalSpec = field(default_factory=GtCapitalSpec)
leg_definition: str = (
"이전 고점 매도 ~ 다음 고점 매도 구간 = leg_id; "
"기간말 잔여 구간은 마지막 leg"
)
execution_order_chrono: str = (
"amount_krw 배분·summary.pnl_pct = 시각순 체결(매도 후 현금 → 다음 매수 반영)"
)
execution_order_leg_block: str = (
"JSON 저장 순서 = leg별 매수 전량 → 매도 전량 (차트·테이블 leg 정합)"
)
def default_model() -> GroundTruthModel:
"""현재 config 기준 GT 모델."""
return GroundTruthModel()
def sell_split_weights(
n_sells: int,
exit_spec: GtExitSpec | None = None,
) -> list[float]:
"""
leg 매도 비중 (1회=100%, N회=split_weights 정규화).
Args:
n_sells: 매도 횟수(1 이상).
exit_spec: None이면 default.
Returns:
weight 리스트 (합 ≈ 1).
"""
spec = exit_spec or GtExitSpec()
if n_sells <= 1:
return [1.0]
weights = list(spec.split_weights[:n_sells])
if len(weights) < n_sells:
weights.extend([weights[-1]] * (n_sells - len(weights)))
return normalize_weights(weights)
def pair_peak_sell_weights(
n_peaks: int,
exit_spec: GtExitSpec | None = None,
) -> list[tuple[float, float]]:
"""
고점 피벗 (피벗, weight) 쌍 — 1회 또는 분할.
Args:
n_peaks: 인정된 고점 수 (1 또는 2+).
exit_spec: 매도 명세.
Returns:
(weight,) 또는 (w1, w2) 리스트. 호출측에서 피벗과 zip.
"""
if n_peaks <= 1:
return [(1.0,)]
w = sell_split_weights(2, exit_spec)
return [(w[0],), (w[1],)]
def remaining_weight_sum(
trades: list[dict[str, Any]],
leg_id: int,
from_index: int,
) -> float:
"""
leg 내 from_index 이후 남은 매수 weight 합.
Args:
trades: 시각순 trade dict.
leg_id: leg 번호.
from_index: chron 리스트 인덱스.
Returns:
남은 weight 합.
"""
total = 0.0
for j, t in enumerate(trades):
if j < from_index:
continue
if int(t.get("leg_id", 0)) != leg_id:
continue
if t.get("action") == "buy":
total += float(t.get("weight", 1.0))
return total
def model_to_dict(model: GroundTruthModel | None = None) -> dict[str, Any]:
"""
JSON·리포트용 모델 dict.
Args:
model: None이면 default.
Returns:
직렬화 dict.
"""
m = model or default_model()
w_rule = m.entry.weight_rule
w_formula = (
"w_i = (1/price_i) / sum(1/price_j)"
if w_rule == "inverse_price_normalized"
else "w_i = 1 / n"
)
return {
"selection_mode": m.selection_mode,
"leg_definition": m.leg_definition,
"entry": {
"pivot": m.entry.pivot_kind,
"price": m.entry.price_field,
"weight_rule": m.entry.weight_rule,
"weight_formula": w_formula,
"max_buys_per_leg": m.entry.max_per_leg,
"min_bars_between_buys": m.entry.min_bars_gap,
"bb_filter": m.entry.bb_filter,
},
"exit": {
"pivot": m.exit.pivot_kind,
"price": m.exit.price_field,
"weight_rule": "fixed_split_or_full",
"weights_split": list(m.exit.split_weights),
"split_gap_pct": m.exit.split_gap_pct,
"max_sells_per_leg": m.exit.max_per_leg,
},
"capital": {
"buy": m.capital.buy_formula,
"optimal_buy_rate": m.capital.optimal_buy_rate,
"large_leg_top_pct": m.capital.large_leg_top_pct,
"pct_large_leg": m.capital.pct_large,
"pct_small_leg": m.capital.pct_small,
"min_order_krw": m.capital.min_order_krw,
"sell": m.capital.sell_formula,
},
"execution": {
"chrono": m.execution_order_chrono,
"leg_block_json": m.execution_order_leg_block,
},
}
def summarize_leg_weights(trades: list[dict[str, Any]]) -> dict[str, Any]:
"""
leg별 매수·매도 비중 합 검증용 요약.
Args:
trades: GT trade dict.
Returns:
leg_id → {buy_sum, sell_sum, n_buy, n_sell, valid}.
"""
legs: dict[int, dict[str, Any]] = {}
for t in trades:
lid = int(t.get("leg_id", 0))
legs.setdefault(
lid,
{"buy_sum": 0.0, "sell_sum": 0.0, "n_buy": 0, "n_sell": 0},
)
w = float(t.get("weight", 0))
if t.get("action") == "buy":
legs[lid]["buy_sum"] += w
legs[lid]["n_buy"] += 1
elif t.get("action") == "sell":
legs[lid]["sell_sum"] += w
legs[lid]["n_sell"] += 1
for lid, info in legs.items():
buy_ok = abs(info["buy_sum"] - 1.0) < 0.02 or info["n_buy"] == 0
sell_ok = abs(info["sell_sum"] - 1.0) < 0.02 or info["n_sell"] == 0
info["valid"] = buy_ok and sell_ok
info["buy_sum"] = round(info["buy_sum"], 4)
info["sell_sum"] = round(info["sell_sum"], 4)
return legs
def weight_policy_summary(model: GroundTruthModel | None = None) -> dict[str, Any]:
"""
시뮬·리포트용 비중 정책 요약.
Args:
model: GT 모델.
Returns:
entry/exit/capital 요약 dict.
"""
m = model or default_model()
return {
"entry_weight_rule": m.entry.weight_rule,
"exit_split_weights": list(m.exit.split_weights),
"capital_large_pct": m.capital.pct_large,
"capital_small_pct": m.capital.pct_small,
"large_leg_top_pct": m.capital.large_leg_top_pct,
}

View File

@@ -1,182 +0,0 @@
"""
인과적(미래 미사용) GT 스타일 신호 — t봉 시점에 t 이하 데이터만 사용.
ZigZag/국소극값: pivot bar i-order 는 bar i 에서 확정 (i-order..i 구간만 관측).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from config import (
GT_BUY_BB_MAX,
GT_BUY_MIN_SWING_PCT,
GT_MIN_SWING_PCT,
GT_PIVOT_ORDER,
)
def _confirmed_trough_mask(
low: np.ndarray,
order: int,
) -> np.ndarray:
"""
bar i 에서 i-order 봉이 저점임을 확정 (low[i-order:i+1] 만 사용).
Args:
low: Low 가격 배열.
order: pivot 반경(봉).
Returns:
길이 n, i 에 1이면 i 시점 매수 확인 신호.
"""
n = len(low)
out = np.zeros(n, dtype=np.int8)
for i in range(2 * order, n):
p = i - order
seg = low[p - order : i + 1]
if len(seg) == 0:
continue
if low[p] <= seg.min() + 1e-12:
out[i] = 1
return out
def _confirmed_peak_mask(
high: np.ndarray,
order: int,
) -> np.ndarray:
"""
bar i 에서 i-order 봉이 고점임을 확정.
Args:
high: High 가격 배열.
order: pivot 반경.
Returns:
i 시점 매도 확인 신호.
"""
n = len(high)
out = np.zeros(n, dtype=np.int8)
for i in range(2 * order, n):
p = i - order
seg = high[p - order : i + 1]
if len(seg) == 0:
continue
if high[p] >= seg.max() - 1e-12:
out[i] = 1
return out
def _zigzag_filter_causal(
confirm: np.ndarray,
prices: np.ndarray,
min_swing_pct: float,
kind: str,
pivot_order: int = GT_PIVOT_ORDER,
) -> np.ndarray:
"""
확정 피벗에 ZigZag 최소 스윙% 필터 (인과적, 순차 갱신).
Args:
confirm: bar i 에 확정 플래그.
prices: pivot 가격 (i-order 위치의 low/high).
pivot_indices: confirm==1 인 bar index.
min_swing_pct: 최소 스윙 %.
kind: trough | peak.
Returns:
zigzag 통과 시점에 1.
"""
n = len(confirm)
out = np.zeros(n, dtype=np.int8)
order = int(pivot_order)
last_kind: str | None = None
last_price = 0.0
min_ratio = min_swing_pct / 100.0
for i in range(n):
if confirm[i] != 1:
continue
p = i - order
if p < 0:
continue
price = float(prices[p])
if last_kind is None:
out[i] = 1
last_kind = kind
last_price = price
continue
if kind == last_kind:
if kind == "trough" and price < last_price:
out[i - 1] = 0
out[i] = 1
last_price = price
elif kind == "peak" and price > last_price:
out[i - 1] = 0
out[i] = 1
last_price = price
continue
move = abs(price - last_price) / max(last_price, 1e-9)
if move >= min_ratio:
out[i] = 1
last_kind = kind
last_price = price
return out
def enrich_scan_frame_gt_signals_causal(
frame: pd.DataFrame,
*,
pivot_order: int = GT_PIVOT_ORDER,
buy_swing_pct: float = GT_BUY_MIN_SWING_PCT,
sell_swing_pct: float = GT_MIN_SWING_PCT,
bb_max: float = GT_BUY_BB_MAX,
) -> pd.DataFrame:
"""
인과적 GT 신호 컬럼 (gt_*). t 시점 신호는 데이터 index<=t 만 사용.
Args:
frame: m3 스캔 프레임.
pivot_order: 확정 지연(봉).
buy_swing_pct: 매수 ZigZag 스윙%.
sell_swing_pct: 매도 ZigZag 스윙%.
bb_max: BB 하단 필터.
Returns:
gt_* 컬럼 추가 DataFrame.
"""
out = frame.copy()
if "Low" not in out.columns or "High" not in out.columns:
return out
low = out["Low"].astype(float).values
high = out["High"].astype(float).values
n = len(low)
trough_conf = _confirmed_trough_mask(low, pivot_order)
peak_conf = _confirmed_peak_mask(high, pivot_order)
trough_z = _zigzag_filter_causal(
trough_conf, low, buy_swing_pct, "trough", pivot_order=pivot_order
)
peak_z = _zigzag_filter_causal(
peak_conf, high, sell_swing_pct, "peak", pivot_order=pivot_order
)
out["gt_trough_local"] = trough_conf
out["gt_peak_local"] = peak_conf
out["gt_trough_zigzag"] = trough_z
out["gt_peak_zigzag"] = peak_z
bb_ok = pd.Series(True, index=out.index)
if "bb_pos" in out.columns:
bb = pd.to_numeric(out["bb_pos"], errors="coerce")
bb_ok = bb <= bb_max
out["gt_buy_signal"] = (pd.Series(trough_z, index=out.index) == 1) & bb_ok
out["gt_buy_signal"] = out["gt_buy_signal"].astype(int)
out["gt_sell_signal"] = pd.Series(peak_z, index=out.index).astype(int)
out["gt_signal_causal"] = 1
return out

View File

@@ -1,197 +0,0 @@
"""
GT 모델(entry/exit)을 규칙 스캔·발화 형식으로 일반화.
ZigZag trough/peak + BB 필터 등 GT 타점 생성 로직과 동일 파라미터를
rule_eval 스캔 프레임 컬럼(gt_*)으로 노출합니다.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
from config import (
GT_BUY_BB_MAX,
GT_BUY_MIN_SWING_PCT,
GT_MIN_SWING_PCT,
GT_PIVOT_ORDER,
MATCH_PRIMARY_INTERVAL,
)
from deepcoin.ground_truth.ground_truth import build_zigzag_pivots
def _local_extrema_mask(
series: pd.Series,
order: int,
kind: str,
) -> pd.Series:
"""
국소 극값 boolean 마스크.
Args:
series: 가격 시리즈.
order: 좌우 봉 수.
kind: min | max.
Returns:
boolean Series (index=series.index).
"""
arr = series.astype(float).values
n = len(arr)
out = np.zeros(n, dtype=bool)
if n < 2 * order + 1:
return pd.Series(out, index=series.index)
for i in range(order, n - order):
window = arr[i - order : i + order + 1]
if kind == "min" and arr[i] <= window.min():
out[i] = True
elif kind == "max" and arr[i] >= window.max():
out[i] = True
return pd.Series(out, index=series.index)
def enrich_scan_frame_gt_signals(
frame: pd.DataFrame,
*,
pivot_order: int = GT_PIVOT_ORDER,
buy_swing_pct: float = GT_BUY_MIN_SWING_PCT,
sell_swing_pct: float = GT_MIN_SWING_PCT,
bb_max: float = GT_BUY_BB_MAX,
causal: bool | None = None,
) -> pd.DataFrame:
"""
스캔 프레임에 GT 모델 신호 컬럼을 추가합니다.
GT_SIGNAL_CAUSAL=1 이면 t 시점까지 데이터만 사용 (운영 정합).
Args:
frame: m3 스캔 프레임 (Low, High, bb_pos).
pivot_order: 피벗 반경.
buy_swing_pct: 매수 ZigZag 스윙%.
sell_swing_pct: 매도 ZigZag 스윙%.
bb_max: BB 하단 필터.
causal: None이면 config GT_SIGNAL_CAUSAL.
Returns:
gt_* 컬럼이 추가된 DataFrame.
"""
from config import GT_SIGNAL_CAUSAL
use_causal = GT_SIGNAL_CAUSAL if causal is None else causal
if use_causal:
from deepcoin.ground_truth.gt_signal_causal import (
enrich_scan_frame_gt_signals_causal,
)
return enrich_scan_frame_gt_signals_causal(
frame,
pivot_order=pivot_order,
buy_swing_pct=buy_swing_pct,
sell_swing_pct=sell_swing_pct,
bb_max=bb_max,
)
out = frame.copy()
if "Low" not in out.columns or "High" not in out.columns:
return out
low = out["Low"].astype(float)
high = out["High"].astype(float)
out["gt_trough_local"] = _local_extrema_mask(low, pivot_order, "min").astype(int)
out["gt_peak_local"] = _local_extrema_mask(high, pivot_order, "max").astype(int)
df_ohlc = out[["Low", "High"]].copy()
if "close" in out.columns:
df_ohlc["close"] = out["close"]
df_ohlc.index = out.index
buy_pivots = build_zigzag_pivots(
df_ohlc,
min_swing_pct=buy_swing_pct,
pivot_order=pivot_order,
)
sell_pivots = build_zigzag_pivots(
df_ohlc,
min_swing_pct=sell_swing_pct,
pivot_order=pivot_order,
)
trough_z = pd.Series(0, index=out.index, dtype=int)
for p in buy_pivots:
if p.kind == "trough" and p.ts in trough_z.index:
trough_z.loc[p.ts] = 1
peak_z = pd.Series(0, index=out.index, dtype=int)
for p in sell_pivots:
if p.kind == "peak" and p.ts in peak_z.index:
peak_z.loc[p.ts] = 1
out["gt_trough_zigzag"] = trough_z
out["gt_peak_zigzag"] = peak_z
bb_ok = pd.Series(True, index=out.index)
if "bb_pos" in out.columns:
bb = pd.to_numeric(out["bb_pos"], errors="coerce")
bb_ok = bb <= bb_max
out["gt_buy_signal"] = ((out["gt_trough_zigzag"] == 1) & bb_ok).astype(int)
out["gt_sell_signal"] = (out["gt_peak_zigzag"] == 1).astype(int)
return out
def build_gt_model_rules() -> list[dict[str, Any]]:
"""
GT entry/exit 명세와 동일한 스캔 규칙 후보.
Returns:
rule dict 리스트 (buy 2종 + sell 2종).
"""
return [
{
"rule_id": "gt_model_buy_zigzag_bb",
"side": "buy",
"kind": "gt_model",
"logic": "and",
"conditions": [
{"col": "gt_buy_signal", "op": "eq_int", "value": 1},
],
"gt_spec": "trough_zigzag + bb_pos <= GT_BUY_BB_MAX",
},
{
"rule_id": "gt_model_buy_trough_local",
"side": "buy",
"kind": "gt_model",
"logic": "and",
"conditions": [
{"col": "gt_trough_local", "op": "eq_int", "value": 1},
{"col": "bb_pos", "op": "lte", "value": GT_BUY_BB_MAX},
],
"gt_spec": "local trough + bb filter",
},
{
"rule_id": "gt_model_sell_zigzag_peak",
"side": "sell",
"kind": "gt_model",
"logic": "and",
"conditions": [
{"col": "gt_sell_signal", "op": "eq_int", "value": 1},
],
"gt_spec": "major swing peak (ZigZag)",
},
{
"rule_id": "gt_model_sell_peak_local",
"side": "sell",
"kind": "gt_model",
"logic": "and",
"conditions": [
{"col": "gt_peak_local", "op": "eq_int", "value": 1},
],
"gt_spec": "local high extremum",
},
]
def gt_signal_rule_ids() -> set[str]:
"""GT 일반화 규칙 ID 집합."""
return {r["rule_id"] for r in build_gt_model_rules()}

View File

@@ -1,200 +0,0 @@
"""
Hybrid DD tier 임계값 train 그리드 → holdout 검증 (Option C 2차).
"""
from __future__ import annotations
import json
from itertools import product
from pathlib import Path
from typing import Any
import pandas as pd
from config import GT_INITIAL_CASH_KRW, MATCH_HOLDOUT_RATIO, TRADING_FEE_RATE
from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_steps
from deepcoin.matching.option_c_phase2 import walk_forward_portfolio_by_month
from deepcoin.matching.portfolio_sim import sort_fires_chronological
from deepcoin.matching.simulation import portfolio_holdout_from_steps
from deepcoin.paths import MATCHING_HYBRID_DD_CALIBRATION_JSON
def default_dd_grid() -> dict[str, list[float]]:
"""DD large/medium 탐색 그리드."""
return {
"dd_large_pct": [5.0, 6.0, 8.0, 10.0, 12.0],
"dd_medium_pct": [2.0, 3.0, 4.0, 6.0],
}
def load_hybrid_dd_params(path: Path | None = None) -> dict[str, float]:
"""
캘리브레이션 JSON 또는 config 기본값.
Args:
path: JSON 경로.
Returns:
{dd_large_pct, dd_medium_pct}.
"""
from config import CAUSAL_GT_DD_LARGE_PCT, CAUSAL_GT_DD_MEDIUM_PCT
p = path or MATCHING_HYBRID_DD_CALIBRATION_JSON
if p.is_file():
data = json.loads(p.read_text(encoding="utf-8"))
best = data.get("best_params") or {}
if best.get("dd_large_pct") is not None:
return {
"dd_large_pct": float(best["dd_large_pct"]),
"dd_medium_pct": float(
best.get("dd_medium_pct", CAUSAL_GT_DD_MEDIUM_PCT)
),
}
return {
"dd_large_pct": float(CAUSAL_GT_DD_LARGE_PCT),
"dd_medium_pct": float(CAUSAL_GT_DD_MEDIUM_PCT),
}
def calibrate_hybrid_dd_thresholds(
fires: pd.DataFrame,
ohlc_df: pd.DataFrame,
*,
holdout_start: pd.Timestamp,
grid: dict[str, list[float]] | None = None,
last_price: float | None = None,
) -> dict[str, Any]:
"""
train 구간 PnL 최대 → holdout PnL로 검증, 최적 DD 임계 저장.
Args:
fires: monitor 전체 발화.
ohlc_df: 3m OHLC.
holdout_start: holdout 시작 시각.
grid: dd_large/medium 후보.
last_price: 미청산 평가가.
Returns:
best_params, train/holdout metrics, grid top-N.
"""
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_summary
grid = grid or default_dd_grid()
chron = sort_fires_chronological(fires)
results: list[dict[str, Any]] = []
for dd_large, dd_medium in product(
grid["dd_large_pct"],
grid["dd_medium_pct"],
):
if dd_medium >= dd_large:
continue
sized, stats = build_monitor_hybrid_sized_trades(
chron,
ohlc_df,
enhanced=False,
dd_large_pct=dd_large,
dd_medium_pct=dd_medium,
)
steps = simulate_portfolio_steps(sized, use_amount_krw=True)
train = portfolio_holdout_from_steps(
[s for s in steps if pd.to_datetime(s["dt"]) < holdout_start],
holdout_start,
initial_if_empty=GT_INITIAL_CASH_KRW,
note="train",
)
# train-only: start 1M → last asset before holdout
if steps:
pre = [
float(s["total_asset_krw"])
for s in steps
if pd.to_datetime(s["dt"]) < holdout_start
]
train_asset_end = pre[-1] if pre else GT_INITIAL_CASH_KRW
train_pnl = (train_asset_end - GT_INITIAL_CASH_KRW) / GT_INITIAL_CASH_KRW * 100
else:
train_pnl = 0.0
holdout = portfolio_holdout_from_steps(
steps,
holdout_start,
note="holdout",
)
full = simulate_portfolio_summary(
sized,
last_price=last_price,
use_amount_krw=True,
)
wf = walk_forward_portfolio_by_month(steps)
pos_months = sum(1 for w in wf if float(w.get("pnl_pct") or 0) > 0)
results.append(
{
"dd_large_pct": dd_large,
"dd_medium_pct": dd_medium,
"train_pnl_pct": round(train_pnl, 2),
"holdout_pnl_pct": float(holdout.get("pnl_pct", 0)),
"full_pnl_pct": float(full.get("pnl_pct", 0)),
"max_drawdown_pct": float(full.get("max_drawdown_pct", 0)),
"wf_positive_months": pos_months,
"wf_months": len(wf),
"large_tier_buys": stats.get("large_tier_buy_count", 0),
}
)
if not results:
return {"best_params": load_hybrid_dd_params(), "note": "empty grid"}
# train PnL 1순위, holdout PnL 2순위
ranked = sorted(
results,
key=lambda x: (x["train_pnl_pct"], x["holdout_pnl_pct"]),
reverse=True,
)
best = ranked[0]
return {
"best_params": {
"dd_large_pct": best["dd_large_pct"],
"dd_medium_pct": best["dd_medium_pct"],
},
"best_metrics": best,
"grid_size": len(results),
"top5": ranked[:5],
"holdout_start": str(holdout_start),
}
def run_and_save_calibration(
fires: pd.DataFrame,
ohlc_df: pd.DataFrame,
*,
outcomes: pd.DataFrame,
last_price: float | None = None,
out_path: Path | None = None,
) -> dict[str, Any]:
"""
캘리브레이션 실행 후 JSON 저장.
Args:
fires: monitor 발화.
ohlc_df: OHLC.
outcomes: fire_outcomes (holdout split).
last_price: 평가 종가.
out_path: 저장 경로.
Returns:
calibrate_hybrid_dd_thresholds 결과.
"""
outcomes_ts = outcomes.copy()
outcomes_ts["ts"] = pd.to_datetime(outcomes_ts["dt"])
holdout_start = outcomes_ts["ts"].quantile(1.0 - MATCH_HOLDOUT_RATIO)
result = calibrate_hybrid_dd_thresholds(
fires,
ohlc_df,
holdout_start=holdout_start,
last_price=last_price,
)
p = out_path or MATCHING_HYBRID_DD_CALIBRATION_JSON
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result

View File

@@ -1,37 +0,0 @@
# Matching — Simulation 축
03b GT 스냅샷에서 규칙 후보 → 전 구간 인과 스캔 → EV·holdout → `matched_rules.json`.
설계: [docs/reference/ARCHITECTURE.md](../../docs/reference/ARCHITECTURE.md)
## PDCA
| 단계 | 스크립트 모듈 | 산출물 |
|------|---------------|--------|
| 4-1 Plan/Do | `profile_rules.py` | `rule_candidates.json` (기본: 복합·대조만) |
| 4-2 Do | `rule_eval.scan` | `rule_fires.csv` |
| 4-3 Check | `label_outcomes.py` | `fire_outcomes.csv` (기본: `leg_gt` 청산) |
| 4-4 Act | `select_rules.py` | `matched_rules.json`, `backtest_summary.html` |
## 실행
```bash
# 전체 (enrich+스캔 약 2~3분)
python scripts/04_match_rules.py
# 단계별
python scripts/04_match_rules.py --phase profile
python scripts/04_match_rules.py --phase scan
python scripts/04_match_rules.py --phase label
python scripts/04_match_rules.py --phase select
```
선행: `python scripts/03_analyze_trades.py`
## 설정 (`.env` / `config.py`)
- `MATCH_LABEL_MODE``leg_gt`(다음 GT 매도/직전 매수) 또는 `forward`
- `MATCH_MAX_HOLD_DAYS` — leg_gt 최대 보유 일수 (기본 45)
- `MATCH_INCLUDE_ATOMIC` — 0이면 atomic 규칙 제외
- `MATCH_FORWARD_BARS` — leg_gt 불가 시 폴백 봉 수 (기본 60)
- `MATCH_TRAIN_RATIO` — train/valid 분할 (기본 0.7)
- `MATCH_MIN_FIRES`, `MATCH_MIN_EV_VALID`, `MATCH_MIN_PROFIT_FACTOR`

View File

@@ -1,8 +0,0 @@
"""
04단계: GT 프로필 + 전구간 EV 필터 매칭.
"""
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.matching.pipeline import run_matching_pipeline
__all__ = ["run_matching_pipeline", "load_monitor_rules"]

View File

@@ -1,70 +0,0 @@
"""
04단계 매칭 설정·메타 컬럼·프로필 피처 목록.
"""
from __future__ import annotations
from deepcoin.analysis.general_analysis_core import ga_col, interval_tf_prefix
from deepcoin.paths import (
ANALYSIS_TRADES_CSV,
MATCHING_BACKTEST_HTML,
MATCHING_FIRE_OUTCOMES,
MATCHING_GT_OVERLAP,
MATCHING_MATCHED_RULES,
MATCHING_RULE_CANDIDATES,
MATCHING_RULE_FIRES,
)
META_COLS: tuple[str, ...] = (
"trade_idx",
"dt",
"action",
"price",
"weight",
"leg_id",
"memo",
)
# 04-1 기본 폴백 (03c gt_mtf_profile.json 없을 때만 사용)
BUY_PROFILE_FEATURES: tuple[str, ...] = (
"m3_bb_pos",
"m3_RSI",
"m3_stoch_k",
"m3_macd_hist",
"m15_RSI",
"m30_RSI",
"m60_RSI",
"ga_align_timing_buy_score",
"ga_align_trend_score",
"ga_align_rsi_oversold_tf",
f"{interval_tf_prefix(60)}_{ga_col('struct_trend')}",
f"{interval_tf_prefix(1440)}_RSI",
)
SELL_PROFILE_FEATURES: tuple[str, ...] = (
"m3_bb_pos",
"m3_RSI",
"m3_stoch_k",
"m3_macd_hist",
"m15_RSI",
"m30_RSI",
"m60_RSI",
"ga_align_timing_sell_score",
"ga_align_trend_score",
"ga_align_rsi_overbought_tf",
f"{interval_tf_prefix(60)}_{ga_col('struct_trend')}",
f"{interval_tf_prefix(1440)}_RSI",
)
__all__ = [
"ANALYSIS_TRADES_CSV",
"META_COLS",
"BUY_PROFILE_FEATURES",
"SELL_PROFILE_FEATURES",
"MATCHING_RULE_CANDIDATES",
"MATCHING_RULE_FIRES",
"MATCHING_FIRE_OUTCOMES",
"MATCHING_MATCHED_RULES",
"MATCHING_BACKTEST_HTML",
"MATCHING_GT_OVERLAP",
]

View File

@@ -1,514 +0,0 @@
"""
GT 매수/매도 타점 MTF 프로필 분석 (3분~일봉 전 TF).
03b wide CSV에서 간격별·기법별 분포를 비교하고,
04 규칙 후보 생성용 피처 목록을 산출합니다.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from config import (
GENERAL_ANALYSIS_INTERVALS,
MATCH_PROFILE_MIN_SAMPLES,
MATCH_PROFILE_MIN_SEPARATION,
MATCH_PROFILE_TOP_GLOBAL,
MATCH_PROFILE_TOP_PER_TF,
)
from deepcoin.analysis.general_analysis_config import INTERVAL_PREFIX
from deepcoin.analysis.general_analysis_core import interval_tf_prefix
from deepcoin.matching.config import ANALYSIS_TRADES_CSV, META_COLS
from deepcoin.paths import ANALYSIS_GT_MTF_PROFILE_HTML, ANALYSIS_GT_MTF_PROFILE_JSON
def _feature_separation(
buy: pd.Series,
sell: pd.Series,
) -> float:
"""
매수·매도 GT 분포 간 분리도(Cohen 유사).
Args:
buy: 매수 타점 값.
sell: 매도 타점 값.
Returns:
분리도(비숫자·표본 부족 시 0).
"""
a = pd.to_numeric(buy, errors="coerce").dropna()
b = pd.to_numeric(sell, errors="coerce").dropna()
if len(a) < MATCH_PROFILE_MIN_SAMPLES or len(b) < MATCH_PROFILE_MIN_SAMPLES:
return 0.0
pooled = np.sqrt((a.var() + b.var()) / 2)
if pooled < 1e-9:
return abs(float(a.mean() - b.mean()))
return abs(float(a.mean() - b.mean())) / pooled
def _numeric_stats(series: pd.Series) -> dict[str, float | int]:
"""
숫자 컬럼 요약 통계.
Args:
series: 한 side GT 값.
Returns:
count, mean, median, q25, q75, std.
"""
s = pd.to_numeric(series, errors="coerce").dropna()
if s.empty:
return {"count": 0}
return {
"count": int(len(s)),
"mean": round(float(s.mean()), 4),
"median": round(float(s.median()), 4),
"q25": round(float(s.quantile(0.25)), 4),
"q75": round(float(s.quantile(0.75)), 4),
"std": round(float(s.std()), 4) if len(s) > 1 else 0.0,
}
def _categorical_stats(series: pd.Series) -> dict[str, Any]:
"""
범주형 컬럼 최빈값·비율.
Args:
series: GT 값.
Returns:
mode, mode_frac, value_counts 상위 5.
"""
s = series.dropna().astype(str)
if s.empty:
return {"count": 0}
vc = s.value_counts()
mode = str(vc.index[0])
return {
"count": int(len(s)),
"mode": mode,
"mode_frac": round(float(vc.iloc[0] / len(s)), 3),
"top": {str(k): int(v) for k, v in vc.head(5).items()},
}
def _parse_tf_column(col: str) -> tuple[str, int | None, str]:
"""
컬럼명에서 TF 접두사·간격·베이스명 추출.
Args:
col: 예 m3_ga_rsi, ga_align_timing_buy_score.
Returns:
(tf_label, interval_minutes|None, base_name).
"""
if col.startswith("ga_align_"):
return ("mtf_align", None, col)
prefixes = sorted(
set(INTERVAL_PREFIX.values()),
key=len,
reverse=True,
)
for p in prefixes:
if col.startswith(f"{p}_"):
inv = {v: k for k, v in INTERVAL_PREFIX.items()}
return (p, inv.get(p), col[len(p) + 1 :])
return ("other", None, col)
def _feature_family(base: str) -> str:
"""기법군 라벨."""
if base in ("bb_pos", "RSI", "macd_hist", "stoch_k", "stoch_d", "BB_Width"):
return "legacy"
if base.startswith("ga_align_"):
return "mtf_align"
if "pattern" in base:
return "pattern"
if "struct" in base or "elliott" in base or "wyckoff" in base or "fib_" in base:
return "wave_structure"
if "chart" in base:
return "chart"
if "volume" in base or "vp_" in base:
return "volume"
if "harmonic" in base:
return "harmonic"
if base.startswith("ga_"):
return "indicator"
return "other"
def discover_profile_columns(df: pd.DataFrame) -> list[str]:
"""
규칙·프로필 분석 대상 컬럼 목록.
Args:
df: 03b wide CSV DataFrame.
Returns:
META 제외·분석 가능 컬럼명.
"""
meta = set(META_COLS)
out: list[str] = []
for col in df.columns:
if col in meta:
continue
if df[col].notna().sum() < MATCH_PROFILE_MIN_SAMPLES:
continue
if pd.api.types.is_numeric_dtype(df[col]):
out.append(col)
continue
nuniq = df[col].dropna().astype(str).nunique()
if 1 < nuniq <= 20:
out.append(col)
return out
def _analyze_one_column(
buy: pd.DataFrame,
sell: pd.DataFrame,
col: str,
) -> dict[str, Any]:
"""
단일 컬럼 매수 vs 매도 GT 비교.
Args:
buy: 매수 행.
sell: 매도 행.
col: 컬럼명.
Returns:
분리도·통계·방향 힌트.
"""
tf_label, interval, base = _parse_tf_column(col)
family = _feature_family(base)
row: dict[str, Any] = {
"col": col,
"tf": tf_label,
"interval": interval,
"base": base,
"family": family,
"dtype": "numeric" if pd.api.types.is_numeric_dtype(buy[col]) else "categorical",
}
if row["dtype"] == "numeric":
row["buy"] = _numeric_stats(buy[col])
row["sell"] = _numeric_stats(sell[col])
sep = _feature_separation(buy[col], sell[col])
row["separation"] = round(sep, 4)
bm = row["buy"].get("median")
sm = row["sell"].get("median")
if bm is not None and sm is not None:
row["buy_lower_than_sell"] = bm < sm
else:
row["buy_lower_than_sell"] = None
else:
row["buy"] = _categorical_stats(buy[col])
row["sell"] = _categorical_stats(sell[col])
row["separation"] = 0.0
if row["buy"].get("mode") and row["sell"].get("mode"):
row["modes_differ"] = row["buy"]["mode"] != row["sell"]["mode"]
return row
def analyze_gt_mtf_profile(df: pd.DataFrame) -> dict[str, Any]:
"""
전 TF·전 컬럼 GT 매수/매도 프로필 분석.
Args:
df: general_analysis_trades.csv.
Returns:
JSON 직렬화 가능 분석 결과.
"""
buy = df[df["action"] == "buy"].copy()
sell = df[df["action"] == "sell"].copy()
cols = discover_profile_columns(df)
features: list[dict[str, Any]] = []
for col in cols:
features.append(_analyze_one_column(buy, sell, col))
numeric_feats = [f for f in features if f["dtype"] == "numeric"]
ranked = sorted(numeric_feats, key=lambda x: x["separation"], reverse=True)
by_interval: dict[str, dict[str, Any]] = {}
for iv in GENERAL_ANALYSIS_INTERVALS:
pfx = interval_tf_prefix(iv)
iv_feats = [f for f in numeric_feats if f["tf"] == pfx]
iv_ranked = sorted(iv_feats, key=lambda x: x["separation"], reverse=True)
buy_favor = [f for f in iv_ranked if f.get("buy_lower_than_sell") is True][:10]
sell_favor = [f for f in iv_ranked if f.get("buy_lower_than_sell") is False][:10]
by_interval[pfx] = {
"interval_minutes": iv,
"feature_count": len(iv_feats),
"top_separation": [
{"col": x["col"], "separation": x["separation"]}
for x in iv_ranked[:15]
],
"buy_favor_lower_median": [
{"col": x["col"], "separation": x["separation"]}
for x in buy_favor[:8]
],
"sell_favor_higher_median": [
{"col": x["col"], "separation": x["separation"]}
for x in sell_favor[:8]
],
}
align_feats = [f for f in features if f["family"] == "mtf_align"]
selected_buy = _select_side_features(ranked, "buy")
selected_sell = _select_side_features(ranked, "sell")
return {
"source_rows": int(len(df)),
"buy_gt_count": int(len(buy)),
"sell_gt_count": int(len(sell)),
"columns_analyzed": len(cols),
"intervals": list(GENERAL_ANALYSIS_INTERVALS),
"config": {
"top_per_tf": MATCH_PROFILE_TOP_PER_TF,
"top_global": MATCH_PROFILE_TOP_GLOBAL,
"min_separation": MATCH_PROFILE_MIN_SEPARATION,
"min_samples": MATCH_PROFILE_MIN_SAMPLES,
},
"global_top_separation": [
{
"col": x["col"],
"tf": x["tf"],
"family": x["family"],
"separation": x["separation"],
"buy_median": x["buy"].get("median"),
"sell_median": x["sell"].get("median"),
}
for x in ranked[:40]
],
"by_interval": by_interval,
"mtf_align": align_feats,
"selected_features": {
"buy": selected_buy,
"sell": selected_sell,
},
"features": features,
}
def _select_side_features(
ranked: list[dict[str, Any]],
side: str,
) -> list[str]:
"""
04 규칙용 피처 목록: TF별 상위 + 글로벌 상위.
Args:
ranked: separation 내림차순 numeric feature dicts.
side: buy | sell.
Returns:
컬럼명 리스트(중복 제거, 순서 유지).
"""
chosen: list[str] = []
seen: set[str] = set()
def add(col: str) -> None:
if col not in seen:
seen.add(col)
chosen.append(col)
for iv in GENERAL_ANALYSIS_INTERVALS:
pfx = interval_tf_prefix(iv)
iv_list = [
f
for f in ranked
if f["tf"] == pfx and f["separation"] >= MATCH_PROFILE_MIN_SEPARATION
]
if side == "buy":
iv_list.sort(
key=lambda x: (
x["separation"],
1 if x.get("buy_lower_than_sell") else 0,
),
reverse=True,
)
else:
iv_list.sort(
key=lambda x: (
x["separation"],
1 if x.get("buy_lower_than_sell") is False else 0,
),
reverse=True,
)
for f in iv_list[:MATCH_PROFILE_TOP_PER_TF]:
add(f["col"])
global_list = [f for f in ranked if f["separation"] >= MATCH_PROFILE_MIN_SEPARATION]
if side == "buy":
global_list.sort(
key=lambda x: (
x["separation"],
1 if x.get("buy_lower_than_sell") else 0,
),
reverse=True,
)
else:
global_list.sort(
key=lambda x: (
x["separation"],
1 if x.get("buy_lower_than_sell") is False else 0,
),
reverse=True,
)
for f in global_list[:MATCH_PROFILE_TOP_GLOBAL]:
add(f["col"])
for name in (
"ga_align_timing_buy_score",
"ga_align_timing_sell_score",
"ga_align_trend_score",
"ga_align_rsi_oversold_tf",
"ga_align_rsi_overbought_tf",
"ga_align_mtf_conflict",
):
add(name)
return chosen
def load_selected_features(
profile_path: Path | None = None,
) -> tuple[list[str], list[str]]:
"""
저장된 프로필 JSON에서 buy/sell 피처 목록 로드.
Args:
profile_path: gt_mtf_profile.json.
Returns:
(buy_features, sell_features). 없으면 빈 리스트.
"""
path = profile_path or ANALYSIS_GT_MTF_PROFILE_JSON
if not path.is_file():
return [], []
data = json.loads(path.read_text(encoding="utf-8"))
sel = data.get("selected_features") or {}
return list(sel.get("buy") or []), list(sel.get("sell") or [])
def run_gt_mtf_profile(
trades_csv: Path | None = None,
*,
write_json: bool = True,
write_html: bool = True,
) -> dict[str, Any]:
"""
03b CSV 분석 후 JSON/HTML 저장.
Args:
trades_csv: 입력 CSV.
write_json: JSON 저장 여부.
write_html: HTML 저장 여부.
Returns:
analyze_gt_mtf_profile 결과.
"""
path = trades_csv or ANALYSIS_TRADES_CSV
if not path.is_file():
raise FileNotFoundError(f"03b CSV 없음: {path}")
df = pd.read_csv(path)
analysis = analyze_gt_mtf_profile(df)
buy_n = len(analysis["selected_features"]["buy"])
sell_n = len(analysis["selected_features"]["sell"])
print(
f"[03c] GT MTF 프로필: 분석 {analysis['columns_analyzed']}"
f"→ 매수 피처 {buy_n}, 매도 피처 {sell_n}"
)
if write_json:
ANALYSIS_GT_MTF_PROFILE_JSON.parent.mkdir(parents=True, exist_ok=True)
ANALYSIS_GT_MTF_PROFILE_JSON.write_text(
json.dumps(analysis, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"[03c] 저장: {ANALYSIS_GT_MTF_PROFILE_JSON}")
if write_html:
write_gt_mtf_profile_html(analysis, ANALYSIS_GT_MTF_PROFILE_HTML)
print(f"[03c] 저장: {ANALYSIS_GT_MTF_PROFILE_HTML}")
return analysis
def write_gt_mtf_profile_html(
analysis: dict[str, Any],
html_path: Path,
) -> Path:
"""
TF별·글로벌 분리도 요약 HTML.
Args:
analysis: analyze_gt_mtf_profile 결과.
html_path: 출력 경로.
Returns:
html_path.
"""
html_path.parent.mkdir(parents=True, exist_ok=True)
def _rows_interval() -> str:
rows = ""
for pfx, block in analysis.get("by_interval", {}).items():
top = block.get("top_separation") or []
top_s = ", ".join(
f"{t['col'].split('_', 1)[-1][:20]}({t['separation']:.2f})"
for t in top[:5]
) or "-"
rows += (
f"<tr><td>{pfx}</td><td>{block.get('feature_count', 0)}</td>"
f"<td>{top_s}</td></tr>"
)
return rows
def _rows_global() -> str:
rows = ""
for item in analysis.get("global_top_separation") or []:
rows += (
f"<tr><td>{item['col']}</td><td>{item['tf']}</td>"
f"<td>{item['family']}</td><td>{item['separation']:.3f}</td>"
f"<td>{item.get('buy_median','')}</td><td>{item.get('sell_median','')}</td></tr>"
)
return rows
buy_feats = ", ".join(analysis["selected_features"]["buy"][:25])
sell_feats = ", ".join(analysis["selected_features"]["sell"][:25])
html = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="utf-8"/>
<title>GT MTF 프로필 (3분~일봉)</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f5f5f5; color: #1e293b; }}
h1, h2 {{ color: #0f172a; }}
table {{ border-collapse: collapse; width: 100%; background: #fff; margin-bottom: 20px; font-size: 0.85rem; }}
th, td {{ border: 1px solid #e2e8f0; padding: 8px; text-align: left; }}
th {{ background: #e2e8f0; }}
p.note {{ font-size: 0.9rem; color: #475569; }}
code {{ font-size: 0.8rem; word-break: break-all; }}
</style></head><body>
<h1>Ground Truth MTF 타점 프로필</h1>
<p>매수 GT {analysis['buy_gt_count']}건 · 매도 GT {analysis['sell_gt_count']}건 ·
분석 컬럼 {analysis['columns_analyzed']}개 (3,5,10,15,30,60,240,1440분 + MTF 합성)</p>
<p class="note">분리도 = |mean_buy mean_sell| / pooled_std. TF별·글로벌 상위 피처로 04 규칙 후보를 생성합니다.</p>
<h2>간격별 분리도 상위 (요약)</h2>
<table><thead><tr><th>TF</th><th>숫자 피처 수</th><th>상위 5 (분리도)</th></tr></thead>
<tbody>{_rows_interval()}</tbody></table>
<h2>글로벌 분리도 Top 40</h2>
<table><thead><tr><th>컬럼</th><th>TF</th><th>기법군</th><th>분리도</th><th>매수 median</th><th>매도 median</th></tr></thead>
<tbody>{_rows_global()}</tbody></table>
<h2>04 규칙 선별용 피처 (발췌)</h2>
<p><strong>매수</strong><br/><code>{buy_feats}</code></p>
<p><strong>매도</strong><br/><code>{sell_feats}</code></p>
</body></html>"""
html_path.write_text(html, encoding="utf-8")
return html_path

View File

@@ -1,64 +0,0 @@
"""
Ground Truth 매수·매도 시각표 (04-3 leg 라벨링용).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from deepcoin.ground_truth.ground_truth import load_ground_truth
from deepcoin.paths import resolve_ground_truth_file
def load_gt_trade_events(
gt_path: Path | str | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
GT trades에서 매수·매도 이벤트 시각(ns)·가격 배열을 만듭니다.
Args:
gt_path: ground_truth JSON 경로. None이면 기본 경로.
Returns:
(buy_ts_ns, buy_px, sell_ts_ns, sell_px) 오름차순 정렬.
"""
path = (
resolve_ground_truth_file()
if gt_path is None
else Path(gt_path)
)
data = load_ground_truth(path) or {}
trades: list[dict[str, Any]] = data.get("trades") or []
buys: list[tuple[pd.Timestamp, float]] = []
sells: list[tuple[pd.Timestamp, float]] = []
for t in trades:
ts = pd.Timestamp(t["dt"])
px = float(t["price"])
if t.get("action") == "buy":
buys.append((ts, px))
elif t.get("action") == "sell":
sells.append((ts, px))
buys.sort(key=lambda x: x[0])
sells.sort(key=lambda x: x[0])
if buys:
buy_ts = np.array([x[0].value for x in buys], dtype=np.int64)
buy_px = np.array([x[1] for x in buys], dtype=float)
else:
buy_ts = np.array([], dtype=np.int64)
buy_px = np.array([], dtype=float)
if sells:
sell_ts = np.array([x[0].value for x in sells], dtype=np.int64)
sell_px = np.array([x[1] for x in sells], dtype=float)
else:
sell_ts = np.array([], dtype=np.int64)
sell_px = np.array([], dtype=float)
return buy_ts, buy_px, sell_ts, sell_px

View File

@@ -1,231 +0,0 @@
"""
04-3: 규칙 발화별 성과 라벨링 (GT leg 청산 + forward 폴백).
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
from config import (
CHART_LOOKBACK_DAYS,
MATCH_FORWARD_BARS,
MATCH_LABEL_MODE,
MATCH_MAX_HOLD_DAYS,
MATCH_PRIMARY_INTERVAL,
SYMBOL,
TRADING_FEE_RATE,
)
from deepcoin.data.mtf_bb import load_frames_from_db
from deepcoin.matching.gt_schedule import load_gt_trade_events
from deepcoin.ops.monitor import Monitor
_NS_PER_DAY = 86_400 * 1_000_000_000
def _forward_ret_vectorized(
fire_ts_ns: np.ndarray,
c0: np.ndarray,
close_ts_ns: np.ndarray,
close_px: np.ndarray,
side: np.ndarray,
n_bars: int,
fee_pct: float,
) -> tuple[np.ndarray, np.ndarray]:
"""
고정 N봉 forward 수익률(벡터화, 루프 최소).
Args:
fire_ts_ns: 발화 시각(ns).
c0: 발화가.
close_ts_ns, close_px: 주간격 종가 시계열.
side: buy | sell.
n_bars: forward 봉 수.
fee_pct: 왕복 수수료 %p.
Returns:
(ret_pct, valid_mask).
"""
ret = np.full(len(fire_ts_ns), np.nan, dtype=float)
valid = np.zeros(len(fire_ts_ns), dtype=bool)
for i in range(len(fire_ts_ns)):
idx = np.searchsorted(close_ts_ns, fire_ts_ns[i], side="right") - 1
if idx < 0:
continue
end = idx + n_bars
if end >= len(close_px):
continue
c_entry = c0[i]
c_exit = float(close_px[end])
if side[i] == "buy":
ret[i] = (c_exit / c_entry - 1.0) * 100.0 - fee_pct
else:
ret[i] = (c_entry / c_exit - 1.0) * 100.0 - fee_pct
valid[i] = True
return ret, valid
def _leg_gt_ret_vectorized(
fire_ts_ns: np.ndarray,
c0: np.ndarray,
side: np.ndarray,
buy_ts: np.ndarray,
buy_px: np.ndarray,
sell_ts: np.ndarray,
sell_px: np.ndarray,
max_hold_days: int,
fee_pct: float,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
GT 이벤트 기준 leg 청산 수익률.
매수 발화: 다음 GT 매도까지 보유.
매도 발화: 직전 GT 매수가 대비 청산 수익.
Args:
fire_ts_ns, c0, side: 발화 배열.
buy_ts, buy_px, sell_ts, sell_px: GT 이벤트.
max_hold_days: 최대 보유 일수.
fee_pct: 왕복 수수료 %p.
Returns:
(ret_pct, valid_mask, hold_days).
"""
n = len(fire_ts_ns)
ret = np.full(n, np.nan, dtype=float)
valid = np.zeros(n, dtype=bool)
hold_days = np.full(n, np.nan, dtype=float)
max_hold_ns = max_hold_days * _NS_PER_DAY
buy_m = side == "buy"
if buy_m.any() and len(sell_ts) > 0:
t_b = fire_ts_ns[buy_m]
idx = np.searchsorted(sell_ts, t_b, side="right")
ok = idx < len(sell_ts)
if ok.any():
i_ok = np.where(buy_m)[0][ok]
exit_ns = sell_ts[idx[ok]]
delta = exit_ns - t_b[ok]
within = (delta > 0) & (delta <= max_hold_ns)
if within.any():
ii = i_ok[within]
exit_px = sell_px[idx[ok][within]]
entry = c0[ii]
ret[ii] = (exit_px / entry - 1.0) * 100.0 - fee_pct
valid[ii] = True
hold_days[ii] = delta[within] / _NS_PER_DAY
sell_m = side == "sell"
if sell_m.any() and len(buy_ts) > 0:
t_s = fire_ts_ns[sell_m]
idx = np.searchsorted(buy_ts, t_s, side="left") - 1
ok = idx >= 0
if ok.any():
i_ok = np.where(sell_m)[0][ok]
entry_ns = buy_ts[idx[ok]]
delta = t_s[ok] - entry_ns
within = (delta > 0) & (delta <= max_hold_ns)
if within.any():
ii = i_ok[within]
entry_px = buy_px[idx[ok][within]]
exit_p = c0[ii]
ret[ii] = (exit_p / entry_px - 1.0) * 100.0 - fee_pct
valid[ii] = True
hold_days[ii] = delta[within] / _NS_PER_DAY
return ret, valid, hold_days
def label_fire_outcomes(
fires: pd.DataFrame,
frames: dict[int, pd.DataFrame] | None = None,
) -> pd.DataFrame:
"""
각 발화의 성과를 라벨링합니다.
MATCH_LABEL_MODE=leg_gt: GT 다음 매도/직전 매수 기준.
미충족 분은 forward N봉으로 폴백.
Args:
fires: rule_fires.
frames: OHLCV (폴백용).
Returns:
fire_outcomes (+ forward_ret_pct, label_method, hold_days, ...).
"""
if fires.empty:
return fires.copy()
fee_pct = TRADING_FEE_RATE * 2 * 100
fts = pd.to_datetime(fires["dt"])
fire_ts_ns = fts.values.astype("datetime64[ns]").astype(np.int64)
c0 = fires["close"].astype(float).values
side = fires["side"].astype(str).values
buy_ts, buy_px, sell_ts, sell_px = load_gt_trade_events()
label_method = np.full(len(fires), "", dtype=object)
hold_days = np.full(len(fires), np.nan, dtype=float)
ret = np.full(len(fires), np.nan, dtype=float)
if MATCH_LABEL_MODE == "leg_gt":
ret, valid, hd = _leg_gt_ret_vectorized(
fire_ts_ns,
c0,
side,
buy_ts,
buy_px,
sell_ts,
sell_px,
MATCH_MAX_HOLD_DAYS,
fee_pct,
)
label_method[valid] = "leg_gt"
hold_days = hd
need_fb = ~np.isfinite(ret)
if need_fb.any():
if frames is None:
mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
if need_fb.any() and frames is not None:
raw = frames.get(MATCH_PRIMARY_INTERVAL)
if raw is not None and not raw.empty:
px = raw.copy()
if not isinstance(px.index, pd.DatetimeIndex):
px.index = pd.to_datetime(px.index)
px = px.sort_index()
col = "close" if "close" in px.columns else "Close"
close_px = px[col].astype(float).values
close_ts_ns = px.index.astype(np.int64).values
fb_ret, fb_ok = _forward_ret_vectorized(
fire_ts_ns[need_fb],
c0[need_fb],
close_ts_ns,
close_px,
side[need_fb],
MATCH_FORWARD_BARS,
fee_pct,
)
ret[need_fb] = np.where(fb_ok, fb_ret, np.nan)
fb_idx = np.where(need_fb)[0][fb_ok]
label_method[fb_idx] = f"forward_{MATCH_FORWARD_BARS}"
out = fires.copy()
out["forward_ret_pct"] = np.round(ret, 4)
out["win"] = (ret > 0).astype(int)
out["label_method"] = label_method
out["hold_days"] = np.round(hold_days, 2)
out["forward_bars"] = MATCH_FORWARD_BARS
out = out[np.isfinite(out["forward_ret_pct"])].reset_index(drop=True)
leg_n = int((out["label_method"] == "leg_gt").sum())
fb_n = int(out["label_method"].astype(str).str.startswith("forward").sum())
print(
f"[04-3] 성과 라벨: {len(out):,}"
f"(mode={MATCH_LABEL_MODE}, leg_gt={leg_n:,}, forward폴백={fb_n:,}, "
f"수수료 {fee_pct:.3f}%p)"
)
return out

View File

@@ -1,86 +0,0 @@
"""
05 연동: 최신 봉에서 matched 규칙 평가.
"""
from __future__ import annotations
import time
from typing import Any
import pandas as pd
from config import CHART_LOOKBACK_DAYS, MATCH_LIVE_CACHE_SEC, MATCH_LIVE_LOOKBACK_DAYS, SYMBOL
from deepcoin.data.mtf_bb import load_frames_from_db
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.matching.rule_eval import (
build_mtf_scan_frame,
conditions_columns,
eval_conditions,
)
from deepcoin.ops.monitor import Monitor
_cache: dict[str, Any] = {"ts": 0.0, "frame": None, "rules_key": ""}
def evaluate_live_rules(
rules: list[dict[str, Any]] | None = None,
*,
lookback_days: int | None = None,
force_refresh: bool = False,
) -> list[dict[str, Any]]:
"""
최신 완성 3분봉에서 활성 규칙 발화 여부를 검사합니다.
Args:
rules: 규칙 목록. None이면 matched_rules에서 로드.
lookback_days: DB 조회 일수(기본 MATCH_LIVE_LOOKBACK_DAYS).
force_refresh: 캐시 무시.
Returns:
발화한 규칙 정보 리스트 (rule_id, side, dt, close).
"""
active = rules if rules is not None else load_monitor_rules()
if not active:
return []
lb = lookback_days if lookback_days is not None else MATCH_LIVE_LOOKBACK_DAYS
rules_key = ",".join(r["rule_id"] for r in active)
now = time.time()
global _cache
if (
not force_refresh
and _cache.get("frame") is not None
and now - float(_cache.get("ts", 0)) < MATCH_LIVE_CACHE_SEC
and _cache.get("rules_key") == rules_key
):
frame = _cache["frame"]
else:
mon = Monitor(cooldown_file=None)
days = min(lb, CHART_LOOKBACK_DAYS)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=days)
needed = conditions_columns(active)
frame = build_mtf_scan_frame(frames, needed)
_cache["ts"] = now
_cache["frame"] = frame
_cache["rules_key"] = rules_key
if frame.empty:
return []
ts = frame.index[-1]
row_frame = frame.iloc[[-1]]
fired: list[dict[str, Any]] = []
close_col = "close"
for rule in active:
if not eval_conditions(row_frame, rule["conditions"]).iloc[0]:
continue
fired.append(
{
"rule_id": rule["rule_id"],
"side": rule["side"],
"kind": rule.get("kind", ""),
"dt": ts.strftime("%Y-%m-%d %H:%M:%S"),
"close": float(row_frame[close_col].iloc[0]),
}
)
return fired

View File

@@ -1,61 +0,0 @@
"""
04 산출 matched_rules.json 로드.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from deepcoin.paths import MATCHING_MATCHED_RULES
def load_matched_rules(path: Path | None = None) -> dict[str, Any]:
"""
matched_rules.json 전체를 로드합니다.
Args:
path: JSON 경로. None이면 기본 경로.
Returns:
matched_rules dict. 없으면 빈 dict.
"""
p = path or MATCHING_MATCHED_RULES
if not p.is_file():
return {}
return json.loads(p.read_text(encoding="utf-8"))
def load_active_rules(path: Path | None = None) -> list[dict[str, Any]]:
"""
04 선별 규칙 전체(strict 우선).
Args:
path: matched_rules.json 경로.
Returns:
규칙 dict 리스트.
"""
data = load_matched_rules(path)
selected = data.get("selected") or []
if selected:
return list(selected)
return list(data.get("selected_best_effort") or [])
def load_monitor_rules(path: Path | None = None) -> list[dict[str, Any]]:
"""
05 모니터·텔레그램에 쓸 규칙 (holdout 통과, 매수·매도 각 최대 1개).
Args:
path: matched_rules.json 경로.
Returns:
monitor_rules 또는 active_rules fallback.
"""
data = load_matched_rules(path)
monitor = data.get("monitor_rules") or []
if monitor:
return list(monitor)
return load_active_rules(path)

View File

@@ -1,245 +0,0 @@
"""
Option C 2차 목표(+1000%) 검증 — hybrid tier 포트폴리오 WF·슬리피지·Go/No-Go.
"""
from __future__ import annotations
from typing import Any
import pandas as pd
from config import (
GT_INITIAL_CASH_KRW,
LIVE_SLIPPAGE_PCT,
SIM_HYBRID_MAX_MDD_PCT,
SIM_HYBRID_PORTFOLIO_WF_MIN_RATIO,
SIM_OPTION_C_MIN_GT_CAPTURE,
SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO,
SIM_OPTION_C_PHASE2_TARGET_PNL_PCT,
)
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_summary
def apply_slippage_to_trades(
trades: list[dict[str, Any]],
slippage_pct: float = LIVE_SLIPPAGE_PCT,
) -> list[dict[str, Any]]:
"""
체결가에 슬리피지 반영 (매수 불리·매도 불리).
Args:
trades: amount_krw·price·action trade dict.
slippage_pct: 체결가 대비 % (0.05 = 0.05%).
Returns:
price 조정된 trade dict 복사본.
"""
slip = float(slippage_pct) / 100.0
out: list[dict[str, Any]] = []
for t in trades:
row = dict(t)
price = float(row.get("price") or 0)
if price <= 0:
out.append(row)
continue
action = row.get("action", "")
if action == "buy":
row["price"] = round(price * (1.0 + slip), 4)
elif action == "sell":
row["price"] = round(price * (1.0 - slip), 4)
out.append(row)
return out
def walk_forward_portfolio_by_month(
steps: list[dict[str, Any]],
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
) -> list[dict[str, Any]]:
"""
포트폴리오 step에서 월별 자산 증감률 (인과적).
Args:
steps: simulate_portfolio_steps 결과.
initial_cash: 첫 달 시작 자산(이전 달 종료 없을 때).
Returns:
{month, pnl_pct, start_asset, end_asset} 리스트.
"""
if not steps:
return []
df = pd.DataFrame(steps)
df["ts"] = pd.to_datetime(df["dt"])
df = df.sort_values("ts")
df["month"] = df["ts"].dt.to_period("M").astype(str)
rows: list[dict[str, Any]] = []
prev_end = float(initial_cash)
for month in sorted(df["month"].unique()):
grp = df[df["month"] == month]
end_asset = float(grp["total_asset_krw"].iloc[-1])
pnl_pct = (end_asset - prev_end) / prev_end * 100.0 if prev_end > 0 else 0.0
rows.append(
{
"month": month,
"pnl_pct": round(pnl_pct, 2),
"start_asset_krw": round(prev_end, 0),
"end_asset_krw": round(end_asset, 0),
}
)
prev_end = end_asset
return rows
def walk_forward_portfolio_summary(wf_rows: list[dict[str, Any]]) -> dict[str, Any]:
"""
월별 포트폴리오 WF 요약.
Args:
wf_rows: walk_forward_portfolio_by_month 결과.
Returns:
months, positive_months, positive_ratio, mean_pnl_pct.
"""
if not wf_rows:
return {"months": 0, "positive_ratio": 0.0, "mean_pnl_pct": 0.0}
n = len(wf_rows)
pos = sum(1 for r in wf_rows if float(r.get("pnl_pct") or 0) > 0)
mean_pnl = sum(float(r.get("pnl_pct") or 0) for r in wf_rows) / n
return {
"months": n,
"positive_months": pos,
"positive_ratio": round(pos / n, 4),
"mean_pnl_pct": round(mean_pnl, 2),
}
def simulate_hybrid_slippage_stress(
sized_trades: list[dict[str, Any]],
*,
last_price: float | None,
slippage_pct: float = LIVE_SLIPPAGE_PCT,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float,
) -> dict[str, Any]:
"""
hybrid sized trades + 슬리피지 포트폴리오 요약.
Args:
sized_trades: build_monitor_hybrid_sized_trades 출력.
last_price: 미청산 평가가.
slippage_pct: 체결 슬리피지 %.
initial_cash: 시작 현금.
fee_rate: 수수료율.
Returns:
simulate_portfolio_summary 결과.
"""
slipped = apply_slippage_to_trades(sized_trades, slippage_pct)
result = simulate_portfolio_summary(
slipped,
initial_cash=initial_cash,
fee_rate=fee_rate,
last_price=last_price,
use_amount_krw=True,
)
result["slippage_pct"] = slippage_pct
result["sizing_mode"] = "hybrid_slippage_stress"
return result
def evaluate_option_c_phase2_go(
hybrid_go: dict[str, Any],
hybrid_full: dict[str, Any],
hybrid_holdout: dict[str, Any],
hybrid_fee_stress: dict[str, Any],
hybrid_slippage: dict[str, Any],
portfolio_wf_summary: dict[str, Any],
gt_pnl_pct: float,
) -> dict[str, Any]:
"""
Option C 2차(+1000%) Go/No-Go.
Args:
hybrid_go: 1차 hybrid tier Go 결과.
hybrid_full: 전기간 hybrid 포트폴리오.
hybrid_holdout: holdout 구간 증감.
hybrid_fee_stress: 수수료 2x hybrid.
hybrid_slippage: 슬리피지 반영 hybrid.
portfolio_wf_summary: 월별 포트폴리오 WF.
gt_pnl_pct: GT 전기간 PnL %.
Returns:
go, checks, targets.
"""
full_pnl = float(hybrid_full.get("pnl_pct", 0))
capture = full_pnl / gt_pnl_pct if abs(gt_pnl_pct) > 1e-6 else 0.0
ho_pnl = float(hybrid_holdout.get("pnl_pct", -999))
mdd = float(hybrid_full.get("max_drawdown_pct", 999))
fee_pnl = float(hybrid_fee_stress.get("pnl_pct", -999))
slip_pnl = float(hybrid_slippage.get("pnl_pct", -999))
wf_ratio = float(portfolio_wf_summary.get("positive_ratio", 0))
c_phase1 = bool(hybrid_go.get("go"))
c_pnl = full_pnl >= SIM_OPTION_C_PHASE2_TARGET_PNL_PCT
c_capture = capture >= SIM_OPTION_C_MIN_GT_CAPTURE
c_holdout = ho_pnl > 0.0
c_mdd = mdd <= SIM_HYBRID_MAX_MDD_PCT
c_fee = fee_pnl >= SIM_OPTION_C_PHASE2_TARGET_PNL_PCT * SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO
c_slip = slip_pnl > 0.0
c_wf = wf_ratio >= SIM_HYBRID_PORTFOLIO_WF_MIN_RATIO
all_go = (
c_phase1
and c_pnl
and c_capture
and c_holdout
and c_mdd
and c_fee
and c_slip
and c_wf
)
return {
"go": all_go,
"gt_capture_ratio": round(capture, 4),
"targets": {
"phase2_pnl_pct": SIM_OPTION_C_PHASE2_TARGET_PNL_PCT,
"min_gt_capture": SIM_OPTION_C_MIN_GT_CAPTURE,
"portfolio_wf_min_ratio": SIM_HYBRID_PORTFOLIO_WF_MIN_RATIO,
},
"checks": [
{"name": "phase1_hybrid_go", "pass": c_phase1},
{
"name": "full_pnl_1000pct",
"pass": c_pnl,
"value": full_pnl,
},
{
"name": "gt_capture_23pct",
"pass": c_capture,
"value": round(capture, 4),
},
{"name": "holdout_pnl_positive", "pass": c_holdout, "value": ho_pnl},
{"name": "max_mdd", "pass": c_mdd, "value": mdd},
{
"name": "fee_stress_ratio",
"pass": c_fee,
"value": fee_pnl,
"threshold": round(
SIM_OPTION_C_PHASE2_TARGET_PNL_PCT * SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO,
2,
),
},
{
"name": "slippage_stress_positive",
"pass": c_slip,
"value": slip_pnl,
"note": "체결가 슬리피지 반영 후에도 흑자",
},
{
"name": "portfolio_wf_positive_ratio",
"pass": c_wf,
"value": wf_ratio,
},
],
}

View File

@@ -1,145 +0,0 @@
"""
04단계 PDCA 파이프라인: 프로필 → 스캔 → 라벨 → 선별.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
import pandas as pd
from config import CHART_LOOKBACK_DAYS, SYMBOL
from deepcoin.data.mtf_bb import load_frames_from_db
from deepcoin.matching.config import (
MATCHING_BACKTEST_HTML,
MATCHING_FIRE_OUTCOMES,
MATCHING_GT_OVERLAP,
MATCHING_MATCHED_RULES,
MATCHING_RULE_CANDIDATES,
MATCHING_RULE_FIRES,
)
from deepcoin.matching.label_outcomes import label_fire_outcomes
from deepcoin.matching.profile_rules import build_rule_candidates, save_rule_candidates
from deepcoin.matching.rule_eval import (
build_mtf_scan_frame,
conditions_columns,
scan_rule_fires,
)
from deepcoin.matching.select_rules import (
select_matched_rules,
write_backtest_summary_html,
)
from deepcoin.ops.monitor import Monitor
from deepcoin.paths import ensure_dirs
def run_matching_pipeline(
phase: str = "all",
trades_csv: Path | None = None,
) -> None:
"""
04a~04d 단계를 순서대로 실행합니다.
Args:
phase: all | profile | scan | label | select.
trades_csv: 03b CSV 경로(선택).
"""
ensure_dirs()
t0 = time.time()
from config import MATCH_INCLUDE_ATOMIC, MATCH_LABEL_MODE
print(
f"=== 04 매칭 파이프라인 {SYMBOL} "
f"(label={MATCH_LABEL_MODE}, atomic={MATCH_INCLUDE_ATOMIC}) ==="
)
sys.stdout.flush()
candidates_path = MATCHING_RULE_CANDIDATES
fires_path = MATCHING_RULE_FIRES
outcomes_path = MATCHING_FIRE_OUTCOMES
candidates: dict | None = None
fires: pd.DataFrame | None = None
outcomes: pd.DataFrame | None = None
frames = None
if phase in ("all", "profile"):
print("[04] Phase 4-1 Plan/Do: GT 프로필 → 규칙 후보")
candidates = build_rule_candidates(trades_csv)
save_rule_candidates(candidates, candidates_path)
if phase == "profile":
return
if phase in ("all", "scan", "label"):
if candidates is None:
candidates = json.loads(candidates_path.read_text(encoding="utf-8"))
rules = candidates.get("rules", [])
print(f"[04] Phase 4-2 Do: 전구간 발화 스캔 ({len(rules)}규칙)")
sys.stdout.flush()
mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
needed = conditions_columns(rules)
scan_frame = build_mtf_scan_frame(frames, needed)
fires = scan_rule_fires(scan_frame, rules)
fires_path.parent.mkdir(parents=True, exist_ok=True)
fires.to_csv(fires_path, index=False, encoding="utf-8-sig")
print(f"[04-2] 저장: {fires_path} ({len(fires):,}행)")
if phase == "scan":
return
if phase in ("all", "label", "select"):
if fires is None:
fires = pd.read_csv(fires_path)
if phase in ("all", "label"):
print("[04] Phase 4-3 Check: 발화별 forward PnL 라벨")
if frames is None:
mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
outcomes = label_fire_outcomes(fires, frames)
outcomes.to_csv(outcomes_path, index=False, encoding="utf-8-sig")
print(f"[04-3] 저장: {outcomes_path}")
if phase == "label":
return
if phase in ("all", "select"):
if candidates is None:
candidates = json.loads(candidates_path.read_text(encoding="utf-8"))
if outcomes is None:
outcomes = pd.read_csv(outcomes_path)
print("[04] Phase 4-4 Act: EV 필터·규칙 선별")
matched = select_matched_rules(outcomes, candidates)
MATCHING_MATCHED_RULES.parent.mkdir(parents=True, exist_ok=True)
MATCHING_MATCHED_RULES.write_text(
json.dumps(matched, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"[04-4] 저장: {MATCHING_MATCHED_RULES}")
overlap = matched.get("gt_overlap", {})
MATCHING_GT_OVERLAP.write_text(
json.dumps(overlap, ensure_ascii=False, indent=2),
encoding="utf-8",
)
write_backtest_summary_html(matched, MATCHING_BACKTEST_HTML)
print(f"완료 ({time.time() - t0:.0f}초)")
def main() -> None:
"""CLI 진입."""
parser = argparse.ArgumentParser(description="04 GT+EV 매칭 파이프라인")
parser.add_argument(
"--phase",
choices=("all", "profile", "scan", "label", "select"),
default="all",
)
parser.add_argument("--trades-csv", type=str, default="")
args = parser.parse_args()
csv = Path(args.trades_csv) if args.trades_csv else None
run_matching_pipeline(phase=args.phase, trades_csv=csv)
if __name__ == "__main__":
main()

View File

@@ -1,343 +0,0 @@
"""
규칙 발화 기반 GT 모델 복리 포트폴리오 시뮬.
"""
from __future__ import annotations
from typing import Any
import pandas as pd
from config import (
GT_INITIAL_CASH_KRW,
GT_SIGNAL_CAUSAL,
LIVE_ORDER_KRW,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_summary
from deepcoin.matching.position_sizing import (
attach_gt_model_amounts,
)
def _planned_order_krw(
t: dict[str, Any],
order_krw: float,
sizing_mode: str,
) -> float:
"""
체결 계획 원화: amount_krw 우선 또는 고정.
Args:
t: trade dict.
order_krw: 고정 1회 금액.
sizing_mode: fixed | amount_krw.
Returns:
계획 원화.
"""
ak = t.get("amount_krw")
if sizing_mode == "amount_krw" or (ak is not None and float(ak) > 0):
return float(ak or 0)
return float(order_krw)
def sort_fires_chronological(fires: pd.DataFrame) -> pd.DataFrame:
"""
발화를 시간순 정렬 (일·금액 한도 없음).
Args:
fires: fire_outcomes.
Returns:
정렬된 DataFrame.
"""
if fires.empty:
return fires
return fires.sort_values("dt").copy()
def simulate_fires_compound(
fires: pd.DataFrame,
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""
발화 → GT tier 복리 amount_krw 배분 (allocate_order_amounts_chronological).
Args:
fires: fire_outcomes.
initial_cash: 시작 현금 (이후 체결마다 누적).
fee_rate: 수수료율.
Returns:
(amount_krw 채워진 trade dict, stats).
"""
trades = fires_to_trade_list(
fires,
apply_dynamic_sizing=True,
initial_cash=initial_cash,
fee_rate=fee_rate,
)
n_in = int(len(fires))
n_out = sum(1 for t in trades if float(t.get("amount_krw") or 0) > 0)
return trades, {
"input_fires": n_in,
"executed": n_out,
"skipped": max(n_in - n_out, 0),
}
def select_capped_fires(
fires: pd.DataFrame,
*,
use_dynamic_sizing: bool = True,
) -> pd.DataFrame:
"""
시각순 발화 반환 (레거시명; 일·금액 한도 미적용).
Args:
fires: fire_outcomes.
use_dynamic_sizing: 미사용 (하위 호환).
Returns:
정렬된 발화 DataFrame.
"""
_ = use_dynamic_sizing
return sort_fires_chronological(fires)
def fires_to_trade_list(
fires: pd.DataFrame,
*,
apply_dynamic_sizing: bool = True,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> list[dict[str, Any]]:
"""
발화 → GT 모델 amount_krw가 채워진 trade dict (복리 배분).
Args:
fires: 체결 대상 발화.
apply_dynamic_sizing: True면 GT tier 복리 배분.
initial_cash: 시작 현금 (누적 복리).
fee_rate: 수수료율.
Returns:
dt, action, price, amount_krw 키 dict 리스트.
"""
if fires.empty:
return []
rows: list[dict[str, Any]] = []
for _, r in sort_fires_chronological(fires).iterrows():
rows.append(
{
"dt": str(r["dt"]),
"action": r["side"],
"price": float(r["close"]),
"rule_id": r.get("rule_id", ""),
"forward_ret_pct": float(r.get("forward_ret_pct", 0)),
}
)
if apply_dynamic_sizing:
attach_gt_model_amounts(
rows,
initial_cash=initial_cash,
fee_rate=fee_rate,
)
return rows
def simulate_sized_portfolio(
trades: list[dict[str, Any]],
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
last_price: float | None = None,
fallback_order_krw: float = LIVE_ORDER_KRW,
) -> dict[str, Any]:
"""
trade.amount_krw(GT 모델·복리 배분) 기준 포트폴리오 시뮬 + MDD.
Args:
trades: 시간순 trade dict (amount_krw 권장).
initial_cash: 시작 현금.
fee_rate: 수수료율.
last_price: 미청산 평가 종가.
fallback_order_krw: amount_krw 없을 때 1회 금액.
Returns:
simulate_truth_portfolio와 동일 키 + max_drawdown_pct.
"""
if trades and not any(float(t.get("amount_krw") or 0) > 0 for t in trades):
attach_gt_model_amounts(trades, initial_cash=initial_cash, fee_rate=fee_rate)
result = simulate_portfolio_summary(
trades,
initial_cash=initial_cash,
fee_rate=fee_rate,
last_price=last_price,
use_amount_krw=True,
)
result["sizing_mode"] = (
"gt_model_compound_causal" if GT_SIGNAL_CAUSAL else "gt_model_compound"
)
result["sizing_note"] = (
"전기간 복리·GT tier·총자산×비중, 보유현금 한도; "
+ ("인과적 신호·tier(미래 미사용)" if GT_SIGNAL_CAUSAL else "상한 없음")
)
return result
def simulate_fixed_order_portfolio(
trades: list[dict[str, Any]],
order_krw: float = LIVE_ORDER_KRW,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
last_price: float | None = None,
sizing_mode: str = "fixed",
) -> dict[str, Any]:
"""
포트폴리오 시뮬 (고정 원화 또는 trade.amount_krw).
Args:
trades: 시간순 {dt, action, price, amount_krw?}.
order_krw: sizing_mode=fixed 일 때 1회 금액(원).
initial_cash: 시작 현금.
fee_rate: 수수료율.
last_price: 미청산 평가 종가.
sizing_mode: 'fixed' | 'amount_krw'.
Returns:
simulate_truth_portfolio와 동일 키 구조.
"""
cash = float(initial_cash)
qty = 0.0
total_fees = 0.0
last_trade_price = last_price
order = float(order_krw)
for t in sorted(trades, key=lambda x: x["dt"]):
action = t["action"]
price = float(t["price"])
if price <= 0:
continue
last_trade_price = price
if action == "buy":
planned = _planned_order_krw(t, order, sizing_mode)
amount = min(planned, max(cash / (1.0 + fee_rate), 0.0))
if amount <= 0:
continue
fee = amount * fee_rate
cash -= amount + fee
total_fees += fee
qty += amount / price
elif action == "sell" and qty > 0:
planned = _planned_order_krw(t, order, sizing_mode)
if planned >= qty * price * 0.999:
sell_qty = qty
else:
sell_qty = min(qty, planned / price)
if sell_qty <= 0:
continue
gross = sell_qty * price
fee = gross * fee_rate
cash += gross - fee
total_fees += fee
qty -= sell_qty
if qty < 1e-12:
qty = 0.0
mark_price = float(last_price if last_price is not None else last_trade_price or 0)
holding_value = qty * mark_price
final_asset = cash + holding_value
pnl_krw = final_asset - initial_cash
pnl_pct = pnl_krw / initial_cash * 100.0 if initial_cash else 0.0
return {
"initial_cash_krw": round(initial_cash, 0),
"final_asset_krw": round(final_asset, 0),
"pnl_krw": round(pnl_krw, 0),
"pnl_pct": round(pnl_pct, 2),
"total_fees_krw": round(total_fees, 0),
"cash_krw": round(cash, 0),
"holding_qty": round(qty, 6),
"holding_value_krw": round(holding_value, 0),
"mark_price": round(mark_price, 2),
"fee_rate": fee_rate,
"order_krw": round(order, 0),
"sizing_mode": sizing_mode,
"trade_count": len(trades),
}
def simulate_fixed_order_portfolio_steps(
trades: list[dict[str, Any]],
order_krw: float = LIVE_ORDER_KRW,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
sizing_mode: str = "fixed",
) -> list[dict[str, Any]]:
"""
체결마다 현금·보유·총평가 스냅샷 (GT 테이블용).
Args:
trades: 시간순 trade dict.
order_krw: 1회 체결 원화.
initial_cash: 시작 현금.
fee_rate: 수수료율.
sizing_mode: fixed | amount_krw.
Returns:
step dict 리스트.
"""
cash = float(initial_cash)
qty = 0.0
order = float(order_krw)
steps: list[dict[str, Any]] = []
for t in sorted(trades, key=lambda x: x["dt"]):
action = t["action"]
price = float(t["price"])
if price <= 0:
continue
if action == "buy":
planned = _planned_order_krw(t, order, sizing_mode)
amount = min(planned, max(cash / (1.0 + fee_rate), 0.0))
if amount <= 0:
continue
fee = amount * fee_rate
cash -= amount + fee
qty += amount / price
elif action == "sell" and qty > 0:
planned = _planned_order_krw(t, order, sizing_mode)
if planned >= qty * price * 0.999:
sell_qty = qty
else:
sell_qty = min(qty, planned / price)
if sell_qty <= 0:
continue
gross = sell_qty * price
fee = gross * fee_rate
cash += gross - fee
qty -= sell_qty
if qty < 1e-12:
qty = 0.0
steps.append(
{
"dt": t["dt"],
"action": action,
"price": price,
"rule_id": t.get("rule_id", ""),
"forward_ret_pct": t.get("forward_ret_pct"),
"amount_krw": t.get("amount_krw"),
"cash_krw": round(cash, 0),
"holding_qty": round(qty, 4),
"total_asset_krw": round(cash + qty * price, 0),
}
)
return steps

View File

@@ -1,453 +0,0 @@
"""
총자산 대비 GT 모델 매수율(비중) · 보유 현금 한도 · leg tier 배분.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from config import (
GT_BUY_PCT_LARGE_LEG,
GT_BUY_PCT_SMALL_LEG,
GT_INITIAL_CASH_KRW,
GT_LARGE_LEG_TOP_PCT,
GT_MIN_ORDER_KRW,
MATCH_GT_TOLERANCE_MIN,
TRADING_FEE_RATE,
)
from deepcoin.matching.load_rules import load_matched_rules
from deepcoin.paths import MATCHING_FIRE_OUTCOMES, MATCHING_MATCHED_RULES
_GT_ALLOC_ANALYSIS_CACHE: dict[str, Any] | None = None
def portfolio_totals(
cash: float,
qty: float,
price: float,
) -> tuple[float, float, float]:
"""
총보유자산·코인평가·가용현금(=총자산-평가액)을 계산합니다.
Args:
cash: 현금.
qty: 보유 수량.
price: 평가·체결가.
Returns:
(total_asset_krw, holding_value_krw, cash_krw).
"""
holding = qty * price
total = cash + holding
return total, holding, cash
def optimal_weight_share(weight: float, weight_sum_remaining: float) -> float:
"""
leg 내 남은 매수 비중 대비 이번 체결 최적 매수율(0~1).
Args:
weight: 이번 타점 weight.
weight_sum_remaining: 동일 leg 남은 매수 weight 합.
Returns:
비중 비율.
"""
if weight_sum_remaining > 0:
return weight / weight_sum_remaining
return 1.0
def compute_buy_amount_krw(
cash: float,
qty: float,
price: float,
weight: float,
weight_sum_remaining: float,
*,
asset_pct_scale: float,
min_order_krw: float = GT_MIN_ORDER_KRW,
fee_rate: float = TRADING_FEE_RATE,
ignore_weight_split: bool = False,
) -> float:
"""
목표=총보유자산×(최적 매수율×scale), 체결=min(목표, 보유현금/(1+fee)) 로 매수 원화를 산출합니다.
보유 현금 = 총보유자산 코인평가액(cash 인자).
Args:
cash: 보유 현금(가용 원화).
qty: 보유 수량.
price: 체결가.
weight: 타점 비중.
weight_sum_remaining: leg 내 남은 매수 weight 합.
asset_pct_scale: leg·규칙 티어(대형/소형) 스케일.
min_order_krw: 최소 주문 원화.
fee_rate: 수수료율.
ignore_weight_split: True면 weight 분할 없이 scale만 적용 (conviction 매수).
Returns:
매수 원화(0이면 미체결).
"""
if price <= 0:
return 0.0
total_asset, _, available_cash = portfolio_totals(cash, qty, price)
budget = max(available_cash / (1.0 + fee_rate), 0.0)
if ignore_weight_split:
opt_rate = asset_pct_scale
else:
opt_rate = optimal_weight_share(weight, weight_sum_remaining) * asset_pct_scale
target = total_asset * opt_rate
amount = min(target, budget)
if budget >= min_order_krw and 0 < amount < min_order_krw:
amount = min(min_order_krw, budget)
return round(max(amount, 0.0), 0)
def large_leg_ids_from_past_returns(
leg_returns: dict[int, float],
top_pct: float = GT_LARGE_LEG_TOP_PCT,
) -> set[int]:
"""
이미 청산된 leg의 realized return 상위 n% (인과적 tier).
Args:
leg_returns: leg_id → realized return %.
top_pct: 상위 비율.
Returns:
large leg id set.
"""
if not leg_returns:
return set()
ranked = sorted(leg_returns.items(), key=lambda x: x[1], reverse=True)
n = max(1, int(len(ranked) * top_pct + 0.999999))
return {lid for lid, _ in ranked[:n]}
def top_leg_ids_by_forward_return(
trades: list[dict[str, Any]],
top_pct: float = GT_LARGE_LEG_TOP_PCT,
) -> set[int]:
"""
leg별 최대 forward_return 기준 상위 n% leg_id 집합.
Args:
trades: GT trade dict.
top_pct: 상위 비율(0~1).
Returns:
대형 매수 leg_id set.
"""
leg_ret: dict[int, float] = {}
for t in trades:
if t.get("action") != "sell":
continue
lid = int(t.get("leg_id", 0))
ret = float(t.get("forward_return_pct") or 0.0)
leg_ret[lid] = max(leg_ret.get(lid, 0.0), ret)
if not leg_ret:
return set()
ranked = sorted(leg_ret.items(), key=lambda x: x[1], reverse=True)
n = max(1, int(len(ranked) * top_pct + 0.999999))
return {lid for lid, _ in ranked[:n]}
def leg_asset_pct_scale(leg_id: int, large_legs: set[int]) -> float:
"""
leg 티어에 따른 총자산 대비 매수 스케일.
Args:
leg_id: leg 번호.
large_legs: 상위 leg 집합.
Returns:
GT_BUY_PCT_LARGE_LEG 또는 GT_BUY_PCT_SMALL_LEG.
"""
if leg_id in large_legs:
return float(GT_BUY_PCT_LARGE_LEG)
return float(GT_BUY_PCT_SMALL_LEG)
def _parse_dt(dt: str) -> datetime:
return datetime.fromisoformat(str(dt).replace("Z", "+00:00")[:19])
def nearest_gt_leg_id(
dt: str,
gt_trades: list[dict[str, Any]],
tolerance_min: int = MATCH_GT_TOLERANCE_MIN,
) -> int | None:
"""
시각에 가장 가까운 GT trade의 leg_id (매수 우선).
Args:
dt: 발화 시각.
gt_trades: GT trades.
tolerance_min: 허용 분.
Returns:
leg_id 또는 None.
"""
if not gt_trades:
return None
t0 = _parse_dt(dt)
best_buy: int | None = None
best_buy_min = float(tolerance_min) + 1.0
best_any: int | None = None
best_any_min = float(tolerance_min) + 1.0
for t in gt_trades:
try:
t1 = _parse_dt(t["dt"])
except ValueError:
continue
delta = abs((t0 - t1).total_seconds()) / 60.0
if delta > tolerance_min:
continue
lid = int(t.get("leg_id", 0))
if t.get("action") == "buy" and delta < best_buy_min:
best_buy_min = delta
best_buy = lid
if delta < best_any_min:
best_any_min = delta
best_any = lid
return best_buy if best_buy is not None else best_any
def load_gt_allocation_analysis(
gt_trades: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""
GT amount_krw 분석 캐시 (tier 권장 pct).
Args:
gt_trades: GT trades. None이면 파일 로드.
Returns:
analyze_gt_buy_allocation 결과.
"""
global _GT_ALLOC_ANALYSIS_CACHE
if _GT_ALLOC_ANALYSIS_CACHE is not None:
return _GT_ALLOC_ANALYSIS_CACHE
from deepcoin.ground_truth.gt_allocation_analysis import analyze_gt_buy_allocation
from deepcoin.paths import resolve_ground_truth_file
trades = gt_trades
if trades is None:
p = resolve_ground_truth_file()
if p.is_file():
trades = json.loads(p.read_text(encoding="utf-8")).get("trades") or []
if not trades:
_GT_ALLOC_ANALYSIS_CACHE = {}
return _GT_ALLOC_ANALYSIS_CACHE
chron = sorted(trades, key=lambda x: x["dt"])
if not any(float(t.get("amount_krw") or 0) > 0 for t in chron):
from deepcoin.ground_truth.ground_truth import allocate_gt_order_amounts
allocate_gt_order_amounts(chron)
_GT_ALLOC_ANALYSIS_CACHE = analyze_gt_buy_allocation(chron)
return _GT_ALLOC_ANALYSIS_CACHE
def gt_tier_scale_for_trade(
trade: dict[str, Any],
gt_trades: list[dict[str, Any]],
large_legs: set[int],
*,
analysis: dict[str, Any] | None = None,
) -> float:
"""
GT leg tier 배분 스케일 (분석 권장값 또는 config).
Args:
trade: {dt, leg_id?, action, ...}.
gt_trades: GT trades (leg 매칭).
large_legs: 상위 leg.
analysis: analyze_gt_buy_allocation 결과.
Returns:
pct_large 또는 pct_small.
"""
from deepcoin.ground_truth.gt_allocation_analysis import gt_tier_scale_from_analysis
lid = trade.get("leg_id")
if lid is None:
lid = nearest_gt_leg_id(str(trade["dt"]), gt_trades)
if lid is None:
return float(GT_BUY_PCT_SMALL_LEG)
return gt_tier_scale_from_analysis(int(lid), large_legs, analysis)
def enrich_sim_trades_with_gt_weights(
trades: list[dict[str, Any]],
gt_trades: list[dict[str, Any]],
*,
causal_legs: bool = False,
) -> list[dict[str, Any]]:
"""
규칙 발화에 GT leg_id·매수/매도 weight를 부여합니다.
causal_legs=True: GT leg 매칭 없이 매수~매도 구간 순번 leg_id (인과적).
Args:
trades: {dt, action/side, price, rule_id}.
gt_trades: GT trades (leg 매칭, causal_legs=False 일 때).
causal_legs: 순차 leg_id.
Returns:
leg_id·weight가 채워진 trade dict.
"""
from deepcoin.ground_truth.gt_model import leg_entry_weights, leg_exit_weights
rows = sorted(trades, key=lambda x: x["dt"])
pos = 0
seq_leg = 0
while pos < len(rows):
action = rows[pos].get("action", rows[pos].get("side", ""))
if action != "buy":
if causal_legs:
rows[pos]["leg_id"] = seq_leg
elif "leg_id" not in rows[pos]:
rows[pos]["leg_id"] = nearest_gt_leg_id(rows[pos]["dt"], gt_trades) or 0
rows[pos]["weight"] = float(rows[pos].get("weight", 1.0))
pos += 1
continue
buy_end = pos
while buy_end < len(rows):
a = rows[buy_end].get("action", rows[buy_end].get("side", ""))
if a != "buy":
break
buy_end += 1
buy_slice = rows[pos:buy_end]
sell_slice: list[dict[str, Any]] = []
sell_end = buy_end
while sell_end < len(rows):
a = rows[sell_end].get("action", rows[sell_end].get("side", ""))
if a == "buy":
break
if a == "sell":
sell_slice.append(rows[sell_end])
sell_end += 1
if causal_legs:
leg_id = seq_leg
else:
leg_id = nearest_gt_leg_id(buy_slice[0]["dt"], gt_trades) or 0
prices = [float(t["price"]) for t in buy_slice]
buy_weights = leg_entry_weights(prices)
for t, w in zip(buy_slice, buy_weights):
t["leg_id"] = leg_id
t["weight"] = round(w, 4)
if "action" not in t and "side" in t:
t["action"] = t["side"]
if sell_slice:
sw = leg_exit_weights(len(sell_slice))
for t, w in zip(sell_slice, sw):
t["leg_id"] = leg_id
t["weight"] = round(w, 4)
if "action" not in t and "side" in t:
t["action"] = t["side"]
if causal_legs and sell_slice:
seq_leg += 1
pos = sell_end if sell_slice else buy_end
return rows
def attach_gt_model_amounts(
trades: list[dict[str, Any]],
*,
gt_trades: list[dict[str, Any]] | None = None,
approved_rules: set[str] | None = None,
large_legs: set[int] | None = None,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> list[dict[str, Any]]:
"""
GT 모델 비중 + 공통 배분 엔진으로 amount_krw를 채웁니다.
시뮬·매칭 전용: leg·tier 모두 인과적(과거 청산 leg 수익만). GT 정답 배분은
ground_truth.allocate_gt_order_amounts 를 사용하세요.
Args:
trades: enrich_sim_trades_with_gt_weights 출력 또는 raw fires.
gt_trades: GT trades. None이면 파일 로드.
approved_rules: EV/WF 통과 rule (live scale용).
large_legs: 상위 leg.
initial_cash: 초기 현금.
fee_rate: 수수료율.
Returns:
amount_krw·weight·leg_id가 채워진 trade dict.
"""
from deepcoin.ground_truth.gt_allocation import allocate_order_amounts_chronological
if gt_trades is None:
gt_trades, _, _ = load_sizing_context_from_gt()
enriched = enrich_sim_trades_with_gt_weights(
list(trades),
gt_trades,
causal_legs=True,
)
allocate_order_amounts_chronological(
enriched,
initial_cash=initial_cash,
fee_rate=fee_rate,
large_legs=None,
asset_pct_scale_fn=None,
causal_tier=True,
)
return enriched
def attach_dynamic_buy_amounts(
trades: list[dict[str, Any]],
*,
gt_trades: list[dict[str, Any]] | None = None,
approved_rules: set[str] | None = None,
large_legs: set[int] | None = None,
initial_cash: float = GT_INITIAL_CASH_KRW,
default_weight: float = 1.0,
fee_rate: float = TRADING_FEE_RATE,
) -> list[dict[str, Any]]:
"""
시뮬 발화 trade dict에 amount_krw(GT 모델·보유 현금 한도)를 채웁니다.
attach_gt_model_amounts 별칭.
"""
return attach_gt_model_amounts(
trades,
gt_trades=gt_trades,
approved_rules=approved_rules,
large_legs=large_legs,
initial_cash=initial_cash,
fee_rate=fee_rate,
)
def load_sizing_context_from_gt(
gt_path: Path | None = None,
) -> tuple[list[dict[str, Any]], set[int], set[str]]:
"""
GT JSON에서 trades, 상위 leg, EV/WF 통과 rule을 로드합니다.
Args:
gt_path: ground_truth_trades.json.
Returns:
(gt_trades, large_legs, approved_rules).
"""
from deepcoin.paths import resolve_ground_truth_file
p = gt_path or resolve_ground_truth_file()
trades: list[dict[str, Any]] = []
if p.is_file():
data = json.loads(p.read_text(encoding="utf-8"))
trades = data.get("trades") or []
large = top_leg_ids_by_forward_return(trades)
return trades, large, set()

View File

@@ -1,427 +0,0 @@
"""
04-1: GT 스냅샷(03b)에서 규칙 후보 생성.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from config import (
MATCH_INCLUDE_ATOMIC,
MATCH_INCLUDE_MTF_CROSS,
MATCH_INCLUDE_WIDE_RULES,
MATCH_PROFILE_QUANTILE_HI,
MATCH_PROFILE_QUANTILE_LO,
MATCH_PROFILE_TIGHT_HI,
MATCH_PROFILE_TIGHT_LO,
)
from deepcoin.analysis.general_analysis_config import GENERAL_ANALYSIS_INTERVALS
from deepcoin.analysis.general_analysis_core import interval_tf_prefix
from deepcoin.matching.config import (
ANALYSIS_TRADES_CSV,
BUY_PROFILE_FEATURES,
SELL_PROFILE_FEATURES,
)
from deepcoin.matching.gt_mtf_profile import (
analyze_gt_mtf_profile,
load_selected_features,
)
from deepcoin.paths import (
ANALYSIS_GT_CALIBRATION_JSON,
ANALYSIS_GT_MTF_PROFILE_JSON,
)
def _feature_separation(
buy: pd.DataFrame,
sell: pd.DataFrame,
col: str,
) -> float:
"""
매수·매도 GT 분포 간 분리도(절대 평균차/합동표준편차)를 계산합니다.
Args:
buy: 매수 타점 행.
sell: 매도 타점 행.
col: 컬럼명.
Returns:
분리도(숫자형만, 그 외 0).
"""
if col not in buy.columns or not pd.api.types.is_numeric_dtype(buy[col]):
return 0.0
a = pd.to_numeric(buy[col], errors="coerce").dropna()
b = pd.to_numeric(sell[col], errors="coerce").dropna()
if len(a) < 5 or len(b) < 5:
return 0.0
pooled = np.sqrt((a.var() + b.var()) / 2)
if pooled < 1e-9:
return abs(float(a.mean() - b.mean()))
return abs(float(a.mean() - b.mean())) / pooled
def _condition_from_series(series: pd.Series, side: str) -> dict[str, Any] | None:
"""
한 컬럼의 GT 분포에서 단일 조건을 추출합니다.
Args:
series: 해당 side 타점 값.
side: buy | sell (설명용).
Returns:
조건 dict 또는 None.
"""
col_name = series.name
if series.dtype == object or series.dtype.name == "string":
mode = series.dropna().astype(str).mode()
if mode.empty:
return None
return {"col": col_name, "op": "eq", "value": str(mode.iloc[0])}
s = pd.to_numeric(series, errors="coerce").dropna()
if len(s) < 10:
return None
if set(s.unique()).issubset({0, 1, 0.0, 1.0}):
frac = float(s.mean())
if frac >= 0.55:
return {"col": col_name, "op": "eq_int", "value": 1}
if frac <= 0.45:
return {"col": col_name, "op": "eq_int", "value": 0}
return None
lo = float(s.quantile(MATCH_PROFILE_QUANTILE_LO))
hi = float(s.quantile(MATCH_PROFILE_QUANTILE_HI))
if lo >= hi:
return None
return {"col": col_name, "op": "between", "lo": lo, "hi": hi}
def _condition_tight(series: pd.Series) -> dict[str, Any] | None:
"""
q35~q65 좁은 구간 조건.
Args:
series: GT 부분집합 값.
Returns:
between 조건 또는 None.
"""
s = pd.to_numeric(series, errors="coerce").dropna()
if len(s) < 10:
return None
lo = float(s.quantile(MATCH_PROFILE_TIGHT_LO))
hi = float(s.quantile(MATCH_PROFILE_TIGHT_HI))
if lo >= hi:
return None
return {"col": series.name, "op": "between", "lo": lo, "hi": hi}
def _contrast_conditions(
buy: pd.DataFrame,
sell: pd.DataFrame,
col: str,
side: str,
) -> list[dict[str, Any]]:
"""
매수·매도 GT 분리가 큰 컬럼에 대해 쪽별 타이트 AND 대조 조건.
Args:
buy: 매수 GT.
sell: 매도 GT.
col: 컬럼명.
side: buy | sell.
Returns:
조건 리스트(비어 있을 수 있음).
"""
if col not in buy.columns or not pd.api.types.is_numeric_dtype(buy[col]):
return []
b = pd.to_numeric(buy[col], errors="coerce").dropna()
s = pd.to_numeric(sell[col], errors="coerce").dropna()
if len(b) < 10 or len(s) < 10:
return []
tight = _condition_tight(b if side == "buy" else s)
if tight is None:
return []
conds = [tight]
if side == "buy" and float(b.median()) < float(s.median()):
conds.append({"col": col, "op": "lte", "value": float(s.quantile(0.40))})
elif side == "sell" and float(b.median()) < float(s.median()):
conds.append({"col": col, "op": "gte", "value": float(b.quantile(0.60))})
return conds
def _resolve_profile_features(
trades_csv: Path,
df: pd.DataFrame,
) -> tuple[list[str], list[str], dict[str, Any] | None]:
"""
03c 프로필 JSON 갱신 후 buy/sell 피처 목록 반환.
Args:
trades_csv: 03b CSV 경로.
df: 동일 CSV DataFrame.
Returns:
(buy_features, sell_features, profile_analysis 또는 None).
"""
profile_path = ANALYSIS_GT_MTF_PROFILE_JSON
need_run = not profile_path.is_file()
if not need_run and profile_path.stat().st_mtime < trades_csv.stat().st_mtime:
need_run = True
analysis: dict[str, Any] | None = None
if need_run:
analysis = analyze_gt_mtf_profile(df)
profile_path.parent.mkdir(parents=True, exist_ok=True)
profile_path.write_text(
json.dumps(analysis, ensure_ascii=False, indent=2),
encoding="utf-8",
)
from deepcoin.matching.gt_mtf_profile import write_gt_mtf_profile_html
from deepcoin.paths import ANALYSIS_GT_MTF_PROFILE_HTML
write_gt_mtf_profile_html(analysis, ANALYSIS_GT_MTF_PROFILE_HTML)
print(f"[04-1] 03c GT MTF 프로필 갱신: {profile_path}")
buy_f, sell_f = load_selected_features(profile_path)
if not buy_f:
buy_f = list(BUY_PROFILE_FEATURES)
if not sell_f:
sell_f = list(SELL_PROFILE_FEATURES)
return buy_f, sell_f, analysis
def _mtf_cross_conditions(
buy: pd.DataFrame,
sell: pd.DataFrame,
features: list[str],
side: str,
) -> list[dict[str, Any]]:
"""
각 TF에서 분리도 1위 컬럼 조건을 AND (크로스-TF 복합).
Args:
buy: 매수 GT.
sell: 매도 GT.
features: 후보 컬럼.
side: buy | sell.
Returns:
조건 리스트(2개 이상일 때만 의미).
"""
subset = buy if side == "buy" else sell
conds: list[dict[str, Any]] = []
for iv in GENERAL_ANALYSIS_INTERVALS:
pfx = interval_tf_prefix(iv)
iv_feats = [f for f in features if f.startswith(f"{pfx}_") and f in subset.columns]
if not iv_feats:
continue
best = max(iv_feats, key=lambda c: _feature_separation(buy, sell, c))
cond = _condition_from_series(subset[best], side)
if cond:
conds.append(cond)
return conds
def build_rule_candidates(
trades_csv: Path | None = None,
) -> dict[str, Any]:
"""
03b CSV + 03c MTF 프로필에서 매수·매도별 규칙 후보를 생성합니다.
Args:
trades_csv: general_analysis_trades.csv 경로.
Returns:
rule_candidates 메타·rules 리스트 dict.
"""
path = trades_csv or ANALYSIS_TRADES_CSV
if not path.is_file():
raise FileNotFoundError(f"03b CSV 없음: {path} — scripts/03_analyze_trades.py 먼저 실행")
df = pd.read_csv(path)
buy = df[df["action"] == "buy"].copy()
sell = df[df["action"] == "sell"].copy()
buy_features, sell_features, profile = _resolve_profile_features(path, df)
rules: list[dict[str, Any]] = []
rid = 0
for side, subset, features in (
("buy", buy, buy_features),
("sell", sell, sell_features),
):
skip_cols = {
"ga_align_trend_score", # 분포가 넓어 전구간 발화 과다
}
if MATCH_INCLUDE_ATOMIC:
for feat in features:
if feat not in df.columns or feat in skip_cols:
continue
cond = _condition_from_series(subset[feat], side)
if cond is None:
continue
rules.append(
{
"rule_id": f"{side}_a{rid:03d}_{feat}",
"side": side,
"kind": "atomic",
"conditions": [cond],
"profile_col": feat,
}
)
rid += 1
ranked = sorted(
[f for f in features if f in df.columns],
key=lambda c: _feature_separation(buy, sell, c),
reverse=True,
)
ranked_top = ranked[:5]
compound_conds: list[dict[str, Any]] = []
for feat in ranked_top[:3]:
cond = _condition_from_series(subset[feat], side)
if cond:
compound_conds.append(cond)
if len(compound_conds) >= 2:
rules.append(
{
"rule_id": f"{side}_compound_top3",
"side": side,
"kind": "compound",
"conditions": compound_conds,
"profile_cols": ranked_top[:3],
}
)
tight_conds: list[dict[str, Any]] = []
for feat in ranked_top[:4]:
if feat not in subset.columns:
continue
tc = _condition_tight(subset[feat])
if tc:
tight_conds.append(tc)
if len(tight_conds) >= 2:
rules.append(
{
"rule_id": f"{side}_compound_tight",
"side": side,
"kind": "compound_tight",
"conditions": tight_conds,
}
)
if ranked_top:
c0 = ranked_top[0]
contrast = _contrast_conditions(buy, sell, c0, side)
if len(contrast) >= 2:
rules.append(
{
"rule_id": f"{side}_contrast_{c0}",
"side": side,
"kind": "contrast",
"conditions": contrast,
}
)
if MATCH_INCLUDE_MTF_CROSS:
cross = _mtf_cross_conditions(buy, sell, features, side)
if len(cross) >= 3:
rules.append(
{
"rule_id": f"{side}_mtf_cross_all_tf",
"side": side,
"kind": "mtf_cross",
"conditions": cross,
}
)
if MATCH_INCLUDE_WIDE_RULES:
for feat in ranked_top[:2]:
if feat not in subset.columns:
continue
s = pd.to_numeric(subset[feat], errors="coerce").dropna()
if len(s) < 10:
continue
lo, hi = float(s.quantile(0.10)), float(s.quantile(0.90))
if lo < hi:
rules.append(
{
"rule_id": f"{side}_wide_{feat}",
"side": side,
"kind": "wide",
"conditions": [
{"col": feat, "op": "between", "lo": lo, "hi": hi}
],
}
)
if ANALYSIS_GT_CALIBRATION_JSON.is_file():
cal = json.loads(ANALYSIS_GT_CALIBRATION_JSON.read_text(encoding="utf-8"))
cal_rules = cal.get("calibrated_rules") or []
if cal.get("final", {}).get("targets_met") and cal_rules:
rules = []
for cr in cal_rules:
if "logic" not in cr:
cr["logic"] = "and"
rules.append(cr)
print(f"[04-1] 캘리브레이션 규칙 적용(90% 달성) → {len(rules)}")
else:
seen_ids = {r["rule_id"] for r in rules}
for cr in cal_rules:
if cr.get("rule_id") not in seen_ids:
if "logic" not in cr:
cr["logic"] = "and"
rules.append(cr)
seen_ids.add(cr["rule_id"])
print(f"[04-1] 캘리브레이션 규칙 병합 → 총 {len(rules)}")
from deepcoin.ground_truth.gt_signal_rules import build_gt_model_rules
seen_ids = {r["rule_id"] for r in rules}
for gr in build_gt_model_rules():
if gr["rule_id"] not in seen_ids:
rules.append(gr)
seen_ids.add(gr["rule_id"])
print(f"[04-1] GT 모델 일반화 규칙 추가 → 총 {len(rules)}")
out = {
"source": str(path),
"profile_json": str(ANALYSIS_GT_MTF_PROFILE_JSON),
"calibration_json": str(ANALYSIS_GT_CALIBRATION_JSON),
"buy_profile_features": buy_features[:50],
"sell_profile_features": sell_features[:50],
"buy_gt_count": int(len(buy)),
"sell_gt_count": int(len(sell)),
"rule_count": len(rules),
"rules": rules,
}
print(
f"[04-1] 규칙 후보 {len(rules)}"
f"(매수 GT {len(buy)}, 매도 GT {len(sell)})"
)
return out
def save_rule_candidates(
data: dict[str, Any],
out_path: Path,
) -> Path:
"""
rule_candidates.json 저장.
Args:
data: build_rule_candidates 결과.
out_path: 출력 경로.
Returns:
out_path.
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[04-1] 저장: {out_path}")
return out_path

View File

@@ -1,335 +0,0 @@
"""
규칙 조건 벡터 평가·MTF 스캔 프레임 병합.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
from config import GENERAL_ANALYSIS_INTERVALS, MATCH_PRIMARY_INTERVAL
from deepcoin.analysis.general_analysis_core import interval_tf_prefix
from deepcoin.analysis.general_analysis_pipeline import general_analysis_enrich_bars
from config import (
ALIGN_RSI_CONFLICT_TIMING_HIGH,
ALIGN_RSI_CONFLICT_TIMING_LOW,
ALIGN_RSI_CONFLICT_TREND_HIGH,
ALIGN_RSI_CONFLICT_TREND_LOW,
ALIGN_RSI_OVERBOUGHT,
ALIGN_RSI_OVERSOLD,
TIMING_INTERVALS,
TREND_INTERVALS,
)
from deepcoin.analysis.general_analysis_core import ga_col
def _add_align_columns_vectorized(frame: pd.DataFrame) -> pd.DataFrame:
"""
스캔 프레임에 ga_align_* 컬럼을 벡터 연산으로 추가합니다.
Args:
frame: TF 접두사 컬럼이 포함된 DataFrame.
Returns:
align 컬럼이 추가된 DataFrame.
"""
out = frame.copy()
rsi_oversold = pd.Series(0, index=out.index, dtype=float)
rsi_overbought = pd.Series(0, index=out.index, dtype=float)
n_timing = 0
for iv in TIMING_INTERVALS:
p = interval_tf_prefix(iv)
rk = f"{p}_RSI"
if rk not in out.columns:
continue
n_timing += 1
rsi = pd.to_numeric(out[rk], errors="coerce")
rsi_oversold += (rsi < ALIGN_RSI_OVERSOLD).astype(int)
rsi_overbought += (rsi > ALIGN_RSI_OVERBOUGHT).astype(int)
trend_up = pd.Series(0, index=out.index, dtype=float)
trend_down = pd.Series(0, index=out.index, dtype=float)
n_trend = 0
for iv in TREND_INTERVALS:
p = interval_tf_prefix(iv)
sk = f"{p}_{ga_col('struct_trend')}"
if sk not in out.columns:
continue
n_trend += 1
st = out[sk].astype(str)
trend_up += (st == "up").astype(int)
trend_down += (st == "down").astype(int)
denom_t = max(n_timing, 1)
denom_r = max(n_trend, 1)
out["ga_align_rsi_oversold_tf"] = rsi_oversold
out["ga_align_rsi_overbought_tf"] = rsi_overbought
out["ga_align_trend_up_tf"] = trend_up
out["ga_align_trend_down_tf"] = trend_down
out["ga_align_timing_buy_score"] = (rsi_oversold / denom_t).round(3)
out["ga_align_timing_sell_score"] = (rsi_overbought / denom_t).round(3)
out["ga_align_trend_score"] = ((trend_up - trend_down) / denom_r).round(3)
conflict = pd.Series(0, index=out.index, dtype=int)
m3_rsi = out.get("m3_RSI")
d1_rsi = out.get("d1_RSI")
if m3_rsi is not None and d1_rsi is not None:
m3v = pd.to_numeric(m3_rsi, errors="coerce")
d1v = pd.to_numeric(d1_rsi, errors="coerce")
conflict = (
((m3v < ALIGN_RSI_CONFLICT_TIMING_LOW) & (d1v > ALIGN_RSI_CONFLICT_TREND_HIGH))
| ((m3v > ALIGN_RSI_CONFLICT_TIMING_HIGH) & (d1v < ALIGN_RSI_CONFLICT_TREND_LOW))
).astype(int)
out["ga_align_mtf_conflict"] = conflict
return out
def _scalar_float(val: Any) -> float:
"""Series/ndarray 스칼라를 float로 변환."""
if isinstance(val, pd.Series):
val = val.iloc[0]
return float(val)
def conditions_columns(rules: list[dict[str, Any]]) -> set[str]:
"""
규칙 목록에서 참조하는 컬럼명 집합을 반환합니다.
Args:
rules: rule_candidates 항목 리스트.
Returns:
컬럼명 set.
"""
cols: set[str] = set()
for rule in rules:
for cond in rule.get("conditions", []):
c = cond.get("col")
if c:
cols.add(c)
return cols
def build_mtf_scan_frame(
frames: dict[int, pd.DataFrame],
needed_cols: set[str],
) -> pd.DataFrame:
"""
주간격(m3) 인덱스에 필요 컬럼만 merge_asof로 붙인 스캔용 DataFrame을 만듭니다.
Args:
frames: interval → OHLCV.
needed_cols: 규칙 평가에 필요한 컬럼명.
Returns:
m3 인덱스 wide DataFrame (close 포함).
"""
primary = MATCH_PRIMARY_INTERVAL
raw = frames.get(primary)
if raw is None or raw.empty:
raise RuntimeError(f"주간격 {primary}분 데이터 없음")
n_tf = len(GENERAL_ANALYSIS_INTERVALS)
print(f"[04b] Phase A: {n_tf}TF enrich (스캔용, 주·월봉 포함)...")
enriched: dict[int, pd.DataFrame] = {}
for iv in GENERAL_ANALYSIS_INTERVALS:
r = frames.get(iv)
if r is None or r.empty:
continue
label = interval_tf_prefix(iv)
print(f" enrich {label} ({len(r):,}봉)...")
enriched[iv] = general_analysis_enrich_bars(r, iv, full_context=True)
base = enriched[primary].copy()
if not isinstance(base.index, pd.DatetimeIndex):
base.index = pd.to_datetime(base.index)
base = base.sort_index()
out = pd.DataFrame(index=base.index)
close_col = "close" if "close" in base.columns else "Close"
out["close"] = base[close_col].astype(float)
def _source_col(prefixed: str, prefix: str, ef: pd.DataFrame) -> str | None:
"""m3_RSI → RSI, m60_ga_struct_trend → ga_struct_trend."""
if not prefixed.startswith(f"{prefix}_"):
return None
suffix = prefixed[len(prefix) + 1 :]
if suffix in ef.columns:
return suffix
return None
for iv in GENERAL_ANALYSIS_INTERVALS:
ef = enriched.get(iv)
if ef is None:
continue
p = interval_tf_prefix(iv)
for col in needed_cols:
if col in out.columns or not col.startswith(f"{p}_"):
continue
src = _source_col(col, p, ef)
if src is None:
continue
if iv == primary:
out[col] = ef[src].reindex(out.index)
else:
sub = ef[[src]].copy()
if not isinstance(sub.index, pd.DatetimeIndex):
sub.index = pd.to_datetime(sub.index)
sub = sub.sort_index().rename(columns={src: col})
merged = pd.merge_asof(
out.reset_index(names="_ts"),
sub.reset_index(names="_ts"),
on="_ts",
direction="backward",
).set_index("_ts")
out[col] = merged[col].values
align_needed = [c for c in needed_cols if c.startswith("ga_align_")]
if align_needed:
out = _add_align_columns_vectorized(out)
gt_needed = [c for c in needed_cols if c.startswith("gt_")]
bb_in_rules = "bb_pos" in needed_cols
if gt_needed or bb_in_rules:
ef = enriched[primary]
for src in ("Low", "High", "low", "high", "bb_pos", "Open", "Volume"):
if src in ef.columns and src not in out.columns:
out[src] = ef[src].reindex(out.index)
if "Low" not in out.columns and "low" in out.columns:
out["Low"] = out["low"]
if "High" not in out.columns and "high" in out.columns:
out["High"] = out["high"]
from deepcoin.ground_truth.gt_signal_rules import enrich_scan_frame_gt_signals
# 시뮬·live 스캔: 타점 판단은 항상 인과적 (GT 정답 생성은 ground_truth.py 별도)
out = enrich_scan_frame_gt_signals(out, causal=True)
out = out.loc[:, ~out.columns.duplicated()]
out = out.dropna(subset=["close"])
print(f"[04b] 스캔 프레임: {len(out):,}× {len(out.columns)}")
return out
def _eval_one_condition(
frame: pd.DataFrame,
cond: dict[str, Any],
) -> pd.Series:
"""
단일 조건 boolean Series.
Args:
frame: 평가 대상.
cond: {col, op, ...}.
Returns:
boolean Series.
"""
col = cond.get("col")
if not col or col not in frame.columns:
return pd.Series(False, index=frame.index)
s = frame[col]
op = cond.get("op", "between")
if op == "between":
lo, hi = float(cond["lo"]), float(cond["hi"])
ok = pd.to_numeric(s, errors="coerce")
part = (ok >= lo) & (ok <= hi)
elif op == "gte":
part = pd.to_numeric(s, errors="coerce") >= float(cond["value"])
elif op == "lte":
part = pd.to_numeric(s, errors="coerce") <= float(cond["value"])
elif op == "eq":
val = cond["value"]
if isinstance(val, (int, float)) and pd.api.types.is_numeric_dtype(s):
part = pd.to_numeric(s, errors="coerce") == float(val)
else:
part = s.astype(str) == str(val)
elif op == "eq_int":
part = (
pd.to_numeric(s, errors="coerce").fillna(-999).astype(int)
== int(cond["value"])
)
else:
part = pd.Series(False, index=frame.index)
return part.fillna(False)
def eval_conditions(frame: pd.DataFrame, conditions: list[dict[str, Any]]) -> pd.Series:
"""
단일 규칙의 조건을 모두 AND로 평가합니다.
Args:
frame: 스캔용 DataFrame.
conditions: {col, op, ...} 리스트.
Returns:
boolean Series (인덱스=frame.index).
"""
mask = pd.Series(True, index=frame.index)
for cond in conditions:
mask &= _eval_one_condition(frame, cond)
return mask
def eval_rule_mask(frame: pd.DataFrame, rule: dict[str, Any]) -> pd.Series:
"""
규칙 dict 평가 (logic=and|or).
Args:
frame: 스캔/스냅샷 DataFrame.
rule: conditions, logic 키 포함.
Returns:
boolean Series.
"""
conditions = rule.get("conditions") or []
if not conditions:
return pd.Series(False, index=frame.index)
logic = str(rule.get("logic", "and")).lower()
if logic == "or":
mask = pd.Series(False, index=frame.index)
for cond in conditions:
mask |= _eval_one_condition(frame, cond)
return mask
return eval_conditions(frame, conditions)
def scan_rule_fires(
frame: pd.DataFrame,
rules: list[dict[str, Any]],
) -> pd.DataFrame:
"""
모든 규칙 후보에 대해 발화 시각을 수집합니다.
Args:
frame: build_mtf_scan_frame 결과.
rules: rule_candidates.
Returns:
fire_id, rule_id, side, dt, close 컬럼 DataFrame.
"""
rows: list[dict[str, Any]] = []
fid = 0
for rule in rules:
rid = rule["rule_id"]
side = rule["side"]
mask = eval_rule_mask(frame, rule)
hits = frame.index[mask]
close_s = frame["close"]
if isinstance(close_s, pd.DataFrame):
close_s = close_s.iloc[:, 0]
for ts in hits:
rows.append(
{
"fire_id": fid,
"rule_id": rid,
"side": side,
"dt": ts.strftime("%Y-%m-%d %H:%M:%S"),
"close": _scalar_float(close_s.loc[ts]),
}
)
fid += 1
print(f" 규칙 {rid}: 발화 {len(hits):,}")
if not rows:
return pd.DataFrame(columns=["fire_id", "rule_id", "side", "dt", "close"])
return pd.DataFrame(rows)

View File

@@ -1,362 +0,0 @@
"""
04-4: EV·리스크 필터로 최종 규칙 선별 및 리포트 생성.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from config import (
MATCH_BEST_EFFORT_PER_SIDE,
MATCH_GT_TOLERANCE_MIN,
MATCH_HOLDOUT_RATIO,
MATCH_KIND_PRIORITY,
MATCH_LABEL_MODE,
MATCH_MAX_RULES_PER_SIDE,
MATCH_MAX_VALID_FIRE_RATE,
MATCH_MIN_EV_VALID,
MATCH_MIN_FIRES,
MATCH_MIN_FIRES_HOLDOUT,
MATCH_MIN_PROFIT_FACTOR,
MATCH_MONITOR_MAX_PER_SIDE,
MATCH_TRAIN_RATIO,
)
from deepcoin.ground_truth.ground_truth import load_ground_truth
from deepcoin.paths import resolve_ground_truth_file
def _split_train_valid_holdout(df: pd.DataFrame, dt_col: str = "dt") -> pd.Series:
"""
시계열 3분할: train / valid / holdout(최근 MATCH_HOLDOUT_RATIO).
Args:
df: fire_outcomes.
dt_col: 시각 컬럼.
Returns:
'train' | 'valid' | 'holdout' Series.
"""
ts = pd.to_datetime(df[dt_col])
holdout_start = ts.quantile(1.0 - MATCH_HOLDOUT_RATIO)
in_sample = ts <= holdout_start
cutoff = (
ts[in_sample].quantile(MATCH_TRAIN_RATIO)
if in_sample.any()
else ts.quantile(MATCH_TRAIN_RATIO)
)
split = np.where(
in_sample,
np.where(ts <= cutoff, "train", "valid"),
"holdout",
)
return pd.Series(split, index=df.index)
def _kind_rank(kind: str) -> int:
"""kind 우선순위 (작을수록 우선)."""
try:
return MATCH_KIND_PRIORITY.index(kind)
except ValueError:
return len(MATCH_KIND_PRIORITY)
def _rule_metrics(sub: pd.DataFrame) -> dict[str, float | int]:
"""
규칙·구간별 집계 지표.
Args:
sub: fire_outcomes 부분집합.
Returns:
count, ev, win_rate, profit_factor.
"""
if sub.empty:
return {"count": 0, "ev_pct": 0.0, "win_rate": 0.0, "profit_factor": 0.0}
r = sub["forward_ret_pct"]
wins = r[r > 0]
losses = r[r <= 0]
pf = (
float(wins.sum() / abs(losses.sum()))
if len(losses) and losses.sum() != 0
else float(wins.sum()) if len(wins) else 0.0
)
return {
"count": int(len(sub)),
"ev_pct": round(float(r.mean()), 4),
"win_rate": round(float((r > 0).mean()), 4),
"profit_factor": round(pf, 4),
}
def gt_overlap_report(
fires: pd.DataFrame,
gt_trades: list[dict[str, Any]],
tolerance_min: int = MATCH_GT_TOLERANCE_MIN,
) -> dict[str, Any]:
"""
GT 타점이 규칙 발화와 ±tolerance 내 겹치는 비율을 계산합니다.
Args:
fires: rule_fires.
gt_trades: ground truth trades.
tolerance_min: 분 단위 허용.
Returns:
side별 recall dict.
"""
tol = pd.Timedelta(minutes=tolerance_min)
report: dict[str, Any] = {}
for side in ("buy", "sell"):
gt_side = [t for t in gt_trades if t.get("action") == side]
f_side = fires[fires["side"] == side] if not fires.empty else pd.DataFrame()
if not gt_side or f_side.empty:
report[side] = {"gt_count": len(gt_side), "matched": 0, "recall": 0.0}
continue
fire_ts = pd.to_datetime(f_side["dt"]).sort_values()
matched = 0
for t in gt_side:
gts = pd.Timestamp(t["dt"])
delta = (fire_ts - gts).abs()
if (delta <= tol).any():
matched += 1
report[side] = {
"gt_count": len(gt_side),
"matched": matched,
"recall": round(matched / len(gt_side), 4) if gt_side else 0.0,
}
return report
def select_matched_rules(
outcomes: pd.DataFrame,
candidates: dict[str, Any],
gt_path: Path | None = None,
) -> dict[str, Any]:
"""
valid 구간 EV·PF 기준으로 규칙을 선별합니다.
Args:
outcomes: fire_outcomes.
candidates: rule_candidates dict.
gt_path: ground truth JSON.
Returns:
matched_rules + summaries.
"""
if outcomes.empty:
return {"selected": [], "rejected": [], "note": "발화 없음"}
outcomes = outcomes.copy()
outcomes["split"] = _split_train_valid_holdout(outcomes)
valid_dt = pd.to_datetime(outcomes.loc[outcomes["split"] == "valid", "dt"])
valid_bars = max(
int((valid_dt.max() - valid_dt.min()).total_seconds() / 180) + 1, 1
) if len(valid_dt) > 1 else 1
gt_file = gt_path or resolve_ground_truth_file()
gt_data = load_ground_truth(gt_file) or {}
gt_trades = gt_data.get("trades") or []
summaries: list[dict[str, Any]] = []
for rule in candidates.get("rules", []):
rid = rule["rule_id"]
sub = outcomes[outcomes["rule_id"] == rid]
train = sub[sub["split"] == "train"]
valid = sub[sub["split"] == "valid"]
holdout = sub[sub["split"] == "holdout"]
m_all = _rule_metrics(sub)
m_train = _rule_metrics(train)
m_valid = _rule_metrics(valid)
m_holdout = _rule_metrics(holdout)
fire_rate = m_valid["count"] / valid_bars if valid_bars else 1.0
pass_valid = (
m_valid["count"] >= MATCH_MIN_FIRES
and m_valid["ev_pct"] >= MATCH_MIN_EV_VALID
and m_valid["profit_factor"] >= MATCH_MIN_PROFIT_FACTOR
and fire_rate <= MATCH_MAX_VALID_FIRE_RATE
)
pass_holdout = (
m_holdout["count"] >= MATCH_MIN_FIRES_HOLDOUT
and m_holdout["ev_pct"] >= MATCH_MIN_EV_VALID
and m_holdout["profit_factor"] >= MATCH_MIN_PROFIT_FACTOR
)
summaries.append(
{
"rule_id": rid,
"side": rule["side"],
"kind": rule.get("kind", ""),
"conditions": rule["conditions"],
"valid_fire_rate": round(fire_rate, 4),
"metrics": {
"all": m_all,
"train": m_train,
"valid": m_valid,
"holdout": m_holdout,
},
"pass_valid": pass_valid,
"pass_holdout": pass_holdout,
}
)
selected: list[dict[str, Any]] = []
for side in ("buy", "sell"):
pool = [s for s in summaries if s["side"] == side and s["pass_valid"]]
pool.sort(
key=lambda x: (
x["metrics"]["valid"]["ev_pct"],
-_kind_rank(x.get("kind", "")),
),
reverse=True,
)
selected.extend(pool[:MATCH_MAX_RULES_PER_SIDE])
best_effort: list[dict[str, Any]] = []
if not selected:
for side in ("buy", "sell"):
pool = [
s
for s in summaries
if s["side"] == side
and s["metrics"]["valid"]["count"] >= MATCH_MIN_FIRES
and s.get("valid_fire_rate", 1) <= MATCH_MAX_VALID_FIRE_RATE
]
pool.sort(
key=lambda x: (
x["metrics"]["valid"]["ev_pct"],
-_kind_rank(x.get("kind", "")),
),
reverse=True,
)
best_effort.extend(pool[:MATCH_BEST_EFFORT_PER_SIDE])
rejected = [s for s in summaries if s not in selected and s not in best_effort]
overlap = gt_overlap_report(
outcomes[["rule_id", "side", "dt"]].drop_duplicates(),
gt_trades,
)
holdout_passed = [s for s in summaries if s["pass_valid"] and s["pass_holdout"]]
monitor_rules: list[dict[str, Any]] = []
for side in ("buy", "sell"):
pool = [s for s in holdout_passed if s["side"] == side]
pool.sort(
key=lambda x: (
x["metrics"]["holdout"]["ev_pct"],
-_kind_rank(x.get("kind", "")),
),
reverse=True,
)
monitor_rules.extend(pool[:MATCH_MONITOR_MAX_PER_SIDE])
if not monitor_rules:
for side in ("buy", "sell"):
pool = [s for s in selected if s["side"] == side] or [
s for s in best_effort if s["side"] == side
]
pool.sort(
key=lambda x: (
x["metrics"].get("holdout", x["metrics"]["valid"])["ev_pct"],
-_kind_rank(x.get("kind", "")),
),
reverse=True,
)
monitor_rules.extend(pool[:MATCH_MONITOR_MAX_PER_SIDE])
active = selected if selected else best_effort
result = {
"method": "gt_profile_plus_full_bar_ev_filter",
"label_mode": MATCH_LABEL_MODE,
"train_ratio": MATCH_TRAIN_RATIO,
"holdout_ratio": MATCH_HOLDOUT_RATIO,
"criteria": {
"min_fires_valid": MATCH_MIN_FIRES,
"min_fires_holdout": MATCH_MIN_FIRES_HOLDOUT,
"min_ev_valid_pct": MATCH_MIN_EV_VALID,
"min_profit_factor_valid": MATCH_MIN_PROFIT_FACTOR,
"max_valid_fire_rate": MATCH_MAX_VALID_FIRE_RATE,
},
"selected": selected,
"selected_best_effort": best_effort,
"holdout_passed": holdout_passed,
"monitor_rules": monitor_rules,
"active_rules": active,
"strict_pass": len(selected) > 0,
"holdout_pass": len(holdout_passed) > 0,
"rejected_count": len(rejected),
"gt_overlap": overlap,
"valid_bars_approx": valid_bars,
"all_rule_summaries": summaries,
"note": (
"strict EV/PF 통과 규칙 없음 — selected_best_effort는 valid EV 상위(튜닝용)"
if not selected
else ""
),
}
n_out = len(selected) or len(best_effort)
print(
f"[04-4] 선별: strict {len(selected)}개, holdout통과 {len(holdout_passed)}개, "
f"05감시 {len(monitor_rules)}개 / 후보 {len(summaries)}"
)
return result
def write_backtest_summary_html(
matched: dict[str, Any],
out_path: Path,
) -> Path:
"""
backtest_summary.html 생성.
Args:
matched: select_matched_rules 결과.
out_path: HTML 경로.
Returns:
out_path.
"""
rows = []
show = matched.get("monitor_rules") or matched.get("selected") or []
title = "05 monitor_rules (holdout 우선)"
for s in show:
v = s["metrics"]["valid"]
h = s["metrics"].get("holdout", {})
rows.append(
f"<tr><td>{s['rule_id']}</td><td>{s['side']}</td>"
f"<td>{v['count']}</td><td>{v['ev_pct']}</td>"
f"<td>{h.get('count', 0)}</td><td>{h.get('ev_pct', 0)}</td>"
f"<td>{h.get('profit_factor', 0)}</td></tr>"
)
gt = matched.get("gt_overlap", {})
html = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="utf-8"/>
<title>04 Backtest Summary</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ccc; padding: 8px; text-align: left; }}
th {{ background: #e2e8f0; }}
</style></head><body>
<h1>04 매칭 — {title} (valid 구간)</h1>
<p>방법: {matched.get('method','')}</p>
<p>{matched.get('note','')}</p>
<h2>선별 규칙</h2>
<table>
<thead><tr><th>rule_id</th><th>side</th><th>valid_n</th><th>valid_ev</th>
<th>holdout_n</th><th>holdout_ev</th><th>holdout_pf</th></tr></thead>
<tbody>{''.join(rows) if rows else '<tr><td colspan="6">통과 규칙 없음</td></tr>'}</tbody>
</table>
<h2>GT recall (±{MATCH_GT_TOLERANCE_MIN}분, 전체 발화 기준)</h2>
<ul>
<li>매수: {gt.get('buy', {})}</li>
<li>매도: {gt.get('sell', {})}</li>
</ul>
</body></html>"""
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(html, encoding="utf-8")
print(f"[04-4] 리포트: {out_path}")
return out_path

View File

@@ -1,936 +0,0 @@
"""
Simulation: walk-forward·민감도·Go/No-Go·portfolio_compare 리포트.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from config import (
GT_INITIAL_CASH_KRW,
LIVE_ORDER_KRW,
LIVE_SLIPPAGE_PCT,
MATCH_HOLDOUT_RATIO,
MATCH_MIN_EV_VALID,
MATCH_MIN_FIRES_HOLDOUT,
MATCH_MIN_PROFIT_FACTOR,
MATCH_TRAIN_RATIO,
SIM_FEE_STRESS_MULT,
SIM_GO_MIN_HOLDOUT_EV,
SIM_GO_MIN_HOLDOUT_PF,
SIM_GO_WF_POSITIVE_RATIO,
SIM_HYBRID_MAX_MDD_PCT,
SIM_HYBRID_MIN_HOLDOUT_PNL_PCT,
SIM_OPTION_C_MIN_GT_CAPTURE,
SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO,
SIM_OPTION_C_PHASE2_TARGET_PNL_PCT,
SIM_OPTION_C_TARGET_PNL_PCT,
SIM_WALK_FORWARD_MIN_MONTHS,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.ground_truth import (
load_ground_truth,
order_trades_chronological,
)
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_summary
from deepcoin.ground_truth.gt_model import (
default_model,
model_to_dict,
summarize_leg_weights,
weight_policy_summary,
)
from deepcoin.matching.portfolio_sim import (
fires_to_trade_list,
simulate_fixed_order_portfolio,
simulate_sized_portfolio,
sort_fires_chronological,
)
from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout
from deepcoin.paths import resolve_ground_truth_file
from deepcoin.paths import (
ANALYSIS_GT_CALIBRATION_JSON,
MATCHING_FIRE_OUTCOMES,
MATCHING_MATCHED_RULES,
MATCHING_SIMULATION_HTML,
MATCHING_SIMULATION_JSON,
resolve_ground_truth_file,
)
def _fee_adjust_ret(series: pd.Series, mult: float) -> pd.Series:
"""
수수료 스트레스: 왕복 수수료 %p를 (mult-1)배 추가 차감.
Args:
series: forward_ret_pct.
mult: 수수료 배수 (2.0 = 2배).
Returns:
조정된 수익률 %.
"""
extra = TRADING_FEE_RATE * 2 * 100 * (mult - 1.0)
return series - extra
def walk_forward_by_month(outcomes: pd.DataFrame) -> list[dict[str, Any]]:
"""
규칙·월별 EV·PF 집계.
Args:
outcomes: fire_outcomes.
Returns:
월별 행 dict 리스트.
"""
if outcomes.empty:
return []
df = outcomes.copy()
df["ts"] = pd.to_datetime(df["dt"])
df["month"] = df["ts"].dt.to_period("M").astype(str)
rows: list[dict[str, Any]] = []
for (rid, month), grp in df.groupby(["rule_id", "month"]):
m = _rule_metrics(grp)
rows.append(
{
"rule_id": rid,
"side": grp["side"].iloc[0],
"month": month,
**m,
}
)
return rows
def walk_forward_summary(wf_rows: list[dict[str, Any]]) -> dict[str, Any]:
"""
규칙별 월별 EV 양수 비율 요약.
Args:
wf_rows: walk_forward_by_month 결과.
Returns:
rule_id → {positive_ratio, months, ...}.
"""
if not wf_rows:
return {}
df = pd.DataFrame(wf_rows)
out: dict[str, Any] = {}
for rid, grp in df.groupby("rule_id"):
n = len(grp)
pos = int((grp["ev_pct"] > 0).sum())
out[rid] = {
"months": n,
"positive_months": pos,
"positive_ratio": round(pos / n, 4) if n else 0.0,
"mean_ev_pct": round(float(grp["ev_pct"].mean()), 4),
}
return out
def simulate_live_order_cap(
outcomes: pd.DataFrame,
*,
rule_ids: set[str] | None = None,
holdout_only: bool = True,
) -> dict[str, Any]:
"""
GT 복리 배분·슬리피지 가정으로 체결 가능한 발화 집계 (일·금액 한도 없음).
Args:
outcomes: fire_outcomes (split 컬럼 있으면 holdout 필터 가능).
rule_ids: None이면 전 규칙, 지정 시 해당 rule만.
holdout_only: True면 split==holdout 만.
Returns:
규칙별·전체 요약.
"""
if outcomes.empty:
return {"rules": {}, "note": "발화 없음"}
df = outcomes.copy()
if holdout_only and "split" in df.columns:
df = df[df["split"] == "holdout"]
if rule_ids is not None:
df = df[df["rule_id"].isin(rule_ids)]
slip = LIVE_SLIPPAGE_PCT
trades = fires_to_trade_list(sort_fires_chronological(df), apply_dynamic_sizing=True)
executed_dts = {
t["dt"]
for t in trades
if t.get("action") == "sell" or float(t.get("amount_krw") or 0) > 0
}
if not executed_dts:
return {"rules": {}, "taken_count": 0, "total_count": int(len(df))}
taken = df[df["dt"].astype(str).isin(executed_dts)].copy()
taken["adj_ret_pct"] = taken["forward_ret_pct"] - slip
by_rule: dict[str, Any] = {}
for rid, grp in taken.groupby("rule_id"):
g = grp.copy()
g["forward_ret_pct"] = g["adj_ret_pct"]
by_rule[rid] = {
"taken_count": int(len(grp)),
"total_count": int((df["rule_id"] == rid).sum()),
"metrics": _rule_metrics(g),
}
return {
"assumptions": {
"slippage_pct": slip,
"sizing": "gt_model_compound_no_daily_cap",
},
"taken_count": int(len(taken)),
"total_count": int(len(df)),
"rules": by_rule,
"portfolio_adj_ev_pct": round(float(taken["adj_ret_pct"].mean()), 4),
}
def evaluate_go_no_go(
matched: dict[str, Any],
wf_summary: dict[str, Any],
fee_stress: dict[str, Any],
live_cap: dict[str, Any],
) -> dict[str, Any]:
"""
monitor_rules·holdout·walk-forward·수수료 스트레스 기준 Go/No-Go.
Args:
matched: matched_rules.json 내용.
wf_summary: walk_forward_summary.
fee_stress: 규칙별 fee 2x EV.
live_cap: simulate_live_order_cap.
Returns:
go, checks, monitor_rules 판정.
"""
rules = matched.get("monitor_rules") or matched.get("selected") or []
checks: list[dict[str, Any]] = []
all_go = True
for rule in rules:
rid = rule["rule_id"]
h = rule.get("metrics", {}).get("holdout", {})
ev_h = float(h.get("ev_pct", -999))
pf_h = float(h.get("profit_factor", 0))
wf = wf_summary.get(rid, {})
wf_ratio = float(wf.get("positive_ratio", 0))
wf_months = int(wf.get("months", 0))
stress_ev = fee_stress.get(rid, {}).get("ev_pct", -999)
c_holdout = ev_h >= SIM_GO_MIN_HOLDOUT_EV and pf_h >= SIM_GO_MIN_HOLDOUT_PF
c_wf = wf_months >= SIM_WALK_FORWARD_MIN_MONTHS and wf_ratio >= SIM_GO_WF_POSITIVE_RATIO
c_fee = stress_ev >= SIM_GO_MIN_HOLDOUT_EV
ok = c_holdout and c_wf and c_fee
if not ok:
all_go = False
checks.append(
{
"rule_id": rid,
"side": rule.get("side"),
"pass": ok,
"holdout_ev": ev_h,
"holdout_pf": pf_h,
"wf_positive_ratio": wf_ratio,
"fee_stress_ev": stress_ev,
}
)
return {
"go": all_go and len(checks) > 0,
"checks": checks,
"live_cap_taken_ratio": round(
live_cap.get("taken_count", 0) / max(live_cap.get("total_count", 1), 1),
4,
),
}
def portfolio_holdout_from_steps(
steps: list[dict[str, Any]],
holdout_start: pd.Timestamp,
*,
initial_if_empty: float = GT_INITIAL_CASH_KRW,
trade_count: int = 0,
note: str = "",
) -> dict[str, Any]:
"""
포트폴리오 step에서 holdout 구간 자산 증감.
Args:
steps: simulate_portfolio_steps 결과.
holdout_start: holdout 시작 시각.
initial_if_empty: step 없을 때 시작 자산.
trade_count: holdout 발화 수.
note: 설명.
Returns:
holdout pnl 요약 dict.
"""
if not steps:
return {"pnl_pct": 0.0, "note": "steps empty"}
assets = [(pd.to_datetime(s["dt"]), float(s["total_asset_krw"])) for s in steps]
pre = [a for d, a in assets if d < holdout_start]
in_h = [a for d, a in assets if d >= holdout_start]
asset_start = pre[-1] if pre else float(initial_if_empty)
asset_end = in_h[-1] if in_h else assets[-1][1]
ho_pnl_pct = (
(asset_end - asset_start) / asset_start * 100.0 if asset_start > 0 else 0.0
)
return {
"initial_asset_krw": round(asset_start, 0),
"final_asset_krw": round(asset_end, 0),
"pnl_krw": round(asset_end - asset_start, 0),
"pnl_pct": round(ho_pnl_pct, 2),
"trade_count": int(trade_count),
"note": note,
}
def evaluate_hybrid_sizing_go(
base_go: dict[str, Any],
hybrid_full: dict[str, Any],
hybrid_holdout: dict[str, Any],
hybrid_fee_stress: dict[str, Any],
) -> dict[str, Any]:
"""
hybrid DD tier 배분 승격 Go/No-Go (규칙 Go + holdout·MDD·수수료 스트레스).
Args:
base_go: monitor 규칙 evaluate_go_no_go 결과.
hybrid_full: 전기간 hybrid 포트폴리오 요약.
hybrid_holdout: holdout 구간 자산 증감.
hybrid_fee_stress: 수수료 스트레스 hybrid 포트폴리오.
Returns:
go, checks, primary_sizing 권장.
"""
from config import (
SIM_HYBRID_MAX_MDD_PCT,
SIM_HYBRID_MIN_HOLDOUT_PNL_PCT,
SIM_OPTION_C_TARGET_PNL_PCT,
SIM_PRIMARY_SIZING,
)
base_ok = bool(base_go.get("go"))
ho_pnl = float(hybrid_holdout.get("pnl_pct", -999))
full_pnl = float(hybrid_full.get("pnl_pct", 0))
mdd = float(hybrid_full.get("max_drawdown_pct", 999))
stress_pnl = float(hybrid_fee_stress.get("pnl_pct", -999))
c_base = base_ok
c_holdout = ho_pnl >= SIM_HYBRID_MIN_HOLDOUT_PNL_PCT
c_mdd = mdd <= SIM_HYBRID_MAX_MDD_PCT
c_fee = stress_pnl > 0.0
c_target = full_pnl >= SIM_OPTION_C_TARGET_PNL_PCT
all_go = c_base and c_holdout and c_mdd and c_fee
if SIM_PRIMARY_SIZING == "hybrid":
primary = "hybrid"
elif SIM_PRIMARY_SIZING == "causal_tier":
primary = "causal_tier"
else:
primary = "hybrid" if all_go else "causal_tier"
return {
"go": all_go,
"primary_sizing": primary,
"checks": [
{"name": "monitor_rules_go", "pass": c_base},
{"name": "hybrid_holdout_pnl", "pass": c_holdout, "value": ho_pnl},
{"name": "hybrid_max_mdd", "pass": c_mdd, "value": mdd},
{"name": "hybrid_fee_stress_pnl", "pass": c_fee, "value": stress_pnl},
{
"name": "option_c_target_300pct",
"pass": c_target,
"value": full_pnl,
"optional": True,
},
],
}
def simulate_hybrid_order_cap(
outcomes: pd.DataFrame,
ohlc_df: pd.DataFrame,
*,
rule_ids: set[str] | None = None,
holdout_only: bool = True,
fee_rate: float = TRADING_FEE_RATE,
dd_large_pct: float | None = None,
dd_medium_pct: float | None = None,
) -> dict[str, Any]:
"""
hybrid tier 복리 배분·슬리피지 가정 체결 가능 발화 집계.
Args:
outcomes: fire_outcomes.
ohlc_df: 3m OHLC (drawdown).
rule_ids: monitor rule_id 필터.
holdout_only: holdout만.
fee_rate: 수수료율.
Returns:
simulate_live_order_cap과 동일 구조.
"""
from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades
if outcomes.empty:
return {"rules": {}, "note": "발화 없음"}
df = outcomes.copy()
if holdout_only and "split" in df.columns:
df = df[df["split"] == "holdout"]
if rule_ids is not None:
df = df[df["rule_id"].isin(rule_ids)]
slip = LIVE_SLIPPAGE_PCT
sized, _ = build_monitor_hybrid_sized_trades(
sort_fires_chronological(df),
ohlc_df,
enhanced=False,
fee_rate=fee_rate,
dd_large_pct=dd_large_pct,
dd_medium_pct=dd_medium_pct,
)
executed_dts = {
t["dt"]
for t in sized
if t.get("action") == "sell" or float(t.get("amount_krw") or 0) > 0
}
if not executed_dts:
return {"rules": {}, "taken_count": 0, "total_count": int(len(df))}
taken = df[df["dt"].astype(str).isin(executed_dts)].copy()
taken["adj_ret_pct"] = taken["forward_ret_pct"] - slip
by_rule: dict[str, Any] = {}
for rid, grp in taken.groupby("rule_id"):
g = grp.copy()
g["forward_ret_pct"] = g["adj_ret_pct"]
by_rule[rid] = {
"taken_count": int(len(grp)),
"total_count": int((df["rule_id"] == rid).sum()),
"metrics": _rule_metrics(g),
}
return {
"assumptions": {
"slippage_pct": slip,
"sizing": "hybrid_dd_tier_compound",
},
"taken_count": int(len(taken)),
"total_count": int(len(df)),
"rules": by_rule,
"portfolio_adj_ev_pct": round(float(taken["adj_ret_pct"].mean()), 4),
}
def build_simulation_report(
outcomes_path: Path | None = None,
matched_path: Path | None = None,
) -> dict[str, Any]:
"""
시뮬레이션 리포트 dict 생성.
Args:
outcomes_path: fire_outcomes.csv.
matched_path: matched_rules.json.
Returns:
simulation_report 전체 dict.
"""
op = outcomes_path or MATCHING_FIRE_OUTCOMES
mp = matched_path or MATCHING_MATCHED_RULES
if not op.is_file():
raise FileNotFoundError(f"fire_outcomes 없음: {op} — 04_match_rules.py 먼저 실행")
outcomes = pd.read_csv(op)
matched: dict[str, Any] = {}
if mp.is_file():
matched = json.loads(mp.read_text(encoding="utf-8"))
outcomes["split"] = _split_train_valid_holdout(outcomes)
wf_rows = walk_forward_by_month(outcomes)
wf_sum = walk_forward_summary(wf_rows)
fee_stress: dict[str, Any] = {}
for rid in outcomes["rule_id"].unique():
sub = outcomes[outcomes["rule_id"] == rid]
adj = _fee_adjust_ret(sub["forward_ret_pct"], SIM_FEE_STRESS_MULT)
fee_stress[rid] = _rule_metrics(
sub.assign(forward_ret_pct=adj)
)
monitor_ids = {r["rule_id"] for r in matched.get("monitor_rules", [])}
live_cap = simulate_live_order_cap(
outcomes, rule_ids=monitor_ids, holdout_only=True
)
go = evaluate_go_no_go(matched, wf_sum, fee_stress, live_cap)
portfolio_compare: dict[str, Any] = {}
gt_data = load_ground_truth(resolve_ground_truth_file()) or {}
gt_trades = gt_data.get("trades") or []
mark = (gt_data.get("summary") or {}).get("mark_price")
gt_chrono = order_trades_chronological(gt_trades) if gt_trades else []
from deepcoin.ground_truth.gt_signal_rules import gt_signal_rule_ids
from config import GT_SIGNAL_CAUSAL, SIM_CAUSAL_TIER
from deepcoin.matching.position_sizing import load_gt_allocation_analysis
gt_alloc_analysis = load_gt_allocation_analysis(gt_trades) if gt_trades else {}
if gt_chrono:
if not any(float(t.get("amount_krw") or 0) > 0 for t in gt_chrono):
from deepcoin.ground_truth.ground_truth import allocate_gt_order_amounts
allocate_gt_order_amounts(gt_chrono)
portfolio_compare["ground_truth_chrono"] = simulate_portfolio_summary(
gt_chrono,
last_price=float(mark) if mark else None,
use_amount_krw=True,
)
# 전기간 monitor 규칙 — GT_INITIAL_CASH_KRW에서 복리 (holdout만 X)
all_monitor = outcomes[outcomes["rule_id"].isin(monitor_ids)]
if not all_monitor.empty:
sim_trades_full = fires_to_trade_list(sort_fires_chronological(all_monitor))
portfolio_compare["sim_sized"] = simulate_sized_portfolio(
sim_trades_full,
last_price=float(mark) if mark else None,
)
portfolio_compare["sim_fixed_order"] = simulate_fixed_order_portfolio(
fires_to_trade_list(all_monitor, apply_dynamic_sizing=False),
last_price=float(mark) if mark else None,
)
# GT 모델 일반화 규칙 (ZigZag+BB 매수 / ZigZag 고점 매도)
gt_buy_rule = "gt_model_buy_zigzag_bb"
gt_sell_rule = "gt_model_sell_zigzag_peak"
gt_pair_ids = {gt_buy_rule, gt_sell_rule}
if gt_pair_ids.issubset(set(outcomes["rule_id"].unique())):
gt_pair_fires = outcomes[outcomes["rule_id"].isin(gt_pair_ids)]
gt_pair_trades = fires_to_trade_list(sort_fires_chronological(gt_pair_fires))
portfolio_compare["sim_gt_model"] = simulate_sized_portfolio(
gt_pair_trades,
last_price=float(mark) if mark else None,
)
# 인과 GT leg 엔진 (split_buy + peak_sell, 캘리브레이션 파라미터)
cg_df = None
try:
from config import CHART_LOOKBACK_DAYS, MATCH_PRIMARY_INTERVAL, SYMBOL
from deepcoin.data.mtf_bb import load_frames_from_db
from deepcoin.ground_truth.causal_gt_calibrate import load_causal_gt_params
from deepcoin.ground_truth.causal_gt_trades import simulate_causal_gt_portfolio
from deepcoin.ops.monitor import Monitor
cg_params = load_causal_gt_params()
mon_cg = Monitor(cooldown_file=None)
cg_frames = load_frames_from_db(mon_cg, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
cg_df = cg_frames[MATCH_PRIMARY_INTERVAL]
portfolio_compare["sim_causal_gt"] = simulate_causal_gt_portfolio(
cg_df,
last_price=float(mark) if mark else None,
**cg_params,
)
# Phase 3: monitor buy + 인과 peak sell + drawdown tier
from deepcoin.ground_truth.causal_gt_hybrid import (
simulate_causal_gt_hybrid_portfolio,
simulate_monitor_tier_enhanced_portfolio,
)
from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params
dd_params = load_hybrid_dd_params()
buy_only = all_monitor[all_monitor["side"] == "buy"]
portfolio_compare["sim_causal_hybrid"] = simulate_causal_gt_hybrid_portfolio(
buy_only,
cg_df,
monitor_fires=all_monitor,
last_price=float(mark) if mark else None,
cg_params=cg_params,
dd_large_pct=dd_params.get("dd_large_pct"),
dd_medium_pct=dd_params.get("dd_medium_pct"),
)
portfolio_compare["hybrid_dd_params"] = dd_params
portfolio_compare["sim_tier_enhanced"] = simulate_monitor_tier_enhanced_portfolio(
all_monitor,
cg_df,
last_price=float(mark) if mark else None,
)
except Exception as exc:
portfolio_compare["sim_causal_gt"] = {
"pnl_pct": 0.0,
"note": f"causal_gt sim skipped: {exc}",
}
portfolio_compare["sim_causal_hybrid"] = {
"pnl_pct": 0.0,
"note": f"causal_hybrid sim skipped: {exc}",
}
portfolio_compare["sim_tier_enhanced"] = {
"pnl_pct": 0.0,
"note": f"tier_enhanced sim skipped: {exc}",
}
holdout = outcomes[
outcomes["rule_id"].isin(monitor_ids) & (outcomes["split"] == "holdout")
]
if not holdout.empty and not all_monitor.empty:
full_trades = fires_to_trade_list(sort_fires_chronological(all_monitor))
if full_trades:
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_steps
steps = simulate_portfolio_steps(full_trades, use_amount_krw=True)
if steps:
outcomes_ts = outcomes.copy()
outcomes_ts["ts"] = pd.to_datetime(outcomes_ts["dt"])
h0 = outcomes_ts["ts"].quantile(1.0 - MATCH_HOLDOUT_RATIO)
portfolio_compare["sim_sized_holdout"] = portfolio_holdout_from_steps(
steps,
h0,
trade_count=int(len(holdout)),
note="전기간 복리(causal tier) 후 holdout 구간 자산 증감",
)
go_hybrid: dict[str, Any] = {"go": False, "note": "hybrid sim unavailable"}
go_option_c_phase2: dict[str, Any] = {"go": False, "note": "phase2 unavailable"}
if (
cg_df is not None
and not all_monitor.empty
and portfolio_compare.get("sim_causal_hybrid", {}).get("sizing_mode")
):
from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_steps
from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params
from deepcoin.matching.option_c_phase2 import (
evaluate_option_c_phase2_go,
simulate_hybrid_slippage_stress,
walk_forward_portfolio_by_month,
walk_forward_portfolio_summary,
)
dd_params = portfolio_compare.get("hybrid_dd_params") or load_hybrid_dd_params()
dd_large = dd_params.get("dd_large_pct")
dd_medium = dd_params.get("dd_medium_pct")
hybrid_full = portfolio_compare["sim_causal_hybrid"]
sized_h, _ = build_monitor_hybrid_sized_trades(
sort_fires_chronological(all_monitor),
cg_df,
enhanced=False,
dd_large_pct=dd_large,
dd_medium_pct=dd_medium,
)
steps_h = simulate_portfolio_steps(sized_h, use_amount_krw=True)
if steps_h and not holdout.empty:
outcomes_ts = outcomes.copy()
outcomes_ts["ts"] = pd.to_datetime(outcomes_ts["dt"])
h0 = outcomes_ts["ts"].quantile(1.0 - MATCH_HOLDOUT_RATIO)
portfolio_compare["sim_hybrid_holdout"] = portfolio_holdout_from_steps(
steps_h,
h0,
trade_count=int(len(holdout)),
note="전기간 복리(hybrid DD tier) 후 holdout 구간 자산 증감",
)
stress_fee = TRADING_FEE_RATE * SIM_FEE_STRESS_MULT
sized_stress, _ = build_monitor_hybrid_sized_trades(
sort_fires_chronological(all_monitor),
cg_df,
enhanced=False,
fee_rate=stress_fee,
dd_large_pct=dd_large,
dd_medium_pct=dd_medium,
)
portfolio_compare["sim_hybrid_fee_stress"] = simulate_portfolio_summary(
sized_stress,
fee_rate=stress_fee,
last_price=float(mark) if mark else None,
use_amount_krw=True,
)
portfolio_compare["sim_hybrid_slippage_stress"] = simulate_hybrid_slippage_stress(
sized_h,
last_price=float(mark) if mark else None,
fee_rate=TRADING_FEE_RATE,
)
wf_rows = walk_forward_portfolio_by_month(steps_h)
wf_port = walk_forward_portfolio_summary(wf_rows)
portfolio_compare["hybrid_portfolio_walk_forward"] = wf_rows
portfolio_compare["hybrid_portfolio_wf_summary"] = wf_port
gt_pnl_for_phase2 = float(
(portfolio_compare.get("ground_truth_chrono") or {}).get("pnl_pct", 0)
)
go_hybrid = evaluate_hybrid_sizing_go(
go,
hybrid_full,
portfolio_compare.get("sim_hybrid_holdout") or {},
portfolio_compare.get("sim_hybrid_fee_stress") or {},
)
go_option_c_phase2 = evaluate_option_c_phase2_go(
go_hybrid,
hybrid_full,
portfolio_compare.get("sim_hybrid_holdout") or {},
portfolio_compare.get("sim_hybrid_fee_stress") or {},
portfolio_compare.get("sim_hybrid_slippage_stress") or {},
wf_port,
gt_pnl_for_phase2,
)
primary = go_hybrid.get("primary_sizing", "causal_tier")
portfolio_compare["primary_sizing"] = primary
if primary == "hybrid":
portfolio_compare["sim_primary"] = {
**hybrid_full,
"sizing_mode": "primary_hybrid_dd_tier",
"sizing_note": (
"권장: monitor + past-leg·drawdown tier (검증 통과, 미래 미사용)"
),
}
live_cap = simulate_hybrid_order_cap(
outcomes,
cg_df,
rule_ids=monitor_ids,
holdout_only=True,
dd_large_pct=dd_large,
dd_medium_pct=dd_medium,
)
else:
portfolio_compare["sim_primary"] = portfolio_compare.get("sim_sized") or {}
if portfolio_compare.get("sim_sized") and portfolio_compare.get("ground_truth_chrono"):
gt_pnl = float(portfolio_compare["ground_truth_chrono"].get("pnl_pct", 0))
sim_pnl = float(portfolio_compare["sim_sized"].get("pnl_pct", 0))
portfolio_compare["gt_capture_ratio"] = round(
sim_pnl / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0,
4,
)
portfolio_compare["gt_pnl_pct"] = gt_pnl
portfolio_compare["sim_sized_pnl_pct"] = sim_pnl
if portfolio_compare.get("sim_gt_model"):
gtp = float(portfolio_compare["sim_gt_model"].get("pnl_pct", 0))
portfolio_compare["gt_model_capture_ratio"] = round(
gtp / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0,
4,
)
if portfolio_compare.get("sim_causal_gt"):
cgp = float(portfolio_compare["sim_causal_gt"].get("pnl_pct", 0))
portfolio_compare["causal_gt_capture_ratio"] = round(
cgp / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0,
4,
)
portfolio_compare["sim_causal_gt_pnl_pct"] = cgp
if portfolio_compare.get("sim_causal_hybrid"):
chp = float(portfolio_compare["sim_causal_hybrid"].get("pnl_pct", 0))
portfolio_compare["causal_hybrid_capture_ratio"] = round(
chp / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0,
4,
)
portfolio_compare["sim_causal_hybrid_pnl_pct"] = chp
if portfolio_compare.get("sim_tier_enhanced"):
tep = float(portfolio_compare["sim_tier_enhanced"].get("pnl_pct", 0))
portfolio_compare["tier_enhanced_capture_ratio"] = round(
tep / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0,
4,
)
portfolio_compare["sim_tier_enhanced_pnl_pct"] = tep
portfolio_compare["causal_gt_params"] = {}
try:
from deepcoin.ground_truth.causal_gt_calibrate import load_causal_gt_params
portfolio_compare["causal_gt_params"] = load_causal_gt_params()
except Exception:
pass
portfolio_compare["gt_allocation_analysis"] = gt_alloc_analysis
portfolio_compare["causal_mode"] = {
"gt_signal_causal": GT_SIGNAL_CAUSAL,
"sim_causal_tier": SIM_CAUSAL_TIER,
"note": "인과적: t 시점까지 데이터만 사용 (운영 정합)",
}
gt_portfolio: dict[str, Any] = {}
if ANALYSIS_GT_CALIBRATION_JSON.is_file():
cal = json.loads(ANALYSIS_GT_CALIBRATION_JSON.read_text(encoding="utf-8"))
gt_portfolio = cal.get("final", {})
summaries = matched.get("all_rule_summaries") or matched.get("monitor_rules") or []
leg_weight_check = summarize_leg_weights(gt_trades) if gt_trades else {}
invalid_legs = [lid for lid, info in leg_weight_check.items() if not info.get("valid", True)]
return {
"label_mode": matched.get("label_mode"),
"train_ratio": MATCH_TRAIN_RATIO,
"holdout_ratio": MATCH_HOLDOUT_RATIO,
"outcomes_rows": int(len(outcomes)),
"walk_forward": wf_rows,
"walk_forward_summary": wf_sum,
"fee_stress_mult": SIM_FEE_STRESS_MULT,
"fee_stress_by_rule": fee_stress,
"live_order_cap_sim": live_cap,
"go_no_go": go,
"go_no_go_hybrid": go_hybrid,
"go_no_go_option_c_phase2": go_option_c_phase2,
"portfolio_compare": portfolio_compare,
"gt_model": gt_data.get("model") or model_to_dict(default_model()),
"gt_weight_policy": weight_policy_summary(default_model()),
"gt_leg_weight_validation": {
"legs": leg_weight_check,
"invalid_leg_ids": invalid_legs,
"all_valid": len(invalid_legs) == 0,
},
"monitor_rules": matched.get("monitor_rules", []),
"gt_portfolio_calibration": gt_portfolio,
"criteria": {
"min_holdout_ev": SIM_GO_MIN_HOLDOUT_EV,
"min_holdout_pf": SIM_GO_MIN_HOLDOUT_PF,
"wf_positive_ratio": SIM_GO_WF_POSITIVE_RATIO,
"wf_min_months": SIM_WALK_FORWARD_MIN_MONTHS,
"hybrid_min_holdout_pnl_pct": SIM_HYBRID_MIN_HOLDOUT_PNL_PCT,
"hybrid_max_mdd_pct": SIM_HYBRID_MAX_MDD_PCT,
"option_c_target_pnl_pct": SIM_OPTION_C_TARGET_PNL_PCT,
"option_c_phase2_target_pnl_pct": SIM_OPTION_C_PHASE2_TARGET_PNL_PCT,
"option_c_phase2_fee_stress_ratio": SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO,
"option_c_min_gt_capture": SIM_OPTION_C_MIN_GT_CAPTURE,
},
}
def write_simulation_html(report: dict[str, Any], out_path: Path) -> Path:
"""
simulation_report.html 저장 (ground_truth 차트 동일 스타일).
Args:
report: build_simulation_report 결과.
out_path: HTML 경로.
Returns:
out_path.
"""
from deepcoin.matching.simulation_html import write_simulation_report_html
return write_simulation_report_html(report, out_path)
def run_simulation_report(
outcomes_path: Path | None = None,
matched_path: Path | None = None,
) -> dict[str, Any]:
"""
시뮬 리포트 생성·저장·요약 출력.
Args:
outcomes_path: fire_outcomes.csv.
matched_path: matched_rules.json.
Returns:
report dict.
"""
report = build_simulation_report(outcomes_path, matched_path)
MATCHING_SIMULATION_JSON.parent.mkdir(parents=True, exist_ok=True)
MATCHING_SIMULATION_JSON.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
write_simulation_html(report, MATCHING_SIMULATION_HTML)
go = report["go_no_go"]["go"]
go_h = report.get("go_no_go_hybrid") or {}
pc_early = report.get("portfolio_compare") or {}
print(f"[시뮬] 저장: {MATCHING_SIMULATION_JSON}")
print(f"[시뮬] 저장: {MATCHING_SIMULATION_HTML}")
print(f"[시뮬] Go/No-Go (규칙): {'GO' if go else 'NO-GO'}")
print(
f"[시뮬] Go/No-Go (hybrid tier): {'GO' if go_h.get('go') else 'NO-GO'} "
f"· primary={pc_early.get('primary_sizing', '-')}"
)
for c in go_h.get("checks", []):
mark = "OK" if c.get("pass") else "NG"
opt = " (optional)" if c.get("optional") else ""
print(f" [hybrid {mark}] {c.get('name')}: {c.get('value', '-')}{opt}")
go_p2 = report.get("go_no_go_option_c_phase2") or {}
print(
f"[시뮬] Option C 2차(+1000%): {'GO' if go_p2.get('go') else 'NO-GO'}"
)
for c in go_p2.get("checks", []):
mark = "OK" if c.get("pass") else "NG"
print(f" [phase2 {mark}] {c.get('name')}: {c.get('value', '-')}")
for c in report["go_no_go"].get("checks", []):
mark = "OK" if c["pass"] else "NG"
print(
f" [{mark}] {c['rule_id']}: holdout EV={c['holdout_ev']} "
f"WF+={c['wf_positive_ratio']} fee2x EV={c['fee_stress_ev']}"
)
cal = report.get("gt_portfolio_calibration") or {}
port = cal.get("portfolio") or {}
pc = report.get("portfolio_compare") or {}
if pc.get("gt_capture_ratio") is not None:
print(
f"[시뮬] GT 대비 sim_sized(전기간 복리): {pc.get('sim_sized_pnl_pct')}% "
f"/ GT {pc.get('gt_pnl_pct')}% "
f"(capture={pc.get('gt_capture_ratio'):.2%})"
)
if pc.get("gt_model_capture_ratio") is not None:
print(
f"[시뮬] GT 대비 sim_gt_model: "
f"{pc.get('sim_gt_model', {}).get('pnl_pct')}% "
f"(capture={pc.get('gt_model_capture_ratio'):.2%})"
)
if pc.get("sim_causal_gt_pnl_pct") is not None:
scg = pc.get("sim_causal_gt") or {}
print(
f"[시뮬] GT 대비 sim_causal_gt(인과 leg): "
f"{pc.get('sim_causal_gt_pnl_pct')}% "
f"(capture={pc.get('causal_gt_capture_ratio', 0):.2%}, "
f"legs={scg.get('leg_count', '-')}, trades={scg.get('trade_count', '-')})"
)
if pc.get("sim_causal_hybrid_pnl_pct") is not None:
sch = pc.get("sim_causal_hybrid") or {}
print(
f"[시뮬] GT 대비 sim_causal_hybrid(monitor+DD tier): "
f"{pc.get('sim_causal_hybrid_pnl_pct')}% "
f"(capture={pc.get('causal_hybrid_capture_ratio', 0):.2%}, "
f"MDD={sch.get('max_drawdown_pct', '-')}%)"
)
if pc.get("sim_primary"):
sp = pc["sim_primary"]
print(
f"[시뮬] 권장 primary ({pc.get('primary_sizing')}): "
f"{sp.get('pnl_pct')}% · MDD={sp.get('max_drawdown_pct', '-')}%"
)
ho_h = pc.get("sim_hybrid_holdout") or {}
if ho_h.get("pnl_pct") is not None:
print(
f"[시뮬] hybrid holdout: {ho_h.get('pnl_pct')}% "
f"({ho_h.get('initial_asset_krw')}{ho_h.get('final_asset_krw')})"
)
if pc.get("sim_tier_enhanced_pnl_pct") is not None:
ste = pc.get("sim_tier_enhanced") or {}
ast = ste.get("alloc_stats") or {}
print(
f"[시뮬] GT 대비 sim_tier_enhanced(conviction tier): "
f"{pc.get('sim_tier_enhanced_pnl_pct')}% "
f"(capture={pc.get('tier_enhanced_capture_ratio', 0):.2%}, "
f"large_buys={ast.get('large_tier_buy_count', '-')}, "
f"avg_buy={ast.get('buy_amount_avg_krw', '-')})"
)
if pc.get("sim_sized", {}).get("max_drawdown_pct") is not None:
print(
f"[시뮬] sim_sized MDD: {pc['sim_sized']['max_drawdown_pct']}% "
f"(GT MDD: {pc.get('ground_truth_chrono', {}).get('max_drawdown_pct')}%)"
)
if port.get("asset_ratio") is not None:
met = cal.get("targets_met", port.get("target_met_90"))
print(
f"[시뮬] GT 총자산 대비 leg subset 비율: {port['asset_ratio']:.2%} "
f"({port.get('legs_covered')}/{port.get('legs_total')} leg) "
f"목표90%={'달성' if met else '미달'}"
)
return report

View File

@@ -1,606 +0,0 @@
"""
1단계 시뮬레이션 HTML — ground_truth 차트와 동일 스타일·타점·수익률·규칙 기준.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pandas as pd
from config import (
CHART_LOOKBACK_DAYS,
COIN_NAME,
GT_INITIAL_CASH_KRW,
LIVE_ORDER_KRW,
SYMBOL,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.ground_truth import (
load_ground_truth,
order_trades_chronological,
simulate_truth_portfolio,
simulate_truth_portfolio_steps,
)
from deepcoin.matching.portfolio_sim import (
fires_to_trade_list,
simulate_fixed_order_portfolio,
simulate_fixed_order_portfolio_steps,
simulate_sized_portfolio,
sort_fires_chronological,
)
from deepcoin.matching.select_rules import _split_train_valid_holdout
from deepcoin.ops.chart_report import (
card_html,
go_no_go_table_html,
market_cards_html,
pnl_cards_html,
rule_criteria_html,
stacked_summary_cards_html,
wrap_chart_report_page,
)
from deepcoin.ops.simulation import build_chart_html, load_chart_frames, _frames_to_mtf
from deepcoin.common.indicators import apply_bar_indicators, get_trend
from deepcoin.paths import (
MATCHING_FIRE_OUTCOMES,
MATCHING_MATCHED_RULES,
MATCHING_SIMULATION_HTML,
resolve_ground_truth_file,
)
def _fires_to_chart_trades(fires: pd.DataFrame) -> list[dict[str, Any]]:
"""
fire_outcomes 행을 차트 마커용 dict 리스트로 변환.
Args:
fires: monitor holdout 발화.
Returns:
build_chart_html sim_trades 인자.
"""
rows: list[dict[str, Any]] = []
for _, r in fires.iterrows():
rows.append(
{
"dt": str(r["dt"]),
"action": r["side"],
"price": float(r["close"]),
"forward_ret_pct": float(r["forward_ret_pct"]),
"rule_id": r["rule_id"],
}
)
return rows
def _sim_fire_table_rows(
fires: pd.DataFrame,
rules_by_id: dict[str, dict],
steps: list[dict[str, Any]],
) -> str:
"""
시뮬 발화 테이블 tbody (GT와 동일하게 총 평가금액 포함).
Args:
fires: holdout 체결 발화.
rules_by_id: rule_id → rule dict.
steps: simulate_fixed_order_portfolio_steps 결과.
Returns:
tr HTML.
"""
if fires.empty:
return "<tr><td colspan='8'>발화 없음</td></tr>"
sorted_fires = fires.sort_values("dt").reset_index(drop=True)
lines: list[str] = []
lines.append(
f"""
<tr class="initial-row">
<td>시작</td><td>-</td><td>-</td><td>-</td><td>-</td>
<td><b>₩{GT_INITIAL_CASH_KRW:,.0f}</b></td>
<td>-</td><td>초기 현금 (1회 ₩{LIVE_ORDER_KRW:,.0f} 가정)</td>
</tr>"""
)
for i in range(len(sorted_fires)):
r = sorted_fires.iloc[i]
side = r["side"]
cls = "buy" if side == "buy" else "sell"
mark = "매수" if side == "buy" else "매도"
ret = float(r["forward_ret_pct"])
ret_s = f" (+{ret:.2f}%)" if ret > 0 else f" ({ret:.2f}%)"
win = "" if int(r.get("win", 0)) else ""
win_cls = "pass" if int(r.get("win", 0)) else "fail"
kind = rules_by_id.get(r["rule_id"], {}).get("kind", "")
step = steps[i] if i < len(steps) else None
if step:
total_s = f"{step['total_asset_krw']:,.0f}"
hold_s = (
f" (현금 ₩{step['cash_krw']:,.0f} + 코인 {step['holding_qty']:,.2f}개)"
)
else:
total_s = "-"
hold_s = ""
lines.append(
f"<tr>"
f"<td>{str(r['dt'])[:16]}</td>"
f'<td class="{cls}">{mark}</td>'
f"<td>{r['rule_id']}</td>"
f"<td>{kind}</td>"
f'<td class="num">₩{float(r["close"]):,.0f}{ret_s}</td>'
f"<td><b>{total_s}</b>{hold_s}</td>"
f'<td class="{win_cls}">{win}</td>'
f"<td>leg_gt 구간 수익</td>"
f"</tr>"
)
return "\n".join(lines)
def _gt_table_rows(trades: list[dict[str, Any]], steps: list[dict[str, Any]]) -> str:
"""
GT 타점 테이블 tbody (ground_truth 차트와 동일).
Args:
trades: ground_truth trades.
steps: simulate_truth_portfolio_steps.
Returns:
tr HTML.
"""
if not trades:
return "<tr><td colspan='6'>타점 없음</td></tr>"
step_key = {
(s["dt"], s["action"], float(s["price"]), float(s["weight"])): s
for s in steps
}
lines: list[str] = []
lines.append(
f"""
<tr class="initial-row">
<td>시작</td><td>-</td><td>-</td><td>-</td>
<td><b>₩{GT_INITIAL_CASH_KRW:,.0f}</b></td>
<td>초기 현금 (보유 0)</td>
</tr>"""
)
for t in sorted(trades, key=lambda x: x["dt"]):
cls = "buy" if t["action"] == "buy" else "sell"
mark = "매수" if t["action"] == "buy" else "매도"
ret = t.get("forward_return_pct")
ret_s = f" (+{ret}%)" if ret is not None else ""
w = float(t.get("weight", 1.0))
key = (t["dt"], t["action"], float(t["price"]), w)
step = step_key.get(key)
if step:
total_s = f"{step['total_asset_krw']:,.0f}"
hold_s = (
f" (현금 ₩{step['cash_krw']:,.0f} + 코인 {step['holding_qty']:,.2f}개)"
)
else:
total_s = "-"
hold_s = ""
lines.append(
f"<tr>"
f"<td>{t['dt'][:16]}</td>"
f'<td class="{cls}">{mark}</td>'
f"<td>{w*100:.0f}%</td>"
f"<td>₩{t['price']:,.0f}{ret_s}</td>"
f"<td><b>{total_s}</b>{hold_s}</td>"
f"<td>{t.get('memo', '')}</td>"
f"</tr>"
)
return "\n".join(lines)
def _summary_cards_html(
close_last: float,
bb_txt: str,
gt_trades: list[dict[str, Any]],
gt_pnl: dict[str, Any],
sim_sized_pnl: dict[str, Any],
sim_fixed_pnl: dict[str, Any],
sim_trade_count: int,
go_flag: bool,
model_note: str = "",
sim_causal_gt_pnl: dict[str, Any] | None = None,
sim_causal_hybrid_pnl: dict[str, Any] | None = None,
sim_tier_enhanced_pnl: dict[str, Any] | None = None,
sim_primary_pnl: dict[str, Any] | None = None,
primary_sizing: str = "",
hybrid_go: bool = False,
phase2_go: bool = False,
) -> str:
"""
ground_truth HTML과 동일 구성의 상단 카드 (GT + 시뮬 2줄).
Args:
close_last: 종가.
bb_txt: BB %B.
gt_trades: GT trades.
gt_pnl: GT 포트폴리오 요약.
sim_sized_pnl: 총자산%·EV/WF·leg 시뮬 요약.
sim_fixed_pnl: 고정 ₩/회 baseline.
sim_trade_count: 체결 가정 발화 수.
go_flag: Go/No-Go.
model_note: GT 모델 한 줄 요약.
sim_causal_gt_pnl: 인과 GT leg 엔진 요약.
sim_causal_hybrid_pnl: monitor buy + 인과 sell 하이브리드.
sim_tier_enhanced_pnl: monitor + conviction tier.
sim_primary_pnl: 검증 통과 권장 배분 경로.
primary_sizing: hybrid | causal_tier.
hybrid_go: hybrid tier Go/No-Go.
Returns:
cards HTML.
"""
go_cls = "go-pass" if go_flag else "go-fail"
gt_sub = (
"저점 분할매수(1/price 비중) · 고점 65/35% 매도 · "
"총자산×비중×leg티어 · 시각순 복리"
)
if model_note:
gt_sub = model_note
gt_cards = market_cards_html(close_last, bb_txt) + pnl_cards_html(
gt_pnl, "정답 GT", len(gt_trades)
)
sim_sized_title = (
"시뮬·GT tier 복리 (전기간, 상한 없음) — "
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span>'
)
sim_fixed_title = f"시뮬·고정 ₩{LIVE_ORDER_KRW:,}/회 (비교)"
primary_block = ""
if sim_primary_pnl and float(sim_primary_pnl.get("pnl_pct") or 0) != 0:
hgo_cls = "go-pass" if hybrid_go else "go-fail"
primary_block = stacked_summary_cards_html(
(
f"권장 primary · {primary_sizing or 'causal_tier'} · "
f'<span class="{hgo_cls}">hybrid {"GO" if hybrid_go else "NO-GO"}</span> · '
f'2차 {"GO" if phase2_go else "NO-GO"}'
),
pnl_cards_html(
sim_primary_pnl,
"Primary",
int(sim_primary_pnl.get("trade_count") or 0),
),
)
causal_block = ""
if sim_causal_gt_pnl and (
float(sim_causal_gt_pnl.get("pnl_pct") or 0) != 0
or sim_causal_gt_pnl.get("trade_count")
):
legs = sim_causal_gt_pnl.get("leg_count", "-")
causal_block += stacked_summary_cards_html(
f"인과 GT leg 엔진 (peak_local·분할매수·causal tier) · leg {legs}",
pnl_cards_html(
sim_causal_gt_pnl,
"인과 GT",
int(sim_causal_gt_pnl.get("trade_count") or 0),
),
)
if sim_causal_hybrid_pnl and (
float(sim_causal_hybrid_pnl.get("pnl_pct") or 0) != 0
or sim_causal_hybrid_pnl.get("trade_count")
):
legs_h = sim_causal_hybrid_pnl.get("leg_count", "-")
causal_block += stacked_summary_cards_html(
f"하이브리드 (monitor + DD tier) · 발화 {sim_causal_hybrid_pnl.get('input_fires', '-')}",
pnl_cards_html(
sim_causal_hybrid_pnl,
"하이브리드",
int(sim_causal_hybrid_pnl.get("trade_count") or 0),
),
)
if sim_tier_enhanced_pnl and (
float(sim_tier_enhanced_pnl.get("pnl_pct") or 0) != 0
or sim_tier_enhanced_pnl.get("trade_count")
):
ast = sim_tier_enhanced_pnl.get("alloc_stats") or {}
causal_block += stacked_summary_cards_html(
f"Enhanced tier (conviction·DD) · large매수 {ast.get('large_tier_buy_count', '-')}",
pnl_cards_html(
sim_tier_enhanced_pnl,
"Enhanced",
int(sim_tier_enhanced_pnl.get("trade_count") or 0),
),
)
return (
'<div class="summary-cards">'
+ stacked_summary_cards_html(gt_sub, gt_cards)
+ stacked_summary_cards_html(
sim_sized_title,
pnl_cards_html(sim_sized_pnl, "시뮬(비율)", sim_trade_count),
)
+ stacked_summary_cards_html(
sim_fixed_title,
pnl_cards_html(sim_fixed_pnl, "시뮬(고정)", sim_trade_count),
)
+ primary_block
+ causal_block
+ "</div>"
)
def build_simulation_page_html(
report: dict[str, Any],
outcomes_path: Path | None = None,
matched_path: Path | None = None,
gt_path: Path | None = None,
close_last: float | None = None,
) -> str:
"""
시뮬 리포트 전체 HTML (차트 + Go/No-Go + 규칙 기준 + 타점 테이블).
Args:
report: build_simulation_report 결과.
outcomes_path: fire_outcomes.csv.
matched_path: matched_rules.json.
gt_path: ground_truth JSON.
close_last: 미청산 평가 종가 (None이면 DB 종가).
Returns:
HTML 문자열.
"""
op = outcomes_path or MATCHING_FIRE_OUTCOMES
mp = matched_path or MATCHING_MATCHED_RULES
matched: dict[str, Any] = {}
if mp.is_file():
matched = json.loads(mp.read_text(encoding="utf-8"))
monitor_rules = matched.get("monitor_rules") or report.get("monitor_rules") or []
monitor_ids = {r["rule_id"] for r in monitor_rules}
rules_by_id = {r["rule_id"]: r for r in monitor_rules}
sim_fires = pd.DataFrame()
holdout_fires = pd.DataFrame()
if op.is_file():
outcomes = pd.read_csv(op)
outcomes["split"] = _split_train_valid_holdout(outcomes)
sim_fires = outcomes[outcomes["rule_id"].isin(monitor_ids)].copy()
holdout_fires = outcomes[
(outcomes["rule_id"].isin(monitor_ids)) & (outcomes["split"] == "holdout")
].copy()
compound_fires = sort_fires_chronological(sim_fires)
gt_data = load_ground_truth(gt_path or resolve_ground_truth_file()) or {}
gt_trades = gt_data.get("trades") or []
gt_summary = gt_data.get("summary") or {}
go = report.get("go_no_go", {})
go_flag = bool(go.get("go"))
go_hybrid = report.get("go_no_go_hybrid") or {}
hybrid_go_flag = bool(go_hybrid.get("go"))
go_phase2 = report.get("go_no_go_option_c_phase2") or {}
phase2_go_flag = bool(go_phase2.get("go"))
pc = report.get("portfolio_compare") or {}
label_mode = report.get("label_mode", "leg_gt")
frames = load_chart_frames()
bb_txt = "-"
trend = "-"
close_val = float(close_last or 0)
if frames is not None:
df_1d, df_1h, df_3m = _frames_to_mtf(frames)
trend = get_trend(df_1d, df_1h)
df_chart = apply_bar_indicators(df_3m)
close_val = float(df_chart["Close"].iloc[-1])
bb_pos = (
float(df_chart["bb_pos"].iloc[-1])
if "bb_pos" in df_chart.columns and pd.notna(df_chart["bb_pos"].iloc[-1])
else None
)
bb_txt = f"{bb_pos:.2f}" if bb_pos is not None else "-"
elif gt_summary.get("mark_price"):
close_val = float(gt_summary["mark_price"])
sim_trades_sized = fires_to_trade_list(compound_fires, apply_dynamic_sizing=True)
sim_trades_fixed = fires_to_trade_list(compound_fires, apply_dynamic_sizing=False)
gt_pnl: dict[str, Any] = {}
if gt_trades:
gt_chron = order_trades_chronological(gt_trades)
gt_pnl = simulate_truth_portfolio(
gt_chron,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=close_val if close_val else None,
)
mark = close_val if close_val else None
sim_sized_pnl = simulate_sized_portfolio(
sim_trades_sized,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=mark,
)
sim_fixed_pnl = simulate_fixed_order_portfolio(
sim_trades_fixed,
order_krw=LIVE_ORDER_KRW,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=mark,
sizing_mode="fixed",
)
sim_steps = simulate_fixed_order_portfolio_steps(
sim_trades_sized,
order_krw=LIVE_ORDER_KRW,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
sizing_mode="amount_krw",
)
gt_steps = (
simulate_truth_portfolio_steps(
order_trades_chronological(gt_trades),
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
)
if gt_trades
else []
)
model = gt_data.get("model") or {}
model_note = (
f"mode={model.get('selection_mode', 'split_buy_peak_sell')} · "
f"매수비중=1/price · 매도=65/35%"
if model
else ""
)
criteria_blocks = "".join(rule_criteria_html(r) for r in monitor_rules)
go_table = go_no_go_table_html(go.get("checks", []), go_flag)
hybrid_checks = go_hybrid.get("checks") or []
if hybrid_checks:
hybrid_rows = "".join(
f"<tr><td>{c.get('name')}</td>"
f"<td>{'PASS' if c.get('pass') else 'FAIL'}</td>"
f"<td>{c.get('value', '-')}</td></tr>"
for c in hybrid_checks
)
hgo_cls = "go-pass" if hybrid_go_flag else "go-fail"
go_table += (
f"<h2>Hybrid tier Go/No-Go · "
f'<span class="{hgo_cls}">{"GO" if hybrid_go_flag else "NO-GO"}</span></h2>'
f"<table><thead><tr><th>검사</th><th>결과</th><th>값</th></tr></thead>"
f"<tbody>{hybrid_rows}</tbody></table>"
)
phase2_checks = go_phase2.get("checks") or []
if phase2_checks:
p2_rows = "".join(
f"<tr><td>{c.get('name')}</td>"
f"<td>{'PASS' if c.get('pass') else 'FAIL'}</td>"
f"<td>{c.get('value', '-')}</td></tr>"
for c in phase2_checks
)
p2_cls = "go-pass" if phase2_go_flag else "go-fail"
go_table += (
f"<h2>Option C 2차 (+1000%) · "
f'<span class="{p2_cls}">{"GO" if phase2_go_flag else "NO-GO"}</span></h2>'
f"<table><thead><tr><th>검사</th><th>결과</th><th>값</th></tr></thead>"
f"<tbody>{p2_rows}</tbody></table>"
)
def _mark_note(price: float) -> str:
if price > 0:
return f" 총보유자산(미청산 포함)은 종가 ₩{price:,.0f} 평가."
return ""
sim_table = f"""
<h2>시뮬 타점 (전기간 {len(sim_fires)}건 → 복리 체결 {len(compound_fires)}건)</h2>
<p class="meta">총자산×GT비중×leg tier·보유현금 한도·전기간 복리(일한도 없음).
가격 열 (+/-) = <b>{label_mode}</b> 구간 수익%.{_mark_note(close_val)}</p>
<div class="table-scroll">
<table>
<thead><tr><th>시각</th><th>구분</th><th>규칙</th><th>유형</th><th>가격</th>
<th>총 평가금액</th><th>승/패</th><th>비고</th></tr></thead>
<tbody>{_sim_fire_table_rows(compound_fires, rules_by_id, sim_steps)}</tbody>
</table>
</div>"""
gt_table = f"""
<h2>정답 타점 (ground_truth)</h2>
<p class="meta">삼각형 크기 = GT 체결 금액. 매수 분할·매도 leg 반영.{_mark_note(close_val)}</p>
<div class="table-scroll">
<table>
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
<tbody>{_gt_table_rows(gt_trades, gt_steps)}</tbody>
</table>
</div>"""
sections = f"""
{go_table}
<h2>매수·매도 판단 기준 (monitor_rules)</h2>
<p class="meta">04 GT 프로필 + 전구간 EV 선별. 조건 <b>모두</b> 충족 시 3분봉 종가에 신호.</p>
{criteria_blocks}
{sim_table}
{gt_table}
"""
note = (
f"1단계 시뮬 · holdout {report.get('holdout_ratio', 0.15)} · "
f"전기간 발화 {len(sim_fires)}건 / holdout {len(holdout_fires)}건. "
"상단 카드: 초기 금액·총보유자산·초기 대비 증감율·수수료."
)
legend = (
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 체결 금액.<br>"
"● <b>시뮬</b> — holdout 발화 (차트). 테이블 = 전기간 GT tier 복리 체결."
)
if frames is not None:
meta_line = (
f"추세(참고): {trend} | 기간: {df_chart.index[0]} ~ {df_chart.index[-1]} "
f"| 봉 {len(df_chart)}"
)
else:
meta_line = (
f"추세·{SYMBOL} | lookback {CHART_LOOKBACK_DAYS}일 | "
f"초기 ₩{GT_INITIAL_CASH_KRW:,.0f}"
)
cards = _summary_cards_html(
close_val,
bb_txt,
gt_trades,
gt_pnl,
sim_sized_pnl,
sim_fixed_pnl,
len(compound_fires),
go_flag,
model_note=model_note,
sim_causal_gt_pnl=pc.get("sim_causal_gt"),
sim_causal_hybrid_pnl=pc.get("sim_causal_hybrid"),
sim_tier_enhanced_pnl=pc.get("sim_tier_enhanced"),
sim_primary_pnl=pc.get("sim_primary"),
primary_sizing=str(pc.get("primary_sizing") or ""),
hybrid_go=hybrid_go_flag,
phase2_go=phase2_go_flag,
)
if frames is not None:
return build_chart_html(
df_chart,
trend,
note=note,
truth_trades=gt_trades,
sim_trades=_fires_to_chart_trades(holdout_fires),
# 차트 마커는 holdout; 카드·테이블은 전기간 GT tier 복리
title_suffix="1단계 시뮬레이션 (monitor · holdout)",
legend_html=legend,
footer_sections=sections,
cards_html=cards,
)
return wrap_chart_report_page(
page_title=f"{SYMBOL} 시뮬레이션",
heading=f"{COIN_NAME} ({SYMBOL}) 1단계 시뮬레이션",
meta_line=meta_line,
note_html=f"<p class='note'>{note}</p>",
legend_html=legend,
cards_html=cards,
chart_html=(
"<p class='note'>차트 데이터 없음 — "
"<code>python scripts/01_download.py</code> 후 재생성.</p>"
),
sections_html=sections,
)
def write_simulation_report_html(
report: dict[str, Any],
out_path: Path,
outcomes_path: Path | None = None,
matched_path: Path | None = None,
) -> Path:
"""
simulation_report.html 저장 (ground_truth 동일 스타일).
Args:
report: build_simulation_report 결과.
out_path: HTML 경로.
outcomes_path: fire_outcomes.csv.
matched_path: matched_rules.json.
Returns:
out_path.
"""
html = build_simulation_page_html(report, outcomes_path, matched_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(html, encoding="utf-8")
return out_path

View File

@@ -1,108 +0,0 @@
"""
05 규칙 알림 텔레그램 메시지 포맷.
"""
from __future__ import annotations
from typing import Any
from config import COIN_NAME, MONITOR_ALERT_KRW_AMOUNT, SYMBOL
def _fmt_krw(value: float) -> str:
"""원화 금액 표시."""
if value >= 100:
return f"{value:,.0f}"
if value >= 1:
return f"{value:,.2f}"
return f"{value:,.4f}"
def _fmt_price(value: float) -> str:
"""코인 단가 표시."""
if value >= 100:
return f"{value:,.0f}"
if value >= 10:
return f"{value:,.2f}"
if value >= 1:
return f"{value:,.3f}"
return f"{value:,.4f}"
def _holding_qty(balances: dict[str, dict[str, float]], symbol: str) -> float:
"""
잔고 dict에서 코인 보유 수량을 반환합니다.
Args:
balances: load_balances_dict() 결과.
symbol: 통화 코드 (예: WLD).
Returns:
보유 수량 (없으면 0).
"""
info = balances.get(symbol) or {}
return float(info.get("balance") or 0.0)
def build_rule_alert_message(
hit: dict[str, Any],
balances: dict[str, dict[str, float]] | None = None,
*,
trade_krw: float | None = None,
trade_qty: float | None = None,
) -> str:
"""
규칙 발화 알림 본문을 만듭니다.
trade_krw·trade_qty가 있으면 실제(모의) 체결 규모를 표시합니다.
없으면 매수는 MONITOR_ALERT_KRW_AMOUNT, 매도는 보유×가격(또는 참고 금액).
Args:
hit: evaluate_live_rules 항목 (side, rule_id, dt, close).
balances: 잔고 dict (모의·실거래).
trade_krw: 체결 원화(모의·실거래 planned/executed).
trade_qty: 체결 수량(매도 시).
Returns:
텔레그램 메시지 문자열.
"""
side = str(hit.get("side", "")).upper()
close = float(hit["close"])
rule_id = hit.get("rule_id", "")
dt = hit.get("dt", "")
qty_basis = ""
if trade_krw is not None and trade_krw > 0:
amount = float(trade_krw)
if trade_qty is not None and trade_qty > 0:
qty = float(trade_qty)
qty_basis = "체결 기준"
elif close > 0:
qty = amount / close
qty_basis = "체결 기준(원화→수량)"
else:
qty = 0.0
qty_basis = "체결 기준"
elif side == "SELL" and balances is not None:
qty = _holding_qty(balances, SYMBOL)
amount = qty * close
qty_basis = "보유 기준(체결 전)"
elif side == "BUY":
amount = float(MONITOR_ALERT_KRW_AMOUNT)
qty = amount / close if close > 0 else 0.0
qty_basis = "참고 매수 규모(알림용)"
else:
amount = float(MONITOR_ALERT_KRW_AMOUNT)
qty = amount / close if close > 0 else 0.0
qty_basis = "참고 규모(잔고 미조회)"
lines = [
f"[DeepCoin {side}] {COIN_NAME}",
f"규칙: {rule_id}",
f"시각: {dt}",
f"가격: {_fmt_price(close)}",
f"수량: {qty:,.4f} {SYMBOL} ({qty_basis})",
f"금액: {_fmt_krw(amount)}",
"※ 알림만 전송, 자동 주문 없음",
]
return "\n".join(lines)

View File

@@ -1,308 +0,0 @@
"""
ground_truth·시뮬 등 차트 HTML 공통 레이아웃·스타일.
"""
from __future__ import annotations
from typing import Any
CHART_REPORT_CSS = """
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f8fafc; }
h1 { font-size: 1.35rem; }
h2 { font-size: 1.1rem; margin-top: 28px; }
.meta { color: #475569; font-size: 0.9rem; }
.note { background: #f1f5f9; border: 1px solid #cbd5e1; padding: 10px; border-radius: 6px;
color: #334155; font-size: 0.9rem; line-height: 1.5; }
.go { font-size: 1.25rem; font-weight: 700; }
.go-pass { color: #16a34a; }
.go-fail { color: #dc2626; }
.cards { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; }
.card span { font-size: 0.75rem; color: #64748b; display: block; }
.card b { font-size: 1.05rem; }
.chart-wrap { background:#fff; border:1px solid #e2e8f0; border-radius:8px; padding:8px; }
.legend-box { font-size:0.85rem; color:#475569; margin-bottom:10px; line-height: 1.6; }
table { width:100%; border-collapse:collapse; background:#fff; font-size:0.85rem; }
th, td { border:1px solid #e2e8f0; padding:8px; text-align:left; }
th { background:#f1f5f9; }
td.buy { color:#16a34a; font-weight:600; }
td.sell { color:#dc2626; font-weight:600; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.criteria { background:#fff; border:1px solid #e2e8f0; border-radius:8px;
padding:12px 16px; margin:12px 0; }
.criteria h3 { margin: 0 0 8px; font-size: 1rem; }
.criteria ul { margin: 6px 0 0 18px; padding: 0; }
.criteria li { margin: 4px 0; color: #334155; }
.criteria .kind { color: #64748b; font-size: 0.85rem; }
.table-scroll { max-height: 480px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; }
.pass { color: #16a34a; font-weight: 600; }
.fail { color: #dc2626; font-weight: 600; }
.summary-cards { margin: 16px 0; }
.summary-cards .cards-row-block { display: block; width: 100%; margin-bottom: 14px; }
.summary-cards .cards-row-block:last-child { margin-bottom: 0; }
.cards-group-title {
font-size: 0.82rem; color: #475569; margin: 0 0 8px; font-weight: 600;
display: block;
}
"""
def initial_change_pct(pnl: dict[str, Any]) -> float:
"""
초기 금액 대비 총보유자산 증감율(%)을 계산합니다.
Args:
pnl: initial_cash_krw, final_asset_krw (또는 pnl_pct) 포함 dict.
Returns:
증감율 %.
"""
if pnl.get("pnl_pct") is not None:
return float(pnl["pnl_pct"])
initial = float(pnl.get("initial_cash_krw") or 0)
final = float(pnl.get("final_asset_krw") or 0)
if initial <= 0:
return 0.0
return (final - initial) / initial * 100.0
def pnl_cards_html(pnl: dict[str, Any], trade_label: str, trade_count: int) -> str:
"""
GT·시뮬 HTML 공통 자산 요약 카드 (총보유자산·초기 대비 증감율).
Args:
pnl: simulate_truth_portfolio 또는 simulate_fixed_order_portfolio 결과.
trade_label: 타점 라벨(예: 정답 타점, 시뮬 체결).
trade_count: 타점 건수.
Returns:
card div HTML 연속 문자열.
"""
if pnl.get("initial_cash_krw") is None:
return card_html(trade_label, f"{trade_count}")
change_pct = initial_change_pct(pnl)
out = card_html(trade_label, f"{trade_count}")
out += card_html("초기 금액", f"{pnl['initial_cash_krw']:,.0f}")
out += card_html("총보유자산", f"{pnl['final_asset_krw']:,.0f}")
out += card_html("초기 대비 증감율", f"{change_pct:+.2f}%")
out += card_html("수수료", f"{pnl['total_fees_krw']:,.0f}")
if pnl.get("holding_qty", 0) > 0:
out += card_html(
"미청산",
f"{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})",
)
return out
def market_cards_html(close_last: float, bb_pos_txt: str) -> str:
"""
종가·BB %B 카드.
Args:
close_last: 종가.
bb_pos_txt: BB %B 표시 문자열.
Returns:
card HTML.
"""
return card_html("종가", f"{close_last:,.2f}") + card_html("BB %B", bb_pos_txt)
def card_html(label: str, value: str) -> str:
"""
요약 카드 HTML 한 칸.
Args:
label: 라벨.
value: 값(HTML 허용).
Returns:
div.card 문자열.
"""
return f'<div class="card"><span>{label}</span><b>{value}</b></div>'
def stacked_summary_cards_html(
title: str,
cards_inner: str,
) -> str:
"""
제목 한 줄 + 카드 flex 한 줄을 세로 블록으로 묶습니다.
Args:
title: cards-group-title 텍스트(HTML 허용).
cards_inner: .cards 안에 넣을 card div 문자열.
Returns:
cards-row-block HTML.
"""
return (
'<div class="cards-row-block">'
f'<p class="cards-group-title">{title}</p>'
f'<div class="cards">{cards_inner}</div>'
"</div>"
)
def wrap_chart_report_page(
page_title: str,
heading: str,
meta_line: str,
note_html: str,
legend_html: str,
cards_html: str,
chart_html: str,
sections_html: str,
) -> str:
"""
Plotly 차트·테이블을 ground_truth와 동일 스타일 페이지로 감쌉니다.
Args:
page_title: document title.
heading: h1.
meta_line: 기간·추세 등.
note_html: 안내 박스.
legend_html: 차트 범례 설명.
cards_html: .cards 내부 HTML 또는 .summary-cards 블록 전체.
chart_html: plotly embed.
sections_html: h2·테이블·criteria 등 본문 하단.
Returns:
전체 HTML 문서.
"""
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>{page_title}</title>
<style>{CHART_REPORT_CSS}</style>
</head>
<body>
<h1>{heading}</h1>
<p class="meta">{meta_line}</p>
{note_html}
<div class="legend-box">{legend_html}</div>
{cards_html if "summary-cards" in cards_html else f'<div class="cards">{cards_html}</div>'}
<div class="chart-wrap">{chart_html}</div>
{sections_html}
</body>
</html>"""
_COL_LABELS: dict[str, str] = {
"m3_bb_pos": "3분 BB %B",
"m15_bb_pos": "15분 BB %B",
"m30_bb_pos": "30분 BB %B",
"m3_RSI": "3분 RSI",
"m15_RSI": "15분 RSI",
"m30_RSI": "30분 RSI",
"m3_stoch_k": "3분 Stoch %K",
"m15_stoch_k": "15분 Stoch %K",
}
def _col_label(col: str) -> str:
"""지표 컬럼 한글 라벨."""
return _COL_LABELS.get(col, col)
def _format_condition(cond: dict[str, Any]) -> str:
"""
규칙 조건 dict를 읽기 쉬운 문자열로 변환.
Args:
cond: {col, op, lo, hi, value}.
Returns:
한 줄 설명.
"""
col = _col_label(str(cond.get("col", "")))
op = cond.get("op", "")
if op == "between":
return f"{col} ∈ [{cond.get('lo'):.4g}, {cond.get('hi'):.4g}]"
if op == "lte":
return f"{col}{cond.get('value'):.4g}"
if op == "gte":
return f"{col}{cond.get('value'):.4g}"
if op == "lt":
return f"{col} < {cond.get('value'):.4g}"
if op == "gt":
return f"{col} > {cond.get('value'):.4g}"
return f"{col} {op} {cond}"
_KIND_LABELS: dict[str, str] = {
"contrast": "대조 — GT 매수·매도 프로필 대비 반대 구간 (m3 중심)",
"compound_tight": "복합 타이트 — GT 프로필 상위 3지표 AND 동시 충족",
"compound": "복합 TOP3 — GT 프로필 상위 3지표 AND",
"atomic": "단일 — GT 프로필 1지표",
"wide": "완화 — 프로필 외곽 구간",
}
def format_rule_kind(kind: str) -> str:
"""규칙 kind 한글 설명."""
return _KIND_LABELS.get(kind, kind)
def rule_criteria_html(rule: dict[str, Any]) -> str:
"""
monitor_rule 1개의 매칭 기준 블록 HTML.
Args:
rule: matched_rules 내 rule dict.
Returns:
.criteria 블록 HTML.
"""
rid = rule.get("rule_id", "")
side = "매수" if rule.get("side") == "buy" else "매도"
kind = rule.get("kind", "")
conds = rule.get("conditions") or []
items = "".join(f"<li>{_format_condition(c)}</li>" for c in conds)
m = rule.get("metrics", {}).get("all", {})
hold = rule.get("metrics", {}).get("holdout", {})
return f"""
<div class="criteria">
<h3>{rid} <span class="kind">({side} · {format_rule_kind(kind)})</span></h3>
<p class="meta">발화 시 3분봉 종가·8TF 지표가 아래를 <b>모두</b> 만족하면 {side} 신호.</p>
<ul>{items or '<li>조건 없음</li>'}</ul>
<p class="meta">전구간 EV {m.get('ev_pct', '-')}%% · holdout EV {hold.get('ev_pct', '-')}%% ·
holdout PF {hold.get('profit_factor', '-')}</p>
</div>"""
def go_no_go_table_html(checks: list[dict[str, Any]], go: bool) -> str:
"""
Go/No-Go 검증 테이블 HTML.
Args:
checks: go_no_go.checks.
go: 종합 판정.
Returns:
section HTML.
"""
flag = "go-pass" if go else "go-fail"
label = "GO" if go else "NO-GO"
rows = []
for c in checks:
mark = "PASS" if c.get("pass") else "FAIL"
cls = "pass" if c.get("pass") else "fail"
rows.append(
f"<tr><td>{c.get('rule_id')}</td><td>{c.get('side')}</td>"
f'<td class="{cls}">{mark}</td>'
f'<td class="num">{c.get("holdout_ev")}</td>'
f'<td class="num">{c.get("holdout_pf")}</td>'
f'<td class="num">{c.get("wf_positive_ratio")}</td>'
f'<td class="num">{c.get("fee_stress_ev")}</td></tr>'
)
body = "\n".join(rows) if rows else "<tr><td colspan='7'>없음</td></tr>"
return f"""
<h2>Go/No-Go (monitor_rules)</h2>
<p class="go {flag}">종합 판정: {label}</p>
<table>
<thead><tr><th>규칙</th><th>side</th><th>판정</th><th>holdout EV%</th>
<th>holdout PF</th><th>WF 양수월</th><th>수수료 2x EV%</th></tr></thead>
<tbody>{body}</tbody>
</table>"""

View File

@@ -1,473 +0,0 @@
"""
시뮬 sim_causal_hybrid 와 동일 체결 엔진 (build_monitor_hybrid_sized_trades).
live(06) plan_live_hit: 발화 이력 → hybrid 배분 → amount_krw·수량 (인과, 현금·보유 제약).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import pandas as pd
from config import (
CHART_LOOKBACK_DAYS,
GT_INITIAL_CASH_KRW,
LIVE_HYBRID_BOOTSTRAP_FIRES,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades
from deepcoin.ground_truth.gt_allocation import resolve_sell_qty
from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.paths import MATCHING_FIRE_OUTCOMES
@dataclass
class SimTradeResult:
"""단일 발화에 대한 시뮬 배분·체결 결과."""
hit: dict[str, Any]
amount_krw: float
sell_qty: float
ok: bool
message: str
leg_id: int | None = None
def bootstrap_monitor_signals_from_outcomes(
*,
end_dt: str | None = None,
lookback_days: int | None = None,
) -> list[dict[str, Any]]:
"""
04 fire_outcomes 에서 monitor 규칙 발화를 로드 (시뮬 all_monitor 와 동일 입력).
Args:
end_dt: 이 시각 이전만 포함 (None=전체).
lookback_days: CHART_LOOKBACK_DAYS 대신 사용할 일수.
Returns:
{dt, rule_id, side, close} 리스트 (시각순).
"""
import pandas as pd
path = MATCHING_FIRE_OUTCOMES
if not path.is_file():
return []
monitor_ids = {r["rule_id"] for r in load_monitor_rules()}
if not monitor_ids:
return []
df = pd.read_csv(path)
if df.empty or "rule_id" not in df.columns:
return []
sub = df[df["rule_id"].isin(monitor_ids)].copy()
if sub.empty:
return []
sub["dt"] = sub["dt"].astype(str)
if end_dt:
sub = sub[sub["dt"] <= str(end_dt)]
if lookback_days is not None and lookback_days > 0:
end_ts = pd.to_datetime(sub["dt"].max()) if end_dt is None else pd.to_datetime(end_dt)
start = end_ts - pd.Timedelta(days=int(lookback_days))
sub = sub[pd.to_datetime(sub["dt"]) >= start]
elif lookback_days is None and CHART_LOOKBACK_DAYS > 0:
end_ts = pd.to_datetime(sub["dt"].max())
start = end_ts - pd.Timedelta(days=int(CHART_LOOKBACK_DAYS))
sub = sub[pd.to_datetime(sub["dt"]) >= start]
rows: list[dict[str, Any]] = []
for _, r in sub.iterrows():
rows.append(
{
"dt": str(r["dt"]),
"rule_id": str(r["rule_id"]),
"side": str(r["side"]),
"close": float(r["close"]),
}
)
return sort_hits_sim_order(rows)
def merge_signal_histories(
*histories: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
발화 이력 병합 (dt+rule_id+side 기준 중복 제거, 시뮬 정렬).
Args:
*histories: 신호 dict 리스트들.
Returns:
병합·정렬된 리스트.
"""
seen: set[tuple[str, str, str]] = set()
merged: list[dict[str, Any]] = []
for hist in histories:
for h in hist:
key = hit_key(h)
if key in seen:
continue
seen.add(key)
merged.append(
{
"dt": key[0],
"rule_id": key[1],
"side": key[2],
"close": float(h["close"]),
}
)
return sort_hits_sim_order(merged)
def build_live_signal_history(
persisted: list[dict[str, Any]] | None = None,
*,
bootstrap_fires: bool | None = None,
) -> list[dict[str, Any]]:
"""
운영 hybrid 이력: fire_outcomes 부트스트랩 + live 저장분 병합.
Args:
persisted: live_signal_history.json signals.
bootstrap_fires: fire_outcomes 부트스트랩 여부. None이면 config.
Returns:
sim_causal_hybrid 입력과 동일 형식의 이력.
"""
use_boot = (
LIVE_HYBRID_BOOTSTRAP_FIRES if bootstrap_fires is None else bootstrap_fires
)
parts: list[list[dict[str, Any]]] = []
if use_boot:
boot = bootstrap_monitor_signals_from_outcomes()
if boot:
parts.append(boot)
if persisted:
parts.append(persisted)
if not parts:
return []
return merge_signal_histories(*parts)
class HybridSimPortfolio:
"""
hybrid 배분 결과를 현금·보유 수량에 적용 (시뮬·live plan 공용, API 미사용).
"""
def __init__(self) -> None:
"""초기 현금만 보유."""
self.cash_krw: float = float(GT_INITIAL_CASH_KRW)
self.qty: float = 0.0
self.qty_by_leg: dict[int, float] = {}
self.sell_leg: int | None = None
self.sell_base_qty: float = 0.0
def apply_buy(self, amount_krw: float, price: float, leg_id: int) -> bool:
"""
매수 체결 (가용 현금·수수료 범위).
Args:
amount_krw: 매수 원화.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if amount_krw <= 0 or price <= 0:
return False
fee = amount_krw * TRADING_FEE_RATE
if self.cash_krw < amount_krw + fee:
return False
self.cash_krw -= amount_krw + fee
bought = amount_krw / price
self.qty += bought
self.qty_by_leg[leg_id] = self.qty_by_leg.get(leg_id, 0.0) + bought
self.sell_leg = None
self.sell_base_qty = 0.0
return True
def apply_sell(self, amount_krw: float, sell_qty: float, price: float, leg_id: int) -> bool:
"""
매도 체결 (보유 수량 필요).
Args:
amount_krw: 매도 원화(총액).
sell_qty: 매도 수량.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if sell_qty <= 0 or amount_krw <= 0:
return False
leg_qty = self.qty_by_leg.get(leg_id, 0.0)
if leg_qty <= 1e-12:
return False
fee = amount_krw * TRADING_FEE_RATE
self.cash_krw += amount_krw - fee
leg_qty -= sell_qty
self.qty_by_leg[leg_id] = max(leg_qty, 0.0)
self.qty = max(self.qty - sell_qty, 0.0)
if self.qty < 1e-12:
self.qty = 0.0
if self.qty_by_leg.get(leg_id, 0.0) <= 1e-12:
self.qty_by_leg.pop(leg_id, None)
self.sell_leg = None
self.sell_base_qty = 0.0
return True
def hit_key(hit: dict[str, Any]) -> tuple[str, str, str]:
"""발화 고유 키 (dt, rule_id, side)."""
return (str(hit["dt"]), str(hit["rule_id"]), str(hit["side"]))
def sort_hits_sim_order(hits: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
시뮬·allocate 순서: 시각순, 동일 시각이면 buy → sell.
Args:
hits: evaluate_live_rules 발화.
Returns:
정렬된 리스트.
"""
side_rank = {"buy": 0, "sell": 1}
def _key(h: dict[str, Any]) -> tuple:
return (str(h["dt"]), side_rank.get(str(h["side"]), 9), str(h["rule_id"]))
return sorted(hits, key=_key)
def _signals_for_hybrid(
signal_history: list[dict[str, Any]],
*,
approved_buy_rules: set[str] | None = None,
) -> list[dict[str, Any]]:
"""
hybrid 배분용 신호 목록.
sim_causal_hybrid 와 동일하려면 approved_buy_rules=None (monitor 전체 발화).
운영에서 추가로 EV/WF 매수만 허용하려면 rule_id 집합을 넘깁니다.
Args:
signal_history: {dt, rule_id, side, close}.
approved_buy_rules: None=필터 없음. set 이면 해당 매수 rule_id 만.
Returns:
시뮬 입력 trade dict 리스트.
"""
out: list[dict[str, Any]] = []
for h in sort_hits_sim_order(signal_history):
side = str(h["side"])
rid = str(h["rule_id"])
if (
side == "buy"
and approved_buy_rules is not None
and rid not in approved_buy_rules
):
continue
out.append(
{
"dt": str(h["dt"]),
"side": side,
"close": float(h["close"]),
"rule_id": rid,
}
)
return out
def size_monitor_signals(
signal_history: list[dict[str, Any]],
ohlc_df: pd.DataFrame,
*,
approved_buy_rules: set[str] | None = None,
) -> list[dict[str, Any]]:
"""
시뮬과 동일 hybrid tier 배분 (amount_krw·weight·leg_id).
Args:
signal_history: 누적 발화.
ohlc_df: 3m OHLC.
approved_buy_rules: 매수 허용 규칙.
Returns:
sized trade dict 리스트 (시각순).
"""
rows = _signals_for_hybrid(signal_history, approved_buy_rules=approved_buy_rules)
if not rows:
return []
fires = pd.DataFrame(rows)
dd = load_hybrid_dd_params()
sized, _stats = build_monitor_hybrid_sized_trades(
fires,
ohlc_df,
enhanced=False,
initial_cash=float(GT_INITIAL_CASH_KRW),
fee_rate=TRADING_FEE_RATE,
dd_large_pct=dd.get("dd_large_pct"),
dd_medium_pct=dd.get("dd_medium_pct"),
)
return sized
def replay_hybrid_signals(
signal_history: list[dict[str, Any]],
ohlc_df: pd.DataFrame,
*,
approved_buy_rules: set[str] | None = None,
) -> tuple[HybridSimPortfolio, dict[tuple[str, str, str], SimTradeResult]]:
"""
신호 이력 전체를 hybrid 배분·체결 규칙으로 재생 (simulate_portfolio_steps 동일).
Args:
signal_history: 누적 발화.
ohlc_df: 3m OHLC.
approved_buy_rules: None=시뮬 동일. set 이면 매수 rule_id 필터.
Returns:
(portfolio, hit_key → SimTradeResult).
"""
sized = size_monitor_signals(
signal_history, ohlc_df, approved_buy_rules=approved_buy_rules
)
portfolio = HybridSimPortfolio()
results: dict[tuple[str, str, str], SimTradeResult] = {}
fee_rate = TRADING_FEE_RATE
current_leg: int | None = None
leg_budget = 0.0
for t in sorted(sized, key=lambda x: x["dt"]):
action = str(t.get("action", t.get("side", "")))
price = float(t["price"])
if price <= 0:
continue
dt = str(t["dt"])
rid = str(t.get("rule_id", ""))
leg_id = int(t.get("leg_id", 0))
weight = float(t.get("weight", 1.0))
hit = {"dt": dt, "rule_id": rid, "side": action, "close": price}
key = hit_key(hit)
if action == "buy":
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
amount = min(
float(ak),
max(portfolio.cash_krw / (1.0 + fee_rate), 0.0),
)
else:
if leg_id != current_leg:
current_leg = leg_id
leg_budget = portfolio.cash_krw
amount = min(
leg_budget * weight,
max(portfolio.cash_krw / (1.0 + fee_rate), 0.0),
)
if amount <= 0:
results[key] = SimTradeResult(
hit, 0.0, 0.0, False, "시뮬 매수 스킵(현금·tier)"
)
continue
fee = amount * fee_rate
portfolio.cash_krw -= amount + fee
bought = amount / price
portfolio.qty += bought
portfolio.qty_by_leg[leg_id] = (
portfolio.qty_by_leg.get(leg_id, 0.0) + bought
)
portfolio.sell_leg = None
portfolio.sell_base_qty = 0.0
results[key] = SimTradeResult(
hit,
amount,
0.0,
True,
f"sim_buy leg={leg_id}{amount:,.0f}",
leg_id=leg_id,
)
continue
if action == "sell" and portfolio.qty > 0:
leg_qty = portfolio.qty_by_leg.get(leg_id, portfolio.qty)
if portfolio.sell_leg != leg_id:
portfolio.sell_leg = leg_id
portfolio.sell_base_qty = leg_qty
sell_qty = resolve_sell_qty(
t, leg_qty, price, portfolio.sell_base_qty, weight
)
if sell_qty <= 0:
results[key] = SimTradeResult(
hit, 0.0, 0.0, False, "시뮬 매도 스킵"
)
continue
gross = sell_qty * price
fee = gross * fee_rate
portfolio.cash_krw += gross - fee
leg_qty -= sell_qty
portfolio.qty_by_leg[leg_id] = max(leg_qty, 0.0)
portfolio.qty = max(portfolio.qty - sell_qty, 0.0)
if portfolio.qty < 1e-12:
portfolio.qty = 0.0
results[key] = SimTradeResult(
hit,
gross,
sell_qty,
True,
f"sim_sell qty={sell_qty:.4f}{gross:,.0f}",
leg_id=leg_id,
)
continue
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "보유 없음")
return portfolio, results
def plan_live_hit(
signal_history: list[dict[str, Any]],
hit: dict[str, Any],
ohlc_df: pd.DataFrame,
*,
approved_buy_rules: set[str] | None = None,
) -> SimTradeResult:
"""
live: 누적 이력 + 신규 발화 1건 — replay 와 동일 sell_qty·amount.
Args:
signal_history: 기존 이력(신규 hit 미포함).
hit: 이번 발화.
ohlc_df: 3m OHLC.
approved_buy_rules: 매수 허용.
Returns:
SimTradeResult.
"""
if ohlc_df is None or getattr(ohlc_df, "empty", True):
return SimTradeResult(hit, 0.0, 0.0, False, "OHLC 없음")
dt, rid, side = hit_key(hit)
hist = list(signal_history)
if not any(
str(s["dt"]) == dt and str(s["rule_id"]) == rid and str(s["side"]) == side
for s in hist
):
hist.append(
{
"dt": dt,
"rule_id": rid,
"side": side,
"close": float(hit["close"]),
}
)
_, results = replay_hybrid_signals(
hist, ohlc_df, approved_buy_rules=approved_buy_rules
)
res = results.get((dt, rid, side))
if res is not None:
return res
return SimTradeResult(hit, 0.0, 0.0, False, "시뮬 배분 없음")

View File

@@ -1,373 +0,0 @@
"""
3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그).
체결 배분: 시뮬 sim_causal_hybrid 와 동일
- fire_outcomes 부트스트랩 + hybrid_sim_execution.plan_live_hit
- enhanced=False, hybrid DD tier, EV/WF 매수 필터
LIVE_TRADING_ENABLED=1 필수.
"""
from __future__ import annotations
import json
import time
from datetime import date, datetime
from typing import Any
from config import (
CHART_LOOKBACK_DAYS,
COIN_NAME,
LIVE_COOLDOWN_MIN,
LIVE_DAILY_KRW_MAX,
LIVE_DAILY_LOSS_LIMIT_KRW,
LIVE_HYBRID_BOOTSTRAP_FIRES,
LIVE_MAX_TRADES_PER_DAY,
LIVE_ORDER_KRW,
LIVE_TRADING_ENABLED,
MATCH_PRIMARY_INTERVAL,
SYMBOL,
TRADING_FEE_RATE,
)
from deepcoin.data.mtf_bb import load_frames_from_db
from deepcoin.matching.live_eval import evaluate_live_rules
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.ops.alert_message import build_rule_alert_message
from deepcoin.ops.hybrid_sim_execution import (
SimTradeResult,
build_live_signal_history,
hit_key,
plan_live_hit,
sort_hits_sim_order,
)
from deepcoin.ops.monitor import Monitor
from deepcoin.ops.portfolio_report import maybe_record_portfolio_snapshot
from deepcoin.paths import (
LIVE_SIGNAL_HISTORY_JSON,
LIVE_TRADES_LOG,
)
class LiveTrader(Monitor):
"""
규칙 발화 시 빗썸 실주문. 배분은 시뮬 sim_causal_hybrid 와 동일 엔진.
"""
def __init__(self) -> None:
"""Monitor 초기화, hybrid 이력·일별 카운터."""
if not LIVE_TRADING_ENABLED:
raise RuntimeError(
"LIVE_TRADING_ENABLED=0 — 실거래만 지원합니다. "
".env 에 LIVE_TRADING_ENABLED=1 설정 후 재기동하세요."
)
super().__init__(cooldown_file=None)
self._rule_last_unix: dict[str, float] = {}
self._day: str = ""
self._day_spent_krw: float = 0.0
self._day_trades: int = 0
self._day_pnl_krw: float = 0.0
self._ohlc_df = None
self._persisted_ops_signals: list[dict[str, Any]] = self._load_persisted_signals()
self._live_signal_history = self._init_signal_history()
self._load_ohlc_df()
def _load_persisted_signals(self) -> list[dict[str, Any]]:
"""live_signal_history.json."""
if not LIVE_SIGNAL_HISTORY_JSON.is_file():
return []
try:
data = json.loads(LIVE_SIGNAL_HISTORY_JSON.read_text(encoding="utf-8"))
return list(data.get("signals") or [])
except (json.JSONDecodeError, OSError):
return []
def _init_signal_history(self) -> list[dict[str, Any]]:
"""
시뮬과 동일 hybrid 입력: fire_outcomes 부트스트랩 + 운영 저장분.
Returns:
병합된 발화 이력.
"""
merged = build_live_signal_history(self._persisted_ops_signals)
n_boot = max(len(merged) - len(self._persisted_ops_signals), 0)
print(
f"[06] hybrid 이력: total={len(merged)} "
f"(bootstrap={'on' if LIVE_HYBRID_BOOTSTRAP_FIRES else 'off'}, "
f"from_fires~{n_boot}, ops_persisted={len(self._persisted_ops_signals)})"
)
return merged
def _save_persisted_ops_signals(self) -> None:
"""운영 체결분만 저장 (fire_outcomes 부트스트랩은 재로드)."""
LIVE_SIGNAL_HISTORY_JSON.parent.mkdir(parents=True, exist_ok=True)
LIVE_SIGNAL_HISTORY_JSON.write_text(
json.dumps(
{"signals": self._persisted_ops_signals[-2000:]},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
def _live_signal_seen(self, hit: dict[str, Any]) -> bool:
"""이력에 동일 봉 발화가 있는지."""
dt, rid, side = hit_key(hit)
return any(
str(s["dt"]) == dt and str(s["rule_id"]) == rid and str(s["side"]) == side
for s in self._live_signal_history
)
def _append_live_signal(self, hit: dict[str, Any]) -> None:
"""체결 성공 발화를 전체 이력·운영 저장분에 추가."""
if self._live_signal_seen(hit):
return
row = {
"dt": str(hit["dt"]),
"rule_id": str(hit["rule_id"]),
"side": str(hit["side"]),
"close": float(hit["close"]),
}
self._live_signal_history.append(row)
if not any(hit_key(s) == hit_key(row) for s in self._persisted_ops_signals):
self._persisted_ops_signals.append(row)
def _reset_day_if_needed(self) -> None:
"""날짜 변경 시 일별 한도 카운터 초기화."""
today = date.today().isoformat()
if today != self._day:
self._day = today
self._day_spent_krw = 0.0
self._day_trades = 0
self._day_pnl_krw = 0.0
def _append_log(self, record: dict[str, Any]) -> None:
"""live_trades.jsonl append."""
LIVE_TRADES_LOG.parent.mkdir(parents=True, exist_ok=True)
with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]:
"""
쿨다운(1봉=3분) + 일한도·손실한도·거래횟수.
Args:
rule_id: 규칙 ID.
planned_krw: 예정 매수 원화.
Returns:
(허용 여부, 거절 사유).
"""
self._reset_day_if_needed()
last = self._rule_last_unix.get(rule_id, 0.0)
if time.time() - last < LIVE_COOLDOWN_MIN * 60:
return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)"
if self._day_trades >= LIVE_MAX_TRADES_PER_DAY:
return False, "일 최대 거래 수 초과"
need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW)
if self._day_spent_krw + need > LIVE_DAILY_KRW_MAX:
return False, "일 주문 한도 초과"
if self._day_pnl_krw <= -abs(LIVE_DAILY_LOSS_LIMIT_KRW):
return False, "일 손실 한도 초과"
return True, ""
def _load_ohlc_df(self) -> None:
"""drawdown tier용 3m OHLC."""
try:
frames = load_frames_from_db(self, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
self._ohlc_df = frames.get(MATCH_PRIMARY_INTERVAL)
except Exception:
self._ohlc_df = None
def _sim_plan(self, hit: dict[str, Any]) -> SimTradeResult:
"""시뮬 hybrid 배분 1건 (누적 이력·인과, 현금·보유 제약)."""
if self._ohlc_df is None:
self._load_ohlc_df()
return plan_live_hit(
list(self._live_signal_history),
hit,
self._ohlc_df,
approved_buy_rules=None,
)
@staticmethod
def _cap_plan_to_exchange(plan: SimTradeResult, hit: dict[str, Any], balances: dict) -> SimTradeResult:
"""
시뮬 planned 금액을 거래소 가용 잔고 이내로 제한.
Args:
plan: hybrid 시뮬 배분 결과.
hit: 발화.
balances: load_balances_dict() 결과.
Returns:
조정된 SimTradeResult.
"""
sym = balances.get(SYMBOL, {})
price = float(hit["close"])
side = hit["side"]
if not plan.ok or plan.amount_krw <= 0:
return plan
if side == "buy":
krw = float(sym.get("krw") or 0)
max_buy = max(krw / (1.0 + TRADING_FEE_RATE) - 1.0, 0.0)
capped = min(float(plan.amount_krw), max_buy)
if capped <= 0:
return SimTradeResult(
plan.hit, 0.0, 0.0, False, "거래소 현금 부족(시뮬 대비)"
)
if capped < plan.amount_krw - 1.0:
return SimTradeResult(
plan.hit,
round(capped, 0),
0.0,
True,
f"sim_buy capped ₩{capped:,.0f} (plan ₩{plan.amount_krw:,.0f})",
leg_id=plan.leg_id,
)
return plan
held = float(sym.get("balance") or 0)
if held <= 0:
return SimTradeResult(plan.hit, 0.0, 0.0, False, "거래소 보유 없음")
sell_qty = min(float(plan.sell_qty), held)
if sell_qty <= 0:
return SimTradeResult(plan.hit, 0.0, 0.0, False, "매도 수량 0")
gross = round(sell_qty * price, 0)
if sell_qty < plan.sell_qty - 1e-8:
return SimTradeResult(
plan.hit,
gross,
sell_qty,
True,
f"sim_sell capped qty={sell_qty:.4f}",
leg_id=plan.leg_id,
)
return plan
def _execute_live_order(
self, hit: dict[str, Any], plan: SimTradeResult, balances: dict
) -> dict[str, Any]:
"""실거래: 시뮬 plan(잔고 cap)으로 API 주문."""
side = hit["side"]
price = float(hit["close"])
plan = self._cap_plan_to_exchange(plan, hit, balances)
record: dict[str, Any] = {
"ts": datetime.now().isoformat(timespec="seconds"),
"rule_id": hit["rule_id"],
"side": side,
"signal_dt": hit["dt"],
"price": price,
"amount_krw": plan.amount_krw,
"live_enabled": True,
"ok": False,
"message": plan.message,
"sizing": "sim_causal_hybrid",
}
if not plan.ok:
return record
try:
if side == "buy":
ok = self.buyCoinMarket(SYMBOL, int(plan.amount_krw), count=None)
record["ok"] = bool(ok)
record["message"] = "buyCoinMarket" if ok else "buy failed"
elif side == "sell":
sell_qty = float(plan.sell_qty)
gross = sell_qty * price
record["amount_krw"] = round(gross, 0)
ok = self.sellCoinMarket(SYMBOL, int(price), sell_qty)
record["ok"] = bool(ok)
record["sell_qty"] = sell_qty
record["message"] = (
f"sell qty={sell_qty:.4f}" if ok else "sell failed"
)
else:
record["message"] = f"unknown side {side}"
except Exception as exc:
record["message"] = str(exc)
if record["ok"]:
spent = float(record.get("amount_krw") or plan.amount_krw)
self._day_spent_krw += spent
self._day_trades += 1
self._rule_last_unix[hit["rule_id"]] = time.time()
self._append_live_signal(hit)
self._save_persisted_ops_signals()
return record
def run_once(self) -> None:
"""1회: 규칙 평가 → hybrid 배분 → 빗썸 주문 → 텔레그램."""
from deepcoin.data.ops_sync import ensure_ops_candles
ensure_ops_candles()
rules = load_monitor_rules()
print(
f"[06] {datetime.now():%Y-%m-%d %H:%M:%S} "
f"{COIN_NAME} LIVE rules={len(rules)} · sim=hybrid · bar={MATCH_PRIMARY_INTERVAL}m"
)
if not rules:
print(" monitor_rules 없음 — scripts/04_match_rules.py 실행")
maybe_record_portfolio_snapshot(self)
return
fired = evaluate_live_rules(rules, force_refresh=True)
if not fired:
print(" 발화 없음")
maybe_record_portfolio_snapshot(self)
return
try:
balances = self.load_balances_dict()
except Exception:
balances = {}
for hit in sort_hits_sim_order(fired):
rid = hit["rule_id"]
if self._live_signal_seen(hit):
continue
plan_preview = self._sim_plan(hit)
ok, reason = self._can_trade(rid, plan_preview.amount_krw)
if not ok:
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
print(f" skip: {reason}")
continue
if not plan_preview.ok:
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
print(f" skip: {plan_preview.message}")
continue
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
log = self._execute_live_order(hit, plan_preview, balances)
self._append_log(log)
print(f" order: {log['message']} ok={log['ok']}")
if not log["ok"]:
continue
try:
balances = self.load_balances_dict()
except Exception:
balances = None
msg = build_rule_alert_message(
hit,
balances,
trade_krw=float(log.get("amount_krw") or 0),
trade_qty=float(log.get("sell_qty") or 0) or None,
)
if balances:
sym = balances.get(SYMBOL, {})
msg += (
f"\n[잔고] 현금 ₩{float(sym.get('krw', 0)):,.0f} · "
f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}"
)
msg += f"\n[체결] {log['message']}"
self._send_coin_msg(msg)
maybe_record_portfolio_snapshot(self)
def run_loop(self, sleep_sec: int) -> None:
"""상시 루프."""
print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s")
while True:
self.run_once()
time.sleep(sleep_sec)

View File

@@ -1,554 +0,0 @@
import pandas as pd
from deepcoin.api.bithumb import HTS
from dateutil.relativedelta import relativedelta
from datetime import datetime
import sqlite3
import time
try:
import telegram
except ImportError:
telegram = None # type: ignore
import requests
import json
import asyncio
from multiprocessing import Pool
import numpy as np
import os
from config import *
from deepcoin.data.candle_intervals import (
candle_api_segment,
interval_display_label,
pagination_step,
)
class Monitor(HTS):
"""WLD 코인 데이터·지표·시장 상태 출력."""
last_signal = None
cooldown_file = None
def __init__(self, cooldown_file: str | None = None) -> None:
HTS.__init__(self)
# 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장)
self.last_signal: dict[str, str] = {}
if cooldown_file is not None:
self.cooldown_file = cooldown_file
self.buy_cooldown = self._load_buy_cooldown()
else:
self.cooldown_file = None
self.buy_cooldown = {}
# ------------- Persistence -------------
def _load_buy_cooldown(self) -> dict:
"""load trade record file into nested dict {symbol:{'buy':{'datetime':dt,'signal':s},'sell':{...}}}"""
if not os.path.exists(self.cooldown_file):
return {}
try:
with open(self.cooldown_file, 'r', encoding='utf-8') as f:
raw = json.load(f)
except Exception as e:
print(f"Error loading cooldown data: {e}")
return {}
record: dict[str, dict] = {}
for symbol, value in raw.items():
# 신규 포맷: value has 'buy'/'sell'
if isinstance(value, dict) and ('buy' in value or 'sell' in value):
record[symbol] = {}
for side in ['buy', 'sell']:
side_val = value.get(side)
if isinstance(side_val, dict):
dt_iso = side_val.get('datetime')
sig = side_val.get('signal', '')
if dt_iso:
try:
dt_obj = datetime.fromisoformat(dt_iso)
except Exception:
dt_obj = None
else:
dt_obj = None
record[symbol][side] = {'datetime': dt_obj, 'signal': sig}
else:
# 구 포맷 처리 (매수만 기록)
try:
dt_obj = None
sig = ''
if isinstance(value, str):
dt_obj = datetime.fromisoformat(value)
elif isinstance(value, dict):
dt_iso = value.get('datetime')
sig = value.get('signal', '')
if dt_iso:
dt_obj = datetime.fromisoformat(dt_iso)
record.setdefault(symbol, {})['buy'] = {'datetime': dt_obj, 'signal': sig}
except Exception:
continue
# last_signal 채우기 (buy 기준)
for sym, sides in record.items():
if 'buy' in sides and sides['buy'].get('signal'):
self.last_signal[sym] = sides['buy']['signal']
return record
def _save_buy_cooldown(self) -> None:
"""save nested trade record structure"""
try:
data: dict[str, dict] = {}
for symbol, sides in self.buy_cooldown.items():
data[symbol] = {}
for side in ['buy', 'sell']:
info = sides.get(side)
if not info:
continue
dt_obj = info.get('datetime')
sig = info.get('signal', '')
data[symbol][side] = {
'datetime': dt_obj.isoformat() if isinstance(dt_obj, datetime) else '',
'signal': sig,
}
with open(self.cooldown_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"Error saving cooldown data: {e}")
# ------------- Telegram -------------
def _send_coin_msg(self, text: str) -> None:
if telegram is None:
print(f"[telegram skip] {text}")
return
coin_client = telegram.Bot(token=COIN_TELEGRAM_BOT_TOKEN)
asyncio.run(coin_client.send_message(chat_id=COIN_TELEGRAM_CHAT_ID, text=text))
def sendMsg(self, msg):
try:
pool = Pool(12)
pool.map(self._send_coin_msg, [msg])
except Exception as e:
print(f"Error sending Telegram message: {str(e)}")
return
def send_coin_telegram_message(self, message_list: list[str], header: str) -> None:
payload = header + "\n"
for i, message in enumerate(message_list):
payload += message
if i + 1 % MONITOR_TELEGRAM_BATCH_SIZE == 0:
pool = Pool(MONITOR_POOL_WORKERS)
pool.map(self._send_coin_msg, [payload])
payload = ''
if len(message_list) % MONITOR_TELEGRAM_BATCH_SIZE != 0:
pool = Pool(MONITOR_POOL_WORKERS)
pool.map(self._send_coin_msg, [payload])
# ------------- Indicators -------------
def normalize_data(self, data: pd.DataFrame) -> pd.DataFrame:
columns_to_normalize = ['Open', 'High', 'Low', 'Close', 'Volume']
normalized_data = data.copy()
for column in columns_to_normalize:
min_val = data[column].rolling(window=MONITOR_NORM_WINDOW).min()
max_val = data[column].rolling(window=MONITOR_NORM_WINDOW).max()
denominator = max_val - min_val
normalized_data[f'{column}_Norm'] = np.where(
denominator != 0,
(data[column] - min_val) / denominator,
0.5,
)
return normalized_data
def inverse_data(self, data: pd.DataFrame) -> pd.DataFrame:
"""원본 data 가격 시계를 상하 대칭(글로벌 min/max 기준)으로 반전하여 하락↔상승 트렌드를 뒤집는다."""
price_cols = ['Open', 'High', 'Low', 'Close']
inv = data.copy()
global_min = data[price_cols].min().min()
global_max = data[price_cols].max().max()
# 축 기준은 global_mid = (max+min), so transformed = max+min - price
for col in price_cols:
inv[col] = global_max + global_min - data[col]
# Volume은 그대로 유지
inv['Volume'] = data['Volume']
# 지표 다시 계산
inv = self.normalize_data(inv)
for w in MONITOR_MA_WINDOWS:
inv[f"MA{w}"] = inv["Close"].rolling(window=w).mean()
inv[f"Deviation{w}"] = (inv["Close"] / inv[f"MA{w}"]) * 100
if len(MONITOR_MA_WINDOWS) >= 2:
w_fast, w_slow = MONITOR_MA_WINDOWS[0], MONITOR_MA_WINDOWS[1]
inv["golden_cross"] = (inv[f"MA{w_fast}"] > inv[f"MA{w_slow}"]) & (
inv[f"MA{w_fast}"].shift(1) <= inv[f"MA{w_slow}"].shift(1)
)
inv["MA"] = inv["Close"].rolling(window=BB_PERIOD).mean()
inv["STD"] = inv["Close"].rolling(window=BB_PERIOD).std()
inv["Upper"] = inv["MA"] + (BB_STD * inv["STD"])
inv["Lower"] = inv["MA"] - (BB_STD * inv["STD"])
return inv
def calculate_technical_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
data = self.normalize_data(data)
for w in MONITOR_MA_WINDOWS:
data[f"MA{w}"] = data["Close"].rolling(window=w).mean()
data[f"Deviation{w}"] = (data["Close"] / data[f"MA{w}"]) * 100
if len(MONITOR_MA_WINDOWS) >= 2:
w_fast, w_slow = MONITOR_MA_WINDOWS[0], MONITOR_MA_WINDOWS[1]
data["golden_cross"] = (data[f"MA{w_fast}"] > data[f"MA{w_slow}"]) & (
data[f"MA{w_fast}"].shift(1) <= data[f"MA{w_slow}"].shift(1)
)
data["MA"] = data["Close"].rolling(window=BB_PERIOD).mean()
data["STD"] = data["Close"].rolling(window=BB_PERIOD).std()
data["Upper"] = data["MA"] + (BB_STD * data["STD"])
data["Lower"] = data["MA"] - (BB_STD * data["STD"])
from deepcoin.common.indicators import add_macd, add_stochastic
data = add_macd(data)
data = add_stochastic(data)
return data
def process_wld_market_status(self, symbol: str) -> None:
"""
WLD: 전 봉 BB·일목 위치·추세만 출력 (자동 매매 없음).
"""
from deepcoin.common.candle_features import describe_latest_position
from deepcoin.common.indicators import get_trend
from deepcoin.data.mtf_bb import load_frames_from_db
try:
frames = load_frames_from_db(self, symbol)
if not frames:
print(f"Data for {symbol}: 로드된 봉 없음.")
return
df_1d = frames.get(TREND_INTERVAL_1D)
df_1h = frames.get(TREND_INTERVAL_1H)
if df_1d is None or df_1d.empty:
df_1d = frames.get(ENTRY_INTERVAL)
if df_1h is None or df_1h.empty:
df_1h = frames.get(ENTRY_INTERVAL)
trend = get_trend(df_1d, df_1h)
print(f"{symbol} 추세(참고): {trend}")
print("--- 봉별 BB·일목 위치 ---")
for iv in sorted(frames.keys()):
pos = describe_latest_position(frames[iv], iv)
macd_s = ""
if pos.get("macd_hist") is not None:
macd_s = f" | MACD {pos.get('macd_state', '-')} h={pos['macd_hist']}"
stoch_s = ""
if pos.get("stoch_k") is not None:
stoch_s = (
f" | Stoch K={pos['stoch_k']} D={pos.get('stoch_d')} "
f"{pos.get('stoch_zone', '')}"
)
disp_s = ""
if pos.get("disparity"):
parts = [f"{p}={v:.1f}" for p, v in sorted(pos["disparity"].items())]
disp_s = " | D.I. " + " ".join(parts)
print(
f" {pos['label']:>6} | BB {pos['bb_zone']} {pos['bb_state']:>16} | "
f"일목 {pos['ichi_position']} TK={pos['ichi_tk']}"
f"{macd_s}{stoch_s}{disp_s}"
)
except Exception as e:
print(f"Error processing {symbol}: {str(e)}")
def process_symbol(
self,
symbol: str,
interval: int | None = None,
balances: dict | None = None,
use_inverse: bool = False,
) -> None:
"""하위 호환: 시장 상태 출력으로 위임."""
self.process_wld_market_status(symbol)
def load_balances_dict(self) -> dict:
"""getBalances() 결과를 currency 키 dict로 변환."""
tmps = self.getBalances()
balances: dict = {}
if isinstance(tmps, dict):
if tmps.get("error"):
raise RuntimeError(f"getBalances: {tmps.get('error')}")
return balances
if not isinstance(tmps, list):
raise RuntimeError(f"getBalances unexpected: {type(tmps)}")
for tmp in tmps:
if not isinstance(tmp, dict) or "currency" not in tmp:
continue
balances[tmp["currency"]] = {
"balance": float(tmp["balance"]),
"avg_buy_price": float(tmp.get("avg_buy_price") or 0),
}
return balances
# ------------- Formatting -------------
def format_message(
self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float
) -> str:
message = f"[매수] {symbol_name} ({symbol}) [{signal}]: "
if int(close) >= 100:
message += f"{close}"
message += f" (₩{buy_amount})"
elif int(close) >= 10:
message += f"{close:.2f}"
message += f" (₩{buy_amount:.2f})"
elif int(close) >= 1:
message += f"{close:.3f}"
message += f" (₩{buy_amount:.3f})"
else:
message += f"{close:.4f}"
message += f" (₩{buy_amount:.4f})"
if signal != '':
message += f"[{signal}]"
return message
# ------------- Data fetch -------------
def get_coin_data(
self,
symbol: str,
interval: int = MONITOR_DEFAULT_INTERVAL,
to: str | None = None,
retries: int = MONITOR_API_RETRIES,
) -> pd.DataFrame | None:
base = BITHUMB_API_URL.rstrip("/")
count = BITHUMB_API_CANDLE_COUNT
segment = candle_api_segment(interval)
for attempt in range(retries):
try:
path = f"/v1/candles/{segment}"
if to is None:
url = f"{base}{path}?market=KRW-{symbol}&count={count}"
else:
url = f"{base}{path}?market=KRW-{symbol}&count={count}&to={to}"
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
json_data = json.loads(response.text)
df_temp = pd.DataFrame(json_data)
df_temp = df_temp.sort_index(ascending=False)
if 'candle_date_time_kst' not in df_temp:
return None
data = pd.DataFrame()
data['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst'], format='%Y-%m-%dT%H:%M:%S')
data['Open'] = df_temp['opening_price']
data['Close'] = df_temp['trade_price']
data['High'] = df_temp['high_price']
data['Low'] = df_temp['low_price']
data['Volume'] = df_temp['candle_acc_trade_volume']
data = data.set_index('datetime')
data = data.astype(float)
data["datetime"] = data.index
if not data.empty:
return data
print(f"No data received for {symbol}, attempt {attempt + 1}")
time.sleep(MONITOR_SLEEP_AFTER_REQUEST_SEC)
except Exception as e:
print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}")
if attempt < retries - 1:
time.sleep(MONITOR_SLEEP_RATE_LIMIT_SEC)
continue
return None
def get_coin_more_data(
self,
symbol: str,
interval: int,
bong_count: int = MONITOR_API_BONG_COUNT,
verbose: bool = False,
) -> pd.DataFrame:
"""
빗썸 API를 반복 호출해 bong_count개까지 과거 봉을 수집합니다.
Args:
verbose: True면 수집 진행 상황을 출력합니다.
"""
to = datetime.now()
data: pd.DataFrame | None = None
step = 0
while data is None or len(data) < bong_count:
step += 1
if data is None:
chunk = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
data = chunk
else:
previous_count = len(data)
df = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
if df is not None and not df.empty:
data = pd.concat([data, df], ignore_index=True)
if df is None or df.empty or previous_count == len(data):
if verbose:
print(f" API 추가 데이터 없음 (수집 {len(data)}봉)")
break
if verbose and (step == 1 or step % 5 == 0 or len(data) >= bong_count):
label = interval_display_label(interval)
print(f" [{label}] 요청 {step}회 — 누적 {len(data)}/{bong_count}")
time.sleep(MONITOR_SLEEP_BETWEEN_CHUNKS_SEC)
to = to - pagination_step(interval, MONITOR_API_CHUNK_BARS)
if data is None or data.empty:
return pd.DataFrame()
data = data.set_index("datetime")
data = data.sort_index()
data = data.drop_duplicates(keep="first")
data["datetime"] = data.index
return data
@staticmethod
def db_row_limit_for_interval(interval: int, lookback_days: int) -> int:
"""
lookback_days 구간 + 지표 워밍업을 담을 SQLite LIMIT(봉 개수)을 계산합니다.
Args:
interval: 봉 간격(분). 1440이면 일봉.
lookback_days: 과거 조회 일수.
Returns:
LIMIT에 넣을 최대 행 수.
"""
from config import MONTH_INTERVAL_MIN, WEEK_INTERVAL_MIN
if interval == WEEK_INTERVAL_MIN:
return max(lookback_days // 7 + 10, DB_ROW_MIN_DAILY_BARS)
if interval == MONTH_INTERVAL_MIN:
return max(lookback_days // 30 + 6, DB_ROW_MIN_DAILY_BARS)
if interval >= DAILY_INTERVAL_MIN:
return max(
lookback_days + DB_ROW_DAILY_PADDING_DAYS,
DB_ROW_MIN_DAILY_BARS,
)
bars_per_day = max((24 * 60) // max(interval, 1), 1)
return bars_per_day * lookback_days + DB_ROW_WARMUP_BARS
def persist_api_candles_to_db(
self,
symbol: str,
interval: int,
data: pd.DataFrame,
db_path: str = DB_PATH,
) -> tuple[int, int]:
"""
API로 받은 봉을 coins.db에 증분 INSERT합니다 (01_download.append_data와 동일).
05·06·live_eval이 load_frames_from_db 할 때마다 최신 봉이 쌓입니다.
Returns:
(추가 행 수, 스킵 행 수)
"""
if not MONITOR_PERSIST_CANDLES or data is None or data.empty:
return 0, 0
from deepcoin.data.downloader import (
append_data,
get_last_timestamp,
months_for_interval,
prune_before_cutoff,
)
if not isinstance(data.index, pd.DatetimeIndex):
data = data.copy()
data.index = pd.to_datetime(data.index)
data = data.sort_index()
last_ts = get_last_timestamp(symbol, interval, db_path=db_path)
inserted, skipped = append_data(
symbol, interval, data, last_ts=last_ts, db_path=db_path
)
if inserted > 0:
months = months_for_interval(interval, DOWNLOAD_MONTHS)
prune_before_cutoff(symbol, interval, months, db_path=db_path)
return inserted, skipped
def read_candles_from_db(
self,
symbol: str,
interval: int,
db_path: str = DB_PATH,
max_rows: int = DB_READ_LIMIT_DEFAULT,
) -> pd.DataFrame:
"""
coins.db에서 저장된 봉을 읽습니다.
scripts/01_download.py 또는 persist_api_candles_to_db로 적재된 데이터.
"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
cursor.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} "
"(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
"Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
)
cursor.execute(
f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
)
cursor.execute(
f"SELECT Open, Close, High, Low, Volume, ymdhms AS datetime "
f"FROM (SELECT Open, Close, High, Low, Volume, ymdhms "
f"FROM {table_name} ORDER BY ymdhms DESC LIMIT {int(max_rows)}) "
f"ORDER BY datetime"
)
result = cursor.fetchall()
conn.commit()
cursor.close()
conn.close()
if not result:
return pd.DataFrame(
columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
)
df = pd.DataFrame(
result, columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
)
df = df.set_index("datetime")
df = df.sort_index()
df["datetime"] = df.index
return df
def get_coin_saved_data(
self,
symbol: str,
interval: int,
data: pd.DataFrame,
db_path: str = DB_PATH,
max_rows: int = DB_READ_LIMIT_DEFAULT,
) -> pd.DataFrame:
"""하위 호환: API 봉 저장 후 DB에서 읽기."""
self.persist_api_candles_to_db(symbol, interval, data, db_path=db_path)
return self.read_candles_from_db(
symbol, interval, db_path=db_path, max_rows=max_rows
)
def get_coin_some_data(
self, symbol: str, interval: int, db_max_rows: int | None = None
) -> pd.DataFrame:
"""
WLD 시세: API 최신 봉 + coins.db 과거 봉을 합칩니다.
MONITOR_PERSIST_CANDLES=1 이면 API 청크를 즉시 coins.db에 INSERT합니다.
1분봉은 다운로드·병합하지 않습니다.
"""
data = self.get_coin_data(symbol, interval)
if data is None or data.empty:
return pd.DataFrame()
self.persist_api_candles_to_db(symbol, interval, data)
row_limit = DB_READ_LIMIT_DEFAULT if db_max_rows is None else int(db_max_rows)
saved_data = self.read_candles_from_db(
symbol, interval, max_rows=row_limit
)
parts = [data]
if saved_data is not None and not saved_data.empty:
parts.append(saved_data)
merged = pd.concat(parts, ignore_index=True)
merged["datetime"] = pd.to_datetime(merged["datetime"], format="%Y-%m-%d %H:%M:%S")
merged = merged.set_index("datetime")
merged = merged.sort_index()
merged = merged.drop_duplicates(keep="first")
merged["datetime"] = merged.index
return merged

View File

@@ -1,92 +0,0 @@
"""
WLD(월드코인) 실시간 모니터 — BB·일목·04 매칭 규칙 알림 (자동 매매 없음).
"""
from __future__ import annotations
from datetime import datetime
import time
from config import COIN_NAME, MONITOR_ALERT_COOLDOWN_MIN, MONITOR_LOOP_SLEEP_SEC, SYMBOL
from deepcoin.matching.live_eval import evaluate_live_rules
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.ops.alert_message import build_rule_alert_message
from deepcoin.ops.monitor import Monitor
class MonitorCoin(Monitor):
"""WLD 시장 상태·매칭 규칙 주기 출력."""
def __init__(self, cooldown_file: str | None = None, *, check_rules: bool = True) -> None:
"""
Args:
cooldown_file: 매매 쿨다운 JSON 경로.
check_rules: True면 04 active_rules 평가·알림.
"""
super().__init__(cooldown_file=cooldown_file)
self.check_rules = check_rules
self._last_alert_unix: dict[str, float] = {}
def monitor_wld(self) -> None:
"""전 봉 BB·일목·추세 및 규칙 발화를 출력합니다."""
from deepcoin.data.ops_sync import ensure_ops_candles
ensure_ops_candles()
print(
"[{}] {} ({})".format(
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
COIN_NAME,
SYMBOL,
)
)
self.process_wld_market_status(SYMBOL)
if self.check_rules:
self._check_matched_rules()
def _check_matched_rules(self) -> None:
"""04 monitor_rules 최신 봉 평가 후 쿨다운 내 중복 알림 방지."""
rules = load_monitor_rules()
if not rules:
print(" [04 규칙] monitor_rules 없음 — scripts/04_match_rules.py 실행")
return
try:
fired = evaluate_live_rules(rules)
except Exception as exc:
print(f" [04 규칙] 평가 오류: {exc}")
return
if not fired:
print(f" [04 규칙] 발화 없음 (감시 {len(rules)}개)")
return
balances: dict | None = None
try:
balances = self.load_balances_dict()
except Exception:
balances = None
cooldown_sec = MONITOR_ALERT_COOLDOWN_MIN * 60
now = time.time()
for hit in fired:
rid = hit["rule_id"]
preview = build_rule_alert_message(hit, balances).replace("\n", " | ")
print(f" [04] {preview}")
last = self._last_alert_unix.get(rid, 0.0)
if now - last < cooldown_sec:
print(f" [04] 쿨다운 skip {rid} ({MONITOR_ALERT_COOLDOWN_MIN}분)")
continue
self._last_alert_unix[rid] = now
self._send_coin_msg(build_rule_alert_message(hit, balances))
def run_schedule(self) -> None:
"""MONITOR_LOOP_SLEEP_SEC 간격으로 상태를 출력합니다."""
rules = load_monitor_rules()
names = ", ".join(r["rule_id"] for r in rules) or "(없음)"
print(
f"05 모니터 시작 · 감시 {len(rules)}개 ({names}) · "
f"주기 {MONITOR_LOOP_SLEEP_SEC}초 · 알림쿨다운 {MONITOR_ALERT_COOLDOWN_MIN}"
)
while True:
self.monitor_wld()
time.sleep(MONITOR_LOOP_SLEEP_SEC)
if __name__ == "__main__":
MonitorCoin(cooldown_file=None).run_schedule()

View File

@@ -1,435 +0,0 @@
"""
운영 포트폴리오 스냅샷·최근 24시간 수익률 텔레그램 리포트.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from config import (
COIN_NAME,
COIN_TELEGRAM_BOT_TOKEN,
COIN_TELEGRAM_CHAT_ID,
DAILY_PNL_REPORT_ENABLED,
DAILY_PNL_REPORT_HOUR,
DAILY_PNL_REPORT_MINUTE,
DAILY_PNL_REPORT_TZ,
DAILY_PNL_SNAPSHOT_ON_LIVE,
DAILY_PNL_SNAPSHOT_RETENTION_DAYS,
ENTRY_INTERVAL,
GT_INITIAL_CASH_KRW,
SYMBOL,
)
from deepcoin.ops.monitor import Monitor
from deepcoin.paths import LIVE_TRADES_LOG, PORTFOLIO_SNAPSHOTS_LOG, ensure_dirs
logger = logging.getLogger(__name__)
ROLLING_HOURS = 24
MAX_ANCHOR_SKEW_HOURS = 6
def _report_tz() -> ZoneInfo:
"""
리포트 기준 타임존.
Returns:
ZoneInfo (잘못된 이름이면 UTC).
"""
try:
return ZoneInfo(DAILY_PNL_REPORT_TZ)
except Exception:
logger.warning("DAILY_PNL_REPORT_TZ=%s invalid, using UTC", DAILY_PNL_REPORT_TZ)
return ZoneInfo("UTC")
def _parse_ts(value: str) -> datetime:
"""ISO 시각 → timezone-aware datetime."""
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def fetch_mark_price(monitor: Monitor, symbol: str = SYMBOL) -> float:
"""
최근 종가(3분봉)를 조회합니다.
Args:
monitor: Monitor 인스턴스.
symbol: 통화 코드.
Returns:
종가. 실패 시 0.
"""
try:
data = monitor.get_coin_data(symbol, interval=ENTRY_INTERVAL)
if data is None or data.empty:
return 0.0
return float(data["Close"].iloc[-1])
except Exception as exc:
logger.warning("mark price fetch failed: %s", exc)
return 0.0
def portfolio_from_balances(
balances: dict[str, dict[str, float]],
mark_price: float,
*,
ts: datetime | None = None,
) -> dict[str, Any]:
"""
잔고·시세로 총자산 스냅샷 dict를 만듭니다.
Args:
balances: load_balances_dict() 결과.
mark_price: 코인 평가 단가.
ts: 기록 시각 (None이면 now UTC).
Returns:
스냅샷 필드 dict.
"""
when = ts or datetime.now(timezone.utc)
krw = float((balances.get("KRW") or {}).get("balance") or 0.0)
qty = float((balances.get(SYMBOL) or {}).get("balance") or 0.0)
holding_value = qty * mark_price if mark_price > 0 else 0.0
total = krw + holding_value
return {
"ts": when.isoformat(timespec="seconds"),
"symbol": SYMBOL,
"krw": round(krw, 0),
"coin_qty": qty,
"mark_price": mark_price,
"holding_value_krw": round(holding_value, 0),
"total_asset_krw": round(total, 0),
}
def fetch_portfolio_snapshot(monitor: Monitor) -> dict[str, Any]:
"""
API 잔고·시세로 현재 포트폴리오 스냅샷을 조회합니다.
Args:
monitor: Monitor 인스턴스.
Returns:
portfolio_from_balances 결과.
Raises:
RuntimeError: 잔고 API 오류.
"""
balances = monitor.load_balances_dict()
mark = fetch_mark_price(monitor)
if mark <= 0:
raise RuntimeError("mark price unavailable")
return portfolio_from_balances(balances, mark)
def append_portfolio_snapshot(snapshot: dict[str, Any]) -> None:
"""portfolio_snapshots.jsonl에 1행 추가 후 오래된 행 정리."""
ensure_dirs()
PORTFOLIO_SNAPSHOTS_LOG.parent.mkdir(parents=True, exist_ok=True)
with PORTFOLIO_SNAPSHOTS_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(snapshot, ensure_ascii=False) + "\n")
_prune_snapshots(DAILY_PNL_SNAPSHOT_RETENTION_DAYS)
def _prune_snapshots(retention_days: int) -> None:
"""보관 일수 초과 스냅샷 삭제."""
if not PORTFOLIO_SNAPSHOTS_LOG.is_file():
return
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
kept: list[str] = []
try:
for line in PORTFOLIO_SNAPSHOTS_LOG.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
row = json.loads(line)
if _parse_ts(row["ts"]) >= cutoff:
kept.append(line)
except (json.JSONDecodeError, OSError, KeyError) as exc:
logger.warning("snapshot prune skipped: %s", exc)
return
PORTFOLIO_SNAPSHOTS_LOG.write_text(
"\n".join(kept) + ("\n" if kept else ""),
encoding="utf-8",
)
def load_snapshots() -> list[dict[str, Any]]:
"""스냅샷 jsonl 전체 로드 (시각 오름차순)."""
if not PORTFOLIO_SNAPSHOTS_LOG.is_file():
return []
rows: list[dict[str, Any]] = []
for line in PORTFOLIO_SNAPSHOTS_LOG.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
rows.sort(key=lambda r: _parse_ts(r["ts"]))
return rows
def _nearest_snapshot(
snapshots: list[dict[str, Any]],
target: datetime,
) -> tuple[dict[str, Any] | None, float]:
"""
target에 가장 가까운 스냅샷.
Returns:
(스냅샷, |시차| 시간).
"""
if not snapshots:
return None, 0.0
best = snapshots[0]
best_hours = abs((_parse_ts(best["ts"]) - target).total_seconds()) / 3600.0
for row in snapshots[1:]:
hours = abs((_parse_ts(row["ts"]) - target).total_seconds()) / 3600.0
if hours < best_hours:
best = row
best_hours = hours
return best, best_hours
def count_trades_since(path: Path, since: datetime) -> dict[str, int]:
"""
live_trades.jsonl에서 구간 내 체결 건수.
Returns:
{"buy": n, "sell": n, "ok": n}.
"""
out = {"buy": 0, "sell": 0, "ok": 0}
if not path.is_file():
return out
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if not row.get("ok"):
continue
try:
ts = _parse_ts(str(row.get("ts", "")))
except (TypeError, ValueError):
continue
if ts < since:
continue
side = str(row.get("side", "")).lower()
if side in out:
out[side] += 1
out["ok"] += 1
return out
def compute_24h_return(
current: dict[str, Any],
snapshots: list[dict[str, Any]] | None = None,
*,
baseline_krw: float | None = None,
) -> dict[str, Any]:
"""
최근 24시간 총자산 수익률을 계산합니다.
Args:
current: 현재 스냅샷.
snapshots: 과거 스냅샷 목록 (None이면 파일 로드).
baseline_krw: 앵커 없을 때 대체 기준(초기 자금).
Returns:
리포트 dict (pnl_pct, anchor, trade_counts 등).
"""
snapshots = snapshots if snapshots is not None else load_snapshots()
now = _parse_ts(current["ts"])
target = now - timedelta(hours=ROLLING_HOURS)
anchor, skew_h = _nearest_snapshot(snapshots, target)
base_asset = float(baseline_krw or GT_INITIAL_CASH_KRW)
anchor_ts = None
anchor_note = f"기준=초기자금 ₩{base_asset:,.0f} (24h 스냅샷 없음)"
if anchor is not None:
base_asset = float(anchor["total_asset_krw"])
anchor_ts = anchor["ts"]
if skew_h <= MAX_ANCHOR_SKEW_HOURS:
anchor_note = f"기준=24h 전 스냅샷 ({anchor_ts}, 시차 {skew_h:.1f}h)"
else:
anchor_note = (
f"기준=가장 가까운 스냅샷 ({anchor_ts}, 24h 대비 시차 {skew_h:.1f}h)"
)
final_asset = float(current["total_asset_krw"])
pnl_krw = final_asset - base_asset
pnl_pct = (pnl_krw / base_asset * 100.0) if base_asset > 0 else 0.0
cum_pct = (
(final_asset - GT_INITIAL_CASH_KRW) / GT_INITIAL_CASH_KRW * 100.0
if GT_INITIAL_CASH_KRW > 0
else 0.0
)
trades = count_trades_since(LIVE_TRADES_LOG, target)
return {
"current": current,
"anchor": anchor,
"anchor_ts": anchor_ts,
"anchor_note": anchor_note,
"anchor_skew_hours": skew_h if anchor else None,
"base_asset_krw": base_asset,
"final_asset_krw": final_asset,
"pnl_krw": pnl_krw,
"pnl_pct": pnl_pct,
"cumulative_pnl_pct": cum_pct,
"trade_counts": trades,
"window_hours": ROLLING_HOURS,
}
def build_daily_pnl_message(report: dict[str, Any]) -> str:
"""
텔레그램 본문 생성.
Args:
report: compute_24h_return 결과.
Returns:
메시지 문자열.
"""
cur = report["current"]
sign = "+" if report["pnl_pct"] >= 0 else ""
cum_sign = "+" if report["cumulative_pnl_pct"] >= 0 else ""
tc = report["trade_counts"]
tz = _report_tz()
now_local = _parse_ts(cur["ts"]).astimezone(tz).strftime("%Y-%m-%d %H:%M")
lines = [
f"[DeepCoin 일일 리포트] {COIN_NAME} ({SYMBOL})",
f"시각: {now_local} ({DAILY_PNL_REPORT_TZ})",
"",
f"최근 {report['window_hours']}시간 수익률: {sign}{report['pnl_pct']:.2f}%",
f" 손익: {sign}{report['pnl_krw']:,.0f}",
f" {report['anchor_note']}",
f" 24h 전 총자산: ₩{report['base_asset_krw']:,.0f}",
f" 현재 총자산: ₩{report['final_asset_krw']:,.0f}",
"",
f"누적(초기 ₩{GT_INITIAL_CASH_KRW:,} 대비): {cum_sign}{report['cumulative_pnl_pct']:.2f}%",
"",
"현재 잔고",
f" 현금: ₩{float(cur['krw']):,.0f}",
f" {SYMBOL}: {float(cur['coin_qty']):.4f} (₩{float(cur['holding_value_krw']):,.0f} @ ₩{float(cur['mark_price']):,.0f})",
"",
f"24h 체결(성공): 매수 {tc['buy']} · 매도 {tc['sell']} · 합계 {tc['ok']}",
"※ 총자산=현금+보유×종가. 미실현 포함.",
]
return "\n".join(lines)
def maybe_record_portfolio_snapshot(monitor: Monitor) -> None:
"""
06 루프용: 설정 시 스냅샷만 기록 (텔레그램 없음).
Args:
monitor: Monitor/LiveTrader 인스턴스.
"""
if not DAILY_PNL_SNAPSHOT_ON_LIVE:
return
try:
snap = fetch_portfolio_snapshot(monitor)
append_portfolio_snapshot(snap)
except Exception as exc:
logger.warning("portfolio snapshot skip: %s", exc)
def send_daily_pnl_report(
monitor: Monitor | None = None,
*,
send_telegram: bool = True,
) -> dict[str, Any]:
"""
스냅샷 저장 → 24h 수익률 계산 → 텔레그램 발송.
Args:
monitor: None이면 Monitor() 생성.
send_telegram: False면 메시지만 반환·콘솔 출력.
Returns:
compute_24h_return 결과 + message 키.
"""
if not DAILY_PNL_REPORT_ENABLED:
raise RuntimeError("DAILY_PNL_REPORT_ENABLED=0")
mon = monitor or Monitor(cooldown_file=None)
current = fetch_portfolio_snapshot(mon)
append_portfolio_snapshot(current)
report = compute_24h_return(current)
msg = build_daily_pnl_message(report)
report["message"] = msg
print(msg)
if send_telegram:
if not COIN_TELEGRAM_BOT_TOKEN or not COIN_TELEGRAM_CHAT_ID:
print("[telegram skip] COIN_TELEGRAM_BOT_TOKEN/CHAT_ID 미설정")
else:
mon._send_coin_msg(msg)
return report
def seconds_until_next_report(
hour: int | None = None,
minute: int | None = None,
) -> float:
"""
다음 리포트 시각(로컬 TZ)까지 대기 초.
Args:
hour: 시 (기본 DAILY_PNL_REPORT_HOUR).
minute: 분 (기본 DAILY_PNL_REPORT_MINUTE).
Returns:
초 (>= 1).
"""
tz = _report_tz()
h = DAILY_PNL_REPORT_HOUR if hour is None else hour
m = DAILY_PNL_REPORT_MINUTE if minute is None else minute
now = datetime.now(tz)
target = now.replace(hour=h, minute=m, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return max((target - now).total_seconds(), 1.0)
def run_schedule_loop(monitor: Monitor | None = None) -> None:
"""
매일 지정 시각에 리포트를 발송하는 무한 루프.
Args:
monitor: 재사용할 Monitor (None이면 매 회 생성).
"""
tz = _report_tz()
print(
f"[07] 일일 수익률 텔레그램 · {DAILY_PNL_REPORT_HOUR:02d}:"
f"{DAILY_PNL_REPORT_MINUTE:02d} {tz.key} · Ctrl+C 종료"
)
while True:
wait = seconds_until_next_report()
next_at = datetime.now(tz) + timedelta(seconds=wait)
print(f"[07] 다음 발송: {next_at:%Y-%m-%d %H:%M:%S} (대기 {wait / 3600:.2f}h)")
import time
time.sleep(wait)
try:
send_daily_pnl_report(monitor)
except Exception as exc:
print(f"[07] 리포트 오류: {exc}")

View File

@@ -1,669 +0,0 @@
"""
Ground Truth 차트 HTML (05_chart_truth).
python scripts/05_chart_truth.py
"""
from __future__ import annotations
import sys
import webbrowser
from pathlib import Path
import numpy as np
import pandas as pd
import plotly.graph_objs as go
from plotly.subplots import make_subplots
from config import (
CHART_LOOKBACK_DAYS,
COIN_NAME,
DISPARITY_OVERBOUGHT,
DISPARITY_OVERSOLD,
DISPARITY_PERIODS,
ENTRY_INTERVAL,
GROUND_TRUTH_FILE,
GT_INITIAL_CASH_KRW,
GT_MARKER_SIZE_MAX,
GT_MARKER_SIZE_MIN,
LIVE_ORDER_KRW,
MACD_FAST,
MACD_SIGNAL,
MACD_SLOW,
STOCH_D_PERIOD,
STOCH_K_PERIOD,
SYMBOL,
TRADING_FEE_RATE,
TREND_INTERVAL_1D,
TREND_INTERVAL_1H,
)
from deepcoin.common.indicators import apply_bar_indicators, disparity_column, get_trend
from deepcoin.ops.monitor import Monitor
from deepcoin.data.mtf_bb import interval_label, load_frames_from_db
from deepcoin.ops.chart_report import wrap_chart_report_page
from deepcoin.paths import CHART_TRUTH_HTML, resolve_ground_truth_file
TRUTH_HTML = CHART_TRUTH_HTML
GROUND_TRUTH_PATH = resolve_ground_truth_file()
REPORT_DIR = CHART_TRUTH_HTML.parent
def interval_chart_label(interval_min: int) -> str:
"""차트 제목용 봉 라벨."""
if interval_min >= 1440:
return "일봉"
return f"{interval_min}분봉"
def _marker_hover_text(
label: str,
t: dict,
*,
default_order_krw: float | None = None,
extra_lines: list[str] | None = None,
) -> str:
"""
차트 마커 툴팁: 체결가(price)와 체결 원화(amount_krw)를 함께 표시.
Args:
label: 정답/시뮬 매수·매도 라벨.
t: trade dict (price, amount_krw, weight, memo …).
default_order_krw: amount_krw 없을 때 표시할 기본 원화(시뮬 고정 주문).
extra_lines: 툴팁 하단 추가 줄.
Returns:
hovertext HTML 줄바꿈 문자열.
"""
action = t.get("action", t.get("side", ""))
amt_label = "매수금액" if action == "buy" else "매도금액"
lines = [
label,
str(t.get("dt", ""))[:16],
f"체결가 ₩{float(t['price']):,.0f}",
]
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
lines.append(f"{amt_label}{float(ak):,.0f}")
elif default_order_krw is not None and action == "buy":
lines.append(f"{amt_label}{float(default_order_krw):,.0f}")
else:
lines.append(f"{amt_label} (미배분)")
if t.get("weight") is not None:
lines.append(f"비중 {float(t.get('weight', 1)) * 100:.0f}%")
if extra_lines:
lines.extend(extra_lines)
memo = t.get("memo", "")
if memo:
lines.append(str(memo))
rule_id = t.get("rule_id", "")
if rule_id:
lines.append(str(rule_id))
return "<br>".join(lines)
def _trade_amount_krw(t: dict) -> float:
"""
마커 크기·툴팁용 체결 원화. amount_krw 없으면 비중×초기자본으로 상대 크기만 추정.
Args:
t: trade dict.
Returns:
원화 금액(0 이상).
"""
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
return float(ak)
return max(float(t.get("weight", 1.0)), 0.05) * float(GT_INITIAL_CASH_KRW)
def _marker_sizes(pts: list[dict]) -> list[float]:
"""
체결 원화(amount_krw)에 비례한 삼각형 크기.
같은 trace(매수 또는 매도) 안에서 최소·최대 금액으로 선형 스케일.
Args:
pts: 동일 action의 trade dict 리스트.
Returns:
plotly marker size(diameter) 리스트.
"""
if not pts:
return []
lo, hi = float(GT_MARKER_SIZE_MIN), float(GT_MARKER_SIZE_MAX)
amounts = [_trade_amount_krw(t) for t in pts]
amin, amax = min(amounts), max(amounts)
sizes: list[float] = []
for amount in amounts:
if amax > amin:
ratio = (amount - amin) / (amax - amin)
else:
ratio = 0.5
ratio = max(ratio, 0.08)
sizes.append(lo + (hi - lo) * ratio)
return sizes
def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
"""
시뮬(규칙) 매수·매도 마커 — 원형, GT 삼각형과 구분.
Args:
fig: plotly Figure.
trades: dt, action, price, forward_ret_pct, rule_id 키.
row: subplot row.
"""
for action, color, label in [
("buy", "#059669", "시뮬 매수"),
("sell", "#b91c1c", "시뮬 매도"),
]:
pts = [t for t in trades if t.get("action") == action]
if not pts:
continue
fig.add_trace(
go.Scatter(
x=[pd.Timestamp(t["dt"]) for t in pts],
y=[float(t["price"]) for t in pts],
mode="markers",
name=label,
legendgroup=label,
marker=dict(
symbol="circle",
size=9,
color=color,
line=dict(width=1, color="#fff"),
opacity=0.75,
),
hovertext=[
_marker_hover_text(
label,
t,
default_order_krw=LIVE_ORDER_KRW,
extra_lines=[
f"leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%",
],
)
for t in pts
],
hovertemplate="%{hovertext}<extra></extra>",
),
row=row,
col=1,
)
def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
"""정답 매수·매도 마커 (삼각형 크기 = 체결 원화 금액)."""
for action, color, symbol, label in [
("buy", "#16a34a", "triangle-up", "정답 매수"),
("sell", "#dc2626", "triangle-down", "정답 매도"),
]:
pts = [t for t in trades if t.get("action") == action]
if not pts:
continue
sizes = _marker_sizes(pts)
fig.add_trace(
go.Scatter(
x=[pd.Timestamp(t["dt"]) for t in pts],
y=[t["price"] for t in pts],
mode="markers",
name=label,
legendgroup=label,
marker=dict(
symbol=symbol,
size=sizes,
sizemode="diameter",
color=color,
line=dict(width=1.5, color="#111"),
),
hovertext=[
_marker_hover_text(label, t)
for t in pts
],
hovertemplate="%{hovertext}<extra></extra>",
),
row=row,
col=1,
)
def build_chart_html(
df: pd.DataFrame,
trend: str,
interval_min: int = ENTRY_INTERVAL,
note: str = "",
truth_trades: list[dict] | None = None,
sim_trades: list[dict] | None = None,
title_suffix: str = "BB 차트",
pnl_summary: dict | None = None,
legend_html: str | None = None,
footer_sections: str | None = None,
cards_html: str | None = None,
) -> str:
"""BB·이격도·RSI·MACD·스토캐스틱·거래량 차트 HTML."""
df = apply_bar_indicators(df.copy())
iv_label = interval_chart_label(interval_min)
close_last = float(df["Close"].iloc[-1])
bb_pos = None
if "bb_pos" in df.columns and pd.notna(df["bb_pos"].iloc[-1]):
bb_pos = float(df["bb_pos"].iloc[-1])
disp_title = "이격도 " + ",".join(str(p) for p in DISPARITY_PERIODS)
fig = make_subplots(
rows=6,
cols=1,
shared_xaxes=True,
vertical_spacing=0.03,
row_heights=[0.42, 0.11, 0.11, 0.11, 0.13, 0.12],
subplot_titles=(
f"{COIN_NAME} ({SYMBOL}) {iv_label}",
disp_title,
f"Stochastic ({STOCH_K_PERIOD},{STOCH_D_PERIOD})",
"RSI (14)",
f"MACD ({MACD_FAST},{MACD_SLOW},{MACD_SIGNAL})",
"거래량",
),
)
disp_colors = ("#0d9488", "#7c3aed", "#ca8a04")
fig.add_trace(
go.Candlestick(
x=df.index,
open=df["Open"],
high=df["High"],
low=df["Low"],
close=df["Close"],
name=f"{iv_label} 캔들",
increasing_line_color="#ef4444",
decreasing_line_color="#3b82f6",
),
row=1,
col=1,
)
if "MA" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["MA"],
name="BB 중심",
line=dict(color="#64748b", width=1, dash="dot"),
),
row=1,
col=1,
)
if "Upper" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["Upper"],
name="BB 상단",
line=dict(color="#94a3b8", width=1),
),
row=1,
col=1,
)
if "Lower" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["Lower"],
name="BB 하단",
line=dict(color="#94a3b8", width=1),
),
row=1,
col=1,
)
if truth_trades:
_add_truth_markers(fig, truth_trades, row=1)
if sim_trades:
_add_sim_markers(fig, sim_trades, row=1)
disp_row = 2
for i, p in enumerate(DISPARITY_PERIODS):
col = disparity_column(p)
if col not in df.columns:
continue
color = disp_colors[i % len(disp_colors)]
fig.add_trace(
go.Scatter(
x=df.index,
y=df[col],
name=f"D.I. {p}",
line=dict(color=color, width=1),
),
row=disp_row,
col=1,
)
if any(disparity_column(p) in df.columns for p in DISPARITY_PERIODS):
fig.add_hline(
y=100, line_dash="solid", line_color="#64748b", row=disp_row, col=1
)
fig.add_hline(
y=DISPARITY_OVERBOUGHT,
line_dash="dot",
line_color="#ef4444",
row=disp_row,
col=1,
)
fig.add_hline(
y=DISPARITY_OVERSOLD,
line_dash="dot",
line_color="#16a34a",
row=disp_row,
col=1,
)
stoch_row = 3
if "stoch_k" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["stoch_k"],
name="Stoch %K",
line=dict(color="#0ea5e9", width=1),
),
row=stoch_row,
col=1,
)
fig.add_trace(
go.Scatter(
x=df.index,
y=df["stoch_d"],
name="Stoch %D",
line=dict(color="#f97316", width=1),
),
row=stoch_row,
col=1,
)
fig.add_hline(y=80, line_dash="dot", line_color="#9ca3af", row=stoch_row, col=1)
fig.add_hline(y=20, line_dash="dot", line_color="#9ca3af", row=stoch_row, col=1)
rsi_row = 4
if "RSI" in df.columns:
fig.add_trace(
go.Scatter(
x=df.index,
y=df["RSI"],
name="RSI",
line=dict(color="#7c3aed"),
),
row=rsi_row,
col=1,
)
fig.add_hline(y=70, line_dash="dot", line_color="#9ca3af", row=rsi_row, col=1)
fig.add_hline(y=30, line_dash="dot", line_color="#9ca3af", row=rsi_row, col=1)
macd_row = 5
vol_row = 6
if "macd_hist" in df.columns:
colors = np.where(df["macd_hist"].astype(float) >= 0, "#ef4444", "#3b82f6")
fig.add_trace(
go.Bar(
x=df.index,
y=df["macd_hist"],
name="MACD Hist",
marker_color=colors,
),
row=macd_row,
col=1,
)
fig.add_trace(
go.Scatter(
x=df.index,
y=df["macd_line"],
name="MACD",
line=dict(color="#2563eb", width=1),
),
row=macd_row,
col=1,
)
fig.add_trace(
go.Scatter(
x=df.index,
y=df["macd_signal"],
name="Signal",
line=dict(color="#ea580c", width=1, dash="dot"),
),
row=macd_row,
col=1,
)
fig.add_trace(
go.Bar(
x=df.index,
y=df["Volume"],
name="Volume",
marker_color="#cbd5e1",
),
row=vol_row,
col=1,
)
fig.update_layout(
height=1180,
template="plotly_white",
xaxis_rangeslider_visible=False,
legend=dict(orientation="h", y=1.05, x=0),
margin=dict(l=60, r=30, t=90, b=40),
)
fig.update_yaxes(title_text="가격 (KRW)", row=1, col=1)
fig.update_yaxes(title_text="이격도", row=2, col=1)
fig.update_yaxes(title_text="Stoch", row=3, col=1, range=[0, 100])
fig.update_yaxes(title_text="RSI", row=4, col=1, range=[0, 100])
fig.update_yaxes(title_text="MACD", row=5, col=1)
chart_html = fig.to_html(full_html=False, include_plotlyjs="cdn")
note_html = f"<p class='note'>{note}</p>" if note else ""
bb_pos_txt = f"{bb_pos:.2f}" if bb_pos is not None else "-"
pnl = pnl_summary or {}
if truth_trades and not pnl:
from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio
pnl = simulate_truth_portfolio(
truth_trades,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=close_last,
)
trade_rows = ""
trade_table = footer_sections or ""
if footer_sections is None and truth_trades:
from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio_steps
steps = simulate_truth_portfolio_steps(
truth_trades,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
)
step_key = {
(s["dt"], s["action"], float(s["price"]), float(s["weight"])): s
for s in steps
}
sorted_trades = sorted(truth_trades, key=lambda x: x["dt"])
trade_rows += f"""
<tr class="initial-row">
<td>시작</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td><b>₩{GT_INITIAL_CASH_KRW:,.0f}</b></td>
<td>초기 현금 (보유 0)</td>
</tr>"""
for t in sorted_trades:
cls = "buy" if t["action"] == "buy" else "sell"
mark = "매수" if t["action"] == "buy" else "매도"
ret = t.get("forward_return_pct")
ret_s = f" (+{ret}%)" if ret is not None else ""
w = float(t.get("weight", 1.0))
key = (t["dt"], t["action"], float(t["price"]), w)
step = step_key.get(key)
if step:
total_s = f"{step['total_asset_krw']:,.0f}"
hold_s = f" (현금 ₩{step['cash_krw']:,.0f} + 코인 {step['holding_qty']:,.2f}개)"
else:
total_s = "-"
hold_s = ""
trade_rows += f"""
<tr>
<td>{t['dt'][:16]}</td>
<td class="{cls}">{mark}</td>
<td>{w*100:.0f}%</td>
<td>₩{t['price']:,.0f}{ret_s}</td>
<td><b>{total_s}</b>{hold_s}</td>
<td>{t.get('memo', '')}</td>
</tr>"""
if not trade_table and truth_trades:
if not trade_rows:
trade_rows = "<tr><td colspan='6'>타점 없음</td></tr>"
mark_note = ""
if pnl.get("mark_price"):
mark_note = (
f" 총보유자산(미청산 포함)은 종가 ₩{pnl['mark_price']:,.0f} 평가."
)
trade_table = f"""
<h2>정답 타점 (ground_truth)</h2>
<p class="meta">삼각형 크기 = 체결 금액(매수/매도 각각 min~max). 매수: 저점 분할 / 매도: 고점 1~2회.
총평가 = 체결 직후 현금 + 보유×체결가.{mark_note}</p>
<table>
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
<tbody>{trade_rows}</tbody>
</table>"""
pnl_cards = ""
if truth_trades and pnl.get("initial_cash_krw") is not None:
from deepcoin.ops.chart_report import card_html, initial_change_pct
change_pct = initial_change_pct(pnl)
pnl_cards = (
card_html("초기 금액", f"{pnl['initial_cash_krw']:,.0f}")
+ card_html("총보유자산", f"{pnl['final_asset_krw']:,.0f}")
+ card_html("초기 대비 증감율", f"{change_pct:+.2f}%")
+ card_html("수수료", f"{pnl['total_fees_krw']:,.0f}")
)
if pnl.get("holding_qty", 0) > 0:
pnl_cards += card_html(
"미청산",
f"{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})",
)
default_legend = (
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 크기=체결금액. "
"매수=총자산×비중×leg티어(상위 대형). "
"툴팁: 체결가·매수/매도금액.<br>"
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = holdout 발화 "
f"(매수 ₩{LIVE_ORDER_KRW:,.0f}/회)."
)
if cards_html:
cards_inner = cards_html
else:
cards_inner = f"""
<div class="card"><span>종가</span><b>₩{close_last:,.2f}</b></div>
<div class="card"><span>BB %B</span><b>{bb_pos_txt}</b></div>
<div class="card"><span>정답 타점</span><b>{len(truth_trades) if truth_trades else 0}건</b></div>
{pnl_cards}"""
return wrap_chart_report_page(
page_title=f"{SYMBOL} {title_suffix}",
heading=f"{COIN_NAME} ({SYMBOL}) {title_suffix}",
meta_line=f"추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}",
note_html=note_html,
legend_html=legend_html or default_legend,
cards_html=cards_inner,
chart_html=chart_html,
sections_html=trade_table,
)
def _frames_to_mtf(
frames: dict[int, pd.DataFrame],
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
"""전 간격 frames에서 1d/1h/3m 추출."""
df_3m = frames.get(ENTRY_INTERVAL)
if df_3m is None or df_3m.empty:
raise ValueError(f"{ENTRY_INTERVAL}분봉 데이터 없음")
df_1d = frames.get(TREND_INTERVAL_1D)
if df_1d is None or df_1d.empty:
df_1d = df_3m
df_1h = frames.get(TREND_INTERVAL_1H)
if df_1h is None or df_1h.empty:
df_1h = df_3m
return df_1d, df_1h, df_3m
def load_chart_frames() -> dict[int, pd.DataFrame] | None:
"""coins.db 전 간격 로드. 부족 시 None."""
monitor = Monitor(cooldown_file=None)
print(f"DB 조회: 최근 {CHART_LOOKBACK_DAYS}일 (CHART_LOOKBACK_DAYS)")
frames = load_frames_from_db(monitor, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
if ENTRY_INTERVAL not in frames:
print("coins.db 데이터 부족. python scripts/01_download.py 실행 후 재시도.")
return None
return frames
def run_ground_truth_chart(
open_browser: bool = True,
*,
from_json: bool = True,
) -> Path:
"""
정답 타점 마커가 포함된 HTML 차트를 만듭니다.
Args:
open_browser: True면 브라우저로 HTML을 엽니다.
from_json: True면 기존 ground_truth_trades.json 을 사용합니다.
False면 DB에서 GT를 재생성합니다.
Returns:
HTML 파일 경로.
"""
from deepcoin.ground_truth.ground_truth import load_ground_truth, run_from_db
gt_path = resolve_ground_truth_file()
if from_json:
data = load_ground_truth(gt_path)
if not data:
print(f"GT JSON 없음({gt_path}) — DB에서 재생성합니다.")
data = run_from_db()
else:
print(f"GT JSON 로드: {gt_path}")
else:
data = run_from_db()
frames = load_chart_frames()
if frames is None:
raise RuntimeError("차트 데이터 로드 실패")
df_1d, df_1h, df_3m = _frames_to_mtf(frames)
trend = get_trend(df_1d, df_1h)
df_chart = apply_bar_indicators(df_3m)
trades = data.get("trades") or []
summary = data.get("summary") or {}
html = build_chart_html(
df_chart,
trend,
note=data.get("note", ""),
truth_trades=trades,
title_suffix=f"정답 타점 ({CHART_LOOKBACK_DAYS}일)",
pnl_summary=summary if summary.get("pnl_krw") is not None else None,
)
REPORT_DIR.mkdir(parents=True, exist_ok=True)
TRUTH_HTML.write_text(html, encoding="utf-8")
print(f"HTML: {TRUTH_HTML}")
if open_browser:
webbrowser.open(TRUTH_HTML.resolve().as_uri())
return TRUTH_HTML
def main() -> None:
"""05_chart_truth CLI 진입 (미사용 시 no-op)."""
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
print("GT 차트: python scripts/05_chart_truth.py")
return
run_ground_truth_chart(open_browser=False)
if __name__ == "__main__":
main()

View File

@@ -1,109 +0,0 @@
"""
DeepCoin 프로젝트 경로 (data + docs 통합).
docs/
reference/ 가이드·기법 명세 (Git 추적)
02_ground_truth/ … 05_ops/ 단계별 산출물 (로컬 재생성, Git 제외)
"""
from __future__ import annotations
import os
from pathlib import Path
# DeepCoin/ (이 파일: DeepCoin/deepcoin/paths.py)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
# --- data ---
DATA_DIR = PROJECT_ROOT / "data"
DB_DIR = DATA_DIR
GROUND_TRUTH_DIR = DATA_DIR / "ground_truth"
OPS_STATE_DIR = DATA_DIR / "ops"
COOLDOWN_FILE = OPS_STATE_DIR / "coins_buy_time.json"
# --- docs (reference + 단계별 산출물) ---
DOCS_DIR = PROJECT_ROOT / "docs"
DOCS_REFERENCE_DIR = DOCS_DIR / "reference"
DOCS_GROUND_TRUTH = DOCS_DIR / "02_ground_truth"
DOCS_ANALYSIS = DOCS_DIR / "03_analysis"
DOCS_MATCHING = DOCS_DIR / "04_matching"
DOCS_OPS = DOCS_DIR / "05_ops"
ANALYSIS_TRADES_CSV = DOCS_ANALYSIS / "general_analysis_trades.csv"
ANALYSIS_GT_MTF_PROFILE_JSON = DOCS_ANALYSIS / "gt_mtf_profile.json"
ANALYSIS_GT_MTF_PROFILE_HTML = DOCS_ANALYSIS / "gt_mtf_profile_report.html"
ANALYSIS_GT_CALIBRATION_JSON = DOCS_ANALYSIS / "gt_calibration_report.json"
ANALYSIS_REPORT_HTML = DOCS_ANALYSIS / "general_analysis_report.html"
ANALYSIS_CAPABILITY_HTML = DOCS_ANALYSIS / "general_analysis_capability.html"
ANALYSIS_LATEST_DIR = DOCS_ANALYSIS / "latest"
MATCHING_RULE_CANDIDATES = DOCS_MATCHING / "rule_candidates.json"
MATCHING_RULE_FIRES = DOCS_MATCHING / "rule_fires.csv"
MATCHING_FIRE_OUTCOMES = DOCS_MATCHING / "fire_outcomes.csv"
MATCHING_MATCHED_RULES = DOCS_MATCHING / "matched_rules.json"
MATCHING_BACKTEST_HTML = DOCS_MATCHING / "backtest_summary.html"
MATCHING_GT_OVERLAP = DOCS_MATCHING / "gt_overlap_report.json"
MATCHING_SIMULATION_JSON = DOCS_MATCHING / "simulation_report.json"
MATCHING_SIMULATION_HTML = DOCS_MATCHING / "simulation_report.html"
MATCHING_CAUSAL_GT_CALIBRATION_JSON = DOCS_MATCHING / "causal_gt_calibration.json"
MATCHING_HYBRID_DD_CALIBRATION_JSON = DOCS_MATCHING / "hybrid_dd_calibration.json"
LIVE_TRADES_LOG = OPS_STATE_DIR / "live_trades.jsonl"
LIVE_SIGNAL_HISTORY_JSON = OPS_STATE_DIR / "live_signal_history.json"
PORTFOLIO_SNAPSHOTS_LOG = OPS_STATE_DIR / "portfolio_snapshots.jsonl"
CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html"
# 하위 호환 (구 reports/ 이름)
REPORTS_DIR = DOCS_DIR
REPORTS_GROUND_TRUTH = DOCS_GROUND_TRUTH
REPORTS_ANALYSIS = DOCS_ANALYSIS
REPORTS_MATCHING = DOCS_MATCHING
REPORTS_OPS = DOCS_OPS
def resolve_db_path() -> Path:
"""존재하는 coins.db 경로."""
candidates = [
DATA_DIR / "coins.db",
PROJECT_ROOT / "coins.db",
]
for p in candidates:
if p.is_file():
return p
return DATA_DIR / "coins.db"
def resolve_ground_truth_file() -> Path:
"""존재하는 ground_truth_trades.json 경로."""
name = os.getenv("GROUND_TRUTH_FILE", "ground_truth_trades.json")
p = Path(name)
if p.is_absolute():
return p
candidates = [
GROUND_TRUTH_DIR / "ground_truth_trades.json",
PROJECT_ROOT / "ground_truth_trades.json",
PROJECT_ROOT / name,
]
for c in candidates:
if c.is_file():
return c
return GROUND_TRUTH_DIR / "ground_truth_trades.json"
def ensure_dirs() -> None:
"""단계별 출력·가이드 디렉터리 생성."""
for d in (
DATA_DIR,
GROUND_TRUTH_DIR,
OPS_STATE_DIR,
DOCS_DIR,
DOCS_REFERENCE_DIR,
DOCS_GROUND_TRUTH,
DOCS_ANALYSIS,
DOCS_MATCHING,
DOCS_OPS,
ANALYSIS_LATEST_DIR,
):
d.mkdir(parents=True, exist_ok=True)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,379 +0,0 @@
{
"symbol": "WLD",
"interval_min": 3,
"gt_pnl_pct": 4291.35,
"grid_combinations": 1728,
"valid_combinations": 432,
"min_trades": 30,
"best": {
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.0,
"sell_swing_pct": 3.0,
"bb_max": 0.55,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
"best_params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.0,
"sell_swing_pct": 3.0,
"bb_max": 0.55,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
},
"top": [
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.0,
"sell_swing_pct": 3.0,
"bb_max": 0.55,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.0,
"sell_swing_pct": 3.0,
"bb_max": 0.65,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.0,
"sell_swing_pct": 3.0,
"bb_max": 0.75,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.0,
"sell_swing_pct": 4.0,
"bb_max": 0.55,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.0,
"sell_swing_pct": 4.0,
"bb_max": 0.65,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.0,
"sell_swing_pct": 4.0,
"bb_max": 0.75,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.5,
"sell_swing_pct": 3.0,
"bb_max": 0.55,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.5,
"sell_swing_pct": 3.0,
"bb_max": 0.65,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.5,
"sell_swing_pct": 3.0,
"bb_max": 0.75,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.5,
"sell_swing_pct": 4.0,
"bb_max": 0.55,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.5,
"sell_swing_pct": 4.0,
"bb_max": 0.65,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 2.5,
"sell_swing_pct": 4.0,
"bb_max": 0.75,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 3.0,
"sell_swing_pct": 3.0,
"bb_max": 0.55,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 3.0,
"sell_swing_pct": 3.0,
"bb_max": 0.65,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 3.0,
"sell_swing_pct": 3.0,
"bb_max": 0.75,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 3.0,
"sell_swing_pct": 4.0,
"bb_max": 0.55,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 3.0,
"sell_swing_pct": 4.0,
"bb_max": 0.65,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.66,
"trade_count": 134,
"leg_count": 17,
"max_drawdown_pct": 0.96,
"capture_ratio": 0.0034,
"params": {
"peak_mode": "local",
"pivot_order": 8,
"buy_swing_pct": 3.0,
"sell_swing_pct": 4.0,
"bb_max": 0.75,
"min_leg_pct": 8.0,
"min_bars_between_legs": 60,
"use_local_trough": false
}
},
{
"pnl_pct": 14.35,
"trade_count": 133,
"leg_count": 18,
"max_drawdown_pct": 0.81,
"capture_ratio": 0.0033,
"params": {
"peak_mode": "local",
"pivot_order": 12,
"buy_swing_pct": 2.0,
"sell_swing_pct": 3.0,
"bb_max": 0.55,
"min_leg_pct": 8.0,
"min_bars_between_legs": 90,
"use_local_trough": false
}
},
{
"pnl_pct": 14.35,
"trade_count": 133,
"leg_count": 18,
"max_drawdown_pct": 0.81,
"capture_ratio": 0.0033,
"params": {
"peak_mode": "local",
"pivot_order": 12,
"buy_swing_pct": 2.0,
"sell_swing_pct": 3.0,
"bb_max": 0.65,
"min_leg_pct": 8.0,
"min_bars_between_legs": 90,
"use_local_trough": false
}
}
],
"target_pnl_pct": 300.0,
"target_met": false
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +0,0 @@
{
"best_params": {
"dd_large_pct": 5.0,
"dd_medium_pct": 2.0
},
"best_metrics": {
"dd_large_pct": 5.0,
"dd_medium_pct": 2.0,
"train_pnl_pct": 681.36,
"holdout_pnl_pct": 62.36,
"full_pnl_pct": 1120.97,
"max_drawdown_pct": 19.22,
"wf_positive_months": 11,
"wf_months": 12,
"large_tier_buys": 1598
},
"grid_size": 18,
"top5": [
{
"dd_large_pct": 5.0,
"dd_medium_pct": 2.0,
"train_pnl_pct": 681.36,
"holdout_pnl_pct": 62.36,
"full_pnl_pct": 1120.97,
"max_drawdown_pct": 19.22,
"wf_positive_months": 11,
"wf_months": 12,
"large_tier_buys": 1598
},
{
"dd_large_pct": 5.0,
"dd_medium_pct": 3.0,
"train_pnl_pct": 681.36,
"holdout_pnl_pct": 62.36,
"full_pnl_pct": 1120.97,
"max_drawdown_pct": 19.22,
"wf_positive_months": 11,
"wf_months": 12,
"large_tier_buys": 1598
},
{
"dd_large_pct": 5.0,
"dd_medium_pct": 4.0,
"train_pnl_pct": 681.36,
"holdout_pnl_pct": 62.36,
"full_pnl_pct": 1120.97,
"max_drawdown_pct": 19.22,
"wf_positive_months": 11,
"wf_months": 12,
"large_tier_buys": 1598
},
{
"dd_large_pct": 12.0,
"dd_medium_pct": 2.0,
"train_pnl_pct": 671.91,
"holdout_pnl_pct": 62.36,
"full_pnl_pct": 1106.19,
"max_drawdown_pct": 15.64,
"wf_positive_months": 11,
"wf_months": 12,
"large_tier_buys": 1531
},
{
"dd_large_pct": 12.0,
"dd_medium_pct": 3.0,
"train_pnl_pct": 671.91,
"holdout_pnl_pct": 62.36,
"full_pnl_pct": 1106.19,
"max_drawdown_pct": 15.64,
"wf_positive_months": 11,
"wf_months": 12,
"large_tier_buys": 1531
}
],
"holdout_start": "2026-04-11 05:33:00"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,435 +0,0 @@
{
"source": "/Users/xavis/workspace/ncue/DeepCoin/docs/03_analysis/general_analysis_trades.csv",
"profile_json": "/Users/xavis/workspace/ncue/DeepCoin/docs/03_analysis/gt_mtf_profile.json",
"calibration_json": "/Users/xavis/workspace/ncue/DeepCoin/docs/03_analysis/gt_calibration_report.json",
"buy_profile_features": [
"m3_bb_pos",
"m3_ga_cci_20",
"m3_ga_keltner_pos",
"m3_ga_mfi_14",
"m3_ga_donchian_pos",
"m3_RSI",
"m5_bb_pos",
"m5_ga_cci_20",
"m5_ga_keltner_pos",
"m5_ga_mfi_14",
"m5_ga_donchian_pos",
"m5_ga_cci_oversold",
"m10_bb_pos",
"m10_ga_cci_20",
"m10_ga_ha_bull",
"m10_ga_donchian_pos",
"m10_ga_keltner_pos",
"m10_stoch_k",
"m15_bb_pos",
"m15_ga_ha_bull",
"m15_ga_cci_20",
"m15_ga_keltner_pos",
"m15_ga_donchian_pos",
"m15_stoch_k",
"m30_ga_ha_bull",
"m30_bb_pos",
"m30_ga_cci_20",
"m30_ga_cci_overbought",
"m30_ga_donchian_pos",
"m30_ga_keltner_pos",
"m60_ga_ha_bull",
"m60_ga_cci_20",
"m60_bb_pos",
"m60_ga_williams_overbought",
"m60_ga_cci_overbought",
"m60_ga_keltner_pos",
"m240_ga_ha_bull",
"m240_ga_ha_trend_up",
"m240_ga_chart_ha_trend",
"m240_ga_cci_20",
"m240_bb_pos",
"m240_ga_keltner_pos",
"d1_ga_ha_bull",
"d1_ga_cci_oversold",
"d1_ga_cci_20",
"d1_ga_hammer",
"d1_bb_pos",
"d1_ga_chart_ha_trend",
"m10_ga_williams_r",
"m10_ga_mfi_14"
],
"sell_profile_features": [
"m3_bb_pos",
"m3_ga_cci_20",
"m3_ga_keltner_pos",
"m3_ga_mfi_14",
"m3_ga_donchian_pos",
"m3_RSI",
"m5_bb_pos",
"m5_ga_cci_20",
"m5_ga_keltner_pos",
"m5_ga_mfi_14",
"m5_ga_donchian_pos",
"m5_ga_cci_oversold",
"m10_bb_pos",
"m10_ga_cci_20",
"m10_ga_ha_bull",
"m10_ga_donchian_pos",
"m10_ga_keltner_pos",
"m10_stoch_k",
"m15_bb_pos",
"m15_ga_ha_bull",
"m15_ga_cci_20",
"m15_ga_keltner_pos",
"m15_ga_donchian_pos",
"m15_stoch_k",
"m30_ga_ha_bull",
"m30_bb_pos",
"m30_ga_cci_20",
"m30_ga_cci_overbought",
"m30_ga_donchian_pos",
"m30_ga_keltner_pos",
"m60_ga_ha_bull",
"m60_ga_cci_20",
"m60_bb_pos",
"m60_ga_williams_overbought",
"m60_ga_cci_overbought",
"m60_ga_keltner_pos",
"m240_ga_ha_bull",
"m240_ga_ha_trend_up",
"m240_ga_chart_ha_trend",
"m240_ga_cci_20",
"m240_bb_pos",
"m240_ga_keltner_pos",
"d1_ga_ha_bull",
"d1_ga_cci_oversold",
"d1_ga_cci_20",
"d1_ga_hammer",
"d1_bb_pos",
"d1_ga_chart_ha_trend",
"m10_ga_williams_r",
"m10_ga_mfi_14"
],
"buy_gt_count": 325,
"sell_gt_count": 160,
"rule_count": 12,
"rules": [
{
"rule_id": "buy_compound_top3",
"side": "buy",
"kind": "compound",
"conditions": [
{
"col": "m10_bb_pos",
"op": "between",
"lo": 0.0,
"hi": 0.2135380413505097
},
{
"col": "m5_bb_pos",
"op": "between",
"lo": 0.0010558937498797,
"hi": 0.2085149451832707
},
{
"col": "m5_ga_cci_20",
"op": "between",
"lo": -188.40579710144863,
"hi": -113.65120836054932
}
],
"profile_cols": [
"m10_bb_pos",
"m5_bb_pos",
"m5_ga_cci_20"
]
},
{
"rule_id": "buy_compound_tight",
"side": "buy",
"kind": "compound_tight",
"conditions": [
{
"col": "m10_bb_pos",
"op": "between",
"lo": 0.02269722528896669,
"hi": 0.16417141470116056
},
{
"col": "m5_bb_pos",
"op": "between",
"lo": 0.050986874829589954,
"hi": 0.17673357601131273
},
{
"col": "m5_ga_cci_20",
"op": "between",
"lo": -169.2052113398648,
"hi": -124.73123841544977
},
{
"col": "m10_ga_cci_20",
"op": "between",
"lo": -175.7862791639258,
"hi": -132.6451584428887
}
]
},
{
"rule_id": "buy_contrast_m10_bb_pos",
"side": "buy",
"kind": "contrast",
"conditions": [
{
"col": "m10_bb_pos",
"op": "between",
"lo": 0.02269722528896669,
"hi": 0.16417141470116056
},
{
"col": "m10_bb_pos",
"op": "lte",
"value": 0.8561892083441174
}
]
},
{
"rule_id": "buy_mtf_cross_all_tf",
"side": "buy",
"kind": "mtf_cross",
"conditions": [
{
"col": "m3_bb_pos",
"op": "between",
"lo": 0.0217975704436916,
"hi": 0.2431494170206665
},
{
"col": "m5_bb_pos",
"op": "between",
"lo": 0.0010558937498797,
"hi": 0.2085149451832707
},
{
"col": "m10_bb_pos",
"op": "between",
"lo": 0.0,
"hi": 0.2135380413505097
},
{
"col": "m15_bb_pos",
"op": "between",
"lo": 0.0,
"hi": 0.2485098739243929
},
{
"col": "m30_ga_ha_bull",
"op": "eq_int",
"value": 0
},
{
"col": "m60_ga_ha_bull",
"op": "eq_int",
"value": 0
},
{
"col": "m240_ga_ha_bull",
"op": "eq_int",
"value": 0
},
{
"col": "d1_ga_ha_bull",
"op": "eq_int",
"value": 0
}
]
},
{
"rule_id": "sell_compound_top3",
"side": "sell",
"kind": "compound",
"conditions": [
{
"col": "m10_bb_pos",
"op": "between",
"lo": 0.7897850598593097,
"hi": 1.0
},
{
"col": "m5_bb_pos",
"op": "between",
"lo": 0.776608631452867,
"hi": 0.9797670778565873
},
{
"col": "m5_ga_cci_20",
"op": "between",
"lo": 93.20386715706546,
"hi": 168.50552522505774
}
],
"profile_cols": [
"m10_bb_pos",
"m5_bb_pos",
"m5_ga_cci_20"
]
},
{
"rule_id": "sell_compound_tight",
"side": "sell",
"kind": "compound_tight",
"conditions": [
{
"col": "m10_bb_pos",
"op": "between",
"lo": 0.8328462659060121,
"hi": 0.9770682040908933
},
{
"col": "m5_bb_pos",
"op": "between",
"lo": 0.8247158558563334,
"hi": 0.9337055148500956
},
{
"col": "m5_ga_cci_20",
"op": "between",
"lo": 107.30920624132844,
"hi": 148.54421175101805
},
{
"col": "m10_ga_cci_20",
"op": "between",
"lo": 118.85113382022408,
"hi": 158.3923823574028
}
]
},
{
"rule_id": "sell_contrast_m10_bb_pos",
"side": "sell",
"kind": "contrast",
"conditions": [
{
"col": "m10_bb_pos",
"op": "between",
"lo": 0.8328462659060121,
"hi": 0.9770682040908933
},
{
"col": "m10_bb_pos",
"op": "gte",
"value": 0.14607994670516883
}
]
},
{
"rule_id": "sell_mtf_cross_all_tf",
"side": "sell",
"kind": "mtf_cross",
"conditions": [
{
"col": "m3_bb_pos",
"op": "between",
"lo": 0.6734769690278859,
"hi": 0.9584317797555851
},
{
"col": "m5_bb_pos",
"op": "between",
"lo": 0.776608631452867,
"hi": 0.9797670778565873
},
{
"col": "m10_bb_pos",
"op": "between",
"lo": 0.7897850598593097,
"hi": 1.0
},
{
"col": "m15_bb_pos",
"op": "between",
"lo": 0.8094990667113329,
"hi": 1.0
},
{
"col": "m30_ga_ha_bull",
"op": "eq_int",
"value": 1
},
{
"col": "m60_ga_ha_bull",
"op": "eq_int",
"value": 1
},
{
"col": "m240_ga_ha_bull",
"op": "eq_int",
"value": 1
},
{
"col": "d1_ga_ha_bull",
"op": "eq_int",
"value": 1
}
]
},
{
"rule_id": "gt_model_buy_zigzag_bb",
"side": "buy",
"kind": "gt_model",
"logic": "and",
"conditions": [
{
"col": "gt_buy_signal",
"op": "eq_int",
"value": 1
}
],
"gt_spec": "trough_zigzag + bb_pos <= GT_BUY_BB_MAX"
},
{
"rule_id": "gt_model_buy_trough_local",
"side": "buy",
"kind": "gt_model",
"logic": "and",
"conditions": [
{
"col": "gt_trough_local",
"op": "eq_int",
"value": 1
},
{
"col": "bb_pos",
"op": "lte",
"value": 0.45
}
],
"gt_spec": "local trough + bb filter"
},
{
"rule_id": "gt_model_sell_zigzag_peak",
"side": "sell",
"kind": "gt_model",
"logic": "and",
"conditions": [
{
"col": "gt_sell_signal",
"op": "eq_int",
"value": 1
}
],
"gt_spec": "major swing peak (ZigZag)"
},
{
"rule_id": "gt_model_sell_peak_local",
"side": "sell",
"kind": "gt_model",
"logic": "and",
"conditions": [
{
"col": "gt_peak_local",
"op": "eq_int",
"value": 1
}
],
"gt_spec": "local high extremum"
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

View File

@@ -1,115 +0,0 @@
# DeepCoin 배포 체크리스트 (GT → Simulation → Operations)
- **설계:** [ARCHITECTURE.md](../reference/ARCHITECTURE.md)
- **초기 자금:** ₩400,000 (`GT_INITIAL_CASH_KRW`)
- **운영 배분:** `sim_causal_hybrid` (= `06_execute_live`)
---
## 1. 한 장 요약
```text
[Ground Truth] 사후 ZigZag · 미래 허용 → 벤치마크 PnL
[Simulation] monitor_rules · 인과 스캔 · 40만·현금/보유 제약
[Operations] 동일 규칙·hybrid · LIVE=1 · 빗썸 실주문
```
| 경로 | 배포 |
|------|------|
| GT oracle | 아님 |
| **sim_causal_hybrid** | **예** (시뮬·운영 공통) |
| sim_tier_enhanced | **금지** |
---
## 2. Ground Truth
- [ ] `01_download.py` 완료 (주봉·월봉 포함)
- [ ] `02_ground_truth.py``CHART_LOOKBACK_DAYS` 의도 확인
- [ ] `03_analyze_enrich.py` + `03_analyze_trades.py` + `03_gt_mtf_profile.py`
- [ ] `05_chart_truth.py` — HTML·JSON 정합
- [ ] `GENERAL_ANALYSIS_INTERVALS``10080,43200` 포함
---
## 3. Simulation
- [ ] `GT_SIGNAL_CAUSAL=1`
- [ ] `04_match_rules.py``monitor_rules` 2개 (`buy_compound_tight`, `sell_mtf_cross_all_tf`)
- [ ] `04_simulation_report.py``simulation_report.html` 확인
- [ ] hybrid Go/No-Go·holdout·MDD 검토 ([SIMULATION.md](../reference/SIMULATION.md))
- [ ] conviction tier 미배포 확인
---
## 4. Operations
### 4.1 `.env`
```env
LIVE_TRADING_ENABLED=1
GT_SIGNAL_CAUSAL=1
SIM_PRIMARY_SIZING=auto
GT_INITIAL_CASH_KRW=400000
LIVE_DAILY_KRW_MAX=400000
LIVE_DAILY_LOSS_LIMIT_KRW=40000
LIVE_MAX_TRADES_PER_DAY=15
LIVE_COOLDOWN_MIN=3
MONITOR_LOOP_SLEEP_SEC=180
```
전체: [env.recommended.md](env.recommended.md)
### 4.2 기동 순서
```bash
python scripts/00_sync_ops.py
python scripts/06_verify_live.py
python scripts/check_balance.py
python scripts/06_execute_live.py --once
python scripts/06_execute_live.py
```
### 4.3 Go/No-Go (운영)
**GO**
- [ ] `06_verify_live.py` PASS
- [ ] 시뮬 hybrid 검토 완료
- [ ] 빗썸 API·잔고 정상
- [ ] `--once``data/ops/live_trades.jsonl` 확인
**NO-GO**
- verify FAIL
- monitor_rules ≠ 2개
- `GT_SIGNAL_CAUSAL=0`
### 4.4 Kill switch
- `06` 프로세스 중지
- 빗썸 수동 청산
---
## 5. 주기적 유지보수
| 주기 | 작업 |
|------|------|
| 일 1회 | `01_download` (또는 06 자동 sync) |
| 주 1회 | `06_verify_live` |
| 월 1회 | `02`~`04` 재실행·시뮬 Go 재확인 |
---
## 6. 스크립트 인덱스
| 축 | 스크립트 |
|----|----------|
| GT | `02`, `03_*`, `05_chart_truth` |
| Sim | `04_match_rules`, `04_simulation_report` |
| Ops | `06_verify_live`, `06_execute_live` |
스크립트 목록: `scripts/README.md`, `docs/reference/ARCHITECTURE.md`

View File

@@ -1,88 +0,0 @@
# 운영 `.env` 권장값 (Operations)
설계: [ARCHITECTURE.md](../reference/ARCHITECTURE.md). dry-run·Phase C 블록은 **사용하지 않습니다**.
---
## 공통 (GT · Simulation · Operations)
```env
SYMBOL=WLD
CHART_LOOKBACK_DAYS=365
# 10TF (주·월봉 포함)
GENERAL_ANALYSIS_INTERVALS=3,5,10,15,30,60,240,1440,10080,43200
TREND_INTERVALS=60,240,1440,10080,43200
# 인과 sim · live hybrid (필수)
GT_SIGNAL_CAUSAL=1
SIM_CAUSAL_TIER=1
SIM_PRIMARY_SIZING=auto
# hybrid DD (hybrid_dd_calibration.json 과 동기)
CAUSAL_GT_DD_LARGE_PCT=5.0
CAUSAL_GT_DD_MEDIUM_PCT=2.0
GT_BUY_PCT_LARGE_LEG=1.0
GT_BUY_PCT_SMALL_LEG=0.05
GT_BUY_PCT_MEDIUM_LEG=0.25
GT_LARGE_LEG_TOP_PCT=0.2
GT_MIN_ORDER_KRW=5000
GT_INITIAL_CASH_KRW=400000
MATCH_MONITOR_MAX_PER_SIDE=1
```
---
## Operations (실거래)
```env
LIVE_TRADING_ENABLED=1
LIVE_ORDER_KRW=40000
LIVE_DAILY_KRW_MAX=400000
LIVE_MAX_TRADES_PER_DAY=15
LIVE_COOLDOWN_MIN=3
LIVE_DAILY_LOSS_LIMIT_KRW=40000
LIVE_SLIPPAGE_PCT=0.05
MONITOR_ALERT_KRW_AMOUNT=40000
MONITOR_ALERT_COOLDOWN_MIN=3
MONITOR_LOOP_SLEEP_SEC=180
MATCH_LIVE_CACHE_SEC=180
```
| 변수 | 값 | 설명 |
|------|-----|------|
| `GT_INITIAL_CASH_KRW` | 400,000 | 시뮬·운영 배분 기준 |
| `LIVE_DAILY_KRW_MAX` | 400,000 | 일 매수 한도 (large tier 1회와 정합) |
| `LIVE_DAILY_LOSS_LIMIT_KRW` | 40,000 | 일 손실 중단 |
### 기동 확인
```bash
python scripts/06_verify_live.py
python scripts/check_balance.py
python scripts/06_execute_live.py --once
```
---
## 한도 상향 (검증 후만)
시뮬 hybrid에 근접하려면 `LIVE_DAILY_KRW_MAX` 등을 올릴 수 있으나 **MDD·슬리피지·실계좌 검증 후**에만 적용합니다.
---
## 절대 변경하지 말 것
```env
# LIVE_TRADING_ENABLED=0 → 06 기동 불가 (dry-run 제거됨)
# GT_SIGNAL_CAUSAL=0 → sim·live 불일치
# conviction tier 배포 금지 (enhanced=False 고정)
# 시뮬 sim_causal_hybrid 정합: fire_outcomes monitor 발화 부트스트랩
LIVE_HYBRID_BOOTSTRAP_FIRES=1
```
체크리스트: [DEPLOYMENT_CHECKLIST.md](./DEPLOYMENT_CHECKLIST.md)

View File

@@ -1,14 +0,0 @@
# docs
| 경로 | 축 | Git |
|------|-----|-----|
| [reference/ARCHITECTURE.md](reference/ARCHITECTURE.md) | **전체 설계** | 추적 |
| [reference/GROUND_TRUTH.md](reference/GROUND_TRUTH.md) | GT | 추적 |
| [reference/SIMULATION.md](reference/SIMULATION.md) | Simulation | 추적 |
| [reference/OPERATIONS.md](reference/OPERATIONS.md) | Operations | 추적 |
| [02_ground_truth/](02_ground_truth/) | GT 산출물 (JSON·HTML 재생성) | CSV/JSON 추적, HTML는 .gitignore |
| [03_analysis/](03_analysis/) | GT 입력·프로필 | CSV/JSON 추적, HTML·latest는 .gitignore |
| [04_matching/](04_matching/) | Sim 산출물 | JSON/CSV 추적, HTML 일부 .gitignore |
| [05_ops/](05_ops/) | Ops 가이드·검증 기록 | env.recommended 추적, live_verification_* .gitignore |
구조: [reference/STRUCTURE.md](reference/STRUCTURE.md)

View File

@@ -1,182 +0,0 @@
# DeepCoin 아키텍처 — Ground Truth · Simulation · Operations
프로젝트는 **세 축만** 사용합니다. 예전 Phase A/B/C·dry-run·paper 모의 체결은 제거되었습니다.
```mermaid
flowchart TB
subgraph data [공통 데이터]
DL[01_download]
DB[(coins.db)]
DL --> DB
end
subgraph gt [Ground Truth]
GT02[02_ground_truth]
GT03[03 분석·프로필]
GT05[05_chart_truth]
DB --> GT02
GT02 --> GJSON[ground_truth_trades.json]
GT03 --> GJSON
GJSON --> GT05
end
subgraph sim [Simulation]
M04[04_match_rules]
S04[04_simulation_report]
GJSON --> M04
DB --> M04
M04 --> MR[matched_rules.json]
MR --> S04
S04 --> SR[simulation_report.*]
end
subgraph ops [Operations]
V06[06_verify_live]
E06[06_execute_live]
MR --> E06
SR --> V06
E06 --> BT[빗썸 API]
end
```
---
## 1. Ground Truth (정답)
### Plan — 목적
- **벤치마크** 매수·매도 타점·leg·비중 (운영 목표 수익의 상한 참고).
- **미래 데이터 허용**: ZigZag·피벗은 사후 라벨링 (`ground_truth.py`).
- 시뮬·운영 규칙의 **교사 신호**가 아님 (oracle 직접 매매 금지).
### Do — 실행
| 순서 | 스크립트 | 산출 |
|------|----------|------|
| (선행) | `01_download.py` | `data/coins.db` (3m~일봉 + 주·월봉) |
| 1 | `02_ground_truth.py` | `data/ground_truth/ground_truth_trades.json` |
| 2 | `03_analyze_enrich.py` | `docs/03_analysis/latest/*` (10TF 지표) |
| 3 | `03_analyze_trades.py` | `general_analysis_trades.csv` (GT 타점 스냅샷) |
| 4 | `03_gt_mtf_profile.py` | `gt_mtf_profile.json` (규칙 후보 입력) |
| 5 | `05_chart_truth.py` | `docs/02_ground_truth/wld_ground_truth_chart.html` |
패키지: `deepcoin/ground_truth/`, `deepcoin/analysis/`
### Check — 핵심 설정
| 변수 | 의미 |
|------|------|
| `CHART_LOOKBACK_DAYS` | GT·스캔 구간 (기본 365) |
| `GT_INITIAL_CASH_KRW` | GT 포트폴리오 시뮬 초기 자금 (40만) |
| `GT_*` | ZigZag·분할 매수·leg 티어 |
### Act — 갱신 시
GT 파라미터·구간 변경 후 **02 → 03* → 05** 순 재실행.
---
## 2. Simulation (시뮬레이션)
### Plan — 목적
- GT 프로필에서 **규칙 후보** 생성 → 전 구간 **발화 스캔** → EV·holdout 검증.
- **과거 데이터만** 사용 (`GT_SIGNAL_CAUSAL=1`, `enrich_scan_frame_gt_signals(causal=True)`).
- 초기 자금 **40만 원**, **현금 있을 때만 매수**, **보유 있을 때만 매도** (`gt_allocation` / `HybridSimPortfolio`).
### Do — 실행
| 순서 | 스크립트 | 산출 |
|------|----------|------|
| 1 | `04_match_rules.py` | `matched_rules.json`, `fire_outcomes.csv` |
| 2 | `04_simulation_report.py` | `simulation_report.json/html`, Go/No-Go |
| (선택) | `04_hybrid_dd_calibrate.py` | `hybrid_dd_calibration.json` |
| (선택) | `04_causal_gt_calibrate.py` | `causal_gt_calibration.json` |
패키지: `deepcoin/matching/`
### Check — 운영 규칙·배분
| 항목 | 값 |
|------|-----|
| `monitor_rules` | `buy_compound_tight`, `sell_mtf_cross_all_tf` (주봉 w1 조건 포함 가능) |
| **권장 primary** | `sim_causal_hybrid` (hybrid DD tier, `enhanced=False`) |
| **배포 금지** | `sim_tier_enhanced` (conviction) |
| Go/No-Go | holdout EV, MDD, fee stress — [SIMULATION.md](SIMULATION.md) |
### Act — 재검증
데이터·GT·`GENERAL_ANALYSIS_INTERVALS` 변경 시 **04_match → 04_simulation** 재실행.
운영 전 **simulation_report.html** 에서 hybrid Go/No-Go 확인.
---
## 3. Operations (운영)
### Plan — 목적
- 시뮬과 **동일 규칙·동일 hybrid 배분**으로 빗썸 **실주문**.
- `LIVE_TRADING_ENABLED=1` **필수**. dry-run·paper 없음.
### Do — 실행
| 순서 | 스크립트 | 역할 |
|------|----------|------|
| (선행) | `00_sync_ops.py` | 운영 전 봉 동기화 |
| 1 | `06_verify_live.py` | 설정·규칙·한도·tier 점검 |
| 2 | `check_balance.py` | 빗썸 KRW·보유 확인 |
| 3 | `06_execute_live.py` | 실거래 루프 (`--once` 1회 테스트) |
| (선택) | `05_run_monitor.py` | 알림만 (주문 없음) |
패키지: `deepcoin/ops/live_trader.py`, `hybrid_sim_execution.py`
### Check — 제약 (시뮬과 동일)
| 제약 | 구현 |
|------|------|
| 초기 자금 | `GT_INITIAL_CASH_KRW=400000` (배분 기준) |
| 매수 | 가용 현금·수수료, `LIVE_DAILY_KRW_MAX`, EV/WF 통과 매수만 |
| 매도 | 보유 수량, `plan_live_hit``sell_qty` |
| 이력 | `LIVE_HYBRID_BOOTSTRAP_FIRES=1``fire_outcomes` monitor 발화 + 운영 체결분 |
| 인과 | 발화 시점까지 OHLC·지표만 (`live_eval` + causal GT signals) |
### Act — Kill switch
1. `06_execute_live` 프로세스 중지
2. 빗썸 앱 수동 청산
3. 필요 시 `01`~`04` 재실행 후 규칙·시뮬 재승인
---
## 스크립트 번호 ↔ 축 (빠른 참조)
| 축 | 스크립트 |
|----|----------|
| **데이터** | `01_download`, `00_sync_ops` |
| **GT** | `02`, `03_*`, `05_chart_truth` |
| **Sim** | `04_match_rules`, `04_simulation_report`, `04_*_calibrate` |
| **Ops** | `06_verify_live`, `06_execute_live`, `check_balance`, `05_run_monitor` |
| **검증** | `verify_env.py`, `test_buy_sell_rehearsal.py` |
---
## 데이터·미래 사용 정책
| 축 | 미래 데이터 |
|----|-------------|
| Ground Truth | **허용** (라벨링·벤치마크) |
| Simulation | **금지** (발화·스캔·배분 tier는 과거 leg·DD만) |
| Operations | **금지** (Simulation과 동일 규칙·엔진) |
---
## 관련 문서
| 문서 | 내용 |
|------|------|
| [GROUND_TRUTH.md](GROUND_TRUTH.md) | GT 타점·배분 상세 |
| [SIMULATION.md](SIMULATION.md) | Go/No-Go·portfolio_compare |
| [LIVE_TRADING.md](LIVE_TRADING.md) | 06 실거래 |
| [OPERATIONS.md](OPERATIONS.md) | 일상 루틴 |
| [RISK.md](RISK.md) | 한도·Kill switch |
| [DEPLOYMENT_CHECKLIST.md](../05_ops/DEPLOYMENT_CHECKLIST.md) | 기동 체크리스트 |

View File

@@ -1,80 +0,0 @@
# Ground Truth (정답 타점)
설계: [ARCHITECTURE.md](ARCHITECTURE.md) — **Ground Truth** 축.
1년(기본 `CHART_LOOKBACK_DAYS=365`) 3분봉에서 **사후 최적 스윙** 매수·매도 라벨.
**미래 데이터 허용** (ZigZag). Simulation·Operations에는 oracle을 직접 쓰지 않습니다.
JSON 필드 `model`에 타점·비중·자본 배분 규칙이 일반화되어 있습니다 (`deepcoin/ground_truth/gt_model.py`).
## Plan — 타점 구조 (일반화)
### Leg (라운드트립 구간)
- **leg_id**: 이전 **고점 매도** 시각 ~ 다음 **고점 매도** 직전까지.
- 마지막 구간: 마지막 major peak 이후 ~ 기간 말 → **기간말 leg** (종가 청산 1회).
### 매수 타점 (Entry)
| 항목 | 규칙 |
|------|------|
| 피벗 | ZigZag **저점(trough)**, `GT_BUY_MIN_SWING_PCT` |
| 가격 | 해당 봉 **Low** |
| 후보 | leg 구간 내 trough, `GT_BUY_MIN_BARS` 간격, BB (`bb_pos <= GT_BUY_BB_MAX`) |
| **비중 weight** | `w_i = (1/price_i) / Σ(1/price_j)`**저가일수록 큰 비중** |
| leg당 상한 | `GT_MAX_BUYS_PER_LEG` (초과 시 저가 순 유지) |
### 매도 타점 (Exit)
| 항목 | 규칙 |
|------|------|
| 피벗 | **major swing 고점(peak)** |
| 가격 | 해당 봉 **High** |
| **비중 weight** | 1회 매도: **100%** · 2회 분할: **65% + 35%** (`GT_SELL_SPLIT_GAP_PCT`) |
| 수량 | leg 보유 수량 × 매도 비중 (마지막 매도 = leg 전량) |
## Do — 자본 배분 (amount_krw)
시각순 체결. **매도 후 현금**이 다음 매수에 반영됩니다.
```
총보유자산 = 현금 + 보유×체결가
최적매수율 = (이번 weight / leg 남은 weight 합) × leg티어스케일
목표매수액 = 총보유자산 × 최적매수율
실제매수액 = min(목표, 가용현금/(1+수수료)), 최소 GT_MIN_ORDER_KRW
```
| leg 티어 | 조건 | 스케일 (`.env`) |
|----------|------|-----------------|
| 대형 | leg 수익률 상위 `GT_LARGE_LEG_TOP_PCT` | `GT_BUY_PCT_LARGE_LEG` (기본 1.0) |
| 소형 | 그 외 | `GT_BUY_PCT_SMALL_LEG` (기본 0.05) |
**summary.pnl_pct**: 위 배분으로 **시각순** 시뮬 + 기간말 **종가 평가**.
**JSON 저장 순서**: leg별 매수 전량 → 매도 전량 (`leg_block`, 차트·테이블 정합).
## 실행
```bash
python scripts/02_ground_truth.py # ground_truth_trades.json (+ model)
python scripts/05_chart_truth.py # HTML 차트
```
## Check — 주요 환경 변수
| 변수 | 기본 | 설명 |
|------|------|------|
| `GT_MIN_SWING_PCT` | 4.0 | 매도 피벗 ZigZag(%) |
| `GT_BUY_MIN_SWING_PCT` | 3.0 | 매수 피벗 ZigZag(%) |
| `GT_PIVOT_ORDER` | 20 | 국소 극값 반경 |
| `GT_MIN_BARS_BETWEEN` | 30 | 체결 최소 간격(봉) |
| `GT_MIN_LEG_PCT` | 8.0 | major leg 최소 수익(%) |
| `GT_BUY_PCT_LARGE_LEG` | 1.0 | 상위 leg 총자산 배분 스케일 |
| `GT_BUY_PCT_SMALL_LEG` | 0.05 | 소형 leg 스케일 |
| `GT_LARGE_LEG_TOP_PCT` | 0.2 | 대형 leg 상위 비율 |
| `GT_MIN_ORDER_KRW` | 5000 | 최소 체결 원화 |
## Act
- JSON·`model` 수정 후 `02` / `05` 재실행
- 시뮬 비교: `04_simulation_report.py` (GT vs 시뮬·총자산% vs 고정 ₩/회)

View File

@@ -1,67 +0,0 @@
# 3단계 — 실거래 (hybrid primary)
## 운영 모델 (시뮬과 동일)
실거래는 **시뮬 `sim_causal_hybrid`** 와 같은 배분 경로만 사용합니다. dry-run·모의 체결은 제거되었습니다.
| 구분 | 내용 |
|------|------|
| 신호 | `matched_rules.json``monitor_rules` 2개 |
| 매수 규칙 | `buy_compound_tight` |
| 매도 규칙 | `sell_mtf_cross_all_tf` (주봉 w1 조건 포함) |
| 배분 | hybrid DD tier + past-leg, **`enhanced=False`** |
| 이력 | `LIVE_HYBRID_BOOTSTRAP_FIRES=1``fire_outcomes` monitor 발화 + 운영 체결분 |
| 체결 엔진 | `plan_live_hit` = `replay_hybrid_signals` (= `build_monitor_hybrid_sized_trades`) |
| 매수 필터 | 시뮬과 동일 — monitor 발화 전체 (`approved_buy_rules=None`) |
| 금지 | `sim_tier_enhanced` (conviction), GT oracle 타점 |
코드: `deepcoin/ops/hybrid_sim_execution.py``live_trader.py` (`LIVE_TRADING_ENABLED=1` 필수)
## 기동 전 점검
```bash
python scripts/06_verify_live.py # 설정·tier·규칙·한도
python scripts/check_balance.py # 빗썸 KRW·보유 확인
```
## 실거래 실행
`.env``LIVE_TRADING_ENABLED=1` 확인 후:
```bash
python scripts/06_execute_live.py --once # 1회
python scripts/06_execute_live.py # 상시 루프
```
## 환경 변수 (운영 예)
| 변수 | 예 | 설명 |
|------|-----|------|
| `LIVE_TRADING_ENABLED` | 1 | 0이면 06 기동 불가 |
| `GT_SIGNAL_CAUSAL` | 1 | hybrid sizing (과거만) |
| `GT_INITIAL_CASH_KRW` | 400000 | 시뮬·배분 기준 |
| `LIVE_DAILY_KRW_MAX` | 400000 | 일 주문 한도 |
| `LIVE_DAILY_LOSS_LIMIT_KRW` | 40000 | 일 손실 한도 |
| `LIVE_COOLDOWN_MIN` | 3 | 규칙당 쿨다운(분) |
| `LIVE_MAX_TRADES_PER_DAY` | 15 | 일 최대 체결 수 |
전체: [env.recommended.md](../05_ops/env.recommended.md)
## 주문·배분 동작
- 발화 이력 → `plan_live_hit` / `replay_hybrid_signals` (인과, 현금·보유 제약)
- **매수:** EV/WF 통과 규칙만, 가용 현금 범위, 일한도·쿨다운
- **매도:** 보유 수량 필요, 시뮬 분할 `sell_qty` 기준 시장가 매도
## 로그
| 경로 | 내용 |
|------|------|
| `data/ops/live_trades.jsonl` | 실주문 기록 |
| `data/ops/live_signal_history.json` | 발화 이력 (배분 정합) |
| `docs/05_ops/live_verification_*.md` | `06_verify_live.py` 점검 기록 |
## Kill switch
1. `06_execute_live` 프로세스 중지
2. 빗썸 앱에서 수동 청산

View File

@@ -1,133 +0,0 @@
# 운영 가이드
## 로드맵과 현재 위치 (2026-06-01)
| 순서 | 단계 | 상태 |
|------|------|------|
| 1 | 시뮬레이션 | **완료** (GO) — [SIMULATION.md](SIMULATION.md) |
| 2 | 문서화 | **본 문서군** — SIMULATION / LIVE / RISK / OPERATIONS |
| 3 | 오픈 (실거래) | **운영 중** — [LIVE_TRADING.md](LIVE_TRADING.md) |
| 4 | 실계좌 검증 | 1~2주 |
| 5 | 지속 운영 | 06 상시 + 월간 재시뮬 |
배포 모델: **hybrid primary** (= 시뮬 `sim_causal_hybrid`). 상세: [LIVE_TRADING.md](LIVE_TRADING.md)
---
## 단계별 스크립트
| 단계 | 스크립트 | 산출·역할 |
|------|----------|-----------|
| 01 | `01_download.py` | `coins.db` (3~1440분, 1분 제외) |
| (자동) | 05/06·`load_frames_from_db` | API 수집 시 `MONITOR_PERSIST_CANDLES=1`이면 **즉시 DB INSERT** |
| 02 | `02_ground_truth.py` | GT JSON·차트 |
| 03 | `03_analyze_enrich.py` | `docs/03_analysis/latest/` |
| 03b | `03_analyze_trades.py` | GT MTF 스냅샷 CSV |
| 03c | `03_gt_mtf_profile.py` | GT 프로필 (04 입력) |
| 04 | `04_match_rules.py` | `matched_rules.json` |
| 04 시뮬 | `04_simulation_report.py` | Go/No-Go 리포트 |
| 05 | `05_run_monitor.py` | 알림 (주문 없음) |
| 06 | `06_execute_live.py` | 알림+주문 (LIVE=1) |
| 점검 | `06_verify_live.py` | hybrid·한도·규칙 PASS (`LIVE=1`) |
| 환경 | `verify_env.py` | `.env`·경로 검증 |
구조: [STRUCTURE.md](STRUCTURE.md)
---
## 실거래 운영 (LIVE=1)
### `.env` 핵심
```env
LIVE_TRADING_ENABLED=1
GT_SIGNAL_CAUSAL=1
SIM_PRIMARY_SIZING=auto
MONITOR_LOOP_SLEEP_SEC=180
```
전체: [env.recommended.md](../05_ops/env.recommended.md)
### 매일 루틴
| 순서 | 명령 | 빈도 |
|------|------|------|
| 1 | `python scripts/01_download.py` | 1일 1회 |
| 2 | `python scripts/06_verify_live.py` | 기동 전·1일 1회 |
| 3 | `python scripts/06_execute_live.py` | 상시 (빗썸 실주문) |
선택: `python scripts/05_run_monitor.py` (알림만 병행)
### 기동 절차
1. [env.recommended.md](../05_ops/env.recommended.md) 적용
2. `LIVE_TRADING_ENABLED=1` 확인
3. `06_verify_live.py` → PASS
4. `06_execute_live.py --once``live_trades.jsonl` 확인
5. `06_execute_live.py` 상시
체크리스트: [DEPLOYMENT_CHECKLIST.md](../05_ops/DEPLOYMENT_CHECKLIST.md)
---
## Phase B-2 이후 (검증 통과 시)
- `LIVE_DAILY_KRW_MAX` 등 한도 상향 (리스크 문서·본인 판단)
- 주간 `04_simulation_report.py`로 EV·hybrid Go 재확인
---
## 텔레그램
| 스크립트 | 내용 |
|----------|------|
| 05 | 규칙 발화·MTF 요약 (주문 없음) |
| 06 | 발화 + 실주문 체결 (`LIVE=1` 필수) |
중복 완화: `MONITOR_ALERT_COOLDOWN_MIN`, `LIVE_COOLDOWN_MIN`
---
## 장애 대응
| 증상 | 조치 |
|------|------|
| 주문 실패 | `live_trades.jsonl`, API 키·잔고 → `LIVE_TRADING_ENABLED=0` |
| 알림만, 주문 없음 | `LIVE_TRADING_ENABLED=1` 및 06 프로세스 확인 |
| verify FAIL | `.env`, `matched_rules` 2개, `GT_SIGNAL_CAUSAL=1` |
| hybrid 금액 0 | 현금·EV/WF·tier 스킵 로그 확인 |
| 과다 알림 | 쿨다운 증가 |
| 시뮬과 수익 괴리 | 일한도·슬리피지 — [RISK.md](RISK.md) |
---
## 데이터·산출물 경로
| 경로 | 용도 |
|------|------|
| `data/coins.db` 또는 루트 `coins.db` | OHLCV |
| `data/ground_truth/ground_truth_trades.json` | GT |
| `docs/04_matching/matched_rules.json` | 운영 규칙 |
| `docs/04_matching/simulation_report.html` | 시뮬 리포트 |
| `data/ops/live_trades.jsonl` | 06 로그 |
---
## 오픈 당일 체크리스트 (3단계)
- [ ] 시뮬·hybrid Go/No-Go **GO** (참고)
- [ ] 운영 `.env`
- [ ] `06_verify_live.py` PASS
- [ ] `LIVE_TRADING_ENABLED=1` 의도 확인
- [ ] `--once``live_trades.jsonl`
- [ ] [RISK.md](RISK.md) Kill switch 숙지
---
## 관련 문서
- [ROADMAP.md](ROADMAP.md)
- [SIMULATION.md](SIMULATION.md)
- [LIVE_TRADING.md](LIVE_TRADING.md)
- [RISK.md](RISK.md)
- [DEPLOYMENT_CHECKLIST.md](../05_ops/DEPLOYMENT_CHECKLIST.md)

View File

@@ -1,51 +0,0 @@
# 리스크 — Simulation · Operations
설계: [ARCHITECTURE.md](ARCHITECTURE.md)
## 모델 리스크 (배포 경로)
| 경로 | 리스크 수준 | 조치 |
|------|-------------|------|
| **hybrid primary** (`sim_causal_hybrid`) | 배포 허용 | `06_verify_live` 후 소액 시작 |
| GT oracle | **운영 금지** | 벤치마크만 (미래 허용) |
| conviction (`sim_tier_enhanced`) | **금지** | `enhanced=False` 고정 |
| sim 고수익 ≠ 실현 | **구조적 갭** | 일한도·슬리피지·부분 체결 |
시뮬 Go/No-Go는 과거 데이터 가정이며 실계좌 수익을 보장하지 않습니다.
## 자금·한도 (Operations)
| 원칙 | 내용 |
|------|------|
| 초기 기준 | `GT_INITIAL_CASH_KRW=400000` |
| 일 매수 | `LIVE_DAILY_KRW_MAX` — hybrid 1회액 초과 시 스킵 |
| 일 손실 | `LIVE_DAILY_LOSS_LIMIT_KRW` 초과 시 당일 중단 |
| 매수 | EV/WF 통과 규칙 + 가용 현금 |
| 매도 | 보유 수량 필요 |
한도는 [env.recommended.md](../05_ops/env.recommended.md) 기준으로 시작하고, 검증 후에만 상향합니다.
## 시장·기술 리스크
| 리스크 | 완화 |
|--------|------|
| API 장애 | 재시도·06 중지 |
| 슬리피지 | 소액·`live_trades.jsonl` 기록 |
| 봉 지연 | `01_download` / `00_sync_ops` |
| 단일 종목(WLD) | 일한도·포지션 상한 |
## Kill switch
1. `06_execute_live` 프로세스 중지
2. 빗썸 수동 청산
## 재평가
| 주기 | 작업 |
|------|------|
| 주 1회 | `06_verify_live.py` |
| 월 1회 | `04_simulation_report.py` |
## 면책
실거래 손익은 운영자 책임입니다. 본 저장소는 투자 자문이 아닙니다.

View File

@@ -1,33 +0,0 @@
# DeepCoin 로드맵
설계: [ARCHITECTURE.md](ARCHITECTURE.md) — **GT → Simulation → Operations** 만 사용합니다.
## 축별 상태
| 축 | 상태 | 다음 액션 |
|----|------|-----------|
| **Ground Truth** | 1년 GT·10TF 프로필 | 구간·파라미터 변경 시 `02`~`03` 재실행 |
| **Simulation** | hybrid Go/No-Go (MDD 참고) | 월 1회 `04_simulation_report` |
| **Operations** | LIVE=1, dry-run 제거 | `06_verify_live``06_execute_live` |
## 권장 워크플로
```text
01 download
→ 02 GT (+ 03 분석·프로필, 05 차트)
→ 04 match + 04 simulation (인과, 40만 원)
→ 06 verify + 06 execute (동일 monitor_rules)
```
운영 배분: **sim_causal_hybrid** = **06 live** (`hybrid_sim_execution`)
## 문서 맵
| 문서 | 축 |
|------|-----|
| [GROUND_TRUTH.md](GROUND_TRUTH.md) | GT |
| [SIMULATION.md](SIMULATION.md) | Simulation |
| [LIVE_TRADING.md](LIVE_TRADING.md) | Operations |
| [OPERATIONS.md](OPERATIONS.md) | Operations |
| [RISK.md](RISK.md) | Operations |
| [DEPLOYMENT_CHECKLIST.md](../05_ops/DEPLOYMENT_CHECKLIST.md) | 기동 체크 |

View File

@@ -1,111 +0,0 @@
# Simulation (시뮬레이션)
설계: [ARCHITECTURE.md](ARCHITECTURE.md). Ground Truth **이후**, Operations **이전** 단계입니다.
## 목적
1. `monitor_rules`가 holdout·walk-forward·수수료 스트레스를 통과하는지 검증한다.
2. 운영에 쓸 **배분 경로(primary)** 를 정한다. (실전 = **hybrid**, GT oracle 아님)
## 실행
```bash
python scripts/04_match_rules.py # 선행
python scripts/04_simulation_report.py
```
## 산출물
| 파일 | 내용 |
|------|------|
| `docs/04_matching/simulation_report.json` | Go/No-Go, `portfolio_compare`, walk-forward |
| `docs/04_matching/simulation_report.html` | GT·시뮬 경로별 카드·차트 |
## 배포 모델 (primary)
| 경로 | `portfolio_compare` 키 | 전기간 PnL (최근 실행) | 운영 |
|------|------------------------|------------------------|------|
| GT oracle (사후 ZigZag) | `ground_truth_chrono` | +4,291% | **미사용** (미래 허용 벤치마크) |
| **권장 primary** | `sim_primary` = `sim_causal_hybrid` | (리포트 참고) | **Operations 배분** |
| causal tier only | `sim_sized` | +75% | 미사용 |
| 인과 GT leg 엔진 | `sim_causal_gt` | +15% | 미사용 |
| conviction tier | `sim_tier_enhanced` | -51% | **금지** |
| 고정 금액 baseline | `sim_fixed_order` | -94% | 비교용 |
- `primary_sizing`: **hybrid** (`go_no_go_hybrid.primary_sizing`)
- hybrid: monitor 발화 + **DD tier + past-leg tier**, `enhanced=False`
- 코드: `deepcoin/ground_truth/causal_gt_hybrid.py`, `deepcoin/matching/simulation.py`
## Go/No-Go (최근 실행 요약)
### 규칙 (`go_no_go`)
| rule_id | holdout EV | WF+ 비율 | fee 2× EV | 결과 |
|---------|------------|----------|-----------|------|
| `buy_compound_tight` | 5.66 | 0.75 | 4.99 | PASS |
| `sell_mtf_cross_all_tf` | 7.13 | 1.00 | 7.12 | PASS |
**GO**
### hybrid primary (`go_no_go_hybrid`)
| 검사 | 값 | 결과 |
|------|-----|------|
| monitor_rules_go | - | PASS |
| hybrid_holdout_pnl | +62.35% | PASS |
| hybrid_max_mdd | 19.22% | PASS |
| hybrid_fee_stress_pnl | +975.74% | PASS |
| option_c_target_300pct (optional) | +1,147% | PASS |
**GO** · `primary_sizing=hybrid`
### Option C 2차 (`go_no_go_option_c_phase2`)
- 전기간 +1,000% 목표, GT capture ≥23%, WF 양수 월 비율, 슬리피지 스트레스 등 → **GO**
재실행 후 수치는 `simulation_report.json`을 기준으로 한다.
## 포트폴리오 비교 (`portfolio_compare`) — 읽는 법
| 키 | 설명 |
|----|------|
| `ground_truth_chrono` | GT 타점·`amount_krw` 시각순 체결 (상한 벤치마크) |
| `sim_primary` / `sim_causal_hybrid` | **운영 배분과 동일** (monitor + hybrid tier) |
| `sim_sized` | EV/WF·leg 가중 복리 (구 경로) |
| `sim_hybrid_holdout` | hybrid 전기간 복리 후 **holdout 구간** 자산 증감 |
| `sim_hybrid_fee_stress` | 수수료 스트레스 hybrid |
| `hybrid_dd_params` | `dd_large_pct`, `dd_medium_pct` (캘리브 JSON과 동기) |
## 검증 항목
| 항목 | 설명 |
|------|------|
| Holdout | 규칙별 EV≥0, PF≥1 |
| Walk-forward | 월별 EV·`SIM_GO_WF_POSITIVE_RATIO` |
| 수수료 스트레스 | `SIM_FEE_STRESS_MULT` (기본 2×) |
| hybrid | holdout PnL, MDD, fee stress, (선택) +300% |
| 슬리피지 | Option C 2차 — 체결가 불리 가정 후 흑자 여부 |
## 시뮬 vs 실운영 (기대 갭)
시뮬 hybrid는 **일한도 없이** 복리·전액 배분을 가정한다. 실거래는 `LIVE_DAILY_KRW_MAX` 등으로 체결이 잘리므로 **수익률이 sim_primary와 같지 않을 수 있다**. (배포 체크리스트 D6: 실현=sim 미달)
## 환경 변수
| 변수 | 용도 |
|------|------|
| `SIM_GO_*`, `SIM_FEE_STRESS_MULT`, `SIM_WALK_FORWARD_MIN_MONTHS` | 규칙 Go/No-Go |
| `SIM_PRIMARY_SIZING` | `auto` \| `hybrid` \| `causal_tier` (권장: **auto** → hybrid) |
| `GT_SIGNAL_CAUSAL` | 1 — hybrid live sizing 활성 |
| `CAUSAL_GT_DD_LARGE_PCT`, `CAUSAL_GT_DD_MEDIUM_PCT` | hybrid tier (캘리브와 동기) |
| `GT_BUY_PCT_*`, `GT_LARGE_LEG_TOP_PCT` | tier 비중 |
## NO-GO 시
1. `04_match_rules.py` 재실행 (프로필·선별)
2. `04_simulation_report.py` 재실행
3. `go_no_go` / `go_no_go_hybrid` checks 확인
## 다음 단계
- **Operations:** [LIVE_TRADING.md](LIVE_TRADING.md), [OPERATIONS.md](OPERATIONS.md), [DEPLOYMENT_CHECKLIST.md](../05_ops/DEPLOYMENT_CHECKLIST.md)

View File

@@ -1,44 +0,0 @@
# DeepCoin 프로젝트 구조
실행은 **`scripts/`** 만 사용합니다. 설계는 **Ground Truth · Simulation · Operations** 세 축입니다.
[ARCHITECTURE.md](ARCHITECTURE.md)
## 축별 매핑
| 축 | 패키지 | 스크립트 | 산출물 |
|----|--------|----------|--------|
| **데이터** | `deepcoin/data/` | `01_download`, `00_sync_ops` | `data/coins.db` |
| **Ground Truth** | `ground_truth/`, `analysis/` | `02`, `03_*`, `05_chart_truth` | `ground_truth_trades.json`, `docs/02_ground_truth/` |
| **Simulation** | `matching/` | `04_match_rules`, `04_simulation_report` | `docs/04_matching/` |
| **Operations** | `ops/` | `06_verify_live`, `06_execute_live` | `data/ops/live_trades.jsonl` |
보조: `05_run_monitor` (알림), `verify_env`, `test_buy_sell_rehearsal`
## 디렉터리 트리
```text
DeepCoin/
├── .env, config.py
├── deepcoin/
│ ├── api/
│ ├── data/
│ ├── ground_truth/
│ ├── analysis/
│ ├── matching/
│ └── ops/
├── scripts/
├── data/
└── docs/
├── reference/ # ARCHITECTURE, GROUND_TRUTH, SIMULATION …
├── 02_ground_truth/
├── 03_analysis/
├── 04_matching/
└── 05_ops/
```
## `docs/` 규칙
- `docs/reference/` — 가이드 (Git)
- `docs/02_*` ~ `docs/05_ops/` — 스크립트 재생성 산출물
코드의 `REPORTS_*` 별칭은 `deepcoin/paths.py`에서 `docs/*`로 연결됩니다.

View File

@@ -1,10 +0,0 @@
# DeepCoin 권장 conda 환경 (기존 ncue에 설치)
# 생성: conda env create -f environment.yml (이미 ncue가 있으면 activate 후 pip만)
name: ncue
channels:
- defaults
dependencies:
- python>=3.10
- pip
- pip:
- -r requirements.txt

View File

@@ -1,8 +1,3 @@
pandas python-dotenv==1.0.1
numpy requests==2.32.3
PyJWT pandas==2.2.3
requests
python-dateutil
python-dotenv>=1.0.0
python-telegram-bot
plotly

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env python3
"""운영 전 누락 봉 증분 보완 (05/06 시작 시 자동 호출과 동일)."""
import argparse
import runpy
from pathlib import Path
runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
from deepcoin.data.ops_sync import ensure_ops_candles
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="운영 전 coins.db 누락 봉 동기화")
parser.add_argument(
"--force",
action="store_true",
help="최신 간격도 전부 재수집",
)
parser.add_argument(
"--verbose",
action="store_true",
help="API 수집 진행 로그 출력",
)
args = parser.parse_args()
ensure_ops_candles(force=args.force, verbose_download=args.verbose)

View File

@@ -1,11 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""01단계: WLD 봉 데이터 다운로드 → data/coins.db""" """0단계: 빗썸 캔들 데이터 수집 (download_candles.py 래퍼)."""
import runpy import runpy
from pathlib import Path from pathlib import Path
runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
from deepcoin.data.downloader import download
if __name__ == "__main__": if __name__ == "__main__":
download() target = Path(__file__).resolve().parent / "download_candles.py"
runpy.run_path(str(target), run_name="__main__")

View File

@@ -1,11 +1,123 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Ground Truth: 매수·매도 정답 타점 JSON 생성.""" """1단계: Ground Truth — 사후 최적 스윙 매수·매도 타점 (1매수·1매도 레그)."""
import runpy
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path from pathlib import Path
runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from deepcoin.config import load_settings
from deepcoin.data.intervals import interval_label
from deepcoin.ground_truth.chart import render_ground_truth_chart
from deepcoin.ground_truth.ground_truth import GtParams, build_ground_truth, save_ground_truth
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="Ground Truth 스윙 레그 생성 (1단계)")
parser.add_argument("--interval", type=int, default=None, help="GT 인터벌(분)")
parser.add_argument("--days", type=int, default=None, help="GT·타점 기간(일). 기본 730(2년)")
parser.add_argument("--zigzag", type=float, default=None, help="ZigZag 되돌림 %%")
parser.add_argument("--min-leg", type=float, default=None, help="최소 레그 수익률 %%")
parser.add_argument("--no-chart", action="store_true", help="HTML 차트 생략")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
_configure_logging(args.verbose)
settings = load_settings()
params = GtParams(
interval_min=args.interval or settings.gt_interval_min,
lookback_days=args.days or settings.gt_lookback_days,
zigzag_reversal_pct=args.zigzag or settings.gt_zigzag_reversal_pct,
min_leg_pct=args.min_leg or settings.gt_min_leg_pct,
pullback_min_pct=settings.gt_pullback_min_pct,
pullback_local_order=settings.gt_pullback_local_order,
breakout_buffer_pct=settings.gt_breakout_buffer_pct,
breakout_consolidation_bars=settings.gt_breakout_consolidation_bars,
breakout_min_rally_pct=settings.gt_breakout_min_rally_pct,
div_local_order=settings.gt_div_local_order,
div_min_bars_between=settings.gt_div_min_bars_between,
div_min_rsi_diff=settings.gt_div_min_rsi_diff,
div_min_future_move_pct=settings.gt_div_min_future_move_pct,
)
logging.info(
"GT 생성: %s %s, %s일, ZigZag=%s%%, min_leg=%s%%, 초기=%s",
settings.symbol,
interval_label(params.interval_min),
params.lookback_days,
params.zigzag_reversal_pct,
params.min_leg_pct,
f"{settings.gt_initial_cash_krw:,.0f}",
)
result = build_ground_truth(
db_path=settings.db_path,
symbol=settings.symbol,
coin_name=settings.coin_name,
params=params,
initial_cash_krw=settings.gt_initial_cash_krw,
fee_rate=settings.gt_trading_fee_rate,
)
out_json = save_ground_truth(result, settings.ground_truth_file)
summary = result["summary"]
meta = result["meta"]
pnl = result["pnl"]
print("\n=== Ground Truth 완료 (1단계 벤치마크) ===")
print(f"대상: {meta['symbol']} ({meta['interval_label']})")
print(f"GT·수익 기간: {meta['data_from']} ~ {meta['data_to']} ({meta['bar_count']}봉)")
print(f"차트·타점: 최근 {settings.download_days}일 (2년)")
print(f"피벗: {meta['pivot_count']}개 → 레그: {summary['leg_count']}")
print(
f"매수 타점: {summary['buy_count']}"
f"(눌림 {summary.get('pullback_buy_count', 0)} / 돌파 {summary.get('breakout_buy_count', 0)} "
f"/ 다이버전스 {summary.get('divergence_buy_count', 0)}) "
f"/ 매도: {summary['sell_count']}개 (다이버전스 {summary.get('divergence_sell_count', 0)})"
)
print(f"레그 수익률 — 평균: {summary['avg_leg_pct']}%, 최대: {summary['max_leg_pct']}%")
period = ""
if pnl.get("period_from"):
period = f" ({pnl['period_from'][:10]} ~ {pnl['period_to'][:10]})"
print(f"\n=== 누적 수익{period} — 초기 {pnl['initial_cash_krw']:,.0f}원 ===")
print(f"최종 자산: {pnl['final_cash_krw']:,.0f}")
print(f"손익: {pnl['total_pnl_krw']:+,.0f}")
print(f"기간 수익률: {pnl['total_return_pct']:+.2f}%")
print(f"거래 레그: {pnl['legs_traded']}")
print(f"JSON: {out_json}")
if not args.no_chart:
chart_path = render_ground_truth_chart(
db_path=settings.db_path,
symbol=settings.symbol,
gt_result=result,
output_path=settings.ground_truth_chart_file,
chart_lookback_days=settings.download_days,
)
print(f"차트: {chart_path}")
return 0
from deepcoin.ground_truth.ground_truth import run_from_db
if __name__ == "__main__": if __name__ == "__main__":
run_from_db() raise SystemExit(main())

Some files were not shown because too many files have changed in this diff Show More