GT MTF 프로필·캘리브레이션과 04 매칭/시뮬/실거래 파이프라인을 추가한다.
3분~일봉 GT 타점 분석(03c), leg 체결 순서 수정, 총자산 90% 검증 루프, walk-forward Go/No-Go 시뮬, monitor·live_trader 및 reference 문서를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
30
.env.example
Normal file
30
.env.example
Normal file
@@ -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
|
||||||
37
README.md
37
README.md
@@ -1,9 +1,19 @@
|
|||||||
# DeepCoin — WLD MTF 분석·정답·운영
|
# DeepCoin — WLD MTF 분석·정답·운영
|
||||||
|
|
||||||
빗썸 KRW-WLD. **1, 3, 5, 10, 15, 30, 60, 240, 1440분** 봉을 적재하고,
|
빗썸 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` |
|
| 02 Ground Truth | 매수·매도 정답 타점 | `python scripts/02_ground_truth.py` |
|
||||||
| 03 분석 | 8TF 기술 지표 enrich | `python scripts/03_analyze_enrich.py` |
|
| 03 분석 | 8TF 기술 지표 enrich | `python scripts/03_analyze_enrich.py` |
|
||||||
| 03b 분석 | GT 타점 MTF 스냅샷 | `python scripts/03_analyze_trades.py` |
|
| 03b 분석 | GT 타점 MTF 스냅샷 | `python scripts/03_analyze_trades.py` |
|
||||||
| 04 매칭 | GT 근접 규칙 선택 (예정) | `python scripts/04_match_rules.py` |
|
| 04 매칭 | GT 프로필 + leg_gt EV | `python scripts/04_match_rules.py` |
|
||||||
| 05 운영 | 차트·1분 모니터 | `scripts/05_chart_*.py`, `05_run_monitor.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)
|
상세: [docs/reference/ROADMAP.md](docs/reference/ROADMAP.md)
|
||||||
|
|
||||||
@@ -27,8 +39,8 @@ DeepCoin/
|
|||||||
│ ├── data/ # 01 다운로드
|
│ ├── data/ # 01 다운로드
|
||||||
│ ├── ground_truth/ # 02 정답 타점
|
│ ├── ground_truth/ # 02 정답 타점
|
||||||
│ ├── analysis/ # 03·03b 지표·스냅샷
|
│ ├── analysis/ # 03·03b 지표·스냅샷
|
||||||
│ ├── matching/ # 04 규칙 매칭 (예정)
|
│ ├── matching/ # 04·시뮬
|
||||||
│ └── ops/ # 05 모니터·차트
|
│ └── ops/ # 05 알림·06 실거래
|
||||||
├── data/ # coins.db, ground_truth/, ops/
|
├── data/ # coins.db, ground_truth/, ops/
|
||||||
└── docs/
|
└── docs/
|
||||||
├── reference/ # 가이드·기법 명세 (Git)
|
├── reference/ # 가이드·기법 명세 (Git)
|
||||||
@@ -56,6 +68,8 @@ python scripts/01_download.py
|
|||||||
python scripts/02_ground_truth.py
|
python scripts/02_ground_truth.py
|
||||||
python scripts/03_analyze_enrich.py
|
python scripts/03_analyze_enrich.py
|
||||||
python scripts/03_analyze_trades.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
|
python scripts/05_chart_truth.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,6 +83,12 @@ python scripts/05_chart_truth.py
|
|||||||
| `CHART_LOOKBACK_DAYS` | 기본 365일 |
|
| `CHART_LOOKBACK_DAYS` | 기본 365일 |
|
||||||
| `DOWNLOAD_MONTHS` | 3분 이상 봉 12개월 |
|
| `DOWNLOAD_MONTHS` | 3분 이상 봉 12개월 |
|
||||||
| `MONITOR_LOOP_SLEEP_SEC` | 05 모니터 루프 주기(초) |
|
| `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/02_ground_truth/wld_ground_truth_chart.html` | 정답 차트 |
|
||||||
| `docs/03_analysis/latest/*_latest.csv` | 간격별 최근 봉 전 기법 |
|
| `docs/03_analysis/latest/*_latest.csv` | 간격별 최근 봉 전 기법 |
|
||||||
| `docs/03_analysis/general_analysis_trades.csv` | GT 타점 MTF 스냅샷 |
|
| `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은 주문하지 않습니다.
|
||||||
|
|||||||
78
config.py
78
config.py
@@ -219,6 +219,8 @@ MONITOR_MA_WINDOWS: tuple[int, ...] = _parse_int_tuple(
|
|||||||
)
|
)
|
||||||
MONITOR_NORM_WINDOW = _getenv_int("MONITOR_NORM_WINDOW", "20")
|
MONITOR_NORM_WINDOW = _getenv_int("MONITOR_NORM_WINDOW", "20")
|
||||||
MONITOR_TELEGRAM_BATCH_SIZE = _getenv_int("MONITOR_TELEGRAM_BATCH_SIZE", "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 ---
|
# --- general_analysis ---
|
||||||
GA_COL_PREFIX = _getenv("GA_COL_PREFIX", "ga_")
|
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_START = _getenv_float("GA_PSAR_AF_START", "0.02")
|
||||||
GA_PSAR_AF_STEP = _getenv_float("GA_PSAR_AF_STEP", "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")
|
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")
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"interval_min": 3,
|
"interval_min": 3,
|
||||||
"lookback_days": 365,
|
"lookback_days": 365,
|
||||||
"period_start": "2025-06-04 03:57:00",
|
"period_start": "2025-06-04 03:57:00",
|
||||||
"period_end": "2026-05-30 21:31:00",
|
"period_end": "2026-05-31 00:51:00",
|
||||||
"trend_at_end": "range",
|
"trend_at_end": "up",
|
||||||
"params": {
|
"params": {
|
||||||
"min_swing_pct": 4.0,
|
"min_swing_pct": 4.0,
|
||||||
"pivot_order": 20,
|
"pivot_order": 20,
|
||||||
@@ -20,23 +20,28 @@
|
|||||||
"summary": {
|
"summary": {
|
||||||
"pivot_candidates": 380,
|
"pivot_candidates": 380,
|
||||||
"sell_peaks": 74,
|
"sell_peaks": 74,
|
||||||
"trade_count": 450,
|
"trade_count": 451,
|
||||||
"buy_count": 303,
|
"buy_count": 303,
|
||||||
"sell_count": 147,
|
"sell_count": 148,
|
||||||
"round_trips": 74,
|
"round_trips": 75,
|
||||||
"sum_sell_leg_return_pct": 1570.3,
|
"sum_sell_leg_return_pct": 1590.3,
|
||||||
"initial_cash_krw": 1000000,
|
"initial_cash_krw": 1000000,
|
||||||
"final_asset_krw": 1512765783.0,
|
"final_asset_krw": 2040862272.0,
|
||||||
"pnl_krw": 1511765783.0,
|
"pnl_krw": 2039862272.0,
|
||||||
"pnl_pct": 151176.58,
|
"pnl_pct": 203986.23,
|
||||||
"total_fees_krw": 16073557.0,
|
"total_fees_krw": 19790914.0,
|
||||||
"cash_krw": -0.0,
|
"cash_krw": 2040862272.0,
|
||||||
"holding_qty": 3361701.741079,
|
"holding_qty": 0.0,
|
||||||
"holding_value_krw": 1512765783.0,
|
"holding_value_krw": 0.0,
|
||||||
"mark_price": 450.0,
|
"mark_price": 515.0,
|
||||||
"fee_rate": 0.0005
|
"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": [
|
"trades": [
|
||||||
{
|
{
|
||||||
"dt": "2025-06-06 06:12:00",
|
"dt": "2025-06-06 06:12:00",
|
||||||
@@ -302,18 +307,6 @@
|
|||||||
"pivot_kind": "peak",
|
"pivot_kind": "peak",
|
||||||
"forward_return_pct": 0.6
|
"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",
|
"dt": "2025-06-24 03:06:00",
|
||||||
"action": "sell",
|
"action": "sell",
|
||||||
@@ -326,6 +319,18 @@
|
|||||||
"pivot_kind": "peak",
|
"pivot_kind": "peak",
|
||||||
"forward_return_pct": 0.43
|
"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",
|
"dt": "2025-06-24 14:57:00",
|
||||||
"action": "sell",
|
"action": "sell",
|
||||||
@@ -1562,18 +1567,6 @@
|
|||||||
"pivot_kind": "peak",
|
"pivot_kind": "peak",
|
||||||
"forward_return_pct": 24.87
|
"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",
|
"dt": "2025-09-09 12:45:00",
|
||||||
"action": "sell",
|
"action": "sell",
|
||||||
@@ -1586,6 +1579,18 @@
|
|||||||
"pivot_kind": "peak",
|
"pivot_kind": "peak",
|
||||||
"forward_return_pct": 27.74
|
"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",
|
"dt": "2025-09-09 14:00:00",
|
||||||
"action": "buy",
|
"action": "buy",
|
||||||
@@ -2294,18 +2299,6 @@
|
|||||||
"pivot_kind": "peak",
|
"pivot_kind": "peak",
|
||||||
"forward_return_pct": 14.1
|
"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",
|
"dt": "2025-10-13 05:03:00",
|
||||||
"action": "sell",
|
"action": "sell",
|
||||||
@@ -2318,6 +2311,18 @@
|
|||||||
"pivot_kind": "peak",
|
"pivot_kind": "peak",
|
||||||
"forward_return_pct": 14.03
|
"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",
|
"dt": "2025-10-13 21:00:00",
|
||||||
"action": "buy",
|
"action": "buy",
|
||||||
@@ -5282,18 +5287,6 @@
|
|||||||
"pivot_kind": "peak",
|
"pivot_kind": "peak",
|
||||||
"forward_return_pct": 30.47
|
"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",
|
"dt": "2026-05-27 01:00:00",
|
||||||
"action": "sell",
|
"action": "sell",
|
||||||
@@ -5306,6 +5299,18 @@
|
|||||||
"pivot_kind": "peak",
|
"pivot_kind": "peak",
|
||||||
"forward_return_pct": 29.17
|
"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",
|
"dt": "2026-05-27 13:18:00",
|
||||||
"action": "buy",
|
"action": "buy",
|
||||||
@@ -5394,49 +5399,61 @@
|
|||||||
"dt": "2026-05-29 21:33:00",
|
"dt": "2026-05-29 21:33:00",
|
||||||
"action": "buy",
|
"action": "buy",
|
||||||
"price": 430.0,
|
"price": 430.0,
|
||||||
"memo": "저점 분할 매수(미청산) · 비중 25%",
|
"memo": "저점 분할 매수 · 비중 25% · leg#74(기간말)",
|
||||||
"weight": 0.25,
|
"weight": 0.25,
|
||||||
"leg_id": 74,
|
"leg_id": 74,
|
||||||
"bb_pos": 0.6,
|
"bb_pos": 0.6,
|
||||||
"rsi": 53.6,
|
"rsi": 53.6,
|
||||||
"pivot_kind": "trough",
|
"pivot_kind": "trough",
|
||||||
"forward_return_pct": null
|
"forward_return_pct": 19.77
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"dt": "2026-05-30 01:24:00",
|
"dt": "2026-05-30 01:24:00",
|
||||||
"action": "buy",
|
"action": "buy",
|
||||||
"price": 439.0,
|
"price": 439.0,
|
||||||
"memo": "저점 분할 매수(미청산) · 비중 24%",
|
"memo": "저점 분할 매수 · 비중 24% · leg#74(기간말)",
|
||||||
"weight": 0.244,
|
"weight": 0.244,
|
||||||
"leg_id": 74,
|
"leg_id": 74,
|
||||||
"bb_pos": 0.085,
|
"bb_pos": 0.085,
|
||||||
"rsi": 29.2,
|
"rsi": 29.2,
|
||||||
"pivot_kind": "trough",
|
"pivot_kind": "trough",
|
||||||
"forward_return_pct": null
|
"forward_return_pct": 17.31
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"dt": "2026-05-30 09:57:00",
|
"dt": "2026-05-30 09:57:00",
|
||||||
"action": "buy",
|
"action": "buy",
|
||||||
"price": 424.0,
|
"price": 424.0,
|
||||||
"memo": "저점 분할 매수(미청산) · 비중 25%",
|
"memo": "저점 분할 매수 · 비중 25% · leg#74(기간말)",
|
||||||
"weight": 0.253,
|
"weight": 0.253,
|
||||||
"leg_id": 74,
|
"leg_id": 74,
|
||||||
"bb_pos": 0.192,
|
"bb_pos": 0.192,
|
||||||
"rsi": 33.3,
|
"rsi": 33.3,
|
||||||
"pivot_kind": "trough",
|
"pivot_kind": "trough",
|
||||||
"forward_return_pct": null
|
"forward_return_pct": 21.46
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"dt": "2026-05-30 13:30:00",
|
"dt": "2026-05-30 13:30:00",
|
||||||
"action": "buy",
|
"action": "buy",
|
||||||
"price": 424.0,
|
"price": 424.0,
|
||||||
"memo": "저점 분할 매수(미청산) · 비중 25%",
|
"memo": "저점 분할 매수 · 비중 25% · leg#74(기간말)",
|
||||||
"weight": 0.253,
|
"weight": 0.253,
|
||||||
"leg_id": 74,
|
"leg_id": 74,
|
||||||
"bb_pos": 0.206,
|
"bb_pos": 0.151,
|
||||||
"rsi": 42.9,
|
"rsi": 28.6,
|
||||||
"pivot_kind": "trough",
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -41,11 +41,25 @@ def main() -> None:
|
|||||||
trades = trades[: args.limit]
|
trades = trades[: args.limit]
|
||||||
print(f"테스트 모드: 타점 {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)
|
mon = Monitor(cooldown_file=None)
|
||||||
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
|
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
|
||||||
if not frames:
|
if not frames:
|
||||||
raise RuntimeError("coins.db 데이터 없음")
|
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 파일
|
# limit 시 임시 trades 파일
|
||||||
if args.limit > 0:
|
if args.limit > 0:
|
||||||
@@ -64,6 +78,10 @@ def main() -> None:
|
|||||||
print(f"저장: {csv_path} ({len(df)}행 × {len(df.columns)}열)")
|
print(f"저장: {csv_path} ({len(df)}행 × {len(df.columns)}열)")
|
||||||
|
|
||||||
write_analysis_report(csv_path, Path(args.html))
|
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("완료.")
|
print("완료.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ general_analysis ground truth 타점 MTF 스냅샷 생성.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -40,15 +42,31 @@ def build_trade_mtf_snapshots(
|
|||||||
Returns:
|
Returns:
|
||||||
wide DataFrame (1 row per trade).
|
wide DataFrame (1 row per trade).
|
||||||
"""
|
"""
|
||||||
|
n_trades = len(trades)
|
||||||
enriched: dict[int, pd.DataFrame] = {}
|
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)
|
raw = frames.get(iv)
|
||||||
if raw is None or raw.empty:
|
if raw is None or raw.empty:
|
||||||
|
print(f" [{step}/8] {interval_tf_prefix(iv)} SKIP (데이터 없음)")
|
||||||
|
sys.stdout.flush()
|
||||||
continue
|
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)
|
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]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
|
t_b = time.time()
|
||||||
for i, t in enumerate(sorted(trades, key=lambda x: x["dt"])):
|
for i, t in enumerate(sorted(trades, key=lambda x: x["dt"])):
|
||||||
ts = pd.Timestamp(t["dt"])
|
ts = pd.Timestamp(t["dt"])
|
||||||
row: dict[str, Any] = {
|
row: dict[str, Any] = {
|
||||||
@@ -70,12 +88,62 @@ def build_trade_mtf_snapshots(
|
|||||||
row.update(flat)
|
row.update(flat)
|
||||||
row.update(general_analysis_mtf_scores(flat))
|
row.update(general_analysis_mtf_scores(flat))
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
if (i + 1) % 50 == 0:
|
done = i + 1
|
||||||
print(f" 타점 스냅샷 {i + 1}/{len(trades)}")
|
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)
|
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(
|
def export_trade_snapshots(
|
||||||
frames: dict[int, pd.DataFrame],
|
frames: dict[int, pd.DataFrame],
|
||||||
trades_path: Path | str = DEFAULT_TRADES_FILE,
|
trades_path: Path | str = DEFAULT_TRADES_FILE,
|
||||||
|
|||||||
@@ -658,7 +658,7 @@ def build_split_buy_peak_sell_trades(
|
|||||||
|
|
||||||
prev_sell_ts = peak.ts
|
prev_sell_ts = peak.ts
|
||||||
|
|
||||||
# 마지막 매도 이후 ~ 기간 끝: 미청산 구간 분할 매수만
|
# 마지막 매도 이후 ~ 기간 말: 분할 매수 후 동일 leg에서 기간말 청산(포트폴리오 정합)
|
||||||
if sell_peaks:
|
if sell_peaks:
|
||||||
last_peak = sell_peaks[-1]
|
last_peak = sell_peaks[-1]
|
||||||
troughs = _collect_buy_troughs(
|
troughs = _collect_buy_troughs(
|
||||||
@@ -667,25 +667,56 @@ def build_split_buy_peak_sell_trades(
|
|||||||
leg_id = len(sell_peaks)
|
leg_id = len(sell_peaks)
|
||||||
if troughs:
|
if troughs:
|
||||||
weights = _normalize_weights([1.0 / max(t.price, 1e-9) for t in 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):
|
for t, w in zip(troughs, weights):
|
||||||
row = _row_at_ts(df, t.ts)
|
row = _row_at_ts(df, t.ts)
|
||||||
bb_pos, rsi, disp = _bb_context(row)
|
bb_pos, rsi, disp = _bb_context(row)
|
||||||
price = float(row["Low"]) if "Low" in row else t.price
|
price = float(row["Low"]) if "Low" in row else t.price
|
||||||
trades.append(
|
leg_buys.append(
|
||||||
TradePoint(
|
TradePoint(
|
||||||
dt=t.ts.strftime("%Y-%m-%d %H:%M:%S"),
|
dt=t.ts.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
action="buy",
|
action="buy",
|
||||||
price=round(price, 2),
|
price=round(price, 2),
|
||||||
weight=round(w, 3),
|
weight=round(w, 3),
|
||||||
leg_id=leg_id,
|
leg_id=leg_id,
|
||||||
memo=f"저점 분할 매수(미청산) · 비중 {w*100:.0f}%",
|
memo=f"저점 분할 매수 · 비중 {w*100:.0f}% · leg#{leg_id}(기간말)",
|
||||||
bb_pos=bb_pos,
|
bb_pos=bb_pos,
|
||||||
rsi=rsi,
|
rsi=rsi,
|
||||||
pivot_kind="trough",
|
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
|
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"
|
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])
|
last_close = float(df["Close"].iloc[-1])
|
||||||
pnl = simulate_truth_portfolio(
|
pnl = simulate_truth_portfolio(
|
||||||
[asdict(t) for t in trades],
|
trade_dicts,
|
||||||
initial_cash=GT_INITIAL_CASH_KRW,
|
initial_cash=GT_INITIAL_CASH_KRW,
|
||||||
fee_rate=TRADING_FEE_RATE,
|
fee_rate=TRADING_FEE_RATE,
|
||||||
last_price=last_close,
|
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 {
|
return {
|
||||||
"name": "ground_truth_split_buy_peak_sell",
|
"name": "ground_truth_split_buy_peak_sell",
|
||||||
@@ -849,21 +887,118 @@ def generate_ground_truth(
|
|||||||
"round_trips": round_trips,
|
"round_trips": round_trips,
|
||||||
"sum_sell_leg_return_pct": round(total_ret, 2),
|
"sum_sell_leg_return_pct": round(total_ret, 2),
|
||||||
**pnl,
|
**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": (
|
"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]]:
|
def _validate_leg_portfolio(
|
||||||
"""TradePoint/dict 리스트를 시간순 dict 행으로 정규화."""
|
trade_dicts: list[dict[str, Any]],
|
||||||
return sorted(
|
last_close: float,
|
||||||
[t if isinstance(t, dict) else asdict(t) for t in trades],
|
) -> None:
|
||||||
key=lambda x: x["dt"],
|
"""
|
||||||
|
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(
|
def simulate_truth_portfolio_steps(
|
||||||
@@ -1024,7 +1159,11 @@ def simulate_truth_portfolio(
|
|||||||
if qty < 1e-12:
|
if qty < 1e-12:
|
||||||
qty = 0.0
|
qty = 0.0
|
||||||
|
|
||||||
mark_price = float(last_price if last_price is not None else last_trade_price or 0)
|
if last_price is None:
|
||||||
|
mark_price = None
|
||||||
|
holding_value = 0.0
|
||||||
|
else:
|
||||||
|
mark_price = float(last_price)
|
||||||
holding_value = qty * mark_price
|
holding_value = qty * mark_price
|
||||||
final_asset = cash + holding_value
|
final_asset = cash + holding_value
|
||||||
pnl_krw = final_asset - initial_cash
|
pnl_krw = final_asset - initial_cash
|
||||||
@@ -1039,7 +1178,7 @@ def simulate_truth_portfolio(
|
|||||||
"cash_krw": round(cash, 0),
|
"cash_krw": round(cash, 0),
|
||||||
"holding_qty": round(qty, 6),
|
"holding_qty": round(qty, 6),
|
||||||
"holding_value_krw": round(holding_value, 0),
|
"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,
|
"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')}%")
|
print(f" 매도 수익 합(참고): {s.get('sum_sell_leg_return_pct')}%")
|
||||||
if s.get("initial_cash_krw"):
|
if s.get("initial_cash_krw"):
|
||||||
print(
|
print(
|
||||||
f" 시뮬(시작 ₩{s['initial_cash_krw']:,.0f}): "
|
f" 포트폴리오: 초기 ₩{s['initial_cash_krw']:,.0f} → "
|
||||||
f"최종 ₩{s['final_asset_krw']:,.0f} | "
|
f"총보유자산 ₩{s['final_asset_krw']:,.0f} | "
|
||||||
f"수익 ₩{s['pnl_krw']:+,.0f} ({s['pnl_pct']:+.2f}%) | "
|
f"초기 대비 {s['pnl_pct']:+.2f}% | "
|
||||||
f"수수료 ₩{s['total_fees_krw']:,.0f}"
|
f"수수료 ₩{s['total_fees_krw']:,.0f}"
|
||||||
)
|
)
|
||||||
if s.get("holding_qty", 0) > 0:
|
if s.get("holding_qty", 0) > 0:
|
||||||
print(
|
print(
|
||||||
f" 미청산: {s['holding_qty']}개 "
|
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')}")
|
print(f" 파라미터: {data.get('params')}")
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
@@ -1133,7 +1274,7 @@ def run_from_db(monitor=None, output: Path = DEFAULT_OUTPUT) -> dict[str, Any]:
|
|||||||
생성된 dict.
|
생성된 dict.
|
||||||
"""
|
"""
|
||||||
from config import TREND_INTERVAL_1D, TREND_INTERVAL_1H
|
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)
|
mon = monitor or Monitor(cooldown_file=None)
|
||||||
print(f"정답 생성: 최근 {CHART_LOOKBACK_DAYS}일 3분봉")
|
print(f"정답 생성: 최근 {CHART_LOOKBACK_DAYS}일 3분봉")
|
||||||
|
|||||||
@@ -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
|
```bash
|
||||||
|
# 전체 (enrich+스캔 약 2~3분)
|
||||||
python scripts/04_match_rules.py
|
python scripts/04_match_rules.py
|
||||||
|
|
||||||
|
# 단계별
|
||||||
|
python scripts/04_match_rules.py --phase profile
|
||||||
|
python scripts/04_match_rules.py --phase scan
|
||||||
|
python scripts/04_match_rules.py --phase label
|
||||||
|
python scripts/04_match_rules.py --phase select
|
||||||
```
|
```
|
||||||
|
|
||||||
|
선행: `python scripts/03_analyze_trades.py`
|
||||||
|
|
||||||
|
## 설정 (`.env` / `config.py`)
|
||||||
|
|
||||||
|
- `MATCH_LABEL_MODE` — `leg_gt`(다음 GT 매도/직전 매수) 또는 `forward`
|
||||||
|
- `MATCH_MAX_HOLD_DAYS` — leg_gt 최대 보유 일수 (기본 45)
|
||||||
|
- `MATCH_INCLUDE_ATOMIC` — 0이면 atomic 규칙 제외
|
||||||
|
- `MATCH_FORWARD_BARS` — leg_gt 불가 시 폴백 봉 수 (기본 60)
|
||||||
|
- `MATCH_TRAIN_RATIO` — train/valid 분할 (기본 0.7)
|
||||||
|
- `MATCH_MIN_FIRES`, `MATCH_MIN_EV_VALID`, `MATCH_MIN_PROFIT_FACTOR`
|
||||||
|
|||||||
@@ -1,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"]
|
||||||
|
|||||||
70
deepcoin/matching/config.py
Normal file
70
deepcoin/matching/config.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
177
deepcoin/matching/gt_asset_calibration.py
Normal file
177
deepcoin/matching/gt_asset_calibration.py
Normal file
@@ -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),
|
||||||
|
}
|
||||||
383
deepcoin/matching/gt_comparison.py
Normal file
383
deepcoin/matching/gt_comparison.py
Normal file
@@ -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"<td>{it.get(c, '')}</td>" for c in cols)
|
||||||
|
lines.append(f"<tr>{cells}</tr>")
|
||||||
|
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"""<!DOCTYPE html>
|
||||||
|
<html lang="ko"><head><meta charset="utf-8"/>
|
||||||
|
<title>GT vs Simulation Comparison</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; max-width: 1100px; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 0.9rem; }}
|
||||||
|
th, td {{ border: 1px solid #ccc; padding: 6px 8px; text-align: right; }}
|
||||||
|
th {{ background: #e2e8f0; text-align: center; }}
|
||||||
|
td:first-child, th:first-child {{ text-align: left; }}
|
||||||
|
h2 {{ margin-top: 28px; }}
|
||||||
|
.warn {{ color: #b45309; }}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>Ground Truth vs 규칙·시뮬 비교</h1>
|
||||||
|
<p>허용 오차: ±{report.get('tolerance_min')}분 · 라벨: {report.get('label_mode')}</p>
|
||||||
|
<p><strong>시뮬 Go/No-Go: {go_flag}</strong></p>
|
||||||
|
|
||||||
|
<h2>GT 기준선 (forward_return_pct)</h2>
|
||||||
|
<p>총 {gb.get('total')}건 (매수 {gb.get('buy')} / 매도 {gb.get('sell')})</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>구분</th><th>건수</th><th>평균 forward%</th><th>중앙값</th><th>승률</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>매수 GT</td><td>{gb.get('buy', {}).get('count', '')}</td>
|
||||||
|
<td>{gb.get('buy', {}).get('mean_forward_pct', '')}</td>
|
||||||
|
<td>{gb.get('buy', {}).get('median_forward_pct', '')}</td>
|
||||||
|
<td>{gb.get('buy', {}).get('win_rate', '')}</td></tr>
|
||||||
|
<tr><td>매도 GT</td><td>{gb.get('sell', {}).get('count', '')}</td>
|
||||||
|
<td>{gb.get('sell', {}).get('mean_forward_pct', '')}</td>
|
||||||
|
<td>{gb.get('sell', {}).get('median_forward_pct', '')}</td>
|
||||||
|
<td>{gb.get('sell', {}).get('win_rate', '')}</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
|
||||||
|
<h2>규칙별 GT recall / precision / EV</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr>{''.join(f'<th>{c}</th>' for c in pr_cols)}</tr></thead>
|
||||||
|
<tbody>{_rows(report.get('per_rule', []), pr_cols)}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>monitor_rules (실감시·시뮬 대상)</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr>{''.join(f'<th>{c}</th>' for c in pr_cols)}</tr></thead>
|
||||||
|
<tbody>{_rows(report.get('monitor_rules', []), pr_cols)}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>GT–발화 수익률 정렬 (±{report.get('tolerance_min')}분)</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>rule</th><th>side</th><th>pairs</th><th>corr</th><th>mean|diff|%</th><th>mean Δmin</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{''.join(
|
||||||
|
f"<tr><td>{p['rule_id']}</td><td>{p['side']}</td><td>{p['pair_count']}</td>"
|
||||||
|
f"<td>{p.get('corr_gt_vs_sim','')}</td><td>{p.get('mean_abs_diff_pct','')}</td>"
|
||||||
|
f"<td>{p.get('mean_delta_min','')}</td></tr>"
|
||||||
|
for p in report.get('pair_alignment', [])
|
||||||
|
)}
|
||||||
|
</tbody></table>
|
||||||
|
|
||||||
|
<h2>시뮬 검증 (monitor)</h2>
|
||||||
|
<pre>{json.dumps(go, ensure_ascii=False, indent=2)}</pre>
|
||||||
|
|
||||||
|
<h2>참고</h2>
|
||||||
|
<ul>
|
||||||
|
{''.join(f'<li>{n}</li>' for n in report.get('notes', []))}
|
||||||
|
</ul>
|
||||||
|
</body></html>"""
|
||||||
|
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
|
||||||
514
deepcoin/matching/gt_mtf_profile.py
Normal file
514
deepcoin/matching/gt_mtf_profile.py
Normal file
@@ -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"<tr><td>{pfx}</td><td>{block.get('feature_count', 0)}</td>"
|
||||||
|
f"<td>{top_s}</td></tr>"
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _rows_global() -> str:
|
||||||
|
rows = ""
|
||||||
|
for item in analysis.get("global_top_separation") or []:
|
||||||
|
rows += (
|
||||||
|
f"<tr><td>{item['col']}</td><td>{item['tf']}</td>"
|
||||||
|
f"<td>{item['family']}</td><td>{item['separation']:.3f}</td>"
|
||||||
|
f"<td>{item.get('buy_median','')}</td><td>{item.get('sell_median','')}</td></tr>"
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
buy_feats = ", ".join(analysis["selected_features"]["buy"][:25])
|
||||||
|
sell_feats = ", ".join(analysis["selected_features"]["sell"][:25])
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="ko"><head><meta charset="utf-8"/>
|
||||||
|
<title>GT MTF 프로필 (3분~일봉)</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f5f5f5; color: #1e293b; }}
|
||||||
|
h1, h2 {{ color: #0f172a; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; background: #fff; margin-bottom: 20px; font-size: 0.85rem; }}
|
||||||
|
th, td {{ border: 1px solid #e2e8f0; padding: 8px; text-align: left; }}
|
||||||
|
th {{ background: #e2e8f0; }}
|
||||||
|
p.note {{ font-size: 0.9rem; color: #475569; }}
|
||||||
|
code {{ font-size: 0.8rem; word-break: break-all; }}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>Ground Truth MTF 타점 프로필</h1>
|
||||||
|
<p>매수 GT {analysis['buy_gt_count']}건 · 매도 GT {analysis['sell_gt_count']}건 ·
|
||||||
|
분석 컬럼 {analysis['columns_analyzed']}개 (3,5,10,15,30,60,240,1440분 + MTF 합성)</p>
|
||||||
|
<p class="note">분리도 = |mean_buy − mean_sell| / pooled_std. TF별·글로벌 상위 피처로 04 규칙 후보를 생성합니다.</p>
|
||||||
|
|
||||||
|
<h2>간격별 분리도 상위 (요약)</h2>
|
||||||
|
<table><thead><tr><th>TF</th><th>숫자 피처 수</th><th>상위 5 (분리도)</th></tr></thead>
|
||||||
|
<tbody>{_rows_interval()}</tbody></table>
|
||||||
|
|
||||||
|
<h2>글로벌 분리도 Top 40</h2>
|
||||||
|
<table><thead><tr><th>컬럼</th><th>TF</th><th>기법군</th><th>분리도</th><th>매수 median</th><th>매도 median</th></tr></thead>
|
||||||
|
<tbody>{_rows_global()}</tbody></table>
|
||||||
|
|
||||||
|
<h2>04 규칙 선별용 피처 (발췌)</h2>
|
||||||
|
<p><strong>매수</strong><br/><code>{buy_feats}</code></p>
|
||||||
|
<p><strong>매도</strong><br/><code>{sell_feats}</code></p>
|
||||||
|
</body></html>"""
|
||||||
|
html_path.write_text(html, encoding="utf-8")
|
||||||
|
return html_path
|
||||||
539
deepcoin/matching/gt_profile_iterate.py
Normal file
539
deepcoin/matching/gt_profile_iterate.py
Normal file
@@ -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
|
||||||
64
deepcoin/matching/gt_schedule.py
Normal file
64
deepcoin/matching/gt_schedule.py
Normal file
@@ -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
|
||||||
231
deepcoin/matching/label_outcomes.py
Normal file
231
deepcoin/matching/label_outcomes.py
Normal file
@@ -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
|
||||||
86
deepcoin/matching/live_eval.py
Normal file
86
deepcoin/matching/live_eval.py
Normal file
@@ -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
|
||||||
61
deepcoin/matching/load_rules.py
Normal file
61
deepcoin/matching/load_rules.py
Normal file
@@ -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)
|
||||||
@@ -1,31 +1,44 @@
|
|||||||
"""
|
"""
|
||||||
04단계 스텁: GT 스냅샷과 현재 상태 유사도·규칙 후보 (구현 예정).
|
04단계: GT 프로필 + 전구간 EV 필터 매칭 파이프라인.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
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:
|
def run_match_stub() -> Path:
|
||||||
"""
|
"""하위 호환: 스텁 대신 phase=profile만 안내."""
|
||||||
입력 파일 존재 여부만 확인하고 04단계 안내를 출력합니다.
|
print("=== Phase 04 Matching ===")
|
||||||
|
print(" 전체 파이프라인: python scripts/04_match_rules.py")
|
||||||
Returns:
|
print(" 단계별: --phase profile|scan|label|select")
|
||||||
matching 리포트 디렉터리.
|
print(f" analysis csv: {ANALYSIS_TRADES_CSV}")
|
||||||
"""
|
|
||||||
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'})")
|
|
||||||
print(f" output dir: {REPORTS_MATCHING}")
|
print(f" output dir: {REPORTS_MATCHING}")
|
||||||
print(" 구현 예정: 유사도·규칙 선택")
|
|
||||||
return REPORTS_MATCHING
|
return REPORTS_MATCHING
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_match_stub()
|
run_match()
|
||||||
|
|||||||
145
deepcoin/matching/pipeline.py
Normal file
145
deepcoin/matching/pipeline.py
Normal file
@@ -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()
|
||||||
215
deepcoin/matching/portfolio_sim.py
Normal file
215
deepcoin/matching/portfolio_sim.py
Normal file
@@ -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
|
||||||
418
deepcoin/matching/profile_rules.py
Normal file
418
deepcoin/matching/profile_rules.py
Normal file
@@ -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
|
||||||
318
deepcoin/matching/rule_eval.py
Normal file
318
deepcoin/matching/rule_eval.py
Normal file
@@ -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)
|
||||||
362
deepcoin/matching/select_rules.py
Normal file
362
deepcoin/matching/select_rules.py
Normal file
@@ -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"<tr><td>{s['rule_id']}</td><td>{s['side']}</td>"
|
||||||
|
f"<td>{v['count']}</td><td>{v['ev_pct']}</td>"
|
||||||
|
f"<td>{h.get('count', 0)}</td><td>{h.get('ev_pct', 0)}</td>"
|
||||||
|
f"<td>{h.get('profit_factor', 0)}</td></tr>"
|
||||||
|
)
|
||||||
|
gt = matched.get("gt_overlap", {})
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="ko"><head><meta charset="utf-8"/>
|
||||||
|
<title>04 Backtest Summary</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; }}
|
||||||
|
th, td {{ border: 1px solid #ccc; padding: 8px; text-align: left; }}
|
||||||
|
th {{ background: #e2e8f0; }}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>04 매칭 — {title} (valid 구간)</h1>
|
||||||
|
<p>방법: {matched.get('method','')}</p>
|
||||||
|
<p>{matched.get('note','')}</p>
|
||||||
|
<h2>선별 규칙</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>rule_id</th><th>side</th><th>valid_n</th><th>valid_ev</th>
|
||||||
|
<th>holdout_n</th><th>holdout_ev</th><th>holdout_pf</th></tr></thead>
|
||||||
|
<tbody>{''.join(rows) if rows else '<tr><td colspan="6">통과 규칙 없음</td></tr>'}</tbody>
|
||||||
|
</table>
|
||||||
|
<h2>GT recall (±{MATCH_GT_TOLERANCE_MIN}분, 전체 발화 기준)</h2>
|
||||||
|
<ul>
|
||||||
|
<li>매수: {gt.get('buy', {})}</li>
|
||||||
|
<li>매도: {gt.get('sell', {})}</li>
|
||||||
|
</ul>
|
||||||
|
</body></html>"""
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_text(html, encoding="utf-8")
|
||||||
|
print(f"[04-4] 리포트: {out_path}")
|
||||||
|
return out_path
|
||||||
371
deepcoin/matching/simulation.py
Normal file
371
deepcoin/matching/simulation.py
Normal file
@@ -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
|
||||||
460
deepcoin/matching/simulation_html.py
Normal file
460
deepcoin/matching/simulation_html.py
Normal file
@@ -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 "<tr><td colspan='8'>발화 없음</td></tr>"
|
||||||
|
sorted_fires = fires.sort_values("dt").reset_index(drop=True)
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(
|
||||||
|
f"""
|
||||||
|
<tr class="initial-row">
|
||||||
|
<td>시작</td><td>-</td><td>-</td><td>-</td><td>-</td>
|
||||||
|
<td><b>₩{GT_INITIAL_CASH_KRW:,.0f}</b></td>
|
||||||
|
<td>-</td><td>초기 현금 (1회 ₩{LIVE_ORDER_KRW:,.0f} 가정)</td>
|
||||||
|
</tr>"""
|
||||||
|
)
|
||||||
|
for i in range(len(sorted_fires)):
|
||||||
|
r = sorted_fires.iloc[i]
|
||||||
|
side = r["side"]
|
||||||
|
cls = "buy" if side == "buy" else "sell"
|
||||||
|
mark = "매수" if side == "buy" else "매도"
|
||||||
|
ret = float(r["forward_ret_pct"])
|
||||||
|
ret_s = f" (+{ret:.2f}%)" if ret > 0 else f" ({ret:.2f}%)"
|
||||||
|
win = "승" if int(r.get("win", 0)) else "패"
|
||||||
|
win_cls = "pass" if int(r.get("win", 0)) else "fail"
|
||||||
|
kind = rules_by_id.get(r["rule_id"], {}).get("kind", "")
|
||||||
|
step = steps[i] if i < len(steps) else None
|
||||||
|
if step:
|
||||||
|
total_s = f"₩{step['total_asset_krw']:,.0f}"
|
||||||
|
hold_s = (
|
||||||
|
f" (현금 ₩{step['cash_krw']:,.0f} + 코인 {step['holding_qty']:,.2f}개)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total_s = "-"
|
||||||
|
hold_s = ""
|
||||||
|
lines.append(
|
||||||
|
f"<tr>"
|
||||||
|
f"<td>{str(r['dt'])[:16]}</td>"
|
||||||
|
f'<td class="{cls}">{mark}</td>'
|
||||||
|
f"<td>{r['rule_id']}</td>"
|
||||||
|
f"<td>{kind}</td>"
|
||||||
|
f'<td class="num">₩{float(r["close"]):,.0f}{ret_s}</td>'
|
||||||
|
f"<td><b>{total_s}</b>{hold_s}</td>"
|
||||||
|
f'<td class="{win_cls}">{win}</td>'
|
||||||
|
f"<td>leg_gt 구간 수익</td>"
|
||||||
|
f"</tr>"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _gt_table_rows(trades: list[dict[str, Any]], steps: list[dict[str, Any]]) -> str:
|
||||||
|
"""
|
||||||
|
GT 타점 테이블 tbody (ground_truth 차트와 동일).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trades: ground_truth trades.
|
||||||
|
steps: simulate_truth_portfolio_steps.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tr HTML.
|
||||||
|
"""
|
||||||
|
if not trades:
|
||||||
|
return "<tr><td colspan='6'>타점 없음</td></tr>"
|
||||||
|
step_key = {
|
||||||
|
(s["dt"], s["action"], float(s["price"]), float(s["weight"])): s
|
||||||
|
for s in steps
|
||||||
|
}
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(
|
||||||
|
f"""
|
||||||
|
<tr class="initial-row">
|
||||||
|
<td>시작</td><td>-</td><td>-</td><td>-</td>
|
||||||
|
<td><b>₩{GT_INITIAL_CASH_KRW:,.0f}</b></td>
|
||||||
|
<td>초기 현금 (보유 0)</td>
|
||||||
|
</tr>"""
|
||||||
|
)
|
||||||
|
for t in sorted(trades, key=lambda x: x["dt"]):
|
||||||
|
cls = "buy" if t["action"] == "buy" else "sell"
|
||||||
|
mark = "매수" if t["action"] == "buy" else "매도"
|
||||||
|
ret = t.get("forward_return_pct")
|
||||||
|
ret_s = f" (+{ret}%)" if ret is not None else ""
|
||||||
|
w = float(t.get("weight", 1.0))
|
||||||
|
key = (t["dt"], t["action"], float(t["price"]), w)
|
||||||
|
step = step_key.get(key)
|
||||||
|
if step:
|
||||||
|
total_s = f"₩{step['total_asset_krw']:,.0f}"
|
||||||
|
hold_s = (
|
||||||
|
f" (현금 ₩{step['cash_krw']:,.0f} + 코인 {step['holding_qty']:,.2f}개)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total_s = "-"
|
||||||
|
hold_s = ""
|
||||||
|
lines.append(
|
||||||
|
f"<tr>"
|
||||||
|
f"<td>{t['dt'][:16]}</td>"
|
||||||
|
f'<td class="{cls}">{mark}</td>'
|
||||||
|
f"<td>{w*100:.0f}%</td>"
|
||||||
|
f"<td>₩{t['price']:,.0f}{ret_s}</td>"
|
||||||
|
f"<td><b>{total_s}</b>{hold_s}</td>"
|
||||||
|
f"<td>{t.get('memo', '')}</td>"
|
||||||
|
f"</tr>"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_cards_html(
|
||||||
|
close_last: float,
|
||||||
|
bb_txt: str,
|
||||||
|
gt_trades: list[dict[str, Any]],
|
||||||
|
gt_pnl: dict[str, Any],
|
||||||
|
sim_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 = (
|
||||||
|
'<p class="cards-group-title">정답 (ground_truth) — 분할 비중·leg 체결</p>'
|
||||||
|
+ market_cards_html(close_last, bb_txt)
|
||||||
|
+ pnl_cards_html(gt_pnl, "정답 타점", len(gt_trades))
|
||||||
|
)
|
||||||
|
sim_row = (
|
||||||
|
'<p class="cards-group-title">시뮬 (monitor_rules · holdout · '
|
||||||
|
f"1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도) — "
|
||||||
|
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span></p>'
|
||||||
|
+ 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"""
|
||||||
|
<h2>시뮬 타점 (holdout {len(holdout_fires)}건 → 체결 가정 {len(capped)}건)</h2>
|
||||||
|
<p class="meta">1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도·최대 거래수 적용 후 체결 순 포트폴리오.
|
||||||
|
가격 열 (+/-) = <b>{label_mode}</b> 구간 수익%.{_mark_note(close_val)}</p>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>시각</th><th>구분</th><th>규칙</th><th>유형</th><th>가격</th>
|
||||||
|
<th>총 평가금액</th><th>승/패</th><th>비고</th></tr></thead>
|
||||||
|
<tbody>{_sim_fire_table_rows(capped, rules_by_id, sim_steps)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
gt_table = f"""
|
||||||
|
<h2>정답 타점 (ground_truth)</h2>
|
||||||
|
<p class="meta">삼각형 = GT. 매수 분할 비중·매도 leg 반영.{_mark_note(close_val)}</p>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
|
||||||
|
<tbody>{_gt_table_rows(gt_trades, gt_steps)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
sections = f"""
|
||||||
|
{go_table}
|
||||||
|
<h2>매수·매도 판단 기준 (monitor_rules)</h2>
|
||||||
|
<p class="meta">04 GT 프로필 + 전구간 EV 선별. 조건 <b>모두</b> 충족 시 3분봉 종가에 신호.</p>
|
||||||
|
{criteria_blocks}
|
||||||
|
{sim_table}
|
||||||
|
{gt_table}
|
||||||
|
"""
|
||||||
|
|
||||||
|
note = (
|
||||||
|
f"1단계 시뮬 · holdout {report.get('holdout_ratio', 0.15)} · "
|
||||||
|
f"발화 {len(holdout_fires)}건 / 체결가정 {len(capped)}건. "
|
||||||
|
"상단 카드: 초기 금액·총보유자산·초기 대비 증감율·수수료."
|
||||||
|
)
|
||||||
|
legend = (
|
||||||
|
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 비중.<br>"
|
||||||
|
"● <b>시뮬</b> — 원 = 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"<p class='note'>{note}</p>",
|
||||||
|
legend_html=legend,
|
||||||
|
cards_html=cards,
|
||||||
|
chart_html=(
|
||||||
|
"<p class='note'>차트 데이터 없음 — "
|
||||||
|
"<code>python scripts/01_download.py</code> 후 재생성.</p>"
|
||||||
|
),
|
||||||
|
sections_html=sections,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_simulation_report_html(
|
||||||
|
report: dict[str, Any],
|
||||||
|
out_path: Path,
|
||||||
|
outcomes_path: Path | None = None,
|
||||||
|
matched_path: Path | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""
|
||||||
|
simulation_report.html 저장 (ground_truth 동일 스타일).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report: build_simulation_report 결과.
|
||||||
|
out_path: HTML 경로.
|
||||||
|
outcomes_path: fire_outcomes.csv.
|
||||||
|
matched_path: matched_rules.json.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
out_path.
|
||||||
|
"""
|
||||||
|
html = build_simulation_page_html(report, outcomes_path, matched_path)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_text(html, encoding="utf-8")
|
||||||
|
return out_path
|
||||||
92
deepcoin/ops/alert_message.py
Normal file
92
deepcoin/ops/alert_message.py
Normal file
@@ -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)
|
||||||
280
deepcoin/ops/chart_report.py
Normal file
280
deepcoin/ops/chart_report.py
Normal file
@@ -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'<div class="card"><span>{label}</span><b>{value}</b></div>'
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_chart_report_page(
|
||||||
|
page_title: str,
|
||||||
|
heading: str,
|
||||||
|
meta_line: str,
|
||||||
|
note_html: str,
|
||||||
|
legend_html: str,
|
||||||
|
cards_html: str,
|
||||||
|
chart_html: str,
|
||||||
|
sections_html: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Plotly 차트·테이블을 ground_truth와 동일 스타일 페이지로 감쌉니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_title: document title.
|
||||||
|
heading: h1.
|
||||||
|
meta_line: 기간·추세 등.
|
||||||
|
note_html: 안내 박스.
|
||||||
|
legend_html: 차트 범례 설명.
|
||||||
|
cards_html: .cards 내부 HTML.
|
||||||
|
chart_html: plotly embed.
|
||||||
|
sections_html: h2·테이블·criteria 등 본문 하단.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
전체 HTML 문서.
|
||||||
|
"""
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>{page_title}</title>
|
||||||
|
<style>{CHART_REPORT_CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{heading}</h1>
|
||||||
|
<p class="meta">{meta_line}</p>
|
||||||
|
{note_html}
|
||||||
|
<div class="legend-box">{legend_html}</div>
|
||||||
|
<div class="cards">{cards_html}</div>
|
||||||
|
<div class="chart-wrap">{chart_html}</div>
|
||||||
|
{sections_html}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
_COL_LABELS: dict[str, str] = {
|
||||||
|
"m3_bb_pos": "3분 BB %B",
|
||||||
|
"m15_bb_pos": "15분 BB %B",
|
||||||
|
"m30_bb_pos": "30분 BB %B",
|
||||||
|
"m3_RSI": "3분 RSI",
|
||||||
|
"m15_RSI": "15분 RSI",
|
||||||
|
"m30_RSI": "30분 RSI",
|
||||||
|
"m3_stoch_k": "3분 Stoch %K",
|
||||||
|
"m15_stoch_k": "15분 Stoch %K",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _col_label(col: str) -> str:
|
||||||
|
"""지표 컬럼 한글 라벨."""
|
||||||
|
return _COL_LABELS.get(col, col)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_condition(cond: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
규칙 조건 dict를 읽기 쉬운 문자열로 변환.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cond: {col, op, lo, hi, value}.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
한 줄 설명.
|
||||||
|
"""
|
||||||
|
col = _col_label(str(cond.get("col", "")))
|
||||||
|
op = cond.get("op", "")
|
||||||
|
if op == "between":
|
||||||
|
return f"{col} ∈ [{cond.get('lo'):.4g}, {cond.get('hi'):.4g}]"
|
||||||
|
if op == "lte":
|
||||||
|
return f"{col} ≤ {cond.get('value'):.4g}"
|
||||||
|
if op == "gte":
|
||||||
|
return f"{col} ≥ {cond.get('value'):.4g}"
|
||||||
|
if op == "lt":
|
||||||
|
return f"{col} < {cond.get('value'):.4g}"
|
||||||
|
if op == "gt":
|
||||||
|
return f"{col} > {cond.get('value'):.4g}"
|
||||||
|
return f"{col} {op} {cond}"
|
||||||
|
|
||||||
|
|
||||||
|
_KIND_LABELS: dict[str, str] = {
|
||||||
|
"contrast": "대조 — GT 매수·매도 프로필 대비 반대 구간 (m3 중심)",
|
||||||
|
"compound_tight": "복합 타이트 — GT 프로필 상위 3지표 AND 동시 충족",
|
||||||
|
"compound": "복합 TOP3 — GT 프로필 상위 3지표 AND",
|
||||||
|
"atomic": "단일 — GT 프로필 1지표",
|
||||||
|
"wide": "완화 — 프로필 외곽 구간",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_rule_kind(kind: str) -> str:
|
||||||
|
"""규칙 kind 한글 설명."""
|
||||||
|
return _KIND_LABELS.get(kind, kind)
|
||||||
|
|
||||||
|
|
||||||
|
def rule_criteria_html(rule: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
monitor_rule 1개의 매칭 기준 블록 HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rule: matched_rules 내 rule dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
.criteria 블록 HTML.
|
||||||
|
"""
|
||||||
|
rid = rule.get("rule_id", "")
|
||||||
|
side = "매수" if rule.get("side") == "buy" else "매도"
|
||||||
|
kind = rule.get("kind", "")
|
||||||
|
conds = rule.get("conditions") or []
|
||||||
|
items = "".join(f"<li>{_format_condition(c)}</li>" for c in conds)
|
||||||
|
m = rule.get("metrics", {}).get("all", {})
|
||||||
|
hold = rule.get("metrics", {}).get("holdout", {})
|
||||||
|
return f"""
|
||||||
|
<div class="criteria">
|
||||||
|
<h3>{rid} <span class="kind">({side} · {format_rule_kind(kind)})</span></h3>
|
||||||
|
<p class="meta">발화 시 3분봉 종가·8TF 지표가 아래를 <b>모두</b> 만족하면 {side} 신호.</p>
|
||||||
|
<ul>{items or '<li>조건 없음</li>'}</ul>
|
||||||
|
<p class="meta">전구간 EV {m.get('ev_pct', '-')}%% · holdout EV {hold.get('ev_pct', '-')}%% ·
|
||||||
|
holdout PF {hold.get('profit_factor', '-')}</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def go_no_go_table_html(checks: list[dict[str, Any]], go: bool) -> str:
|
||||||
|
"""
|
||||||
|
Go/No-Go 검증 테이블 HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
checks: go_no_go.checks.
|
||||||
|
go: 종합 판정.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
section HTML.
|
||||||
|
"""
|
||||||
|
flag = "go-pass" if go else "go-fail"
|
||||||
|
label = "GO" if go else "NO-GO"
|
||||||
|
rows = []
|
||||||
|
for c in checks:
|
||||||
|
mark = "PASS" if c.get("pass") else "FAIL"
|
||||||
|
cls = "pass" if c.get("pass") else "fail"
|
||||||
|
rows.append(
|
||||||
|
f"<tr><td>{c.get('rule_id')}</td><td>{c.get('side')}</td>"
|
||||||
|
f'<td class="{cls}">{mark}</td>'
|
||||||
|
f'<td class="num">{c.get("holdout_ev")}</td>'
|
||||||
|
f'<td class="num">{c.get("holdout_pf")}</td>'
|
||||||
|
f'<td class="num">{c.get("wf_positive_ratio")}</td>'
|
||||||
|
f'<td class="num">{c.get("fee_stress_ev")}</td></tr>'
|
||||||
|
)
|
||||||
|
body = "\n".join(rows) if rows else "<tr><td colspan='7'>없음</td></tr>"
|
||||||
|
return f"""
|
||||||
|
<h2>Go/No-Go (monitor_rules)</h2>
|
||||||
|
<p class="go {flag}">종합 판정: {label}</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>규칙</th><th>side</th><th>판정</th><th>holdout EV%</th>
|
||||||
|
<th>holdout PF</th><th>WF 양수월</th><th>수수료 2x EV%</th></tr></thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>"""
|
||||||
191
deepcoin/ops/live_trader.py
Normal file
191
deepcoin/ops/live_trader.py
Normal file
@@ -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)
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
"""
|
"""
|
||||||
WLD(월드코인) 실시간 모니터 — BB·일목 위치·추세 출력 (자동 매매 없음).
|
WLD(월드코인) 실시간 모니터 — BB·일목·04 매칭 규칙 알림 (자동 매매 없음).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import time
|
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
|
from deepcoin.ops.monitor import Monitor
|
||||||
|
|
||||||
|
|
||||||
class MonitorCoin(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:
|
def monitor_wld(self) -> None:
|
||||||
"""전 봉 BB·일목·추세를 콘솔에 출력합니다."""
|
"""전 봉 BB·일목·추세 및 규칙 발화를 출력합니다."""
|
||||||
print(
|
print(
|
||||||
"[{}] {} ({})".format(
|
"[{}] {} ({})".format(
|
||||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
@@ -22,9 +37,49 @@ class MonitorCoin(Monitor):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.process_wld_market_status(SYMBOL)
|
self.process_wld_market_status(SYMBOL)
|
||||||
|
if self.check_rules:
|
||||||
|
self._check_matched_rules()
|
||||||
|
|
||||||
|
def _check_matched_rules(self) -> None:
|
||||||
|
"""04 monitor_rules 최신 봉 평가 후 쿨다운 내 중복 알림 방지."""
|
||||||
|
rules = load_monitor_rules()
|
||||||
|
if not rules:
|
||||||
|
print(" [04 규칙] monitor_rules 없음 — scripts/04_match_rules.py 실행")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
fired = evaluate_live_rules(rules)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" [04 규칙] 평가 오류: {exc}")
|
||||||
|
return
|
||||||
|
if not fired:
|
||||||
|
print(f" [04 규칙] 발화 없음 (감시 {len(rules)}개)")
|
||||||
|
return
|
||||||
|
balances: dict | None = None
|
||||||
|
try:
|
||||||
|
balances = self.load_balances_dict()
|
||||||
|
except Exception:
|
||||||
|
balances = None
|
||||||
|
cooldown_sec = MONITOR_ALERT_COOLDOWN_MIN * 60
|
||||||
|
now = time.time()
|
||||||
|
for hit in fired:
|
||||||
|
rid = hit["rule_id"]
|
||||||
|
preview = build_rule_alert_message(hit, balances).replace("\n", " | ")
|
||||||
|
print(f" [04] {preview}")
|
||||||
|
last = self._last_alert_unix.get(rid, 0.0)
|
||||||
|
if now - last < cooldown_sec:
|
||||||
|
print(f" [04] 쿨다운 skip {rid} ({MONITOR_ALERT_COOLDOWN_MIN}분)")
|
||||||
|
continue
|
||||||
|
self._last_alert_unix[rid] = now
|
||||||
|
self._send_coin_msg(build_rule_alert_message(hit, balances))
|
||||||
|
|
||||||
def run_schedule(self) -> None:
|
def run_schedule(self) -> None:
|
||||||
"""MONITOR_LOOP_SLEEP_SEC 간격으로 상태를 출력합니다."""
|
"""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:
|
while True:
|
||||||
self.monitor_wld()
|
self.monitor_wld()
|
||||||
time.sleep(MONITOR_LOOP_SLEEP_SEC)
|
time.sleep(MONITOR_LOOP_SLEEP_SEC)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from deepcoin.common.indicators import apply_bar_indicators, disparity_column, g
|
|||||||
from deepcoin.ops.monitor import Monitor
|
from deepcoin.ops.monitor import Monitor
|
||||||
from deepcoin.data.mtf_bb import interval_label, load_frames_from_db
|
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
|
from deepcoin.paths import CHART_BB_HTML, CHART_TRUTH_HTML, resolve_ground_truth_file
|
||||||
|
|
||||||
OUTPUT_HTML = CHART_BB_HTML
|
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}<br>{t['dt'][:16]}<br>₩{float(t['price']):,.0f}"
|
||||||
|
f"<br>leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%"
|
||||||
|
f"<br>{t.get('rule_id', '')}"
|
||||||
|
for t in pts
|
||||||
|
],
|
||||||
|
hovertemplate="%{hovertext}<extra></extra>",
|
||||||
|
),
|
||||||
|
row=row,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
|
def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||||
"""정답 매수·매도 마커 (삼각형 크기 = 비중)."""
|
"""정답 매수·매도 마커 (삼각형 크기 = 비중)."""
|
||||||
for action, color, symbol, label in [
|
for action, color, symbol, label in [
|
||||||
@@ -112,8 +156,12 @@ def build_chart_html(
|
|||||||
interval_min: int = ENTRY_INTERVAL,
|
interval_min: int = ENTRY_INTERVAL,
|
||||||
note: str = "",
|
note: str = "",
|
||||||
truth_trades: list[dict] | None = None,
|
truth_trades: list[dict] | None = None,
|
||||||
|
sim_trades: list[dict] | None = None,
|
||||||
title_suffix: str = "BB 차트",
|
title_suffix: str = "BB 차트",
|
||||||
pnl_summary: dict | None = None,
|
pnl_summary: dict | None = None,
|
||||||
|
legend_html: str | None = None,
|
||||||
|
footer_sections: str | None = None,
|
||||||
|
cards_html: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""BB·이격도·RSI·MACD·스토캐스틱·거래량 차트 HTML."""
|
"""BB·이격도·RSI·MACD·스토캐스틱·거래량 차트 HTML."""
|
||||||
df = apply_bar_indicators(df.copy())
|
df = apply_bar_indicators(df.copy())
|
||||||
@@ -191,6 +239,8 @@ def build_chart_html(
|
|||||||
|
|
||||||
if truth_trades:
|
if truth_trades:
|
||||||
_add_truth_markers(fig, truth_trades, row=1)
|
_add_truth_markers(fig, truth_trades, row=1)
|
||||||
|
if sim_trades:
|
||||||
|
_add_sim_markers(fig, sim_trades, row=1)
|
||||||
|
|
||||||
disp_row = 2
|
disp_row = 2
|
||||||
for i, p in enumerate(DISPARITY_PERIODS):
|
for i, p in enumerate(DISPARITY_PERIODS):
|
||||||
@@ -340,7 +390,8 @@ def build_chart_html(
|
|||||||
last_price=close_last,
|
last_price=close_last,
|
||||||
)
|
)
|
||||||
trade_rows = ""
|
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
|
from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio_steps
|
||||||
|
|
||||||
steps = simulate_truth_portfolio_steps(
|
steps = simulate_truth_portfolio_steps(
|
||||||
@@ -385,14 +436,13 @@ def build_chart_html(
|
|||||||
<td><b>{total_s}</b>{hold_s}</td>
|
<td><b>{total_s}</b>{hold_s}</td>
|
||||||
<td>{t.get('memo', '')}</td>
|
<td>{t.get('memo', '')}</td>
|
||||||
</tr>"""
|
</tr>"""
|
||||||
trade_table = ""
|
if not trade_table and truth_trades:
|
||||||
if truth_trades:
|
|
||||||
if not trade_rows:
|
if not trade_rows:
|
||||||
trade_rows = "<tr><td colspan='6'>타점 없음</td></tr>"
|
trade_rows = "<tr><td colspan='6'>타점 없음</td></tr>"
|
||||||
mark_note = ""
|
mark_note = ""
|
||||||
if pnl.get("mark_price"):
|
if pnl.get("mark_price"):
|
||||||
mark_note = (
|
mark_note = (
|
||||||
f" 상단 최종 자산은 미청산 포함 종가 ₩{pnl['mark_price']:,.0f} 평가."
|
f" 총보유자산(미청산 포함)은 종가 ₩{pnl['mark_price']:,.0f} 평가."
|
||||||
)
|
)
|
||||||
trade_table = f"""
|
trade_table = f"""
|
||||||
<h2>정답 타점 (ground_truth)</h2>
|
<h2>정답 타점 (ground_truth)</h2>
|
||||||
@@ -405,54 +455,43 @@ def build_chart_html(
|
|||||||
|
|
||||||
pnl_cards = ""
|
pnl_cards = ""
|
||||||
if truth_trades and pnl.get("initial_cash_krw") is not None:
|
if truth_trades and pnl.get("initial_cash_krw") is not None:
|
||||||
pnl_cards = f"""
|
from deepcoin.ops.chart_report import card_html, initial_change_pct
|
||||||
<div class="card"><span>시작</span><b>₩{pnl['initial_cash_krw']:,.0f}</b></div>
|
|
||||||
<div class="card"><span>최종 자산</span><b>₩{pnl['final_asset_krw']:,.0f}</b></div>
|
|
||||||
<div class="card"><span>수익금</span><b>₩{pnl['pnl_krw']:+,.0f}</b></div>
|
|
||||||
<div class="card"><span>수익률</span><b>{pnl['pnl_pct']:+.2f}%</b></div>
|
|
||||||
<div class="card"><span>수수료</span><b>₩{pnl['total_fees_krw']:,.0f}</b></div>"""
|
|
||||||
if pnl.get("holding_qty", 0) > 0:
|
|
||||||
pnl_cards += f"""
|
|
||||||
<div class="card"><span>미청산</span><b>{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})</b></div>"""
|
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
change_pct = initial_change_pct(pnl)
|
||||||
<html lang="ko">
|
pnl_cards = (
|
||||||
<head>
|
card_html("초기 금액", f"₩{pnl['initial_cash_krw']:,.0f}")
|
||||||
<meta charset="utf-8"/>
|
+ card_html("총보유자산", f"₩{pnl['final_asset_krw']:,.0f}")
|
||||||
<title>{SYMBOL} {title_suffix}</title>
|
+ card_html("초기 대비 증감율", f"{change_pct:+.2f}%")
|
||||||
<style>
|
+ card_html("수수료", f"₩{pnl['total_fees_krw']:,.0f}")
|
||||||
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f8fafc; }}
|
)
|
||||||
h1 {{ font-size: 1.35rem; }}
|
if pnl.get("holding_qty", 0) > 0:
|
||||||
.meta {{ color: #475569; font-size: 0.9rem; }}
|
pnl_cards += card_html(
|
||||||
.note {{ background: #f1f5f9; border: 1px solid #cbd5e1; padding: 10px; border-radius: 6px; color: #334155; }}
|
"미청산",
|
||||||
.cards {{ display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }}
|
f"{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})",
|
||||||
.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; }}
|
default_legend = (
|
||||||
.chart-wrap {{ background:#fff; border:1px solid #e2e8f0; border-radius:8px; padding:8px; }}
|
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 크기 = 비중.<br>"
|
||||||
.legend-box {{ font-size:0.85rem; color:#475569; margin-bottom:10px; }}
|
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = monitor_rules holdout 발화."
|
||||||
table {{ width:100%; border-collapse:collapse; background:#fff; font-size:0.85rem; }}
|
)
|
||||||
th, td {{ border:1px solid #e2e8f0; padding:8px; text-align:left; }}
|
if cards_html:
|
||||||
th {{ background:#f1f5f9; }}
|
cards_inner = cards_html
|
||||||
td.buy {{ color:#16a34a; font-weight:600; }}
|
else:
|
||||||
td.sell {{ color:#dc2626; font-weight:600; }}
|
cards_inner = f"""
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>{COIN_NAME} ({SYMBOL}) {title_suffix}</h1>
|
|
||||||
<p class="meta">추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}</p>
|
|
||||||
{note_html}
|
|
||||||
<div class="legend-box">▲ 매수 · ▼ 매도 — 삼각형이 클수록 비중이 큽니다.</div>
|
|
||||||
<div class="cards">
|
|
||||||
<div class="card"><span>종가</span><b>₩{close_last:,.2f}</b></div>
|
<div class="card"><span>종가</span><b>₩{close_last:,.2f}</b></div>
|
||||||
<div class="card"><span>BB %B</span><b>{bb_pos_txt}</b></div>
|
<div class="card"><span>BB %B</span><b>{bb_pos_txt}</b></div>
|
||||||
<div class="card"><span>정답 타점</span><b>{len(truth_trades) if truth_trades else 0}건</b></div>
|
<div class="card"><span>정답 타점</span><b>{len(truth_trades) if truth_trades else 0}건</b></div>
|
||||||
{pnl_cards}
|
{pnl_cards}"""
|
||||||
</div>
|
return wrap_chart_report_page(
|
||||||
<div class="chart-wrap">{chart_html}</div>
|
page_title=f"{SYMBOL} {title_suffix}",
|
||||||
{trade_table}
|
heading=f"{COIN_NAME} ({SYMBOL}) {title_suffix}",
|
||||||
</body>
|
meta_line=f"추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}",
|
||||||
</html>"""
|
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(
|
def _frames_to_mtf(
|
||||||
|
|||||||
@@ -32,10 +32,26 @@ DOCS_MATCHING = DOCS_DIR / "04_matching"
|
|||||||
DOCS_OPS = DOCS_DIR / "05_ops"
|
DOCS_OPS = DOCS_DIR / "05_ops"
|
||||||
|
|
||||||
ANALYSIS_TRADES_CSV = DOCS_ANALYSIS / "general_analysis_trades.csv"
|
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_REPORT_HTML = DOCS_ANALYSIS / "general_analysis_report.html"
|
||||||
ANALYSIS_CAPABILITY_HTML = DOCS_ANALYSIS / "general_analysis_capability.html"
|
ANALYSIS_CAPABILITY_HTML = DOCS_ANALYSIS / "general_analysis_capability.html"
|
||||||
ANALYSIS_LATEST_DIR = DOCS_ANALYSIS / "latest"
|
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_BB_HTML = DOCS_CHARTS / "wld_bb_chart.html"
|
||||||
CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html"
|
CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html"
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
- **목적**: 차트 상 의미 있는 저점 매수·고점 매도를 JSON으로 고정
|
- **목적**: 차트 상 의미 있는 저점 매수·고점 매도를 JSON으로 고정
|
||||||
- **방법**: 고점(major swing)에서 1~2회 매도 · 저점(ZigZag+BB)에서 분할 매수 · 삼각형 크기=비중
|
- **방법**: 고점(major swing)에서 1~2회 매도 · 저점(ZigZag+BB)에서 분할 매수 · 삼각형 크기=비중
|
||||||
|
- **체결 순서**: JSON 저장·포트폴리오 시뮬은 **leg별 매수 전량 → 매도 전량** (시각순 아님). 차트 표는 시각순 정렬.
|
||||||
|
- **HTML 카드**: 초기 금액, 총보유자산, 초기 대비 증감율(종가 평가 포함). 기간말 leg는 **종가 청산** 포함.
|
||||||
|
|
||||||
## Do
|
## Do
|
||||||
|
|
||||||
|
|||||||
44
docs/reference/LIVE_TRADING.md
Normal file
44
docs/reference/LIVE_TRADING.md
Normal file
@@ -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`에 기록합니다.
|
||||||
40
docs/reference/OPERATIONS.md
Normal file
40
docs/reference/OPERATIONS.md
Normal file
@@ -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` 기록 확인
|
||||||
25
docs/reference/RISK.md
Normal file
25
docs/reference/RISK.md
Normal file
@@ -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만 원 초과 시 중단
|
||||||
|
|
||||||
|
운영 전 본인 자금에 맞게 **반드시** 조정하세요.
|
||||||
|
|
||||||
|
## 면책
|
||||||
|
|
||||||
|
실거래 손익은 전적으로 운영자 책임입니다. 본 저장소는 투자 자문이 아닙니다.
|
||||||
@@ -1,29 +1,30 @@
|
|||||||
# DeepCoin 로드맵 (WLD)
|
# DeepCoin 로드맵 (WLD)
|
||||||
|
|
||||||
## 완료
|
## 완료 (기반)
|
||||||
|
|
||||||
| 단계 | 내용 | 실행 |
|
| 단계 | 내용 | 실행 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 01 데이터 | 1년치 3분~일봉 `coins.db` 적재 | `python scripts/01_download.py` |
|
| 01~03c | DB, GT, enrich, GT MTF 스냅샷, **전 TF 프로필(매수/매도 대조)** | `01`~`03_gt_mtf_profile.py` |
|
||||||
| 02 Ground Truth | 매수·매도 정답 타점 JSON | `python scripts/02_ground_truth.py` |
|
| 04 | GT 프로필 + leg_gt EV + holdout | `04_match_rules.py` |
|
||||||
| 03 분석 준비 | 8TF 기술적 지표·패턴 enrich | `python scripts/03_analyze_enrich.py` |
|
| 05 | 텔레그램 알림 (주문 없음) | `05_run_monitor.py` |
|
||||||
|
|
||||||
## 진행 예정
|
## 남은 작업 (합의 순서)
|
||||||
|
|
||||||
| 단계 | 내용 | 패키지 | 실행 (예정) |
|
| 순서 | 단계 | 내용 | 실행 |
|
||||||
|------|------|--------|-------------|
|
|------|------|------|------|
|
||||||
| 03b | GT 타점 3분~일봉 기술적 상태 분석 (CLI 준비, 전량 CSV 재실행 필요) | `deepcoin/analysis/` | `python scripts/03_analyze_trades.py` |
|
| **1** | 시뮬레이션 | walk-forward·민감도·Go/No-Go | `04_simulation_report.py` |
|
||||||
| 04 | GT에 가장 근접한 기술적 상태 선택 | `deepcoin/matching/` | `python scripts/04_match_rules.py` |
|
| **2** | 문서화 | SIMULATION, LIVE, RISK, OPERATIONS | `docs/reference/` |
|
||||||
| 05 | 1분 단위 상태 확인·실거래 | `deepcoin/ops/` | `python scripts/05_run_monitor.py` |
|
| **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
|
```text
|
||||||
scripts/01~05_*.py 단계별 CLI
|
scripts/01~06_*.py
|
||||||
data/ coins.db, ground_truth/, ops/
|
data/coins.db, ground_truth/, ops/live_trades.jsonl
|
||||||
docs/reference/ 가이드·명세
|
docs/04_matching/simulation_report.*
|
||||||
docs/02~05, charts/ 단계별 산출물 (HTML·CSV)
|
docs/reference/
|
||||||
deepcoin/ 단계별 Python 패키지
|
|
||||||
```
|
```
|
||||||
|
|||||||
40
docs/reference/SIMULATION.md
Normal file
40
docs/reference/SIMULATION.md
Normal file
@@ -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)
|
||||||
11
scripts/03_gt_mtf_profile.py
Normal file
11
scripts/03_gt_mtf_profile.py
Normal file
@@ -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()
|
||||||
17
scripts/03_patch_gt_snapshots.py
Normal file
17
scripts/03_patch_gt_snapshots.py
Normal file
@@ -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}건 추가")
|
||||||
11
scripts/04_calibrate_gt_assets.py
Normal file
11
scripts/04_calibrate_gt_assets.py
Normal file
@@ -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()
|
||||||
11
scripts/04_gt_comparison_report.py
Normal file
11
scripts/04_gt_comparison_report.py
Normal file
@@ -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()
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""04단계: GT 근접 규칙 선택 (스텁)."""
|
"""04단계: GT 프로필 + 전구간 EV 매칭 (04a~04d)."""
|
||||||
import runpy
|
import runpy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
|
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__":
|
if __name__ == "__main__":
|
||||||
run_match_stub()
|
main()
|
||||||
|
|||||||
11
scripts/04_simulation_report.py
Normal file
11
scripts/04_simulation_report.py
Normal file
@@ -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()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""05단계: WLD 실시간 모니터 루프."""
|
"""05단계: WLD 실시간 모니터 루프 (04 규칙 알림 포함)."""
|
||||||
|
import argparse
|
||||||
import runpy
|
import runpy
|
||||||
from pathlib import Path
|
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
|
from deepcoin.ops.monitor_coin import MonitorCoin
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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()
|
||||||
|
|||||||
22
scripts/06_execute_live.py
Normal file
22
scripts/06_execute_live.py
Normal file
@@ -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)
|
||||||
@@ -7,10 +7,12 @@ python scripts/01_download.py
|
|||||||
python scripts/02_ground_truth.py
|
python scripts/02_ground_truth.py
|
||||||
python scripts/03_analyze_enrich.py
|
python scripts/03_analyze_enrich.py
|
||||||
python scripts/03_analyze_trades.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_truth.py
|
||||||
python scripts/05_chart_bb.py
|
python scripts/05_chart_bb.py
|
||||||
python scripts/05_run_monitor.py
|
|
||||||
python scripts/verify_env.py
|
python scripts/verify_env.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user