WLD 전용 BB MTF 전략 및 HTML 시뮬 최적화

- strategy.py, candle_features.py, rule_discovery.py로 다봉 BB·캔들 규칙 탐색
- simulation_1h.py: discover 명령, 기본 BB vs 탐색 규칙 자동 선택, Plotly Y축 줌
- mtf_bb.py, downloader/monitor 정리, 다코인 파일 제거

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-27 19:14:44 +09:00
parent 1c12a6c94a
commit 7d53090034
42 changed files with 2941 additions and 1650 deletions

28
.env.example Normal file
View File

@@ -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

19
HTS2.py
View File

@@ -1,3 +1,4 @@
import os
import pandas as pd import pandas as pd
import jwt import jwt
import uuid import uuid
@@ -7,22 +8,20 @@ import json
import hashlib import hashlib
from urllib.parse import urlencode from urllib.parse import urlencode
class HTS: class HTS:
"""빗썸 Open API 래퍼 (시세 조회, 잔고, 주문)."""
bithumb = None bithumb = None
accessKey = "a5d33ce55f598185d37cd26272341b7b965c31a59457f7" # 본인의 Connect Key를 입력한다. accessKey = ""
secretKey = "ODBiYWFmNWE2MTkwYjdhMTNhZTM1YjU5OGY4OGE2MGNkNDY2NzMzMjE2Nzc5NDVlMzBhMDk3NTNmM2M2Mg==" # 본인의 Secret Key를 입력한다. secretKey = ""
apiUrl = 'https://api.bithumb.com' apiUrl = "https://api.bithumb.com"
def __init__(self): def __init__(self):
#self.bithumb = pybithumb.Bithumb(self.con_key, self.sec_key)
self.bithumb = None self.bithumb = None
self.accessKey = "a5d33ce55f598185d37cd26272341b7b965c31a59457f7" # 본인의 Connect Key를 입력한다. self.accessKey = os.getenv("BITHUMB_ACCESS_KEY", "")
self.secretKey = "ODBiYWFmNWE2MTkwYjdhMTNhZTM1YjU5OGY4OGE2MGNkNDY2NzMzMjE2Nzc5NDVlMzBhMDk3NTNmM2M2Mg==" # 본인의 Secret Key를 입력한다. self.secretKey = os.getenv("BITHUMB_SECRET_KEY", "")
self.apiUrl = 'https://api.bithumb.com' self.apiUrl = "https://api.bithumb.com"
return
def append(self, stock, df=None, data_1=None): def append(self, stock, df=None, data_1=None):
if df is not None: if df is not None:

View File

@@ -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를 사용하세요.

164
README.md
View File

@@ -1,146 +1,50 @@
# AssetMonitor 주식·코인 모니터링 시스템 # DeepCoin — WLD 볼린저 MTF
## 개요 빗썸 KRW-WLD 현물 전용. **모든 봉**에 동일한 BB 규칙을 적용하고, 봉별 상태를 비교해 실행·확인 봉을 정합니다.
`AssetMonitor`는 주식‧ETF 및 암호화폐 시장을 실시간으로 감시하여 Bollinger Band, RSI, MACD, 이동평균(Golden-Cross), 거래량 등을 종합 분석한 **매수 후보(signals)**를 텔레그램으로 통보하는 자동화 봇입니다.
**주요 개선사항:** ## BB 기본 규칙 (모든 간격 동일)
- **데이터 표준화**: 모든 코인에 동일한 기술적 분석 기준 적용
- **순수 기술적 분석**: 날짜 기반 조건 제거, 기술적 지표만 사용
- **강화된 기술적 지표**: 스토캐스틱, MFI, OBV, ATR 등 추가 지표 활용
--- | 구분 | 조건 |
## 주요 구성 파일
| 파일 | 설명 |
|------|------| |------|------|
| `config.py` | ✅ API 토큰, 텔레그램 채널 ID, 볼린저 밴드/임계값, 모니터링 자산 목록(KR_COINS, US_STOCKS, KR_ETFS) 등 전역 설정을 보관합니다. | | 매수 | 이전 종가 ≤ 하단, 현재 종가 > 하단 (하단 **상향 돌파**) |
| `stock_monitor.py` | 시스템의 핵심 로직이 담긴 실행 스크립트입니다. <br/>• 데이터 수집 ⇒ 기술적 지표 계산 ⇒ 매수 신호 판단 ⇒ 메시지 포맷팅/발송 <br/>• `schedule` 라이브러리로 정해진 시간마다 작업을 자동 실행합니다. | | 매도 | 이전 종가 < 상단, 현재 종가 ≥ 상단 (상단 **상향 돌파**) |
| `requirements.txt` | 프로젝트 의존 패키지를 명시합니다. | | 손절(선택) | 하단 재이탈 |
--- **MTF 적용** (`mtf_bb.py`, `ACTIVE_MTF_POLICY` / `mtf_bb_policy.json`)
## 데이터 흐름 - 실행 봉: 3·10·15·30·60분 중 백테스트 수익률 1위
1. **스케줄 트리거** (`run_schedule`) - 확인 봉: 60분·일봉 등 상위 봉 상태가 매수/매도에 맞을 때만 체결
지정된 시각에 각 모니터링 함수가 호출됩니다. - 하락 추세: 매수 차단 (설정 시)
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을 이용해 다중 메시지를 병렬로 전송합니다.
--- 봉별 상태: `inside`, `cross_up_lower`, `cross_up_upper`, `below_lower`, `above_upper`, `squeeze`
## 매수 후보 전략 (표준화된 기술적 분석) ## 파일
| 신호 | 변수명 | 조건 | 의미 | | 파일 | 역할 |
|------|--------|------|------| |------|------|
| 볼린저 하단 근접 | `bb_signal` | `(현재가 - LowerBand) / (UpperBand - LowerBand) < BOLLINGER_THRESHOLD` | 밴드 하단(과매도 영역) 접근 | | `strategy.py` | 신호·금액·매도 비율 |
| RSI 과매도 | `rsi_signal` | `RSI < 30` | 추세 과매도 | | `monitor.py` | MTF 데이터, `process_wld_mtf`, 현물 주문 |
| MACD 골든크로스 | `macd_signal` | `이전 MACD < 이전 Signal` **AND** `현재 MACD > 현재 Signal` | 모멘텀 전환 | | `monitor_coin.py` | 실시간 루프 |
| 이동평균 골든크로스 | `ma_signal` | `이전 MA5 < 이전 MA20` **AND** `현재 MA5 ≥ 현재 MA20` | 단기 추세 ↗ 전환 | | `downloader.py` | `coins.db` (3분·1시간·일봉) |
| 거래량 급증 | `volume_signal` | `현재 거래량 > MA5 Volume × 1.5` | 수급 증가 | | `mtf_bb.py` | 봉별 BB 비교·정책 추천 |
| **U자 반등 돌파** | `breakout_signal` | ① 최근 `BREAKOUT_LOOKBACK`(30)개 캔들 동안 최고·최저가 차이가 `BUY_THRESHOLD`(15 %) 이상 하락 → ② **현재가가 그 최고가 돌파** | 하락 후 반등의 추세 전환 확인 | | `simulation_1h.py` | 백테스트 차트 |
| **장기 저항 돌파** | `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` | 변동성 급증 |
### 최종 매수 후보 결정 로직 ## 실행
```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 ```bash
$ git clone <repo-url> cp .env.example .env
$ cd AssetMonitor python downloader.py
``` python simulation_1h.py discover # 모든 봉·캔들 특징 탐색 → discovered_rules.json
3. 패키지 설치: python simulation_1h.py # 탐색 규칙 HTML 차트 (기본)
```bash python simulation_1h.py compare # 9종 조합 순위
$ pip install -r requirements.txt python simulation_1h.py mtf # 봉별 BB 비교 (실거래 전 참고)
``` python monitor_coin.py # 실거래는 HTML 최적화 후 연동 예정
4. **보안 키 등록**
민감 정보는 코드에 직접 기록하지 말고 *환경 변수*로 주입하기를 권장합니다.
```bash
# zsh 예시
export COIN_TELEGRAM_BOT_TOKEN="<TOKEN>"
export STOCK_TELEGRAM_BOT_TOKEN="<TOKEN>"
export COIN_TELEGRAM_CHAT_ID="<CHAT_ID>"
export STOCK_TELEGRAM_CHAT_ID="<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"]
``` ```
--- `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` 참고.

156
candle_features.py Normal file
View File

@@ -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()]

1
coins_buy_time.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -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"
}
}
}

View File

@@ -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": ""
}
}
}

302
config.py
View File

@@ -1,258 +1,68 @@
"""
전역 설정 (WLD 월드코인, 3분 BB MTF 전략).
"""
import os import os
# 텔레그램 설정 try:
COIN_TELEGRAM_BOT_TOKEN = "6435061393:AAHOh9wB5yGNGUdb3SfCYJrrWTBe7wgConM" from dotenv import load_dotenv
COIN_TELEGRAM_CHAT_ID = '574661323'
STOCK_TELEGRAM_BOT_TOKEN = "6874078562:AAEHxGDavfc0ssAXPQIaW8JGYmTR7LNUJOw" load_dotenv()
STOCK_TELEGRAM_CHAT_ID = '574661323' except ImportError:
pass
# 몇초 만에 다시 매수를 할 것인지 체크 # --- API / 알림 ---
BUY_MINUTE_LIMIT = 900 COIN_TELEGRAM_BOT_TOKEN = os.getenv("COIN_TELEGRAM_BOT_TOKEN", "")
COIN_TELEGRAM_CHAT_ID = os.getenv("COIN_TELEGRAM_CHAT_ID", "")
# 볼린저 밴드 설정 # --- 거래 대상 ---
BOLLINGER_PERIOD = 20 # 볼린저 밴드 기간 SYMBOL = "WLD"
BOLLINGER_STD = 2 # 표준편차 승수 COIN_NAME = "월드코인"
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% 미만 상승 조건
# 볼린저 밴드 squeeze 탐지 임계값 (밴드폭/중심선) KR_COINS: dict[str, str] = {
SQUEEZE_THRESHOLD = 0.04 # 4% 이하 SYMBOL: COIN_NAME,
# 장기간 저항선 돌파 감지 설정
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_1 = { # --- 타임프레임 (분) ---
"ADA": "에이다", ENTRY_INTERVAL = 3
"APE": "에이프코인", TREND_INTERVAL_1H = 60
"APT": "앱토스", TREND_INTERVAL_1D = 1440
"ARB": "아비트럼",
"BONK": "봉크",
"ENA": "에테나",
"FANC": "팬시",
"HBAR": "헤데라",
"KAIA": "카이아",
"LINK": "체인링크",
"ONDO": "온도파이낸스",
"PENGU": "펏지 펭귄",
"PEPE": "페페",
}
KR_COINS_2 = { # --- 쿨다운(초) ---
"POL": "폴리곤 에코시스템 토큰", BUY_COOLDOWN_SEC = int(os.getenv("BUY_COOLDOWN_SEC", "300"))
"PYTH":"피스 네트워크", SELL_COOLDOWN_SEC = int(os.getenv("SELL_COOLDOWN_SEC", "180"))
"SEI": "세이", BUY_MINUTE_LIMIT = BUY_COOLDOWN_SEC
"SHIB": "시바이누",
"STORJ": "스토리지",
"SUI": "수이",
"TON": "톤코인",
"TRX": "트론",
"UXLINK": "유엑스링크",
"VIRTUAL": "버추얼 프로토콜",
"WLD": "월드코인",
"XLM": "스텔라루멘",
"XRP": "엑스알피"
}
# 주식 설정 # --- 볼린저 (3분봉, 20, 2σ) ---
US_STOCKS = { BB_PERIOD = 20
'VOO': 'Vanguard S&P 500 ETF', BB_STD = 2
'SQQQ': 'ProShares UltraPro Short QQQ', BB_MIN_WIDTH_PCT = float(os.getenv("BB_MIN_WIDTH_PCT", "0.8"))
'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',
"AAPL": "Apple / AI 칩셋", # --- RSI / 거래량 (조합 필터) ---
"ACN": "Accenture", RSI_PERIOD = 14
"ADBE": "Adobe", RSI_BUY_MAX = float(os.getenv("RSI_BUY_MAX", "42"))
"AMD": "Advanced Micro Devices / AI 반도체", VOLUME_BUY_RATIO = float(os.getenv("VOLUME_BUY_RATIO", "1.0"))
"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"
}
# 한국 ETF 설정 # --- 추세 / 레짐 ---
KR_ETFS = { TREND_RANGE_MA_GAP_PCT = 0.5
"251340.KS": 'KODEX 코스닥150선물인버스',
"233740.KS": 'KODEX 코스닥150 레버리지', # --- 주문 ---
"252670.KS": 'KODEX 200선물인버스2X', DEFAULT_BUY_KRW = int(os.getenv("DEFAULT_BUY_KRW", "30000"))
"122630.KS": 'KODEX 레버리지', RANGE_BUY_KRW = int(os.getenv("RANGE_BUY_KRW", "15000"))
"114800.KS": 'KODEX 인버스',
"283580.KS": 'KODEX 중국본토CSI300', # --- 수수료 (매수·매도 각각 적용, 시뮬레이션) ---
"256750.KS": 'KODEX 심천ChiNext(합성)', TRADING_FEE_RATE = float(os.getenv("TRADING_FEE_RATE", "0.0005"))
"185680.KS": 'KODEX 미국S&P바이오(합성)',
"218420.KS": 'KODEX 미국S&P에너지(합성)', # --- coins.db (downloader.py 적재 간격, 분) ---
"132030.KS": 'KODEX 골드선물(H)', # 빗썸 분봉 API: 1,3,5,10,15,30,60,240 / 일봉 1440
"138920.KS": 'KODEX 콩선물(H)', DOWNLOAD_INTERVALS: tuple[int, ...] = (3, 10, 15, 30, 60, 240, 1440)
"271060.KS": 'KODEX 3대농산물선물(H)', DOWNLOAD_MONTHS = int(os.getenv("DOWNLOAD_MONTHS", "6"))
"117700.KS": 'KODEX 건설', DB_PATH = "coins.db"
"266420.KS": 'KODEX 헬스케어',
"276990.KS": 'KODEX 글로벌4차산업로보틱스(합성)', # --- 시뮬레이션 ---
"244580.KS": 'KODEX 바이오', SIM_INITIAL_CASH_KRW = int(os.getenv("SIM_INITIAL_CASH_KRW", "200000"))
"091160.KS": 'KODEX 반도체', SIM_MIN_ORDER_KRW = int(os.getenv("SIM_MIN_ORDER_KRW", "5000"))
"140700.KS": 'KODEX 보험',
"266410.KS": 'KODEX 필수소비재', # --- 실행 ---
"305720.KS": 'KODEX 2차전지산업', MONITOR_LOOP_SLEEP_SEC = 10
"266390.KS": 'KODEX 경기소비재', COOLDOWN_FILE = "coins_buy_time.json"
"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": "두산에너빌리티 / 원전,친환경",
}

22
discovered_rules.json Normal file
View File

@@ -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
}

View File

@@ -1,76 +1,312 @@
"""
WLD 과거 봉을 빗썸 API에서 받아 coins.db에 저장합니다.
- 최초: 최근 N개월 전량 적재
- 이후: DB 마지막 시각 **이후** 봉만 추가 (증분)
"""
from __future__ import annotations
import sqlite3 import sqlite3
from datetime import datetime
from config import * import pandas as pd
from HTS2 import HTS from dateutil.relativedelta import relativedelta
from monitor_coin import MonitorCoin
monitorCoin = MonitorCoin() from config import (
hts = HTS() 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): def bong_count_for_months(interval_minutes: int, months: int) -> int:
conn = sqlite3.connect('coins.db') """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() 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)): def get_row_count(symbol: str, interval: int, db_path: str = DB_PATH) -> int:
ymd = data.index[i].strftime('%Y%m%d') """저장된 봉 개수."""
hms = data.index[i].strftime('%H%M%S') conn = sqlite3.connect(db_path)
ymdhms = data.index[i].strftime('%Y-%m-%d %H:%M:%S') cursor = conn.cursor()
Open = data.Open.iloc[i] table_name = f"{symbol}_{interval}"
High = data.High.iloc[i] ensure_table(cursor, table_name)
Low = data.Low.iloc[i] cursor.execute(
Close = data.Close.iloc[i] f"SELECT COUNT(*) FROM {table_name} WHERE CODE = ?",
Volume = data.Volume.iloc[i] (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() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
return return deleted
def download():
for symbol in KR_COINS:
print(symbol)
# 1일 def append_data(
interval = 1440 symbol: str,
data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=5000) interval: int,
if data is not None and not data.empty: data: pd.DataFrame,
try: last_ts: pd.Timestamp | None = None,
inserData(symbol, interval, data) db_path: str = DB_PATH,
except Exception as e: ) -> tuple[int, int]:
print(f"Error processing data for {symbol}: {str(e)}") """
마지막 시각 이후 봉만 INSERT합니다. 기존 데이터는 삭제하지 않습니다.
# 1시간 Args:
interval = 60 last_ts: None이면 전체 data 적재, 있으면 index > last_ts 만 적재
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)}")
# 5분 Returns:
interval = 5 (추가된 행 수, 스킵된 행 수)
data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=10000) """
if data is not None and not data.empty: if data is None or data.empty:
try: return 0, 0
inserData(symbol, interval, data)
except Exception as e: total = len(data)
print(f"Error processing data for {symbol}: {str(e)}") 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__": if __name__ == "__main__":
download() download()

View File

@@ -1,34 +1,40 @@
import pandas as pd import pandas as pd
from HTS2 import HTS from HTS2 import HTS
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from datetime import datetime, timedelta from datetime import datetime
import sqlite3 import sqlite3
import telegram
import time import time
try:
import telegram
except ImportError:
telegram = None # type: ignore
import requests import requests
import json import json
import asyncio import asyncio
from multiprocessing import Pool from multiprocessing import Pool
import FinanceDataReader as fdr
import numpy as np import numpy as np
import os import os
from config import * from config import *
from HTS2 import HTS import strategy
class Monitor(HTS): class Monitor(HTS):
"""자산(코인/주식/ETF) 모니터링 및 매 실행 클래스""" """WLD 코인 모니터링 및 매 실행."""
last_signal = None last_signal = None
cooldown_file = None cooldown_file = None
def __init__(self, cooldown_file='coins_buy_time.json') -> None: def __init__(self, cooldown_file='coins_buy_time.json') -> None:
self.hts = HTS() HTS.__init__(self)
# 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장) # 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장)
self.last_signal: dict[str, str] = {} self.last_signal: dict[str, str] = {}
if cooldown_file is not None: if cooldown_file is not None:
self.cooldown_file = cooldown_file self.cooldown_file = cooldown_file
self.buy_cooldown = self._load_buy_cooldown() self.buy_cooldown = self._load_buy_cooldown()
else:
self.cooldown_file = None
self.buy_cooldown = {}
# ------------- Persistence ------------- # ------------- Persistence -------------
def _load_buy_cooldown(self) -> dict: def _load_buy_cooldown(self) -> dict:
@@ -106,13 +112,12 @@ class Monitor(HTS):
# ------------- Telegram ------------- # ------------- Telegram -------------
def _send_coin_msg(self, text: str) -> None: 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) coin_client = telegram.Bot(token=COIN_TELEGRAM_BOT_TOKEN)
asyncio.run(coin_client.send_message(chat_id=COIN_TELEGRAM_CHAT_ID, text=text)) 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): def sendMsg(self, msg):
try: try:
pool = Pool(12) pool = Pool(12)
@@ -133,18 +138,6 @@ class Monitor(HTS):
pool = Pool(12) pool = Pool(12)
pool.map(self._send_coin_msg, [payload]) 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 ------------- # ------------- Indicators -------------
def normalize_data(self, data: pd.DataFrame) -> pd.DataFrame: def normalize_data(self, data: pd.DataFrame) -> pd.DataFrame:
columns_to_normalize = ['Open', 'High', 'Low', 'Close', 'Volume'] columns_to_normalize = ['Open', 'High', 'Low', 'Close', 'Volume']
@@ -224,236 +217,169 @@ class Monitor(HTS):
return data return data
# ------------- Strategy ------------- # ------------- Strategy (strategy.py에 구현) -------------
def buy_sell_ticker_1h(self, symbol: str, data: pd.DataFrame, balances=None, is_inverse: bool = False) -> bool: 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: try:
# 신호 생성 및 최신 포인트 확인 coin_name = KR_COINS.get(symbol, symbol)
data = self.annotate_signals(symbol, data) signal_name = trade.signal
if data['point'].iloc[-1] != 1: close = trade.close
return False
if is_inverse: if trade.action == "sell":
# BUY_MINUTE_LIMIT 이내라면 매수하지 않음 if self._is_in_cooldown(symbol, "sell"):
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']:
return False return False
available_balance = 0 available = 0.0
try: if balances and symbol in balances:
if balances and symbol in balances: available = float(balances[symbol].get("balance", 0))
available_balance = float(balances[symbol].get('balance', 0)) if available <= 0:
except Exception: print(f"{symbol}: 매도 신호({signal_name}) — 보유 없음, 스킵")
available_balance = 0
if available_balance <= 0:
return False return False
sell_amount = available_balance * 0.7 sell_amount = available * strategy.get_sell_ratio(symbol, signal_name)
_ = self.hts.sellCoinMarket(symbol, 0, sell_amount) if sell_amount <= 0:
if self.cooldown_file is not None: return False
try: self.sellCoinMarket(symbol, 0, sell_amount)
self.last_signal[symbol] = str(data['signal'].iloc[-1]) self._record_trade(symbol, "sell", signal_name)
except Exception: print(f"{coin_name} ({symbol}) [매도 {signal_name}] ₩{close:.4f}, 수량 {sell_amount:.6f}")
self.last_signal[symbol] = '' self.sendMsg(
self.buy_cooldown.setdefault(symbol, {})['sell'] = {'datetime': current_time, 'signal': str(data['signal'].iloc[-1])} f"[KRW-COIN]\n• 매도 {coin_name} ({symbol}): {signal_name}{close:.4f}"
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})")
return True return True
else: if self._is_in_cooldown(symbol, "buy"):
check_5_week_lowest = False return False
buy_amount = strategy.get_buy_amount(
# BUY_MINUTE_LIMIT 이내라면 매수하지 않음 symbol, signal_name, close, trend=trade.trend
current_time = datetime.now() )
last_buy_dt = self.buy_cooldown.get(symbol, {}).get('buy', {}).get('datetime') if strategy.should_double_buy(symbol, signal_name, pd.DataFrame()):
if last_buy_dt: buy_amount *= 2
time_diff = current_time - last_buy_dt executed = self.buyCoinMarket(symbol, buy_amount)
if time_diff.total_seconds() < BUY_MINUTE_LIMIT: self._record_trade(symbol, "buy", signal_name)
print(f"{symbol}: 매수 금지 중 (남은 시간: {BUY_MINUTE_LIMIT - time_diff.total_seconds():.0f}초)") print(
return False f"{coin_name} ({symbol}) [매수 {signal_name}] ₩{close:.4f} "
f"({buy_amount} KRW, 추세={trade.trend})"
try: )
# 5주봉이 20주봉이나 40주봉보다 아래에 있는지 체크 self.sendMsg(
# Convert hourly data to week-based rolling periods (5, 20, 40 weeks) self.format_message(
hours_in_week = 24 * 7 # 168 hours symbol, coin_name, close, signal_name, executed or buy_amount
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 return True
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)))
except Exception as e: except Exception as e:
print(f"Error buying {symbol}: {str(e)}") print(f"Error trading {symbol}: {str(e)}")
return False return False
return True
def annotate_signals(self, symbol: str, data: pd.DataFrame, simulation: bool | None = None) -> pd.DataFrame: def process_wld_mtf(self, symbol: str, balances: dict | None = None) -> None:
data = data.copy() """
data['signal'] = '' WLD MTF: 모든 봉 BB 상태 비교 후 정책에 따라 매수/매도.
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
if data['Deviation40'].iloc[i - 1] < data['Deviation40'].iloc[i] and data['Deviation40'].iloc[i - 1] <= 90: mtf_bb_policy.json 이 있으면 해당 정책, 없으면 ACTIVE_MTF_POLICY 사용.
data.at[data.index[i], 'signal'] = 'deviation40' """
data.at[data.index[i], 'point'] = 1 from mtf_bb import load_frames_from_db, load_policy, print_latest_states
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'deviation40'
data.at[data.index[-1], 'point'] = 1
if symbol not in ['BONK']: try:
if symbol in ['TRX']: frames = load_frames_from_db(self, symbol)
if data['Deviation240'].iloc[i - 1] < data['Deviation240'].iloc[i] and data['Deviation240'].iloc[i - 1] <= 98: if not frames:
data.at[data.index[i], 'signal'] = 'deviation240' print(f"Data for {symbol}: 로드된 봉 없음.")
data.at[data.index[i], 'point'] = 1 return
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
if symbol in ['TON']: df_1d = frames.get(TREND_INTERVAL_1D)
if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 89: df_1h = frames.get(TREND_INTERVAL_1H)
data.at[data.index[i], 'signal'] = 'deviation1440' if df_1d is None or df_1d.empty:
data.at[data.index[i], 'point'] = 1 df_1d = frames.get(ENTRY_INTERVAL)
if not simulation and data['point'][-3:].sum() > 0: if df_1h is None or df_1h.empty:
data.at[data.index[-1], 'signal'] = 'deviation1440' df_1h = frames.get(ENTRY_INTERVAL)
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
# Deviation720 상향 돌파 매수 (92, 93) policy = load_policy() or strategy.ACTIVE_MTF_POLICY
try: cfg = strategy.ACTIVE_CONFIG
prev_d720 = data['Deviation720'].iloc[i - 1] print_latest_states(frames, cfg)
curr_d720 = data['Deviation720'].iloc[i] print(
# 92 상향 돌파 f"MTF 정책: {policy.name} | "
if prev_d720 < 92 and curr_d720 >= 92: f"매수={policy.buy_interval}분 | 매도={policy.sell_interval}분 | "
data.at[data.index[i], 'signal'] = 'Deviation720' f"확인={list(policy.buy_confirm_intervals)}"
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
try: trend = strategy.get_trend(df_1d, df_1h)
prev_low = data['Low'].iloc[i - 1] print(f"{symbol} 추세: {trend}")
curr_close = data['Close'].iloc[i]
curr_low = data['Low'].iloc[i] entry = frames.get(ENTRY_INTERVAL)
cond_close_drop = curr_close <= prev_low * 0.94 trade = strategy.evaluate(
cond_low_drop = curr_low <= prev_low * 0.94 symbol,
if cond_close_drop or cond_low_drop: entry if entry is not None else frames[policy.buy_interval],
data.at[data.index[i], 'signal'] = 'fall_6p' df_1h,
data.at[data.index[i], 'point'] = 1 df_1d,
if not simulation and data['point'][-3:].sum() > 0: config=cfg,
data.at[data.index[-1], 'signal'] = 'fall_6p' frames=frames,
data.at[data.index[-1], 'point'] = 1 policy=policy,
except Exception: )
pass if trade is None:
return data 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 ------------- # ------------- Formatting -------------
def format_message(self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float) -> str: def format_message(
message = f"[매수] {symbol_name} ({symbol}): " self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float
) -> str:
message = f"[매수] {symbol_name} ({symbol}) [{signal}]: "
if int(close) >= 100: if int(close) >= 100:
message += f"{close}" message += f"{close}"
@@ -472,12 +398,6 @@ class Monitor(HTS):
message += f"[{signal}]" message += f"[{signal}]"
return message 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 ------------- # ------------- Data fetch -------------
def get_coin_data(self, symbol: str, interval: int = 60, to: str | None = None, retries: int = 3) -> pd.DataFrame | None: 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): for attempt in range(retries):
@@ -520,96 +440,146 @@ class Monitor(HTS):
continue continue
return None 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() to = datetime.now()
data: pd.DataFrame | None = None data: pd.DataFrame | None = None
step = 0
while data is None or len(data) < bong_count: while data is None or len(data) < bong_count:
step += 1
if data is None: 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: else:
previous_count = len(data) previous_count = len(data)
df = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S")) df = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
data = pd.concat([data, df], ignore_index=True) if df is not None and not df.empty:
if previous_count == len(data): 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 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) time.sleep(0.3)
to = to - relativedelta(minutes=interval * 200) 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.sort_index()
data = data.drop_duplicates(keep='first') data = data.drop_duplicates(keep="first")
data["datetime"] = data.index data["datetime"] = data.index
return data return data
def get_coin_saved_data(self, symbol: str, interval: int, data: pd.DataFrame) -> pd.DataFrame: def get_coin_saved_data(
conn = sqlite3.connect('coins.db') 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() 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)): 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')),) ymdhms = data["datetime"].iloc[-i].strftime("%Y-%m-%d %H:%M:%S")
arr = cursor.fetchone() cursor.execute(
if not arr: f"SELECT 1 FROM {table_name} WHERE CODE = ? AND ymdhms = ?",
(symbol, ymdhms),
)
if not cursor.fetchone():
cursor.execute( 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, symbol,
KR_COINS[symbol], KR_COINS[symbol],
data['datetime'].iloc[-i].strftime('%Y-%m-%d %H:%M:%S'), ymdhms,
data['datetime'].iloc[-i].strftime('%Y%m%d'), data["datetime"].iloc[-i].strftime("%Y%m%d"),
data['datetime'].iloc[-i].strftime('%H%M%S'), data["datetime"].iloc[-i].strftime("%H%M%S"),
data['Close'].iloc[-i], data["Close"].iloc[-i],
data['Open'].iloc[-i], data["Open"].iloc[-i],
data['High'].iloc[-i], data["High"].iloc[-i],
data['Low'].iloc[-i], data["Low"].iloc[-i],
data['Volume'].iloc[-i], data["Volume"].iloc[-i],
), ),
) )
else: else:
break 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() result = cursor.fetchall()
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
df = pd.DataFrame(result)
df.columns = ['Open', 'Close', 'High', 'Low', 'Volume', 'datetime'] if not result:
df = df.set_index('datetime') 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 = df.sort_index()
df['datetime'] = df.index df["datetime"] = df.index
return df return df
def get_coin_some_data(self, symbol: str, interval: int) -> pd.DataFrame: 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) 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 = 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) saved_data = self.get_coin_saved_data(symbol, interval, data)
data = pd.concat([data, saved_data, data_1.iloc[[-1]]], ignore_index=True) parts = [data]
data['datetime'] = pd.to_datetime(data['datetime'], format='%Y-%m-%d %H:%M:%S') if saved_data is not None and not saved_data.empty:
data = data.set_index('datetime') parts.append(saved_data)
data = data.sort_index() if data_1 is not None and not data_1.empty:
data = data.drop_duplicates(keep='first') parts.append(data_1.iloc[[-1]])
data["datetime"] = data.index
return data
def get_kr_stock_data(self, symbol: str, retries: int = 3) -> pd.DataFrame | None: merged = pd.concat(parts, ignore_index=True)
for attempt in range(retries): merged["datetime"] = pd.to_datetime(merged["datetime"], format="%Y-%m-%d %H:%M:%S")
try: merged = merged.set_index("datetime")
end = datetime.now() merged = merged.sort_index()
start = end - timedelta(days=300) merged = merged.drop_duplicates(keep="first")
data = fdr.DataReader(symbol, start.strftime('%Y-%m-%d'), end.strftime('%Y-%m-%d')) merged["datetime"] = merged.index
if not data.empty: return merged
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

View File

@@ -1,52 +1,39 @@
"""
WLD(월드코인) 실시간 모니터 — 3분 BB MTF (평균회귀 + 돌파).
전략: strategy.py
"""
from datetime import datetime from datetime import datetime
import time import time
from config import *
from config import COIN_NAME, COOLDOWN_FILE, MONITOR_LOOP_SLEEP_SEC, SYMBOL
from monitor import Monitor 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) super().__init__(cooldown_file)
def monitor_coins(self) -> None: def monitor_wld(self) -> None:
tmps = self.getBalances() """일봉·1시간 추세 + 3분 신호로 현물 매수/매도."""
balances = {} balances = self.load_balances_dict()
for tmp in tmps: print(
balances[tmp['currency']] = {'balance': float(tmp['balance']), 'avg_buy_price': float(tmp['avg_buy_price'])} "[{}] {} ({})".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: def run_schedule(self) -> None:
while True: while True:
self.monitor_coins() self.monitor_wld()
time.sleep(10) time.sleep(MONITOR_LOOP_SLEEP_SEC)
if __name__ == "__main__": if __name__ == "__main__":
KR_COINS.keys()
MonitorCoin().run_schedule() MonitorCoin().run_schedule()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

222
mtf_bb.py Normal file
View File

@@ -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}")

11
mtf_bb_policy.json Normal file
View File

@@ -0,0 +1,11 @@
{
"buy_interval": 60,
"sell_interval": 60,
"buy_confirm_intervals": [
1440
],
"sell_confirm_intervals": [
1440
],
"name": "auto_60분_buy_60분_sell"
}

View File

@@ -1,14 +1,7 @@
yfinance
pandas pandas
mplcursors
numpy numpy
ccxt
PyJWT PyJWT
pycurl requests
schedule
python-dateutil python-dateutil
python-telegram-bot python-telegram-bot
finance-datareader
psutil
mpld3
plotly plotly

View File

@@ -1,6 +0,0 @@
{
"ADA": {
"datetime": "2025-08-14T22:41:28.363958",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"APE": {
"datetime": "2025-08-09T14:22:02.089619",
"signal": "movingaverage"
}
}

View File

@@ -1,6 +0,0 @@
{
"ARB": {
"datetime": "2025-08-14T22:43:59.078775",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"BONK": {
"datetime": "2025-08-14T22:41:42.247356",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"ENA": {
"datetime": "2025-08-16T01:03:31.916209",
"signal": "deviation240"
}
}

View File

@@ -1,6 +0,0 @@
{
"HBAR": {
"datetime": "2025-08-14T21:37:21.575425",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"KAIA": {
"datetime": "2025-08-14T22:42:27.079125",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"LINK": {
"datetime": "2025-08-14T22:42:38.780771",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"ONDO": {
"datetime": "2025-08-14T22:04:53.097618",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"PENGU": {
"datetime": "2025-08-16T07:53:40.994785",
"signal": "deviation240"
}
}

View File

@@ -1,6 +0,0 @@
{
"PEPE": {
"datetime": "2025-08-14T22:06:10.012326",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"SAND": {
"datetime": "2025-08-14T22:05:08.098364",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"SEI": {
"datetime": "2025-08-14T21:36:00.600483",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"SHIB": {
"datetime": "2025-08-14T22:05:20.734073",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"STORJ": {
"datetime": "2025-08-14T23:32:08.979598",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"SUI": {
"datetime": "2025-08-14T21:36:14.758922",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"UXLINK": {
"datetime": "2025-08-14T22:05:36.242448",
"signal": "fall_6p"
}
}

View File

@@ -1,6 +0,0 @@
{
"VIRTUAL": {
"datetime": "2025-08-16T21:02:23.634183",
"signal": "deviation1440"
}
}

View File

@@ -1,6 +0,0 @@
{
"WLD": {
"datetime": "2025-08-14T22:43:33.737340",
"signal": "fall_6p"
}
}

542
rule_discovery.py Normal file
View File

@@ -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)

View File

@@ -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 pandas as pd
import yfinance as yf
import plotly.graph_objs as go import plotly.graph_objs as go
from plotly import subplots
import plotly.io as pio import plotly.io as pio
from datetime import datetime 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 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: 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: def __init__(self) -> None:
self.monitor = Monitor() self.monitor = Monitor(cooldown_file=None)
self.INTERVAL_MAP = {
60: "60m",
240: "4h",
}
def detect_turnaround_signal(self, symbol, data, interval=0, params=None): def load_mtf(self, symbol: str):
if len(data) < 7: df_1d = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1D)
return None df_1h = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1H)
current_data = data.iloc[-1] df_3m = self.monitor.get_coin_some_data(symbol, ENTRY_INTERVAL)
if current_data.get('point', 0) == 1:
return {
'alert': True,
'details': f"매수신호: {current_data.get('signal', 'unknown')}"
}
return {'alert': False, 'details': "매수신호 없음"}
def fetch_price_history(self, symbol: str, interval_minutes: int, days: int = 30) -> pd.DataFrame: if df_1d is None or df_1d.empty:
if symbol in KR_COINS: df_1d = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1D, bong_count=500)
bong_count = 3000 if df_1h is None or df_1h.empty:
return self.monitor.get_coin_more_data(symbol, interval_minutes, bong_count=bong_count) df_1h = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1H, bong_count=5000)
if interval_minutes not in self.INTERVAL_MAP: if df_3m is None or df_3m.empty:
raise ValueError("interval must be 60 or 240") df_3m = self.monitor.get_coin_more_data(
interval_str = self.INTERVAL_MAP[interval_minutes] symbol, ENTRY_INTERVAL, bong_count=90000, verbose=True
df = yf.download( )
tickers=symbol,
period=f"{days}d", df_1d = self.monitor.calculate_technical_indicators(df_1d)
interval=interval_str, df_1h = self.monitor.calculate_technical_indicators(df_1h)
progress=False, 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: fig = subplots.make_subplots(
raise RuntimeError("No data fetched. Check symbol or interval support.") rows=3,
return df 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): buy_trades = [t for t in result.trades if t.action == "매수"]
data = self.fetch_price_history(symbol, interval_minutes, days) sell_trades = [t for t in result.trades if t.action == "매도"]
data = self.monitor.calculate_technical_indicators(data) fig.add_trace(
data = self.monitor.annotate_signals(symbol, data, simulation=True) go.Scatter(
print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}") x=[t.dt for t in buy_trades],
print(f"총 데이터 수: {len(data)}") y=[t.price for t in buy_trades],
bottom_start = pd.Timestamp('2025-06-22') mode="markers",
bottom_end = pd.Timestamp('2025-07-09') name="매수",
bottom_data = data[(data.index >= bottom_start) & (data.index <= bottom_end)] legendgroup="trades",
if len(bottom_data) == 0: showlegend=True,
print("저점 기간 데이터가 없습니다.") marker=dict(
return None, [] color="#22c55e",
print(f"\n저점 기간 데이터: {bottom_data.index[0]} ~ {bottom_data.index[-1]}") size=11,
print(f"저점 기간 데이터 수: {len(bottom_data)}") symbol="triangle-up",
print("\n=== 저점 기간 기술적 지표 분석 ===") line=dict(width=1, color="#166534"),
min_price = bottom_data['Low'].min() ),
max_price = bottom_data['High'].max() ),
avg_price = bottom_data['Close'].mean() row=1,
print(f"최저가: {min_price:.4f}") col=1,
print(f"최고가: {max_price:.4f}") )
print(f"평균가: {avg_price:.4f}") fig.add_trace(
print(f"가격 변동폭: {((max_price - min_price) / min_price * 100):.2f}%") go.Scatter(
bb_lower_min = bottom_data['Lower'].min() x=[t.dt for t in sell_trades],
bb_upper_max = bottom_data['Upper'].max() y=[t.price for t in sell_trades],
print(f"\n볼린저 밴드 분석:") mode="markers",
print(f"하단 밴드 최저: {bb_lower_min:.4f}") name="매도",
print(f"상단 밴드 최고: {bb_upper_max:.4f}") legendgroup="trades",
volume_avg = bottom_data['Volume'].mean() showlegend=True,
volume_max = bottom_data['Volume'].max() marker=dict(
print(f"\n거래량 분석:") color="#ef4444",
print(f"평균 거래량: {volume_avg:.0f}") size=11,
print(f"최대 거래량: {volume_max:.0f}") symbol="triangle-down",
actual_bottom_idx = bottom_data['Low'].idxmin() line=dict(width=1, color="#991b1b"),
actual_bottom_price = bottom_data.loc[actual_bottom_idx, 'Low'] ),
actual_bottom_date = actual_bottom_idx ),
print(f"\n실제 저점:") row=1,
print(f"날짜: {actual_bottom_date}") col=1,
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}%") if "RSI" in df_3m.columns:
print(f"\n=== 매수 신호 분석 ===") fig.add_trace(
bottom_alerts = bottom_data[bottom_data['point'] == 1] go.Scatter(
alerts = [(idx, row['Close']) for idx, row in bottom_alerts.iterrows()] x=df_3m.index,
print(f"저점 기간 매수 신호 수: {len(alerts)}") y=df_3m["RSI"],
if alerts: name="RSI",
print("매수 신호 발생 시점:") showlegend=False,
for date, price in alerts: ),
print(f" {date}: {price:.4f}") row=2,
return bottom_data, alerts 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): def load_all_frames(self) -> dict[int, pd.DataFrame]:
data = self.fetch_price_history(symbol, interval_minutes) """discovered 규칙용 전 간격 로드."""
from mtf_bb import load_frames_from_db
inverseData = self.monitor.inverse_data(data) return load_frames_from_db(self.monitor, SYMBOL)
inverseData = self.monitor.annotate_signals(symbol, inverseData, simulation=True)
data = self.monitor.calculate_technical_indicators(data) def _run_one_strategy(
data = self.monitor.annotate_signals(symbol, data, simulation=True) self,
print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}") name: str,
print(f"총 데이터 수: {len(data)}") df_1d: pd.DataFrame,
alerts = [] df_1h: pd.DataFrame,
for i in range(len(data)): df_3m: pd.DataFrame,
if data['point'].iloc[i] == 1: cfg: strategy.StrategyConfig,
alerts.append((data.index[i], data['Close'].iloc[i])) frames: dict | None = None,
print(f"\n총 매수 신호 수: {len(alerts)}") ) -> tuple[pd.DataFrame, SimResult, int]:
ma_signals = len(data[(data['point'] == 1) & (data['signal'] == 'movingaverage')]) """한 전략으로 신호·백테스트. 반환: (df, result, 신호수)."""
dev40_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation40')]) df_sig = strategy.annotate_signals(
dev240_signals = len(data[(data['point'] == 1) & (data['signal'] == 'Deviation720')]) SYMBOL,
dev1440_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation1440')]) df_3m.copy(),
print(f" - MA 신호: {ma_signals}") simulation=True,
print(f" - Dev40 신호: {dev40_signals}") df_1h=df_1h,
print(f" - Dev240 신호: {dev240_signals}") df_1d=df_1d,
print(f" - Dev1440 신호: {dev1440_signals}") 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 기반 시각화로 전환 def run(self, config: strategy.StrategyConfig | None = None) -> SimResult:
self.render_plotly(symbol, interval_minutes, data, inverseData) """기본 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 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__": if __name__ == "__main__":
sim = Simulation() main()
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)}")

624
strategy.py Normal file
View File

@@ -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