diff --git a/.env.example b/.env.example
index 3fb85d9..72e048e 100644
--- a/.env.example
+++ b/.env.example
@@ -20,8 +20,10 @@ BB_MIN_WIDTH_PCT=0.8
# downloader.py — coins.db 적재 개월 수
DOWNLOAD_MONTHS=6
+DOWNLOAD_MONTHS_1M=2
+USE_DISCOVERED_LIVE=true
-# simulation_1h.py
+# simulation.py
SIM_INITIAL_CASH_KRW=200000
SIM_MIN_ORDER_KRW=5000
TRADING_FEE_RATE=0.0005
diff --git a/README.md b/README.md
index 8b85caa..ea09da2 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,64 @@
-# DeepCoin — WLD 볼린저 MTF
+# DeepCoin — WLD 전봉 BB·일목 조합 매매
-빗썸 KRW-WLD 현물 전용. **모든 봉**에 동일한 BB 규칙을 적용하고, 봉별 상태를 비교해 실행·확인 봉을 정합니다.
+빗썸 KRW-WLD 현물. **1, 3, 5, 10, 15, 30, 60, 240, 1440분** 모든 봉에서
+볼린저 밴드·일목균형표 **캔들 위치**를 분석하고, 봉 조합으로 매수·매도 규칙을 탐색합니다.
-## BB 기본 규칙 (모든 간격 동일)
+## 구조
-| 구분 | 조건 |
-|------|------|
-| 매수 | 이전 종가 ≤ 하단, 현재 종가 > 하단 (하단 **상향 돌파**) |
-| 매도 | 이전 종가 < 상단, 현재 종가 ≥ 상단 (상단 **상향 돌파**) |
-| 손절(선택) | 하단 재이탈 |
+```text
+downloader.py → coins.db (전 간격 증분)
+indicators.py → BB·일목 계산
+candle_features.py → 봉별 위치 특징 → 3분 타임라인 행렬
+combination_analyzer.py → 조합 분석·combination_report.json
+rule_discovery.py → discovered_rules.json
+strategy.py → 실시간 evaluate_discovered_live
+monitor_coin.py → 실거래 루프
+simulation.py → 백테스트·HTML 차트
+```
-**MTF 적용** (`mtf_bb.py`, `ACTIVE_MTF_POLICY` / `mtf_bb_policy.json`)
+## 봉별 분석 항목
-- 실행 봉: 3·10·15·30·60분 중 백테스트 수익률 1위
-- 확인 봉: 60분·일봉 등 상위 봉 상태가 매수/매도에 맞을 때만 체결
-- 하락 추세: 매수 차단 (설정 시)
+### 볼린저
+- 이벤트: `cross_up_lower`, `cross_up_upper`, `inside_band`, `squeeze` …
+- 구간: `bb_zone_bottom` ~ `bb_zone_top` (%B)
-봉별 상태: `inside`, `cross_up_lower`, `cross_up_upper`, `below_lower`, `above_upper`, `squeeze` 등
+### 일목균형표
+- `ichi_above_cloud`, `ichi_below_cloud`, `ichi_in_cloud`
+- `ichi_tk_bull` / `ichi_tk_cross_up`, `ichi_cloud_bull` …
-## 파일
+### 조합
+- 3분 기준 `merge_asof`로 모든 봉 특징을 한 행에 정렬
+- `discover`가 AND/OR 조합으로 매수·매도 규칙 탐색
-| 파일 | 역할 |
-|------|------|
-| `strategy.py` | 신호·금액·매도 비율 |
-| `monitor.py` | MTF 데이터, `process_wld_mtf`, 현물 주문 |
-| `monitor_coin.py` | 실시간 루프 |
-| `downloader.py` | `coins.db` (3분·1시간·일봉) |
-| `mtf_bb.py` | 봉별 BB 비교·정책 추천 |
-| `simulation_1h.py` | 백테스트 차트 |
-
-## 실행
+## 실행 순서
```bash
cp .env.example .env
-python downloader.py
-python simulation_1h.py discover # 모든 봉·캔들 특징 탐색 → discovered_rules.json
-python simulation_1h.py # 탐색 규칙 HTML 차트 (기본)
-python simulation_1h.py compare # 9종 조합 순위
-python simulation_1h.py mtf # 봉별 BB 비교 (실거래 전 참고)
-python monitor_coin.py # 실거래는 HTML 최적화 후 연동 예정
+python downloader.py # 1분봉 2개월, 나머지 6개월
+python simulation.py # analyze → discover → HTML (탐색 매수·매도 규칙 표시)
+python monitor_coin.py # 실거래
```
-`DOWNLOAD_MONTHS=6` — 간격: **3, 10, 15, 30, 60, 240, 1440**분.
-**증분 저장**: DB `MAX(ymdhms)` 이후 봉만 INSERT (재실행 시 전체 삭제 없음).
+HTML 차트에는 `discovered_rules.json` 에서 찾은 **매수·매도 규칙**의 체결만 표시합니다.
+고급: `analyze`, `discover`, `compare`, `mtf`.
-## 환경 변수
+## 설정 (`config.py`)
-`BITHUMB_ACCESS_KEY`, `BITHUMB_SECRET_KEY`, `COIN_TELEGRAM_*`,
-`BUY_COOLDOWN_SEC`(기본 300), `SELL_COOLDOWN_SEC`(180), `DEFAULT_BUY_KRW` 등 — `.env.example` 참고.
+| 항목 | 설명 |
+|------|------|
+| `ALL_INTERVALS` | 1,3,5,10,15,30,60,240,1440 |
+| `ENTRY_INTERVAL` | 조합 행렬 기준 3분 |
+| `DOWNLOAD_MONTHS_1M` | 1분봉 보관 개월 (기본 2) |
+| `USE_DISCOVERED_LIVE` | 실거래에 discovered_rules 사용 |
+
+## 출력 파일
+
+| 파일 | 내용 |
+|------|------|
+| `combination_report.json` | 봉별 최신 위치·매수/매도 힌트 |
+| `discovered_rules.json` | 탐색된 매매 규칙 |
+| `reports/wld_bb_simulation.html` | 시뮬 차트 |
+
+## 면책
+
+실거래 손실 책임은 사용자에게 있습니다.
diff --git a/candle_features.py b/candle_features.py
index c088b07..7366683 100644
--- a/candle_features.py
+++ b/candle_features.py
@@ -1,5 +1,5 @@
"""
-모든 봉 간격에 대해 BB 위치·캔들 형태(몸통/꼬리/높이) 특징을 계산하고
+모든 봉(1~1440분)에 BB·일목 위치·캔들 형태 특징을 계산하고
기준 타임라인(3분)에 맞춰 정렬합니다.
"""
@@ -8,11 +8,14 @@ from __future__ import annotations
import numpy as np
import pandas as pd
-from config import ENTRY_INTERVAL
+from config import ALL_INTERVALS, ENTRY_INTERVAL
+from indicators import add_bollinger, add_ichimoku
from strategy import prepare_entry_df
INTERVAL_LABELS: dict[int, str] = {
+ 1: "m1",
3: "m3",
+ 5: "m5",
10: "m10",
15: "m15",
30: "m30",
@@ -27,9 +30,69 @@ def interval_prefix(interval: int) -> str:
return INTERVAL_LABELS.get(interval, f"m{interval}")
+def interval_display(interval: int) -> str:
+ if interval >= 1440:
+ return "일봉"
+ return f"{interval}분"
+
+
+# BB 위치 (밴드 내 %B 구간)
+BB_ZONE_FEATURES: tuple[str, ...] = (
+ "bb_zone_bottom",
+ "bb_zone_low",
+ "bb_zone_mid",
+ "bb_zone_high",
+ "bb_zone_top",
+)
+
+# 일목 위치
+ICHI_FEATURES: tuple[str, ...] = (
+ "ichi_above_cloud",
+ "ichi_below_cloud",
+ "ichi_in_cloud",
+ "ichi_cloud_bull",
+ "ichi_cloud_bear",
+ "ichi_tk_bull",
+ "ichi_tk_bear",
+ "ichi_price_above_tenkan",
+ "ichi_price_below_kijun",
+ "ichi_tk_cross_up",
+ "ichi_tk_cross_down",
+)
+
+# BB 이벤트·캔들 형태
+BB_EVENT_FEATURES: tuple[str, ...] = (
+ "cross_up_lower",
+ "cross_up_upper",
+ "cross_down_lower",
+ "below_lower",
+ "above_upper",
+ "inside_band",
+ "bb_pos_low",
+ "bb_pos_high",
+ "squeeze",
+)
+
+CANDLE_SHAPE_FEATURES: tuple[str, ...] = (
+ "body_strong",
+ "body_weak",
+ "hammer",
+ "shooting_star",
+ "bullish",
+ "bearish",
+)
+
+FEATURE_BOOL_COLS: tuple[str, ...] = (
+ BB_EVENT_FEATURES
+ + BB_ZONE_FEATURES
+ + ICHI_FEATURES
+ + CANDLE_SHAPE_FEATURES
+)
+
+
def compute_bar_features(df: pd.DataFrame) -> pd.DataFrame:
- """단일 봉 DataFrame에 위치·캔들 높이 특징을 추가합니다."""
- out = prepare_entry_df(df.copy())
+ """단일 봉 DataFrame에 BB·일목·캔들 위치 특징을 추가합니다."""
+ out = add_bollinger(add_ichimoku(prepare_entry_df(df.copy())))
if len(out) < 2:
return out
@@ -42,13 +105,6 @@ def compute_bar_features(df: pd.DataFrame) -> pd.DataFrame:
lower = out["Lower"].astype(float)
prev_upper = upper.shift(1)
prev_lower = lower.shift(1)
- ma = out["MA"].astype(float)
-
- band = (upper - lower).replace(0, np.nan)
- out["bb_pos"] = ((c - lower) / band).clip(0, 1)
- out["bb_width_pct"] = (
- out["BB_Width"] if "BB_Width" in out.columns else (band / ma.replace(0, np.nan) * 100)
- )
rng = (h - l).replace(0, np.nan)
body = (c - o).abs()
@@ -57,8 +113,13 @@ def compute_bar_features(df: pd.DataFrame) -> pd.DataFrame:
out["upper_wick_ratio"] = ((h - np.maximum(o, c)) / rng).fillna(0).clip(0, 1)
out["lower_wick_ratio"] = ((np.minimum(o, c) - l) / rng).fillna(0).clip(0, 1)
out["ret_pct"] = ((c - prev_c) / prev_c.replace(0, np.nan)) * 100
- out["bullish"] = (c > o).astype(int)
- out["bearish"] = (c < o).astype(int)
+
+ pos = out["bb_pos"].astype(float)
+ out["bb_zone_bottom"] = (pos < 0.15).astype(int)
+ out["bb_zone_low"] = ((pos >= 0.15) & (pos < 0.35)).astype(int)
+ out["bb_zone_mid"] = ((pos >= 0.35) & (pos < 0.65)).astype(int)
+ out["bb_zone_high"] = ((pos >= 0.65) & (pos < 0.85)).astype(int)
+ out["bb_zone_top"] = (pos >= 0.85).astype(int)
out["cross_up_lower"] = ((prev_c <= prev_lower) & (c > lower)).astype(int)
out["cross_up_upper"] = ((prev_c < prev_upper) & (c >= upper)).astype(int)
@@ -66,35 +127,83 @@ def compute_bar_features(df: pd.DataFrame) -> pd.DataFrame:
out["below_lower"] = (c < lower).astype(int)
out["above_upper"] = (c > upper).astype(int)
out["inside_band"] = ((c >= lower) & (c <= upper)).astype(int)
+ out["bb_pos_low"] = (pos < 0.2).astype(int)
+ out["bb_pos_high"] = (pos > 0.8).astype(int)
+ out["squeeze"] = (out["BB_Width"] < 0.8).astype(int)
+
+ ct = out["ichi_cloud_top"].astype(float)
+ cb = out["ichi_cloud_bottom"].astype(float)
+ ten = out["ichi_tenkan"].astype(float)
+ kij = out["ichi_kijun"].astype(float)
+ prev_ten = ten.shift(1)
+ prev_kij = kij.shift(1)
+
+ out["ichi_above_cloud"] = (c > ct).astype(int)
+ out["ichi_below_cloud"] = (c < cb).astype(int)
+ out["ichi_in_cloud"] = ((c >= cb) & (c <= ct)).astype(int)
+ out["ichi_cloud_bull"] = (out["ichi_span_a"] > out["ichi_span_b"]).astype(int)
+ out["ichi_cloud_bear"] = (out["ichi_span_a"] < out["ichi_span_b"]).astype(int)
+ out["ichi_tk_bull"] = (ten > kij).astype(int)
+ out["ichi_tk_bear"] = (ten < kij).astype(int)
+ out["ichi_price_above_tenkan"] = (c > ten).astype(int)
+ out["ichi_price_below_kijun"] = (c < kij).astype(int)
+ out["ichi_tk_cross_up"] = ((prev_ten <= prev_kij) & (ten > kij)).astype(int)
+ out["ichi_tk_cross_down"] = ((prev_ten >= prev_kij) & (ten < kij)).astype(int)
- out["bb_pos_low"] = (out["bb_pos"] < 0.2).astype(int)
- out["bb_pos_high"] = (out["bb_pos"] > 0.8).astype(int)
out["body_strong"] = (out["body_ratio"] > 0.55).astype(int)
out["body_weak"] = (out["body_ratio"] < 0.25).astype(int)
out["hammer"] = ((out["lower_wick_ratio"] > 0.45) & (out["body_ratio"] < 0.35)).astype(int)
out["shooting_star"] = ((out["upper_wick_ratio"] > 0.45) & (out["body_ratio"] < 0.35)).astype(int)
- out["squeeze"] = (out["bb_width_pct"] < 0.8).astype(int)
+ out["bullish"] = (c > o).astype(int)
+ out["bearish"] = (c < o).astype(int)
return out
-FEATURE_BOOL_COLS: tuple[str, ...] = (
- "cross_up_lower",
- "cross_up_upper",
- "cross_down_lower",
- "below_lower",
- "above_upper",
- "inside_band",
- "bb_pos_low",
- "bb_pos_high",
- "body_strong",
- "body_weak",
- "hammer",
- "shooting_star",
- "squeeze",
- "bullish",
- "bearish",
-)
+def describe_latest_position(df: pd.DataFrame, interval: int) -> dict:
+ """한 봉의 최신 BB·일목 위치 요약."""
+ feat = compute_bar_features(df)
+ if feat.empty:
+ return {"interval": interval, "label": interval_display(interval)}
+ row = feat.iloc[-1]
+ pos = float(row.get("bb_pos", 0.5))
+ bb_zone = "mid"
+ for z in BB_ZONE_FEATURES:
+ if int(row.get(z, 0)) == 1:
+ bb_zone = z.replace("bb_zone_", "")
+ break
+ ichi_pos = "in_cloud"
+ if int(row.get("ichi_above_cloud", 0)):
+ ichi_pos = "above_cloud"
+ elif int(row.get("ichi_below_cloud", 0)):
+ ichi_pos = "below_cloud"
+
+ return {
+ "interval": interval,
+ "label": interval_display(interval),
+ "close": float(row["Close"]),
+ "bb_pos": round(pos, 3),
+ "bb_zone": bb_zone,
+ "bb_state": _bb_event_label(row),
+ "ichi_position": ichi_pos,
+ "ichi_tk": "bull" if int(row.get("ichi_tk_bull", 0)) else "bear",
+ "ichi_cloud": "bull" if int(row.get("ichi_cloud_bull", 0)) else "bear",
+ }
+
+
+def _bb_event_label(row: pd.Series) -> str:
+ for name in (
+ "cross_up_lower",
+ "cross_up_upper",
+ "cross_down_lower",
+ "below_lower",
+ "above_upper",
+ "squeeze",
+ "inside_band",
+ ):
+ if int(row.get(name, 0)) == 1:
+ return name
+ return "neutral"
def _merge_interval_features(
@@ -104,7 +213,16 @@ def _merge_interval_features(
) -> pd.DataFrame:
"""master_index 길이와 동일한 간격 특징만 반환."""
pick = [c for c in FEATURE_BOOL_COLS if c in feat.columns]
- extra = [c for c in ("bb_pos", "body_ratio", "lower_wick_ratio", "ret_pct") if c in feat.columns]
+ extra = [
+ c
+ for c in ("bb_pos", "body_ratio", "lower_wick_ratio", "ret_pct", "bb_width_pct")
+ if c in feat.columns
+ ]
+ if "bb_width_pct" not in feat.columns and "BB_Width" in feat.columns:
+ feat = feat.copy()
+ feat["bb_width_pct"] = feat["BB_Width"]
+ extra.append("bb_width_pct")
+
sub = feat[pick + extra].copy()
sub.columns = [f"{prefix}_{c}" for c in sub.columns]
@@ -124,33 +242,35 @@ def _merge_interval_features(
def build_master_feature_matrix(frames: dict[int, pd.DataFrame]) -> pd.DataFrame:
- """3분 타임라인에 모든 봉의 위치·캔들 특징을 붙인 행렬 (인덱스 유일)."""
+ """3분 타임라인에 모든 봉의 BB·일목·캔들 특징을 붙인 행렬."""
entry = frames.get(ENTRY_INTERVAL)
if entry is None or entry.empty:
- raise ValueError("ENTRY_INTERVAL 데이터가 없습니다.")
+ raise ValueError(f"{ENTRY_INTERVAL}분봉(ENTRY_INTERVAL) 데이터가 없습니다.")
entry_feat = compute_bar_features(entry)
entry_feat = entry_feat[~entry_feat.index.duplicated(keep="last")].sort_index()
- p3 = interval_prefix(ENTRY_INTERVAL)
+ p0 = interval_prefix(ENTRY_INTERVAL)
ohlc = ["Open", "High", "Low", "Close", "Volume", "Upper", "Lower", "MA"]
master = entry_feat[[c for c in ohlc if c in entry_feat.columns]].copy()
for col in FEATURE_BOOL_COLS:
if col in entry_feat.columns:
- master[f"{p3}_{col}"] = entry_feat[col]
- for col in ("bb_pos", "body_ratio", "lower_wick_ratio", "ret_pct", "bb_width_pct"):
+ master[f"{p0}_{col}"] = entry_feat[col]
+ for col in ("bb_pos", "body_ratio", "lower_wick_ratio", "ret_pct", "bb_width_pct", "BB_Width"):
if col in entry_feat.columns:
- master[f"{p3}_{col}"] = entry_feat[col]
+ master[f"{p0}_{col}"] = entry_feat[col]
- parts = [master]
- for interval, df in sorted(frames.items()):
- if interval == ENTRY_INTERVAL or df is None or df.empty:
+ for interval in ALL_INTERVALS:
+ if interval == ENTRY_INTERVAL:
+ continue
+ df = frames.get(interval)
+ if df is None or df.empty:
continue
feat = compute_bar_features(df)
feat = feat[~feat.index.duplicated(keep="last")].sort_index()
prefix = interval_prefix(interval)
- parts.append(_merge_interval_features(master.index, feat, prefix))
+ merged = _merge_interval_features(master.index, feat, prefix)
+ master = pd.concat([master, merged], axis=1)
- out = pd.concat(parts, axis=1)
- return out.loc[:, ~out.columns.duplicated()]
+ return master.loc[:, ~master.columns.duplicated()]
diff --git a/combination_analyzer.py b/combination_analyzer.py
new file mode 100644
index 0000000..2af7478
--- /dev/null
+++ b/combination_analyzer.py
@@ -0,0 +1,222 @@
+"""
+모든 봉의 BB·일목 위치를 조합해 매수/매도 후보 규칙을 분석합니다.
+
+ python simulation.py analyze
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+
+from candle_features import (
+ FEATURE_BOOL_COLS,
+ INTERVAL_LABELS,
+ build_master_feature_matrix,
+ describe_latest_position,
+ interval_prefix,
+)
+from config import ALL_INTERVALS, ENTRY_INTERVAL, SYMBOL
+
+REPORT_FILE = Path(__file__).parent / "combination_report.json"
+
+
+@dataclass
+class CombinationReport:
+ """봉 조합 분석 결과."""
+
+ generated_at: str
+ intervals_loaded: list[int]
+ latest_positions: list[dict]
+ buy_recommendations: list[str] = field(default_factory=list)
+ sell_recommendations: list[str] = field(default_factory=list)
+ buy_avoid: list[str] = field(default_factory=list)
+ top_buy_pairs: list[dict] = field(default_factory=list)
+ top_sell_pairs: list[dict] = field(default_factory=list)
+ suggested_rules: dict = field(default_factory=dict)
+
+
+def _forward_return(close: pd.Series, bars: int = 20) -> pd.Series:
+ """N봉 후 수익률 (%)."""
+ future = close.shift(-bars)
+ return (future - close) / close.replace(0, np.nan) * 100
+
+
+def _predicate_keys(intervals: list[int]) -> list[str]:
+ keys: list[str] = []
+ for iv in intervals:
+ pfx = interval_prefix(iv)
+ for feat in FEATURE_BOOL_COLS:
+ keys.append(f"{pfx}:{feat}")
+ return keys
+
+
+def analyze_forward_edge(
+ matrix: pd.DataFrame,
+ forward_bars: int = 20,
+ min_samples: int = 40,
+) -> tuple[list[dict], list[dict], list[dict]]:
+ """
+ 단일 조건·2봉 조합별 미래 수익 통계 (학습용 힌트).
+
+ Returns:
+ (매수 유리 top, 매도/회피 top)
+ """
+ close = matrix["Close"].astype(float)
+ fwd = _forward_return(close, forward_bars)
+ valid = fwd.notna()
+ base_mean = float(fwd[valid].mean()) if valid.any() else 0.0
+
+ singles: list[dict] = []
+ cols = [c for c in matrix.columns if any(c.startswith(f"{interval_prefix(iv)}_") for iv in ALL_INTERVALS)]
+
+ for col in cols:
+ mask = matrix[col].fillna(0).astype(bool) & valid
+ n = int(mask.sum())
+ if n < min_samples:
+ continue
+ avg = float(fwd[mask].mean())
+ singles.append(
+ {
+ "key": _col_to_key(col),
+ "column": col,
+ "count": n,
+ "avg_forward_pct": round(avg, 4),
+ "edge_vs_base": round(avg - base_mean, 4),
+ }
+ )
+
+ singles.sort(key=lambda x: x["edge_vs_base"], reverse=True)
+ buy_top = [s for s in singles if s["edge_vs_base"] > 0][:25]
+ sell_top = sorted(singles, key=lambda x: x["edge_vs_base"])[:15]
+
+ pairs: list[dict] = []
+ buy_cols = [s["column"] for s in buy_top[:12]]
+ for i, c1 in enumerate(buy_cols):
+ for c2 in buy_cols[i + 1 :]:
+ if c1.split("_")[0] == c2.split("_")[0]:
+ continue
+ mask = (
+ matrix[c1].fillna(0).astype(bool)
+ & matrix[c2].fillna(0).astype(bool)
+ & valid
+ )
+ n = int(mask.sum())
+ if n < min_samples // 2:
+ continue
+ avg = float(fwd[mask].mean())
+ pairs.append(
+ {
+ "keys": [_col_to_key(c1), _col_to_key(c2)],
+ "count": n,
+ "avg_forward_pct": round(avg, 4),
+ "edge_vs_base": round(avg - base_mean, 4),
+ }
+ )
+ pairs.sort(key=lambda x: x["edge_vs_base"], reverse=True)
+
+ return buy_top, pairs[:20], sell_top
+
+
+def _col_to_key(col: str) -> str:
+ """m3_cross_up_lower -> m3:cross_up_lower."""
+ for pfx in INTERVAL_LABELS.values():
+ if col.startswith(f"{pfx}_"):
+ return f"{pfx}:{col[len(pfx) + 1:]}"
+ return col
+
+
+def build_recommendations(
+ buy_top: list[dict],
+ pair_top: list[dict],
+ sell_top: list[dict],
+) -> tuple[list[str], list[str], list[str], dict]:
+ """사람이 읽을 수 있는 권장·규칙 초안."""
+ buy_rec: list[str] = []
+ sell_rec: list[str] = []
+ avoid: list[str] = []
+
+ for s in buy_top[:8]:
+ buy_rec.append(
+ f"{s['key']} — {s['count']}회, {s['avg_forward_pct']:+.2f}% ({s['edge_vs_base']:+.2f}%p)"
+ )
+ for p in pair_top[:5]:
+ buy_rec.append(
+ f"조합 {' + '.join(p['keys'])} — {p['count']}회, {p['edge_vs_base']:+.2f}%p"
+ )
+ for s in sell_top[:6]:
+ if s["edge_vs_base"] < -0.05:
+ avoid.append(f"매수 회피: {s['key']} ({s['edge_vs_base']:.2f}%p)")
+
+ for s in sell_top[:5]:
+ if "cross_up_upper" in s["key"] or "above_upper" in s["key"] or "ichi_above" in s["key"]:
+ sell_rec.append(f"매도 후보: {s['key']}")
+
+ suggested: dict = {"buy_all": [], "buy_any": [], "sell_all": [], "sell_stop": []}
+ if pair_top:
+ suggested["buy_all"] = pair_top[0]["keys"]
+ elif buy_top:
+ suggested["buy_all"] = [buy_top[0]["key"]]
+ if sell_top:
+ for s in sell_top:
+ if "cross_up_upper" in s.get("key", ""):
+ suggested["sell_all"] = [s["key"]]
+ break
+
+ return buy_rec, sell_rec, avoid, suggested
+
+
+def analyze_combinations(frames: dict[int, pd.DataFrame]) -> CombinationReport:
+ """전체 봉 BB·일목 위치 분석 + 조합 매매 힌트."""
+ from datetime import datetime
+
+ loaded = sorted(frames.keys())
+ latest = [describe_latest_position(frames[iv], iv) for iv in ALL_INTERVALS if iv in frames]
+
+ print("\n=== 봉별 최신 BB·일목 위치 ===")
+ for p in latest:
+ print(
+ f" {p['label']:>6} | BB {p['bb_zone']:>6} ({p['bb_pos']:.2f}) {p['bb_state']:>16} | "
+ f"일목 {p['ichi_position']:>12} TK={p['ichi_tk']} 구름={p['ichi_cloud']}"
+ )
+
+ matrix = build_master_feature_matrix(frames).iloc[52:].copy()
+ print(f"\n특징 행렬: {len(matrix)}행 × {len(matrix.columns)}열")
+
+ buy_top, pair_top, sell_top = analyze_forward_edge(matrix)
+ buy_rec, sell_rec, avoid, suggested = build_recommendations(buy_top, pair_top, sell_top)
+
+ print("\n=== 매수 유리 조건 (단일·상위) ===")
+ for line in buy_rec[:10]:
+ print(f" {line}")
+ print("\n=== 매수 회피 / 매도 참고 ===")
+ for line in avoid[:6]:
+ print(f" {line}")
+ for line in sell_rec[:5]:
+ print(f" {line}")
+
+ return CombinationReport(
+ generated_at=datetime.now().isoformat(timespec="seconds"),
+ intervals_loaded=loaded,
+ latest_positions=latest,
+ buy_recommendations=buy_rec,
+ sell_recommendations=sell_rec,
+ buy_avoid=avoid,
+ top_buy_pairs=pair_top,
+ suggested_rules=suggested,
+ )
+
+
+def save_report(report: CombinationReport, path: Path = REPORT_FILE) -> None:
+ path.write_text(json.dumps(asdict(report), ensure_ascii=False, indent=2), encoding="utf-8")
+ print(f"\n저장: {path}")
+
+
+def load_frames(monitor) -> dict[int, pd.DataFrame]:
+ from mtf_bb import load_frames_from_db
+
+ return load_frames_from_db(monitor, SYMBOL)
diff --git a/config.py b/config.py
index f39c8cb..e6cd40f 100644
--- a/config.py
+++ b/config.py
@@ -24,7 +24,6 @@ KR_COINS: dict[str, str] = {
}
# --- 타임프레임 (분) ---
-ENTRY_INTERVAL = 3
TREND_INTERVAL_1H = 60
TREND_INTERVAL_1D = 1440
@@ -55,10 +54,19 @@ TRADING_FEE_RATE = float(os.getenv("TRADING_FEE_RATE", "0.0005"))
# --- coins.db (downloader.py 적재 간격, 분) ---
# 빗썸 분봉 API: 1,3,5,10,15,30,60,240 / 일봉 1440
-DOWNLOAD_INTERVALS: tuple[int, ...] = (3, 10, 15, 30, 60, 240, 1440)
+ALL_INTERVALS: tuple[int, ...] = (1, 3, 5, 10, 15, 30, 60, 240, 1440)
+DOWNLOAD_INTERVALS: tuple[int, ...] = ALL_INTERVALS
DOWNLOAD_MONTHS = int(os.getenv("DOWNLOAD_MONTHS", "6"))
+# 1분봉은 용량·API 부담으로 기본 2개월 (환경변수로 조정)
+DOWNLOAD_MONTHS_1M = int(os.getenv("DOWNLOAD_MONTHS_1M", "2"))
DB_PATH = "coins.db"
+# 규칙 탐색·조합 분석 기준 타임라인
+ENTRY_INTERVAL = 3
+
+# 실시간: discovered_rules + 전 봉 BB·일목 조합 (False면 mtf_bb_policy)
+USE_DISCOVERED_LIVE = os.getenv("USE_DISCOVERED_LIVE", "true").lower() in ("1", "true", "yes")
+
# --- 시뮬레이션 ---
SIM_INITIAL_CASH_KRW = int(os.getenv("SIM_INITIAL_CASH_KRW", "200000"))
SIM_MIN_ORDER_KRW = int(os.getenv("SIM_MIN_ORDER_KRW", "5000"))
diff --git a/discovered_rules.json b/discovered_rules.json
index ff08afc..c32b1d0 100644
--- a/discovered_rules.json
+++ b/discovered_rules.json
@@ -1,22 +1,19 @@
{
"name": "discovered_best",
"buy_all": [
- "m3:bb_pos_low",
- "m3:shooting_star",
- "m15:inside_band",
- "m3:inside_band",
- "m3:hammer"
+ "m240:above_upper",
+ "m3:bb_zone_bottom"
],
"buy_any": [],
"sell_all": [
"m3:cross_up_upper",
- "m3:bearish",
- "m10:cross_up_upper",
- "m30:bb_pos_high"
+ "m3:ichi_above_cloud",
+ "m3:ichi_cloud_bull",
+ "m3:ichi_tk_cross_up"
],
"sell_stop": [],
- "train_return_pct": 1.9043499145753595,
- "test_return_pct": 0.6201861088011792,
- "full_return_pct": 2.524536023376539,
- "trade_count": 9
+ "train_return_pct": 2.9835000000000003,
+ "test_return_pct": 1.45272321428571,
+ "full_return_pct": 4.43622321428571,
+ "trade_count": 3
}
\ No newline at end of file
diff --git a/downloader.py b/downloader.py
index 92f3674..879ba9e 100644
--- a/downloader.py
+++ b/downloader.py
@@ -18,6 +18,7 @@ from config import (
DB_PATH,
DOWNLOAD_INTERVALS,
DOWNLOAD_MONTHS,
+ DOWNLOAD_MONTHS_1M,
KR_COINS,
SYMBOL,
)
@@ -71,9 +72,18 @@ def interval_label(interval: int) -> str:
return f"{interval}분봉"
+def months_for_interval(interval: int, default_months: int) -> int:
+ """간격별 DB 보관 개월 수 (1분봉은 별도 상한)."""
+ if interval == 1:
+ return DOWNLOAD_MONTHS_1M
+ return default_months
+
+
def download_jobs() -> list[tuple[int, str]]:
labels = {
+ 1: "1분",
3: "3분",
+ 5: "5분",
10: "10분",
15: "15분",
30: "30분",
@@ -241,6 +251,7 @@ def download_symbol(
months: int,
) -> None:
"""한 간격의 봉을 API로 받아 증분 저장합니다."""
+ months = months_for_interval(interval, months)
label = interval_label(interval)
last_ts = get_last_timestamp(symbol, interval)
existing = get_row_count(symbol, interval)
diff --git a/indicators.py b/indicators.py
new file mode 100644
index 0000000..0169bc0
--- /dev/null
+++ b/indicators.py
@@ -0,0 +1,58 @@
+"""
+볼린저 밴드·일목균형표 계산 (모든 봉 간격 공용).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from config import BB_PERIOD, BB_STD
+
+
+def add_bollinger(
+ df: pd.DataFrame,
+ period: int = BB_PERIOD,
+ std_mult: float = BB_STD,
+) -> pd.DataFrame:
+ """MA, Upper, Lower, BB_Width, bb_pos 컬럼 추가."""
+ out = df.copy()
+ if "MA" not in out.columns:
+ out["MA"] = out["Close"].rolling(period).mean()
+ if "Upper" not in out.columns or "Lower" not in out.columns:
+ std = out["Close"].rolling(period).std()
+ out["STD"] = std
+ out["Upper"] = out["MA"] + std_mult * std
+ out["Lower"] = out["MA"] - std_mult * std
+ ma = out["MA"].replace(0, np.nan)
+ band = (out["Upper"] - out["Lower"]).replace(0, np.nan)
+ out["bb_pos"] = ((out["Close"] - out["Lower"]) / band).clip(0, 1)
+ out["BB_Width"] = band / ma * 100
+ return out
+
+
+def add_ichimoku(
+ df: pd.DataFrame,
+ tenkan: int = 9,
+ kijun: int = 26,
+ senkou_b_period: int = 52,
+) -> pd.DataFrame:
+ """
+ 일목균형표 라인·구름 위치 컬럼 추가 (해당 봉 시점, 미래 데이터 미사용).
+
+ Returns:
+ ichi_tenkan, ichi_kijun, ichi_span_a, ichi_span_b,
+ ichi_cloud_top, ichi_cloud_bottom
+ """
+ out = df.copy()
+ h = out["High"].astype(float)
+ l = out["Low"].astype(float)
+ c = out["Close"].astype(float)
+
+ out["ichi_tenkan"] = (h.rolling(tenkan).max() + l.rolling(tenkan).min()) / 2
+ out["ichi_kijun"] = (h.rolling(kijun).max() + l.rolling(kijun).min()) / 2
+ out["ichi_span_a"] = (out["ichi_tenkan"] + out["ichi_kijun"]) / 2
+ out["ichi_span_b"] = (h.rolling(senkou_b_period).max() + l.rolling(senkou_b_period).min()) / 2
+ out["ichi_cloud_top"] = np.maximum(out["ichi_span_a"], out["ichi_span_b"])
+ out["ichi_cloud_bottom"] = np.minimum(out["ichi_span_a"], out["ichi_span_b"])
+ return out
diff --git a/monitor.py b/monitor.py
index 6f97497..e704502 100644
--- a/monitor.py
+++ b/monitor.py
@@ -307,11 +307,14 @@ class Monitor(HTS):
def process_wld_mtf(self, symbol: str, balances: dict | None = None) -> None:
"""
- WLD MTF: 모든 봉 BB 상태 비교 후 정책에 따라 매수/매도.
+ WLD: 전 봉(1~1440분) BB·일목 위치 조합 매매.
- mtf_bb_policy.json 이 있으면 해당 정책, 없으면 ACTIVE_MTF_POLICY 사용.
+ USE_DISCOVERED_LIVE=True: discovered_rules.json + combination 특징
+ False: mtf_bb_policy.json BB MTF
"""
+ from config import USE_DISCOVERED_LIVE
from mtf_bb import load_frames_from_db, load_policy, print_latest_states
+ from candle_features import describe_latest_position
try:
frames = load_frames_from_db(self, symbol)
@@ -326,29 +329,41 @@ class Monitor(HTS):
if df_1h is None or df_1h.empty:
df_1h = frames.get(ENTRY_INTERVAL)
- policy = load_policy() or strategy.ACTIVE_MTF_POLICY
- cfg = strategy.ACTIVE_CONFIG
- print_latest_states(frames, cfg)
- print(
- f"MTF 정책: {policy.name} | "
- f"매수={policy.buy_interval}분 | 매도={policy.sell_interval}분 | "
- f"확인={list(policy.buy_confirm_intervals)}"
- )
-
trend = strategy.get_trend(df_1d, df_1h)
print(f"{symbol} 추세: {trend}")
+ print("--- 봉별 BB·일목 위치 ---")
+ for iv in sorted(frames.keys()):
+ pos = describe_latest_position(frames[iv], iv)
+ print(
+ f" {pos['label']:>6} | BB {pos['bb_zone']} {pos['bb_state']:>16} | "
+ f"일목 {pos['ichi_position']} TK={pos['ichi_tk']}"
+ )
- entry = frames.get(ENTRY_INTERVAL)
- trade = strategy.evaluate(
- symbol,
- entry if entry is not None else frames[policy.buy_interval],
- df_1h,
- df_1d,
- config=cfg,
- frames=frames,
- policy=policy,
- )
+ if USE_DISCOVERED_LIVE:
+ print("모드: 전봉 BB·일목 조합 (discovered_rules)")
+ trade = strategy.evaluate_discovered_live(
+ symbol, frames, df_1d, df_1h, balances or {}
+ )
+ else:
+ policy = load_policy() or strategy.ACTIVE_MTF_POLICY
+ cfg = strategy.ACTIVE_CONFIG
+ print_latest_states(frames, cfg)
+ print(
+ f"MTF 정책: {policy.name} | 매수={policy.buy_interval}분 | "
+ f"매도={policy.sell_interval}분"
+ )
+ entry = frames.get(ENTRY_INTERVAL)
+ trade = strategy.evaluate(
+ symbol,
+ entry if entry is not None else frames[policy.buy_interval],
+ df_1h,
+ df_1d,
+ config=cfg,
+ frames=frames,
+ policy=policy,
+ )
if trade is None:
+ print("신호 없음")
return
self.execute_trade_signal(symbol, trade, balances=balances)
except Exception as e:
diff --git a/monitor_coin.py b/monitor_coin.py
index 879de22..87b8f86 100644
--- a/monitor_coin.py
+++ b/monitor_coin.py
@@ -1,7 +1,7 @@
"""
-WLD(월드코인) 실시간 모니터 — 3분 BB MTF (평균회귀 + 돌파).
+WLD(월드코인) 실시간 모니터 — 전 봉 BB·일목 조합 (discovered_rules).
-전략: strategy.py
+전략: strategy.py / rule_discovery.py
"""
from datetime import datetime
diff --git a/mtf_bb.py b/mtf_bb.py
index 5673fed..b2b06a7 100644
--- a/mtf_bb.py
+++ b/mtf_bb.py
@@ -83,7 +83,7 @@ def backtest_interval(
cfg: StrategyConfig,
) -> IntervalBacktestSummary | None:
"""한 간격만으로 BB 매매 백테스트."""
- from simulation_1h import run_backtest
+ from simulation import run_backtest
if interval not in frames:
return None
diff --git a/rule_discovery.py b/rule_discovery.py
index 2074c16..cc16459 100644
--- a/rule_discovery.py
+++ b/rule_discovery.py
@@ -1,7 +1,7 @@
"""
모든 봉·캔들 특징 행렬에서 매수/매도 규칙을 탐색합니다 (인과적 백테스트).
- python simulation_1h.py discover
+ python simulation.py discover
"""
from __future__ import annotations
@@ -46,6 +46,9 @@ NEG_BLOCK_FEATURES: tuple[str, ...] = (
"above_upper",
"cross_down_lower",
"shooting_star",
+ "ichi_above_cloud",
+ "bb_zone_top",
+ "bb_pos_high",
)
# 탐색 규칙 적용 시 항상 매수 차단 (상단 돌파·과열)
@@ -238,7 +241,7 @@ def backtest_rules(
"""
HTML 시뮬과 동일한 run_backtest 로직으로 수익률 계산.
"""
- from simulation_1h import run_backtest
+ from simulation import run_backtest
import strategy as st
df = entry_ohlc.loc[matrix.index].copy()
@@ -261,12 +264,39 @@ def backtest_rules(
def _baseline_rules() -> DiscoveredRules:
+ """다봉 BB 하단 돌파 + 상단 돌파 기준선."""
p3 = interval_prefix(ENTRY_INTERVAL)
return DiscoveredRules(
- name="baseline_bb",
- buy_all=[f"{p3}:cross_up_lower"],
- sell_all=[f"{p3}:cross_up_upper"],
- sell_stop=[],
+ name="baseline_bb_mtf",
+ buy_all=[
+ f"{p3}:cross_up_lower",
+ f"{interval_prefix(60)}:ichi_tk_bull",
+ f"{interval_prefix(1440)}:!ichi_below_cloud",
+ ],
+ sell_all=[
+ f"{p3}:cross_up_upper",
+ f"{interval_prefix(60)}:cross_up_upper",
+ ],
+ sell_stop=[f"{p3}:cross_down_lower"],
+ )
+
+
+def _seed_from_combination_report() -> DiscoveredRules | None:
+ """combination_report.json 제안 규칙."""
+ path = Path(__file__).parent / "combination_report.json"
+ if not path.exists():
+ return None
+ data = json.loads(path.read_text(encoding="utf-8"))
+ sug = data.get("suggested_rules") or {}
+ buy_all = list(sug.get("buy_all") or [])
+ if not buy_all:
+ return None
+ return DiscoveredRules(
+ name="combination_seed",
+ buy_all=buy_all,
+ buy_any=[list(g) for g in (sug.get("buy_any") or []) if g],
+ sell_all=list(sug.get("sell_all") or [f"{interval_prefix(ENTRY_INTERVAL)}:cross_up_upper"]),
+ sell_stop=list(sug.get("sell_stop") or []),
)
@@ -278,8 +308,8 @@ def greedy_search(
df_1d: pd.DataFrame,
df_1h: pd.DataFrame,
entry_ohlc: pd.DataFrame,
- max_buy: int = 5,
- max_sell: int = 4,
+ max_buy: int = 8,
+ max_sell: int = 6,
max_stop: int = 2,
) -> DiscoveredRules:
"""학습 구간 수익률을 올리도록 매수/매도 조건을 탐욕적으로 확장."""
@@ -375,7 +405,14 @@ def try_buy_any_branches(
) -> DiscoveredRules:
"""매수 OR 분기: 다른 봉의 cross_up_lower / hammer 등."""
train = matrix.iloc[:train_end]
- triggers = [p for p in pool if p.endswith(":cross_up_lower") or p.endswith(":hammer")]
+ triggers = [
+ p
+ for p in pool
+ if p.endswith(":cross_up_lower")
+ or p.endswith(":hammer")
+ or p.endswith(":bb_zone_bottom")
+ or p.endswith(":ichi_tk_cross_up")
+ ]
best = DiscoveredRules(
name=base.name,
buy_all=list(base.buy_all),
@@ -476,8 +513,9 @@ def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules:
pool = generate_predicate_pool(intervals)
print(f" 샘플 {n}봉 | 학습 {train_end} | predicate 후보 {len(pool)}개")
- baseline = _baseline_rules()
+ 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}")
bf, _ = backtest_rules(matrix, baseline, df_1d, df_1h, entry_ohlc)
print(f" 기준선(3분 BB만): 학습 {br:+.2f}% | 전체 {bf:+.2f}%")
diff --git a/simulation.py b/simulation.py
new file mode 100644
index 0000000..6f5e4de
--- /dev/null
+++ b/simulation.py
@@ -0,0 +1,855 @@
+"""
+WLD 3분 BB 시뮬레이션.
+
+기본: 하단 상향 돌파 매수, 상단 상향 돌파 매도.
+수수료 반영, 레짐/필터 조합 비교 지원.
+
+ python simulation.py # analyze → discover → HTML (탐색 규칙 매수·매도)
+ python simulation.py analyze # (고급) 조합 분석만
+ python simulation.py discover # (고급) 규칙 탐색만
+ python simulation.py compare # (고급) 9종 프리셋 비교
+ python simulation.py mtf # (고급) 구 MTF BB 정책
+"""
+
+from __future__ import annotations
+
+import sys
+import webbrowser
+from dataclasses import dataclass
+from pathlib import Path
+
+import pandas as pd
+import plotly.graph_objs as go
+from plotly.subplots import make_subplots
+
+from config import (
+ BUY_COOLDOWN_SEC,
+ COIN_NAME,
+ ENTRY_INTERVAL,
+ SELL_COOLDOWN_SEC,
+ SIM_INITIAL_CASH_KRW,
+ SIM_MIN_ORDER_KRW,
+ SYMBOL,
+ TRADING_FEE_RATE,
+ TREND_INTERVAL_1D,
+ TREND_INTERVAL_1H,
+)
+from monitor import Monitor
+import strategy
+
+REPORT_DIR = Path(__file__).resolve().parent / "reports"
+OUTPUT_HTML = REPORT_DIR / "wld_bb_simulation.html"
+
+
+def interval_chart_label(interval_min: int) -> str:
+ """차트 제목용 봉 라벨."""
+ if interval_min >= 1440:
+ return "일봉"
+ return f"{interval_min}분봉"
+
+
+def _add_trade_markers(fig, trades: list["SimTrade"], row: int = 1) -> None:
+ """
+ 매수·매도 마커·라벨 (Scatter trace만 사용, 범례와 함께 토글).
+ simulate_mtf.py 와 동일 스타일.
+ """
+ for action, color, symbol, label, text_pos in [
+ ("매수", "#16a34a", "triangle-up", "매수", "top center"),
+ ("매도", "#dc2626", "triangle-down", "매도", "bottom center"),
+ ]:
+ pts = [t for t in trades if t.action == action]
+ if not pts:
+ continue
+ fig.add_trace(
+ go.Scatter(
+ x=[t.dt for t in pts],
+ y=[t.price for t in pts],
+ mode="markers+text",
+ name=label,
+ legendgroup=label,
+ text=[label] * len(pts),
+ textposition=text_pos,
+ textfont=dict(
+ size=12,
+ color=color,
+ family="Malgun Gothic, Arial, sans-serif",
+ ),
+ marker=dict(
+ symbol=symbol,
+ size=16,
+ color=color,
+ line=dict(width=2, color="#111"),
+ ),
+ hovertext=[
+ f"{label} 체결
{t.signal}
₩{t.price:,.2f}
₩{t.krw:,.0f}"
+ for t in pts
+ ],
+ hovertemplate="%{hovertext}
"
+ f"전략: {result.config_name} | 추세: {trend}"
+ ),
+ xref="paper",
+ yref="paper",
+ x=0.5,
+ y=0.88,
+ showarrow=False,
+ font=dict(size=14, color="#b45309"),
+ bgcolor="#fffbeb",
+ bordercolor="#f59e0b",
+ borderwidth=1,
+ )
+
+ if "RSI" in df.columns:
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df["RSI"],
+ name="RSI",
+ line=dict(color="#7c3aed"),
+ ),
+ row=2,
+ col=1,
+ )
+ fig.add_hline(y=70, line_dash="dot", line_color="#9ca3af", row=2, col=1)
+ fig.add_hline(y=50, line_dash="dot", line_color="#d1d5db", row=2, col=1)
+
+ fig.add_trace(
+ go.Bar(
+ x=df.index,
+ y=df["Volume"],
+ name="Volume",
+ marker_color="#cbd5e1",
+ ),
+ row=3,
+ col=1,
+ )
+
+ fig.update_layout(
+ height=920,
+ template="plotly_white",
+ xaxis_rangeslider_visible=False,
+ legend=dict(orientation="h", y=1.05, x=0),
+ margin=dict(l=60, r=30, t=90, b=40),
+ )
+ fig.update_yaxes(title_text="가격 (KRW)", row=1, col=1)
+ fig.update_yaxes(title_text="RSI", row=2, col=1, range=[0, 100])
+
+ chart_html = fig.to_html(full_html=False, include_plotlyjs="cdn")
+
+ trade_rows = ""
+ 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"""
+
{summary['note']}
" if summary.get("note") else "" + sells = summary["sell_signal_count"] + win_rate = ( + summary["win_count"] / sells * 100 if sells else 0.0 + ) + + return f""" + + + +| 시각 | 구분 | 상태 | 가격 | 신호 | 금액 | 수수료 | 손익 |
|---|