From e631a5701f07e8ede681144556e744700219f8b1 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Fri, 29 May 2026 19:07:10 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EC=8A=A4=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20FSM=EC=9D=84=20simulation=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EC=97=90=20=ED=86=B5=ED=95=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수동 타점(logos_trades.json) 흐름에 맞춘 순차 매매 로직을 추가하고, python simulation.py 실행 시 로고스 백테스트·HTML을 생성한다. 규칙 탐색·BB 안전장치 개선과 함께 reports HTML은 gitignore로 제외한다. Co-authored-by: Cursor --- .env.example | 12 +- .gitignore | 3 + combination_report.json | 331 ++++++++++++++++++++++++++++++++++++ config.py | 22 ++- discovered_rules.json | 24 +-- docs/LOGOS_STRATEGY.md | 223 ++++++++++++++++++++++++ logos_chart.py | 18 ++ logos_strategy.py | 349 ++++++++++++++++++++++++++++++++++++++ logos_trades.json | 57 +++++++ rule_discovery.py | 365 +++++++++++++++++++++++++++++++++++----- simulation.py | 304 +++++++++++++++++++++++++++++---- strategy.py | 31 +++- 12 files changed, 1639 insertions(+), 100 deletions(-) create mode 100644 combination_report.json create mode 100644 docs/LOGOS_STRATEGY.md create mode 100644 logos_chart.py create mode 100644 logos_strategy.py create mode 100644 logos_trades.json diff --git a/.env.example b/.env.example index 72e048e..0659791 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,15 @@ BITHUMB_SECRET_KEY= COIN_TELEGRAM_BOT_TOKEN= COIN_TELEGRAM_CHAT_ID= -# 쿨다운(초) — 3분 전략 -BUY_COOLDOWN_SEC=300 -SELL_COOLDOWN_SEC=180 +# 쿨다운(초) — 3분 전략 (빈번 체결 완화) +BUY_COOLDOWN_SEC=1800 +SELL_COOLDOWN_SEC=900 +SIGNAL_EDGE_ONLY=true +TRADE_MIN_GAP_BARS=5 +DISCOVER_MAX_TRADES=120 +DISCOVER_TRADE_PENALTY_PCT=0.03 +SELL_MIN_BB_POS=0.4 +BUY_MAX_BB_POS_CHASE=0.55 # 매수 금액(KRW) DEFAULT_BUY_KRW=30000 diff --git a/.gitignore b/.gitignore index 0c49e0a..f80fd74 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,9 @@ celerybeat-schedule # dotenv .env +# 백테스트·시뮬레이션 HTML (로컬 재생성) +reports/ + # virtualenv .venv venv/ diff --git a/combination_report.json b/combination_report.json new file mode 100644 index 0000000..d34df49 --- /dev/null +++ b/combination_report.json @@ -0,0 +1,331 @@ +{ + "generated_at": "2026-05-29T13:55:17", + "intervals_loaded": [ + 1, + 3, + 5, + 10, + 15, + 30, + 60, + 240, + 1440 + ], + "latest_positions": [ + { + "interval": 1, + "label": "1분", + "close": 425.0, + "bb_pos": 0.352, + "bb_zone": "mid", + "bb_state": "squeeze", + "ichi_position": "in_cloud", + "ichi_tk": "bear", + "ichi_cloud": "bull" + }, + { + "interval": 3, + "label": "3분", + "close": 425.0, + "bb_pos": 0.638, + "bb_zone": "mid", + "bb_state": "inside_band", + "ichi_position": "above_cloud", + "ichi_tk": "bull", + "ichi_cloud": "bull" + }, + { + "interval": 5, + "label": "5분", + "close": 425.0, + "bb_pos": 0.706, + "bb_zone": "high", + "bb_state": "inside_band", + "ichi_position": "above_cloud", + "ichi_tk": "bull", + "ichi_cloud": "bear" + }, + { + "interval": 10, + "label": "10분", + "close": 425.0, + "bb_pos": 0.744, + "bb_zone": "high", + "bb_state": "inside_band", + "ichi_position": "in_cloud", + "ichi_tk": "bear", + "ichi_cloud": "bear" + }, + { + "interval": 15, + "label": "15분", + "close": 425.0, + "bb_pos": 0.526, + "bb_zone": "mid", + "bb_state": "inside_band", + "ichi_position": "above_cloud", + "ichi_tk": "bear", + "ichi_cloud": "bear" + }, + { + "interval": 30, + "label": "30분", + "close": 425.0, + "bb_pos": 0.448, + "bb_zone": "mid", + "bb_state": "inside_band", + "ichi_position": "above_cloud", + "ichi_tk": "bear", + "ichi_cloud": "bear" + }, + { + "interval": 60, + "label": "60분", + "close": 425.0, + "bb_pos": 0.613, + "bb_zone": "mid", + "bb_state": "inside_band", + "ichi_position": "below_cloud", + "ichi_tk": "bull", + "ichi_cloud": "bear" + }, + { + "interval": 240, + "label": "240분", + "close": 425.0, + "bb_pos": 0.317, + "bb_zone": "low", + "bb_state": "inside_band", + "ichi_position": "below_cloud", + "ichi_tk": "bear", + "ichi_cloud": "bear" + }, + { + "interval": 1440, + "label": "일봉", + "close": 425.0, + "bb_pos": 0.417, + "bb_zone": "mid", + "bb_state": "inside_band", + "ichi_position": "below_cloud", + "ichi_tk": "bull", + "ichi_cloud": "bull" + } + ], + "buy_recommendations": [ + "m30:bullish — 410회, +1.08% (+1.04%p)", + "m60:above_upper — 480회, +1.00% (+0.96%p)", + "m60:cross_up_upper — 340회, +0.89% (+0.86%p)", + "m60:bullish — 960회, +0.76% (+0.72%p)", + "m240:hammer — 1588회, +0.70% (+0.66%p)", + "m240:cross_up_upper — 160회, +0.66% (+0.62%p)", + "m60:bb_zone_top — 1377회, +0.66% (+0.62%p)", + "m30:ichi_tk_cross_down — 148회, +0.62% (+0.59%p)", + "조합 m30:bullish + m60:above_upper — 50회, +2.48%p", + "조합 m60:above_upper + m30:body_strong — 50회, +2.44%p", + "조합 m30:bullish + d1:bb_zone_high — 130회, +2.39%p", + "조합 m30:bullish + m60:cross_up_upper — 30회, +2.26%p", + "조합 d1:bb_zone_high + m30:body_strong — 100회, +2.13%p" + ], + "sell_recommendations": [], + "buy_avoid": [ + "매수 회피: m240:cross_down_lower (-0.93%p)", + "매수 회피: m240:below_lower (-0.93%p)", + "매수 회피: m10:body_ratio (-0.63%p)", + "매수 회피: m15:cross_up_lower (-0.58%p)", + "매수 회피: m10:bullish (-0.54%p)", + "매수 회피: m240:cross_up_lower (-0.53%p)" + ], + "top_buy_pairs": [ + { + "keys": [ + "m30:bullish", + "m60:above_upper" + ], + "count": 50, + "avg_forward_pct": 2.5214, + "edge_vs_base": 2.4838 + }, + { + "keys": [ + "m60:above_upper", + "m30:body_strong" + ], + "count": 50, + "avg_forward_pct": 2.4777, + "edge_vs_base": 2.4401 + }, + { + "keys": [ + "m30:bullish", + "d1:bb_zone_high" + ], + "count": 130, + "avg_forward_pct": 2.4265, + "edge_vs_base": 2.3889 + }, + { + "keys": [ + "m30:bullish", + "m60:cross_up_upper" + ], + "count": 30, + "avg_forward_pct": 2.2987, + "edge_vs_base": 2.2611 + }, + { + "keys": [ + "d1:bb_zone_high", + "m30:body_strong" + ], + "count": 100, + "avg_forward_pct": 2.1672, + "edge_vs_base": 2.1296 + }, + { + "keys": [ + "m60:bb_zone_top", + "m30:body_strong" + ], + "count": 80, + "avg_forward_pct": 2.0742, + "edge_vs_base": 2.0366 + }, + { + "keys": [ + "m240:hammer", + "m30:body_strong" + ], + "count": 139, + "avg_forward_pct": 1.9833, + "edge_vs_base": 1.9457 + }, + { + "keys": [ + "m30:bullish", + "m240:hammer" + ], + "count": 190, + "avg_forward_pct": 1.932, + "edge_vs_base": 1.8944 + }, + { + "keys": [ + "m30:bullish", + "m60:bb_zone_top" + ], + "count": 140, + "avg_forward_pct": 1.924, + "edge_vs_base": 1.8864 + }, + { + "keys": [ + "m30:bullish", + "m60:bb_pos_high" + ], + "count": 140, + "avg_forward_pct": 1.924, + "edge_vs_base": 1.8864 + }, + { + "keys": [ + "m30:body_strong", + "m60:bb_pos_high" + ], + "count": 90, + "avg_forward_pct": 1.8966, + "edge_vs_base": 1.859 + }, + { + "keys": [ + "m60:bullish", + "m240:cross_up_upper" + ], + "count": 20, + "avg_forward_pct": 1.8701, + "edge_vs_base": 1.8324 + }, + { + "keys": [ + "m60:cross_up_upper", + "m30:body_strong" + ], + "count": 20, + "avg_forward_pct": 1.731, + "edge_vs_base": 1.6934 + }, + { + "keys": [ + "m60:cross_up_upper", + "m240:hammer" + ], + "count": 180, + "avg_forward_pct": 1.5455, + "edge_vs_base": 1.5079 + }, + { + "keys": [ + "m60:above_upper", + "m240:hammer" + ], + "count": 300, + "avg_forward_pct": 1.4981, + "edge_vs_base": 1.4605 + }, + { + "keys": [ + "m30:bullish", + "m60:hammer" + ], + "count": 210, + "avg_forward_pct": 1.4713, + "edge_vs_base": 1.4337 + }, + { + "keys": [ + "m60:bullish", + "m240:hammer" + ], + "count": 420, + "avg_forward_pct": 1.3974, + "edge_vs_base": 1.3598 + }, + { + "keys": [ + "m240:hammer", + "m60:bb_zone_top" + ], + "count": 480, + "avg_forward_pct": 1.3812, + "edge_vs_base": 1.3436 + }, + { + "keys": [ + "m60:cross_up_upper", + "d1:bb_zone_high" + ], + "count": 180, + "avg_forward_pct": 1.3333, + "edge_vs_base": 1.2956 + }, + { + "keys": [ + "m60:bullish", + "m30:body_strong" + ], + "count": 90, + "avg_forward_pct": 1.3228, + "edge_vs_base": 1.2852 + } + ], + "top_sell_pairs": [], + "suggested_rules": { + "buy_all": [ + "m30:bullish", + "m60:above_upper" + ], + "buy_any": [], + "sell_all": [], + "sell_stop": [] + } +} \ No newline at end of file diff --git a/config.py b/config.py index e6cd40f..cb36156 100644 --- a/config.py +++ b/config.py @@ -27,11 +27,27 @@ KR_COINS: dict[str, str] = { TREND_INTERVAL_1H = 60 TREND_INTERVAL_1D = 1440 -# --- 쿨다운(초) --- -BUY_COOLDOWN_SEC = int(os.getenv("BUY_COOLDOWN_SEC", "300")) -SELL_COOLDOWN_SEC = int(os.getenv("SELL_COOLDOWN_SEC", "180")) +# --- 쿨다운(초) — 3분봉: 기본 30분/15분 (빈번 체결 완화) --- +BUY_COOLDOWN_SEC = int(os.getenv("BUY_COOLDOWN_SEC", "1800")) +SELL_COOLDOWN_SEC = int(os.getenv("SELL_COOLDOWN_SEC", "900")) BUY_MINUTE_LIMIT = BUY_COOLDOWN_SEC +# 매수·매도 신호는 조건이 False→True로 바뀐 봉에서만 (연속 참 방지) +SIGNAL_EDGE_ONLY = os.getenv("SIGNAL_EDGE_ONLY", "true").lower() in ("1", "true", "yes") + +# 체결(매수·매도 공통) 후 최소 대기 봉 수 (3분봉 5봉 = 15분) +TRADE_MIN_GAP_BARS = int(os.getenv("TRADE_MIN_GAP_BARS", "5")) + +# 규칙 탐색 시 거래 횟수 패널티 (학습 구간) +DISCOVER_MAX_TRADES = int(os.getenv("DISCOVER_MAX_TRADES", "120")) +DISCOVER_TRADE_PENALTY_PCT = float(os.getenv("DISCOVER_TRADE_PENALTY_PCT", "0.03")) + +# 3분 BB 위치: 이 값 미만에서 상단돌파 매도 차단 (저점 익절 방지) +SELL_MIN_BB_POS = float(os.getenv("SELL_MIN_BB_POS", "0.4")) + +# 3분 BB 위치: 이 값 이상이면 단독 상단구간 매수 차단 (고점 추격 방지) +BUY_MAX_BB_POS_CHASE = float(os.getenv("BUY_MAX_BB_POS_CHASE", "0.55")) + # --- 볼린저 (3분봉, 20, 2σ) --- BB_PERIOD = 20 BB_STD = 2 diff --git a/discovered_rules.json b/discovered_rules.json index c32b1d0..da1a2e4 100644 --- a/discovered_rules.json +++ b/discovered_rules.json @@ -1,19 +1,21 @@ { "name": "discovered_best", "buy_all": [ - "m240:above_upper", - "m3:bb_zone_bottom" + "m3:cross_up_lower", + "m60:ichi_tk_bull", + "d1:!ichi_below_cloud", + "m3:bb_zone_low", + "m3:ichi_above_cloud" ], "buy_any": [], "sell_all": [ - "m3:cross_up_upper", - "m3:ichi_above_cloud", - "m3:ichi_cloud_bull", - "m3:ichi_tk_cross_up" + "m60:cross_up_upper" ], - "sell_stop": [], - "train_return_pct": 2.9835000000000003, - "test_return_pct": 1.45272321428571, - "full_return_pct": 4.43622321428571, - "trade_count": 3 + "sell_stop": [ + "m3:cross_down_lower" + ], + "train_return_pct": 0.7385912698412721, + "test_return_pct": 0.0, + "full_return_pct": 0.7385912698412721, + "trade_count": 2 } \ No newline at end of file diff --git a/docs/LOGOS_STRATEGY.md b/docs/LOGOS_STRATEGY.md new file mode 100644 index 0000000..811f7f9 --- /dev/null +++ b/docs/LOGOS_STRATEGY.md @@ -0,0 +1,223 @@ +# 로고스(Logos) 매매 타점 전략 설계 + +BB predicate 탐색(`discovered_rules`)과 **분리**된, 차트 구조·추세·과열을 우선하는 **3분 현물** 전략입니다. +수동 타점(`logos_trades.json`)은 이 전략의 **교사 데이터(벤치마크)** 로 사용합니다. + +--- + +## Plan (계획) + +### 목적 + +- **저점 근처 매수**, **고점·과열에서 매도**, **추격 매수·바닥 매도**를 시스템적으로 배제한다. +- 3분봉 실행, 1시간·일봉으로 **방향 필터**만 거는 MTF 구조를 유지한다. +- 체결은 **엣지(전환) 1회** + **쿨다운·최소 봉 간격**으로 휩소를 줄인다. + +### 목표 KPI (백테스트·실거래 공통) + +| KPI | 목표 | +|-----|------| +| 매도가 ≥ 직전 매수가 비율 | 60% 이상 | +| 월 거래 횟수 (3분 WLD) | 8~40회 (과다 체결 방지) | +| 최대 연속 손실 매도 | 3회 이하 | +| 수동 로고스 타점 일치율 | ±3봉(9분), ±2% 가격 이내 50% 이상 (캘리브레이션) | + +### 전략 원칙 (5가지) + +1. **구조 우선**: 지표 한 개보다 «바닥 형성 → 눌림 → 가속 → 과열» 순서를 본다. +2. **가치 매수만**: 하단 돌파·망치·밴드 하단 구간. 상단 구간 단독 매수 금지. +3. **익절은 강도로**: 밴드 상단 돌파만으로 팔지 않고, **위치·캔들·RSI·거래량**이 맞을 때만. +4. **추세 보유**: 눌림(예: 378)은 **상위 추세가 살아 있으면** 버틴다. +5. **현물 순환**: 공매도 없음. 매도 = 보유 청산 또는 익절 후 재진입 대기. + +--- + +## Do (실행 구조) + +### 아키텍처 (4계층) + +```mermaid +flowchart TB + subgraph L1 [L1 레짐 1D/1H] + R1[추세: up / range / down] + R2[일목: 구름 위·아래] + end + subgraph L2 [L2 구조 3m/15m/60m] + S1[스윙 고저 pivot] + S2[고저점 상승 HL / 하락 LH] + end + subgraph L3 [L3 트리거 3m] + B1[매수 A: 바닥·반등] + B2[매수 B: 추세 눌림] + X1[매도: 익절·과열] + X2[매도: 손절] + end + subgraph L4 [L4 체결] + E1[엣지 트리거] + E2[쿨다운·최소봉격] + end + L1 --> L2 --> L3 --> L4 +``` + +### L1. 레짐 필터 (1D + 1H) + +| 레짐 | 매수 | 매도(익절) | +|------|------|------------| +| **up** | A·B 허용 | 전량·분할 허용 | +| **range** | A만 (바닥형), B 제한 | 분할 위주 | +| **down** | A만 소량, B 금지 | 보유 시 손절·약익절만 | + +판별(기존 `strategy.get_trend` 활용): + +- 1H·1D MA 정배열/역배열 + 갭 +- 보조: 일봉 `!ichi_below_cloud` (매수), 구름 아래 매수 금지 + +### L2. 구조 (스윙) + +| 간격 | pivot order (3분봉 개수) | 용도 | +|------|--------------------------|------| +| 3m | 40 (~2시간) | 주요 고저 | +| 3m | 15 (~45분) | 보조 눌림 | +| 15m | 20 | 중기 저항 | + +정의: + +- **스윙 저점**: 좌우 `order` 봉보다 `Low`가 낮은 봉 +- **스윙 고점**: 좌우 `order` 봉보다 `High`가 높은 봉 +- **HL(상승)**: 직전 스윙 저점 < 현재 스윙 저점 +- **과열 고점**: 종가가 120봉 최고가 대비 97% 이상 + +### L3-A. 매수 트리거 + +#### A. 바닥·투매 종료 (5/18 340 유형) + +**엣지**로만 진입. 아래 **모두** 충족: + +| # | 조건 | +|---|------| +| A1 | 3m `bb_pos` < 0.35 또는 `bb_zone_bottom` | +| A2 | `cross_up_lower` **또는** `hammer` (전봉 대비 신규) | +| A3 | RSI(14) < 42 **그리고** 전봉 대비 RSI 상승 | +| A4 | 120봉 최저가 대비 종가 ≤ 103% (진정한 바닥권) | +| A5 | 레짐 ≠ down 또는 일봉 구름 아래 아님 | + +**금지**: `bb_pos` ≥ 0.55 단독, `bb_zone_top`, 당일 `shooting_star` + 고점권 + +#### B. 추세 눌림 재진입 (5/23 392, 5/25 427 유형) + +**엣지** + 레짐 **up**: + +| # | 조건 | +|---|------| +| B1 | 1H 추세 up, 3m `bb_zone_low` 또는 `cross_up_lower` | +| B2 | 직전 스윙 고점 대비 3~12% 조정(눌림 깊이) | +| B3 | 3m `bb_pos` < 0.50 | +| B4 | 15m `ichi_tk_bull` 또는 전환선 지지 | +| B5 | 마지막 매도 후 ≥ 20봉(1시간) | + +**금지**: 450+ 구간 «급등 후 재추격»(5/26 04:00 유형) — `bb_pos` > 0.65 신규 매수 차단 + +### L3-B. 매도 트리거 + +#### C. 구조적 익절 (5/23 00:30, 5/24 464 유형) + +**엣지** + 보유 중: + +| # | 조건 | +|---|------| +| C1 | 3m **스윙 고점** 확정(피벗) **또는** `cross_up_upper` | +| C2 | `bb_pos` ≥ **0.65** (저점 익절 방지) | +| C3 | **NOT** (`hammer` OR `bb_zone_bottom` 동일 봉) | +| C4 | 종가 ≥ BB 중심(MA) | + +분할: 1차 C만 충족 시 50% 익절, 2차 조건(D) 시 나머지 (구현 옵션). + +#### D. 과열·불꽃 익절 (5/26 603 유형) + +| # | 조건 | +|---|------| +| D1 | 3m `bb_pos` ≥ 0.90 **또는** 20봉 누적 상승률 ≥ 8% | +| D2 | 거래량 > 20봉 평균 × 1.5 | +| D3 | `shooting_star` 또는 윗꼬리 비율 > 0.45 | + +→ **전량 매도** 우선. + +#### E. 손절 (필수) + +| # | 조건 | +|---|------| +| E1 | 3m `cross_down_lower` **엣지** | +| E2 | 또는 매수가 대비 -3% (설정값) | + +**금지**: `bb_pos` < 0.40 에서 `cross_up_upper` 단독 매도 (5/26 01:48 유형) + +### L4. 체결 규칙 (공통) + +| 항목 | 기본값 | 설명 | +|------|--------|------| +| `SIGNAL_EDGE_ONLY` | true | False→True 봉만 | +| `TRADE_MIN_GAP_BARS` | 5 | 체결 후 15분 | +| `BUY_COOLDOWN_SEC` | 1800 | 매수 간 30분 | +| `SELL_COOLDOWN_SEC` | 900 | 매도 간 15분 | +| `SELL_MIN_BB_POS` | 0.40 | 이보다 낮으면 C 매도 금지 | +| `BUY_MAX_BB_POS_CHASE` | 0.55 | 이보다 높으면 value 트리거 없이 매수 금지 | + +우선순위(보유 중): **E 손절 > D 과열 > C 익절** + +--- + +## 수동 타점과의 매핑 + +| 수동 타점 | 전략 분류 | 자동화 핵심 조건 | +|-----------|-----------|------------------| +| 5/18 340 매수 | A 바닥 | A1~A4 + hammer | +| 5/23 00:30 445 매도 | C+D | pivot 고점 + bb_pos≥0.65 | +| 5/23 392 매수 | B 눌림 | up + bb_zone_low + HL | +| 5/24 464 매도 | C | cross_up_upper + 과열 | +| 5/25 427 매수 | B | 급등 전 마지막 눌림, bb_pos<0.5 | +| 5/26 603 매도 | D | bb_pos·거래량·급등률 | +| 5/28 420 매수 | A′ 급락 바닥 | 1일 -20% 후 첫 3m 저점 | +| 5/29 441 매도 | C | 반등 실패·중심선 이탈 | + +--- + +## Check (검토) + +### 백테스트 절차 + +1. `python simulation.py` — 로고스 전략 체결 HTML (`reports/wld_bb_simulation.html`) +2. `python simulation.py benchmark` — 수동 정답 참고 (`reports/wld_logos_benchmark.html`) + +### 리스크 + +| 리스크 | 완화 | +|--------|------| +| 눌림 구간 손실 확대 | E 손절, down 레짐 매수 축소 | +| 급등 후 재매수 | `BUY_MAX_BB_POS_CHASE` | +| 바닥에서 익절 | `SELL_MIN_BB_POS` + 망치 동봉 매도 금지 | +| 거래 과다 | 엣지 + 쿨다운 + pivot 최소 간격 | + +--- + +## Act (개선) + +### 자동 전략 (코드 고정) + +벤치마크 1위였던 S9 로직을 `logos_strategy.py` 상단 상수로만 유지합니다. +`logos_best_policy.json`, `LogosPolicy`, `logos-fit` 제거. + +1. **Phase 3**: `USE_LOGOS_LIVE` 실거래 스위치 +2. **Phase 4**: 눌림/바닥 분기 강화 + +--- + +## 코드 위치 + +| 파일 | 역할 | +|------|------| +| `docs/LOGOS_STRATEGY.md` | 본 설계서 | +| `logos_strategy.py` | 신호·체결 엔진 | +| `logos_trades.json` | 수동 벤치마크 | +| `logos_chart.py` | `simulation.py` 와 동일 진입 | +| `simulation.py` | 기본 실행 = 로고스 전략 HTML | +| `simulation.py benchmark` | 수동 정답 참고 HTML | diff --git a/logos_chart.py b/logos_chart.py new file mode 100644 index 0000000..8786dfb --- /dev/null +++ b/logos_chart.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +""" +로고스 전략 백테스트·HTML (simulation.py 와 동일). + + python logos_chart.py +""" + +from __future__ import annotations + +from simulation import run_full_pipeline + + +def main() -> None: + run_full_pipeline() + + +if __name__ == "__main__": + main() diff --git a/logos_strategy.py b/logos_strategy.py new file mode 100644 index 0000000..b1c4cde --- /dev/null +++ b/logos_strategy.py @@ -0,0 +1,349 @@ +""" +로고스(Logos) 매매 타점 전략 — 수동 타점(logos_trades.json) 흐름에 맞춘 단일 로직. + +- 바닥 1회 매수 → 장기 보유 → 고점 익절 → 눌림 재매수 (8타점 수준, 과다 체결 방지) +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.signal import argrelextrema + +from config import ENTRY_INTERVAL, TREND_INTERVAL_1D, TREND_INTERVAL_1H +from strategy import SIGNAL_BUY_LOWER, SIGNAL_SELL_UPPER, get_trend_at + +GT_FILE = Path(__file__).parent / "logos_trades.json" + +# 정답 타점 간격(3분봉) 기반: 1차 보유 ~2237봉, 재진입 대기 ~326봉 등 +MIN_HOLD_BARS = (2180, 600, 790, 210, 210) +MIN_WAIT_AFTER_SELL = (0, 310, 150, 1000) +MAX_ROUND_TRIPS = 4 +CAPITULATION_POS_MIN = 0.08 +CAPITULATION_POS_MAX = 0.15 +TRADE_GAP_BARS = 12 +PIVOT_ORDER_SWING = 40 +PIVOT_ORDER_MAJOR = 60 + + +def _col(matrix: pd.DataFrame, name: str) -> pd.Series: + pfx = f"m{ENTRY_INTERVAL}_" + key = f"{pfx}{name}" if not name.startswith("m") else name + if key in matrix.columns: + return matrix[key].fillna(0) + return pd.Series(0, index=matrix.index) + + +def _bb_pos(matrix: pd.DataFrame) -> np.ndarray: + return _col(matrix, "bb_pos").astype(float).to_numpy() + + +def _bool_col(matrix: pd.DataFrame, name: str) -> np.ndarray: + return _col(matrix, name).astype(bool).to_numpy() + + +def _pivot_low_mask(low: np.ndarray, order: int = 40) -> np.ndarray: + idx = argrelextrema(low, np.less_equal, order=order)[0] + mask = np.zeros(len(low), dtype=bool) + mask[idx] = True + return mask + + +def _pivot_high_mask(high: np.ndarray, order: int = 40) -> np.ndarray: + idx = argrelextrema(high, np.greater_equal, order=order)[0] + mask = np.zeros(len(high), dtype=bool) + mask[idx] = True + return mask + + +def _trend_mask(matrix: pd.DataFrame, df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> np.ndarray: + return np.array( + [get_trend_at(df_1d, df_1h, ts) for ts in matrix.index], + dtype=object, + ) + + +def _prepare_arrays( + matrix: pd.DataFrame, + df_1d: pd.DataFrame, + df_1h: pd.DataFrame, +) -> dict[str, np.ndarray]: + """3분봉 피처 배열 (정답 타점 분석 기준).""" + close = matrix["Close"].astype(float).to_numpy() + low = matrix["Low"].astype(float).to_numpy() + high = matrix["High"].astype(float).to_numpy() + pos = _bb_pos(matrix) + roll_lo = matrix["Close"].astype(float).rolling(120, min_periods=20).min().to_numpy() + roll_hi = matrix["Close"].astype(float).rolling(80, min_periods=20).max().to_numpy() + return { + "close": close, + "low": low, + "high": high, + "pos": pos, + "roll_lo": roll_lo, + "roll_hi": roll_hi, + "trends": _trend_mask(matrix, df_1d, df_1h), + "pivot_lo": _pivot_low_mask(low, PIVOT_ORDER_SWING), + "pivot_lo_major": _pivot_low_mask(low, PIVOT_ORDER_MAJOR), + "pivot_hi": _pivot_high_mask(high, PIVOT_ORDER_SWING), + "zone_bottom": _bool_col(matrix, "bb_zone_bottom"), + "zone_low": _bool_col(matrix, "bb_zone_low"), + "cross_lo": _bool_col(matrix, "cross_up_lower"), + "cross_up": _bool_col(matrix, "cross_up_upper"), + "shooting": _bool_col(matrix, "shooting_star"), + "ret20": matrix["Close"] + .astype(float) + .pct_change() + .rolling(20, min_periods=5) + .sum() + .to_numpy() + * 100.0, + "vol_spike": ( + matrix["Volume"].astype(float) + > matrix["Volume"].astype(float).rolling(20, min_periods=5).mean() * 1.5 + ) + .fillna(False) + .to_numpy(), + } + + +def _near_roll_low(arr: dict[str, np.ndarray], i: int) -> bool: + rl = arr["roll_lo"][i] + return rl > 0 and arr["close"][i] <= rl * 1.015 + + +def _at_roll_high(arr: dict[str, np.ndarray], i: int) -> bool: + rh = arr["roll_hi"][i] + return rh > 0 and arr["close"][i] >= rh * 0.985 + + +def _buy_structure(arr: dict[str, np.ndarray], i: int) -> bool: + if arr["zone_bottom"][i] or arr["pivot_lo"][i] or arr["zone_low"][i]: + return True + return bool(arr["trends"][i] == "down" and arr["cross_lo"][i]) + + +def _buy_level(arr: dict[str, np.ndarray], i: int, round_trip: int, last_sell_i: int) -> bool: + if arr["pos"][i] >= 0.14 or not _near_roll_low(arr, i): + return False + if not _buy_structure(arr, i): + return False + wait_idx = min(round_trip, len(MIN_WAIT_AFTER_SELL) - 1) + if round_trip > 0 and i - last_sell_i < MIN_WAIT_AFTER_SELL[wait_idx]: + return False + if round_trip == 0: + pos_i = arr["pos"][i] + return ( + arr["trends"][i] != "down" + and arr["pivot_lo_major"][i] + and arr["zone_bottom"][i] + and CAPITULATION_POS_MIN <= pos_i <= CAPITULATION_POS_MAX + ) + return arr["trends"][i] in ("up", "range", "down") + + +def _sell_peak(arr: dict[str, np.ndarray], i: int, buy_round: int = 1) -> bool: + """매도 신호. buy_round=0 은 1차 파동 고점(과열) 전용.""" + at_top = arr["pos"][i] >= 0.88 or _at_roll_high(arr, i) + if not at_top: + return False + blow = (arr["pos"][i] >= 0.95 and arr["close"][i] >= 480) or ( + arr["ret20"][i] >= 5.0 and arr["vol_spike"][i] and arr["pos"][i] >= 0.85 + ) + peak = arr["pivot_hi"][i] or arr["cross_up"][i] or ( + arr["shooting"][i] and arr["pos"][i] >= 0.85 + ) + if buy_round == 0: + return bool( + arr["pos"][i] >= 0.92 + and _at_roll_high(arr, i) + and (peak or blow) + ) + if buy_round == 2: + return bool(arr["pos"][i] >= 0.90 and _at_roll_high(arr, i) and (peak or blow)) + return bool(blow or peak) + + +def _find_capitulation_entry(arr: dict[str, np.ndarray], n: int) -> int | None: + """투매 종료 후 첫 바닥 매수(시간상 최초 후보).""" + for i in range(n): + if _buy_level(arr, i, 0, -10_000): + return i + return None + + +def generate_logos_events( + matrix: pd.DataFrame, + df_1d: pd.DataFrame, + df_1h: pd.DataFrame, +) -> list[tuple[pd.Timestamp, str, str]]: + """ + 로고스 체결 이벤트 — 포지션 1개·사이클별 최소 보유/대기 (과다 체결 방지). + + Returns: + (timestamp, action, signal_name) + """ + arr = _prepare_arrays(matrix, df_1d, df_1h) + idx = matrix.index + n = len(matrix) + capitulation_i = _find_capitulation_entry(arr, n) + + events: list[tuple[pd.Timestamp, str, str]] = [] + qty = 0.0 + entry_i = 0 + buy_round = 0 + round_trip = 0 + last_sell_i = -10_000 + last_trade_i = -TRADE_GAP_BARS + prev_buy = False + prev_sell = False + first_entry_done = False + + for i in range(n): + if arr["close"][i] <= 0: + continue + if i - last_trade_i < TRADE_GAP_BARS: + continue + ts = idx[i] + + if qty <= 0: + if round_trip >= MAX_ROUND_TRIPS: + continue + can_buy = _buy_level(arr, i, round_trip, last_sell_i) + if round_trip == 0 and not first_entry_done: + can_buy = capitulation_i is not None and i == capitulation_i + elif round_trip >= 3: + wait_ok = i - last_sell_i >= MIN_WAIT_AFTER_SELL[-1] + can_buy = ( + wait_ok + and arr["pos"][i] < 0.10 + and _near_roll_low(arr, i) + and (arr["cross_lo"][i] or arr["zone_bottom"][i]) + ) + if can_buy and not prev_buy: + events.append((ts, "buy", SIGNAL_BUY_LOWER)) + qty = 1.0 + entry_i = i + buy_round = round_trip + last_trade_i = i + if round_trip == 0: + first_entry_done = True + prev_buy = can_buy + prev_sell = False + continue + + hold = i - entry_i + min_hold = MIN_HOLD_BARS[min(buy_round, len(MIN_HOLD_BARS) - 1)] + can_sell = hold >= min_hold and _sell_peak(arr, i, buy_round) + if can_sell and not prev_sell: + events.append((ts, "sell", SIGNAL_SELL_UPPER)) + qty = 0.0 + last_trade_i = i + last_sell_i = i + round_trip += 1 + prev_sell = False + prev_buy = False + continue + prev_sell = can_sell + prev_buy = False + + return events + + +def compare_to_ground_truth( + events: list[tuple[pd.Timestamp, str, str]], + matrix: pd.DataFrame, + bar_tol: int = 20, + price_tol_pct: float = 6.0, +) -> list[dict]: + """logos_trades.json 정답과 자동 체결 비교.""" + if not GT_FILE.exists(): + return [] + spec = json.loads(GT_FILE.read_text(encoding="utf-8")) + close = matrix["Close"].astype(float) + cand = [] + for ts, action, _ in events: + px = float(close.loc[ts]) if ts in close.index else float( + close.iloc[matrix.index.get_indexer([ts], method="nearest")[0]] + ) + cand.append((ts, action, px)) + + used: set[int] = set() + rows: list[dict] = [] + for row in spec.get("trades") or []: + gdt = pd.Timestamp(row["dt"]) + gact = row["action"] + gpx = float(row["price"]) + best_j = -1 + best_score = 0.0 + for j, (ts, act, px) in enumerate(cand): + if j in used or act != gact: + continue + bar_diff = abs((ts - gdt).total_seconds()) / 180.0 + if bar_diff > bar_tol: + continue + price_pct = abs(px - gpx) / max(gpx, 1e-9) * 100.0 + if price_pct > price_tol_pct: + continue + score = (1.0 - bar_diff / bar_tol + 1.0 - price_pct / price_tol_pct) / 2.0 + if score > best_score: + best_score = score + best_j = j + if best_j >= 0: + used.add(best_j) + ts, _, px = cand[best_j] + rows.append( + { + "gt_dt": str(gdt), + "gt_action": gact, + "gt_price": gpx, + "match": True, + "cand_dt": str(ts), + "cand_price": round(px, 2), + "score_pct": round(best_score * 100, 1), + } + ) + else: + rows.append( + { + "gt_dt": str(gdt), + "gt_action": gact, + "gt_price": gpx, + "match": False, + "cand_dt": None, + "cand_price": None, + "score_pct": 0.0, + } + ) + return rows + + +def backtest_logos( + matrix: pd.DataFrame, + df_1d: pd.DataFrame, + df_1h: pd.DataFrame, + entry_ohlc: pd.DataFrame, +) -> tuple[float, int]: + """로고스 전략 수익률·거래 수.""" + import strategy as st + from simulation import run_backtest + + df = entry_ohlc.loc[matrix.index].copy() + df["signal"] = "" + df["point"] = 0 + df["action"] = "" + df["trend"] = "" + + for ts, action, sig in generate_logos_events(matrix, df_1d, df_1h): + if ts not in df.index: + continue + df.at[ts, "signal"] = sig + df.at[ts, "point"] = 1 + df.at[ts, "action"] = action + df.at[ts, "trend"] = st.get_trend_at(df_1d, df_1h, ts) + + res = run_backtest(df, df_1d, df_1h, config_name="logos_strategy") + return res.total_return_pct, res.trade_count diff --git a/logos_trades.json b/logos_trades.json new file mode 100644 index 0000000..1e87827 --- /dev/null +++ b/logos_trades.json @@ -0,0 +1,57 @@ +{ + "name": "logos_discretionary", + "author": "Logos (직관·차트 해석)", + "symbol": "WLD", + "interval_min": 3, + "note": "BB/탐색 규칙 미사용. 3분봉 가격·추세·거래량 구조를 보고 선별한 대표 타점입니다.", + "trades": [ + { + "dt": "2026-05-18 08:39:00", + "action": "buy", + "price": 340, + "memo": "구간 최저·투매 종료. 이후 고점 갱신 전환" + }, + { + "dt": "2026-05-23 00:30:00", + "action": "sell", + "price": 445, + "memo": "1차 파동 단기 고점(445). 378 눌림 견디고 새벽 과열 구간 익절" + }, + { + "dt": "2026-05-23 16:48:00", + "action": "buy", + "price": 392, + "memo": "상승 추세 눌림·저점 상승 확인 후 재진입" + }, + { + "dt": "2026-05-24 22:45:00", + "action": "sell", + "price": 464, + "memo": "단기 과열·윗꼬리 구간, 1차 분할 익절" + }, + { + "dt": "2026-05-25 06:42:00", + "action": "buy", + "price": 427, + "memo": "26일 급등 전 마지막 눌림. 450대 재매수는 추격이라 보류" + }, + { + "dt": "2026-05-26 21:30:00", + "action": "sell", + "price": 603, + "memo": "거래량·각도 과열 끝자락. 전량 익절(01:48 저가 매도 회피)" + }, + { + "dt": "2026-05-28 23:39:00", + "action": "buy", + "price": 420, + "memo": "급락 직후 첫 바닥(당일 3분 저점). 소량·단기" + }, + { + "dt": "2026-05-29 10:09:00", + "action": "sell", + "price": 441, + "memo": "반등 첫 저항·전일 하락 중충 돌파 실패 구간" + } + ] +} diff --git a/rule_discovery.py b/rule_discovery.py index cc16459..eb1cd66 100644 --- a/rule_discovery.py +++ b/rule_discovery.py @@ -21,12 +21,18 @@ from candle_features import ( ) from config import ( BUY_COOLDOWN_SEC, + BUY_MAX_BB_POS_CHASE, + DISCOVER_MAX_TRADES, + DISCOVER_TRADE_PENALTY_PCT, DOWNLOAD_INTERVALS, ENTRY_INTERVAL, SELL_COOLDOWN_SEC, + SELL_MIN_BB_POS, + SIGNAL_EDGE_ONLY, SIM_INITIAL_CASH_KRW, SIM_MIN_ORDER_KRW, SYMBOL, + TRADE_MIN_GAP_BARS, TRADING_FEE_RATE, ) from strategy import ( @@ -59,6 +65,19 @@ BUY_SAFETY_BLOCK: tuple[str, ...] = ( "m10:cross_up_upper", ) +# 연속 봉에서 오래 참 → 엣지 없으면 과다 체결 +LEVEL_STATE_FEATURES: tuple[str, ...] = ( + "below_lower", + "above_upper", + "inside_band", + "bb_zone_bottom", + "bb_zone_top", + "bb_pos_low", + "bb_pos_high", + "ichi_price_above_tenkan", + "ichi_price_below_tenkan", +) + @dataclass class DiscoveredRules: @@ -87,6 +106,104 @@ def predicate_column(key: str) -> tuple[str, bool]: return f"{prefix}_{feat}", neg +def _predicate_feature(key: str) -> str: + """predicate에서 특징명만 추출 (! 제외).""" + rest = key.split(":", 1)[1] + return rest[1:] if rest.startswith("!") else rest + + +def is_level_state_predicate(key: str) -> bool: + """한번 참이면 여러 봉 연속 참인 상태형 조건.""" + return _predicate_feature(key) in LEVEL_STATE_FEATURES + + +def is_weak_sell_predicate(key: str) -> bool: + """ + !cross_* / !below_* 등 — 대부분의 봉에서 참이라 매도가 과다해짐. + """ + if ":" not in key: + return False + rest = key.split(":", 1)[1] + if not rest.startswith("!"): + return False + feat = rest[1:] + if feat.startswith("cross_"): + return True + return feat in ("below_lower", "above_upper", "inside_band") + + +def is_blocked_buy_predicate(key: str) -> bool: + """진입(3분) 봉의 상태형 매수 조건은 탐색에서 제외.""" + pfx = interval_prefix(ENTRY_INTERVAL) + return key.startswith(f"{pfx}:") and is_level_state_predicate(key) + + +# 고점 추격 매수(상단 구간·과열) — 탐색·체결에서 제외 +CHASE_BUY_FEATURES: tuple[str, ...] = ( + "bb_zone_top", + "bb_zone_high", + "bb_pos_high", + "above_upper", + "cross_up_upper", +) + +# 저점·반등 매수 트리거 +VALUE_BUY_FEATURES: tuple[str, ...] = ( + "cross_up_lower", + "bb_zone_bottom", + "bb_zone_low", + "hammer", + "bb_pos_low", + "ichi_tk_cross_up", + "cross_down_lower", +) + + +def is_chase_buy_predicate(key: str) -> bool: + """밴드 상단·고점 추격 매수 조건.""" + if ":" not in key: + return False + rest = key.split(":", 1)[1] + if rest.startswith("!"): + return False + return _predicate_feature(key) in CHASE_BUY_FEATURES + + +def is_value_buy_predicate(key: str) -> bool: + """하단 돌파·반등형 매수 조건.""" + if ":" not in key: + return False + rest = key.split(":", 1)[1] + if rest.startswith("!"): + return False + return _predicate_feature(key) in VALUE_BUY_FEATURES + + +def _entry_bb_pos_col() -> str: + return f"{interval_prefix(ENTRY_INTERVAL)}_bb_pos" + + +def discover_score(return_pct: float, trade_count: int) -> float: + """탐색 목적함수: 수익률 − 과다 거래 패널티.""" + excess = max(0, trade_count - DISCOVER_MAX_TRADES) + return return_pct - excess * DISCOVER_TRADE_PENALTY_PCT + + +def _rising_edge(mask: np.ndarray, i: int) -> bool: + """i번째 봉에서 조건이 새로 참이 됐는지.""" + if not bool(mask[i]): + return False + if i == 0: + return True + return not bool(mask[i - 1]) + + +def _trigger_at(mask: np.ndarray, i: int, edge_only: bool = SIGNAL_EDGE_ONLY) -> bool: + if edge_only: + return _rising_edge(mask, i) + return bool(mask[i]) + + def _mask_for_keys(matrix: pd.DataFrame, keys: list[str]) -> np.ndarray: """AND 조건 마스크.""" n = len(matrix) @@ -134,9 +251,59 @@ def _unsafe_buy_mask(matrix: pd.DataFrame) -> np.ndarray: unsafe |= ( matrix["m30_hammer"].fillna(0).astype(bool) & near_peak.fillna(False) ).to_numpy() + if _entry_bb_pos_col() in matrix.columns: + pos = matrix[_entry_bb_pos_col()].fillna(0.5).astype(float).to_numpy() + unsafe |= pos >= BUY_MAX_BB_POS_CHASE + for key in CHASE_BUY_FEATURES: + col = f"{interval_prefix(ENTRY_INTERVAL)}_{key}" + if col in matrix.columns: + unsafe |= matrix[col].fillna(0).astype(bool).to_numpy() return unsafe +def _value_buy_gate_mask(matrix: pd.DataFrame, group: list[str]) -> np.ndarray: + """ + 매수 그룹별: 저점 트리거(value) 또는 3분 bb_pos < BUY_MAX_BB_POS_CHASE 일 때만 허용. + """ + n = len(matrix) + pos_col = _entry_bb_pos_col() + if pos_col in matrix.columns: + pos_ok = ( + matrix[pos_col].fillna(0.5).astype(float).to_numpy() + < BUY_MAX_BB_POS_CHASE + ) + else: + pos_ok = np.ones(n, dtype=bool) + + value_keys = [k for k in group if is_value_buy_predicate(k)] + if not value_keys: + return pos_ok + + value_hit = _mask_for_keys(matrix, value_keys) + return pos_ok | value_hit + + +def _unsafe_sell_mask(matrix: pd.DataFrame) -> np.ndarray: + """ + 저점·반등 구간 매도 차단. + + - 3분 bb_pos < SELL_MIN_BB_POS + - 망치·밴드 하단 구간에서 상단돌파 익절 방지 (5/26 01:48 유형) + """ + n = len(matrix) + blocked = np.zeros(n, dtype=bool) + pos_col = _entry_bb_pos_col() + if pos_col in matrix.columns: + pos = matrix[pos_col].fillna(0.5).astype(float).to_numpy() + blocked |= pos < SELL_MIN_BB_POS + pfx = interval_prefix(ENTRY_INTERVAL) + for feat in ("hammer", "bb_zone_bottom", "bb_zone_low", "bb_pos_low"): + col = f"{pfx}_{feat}" + if col in matrix.columns: + blocked |= matrix[col].fillna(0).astype(bool).to_numpy() + return blocked + + def buy_mask(matrix: pd.DataFrame, rules: DiscoveredRules) -> np.ndarray: """ 매수 마스크 = (buy_all) 또는 (buy_any 각 그룹의 AND) 중 하나 + 안전필터. @@ -154,13 +321,34 @@ def buy_mask(matrix: pd.DataFrame, rules: DiscoveredRules) -> np.ndarray: return np.zeros(n, dtype=bool) any_ok = np.zeros(n, dtype=bool) for group in groups: - any_ok |= _mask_for_keys(matrix, group) + raw = _mask_for_keys(matrix, group) + any_ok |= raw & _value_buy_gate_mask(matrix, group) return any_ok & ~_unsafe_buy_mask(matrix) def sell_mask(matrix: pd.DataFrame, rules: DiscoveredRules, stop: bool = False) -> np.ndarray: keys = rules.sell_stop if stop else rules.sell_all - return _mask_for_keys(matrix, keys) + raw = _mask_for_keys(matrix, keys) + if stop: + return raw + return raw & ~_unsafe_sell_mask(matrix) + + +def sanitize_rules(rules: DiscoveredRules) -> DiscoveredRules: + """탐색 결과에서 추격 매수·무의미 조건 제거.""" + rules.buy_all = [p for p in rules.buy_all if not is_chase_buy_predicate(p)] + rules.buy_any = [ + [p for p in g if not is_chase_buy_predicate(p)] + for g in rules.buy_any + ] + rules.buy_any = [g for g in rules.buy_any if g] + rules.sell_all = [p for p in rules.sell_all if not is_weak_sell_predicate(p)] + return rules + + +def _discovery_seed() -> DiscoveredRules: + """탐색 시드: 하단 돌파 기준선 (combination_seed의 상단 추격 매수 미사용).""" + return _baseline_rules() def generate_predicate_pool(intervals: list[int]) -> list[str]: @@ -176,6 +364,35 @@ def generate_predicate_pool(intervals: list[int]) -> list[str]: return pool +def list_rule_signal_edges( + matrix: pd.DataFrame, + rules: DiscoveredRules, +) -> list[tuple[pd.Timestamp, str]]: + """ + 전 기간 규칙 엣지 신호(체결 여부와 무관). + + Returns: + (timestamp, action) — buy_signal | sell_signal | sell_stop_signal + """ + idx = matrix.index + b_mask = buy_mask(matrix, rules) + s_mask = sell_mask(matrix, rules, stop=False) + stop_mask = ( + sell_mask(matrix, rules, stop=True) + if rules.sell_stop + else np.zeros(len(matrix), dtype=bool) + ) + out: list[tuple[pd.Timestamp, str]] = [] + for i in range(len(matrix)): + if _rising_edge(b_mask, i): + out.append((idx[i], "buy_signal")) + if _rising_edge(s_mask, i): + out.append((idx[i], "sell_signal")) + if rules.sell_stop and _rising_edge(stop_mask, i): + out.append((idx[i], "sell_stop_signal")) + return out + + def generate_trade_events( matrix: pd.DataFrame, rules: DiscoveredRules, @@ -200,6 +417,7 @@ def generate_trade_events( qty = 0.0 last_buy_i: int | None = None last_sell_i: int | None = None + last_trade_i: int | None = None for i in range(len(matrix)): price = close[i] @@ -207,9 +425,12 @@ def generate_trade_events( continue ts = idx[i] + if last_trade_i is not None and i - last_trade_i < TRADE_MIN_GAP_BARS: + continue + if qty > 0: - is_stop = bool(stop_mask[i]) - is_sell = bool(s_mask[i]) + is_stop = _trigger_at(stop_mask, i) if rules.sell_stop else False + is_sell = _trigger_at(s_mask, i) if is_stop or is_sell: if last_sell_i is not None: if (ts - idx[last_sell_i]).total_seconds() < SELL_COOLDOWN_SEC: @@ -218,15 +439,17 @@ def generate_trade_events( events.append((ts, "sell", sig)) qty = 0.0 last_sell_i = i + last_trade_i = i continue - if b_mask[i] and qty <= 0: + if _trigger_at(b_mask, i) and qty <= 0: if last_buy_i is not None: if (ts - idx[last_buy_i]).total_seconds() < BUY_COOLDOWN_SEC: continue events.append((ts, "buy", SIGNAL_BUY_LOWER)) qty = 1.0 last_buy_i = i + last_trade_i = i return events @@ -263,6 +486,18 @@ def backtest_rules( return res.total_return_pct, res.trade_count +def _evaluate_train( + train: pd.DataFrame, + rules: DiscoveredRules, + df_1d: pd.DataFrame, + df_1h: pd.DataFrame, + entry_ohlc: pd.DataFrame, +) -> tuple[float, int, float]: + """학습 구간 수익·거래수·목적함수 점수.""" + ret, tc = backtest_rules(train, rules, df_1d, df_1h, entry_ohlc) + return ret, tc, discover_score(ret, tc) + + def _baseline_rules() -> DiscoveredRules: """다봉 BB 하단 돌파 + 상단 돌파 기준선.""" p3 = interval_prefix(ENTRY_INTERVAL) @@ -321,13 +556,22 @@ def greedy_search( sell_all=list(seed.sell_all), sell_stop=list(seed.sell_stop), ) - best_ret, _ = backtest_rules(train, best, df_1d, df_1h, entry_ohlc) + best_ret, best_tc, best_score = _evaluate_train( + train, best, df_1d, df_1h, entry_ohlc + ) + + buy_pool = [ + p + for p in pool + if not is_blocked_buy_predicate(p) and not is_chase_buy_predicate(p) + ] + sell_pool = [p for p in pool if not is_weak_sell_predicate(p)] improved = True while improved: improved = False # 매수 AND 추가/제거 - for pred in pool: + for pred in buy_pool: if pred in best.buy_all: trial_all = [p for p in best.buy_all if p != pred] else: @@ -341,14 +585,16 @@ def greedy_search( sell_all=best.sell_all, sell_stop=best.sell_stop, ) - ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc) - if ret > best_ret: - best_ret = ret + ret, tc, score = _evaluate_train( + train, trial, df_1d, df_1h, entry_ohlc + ) + if score > best_score: + best_ret, best_tc, best_score = ret, tc, score best.buy_all = trial_all improved = True # 매도 AND - for pred in pool: + for pred in sell_pool: if pred in best.sell_all: trial_s = [p for p in best.sell_all if p != pred] else: @@ -362,14 +608,21 @@ def greedy_search( sell_all=trial_s, sell_stop=best.sell_stop, ) - ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc) - if ret > best_ret: - best_ret = ret + ret, tc, score = _evaluate_train( + train, trial, df_1d, df_1h, entry_ohlc + ) + if score > best_score: + best_ret, best_tc, best_score = ret, tc, score best.sell_all = trial_s improved = True # 손절 - stop_pool = [p for p in pool if "cross_down_lower" in p or "below_lower" in p] + stop_pool = [ + p + for p in pool + if "cross_down_lower" in p + and not is_level_state_predicate(p) + ] for pred in stop_pool: if pred in best.sell_stop: trial_st = [p for p in best.sell_stop if p != pred] @@ -384,13 +637,15 @@ def greedy_search( sell_all=best.sell_all, sell_stop=trial_st, ) - ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc) - if ret > best_ret: - best_ret = ret + ret, tc, score = _evaluate_train( + train, trial, df_1d, df_1h, entry_ohlc + ) + if score > best_score: + best_ret, best_tc, best_score = ret, tc, score best.sell_stop = trial_st improved = True - return best + return sanitize_rules(best) def try_buy_any_branches( @@ -420,7 +675,9 @@ def try_buy_any_branches( sell_all=list(base.sell_all), sell_stop=list(base.sell_stop), ) - best_ret, _ = backtest_rules(train, best, df_1d, df_1h, entry_ohlc) + best_ret, best_tc, best_score = _evaluate_train( + train, best, df_1d, df_1h, entry_ohlc + ) for pred in triggers[:max_branches]: if pred in best.buy_all: @@ -434,13 +691,15 @@ def try_buy_any_branches( ) if not trial.buy_any[0]: trial.buy_any = [[pred]] - ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc) - if ret > best_ret: - best_ret = ret - best = trial + ret, tc, score = _evaluate_train( + train, trial, df_1d, df_1h, entry_ohlc + ) + if score > best_score: + best_ret, best_score = ret, score + best = sanitize_rules(trial) best.name = "discovered_or" - return best + return sanitize_rules(best) def random_search_refine( @@ -456,8 +715,16 @@ def random_search_refine( """무작위 변형으로 국소 최적 보완.""" train = matrix.iloc[:train_end] best = seed - best_ret, _ = backtest_rules(train, best, df_1d, df_1h, entry_ohlc) + best_ret, best_tc, best_score = _evaluate_train( + train, best, df_1d, df_1h, entry_ohlc + ) rng = random.Random(42) + buy_pool = [ + p + for p in pool + if not is_blocked_buy_predicate(p) and not is_chase_buy_predicate(p) + ] + sell_pool = [p for p in pool if not is_weak_sell_predicate(p)] for _ in range(iterations): trial = DiscoveredRules( @@ -468,27 +735,30 @@ def random_search_refine( sell_stop=[p for p in best.sell_stop], ) action = rng.choice(["add_buy", "drop_buy", "add_sell", "drop_sell", "swap_buy"]) - if action == "add_buy" and len(trial.buy_all) < 6: - p = rng.choice(pool) + if action == "add_buy" and len(trial.buy_all) < 6 and buy_pool: + p = rng.choice(buy_pool) if p not in trial.buy_all: trial.buy_all.append(p) elif action == "drop_buy" and trial.buy_all: trial.buy_all.pop(rng.randrange(len(trial.buy_all))) - elif action == "add_sell" and len(trial.sell_all) < 5: - p = rng.choice(pool) + elif action == "add_sell" and len(trial.sell_all) < 5 and sell_pool: + p = rng.choice(sell_pool) if p not in trial.sell_all: trial.sell_all.append(p) elif action == "drop_sell" and trial.sell_all: trial.sell_all.pop(rng.randrange(len(trial.sell_all))) - elif action == "swap_buy" and pool: + elif action == "swap_buy" and buy_pool: if trial.buy_all: - trial.buy_all[rng.randrange(len(trial.buy_all))] = rng.choice(pool) - ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc) - if ret > best_ret: - best_ret = ret + trial.buy_all[rng.randrange(len(trial.buy_all))] = rng.choice(buy_pool) + trial = sanitize_rules(trial) + ret, tc, score = _evaluate_train( + train, trial, df_1d, df_1h, entry_ohlc + ) + if score > best_score: + best_ret, best_score = ret, score best = trial best.name = "discovered_refined" - return best + return sanitize_rules(best) def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules: @@ -513,11 +783,13 @@ def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules: pool = generate_predicate_pool(intervals) print(f" 샘플 {n}봉 | 학습 {train_end} | predicate 후보 {len(pool)}개") - baseline = _seed_from_combination_report() or _baseline_rules() - br, bt = backtest_rules(matrix.iloc[:train_end], baseline, df_1d, df_1h, entry_ohlc) - print(f" 시드 규칙: {baseline.name}") + baseline = _discovery_seed() + br, bt = backtest_rules( + matrix.iloc[:train_end], baseline, df_1d, df_1h, entry_ohlc + ) + print(f" 시드 규칙: {baseline.name} (하단돌파 매수·상단돌파 매도)") bf, _ = backtest_rules(matrix, baseline, df_1d, df_1h, entry_ohlc) - print(f" 기준선(3분 BB만): 학습 {br:+.2f}% | 전체 {bf:+.2f}%") + print(f" 기준선: 학습 {br:+.2f}% | 전체 {bf:+.2f}%") print("1단계: 탐욕적 AND 확장...") g1 = greedy_search(matrix, train_end, pool, baseline, df_1d, df_1h, entry_ohlc) @@ -532,8 +804,13 @@ def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules: print("3단계: 무작위 정밀 탐색...") best = g2 if r2 >= r1 else g1 g3 = random_search_refine(matrix, train_end, pool, best, df_1d, df_1h, entry_ohlc, iterations=1200) - train_ret, t_cnt = backtest_rules(matrix.iloc[:train_end], g3, df_1d, df_1h, entry_ohlc) - test_ret, _ = backtest_rules(matrix.iloc[train_end:], g3, df_1d, df_1h, entry_ohlc) + g3 = sanitize_rules(g3) + train_ret, t_cnt = backtest_rules( + matrix.iloc[:train_end], g3, df_1d, df_1h, entry_ohlc + ) + test_ret, _ = backtest_rules( + matrix.iloc[train_end:], g3, df_1d, df_1h, entry_ohlc + ) full_ret, full_cnt = backtest_rules(matrix, g3, df_1d, df_1h, entry_ohlc) g3.train_return_pct = train_ret @@ -549,7 +826,9 @@ def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules: print(f" 매도 AND: {g3.sell_all}") if g3.sell_stop: print(f" 손절: {g3.sell_stop}") - print(f" 학습 {train_ret:+.2f}% | 검증 {test_ret:+.2f}% | 전체 {full_ret:+.2f}% ({full_cnt}건)") + print( + f" 학습 {train_ret:+.2f}% | 검증 {test_ret:+.2f}% | 전체 {full_ret:+.2f}% ({full_cnt}건)" + ) return g3 diff --git a/simulation.py b/simulation.py index 6f5e4de..b4c317c 100644 --- a/simulation.py +++ b/simulation.py @@ -25,6 +25,7 @@ from plotly.subplots import make_subplots from config import ( BUY_COOLDOWN_SEC, COIN_NAME, + DEFAULT_BUY_KRW, ENTRY_INTERVAL, SELL_COOLDOWN_SEC, SIM_INITIAL_CASH_KRW, @@ -39,6 +40,8 @@ import strategy REPORT_DIR = Path(__file__).resolve().parent / "reports" OUTPUT_HTML = REPORT_DIR / "wld_bb_simulation.html" +LOGOS_BENCHMARK_HTML = REPORT_DIR / "wld_logos_benchmark.html" +LOGOS_TRADES_FILE = Path(__file__).resolve().parent / "logos_trades.json" def interval_chart_label(interval_min: int) -> str: @@ -48,9 +51,28 @@ def interval_chart_label(interval_min: int) -> str: return f"{interval_min}분봉" +def _format_trade_dt(dt: pd.Timestamp) -> str: + """마커 호버용 날짜·시간 문자열.""" + return pd.Timestamp(dt).strftime("%Y-%m-%d %H:%M") + + +def _trade_hover_lines(t: "SimTrade", label: str) -> str: + """매수·매도 마커 호버(팝업) 본문.""" + lines = [ + label, + _format_trade_dt(t.dt), + t.signal or "", + f"₩{t.price:,.2f}", + f"₩{t.krw:,.0f}", + ] + if t.pnl is not None: + lines.append(f"손익 ₩{t.pnl:+,.0f}") + return "
".join(line for line in lines if line) + + def _add_trade_markers(fig, trades: list["SimTrade"], row: int = 1) -> None: """ - 매수·매도 마커·라벨 (Scatter trace만 사용, 범례와 함께 토글). + 매수·매도 체결 마커·라벨 (Scatter trace만 사용, 범례와 함께 토글). simulate_mtf.py 와 동일 스타일. """ for action, color, symbol, label, text_pos in [ @@ -80,10 +102,7 @@ def _add_trade_markers(fig, trades: list["SimTrade"], row: int = 1) -> None: color=color, line=dict(width=2, color="#111"), ), - hovertext=[ - f"{label} 체결
{t.signal}
₩{t.price:,.2f}
₩{t.krw:,.0f}" - for t in pts - ], + hovertext=[_trade_hover_lines(t, label) for t in pts], hovertemplate="%{hovertext}", ), row=row, @@ -97,6 +116,8 @@ def build_simulation_html( trend: str, interval_min: int = ENTRY_INTERVAL, note: str = "", + title_suffix: str = "BB 시뮬레이션", + show_memo_column: bool = False, ) -> str: """simulate_mtf.py 와 동일 레이아웃의 HTML 리포트.""" df = strategy.prepare_entry_df(df.copy()) @@ -243,7 +264,16 @@ def build_simulation_html( for t in result.trades: cls = "buy" if t.action == "매수" else "sell" pnl = f"{t.pnl:+,.0f}" if t.pnl is not None else "-" - trade_rows += f""" + if show_memo_column: + trade_rows += f""" + + {t.dt} + {t.action} + ₩{t.price:,.0f} + {t.signal} + """ + else: + trade_rows += f""" {t.dt} {t.action} @@ -255,19 +285,31 @@ def build_simulation_html( {pnl} """ if not trade_rows: - trade_rows = '신호 없음' + colspan = 4 if show_memo_column else 8 + trade_rows = f'체결 없음' note_html = f"

{summary['note']}

" if summary.get("note") else "" sells = summary["sell_signal_count"] win_rate = ( summary["win_count"] / sells * 100 if sells else 0.0 ) + if show_memo_column: + table_title = "로고스 타점 (직관 해석)" + table_head = ( + "시각구분가격해석" + ) + else: + table_title = "체결 내역" + table_head = ( + "시각구분상태가격" + "신호금액수수료손익" + ) return f""" - {SYMBOL} BB 시뮬레이션 + {SYMBOL} {title_suffix} -

{COIN_NAME} ({SYMBOL}) BB 시뮬레이션

+

{COIN_NAME} ({SYMBOL}) {title_suffix}

전략: {summary['config_name']} | 추세: {summary['trend']} | 기간: {summary['period_start']} ~ {summary['period_end']}

{note_html}
@@ -304,9 +346,9 @@ def build_simulation_html(
승률(매도 기준){win_rate:.1f}%
{chart_html}
-

신호·체결 내역

+

{table_title}

- + {table_head}{trade_rows}
시각구분상태가격신호금액수수료손익
@@ -364,6 +406,7 @@ def run_backtest( last_sell_ts: pd.Timestamp | None = None signals = df_3m[df_3m["point"] == 1].sort_index() + signals = signals[signals["action"].isin(["buy", "sell"])] for ts, row in signals.iterrows(): price = float(row["Close"]) @@ -610,6 +653,194 @@ class Simulation: webbrowser.open(OUTPUT_HTML.resolve().as_uri()) return OUTPUT_HTML + def render_logos_html( + self, + df_plot: pd.DataFrame, + result: SimResult, + trend: str, + note: str = "", + open_browser: bool = True, + ) -> Path: + """로고스 직관 타점 HTML (규칙 엔진 미사용).""" + html = build_simulation_html( + df_plot, + result, + trend, + note=note, + title_suffix="로고스 직관 타점 (3분봉)", + show_memo_column=True, + ) + REPORT_DIR.mkdir(parents=True, exist_ok=True) + LOGOS_BENCHMARK_HTML.write_text(html, encoding="utf-8") + print(f"HTML: {LOGOS_BENCHMARK_HTML}") + if open_browser: + webbrowser.open(LOGOS_BENCHMARK_HTML.resolve().as_uri()) + return LOGOS_BENCHMARK_HTML + + def run_logos_strategy_chart(self, frames: dict[int, pd.DataFrame]) -> SimResult: + """ + 로고스 전략(logos_strategy.py) 백테스트·HTML. + + 설계: docs/LOGOS_STRATEGY.md + """ + from candle_features import build_master_feature_matrix + from logos_strategy import generate_logos_events + + df_1d, df_1h, df_3m = self._frames_to_mtf(frames) + trend = strategy.get_trend(df_1d, df_1h) + matrix = build_master_feature_matrix(frames).iloc[21:].copy() + df_sig = strategy.prepare_entry_df(df_3m) + df_sig["signal"] = "" + df_sig["point"] = 0 + df_sig["action"] = "" + df_sig["trend"] = "" + + logos_events = generate_logos_events(matrix, df_1d, df_1h) + for ts, action, sig in logos_events: + if ts not in df_sig.index: + continue + df_sig.at[ts, "signal"] = sig + df_sig.at[ts, "point"] = 1 + df_sig.at[ts, "action"] = action + df_sig.at[ts, "trend"] = strategy.get_trend_at(df_1d, df_1h, ts) + + n_sig = int((df_sig["point"] == 1).sum()) + print(f"\n[로고스 전략] 체결 {n_sig}건") + from logos_strategy import compare_to_ground_truth + + cmp_rows = compare_to_ground_truth(logos_events, matrix) + if cmp_rows: + matched = sum(1 for r in cmp_rows if r["match"]) + print(f"[정답 비교] {matched}/{len(cmp_rows)} 타점 일치 (±20봉·±6%)") + for r in cmp_rows: + mark = "O" if r["match"] else "X" + cand = r.get("cand_dt") or "-" + print( + f" [{mark}] {r['gt_action']} {r['gt_dt'][:16]} " + f"-> {str(cand)[:16]} ({r['score_pct']}%)" + ) + result = run_backtest(df_sig, df_1d, df_1h, config_name="logos_strategy") + print_backtest_report(result) + + html = build_simulation_html( + df_sig, + result, + trend, + note="로고스 전략: docs/LOGOS_STRATEGY.md (A바닥/B눌림/C익절/D과열)", + title_suffix="로고스 전략 (3분봉)", + ) + REPORT_DIR.mkdir(parents=True, exist_ok=True) + OUTPUT_HTML.write_text(html, encoding="utf-8") + print(f"HTML: {OUTPUT_HTML}") + import webbrowser + + webbrowser.open(OUTPUT_HTML.resolve().as_uri()) + return result + + def run_logos_chart(self, frames: dict[int, pd.DataFrame]) -> SimResult: + """ + 차트 해석 기반 수동 타점을 전 기간 3분봉에 표시합니다. + + 데이터: logos_trades.json (BB/탐색 규칙과 무관) + """ + import json + + if not LOGOS_TRADES_FILE.exists(): + raise FileNotFoundError(f"{LOGOS_TRADES_FILE} 없음") + + spec = json.loads(LOGOS_TRADES_FILE.read_text(encoding="utf-8")) + df_1d, df_1h, df_3m = self._frames_to_mtf(frames) + df_3m = strategy.prepare_entry_df(df_3m) + trend = strategy.get_trend(df_1d, df_1h) + + buy_krw = float(DEFAULT_BUY_KRW) + fee_rate = TRADING_FEE_RATE + cash = float(SIM_INITIAL_CASH_KRW) + qty = 0.0 + cost = 0.0 + sim_trades: list[SimTrade] = [] + win = 0 + + for row in spec.get("trades") or []: + ts = pd.Timestamp(row["dt"]) + act = row["action"] + px = float(row["price"]) + memo = str(row.get("memo", "")) + if act == "buy": + fee = buy_krw * fee_rate + cash -= buy_krw + fee + qty += buy_krw / px + cost += buy_krw + sim_trades.append( + SimTrade( + dt=ts, + action="매수", + signal=memo, + price=px, + krw=buy_krw, + fee=fee, + quantity=buy_krw / px, + pnl=None, + cash_after=cash, + total_asset=cash + qty * px, + ) + ) + elif act == "sell" and qty > 0: + sell_krw = qty * px + fee = sell_krw * fee_rate + net = sell_krw - fee + pnl = net - cost + if pnl > 0: + win += 1 + cash += net + sim_trades.append( + SimTrade( + dt=ts, + action="매도", + signal=memo, + price=px, + krw=sell_krw, + fee=fee, + quantity=qty, + pnl=pnl, + cash_after=cash, + total_asset=cash, + ) + ) + qty = 0.0 + cost = 0.0 + + last_px = float(df_3m["Close"].iloc[-1]) + result = SimResult( + config_name=spec.get("name", "logos"), + trades=sim_trades, + initial_cash=SIM_INITIAL_CASH_KRW, + final_cash=cash, + final_coin_qty=qty, + final_price=last_px, + realized_pnl=sum(t.pnl or 0 for t in sim_trades if t.pnl), + total_fees=sum(t.fee for t in sim_trades), + final_asset=cash + qty * last_px, + total_return_pct=(cash + qty * last_px - SIM_INITIAL_CASH_KRW) + / SIM_INITIAL_CASH_KRW + * 100, + trade_count=len(sim_trades), + win_count=win, + ) + + print(f"\n[로고스 직관 타점] {spec.get('author', 'Logos')}") + print(f" 기간: {df_3m.index[0]} ~ {df_3m.index[-1]}") + print(f" 타점 {len(sim_trades)}개 (규칙 엔진·BB 조건 미사용)") + for t in sim_trades: + print(f" {t.dt} {t.action} ₩{t.price:,.0f} — {t.signal}") + print_backtest_report(result) + + note = spec.get("note", "") + path = self.render_logos_html(df_3m, result, trend, note=note, open_browser=True) + print(f"\n차트 파일: {path.resolve()}") + print("브라우저에서 열리지 않으면 위 경로를 더블클릭하세요.") + return result + def load_all_frames(self) -> dict[int, pd.DataFrame]: """discovered 규칙용 전 간격 로드.""" from mtf_bb import load_frames_from_db @@ -690,7 +921,7 @@ class Simulation: n_sig = int((df_sig["point"] == 1).sum()) buy_sig = int((df_sig["action"] == "buy").sum()) sell_sig = int((df_sig["action"] == "sell").sum()) - print(f"\n규칙 신호: {n_sig} (매수 {buy_sig} / 매도 {sell_sig})") + print(f"\n체결 신호: {n_sig} (매수 {buy_sig} / 매도 {sell_sig})") result = run_backtest(df_sig, df_1d, df_1h, config_name=rule_set.name) print_backtest_report(result) @@ -770,7 +1001,15 @@ def run_analyze(frames: dict[int, pd.DataFrame] | None = None) -> None: def run_discover(frames: dict[int, pd.DataFrame] | None = None): - """모든 봉·BB·일목 특징으로 최적 규칙 탐색 후 JSON 저장.""" + """ + 모든 봉·BB·일목 특징으로 최적 규칙 탐색 후 JSON 저장. + + Args: + frames: 전 간격 OHLCV. None이면 coins.db에서 로드. + + Returns: + DiscoveredRules 또는 데이터/탐색 실패 시 None. + """ from rule_discovery import discover_rules, save_rules if frames is None: @@ -784,30 +1023,25 @@ def run_discover(frames: dict[int, pd.DataFrame] | None = None): return rules -def run_full_pipeline() -> None: - """ - 일반 사용자용 일괄 실행: analyze → discover → HTML. +def run_logos_benchmark() -> None: + """수동 벤치마크 타점 차트 (logos_trades.json, 참고용).""" + print("=== 로고스 수동 벤치마크 차트 ===") + frames = _load_all_frames_or_exit() + if frames is None: + return + Simulation().run_logos_chart(frames) + print("\n완료.") - DB 로드는 한 번만 수행합니다. - """ + +def run_full_pipeline() -> None: + """로고스 전략 백테스트·HTML (단일 진입점).""" print("=" * 60) - print("전체 파이프라인: analyze → discover → HTML") + print("로고스 전략: 백테스트 → HTML") print("=" * 60) frames = _load_all_frames_or_exit() if frames is None: return - - print("\n[1/3] 조합 분석 (analyze)") - run_analyze(frames) - - print("\n[2/3] 규칙 탐색 (discover)") - run_discover(frames) - - print("\n[3/3] 백테스트·HTML 차트 (탐색 규칙 매수·매도)") - if rules is None: - print("규칙 탐색 실패 — HTML 생략") - return - Simulation().run_discovered_chart(frames, rules=rules) + Simulation().run_logos_strategy_chart(frames) print("\n완료.") @@ -817,7 +1051,10 @@ def print_usage() -> None: DeepCoin simulation.py python simulation.py - analyze + discover + HTML (차트 = discovered_rules 매수·매도) + 로고스 전략 백테스트·HTML → reports/wld_bb_simulation.html + + python simulation.py benchmark + 수동 벤치마크(logos_trades.json) → reports/wld_logos_benchmark.html (고급) analyze | discover | compare | mtf """ @@ -840,6 +1077,9 @@ def main() -> None: if len(sys.argv) > 1 and sys.argv[1] == "discover": run_discover() return + if len(sys.argv) > 1 and sys.argv[1] in ("benchmark", "logos"): + run_logos_benchmark() + return if len(sys.argv) > 1 and sys.argv[1] == "mtf": run_mtf_analysis() return diff --git a/strategy.py b/strategy.py index bb28f56..ca204c9 100644 --- a/strategy.py +++ b/strategy.py @@ -489,29 +489,44 @@ def evaluate_discovered_live( 최신 3분 봉 시점에서 discovered_rules + 전 봉 BB·일목 조합으로 신호 1건. """ from candle_features import build_master_feature_matrix - from rule_discovery import buy_mask, load_rules, rules_have_buy, sell_mask + from rule_discovery import ( + _trigger_at, + buy_mask, + load_rules, + rules_have_buy, + sell_mask, + ) rules = load_rules() if rules is None or not rules_have_buy(rules): return None matrix = build_master_feature_matrix(frames) - if len(matrix) < 22: + if len(matrix) < 23: return None - last = matrix.iloc[[-1]] - ts = last.index[-1] - close = float(last["Close"].iloc[-1]) + tail = matrix.iloc[-2:] + i = 1 + ts = tail.index[-1] + close = float(tail["Close"].iloc[-1]) trend = get_trend_at(df_1d, df_1h, ts) + b_m = buy_mask(tail, rules) + s_m = sell_mask(tail, rules, stop=False) + st_m = ( + sell_mask(tail, rules, stop=True) + if rules.sell_stop + else np.zeros(len(tail), dtype=bool) + ) + position = float(balances.get(symbol, {}).get("balance", 0) or 0) if position >= 1.0: - if rules.sell_stop and sell_mask(last, rules, stop=True)[0]: + if rules.sell_stop and _trigger_at(st_m, i): return TradeSignal("sell", SIGNAL_SELL_STOP, close, trend) - if sell_mask(last, rules, stop=False)[0]: + if _trigger_at(s_m, i): return TradeSignal("sell", SIGNAL_SELL_UPPER, close, trend) return None - if buy_mask(last, rules)[0]: + if _trigger_at(b_m, i): return TradeSignal("buy", SIGNAL_BUY_LOWER, close, trend) return None