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:
183
.env
183
.env
@@ -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
|
|
||||||
|
|
||||||
230
.env.example
230
.env.example
@@ -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
8
.gitignore
vendored
@@ -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
190
README.md
@@ -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
439
config.py
@@ -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
@@ -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()
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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()}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""외부 API 연동 (빗썸 등)."""
|
|
||||||
|
|
||||||
from deepcoin.api.bithumb import HTS
|
|
||||||
|
|
||||||
__all__ = ["HTS"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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()]
|
|
||||||
@@ -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:
|
|
||||||
"""
|
|
||||||
스토캐스틱 %K·%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
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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", ""),
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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",
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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()}
|
|
||||||
@@ -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
|
|
||||||
@@ -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`
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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>"""
|
|
||||||
@@ -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, "시뮬 배분 없음")
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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) | 기동 체크리스트 |
|
|
||||||
@@ -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 고정 ₩/회)
|
|
||||||
@@ -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. 빗썸 앱에서 수동 청산
|
|
||||||
@@ -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)
|
|
||||||
@@ -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` |
|
|
||||||
|
|
||||||
## 면책
|
|
||||||
|
|
||||||
실거래 손익은 운영자 책임입니다. 본 저장소는 투자 자문이 아닙니다.
|
|
||||||
@@ -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) | 기동 체크 |
|
|
||||||
@@ -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)
|
|
||||||
@@ -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/*`로 연결됩니다.
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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__")
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user