From 2cb67c42b33a8e91e826b487a193e2cad3aa38ee Mon Sep 17 00:00:00 2001 From: dsyoon Date: Sun, 31 May 2026 11:27:50 +0900 Subject: [PATCH] =?UTF-8?q?GT=20MTF=20=ED=94=84=EB=A1=9C=ED=95=84=C2=B7?= =?UTF-8?q?=EC=BA=98=EB=A6=AC=EB=B8=8C=EB=A0=88=EC=9D=B4=EC=85=98=EA=B3=BC?= =?UTF-8?q?=2004=20=EB=A7=A4=EC=B9=AD/=EC=8B=9C=EB=AE=AC/=EC=8B=A4?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3분~일봉 GT 타점 분석(03c), leg 체결 순서 수정, 총자산 90% 검증 루프, walk-forward Go/No-Go 시뮬, monitor·live_trader 및 reference 문서를 포함한다. Co-authored-by: Cursor --- .env.example | 30 + README.md | 37 +- config.py | 78 +++ data/ground_truth/ground_truth_trades.json | 165 +++--- deepcoin/analysis/general_analysis_runner.py | 20 +- .../analysis/general_analysis_snapshot.py | 76 ++- deepcoin/ground_truth/ground_truth.py | 187 +++++- deepcoin/matching/README.md | 35 +- deepcoin/matching/__init__.py | 7 +- deepcoin/matching/config.py | 70 +++ deepcoin/matching/gt_asset_calibration.py | 177 ++++++ deepcoin/matching/gt_comparison.py | 383 +++++++++++++ deepcoin/matching/gt_mtf_profile.py | 514 +++++++++++++++++ deepcoin/matching/gt_profile_iterate.py | 539 ++++++++++++++++++ deepcoin/matching/gt_schedule.py | 64 +++ deepcoin/matching/label_outcomes.py | 231 ++++++++ deepcoin/matching/live_eval.py | 86 +++ deepcoin/matching/load_rules.py | 61 ++ deepcoin/matching/match_rules.py | 45 +- deepcoin/matching/pipeline.py | 145 +++++ deepcoin/matching/portfolio_sim.py | 215 +++++++ deepcoin/matching/profile_rules.py | 418 ++++++++++++++ deepcoin/matching/rule_eval.py | 318 +++++++++++ deepcoin/matching/select_rules.py | 362 ++++++++++++ deepcoin/matching/simulation.py | 371 ++++++++++++ deepcoin/matching/simulation_html.py | 460 +++++++++++++++ deepcoin/ops/alert_message.py | 92 +++ deepcoin/ops/chart_report.py | 280 +++++++++ deepcoin/ops/live_trader.py | 191 +++++++ deepcoin/ops/monitor_coin.py | 63 +- deepcoin/ops/simulation.py | 135 +++-- deepcoin/paths.py | 16 + docs/reference/GROUND_TRUTH.md | 2 + docs/reference/LIVE_TRADING.md | 44 ++ docs/reference/OPERATIONS.md | 40 ++ docs/reference/RISK.md | 25 + docs/reference/ROADMAP.md | 35 +- docs/reference/SIMULATION.md | 40 ++ scripts/03_gt_mtf_profile.py | 11 + scripts/03_patch_gt_snapshots.py | 17 + scripts/04_calibrate_gt_assets.py | 11 + scripts/04_gt_comparison_report.py | 11 + scripts/04_match_rules.py | 6 +- scripts/04_simulation_report.py | 11 + scripts/05_run_monitor.py | 13 +- scripts/06_execute_live.py | 22 + scripts/README.md | 6 +- 47 files changed, 5956 insertions(+), 209 deletions(-) create mode 100644 .env.example create mode 100644 deepcoin/matching/config.py create mode 100644 deepcoin/matching/gt_asset_calibration.py create mode 100644 deepcoin/matching/gt_comparison.py create mode 100644 deepcoin/matching/gt_mtf_profile.py create mode 100644 deepcoin/matching/gt_profile_iterate.py create mode 100644 deepcoin/matching/gt_schedule.py create mode 100644 deepcoin/matching/label_outcomes.py create mode 100644 deepcoin/matching/live_eval.py create mode 100644 deepcoin/matching/load_rules.py create mode 100644 deepcoin/matching/pipeline.py create mode 100644 deepcoin/matching/portfolio_sim.py create mode 100644 deepcoin/matching/profile_rules.py create mode 100644 deepcoin/matching/rule_eval.py create mode 100644 deepcoin/matching/select_rules.py create mode 100644 deepcoin/matching/simulation.py create mode 100644 deepcoin/matching/simulation_html.py create mode 100644 deepcoin/ops/alert_message.py create mode 100644 deepcoin/ops/chart_report.py create mode 100644 deepcoin/ops/live_trader.py create mode 100644 docs/reference/LIVE_TRADING.md create mode 100644 docs/reference/OPERATIONS.md create mode 100644 docs/reference/RISK.md create mode 100644 docs/reference/SIMULATION.md create mode 100644 scripts/03_gt_mtf_profile.py create mode 100644 scripts/03_patch_gt_snapshots.py create mode 100644 scripts/04_calibrate_gt_assets.py create mode 100644 scripts/04_gt_comparison_report.py create mode 100644 scripts/04_simulation_report.py create mode 100644 scripts/06_execute_live.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..10c86d6 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# DeepCoin — .env.example (비밀값 없음). 복사: cp .env.example .env + +BITHUMB_ACCESS_KEY= +BITHUMB_SECRET_KEY= +COIN_TELEGRAM_BOT_TOKEN= +COIN_TELEGRAM_CHAT_ID= + +SYMBOL=WLD +CHART_LOOKBACK_DAYS=365 + +# 04 매칭 +MATCH_LABEL_MODE=leg_gt +MATCH_HOLDOUT_RATIO=0.15 +MATCH_MONITOR_MAX_PER_SIDE=1 + +# 1 시뮬레이션 +SIM_GO_WF_POSITIVE_RATIO=0.5 +SIM_FEE_STRESS_MULT=2.0 + +# 05 알림 +MONITOR_ALERT_COOLDOWN_MIN=180 +MONITOR_ALERT_KRW_AMOUNT=100000 + +# 3 실거래 (오픈 시에만 1) +LIVE_TRADING_ENABLED=0 +LIVE_ORDER_KRW=100000 +LIVE_DAILY_KRW_MAX=300000 +LIVE_COOLDOWN_MIN=180 +LIVE_MAX_TRADES_PER_DAY=10 +LIVE_DAILY_LOSS_LIMIT_KRW=50000 diff --git a/README.md b/README.md index 0f789d8..72526d1 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,19 @@ # DeepCoin — WLD MTF 분석·정답·운영 빗썸 KRW-WLD. **1, 3, 5, 10, 15, 30, 60, 240, 1440분** 봉을 적재하고, -Ground Truth·기술적 분석·(예정) 규칙 매칭·1분 모니터까지 **단계별 폴더**로 관리합니다. +Ground Truth·기술적 분석·규칙 매칭·알림·**실거래(선택)**까지 단계별로 관리합니다. -## 로드맵 +## 남은 작업 순서 + +| 순서 | 단계 | 실행 | +|------|------|------| +| 1 | 시뮬레이션 | `python scripts/04_simulation_report.py` | +| 2 | 문서화 | `docs/reference/SIMULATION.md` 등 | +| 3 | 오픈(실거래) | `python scripts/06_execute_live.py` | +| 4 | 1~2주 검증 | 실계좌 기록 | +| 5 | 지속 거래 | 06 상시 | + +## 파이프라인 CLI | 단계 | 목적 | 실행 | |------|------|------| @@ -11,8 +21,10 @@ Ground Truth·기술적 분석·(예정) 규칙 매칭·1분 모니터까지 ** | 02 Ground Truth | 매수·매도 정답 타점 | `python scripts/02_ground_truth.py` | | 03 분석 | 8TF 기술 지표 enrich | `python scripts/03_analyze_enrich.py` | | 03b 분석 | GT 타점 MTF 스냅샷 | `python scripts/03_analyze_trades.py` | -| 04 매칭 | GT 근접 규칙 선택 (예정) | `python scripts/04_match_rules.py` | -| 05 운영 | 차트·1분 모니터 | `scripts/05_chart_*.py`, `05_run_monitor.py` | +| 04 매칭 | GT 프로필 + leg_gt EV | `python scripts/04_match_rules.py` | +| 04 시뮬 | Go/No-Go 리포트 | `python scripts/04_simulation_report.py` | +| 05 알림 | 텔레그램 (주문 없음) | `python scripts/05_run_monitor.py` | +| 06 실거래 | 빗썸 주문 (`LIVE_TRADING_ENABLED=1`) | `python scripts/06_execute_live.py` | 상세: [docs/reference/ROADMAP.md](docs/reference/ROADMAP.md) @@ -27,8 +39,8 @@ DeepCoin/ │ ├── data/ # 01 다운로드 │ ├── ground_truth/ # 02 정답 타점 │ ├── analysis/ # 03·03b 지표·스냅샷 -│ ├── matching/ # 04 규칙 매칭 (예정) -│ └── ops/ # 05 모니터·차트 +│ ├── matching/ # 04·시뮬 +│ └── ops/ # 05 알림·06 실거래 ├── data/ # coins.db, ground_truth/, ops/ └── docs/ ├── reference/ # 가이드·기법 명세 (Git) @@ -56,6 +68,8 @@ python scripts/01_download.py 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 # GT 타점 3분~일봉 프로필 (04 입력) +python scripts/04_match_rules.py python scripts/05_chart_truth.py ``` @@ -69,6 +83,12 @@ python scripts/05_chart_truth.py | `CHART_LOOKBACK_DAYS` | 기본 365일 | | `DOWNLOAD_MONTHS` | 3분 이상 봉 12개월 | | `MONITOR_LOOP_SLEEP_SEC` | 05 모니터 루프 주기(초) | +| `MONITOR_ALERT_KRW_AMOUNT` | 규칙 알림 참고 금액(원, 매수·잔고 미조회 시) | +| `MONITOR_ALERT_COOLDOWN_MIN` | 동일 규칙 텔레그램 재알림 최소 간격(분, 기본 180) | +| `MATCH_HOLDOUT_RATIO` | 홀드아웃(최근) 구간 비율 (기본 0.15) | +| `MATCH_MONITOR_MAX_PER_SIDE` | 05·06 감시 규칙 수 (매수·매도 각, 기본 1) | +| `LIVE_TRADING_ENABLED` | 1일 때만 06 실주문 (기본 0) | +| `LIVE_ORDER_KRW` / `LIVE_DAILY_KRW_MAX` | 1회·일 주문 한도 | ## 산출물 @@ -80,7 +100,10 @@ python scripts/05_chart_truth.py | `docs/02_ground_truth/wld_ground_truth_chart.html` | 정답 차트 | | `docs/03_analysis/latest/*_latest.csv` | 간격별 최근 봉 전 기법 | | `docs/03_analysis/general_analysis_trades.csv` | GT 타점 MTF 스냅샷 | +| `docs/04_matching/matched_rules.json` | EV·holdout 통과 규칙 | +| `docs/04_matching/simulation_report.html` | 1단계 Go/No-Go | +| `data/ops/live_trades.jsonl` | 06 실거래 로그 | ## 면책 -실거래는 사용자 책임입니다. 본 저장소는 주문 실행을 포함하지 않습니다. +실거래 손익은 사용자 책임입니다. `LIVE_TRADING_ENABLED=0`이면 06은 주문하지 않습니다. diff --git a/config.py b/config.py index 302a023..567f6c1 100644 --- a/config.py +++ b/config.py @@ -219,6 +219,8 @@ MONITOR_MA_WINDOWS: tuple[int, ...] = _parse_int_tuple( ) 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", "100000") # --- general_analysis --- GA_COL_PREFIX = _getenv("GA_COL_PREFIX", "ga_") @@ -262,3 +264,79 @@ 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", "300") +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", "180") +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() +) + +# --- 1단계 시뮬레이션 리포트 --- +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") + +# --- 3단계 실거래 (오픈 전 문서·시뮬 Go 필수) --- +LIVE_TRADING_ENABLED = _getenv("LIVE_TRADING_ENABLED", "0").strip() in ( + "1", + "true", + "True", + "yes", +) +LIVE_ORDER_KRW = _getenv_int("LIVE_ORDER_KRW", "100000") +LIVE_DAILY_KRW_MAX = _getenv_int("LIVE_DAILY_KRW_MAX", "300000") +LIVE_COOLDOWN_MIN = _getenv_int("LIVE_COOLDOWN_MIN", "180") +LIVE_MAX_TRADES_PER_DAY = _getenv_int("LIVE_MAX_TRADES_PER_DAY", "10") +LIVE_DAILY_LOSS_LIMIT_KRW = _getenv_int("LIVE_DAILY_LOSS_LIMIT_KRW", "50000") +LIVE_SLIPPAGE_PCT = _getenv_float("LIVE_SLIPPAGE_PCT", "0.05") diff --git a/data/ground_truth/ground_truth_trades.json b/data/ground_truth/ground_truth_trades.json index 660634f..0a0ae49 100644 --- a/data/ground_truth/ground_truth_trades.json +++ b/data/ground_truth/ground_truth_trades.json @@ -5,8 +5,8 @@ "interval_min": 3, "lookback_days": 365, "period_start": "2025-06-04 03:57:00", - "period_end": "2026-05-30 21:31:00", - "trend_at_end": "range", + "period_end": "2026-05-31 00:51:00", + "trend_at_end": "up", "params": { "min_swing_pct": 4.0, "pivot_order": 20, @@ -20,23 +20,28 @@ "summary": { "pivot_candidates": 380, "sell_peaks": 74, - "trade_count": 450, + "trade_count": 451, "buy_count": 303, - "sell_count": 147, - "round_trips": 74, - "sum_sell_leg_return_pct": 1570.3, + "sell_count": 148, + "round_trips": 75, + "sum_sell_leg_return_pct": 1590.3, "initial_cash_krw": 1000000, - "final_asset_krw": 1512765783.0, - "pnl_krw": 1511765783.0, - "pnl_pct": 151176.58, - "total_fees_krw": 16073557.0, - "cash_krw": -0.0, - "holding_qty": 3361701.741079, - "holding_value_krw": 1512765783.0, - "mark_price": 450.0, - "fee_rate": 0.0005 + "final_asset_krw": 2040862272.0, + "pnl_krw": 2039862272.0, + "pnl_pct": 203986.23, + "total_fees_krw": 19790914.0, + "cash_krw": 2040862272.0, + "holding_qty": 0.0, + "holding_value_krw": 0.0, + "mark_price": 515.0, + "fee_rate": 0.0005, + "realized_final_asset_krw": 2040862272.0, + "realized_pnl_krw": 2039862272.0, + "realized_pnl_pct": 203986.23, + "unrealized_pnl_krw": 0.0, + "execution_order": "leg_block" }, - "note": "저점 분할 매수(삼각형 크기=비중), 고점 1~2회 매도. 사후 라벨·캘리브레이션용.", + "note": "저점 분할 매수(비중=삼각형), 고점 1~2회 매도. 체결 순서=leg별 매수→매도(시각순 아님). 기간말 leg는 종가 청산. summary.pnl_pct는 미청산 포함 종가 평가, realized_pnl_pct는 체결만 반영.", "trades": [ { "dt": "2025-06-06 06:12:00", @@ -302,18 +307,6 @@ "pivot_kind": "peak", "forward_return_pct": 0.6 }, - { - "dt": "2025-06-24 01:33:00", - "action": "buy", - "price": 1124.0, - "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#3", - "weight": 1.0, - "leg_id": 3, - "bb_pos": 0.081, - "rsi": 11.8, - "pivot_kind": "trough", - "forward_return_pct": 17.35 - }, { "dt": "2025-06-24 03:06:00", "action": "sell", @@ -326,6 +319,18 @@ "pivot_kind": "peak", "forward_return_pct": 0.43 }, + { + "dt": "2025-06-24 01:33:00", + "action": "buy", + "price": 1124.0, + "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#3", + "weight": 1.0, + "leg_id": 3, + "bb_pos": 0.081, + "rsi": 11.8, + "pivot_kind": "trough", + "forward_return_pct": 17.35 + }, { "dt": "2025-06-24 14:57:00", "action": "sell", @@ -1562,18 +1567,6 @@ "pivot_kind": "peak", "forward_return_pct": 24.87 }, - { - "dt": "2025-09-09 11:45:00", - "action": "buy", - "price": 2334.0, - "memo": "저점 분할 매수 · 비중 35% · 3회 · BB하단 · leg#22", - "weight": 0.355, - "leg_id": 22, - "bb_pos": 0.19, - "rsi": 35.8, - "pivot_kind": "trough", - "forward_return_pct": 25.92 - }, { "dt": "2025-09-09 12:45:00", "action": "sell", @@ -1586,6 +1579,18 @@ "pivot_kind": "peak", "forward_return_pct": 27.74 }, + { + "dt": "2025-09-09 11:45:00", + "action": "buy", + "price": 2334.0, + "memo": "저점 분할 매수 · 비중 35% · 3회 · BB하단 · leg#22", + "weight": 0.355, + "leg_id": 22, + "bb_pos": 0.19, + "rsi": 35.8, + "pivot_kind": "trough", + "forward_return_pct": 25.92 + }, { "dt": "2025-09-09 14:00:00", "action": "buy", @@ -2294,18 +2299,6 @@ "pivot_kind": "peak", "forward_return_pct": 14.1 }, - { - "dt": "2025-10-13 03:39:00", - "action": "buy", - "price": 1517.0, - "memo": "저점 분할 매수 · 비중 13% · 7회 · BB하단 · leg#35", - "weight": 0.13, - "leg_id": 35, - "bb_pos": 0.0, - "rsi": 31.1, - "pivot_kind": "trough", - "forward_return_pct": -7.25 - }, { "dt": "2025-10-13 05:03:00", "action": "sell", @@ -2318,6 +2311,18 @@ "pivot_kind": "peak", "forward_return_pct": 14.03 }, + { + "dt": "2025-10-13 03:39:00", + "action": "buy", + "price": 1517.0, + "memo": "저점 분할 매수 · 비중 13% · 7회 · BB하단 · leg#35", + "weight": 0.13, + "leg_id": 35, + "bb_pos": 0.0, + "rsi": 31.1, + "pivot_kind": "trough", + "forward_return_pct": -7.25 + }, { "dt": "2025-10-13 21:00:00", "action": "buy", @@ -5282,18 +5287,6 @@ "pivot_kind": "peak", "forward_return_pct": 30.47 }, - { - "dt": "2026-05-26 23:03:00", - "action": "buy", - "price": 564.0, - "memo": "저점 분할 매수 · 비중 14% · 6회 · BB하단 · leg#73", - "weight": 0.138, - "leg_id": 73, - "bb_pos": 0.136, - "rsi": 46.7, - "pivot_kind": "trough", - "forward_return_pct": -18.26 - }, { "dt": "2026-05-27 01:00:00", "action": "sell", @@ -5306,6 +5299,18 @@ "pivot_kind": "peak", "forward_return_pct": 29.17 }, + { + "dt": "2026-05-26 23:03:00", + "action": "buy", + "price": 564.0, + "memo": "저점 분할 매수 · 비중 14% · 6회 · BB하단 · leg#73", + "weight": 0.138, + "leg_id": 73, + "bb_pos": 0.136, + "rsi": 46.7, + "pivot_kind": "trough", + "forward_return_pct": -18.26 + }, { "dt": "2026-05-27 13:18:00", "action": "buy", @@ -5394,49 +5399,61 @@ "dt": "2026-05-29 21:33:00", "action": "buy", "price": 430.0, - "memo": "저점 분할 매수(미청산) · 비중 25%", + "memo": "저점 분할 매수 · 비중 25% · leg#74(기간말)", "weight": 0.25, "leg_id": 74, "bb_pos": 0.6, "rsi": 53.6, "pivot_kind": "trough", - "forward_return_pct": null + "forward_return_pct": 19.77 }, { "dt": "2026-05-30 01:24:00", "action": "buy", "price": 439.0, - "memo": "저점 분할 매수(미청산) · 비중 24%", + "memo": "저점 분할 매수 · 비중 24% · leg#74(기간말)", "weight": 0.244, "leg_id": 74, "bb_pos": 0.085, "rsi": 29.2, "pivot_kind": "trough", - "forward_return_pct": null + "forward_return_pct": 17.31 }, { "dt": "2026-05-30 09:57:00", "action": "buy", "price": 424.0, - "memo": "저점 분할 매수(미청산) · 비중 25%", + "memo": "저점 분할 매수 · 비중 25% · leg#74(기간말)", "weight": 0.253, "leg_id": 74, "bb_pos": 0.192, "rsi": 33.3, "pivot_kind": "trough", - "forward_return_pct": null + "forward_return_pct": 21.46 }, { "dt": "2026-05-30 13:30:00", "action": "buy", "price": 424.0, - "memo": "저점 분할 매수(미청산) · 비중 25%", + "memo": "저점 분할 매수 · 비중 25% · leg#74(기간말)", "weight": 0.253, "leg_id": 74, - "bb_pos": 0.206, - "rsi": 42.9, + "bb_pos": 0.151, + "rsi": 28.6, "pivot_kind": "trough", - "forward_return_pct": null + "forward_return_pct": 21.46 + }, + { + "dt": "2026-05-31 00:51:00", + "action": "sell", + "price": 515.0, + "memo": "기간말 잔여 청산 · leg#74", + "weight": 1.0, + "leg_id": 74, + "bb_pos": 0.881, + "rsi": 55.8, + "pivot_kind": "peak", + "forward_return_pct": 20.0 } ] } \ No newline at end of file diff --git a/deepcoin/analysis/general_analysis_runner.py b/deepcoin/analysis/general_analysis_runner.py index 61aa599..b191acf 100644 --- a/deepcoin/analysis/general_analysis_runner.py +++ b/deepcoin/analysis/general_analysis_runner.py @@ -41,11 +41,25 @@ def main() -> None: trades = trades[: args.limit] print(f"테스트 모드: 타점 {args.limit}건만") - print(f"=== general_analysis {SYMBOL} (lookback {CHART_LOOKBACK_DAYS}일) ===") + 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: @@ -64,6 +78,10 @@ def main() -> None: 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("완료.") diff --git a/deepcoin/analysis/general_analysis_snapshot.py b/deepcoin/analysis/general_analysis_snapshot.py index c9386cd..fed5f3f 100644 --- a/deepcoin/analysis/general_analysis_snapshot.py +++ b/deepcoin/analysis/general_analysis_snapshot.py @@ -5,6 +5,8 @@ general_analysis ground truth 타점 MTF 스냅샷 생성. from __future__ import annotations import json +import sys +import time from pathlib import Path from typing import Any @@ -40,15 +42,31 @@ def build_trade_mtf_snapshots( Returns: wide DataFrame (1 row per trade). """ + n_trades = len(trades) enriched: dict[int, pd.DataFrame] = {} - for iv in GENERAL_ANALYSIS_INTERVALS: + t0 = time.time() + print(f"[03b] Phase A: 8TF enrich (1분봉 제외, 전 기법) — {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 - print(f" [GA] {interval_tf_prefix(iv)} 봉 지표 계산 ({len(raw)}봉)...") + 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] = { @@ -70,12 +88,62 @@ def build_trade_mtf_snapshots( row.update(flat) row.update(general_analysis_mtf_scores(flat)) rows.append(row) - if (i + 1) % 50 == 0: - print(f" 타점 스냅샷 {i + 1}/{len(trades)}") + 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 append_missing_gt_snapshots( + frames: dict[int, pd.DataFrame], + trades_path: Path | str = DEFAULT_TRADES_FILE, + output_csv: Path | str = DEFAULT_OUTPUT_CSV, +) -> int: + """ + CSV에 없는 GT 타점만 MTF 스냅샷 추가. + + Args: + frames: interval → OHLCV. + trades_path: ground_truth JSON. + output_csv: 03b CSV. + + Returns: + 추가된 행 수. + """ + out = Path(output_csv) + if not out.is_file(): + return 0 + data = load_ground_truth(Path(trades_path)) + if not data: + return 0 + trades = data.get("trades") or [] + existing = pd.read_csv(out) + have = set(zip(existing["dt"].astype(str), existing["action"].astype(str))) + missing = [ + t + for t in trades + if (str(t["dt"]), str(t["action"])) not in have + ] + if not missing: + return 0 + print(f"[03b] 누락 GT 타점 {len(missing)}건 스냅샷 추가") + add_df = build_trade_mtf_snapshots(frames, missing) + merged = pd.concat([existing, add_df], ignore_index=True) + merged.to_csv(out, index=False, encoding="utf-8-sig") + print(f"[03b] CSV 갱신: {out} ({len(merged)}행)") + return len(missing) + + def export_trade_snapshots( frames: dict[int, pd.DataFrame], trades_path: Path | str = DEFAULT_TRADES_FILE, diff --git a/deepcoin/ground_truth/ground_truth.py b/deepcoin/ground_truth/ground_truth.py index 14881ae..3daf02a 100644 --- a/deepcoin/ground_truth/ground_truth.py +++ b/deepcoin/ground_truth/ground_truth.py @@ -658,7 +658,7 @@ def build_split_buy_peak_sell_trades( prev_sell_ts = peak.ts - # 마지막 매도 이후 ~ 기간 끝: 미청산 구간 분할 매수만 + # 마지막 매도 이후 ~ 기간 말: 분할 매수 후 동일 leg에서 기간말 청산(포트폴리오 정합) if sell_peaks: last_peak = sell_peaks[-1] troughs = _collect_buy_troughs( @@ -667,25 +667,56 @@ def build_split_buy_peak_sell_trades( leg_id = len(sell_peaks) if troughs: weights = _normalize_weights([1.0 / max(t.price, 1e-9) for t in troughs]) + leg_buys: list[TradePoint] = [] for t, w in zip(troughs, weights): row = _row_at_ts(df, t.ts) bb_pos, rsi, disp = _bb_context(row) price = float(row["Low"]) if "Low" in row else t.price - trades.append( + leg_buys.append( TradePoint( dt=t.ts.strftime("%Y-%m-%d %H:%M:%S"), action="buy", price=round(price, 2), weight=round(w, 3), leg_id=leg_id, - memo=f"저점 분할 매수(미청산) · 비중 {w*100:.0f}%", + memo=f"저점 분할 매수 · 비중 {w*100:.0f}% · leg#{leg_id}(기간말)", bb_pos=bb_pos, rsi=rsi, pivot_kind="trough", ) ) + trades.extend(leg_buys) + leg_avg = ( + sum(x.price * x.weight for x in leg_buys) + / max(sum(x.weight for x in leg_buys), 1e-9) + ) + end_ts = df.index[-1] + end_row = df.loc[end_ts] + if isinstance(end_row, pd.DataFrame): + end_row = end_row.iloc[-1] + end_price = float(end_row["Close"]) + bb_pos, rsi, _ = _bb_context(end_row) + ret = (end_price - leg_avg) / max(leg_avg, 1e-9) * 100.0 if leg_avg > 0 else None + trades.append( + TradePoint( + dt=end_ts.strftime("%Y-%m-%d %H:%M:%S"), + action="sell", + price=round(end_price, 2), + weight=1.0, + leg_id=leg_id, + memo=f"기간말 잔여 청산 · leg#{leg_id}", + bb_pos=bb_pos, + rsi=rsi, + pivot_kind="peak", + forward_return_pct=round(ret, 2) if ret is not None else None, + ) + ) + for b in leg_buys: + if b.forward_return_pct is None and ret is not None: + b.forward_return_pct = round( + (end_price - b.price) / max(b.price, 1e-9) * 100.0, 2 + ) - trades.sort(key=lambda t: t.dt) return trades @@ -812,14 +843,21 @@ def generate_ground_truth( t.forward_return_pct or 0.0 for t in trades if t.action == "sell" ) - trades.sort(key=lambda t: t.dt) + trade_dicts = order_trades_leg_block(trades) last_close = float(df["Close"].iloc[-1]) pnl = simulate_truth_portfolio( - [asdict(t) for t in trades], + trade_dicts, initial_cash=GT_INITIAL_CASH_KRW, fee_rate=TRADING_FEE_RATE, last_price=last_close, ) + pnl_realized = simulate_truth_portfolio( + trade_dicts, + initial_cash=GT_INITIAL_CASH_KRW, + fee_rate=TRADING_FEE_RATE, + last_price=None, + ) + _validate_leg_portfolio(trade_dicts, last_close) return { "name": "ground_truth_split_buy_peak_sell", @@ -849,21 +887,118 @@ def generate_ground_truth( "round_trips": round_trips, "sum_sell_leg_return_pct": round(total_ret, 2), **pnl, + "realized_final_asset_krw": pnl_realized.get("final_asset_krw"), + "realized_pnl_krw": pnl_realized.get("pnl_krw"), + "realized_pnl_pct": pnl_realized.get("pnl_pct"), + "unrealized_pnl_krw": round( + float(pnl.get("pnl_krw", 0)) - float(pnl_realized.get("pnl_krw", 0)), 0 + ), + "execution_order": "leg_block", }, "note": ( - "저점 분할 매수(삼각형 크기=비중), 고점 1~2회 매도. " - "사후 라벨·캘리브레이션용." + "저점 분할 매수(비중=삼각형), 고점 1~2회 매도. " + "체결 순서=leg별 매수→매도(시각순 아님). 기간말 leg는 종가 청산. " + "summary.pnl_pct는 미청산 포함 종가 평가, realized_pnl_pct는 체결만 반영." ), - "trades": [asdict(t) for t in trades], + "trades": trade_dicts, } -def _truth_simulation_rows(trades: list[dict[str, Any]]) -> list[dict[str, Any]]: - """TradePoint/dict 리스트를 시간순 dict 행으로 정규화.""" - return sorted( - [t if isinstance(t, dict) else asdict(t) for t in trades], - key=lambda x: x["dt"], - ) +def _validate_leg_portfolio( + trade_dicts: list[dict[str, Any]], + last_close: float, +) -> None: + """ + leg 블록 체결 후 보유·현금 불변식을 검증합니다. + + Args: + trade_dicts: order_trades_leg_block 결과. + last_close: 기간 말 종가. + + Raises: + ValueError: leg 매도 후에도 보유가 남는 경우(비정상). + """ + steps = simulate_truth_portfolio_steps(trade_dicts) + if not steps: + return + leg_ids = sorted({int(s["leg_id"]) for s in steps}) + for lid in leg_ids: + leg_steps = [s for s in steps if int(s["leg_id"]) == lid] + sells = [s for s in leg_steps if s["action"] == "sell"] + if not sells: + continue + last_sell = sells[-1] + if float(last_sell["holding_qty"]) > 1e-4: + raise ValueError( + f"leg#{lid} 마지막 매도 후 보유 잔존 qty={last_sell['holding_qty']} " + "(leg 블록 체결·매도 비중 합 검토 필요)" + ) + final = steps[-1] + if float(final["holding_qty"]) > 1e-2: + raise ValueError( + f"최종 보유 잔존 qty={final['holding_qty']} — 기간말 청산 누락 가능" + ) + pnl = simulate_truth_portfolio(trade_dicts, last_price=last_close) + if float(pnl.get("holding_qty", 0)) > 1e-2: + raise ValueError("종가 평가 후에도 미청산 보유가 남음") + + +def order_trades_leg_block( + trades: list[TradePoint] | list[dict[str, Any]], +) -> list[dict[str, Any]]: + """ + leg별 매수 전량 → 매도 전량 순으로 정렬합니다 (포트폴리오 시뮬·JSON 저장용). + + 시각순 정렬은 leg가 섞여 매도 미완료·보유 누적 오류를 만듭니다. + + Args: + trades: TradePoint 또는 dict 리스트. + + Returns: + leg_id, action(buy=0), dt 순 dict 리스트. + """ + rows = [t if isinstance(t, dict) else asdict(t) for t in trades] + + def _sort_key(x: dict[str, Any]) -> tuple[int, int, str]: + return (int(x.get("leg_id", 0)), 0 if x.get("action") == "buy" else 1, x["dt"]) + + return sorted(rows, key=_sort_key) + + +def order_trades_chronological( + trades: list[TradePoint] | list[dict[str, Any]], +) -> list[dict[str, Any]]: + """ + 시각순 dict 리스트 (차트 표시·분석용). + + Args: + trades: TradePoint 또는 dict. + + Returns: + dt 순 정렬된 dict 리스트. + """ + rows = [t if isinstance(t, dict) else asdict(t) for t in trades] + return sorted(rows, key=lambda x: x["dt"]) + + +def _truth_simulation_rows( + trades: list[dict[str, Any]] | list[TradePoint], + *, + chronological: bool = False, +) -> list[dict[str, Any]]: + """ + 포트폴리오 시뮬용 체결 순서로 정규화합니다. + + Args: + trades: JSON trades 또는 TradePoint. + chronological: True면 시각순(레거시), False면 leg 블록 순(기본). + + Returns: + dict 행 리스트. + """ + if chronological: + return order_trades_chronological(trades) + return order_trades_leg_block(trades) def simulate_truth_portfolio_steps( @@ -1024,8 +1159,12 @@ def simulate_truth_portfolio( 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 + if last_price is None: + mark_price = None + holding_value = 0.0 + else: + mark_price = float(last_price) + 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 @@ -1039,7 +1178,7 @@ def simulate_truth_portfolio( "cash_krw": round(cash, 0), "holding_qty": round(qty, 6), "holding_value_krw": round(holding_value, 0), - "mark_price": round(mark_price, 2), + "mark_price": round(mark_price, 2) if last_price is not None else None, "fee_rate": fee_rate, } @@ -1087,16 +1226,18 @@ def print_ground_truth_report(data: dict[str, Any]) -> None: print(f" 매도 수익 합(참고): {s.get('sum_sell_leg_return_pct')}%") if s.get("initial_cash_krw"): print( - f" 시뮬(시작 ₩{s['initial_cash_krw']:,.0f}): " - f"최종 ₩{s['final_asset_krw']:,.0f} | " - f"수익 ₩{s['pnl_krw']:+,.0f} ({s['pnl_pct']:+.2f}%) | " + f" 포트폴리오: 초기 ₩{s['initial_cash_krw']:,.0f} → " + f"총보유자산 ₩{s['final_asset_krw']:,.0f} | " + f"초기 대비 {s['pnl_pct']:+.2f}% | " f"수수료 ₩{s['total_fees_krw']:,.0f}" ) if s.get("holding_qty", 0) > 0: print( f" 미청산: {s['holding_qty']}개 " - f"(평가 ₩{s['holding_value_krw']:,.0f}, 종가 ₩{s['mark_price']:,.0f})" + f"(평가 ₩{s['holding_value_krw']:,.0f}, 종가 ₩{s.get('mark_price', 0):,.0f})" ) + elif s.get("execution_order"): + print(f" 체결 순서: {s['execution_order']} (leg별 매수→매도)") print(f" 파라미터: {data.get('params')}") from collections import Counter @@ -1133,7 +1274,7 @@ def run_from_db(monitor=None, output: Path = DEFAULT_OUTPUT) -> dict[str, Any]: 생성된 dict. """ from config import TREND_INTERVAL_1D, TREND_INTERVAL_1H - from monitor import Monitor + from deepcoin.ops.monitor import Monitor mon = monitor or Monitor(cooldown_file=None) print(f"정답 생성: 최근 {CHART_LOOKBACK_DAYS}일 3분봉") diff --git a/deepcoin/matching/README.md b/deepcoin/matching/README.md index 22a07e3..2929086 100644 --- a/deepcoin/matching/README.md +++ b/deepcoin/matching/README.md @@ -1,15 +1,36 @@ -# Phase 04 — Matching +# Phase 04 — Matching (GT + 전구간 EV) -Ground Truth 매수·매도 타점의 MTF 스냅샷(`docs/03_analysis/general_analysis_trades.csv`)과 -실시간·최근 봉 상태를 비교해 **가장 근접한 기술적 프로파일** 및 **진입·청산 규칙**을 선택합니다. +안2 파이프라인: 03b GT 스냅샷에서 규칙 후보를 만들고, 3분봉 전 구간에서 발화·forward 수익을 검증한 뒤 valid 구간 EV로 최종 규칙을 고릅니다. -예정 산출물: +## PDCA -- `docs/04_matching/rule_candidates.json` -- `docs/04_matching/similarity_report.html` +| 단계 | 스크립트 모듈 | 산출물 | +|------|---------------|--------| +| 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` diff --git a/deepcoin/matching/__init__.py b/deepcoin/matching/__init__.py index a05f34c..861c800 100644 --- a/deepcoin/matching/__init__.py +++ b/deepcoin/matching/__init__.py @@ -1,3 +1,8 @@ """ -04단계: Ground Truth에 근접한 기술적 상태·규칙 선택 (예정). +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"] diff --git a/deepcoin/matching/config.py b/deepcoin/matching/config.py new file mode 100644 index 0000000..0b9ba6f --- /dev/null +++ b/deepcoin/matching/config.py @@ -0,0 +1,70 @@ +""" +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", +] diff --git a/deepcoin/matching/gt_asset_calibration.py b/deepcoin/matching/gt_asset_calibration.py new file mode 100644 index 0000000..382d121 --- /dev/null +++ b/deepcoin/matching/gt_asset_calibration.py @@ -0,0 +1,177 @@ +""" +GT 총자산 대비 시뮬/규칙 정확도 측정 (동일 체결·평가 모델). +""" + +from __future__ import annotations + +from typing import Any + +import pandas as pd + +from config import GT_INITIAL_CASH_KRW, MATCH_GT_TOLERANCE_MIN, TRADING_FEE_RATE +from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio +from deepcoin.matching.rule_eval import eval_rule_mask + + +def gt_trades_for_legs( + trades: list[dict[str, Any]], + leg_ids: set[int], +) -> list[dict[str, Any]]: + """ + leg_id 집합에 속한 GT 체결만 반환. + + Args: + trades: ground_truth trades. + leg_ids: 포함할 leg_id. + + Returns: + 필터된 trade dict 리스트. + """ + return [t for t in trades if int(t.get("leg_id", 0)) in leg_ids] + + +def covered_legs_from_fires( + trades: list[dict[str, Any]], + fires: pd.DataFrame, + buy_rule_ids: list[str], + sell_rule_ids: list[str], + tolerance_min: int = MATCH_GT_TOLERANCE_MIN, +) -> set[int]: + """ + 매수·매도 규칙 발화가 GT 타점 ±허용 내인 leg_id 집합. + + Args: + trades: GT trades. + fires: rule_fires. + buy_rule_ids: 매수 규칙 ID. + sell_rule_ids: 매도 규칙 ID. + tolerance_min: 허용 분. + + Returns: + 양쪽 모두 커버된 leg_id. + """ + if fires.empty: + return set() + tol = pd.Timedelta(minutes=tolerance_min) + gt_df = pd.DataFrame(trades) + gt_df["ts"] = pd.to_datetime(gt_df["dt"]) + fires = fires.copy() + fires["ts"] = pd.to_datetime(fires["dt"]) + bf = fires[fires["rule_id"].isin(buy_rule_ids) & (fires["side"] == "buy")] + sf = fires[fires["rule_id"].isin(sell_rule_ids) & (fires["side"] == "sell")] + + covered: set[int] = set() + for lid in gt_df["leg_id"].unique(): + leg = gt_df[gt_df["leg_id"] == lid] + buys = leg[leg["action"] == "buy"] + sells = leg[leg["action"] == "sell"] + buy_ok = True + for ts in buys["ts"]: + if bf.empty or (bf["ts"] - ts).abs().min() > tol: + buy_ok = False + break + sell_ok = True + for ts in sells["ts"]: + if sf.empty or (sf["ts"] - ts).abs().min() > tol: + sell_ok = False + break + if buy_ok and sell_ok: + covered.add(int(lid)) + return covered + + +def portfolio_asset_ratio( + trades: list[dict[str, Any]], + leg_ids: set[int], + last_price: float | None, +) -> dict[str, Any]: + """ + GT 체결 모델로 전체 vs 부분 leg 포트폴리오 비율. + + Args: + trades: 전체 GT trades. + leg_ids: 포함 leg. + last_price: 종가 평가. + + Returns: + full/subset final_asset, asset_ratio, leg counts. + """ + full = simulate_truth_portfolio( + trades, + initial_cash=GT_INITIAL_CASH_KRW, + fee_rate=TRADING_FEE_RATE, + last_price=last_price, + ) + subset_trades = gt_trades_for_legs(trades, leg_ids) + part = simulate_truth_portfolio( + subset_trades, + initial_cash=GT_INITIAL_CASH_KRW, + fee_rate=TRADING_FEE_RATE, + last_price=last_price, + ) + gt_final = float(full["final_asset_krw"]) + sub_final = float(part["final_asset_krw"]) + ratio = sub_final / gt_final if gt_final > 0 else 0.0 + return { + "gt_final_asset_krw": gt_final, + "subset_final_asset_krw": sub_final, + "asset_ratio": round(ratio, 4), + "asset_accuracy_pct": round(ratio * 100.0, 2), + "target_met_90": ratio >= 0.9, + "legs_total": len(set(int(t.get("leg_id", 0)) for t in trades)), + "legs_covered": len(leg_ids), + "leg_coverage_ratio": round( + len(leg_ids) / max(len(set(int(t.get("leg_id", 0)) for t in trades)), 1), + 4, + ), + "full_pnl_pct": full.get("pnl_pct"), + "subset_pnl_pct": part.get("pnl_pct"), + } + + +def evaluate_gt_snapshot_recall( + trades_df: pd.DataFrame, + rules: list[dict[str, Any]], +) -> dict[str, Any]: + """ + 03b 각 GT 행에서 규칙 스냅샷 충족 여부(OR across rules per side). + + Args: + trades_df: general_analysis_trades.csv. + rules: rule dict 리스트. + + Returns: + buy/sell recall, per-rule counts. + """ + buy_gt = trades_df[trades_df["action"] == "buy"] + sell_gt = trades_df[trades_df["action"] == "sell"] + buy_rules = [r for r in rules if r.get("side") == "buy"] + sell_rules = [r for r in rules if r.get("side") == "sell"] + + def _side_recall(gt: pd.DataFrame, side_rules: list[dict]) -> dict[str, Any]: + if gt.empty or not side_rules: + return {"gt_count": int(len(gt)), "matched": 0, "recall": 0.0} + hit = 0 + per_rule: dict[str, int] = {} + for _, row in gt.iterrows(): + fr = pd.DataFrame([row]) + ok = False + for rule in side_rules: + if bool(eval_rule_mask(fr, rule).iloc[0]): + ok = True + rid = rule["rule_id"] + per_rule[rid] = per_rule.get(rid, 0) + 1 + if ok: + hit += 1 + n = len(gt) + return { + "gt_count": n, + "matched": hit, + "recall": round(hit / n, 4) if n else 0.0, + "per_rule_hits": per_rule, + } + + return { + "buy": _side_recall(buy_gt, buy_rules), + "sell": _side_recall(sell_gt, sell_rules), + } diff --git a/deepcoin/matching/gt_comparison.py b/deepcoin/matching/gt_comparison.py new file mode 100644 index 0000000..6716c21 --- /dev/null +++ b/deepcoin/matching/gt_comparison.py @@ -0,0 +1,383 @@ +""" +Ground truth(450타점) vs 규칙 발화·시뮬 결과 비교 리포트. +""" + +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_GT_TOLERANCE_MIN +from deepcoin.ground_truth.ground_truth import load_ground_truth +from deepcoin.matching.select_rules import ( + _rule_metrics, + _split_train_valid_holdout, + gt_overlap_report, +) +from deepcoin.paths import ( + MATCHING_FIRE_OUTCOMES, + MATCHING_GT_COMPARISON_HTML, + MATCHING_GT_COMPARISON_JSON, + MATCHING_MATCHED_RULES, + MATCHING_SIMULATION_JSON, + resolve_ground_truth_file, +) + + +def _precision_near_gt( + fire_ts: pd.Series, + gt_ts: pd.Series, + tolerance: pd.Timedelta, +) -> dict[str, Any]: + """ + 발화 시각이 GT 타점 ±허용 내인 비율(precision proxy). + + Args: + fire_ts: 규칙 발화 시각. + gt_ts: GT 시각. + tolerance: 허용 timedelta. + + Returns: + near_count, fire_count, precision. + """ + if fire_ts.empty: + return {"near_count": 0, "fire_count": 0, "precision": 0.0} + gt_sorted = gt_ts.sort_values() + near = 0 + for fts in fire_ts: + if (gt_sorted - fts).abs().min() <= tolerance: + near += 1 + n = len(fire_ts) + return { + "near_count": near, + "fire_count": n, + "precision": round(near / n, 4) if n else 0.0, + } + + +def _matched_pairs( + fires: pd.DataFrame, + gt_df: pd.DataFrame, + rule_id: str, + tolerance: pd.Timedelta, +) -> pd.DataFrame: + """ + GT 타점별 가장 가까운 동일 rule·side 발화와 수익률 쌍을 만듭니다. + + Args: + fires: fire_outcomes. + gt_df: GT trades DataFrame. + rule_id: 규칙 ID. + tolerance: 매칭 허용. + + Returns: + 매칭된 행 DataFrame. + """ + sub = fires[fires["rule_id"] == rule_id].copy() + if sub.empty: + return pd.DataFrame() + side = sub["side"].iloc[0] + g = gt_df[gt_df["action"] == side].copy() + g["ts"] = pd.to_datetime(g["dt"]) + sub["ts"] = pd.to_datetime(sub["dt"]) + rows: list[dict[str, Any]] = [] + for _, gt_row in g.iterrows(): + gts = pd.Timestamp(gt_row["ts"]) + delta = (sub["ts"] - gts).abs() + if delta.empty or delta.min() > tolerance: + continue + idx = delta.idxmin() + fr = sub.loc[idx] + rows.append( + { + "side": side, + "rule_id": rule_id, + "gt_dt": str(gt_row["dt"]), + "fire_dt": str(fr["dt"]), + "delta_min": round(delta.min().total_seconds() / 60, 2), + "gt_forward_pct": float(gt_row.get("forward_return_pct") or 0), + "sim_leg_gt_pct": float(fr["forward_ret_pct"]), + "split": fr.get("split"), + } + ) + return pd.DataFrame(rows) + + +def build_gt_comparison_report( + outcomes_path: Path | None = None, + matched_path: Path | None = None, + gt_path: Path | None = None, + sim_path: Path | None = None, + tolerance_min: int = MATCH_GT_TOLERANCE_MIN, +) -> dict[str, Any]: + """ + GT vs 발화·시뮬 비교 dict 생성. + + Args: + outcomes_path: fire_outcomes.csv. + matched_path: matched_rules.json. + gt_path: ground_truth_trades.json. + sim_path: simulation_report.json. + tolerance_min: GT 매칭 허용(분). + + Returns: + gt_comparison_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}") + + outcomes = pd.read_csv(op) + outcomes["ts"] = pd.to_datetime(outcomes["dt"]) + outcomes["split"] = _split_train_valid_holdout(outcomes) + matched: dict[str, Any] = {} + if mp.is_file(): + matched = json.loads(mp.read_text(encoding="utf-8")) + + sim_report: dict[str, Any] = {} + sp = sim_path or MATCHING_SIMULATION_JSON + if sp.is_file(): + sim_report = json.loads(sp.read_text(encoding="utf-8")) + + gt_data = load_ground_truth(gt_path or resolve_ground_truth_file()) or {} + gt_trades = gt_data.get("trades") or [] + gt_df = pd.DataFrame(gt_trades) + tol = pd.Timedelta(minutes=tolerance_min) + + gt_baseline: dict[str, Any] = { + "total": len(gt_df), + "buy": int((gt_df["action"] == "buy").sum()) if not gt_df.empty else 0, + "sell": int((gt_df["action"] == "sell").sum()) if not gt_df.empty else 0, + } + for side in ("buy", "sell"): + sub = gt_df[gt_df["action"] == side] if not gt_df.empty else pd.DataFrame() + if sub.empty or "forward_return_pct" not in sub.columns: + gt_baseline[side] = {} + continue + r = sub["forward_return_pct"].astype(float) + gt_baseline[side] = { + "mean_forward_pct": round(float(r.mean()), 4), + "median_forward_pct": round(float(r.median()), 4), + "win_rate": round(float((r > 0).mean()), 4), + "count": int(len(r)), + } + + all_fires = outcomes.copy() + if "rule_id" not in all_fires.columns: + all_fires["rule_id"] = "all" + overlap_all = gt_overlap_report( + all_fires.drop_duplicates(subset=["dt", "side"]), + gt_trades, + tolerance_min=tolerance_min, + ) + + per_rule: list[dict[str, Any]] = [] + pair_stats: list[dict[str, Any]] = [] + for rid in sorted(outcomes["rule_id"].unique()): + sub = outcomes[outcomes["rule_id"] == rid] + side = str(sub["side"].iloc[0]) + gt_side = gt_df[gt_df["action"] == side] + gt_ts = pd.to_datetime(gt_side["dt"]) if not gt_side.empty else pd.Series(dtype="datetime64[ns]") + fire_ts = sub["ts"] + ov = gt_overlap_report(sub, gt_trades, tolerance_min=tolerance_min) + prec = _precision_near_gt(fire_ts, gt_ts, tol) + m_all = _rule_metrics(sub) + m_hold = _rule_metrics(sub[sub["split"] == "holdout"]) + + pairs = _matched_pairs(outcomes, gt_df, rid, tol) + pair_row: dict[str, Any] = {"rule_id": rid, "side": side, "pair_count": len(pairs)} + if len(pairs) >= 2: + corr = pairs["gt_forward_pct"].corr(pairs["sim_leg_gt_pct"]) + pair_row["corr_gt_vs_sim"] = round(float(corr), 4) if pd.notna(corr) else None + pair_row["mean_abs_diff_pct"] = round( + float((pairs["gt_forward_pct"] - pairs["sim_leg_gt_pct"]).abs().mean()), + 4, + ) + pair_row["mean_delta_min"] = round(float(pairs["delta_min"].mean()), 2) + pair_stats.append(pair_row) + + near_mask = [] + for fts in fire_ts: + near_mask.append( + not gt_ts.empty and (gt_ts - fts).abs().min() <= tol + ) + sub_near = sub.loc[near_mask] if near_mask else sub.iloc[0:0] + sub_far = sub.loc[[not x for x in near_mask]] if near_mask else sub + + per_rule.append( + { + "rule_id": rid, + "side": side, + "fire_count": int(len(sub)), + "gt_recall": ov.get(side, {}).get("recall", 0), + "gt_matched": ov.get(side, {}).get("matched", 0), + "gt_count": ov.get(side, {}).get("gt_count", 0), + "precision_near_gt": prec["precision"], + "fires_near_gt": prec["near_count"], + "sim_ev_all_pct": m_all.get("ev_pct"), + "sim_ev_near_gt_pct": _rule_metrics(sub_near).get("ev_pct") if len(sub_near) else None, + "sim_ev_far_gt_pct": _rule_metrics(sub_far).get("ev_pct") if len(sub_far) else None, + "sim_win_rate": m_all.get("win_rate"), + "sim_profit_factor": m_all.get("profit_factor"), + "holdout_ev_pct": m_hold.get("ev_pct"), + "holdout_count": m_hold.get("count"), + } + ) + + monitor_ids = [r["rule_id"] for r in matched.get("monitor_rules", [])] + monitor_summary = [r for r in per_rule if r["rule_id"] in monitor_ids] + + go = sim_report.get("go_no_go", {}) + + return { + "tolerance_min": tolerance_min, + "label_mode": matched.get("label_mode"), + "gt_baseline": gt_baseline, + "gt_overlap_all_fires_dedup": overlap_all, + "gt_overlap_matched_json": matched.get("gt_overlap"), + "per_rule": per_rule, + "pair_alignment": pair_stats, + "monitor_rules": monitor_summary, + "simulation_go_no_go": { + "go": go.get("go"), + "checks": go.get("checks", []), + "live_cap_taken_ratio": go.get("live_cap_taken_ratio"), + }, + "notes": [ + "gt_overlap_matched_json: 04 선별 시 전 규칙 발화 합산(중복 dt 제거 전) 기준.", + "per_rule.gt_recall: 해당 규칙 발화만으로 GT 타점 커버.", + "precision_near_gt: 발화 중 GT±tolerance 내 비율(낮을수록 잡음 많음).", + "gt_forward_pct vs sim_leg_gt_pct: leg_gt 라벨과 GT JSON forward_return_pct 정의 차이 가능.", + ], + } + + +def write_gt_comparison_html(report: dict[str, Any], out_path: Path) -> Path: + """ + gt_comparison_report.html 저장. + + Args: + report: build_gt_comparison_report 결과. + out_path: HTML 경로. + + Returns: + out_path. + """ + def _rows(items: list[dict], cols: list[str]) -> str: + lines = [] + for it in items: + cells = "".join(f"{it.get(c, '')}" for c in cols) + lines.append(f"{cells}") + return "\n".join(lines) + + pr_cols = [ + "rule_id", "side", "fire_count", "gt_recall", "precision_near_gt", + "sim_ev_all_pct", "sim_ev_near_gt_pct", "sim_ev_far_gt_pct", "holdout_ev_pct", + ] + go = report.get("simulation_go_no_go", {}) + go_flag = "GO" if go.get("go") else "NO-GO" + gb = report.get("gt_baseline", {}) + html = f""" + +GT vs Simulation Comparison + +

Ground Truth vs 규칙·시뮬 비교

+

허용 오차: ±{report.get('tolerance_min')}분 · 라벨: {report.get('label_mode')}

+

시뮬 Go/No-Go: {go_flag}

+ +

GT 기준선 (forward_return_pct)

+

총 {gb.get('total')}건 (매수 {gb.get('buy')} / 매도 {gb.get('sell')})

+ + + + + + + + + + + +
구분건수평균 forward%중앙값승률
매수 GT{gb.get('buy', {}).get('count', '')}{gb.get('buy', {}).get('mean_forward_pct', '')}{gb.get('buy', {}).get('median_forward_pct', '')}{gb.get('buy', {}).get('win_rate', '')}
매도 GT{gb.get('sell', {}).get('count', '')}{gb.get('sell', {}).get('mean_forward_pct', '')}{gb.get('sell', {}).get('median_forward_pct', '')}{gb.get('sell', {}).get('win_rate', '')}
+ +

규칙별 GT recall / precision / EV

+ +{''.join(f'' for c in pr_cols)} +{_rows(report.get('per_rule', []), pr_cols)} +
{c}
+ +

monitor_rules (실감시·시뮬 대상)

+ +{''.join(f'' for c in pr_cols)} +{_rows(report.get('monitor_rules', []), pr_cols)} +
{c}
+ +

GT–발화 수익률 정렬 (±{report.get('tolerance_min')}분)

+ + + +{''.join( + f"" + f"" + f"" + for p in report.get('pair_alignment', []) +)} +
rulesidepairscorrmean|diff|%mean Δmin
{p['rule_id']}{p['side']}{p['pair_count']}{p.get('corr_gt_vs_sim','')}{p.get('mean_abs_diff_pct','')}{p.get('mean_delta_min','')}
+ +

시뮬 검증 (monitor)

+
{json.dumps(go, ensure_ascii=False, indent=2)}
+ +

참고

+ +""" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(html, encoding="utf-8") + return out_path + + +def run_gt_comparison_report( + outcomes_path: Path | None = None, + matched_path: Path | None = None, +) -> dict[str, Any]: + """ + GT 비교 리포트 생성·저장. + + Args: + outcomes_path: fire_outcomes.csv. + matched_path: matched_rules.json. + + Returns: + report dict. + """ + report = build_gt_comparison_report(outcomes_path, matched_path) + MATCHING_GT_COMPARISON_JSON.parent.mkdir(parents=True, exist_ok=True) + MATCHING_GT_COMPARISON_JSON.write_text( + json.dumps(report, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + write_gt_comparison_html(report, MATCHING_GT_COMPARISON_HTML) + print(f"[GT비교] 저장: {MATCHING_GT_COMPARISON_JSON}") + print(f"[GT비교] 저장: {MATCHING_GT_COMPARISON_HTML}") + for m in report.get("monitor_rules", []): + print( + f" {m['rule_id']}: recall={m['gt_recall']:.1%} prec={m['precision_near_gt']:.1%} " + f"fires={m['fire_count']} EV={m['sim_ev_all_pct']}% holdout={m['holdout_ev_pct']}%" + ) + go = report.get("simulation_go_no_go", {}) + print(f"[GT비교] 시뮬 연동: {'GO' if go.get('go') else 'NO-GO'}") + return report diff --git a/deepcoin/matching/gt_mtf_profile.py b/deepcoin/matching/gt_mtf_profile.py new file mode 100644 index 0000000..822213a --- /dev/null +++ b/deepcoin/matching/gt_mtf_profile.py @@ -0,0 +1,514 @@ +""" +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"{pfx}{block.get('feature_count', 0)}" + f"{top_s}" + ) + return rows + + def _rows_global() -> str: + rows = "" + for item in analysis.get("global_top_separation") or []: + rows += ( + f"{item['col']}{item['tf']}" + f"{item['family']}{item['separation']:.3f}" + f"{item.get('buy_median','')}{item.get('sell_median','')}" + ) + return rows + + buy_feats = ", ".join(analysis["selected_features"]["buy"][:25]) + sell_feats = ", ".join(analysis["selected_features"]["sell"][:25]) + + html = f""" + +GT MTF 프로필 (3분~일봉) + +

Ground Truth MTF 타점 프로필

+

매수 GT {analysis['buy_gt_count']}건 · 매도 GT {analysis['sell_gt_count']}건 · +분석 컬럼 {analysis['columns_analyzed']}개 (3,5,10,15,30,60,240,1440분 + MTF 합성)

+

분리도 = |mean_buy − mean_sell| / pooled_std. TF별·글로벌 상위 피처로 04 규칙 후보를 생성합니다.

+ +

간격별 분리도 상위 (요약)

+ +{_rows_interval()}
TF숫자 피처 수상위 5 (분리도)
+ +

글로벌 분리도 Top 40

+ +{_rows_global()}
컬럼TF기법군분리도매수 median매도 median
+ +

04 규칙 선별용 피처 (발췌)

+

매수
{buy_feats}

+

매도
{sell_feats}

+""" + html_path.write_text(html, encoding="utf-8") + return html_path diff --git a/deepcoin/matching/gt_profile_iterate.py b/deepcoin/matching/gt_profile_iterate.py new file mode 100644 index 0000000..72e147f --- /dev/null +++ b/deepcoin/matching/gt_profile_iterate.py @@ -0,0 +1,539 @@ +""" +GT 타점 MTF 프로필 반복 보강 — 스냅샷 recall·총자산 비율 90% 목표. +""" + +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, +) +from deepcoin.analysis.general_analysis_core import interval_tf_prefix +from deepcoin.matching.config import ANALYSIS_TRADES_CSV +from deepcoin.matching.gt_asset_calibration import ( + evaluate_gt_snapshot_recall, + portfolio_asset_ratio, +) +from deepcoin.matching.gt_mtf_profile import ( + analyze_gt_mtf_profile, + discover_profile_columns, +) +from deepcoin.matching.profile_rules import ( + _condition_from_series, + _feature_separation, + build_rule_candidates, +) +from deepcoin.matching.rule_eval import eval_rule_mask +from deepcoin.paths import ( + ANALYSIS_GT_CALIBRATION_JSON, + ANALYSIS_GT_MTF_PROFILE_JSON, + resolve_ground_truth_file, +) +from deepcoin.ground_truth.ground_truth import load_ground_truth + + +def _condition_or_group( + series: pd.Series, + side: str, + quantile_lo: float = 0.15, + quantile_hi: float = 0.85, +) -> dict[str, Any] | None: + """ + 한 컬럼 GT 분포에서 between 조건. + + Args: + series: side GT 값. + side: buy | sell. + quantile_lo: 하한 분위. + quantile_hi: 상한 분위. + + Returns: + 조건 dict. + """ + col_name = series.name + if series.dtype == object or not pd.api.types.is_numeric_dtype(series): + 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) < MATCH_PROFILE_MIN_SAMPLES: + return None + lo = float(s.quantile(quantile_lo)) + hi = float(s.quantile(quantile_hi)) + if lo >= hi: + return None + return {"col": col_name, "op": "between", "lo": lo, "hi": hi} + + +def build_or_tf_rules( + buy: pd.DataFrame, + sell: pd.DataFrame, + ranked_cols: list[str], + *, + per_tf: int = 4, +) -> list[dict[str, Any]]: + """ + TF별 OR 복합 규칙 (해당 TF 상위 분리 컬럼 중 하나만 충족). + + Args: + buy: 매수 GT. + sell: 매도 GT. + ranked_cols: 분리도 순 컬럼. + per_tf: TF당 OR 조건 수. + + Returns: + rule dict 리스트. + """ + rules: list[dict[str, Any]] = [] + for side, subset in (("buy", buy), ("sell", sell)): + for iv in GENERAL_ANALYSIS_INTERVALS: + pfx = interval_tf_prefix(iv) + iv_cols = [ + c + for c in ranked_cols + if c.startswith(f"{pfx}_") and c in subset.columns + ] + iv_cols = sorted( + iv_cols, + key=lambda c: _feature_separation(buy, sell, c), + reverse=True, + )[:per_tf] + conds: list[dict[str, Any]] = [] + for col in iv_cols: + c = _condition_or_group(subset[col], side, 0.20, 0.80) + if c: + conds.append(c) + if len(conds) >= 2 and pfx not in ("m240",): + rules.append( + { + "rule_id": f"{side}_or_{pfx}", + "side": side, + "kind": "or_tf", + "logic": "or", + "conditions": conds, + } + ) + return rules + + +def build_unmatched_atomic_rules( + trades_df: pd.DataFrame, + rules: list[dict[str, Any]], + side: str, + *, + max_new: int = 12, +) -> list[dict[str, Any]]: + """ + 스냅샷 미매칭 GT 행에서 분리도 큰 컬럼 atomic 규칙 추가. + + Args: + trades_df: 03b CSV. + rules: 기존 규칙. + side: buy | sell. + + Returns: + 신규 atomic rule dict. + """ + gt = trades_df[trades_df["action"] == side] + buy_all = trades_df[trades_df["action"] == "buy"] + sell_all = trades_df[trades_df["action"] == "sell"] + side_rules = [r for r in rules if r.get("side") == side] + + unmatched_idx: list[int] = [] + for idx, row in gt.iterrows(): + fr = pd.DataFrame([row]) + if not any(bool(eval_rule_mask(fr, r).iloc[0]) for r in side_rules): + unmatched_idx.append(idx) + + if not unmatched_idx: + return [] + + unmatched = gt.loc[unmatched_idx] + matched = gt.drop(index=unmatched_idx, errors="ignore") + other = sell_all if side == "buy" else buy_all + + cols = discover_profile_columns(trades_df) + scores: list[tuple[float, str]] = [] + for col in cols: + if col not in unmatched.columns: + continue + if not pd.api.types.is_numeric_dtype(unmatched[col]): + continue + u = pd.to_numeric(unmatched[col], errors="coerce").dropna() + m = pd.to_numeric(matched[col], errors="coerce").dropna() if len(matched) >= 5 else pd.to_numeric(gt[col], errors="coerce").dropna() + o = pd.to_numeric(other[col], errors="coerce").dropna() + if len(u) < 3 or len(o) < 5: + continue + sep = abs(float(u.mean() - o.mean())) / (np.sqrt((u.var() + o.var()) / 2) + 1e-9) + scores.append((sep, col)) + + scores.sort(reverse=True) + new_rules: list[dict[str, Any]] = [] + existing_cols = { + c["col"] + for r in rules + if r.get("side") == side + for c in r.get("conditions", []) + } + for sep, col in scores[: max_new * 3]: + if col in existing_cols: + continue + if sep < MATCH_PROFILE_MIN_SEPARATION * 0.5: + continue + cond = _condition_from_series(unmatched[col], side) + if cond is None: + cond = _condition_or_group(unmatched[col], side, 0.10, 0.90) + if cond is None: + continue + rid = f"{side}_cal_{col}" + new_rules.append( + { + "rule_id": rid, + "side": side, + "kind": "calibration_atomic", + "logic": "and", + "conditions": [cond], + "profile_col": col, + "calibration_sep": round(sep, 4), + } + ) + existing_cols.add(col) + if len(new_rules) >= max_new: + break + return new_rules + + +def _feature_separation_df( + buy: pd.DataFrame, + sell: pd.DataFrame, + col: str, +) -> float: + """DataFrame 컬럼 분리도.""" + if col not in buy.columns: + 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 run_profile_calibration_loop( + trades_csv: Path | None = None, + *, + target_recall: float = 0.90, + target_asset_ratio: float = 0.90, + max_iterations: int = 5, +) -> dict[str, Any]: + """ + 03b·GT 기준 반복 규칙 보강 및 검증. + + Args: + trades_csv: 03b CSV. + target_recall: 매수·매도 스냅샷 recall 목표. + target_asset_ratio: GT 총자산 대비 subset 비율 목표. + max_iterations: 최대 반복. + + Returns: + calibration 리포트 dict. + """ + path = trades_csv or ANALYSIS_TRADES_CSV + df = pd.read_csv(path) + buy = df[df["action"] == "buy"] + sell = df[df["action"] == "sell"] + + analysis = analyze_gt_mtf_profile(df) + 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", + ) + + numeric_ranked = sorted( + [ + f["col"] + for f in analysis["features"] + if f["dtype"] == "numeric" + ], + key=lambda c: next( + (x["separation"] for x in analysis["global_top_separation"] if x["col"] == c), + _feature_separation_df(buy, sell, c), + ), + reverse=True, + ) + + base = build_rule_candidates(path) + rules: list[dict[str, Any]] = list(base.get("rules", [])) + for r in rules: + if "logic" not in r: + r["logic"] = "and" + + rules.extend(build_or_tf_rules(buy, sell, numeric_ranked[:80])) + + history: list[dict[str, Any]] = [] + best_rules: list[dict[str, Any]] = list(rules) + best_asset_ratio = -1.0 + 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") + + for it in range(max_iterations): + recall = evaluate_gt_snapshot_recall(df, rules) + buy_rec = recall["buy"]["recall"] + sell_rec = recall["sell"]["recall"] + + buy_legs = {int(t["leg_id"]) for t in gt_trades if t["action"] == "buy"} + sell_legs = {int(t["leg_id"]) for t in gt_trades if t["action"] == "sell"} + all_legs = buy_legs | sell_legs + + included_legs = set() + gt_df = pd.DataFrame(gt_trades) + for lid in all_legs: + leg = gt_df[gt_df["leg_id"] == lid] + leg_buy_ok = True + leg_sell_ok = True + for _, row in leg[leg["action"] == "buy"].iterrows(): + sub = df[(df["dt"] == row["dt"]) & (df["action"] == "buy")] + if sub.empty: + leg_buy_ok = False + break + fr = pd.DataFrame([sub.iloc[0]]) + if not any( + bool(eval_rule_mask(fr, r).iloc[0]) + for r in rules + if r.get("side") == "buy" + ): + leg_buy_ok = False + break + for _, row in leg[leg["action"] == "sell"].iterrows(): + sub = df[(df["dt"] == row["dt"]) & (df["action"] == "sell")] + if sub.empty: + leg_sell_ok = False + break + fr = pd.DataFrame([sub.iloc[0]]) + if not any( + bool(eval_rule_mask(fr, r).iloc[0]) + for r in rules + if r.get("side") == "sell" + ): + leg_sell_ok = False + break + if leg_buy_ok and leg_sell_ok: + included_legs.add(int(lid)) + + asset = portfolio_asset_ratio(gt_trades, included_legs, mark) + row_hist = { + "iteration": it, + "rule_count": len(rules), + "buy_recall": buy_rec, + "sell_recall": sell_rec, + **asset, + } + history.append(row_hist) + print( + f"[cal {it}] rules={len(rules)} " + f"buy_rec={buy_rec:.2%} sell_rec={sell_rec:.2%} " + f"asset_ratio={asset['asset_ratio']:.2%} legs={asset['legs_covered']}/{asset['legs_total']}" + ) + if asset["asset_ratio"] > best_asset_ratio: + best_asset_ratio = asset["asset_ratio"] + best_rules = list(rules) + + if ( + buy_rec >= target_recall + and sell_rec >= target_recall + and asset["asset_ratio"] >= target_asset_ratio + ): + break + + added = 0 + for side in ("buy", "sell"): + rec = recall[side]["recall"] + if rec >= target_recall: + continue + new_rules = build_unmatched_atomic_rules(df, rules, side, max_new=15) + rules.extend(new_rules) + added += len(new_rules) + if added == 0: + rules.extend(build_or_tf_rules(buy, sell, numeric_ranked[:120])) + for side in ("buy", "sell"): + rules.extend( + build_unmatched_atomic_rules(df, rules, side, max_new=20) + ) + if len(rules) > 200: + break + + final_recall = evaluate_gt_snapshot_recall(df, rules) + final_legs: set[int] = set() + gt_df = pd.DataFrame(gt_trades) + for lid in gt_df["leg_id"].unique(): + leg = gt_df[gt_df["leg_id"] == lid] + ok_b = ok_s = True + for _, row in leg[leg["action"] == "buy"].iterrows(): + sub = df[(df["dt"] == row["dt"]) & (df["action"] == "buy")] + if sub.empty or not any( + bool(eval_rule_mask(pd.DataFrame([sub.iloc[0]]), r).iloc[0]) + for r in rules + if r.get("side") == "buy" + ): + ok_b = False + for _, row in leg[leg["action"] == "sell"].iterrows(): + sub = df[(df["dt"] == row["dt"]) & (df["action"] == "sell")] + if sub.empty or not any( + bool(eval_rule_mask(pd.DataFrame([sub.iloc[0]]), r).iloc[0]) + for r in rules + if r.get("side") == "sell" + ): + ok_s = False + if ok_b and ok_s: + final_legs.add(int(lid)) + + final_asset = portfolio_asset_ratio(gt_trades, final_legs, mark) + + out = { + "target_recall": target_recall, + "target_asset_ratio": target_asset_ratio, + "iterations": history, + "final": { + "rule_count": len(rules), + "snapshot_recall": final_recall, + "portfolio": final_asset, + "targets_met": ( + final_recall["buy"]["recall"] >= target_recall + and final_recall["sell"]["recall"] >= target_recall + and final_asset["asset_ratio"] >= target_asset_ratio + ), + }, + "calibrated_rules": rules, + } + deduped: list[dict[str, Any]] = [] + seen_rid: set[str] = set() + for r in best_rules: + rid = r.get("rule_id", "") + if rid in seen_rid: + continue + seen_rid.add(rid) + deduped.append(r) + rules = _greedy_recall_cover(df, deduped, target_recall=target_recall) + out["final"]["rule_count_after_greedy"] = len(rules) + out["calibrated_rules"] = rules + out["final"]["snapshot_recall"] = evaluate_gt_snapshot_recall(df, rules) + final_legs_g: set[int] = set() + gt_df = pd.DataFrame(gt_trades) + for lid in gt_df["leg_id"].unique(): + leg = gt_df[gt_df["leg_id"] == lid] + ok_b = ok_s = True + for _, row in leg[leg["action"] == "buy"].iterrows(): + sub = df[(df["dt"] == row["dt"]) & (df["action"] == "buy")] + if sub.empty or not any( + bool(eval_rule_mask(pd.DataFrame([sub.iloc[0]]), r).iloc[0]) + for r in rules + if r.get("side") == "buy" + ): + ok_b = False + for _, row in leg[leg["action"] == "sell"].iterrows(): + sub = df[(df["dt"] == row["dt"]) & (df["action"] == "sell")] + if sub.empty or not any( + bool(eval_rule_mask(pd.DataFrame([sub.iloc[0]]), r).iloc[0]) + for r in rules + if r.get("side") == "sell" + ): + ok_s = False + if ok_b and ok_s: + final_legs_g.add(int(lid)) + out["final"]["portfolio"] = portfolio_asset_ratio( + gt_trades, final_legs_g, mark + ) + fr = out["final"]["snapshot_recall"] + pa = out["final"]["portfolio"] + out["final"]["targets_met"] = ( + fr["buy"]["recall"] >= target_recall + and fr["sell"]["recall"] >= target_recall + and pa["asset_ratio"] >= target_asset_ratio + ) + ANALYSIS_GT_CALIBRATION_JSON.parent.mkdir(parents=True, exist_ok=True) + ANALYSIS_GT_CALIBRATION_JSON.write_text( + json.dumps(out, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return out + + +def _greedy_recall_cover( + trades_df: pd.DataFrame, + rules: list[dict[str, Any]], + *, + target_recall: float = 0.90, + max_per_side: int = 40, +) -> list[dict[str, Any]]: + """ + 측면별 recall 목표까지 greedy로 규칙 축소. + + Args: + trades_df: 03b CSV. + rules: 후보 규칙 전체. + target_recall: 목표 recall. + + Returns: + 축소된 규칙 + 기존 compound/mtf_cross 유지. + """ + keep_kinds = { + "compound_tight", + "compound", + "contrast", + "mtf_cross", + "or_tf", + } + kept = [r for r in rules if r.get("kind") in keep_kinds] + pool = [r for r in rules if r not in kept] + + for side in ("buy", "sell"): + gt = trades_df[trades_df["action"] == side] + if gt.empty: + continue + uncovered = set(gt.index) + side_pool = [r for r in pool if r.get("side") == side] + picked: list[dict[str, Any]] = [] + while uncovered and len(picked) < max_per_side: + best_rule = None + best_new = 0 + for rule in side_pool: + if rule in picked: + continue + new_hit = 0 + for idx in list(uncovered): + row = gt.loc[idx] + if bool(eval_rule_mask(pd.DataFrame([row]), rule).iloc[0]): + new_hit += 1 + if new_hit > best_new: + best_new = new_hit + best_rule = rule + if best_rule is None or best_new == 0: + break + picked.append(best_rule) + still = set() + for idx in uncovered: + row = gt.loc[idx] + if not any( + bool(eval_rule_mask(pd.DataFrame([row]), r).iloc[0]) + for r in picked + [x for x in kept if x.get("side") == side] + ): + still.add(idx) + uncovered = still + rec = 1.0 - len(uncovered) / len(gt) + if rec >= target_recall: + break + kept.extend(picked) + return kept diff --git a/deepcoin/matching/gt_schedule.py b/deepcoin/matching/gt_schedule.py new file mode 100644 index 0000000..27ccab3 --- /dev/null +++ b/deepcoin/matching/gt_schedule.py @@ -0,0 +1,64 @@ +""" +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 diff --git a/deepcoin/matching/label_outcomes.py b/deepcoin/matching/label_outcomes.py new file mode 100644 index 0000000..597a520 --- /dev/null +++ b/deepcoin/matching/label_outcomes.py @@ -0,0 +1,231 @@ +""" +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 diff --git a/deepcoin/matching/live_eval.py b/deepcoin/matching/live_eval.py new file mode 100644 index 0000000..4571589 --- /dev/null +++ b/deepcoin/matching/live_eval.py @@ -0,0 +1,86 @@ +""" +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 diff --git a/deepcoin/matching/load_rules.py b/deepcoin/matching/load_rules.py new file mode 100644 index 0000000..0beb54e --- /dev/null +++ b/deepcoin/matching/load_rules.py @@ -0,0 +1,61 @@ +""" +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) diff --git a/deepcoin/matching/match_rules.py b/deepcoin/matching/match_rules.py index 3a2b81d..8563791 100644 --- a/deepcoin/matching/match_rules.py +++ b/deepcoin/matching/match_rules.py @@ -1,31 +1,44 @@ """ -04단계 스텁: GT 스냅샷과 현재 상태 유사도·규칙 후보 (구현 예정). +04단계: GT 프로필 + 전구간 EV 필터 매칭 파이프라인. """ from __future__ import annotations from pathlib import Path -from deepcoin.paths import REPORTS_ANALYSIS, REPORTS_MATCHING, resolve_ground_truth_file +from deepcoin.matching.pipeline import run_matching_pipeline +from deepcoin.paths import ANALYSIS_TRADES_CSV, REPORTS_ANALYSIS, REPORTS_MATCHING + + +def run_match( + phase: str = "all", + trades_csv: Path | None = None, +) -> None: + """ + 04 파이프라인 실행. + + Args: + phase: all | profile | scan | label | select. + trades_csv: 03b CSV 경로(선택). + """ + REPORTS_MATCHING.mkdir(parents=True, exist_ok=True) + csv = trades_csv or ANALYSIS_TRADES_CSV + if not csv.is_file(): + raise FileNotFoundError( + f"03b CSV 없음: {csv}\n python scripts/03_analyze_trades.py 먼저 실행" + ) + run_matching_pipeline(phase=phase, trades_csv=csv) def run_match_stub() -> Path: - """ - 입력 파일 존재 여부만 확인하고 04단계 안내를 출력합니다. - - Returns: - matching 리포트 디렉터리. - """ - REPORTS_MATCHING.mkdir(parents=True, exist_ok=True) - gt = resolve_ground_truth_file() - csv = REPORTS_ANALYSIS / "general_analysis_trades.csv" - print("=== Phase 04 Matching (stub) ===") - print(f" ground truth: {gt} ({'OK' if gt.is_file() else 'MISSING'})") - print(f" analysis csv: {csv} ({'OK' if csv.is_file() else 'MISSING — run scripts/03_analyze_trades.py'})") + """하위 호환: 스텁 대신 phase=profile만 안내.""" + print("=== Phase 04 Matching ===") + print(" 전체 파이프라인: python scripts/04_match_rules.py") + print(" 단계별: --phase profile|scan|label|select") + print(f" analysis csv: {ANALYSIS_TRADES_CSV}") print(f" output dir: {REPORTS_MATCHING}") - print(" 구현 예정: 유사도·규칙 선택") return REPORTS_MATCHING if __name__ == "__main__": - run_match_stub() + run_match() diff --git a/deepcoin/matching/pipeline.py b/deepcoin/matching/pipeline.py new file mode 100644 index 0000000..1ff3ad3 --- /dev/null +++ b/deepcoin/matching/pipeline.py @@ -0,0 +1,145 @@ +""" +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() diff --git a/deepcoin/matching/portfolio_sim.py b/deepcoin/matching/portfolio_sim.py new file mode 100644 index 0000000..38e03f9 --- /dev/null +++ b/deepcoin/matching/portfolio_sim.py @@ -0,0 +1,215 @@ +""" +규칙 발화 기반 고정 금액 체결 포트폴리오 시뮬 (GT HTML 카드·테이블용). +""" + +from __future__ import annotations + +from typing import Any + +import pandas as pd + +from config import ( + GT_INITIAL_CASH_KRW, + LIVE_DAILY_KRW_MAX, + LIVE_MAX_TRADES_PER_DAY, + LIVE_ORDER_KRW, + TRADING_FEE_RATE, +) + + +def select_capped_fires(fires: pd.DataFrame) -> pd.DataFrame: + """ + 일한도·회수 제한으로 체결 가능한 발화만 남깁니다. + + Args: + fires: fire_outcomes (dt, side, close, rule_id …). + + Returns: + 체결된 발화 DataFrame. + """ + if fires.empty: + return fires + df = fires.sort_values("dt").copy() + df["ts"] = pd.to_datetime(df["dt"]) + df["day"] = df["ts"].dt.date.astype(str) + taken: list[pd.DataFrame] = [] + for _, day_grp in df.groupby("day", sort=True): + spent = 0.0 + n_trades = 0 + idxs: list[Any] = [] + for idx, _row in day_grp.iterrows(): + if n_trades >= LIVE_MAX_TRADES_PER_DAY: + break + if spent + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX: + break + spent += LIVE_ORDER_KRW + n_trades += 1 + idxs.append(idx) + if idxs: + taken.append(day_grp.loc[idxs]) + if not taken: + return df.iloc[0:0] + return pd.concat(taken, ignore_index=True) + + +def fires_to_trade_list(fires: pd.DataFrame) -> list[dict[str, Any]]: + """ + 발화 DataFrame을 포트폴리오 시뮬용 trade dict 리스트로 변환. + + Args: + fires: 체결 대상 발화. + + Returns: + dt, action, price 키를 가진 dict 리스트. + """ + rows: list[dict[str, Any]] = [] + for _, r in fires.sort_values("dt").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)), + } + ) + return rows + + +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, +) -> dict[str, Any]: + """ + 매 체결마다 고정 원화 금액으로 매수·매도한 뒤 총평가·수익률을 계산합니다. + + Args: + trades: 시간순 {dt, action, price}. + order_krw: 1회 매수·매도 금액(원). + initial_cash: 시작 현금. + fee_rate: 수수료율. + last_price: 미청산 평가 종가. + + 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": + amount = min(order, 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: + sell_qty = min(qty, order / 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), + "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, +) -> list[dict[str, Any]]: + """ + 체결마다 현금·보유·총평가 스냅샷 (GT 테이블용). + + Args: + trades: 시간순 trade dict. + order_krw: 1회 체결 원화. + initial_cash: 시작 현금. + fee_rate: 수수료율. + + 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": + amount = min(order, 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: + sell_qty = min(qty, order / 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"), + "cash_krw": round(cash, 0), + "holding_qty": round(qty, 4), + "total_asset_krw": round(cash + qty * price, 0), + } + ) + return steps diff --git a/deepcoin/matching/profile_rules.py b/deepcoin/matching/profile_rules.py new file mode 100644 index 0000000..ba5a978 --- /dev/null +++ b/deepcoin/matching/profile_rules.py @@ -0,0 +1,418 @@ +""" +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)}개") + + 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 diff --git a/deepcoin/matching/rule_eval.py b/deepcoin/matching/rule_eval.py new file mode 100644 index 0000000..9b03014 --- /dev/null +++ b/deepcoin/matching/rule_eval.py @@ -0,0 +1,318 @@ +""" +규칙 조건 벡터 평가·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}분 데이터 없음") + + print(f"[04b] Phase A: 8TF 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) + + 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) diff --git a/deepcoin/matching/select_rules.py b/deepcoin/matching/select_rules.py new file mode 100644 index 0000000..5bb1bc6 --- /dev/null +++ b/deepcoin/matching/select_rules.py @@ -0,0 +1,362 @@ +""" +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"{s['rule_id']}{s['side']}" + f"{v['count']}{v['ev_pct']}" + f"{h.get('count', 0)}{h.get('ev_pct', 0)}" + f"{h.get('profit_factor', 0)}" + ) + gt = matched.get("gt_overlap", {}) + html = f""" + +04 Backtest Summary + +

04 매칭 — {title} (valid 구간)

+

방법: {matched.get('method','')}

+

{matched.get('note','')}

+

선별 규칙

+ + + +{''.join(rows) if rows else ''} +
rule_idsidevalid_nvalid_evholdout_nholdout_evholdout_pf
통과 규칙 없음
+

GT recall (±{MATCH_GT_TOLERANCE_MIN}분, 전체 발화 기준)

+ +""" + 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 diff --git a/deepcoin/matching/simulation.py b/deepcoin/matching/simulation.py new file mode 100644 index 0000000..63b55cd --- /dev/null +++ b/deepcoin/matching/simulation.py @@ -0,0 +1,371 @@ +""" +1단계: walk-forward·민감도·실거래 한도 가정 시뮬·Go/No-Go 리포트. +""" + +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 ( + LIVE_DAILY_KRW_MAX, + LIVE_MAX_TRADES_PER_DAY, + 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_WALK_FORWARD_MIN_MONTHS, + TRADING_FEE_RATE, +) +from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout +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) -> dict[str, Any]: + """ + 1회·일 한도·슬리피지 가정으로 체결 가능한 발화만 집계. + + Args: + outcomes: fire_outcomes. + + Returns: + 규칙별·전체 요약. + """ + if outcomes.empty: + return {"rules": {}, "note": "발화 없음"} + + df = outcomes.sort_values("dt").copy() + df["ts"] = pd.to_datetime(df["dt"]) + df["day"] = df["ts"].dt.date.astype(str) + slip = LIVE_SLIPPAGE_PCT + taken_rows: list[pd.DataFrame] = [] + + for day, day_grp in df.groupby("day", sort=True): + spent = 0.0 + n_trades = 0 + taken_idx: list[int] = [] + for idx, row in day_grp.iterrows(): + if n_trades >= LIVE_MAX_TRADES_PER_DAY: + break + if spent + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX: + break + spent += LIVE_ORDER_KRW + n_trades += 1 + taken_idx.append(idx) + if taken_idx: + taken_rows.append(day_grp.loc[taken_idx]) + + if not taken_rows: + return {"rules": {}, "taken_count": 0} + + taken = pd.concat(taken_rows, ignore_index=True) + 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": { + "order_krw": LIVE_ORDER_KRW, + "daily_krw_max": LIVE_DAILY_KRW_MAX, + "slippage_pct": slip, + }, + "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 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) + ) + + live_cap = simulate_live_order_cap(outcomes) + go = evaluate_go_no_go(matched, wf_sum, fee_stress, live_cap) + + 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", {}) + else: + from deepcoin.ground_truth.ground_truth import load_ground_truth + from deepcoin.matching.gt_asset_calibration import ( + portfolio_asset_ratio, + ) + + gt_data = load_ground_truth(resolve_ground_truth_file()) or {} + trades = gt_data.get("trades") or [] + mark = (gt_data.get("summary") or {}).get("mark_price") + if trades: + gt_portfolio = { + "portfolio": portfolio_asset_ratio(trades, set(), mark), + "note": "캘리브레이션 미실행 — scripts/04_calibrate_gt_assets.py", + } + + summaries = matched.get("all_rule_summaries") or matched.get("monitor_rules") or [] + 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, + "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, + }, + } + + +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"] + print(f"[시뮬] 저장: {MATCHING_SIMULATION_JSON}") + print(f"[시뮬] 저장: {MATCHING_SIMULATION_HTML}") + print(f"[시뮬] Go/No-Go: {'GO' if go else 'NO-GO'}") + 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 {} + 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 diff --git a/deepcoin/matching/simulation_html.py b/deepcoin/matching/simulation_html.py new file mode 100644 index 0000000..a0f0370 --- /dev/null +++ b/deepcoin/matching/simulation_html.py @@ -0,0 +1,460 @@ +""" +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, + simulate_truth_portfolio, + simulate_truth_portfolio_steps, +) +from deepcoin.matching.portfolio_sim import ( + fires_to_trade_list, + select_capped_fires, + simulate_fixed_order_portfolio, + simulate_fixed_order_portfolio_steps, +) +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, + 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 "발화 없음" + sorted_fires = fires.sort_values("dt").reset_index(drop=True) + lines: list[str] = [] + lines.append( + f""" + + 시작---- + ₩{GT_INITIAL_CASH_KRW:,.0f} + -초기 현금 (1회 ₩{LIVE_ORDER_KRW:,.0f} 가정) + """ + ) + 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"" + f"{str(r['dt'])[:16]}" + f'{mark}' + f"{r['rule_id']}" + f"{kind}" + f'₩{float(r["close"]):,.0f}{ret_s}' + f"{total_s}{hold_s}" + f'{win}' + f"leg_gt 구간 수익" + f"" + ) + 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 "타점 없음" + step_key = { + (s["dt"], s["action"], float(s["price"]), float(s["weight"])): s + for s in steps + } + lines: list[str] = [] + lines.append( + f""" + + 시작--- + ₩{GT_INITIAL_CASH_KRW:,.0f} + 초기 현금 (보유 0) + """ + ) + 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"" + f"{t['dt'][:16]}" + f'{mark}' + f"{w*100:.0f}%" + f"₩{t['price']:,.0f}{ret_s}" + f"{total_s}{hold_s}" + f"{t.get('memo', '')}" + f"" + ) + 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_pnl: dict[str, Any], + sim_trade_count: int, + go_flag: bool, +) -> str: + """ + ground_truth HTML과 동일 구성의 상단 카드 (GT + 시뮬 2줄). + + Args: + close_last: 종가. + bb_txt: BB %B. + gt_trades: GT trades. + gt_pnl: GT 포트폴리오 요약. + sim_pnl: 시뮬 포트폴리오 요약. + sim_trade_count: 체결 가정 발화 수. + go_flag: Go/No-Go. + + Returns: + cards HTML. + """ + go_cls = "go-pass" if go_flag else "go-fail" + gt_row = ( + '

정답 (ground_truth) — 분할 비중·leg 체결

' + + market_cards_html(close_last, bb_txt) + + pnl_cards_html(gt_pnl, "정답 타점", len(gt_trades)) + ) + sim_row = ( + '

시뮬 (monitor_rules · holdout · ' + f"1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도) — " + f'{"GO" if go_flag else "NO-GO"}

' + + pnl_cards_html(sim_pnl, "시뮬 체결", sim_trade_count) + ) + return gt_row + sim_row + + +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} + + holdout_fires = pd.DataFrame() + if op.is_file(): + outcomes = pd.read_csv(op) + outcomes["split"] = _split_train_valid_holdout(outcomes) + holdout_fires = outcomes[ + (outcomes["rule_id"].isin(monitor_ids)) & (outcomes["split"] == "holdout") + ].copy() + + capped = select_capped_fires(holdout_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")) + 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 = fires_to_trade_list(capped) + gt_pnl = {} + gt_summary_pnl = gt_data.get("summary") or {} + if gt_summary_pnl.get("pnl_krw") is not None and gt_summary_pnl.get( + "execution_order" + ) == "leg_block": + gt_pnl = { + k: gt_summary_pnl[k] + for k in ( + "initial_cash_krw", + "final_asset_krw", + "pnl_pct", + "total_fees_krw", + "holding_qty", + "holding_value_krw", + "mark_price", + "cash_krw", + ) + if k in gt_summary_pnl + } + elif gt_trades: + gt_pnl = simulate_truth_portfolio( + gt_trades, + initial_cash=GT_INITIAL_CASH_KRW, + fee_rate=TRADING_FEE_RATE, + last_price=close_val if close_val else None, + ) + + sim_pnl = simulate_fixed_order_portfolio( + sim_trades, + order_krw=LIVE_ORDER_KRW, + initial_cash=GT_INITIAL_CASH_KRW, + fee_rate=TRADING_FEE_RATE, + last_price=close_val if close_val else None, + ) + sim_steps = simulate_fixed_order_portfolio_steps( + sim_trades, + order_krw=LIVE_ORDER_KRW, + initial_cash=GT_INITIAL_CASH_KRW, + fee_rate=TRADING_FEE_RATE, + ) + gt_steps = ( + simulate_truth_portfolio_steps( + gt_trades, + initial_cash=GT_INITIAL_CASH_KRW, + fee_rate=TRADING_FEE_RATE, + ) + if gt_trades + 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) + + def _mark_note(price: float) -> str: + if price > 0: + return f" 총보유자산(미청산 포함)은 종가 ₩{price:,.0f} 평가." + return "" + + sim_table = f""" +

시뮬 타점 (holdout {len(holdout_fires)}건 → 체결 가정 {len(capped)}건)

+

1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도·최대 거래수 적용 후 체결 순 포트폴리오. + 가격 열 (+/-) = {label_mode} 구간 수익%.{_mark_note(close_val)}

+
+ + + + {_sim_fire_table_rows(capped, rules_by_id, sim_steps)} +
시각구분규칙유형가격총 평가금액승/패비고
+
""" + + gt_table = f""" +

정답 타점 (ground_truth)

+

삼각형 = GT. 매수 분할 비중·매도 leg 반영.{_mark_note(close_val)}

+
+ + + {_gt_table_rows(gt_trades, gt_steps)} +
시각구분비중가격총 평가금액해석
+
""" + + sections = f""" + {go_table} +

매수·매도 판단 기준 (monitor_rules)

+

04 GT 프로필 + 전구간 EV 선별. 조건 모두 충족 시 3분봉 종가에 신호.

+ {criteria_blocks} + {sim_table} + {gt_table} +""" + + note = ( + f"1단계 시뮬 · holdout {report.get('holdout_ratio', 0.15)} · " + f"발화 {len(holdout_fires)}건 / 체결가정 {len(capped)}건. " + "상단 카드: 초기 금액·총보유자산·초기 대비 증감율·수수료." + ) + legend = ( + "▲ 정답 매수 · ▼ 정답 매도 — 삼각형 = GT 비중.
" + "● 시뮬 — 원 = holdout 발화 (차트). 테이블 = 일한도 적용 체결 순서." + ) + 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_pnl, len(capped), 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), + 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"

{note}

", + legend_html=legend, + cards_html=cards, + chart_html=( + "

차트 데이터 없음 — " + "python scripts/01_download.py 후 재생성.

" + ), + 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 diff --git a/deepcoin/ops/alert_message.py b/deepcoin/ops/alert_message.py new file mode 100644 index 0000000..b01c840 --- /dev/null +++ b/deepcoin/ops/alert_message.py @@ -0,0 +1,92 @@ +""" +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, +) -> str: + """ + 규칙 발화 알림 본문을 만듭니다. + + 매수: MONITOR_ALERT_KRW_AMOUNT 기준 수량·금액. + 매도: 보유 수량(잔고 조회 가능 시) × 가격 = 금액, 없으면 참고 금액 기준. + + Args: + hit: evaluate_live_rules 항목 (side, rule_id, dt, close). + balances: 빗썸 잔고 dict. None이면 매도도 참고 금액 기준. + + Returns: + 텔레그램 메시지 문자열. + """ + side = str(hit.get("side", "")).upper() + close = float(hit["close"]) + rule_id = hit.get("rule_id", "") + dt = hit.get("dt", "") + + qty_basis = "" + if 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) diff --git a/deepcoin/ops/chart_report.py b/deepcoin/ops/chart_report.py new file mode 100644 index 0000000..c44af12 --- /dev/null +++ b/deepcoin/ops/chart_report.py @@ -0,0 +1,280 @@ +""" +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; } + .cards-group-title { font-size: 0.82rem; color: #475569; margin: 14px 0 6px; font-weight: 600; } +""" + + +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'
{label}{value}
' + + +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. + chart_html: plotly embed. + sections_html: h2·테이블·criteria 등 본문 하단. + + Returns: + 전체 HTML 문서. + """ + return f""" + + + + {page_title} + + + +

{heading}

+

{meta_line}

+ {note_html} +
{legend_html}
+
{cards_html}
+
{chart_html}
+ {sections_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"
  • {_format_condition(c)}
  • " for c in conds) + m = rule.get("metrics", {}).get("all", {}) + hold = rule.get("metrics", {}).get("holdout", {}) + return f""" +
    +

    {rid} ({side} · {format_rule_kind(kind)})

    +

    발화 시 3분봉 종가·8TF 지표가 아래를 모두 만족하면 {side} 신호.

    +
      {items or '
    • 조건 없음
    • '}
    +

    전구간 EV {m.get('ev_pct', '-')}%% · holdout EV {hold.get('ev_pct', '-')}%% · + holdout PF {hold.get('profit_factor', '-')}

    +
    """ + + +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"{c.get('rule_id')}{c.get('side')}" + f'{mark}' + f'{c.get("holdout_ev")}' + f'{c.get("holdout_pf")}' + f'{c.get("wf_positive_ratio")}' + f'{c.get("fee_stress_ev")}' + ) + body = "\n".join(rows) if rows else "없음" + return f""" +

    Go/No-Go (monitor_rules)

    +

    종합 판정: {label}

    + + + + {body} +
    규칙side판정holdout EV%holdout PFWF 양수월수수료 2x EV%
    """ diff --git a/deepcoin/ops/live_trader.py b/deepcoin/ops/live_trader.py new file mode 100644 index 0000000..3c55a0d --- /dev/null +++ b/deepcoin/ops/live_trader.py @@ -0,0 +1,191 @@ +""" +3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그). +""" + +from __future__ import annotations + +import json +import time +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from config import ( + COIN_NAME, + LIVE_COOLDOWN_MIN, + LIVE_DAILY_KRW_MAX, + LIVE_DAILY_LOSS_LIMIT_KRW, + LIVE_MAX_TRADES_PER_DAY, + LIVE_ORDER_KRW, + LIVE_TRADING_ENABLED, + 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 +from deepcoin.paths import LIVE_TRADES_LOG + + +class LiveTrader(Monitor): + """ + 규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 주문 없음(드라이런 로그만). + """ + + def __init__(self) -> None: + """Monitor 초기화, 일별 카운터 비움.""" + 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 + + 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. + + Args: + record: 로그 dict. + """ + 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) -> tuple[bool, str]: + """ + 일·쿨다운·손실 한도 검사. + + Args: + rule_id: 규칙 ID. + + Returns: + (허용 여부, 사유). + """ + self._reset_day_if_needed() + if self._day_trades >= LIVE_MAX_TRADES_PER_DAY: + return False, "일 최대 거래 수 초과" + if self._day_spent_krw + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX: + return False, "일 주문 한도 초과" + if self._day_pnl_krw <= -abs(LIVE_DAILY_LOSS_LIMIT_KRW): + return False, "일 손실 한도 초과" + last = self._rule_last_unix.get(rule_id, 0.0) + if time.time() - last < LIVE_COOLDOWN_MIN * 60: + return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)" + return True, "" + + def _execute_order(self, hit: dict[str, Any]) -> dict[str, Any]: + """ + 매수·매도 주문 실행 또는 드라이런. + + Args: + hit: evaluate_live_rules 항목. + + Returns: + 로그용 결과 dict. + """ + side = hit["side"] + price = float(hit["close"]) + amount_krw = float(LIVE_ORDER_KRW) + 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": amount_krw, + "live_enabled": LIVE_TRADING_ENABLED, + "ok": False, + "message": "", + } + + if not LIVE_TRADING_ENABLED: + record["message"] = "dry_run (LIVE_TRADING_ENABLED=0)" + record["ok"] = True + return record + + try: + if side == "buy": + ok = self.buyCoinMarket(SYMBOL, int(amount_krw), count=None) + record["ok"] = bool(ok) + record["message"] = "buyCoinMarket" if ok else "buy failed" + elif side == "sell": + bal = self.load_balances_dict().get(SYMBOL, {}) + qty = float(bal.get("balance") or 0) + if qty <= 0: + record["message"] = "보유 없음" + else: + ok = self.sellCoinMarket(SYMBOL, int(price), qty) + record["ok"] = bool(ok) + record["message"] = f"sell qty={qty}" if ok else "sell failed" + else: + record["message"] = f"unknown side {side}" + except Exception as exc: + record["message"] = str(exc) + + if record["ok"]: + self._day_spent_krw += amount_krw + self._day_trades += 1 + self._rule_last_unix[hit["rule_id"]] = time.time() + return record + + def run_once(self) -> None: + """1회: 규칙 평가 → (허용 시) 주문 → 텔레그램.""" + rules = load_monitor_rules() + print( + f"[06] {datetime.now():%Y-%m-%d %H:%M:%S} " + f"{COIN_NAME} live={'ON' if LIVE_TRADING_ENABLED else 'OFF'} " + f"rules={len(rules)}" + ) + if not rules: + print(" monitor_rules 없음") + return + + fired = evaluate_live_rules(rules) + balances = None + try: + balances = self.load_balances_dict() + except Exception: + pass + + if not fired: + print(" 발화 없음") + return + + for hit in fired: + rid = hit["rule_id"] + ok, reason = self._can_trade(rid) + print(f" [{hit['side']}] {rid} @ {hit['dt']}") + if not ok: + print(f" skip: {reason}") + continue + log = self._execute_order(hit) + self._append_log(log) + print(f" order: {log['message']} ok={log['ok']}") + msg = build_rule_alert_message(hit, balances) + if log["ok"]: + msg += f"\n[체결] {log['message']}" + else: + msg += f"\n[실패] {log['message']}" + self._send_coin_msg(msg) + + def run_loop(self, sleep_sec: int) -> None: + """ + 상시 루프. + + Args: + sleep_sec: 대기 초. + """ + print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s") + while True: + self.run_once() + time.sleep(sleep_sec) diff --git a/deepcoin/ops/monitor_coin.py b/deepcoin/ops/monitor_coin.py index 4434016..3b544a1 100644 --- a/deepcoin/ops/monitor_coin.py +++ b/deepcoin/ops/monitor_coin.py @@ -1,19 +1,34 @@ """ -WLD(월드코인) 실시간 모니터 — BB·일목 위치·추세 출력 (자동 매매 없음). +WLD(월드코인) 실시간 모니터 — BB·일목·04 매칭 규칙 알림 (자동 매매 없음). """ +from __future__ import annotations + from datetime import datetime import time -from config import COIN_NAME, MONITOR_LOOP_SLEEP_SEC, SYMBOL +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 시장 상태 주기 출력.""" + """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·일목·추세를 콘솔에 출력합니다.""" + """전 봉 BB·일목·추세 및 규칙 발화를 출력합니다.""" print( "[{}] {} ({})".format( datetime.now().strftime("%Y-%m-%d %H:%M:%S"), @@ -22,9 +37,49 @@ class MonitorCoin(Monitor): ) ) 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) diff --git a/deepcoin/ops/simulation.py b/deepcoin/ops/simulation.py index 536383c..b1b5569 100644 --- a/deepcoin/ops/simulation.py +++ b/deepcoin/ops/simulation.py @@ -42,6 +42,7 @@ from deepcoin.common.indicators import apply_bar_indicators, disparity_column, g 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_BB_HTML, CHART_TRUTH_HTML, resolve_ground_truth_file OUTPUT_HTML = CHART_BB_HTML @@ -69,6 +70,49 @@ def _marker_sizes(trades: list[dict], action: str) -> list[float]: ] +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=[ + f"{label}
    {t['dt'][:16]}
    ₩{float(t['price']):,.0f}" + f"
    leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%" + f"
    {t.get('rule_id', '')}" + for t in pts + ], + hovertemplate="%{hovertext}", + ), + row=row, + col=1, + ) + + def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None: """정답 매수·매도 마커 (삼각형 크기 = 비중).""" for action, color, symbol, label in [ @@ -112,8 +156,12 @@ def build_chart_html( 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()) @@ -191,6 +239,8 @@ def build_chart_html( 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): @@ -340,7 +390,8 @@ def build_chart_html( last_price=close_last, ) trade_rows = "" - if truth_trades: + 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( @@ -385,14 +436,13 @@ def build_chart_html( {total_s}{hold_s} {t.get('memo', '')} """ - trade_table = "" - if truth_trades: + if not trade_table and truth_trades: if not trade_rows: trade_rows = "타점 없음" mark_note = "" if pnl.get("mark_price"): mark_note = ( - f" 상단 최종 자산은 미청산 포함 종가 ₩{pnl['mark_price']:,.0f} 평가." + f" 총보유자산(미청산 포함)은 종가 ₩{pnl['mark_price']:,.0f} 평가." ) trade_table = f"""

    정답 타점 (ground_truth)

    @@ -405,54 +455,43 @@ def build_chart_html( pnl_cards = "" if truth_trades and pnl.get("initial_cash_krw") is not None: - pnl_cards = f""" -
    시작₩{pnl['initial_cash_krw']:,.0f}
    -
    최종 자산₩{pnl['final_asset_krw']:,.0f}
    -
    수익금₩{pnl['pnl_krw']:+,.0f}
    -
    수익률{pnl['pnl_pct']:+.2f}%
    -
    수수료₩{pnl['total_fees_krw']:,.0f}
    """ - if pnl.get("holding_qty", 0) > 0: - pnl_cards += f""" -
    미청산{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})
    """ + from deepcoin.ops.chart_report import card_html, initial_change_pct - return f""" - - - - {SYMBOL} {title_suffix} - - - -

    {COIN_NAME} ({SYMBOL}) {title_suffix}

    -

    추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}

    - {note_html} -
    ▲ 매수 · ▼ 매도 — 삼각형이 클수록 비중이 큽니다.
    -
    + 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 = ( + "▲ 정답 매수 · ▼ 정답 매도 — 삼각형 크기 = 비중.
    " + "● 시뮬 매수 · ● 시뮬 매도 — 원 = monitor_rules holdout 발화." + ) + if cards_html: + cards_inner = cards_html + else: + cards_inner = f"""
    종가₩{close_last:,.2f}
    BB %B{bb_pos_txt}
    정답 타점{len(truth_trades) if truth_trades else 0}건
    - {pnl_cards} -
    -
    {chart_html}
    - {trade_table} - -""" + {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( diff --git a/deepcoin/paths.py b/deepcoin/paths.py index b20befe..efbfc00 100644 --- a/deepcoin/paths.py +++ b/deepcoin/paths.py @@ -32,10 +32,26 @@ 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_GT_COMPARISON_JSON = DOCS_MATCHING / "gt_comparison_report.json" +MATCHING_GT_COMPARISON_HTML = DOCS_MATCHING / "gt_comparison_report.html" + +LIVE_TRADES_LOG = OPS_STATE_DIR / "live_trades.jsonl" + CHART_BB_HTML = DOCS_CHARTS / "wld_bb_chart.html" CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html" diff --git a/docs/reference/GROUND_TRUTH.md b/docs/reference/GROUND_TRUTH.md index ace18b9..f4af9c7 100644 --- a/docs/reference/GROUND_TRUTH.md +++ b/docs/reference/GROUND_TRUTH.md @@ -7,6 +7,8 @@ - **목적**: 차트 상 의미 있는 저점 매수·고점 매도를 JSON으로 고정 - **방법**: 고점(major swing)에서 1~2회 매도 · 저점(ZigZag+BB)에서 분할 매수 · 삼각형 크기=비중 +- **체결 순서**: JSON 저장·포트폴리오 시뮬은 **leg별 매수 전량 → 매도 전량** (시각순 아님). 차트 표는 시각순 정렬. +- **HTML 카드**: 초기 금액, 총보유자산, 초기 대비 증감율(종가 평가 포함). 기간말 leg는 **종가 청산** 포함. ## Do diff --git a/docs/reference/LIVE_TRADING.md b/docs/reference/LIVE_TRADING.md new file mode 100644 index 0000000..8a77c51 --- /dev/null +++ b/docs/reference/LIVE_TRADING.md @@ -0,0 +1,44 @@ +# 3단계 — 오픈 (실거래) + +## 정의 + +**실제 KRW가 빗썸 주문으로 나가는 단계**입니다. 05 텔레그램 알림만으로는 3단계가 아닙니다. + +## 선행 조건 + +1. `python scripts/04_simulation_report.py` → **Go/No-Go: GO** +2. 본 문서·`RISK.md`·`OPERATIONS.md` 숙지 +3. `.env` 한도 값 확정 + +## 실행 + +```bash +# 반드시 LIVE_TRADING_ENABLED=1 일 때만 주문 +python scripts/06_execute_live.py --once # 1회 점검 +python scripts/06_execute_live.py # 상시 (알림+주문) +``` + +## 환경 변수 + +| 변수 | 기본 | 설명 | +|------|------|------| +| `LIVE_TRADING_ENABLED` | 0 | **1**일 때만 실주문 | +| `LIVE_ORDER_KRW` | 100000 | 1회 주문 금액(원) | +| `LIVE_DAILY_KRW_MAX` | 300000 | 일일 총 주문 한도 | +| `LIVE_COOLDOWN_MIN` | 180 | 동일 규칙 재주문 최소 간격(분) | +| `LIVE_MAX_TRADES_PER_DAY` | 10 | 일일 최대 체결 시도 | +| `LIVE_DAILY_LOSS_LIMIT_KRW` | 50000 | 일 손실 한도(추가 주문 중단) | + +## 주문 규칙 + +- `matched_rules.json`의 **`monitor_rules`** 만 사용 (매수·매도 각 1개) +- 매수: 시장가 매수 (`buyCoinMarket`) +- 매도: 보유 수량 기준 시장가 매도 (`sellCoinMarket`) + +## 로그 + +- `data/ops/live_trades.jsonl` — 주문 시도·결과 + +## 4단계 연결 + +오픈 후 **1~2주** 실계좌 PnL·슬리피지·장애를 `docs/05_ops/live_verification_*.md`에 기록합니다. diff --git a/docs/reference/OPERATIONS.md b/docs/reference/OPERATIONS.md new file mode 100644 index 0000000..ca653bd --- /dev/null +++ b/docs/reference/OPERATIONS.md @@ -0,0 +1,40 @@ +# 운영 가이드 + +## 단계별 스크립트 + +| 단계 | 스크립트 | +|------|----------| +| 데이터 | `01_download.py` | +| GT | `02_ground_truth.py` | +| 분석 | `03_analyze_enrich.py`, `03_analyze_trades.py` | +| 매칭 | `04_match_rules.py` | +| 시뮬 | `04_simulation_report.py` | +| 알림 | `05_run_monitor.py` | +| 실거래 | `06_execute_live.py` | + +## 일상 운영 (5단계 이후) + +1. `01_download.py` — 일 1회 권장 +2. `06_execute_live.py` — 상시 (`LIVE_TRADING_ENABLED=1`) +3. 주간 — `04_simulation_report.py`로 EV·Go 재확인 + +## 텔레그램 + +- 05/06: 규칙 발화·**체결 결과** (06) +- `MONITOR_ALERT_COOLDOWN_MIN` / `LIVE_COOLDOWN_MIN` 으로 중복 완화 + +## 장애 대응 + +| 증상 | 조치 | +|------|------| +| 주문 실패 | 로그·빗썸 API 키·잔고 확인, `LIVE_TRADING_ENABLED=0` | +| 알림만 오고 주문 없음 | `LIVE_TRADING_ENABLED` 확인 | +| 과다 알림 | 쿨다운 증가·`monitor_rules` 축소 | + +## 오픈 체크리스트 (3단계 당일) + +- [ ] 시뮬 Go/No-Go **GO** +- [ ] `.env` 한도 확인 +- [ ] `LIVE_TRADING_ENABLED=1` 의도적 설정 +- [ ] `--once` 1회 테스트 +- [ ] `live_trades.jsonl` 기록 확인 diff --git a/docs/reference/RISK.md b/docs/reference/RISK.md new file mode 100644 index 0000000..b041d44 --- /dev/null +++ b/docs/reference/RISK.md @@ -0,0 +1,25 @@ +# 리스크 — 실거래 + +## 원칙 + +- 소액 파일럿만 허용 (`LIVE_ORDER_KRW`, `LIVE_DAILY_KRW_MAX`) +- 손실 한도 초과 시 **당일 추가 주문 중단** +- API·네트워크 오류 시 주문 중단·로그 기록 + +## Kill switch + +| 방법 | 동작 | +|------|------| +| `.env` | `LIVE_TRADING_ENABLED=0` 설정 후 프로세스 재시작 | +| 프로세스 | `06_execute_live.py` 중지 | +| 빗썸 | 앱/웹에서 수동 청산 | + +## 한도 (기본값 예시) + +- 1회 10만 원 · 일 30만 원 · 일 손실 5만 원 초과 시 중단 + +운영 전 본인 자금에 맞게 **반드시** 조정하세요. + +## 면책 + +실거래 손익은 전적으로 운영자 책임입니다. 본 저장소는 투자 자문이 아닙니다. diff --git a/docs/reference/ROADMAP.md b/docs/reference/ROADMAP.md index 938e527..3b0b642 100644 --- a/docs/reference/ROADMAP.md +++ b/docs/reference/ROADMAP.md @@ -1,29 +1,30 @@ # DeepCoin 로드맵 (WLD) -## 완료 +## 완료 (기반) | 단계 | 내용 | 실행 | |------|------|------| -| 01 데이터 | 1년치 3분~일봉 `coins.db` 적재 | `python scripts/01_download.py` | -| 02 Ground Truth | 매수·매도 정답 타점 JSON | `python scripts/02_ground_truth.py` | -| 03 분석 준비 | 8TF 기술적 지표·패턴 enrich | `python scripts/03_analyze_enrich.py` | +| 01~03c | DB, GT, enrich, GT MTF 스냅샷, **전 TF 프로필(매수/매도 대조)** | `01`~`03_gt_mtf_profile.py` | +| 04 | GT 프로필 + leg_gt EV + holdout | `04_match_rules.py` | +| 05 | 텔레그램 알림 (주문 없음) | `05_run_monitor.py` | -## 진행 예정 +## 남은 작업 (합의 순서) -| 단계 | 내용 | 패키지 | 실행 (예정) | -|------|------|--------|-------------| -| 03b | GT 타점 3분~일봉 기술적 상태 분석 (CLI 준비, 전량 CSV 재실행 필요) | `deepcoin/analysis/` | `python scripts/03_analyze_trades.py` | -| 04 | GT에 가장 근접한 기술적 상태 선택 | `deepcoin/matching/` | `python scripts/04_match_rules.py` | -| 05 | 1분 단위 상태 확인·실거래 | `deepcoin/ops/` | `python scripts/05_run_monitor.py` | +| 순서 | 단계 | 내용 | 실행 | +|------|------|------|------| +| **1** | 시뮬레이션 | walk-forward·민감도·Go/No-Go | `04_simulation_report.py` | +| **2** | 문서화 | SIMULATION, LIVE, RISK, OPERATIONS | `docs/reference/` | +| **3** | 오픈 | **실거래** (소액) | `06_execute_live.py` | +| **4** | 검증 | 실계좌 1~2주 | `docs/05_ops/live_verification_*.md` | +| **5** | 지속 | 실거래 유지·월간 재시뮬 | 06 상시 | + +가이드: [SIMULATION.md](SIMULATION.md) · [LIVE_TRADING.md](LIVE_TRADING.md) ## 디렉터리 -구조: [STRUCTURE.md](STRUCTURE.md) - ```text -scripts/01~05_*.py 단계별 CLI -data/ coins.db, ground_truth/, ops/ -docs/reference/ 가이드·명세 -docs/02~05, charts/ 단계별 산출물 (HTML·CSV) -deepcoin/ 단계별 Python 패키지 +scripts/01~06_*.py +data/coins.db, ground_truth/, ops/live_trades.jsonl +docs/04_matching/simulation_report.* +docs/reference/ ``` diff --git a/docs/reference/SIMULATION.md b/docs/reference/SIMULATION.md new file mode 100644 index 0000000..3de1978 --- /dev/null +++ b/docs/reference/SIMULATION.md @@ -0,0 +1,40 @@ +# 1단계 — 시뮬레이션 + +## 목적 + +실거래(3단계) 전에 `monitor_rules`가 **과적합이 아닌지** 숫자로 검증합니다. + +## 실행 + +```bash +python scripts/04_match_rules.py # 선행: 04 전체 또는 select +python scripts/04_simulation_report.py +``` + +## 산출물 + +| 파일 | 내용 | +|------|------| +| `docs/04_matching/simulation_report.json` | walk-forward·민감도·Go/No-Go | +| `docs/04_matching/simulation_report.html` | GT 동일 카드(초기 금액·총보유자산·증감율)·차트·타점·규칙 기준 | + +## 검증 항목 + +| 항목 | 설명 | +|------|------| +| Holdout | 최근 15% 구간 EV≥0, PF≥1 | +| Walk-forward | 월별 EV, 양수 월 비율 ≥ `SIM_GO_WF_POSITIVE_RATIO` | +| 수수료 스트레스 | 수수료 2배(`SIM_FEE_STRESS_MULT`) 후에도 EV≥0 | +| 실거래 한도 가정 | `LIVE_ORDER_KRW`·`LIVE_DAILY_KRW_MAX` 내 체결 가능 비율 | + +## Go/No-Go + +- **GO**: `monitor_rules` 전 규칙이 checks 통과 → 2·3단계 진행 가능 +- **NO-GO**: 04 재선별·규칙 축소 후 재실행 + +## 환경 변수 (`config.py` / `.env`) + +- `SIM_GO_MIN_HOLDOUT_EV`, `SIM_GO_MIN_HOLDOUT_PF` +- `SIM_GO_WF_POSITIVE_RATIO` (기본 0.5) +- `SIM_WALK_FORWARD_MIN_MONTHS` (기본 3) +- `SIM_FEE_STRESS_MULT` (기본 2.0) diff --git a/scripts/03_gt_mtf_profile.py b/scripts/03_gt_mtf_profile.py new file mode 100644 index 0000000..c14e362 --- /dev/null +++ b/scripts/03_gt_mtf_profile.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""03c단계: GT 타점 MTF 프로필 분석 (3분~일봉, 매수/매도 대조).""" +import runpy +from pathlib import Path + +runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) + +from deepcoin.matching.gt_mtf_profile import run_gt_mtf_profile + +if __name__ == "__main__": + run_gt_mtf_profile() diff --git a/scripts/03_patch_gt_snapshots.py b/scripts/03_patch_gt_snapshots.py new file mode 100644 index 0000000..561e33b --- /dev/null +++ b/scripts/03_patch_gt_snapshots.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""03b CSV에 누락된 GT 타점만 MTF 스냅샷 보강.""" +import runpy +from pathlib import Path + +runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) + +from config import CHART_LOOKBACK_DAYS, SYMBOL +from deepcoin.analysis.general_analysis_snapshot import append_missing_gt_snapshots +from deepcoin.data.mtf_bb import load_frames_from_db +from deepcoin.ops.monitor import Monitor + +if __name__ == "__main__": + mon = Monitor(cooldown_file=None) + frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS) + n = append_missing_gt_snapshots(frames) + print(f"완료: {n}건 추가") diff --git a/scripts/04_calibrate_gt_assets.py b/scripts/04_calibrate_gt_assets.py new file mode 100644 index 0000000..ffe4c79 --- /dev/null +++ b/scripts/04_calibrate_gt_assets.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""GT 총자산 90% 목표 MTF 프로필 반복 캘리브레이션 (03b 스냅샷).""" +import runpy +from pathlib import Path + +runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) + +from deepcoin.matching.gt_profile_iterate import run_profile_calibration_loop + +if __name__ == "__main__": + run_profile_calibration_loop() diff --git a/scripts/04_gt_comparison_report.py b/scripts/04_gt_comparison_report.py new file mode 100644 index 0000000..1459f82 --- /dev/null +++ b/scripts/04_gt_comparison_report.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""GT(450타점) vs 규칙 발화·시뮬 Go/No-Go 비교 리포트.""" +import runpy +from pathlib import Path + +runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) + +from deepcoin.matching.gt_comparison import run_gt_comparison_report + +if __name__ == "__main__": + run_gt_comparison_report() diff --git a/scripts/04_match_rules.py b/scripts/04_match_rules.py index 9e36d08..905e6f4 100644 --- a/scripts/04_match_rules.py +++ b/scripts/04_match_rules.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -"""04단계: GT 근접 규칙 선택 (스텁).""" +"""04단계: GT 프로필 + 전구간 EV 매칭 (04a~04d).""" import runpy from pathlib import Path runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) -from deepcoin.matching.match_rules import run_match_stub +from deepcoin.matching.pipeline import main if __name__ == "__main__": - run_match_stub() + main() diff --git a/scripts/04_simulation_report.py b/scripts/04_simulation_report.py new file mode 100644 index 0000000..af17b76 --- /dev/null +++ b/scripts/04_simulation_report.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""1단계: walk-forward·민감도·Go/No-Go 시뮬레이션 리포트.""" +import runpy +from pathlib import Path + +runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) + +from deepcoin.matching.simulation import run_simulation_report + +if __name__ == "__main__": + run_simulation_report() diff --git a/scripts/05_run_monitor.py b/scripts/05_run_monitor.py index 1c35b83..419ccd6 100644 --- a/scripts/05_run_monitor.py +++ b/scripts/05_run_monitor.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -"""05단계: WLD 실시간 모니터 루프.""" +"""05단계: WLD 실시간 모니터 루프 (04 규칙 알림 포함).""" +import argparse import runpy from pathlib import Path @@ -8,4 +9,12 @@ runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) from deepcoin.ops.monitor_coin import MonitorCoin if __name__ == "__main__": - MonitorCoin(cooldown_file=None).run_schedule() + parser = argparse.ArgumentParser(description="WLD 모니터") + parser.add_argument("--once", action="store_true", help="1회만 출력 후 종료") + parser.add_argument("--no-rules", action="store_true", help="04 규칙 평가 생략") + args = parser.parse_args() + mon = MonitorCoin(cooldown_file=None, check_rules=not args.no_rules) + if args.once: + mon.monitor_wld() + else: + mon.run_schedule() diff --git a/scripts/06_execute_live.py b/scripts/06_execute_live.py new file mode 100644 index 0000000..609fa68 --- /dev/null +++ b/scripts/06_execute_live.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""3단계: 실거래 (monitor_rules + 빗썸 주문). LIVE_TRADING_ENABLED=1 필수.""" +import argparse +import runpy +from pathlib import Path + +runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) + +from config import LIVE_TRADING_ENABLED, MONITOR_LOOP_SLEEP_SEC +from deepcoin.ops.live_trader import LiveTrader + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="WLD 실거래 (06)") + parser.add_argument("--once", action="store_true", help="1회만 실행") + args = parser.parse_args() + trader = LiveTrader() + if not LIVE_TRADING_ENABLED: + print("주의: LIVE_TRADING_ENABLED=0 — 주문 없이 dry_run 로그만") + if args.once: + trader.run_once() + else: + trader.run_loop(MONITOR_LOOP_SLEEP_SEC) diff --git a/scripts/README.md b/scripts/README.md index 2d6d890..366f74f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -7,10 +7,12 @@ python scripts/01_download.py python scripts/02_ground_truth.py python scripts/03_analyze_enrich.py python scripts/03_analyze_trades.py -python scripts/04_match_rules.py # 스텁 +python scripts/04_match_rules.py +python scripts/04_simulation_report.py # 1단계 Go/No-Go +python scripts/05_run_monitor.py # 알림만 +python scripts/06_execute_live.py # 3단계 실거래 python scripts/05_chart_truth.py python scripts/05_chart_bb.py -python scripts/05_run_monitor.py python scripts/verify_env.py ```