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}
{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 = ( + "| 시각 | 구분 | 상태 | 가격 | 신호 | 금액 | 수수료 | 손익 |
|---|---|---|---|---|---|---|---|