diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3fb85d9
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,28 @@
+# 빗썸 API
+BITHUMB_ACCESS_KEY=
+BITHUMB_SECRET_KEY=
+
+# 텔레그램
+COIN_TELEGRAM_BOT_TOKEN=
+COIN_TELEGRAM_CHAT_ID=
+
+# 쿨다운(초) — 3분 전략
+BUY_COOLDOWN_SEC=300
+SELL_COOLDOWN_SEC=180
+
+# 매수 금액(KRW)
+DEFAULT_BUY_KRW=30000
+RANGE_BUY_KRW=15000
+BREAKOUT_BUY_KRW=25000
+
+# BB: 폭이 BB_MIN_WIDTH_PCT(%) 미만이면 매매 중단
+BB_MIN_WIDTH_PCT=0.8
+
+# downloader.py — coins.db 적재 개월 수
+DOWNLOAD_MONTHS=6
+
+# simulation_1h.py
+SIM_INITIAL_CASH_KRW=200000
+SIM_MIN_ORDER_KRW=5000
+TRADING_FEE_RATE=0.0005
+RSI_BUY_MAX=42
diff --git a/HTS2.py b/HTS2.py
index f378731..eee6a30 100644
--- a/HTS2.py
+++ b/HTS2.py
@@ -1,3 +1,4 @@
+import os
import pandas as pd
import jwt
import uuid
@@ -7,22 +8,20 @@ import json
import hashlib
from urllib.parse import urlencode
+
class HTS:
+ """빗썸 Open API 래퍼 (시세 조회, 잔고, 주문)."""
bithumb = None
- accessKey = "a5d33ce55f598185d37cd26272341b7b965c31a59457f7" # 본인의 Connect Key를 입력한다.
- secretKey = "ODBiYWFmNWE2MTkwYjdhMTNhZTM1YjU5OGY4OGE2MGNkNDY2NzMzMjE2Nzc5NDVlMzBhMDk3NTNmM2M2Mg==" # 본인의 Secret Key를 입력한다.
- apiUrl = 'https://api.bithumb.com'
+ accessKey = ""
+ secretKey = ""
+ apiUrl = "https://api.bithumb.com"
def __init__(self):
- #self.bithumb = pybithumb.Bithumb(self.con_key, self.sec_key)
-
self.bithumb = None
- self.accessKey = "a5d33ce55f598185d37cd26272341b7b965c31a59457f7" # 본인의 Connect Key를 입력한다.
- self.secretKey = "ODBiYWFmNWE2MTkwYjdhMTNhZTM1YjU5OGY4OGE2MGNkNDY2NzMzMjE2Nzc5NDVlMzBhMDk3NTNmM2M2Mg==" # 본인의 Secret Key를 입력한다.
- self.apiUrl = 'https://api.bithumb.com'
-
- return
+ self.accessKey = os.getenv("BITHUMB_ACCESS_KEY", "")
+ self.secretKey = os.getenv("BITHUMB_SECRET_KEY", "")
+ self.apiUrl = "https://api.bithumb.com"
def append(self, stock, df=None, data_1=None):
if df is not None:
diff --git a/PROMPT.txt b/PROMPT.txt
deleted file mode 100644
index 23b2b2b..0000000
--- a/PROMPT.txt
+++ /dev/null
@@ -1,24 +0,0 @@
-#1.
-봉을 모두 1500개를 출력하고 있습니다.
-전반적으로 그래프를 이해하세요.
-그리고 시가, 종가, 고가, 저가, 거래량을 활용하여 많은 기술적 분석을 시도하세요.
-그리고 최적의 저점에 매수를 할 수 있도록 탐색코드를 작성하세요.
-
-모든 코인에 동일 조건을 적용할 수 있도록 활용하는 수치는 모두 정규화를 시킨 이후에 시도해야 합니다.
-
-코드에 탐색을 반영하고 실행하세요, 그리고 매수 시점을 표기하세요. 그리고 최적이 아니라면 다시 실행하세요. 최적의 매수시점을 찾을 때까지 반복하세요.
-
-
-#2.
-전제 소스코들 살펴보세요. 데이터는 모든 코인에 동일 적용할 수 있도록 표준화가 되어야 합니다. 그리고 기술적 분석을 이용하여 매수 타이밍을 탐색해야 합니다.
-혹시 기술적 분석이 아닌 날짜가 매수조건에 들어가 있다면 그러한 부분은 모두 제거해주세요.
-
-
-#3.
-5월 6일과 5월 8일 급락하고 그 이후 상승 전환되었습니다. 이 지점만 탐지해서 매수할 수 있는 기술적 기법을 고민해서 찾아주세요. 그리고 저점이라는 매수 구분자로 코드로 반영해주세요. 가장 먼서 실행을 해서 데이터를 확인하세요. 그리고 탐지할때까지 계속 설계와 코드 반영 그리고 다시 실행을 반복하세요. stock_monitor.py는 스케줄러입니다. 시뮬레이션은 stock_simulation.py를 사용하세요.
-
-특정 월의 데이터를 가져오기 위한 코드를 추가 작성하지 마세요. 데이터는 현재 전체 기간을 이용해서 기술적 분석을 하고 저점을 찾으세요.
-
-calculate_technical_indicators 함수에 기술적 분석들을 작성하세요
-check_point 함수에서 매수 여부를 판단하세요.
-저점 매수는 구분지 buy_lower를 사용하세요.
\ No newline at end of file
diff --git a/README.md b/README.md
index 3c364e8..8b85caa 100644
--- a/README.md
+++ b/README.md
@@ -1,146 +1,50 @@
-# AssetMonitor 주식·코인 모니터링 시스템
+# DeepCoin — WLD 볼린저 MTF
-## 개요
-`AssetMonitor`는 주식‧ETF 및 암호화폐 시장을 실시간으로 감시하여 Bollinger Band, RSI, MACD, 이동평균(Golden-Cross), 거래량 등을 종합 분석한 **매수 후보(signals)**를 텔레그램으로 통보하는 자동화 봇입니다.
+빗썸 KRW-WLD 현물 전용. **모든 봉**에 동일한 BB 규칙을 적용하고, 봉별 상태를 비교해 실행·확인 봉을 정합니다.
-**주요 개선사항:**
-- **데이터 표준화**: 모든 코인에 동일한 기술적 분석 기준 적용
-- **순수 기술적 분석**: 날짜 기반 조건 제거, 기술적 지표만 사용
-- **강화된 기술적 지표**: 스토캐스틱, MFI, OBV, ATR 등 추가 지표 활용
+## BB 기본 규칙 (모든 간격 동일)
----
-
-## 주요 구성 파일
-| 파일 | 설명 |
+| 구분 | 조건 |
|------|------|
-| `config.py` | ✅ API 토큰, 텔레그램 채널 ID, 볼린저 밴드/임계값, 모니터링 자산 목록(KR_COINS, US_STOCKS, KR_ETFS) 등 전역 설정을 보관합니다. |
-| `stock_monitor.py` | 시스템의 핵심 로직이 담긴 실행 스크립트입니다.
• 데이터 수집 ⇒ 기술적 지표 계산 ⇒ 매수 신호 판단 ⇒ 메시지 포맷팅/발송
• `schedule` 라이브러리로 정해진 시간마다 작업을 자동 실행합니다. |
-| `requirements.txt` | 프로젝트 의존 패키지를 명시합니다. |
+| 매수 | 이전 종가 ≤ 하단, 현재 종가 > 하단 (하단 **상향 돌파**) |
+| 매도 | 이전 종가 < 상단, 현재 종가 ≥ 상단 (상단 **상향 돌파**) |
+| 손절(선택) | 하단 재이탈 |
----
+**MTF 적용** (`mtf_bb.py`, `ACTIVE_MTF_POLICY` / `mtf_bb_policy.json`)
-## 데이터 흐름
-1. **스케줄 트리거** (`run_schedule`)
- 지정된 시각에 각 모니터링 함수가 호출됩니다.
-2. **데이터 획득**
- *주식 / ETF*: `FinanceDataReader`
- *암호화폐*: 빗썸 **240분 봉** Open API
-3. **데이터 표준화** (`normalize_data`)
- - 모든 코인에 동일한 정규화 적용
- - 20일 롤링 윈도우 기반 Min-Max 정규화
-4. **기술적 지표 계산** (`calculate_technical_indicators`)
- - Bollinger Band (기간 20, ±2σ)
- - RSI(14)
- - MACD(12-26-9)
- - 단/중/장기 이동평균선(MA5/20/60)
- - 거래량 MA5
- - **추가 지표**: 스토캐스틱, OBV, ATR, MFI
-5. **매수 후보 판정** (`check_signals`)
-- *아래 새로운 "매수 후보 전략" 섹션 참조*
-6. **알림 발송** (`send_*_telegram_message`)
- multiprocessing Pool을 이용해 다중 메시지를 병렬로 전송합니다.
+- 실행 봉: 3·10·15·30·60분 중 백테스트 수익률 1위
+- 확인 봉: 60분·일봉 등 상위 봉 상태가 매수/매도에 맞을 때만 체결
+- 하락 추세: 매수 차단 (설정 시)
----
+봉별 상태: `inside`, `cross_up_lower`, `cross_up_upper`, `below_lower`, `above_upper`, `squeeze` 등
-## 매수 후보 전략 (표준화된 기술적 분석)
+## 파일
-| 신호 | 변수명 | 조건 | 의미 |
-|------|--------|------|------|
-| 볼린저 하단 근접 | `bb_signal` | `(현재가 - LowerBand) / (UpperBand - LowerBand) < BOLLINGER_THRESHOLD` | 밴드 하단(과매도 영역) 접근 |
-| RSI 과매도 | `rsi_signal` | `RSI < 30` | 추세 과매도 |
-| MACD 골든크로스 | `macd_signal` | `이전 MACD < 이전 Signal` **AND** `현재 MACD > 현재 Signal` | 모멘텀 전환 |
-| 이동평균 골든크로스 | `ma_signal` | `이전 MA5 < 이전 MA20` **AND** `현재 MA5 ≥ 현재 MA20` | 단기 추세 ↗ 전환 |
-| 거래량 급증 | `volume_signal` | `현재 거래량 > MA5 Volume × 1.5` | 수급 증가 |
-| **U자 반등 돌파** | `breakout_signal` | ① 최근 `BREAKOUT_LOOKBACK`(30)개 캔들 동안 최고·최저가 차이가 `BUY_THRESHOLD`(15 %) 이상 하락 → ② **현재가가 그 최고가 돌파** | 하락 후 반등의 추세 전환 확인 |
-| **장기 저항 돌파** | `long_breakout_signal` | 장기간 저항선 돌파 감지 | 장기 추세 전환 |
-| **스토캐스틱 과매도** | `stoch_signal` | `%K < 20 AND %K > 이전 %K` | 스토캐스틱 과매도 반등 |
-| **MFI 과매도** | `mfi_signal` | `MFI < 20 AND MFI > 이전 MFI` | 자금 흐름 과매도 반등 |
-| **OBV 상승** | `obv_signal` | `현재 OBV > 이전 OBV × 1.1` | 거래량 가중 상승 |
-| **ATR 급증** | `atr_signal` | `현재 ATR > ATR 20일 평균 × 1.5` | 변동성 급증 |
+| 파일 | 역할 |
+|------|------|
+| `strategy.py` | 신호·금액·매도 비율 |
+| `monitor.py` | MTF 데이터, `process_wld_mtf`, 현물 주문 |
+| `monitor_coin.py` | 실시간 루프 |
+| `downloader.py` | `coins.db` (3분·1시간·일봉) |
+| `mtf_bb.py` | 봉별 BB 비교·정책 추천 |
+| `simulation_1h.py` | 백테스트 차트 |
-### 최종 매수 후보 결정 로직
-```text
-if breakout_signal or long_breakout_signal:
- buy = True # 돌파 신호 단독으로도 매수 후보
-else:
- # ① 볼린저 + RSI 동시, 또는 ② (신호 ≥ 3개) & (볼린저 또는 RSI 포함)
- buy = (bb_signal and rsi_signal) or (signal_count >= 3 and (bb_signal or rsi_signal))
-```
-*`signal_count` = 위 11개 신호 중 True 개수*
+## 실행
-### 메시지 구성
-- `매수` : 최종 `buy=True`일 때 메시지 맨 앞에 부착
-- `신호(n):` 뒤에 활성화된 신호 목록
- - 볼린저/RSI/MACD/MA/거래량/Breakout/스토캐스틱/MFI/OBV/ATR 각각 표시
-
-해당 전략으로 **과매도 바닥근처 매수 기회 + 상승 추세 전환 브레이크아웃** 두 영역을 모두 포착할 수 있습니다.
-
----
-
-## 스케줄 테이블 (기본값)
-| 대상 | 실행 시각(서버 기준) | 호출 함수 |
-|------|----------------------|-----------|
-| KRW 코인 | 매시간 04, 14, 24, 34, 44, 54분 | `monitor_coins()` |
-| 미국 주식 / ETF | 05:10, 16:30, 23:30 | `monitor_us_stocks()` |
-| 한국 ETF / 주식 | 07:10, 18:20 | `monitor_kr_stocks()` |
-
-> 시간은 `config.py`가 아닌 `stock_monitor.py`의 `run_schedule()` 내부에 하드코딩되어 있습니다. 필요 시 직접 수정하세요.
-
----
-
-## 설치 방법
-1. Python ≥ 3.9 환경을 준비합니다.
-2. 저장소를 클론하고 디렉터리로 이동:
```bash
-$ git clone
-$ cd AssetMonitor
-```
-3. 패키지 설치:
-```bash
-$ pip install -r requirements.txt
-```
-4. **보안 키 등록**
- 민감 정보는 코드에 직접 기록하지 말고 *환경 변수*로 주입하기를 권장합니다.
-```bash
-# zsh 예시
-export COIN_TELEGRAM_BOT_TOKEN=""
-export STOCK_TELEGRAM_BOT_TOKEN=""
-export COIN_TELEGRAM_CHAT_ID=""
-export STOCK_TELEGRAM_CHAT_ID=""
-```
- 또는 `config.py` 내부 상수를 직접 수정할 수 있습니다.
-
----
-
-## 사용 방법
-```bash
-$ python monitor_coin.py
-```
-스크립트가 백그라운드에서 무한 루프로 동작하며 지정된 시간마다 텔레그램 알림을 전송합니다.
-
-### Docker(선택)
-컨테이너 실행 예시:
-```dockerfile
-FROM python:3.11-slim
-WORKDIR /app
-COPY . .
-RUN pip install -r requirements.txt
-CMD ["python", "stock_monitor.py"]
+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 최적화 후 연동 예정
```
----
+`DOWNLOAD_MONTHS=6` — 간격: **3, 10, 15, 30, 60, 240, 1440**분.
+**증분 저장**: DB `MAX(ymdhms)` 이후 봉만 INSERT (재실행 시 전체 삭제 없음).
-## 커스터마이징
-- **자산 목록 추가/삭제**: `config.py`의 `KR_COINS`, `US_STOCKS`, `KR_ETFS` 사전을 편집합니다.
-- **임계값·기간 조정**: `BOLLINGER_PERIOD`, `BOLLINGER_STD`, `BOLLINGER_THRESHOLD`, `BUY_THRESHOLD` 등 변경.
-
----
-
-## 한계 및 면책 조항
-본 프로젝트는 교육·연구 목적의 오픈소스 예제로, 투자 손실에 대해 어떠한 책임도 지지 않습니다. 실거래에 사용하려면 충분한 검증과 백테스트를 진행하십시오.
-
----
-
-## 라이선스
-MIT (프로젝트 루트의 `LICENSE` 파일 참조, 미존재 시 필요에 따라 추가하세요.)
+## 환경 변수
+`BITHUMB_ACCESS_KEY`, `BITHUMB_SECRET_KEY`, `COIN_TELEGRAM_*`,
+`BUY_COOLDOWN_SEC`(기본 300), `SELL_COOLDOWN_SEC`(180), `DEFAULT_BUY_KRW` 등 — `.env.example` 참고.
diff --git a/candle_features.py b/candle_features.py
new file mode 100644
index 0000000..c088b07
--- /dev/null
+++ b/candle_features.py
@@ -0,0 +1,156 @@
+"""
+모든 봉 간격에 대해 BB 위치·캔들 형태(몸통/꼬리/높이) 특징을 계산하고
+기준 타임라인(3분)에 맞춰 정렬합니다.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from config import ENTRY_INTERVAL
+from strategy import prepare_entry_df
+
+INTERVAL_LABELS: dict[int, str] = {
+ 3: "m3",
+ 10: "m10",
+ 15: "m15",
+ 30: "m30",
+ 60: "m60",
+ 240: "m240",
+ 1440: "d1",
+}
+
+
+def interval_prefix(interval: int) -> str:
+ """컬럼 접두사 (예: m3, d1)."""
+ return INTERVAL_LABELS.get(interval, f"m{interval}")
+
+
+def compute_bar_features(df: pd.DataFrame) -> pd.DataFrame:
+ """단일 봉 DataFrame에 위치·캔들 높이 특징을 추가합니다."""
+ out = prepare_entry_df(df.copy())
+ if len(out) < 2:
+ return out
+
+ o = out["Open"].astype(float)
+ h = out["High"].astype(float)
+ l = out["Low"].astype(float)
+ c = out["Close"].astype(float)
+ prev_c = c.shift(1)
+ upper = out["Upper"].astype(float)
+ 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()
+ out["range_pct"] = (rng / c.replace(0, np.nan)) * 100
+ out["body_ratio"] = (body / rng).fillna(0).clip(0, 1)
+ 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)
+
+ out["cross_up_lower"] = ((prev_c <= prev_lower) & (c > lower)).astype(int)
+ out["cross_up_upper"] = ((prev_c < prev_upper) & (c >= upper)).astype(int)
+ out["cross_down_lower"] = ((prev_c >= prev_lower) & (c < lower)).astype(int)
+ 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"] = (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)
+
+ 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 _merge_interval_features(
+ master_index: pd.DatetimeIndex,
+ feat: pd.DataFrame,
+ prefix: str,
+) -> 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]
+ sub = feat[pick + extra].copy()
+ sub.columns = [f"{prefix}_{c}" for c in sub.columns]
+
+ left = pd.DataFrame({"ts": master_index})
+ right = sub.reset_index()
+ time_col = right.columns[0]
+ right = right.rename(columns={time_col: "ts"})
+
+ merged = pd.merge_asof(
+ left.sort_values("ts"),
+ right.sort_values("ts"),
+ on="ts",
+ direction="backward",
+ )
+ merged.index = master_index
+ return merged.drop(columns=["ts"])
+
+
+def build_master_feature_matrix(frames: dict[int, pd.DataFrame]) -> pd.DataFrame:
+ """3분 타임라인에 모든 봉의 위치·캔들 특징을 붙인 행렬 (인덱스 유일)."""
+ entry = frames.get(ENTRY_INTERVAL)
+ if entry is None or entry.empty:
+ raise ValueError("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)
+ 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"):
+ if col in entry_feat.columns:
+ master[f"{p3}_{col}"] = entry_feat[col]
+
+ parts = [master]
+ for interval, df in sorted(frames.items()):
+ if interval == ENTRY_INTERVAL or 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))
+
+ out = pd.concat(parts, axis=1)
+ return out.loc[:, ~out.columns.duplicated()]
diff --git a/coins_buy_time.json b/coins_buy_time.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/coins_buy_time.json
@@ -0,0 +1 @@
+{}
diff --git a/coins_buy_time_1h_1.json b/coins_buy_time_1h_1.json
deleted file mode 100644
index fa338dc..0000000
--- a/coins_buy_time_1h_1.json
+++ /dev/null
@@ -1,66 +0,0 @@
-{
- "ARB": {
- "buy": {
- "datetime": "2025-08-18T11:22:46.083340",
- "signal": "fall_6p"
- }
- },
- "ENA": {
- "buy": {
- "datetime": "2025-08-19T23:41:47.822635",
- "signal": "deviation240"
- }
- },
- "BONK": {
- "buy": {
- "datetime": "2025-09-07T19:23:01.960389",
- "signal": "movingaverage"
- }
- },
- "PEPE": {
- "buy": {
- "datetime": "2025-09-06T12:33:35.064847",
- "signal": "movingaverage"
- }
- },
- "HBAR": {
- "buy": {
- "datetime": "2025-09-04T00:35:27.567509",
- "signal": "Deviation720"
- }
- },
- "ONDO": {
- "buy": {
- "datetime": "2025-09-02T12:32:52.105429",
- "signal": "Deviation720"
- }
- },
- "APT": {
- "buy": {
- "datetime": "2025-09-05T23:02:01.568291",
- "signal": "movingaverage"
- }
- },
- "APE": {
- "buy": {
- "datetime": "2025-09-06T16:59:22.979500",
- "signal": "movingaverage"
- }
- },
- "KAIA": {
- "buy": {
- "datetime": "2025-09-05T16:51:55.172129",
- "signal": "movingaverage"
- }
- },
- "PENGU": {
- "buy": {
- "datetime": "2025-09-06T17:01:45.419112",
- "signal": "Deviation720"
- },
- "sell": {
- "datetime": "2025-09-03T06:15:29.849873",
- "signal": "fall_6p"
- }
- }
-}
\ No newline at end of file
diff --git a/coins_buy_time_1h_2.json b/coins_buy_time_1h_2.json
deleted file mode 100644
index e3613ab..0000000
--- a/coins_buy_time_1h_2.json
+++ /dev/null
@@ -1,74 +0,0 @@
-{
- "SUI": {
- "buy": {
- "datetime": "2025-09-03T00:49:31.559907",
- "signal": "Deviation720"
- }
- },
- "TRX": {
- "buy": {
- "datetime": "2025-09-07T19:23:23.239284",
- "signal": "deviation240"
- }
- },
- "VIRTUAL": {
- "buy": {
- "datetime": "2025-09-07T19:23:39.470757",
- "signal": "movingaverage"
- }
- },
- "WLD": {
- "buy": {
- "datetime": "2025-09-06T17:00:58.538967",
- "signal": "movingaverage"
- }
- },
- "XRP": {
- "buy": {
- "datetime": "2025-09-01T14:49:49.052879",
- "signal": ""
- }
- },
- "SEI": {
- "buy": {
- "datetime": "2025-09-02T05:39:37.438356",
- "signal": ""
- }
- },
- "POL": {
- "buy": {
- "datetime": "2025-09-02T05:40:39.898129",
- "signal": ""
- }
- },
- "SHIB": {
- "buy": {
- "datetime": "2025-09-06T16:35:02.824079",
- "signal": "movingaverage"
- }
- },
- "STORJ": {
- "buy": {
- "datetime": "2025-09-02T07:07:30.148618",
- "signal": "Deviation720"
- }
- },
- "TON": {
- "buy": {
- "datetime": "2025-09-02T06:27:28.447305",
- "signal": ""
- }
- },
- "UXLINK": {
- "buy": {
- "datetime": "2025-09-04T03:07:42.030095",
- "signal": "movingaverage"
- }
- },
- "XLM": {
- "buy": {
- "datetime": "2025-09-02T05:49:19.368261",
- "signal": ""
- }
- }
-}
\ No newline at end of file
diff --git a/config.py b/config.py
index 95d882a..f39c8cb 100644
--- a/config.py
+++ b/config.py
@@ -1,258 +1,68 @@
+"""
+전역 설정 (WLD 월드코인, 3분 BB MTF 전략).
+"""
+
import os
-# 텔레그램 설정
-COIN_TELEGRAM_BOT_TOKEN = "6435061393:AAHOh9wB5yGNGUdb3SfCYJrrWTBe7wgConM"
-COIN_TELEGRAM_CHAT_ID = '574661323'
+try:
+ from dotenv import load_dotenv
-STOCK_TELEGRAM_BOT_TOKEN = "6874078562:AAEHxGDavfc0ssAXPQIaW8JGYmTR7LNUJOw"
-STOCK_TELEGRAM_CHAT_ID = '574661323'
+ load_dotenv()
+except ImportError:
+ pass
-# 몇초 만에 다시 매수를 할 것인지 체크
-BUY_MINUTE_LIMIT = 900
+# --- API / 알림 ---
+COIN_TELEGRAM_BOT_TOKEN = os.getenv("COIN_TELEGRAM_BOT_TOKEN", "")
+COIN_TELEGRAM_CHAT_ID = os.getenv("COIN_TELEGRAM_CHAT_ID", "")
-# 볼린저 밴드 설정
-BOLLINGER_PERIOD = 20 # 볼린저 밴드 기간
-BOLLINGER_STD = 2 # 표준편차 승수
-BOLLINGER_THRESHOLD = 0.10 # 하단 밴드 대비 10% 근접 시 알림
-BUY_THRESHOLD = 0.15
-BREAKOUT_LOOKBACK = 30 # U자 반등 후 돌파 판단에 사용할 과거 캔들 수 (4시간봉 기준 약 5일)
-BREAKOUT_WEEK_LOOKBACK = 42 # 4시간봉 1주일 ≒ 42개
-BREAKOUT_WEEK_LIMIT = 0.05 # 1주일 대비 5% 미만 상승 조건
+# --- 거래 대상 ---
+SYMBOL = "WLD"
+COIN_NAME = "월드코인"
-# 볼린저 밴드 squeeze 탐지 임계값 (밴드폭/중심선)
-SQUEEZE_THRESHOLD = 0.04 # 4% 이하
-
-# 장기간 저항선 돌파 감지 설정
-RESISTANCE_LOOKBACK = 120 # 저항선 판단을 위한 과거 캔들 수 (예: 120개)
-RESISTANCE_BREAK_THRESHOLD = 0.01 # 저항선 대비 1% 이상 돌파 시 신호
-
-KR_COINS = {
- "ADA": "에이다",
- "APE": "에이프코인",
- "APT": "앱토스",
- "ARB": "아비트럼",
- "BONK": "봉크",
- "ENA": "에테나",
- "FANC": "팬시",
- "HBAR": "헤데라",
- "KAIA": "카이아",
- "LINK": "체인링크",
- "ONDO": "온도파이낸스",
- "PENGU": "펏지 펭귄",
- "PEPE": "페페",
- "POL": "폴리곤 에코시스템 토큰",
- "PYTH":"피스 네트워크",
- "SEI": "세이",
- "SHIB": "시바이누",
- "STORJ": "스토리지",
- "SUI": "수이",
- "TON": "톤코인",
- "TRX": "트론",
- "UXLINK": "유엑스링크",
- "VIRTUAL": "버추얼 프로토콜",
- "WLD": "월드코인",
- "XLM": "스텔라루멘",
- "XRP": "엑스알피"
+KR_COINS: dict[str, str] = {
+ SYMBOL: COIN_NAME,
}
-KR_COINS_1 = {
- "ADA": "에이다",
- "APE": "에이프코인",
- "APT": "앱토스",
- "ARB": "아비트럼",
- "BONK": "봉크",
- "ENA": "에테나",
- "FANC": "팬시",
- "HBAR": "헤데라",
- "KAIA": "카이아",
- "LINK": "체인링크",
- "ONDO": "온도파이낸스",
- "PENGU": "펏지 펭귄",
- "PEPE": "페페",
-}
+# --- 타임프레임 (분) ---
+ENTRY_INTERVAL = 3
+TREND_INTERVAL_1H = 60
+TREND_INTERVAL_1D = 1440
-KR_COINS_2 = {
- "POL": "폴리곤 에코시스템 토큰",
- "PYTH":"피스 네트워크",
- "SEI": "세이",
- "SHIB": "시바이누",
- "STORJ": "스토리지",
- "SUI": "수이",
- "TON": "톤코인",
- "TRX": "트론",
- "UXLINK": "유엑스링크",
- "VIRTUAL": "버추얼 프로토콜",
- "WLD": "월드코인",
- "XLM": "스텔라루멘",
- "XRP": "엑스알피"
-}
+# --- 쿨다운(초) ---
+BUY_COOLDOWN_SEC = int(os.getenv("BUY_COOLDOWN_SEC", "300"))
+SELL_COOLDOWN_SEC = int(os.getenv("SELL_COOLDOWN_SEC", "180"))
+BUY_MINUTE_LIMIT = BUY_COOLDOWN_SEC
-# 주식 설정
-US_STOCKS = {
- 'VOO': 'Vanguard S&P 500 ETF',
- 'SQQQ': 'ProShares UltraPro Short QQQ',
- 'QID': 'ProShares UltraShort QQQ',
- 'PSQ': 'ProShares Short QQQ',
- 'TQQQ': 'ProShares UltraPro QQQ',
- 'QQQ': 'Invesco QQQ Trust',
- 'SCO': 'ProShares UltraShort Bloomberg Crude Oil',
- 'UCO': 'ProShares Ultra Bloomberg Crude Oil',
- 'GLL': 'ProShares UltraShort Gold',
- 'UGL': 'ProShares Ultra Gold',
- 'SOXS': 'Direxion Daily Semiconductor Bear -3X Shares',
- 'SOXL': 'Direxion Daily Semiconductor Bull 3X Shares',
- 'FNGD': 'MicroSectors™ FANG+™ Index -3X Inverse Leveraged ETN',
- 'FNGU': 'MicroSectors™ FANG+™ Index 3X Leveraged ETN',
- 'FXI': 'iShares China Large-Cap ETF',
+# --- 볼린저 (3분봉, 20, 2σ) ---
+BB_PERIOD = 20
+BB_STD = 2
+BB_MIN_WIDTH_PCT = float(os.getenv("BB_MIN_WIDTH_PCT", "0.8"))
- "AAPL": "Apple / AI 칩셋",
- "ACN": "Accenture",
- "ADBE": "Adobe",
- "AMD": "Advanced Micro Devices / AI 반도체",
- "AMZN": "Amazon / AI 로봇/클라우드",
- "ASML": "ASML Holding / EUV 리소그래피",
- "ASTS": "AST SpaceMobile / 위성통신",
- "AVGO": "Broadcom",
- "BABA": "Alibaba Group Holdings Ltd ADR",
- "BAC": "Bank of America",
- "BE": "Bloom Energy / 고체산화물 연료전지",
- "CAMT": "Camtek / 반도체 계측기기6",
- "CHWY": "Chewy / 애완용품 전자상거래",
- "COIN": "Coinbase / 암호화폐 거래소",
- "COST": "Costco Wholesale / 회원제 유통",
- "CPNG": "Coupang LLC",
- "CRM": "Salesforce.com",
- "CRWD": "CrowdStrike / AI 사이버보안",
- "CSCO": "Cisco",
- "CVX": "Chevron Corp",
- "DASH": "DoorDash / 배달 플랫폼",
- "DIS": "Walt Disney",
- "DQ": "Daqo New Energy Corp ADR",
- "DXCM": "DexCom / 지속형 혈당측정기",
- "EBAY": "eBay Inc",
- "ENPH": "Enphase Energy / 태양광 인버터",
- "GEO": "GEO Group / 교정시설 운영3",
- "GOOG": "Alphabet C",
- "GOOGL": "Alphabet (Google) / AI 검색/자율주행",
- "GRVY": "Gravity / 온라인 게임",
- "HD": "Home Depot",
- "HON": "Honeywell",
- "IBM": "IBM",
- "INTC": "Intel / 차세대 반도체",
- "ISRG": "Intuitive Surgical / 수술로봇",
- "JNJ": "Johnson & Johnson (JNJ)",
- "JPM": "JPMorgan",
- "KLAC": "KLA Corporation / 반도체 검사장비",
- "KO": "Coca-Cola",
- "LB": "LandBridge Co / 에너지 인프라3",
- "LCID": "Lucid Group / 고급 전기차",
- "LMT": "Lockheed Martin / 방위 시스템",
- "LRCX": "Lam Research / 반도체 장비",
- "MA": "Mastercard",
- "MELI": "MercadoLibre / 라틴아메리카 전자상거래",
- "META": "Meta Platforms / AI 메타버스",
- "MNMD": "Mind Medicine / 사이키델릭 치료제",
- "MS": "Morgan Stanley",
- "MSFT": "Microsoft / AI 클라우드",
- "NKE": "Nike",
- "NOC": "Northrop Grumman / 우주항공",
- "NTAP": "NetApp Inc",
- "NVDA": "NVIDIA / AI 반도체",
- "ORCL": "Oracle",
- "PLTR": "Palantir Technologies / AI 데이터 분석",
- "PLUG": "Plug Power / 수소연료전지",
- "QCOM": "Qualcomm / 모바일 칩셋",
- "REGN": "Regeneron Pharmaceuticals / 항체 치료제",
- "RIVN": "Rivian Automotive / 전기트럭",
- "RKLB": "Rocket Lab / 소형위성 발사체",
- "RTX": "RTX Corporation / 제트엔진/미사일",
- "SEDG": "SolarEdge Technologies / 태양광 시스템",
- "SNOW": "Snowflake / AI 데이터 플랫폼",
- "SOFI": "SoFi Technologies / 디지털 뱅킹",
- "SPCE": "Virgin Galactic / 우주관광",
- "T": "AT&T",
- "TCTZF": "Tencent Holdings",
- "TDOC": "Teladoc Health / 원격의료",
- "TGT": "Target / 오프라인 리테일 혁신",
- "TSLA": "Tesla / 전기차/에너지 저장",
- "TSM": "Taiwan Semiconductor",
- "UNH": "UnitedHealth",
- "UPST": "Upstart Holdings / AI 대출플랫폼",
- "V": "Visa A",
- "VRTX": "Vertex Pharmaceuticals / 난치병 치료제",
- "VZ": "Verizon",
- "WGS": "GeneDx Holdings / 유전체 분석3",
- "WMT": "Walmart",
- "X": "United States Steel Corporation",
- "XOM": "Exxon Mobil"
-}
+# --- RSI / 거래량 (조합 필터) ---
+RSI_PERIOD = 14
+RSI_BUY_MAX = float(os.getenv("RSI_BUY_MAX", "42"))
+VOLUME_BUY_RATIO = float(os.getenv("VOLUME_BUY_RATIO", "1.0"))
-# 한국 ETF 설정
-KR_ETFS = {
- "251340.KS": 'KODEX 코스닥150선물인버스',
- "233740.KS": 'KODEX 코스닥150 레버리지',
- "252670.KS": 'KODEX 200선물인버스2X',
- "122630.KS": 'KODEX 레버리지',
- "114800.KS": 'KODEX 인버스',
- "283580.KS": 'KODEX 중국본토CSI300',
- "256750.KS": 'KODEX 심천ChiNext(합성)',
- "185680.KS": 'KODEX 미국S&P바이오(합성)',
- "218420.KS": 'KODEX 미국S&P에너지(합성)',
- "132030.KS": 'KODEX 골드선물(H)',
- "138920.KS": 'KODEX 콩선물(H)',
- "271060.KS": 'KODEX 3대농산물선물(H)',
- "117700.KS": 'KODEX 건설',
- "266420.KS": 'KODEX 헬스케어',
- "276990.KS": 'KODEX 글로벌4차산업로보틱스(합성)',
- "244580.KS": 'KODEX 바이오',
- "091160.KS": 'KODEX 반도체',
- "140700.KS": 'KODEX 보험',
- "266410.KS": 'KODEX 필수소비재',
- "305720.KS": 'KODEX 2차전지산업',
- "266390.KS": 'KODEX 경기소비재',
- "117680.KS": 'KODEX 철강',
- "117460.KS": 'KODEX 에너지화학',
- "091170.KS": 'KODEX 은행',
- "376410.KS": 'TIGER 탄소효율그린뉴딜',
- "005930.KS": "삼성전자 / 반도체,AI",
- "000660.KS": "SK하이닉스 / 반도체,AI",
- "035420.KS": "NAVER / 플랫폼,AI",
- "035720.KS": "카카오 / 플랫폼,AI,핀테크",
- "051910.KS": "LG화학 / 2차전지,소재",
- "373220.KS": "LG에너지솔루션 / 2차전지",
- "096770.KS": "SK이노베이션 / 2차전지,친환경",
- "066570.KS": "LG전자 / 전장,AI,가전",
- "003550.KS": "LG / 지주,전지,AI",
- "005380.KS": "현대차 / 전기차,수소차",
- "000270.KS": "기아 / 전기차,수소차",
- "086520.KS": "에코프로 / 2차전지 소재",
- "336370.KS": "솔루스첨단소재 / 2차전지,소재",
- "009150.KS": "삼성전기 / 전장,MLCC",
- "006400.KS": "삼성SDI / 2차전지",
- "011170.KS": "롯데케미칼 / 2차전지,소재",
- "010950.KS": "S-Oil / 친환경,정유",
- "034730.KS": "SK / 지주,AI,친환경",
- "028260.KS": "삼성물산 / 바이오,건설",
- "207940.KS": "삼성바이오로직스 / 바이오,CMO",
- "068270.KS": "셀트리온 / 바이오,항체치료제",
- "196170.KS": "알테오젠 / 바이오,바이오시밀러",
- "051900.KS": "LG생활건강 / 소비재,중국",
- "003490.KS": "대한항공 / 항공,물류",
- "005935.KS": "삼성전자우 / 반도체",
- "000810.KS": "삼성화재 / 보험,금융",
- "105560.KS": "KB금융 / 금융,디지털전환",
- "055550.KS": "신한지주 / 금융,디지털전환",
- "316140.KS": "우리금융지주 / 금융",
- "086790.KS": "하나금융지주 / 금융",
- "032830.KS": "삼성생명 / 보험",
- "003670.KS": "포스코홀딩스 / 2차전지,철강,수소",
- "036570.KS": "엔씨소프트 / 게임,AI",
- "011200.KS": "HMM / 해운,물류",
- "005940.KS": "NH투자증권 / 금융",
- "010130.KS": "고려아연 / 비철금속,2차전지",
- "001510.KS": "SK증권 / 금융",
- "017670.KS": "SK텔레콤 / 5G,AI",
- "030200.KS": "KT / 5G,AI",
- "033780.KS": "KT&G / 소비재,담배",
- "034020.KS": "두산에너빌리티 / 원전,친환경",
-}
+# --- 추세 / 레짐 ---
+TREND_RANGE_MA_GAP_PCT = 0.5
+
+# --- 주문 ---
+DEFAULT_BUY_KRW = int(os.getenv("DEFAULT_BUY_KRW", "30000"))
+RANGE_BUY_KRW = int(os.getenv("RANGE_BUY_KRW", "15000"))
+
+# --- 수수료 (매수·매도 각각 적용, 시뮬레이션) ---
+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)
+DOWNLOAD_MONTHS = int(os.getenv("DOWNLOAD_MONTHS", "6"))
+DB_PATH = "coins.db"
+
+# --- 시뮬레이션 ---
+SIM_INITIAL_CASH_KRW = int(os.getenv("SIM_INITIAL_CASH_KRW", "200000"))
+SIM_MIN_ORDER_KRW = int(os.getenv("SIM_MIN_ORDER_KRW", "5000"))
+
+# --- 실행 ---
+MONITOR_LOOP_SLEEP_SEC = 10
+COOLDOWN_FILE = "coins_buy_time.json"
diff --git a/discovered_rules.json b/discovered_rules.json
new file mode 100644
index 0000000..ff08afc
--- /dev/null
+++ b/discovered_rules.json
@@ -0,0 +1,22 @@
+{
+ "name": "discovered_best",
+ "buy_all": [
+ "m3:bb_pos_low",
+ "m3:shooting_star",
+ "m15:inside_band",
+ "m3:inside_band",
+ "m3:hammer"
+ ],
+ "buy_any": [],
+ "sell_all": [
+ "m3:cross_up_upper",
+ "m3:bearish",
+ "m10:cross_up_upper",
+ "m30:bb_pos_high"
+ ],
+ "sell_stop": [],
+ "train_return_pct": 1.9043499145753595,
+ "test_return_pct": 0.6201861088011792,
+ "full_return_pct": 2.524536023376539,
+ "trade_count": 9
+}
\ No newline at end of file
diff --git a/downloader.py b/downloader.py
index 8ec64d8..92f3674 100644
--- a/downloader.py
+++ b/downloader.py
@@ -1,76 +1,312 @@
+"""
+WLD 과거 봉을 빗썸 API에서 받아 coins.db에 저장합니다.
+
+- 최초: 최근 N개월 전량 적재
+- 이후: DB 마지막 시각 **이후** 봉만 추가 (증분)
+"""
+
+from __future__ import annotations
+
import sqlite3
+from datetime import datetime
-from config import *
-from HTS2 import HTS
-from monitor_coin import MonitorCoin
+import pandas as pd
+from dateutil.relativedelta import relativedelta
-monitorCoin = MonitorCoin()
-hts = HTS()
+from config import (
+ COIN_NAME,
+ DB_PATH,
+ DOWNLOAD_INTERVALS,
+ DOWNLOAD_MONTHS,
+ KR_COINS,
+ SYMBOL,
+)
+from monitor import Monitor
+
+BITHUMB_MINUTE_INTERVALS = {1, 3, 5, 10, 15, 30, 60, 240}
+# 증분 시 마지막 봉 재확인용 겹침 봉 수
+INCREMENTAL_OVERLAP_BARS = 3
-def inserData(symbol, interval, data):
- conn = sqlite3.connect('coins.db')
+def bong_count_for_months(interval_minutes: int, months: int) -> int:
+ """N개월치 봉 개수(여유분 포함)."""
+ days = months * 30
+ if interval_minutes >= 1440:
+ return days + 20
+ bars_per_day = (24 * 60) // interval_minutes
+ return days * bars_per_day + 200
+
+
+def bong_count_since(
+ interval_minutes: int, last_ts: pd.Timestamp, overlap: int = INCREMENTAL_OVERLAP_BARS
+) -> int:
+ """마지막 저장 시각 이후 필요한 API 봉 수(겹침 포함)."""
+ now = pd.Timestamp.now()
+ if last_ts.tzinfo is not None and now.tzinfo is None:
+ last_ts = last_ts.tz_localize(None)
+ delta_min = max(0, (now - last_ts).total_seconds() / 60)
+ bars = int(delta_min / interval_minutes) + overlap + 10
+ return max(bars, 50)
+
+
+def months_cutoff(months: int) -> pd.Timestamp:
+ """N개월 전 시각."""
+ return pd.Timestamp(datetime.now() - relativedelta(months=months))
+
+
+def trim_to_recent_months(data: pd.DataFrame, months: int) -> pd.DataFrame:
+ """최근 N개월 구간만 남깁니다."""
+ if data is None or data.empty:
+ return data
+ cutoff = months_cutoff(months)
+ if not isinstance(data.index, pd.DatetimeIndex):
+ data = data.copy()
+ data.index = pd.to_datetime(data.index)
+ return data[data.index >= cutoff].copy()
+
+
+def interval_label(interval: int) -> str:
+ if interval >= 1440:
+ return "일봉(1440)"
+ return f"{interval}분봉"
+
+
+def download_jobs() -> list[tuple[int, str]]:
+ labels = {
+ 3: "3분",
+ 10: "10분",
+ 15: "15분",
+ 30: "30분",
+ 60: "60분(1시간)",
+ 240: "240분(4시간)",
+ 1440: "1440분(1일)",
+ }
+ jobs = []
+ for iv in DOWNLOAD_INTERVALS:
+ if iv < 1440 and iv not in BITHUMB_MINUTE_INTERVALS:
+ print(f"경고: {iv}분봉은 빗썸 API 미지원 — 건너뜀")
+ continue
+ jobs.append((iv, labels.get(iv, f"{iv}분")))
+ return jobs
+
+
+def ensure_table(cursor, table_name: str) -> None:
+ cursor.execute(
+ f"CREATE TABLE IF NOT EXISTS {table_name} "
+ "(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
+ "Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
+ )
+ cursor.execute(
+ f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
+ )
+
+
+def get_last_timestamp(
+ symbol: str, interval: int, db_path: str = DB_PATH
+) -> pd.Timestamp | None:
+ """테이블에 저장된 해당 심볼의 마지막 봉 시각."""
+ conn = sqlite3.connect(db_path)
cursor = conn.cursor()
+ table_name = f"{symbol}_{interval}"
+ ensure_table(cursor, table_name)
+ cursor.execute(
+ f"SELECT MAX(ymdhms) FROM {table_name} WHERE CODE = ?",
+ (symbol,),
+ )
+ row = cursor.fetchone()
+ conn.close()
+ if row and row[0]:
+ return pd.Timestamp(row[0])
+ return None
- tableName = "{}_{}".format(symbol, str(interval))
- # 테이블/키 생성
- cursor.execute("CREATE TABLE IF NOT EXISTS {} (CODE text, NAME text, ymdhms datetime, ymd text, hms text, Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)".format(tableName))
- cursor.execute("CREATE INDEX IF NOT EXISTS {}_idx on {}(CODE, ymdhms)".format(tableName, tableName))
- for i in range(len(data)):
- ymd = data.index[i].strftime('%Y%m%d')
- hms = data.index[i].strftime('%H%M%S')
- ymdhms = data.index[i].strftime('%Y-%m-%d %H:%M:%S')
- Open = data.Open.iloc[i]
- High = data.High.iloc[i]
- Low = data.Low.iloc[i]
- Close = data.Close.iloc[i]
- Volume = data.Volume.iloc[i]
+def get_row_count(symbol: str, interval: int, db_path: str = DB_PATH) -> int:
+ """저장된 봉 개수."""
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+ table_name = f"{symbol}_{interval}"
+ ensure_table(cursor, table_name)
+ cursor.execute(
+ f"SELECT COUNT(*) FROM {table_name} WHERE CODE = ?",
+ (symbol,),
+ )
+ row = cursor.fetchone()
+ conn.close()
+ return int(row[0]) if row else 0
- cursor.execute("SELECT * from {} where CODE = ? and ymdhms = ?".format(tableName), (symbol, ymdhms, ))
- arr = cursor.fetchone()
- if arr:
- cursor.execute("UPDATE {} SET Close=?, Open=?, High=?, Low=?, Volume=? where CODE=? and ymdhms=?".format(tableName), (Close, Open, High, Low, Volume, symbol, ymdhms))
- else:
- cursor.execute("INSERT INTO {} (CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".format(tableName), (symbol, KR_COINS[symbol], ymdhms, ymd, hms, Close, Open, High, Low, Volume))
+def filter_after_last(
+ data: pd.DataFrame, last_ts: pd.Timestamp | None
+) -> pd.DataFrame:
+ """마지막 저장 시각보다 이후(>)인 봉만 반환."""
+ if data is None or data.empty or last_ts is None:
+ return data
+ if not isinstance(data.index, pd.DatetimeIndex):
+ data = data.copy()
+ data.index = pd.to_datetime(data.index)
+ last = pd.Timestamp(last_ts)
+ return data[data.index > last].copy()
+
+
+def prune_before_cutoff(
+ symbol: str, interval: int, months: int, db_path: str = DB_PATH
+) -> int:
+ """N개월보다 오래된 봉 삭제 (DB 용량 유지)."""
+ cutoff = months_cutoff(months).strftime("%Y-%m-%d %H:%M:%S")
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+ table_name = f"{symbol}_{interval}"
+ ensure_table(cursor, table_name)
+ cursor.execute(
+ f"DELETE FROM {table_name} WHERE CODE = ? AND ymdhms < ?",
+ (symbol, cutoff),
+ )
+ deleted = cursor.rowcount
conn.commit()
cursor.close()
conn.close()
- return
+ return deleted
-def download():
- for symbol in KR_COINS:
- print(symbol)
- # 1일
- interval = 1440
- data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=5000)
- if data is not None and not data.empty:
- try:
- inserData(symbol, interval, data)
- except Exception as e:
- print(f"Error processing data for {symbol}: {str(e)}")
+def append_data(
+ symbol: str,
+ interval: int,
+ data: pd.DataFrame,
+ last_ts: pd.Timestamp | None = None,
+ db_path: str = DB_PATH,
+) -> tuple[int, int]:
+ """
+ 마지막 시각 이후 봉만 INSERT합니다. 기존 데이터는 삭제하지 않습니다.
- # 1시간
- interval = 60
- data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=10000)
- if data is not None and not data.empty:
- try:
- inserData(symbol, interval, data)
- except Exception as e:
- print(f"Error processing data for {symbol}: {str(e)}")
+ Args:
+ last_ts: None이면 전체 data 적재, 있으면 index > last_ts 만 적재
- # 5분
- interval = 5
- data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=10000)
- if data is not None and not data.empty:
- try:
- inserData(symbol, interval, data)
- except Exception as e:
- print(f"Error processing data for {symbol}: {str(e)}")
+ Returns:
+ (추가된 행 수, 스킵된 행 수)
+ """
+ if data is None or data.empty:
+ return 0, 0
+
+ total = len(data)
+ to_save = data if last_ts is None else filter_after_last(data, last_ts)
+ skipped = total - len(to_save)
+
+ if to_save.empty:
+ return 0, skipped
+
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+ table_name = f"{symbol}_{interval}"
+ ensure_table(cursor, table_name)
+
+ records = []
+ for i in range(len(to_save)):
+ ts = to_save.index[i]
+ if hasattr(ts, "to_pydatetime"):
+ ts = ts.to_pydatetime()
+ ymd = ts.strftime("%Y%m%d")
+ hms = ts.strftime("%H%M%S")
+ ymdhms = ts.strftime("%Y-%m-%d %H:%M:%S")
+ records.append(
+ (
+ symbol,
+ KR_COINS[symbol],
+ ymdhms,
+ ymd,
+ hms,
+ float(to_save["Open"].iloc[i]),
+ float(to_save["High"].iloc[i]),
+ float(to_save["Low"].iloc[i]),
+ float(to_save["Close"].iloc[i]),
+ float(to_save["Volume"].iloc[i]),
+ )
+ )
+
+ cursor.executemany(
+ f"INSERT INTO {table_name} "
+ "(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ records,
+ )
+ conn.commit()
+ cursor.close()
+ conn.close()
+ return len(records), skipped
+
+
+def download_symbol(
+ monitor: Monitor,
+ symbol: str,
+ interval: int,
+ months: int,
+) -> None:
+ """한 간격의 봉을 API로 받아 증분 저장합니다."""
+ label = interval_label(interval)
+ last_ts = get_last_timestamp(symbol, interval)
+ existing = get_row_count(symbol, interval)
+
+ if last_ts is None:
+ target = bong_count_for_months(interval, months)
+ mode = "초기 적재"
+ else:
+ target = min(
+ bong_count_since(interval, last_ts),
+ bong_count_for_months(interval, months),
+ )
+ mode = f"증분 (마지막 {last_ts.strftime('%Y-%m-%d %H:%M:%S')} 이후)"
+
+ print(f"\n[{symbol}] {label} — {mode}")
+ print(f" DB 기존 {existing}행 | API 목표 약 {target}봉")
+
+ data = monitor.get_coin_more_data(
+ symbol, interval, bong_count=target, verbose=True
+ )
+ if data is None or data.empty:
+ print(" -> API 데이터 없음")
+ return
+
+ data = trim_to_recent_months(data, months)
+ if data.empty:
+ print(" -> 최근 N개월 필터 후 데이터 없음")
+ return
+
+ inserted, skipped = append_data(symbol, interval, data, last_ts=last_ts)
+ pruned = prune_before_cutoff(symbol, interval, months)
+
+ new_last = get_last_timestamp(symbol, interval)
+ total = get_row_count(symbol, interval)
+ print(f" -> API {len(data)}봉 | 추가 {inserted}행 | 스킵(기존) {skipped}행")
+ if pruned > 0:
+ print(f" -> {months}개월 이전 {pruned}행 정리")
+ if new_last is not None:
+ print(f" -> DB 합계 {total}행 | {data.index[0]} ~ {new_last}")
+
+
+def download(months: int | None = None) -> None:
+ """
+ WLD 다중 분봉·일봉을 coins.db에 증분 적재합니다.
+
+ 간격: config.DOWNLOAD_INTERVALS
+ """
+ months = months or DOWNLOAD_MONTHS
+ monitor = Monitor(cooldown_file=None)
+ jobs = download_jobs()
+
+ intervals_str = ", ".join(str(iv) for iv, _ in jobs)
+ print(f"=== {COIN_NAME} ({SYMBOL}) -> {DB_PATH} (증분 INSERT) ===")
+ print(f"보관 {months}개월 | 간격(분): {intervals_str}")
+ started = datetime.now()
+
+ for interval, desc in jobs:
+ print(f"\n--- {desc} ---")
+ try:
+ download_symbol(monitor, SYMBOL, interval, months)
+ except Exception as e:
+ print(f"오류 interval={interval}: {e}")
+
+ elapsed = datetime.now() - started
+ print(f"\n완료 (소요: {elapsed})")
- return
if __name__ == "__main__":
download()
diff --git a/monitor.py b/monitor.py
index 227b48b..6f97497 100644
--- a/monitor.py
+++ b/monitor.py
@@ -1,34 +1,40 @@
import pandas as pd
from HTS2 import HTS
from dateutil.relativedelta import relativedelta
-from datetime import datetime, timedelta
+from datetime import datetime
import sqlite3
-import telegram
import time
+
+try:
+ import telegram
+except ImportError:
+ telegram = None # type: ignore
import requests
import json
import asyncio
from multiprocessing import Pool
-import FinanceDataReader as fdr
import numpy as np
import os
from config import *
-from HTS2 import HTS
+import strategy
class Monitor(HTS):
- """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스"""
+ """WLD 코인 모니터링 및 매매 실행."""
last_signal = None
cooldown_file = None
def __init__(self, cooldown_file='coins_buy_time.json') -> None:
- self.hts = HTS()
+ HTS.__init__(self)
# 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장)
self.last_signal: dict[str, str] = {}
if cooldown_file is not None:
self.cooldown_file = cooldown_file
self.buy_cooldown = self._load_buy_cooldown()
+ else:
+ self.cooldown_file = None
+ self.buy_cooldown = {}
# ------------- Persistence -------------
def _load_buy_cooldown(self) -> dict:
@@ -106,13 +112,12 @@ class Monitor(HTS):
# ------------- Telegram -------------
def _send_coin_msg(self, text: str) -> None:
+ if telegram is None:
+ print(f"[telegram skip] {text}")
+ return
coin_client = telegram.Bot(token=COIN_TELEGRAM_BOT_TOKEN)
asyncio.run(coin_client.send_message(chat_id=COIN_TELEGRAM_CHAT_ID, text=text))
- def _send_stock_msg(self, text: str) -> None:
- stock_client = telegram.Bot(token=STOCK_TELEGRAM_BOT_TOKEN)
- asyncio.run(stock_client.send_message(chat_id=STOCK_TELEGRAM_CHAT_ID, text=text))
-
def sendMsg(self, msg):
try:
pool = Pool(12)
@@ -133,18 +138,6 @@ class Monitor(HTS):
pool = Pool(12)
pool.map(self._send_coin_msg, [payload])
- def send_stock_telegram_message(self, message_list: list[str], header: str) -> None:
- payload = header + "\n"
- for i, message in enumerate(message_list):
- payload += message + "\n"
- if i + 1 % 20 == 0:
- pool = Pool(12)
- pool.map(self._send_stock_msg, [payload])
- payload = ''
- if len(message_list) % 20 != 0:
- pool = Pool(12)
- pool.map(self._send_stock_msg, [payload])
-
# ------------- Indicators -------------
def normalize_data(self, data: pd.DataFrame) -> pd.DataFrame:
columns_to_normalize = ['Open', 'High', 'Low', 'Close', 'Volume']
@@ -224,236 +217,169 @@ class Monitor(HTS):
return data
- # ------------- Strategy -------------
- def buy_sell_ticker_1h(self, symbol: str, data: pd.DataFrame, balances=None, is_inverse: bool = False) -> bool:
+ # ------------- Strategy (strategy.py에 구현) -------------
+ def annotate_signals(self, symbol: str, data: pd.DataFrame, simulation: bool | None = None) -> pd.DataFrame:
+ """strategy.annotate_signals에 위임."""
+ return strategy.annotate_signals(
+ symbol, data, simulation=simulation, config=strategy.ACTIVE_CONFIG
+ )
+
+ def _is_in_cooldown(self, symbol: str, side: str) -> bool:
+ """매수/매도 쿨다운 여부."""
+ if self.cooldown_file is None:
+ return False
+ last_dt = self.buy_cooldown.get(symbol, {}).get(side, {}).get("datetime")
+ if not last_dt:
+ return False
+ limit = BUY_COOLDOWN_SEC if side == "buy" else SELL_COOLDOWN_SEC
+ elapsed = (datetime.now() - last_dt).total_seconds()
+ if elapsed < limit:
+ print(f"{symbol}: {side} 쿨다운 중 (남은 시간: {limit - elapsed:.0f}초)")
+ return True
+ return False
+
+ def _record_trade(self, symbol: str, side: str, signal: str) -> None:
+ """매매 기록 저장."""
+ if self.cooldown_file is None:
+ return
+ current_time = datetime.now()
+ self.last_signal[symbol] = signal
+ self.buy_cooldown.setdefault(symbol, {})[side] = {
+ "datetime": current_time,
+ "signal": signal,
+ }
+ self._save_buy_cooldown()
+
+ def execute_trade_signal(
+ self,
+ symbol: str,
+ trade: strategy.TradeSignal,
+ balances: dict | None = None,
+ ) -> bool:
+ """TradeSignal 1건에 대해 현물 매수 또는 매도를 실행합니다."""
try:
- # 신호 생성 및 최신 포인트 확인
- data = self.annotate_signals(symbol, data)
- if data['point'].iloc[-1] != 1:
- return False
+ coin_name = KR_COINS.get(symbol, symbol)
+ signal_name = trade.signal
+ close = trade.close
- if is_inverse:
- # BUY_MINUTE_LIMIT 이내라면 매수하지 않음
- current_time = datetime.now()
- last_buy_dt = self.buy_cooldown.get(symbol, {}).get('sell', {}).get('datetime')
- if last_buy_dt:
- time_diff = current_time - last_buy_dt
- if time_diff.total_seconds() < BUY_MINUTE_LIMIT:
- print(f"{symbol}: 매수 금지 중 (남은 시간: {BUY_MINUTE_LIMIT - time_diff.total_seconds():.0f}초)")
- return False
-
- # 인버스 데이터: 매수 신호를 매도로 처리 (fall_6p, deviation40 만 허용)
- # 허용된 인버스 매도 신호만 처리
- last_signal = str(data['signal'].iloc[-1]) if 'signal' in data.columns else ''
- if last_signal not in ['fall_6p', 'deviation40']:
+ if trade.action == "sell":
+ if self._is_in_cooldown(symbol, "sell"):
return False
- available_balance = 0
- try:
- if balances and symbol in balances:
- available_balance = float(balances[symbol].get('balance', 0))
- except Exception:
- available_balance = 0
- if available_balance <= 0:
+ available = 0.0
+ if balances and symbol in balances:
+ available = float(balances[symbol].get("balance", 0))
+ if available <= 0:
+ print(f"{symbol}: 매도 신호({signal_name}) — 보유 없음, 스킵")
return False
- sell_amount = available_balance * 0.7
- _ = self.hts.sellCoinMarket(symbol, 0, sell_amount)
- if self.cooldown_file is not None:
- try:
- self.last_signal[symbol] = str(data['signal'].iloc[-1])
- except Exception:
- self.last_signal[symbol] = ''
- self.buy_cooldown.setdefault(symbol, {})['sell'] = {'datetime': current_time, 'signal': str(data['signal'].iloc[-1])}
- self._save_buy_cooldown()
-
- print(f"{KR_COINS[symbol]} ({symbol}) [{data['signal'].iloc[-1]} 매도], 현재가: {data['Close'].iloc[-1]:.4f}")
- self.sendMsg("[KRW-COIN]\n" + f"• 매도 [COIN] {KR_COINS[symbol]} ({symbol}): {data['signal'].iloc[-1]} ({'₩'}{data['Close'].iloc[-1]:.4f})")
+ sell_amount = available * strategy.get_sell_ratio(symbol, signal_name)
+ if sell_amount <= 0:
+ return False
+ self.sellCoinMarket(symbol, 0, sell_amount)
+ self._record_trade(symbol, "sell", signal_name)
+ print(f"{coin_name} ({symbol}) [매도 {signal_name}] ₩{close:.4f}, 수량 {sell_amount:.6f}")
+ self.sendMsg(
+ f"[KRW-COIN]\n• 매도 {coin_name} ({symbol}): {signal_name} ₩{close:.4f}"
+ )
return True
- else:
- check_5_week_lowest = False
-
- # BUY_MINUTE_LIMIT 이내라면 매수하지 않음
- current_time = datetime.now()
- last_buy_dt = self.buy_cooldown.get(symbol, {}).get('buy', {}).get('datetime')
- if last_buy_dt:
- time_diff = current_time - last_buy_dt
- if time_diff.total_seconds() < BUY_MINUTE_LIMIT:
- print(f"{symbol}: 매수 금지 중 (남은 시간: {BUY_MINUTE_LIMIT - time_diff.total_seconds():.0f}초)")
- return False
-
- try:
- # 5주봉이 20주봉이나 40주봉보다 아래에 있는지 체크
- # Convert hourly data to week-based rolling periods (5, 20, 40 weeks)
- hours_in_week = 24 * 7 # 168 hours
- period_5w = 5 * hours_in_week # 840 hours
- period_20w = 20 * hours_in_week # 3,360 hours
- period_40w = 40 * hours_in_week # 6,720 hours
-
- if len(data) >= period_40w:
- wma5 = data['Close'].rolling(window=period_5w).mean().iloc[-1]
- wma20 = data['Close'].rolling(window=period_20w).mean().iloc[-1]
- wma40 = data['Close'].rolling(window=period_40w).mean().iloc[-1]
-
- # 5-week MA is the lowest among 5, 20, 40 week MAs
- if (wma5 < wma20) and (wma5 < wma40):
- check_5_week_lowest = True
-
- except Exception:
- # Ignore errors in MA calculation so as not to block trading logic
- pass
-
- # 체크: fall_6p
- buy_amount = 5100
- current_time = datetime.now()
- if data['signal'].iloc[-1] == 'fall_6p':
- if data['Close'].iloc[-1] > 100:
- buy_amount = 300000
- else:
- buy_amount = 150000
- elif data['signal'].iloc[-1] == 'movingaverage':
- buy_amount = 10000
- elif data['signal'].iloc[-1] == 'deviation40':
- buy_amount = 30000
- elif data['signal'].iloc[-1] == 'deviation240':
- buy_amount = 7000
- elif data['signal'].iloc[-1] == 'deviation1440':
- if symbol in ['BONK', 'PEPE', 'TON']:
- buy_amount = 50000
- else:
- buy_amount = 70000
-
- if data['signal'].iloc[-1] in ['movingaverage', 'deviation40', 'deviation240', 'deviation1440']:
- if check_5_week_lowest:
- buy_amount *= 2
-
- # 매수를 진행함
- buy_amount = self.hts.buyCoinMarket(symbol, buy_amount)
-
- # 최근 매수 신호를 함께 기록하여 [신규] 포맷으로 저장
- if self.cooldown_file is not None:
- try:
- self.last_signal[symbol] = str(data['signal'].iloc[-1])
- except Exception:
- self.last_signal[symbol] = ''
- self.buy_cooldown.setdefault(symbol, {})['buy'] = {'datetime': current_time, 'signal': str(data['signal'].iloc[-1])}
-
- # 매수를 저장함
- self._save_buy_cooldown()
-
- print(f"{KR_COINS[symbol]} ({symbol}) [{data['signal'].iloc[-1]}], 현재가: {data['Close'].iloc[-1]:.4f}, {int(BUY_MINUTE_LIMIT/60)}분간 매수 금지 시작")
- self.sendMsg("{}".format(self.format_message(symbol, KR_COINS[symbol], data['Close'].iloc[-1], data['signal'].iloc[-1], buy_amount)))
+ if self._is_in_cooldown(symbol, "buy"):
+ return False
+ buy_amount = strategy.get_buy_amount(
+ symbol, signal_name, close, trend=trade.trend
+ )
+ if strategy.should_double_buy(symbol, signal_name, pd.DataFrame()):
+ buy_amount *= 2
+ executed = self.buyCoinMarket(symbol, buy_amount)
+ self._record_trade(symbol, "buy", signal_name)
+ print(
+ f"{coin_name} ({symbol}) [매수 {signal_name}] ₩{close:.4f} "
+ f"({buy_amount} KRW, 추세={trade.trend})"
+ )
+ self.sendMsg(
+ self.format_message(
+ symbol, coin_name, close, signal_name, executed or buy_amount
+ )
+ )
+ return True
except Exception as e:
- print(f"Error buying {symbol}: {str(e)}")
+ print(f"Error trading {symbol}: {str(e)}")
return False
- return True
- def annotate_signals(self, symbol: str, data: pd.DataFrame, simulation: bool | None = None) -> pd.DataFrame:
- data = data.copy()
- data['signal'] = ''
- data['point'] = 0
- if data['point'].iloc[-1] != 1:
- for i in range(1, len(data)):
- if all(data[f'MA{n}'].iloc[i] < data['MA720'].iloc[i] for n in [5, 20, 40, 120, 200, 240]) and \
- all(data[f'MA{n}'].iloc[i] > data[f'MA{n}'].iloc[i - 1] for n in [5, 20, 40, 120, 200, 240]) and \
- data['MA720'].iloc[i] < data['MA1440'].iloc[i]:
- data.at[data.index[i], 'signal'] = 'movingaverage'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'movingaverage'
- data.at[data.index[-1], 'point'] = 1
+ def process_wld_mtf(self, symbol: str, balances: dict | None = None) -> None:
+ """
+ WLD MTF: 모든 봉 BB 상태 비교 후 정책에 따라 매수/매도.
- if data['Deviation40'].iloc[i - 1] < data['Deviation40'].iloc[i] and data['Deviation40'].iloc[i - 1] <= 90:
- data.at[data.index[i], 'signal'] = 'deviation40'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'deviation40'
- data.at[data.index[-1], 'point'] = 1
+ mtf_bb_policy.json 이 있으면 해당 정책, 없으면 ACTIVE_MTF_POLICY 사용.
+ """
+ from mtf_bb import load_frames_from_db, load_policy, print_latest_states
- if symbol not in ['BONK']:
- if symbol in ['TRX']:
- if data['Deviation240'].iloc[i - 1] < data['Deviation240'].iloc[i] and data['Deviation240'].iloc[i - 1] <= 98:
- data.at[data.index[i], 'signal'] = 'deviation240'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'deviation240'
- data.at[data.index[-1], 'point'] = 1
- else:
- if data['Deviation240'].iloc[i - 1] < data['Deviation240'].iloc[i] and data['Deviation240'].iloc[i - 1] <= 90:
- data.at[data.index[i], 'signal'] = 'deviation240'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'deviation240'
- data.at[data.index[-1], 'point'] = 1
+ try:
+ frames = load_frames_from_db(self, symbol)
+ if not frames:
+ print(f"Data for {symbol}: 로드된 봉 없음.")
+ return
- if symbol in ['TON']:
- if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 89:
- data.at[data.index[i], 'signal'] = 'deviation1440'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'deviation1440'
- data.at[data.index[-1], 'point'] = 1
- elif symbol in ['XRP']:
- if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 90:
- data.at[data.index[i], 'signal'] = 'deviation1440'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'deviation1440'
- data.at[data.index[-1], 'point'] = 1
- elif symbol in ['BONK']:
- if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 76:
- data.at[data.index[i], 'signal'] = 'deviation1440'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'deviation1440'
- data.at[data.index[-1], 'point'] = 1
- else:
- if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 80:
- data.at[data.index[i], 'signal'] = 'deviation1440'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'deviation1440'
- data.at[data.index[-1], 'point'] = 1
+ df_1d = frames.get(TREND_INTERVAL_1D)
+ df_1h = frames.get(TREND_INTERVAL_1H)
+ if df_1d is None or df_1d.empty:
+ df_1d = frames.get(ENTRY_INTERVAL)
+ if df_1h is None or df_1h.empty:
+ df_1h = frames.get(ENTRY_INTERVAL)
- # Deviation720 상향 돌파 매수 (92, 93)
- try:
- prev_d720 = data['Deviation720'].iloc[i - 1]
- curr_d720 = data['Deviation720'].iloc[i]
- # 92 상향 돌파
- if prev_d720 < 92 and curr_d720 >= 92:
- data.at[data.index[i], 'signal'] = 'Deviation720'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'Deviation720'
- data.at[data.index[-1], 'point'] = 1
- # 93 상향 돌파
- if prev_d720 < 93 and curr_d720 >= 93:
- data.at[data.index[i], 'signal'] = 'Deviation720'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'Deviation720'
- data.at[data.index[-1], 'point'] = 1
- except Exception:
- pass
+ 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)}"
+ )
- try:
- prev_low = data['Low'].iloc[i - 1]
- curr_close = data['Close'].iloc[i]
- curr_low = data['Low'].iloc[i]
- cond_close_drop = curr_close <= prev_low * 0.94
- cond_low_drop = curr_low <= prev_low * 0.94
- if cond_close_drop or cond_low_drop:
- data.at[data.index[i], 'signal'] = 'fall_6p'
- data.at[data.index[i], 'point'] = 1
- if not simulation and data['point'][-3:].sum() > 0:
- data.at[data.index[-1], 'signal'] = 'fall_6p'
- data.at[data.index[-1], 'point'] = 1
- except Exception:
- pass
- return data
+ trend = strategy.get_trend(df_1d, df_1h)
+ print(f"{symbol} 추세: {trend}")
+
+ 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:
+ return
+ self.execute_trade_signal(symbol, trade, balances=balances)
+ except Exception as e:
+ print(f"Error processing {symbol}: {str(e)}")
+
+ def process_symbol(
+ self,
+ symbol: str,
+ interval: int | None = None,
+ balances: dict | None = None,
+ use_inverse: bool = False,
+ ) -> None:
+ """하위 호환: MTF 전략으로 위임 (use_inverse 무시)."""
+ self.process_wld_mtf(symbol, balances=balances)
+
+ def load_balances_dict(self) -> dict:
+ """getBalances() 결과를 currency 키 dict로 변환."""
+ tmps = self.getBalances()
+ balances = {}
+ for tmp in tmps:
+ balances[tmp["currency"]] = {
+ "balance": float(tmp["balance"]),
+ "avg_buy_price": float(tmp["avg_buy_price"]),
+ }
+ return balances
# ------------- Formatting -------------
- def format_message(self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float) -> str:
- message = f"[매수] {symbol_name} ({symbol}): "
+ def format_message(
+ self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float
+ ) -> str:
+ message = f"[매수] {symbol_name} ({symbol}) [{signal}]: "
if int(close) >= 100:
message += f"₩{close}"
@@ -472,12 +398,6 @@ class Monitor(HTS):
message += f"[{signal}]"
return message
- def format_ma_message(self, info: dict, market_type: str) -> str:
- prefix = '상승 ' if info.get('alert') else ''
- message = prefix + f"[{market_type}] {info['name']} ({info['symbol']}) "
- message += f"{'$' if market_type == 'US' else '₩'}({info['price']:.4f}) \n"
- return message
-
# ------------- Data fetch -------------
def get_coin_data(self, symbol: str, interval: int = 60, to: str | None = None, retries: int = 3) -> pd.DataFrame | None:
for attempt in range(retries):
@@ -520,96 +440,146 @@ class Monitor(HTS):
continue
return None
- def get_coin_more_data(self, symbol: str, interval: int, bong_count: int = 3000) -> pd.DataFrame:
+ def get_coin_more_data(
+ self,
+ symbol: str,
+ interval: int,
+ bong_count: int = 3000,
+ verbose: bool = False,
+ ) -> pd.DataFrame:
+ """
+ 빗썸 API를 반복 호출해 bong_count개까지 과거 봉을 수집합니다.
+
+ Args:
+ verbose: True면 수집 진행 상황을 출력합니다.
+ """
to = datetime.now()
data: pd.DataFrame | None = None
+ step = 0
while data is None or len(data) < bong_count:
+ step += 1
if data is None:
- data = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
+ chunk = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
+ data = chunk
else:
previous_count = len(data)
df = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
- data = pd.concat([data, df], ignore_index=True)
- if previous_count == len(data):
+ if df is not None and not df.empty:
+ data = pd.concat([data, df], ignore_index=True)
+ if df is None or df.empty or previous_count == len(data):
+ if verbose:
+ print(f" API 추가 데이터 없음 (수집 {len(data)}봉)")
break
+ if verbose and (step == 1 or step % 5 == 0 or len(data) >= bong_count):
+ label = "일봉" if interval >= 1440 else f"{interval}분"
+ print(f" [{label}] 요청 {step}회 — 누적 {len(data)}/{bong_count}봉")
time.sleep(0.3)
to = to - relativedelta(minutes=interval * 200)
- data = data.set_index('datetime')
+ if data is None or data.empty:
+ return pd.DataFrame()
+ data = data.set_index("datetime")
data = data.sort_index()
- data = data.drop_duplicates(keep='first')
+ data = data.drop_duplicates(keep="first")
data["datetime"] = data.index
return data
- def get_coin_saved_data(self, symbol: str, interval: int, data: pd.DataFrame) -> pd.DataFrame:
- conn = sqlite3.connect('coins.db')
+ def get_coin_saved_data(
+ self, symbol: str, interval: int, data: pd.DataFrame, db_path: str = "coins.db"
+ ) -> pd.DataFrame:
+ """
+ coins.db에서 저장된 봉을 읽고, API로 받은 최신 봉을 DB에 반영합니다.
+
+ downloader.py로 미리 적재해 두면 장기 MA 계산에 유리합니다.
+ """
+ conn = sqlite3.connect(db_path)
cursor = conn.cursor()
+ table_name = f"{symbol}_{interval}"
+ cursor.execute(
+ f"CREATE TABLE IF NOT EXISTS {table_name} "
+ "(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
+ "Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
+ )
+ cursor.execute(
+ f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
+ )
+
for i in range(1, len(data)):
- cursor.execute("SELECT * from {}_{} where CODE = ? and ymdhms = ?".format(symbol, str(interval)), (symbol, data['datetime'].iloc[-i].strftime('%Y-%m-%d %H:%M:%S')),)
- arr = cursor.fetchone()
- if not arr:
+ ymdhms = data["datetime"].iloc[-i].strftime("%Y-%m-%d %H:%M:%S")
+ cursor.execute(
+ f"SELECT 1 FROM {table_name} WHERE CODE = ? AND ymdhms = ?",
+ (symbol, ymdhms),
+ )
+ if not cursor.fetchone():
cursor.execute(
- "INSERT INTO {}_{} (CODE, NAME, ymdhms, ymd, hms, close, open, high, low, volume) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".format(symbol, interval),
+ f"INSERT INTO {table_name} "
+ "(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
symbol,
KR_COINS[symbol],
- data['datetime'].iloc[-i].strftime('%Y-%m-%d %H:%M:%S'),
- data['datetime'].iloc[-i].strftime('%Y%m%d'),
- data['datetime'].iloc[-i].strftime('%H%M%S'),
- data['Close'].iloc[-i],
- data['Open'].iloc[-i],
- data['High'].iloc[-i],
- data['Low'].iloc[-i],
- data['Volume'].iloc[-i],
+ ymdhms,
+ data["datetime"].iloc[-i].strftime("%Y%m%d"),
+ data["datetime"].iloc[-i].strftime("%H%M%S"),
+ data["Close"].iloc[-i],
+ data["Open"].iloc[-i],
+ data["High"].iloc[-i],
+ data["Low"].iloc[-i],
+ data["Volume"].iloc[-i],
),
)
else:
break
- cursor.execute("select * from (SELECT Open,Close,High,Low,Volume,ymdhms as datetime from {}_{} order by ymdhms desc limit 7000) subquery order by datetime".format(symbol, str(interval)))
+
+ cursor.execute(
+ f"SELECT Open, Close, High, Low, Volume, ymdhms AS datetime "
+ f"FROM (SELECT Open, Close, High, Low, Volume, ymdhms "
+ f"FROM {table_name} ORDER BY ymdhms DESC LIMIT 7000) "
+ f"ORDER BY datetime"
+ )
result = cursor.fetchall()
conn.commit()
cursor.close()
conn.close()
- df = pd.DataFrame(result)
- df.columns = ['Open', 'Close', 'High', 'Low', 'Volume', 'datetime']
- df = df.set_index('datetime')
+
+ if not result:
+ return pd.DataFrame(
+ columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
+ )
+
+ df = pd.DataFrame(
+ result, columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
+ )
+ df = df.set_index("datetime")
df = df.sort_index()
- df['datetime'] = df.index
+ df["datetime"] = df.index
return df
def get_coin_some_data(self, symbol: str, interval: int) -> pd.DataFrame:
+ """
+ WLD 시세: API 최신 봉 + coins.db 과거 봉 + 1분봉 최신 1개를 합칩니다.
+
+ DB가 비어 있으면 API·1분봉만 사용합니다. 과거 적재는 downloader.py 실행.
+ """
data = self.get_coin_data(symbol, interval)
+ if data is None or data.empty:
+ return pd.DataFrame()
+
data_1 = self.get_coin_data(symbol, interval=1)
- data_1.at[data_1.index[-1], 'Volume'] = data_1['Volume'].iloc[-1] * 60
+ if data_1 is not None and not data_1.empty:
+ data_1 = data_1.copy()
+ data_1.at[data_1.index[-1], "Volume"] = data_1["Volume"].iloc[-1] * 60
saved_data = self.get_coin_saved_data(symbol, interval, data)
- data = pd.concat([data, saved_data, data_1.iloc[[-1]]], ignore_index=True)
- data['datetime'] = pd.to_datetime(data['datetime'], format='%Y-%m-%d %H:%M:%S')
- data = data.set_index('datetime')
- data = data.sort_index()
- data = data.drop_duplicates(keep='first')
- data["datetime"] = data.index
- return data
+ parts = [data]
+ if saved_data is not None and not saved_data.empty:
+ parts.append(saved_data)
+ if data_1 is not None and not data_1.empty:
+ parts.append(data_1.iloc[[-1]])
- def get_kr_stock_data(self, symbol: str, retries: int = 3) -> pd.DataFrame | None:
- for attempt in range(retries):
- try:
- end = datetime.now()
- start = end - timedelta(days=300)
- data = fdr.DataReader(symbol, start.strftime('%Y-%m-%d'), end.strftime('%Y-%m-%d'))
- if not data.empty:
- data = data.rename(columns={
- 'Open': 'Open',
- 'High': 'High',
- 'Low': 'Low',
- 'Close': 'Close',
- 'Volume': 'Volume',
- })
- return data
- print(f"No data received for {symbol}, attempt {attempt + 1}")
- time.sleep(2)
- except Exception as e:
- print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}")
- if attempt < retries - 1:
- time.sleep(5)
- continue
- return None
+ merged = pd.concat(parts, ignore_index=True)
+ merged["datetime"] = pd.to_datetime(merged["datetime"], format="%Y-%m-%d %H:%M:%S")
+ merged = merged.set_index("datetime")
+ merged = merged.sort_index()
+ merged = merged.drop_duplicates(keep="first")
+ merged["datetime"] = merged.index
+ return merged
diff --git a/monitor_coin.py b/monitor_coin.py
index 2a4e3da..879de22 100644
--- a/monitor_coin.py
+++ b/monitor_coin.py
@@ -1,52 +1,39 @@
+"""
+WLD(월드코인) 실시간 모니터 — 3분 BB MTF (평균회귀 + 돌파).
+
+전략: strategy.py
+"""
+
from datetime import datetime
import time
-from config import *
+from config import COIN_NAME, COOLDOWN_FILE, MONITOR_LOOP_SLEEP_SEC, SYMBOL
from monitor import Monitor
-class MonitorCoin (Monitor):
- """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스"""
- def __init__(self, cooldown_file: str = 'coins_buy_time.json') -> None:
+class MonitorCoin(Monitor):
+ """WLD 모니터링 및 매매 실행."""
+
+ def __init__(self, cooldown_file: str = COOLDOWN_FILE) -> None:
super().__init__(cooldown_file)
- def monitor_coins(self) -> None:
- tmps = self.getBalances()
- balances = {}
- for tmp in tmps:
- balances[tmp['currency']] = {'balance': float(tmp['balance']), 'avg_buy_price': float(tmp['avg_buy_price'])}
+ def monitor_wld(self) -> None:
+ """일봉·1시간 추세 + 3분 신호로 현물 매수/매도."""
+ balances = self.load_balances_dict()
+ print(
+ "[{}] {} ({})".format(
+ datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ COIN_NAME,
+ SYMBOL,
+ )
+ )
+ self.process_wld_mtf(SYMBOL, balances=balances)
- print("[{}] KRW COINs: {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'), ','.join(KR_COINS.keys())))
- for symbol in KR_COINS:
- interval = 60
- data = self.get_coin_some_data(symbol, interval)
- if data is not None and not data.empty:
- try:
- inverseData= self.inverse_data(data)
- recent_inverseData = self.annotate_signals(symbol, inverseData)
- if not self.buy_sell_ticker_1h(symbol, recent_inverseData, balances=balances, is_inverse=True):
- pass
-
- data = self.calculate_technical_indicators(data)
- recent_data = self.annotate_signals(symbol, data)
- _ = self.buy_sell_ticker_1h(symbol, recent_data, balances=None, is_inverse=False)
-
- except Exception as e:
- print(f"Error processing data for {symbol}: {str(e)}")
- else:
- print(f"Data for {symbol} is empty or None.")
-
- time.sleep(0.5)
-
- return
- # ------------- Scheduler -------------
def run_schedule(self) -> None:
-
while True:
- self.monitor_coins()
- time.sleep(10)
+ self.monitor_wld()
+ time.sleep(MONITOR_LOOP_SLEEP_SEC)
+
if __name__ == "__main__":
- KR_COINS.keys()
-
MonitorCoin().run_schedule()
diff --git a/monitor_coin_1h_1.py b/monitor_coin_1h_1.py
deleted file mode 100644
index b8a36f5..0000000
--- a/monitor_coin_1h_1.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from datetime import datetime
-import time
-from config import *
-
-from monitor import Monitor
-
-class MonitorCoin (Monitor):
- """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스"""
-
- def __init__(self, cooldown_file: str = 'coins_buy_time.json') -> None:
- super().__init__(cooldown_file)
-
- def monitor_coins(self) -> None:
- tmps = self.getBalances()
- balances = {}
- for tmp in tmps:
- balances[tmp['currency']] = {'balance': float(tmp['balance']), 'avg_buy_price': float(tmp['avg_buy_price'])}
-
- for symbol in KR_COINS_1:
-
- print("[{}] {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'), symbol))
- interval = 1440
- data = self.get_coin_some_data(symbol, interval)
- data = self.calculate_technical_indicators(data)
- recent_data = self.annotate_signals(symbol, data)
- if recent_data['point'].iloc[-1] == 1:
-
- interval = 60
- data = self.get_coin_some_data(symbol, interval)
- if data is not None and not data.empty:
- try:
- inverseData= self.inverse_data(data)
- recent_inverseData = self.annotate_signals(symbol, inverseData)
- _ = self.buy_sell_ticker_1h(symbol, recent_inverseData, balances=balances, is_inverse=True)
-
- data = self.calculate_technical_indicators(data)
- recent_data = self.annotate_signals(symbol, data)
-
- _ = self.buy_sell_ticker_1h(symbol, recent_data, balances=None, is_inverse=False)
- except Exception as e:
- print(f"Error processing data for {symbol}: {str(e)}")
- else:
- print(f"Data for {symbol} is empty or None.")
-
- time.sleep(1)
-
- return
- # ------------- Scheduler -------------
- def run_schedule(self) -> None:
-
- while True:
- self.monitor_coins()
- time.sleep(3)
-
-if __name__ == "__main__":
- MonitorCoin(cooldown_file='coins_buy_time_1h_1.json').run_schedule()
diff --git a/monitor_coin_1h_2.py b/monitor_coin_1h_2.py
deleted file mode 100644
index 9481182..0000000
--- a/monitor_coin_1h_2.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from datetime import datetime
-import time
-from config import *
-
-from monitor import Monitor
-
-class MonitorCoin (Monitor):
- """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스"""
-
- def __init__(self, cooldown_file: str = 'coins_buy_time.json') -> None:
- super().__init__(cooldown_file)
-
- def monitor_coins(self) -> None:
- tmps = self.getBalances()
- balances = {}
- for tmp in tmps:
- balances[tmp['currency']] = {'balance': float(tmp['balance']), 'avg_buy_price': float(tmp['avg_buy_price'])}
-
- for symbol in KR_COINS_2:
-
- print("[{}] {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'), symbol))
- interval = 1440
- data = self.get_coin_some_data(symbol, interval)
- data = self.calculate_technical_indicators(data)
- recent_data = self.annotate_signals(symbol, data)
- if recent_data['point'].iloc[-1] == 1:
-
- interval = 60
- data = self.get_coin_some_data(symbol, interval)
- if data is not None and not data.empty:
- try:
- inverseData= self.inverse_data(data)
- recent_inverseData = self.annotate_signals(symbol, inverseData)
- _ = self.buy_sell_ticker_1h(symbol, recent_inverseData, balances=balances, is_inverse=True)
-
- data = self.calculate_technical_indicators(data)
- recent_data = self.annotate_signals(symbol, data)
- _ = self.buy_sell_ticker_1h(symbol, recent_data, balances=None, is_inverse=False)
- except Exception as e:
- print(f"Error processing data for {symbol}: {str(e)}")
- else:
- print(f"Data for {symbol} is empty or None.")
-
- time.sleep(1)
-
- return
- # ------------- Scheduler -------------
- def run_schedule(self) -> None:
-
- while True:
- self.monitor_coins()
- time.sleep(3)
-
-if __name__ == "__main__":
- MonitorCoin(cooldown_file='coins_buy_time_1h_2.json').run_schedule()
diff --git a/monitor_processor.py b/monitor_processor.py
deleted file mode 100644
index baea3d6..0000000
--- a/monitor_processor.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import time
-import psutil
-import subprocess
-import telegram
-import asyncio
-from config import *
-
-class ProcessMonitor:
-
- def __init__(self, python_executable="python"):
- self.python = python_executable
- # 실행된 프로세스 저장용
- self.process_map = {}
-
- def is_running(self, script_path):
- """해당 스크립트가 실행 중인지 확인"""
- for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
- try:
- if proc.info['cmdline'] and script_path in proc.info['cmdline']:
- return True
- except (psutil.NoSuchProcess, psutil.AccessDenied):
- continue
- return False
-
- def start_process(self, script_path):
- """해당 스크립트 실행"""
- print(f"[INFO] Starting {script_path}")
- process = subprocess.Popen([self.python, script_path], creationflags=subprocess.CREATE_NEW_CONSOLE)
- self.process_map[script_path] = process
-
- def sendMsg(self, text):
- coin_client = telegram.Bot(token=COIN_TELEGRAM_BOT_TOKEN)
- asyncio.run(coin_client.send_message(chat_id=COIN_TELEGRAM_CHAT_ID, text=text))
- return
-
- def monitor(self, scripts, interval=60):
- """1분 단위로 프로세스 상태 확인 및 관리"""
- while True:
- for script in scripts:
- if not self.is_running(script):
- self.sendMsg("🔔{} process is killed.".format(script))
-
- time.sleep(interval)
-
-if __name__ == "__main__":
- monitor = ProcessMonitor()
-
- # 모니터링할 스크립트 목록
- scripts = [
- r"C:\workspace\AssetMonitor\monitor_coin_1h_1.py",
- r"C:\workspace\AssetMonitor\monitor_coin_1h_2.py"
- ]
- monitor.monitor(scripts, interval=60)
\ No newline at end of file
diff --git a/monitor_stock.py b/monitor_stock.py
deleted file mode 100644
index 76960a2..0000000
--- a/monitor_stock.py
+++ /dev/null
@@ -1,110 +0,0 @@
-import pandas as pd
-from datetime import datetime, timedelta
-import time
-import schedule
-from config import *
-import FinanceDataReader as fdr
-
-from monitor import Monitor
-
-class MonitorStock (Monitor):
- """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스"""
-
- def __init__(self) -> None:
- super().__init__(None)
-
-
- def get_kr_stock_data(self, symbol: str, retries: int = 3) -> pd.DataFrame | None:
- for attempt in range(retries):
- try:
- end = datetime.now()
- start = end - timedelta(days=300)
- data = fdr.DataReader(symbol, start.strftime('%Y-%m-%d'), end.strftime('%Y-%m-%d'))
- if not data.empty:
- data = data.rename(columns={
- 'Open': 'Open',
- 'High': 'High',
- 'Low': 'Low',
- 'Close': 'Close',
- 'Volume': 'Volume',
- })
- return data
- print(f"No data received for {symbol}, attempt {attempt + 1}")
- time.sleep(2)
- except Exception as e:
- print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}")
- if attempt < retries - 1:
- time.sleep(5)
- continue
- return None
-
- # ------------- Monitors -------------
- def monitor_us_stocks(self) -> None:
- message_list: list[str] = []
- print("US Stocks {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
- for symbol in US_STOCKS:
- data = self.get_kr_stock_data(symbol)
- if data is not None and not data.empty:
- try:
- data = self.calculate_technical_indicators(data)
- recent_data = self.check_point(symbol, data)
- if recent_data['point'].iloc[-1] != 1:
- continue
- print(f" - {US_STOCKS[symbol]} ({symbol}): {recent_data['Close'].iloc[-1]:.2f}")
- message_list.append(
- self.format_message('US', symbol, US_STOCKS[symbol], recent_data['Close'].iloc[-1], recent_data['signal'].iloc[-1])
- )
- except Exception as e:
- print(f"Error processing data for {symbol}: {str(e)}")
- time.sleep(0.5)
- if len(message_list) > 0:
- try:
- self.send_stock_telegram_message(message_list, header="[US-STOCK]")
- except Exception as e:
- print(f"Error sending Telegram message: {str(e)}")
-
- def monitor_kr_stocks(self) -> None:
- message_list: list[str] = []
- print("KR ETFs {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
- for symbol in KR_ETFS:
- try:
- clean_symbol = symbol.replace('.KS', '')
- data = self.get_kr_stock_data(clean_symbol)
- if data is not None and not data.empty:
- try:
- data = self.calculate_technical_indicators(data)
- recent_data = self.check_point(symbol, data)
- if recent_data['point'].iloc[-1] != 1:
- continue
- print(f" - {KR_ETFS[symbol]} ({symbol}): {recent_data['Close'].iloc[-1]:.2f}")
- message_list.append(
- self.format_message('KR', symbol, KR_ETFS[symbol], recent_data['Close'].iloc[-1], recent_data['signal'].iloc[-1])
- )
- except Exception as e:
- print(f"Error processing data for {symbol}: {str(e)}")
- else:
- print(f"Data for {symbol} is empty or None.")
- time.sleep(1)
- except Exception as e:
- print(f"Unexpected error processing {symbol}: {str(e)}")
- continue
- if len(message_list) > 0:
- try:
- self.send_stock_telegram_message(message_list, header="[KR-STOCK]")
- except Exception as e:
- print(f"Error sending Telegram message: {str(e)}")
-
- # ------------- Scheduler -------------
- def run_schedule(self) -> None:
- schedule.every().day.at("16:30").do(self.monitor_us_stocks)
- schedule.every().day.at("23:30").do(self.monitor_us_stocks)
- schedule.every().day.at("08:10").do(self.monitor_kr_stocks)
- schedule.every().day.at("18:20").do(self.monitor_kr_stocks)
- print("Scheduler started. Stock Monitoring will run at specified times.")
- while True:
- schedule.run_pending()
- time.sleep(1)
-
-
-if __name__ == "__main__":
- MonitorStock().run_schedule()
diff --git a/mtf_bb.py b/mtf_bb.py
new file mode 100644
index 0000000..5673fed
--- /dev/null
+++ b/mtf_bb.py
@@ -0,0 +1,222 @@
+"""
+봉 간격별 볼린저밴드 상태 분석 및 최적 매수/매도 봉 추천.
+
+기본 규칙(모든 봉 동일):
+ - 매수: 하단 밴드 상향 돌파
+ - 매도: 상단 밴드 상향 돌파
+ - 손절(선택): 하단 재이탈
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+import pandas as pd
+
+from config import DOWNLOAD_INTERVALS, SYMBOL
+from strategy import (
+ ACTIVE_CONFIG,
+ StrategyConfig,
+ annotate_interval_signals,
+ get_latest_bb_state,
+ MtfBbPolicy,
+)
+
+POLICY_FILE = Path(__file__).parent / "mtf_bb_policy.json"
+
+# 매수/매도 트리거 실행 후보 (긴 봉은 확인용만)
+EXECUTION_INTERVAL_CANDIDATES: tuple[int, ...] = (3, 10, 15, 30, 60)
+DEFAULT_CONFIRM_INTERVALS: tuple[int, ...] = (60, 1440)
+
+
+@dataclass
+class IntervalBacktestSummary:
+ """봉 간격별 백테스트 요약."""
+
+ interval: int
+ label: str
+ return_pct: float
+ trade_count: int
+ buy_count: int
+ sell_count: int
+ final_asset: float
+
+
+def interval_label(interval: int) -> str:
+ if interval >= 1440:
+ return "일봉"
+ return f"{interval}분"
+
+
+def load_frames_from_db(monitor, symbol: str) -> dict[int, pd.DataFrame]:
+ """coins.db에서 DOWNLOAD_INTERVALS 전부 로드·지표 계산."""
+ frames: dict[int, pd.DataFrame] = {}
+ for iv in DOWNLOAD_INTERVALS:
+ df = monitor.get_coin_some_data(symbol, iv)
+ if df is None or df.empty:
+ print(f" [{interval_label(iv)}] DB/API 데이터 없음 — 스킵")
+ continue
+ df = monitor.calculate_technical_indicators(df)
+ frames[iv] = df
+ print(f" [{interval_label(iv)}] {len(df)}봉 {df.index[0]} ~ {df.index[-1]}")
+ return frames
+
+
+def print_latest_states(frames: dict[int, pd.DataFrame], cfg: StrategyConfig) -> None:
+ """각 봉의 최신 BB 상태 출력."""
+ print("\n--- 봉별 최신 BB 상태 ---")
+ for iv in sorted(frames.keys()):
+ st = get_latest_bb_state(frames[iv], cfg)
+ df = frames[iv]
+ close = df["Close"].iloc[-1]
+ print(
+ f" {interval_label(iv):>6} ({iv:>4}분) | {st:20} | 종가 {close:,.2f} | "
+ f"L={df['Lower'].iloc[-1]:,.2f} U={df['Upper'].iloc[-1]:,.2f}"
+ )
+
+
+def backtest_interval(
+ frames: dict[int, pd.DataFrame],
+ interval: int,
+ cfg: StrategyConfig,
+) -> IntervalBacktestSummary | None:
+ """한 간격만으로 BB 매매 백테스트."""
+ from simulation_1h import run_backtest
+
+ if interval not in frames:
+ return None
+ df = annotate_interval_signals(SYMBOL, frames[interval].copy(), config=cfg)
+ # 단일 봉 비교이므로 추세 필터 없이 동일 df를 HTF로 전달
+ res = run_backtest(df, frames[interval], frames[interval], config_name=f"{interval}분")
+ buys = sum(1 for t in res.trades if t.action == "매수")
+ sells = sum(1 for t in res.trades if t.action == "매도")
+ return IntervalBacktestSummary(
+ interval=interval,
+ label=interval_label(interval),
+ return_pct=res.total_return_pct,
+ trade_count=res.trade_count,
+ buy_count=buys,
+ sell_count=sells,
+ final_asset=res.final_asset,
+ )
+
+
+def recommend_policy(
+ summaries: list[IntervalBacktestSummary],
+ frames: dict[int, pd.DataFrame],
+) -> MtfBbPolicy:
+ """
+ 백테스트 결과로 매수/매도 실행 봉과 확인용 상위 봉을 추천합니다.
+
+ - 매수/매도 실행: 수익률 1위 간격 (동일하면 더 긴 봉 우선)
+ - 확인 봉: 실행 봉보다 긴 간격 중 가장 가까운 2개
+ """
+ if not summaries:
+ return MtfBbPolicy()
+
+ exec_pool = [s for s in summaries if s.interval in EXECUTION_INTERVAL_CANDIDATES]
+ if not exec_pool:
+ exec_pool = list(summaries)
+
+ ranked = sorted(
+ exec_pool,
+ key=lambda s: (s.return_pct, s.trade_count, -s.interval),
+ reverse=True,
+ )
+ best = ranked[0]
+ buy_iv = sell_iv = best.interval
+ if best.trade_count == 0:
+ buy_iv = sell_iv = min(
+ (iv for iv in frames if iv in EXECUTION_INTERVAL_CANDIDATES),
+ default=min(frames.keys()),
+ )
+
+ longer = sorted([iv for iv in frames if iv > buy_iv])
+ confirm = tuple(iv for iv in DEFAULT_CONFIRM_INTERVALS if iv in frames and iv > buy_iv)
+ if not confirm:
+ confirm = tuple(longer[-2:]) if len(longer) >= 2 else tuple(longer)
+ if not confirm and len(longer) == 1:
+ confirm = (longer[0],)
+
+ return MtfBbPolicy(
+ buy_interval=buy_iv,
+ sell_interval=sell_iv,
+ buy_confirm_intervals=confirm,
+ sell_confirm_intervals=confirm[:1] if confirm else (),
+ name=f"auto_{interval_label(buy_iv)}_buy_{interval_label(sell_iv)}_sell",
+ )
+
+
+def run_interval_comparison(monitor) -> tuple[MtfBbPolicy, list[IntervalBacktestSummary]]:
+ """모든 봉 간격 BB 백테스트 후 정책 추천."""
+ cfg = StrategyConfig(
+ name="봉별_BB_기본",
+ use_mtf=False,
+ use_regime_switch=False,
+ use_rsi_filter=False,
+ use_volume_filter=False,
+ use_squeeze_filter=False,
+ use_stop_loss=True,
+ )
+ print(f"\n{'='*72}")
+ print("봉 간격별 BB 매매 비교 (하단↑매수 / 상단↑매도 / 수수료 반영)")
+ print(f"{'='*72}")
+
+ frames = load_frames_from_db(monitor, SYMBOL)
+ if not frames:
+ raise RuntimeError("로드된 봉 데이터가 없습니다. downloader.py 먼저 실행하세요.")
+
+ print_latest_states(frames, cfg)
+
+ summaries: list[IntervalBacktestSummary] = []
+ for iv in sorted(frames.keys()):
+ s = backtest_interval(frames, iv, cfg)
+ if s:
+ summaries.append(s)
+
+ summaries.sort(key=lambda x: x.return_pct, reverse=True)
+ print(f"\n{'순위':<4} {'봉':>8} {'수익률':>9} {'거래':>6} {'매수':>5} {'매도':>5}")
+ print("-" * 45)
+ for i, s in enumerate(summaries, 1):
+ print(
+ f"{i:<4} {s.label:>8} {s.return_pct:>+8.2f}% "
+ f"{s.trade_count:>6} {s.buy_count:>5} {s.sell_count:>5}"
+ )
+
+ policy = recommend_policy(summaries, frames)
+ print(f"\n추천 정책:")
+ print(f" 매수 실행 봉: {interval_label(policy.buy_interval)}")
+ print(f" 매도 실행 봉: {interval_label(policy.sell_interval)}")
+ print(f" 매수 확인 봉: {[interval_label(i) for i in policy.buy_confirm_intervals]}")
+ print(f" 매도 확인 봉: {[interval_label(i) for i in policy.sell_confirm_intervals]}")
+ print(f"{'='*72}\n")
+ return policy, summaries
+
+
+def save_policy(policy: MtfBbPolicy, path: Path = POLICY_FILE) -> None:
+ """추천 정책을 JSON으로 저장."""
+ path.write_text(json.dumps(asdict(policy), ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def load_policy(path: Path = POLICY_FILE) -> MtfBbPolicy | None:
+ """저장된 정책 로드."""
+ if not path.exists():
+ return None
+ data = json.loads(path.read_text(encoding="utf-8"))
+ return MtfBbPolicy(
+ buy_interval=int(data["buy_interval"]),
+ sell_interval=int(data["sell_interval"]),
+ buy_confirm_intervals=tuple(data.get("buy_confirm_intervals", [])),
+ sell_confirm_intervals=tuple(data.get("sell_confirm_intervals", [])),
+ name=data.get("name", "loaded"),
+ )
+
+
+def apply_policy(policy: MtfBbPolicy) -> None:
+ """strategy.ACTIVE_MTF_POLICY 에 반영."""
+ import strategy as st
+
+ st.ACTIVE_MTF_POLICY = policy
+ print(f"ACTIVE_MTF_POLICY 적용: {policy.name}")
diff --git a/mtf_bb_policy.json b/mtf_bb_policy.json
new file mode 100644
index 0000000..0915f0a
--- /dev/null
+++ b/mtf_bb_policy.json
@@ -0,0 +1,11 @@
+{
+ "buy_interval": 60,
+ "sell_interval": 60,
+ "buy_confirm_intervals": [
+ 1440
+ ],
+ "sell_confirm_intervals": [
+ 1440
+ ],
+ "name": "auto_60분_buy_60분_sell"
+}
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 1e18de3..4b9f6af 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,14 +1,7 @@
-yfinance
pandas
-mplcursors
numpy
-ccxt
PyJWT
-pycurl
-schedule
+requests
python-dateutil
python-telegram-bot
-finance-datareader
-psutil
-mpld3
-plotly
\ No newline at end of file
+plotly
diff --git a/resources/coins_buy_ADA.json b/resources/coins_buy_ADA.json
deleted file mode 100644
index a3bc558..0000000
--- a/resources/coins_buy_ADA.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "ADA": {
- "datetime": "2025-08-14T22:41:28.363958",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_APE.json b/resources/coins_buy_APE.json
deleted file mode 100644
index b099b16..0000000
--- a/resources/coins_buy_APE.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "APE": {
- "datetime": "2025-08-09T14:22:02.089619",
- "signal": "movingaverage"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_ARB.json b/resources/coins_buy_ARB.json
deleted file mode 100644
index e5183b8..0000000
--- a/resources/coins_buy_ARB.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "ARB": {
- "datetime": "2025-08-14T22:43:59.078775",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_BONK.json b/resources/coins_buy_BONK.json
deleted file mode 100644
index 35b3aa6..0000000
--- a/resources/coins_buy_BONK.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "BONK": {
- "datetime": "2025-08-14T22:41:42.247356",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_ENA.json b/resources/coins_buy_ENA.json
deleted file mode 100644
index c0ddb1e..0000000
--- a/resources/coins_buy_ENA.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "ENA": {
- "datetime": "2025-08-16T01:03:31.916209",
- "signal": "deviation240"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_HBAR.json b/resources/coins_buy_HBAR.json
deleted file mode 100644
index 74f5c81..0000000
--- a/resources/coins_buy_HBAR.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "HBAR": {
- "datetime": "2025-08-14T21:37:21.575425",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_KAIA.json b/resources/coins_buy_KAIA.json
deleted file mode 100644
index 1a942d2..0000000
--- a/resources/coins_buy_KAIA.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "KAIA": {
- "datetime": "2025-08-14T22:42:27.079125",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_LINK.json b/resources/coins_buy_LINK.json
deleted file mode 100644
index 86501d4..0000000
--- a/resources/coins_buy_LINK.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "LINK": {
- "datetime": "2025-08-14T22:42:38.780771",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_ONDO.json b/resources/coins_buy_ONDO.json
deleted file mode 100644
index 75b516b..0000000
--- a/resources/coins_buy_ONDO.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "ONDO": {
- "datetime": "2025-08-14T22:04:53.097618",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_PENGU.json b/resources/coins_buy_PENGU.json
deleted file mode 100644
index 177b9ba..0000000
--- a/resources/coins_buy_PENGU.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "PENGU": {
- "datetime": "2025-08-16T07:53:40.994785",
- "signal": "deviation240"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_PEPE.json b/resources/coins_buy_PEPE.json
deleted file mode 100644
index ccad821..0000000
--- a/resources/coins_buy_PEPE.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "PEPE": {
- "datetime": "2025-08-14T22:06:10.012326",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_SAND.json b/resources/coins_buy_SAND.json
deleted file mode 100644
index 92caecb..0000000
--- a/resources/coins_buy_SAND.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "SAND": {
- "datetime": "2025-08-14T22:05:08.098364",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_SEI.json b/resources/coins_buy_SEI.json
deleted file mode 100644
index 4b17c13..0000000
--- a/resources/coins_buy_SEI.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "SEI": {
- "datetime": "2025-08-14T21:36:00.600483",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_SHIB.json b/resources/coins_buy_SHIB.json
deleted file mode 100644
index 7bcb80a..0000000
--- a/resources/coins_buy_SHIB.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "SHIB": {
- "datetime": "2025-08-14T22:05:20.734073",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_STORJ.json b/resources/coins_buy_STORJ.json
deleted file mode 100644
index f378355..0000000
--- a/resources/coins_buy_STORJ.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "STORJ": {
- "datetime": "2025-08-14T23:32:08.979598",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_SUI.json b/resources/coins_buy_SUI.json
deleted file mode 100644
index ac30937..0000000
--- a/resources/coins_buy_SUI.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "SUI": {
- "datetime": "2025-08-14T21:36:14.758922",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_UXLINK.json b/resources/coins_buy_UXLINK.json
deleted file mode 100644
index 515bcaa..0000000
--- a/resources/coins_buy_UXLINK.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "UXLINK": {
- "datetime": "2025-08-14T22:05:36.242448",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_VIRTUAL.json b/resources/coins_buy_VIRTUAL.json
deleted file mode 100644
index 51f6956..0000000
--- a/resources/coins_buy_VIRTUAL.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "VIRTUAL": {
- "datetime": "2025-08-16T21:02:23.634183",
- "signal": "deviation1440"
- }
-}
\ No newline at end of file
diff --git a/resources/coins_buy_WLD.json b/resources/coins_buy_WLD.json
deleted file mode 100644
index b98bc8d..0000000
--- a/resources/coins_buy_WLD.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "WLD": {
- "datetime": "2025-08-14T22:43:33.737340",
- "signal": "fall_6p"
- }
-}
\ No newline at end of file
diff --git a/rule_discovery.py b/rule_discovery.py
new file mode 100644
index 0000000..2074c16
--- /dev/null
+++ b/rule_discovery.py
@@ -0,0 +1,542 @@
+"""
+모든 봉·캔들 특징 행렬에서 매수/매도 규칙을 탐색합니다 (인과적 백테스트).
+
+ python simulation_1h.py discover
+"""
+
+from __future__ import annotations
+
+import json
+import random
+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,
+ build_master_feature_matrix,
+ interval_prefix,
+)
+from config import (
+ BUY_COOLDOWN_SEC,
+ DOWNLOAD_INTERVALS,
+ ENTRY_INTERVAL,
+ SELL_COOLDOWN_SEC,
+ SIM_INITIAL_CASH_KRW,
+ SIM_MIN_ORDER_KRW,
+ SYMBOL,
+ TRADING_FEE_RATE,
+)
+from strategy import (
+ SIGNAL_BUY_LOWER,
+ SIGNAL_SELL_STOP,
+ SIGNAL_SELL_UPPER,
+)
+
+RULES_FILE = Path(__file__).parent / "discovered_rules.json"
+
+# 탐색에 쓸 특징 (불리언 컬럼)
+SEARCH_FEATURES: tuple[str, ...] = FEATURE_BOOL_COLS
+
+# 상위 봉 과열·하락 차단용 부정 조건 후보
+NEG_BLOCK_FEATURES: tuple[str, ...] = (
+ "cross_up_upper",
+ "above_upper",
+ "cross_down_lower",
+ "shooting_star",
+)
+
+# 탐색 규칙 적용 시 항상 매수 차단 (상단 돌파·과열)
+BUY_SAFETY_BLOCK: tuple[str, ...] = (
+ "m3:above_upper",
+ "m3:cross_up_upper",
+ "m10:above_upper",
+ "m10:cross_up_upper",
+)
+
+
+@dataclass
+class DiscoveredRules:
+ """탐색된 매수/매도 규칙 (모든 봉 특징 조합)."""
+
+ name: str = "discovered"
+ buy_all: list[str] = field(default_factory=list)
+ buy_any: list[list[str]] = field(default_factory=list)
+ sell_all: list[str] = field(default_factory=list)
+ sell_stop: list[str] = field(default_factory=list)
+ train_return_pct: float = 0.0
+ test_return_pct: float = 0.0
+ full_return_pct: float = 0.0
+ trade_count: int = 0
+
+
+def predicate_column(key: str) -> tuple[str, bool]:
+ """
+ 'm60:cross_up_lower' / 'd1:!above_upper' -> (컬럼명, negated).
+ """
+ if ":" not in key:
+ raise ValueError(f"잘못된 predicate: {key}")
+ prefix, rest = key.split(":", 1)
+ neg = rest.startswith("!")
+ feat = rest[1:] if neg else rest
+ return f"{prefix}_{feat}", neg
+
+
+def _mask_for_keys(matrix: pd.DataFrame, keys: list[str]) -> np.ndarray:
+ """AND 조건 마스크."""
+ n = len(matrix)
+ if not keys:
+ return np.ones(n, dtype=bool)
+ out = np.ones(n, dtype=bool)
+ for key in keys:
+ col, neg = predicate_column(key)
+ if col not in matrix.columns:
+ return np.zeros(n, dtype=bool)
+ vals = matrix[col].fillna(0).astype(bool).to_numpy()
+ if neg:
+ vals = ~vals
+ out &= vals
+ return out
+
+
+def _unsafe_buy_mask(matrix: pd.DataFrame) -> np.ndarray:
+ """
+ 고점 매수 차단.
+
+ - 상단/상향돌파
+ - 3분 밴드 하단(low)인데 유성형 → 급등 끝물음 (5/27 사례)
+ """
+ n = len(matrix)
+ unsafe = np.zeros(n, dtype=bool)
+ for key in BUY_SAFETY_BLOCK:
+ col, neg = predicate_column(key)
+ if neg or col not in matrix.columns:
+ continue
+ unsafe |= matrix[col].fillna(0).astype(bool).to_numpy()
+ if "Close" in matrix.columns:
+ roll_hi = matrix["Close"].astype(float).rolling(20, min_periods=5).max()
+ near_peak = matrix["Close"].astype(float) >= roll_hi * 0.97
+ if "m3_bb_pos_low" in matrix.columns and "m3_shooting_star" in matrix.columns:
+ # 급등 끝 고점: 밴드하단+유성형 (5/27 02:33) — 차트상 매도 구간
+ toxic = (
+ matrix["m3_bb_pos_low"].fillna(0).astype(bool)
+ & matrix["m3_shooting_star"].fillna(0).astype(bool)
+ & near_peak.fillna(False)
+ )
+ unsafe |= toxic.to_numpy()
+ if "m30_hammer" in matrix.columns:
+ # 30분 망치만으로 고점 매수 (5/27 00:00)
+ unsafe |= (
+ matrix["m30_hammer"].fillna(0).astype(bool) & near_peak.fillna(False)
+ ).to_numpy()
+ return unsafe
+
+
+def buy_mask(matrix: pd.DataFrame, rules: DiscoveredRules) -> np.ndarray:
+ """
+ 매수 마스크 = (buy_all) 또는 (buy_any 각 그룹의 AND) 중 하나 + 안전필터.
+
+ buy_any는 추가 분기(OR)이지, buy_all과의 AND가 아닙니다.
+ """
+ n = len(matrix)
+ groups: list[list[str]] = []
+ if rules.buy_all:
+ groups.append(list(rules.buy_all))
+ for g in rules.buy_any:
+ if g:
+ groups.append(list(g))
+ if not groups:
+ return np.zeros(n, dtype=bool)
+ any_ok = np.zeros(n, dtype=bool)
+ for group in groups:
+ any_ok |= _mask_for_keys(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)
+
+
+def generate_predicate_pool(intervals: list[int]) -> list[str]:
+ """탐색 후보 predicate 목록."""
+ pool: list[str] = []
+ for iv in intervals:
+ pfx = interval_prefix(iv)
+ for feat in SEARCH_FEATURES:
+ pool.append(f"{pfx}:{feat}")
+ if iv != ENTRY_INTERVAL:
+ for feat in NEG_BLOCK_FEATURES:
+ pool.append(f"{pfx}:!{feat}")
+ return pool
+
+
+def generate_trade_events(
+ matrix: pd.DataFrame,
+ rules: DiscoveredRules,
+) -> list[tuple[pd.Timestamp, str, str]]:
+ """
+ 규칙에 따른 체결 이벤트 목록.
+
+ Returns:
+ (timestamp, action, signal_name)
+ """
+ close = matrix["Close"].astype(float).to_numpy()
+ 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)
+ )
+
+ events: list[tuple[pd.Timestamp, str, str]] = []
+ qty = 0.0
+ last_buy_i: int | None = None
+ last_sell_i: int | None = None
+
+ for i in range(len(matrix)):
+ price = close[i]
+ if price <= 0 or np.isnan(price):
+ continue
+ ts = idx[i]
+
+ if qty > 0:
+ is_stop = bool(stop_mask[i])
+ is_sell = bool(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:
+ continue
+ sig = SIGNAL_SELL_STOP if is_stop else SIGNAL_SELL_UPPER
+ events.append((ts, "sell", sig))
+ qty = 0.0
+ last_sell_i = i
+ continue
+
+ if 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
+
+ return events
+
+
+def backtest_rules(
+ matrix: pd.DataFrame,
+ rules: DiscoveredRules,
+ df_1d: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ entry_ohlc: pd.DataFrame,
+) -> tuple[float, int]:
+ """
+ HTML 시뮬과 동일한 run_backtest 로직으로 수익률 계산.
+ """
+ from simulation_1h import run_backtest
+ import strategy as st
+
+ df = entry_ohlc.loc[matrix.index].copy()
+ df["signal"] = ""
+ df["point"] = 0
+ df["action"] = ""
+ df["trend"] = ""
+
+ for ts, action, sig in generate_trade_events(matrix, rules):
+ if ts not in df.index:
+ continue
+ trend_at = st.get_trend_at(df_1d, df_1h, ts)
+ df.at[ts, "signal"] = sig
+ df.at[ts, "point"] = 1
+ df.at[ts, "action"] = action
+ df.at[ts, "trend"] = trend_at
+
+ res = run_backtest(df, df_1d, df_1h, config_name=rules.name)
+ return res.total_return_pct, res.trade_count
+
+
+def _baseline_rules() -> DiscoveredRules:
+ 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=[],
+ )
+
+
+def greedy_search(
+ matrix: pd.DataFrame,
+ train_end: int,
+ pool: list[str],
+ seed: DiscoveredRules,
+ df_1d: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ entry_ohlc: pd.DataFrame,
+ max_buy: int = 5,
+ max_sell: int = 4,
+ max_stop: int = 2,
+) -> DiscoveredRules:
+ """학습 구간 수익률을 올리도록 매수/매도 조건을 탐욕적으로 확장."""
+ train = matrix.iloc[:train_end]
+ best = DiscoveredRules(
+ name=seed.name,
+ buy_all=list(seed.buy_all),
+ buy_any=[list(g) for g in seed.buy_any],
+ sell_all=list(seed.sell_all),
+ sell_stop=list(seed.sell_stop),
+ )
+ best_ret, _ = backtest_rules(train, best, df_1d, df_1h, entry_ohlc)
+
+ improved = True
+ while improved:
+ improved = False
+ # 매수 AND 추가/제거
+ for pred in pool:
+ if pred in best.buy_all:
+ trial_all = [p for p in best.buy_all if p != pred]
+ else:
+ if len(best.buy_all) >= max_buy:
+ continue
+ trial_all = best.buy_all + [pred]
+ trial = DiscoveredRules(
+ name="trial",
+ buy_all=trial_all,
+ buy_any=best.buy_any,
+ 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
+ best.buy_all = trial_all
+ improved = True
+
+ # 매도 AND
+ for pred in pool:
+ if pred in best.sell_all:
+ trial_s = [p for p in best.sell_all if p != pred]
+ else:
+ if len(best.sell_all) >= max_sell:
+ continue
+ trial_s = best.sell_all + [pred]
+ trial = DiscoveredRules(
+ name="trial",
+ buy_all=best.buy_all,
+ buy_any=best.buy_any,
+ 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
+ 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]
+ for pred in stop_pool:
+ if pred in best.sell_stop:
+ trial_st = [p for p in best.sell_stop if p != pred]
+ else:
+ if len(best.sell_stop) >= max_stop:
+ continue
+ trial_st = best.sell_stop + [pred]
+ trial = DiscoveredRules(
+ name="trial",
+ buy_all=best.buy_all,
+ buy_any=best.buy_any,
+ 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
+ best.sell_stop = trial_st
+ improved = True
+
+ return best
+
+
+def try_buy_any_branches(
+ matrix: pd.DataFrame,
+ train_end: int,
+ base: DiscoveredRules,
+ pool: list[str],
+ df_1d: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ entry_ohlc: pd.DataFrame,
+ max_branches: int = 8,
+) -> 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")]
+ best = DiscoveredRules(
+ name=base.name,
+ buy_all=list(base.buy_all),
+ buy_any=[list(g) for g in base.buy_any],
+ sell_all=list(base.sell_all),
+ sell_stop=list(base.sell_stop),
+ )
+ best_ret, _ = backtest_rules(train, best, df_1d, df_1h, entry_ohlc)
+
+ for pred in triggers[:max_branches]:
+ if pred in best.buy_all:
+ continue
+ trial = DiscoveredRules(
+ name="trial_or",
+ buy_all=[],
+ buy_any=[list(best.buy_all), [pred]],
+ sell_all=best.sell_all,
+ sell_stop=best.sell_stop,
+ )
+ 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
+ best.name = "discovered_or"
+
+ return best
+
+
+def random_search_refine(
+ matrix: pd.DataFrame,
+ train_end: int,
+ pool: list[str],
+ seed: DiscoveredRules,
+ df_1d: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ entry_ohlc: pd.DataFrame,
+ iterations: int = 1200,
+) -> DiscoveredRules:
+ """무작위 변형으로 국소 최적 보완."""
+ train = matrix.iloc[:train_end]
+ best = seed
+ best_ret, _ = backtest_rules(train, best, df_1d, df_1h, entry_ohlc)
+ rng = random.Random(42)
+
+ for _ in range(iterations):
+ trial = DiscoveredRules(
+ name="rand",
+ buy_all=[p for p in best.buy_all],
+ buy_any=[list(g) for g in best.buy_any],
+ sell_all=[p for p in best.sell_all],
+ 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 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)
+ 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:
+ 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
+ best = trial
+ best.name = "discovered_refined"
+ return best
+
+
+def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules:
+ """전체 탐색 파이프라인."""
+ print("특징 행렬 생성 (모든 봉·캔들 위치/높이)...")
+ from config import ENTRY_INTERVAL, TREND_INTERVAL_1D, TREND_INTERVAL_1H
+
+ entry_raw = frames[ENTRY_INTERVAL]
+ df_1d = frames.get(TREND_INTERVAL_1D)
+ if df_1d is None or df_1d.empty:
+ df_1d = entry_raw
+ df_1h = frames.get(TREND_INTERVAL_1H)
+ if df_1h is None or df_1h.empty:
+ df_1h = entry_raw
+
+ matrix = build_master_feature_matrix(frames)
+ matrix = matrix.iloc[21:].copy()
+ entry_ohlc = entry_raw.iloc[21:].loc[matrix.index]
+ n = len(matrix)
+ train_end = int(n * 0.7)
+ intervals = sorted(frames.keys())
+ pool = generate_predicate_pool(intervals)
+ print(f" 샘플 {n}봉 | 학습 {train_end} | predicate 후보 {len(pool)}개")
+
+ baseline = _baseline_rules()
+ br, bt = backtest_rules(matrix.iloc[:train_end], baseline, df_1d, df_1h, entry_ohlc)
+ bf, _ = backtest_rules(matrix, baseline, df_1d, df_1h, entry_ohlc)
+ print(f" 기준선(3분 BB만): 학습 {br:+.2f}% | 전체 {bf:+.2f}%")
+
+ print("1단계: 탐욕적 AND 확장...")
+ g1 = greedy_search(matrix, train_end, pool, baseline, df_1d, df_1h, entry_ohlc)
+ r1, _ = backtest_rules(matrix.iloc[:train_end], g1, df_1d, df_1h, entry_ohlc)
+ print(f" 학습 {r1:+.2f}% | buy={g1.buy_all} sell={g1.sell_all}")
+
+ print("2단계: 매수 OR 분기(다른 봉 트리거)...")
+ g2 = try_buy_any_branches(matrix, train_end, g1, pool, df_1d, df_1h, entry_ohlc)
+ r2, _ = backtest_rules(matrix.iloc[:train_end], g2, df_1d, df_1h, entry_ohlc)
+ print(f" 학습 {r2:+.2f}%")
+
+ 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)
+ full_ret, full_cnt = backtest_rules(matrix, g3, df_1d, df_1h, entry_ohlc)
+
+ g3.train_return_pct = train_ret
+ g3.test_return_pct = test_ret
+ g3.full_return_pct = full_ret
+ g3.trade_count = full_cnt
+ g3.name = "discovered_best"
+
+ print(f"\n최종 규칙 ({g3.name})")
+ print(f" 매수 AND: {g3.buy_all}")
+ if g3.buy_any:
+ print(f" 매수 OR: {g3.buy_any}")
+ 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}건)")
+ return g3
+
+
+def save_rules(rules: DiscoveredRules, path: Path = RULES_FILE) -> None:
+ path.write_text(json.dumps(asdict(rules), ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def rules_have_buy(rules: DiscoveredRules) -> bool:
+ """매수 규칙이 하나라도 있는지."""
+ if rules.buy_all:
+ return True
+ return any(bool(g) for g in rules.buy_any)
+
+
+def load_rules(path: Path = RULES_FILE) -> DiscoveredRules | None:
+ if not path.exists():
+ return None
+ data = json.loads(path.read_text(encoding="utf-8"))
+ rules = DiscoveredRules(**{k: data[k] for k in asdict(DiscoveredRules()).keys() if k in data})
+ if not rules_have_buy(rules):
+ return None
+ return rules
+
+
+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/simulation_1h.py b/simulation_1h.py
index bfaf3d6..cf4c2f4 100644
--- a/simulation_1h.py
+++ b/simulation_1h.py
@@ -1,333 +1,671 @@
+"""
+WLD 3분 BB 시뮬레이션.
+
+기본: 하단 상향 돌파 매수, 상단 상향 돌파 매도.
+수수료 반영, 레짐/필터 조합 비교 지원.
+
+ python simulation_1h.py # discovered_rules HTML 차트 (기본)
+ python simulation_1h.py discover # 모든 봉 특징 탐색 → discovered_rules.json
+ python simulation_1h.py compare # 9종 조합 수익률 순위
+ python simulation_1h.py mtf # 봉별 BB 비교 + MTF 시뮬
+"""
+
+from __future__ import annotations
+
+import sys
+from dataclasses import dataclass
+
import pandas as pd
-import yfinance as yf
import plotly.graph_objs as go
-from plotly import subplots
import plotly.io as pio
from datetime import datetime
-pio.renderers.default = 'browser'
+from plotly import subplots
-from config import *
+pio.renderers.default = "browser"
+
+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
+
+
+@dataclass
+class SimTrade:
+ dt: pd.Timestamp
+ action: str
+ signal: str
+ price: float
+ krw: float
+ fee: float
+ quantity: float
+ pnl: float | None
+ cash_after: float
+ total_asset: float
+
+
+@dataclass
+class SimResult:
+ config_name: str
+ trades: list[SimTrade]
+ initial_cash: float
+ final_cash: float
+ final_coin_qty: float
+ final_price: float
+ realized_pnl: float
+ total_fees: float
+ final_asset: float
+ total_return_pct: float
+ trade_count: int
+ win_count: int
+
+
+def run_backtest(
+ df_3m: pd.DataFrame,
+ df_1d: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ config_name: str = "",
+ initial_cash: float = SIM_INITIAL_CASH_KRW,
+ min_order_krw: float = SIM_MIN_ORDER_KRW,
+ fee_rate: float = TRADING_FEE_RATE,
+) -> SimResult:
+ """신호 순서대로 현물 매수/매도 시뮬레이션 (수수료 차감)."""
+ cash = float(initial_cash)
+ coin_qty = 0.0
+ cost_basis = 0.0
+ realized_pnl = 0.0
+ total_fees = 0.0
+ win_count = 0
+ trades: list[SimTrade] = []
+ last_buy_ts: pd.Timestamp | None = None
+ last_sell_ts: pd.Timestamp | None = None
+
+ signals = df_3m[df_3m["point"] == 1].sort_index()
+
+ for ts, row in signals.iterrows():
+ price = float(row["Close"])
+ action = str(row.get("action", ""))
+ signal_name = str(row.get("signal", ""))
+ if price <= 0:
+ continue
+
+ trend_at = str(row.get("trend", "")) or strategy.get_trend_at(df_1d, df_1h, ts)
+ if trend_at not in ("up", "down", "range"):
+ trend_at = strategy.get_trend_at(df_1d, df_1h, ts)
+
+ if action == "buy":
+ if last_buy_ts is not None:
+ if (ts - last_buy_ts).total_seconds() < BUY_COOLDOWN_SEC:
+ continue
+
+ buy_krw = float(
+ strategy.get_buy_amount(SYMBOL, signal_name, price, trend_at)
+ )
+ buy_krw = max(min_order_krw, min(buy_krw, cash))
+ fee = buy_krw * fee_rate
+ total_cost = buy_krw + fee
+ if buy_krw < min_order_krw or cash < total_cost:
+ continue
+
+ qty = buy_krw / price
+ cash -= total_cost
+ total_fees += fee
+ cost_basis += buy_krw
+ coin_qty += qty
+ last_buy_ts = ts
+
+ trades.append(
+ SimTrade(
+ dt=ts,
+ action="매수",
+ signal=signal_name,
+ price=price,
+ krw=buy_krw,
+ fee=fee,
+ quantity=qty,
+ pnl=None,
+ cash_after=cash,
+ total_asset=cash + coin_qty * price,
+ )
+ )
+ continue
+
+ if action == "sell":
+ if coin_qty <= 0:
+ continue
+ if last_sell_ts is not None:
+ if (ts - last_sell_ts).total_seconds() < SELL_COOLDOWN_SEC:
+ continue
+
+ ratio = strategy.get_sell_ratio(SYMBOL, signal_name)
+ sell_qty = min(coin_qty * ratio, coin_qty)
+ sell_krw = sell_qty * price
+
+ if sell_krw < min_order_krw:
+ if coin_qty * price < min_order_krw:
+ continue
+ sell_qty = coin_qty
+ sell_krw = sell_qty * price
+
+ fee = sell_krw * fee_rate
+ net = sell_krw - fee
+ avg_cost = cost_basis / coin_qty
+ sold_cost = avg_cost * sell_qty
+ pnl = net - sold_cost
+
+ cash += net
+ total_fees += fee
+ cost_basis -= sold_cost
+ coin_qty -= sell_qty
+ realized_pnl += pnl
+ if pnl > 0:
+ win_count += 1
+ if coin_qty < 1e-12:
+ coin_qty = 0.0
+ cost_basis = 0.0
+ last_sell_ts = ts
+
+ trades.append(
+ SimTrade(
+ dt=ts,
+ action="매도",
+ signal=signal_name,
+ price=price,
+ krw=sell_krw,
+ fee=fee,
+ quantity=sell_qty,
+ pnl=pnl,
+ cash_after=cash,
+ total_asset=cash + coin_qty * price,
+ )
+ )
+
+ final_price = float(df_3m["Close"].iloc[-1])
+ final_asset = cash + coin_qty * final_price
+ sell_trades = sum(1 for t in trades if t.action == "매도")
+
+ return SimResult(
+ config_name=config_name,
+ trades=trades,
+ initial_cash=initial_cash,
+ final_cash=cash,
+ final_coin_qty=coin_qty,
+ final_price=final_price,
+ realized_pnl=realized_pnl,
+ total_fees=total_fees,
+ final_asset=final_asset,
+ total_return_pct=(final_asset - initial_cash) / initial_cash * 100
+ if initial_cash > 0
+ else 0.0,
+ trade_count=len(trades),
+ win_count=win_count if sell_trades else 0,
+ )
+
+
+def print_backtest_report(result: SimResult) -> None:
+ fee_pct = TRADING_FEE_RATE * 100
+ print("\n" + "=" * 80)
+ print(
+ f"[{result.config_name}] 시작 {result.initial_cash:,.0f}원 | "
+ f"최소주문 {SIM_MIN_ORDER_KRW:,.0f}원 | 수수료 {fee_pct:.3f}%/쪽"
+ )
+ print("=" * 80)
+ if not result.trades:
+ print("체결 없음")
+ else:
+ print(
+ f"{'일시':<18} {'구분':<4} {'신호':<22} {'가격':>9} {'금액':>10} "
+ f"{'수수료':>8} {'수익':>10}"
+ )
+ print("-" * 80)
+ for t in result.trades:
+ pnl_s = f"{t.pnl:+,.0f}" if t.pnl is not None else "-"
+ print(
+ f"{t.dt.strftime('%Y-%m-%d %H:%M'):<18} {t.action:<4} {t.signal:<22} "
+ f"{t.price:>9,.2f} {t.krw:>10,.0f} {t.fee:>8,.0f} {pnl_s:>10}"
+ )
+ print("-" * 80)
+ sells = sum(1 for t in result.trades if t.action == "매도")
+ win_rate = result.win_count / sells * 100 if sells else 0.0
+ print(f"거래 횟수: {result.trade_count} (매도 {sells}회) | 승률: {win_rate:.1f}%")
+ print(f"수수료 합계: {result.total_fees:,.0f}원")
+ print(f"실현 손익(수수료 반영): {result.realized_pnl:+,.0f}원")
+ print(
+ f"최종 자산: {result.final_asset:,.0f}원 | "
+ f"총수익: {result.final_asset - result.initial_cash:+,.0f}원 "
+ f"({result.total_return_pct:+.2f}%)"
+ )
+ print("=" * 80)
+
+
+def run_comparison(df_1d: pd.DataFrame, df_1h: pd.DataFrame, df_3m: pd.DataFrame) -> None:
+ """기법 조합별 수익률 비교 (수수료 포함)."""
+ print(f"\n{'='*80}")
+ print(f"전략 조합 비교 — {SYMBOL} 3분 | {df_3m.index[0]} ~ {df_3m.index[-1]}")
+ print(f"시작 {SIM_INITIAL_CASH_KRW:,}원 | 수수료 {TRADING_FEE_RATE*100:.3f}%/매수·매도")
+ print(f"{'='*80}")
+ print(
+ f"{'순위':<4} {'조합':<22} {'수익률':>9} {'최종자산':>12} "
+ f"{'거래':>6} {'승률':>7} {'수수료':>10}"
+ )
+ print("-" * 80)
+
+ rows: list[tuple[SimResult, strategy.StrategyConfig]] = []
+ for cfg in strategy.comparison_presets():
+ df_sig = strategy.annotate_signals(
+ SYMBOL,
+ df_3m.copy(),
+ simulation=True,
+ df_1h=df_1h,
+ df_1d=df_1d,
+ config=cfg,
+ )
+ res = run_backtest(df_sig, df_1d, df_1h, config_name=cfg.name)
+ rows.append((res, cfg))
+
+ rows.sort(key=lambda x: x[0].total_return_pct, reverse=True)
+
+ for rank, (res, cfg) in enumerate(rows, 1):
+ sells = sum(1 for t in res.trades if t.action == "매도")
+ wr = res.win_count / sells * 100 if sells else 0.0
+ print(
+ f"{rank:<4} {res.config_name:<22} {res.total_return_pct:>+8.2f}% "
+ f"{res.final_asset:>12,.0f} {res.trade_count:>6} {wr:>6.1f}% "
+ f"{res.total_fees:>10,.0f}"
+ )
+
+ best_res, best_cfg = rows[0]
+ print("-" * 80)
+ print(f"1위: {best_cfg.name} ({best_res.total_return_pct:+.2f}%)")
+ print(
+ "실거래 적용: strategy.ACTIVE_CONFIG 를 1위 조합으로 맞추세요 "
+ "(현재 ACTIVE_CONFIG.name=%s)" % strategy.ACTIVE_CONFIG.name
+ )
+ print(f"{'='*80}\n")
class Simulation:
-
- def render_plotly(self, symbol: str, interval_minutes: int, data: pd.DataFrame, inverseData: pd.DataFrame) -> None:
- fig = subplots.make_subplots(
- rows=3, cols=1,
- subplot_titles=("캔들", "이격도/거래량", "장기 이격도"),
- shared_xaxes=True, horizontal_spacing=0.03, vertical_spacing=0.03,
- row_heights=[0.6, 0.2, 0.2]
- )
-
- # Row 1: 캔들 + 이동평균 + 볼린저
- fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'], low=data['Low'], close=data['Close'], name='캔들'), row=1, col=1)
- for ma_col, color in [('MA5','red'),('MA20','blue'),('MA40','green'),('MA120','purple'),('MA200','brown'),('MA240','darkred'),('MA720','cyan'),('MA1440','magenta')]:
- if ma_col in data.columns:
- fig.add_trace(go.Scatter(x=data.index, y=data[ma_col], name=ma_col, mode='lines', line=dict(color=color, width=1)), row=1, col=1)
- if 'Lower' in data.columns and 'Upper' in data.columns:
- fig.add_trace(go.Scatter(x=data.index, y=data['Lower'], name='볼린저 하단', mode='lines', line=dict(color='grey', width=1, dash='dot')), row=1, col=1)
- fig.add_trace(go.Scatter(x=data.index, y=data['Upper'], name='볼린저 상단', mode='lines', line=dict(color='grey', width=1, dash='dot')), row=1, col=1)
-
- # 매수 포인트
- for sig, color in [('movingaverage','red'),('deviation40','orange'),('Deviation720','blue'),('deviation1440','purple'),('fall_6p','black')]:
- pts = data[(data['point']==1) & (data['signal']==sig)]
- if len(pts)>0:
- fig.add_trace(go.Scatter(x=pts.index, y=pts['Close'], mode='markers', name=f'{sig} 매수', marker=dict(color=color, size=8, symbol='circle')), row=1, col=1)
-
- # 매도 포인트: inverseData의 buy 신호 중 fall_6p, deviation40만 일반 그래프 가격축에 매도로 표시
- inv_sell_pts = inverseData[(inverseData['point']==1) & (inverseData['signal'].isin(['deviation40','fall_6p']))]
- if len(inv_sell_pts)>0:
- idx = inv_sell_pts.index.intersection(data.index)
- if len(idx)>0:
- fig.add_trace(
- go.Scatter(
- x=idx,
- y=data.loc[idx, 'Close'],
- mode='markers',
- name='매도',
- marker=dict(color='orange', size=10, symbol='triangle-down')
- ),
- row=1, col=1
- )
-
- # Row 2: 이격도 + 거래량
- for dev_col, color, width in [('Deviation5','red',1),('Deviation20','blue',1),('Deviation40','green',2),('Deviation120','purple',1),('Deviation200','brown',1),('Deviation720','darkred',2),('Deviation720','cyan',1),('Deviation1440','magenta',1)]:
- if dev_col in data.columns:
- fig.add_trace(go.Scatter(x=data.index, y=data[dev_col], name=dev_col, mode='lines', line=dict(color=color, width=width)), row=2, col=1)
- if 'Volume' in data.columns:
- fig.add_trace(go.Bar(x=data.index, y=data['Volume'], name='거래량', marker_color='lightgray', opacity=0.5), row=2, col=1)
-
- # Row 3: 장기 이격도 및 기준선
- for dev_col, color in [('Deviation720','darkred'),('Deviation1440','magenta')]:
- if dev_col in data.columns:
- fig.add_trace(go.Scatter(x=data.index, y=data[dev_col], name=f'{dev_col}(장기)', mode='lines', line=dict(color=color, width=2)), row=3, col=1)
- for h, color in [(90,'red'),(95,'green'),(100,'black')]:
- fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1)
-
- # ----------------- 인버스용 트레이스 (초기 숨김) -----------------
- n_orig = len(fig.data)
-
- # Row 1: 캔들/MA/볼린저 (inverseData)
- fig.add_trace(go.Candlestick(x=inverseData.index, open=inverseData['Open'], high=inverseData['High'], low=inverseData['Low'], close=inverseData['Close'], name='캔들(인버스)', showlegend=True, visible=False), row=1, col=1)
- for ma_col, color in [('MA5','red'),('MA20','blue'),('MA40','green'),('MA120','purple'),('MA200','brown'),('MA240','darkred'),('MA720','cyan'),('MA1440','magenta')]:
- if ma_col in inverseData.columns:
- fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[ma_col], name=f'{ma_col}(인버스)', mode='lines', line=dict(color=color, width=1), showlegend=True, visible=False), row=1, col=1)
- if 'Lower' in inverseData.columns and 'Upper' in inverseData.columns:
- fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData['Lower'], name='볼린저 하단(인버스)', mode='lines', line=dict(color='grey', width=1, dash='dot'), showlegend=True, visible=False), row=1, col=1)
- fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData['Upper'], name='볼린저 상단(인버스)', mode='lines', line=dict(color='grey', width=1, dash='dot'), showlegend=True, visible=False), row=1, col=1)
-
- # 인버스 매수 포인트: fall_6p, deviation40만 표시
- for sig, color in [('deviation40','orange'),('fall_6p','black')]:
- pts_inv = inverseData[(inverseData['point']==1) & (inverseData['signal']==sig)]
- if len(pts_inv)>0:
- fig.add_trace(go.Scatter(x=pts_inv.index, y=inverseData.loc[pts_inv.index,'Close'], mode='markers', name=f'{sig} 매수(인버스)', marker=dict(color=color, size=8, symbol='circle'), showlegend=True, visible=False), row=1, col=1)
-
- # 인버스 보기에서의 매도 포인트: 일반 그래프의 매수를 인버스 그래프의 매도로 표시 (모든 매수 신호 반영)
- normal_to_inv_sell = data[(data['point']==1)]
- if len(normal_to_inv_sell) > 0:
- idx2 = normal_to_inv_sell.index.intersection(inverseData.index)
- if len(idx2) > 0:
- fig.add_trace(
- go.Scatter(
- x=idx2,
- y=inverseData.loc[idx2, 'Close'],
- mode='markers',
- name='매도(일반→인버스)',
- marker=dict(color='orange', size=10, symbol='triangle-down'),
- showlegend=True,
- visible=False
- ),
- row=1, col=1
- )
-
- # Row 2: 이격도 + 거래량 (inverseData)
- for dev_col, color, width in [('Deviation5','red',1),('Deviation20','blue',1),('Deviation40','green',2),('Deviation120','purple',1),('Deviation200','brown',1),('Deviation720','darkred',2),('Deviation720','cyan',1),('Deviation1440','magenta',1)]:
- if dev_col in inverseData.columns:
- fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[dev_col], name=f'{dev_col}(인버스)', mode='lines', line=dict(color=color, width=width), showlegend=True, visible=False), row=2, col=1)
- if 'Volume' in inverseData.columns:
- fig.add_trace(go.Bar(x=inverseData.index, y=inverseData['Volume'], name='거래량(인버스)', marker_color='lightgray', opacity=0.5, showlegend=True, visible=False), row=2, col=1)
-
- # Row 3: 장기 이격도 (inverseData)
- for dev_col, color in [('Deviation720','darkred'),('Deviation1440','magenta')]:
- if dev_col in inverseData.columns:
- fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[dev_col], name=f'{dev_col}(장기-인버스)', mode='lines', line=dict(color=color, width=2), showlegend=True, visible=False), row=3, col=1)
-
- n_total = len(fig.data)
- n_inv = n_total - n_orig
- visible_orig = [True]*n_orig + [False]*n_inv
- visible_inv = [False]*n_orig + [True]*n_inv
- legendtitle_orig = {'text': '일반 그래프'}
- legendtitle_inv = {'text': '인버스 그래프'}
-
- fig.update_layout(
- height=1000,
- margin=dict(t=180, l=40, r=240, b=40),
- title=dict(
- text=f"{symbol}, {interval_minutes} 분봉, ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})",
- x=0.5,
- xanchor='center',
- y=0.995,
- yanchor='top',
- pad=dict(t=10, b=12)
- ),
- xaxis_rangeslider_visible=False,
- xaxis1_rangeslider_visible=False,
- xaxis2_rangeslider_visible=False,
- legend=dict(orientation='v', yref='paper', yanchor='top', y=1.0, xref='paper', xanchor='left', x=1.02, title=legendtitle_orig),
- dragmode='zoom',
- updatemenus=[dict(
- type='buttons',
- direction='left',
- x=0.0,
- xanchor='left',
- y=1.11,
- yanchor='top',
- pad=dict(t=0, r=10, b=0, l=0),
- buttons=[
- dict(
- label='홈',
- method='update',
- args=[
- {'visible': visible_orig},
- {
- 'legend': {'title': legendtitle_orig},
- 'xaxis.autorange': True,
- 'xaxis2.autorange': True,
- 'xaxis3.autorange': True,
- 'yaxis.autorange': True,
- 'yaxis2.autorange': True,
- 'yaxis3.autorange': True,
- }
- ],
- execute=True
- ),
- dict(
- label='인버스',
- method='update',
- args=[
- {'visible': visible_inv},
- {'legend': {'title': legendtitle_inv, 'orientation': 'v', 'y': 1.0, 'yanchor': 'top', 'x': 1.02, 'xanchor': 'left'}}
- ],
- args2=[
- {'visible': visible_orig},
- {'legend': {'title': legendtitle_orig, 'orientation': 'v', 'y': 1.0, 'yanchor': 'top', 'x': 1.02, 'xanchor': 'left'}}
- ],
- execute=True
- ),
- ]
- )]
- )
- fig.update_xaxes(title_text='시간', row=3, col=1)
- fig.update_yaxes(title_text='가격 (KRW)', row=1, col=1)
- fig.update_yaxes(title_text='이격도/거래량', row=2, col=1)
- fig.update_yaxes(title_text='장기 이격도', row=3, col=1)
-
- fig.show(config={'scrollZoom': True, 'displaylogo': False})
def __init__(self) -> None:
- self.monitor = Monitor()
- self.INTERVAL_MAP = {
- 60: "60m",
- 240: "4h",
- }
+ self.monitor = Monitor(cooldown_file=None)
- def detect_turnaround_signal(self, symbol, data, interval=0, params=None):
- if len(data) < 7:
- return None
- current_data = data.iloc[-1]
- if current_data.get('point', 0) == 1:
- return {
- 'alert': True,
- 'details': f"매수신호: {current_data.get('signal', 'unknown')}"
- }
- return {'alert': False, 'details': "매수신호 없음"}
+ def load_mtf(self, symbol: str):
+ df_1d = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1D)
+ df_1h = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1H)
+ df_3m = self.monitor.get_coin_some_data(symbol, ENTRY_INTERVAL)
- def fetch_price_history(self, symbol: str, interval_minutes: int, days: int = 30) -> pd.DataFrame:
- if symbol in KR_COINS:
- bong_count = 3000
- return self.monitor.get_coin_more_data(symbol, interval_minutes, bong_count=bong_count)
- if interval_minutes not in self.INTERVAL_MAP:
- raise ValueError("interval must be 60 or 240")
- interval_str = self.INTERVAL_MAP[interval_minutes]
- df = yf.download(
- tickers=symbol,
- period=f"{days}d",
- interval=interval_str,
- progress=False,
+ if df_1d is None or df_1d.empty:
+ df_1d = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1D, bong_count=500)
+ if df_1h is None or df_1h.empty:
+ df_1h = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1H, bong_count=5000)
+ if df_3m is None or df_3m.empty:
+ df_3m = self.monitor.get_coin_more_data(
+ symbol, ENTRY_INTERVAL, bong_count=90000, verbose=True
+ )
+
+ df_1d = self.monitor.calculate_technical_indicators(df_1d)
+ df_1h = self.monitor.calculate_technical_indicators(df_1h)
+ df_3m = self.monitor.calculate_technical_indicators(df_3m)
+ return df_1d, df_1h, df_3m
+
+ def render_plotly(self, df_3m: pd.DataFrame, trend: str, result: SimResult) -> None:
+ cfg = strategy.ACTIVE_CONFIG.name
+ summary = (
+ f"[{cfg}] 시작 {result.initial_cash:,.0f} | 최종 {result.final_asset:,.0f} | "
+ f"{result.total_return_pct:+.2f}% | 수수료 {result.total_fees:,.0f}"
)
- if df.empty:
- raise RuntimeError("No data fetched. Check symbol or interval support.")
- return df
+ fig = subplots.make_subplots(
+ rows=3,
+ cols=1,
+ subplot_titles=(
+ f"{COIN_NAME} 3분 BB — {trend}",
+ "RSI / BB폭(%)",
+ summary,
+ ),
+ shared_xaxes=False,
+ vertical_spacing=0.06,
+ row_heights=[0.5, 0.18, 0.32],
+ specs=[[{"type": "xy"}], [{"type": "xy"}], [{"type": "table"}]],
+ )
+ fig.add_trace(
+ go.Candlestick(
+ x=df_3m.index,
+ open=df_3m["Open"],
+ high=df_3m["High"],
+ low=df_3m["Low"],
+ close=df_3m["Close"],
+ name="캔들",
+ showlegend=False,
+ ),
+ row=1,
+ col=1,
+ )
+ for col, color in [("MA", "blue"), ("Upper", "gray"), ("Lower", "gray")]:
+ if col in df_3m.columns:
+ fig.add_trace(
+ go.Scatter(
+ x=df_3m.index,
+ y=df_3m[col],
+ name=col,
+ line=dict(color=color, dash="dot" if col != "MA" else "solid"),
+ showlegend=False,
+ ),
+ row=1,
+ col=1,
+ )
- def analyze_bottom_period(self, symbol: str, interval_minutes: int, days: int = 90):
- data = self.fetch_price_history(symbol, interval_minutes, days)
- data = self.monitor.calculate_technical_indicators(data)
- data = self.monitor.annotate_signals(symbol, data, simulation=True)
- print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}")
- print(f"총 데이터 수: {len(data)}")
- bottom_start = pd.Timestamp('2025-06-22')
- bottom_end = pd.Timestamp('2025-07-09')
- bottom_data = data[(data.index >= bottom_start) & (data.index <= bottom_end)]
- if len(bottom_data) == 0:
- print("저점 기간 데이터가 없습니다.")
- return None, []
- print(f"\n저점 기간 데이터: {bottom_data.index[0]} ~ {bottom_data.index[-1]}")
- print(f"저점 기간 데이터 수: {len(bottom_data)}")
- print("\n=== 저점 기간 기술적 지표 분석 ===")
- min_price = bottom_data['Low'].min()
- max_price = bottom_data['High'].max()
- avg_price = bottom_data['Close'].mean()
- print(f"최저가: {min_price:.4f}")
- print(f"최고가: {max_price:.4f}")
- print(f"평균가: {avg_price:.4f}")
- print(f"가격 변동폭: {((max_price - min_price) / min_price * 100):.2f}%")
- bb_lower_min = bottom_data['Lower'].min()
- bb_upper_max = bottom_data['Upper'].max()
- print(f"\n볼린저 밴드 분석:")
- print(f"하단 밴드 최저: {bb_lower_min:.4f}")
- print(f"상단 밴드 최고: {bb_upper_max:.4f}")
- volume_avg = bottom_data['Volume'].mean()
- volume_max = bottom_data['Volume'].max()
- print(f"\n거래량 분석:")
- print(f"평균 거래량: {volume_avg:.0f}")
- print(f"최대 거래량: {volume_max:.0f}")
- actual_bottom_idx = bottom_data['Low'].idxmin()
- actual_bottom_price = bottom_data.loc[actual_bottom_idx, 'Low']
- actual_bottom_date = actual_bottom_idx
- print(f"\n실제 저점:")
- print(f"날짜: {actual_bottom_date}")
- print(f"가격: {actual_bottom_price:.4f}")
- print(f"볼린저 하단 대비: {((actual_bottom_price - bottom_data.loc[actual_bottom_idx, 'Lower']) / bottom_data.loc[actual_bottom_idx, 'Lower'] * 100):.2f}%")
- print(f"\n=== 매수 신호 분석 ===")
- bottom_alerts = bottom_data[bottom_data['point'] == 1]
- alerts = [(idx, row['Close']) for idx, row in bottom_alerts.iterrows()]
- print(f"저점 기간 매수 신호 수: {len(alerts)}")
- if alerts:
- print("매수 신호 발생 시점:")
- for date, price in alerts:
- print(f" {date}: {price:.4f}")
- return bottom_data, alerts
+ buy_trades = [t for t in result.trades if t.action == "매수"]
+ sell_trades = [t for t in result.trades if t.action == "매도"]
+ fig.add_trace(
+ go.Scatter(
+ x=[t.dt for t in buy_trades],
+ y=[t.price for t in buy_trades],
+ mode="markers",
+ name="매수",
+ legendgroup="trades",
+ showlegend=True,
+ marker=dict(
+ color="#22c55e",
+ size=11,
+ symbol="triangle-up",
+ line=dict(width=1, color="#166534"),
+ ),
+ ),
+ row=1,
+ col=1,
+ )
+ fig.add_trace(
+ go.Scatter(
+ x=[t.dt for t in sell_trades],
+ y=[t.price for t in sell_trades],
+ mode="markers",
+ name="매도",
+ legendgroup="trades",
+ showlegend=True,
+ marker=dict(
+ color="#ef4444",
+ size=11,
+ symbol="triangle-down",
+ line=dict(width=1, color="#991b1b"),
+ ),
+ ),
+ row=1,
+ col=1,
+ )
+ if "RSI" in df_3m.columns:
+ fig.add_trace(
+ go.Scatter(
+ x=df_3m.index,
+ y=df_3m["RSI"],
+ name="RSI",
+ showlegend=False,
+ ),
+ row=2,
+ col=1,
+ )
+ if "BB_Width" in df_3m.columns:
+ fig.add_trace(
+ go.Scatter(
+ x=df_3m.index,
+ y=df_3m["BB_Width"],
+ name="BB폭%",
+ showlegend=False,
+ ),
+ row=2,
+ col=1,
+ )
+ if result.trades:
+ cells = [
+ [t.dt.strftime("%Y-%m-%d %H:%M") for t in result.trades],
+ [t.action for t in result.trades],
+ [t.signal for t in result.trades],
+ [f"{t.price:,.2f}" for t in result.trades],
+ [f"{t.krw:,.0f}" for t in result.trades],
+ [f"{t.fee:,.0f}" for t in result.trades],
+ [f"{t.pnl:+,.0f}" if t.pnl is not None else "-" for t in result.trades],
+ [f"{t.total_asset:,.0f}" for t in result.trades],
+ ]
+ else:
+ cells = [["-"] * 8]
+ fig.add_trace(
+ go.Table(
+ header=dict(
+ values=[
+ "일시",
+ "구분",
+ "신호",
+ "가격",
+ "금액",
+ "수수료",
+ "수익",
+ "총자산",
+ ],
+ fill_color="#e8e8e8",
+ ),
+ cells=dict(values=cells),
+ ),
+ row=3,
+ col=1,
+ )
+ fig.update_layout(
+ height=1100,
+ title=f"{SYMBOL} BB 타이밍 시뮬 (범례 클릭: 매수/매도 표시 토글)",
+ margin=dict(l=50, r=140, t=80, b=40),
+ dragmode="zoom",
+ legend=dict(
+ orientation="v",
+ yanchor="top",
+ y=0.99,
+ xanchor="left",
+ x=1.01,
+ bgcolor="rgba(255,255,255,0.9)",
+ bordercolor="#cccccc",
+ borderwidth=1,
+ font=dict(size=12),
+ title=dict(text="체결 (클릭 토글)", side="top"),
+ itemclick="toggle",
+ itemdoubleclick="toggleothers",
+ ),
+ )
+ # Y축 고정·rangeslider 해제 → 세로 드래그/박스줌·휠 줌 가능
+ fig.update_xaxes(
+ rangeslider_visible=False,
+ fixedrange=False,
+ row=1,
+ col=1,
+ )
+ fig.update_xaxes(fixedrange=False, row=2, col=1)
+ fig.update_yaxes(
+ title_text="가격 (KRW)",
+ fixedrange=False,
+ scaleanchor=None,
+ scaleratio=None,
+ row=1,
+ col=1,
+ )
+ fig.update_yaxes(
+ fixedrange=False,
+ scaleanchor=None,
+ scaleratio=None,
+ row=2,
+ col=1,
+ )
+ fig.show(
+ config={
+ "scrollZoom": True,
+ "displaylogo": False,
+ "doubleClick": "reset",
+ "modeBarButtonsToAdd": ["zoom2d", "pan2d", "resetScale2d"],
+ }
+ )
- def run_simulation(self, symbol: str, interval_minutes: int, days: int = 30):
- data = self.fetch_price_history(symbol, interval_minutes)
+ def load_all_frames(self) -> dict[int, pd.DataFrame]:
+ """discovered 규칙용 전 간격 로드."""
+ from mtf_bb import load_frames_from_db
- inverseData = self.monitor.inverse_data(data)
- inverseData = self.monitor.annotate_signals(symbol, inverseData, simulation=True)
+ return load_frames_from_db(self.monitor, SYMBOL)
- data = self.monitor.calculate_technical_indicators(data)
- data = self.monitor.annotate_signals(symbol, data, simulation=True)
- print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}")
- print(f"총 데이터 수: {len(data)}")
- alerts = []
- for i in range(len(data)):
- if data['point'].iloc[i] == 1:
- alerts.append((data.index[i], data['Close'].iloc[i]))
- print(f"\n총 매수 신호 수: {len(alerts)}")
- ma_signals = len(data[(data['point'] == 1) & (data['signal'] == 'movingaverage')])
- dev40_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation40')])
- dev240_signals = len(data[(data['point'] == 1) & (data['signal'] == 'Deviation720')])
- dev1440_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation1440')])
- print(f" - MA 신호: {ma_signals}")
- print(f" - Dev40 신호: {dev40_signals}")
- print(f" - Dev240 신호: {dev240_signals}")
- print(f" - Dev1440 신호: {dev1440_signals}")
+ def _run_one_strategy(
+ self,
+ name: str,
+ df_1d: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ df_3m: pd.DataFrame,
+ cfg: strategy.StrategyConfig,
+ frames: dict | None = None,
+ ) -> tuple[pd.DataFrame, SimResult, int]:
+ """한 전략으로 신호·백테스트. 반환: (df, result, 신호수)."""
+ df_sig = strategy.annotate_signals(
+ SYMBOL,
+ df_3m.copy(),
+ simulation=True,
+ df_1h=df_1h,
+ df_1d=df_1d,
+ config=cfg,
+ frames=frames,
+ )
+ n_sig = int((df_sig["point"] == 1).sum())
+ res = run_backtest(df_sig, df_1d, df_1h, config_name=name)
+ return df_sig, res, n_sig
- # Plotly 기반 시각화로 전환
- self.render_plotly(symbol, interval_minutes, data, inverseData)
+ def run(self, config: strategy.StrategyConfig | None = None) -> SimResult:
+ """기본 BB vs 탐색 규칙 중 수익률·신호가 있는 쪽을 HTML에 표시."""
+ df_1d, df_1h, df_3m = self.load_mtf(SYMBOL)
+ trend = strategy.get_trend(df_1d, df_1h)
+ print(f"추세(최신): {trend}")
+ print(f"3분: {df_3m.index[0]} ~ {df_3m.index[-1]} ({len(df_3m)}봉)")
+
+ cfg_base = strategy.StrategyConfig(
+ name="01_기본_BB만",
+ use_discovered_rules=False,
+ use_regime_switch=False,
+ use_rsi_filter=False,
+ use_volume_filter=False,
+ use_squeeze_filter=False,
+ use_stop_loss=False,
+ )
+ df_base, res_base, n_base = self._run_one_strategy(
+ cfg_base.name, df_1d, df_1h, df_3m, cfg_base
+ )
+ print(f"\n[기본 BB] 신호 {n_base} | 수익 {res_base.total_return_pct:+.2f}% | 거래 {res_base.trade_count}")
+
+ candidates: list[tuple[str, pd.DataFrame, SimResult, int]] = [
+ (cfg_base.name, df_base, res_base, n_base),
+ ]
+
+ try:
+ from rule_discovery import load_rules
+
+ rules = load_rules()
+ frames = self.load_all_frames()
+ if rules and frames:
+ cfg_disc = strategy.StrategyConfig(
+ name=rules.name,
+ use_discovered_rules=True,
+ use_regime_switch=False,
+ use_rsi_filter=False,
+ use_volume_filter=False,
+ use_squeeze_filter=False,
+ use_stop_loss=False,
+ )
+ df_disc, res_disc, n_disc = self._run_one_strategy(
+ cfg_disc.name, df_1d, df_1h, df_3m, cfg_disc, frames=frames
+ )
+ print(
+ f"[탐색 규칙] 신호 {n_disc} | 수익 {res_disc.total_return_pct:+.2f}% "
+ f"| 거래 {res_disc.trade_count}"
+ )
+ print(f" 매수: {rules.buy_all} | OR: {rules.buy_any}")
+ print(f" 매도: {rules.sell_all} | 손절: {rules.sell_stop}")
+ if n_disc > 0 and res_disc.trade_count > 0:
+ candidates.append((cfg_disc.name, df_disc, res_disc, n_disc))
+ except Exception as e:
+ print(f"[탐색 규칙] 스킵: {e}")
+
+ # 신호·거래 있는 후보 중 수익률 최대
+ valid = [c for c in candidates if c[3] > 0 and c[2].trade_count > 0]
+ if not valid:
+ valid = candidates
+ name, df_plot, result, n_sig = max(valid, key=lambda c: c[2].total_return_pct)
+
+ print(f"\n>>> HTML 적용: {name} (신호 {n_sig}, 거래 {result.trade_count}, {result.total_return_pct:+.2f}%)")
+ sigs = df_plot[df_plot["point"] == 1]
+ if len(sigs):
+ print(sigs["action"].value_counts().to_string())
+
+ print_backtest_report(result)
+ self.render_plotly(df_plot, trend, result)
+ return result
+
+
+def run_mtf_analysis() -> None:
+ """봉별 BB 백테스트 비교, 정책 저장, MTF 시뮬 차트."""
+ from mtf_bb import apply_policy, load_frames_from_db, run_interval_comparison, save_policy
+
+ monitor = Monitor()
+ policy, _ = run_interval_comparison(monitor)
+ save_policy(policy)
+ apply_policy(policy)
+
+ frames = load_frames_from_db(monitor, SYMBOL)
+ df_1d = frames.get(TREND_INTERVAL_1D)
+ if df_1d is None or df_1d.empty:
+ df_1d = frames[ENTRY_INTERVAL]
+ df_1h = frames.get(TREND_INTERVAL_1H)
+ if df_1h is None or df_1h.empty:
+ df_1h = frames[ENTRY_INTERVAL]
+
+ cfg = strategy.StrategyConfig(
+ name="MTF_BB",
+ use_mtf=True,
+ use_regime_switch=strategy.ACTIVE_CONFIG.use_regime_switch,
+ use_rsi_filter=False,
+ use_volume_filter=False,
+ use_squeeze_filter=False,
+ use_stop_loss=True,
+ )
+ df_sig = strategy.annotate_mtf_signals(SYMBOL, frames, df_1d, df_1h, policy, cfg)
+ trend = strategy.get_trend(df_1d, df_1h)
+ print(f"\nMTF 시뮬 ({policy.name}) | 추세: {trend}")
+ result = run_backtest(df_sig, df_1d, df_1h, config_name=policy.name)
+ print_backtest_report(result)
+ Simulation().render_plotly(df_sig, trend, result)
+
+
+def run_discover() -> None:
+ """모든 봉·캔들 특징으로 최적 규칙 탐색 후 JSON 저장."""
+ from rule_discovery import discover_rules, load_frames, save_rules
+
+ monitor = Monitor(cooldown_file=None)
+ frames = load_frames(monitor)
+ rules = discover_rules(frames)
+ save_rules(rules)
+ print(f"\n저장: discovered_rules.json")
+ print("HTML 차트: python simulation_1h.py")
+
+
+def main() -> None:
+ sim = Simulation()
+ if len(sys.argv) > 1 and sys.argv[1] == "discover":
+ run_discover()
return
+ if len(sys.argv) > 1 and sys.argv[1] == "mtf":
+ run_mtf_analysis()
+ return
+ df_1d, df_1h, df_3m = sim.load_mtf(SYMBOL)
+ if len(sys.argv) > 1 and sys.argv[1] == "compare":
+ run_comparison(df_1d, df_1h, df_3m)
+ return
+ sim.run()
+
if __name__ == "__main__":
- sim = Simulation()
- interval = 60
- days = 90
- target_coins = KR_COINS
- #target_coins = ['XRP']
- show_graphs = True
- for symbol in target_coins:
- print(f"\n=== {symbol} 저점 기간 분석 시작 ===")
- try:
- bottom_data, alerts = sim.analyze_bottom_period(symbol, interval, days)
- print(f"\n=== {symbol} 전체 기간 시뮬레이션 ===")
- if show_graphs:
- sim.run_simulation(symbol, interval, days)
- else:
- data = sim.fetch_price_history(symbol, interval, days)
-
- inverseData = sim.monitor.inverse_data(data)
- inverseData = sim.monitor.annotate_signals(symbol, inverseData, simulation=True)
-
- data = sim.monitor.calculate_technical_indicators(data)
- data = sim.monitor.annotate_signals(symbol, data, simulation=True)
-
- total_signals = len(data[data['point'] == 1])
- ma_signals = len(data[(data['point'] == 1) & (data['signal'] == 'movingaverage')])
- dev40_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation40')])
- dev240_signals = len(data[(data['point'] == 1) & (data['signal'] == 'Deviation720')])
- dev1440_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation1440')])
- print(f"총 매수 신호: {total_signals}")
- print(f" - MA 신호: {ma_signals}")
- print(f" - Dev40 신호: {dev40_signals}")
- print(f" - Dev240 신호: {dev240_signals}")
- print(f" - Dev1440 신호: {dev1440_signals}")
- except Exception as e:
- print(f"Error analyzing {symbol}: {str(e)}")
+ main()
diff --git a/strategy.py b/strategy.py
new file mode 100644
index 0000000..152109d
--- /dev/null
+++ b/strategy.py
@@ -0,0 +1,624 @@
+"""
+WLD 볼린저밴드 MTF 전략.
+
+기본 타이밍 (모든 봉 동일):
+ - 매수: 하단 밴드 상향 돌파 (prev_close <= Lower, close > Lower)
+ - 매도: 상단 밴드 상향 돌파 (prev_close < Upper, close >= Upper)
+
+여러 봉(3·10·15·30·60·240·1440분)의 BB 상태를 비교해
+실행 봉·확인 봉을 정한 뒤 매매합니다 (MtfBbPolicy).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Literal
+
+import numpy as np
+import pandas as pd
+
+from config import (
+ BB_MIN_WIDTH_PCT,
+ DEFAULT_BUY_KRW,
+ ENTRY_INTERVAL,
+ RANGE_BUY_KRW,
+ RSI_BUY_MAX,
+ RSI_PERIOD,
+ TREND_RANGE_MA_GAP_PCT,
+ VOLUME_BUY_RATIO,
+)
+
+Trend = Literal["up", "down", "range"]
+Action = Literal["buy", "sell"]
+
+# 기본 신호
+SIGNAL_BUY_LOWER = "bb_base_buy_lower"
+SIGNAL_SELL_UPPER = "bb_base_sell_upper"
+SIGNAL_SELL_STOP = "bb_base_sell_stop"
+
+BUY_SIGNALS = {SIGNAL_BUY_LOWER}
+SELL_SIGNALS = {SIGNAL_SELL_UPPER, SIGNAL_SELL_STOP}
+
+# 봉별 BB 상태 (분석·확인용)
+STATE_SQUEEZE = "squeeze"
+STATE_CROSS_UP_LOWER = "cross_up_lower"
+STATE_CROSS_UP_UPPER = "cross_up_upper"
+STATE_CROSS_DOWN_LOWER = "cross_down_lower"
+STATE_BELOW_LOWER = "below_lower"
+STATE_ABOVE_UPPER = "above_upper"
+STATE_INSIDE = "inside"
+
+BUY_TRIGGER_STATE = STATE_CROSS_UP_LOWER
+SELL_TRIGGER_STATE = STATE_CROSS_UP_UPPER
+STOP_TRIGGER_STATE = STATE_CROSS_DOWN_LOWER
+
+# 확인 봉에서 매수 허용/차단 상태
+BUY_CONFIRM_OK = {
+ STATE_INSIDE,
+ STATE_BELOW_LOWER,
+ STATE_CROSS_UP_LOWER,
+ STATE_SQUEEZE,
+}
+BUY_CONFIRM_BLOCK = {
+ STATE_CROSS_UP_UPPER,
+ STATE_ABOVE_UPPER,
+ STATE_CROSS_DOWN_LOWER,
+}
+SELL_CONFIRM_OK = {
+ STATE_INSIDE,
+ STATE_ABOVE_UPPER,
+ STATE_CROSS_UP_UPPER,
+ STATE_CROSS_UP_LOWER,
+ STATE_BELOW_LOWER,
+}
+
+
+@dataclass
+class StrategyConfig:
+ """전략 조합 설정 (시뮬 비교·실거래 공용)."""
+
+ name: str = "default"
+ use_mtf: bool = True
+ use_regime_switch: bool = True
+ use_rsi_filter: bool = True
+ rsi_buy_max: float = RSI_BUY_MAX
+ use_volume_filter: bool = False
+ volume_ratio: float = VOLUME_BUY_RATIO
+ use_squeeze_filter: bool = True
+ use_stop_loss: bool = True
+ allow_buy_in_up: bool = True
+ allow_buy_in_range: bool = True
+ allow_buy_in_down: bool = False
+ use_discovered_rules: bool = False
+
+
+# HTML 시뮬: discovered_rules.json (python simulation_1h.py discover)
+ACTIVE_CONFIG = StrategyConfig(
+ name="discovered_best",
+ use_discovered_rules=True,
+ use_mtf=False,
+ use_regime_switch=False,
+ use_rsi_filter=False,
+ use_volume_filter=False,
+ use_squeeze_filter=False,
+ use_stop_loss=False,
+)
+
+
+@dataclass
+class MtfBbPolicy:
+ """
+ 봉 간격별 BB 비교 후 적용할 매매 정책.
+
+ buy_interval / sell_interval: 실제 주문 트리거 봉
+ buy_confirm_intervals: 매수 시 함께 봐야 할 상위(긴) 봉
+ """
+
+ buy_interval: int = 3
+ sell_interval: int = 3
+ buy_confirm_intervals: tuple[int, ...] = (60, 1440)
+ sell_confirm_intervals: tuple[int, ...] = (60,)
+ name: str = "default_mtf"
+
+
+ACTIVE_MTF_POLICY = MtfBbPolicy(
+ name="3분실행_60·일봉확인",
+ buy_interval=3,
+ sell_interval=3,
+ buy_confirm_intervals=(60, 1440),
+ sell_confirm_intervals=(60,),
+)
+
+
+@dataclass
+class TradeSignal:
+ """실시간 1회 매매 신호."""
+
+ action: Action
+ signal: str
+ close: float
+ trend: Trend
+
+
+def prepare_entry_df(data: pd.DataFrame) -> pd.DataFrame:
+ """3분봉 보조 지표 추가."""
+ df = data.copy()
+ delta = df["Close"].diff()
+ gain = delta.where(delta > 0, 0.0).rolling(RSI_PERIOD).mean()
+ loss = (-delta.where(delta < 0, 0.0)).rolling(RSI_PERIOD).mean()
+ rs = gain / loss.replace(0, np.nan)
+ df["RSI"] = 100 - (100 / (1 + rs))
+ df["VolMA5"] = df["Volume"].rolling(5).mean()
+ ma = df["MA"].replace(0, np.nan)
+ df["BB_Width"] = (df["Upper"] - df["Lower"]) / ma * 100
+ return df
+
+
+def get_trend(df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> Trend:
+ """일봉·1시간봉 기준 추세."""
+ if len(df_1d) < 20 or len(df_1h) < 40:
+ return "range"
+
+ d_close = float(df_1d["Close"].iloc[-1])
+ d_ma20 = float(df_1d["MA20"].iloc[-1])
+ h_close = float(df_1h["Close"].iloc[-1])
+ h_ma20 = float(df_1h["MA20"].iloc[-1])
+ h_ma40 = float(df_1h["MA40"].iloc[-1])
+
+ if h_ma40 == 0:
+ return "range"
+
+ ma_gap_pct = abs(h_ma20 - h_ma40) / h_ma40 * 100
+ if ma_gap_pct < TREND_RANGE_MA_GAP_PCT:
+ return "range"
+
+ if d_close > d_ma20 and h_ma20 > h_ma40 and h_close > h_ma20:
+ return "up"
+ if d_close < d_ma20 and h_ma20 < h_ma40 and h_close < h_ma20:
+ return "down"
+ return "range"
+
+
+def get_trend_at(
+ df_1d: pd.DataFrame, df_1h: pd.DataFrame, ts: pd.Timestamp
+) -> Trend:
+ """특정 시점까지의 데이터로 추세 판별 (백테스트용)."""
+ d = df_1d[df_1d.index <= ts]
+ h = df_1h[df_1h.index <= ts]
+ if d.empty or h.empty:
+ return "range"
+ return get_trend(d, h)
+
+
+def _bb_squeeze(bb_width: float) -> bool:
+ return bb_width < BB_MIN_WIDTH_PCT
+
+
+def classify_bb_state_at(
+ df: pd.DataFrame,
+ index_pos: int,
+ cfg: StrategyConfig | None = None,
+) -> str:
+ """
+ 한 봉의 볼린저밴드 상태를 분류합니다.
+
+ Returns:
+ cross_up_lower, cross_up_upper, cross_down_lower, below_lower,
+ above_upper, inside, squeeze
+ """
+ cfg = cfg or ACTIVE_CONFIG
+ if index_pos < 0:
+ index_pos = len(df) + index_pos
+ if index_pos < 1 or index_pos >= len(df):
+ return STATE_INSIDE
+
+ close = float(df["Close"].iloc[index_pos])
+ prev_close = float(df["Close"].iloc[index_pos - 1])
+ lower = float(df["Lower"].iloc[index_pos])
+ prev_lower = float(df["Lower"].iloc[index_pos - 1])
+ upper = float(df["Upper"].iloc[index_pos])
+ prev_upper = float(df["Upper"].iloc[index_pos - 1])
+ bb_width = float(df["BB_Width"].iloc[index_pos]) if "BB_Width" in df.columns else 999.0
+
+ if cfg.use_squeeze_filter and _bb_squeeze(bb_width):
+ return STATE_SQUEEZE
+ if prev_close <= prev_lower and close > lower:
+ return STATE_CROSS_UP_LOWER
+ if prev_close < prev_upper and close >= upper:
+ return STATE_CROSS_UP_UPPER
+ if prev_close >= prev_lower and close < lower:
+ return STATE_CROSS_DOWN_LOWER
+ if close < lower:
+ return STATE_BELOW_LOWER
+ if close > upper:
+ return STATE_ABOVE_UPPER
+ return STATE_INSIDE
+
+
+def get_latest_bb_state(df: pd.DataFrame, cfg: StrategyConfig | None = None) -> str:
+ """마지막 봉의 BB 상태."""
+ df = prepare_entry_df(df)
+ if len(df) < 2:
+ return STATE_INSIDE
+ return classify_bb_state_at(df, -1, cfg)
+
+
+def _allow_buy(trend: Trend, cfg: StrategyConfig) -> bool:
+ """레짐·MTF에 따른 매수 허용 여부."""
+ if cfg.use_regime_switch:
+ if trend == "down":
+ return cfg.allow_buy_in_down
+ if trend == "range":
+ return cfg.allow_buy_in_range
+ if trend == "up":
+ return cfg.allow_buy_in_up
+ if cfg.use_mtf and trend == "down":
+ return False
+ return True
+
+
+def evaluate_interval_bb(
+ symbol: str,
+ df_entry: pd.DataFrame,
+ config: StrategyConfig | None = None,
+) -> TradeSignal | None:
+ """
+ 단일 봉 간격에서 BB 기본 규칙만으로 신호 1건.
+
+ 매도 우선 → 매수.
+ """
+ cfg = config or ACTIVE_CONFIG
+ df = prepare_entry_df(df_entry)
+ if len(df) < 22:
+ return None
+
+ state = classify_bb_state_at(df, -1, cfg)
+ close = float(df["Close"].iloc[-1])
+ trend: Trend = "range"
+
+ if cfg.use_stop_loss and state == STATE_CROSS_DOWN_LOWER:
+ return TradeSignal("sell", SIGNAL_SELL_STOP, close, trend)
+ if state == STATE_CROSS_UP_UPPER:
+ return TradeSignal("sell", SIGNAL_SELL_UPPER, close, trend)
+ if state == STATE_CROSS_UP_LOWER:
+ if cfg.use_rsi_filter:
+ rsi = float(df["RSI"].iloc[-1])
+ if not np.isnan(rsi) and rsi > cfg.rsi_buy_max:
+ return None
+ if cfg.use_volume_filter:
+ vol = float(df["Volume"].iloc[-1])
+ vol_ma = float(df["VolMA5"].iloc[-1])
+ if vol_ma > 0 and vol < vol_ma * cfg.volume_ratio:
+ return None
+ return TradeSignal("buy", SIGNAL_BUY_LOWER, close, trend)
+ return None
+
+
+def _confirm_buy(states: dict[int, str], policy: MtfBbPolicy) -> bool:
+ """상위 봉 상태가 매수에 적합한지."""
+ for iv in policy.buy_confirm_intervals:
+ st = states.get(iv)
+ if st is None:
+ return False
+ if st in BUY_CONFIRM_BLOCK:
+ return False
+ if st not in BUY_CONFIRM_OK and st != STATE_SQUEEZE:
+ return False
+ return True
+
+
+def _confirm_sell(states: dict[int, str], policy: MtfBbPolicy) -> bool:
+ """상위 봉 상태가 매도에 적합한지."""
+ for iv in policy.sell_confirm_intervals:
+ st = states.get(iv)
+ if st is None:
+ continue
+ if st in {STATE_CROSS_DOWN_LOWER, STATE_BELOW_LOWER}:
+ return False
+ return True
+
+
+def evaluate_mtf_bb(
+ symbol: str,
+ frames: dict[int, pd.DataFrame],
+ df_1d: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ policy: MtfBbPolicy | None = None,
+ config: StrategyConfig | None = None,
+) -> TradeSignal | None:
+ """
+ 여러 봉의 BB 상태를 비교해 최종 매수/매도 1건을 결정합니다.
+
+ 1) 각 봉 최신 BB 상태 분류
+ 2) policy.sell_interval 에서 손절/상단돌파 → 매도
+ 3) policy.buy_interval 에서 하단돌파 + 확인봉 OK → 매수
+ 4) 일봉·1시간 추세로 하락장 매수 차단 (config)
+ """
+ cfg = config or ACTIVE_CONFIG
+ policy = policy or ACTIVE_MTF_POLICY
+ if policy.buy_interval not in frames or policy.sell_interval not in frames:
+ return None
+
+ states = {iv: get_latest_bb_state(frames[iv], cfg) for iv in frames}
+ trend = get_trend(df_1d, df_1h)
+
+ sell_df = prepare_entry_df(frames[policy.sell_interval])
+ sell_close = float(sell_df["Close"].iloc[-1])
+ sell_state = states[policy.sell_interval]
+
+ if cfg.use_stop_loss and sell_state == STATE_CROSS_DOWN_LOWER:
+ if not policy.sell_confirm_intervals or _confirm_sell(states, policy):
+ return TradeSignal("sell", SIGNAL_SELL_STOP, sell_close, trend)
+
+ if sell_state == STATE_CROSS_UP_UPPER:
+ if not policy.sell_confirm_intervals or _confirm_sell(states, policy):
+ return TradeSignal("sell", SIGNAL_SELL_UPPER, sell_close, trend)
+
+ buy_state = states[policy.buy_interval]
+ if buy_state != STATE_CROSS_UP_LOWER:
+ return None
+ if not _allow_buy(trend, cfg):
+ return None
+ if not _confirm_buy(states, policy):
+ return None
+
+ buy_close = float(frames[policy.buy_interval]["Close"].iloc[-1])
+ sig = evaluate_interval_bb(symbol, frames[policy.buy_interval], cfg)
+ if sig and sig.action == "buy":
+ return TradeSignal("buy", sig.signal, buy_close, trend)
+ return None
+
+
+def evaluate(
+ symbol: str,
+ df_entry: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ df_1d: pd.DataFrame,
+ config: StrategyConfig | None = None,
+ trend_override: Trend | None = None,
+ frames: dict[int, pd.DataFrame] | None = None,
+ policy: MtfBbPolicy | None = None,
+) -> TradeSignal | None:
+ """
+ 매매 신호 1건.
+
+ frames 가 있으면 evaluate_mtf_bb, 없으면 df_entry 단일 봉 BB.
+ """
+ if frames:
+ return evaluate_mtf_bb(symbol, frames, df_1d, df_1h, policy, config)
+ cfg = config or ACTIVE_CONFIG
+ trend = trend_override if trend_override else get_trend(df_1d, df_1h)
+ sig = evaluate_interval_bb(symbol, df_entry, cfg)
+ if sig is None:
+ return None
+ if sig.action == "buy" and not _allow_buy(trend, cfg):
+ return None
+ return TradeSignal(sig.action, sig.signal, sig.close, trend)
+
+
+def annotate_interval_signals(
+ symbol: str,
+ data: pd.DataFrame,
+ simulation: bool | None = None,
+ config: StrategyConfig | None = None,
+) -> pd.DataFrame:
+ """단일 봉 간격 전체에 BB 신호 기록 (봉별 백테스트용)."""
+ cfg = config or ACTIVE_CONFIG
+ df = prepare_entry_df(data)
+ df["signal"] = ""
+ df["point"] = 0
+ df["action"] = ""
+ df["bb_state"] = ""
+
+ for i in range(21, len(df)):
+ st = classify_bb_state_at(df, i, cfg)
+ df.at[df.index[i], "bb_state"] = st
+ sig = evaluate_interval_bb(symbol, df.iloc[: i + 1], cfg)
+ if sig:
+ df.at[df.index[i], "signal"] = sig.signal
+ df.at[df.index[i], "point"] = 1
+ df.at[df.index[i], "action"] = sig.action
+
+ if not simulation:
+ sig = evaluate_interval_bb(symbol, df, cfg)
+ if sig:
+ df.at[df.index[-1], "signal"] = sig.signal
+ df.at[df.index[-1], "point"] = 1
+ df.at[df.index[-1], "action"] = sig.action
+ df.at[df.index[-1], "bb_state"] = get_latest_bb_state(df, cfg)
+
+ return df
+
+
+def _slice_frames_at(frames: dict[int, pd.DataFrame], ts: pd.Timestamp) -> dict[int, pd.DataFrame]:
+ """시점 ts 이하 봉만 남긴 frames."""
+ out: dict[int, pd.DataFrame] = {}
+ for iv, df in frames.items():
+ part = df[df.index <= ts]
+ if len(part) >= 22:
+ out[iv] = part
+ return out
+
+
+def annotate_mtf_signals(
+ symbol: str,
+ frames: dict[int, pd.DataFrame],
+ df_1d: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ policy: MtfBbPolicy | None = None,
+ config: StrategyConfig | None = None,
+) -> pd.DataFrame:
+ """실행 봉(buy_interval) 타임라인에 MTF BB 신호 기록."""
+ policy = policy or ACTIVE_MTF_POLICY
+ cfg = config or ACTIVE_CONFIG
+ if policy.buy_interval not in frames:
+ raise ValueError(f"buy_interval {policy.buy_interval} 데이터 없음")
+
+ df = prepare_entry_df(frames[policy.buy_interval].copy())
+ df["signal"] = ""
+ df["point"] = 0
+ df["action"] = ""
+ df["trend"] = ""
+ df["bb_state"] = ""
+
+ for i in range(21, len(df)):
+ ts = df.index[i]
+ sliced = _slice_frames_at(frames, ts)
+ if policy.buy_interval not in sliced:
+ continue
+ df.at[ts, "bb_state"] = get_latest_bb_state(sliced[policy.buy_interval], cfg)
+ trend_at = get_trend_at(df_1d, df_1h, ts)
+ sig = evaluate_mtf_bb(symbol, sliced, df_1d, df_1h, policy, cfg)
+ if sig:
+ df.at[ts, "signal"] = sig.signal
+ df.at[ts, "point"] = 1
+ df.at[ts, "action"] = sig.action
+ df.at[ts, "trend"] = sig.trend
+
+ return df
+
+
+def annotate_discovered_signals(
+ symbol: str,
+ frames: dict[int, pd.DataFrame],
+ df_1d: pd.DataFrame,
+ df_1h: pd.DataFrame,
+ rules=None,
+ data: pd.DataFrame | None = None,
+) -> pd.DataFrame:
+ """탐색된 다봉·캔들 규칙으로 3분 타임라인(전체 봉)에 신호 기록."""
+ from candle_features import build_master_feature_matrix
+ from rule_discovery import generate_trade_events, load_rules, rules_have_buy
+
+ rule_set = rules or load_rules()
+ if rule_set is None or not rules_have_buy(rule_set):
+ raise FileNotFoundError(
+ "discovered_rules.json 없거나 매수 규칙이 비어 있습니다. "
+ "python simulation_1h.py discover 실행"
+ )
+
+ entry = frames.get(ENTRY_INTERVAL)
+ if entry is None or entry.empty:
+ raise ValueError("3분봉 데이터가 없습니다.")
+
+ matrix = build_master_feature_matrix(frames).iloc[21:].copy()
+ df = prepare_entry_df(data if data is not None else entry)
+ df["signal"] = ""
+ df["point"] = 0
+ df["action"] = ""
+ df["trend"] = ""
+
+ for ts, action, sig in generate_trade_events(matrix, rule_set):
+ if ts not in df.index:
+ continue
+ trend_at = get_trend_at(df_1d, df_1h, ts)
+ df.at[ts, "signal"] = sig
+ df.at[ts, "point"] = 1
+ df.at[ts, "action"] = action
+ df.at[ts, "trend"] = trend_at
+
+ return df
+
+
+def annotate_signals(
+ symbol: str,
+ data: pd.DataFrame,
+ simulation: bool | None = None,
+ df_1h: pd.DataFrame | None = None,
+ df_1d: pd.DataFrame | None = None,
+ config: StrategyConfig | None = None,
+ frames: dict[int, pd.DataFrame] | None = None,
+) -> pd.DataFrame:
+ """3분봉 구간 전체에 signal/point/action/trend 기록."""
+ cfg = config or ACTIVE_CONFIG
+ if cfg.use_discovered_rules and frames:
+ h1d = df_1d if df_1d is not None and not df_1d.empty else data
+ h1h = df_1h if df_1h is not None and not df_1h.empty else data
+ return annotate_discovered_signals(symbol, frames, h1d, h1h, data=data)
+ df = prepare_entry_df(data)
+ df["signal"] = ""
+ df["point"] = 0
+ df["action"] = ""
+ df["trend"] = ""
+
+ htf_1h = df_1h if df_1h is not None else df
+ htf_1d = df_1d if df_1d is not None else df
+
+ for i in range(21, len(df)):
+ ts = df.index[i]
+ trend_at = get_trend_at(htf_1d, htf_1h, ts) if simulation else get_trend(htf_1d, htf_1h)
+ sig = evaluate(
+ symbol,
+ df.iloc[: i + 1],
+ htf_1h,
+ htf_1d,
+ config=cfg,
+ trend_override=trend_at if simulation else None,
+ )
+ if sig:
+ df.at[df.index[i], "signal"] = sig.signal
+ df.at[df.index[i], "point"] = 1
+ df.at[df.index[i], "action"] = sig.action
+ df.at[df.index[i], "trend"] = sig.trend
+
+ if not simulation:
+ live = evaluate(symbol, df, htf_1h, htf_1d, config=cfg)
+ if live:
+ df.at[df.index[-1], "signal"] = live.signal
+ df.at[df.index[-1], "point"] = 1
+ df.at[df.index[-1], "action"] = live.action
+ df.at[df.index[-1], "trend"] = live.trend
+
+ return df
+
+
+def comparison_presets() -> list[StrategyConfig]:
+ """기법 조합 비교용 프리셋."""
+ return [
+ StrategyConfig(name="01_기본_BB만", use_mtf=False, use_regime_switch=False,
+ use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=False),
+ StrategyConfig(name="02_기본+손절", use_mtf=False, use_regime_switch=False,
+ use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=True),
+ StrategyConfig(name="03_기본+MTF", use_mtf=True, use_regime_switch=False,
+ use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=True),
+ StrategyConfig(name="04_기본+MTF+스퀴즈", use_mtf=True, use_regime_switch=False,
+ use_rsi_filter=False, use_squeeze_filter=True, use_stop_loss=True),
+ StrategyConfig(name="05_기본+MTF+RSI", use_mtf=True, use_regime_switch=False,
+ use_rsi_filter=True, use_squeeze_filter=False, use_stop_loss=True),
+ StrategyConfig(name="06_기본+MTF+거래량", use_mtf=True, use_regime_switch=False,
+ use_rsi_filter=False, use_volume_filter=True, volume_ratio=1.1,
+ use_squeeze_filter=False, use_stop_loss=True),
+ StrategyConfig(name="07_레짐스위치", use_mtf=True, use_regime_switch=True,
+ use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=True),
+ StrategyConfig(name="08_레짐+RSI+스퀴즈", use_mtf=True, use_regime_switch=True,
+ use_rsi_filter=True, use_squeeze_filter=True, use_stop_loss=True),
+ StrategyConfig(name="09_풀필터", use_mtf=True, use_regime_switch=True,
+ use_rsi_filter=True, use_volume_filter=True, volume_ratio=1.1,
+ use_squeeze_filter=True, use_stop_loss=True),
+ ]
+
+
+def get_signal_action(signal: str) -> Action | None:
+ if signal in BUY_SIGNALS:
+ return "buy"
+ if signal in SELL_SIGNALS:
+ return "sell"
+ return None
+
+
+def get_buy_amount(symbol: str, signal: str, close: float, trend: Trend = "up") -> int:
+ if trend == "range":
+ return RANGE_BUY_KRW
+ return DEFAULT_BUY_KRW
+
+
+def get_sell_ratio(symbol: str, signal: str) -> float:
+ return 1.0
+
+
+def allowed_inverse_sell_signals() -> set[str]:
+ return set()
+
+
+def should_double_buy(symbol: str, signal: str, data: pd.DataFrame) -> bool:
+ return False