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'| {c} | ' for c in pr_cols)}
+{_rows(report.get('per_rule', []), pr_cols)}
+
+
+monitor_rules (실감시·시뮬 대상)
+
+{''.join(f'| {c} | ' for c in pr_cols)}
+{_rows(report.get('monitor_rules', []), pr_cols)}
+
+
+GT–발화 수익률 정렬 (±{report.get('tolerance_min')}분)
+
+| rule | side | pairs | corr | mean|diff|% | mean Δmin |
+
+{''.join(
+ f"| {p['rule_id']} | {p['side']} | {p['pair_count']} | "
+ f"{p.get('corr_gt_vs_sim','')} | {p.get('mean_abs_diff_pct','')} | "
+ f"{p.get('mean_delta_min','')} |
"
+ for p in report.get('pair_alignment', [])
+)}
+
+
+시뮬 검증 (monitor)
+{json.dumps(go, ensure_ascii=False, indent=2)}
+
+참고
+
+{''.join(f'- {n}
' for n in report.get('notes', []))}
+
+"""
+ 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 규칙 후보를 생성합니다.
+
+간격별 분리도 상위 (요약)
+| TF | 숫자 피처 수 | 상위 5 (분리도) |
+{_rows_interval()}
+
+글로벌 분리도 Top 40
+| 컬럼 | TF | 기법군 | 분리도 | 매수 median | 매도 median |
+{_rows_global()}
+
+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','')}
+선별 규칙
+
+| rule_id | side | valid_n | valid_ev |
+holdout_n | holdout_ev | holdout_pf |
+{''.join(rows) if rows else '| 통과 규칙 없음 |
'}
+
+GT recall (±{MATCH_GT_TOLERANCE_MIN}분, 전체 발화 기준)
+
+- 매수: {gt.get('buy', {})}
+- 매도: {gt.get('sell', {})}
+
+"""
+ 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)}
+ """
+
+ gt_table = f"""
+ 정답 타점 (ground_truth)
+ 삼각형 = GT. 매수 분할 비중·매도 leg 반영.{_mark_note(close_val)}
+ """
+
+ 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} 신호.
+
+
전구간 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}
+
+ | 규칙 | side | 판정 | holdout EV% |
+ holdout PF | WF 양수월 | 수수료 2x EV% |
+ {body}
+
"""
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
```