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