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

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
## 개요
`AssetMonitor`는 주식‧ETF 및 암호화폐 시장을 실시간으로 감시하여 Bollinger Band, RSI, MACD, 이동평균(Golden-Cross), 거래량 등을 종합 분석한 **매수 후보(signals)**를 텔레그램으로 통보하는 자동화 봇입니다.
빗썸 KRW-WLD 현물 전용. **모든 봉**에 동일한 BB 규칙을 적용하고, 봉별 상태를 비교해 실행·확인 봉을 정합니다.
**주요 개선사항:**
- **데이터 표준화**: 모든 코인에 동일한 기술적 분석 기준 적용
- **순수 기술적 분석**: 날짜 기반 조건 제거, 기술적 지표만 사용
- **강화된 기술적 지표**: 스토캐스틱, MFI, OBV, ATR 등 추가 지표 활용
## BB 기본 규칙 (모든 간격 동일)
---
## 주요 구성 파일
| 파일 | 설명 |
| 구분 | 조건 |
|------|------|
| `config.py` | ✅ API 토큰, 텔레그램 채널 ID, 볼린저 밴드/임계값, 모니터링 자산 목록(KR_COINS, US_STOCKS, KR_ETFS) 등 전역 설정을 보관합니다. |
| `stock_monitor.py` | 시스템의 핵심 로직이 담긴 실행 스크립트입니다. <br/>• 데이터 수집 ⇒ 기술적 지표 계산 ⇒ 매수 신호 판단 ⇒ 메시지 포맷팅/발송 <br/>• `schedule` 라이브러리로 정해진 시간마다 작업을 자동 실행합니다. |
| `requirements.txt` | 프로젝트 의존 패키지를 명시합니다. |
| 매수 | 이전 종가 ≤ 하단, 현재 종가 > 하단 (하단 **상향 돌파**) |
| 매도 | 이전 종가 < 상단, 현재 종가 ≥ 상단 (상단 **상향 돌파**) |
| 손절(선택) | 하단 재이탈 |
---
**MTF 적용** (`mtf_bb.py`, `ACTIVE_MTF_POLICY` / `mtf_bb_policy.json`)
## 데이터 흐름
1. **스케줄 트리거** (`run_schedule`)
지정된 시각에 각 모니터링 함수가 호출됩니다.
2. **데이터 획득**
*주식 / ETF*: `FinanceDataReader`
*암호화폐*: 빗썸 **240분 봉** Open API
3. **데이터 표준화** (`normalize_data`)
- 모든 코인에 동일한 정규화 적용
- 20일 롤링 윈도우 기반 Min-Max 정규화
4. **기술적 지표 계산** (`calculate_technical_indicators`)
- Bollinger Band (기간 20, ±2σ)
- RSI(14)
- MACD(12-26-9)
- 단/중/장기 이동평균선(MA5/20/60)
- 거래량 MA5
- **추가 지표**: 스토캐스틱, OBV, ATR, MFI
5. **매수 후보 판정** (`check_signals`)
- *아래 새로운 "매수 후보 전략" 섹션 참조*
6. **알림 발송** (`send_*_telegram_message`)
multiprocessing Pool을 이용해 다중 메시지를 병렬로 전송합니다.
- 실행 봉: 3·10·15·30·60분 중 백테스트 수익률 1위
- 확인 봉: 60분·일봉 등 상위 봉 상태가 매수/매도에 맞을 때만 체결
- 하락 추세: 매수 차단 (설정 시)
---
봉별 상태: `inside`, `cross_up_lower`, `cross_up_upper`, `below_lower`, `above_upper`, `squeeze`
## 매수 후보 전략 (표준화된 기술적 분석)
## 파일
| 신호 | 변수명 | 조건 | 의미 |
|------|--------|------|------|
| 볼린저 하단 근접 | `bb_signal` | `(현재가 - LowerBand) / (UpperBand - LowerBand) < BOLLINGER_THRESHOLD` | 밴드 하단(과매도 영역) 접근 |
| RSI 과매도 | `rsi_signal` | `RSI < 30` | 추세 과매도 |
| MACD 골든크로스 | `macd_signal` | `이전 MACD < 이전 Signal` **AND** `현재 MACD > 현재 Signal` | 모멘텀 전환 |
| 이동평균 골든크로스 | `ma_signal` | `이전 MA5 < 이전 MA20` **AND** `현재 MA5 ≥ 현재 MA20` | 단기 추세 ↗ 전환 |
| 거래량 급증 | `volume_signal` | `현재 거래량 > MA5 Volume × 1.5` | 수급 증가 |
| **U자 반등 돌파** | `breakout_signal` | ① 최근 `BREAKOUT_LOOKBACK`(30)개 캔들 동안 최고·최저가 차이가 `BUY_THRESHOLD`(15 %) 이상 하락 → ② **현재가가 그 최고가 돌파** | 하락 후 반등의 추세 전환 확인 |
| **장기 저항 돌파** | `long_breakout_signal` | 장기간 저항선 돌파 감지 | 장기 추세 전환 |
| **스토캐스틱 과매도** | `stoch_signal` | `%K < 20 AND %K > 이전 %K` | 스토캐스틱 과매도 반등 |
| **MFI 과매도** | `mfi_signal` | `MFI < 20 AND MFI > 이전 MFI` | 자금 흐름 과매도 반등 |
| **OBV 상승** | `obv_signal` | `현재 OBV > 이전 OBV × 1.1` | 거래량 가중 상승 |
| **ATR 급증** | `atr_signal` | `현재 ATR > ATR 20일 평균 × 1.5` | 변동성 급증 |
| 파일 | 역할 |
|------|------|
| `strategy.py` | 신호·금액·매도 비율 |
| `monitor.py` | MTF 데이터, `process_wld_mtf`, 현물 주문 |
| `monitor_coin.py` | 실시간 루프 |
| `downloader.py` | `coins.db` (3분·1시간·일봉) |
| `mtf_bb.py` | 봉별 BB 비교·정책 추천 |
| `simulation_1h.py` | 백테스트 차트 |
### 최종 매수 후보 결정 로직
```text
if breakout_signal or long_breakout_signal:
buy = True # 돌파 신호 단독으로도 매수 후보
else:
# ① 볼린저 + RSI 동시, 또는 ② (신호 ≥ 3개) & (볼린저 또는 RSI 포함)
buy = (bb_signal and rsi_signal) or (signal_count >= 3 and (bb_signal or rsi_signal))
```
*`signal_count` = 위 11개 신호 중 True 개수*
## 실행
### 메시지 구성
- `매수` : 최종 `buy=True`일 때 메시지 맨 앞에 부착
- `신호(n):` 뒤에 활성화된 신호 목록
- 볼린저/RSI/MACD/MA/거래량/Breakout/스토캐스틱/MFI/OBV/ATR 각각 표시
해당 전략으로 **과매도 바닥근처 매수 기회 + 상승 추세 전환 브레이크아웃** 두 영역을 모두 포착할 수 있습니다.
---
## 스케줄 테이블 (기본값)
| 대상 | 실행 시각(서버 기준) | 호출 함수 |
|------|----------------------|-----------|
| KRW 코인 | 매시간 04, 14, 24, 34, 44, 54분 | `monitor_coins()` |
| 미국 주식 / ETF | 05:10, 16:30, 23:30 | `monitor_us_stocks()` |
| 한국 ETF / 주식 | 07:10, 18:20 | `monitor_kr_stocks()` |
> 시간은 `config.py`가 아닌 `stock_monitor.py`의 `run_schedule()` 내부에 하드코딩되어 있습니다. 필요 시 직접 수정하세요.
---
## 설치 방법
1. Python ≥ 3.9 환경을 준비합니다.
2. 저장소를 클론하고 디렉터리로 이동:
```bash
$ git clone <repo-url>
$ cd AssetMonitor
```
3. 패키지 설치:
```bash
$ pip install -r requirements.txt
```
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"]
cp .env.example .env
python downloader.py
python simulation_1h.py discover # 모든 봉·캔들 특징 탐색 → discovered_rules.json
python simulation_1h.py # 탐색 규칙 HTML 차트 (기본)
python simulation_1h.py compare # 9종 조합 순위
python simulation_1h.py mtf # 봉별 BB 비교 (실거래 전 참고)
python monitor_coin.py # 실거래는 HTML 최적화 후 연동 예정
```
---
`DOWNLOAD_MONTHS=6` — 간격: **3, 10, 15, 30, 60, 240, 1440**분.
**증분 저장**: DB `MAX(ymdhms)` 이후 봉만 INSERT (재실행 시 전체 삭제 없음).
## 커스터마이징
- **자산 목록 추가/삭제**: `config.py``KR_COINS`, `US_STOCKS`, `KR_ETFS` 사전을 편집합니다.
- **임계값·기간 조정**: `BOLLINGER_PERIOD`, `BOLLINGER_STD`, `BOLLINGER_THRESHOLD`, `BUY_THRESHOLD` 등 변경.
---
## 한계 및 면책 조항
본 프로젝트는 교육·연구 목적의 오픈소스 예제로, 투자 손실에 대해 어떠한 책임도 지지 않습니다. 실거래에 사용하려면 충분한 검증과 백테스트를 진행하십시오.
---
## 라이선스
MIT (프로젝트 루트의 `LICENSE` 파일 참조, 미존재 시 필요에 따라 추가하세요.)
## 환경 변수
`BITHUMB_ACCESS_KEY`, `BITHUMB_SECRET_KEY`, `COIN_TELEGRAM_*`,
`BUY_COOLDOWN_SEC`(기본 300), `SELL_COOLDOWN_SEC`(180), `DEFAULT_BUY_KRW` 등 — `.env.example` 참고.

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
# 텔레그램 설정
COIN_TELEGRAM_BOT_TOKEN = "6435061393:AAHOh9wB5yGNGUdb3SfCYJrrWTBe7wgConM"
COIN_TELEGRAM_CHAT_ID = '574661323'
try:
from dotenv import load_dotenv
STOCK_TELEGRAM_BOT_TOKEN = "6874078562:AAEHxGDavfc0ssAXPQIaW8JGYmTR7LNUJOw"
STOCK_TELEGRAM_CHAT_ID = '574661323'
load_dotenv()
except ImportError:
pass
# 몇초 만에 다시 매수를 할 것인지 체크
BUY_MINUTE_LIMIT = 900
# --- API / 알림 ---
COIN_TELEGRAM_BOT_TOKEN = os.getenv("COIN_TELEGRAM_BOT_TOKEN", "")
COIN_TELEGRAM_CHAT_ID = os.getenv("COIN_TELEGRAM_CHAT_ID", "")
# 볼린저 밴드 설정
BOLLINGER_PERIOD = 20 # 볼린저 밴드 기간
BOLLINGER_STD = 2 # 표준편차 승수
BOLLINGER_THRESHOLD = 0.10 # 하단 밴드 대비 10% 근접 시 알림
BUY_THRESHOLD = 0.15
BREAKOUT_LOOKBACK = 30 # U자 반등 후 돌파 판단에 사용할 과거 캔들 수 (4시간봉 기준 약 5일)
BREAKOUT_WEEK_LOOKBACK = 42 # 4시간봉 1주일 ≒ 42개
BREAKOUT_WEEK_LIMIT = 0.05 # 1주일 대비 5% 미만 상승 조건
# --- 거래 대상 ---
SYMBOL = "WLD"
COIN_NAME = "월드코인"
# 볼린저 밴드 squeeze 탐지 임계값 (밴드폭/중심선)
SQUEEZE_THRESHOLD = 0.04 # 4% 이하
# 장기간 저항선 돌파 감지 설정
RESISTANCE_LOOKBACK = 120 # 저항선 판단을 위한 과거 캔들 수 (예: 120개)
RESISTANCE_BREAK_THRESHOLD = 0.01 # 저항선 대비 1% 이상 돌파 시 신호
KR_COINS = {
"ADA": "에이다",
"APE": "에이프코인",
"APT": "앱토스",
"ARB": "아비트럼",
"BONK": "봉크",
"ENA": "에테나",
"FANC": "팬시",
"HBAR": "헤데라",
"KAIA": "카이아",
"LINK": "체인링크",
"ONDO": "온도파이낸스",
"PENGU": "펏지 펭귄",
"PEPE": "페페",
"POL": "폴리곤 에코시스템 토큰",
"PYTH":"피스 네트워크",
"SEI": "세이",
"SHIB": "시바이누",
"STORJ": "스토리지",
"SUI": "수이",
"TON": "톤코인",
"TRX": "트론",
"UXLINK": "유엑스링크",
"VIRTUAL": "버추얼 프로토콜",
"WLD": "월드코인",
"XLM": "스텔라루멘",
"XRP": "엑스알피"
KR_COINS: dict[str, str] = {
SYMBOL: COIN_NAME,
}
KR_COINS_1 = {
"ADA": "에이다",
"APE": "에이프코인",
"APT": "앱토스",
"ARB": "아비트럼",
"BONK": "봉크",
"ENA": "에테나",
"FANC": "팬시",
"HBAR": "헤데라",
"KAIA": "카이아",
"LINK": "체인링크",
"ONDO": "온도파이낸스",
"PENGU": "펏지 펭귄",
"PEPE": "페페",
}
# --- 타임프레임 (분) ---
ENTRY_INTERVAL = 3
TREND_INTERVAL_1H = 60
TREND_INTERVAL_1D = 1440
KR_COINS_2 = {
"POL": "폴리곤 에코시스템 토큰",
"PYTH":"피스 네트워크",
"SEI": "세이",
"SHIB": "시바이누",
"STORJ": "스토리지",
"SUI": "수이",
"TON": "톤코인",
"TRX": "트론",
"UXLINK": "유엑스링크",
"VIRTUAL": "버추얼 프로토콜",
"WLD": "월드코인",
"XLM": "스텔라루멘",
"XRP": "엑스알피"
}
# --- 쿨다운(초) ---
BUY_COOLDOWN_SEC = int(os.getenv("BUY_COOLDOWN_SEC", "300"))
SELL_COOLDOWN_SEC = int(os.getenv("SELL_COOLDOWN_SEC", "180"))
BUY_MINUTE_LIMIT = BUY_COOLDOWN_SEC
# 주식 설정
US_STOCKS = {
'VOO': 'Vanguard S&P 500 ETF',
'SQQQ': 'ProShares UltraPro Short QQQ',
'QID': 'ProShares UltraShort QQQ',
'PSQ': 'ProShares Short QQQ',
'TQQQ': 'ProShares UltraPro QQQ',
'QQQ': 'Invesco QQQ Trust',
'SCO': 'ProShares UltraShort Bloomberg Crude Oil',
'UCO': 'ProShares Ultra Bloomberg Crude Oil',
'GLL': 'ProShares UltraShort Gold',
'UGL': 'ProShares Ultra Gold',
'SOXS': 'Direxion Daily Semiconductor Bear -3X Shares',
'SOXL': 'Direxion Daily Semiconductor Bull 3X Shares',
'FNGD': 'MicroSectors™ FANG+™ Index -3X Inverse Leveraged ETN',
'FNGU': 'MicroSectors™ FANG+™ Index 3X Leveraged ETN',
'FXI': 'iShares China Large-Cap ETF',
# --- 볼린저 (3분봉, 20, 2σ) ---
BB_PERIOD = 20
BB_STD = 2
BB_MIN_WIDTH_PCT = float(os.getenv("BB_MIN_WIDTH_PCT", "0.8"))
"AAPL": "Apple / AI 칩셋",
"ACN": "Accenture",
"ADBE": "Adobe",
"AMD": "Advanced Micro Devices / AI 반도체",
"AMZN": "Amazon / AI 로봇/클라우드",
"ASML": "ASML Holding / EUV 리소그래피",
"ASTS": "AST SpaceMobile / 위성통신",
"AVGO": "Broadcom",
"BABA": "Alibaba Group Holdings Ltd ADR",
"BAC": "Bank of America",
"BE": "Bloom Energy / 고체산화물 연료전지",
"CAMT": "Camtek / 반도체 계측기기6",
"CHWY": "Chewy / 애완용품 전자상거래",
"COIN": "Coinbase / 암호화폐 거래소",
"COST": "Costco Wholesale / 회원제 유통",
"CPNG": "Coupang LLC",
"CRM": "Salesforce.com",
"CRWD": "CrowdStrike / AI 사이버보안",
"CSCO": "Cisco",
"CVX": "Chevron Corp",
"DASH": "DoorDash / 배달 플랫폼",
"DIS": "Walt Disney",
"DQ": "Daqo New Energy Corp ADR",
"DXCM": "DexCom / 지속형 혈당측정기",
"EBAY": "eBay Inc",
"ENPH": "Enphase Energy / 태양광 인버터",
"GEO": "GEO Group / 교정시설 운영3",
"GOOG": "Alphabet C",
"GOOGL": "Alphabet (Google) / AI 검색/자율주행",
"GRVY": "Gravity / 온라인 게임",
"HD": "Home Depot",
"HON": "Honeywell",
"IBM": "IBM",
"INTC": "Intel / 차세대 반도체",
"ISRG": "Intuitive Surgical / 수술로봇",
"JNJ": "Johnson & Johnson (JNJ)",
"JPM": "JPMorgan",
"KLAC": "KLA Corporation / 반도체 검사장비",
"KO": "Coca-Cola",
"LB": "LandBridge Co / 에너지 인프라3",
"LCID": "Lucid Group / 고급 전기차",
"LMT": "Lockheed Martin / 방위 시스템",
"LRCX": "Lam Research / 반도체 장비",
"MA": "Mastercard",
"MELI": "MercadoLibre / 라틴아메리카 전자상거래",
"META": "Meta Platforms / AI 메타버스",
"MNMD": "Mind Medicine / 사이키델릭 치료제",
"MS": "Morgan Stanley",
"MSFT": "Microsoft / AI 클라우드",
"NKE": "Nike",
"NOC": "Northrop Grumman / 우주항공",
"NTAP": "NetApp Inc",
"NVDA": "NVIDIA / AI 반도체",
"ORCL": "Oracle",
"PLTR": "Palantir Technologies / AI 데이터 분석",
"PLUG": "Plug Power / 수소연료전지",
"QCOM": "Qualcomm / 모바일 칩셋",
"REGN": "Regeneron Pharmaceuticals / 항체 치료제",
"RIVN": "Rivian Automotive / 전기트럭",
"RKLB": "Rocket Lab / 소형위성 발사체",
"RTX": "RTX Corporation / 제트엔진/미사일",
"SEDG": "SolarEdge Technologies / 태양광 시스템",
"SNOW": "Snowflake / AI 데이터 플랫폼",
"SOFI": "SoFi Technologies / 디지털 뱅킹",
"SPCE": "Virgin Galactic / 우주관광",
"T": "AT&T",
"TCTZF": "Tencent Holdings",
"TDOC": "Teladoc Health / 원격의료",
"TGT": "Target / 오프라인 리테일 혁신",
"TSLA": "Tesla / 전기차/에너지 저장",
"TSM": "Taiwan Semiconductor",
"UNH": "UnitedHealth",
"UPST": "Upstart Holdings / AI 대출플랫폼",
"V": "Visa A",
"VRTX": "Vertex Pharmaceuticals / 난치병 치료제",
"VZ": "Verizon",
"WGS": "GeneDx Holdings / 유전체 분석3",
"WMT": "Walmart",
"X": "United States Steel Corporation",
"XOM": "Exxon Mobil"
}
# --- RSI / 거래량 (조합 필터) ---
RSI_PERIOD = 14
RSI_BUY_MAX = float(os.getenv("RSI_BUY_MAX", "42"))
VOLUME_BUY_RATIO = float(os.getenv("VOLUME_BUY_RATIO", "1.0"))
# 한국 ETF 설정
KR_ETFS = {
"251340.KS": 'KODEX 코스닥150선물인버스',
"233740.KS": 'KODEX 코스닥150 레버리지',
"252670.KS": 'KODEX 200선물인버스2X',
"122630.KS": 'KODEX 레버리지',
"114800.KS": 'KODEX 인버스',
"283580.KS": 'KODEX 중국본토CSI300',
"256750.KS": 'KODEX 심천ChiNext(합성)',
"185680.KS": 'KODEX 미국S&P바이오(합성)',
"218420.KS": 'KODEX 미국S&P에너지(합성)',
"132030.KS": 'KODEX 골드선물(H)',
"138920.KS": 'KODEX 콩선물(H)',
"271060.KS": 'KODEX 3대농산물선물(H)',
"117700.KS": 'KODEX 건설',
"266420.KS": 'KODEX 헬스케어',
"276990.KS": 'KODEX 글로벌4차산업로보틱스(합성)',
"244580.KS": 'KODEX 바이오',
"091160.KS": 'KODEX 반도체',
"140700.KS": 'KODEX 보험',
"266410.KS": 'KODEX 필수소비재',
"305720.KS": 'KODEX 2차전지산업',
"266390.KS": 'KODEX 경기소비재',
"117680.KS": 'KODEX 철강',
"117460.KS": 'KODEX 에너지화학',
"091170.KS": 'KODEX 은행',
"376410.KS": 'TIGER 탄소효율그린뉴딜',
"005930.KS": "삼성전자 / 반도체,AI",
"000660.KS": "SK하이닉스 / 반도체,AI",
"035420.KS": "NAVER / 플랫폼,AI",
"035720.KS": "카카오 / 플랫폼,AI,핀테크",
"051910.KS": "LG화학 / 2차전지,소재",
"373220.KS": "LG에너지솔루션 / 2차전지",
"096770.KS": "SK이노베이션 / 2차전지,친환경",
"066570.KS": "LG전자 / 전장,AI,가전",
"003550.KS": "LG / 지주,전지,AI",
"005380.KS": "현대차 / 전기차,수소차",
"000270.KS": "기아 / 전기차,수소차",
"086520.KS": "에코프로 / 2차전지 소재",
"336370.KS": "솔루스첨단소재 / 2차전지,소재",
"009150.KS": "삼성전기 / 전장,MLCC",
"006400.KS": "삼성SDI / 2차전지",
"011170.KS": "롯데케미칼 / 2차전지,소재",
"010950.KS": "S-Oil / 친환경,정유",
"034730.KS": "SK / 지주,AI,친환경",
"028260.KS": "삼성물산 / 바이오,건설",
"207940.KS": "삼성바이오로직스 / 바이오,CMO",
"068270.KS": "셀트리온 / 바이오,항체치료제",
"196170.KS": "알테오젠 / 바이오,바이오시밀러",
"051900.KS": "LG생활건강 / 소비재,중국",
"003490.KS": "대한항공 / 항공,물류",
"005935.KS": "삼성전자우 / 반도체",
"000810.KS": "삼성화재 / 보험,금융",
"105560.KS": "KB금융 / 금융,디지털전환",
"055550.KS": "신한지주 / 금융,디지털전환",
"316140.KS": "우리금융지주 / 금융",
"086790.KS": "하나금융지주 / 금융",
"032830.KS": "삼성생명 / 보험",
"003670.KS": "포스코홀딩스 / 2차전지,철강,수소",
"036570.KS": "엔씨소프트 / 게임,AI",
"011200.KS": "HMM / 해운,물류",
"005940.KS": "NH투자증권 / 금융",
"010130.KS": "고려아연 / 비철금속,2차전지",
"001510.KS": "SK증권 / 금융",
"017670.KS": "SK텔레콤 / 5G,AI",
"030200.KS": "KT / 5G,AI",
"033780.KS": "KT&G / 소비재,담배",
"034020.KS": "두산에너빌리티 / 원전,친환경",
}
# --- 추세 / 레짐 ---
TREND_RANGE_MA_GAP_PCT = 0.5
# --- 주문 ---
DEFAULT_BUY_KRW = int(os.getenv("DEFAULT_BUY_KRW", "30000"))
RANGE_BUY_KRW = int(os.getenv("RANGE_BUY_KRW", "15000"))
# --- 수수료 (매수·매도 각각 적용, 시뮬레이션) ---
TRADING_FEE_RATE = float(os.getenv("TRADING_FEE_RATE", "0.0005"))
# --- coins.db (downloader.py 적재 간격, 분) ---
# 빗썸 분봉 API: 1,3,5,10,15,30,60,240 / 일봉 1440
DOWNLOAD_INTERVALS: tuple[int, ...] = (3, 10, 15, 30, 60, 240, 1440)
DOWNLOAD_MONTHS = int(os.getenv("DOWNLOAD_MONTHS", "6"))
DB_PATH = "coins.db"
# --- 시뮬레이션 ---
SIM_INITIAL_CASH_KRW = int(os.getenv("SIM_INITIAL_CASH_KRW", "200000"))
SIM_MIN_ORDER_KRW = int(os.getenv("SIM_MIN_ORDER_KRW", "5000"))
# --- 실행 ---
MONITOR_LOOP_SLEEP_SEC = 10
COOLDOWN_FILE = "coins_buy_time.json"

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
from datetime import datetime
from config import *
from HTS2 import HTS
from monitor_coin import MonitorCoin
import pandas as pd
from dateutil.relativedelta import relativedelta
monitorCoin = MonitorCoin()
hts = HTS()
from config import (
COIN_NAME,
DB_PATH,
DOWNLOAD_INTERVALS,
DOWNLOAD_MONTHS,
KR_COINS,
SYMBOL,
)
from monitor import Monitor
BITHUMB_MINUTE_INTERVALS = {1, 3, 5, 10, 15, 30, 60, 240}
# 증분 시 마지막 봉 재확인용 겹침 봉 수
INCREMENTAL_OVERLAP_BARS = 3
def inserData(symbol, interval, data):
conn = sqlite3.connect('coins.db')
def bong_count_for_months(interval_minutes: int, months: int) -> int:
"""N개월치 봉 개수(여유분 포함)."""
days = months * 30
if interval_minutes >= 1440:
return days + 20
bars_per_day = (24 * 60) // interval_minutes
return days * bars_per_day + 200
def bong_count_since(
interval_minutes: int, last_ts: pd.Timestamp, overlap: int = INCREMENTAL_OVERLAP_BARS
) -> int:
"""마지막 저장 시각 이후 필요한 API 봉 수(겹침 포함)."""
now = pd.Timestamp.now()
if last_ts.tzinfo is not None and now.tzinfo is None:
last_ts = last_ts.tz_localize(None)
delta_min = max(0, (now - last_ts).total_seconds() / 60)
bars = int(delta_min / interval_minutes) + overlap + 10
return max(bars, 50)
def months_cutoff(months: int) -> pd.Timestamp:
"""N개월 전 시각."""
return pd.Timestamp(datetime.now() - relativedelta(months=months))
def trim_to_recent_months(data: pd.DataFrame, months: int) -> pd.DataFrame:
"""최근 N개월 구간만 남깁니다."""
if data is None or data.empty:
return data
cutoff = months_cutoff(months)
if not isinstance(data.index, pd.DatetimeIndex):
data = data.copy()
data.index = pd.to_datetime(data.index)
return data[data.index >= cutoff].copy()
def interval_label(interval: int) -> str:
if interval >= 1440:
return "일봉(1440)"
return f"{interval}분봉"
def download_jobs() -> list[tuple[int, str]]:
labels = {
3: "3분",
10: "10분",
15: "15분",
30: "30분",
60: "60분(1시간)",
240: "240분(4시간)",
1440: "1440분(1일)",
}
jobs = []
for iv in DOWNLOAD_INTERVALS:
if iv < 1440 and iv not in BITHUMB_MINUTE_INTERVALS:
print(f"경고: {iv}분봉은 빗썸 API 미지원 — 건너뜀")
continue
jobs.append((iv, labels.get(iv, f"{iv}")))
return jobs
def ensure_table(cursor, table_name: str) -> None:
cursor.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} "
"(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
"Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
)
cursor.execute(
f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
)
def get_last_timestamp(
symbol: str, interval: int, db_path: str = DB_PATH
) -> pd.Timestamp | None:
"""테이블에 저장된 해당 심볼의 마지막 봉 시각."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"SELECT MAX(ymdhms) FROM {table_name} WHERE CODE = ?",
(symbol,),
)
row = cursor.fetchone()
conn.close()
if row and row[0]:
return pd.Timestamp(row[0])
return None
tableName = "{}_{}".format(symbol, str(interval))
# 테이블/키 생성
cursor.execute("CREATE TABLE IF NOT EXISTS {} (CODE text, NAME text, ymdhms datetime, ymd text, hms text, Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)".format(tableName))
cursor.execute("CREATE INDEX IF NOT EXISTS {}_idx on {}(CODE, ymdhms)".format(tableName, tableName))
for i in range(len(data)):
ymd = data.index[i].strftime('%Y%m%d')
hms = data.index[i].strftime('%H%M%S')
ymdhms = data.index[i].strftime('%Y-%m-%d %H:%M:%S')
Open = data.Open.iloc[i]
High = data.High.iloc[i]
Low = data.Low.iloc[i]
Close = data.Close.iloc[i]
Volume = data.Volume.iloc[i]
def get_row_count(symbol: str, interval: int, db_path: str = DB_PATH) -> int:
"""저장된 봉 개수."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"SELECT COUNT(*) FROM {table_name} WHERE CODE = ?",
(symbol,),
)
row = cursor.fetchone()
conn.close()
return int(row[0]) if row else 0
cursor.execute("SELECT * from {} where CODE = ? and ymdhms = ?".format(tableName), (symbol, ymdhms, ))
arr = cursor.fetchone()
if arr:
cursor.execute("UPDATE {} SET Close=?, Open=?, High=?, Low=?, Volume=? where CODE=? and ymdhms=?".format(tableName), (Close, Open, High, Low, Volume, symbol, ymdhms))
else:
cursor.execute("INSERT INTO {} (CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".format(tableName), (symbol, KR_COINS[symbol], ymdhms, ymd, hms, Close, Open, High, Low, Volume))
def filter_after_last(
data: pd.DataFrame, last_ts: pd.Timestamp | None
) -> pd.DataFrame:
"""마지막 저장 시각보다 이후(>)인 봉만 반환."""
if data is None or data.empty or last_ts is None:
return data
if not isinstance(data.index, pd.DatetimeIndex):
data = data.copy()
data.index = pd.to_datetime(data.index)
last = pd.Timestamp(last_ts)
return data[data.index > last].copy()
def prune_before_cutoff(
symbol: str, interval: int, months: int, db_path: str = DB_PATH
) -> int:
"""N개월보다 오래된 봉 삭제 (DB 용량 유지)."""
cutoff = months_cutoff(months).strftime("%Y-%m-%d %H:%M:%S")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"DELETE FROM {table_name} WHERE CODE = ? AND ymdhms < ?",
(symbol, cutoff),
)
deleted = cursor.rowcount
conn.commit()
cursor.close()
conn.close()
return
return deleted
def download():
for symbol in KR_COINS:
print(symbol)
# 1일
interval = 1440
data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=5000)
if data is not None and not data.empty:
try:
inserData(symbol, interval, data)
except Exception as e:
print(f"Error processing data for {symbol}: {str(e)}")
def append_data(
symbol: str,
interval: int,
data: pd.DataFrame,
last_ts: pd.Timestamp | None = None,
db_path: str = DB_PATH,
) -> tuple[int, int]:
"""
마지막 시각 이후 봉만 INSERT합니다. 기존 데이터는 삭제하지 않습니다.
# 1시간
interval = 60
data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=10000)
if data is not None and not data.empty:
try:
inserData(symbol, interval, data)
except Exception as e:
print(f"Error processing data for {symbol}: {str(e)}")
Args:
last_ts: None이면 전체 data 적재, 있으면 index > last_ts 만 적재
# 5분
interval = 5
data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=10000)
if data is not None and not data.empty:
try:
inserData(symbol, interval, data)
except Exception as e:
print(f"Error processing data for {symbol}: {str(e)}")
Returns:
(추가된 행 수, 스킵된 행 수)
"""
if data is None or data.empty:
return 0, 0
total = len(data)
to_save = data if last_ts is None else filter_after_last(data, last_ts)
skipped = total - len(to_save)
if to_save.empty:
return 0, skipped
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
records = []
for i in range(len(to_save)):
ts = to_save.index[i]
if hasattr(ts, "to_pydatetime"):
ts = ts.to_pydatetime()
ymd = ts.strftime("%Y%m%d")
hms = ts.strftime("%H%M%S")
ymdhms = ts.strftime("%Y-%m-%d %H:%M:%S")
records.append(
(
symbol,
KR_COINS[symbol],
ymdhms,
ymd,
hms,
float(to_save["Open"].iloc[i]),
float(to_save["High"].iloc[i]),
float(to_save["Low"].iloc[i]),
float(to_save["Close"].iloc[i]),
float(to_save["Volume"].iloc[i]),
)
)
cursor.executemany(
f"INSERT INTO {table_name} "
"(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
records,
)
conn.commit()
cursor.close()
conn.close()
return len(records), skipped
def download_symbol(
monitor: Monitor,
symbol: str,
interval: int,
months: int,
) -> None:
"""한 간격의 봉을 API로 받아 증분 저장합니다."""
label = interval_label(interval)
last_ts = get_last_timestamp(symbol, interval)
existing = get_row_count(symbol, interval)
if last_ts is None:
target = bong_count_for_months(interval, months)
mode = "초기 적재"
else:
target = min(
bong_count_since(interval, last_ts),
bong_count_for_months(interval, months),
)
mode = f"증분 (마지막 {last_ts.strftime('%Y-%m-%d %H:%M:%S')} 이후)"
print(f"\n[{symbol}] {label}{mode}")
print(f" DB 기존 {existing}행 | API 목표 약 {target}")
data = monitor.get_coin_more_data(
symbol, interval, bong_count=target, verbose=True
)
if data is None or data.empty:
print(" -> API 데이터 없음")
return
data = trim_to_recent_months(data, months)
if data.empty:
print(" -> 최근 N개월 필터 후 데이터 없음")
return
inserted, skipped = append_data(symbol, interval, data, last_ts=last_ts)
pruned = prune_before_cutoff(symbol, interval, months)
new_last = get_last_timestamp(symbol, interval)
total = get_row_count(symbol, interval)
print(f" -> API {len(data)}봉 | 추가 {inserted}행 | 스킵(기존) {skipped}")
if pruned > 0:
print(f" -> {months}개월 이전 {pruned}행 정리")
if new_last is not None:
print(f" -> DB 합계 {total}행 | {data.index[0]} ~ {new_last}")
def download(months: int | None = None) -> None:
"""
WLD 다중 분봉·일봉을 coins.db에 증분 적재합니다.
간격: config.DOWNLOAD_INTERVALS
"""
months = months or DOWNLOAD_MONTHS
monitor = Monitor(cooldown_file=None)
jobs = download_jobs()
intervals_str = ", ".join(str(iv) for iv, _ in jobs)
print(f"=== {COIN_NAME} ({SYMBOL}) -> {DB_PATH} (증분 INSERT) ===")
print(f"보관 {months}개월 | 간격(분): {intervals_str}")
started = datetime.now()
for interval, desc in jobs:
print(f"\n--- {desc} ---")
try:
download_symbol(monitor, SYMBOL, interval, months)
except Exception as e:
print(f"오류 interval={interval}: {e}")
elapsed = datetime.now() - started
print(f"\n완료 (소요: {elapsed})")
return
if __name__ == "__main__":
download()

View File

@@ -1,34 +1,40 @@
import pandas as pd
from HTS2 import HTS
from dateutil.relativedelta import relativedelta
from datetime import datetime, timedelta
from datetime import datetime
import sqlite3
import telegram
import time
try:
import telegram
except ImportError:
telegram = None # type: ignore
import requests
import json
import asyncio
from multiprocessing import Pool
import FinanceDataReader as fdr
import numpy as np
import os
from config import *
from HTS2 import HTS
import strategy
class Monitor(HTS):
"""자산(코인/주식/ETF) 모니터링 및 매 실행 클래스"""
"""WLD 코인 모니터링 및 매 실행."""
last_signal = None
cooldown_file = None
def __init__(self, cooldown_file='coins_buy_time.json') -> None:
self.hts = HTS()
HTS.__init__(self)
# 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장)
self.last_signal: dict[str, str] = {}
if cooldown_file is not None:
self.cooldown_file = cooldown_file
self.buy_cooldown = self._load_buy_cooldown()
else:
self.cooldown_file = None
self.buy_cooldown = {}
# ------------- Persistence -------------
def _load_buy_cooldown(self) -> dict:
@@ -106,13 +112,12 @@ class Monitor(HTS):
# ------------- Telegram -------------
def _send_coin_msg(self, text: str) -> None:
if telegram is None:
print(f"[telegram skip] {text}")
return
coin_client = telegram.Bot(token=COIN_TELEGRAM_BOT_TOKEN)
asyncio.run(coin_client.send_message(chat_id=COIN_TELEGRAM_CHAT_ID, text=text))
def _send_stock_msg(self, text: str) -> None:
stock_client = telegram.Bot(token=STOCK_TELEGRAM_BOT_TOKEN)
asyncio.run(stock_client.send_message(chat_id=STOCK_TELEGRAM_CHAT_ID, text=text))
def sendMsg(self, msg):
try:
pool = Pool(12)
@@ -133,18 +138,6 @@ class Monitor(HTS):
pool = Pool(12)
pool.map(self._send_coin_msg, [payload])
def send_stock_telegram_message(self, message_list: list[str], header: str) -> None:
payload = header + "\n"
for i, message in enumerate(message_list):
payload += message + "\n"
if i + 1 % 20 == 0:
pool = Pool(12)
pool.map(self._send_stock_msg, [payload])
payload = ''
if len(message_list) % 20 != 0:
pool = Pool(12)
pool.map(self._send_stock_msg, [payload])
# ------------- Indicators -------------
def normalize_data(self, data: pd.DataFrame) -> pd.DataFrame:
columns_to_normalize = ['Open', 'High', 'Low', 'Close', 'Volume']
@@ -224,236 +217,169 @@ class Monitor(HTS):
return data
# ------------- Strategy -------------
def buy_sell_ticker_1h(self, symbol: str, data: pd.DataFrame, balances=None, is_inverse: bool = False) -> bool:
# ------------- Strategy (strategy.py에 구현) -------------
def annotate_signals(self, symbol: str, data: pd.DataFrame, simulation: bool | None = None) -> pd.DataFrame:
"""strategy.annotate_signals에 위임."""
return strategy.annotate_signals(
symbol, data, simulation=simulation, config=strategy.ACTIVE_CONFIG
)
def _is_in_cooldown(self, symbol: str, side: str) -> bool:
"""매수/매도 쿨다운 여부."""
if self.cooldown_file is None:
return False
last_dt = self.buy_cooldown.get(symbol, {}).get(side, {}).get("datetime")
if not last_dt:
return False
limit = BUY_COOLDOWN_SEC if side == "buy" else SELL_COOLDOWN_SEC
elapsed = (datetime.now() - last_dt).total_seconds()
if elapsed < limit:
print(f"{symbol}: {side} 쿨다운 중 (남은 시간: {limit - elapsed:.0f}초)")
return True
return False
def _record_trade(self, symbol: str, side: str, signal: str) -> None:
"""매매 기록 저장."""
if self.cooldown_file is None:
return
current_time = datetime.now()
self.last_signal[symbol] = signal
self.buy_cooldown.setdefault(symbol, {})[side] = {
"datetime": current_time,
"signal": signal,
}
self._save_buy_cooldown()
def execute_trade_signal(
self,
symbol: str,
trade: strategy.TradeSignal,
balances: dict | None = None,
) -> bool:
"""TradeSignal 1건에 대해 현물 매수 또는 매도를 실행합니다."""
try:
# 신호 생성 및 최신 포인트 확인
data = self.annotate_signals(symbol, data)
if data['point'].iloc[-1] != 1:
return False
coin_name = KR_COINS.get(symbol, symbol)
signal_name = trade.signal
close = trade.close
if is_inverse:
# BUY_MINUTE_LIMIT 이내라면 매수하지 않음
current_time = datetime.now()
last_buy_dt = self.buy_cooldown.get(symbol, {}).get('sell', {}).get('datetime')
if last_buy_dt:
time_diff = current_time - last_buy_dt
if time_diff.total_seconds() < BUY_MINUTE_LIMIT:
print(f"{symbol}: 매수 금지 중 (남은 시간: {BUY_MINUTE_LIMIT - time_diff.total_seconds():.0f}초)")
return False
# 인버스 데이터: 매수 신호를 매도로 처리 (fall_6p, deviation40 만 허용)
# 허용된 인버스 매도 신호만 처리
last_signal = str(data['signal'].iloc[-1]) if 'signal' in data.columns else ''
if last_signal not in ['fall_6p', 'deviation40']:
if trade.action == "sell":
if self._is_in_cooldown(symbol, "sell"):
return False
available_balance = 0
try:
if balances and symbol in balances:
available_balance = float(balances[symbol].get('balance', 0))
except Exception:
available_balance = 0
if available_balance <= 0:
available = 0.0
if balances and symbol in balances:
available = float(balances[symbol].get("balance", 0))
if available <= 0:
print(f"{symbol}: 매도 신호({signal_name}) — 보유 없음, 스킵")
return False
sell_amount = available_balance * 0.7
_ = self.hts.sellCoinMarket(symbol, 0, sell_amount)
if self.cooldown_file is not None:
try:
self.last_signal[symbol] = str(data['signal'].iloc[-1])
except Exception:
self.last_signal[symbol] = ''
self.buy_cooldown.setdefault(symbol, {})['sell'] = {'datetime': current_time, 'signal': str(data['signal'].iloc[-1])}
self._save_buy_cooldown()
print(f"{KR_COINS[symbol]} ({symbol}) [{data['signal'].iloc[-1]} 매도], 현재가: {data['Close'].iloc[-1]:.4f}")
self.sendMsg("[KRW-COIN]\n" + f"• 매도 [COIN] {KR_COINS[symbol]} ({symbol}): {data['signal'].iloc[-1]} ({''}{data['Close'].iloc[-1]:.4f})")
sell_amount = available * strategy.get_sell_ratio(symbol, signal_name)
if sell_amount <= 0:
return False
self.sellCoinMarket(symbol, 0, sell_amount)
self._record_trade(symbol, "sell", signal_name)
print(f"{coin_name} ({symbol}) [매도 {signal_name}] ₩{close:.4f}, 수량 {sell_amount:.6f}")
self.sendMsg(
f"[KRW-COIN]\n• 매도 {coin_name} ({symbol}): {signal_name}{close:.4f}"
)
return True
else:
check_5_week_lowest = False
# BUY_MINUTE_LIMIT 이내라면 매수하지 않음
current_time = datetime.now()
last_buy_dt = self.buy_cooldown.get(symbol, {}).get('buy', {}).get('datetime')
if last_buy_dt:
time_diff = current_time - last_buy_dt
if time_diff.total_seconds() < BUY_MINUTE_LIMIT:
print(f"{symbol}: 매수 금지 중 (남은 시간: {BUY_MINUTE_LIMIT - time_diff.total_seconds():.0f}초)")
return False
try:
# 5주봉이 20주봉이나 40주봉보다 아래에 있는지 체크
# Convert hourly data to week-based rolling periods (5, 20, 40 weeks)
hours_in_week = 24 * 7 # 168 hours
period_5w = 5 * hours_in_week # 840 hours
period_20w = 20 * hours_in_week # 3,360 hours
period_40w = 40 * hours_in_week # 6,720 hours
if len(data) >= period_40w:
wma5 = data['Close'].rolling(window=period_5w).mean().iloc[-1]
wma20 = data['Close'].rolling(window=period_20w).mean().iloc[-1]
wma40 = data['Close'].rolling(window=period_40w).mean().iloc[-1]
# 5-week MA is the lowest among 5, 20, 40 week MAs
if (wma5 < wma20) and (wma5 < wma40):
check_5_week_lowest = True
except Exception:
# Ignore errors in MA calculation so as not to block trading logic
pass
# 체크: fall_6p
buy_amount = 5100
current_time = datetime.now()
if data['signal'].iloc[-1] == 'fall_6p':
if data['Close'].iloc[-1] > 100:
buy_amount = 300000
else:
buy_amount = 150000
elif data['signal'].iloc[-1] == 'movingaverage':
buy_amount = 10000
elif data['signal'].iloc[-1] == 'deviation40':
buy_amount = 30000
elif data['signal'].iloc[-1] == 'deviation240':
buy_amount = 7000
elif data['signal'].iloc[-1] == 'deviation1440':
if symbol in ['BONK', 'PEPE', 'TON']:
buy_amount = 50000
else:
buy_amount = 70000
if data['signal'].iloc[-1] in ['movingaverage', 'deviation40', 'deviation240', 'deviation1440']:
if check_5_week_lowest:
buy_amount *= 2
# 매수를 진행함
buy_amount = self.hts.buyCoinMarket(symbol, buy_amount)
# 최근 매수 신호를 함께 기록하여 [신규] 포맷으로 저장
if self.cooldown_file is not None:
try:
self.last_signal[symbol] = str(data['signal'].iloc[-1])
except Exception:
self.last_signal[symbol] = ''
self.buy_cooldown.setdefault(symbol, {})['buy'] = {'datetime': current_time, 'signal': str(data['signal'].iloc[-1])}
# 매수를 저장함
self._save_buy_cooldown()
print(f"{KR_COINS[symbol]} ({symbol}) [{data['signal'].iloc[-1]}], 현재가: {data['Close'].iloc[-1]:.4f}, {int(BUY_MINUTE_LIMIT/60)}분간 매수 금지 시작")
self.sendMsg("{}".format(self.format_message(symbol, KR_COINS[symbol], data['Close'].iloc[-1], data['signal'].iloc[-1], buy_amount)))
if self._is_in_cooldown(symbol, "buy"):
return False
buy_amount = strategy.get_buy_amount(
symbol, signal_name, close, trend=trade.trend
)
if strategy.should_double_buy(symbol, signal_name, pd.DataFrame()):
buy_amount *= 2
executed = self.buyCoinMarket(symbol, buy_amount)
self._record_trade(symbol, "buy", signal_name)
print(
f"{coin_name} ({symbol}) [매수 {signal_name}] ₩{close:.4f} "
f"({buy_amount} KRW, 추세={trade.trend})"
)
self.sendMsg(
self.format_message(
symbol, coin_name, close, signal_name, executed or buy_amount
)
)
return True
except Exception as e:
print(f"Error buying {symbol}: {str(e)}")
print(f"Error trading {symbol}: {str(e)}")
return False
return True
def annotate_signals(self, symbol: str, data: pd.DataFrame, simulation: bool | None = None) -> pd.DataFrame:
data = data.copy()
data['signal'] = ''
data['point'] = 0
if data['point'].iloc[-1] != 1:
for i in range(1, len(data)):
if all(data[f'MA{n}'].iloc[i] < data['MA720'].iloc[i] for n in [5, 20, 40, 120, 200, 240]) and \
all(data[f'MA{n}'].iloc[i] > data[f'MA{n}'].iloc[i - 1] for n in [5, 20, 40, 120, 200, 240]) and \
data['MA720'].iloc[i] < data['MA1440'].iloc[i]:
data.at[data.index[i], 'signal'] = 'movingaverage'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'movingaverage'
data.at[data.index[-1], 'point'] = 1
def process_wld_mtf(self, symbol: str, balances: dict | None = None) -> None:
"""
WLD MTF: 모든 봉 BB 상태 비교 후 정책에 따라 매수/매도.
if data['Deviation40'].iloc[i - 1] < data['Deviation40'].iloc[i] and data['Deviation40'].iloc[i - 1] <= 90:
data.at[data.index[i], 'signal'] = 'deviation40'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'deviation40'
data.at[data.index[-1], 'point'] = 1
mtf_bb_policy.json 이 있으면 해당 정책, 없으면 ACTIVE_MTF_POLICY 사용.
"""
from mtf_bb import load_frames_from_db, load_policy, print_latest_states
if symbol not in ['BONK']:
if symbol in ['TRX']:
if data['Deviation240'].iloc[i - 1] < data['Deviation240'].iloc[i] and data['Deviation240'].iloc[i - 1] <= 98:
data.at[data.index[i], 'signal'] = 'deviation240'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'deviation240'
data.at[data.index[-1], 'point'] = 1
else:
if data['Deviation240'].iloc[i - 1] < data['Deviation240'].iloc[i] and data['Deviation240'].iloc[i - 1] <= 90:
data.at[data.index[i], 'signal'] = 'deviation240'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'deviation240'
data.at[data.index[-1], 'point'] = 1
try:
frames = load_frames_from_db(self, symbol)
if not frames:
print(f"Data for {symbol}: 로드된 봉 없음.")
return
if symbol in ['TON']:
if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 89:
data.at[data.index[i], 'signal'] = 'deviation1440'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'deviation1440'
data.at[data.index[-1], 'point'] = 1
elif symbol in ['XRP']:
if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 90:
data.at[data.index[i], 'signal'] = 'deviation1440'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'deviation1440'
data.at[data.index[-1], 'point'] = 1
elif symbol in ['BONK']:
if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 76:
data.at[data.index[i], 'signal'] = 'deviation1440'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'deviation1440'
data.at[data.index[-1], 'point'] = 1
else:
if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 80:
data.at[data.index[i], 'signal'] = 'deviation1440'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'deviation1440'
data.at[data.index[-1], 'point'] = 1
df_1d = frames.get(TREND_INTERVAL_1D)
df_1h = frames.get(TREND_INTERVAL_1H)
if df_1d is None or df_1d.empty:
df_1d = frames.get(ENTRY_INTERVAL)
if df_1h is None or df_1h.empty:
df_1h = frames.get(ENTRY_INTERVAL)
# Deviation720 상향 돌파 매수 (92, 93)
try:
prev_d720 = data['Deviation720'].iloc[i - 1]
curr_d720 = data['Deviation720'].iloc[i]
# 92 상향 돌파
if prev_d720 < 92 and curr_d720 >= 92:
data.at[data.index[i], 'signal'] = 'Deviation720'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'Deviation720'
data.at[data.index[-1], 'point'] = 1
# 93 상향 돌파
if prev_d720 < 93 and curr_d720 >= 93:
data.at[data.index[i], 'signal'] = 'Deviation720'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'Deviation720'
data.at[data.index[-1], 'point'] = 1
except Exception:
pass
policy = load_policy() or strategy.ACTIVE_MTF_POLICY
cfg = strategy.ACTIVE_CONFIG
print_latest_states(frames, cfg)
print(
f"MTF 정책: {policy.name} | "
f"매수={policy.buy_interval}분 | 매도={policy.sell_interval}분 | "
f"확인={list(policy.buy_confirm_intervals)}"
)
try:
prev_low = data['Low'].iloc[i - 1]
curr_close = data['Close'].iloc[i]
curr_low = data['Low'].iloc[i]
cond_close_drop = curr_close <= prev_low * 0.94
cond_low_drop = curr_low <= prev_low * 0.94
if cond_close_drop or cond_low_drop:
data.at[data.index[i], 'signal'] = 'fall_6p'
data.at[data.index[i], 'point'] = 1
if not simulation and data['point'][-3:].sum() > 0:
data.at[data.index[-1], 'signal'] = 'fall_6p'
data.at[data.index[-1], 'point'] = 1
except Exception:
pass
return data
trend = strategy.get_trend(df_1d, df_1h)
print(f"{symbol} 추세: {trend}")
entry = frames.get(ENTRY_INTERVAL)
trade = strategy.evaluate(
symbol,
entry if entry is not None else frames[policy.buy_interval],
df_1h,
df_1d,
config=cfg,
frames=frames,
policy=policy,
)
if trade is None:
return
self.execute_trade_signal(symbol, trade, balances=balances)
except Exception as e:
print(f"Error processing {symbol}: {str(e)}")
def process_symbol(
self,
symbol: str,
interval: int | None = None,
balances: dict | None = None,
use_inverse: bool = False,
) -> None:
"""하위 호환: MTF 전략으로 위임 (use_inverse 무시)."""
self.process_wld_mtf(symbol, balances=balances)
def load_balances_dict(self) -> dict:
"""getBalances() 결과를 currency 키 dict로 변환."""
tmps = self.getBalances()
balances = {}
for tmp in tmps:
balances[tmp["currency"]] = {
"balance": float(tmp["balance"]),
"avg_buy_price": float(tmp["avg_buy_price"]),
}
return balances
# ------------- Formatting -------------
def format_message(self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float) -> str:
message = f"[매수] {symbol_name} ({symbol}): "
def format_message(
self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float
) -> str:
message = f"[매수] {symbol_name} ({symbol}) [{signal}]: "
if int(close) >= 100:
message += f"{close}"
@@ -472,12 +398,6 @@ class Monitor(HTS):
message += f"[{signal}]"
return message
def format_ma_message(self, info: dict, market_type: str) -> str:
prefix = '상승 ' if info.get('alert') else ''
message = prefix + f"[{market_type}] {info['name']} ({info['symbol']}) "
message += f"{'$' if market_type == 'US' else ''}({info['price']:.4f}) \n"
return message
# ------------- Data fetch -------------
def get_coin_data(self, symbol: str, interval: int = 60, to: str | None = None, retries: int = 3) -> pd.DataFrame | None:
for attempt in range(retries):
@@ -520,96 +440,146 @@ class Monitor(HTS):
continue
return None
def get_coin_more_data(self, symbol: str, interval: int, bong_count: int = 3000) -> pd.DataFrame:
def get_coin_more_data(
self,
symbol: str,
interval: int,
bong_count: int = 3000,
verbose: bool = False,
) -> pd.DataFrame:
"""
빗썸 API를 반복 호출해 bong_count개까지 과거 봉을 수집합니다.
Args:
verbose: True면 수집 진행 상황을 출력합니다.
"""
to = datetime.now()
data: pd.DataFrame | None = None
step = 0
while data is None or len(data) < bong_count:
step += 1
if data is None:
data = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
chunk = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
data = chunk
else:
previous_count = len(data)
df = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
data = pd.concat([data, df], ignore_index=True)
if previous_count == len(data):
if df is not None and not df.empty:
data = pd.concat([data, df], ignore_index=True)
if df is None or df.empty or previous_count == len(data):
if verbose:
print(f" API 추가 데이터 없음 (수집 {len(data)}봉)")
break
if verbose and (step == 1 or step % 5 == 0 or len(data) >= bong_count):
label = "일봉" if interval >= 1440 else f"{interval}"
print(f" [{label}] 요청 {step}회 — 누적 {len(data)}/{bong_count}")
time.sleep(0.3)
to = to - relativedelta(minutes=interval * 200)
data = data.set_index('datetime')
if data is None or data.empty:
return pd.DataFrame()
data = data.set_index("datetime")
data = data.sort_index()
data = data.drop_duplicates(keep='first')
data = data.drop_duplicates(keep="first")
data["datetime"] = data.index
return data
def get_coin_saved_data(self, symbol: str, interval: int, data: pd.DataFrame) -> pd.DataFrame:
conn = sqlite3.connect('coins.db')
def get_coin_saved_data(
self, symbol: str, interval: int, data: pd.DataFrame, db_path: str = "coins.db"
) -> pd.DataFrame:
"""
coins.db에서 저장된 봉을 읽고, API로 받은 최신 봉을 DB에 반영합니다.
downloader.py로 미리 적재해 두면 장기 MA 계산에 유리합니다.
"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
cursor.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} "
"(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
"Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
)
cursor.execute(
f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
)
for i in range(1, len(data)):
cursor.execute("SELECT * from {}_{} where CODE = ? and ymdhms = ?".format(symbol, str(interval)), (symbol, data['datetime'].iloc[-i].strftime('%Y-%m-%d %H:%M:%S')),)
arr = cursor.fetchone()
if not arr:
ymdhms = data["datetime"].iloc[-i].strftime("%Y-%m-%d %H:%M:%S")
cursor.execute(
f"SELECT 1 FROM {table_name} WHERE CODE = ? AND ymdhms = ?",
(symbol, ymdhms),
)
if not cursor.fetchone():
cursor.execute(
"INSERT INTO {}_{} (CODE, NAME, ymdhms, ymd, hms, close, open, high, low, volume) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".format(symbol, interval),
f"INSERT INTO {table_name} "
"(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
symbol,
KR_COINS[symbol],
data['datetime'].iloc[-i].strftime('%Y-%m-%d %H:%M:%S'),
data['datetime'].iloc[-i].strftime('%Y%m%d'),
data['datetime'].iloc[-i].strftime('%H%M%S'),
data['Close'].iloc[-i],
data['Open'].iloc[-i],
data['High'].iloc[-i],
data['Low'].iloc[-i],
data['Volume'].iloc[-i],
ymdhms,
data["datetime"].iloc[-i].strftime("%Y%m%d"),
data["datetime"].iloc[-i].strftime("%H%M%S"),
data["Close"].iloc[-i],
data["Open"].iloc[-i],
data["High"].iloc[-i],
data["Low"].iloc[-i],
data["Volume"].iloc[-i],
),
)
else:
break
cursor.execute("select * from (SELECT Open,Close,High,Low,Volume,ymdhms as datetime from {}_{} order by ymdhms desc limit 7000) subquery order by datetime".format(symbol, str(interval)))
cursor.execute(
f"SELECT Open, Close, High, Low, Volume, ymdhms AS datetime "
f"FROM (SELECT Open, Close, High, Low, Volume, ymdhms "
f"FROM {table_name} ORDER BY ymdhms DESC LIMIT 7000) "
f"ORDER BY datetime"
)
result = cursor.fetchall()
conn.commit()
cursor.close()
conn.close()
df = pd.DataFrame(result)
df.columns = ['Open', 'Close', 'High', 'Low', 'Volume', 'datetime']
df = df.set_index('datetime')
if not result:
return pd.DataFrame(
columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
)
df = pd.DataFrame(
result, columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
)
df = df.set_index("datetime")
df = df.sort_index()
df['datetime'] = df.index
df["datetime"] = df.index
return df
def get_coin_some_data(self, symbol: str, interval: int) -> pd.DataFrame:
"""
WLD 시세: API 최신 봉 + coins.db 과거 봉 + 1분봉 최신 1개를 합칩니다.
DB가 비어 있으면 API·1분봉만 사용합니다. 과거 적재는 downloader.py 실행.
"""
data = self.get_coin_data(symbol, interval)
if data is None or data.empty:
return pd.DataFrame()
data_1 = self.get_coin_data(symbol, interval=1)
data_1.at[data_1.index[-1], 'Volume'] = data_1['Volume'].iloc[-1] * 60
if data_1 is not None and not data_1.empty:
data_1 = data_1.copy()
data_1.at[data_1.index[-1], "Volume"] = data_1["Volume"].iloc[-1] * 60
saved_data = self.get_coin_saved_data(symbol, interval, data)
data = pd.concat([data, saved_data, data_1.iloc[[-1]]], ignore_index=True)
data['datetime'] = pd.to_datetime(data['datetime'], format='%Y-%m-%d %H:%M:%S')
data = data.set_index('datetime')
data = data.sort_index()
data = data.drop_duplicates(keep='first')
data["datetime"] = data.index
return data
parts = [data]
if saved_data is not None and not saved_data.empty:
parts.append(saved_data)
if data_1 is not None and not data_1.empty:
parts.append(data_1.iloc[[-1]])
def get_kr_stock_data(self, symbol: str, retries: int = 3) -> pd.DataFrame | None:
for attempt in range(retries):
try:
end = datetime.now()
start = end - timedelta(days=300)
data = fdr.DataReader(symbol, start.strftime('%Y-%m-%d'), end.strftime('%Y-%m-%d'))
if not data.empty:
data = data.rename(columns={
'Open': 'Open',
'High': 'High',
'Low': 'Low',
'Close': 'Close',
'Volume': 'Volume',
})
return data
print(f"No data received for {symbol}, attempt {attempt + 1}")
time.sleep(2)
except Exception as e:
print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}")
if attempt < retries - 1:
time.sleep(5)
continue
return None
merged = pd.concat(parts, ignore_index=True)
merged["datetime"] = pd.to_datetime(merged["datetime"], format="%Y-%m-%d %H:%M:%S")
merged = merged.set_index("datetime")
merged = merged.sort_index()
merged = merged.drop_duplicates(keep="first")
merged["datetime"] = merged.index
return merged

View File

@@ -1,52 +1,39 @@
"""
WLD(월드코인) 실시간 모니터 — 3분 BB MTF (평균회귀 + 돌파).
전략: strategy.py
"""
from datetime import datetime
import time
from config import *
from config import COIN_NAME, COOLDOWN_FILE, MONITOR_LOOP_SLEEP_SEC, SYMBOL
from monitor import Monitor
class MonitorCoin (Monitor):
"""자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스"""
def __init__(self, cooldown_file: str = 'coins_buy_time.json') -> None:
class MonitorCoin(Monitor):
"""WLD 모니터링 및 매매 실행."""
def __init__(self, cooldown_file: str = COOLDOWN_FILE) -> None:
super().__init__(cooldown_file)
def monitor_coins(self) -> None:
tmps = self.getBalances()
balances = {}
for tmp in tmps:
balances[tmp['currency']] = {'balance': float(tmp['balance']), 'avg_buy_price': float(tmp['avg_buy_price'])}
def monitor_wld(self) -> None:
"""일봉·1시간 추세 + 3분 신호로 현물 매수/매도."""
balances = self.load_balances_dict()
print(
"[{}] {} ({})".format(
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
COIN_NAME,
SYMBOL,
)
)
self.process_wld_mtf(SYMBOL, balances=balances)
print("[{}] KRW COINs: {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'), ','.join(KR_COINS.keys())))
for symbol in KR_COINS:
interval = 60
data = self.get_coin_some_data(symbol, interval)
if data is not None and not data.empty:
try:
inverseData= self.inverse_data(data)
recent_inverseData = self.annotate_signals(symbol, inverseData)
if not self.buy_sell_ticker_1h(symbol, recent_inverseData, balances=balances, is_inverse=True):
pass
data = self.calculate_technical_indicators(data)
recent_data = self.annotate_signals(symbol, data)
_ = self.buy_sell_ticker_1h(symbol, recent_data, balances=None, is_inverse=False)
except Exception as e:
print(f"Error processing data for {symbol}: {str(e)}")
else:
print(f"Data for {symbol} is empty or None.")
time.sleep(0.5)
return
# ------------- Scheduler -------------
def run_schedule(self) -> None:
while True:
self.monitor_coins()
time.sleep(10)
self.monitor_wld()
time.sleep(MONITOR_LOOP_SLEEP_SEC)
if __name__ == "__main__":
KR_COINS.keys()
MonitorCoin().run_schedule()

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
mplcursors
numpy
ccxt
PyJWT
pycurl
schedule
requests
python-dateutil
python-telegram-bot
finance-datareader
psutil
mpld3
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 yfinance as yf
import plotly.graph_objs as go
from plotly import subplots
import plotly.io as pio
from datetime import datetime
pio.renderers.default = 'browser'
from plotly import subplots
from config import *
pio.renderers.default = "browser"
from config import (
BUY_COOLDOWN_SEC,
COIN_NAME,
ENTRY_INTERVAL,
SELL_COOLDOWN_SEC,
SIM_INITIAL_CASH_KRW,
SIM_MIN_ORDER_KRW,
SYMBOL,
TRADING_FEE_RATE,
TREND_INTERVAL_1D,
TREND_INTERVAL_1H,
)
from monitor import Monitor
import strategy
@dataclass
class SimTrade:
dt: pd.Timestamp
action: str
signal: str
price: float
krw: float
fee: float
quantity: float
pnl: float | None
cash_after: float
total_asset: float
@dataclass
class SimResult:
config_name: str
trades: list[SimTrade]
initial_cash: float
final_cash: float
final_coin_qty: float
final_price: float
realized_pnl: float
total_fees: float
final_asset: float
total_return_pct: float
trade_count: int
win_count: int
def run_backtest(
df_3m: pd.DataFrame,
df_1d: pd.DataFrame,
df_1h: pd.DataFrame,
config_name: str = "",
initial_cash: float = SIM_INITIAL_CASH_KRW,
min_order_krw: float = SIM_MIN_ORDER_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> SimResult:
"""신호 순서대로 현물 매수/매도 시뮬레이션 (수수료 차감)."""
cash = float(initial_cash)
coin_qty = 0.0
cost_basis = 0.0
realized_pnl = 0.0
total_fees = 0.0
win_count = 0
trades: list[SimTrade] = []
last_buy_ts: pd.Timestamp | None = None
last_sell_ts: pd.Timestamp | None = None
signals = df_3m[df_3m["point"] == 1].sort_index()
for ts, row in signals.iterrows():
price = float(row["Close"])
action = str(row.get("action", ""))
signal_name = str(row.get("signal", ""))
if price <= 0:
continue
trend_at = str(row.get("trend", "")) or strategy.get_trend_at(df_1d, df_1h, ts)
if trend_at not in ("up", "down", "range"):
trend_at = strategy.get_trend_at(df_1d, df_1h, ts)
if action == "buy":
if last_buy_ts is not None:
if (ts - last_buy_ts).total_seconds() < BUY_COOLDOWN_SEC:
continue
buy_krw = float(
strategy.get_buy_amount(SYMBOL, signal_name, price, trend_at)
)
buy_krw = max(min_order_krw, min(buy_krw, cash))
fee = buy_krw * fee_rate
total_cost = buy_krw + fee
if buy_krw < min_order_krw or cash < total_cost:
continue
qty = buy_krw / price
cash -= total_cost
total_fees += fee
cost_basis += buy_krw
coin_qty += qty
last_buy_ts = ts
trades.append(
SimTrade(
dt=ts,
action="매수",
signal=signal_name,
price=price,
krw=buy_krw,
fee=fee,
quantity=qty,
pnl=None,
cash_after=cash,
total_asset=cash + coin_qty * price,
)
)
continue
if action == "sell":
if coin_qty <= 0:
continue
if last_sell_ts is not None:
if (ts - last_sell_ts).total_seconds() < SELL_COOLDOWN_SEC:
continue
ratio = strategy.get_sell_ratio(SYMBOL, signal_name)
sell_qty = min(coin_qty * ratio, coin_qty)
sell_krw = sell_qty * price
if sell_krw < min_order_krw:
if coin_qty * price < min_order_krw:
continue
sell_qty = coin_qty
sell_krw = sell_qty * price
fee = sell_krw * fee_rate
net = sell_krw - fee
avg_cost = cost_basis / coin_qty
sold_cost = avg_cost * sell_qty
pnl = net - sold_cost
cash += net
total_fees += fee
cost_basis -= sold_cost
coin_qty -= sell_qty
realized_pnl += pnl
if pnl > 0:
win_count += 1
if coin_qty < 1e-12:
coin_qty = 0.0
cost_basis = 0.0
last_sell_ts = ts
trades.append(
SimTrade(
dt=ts,
action="매도",
signal=signal_name,
price=price,
krw=sell_krw,
fee=fee,
quantity=sell_qty,
pnl=pnl,
cash_after=cash,
total_asset=cash + coin_qty * price,
)
)
final_price = float(df_3m["Close"].iloc[-1])
final_asset = cash + coin_qty * final_price
sell_trades = sum(1 for t in trades if t.action == "매도")
return SimResult(
config_name=config_name,
trades=trades,
initial_cash=initial_cash,
final_cash=cash,
final_coin_qty=coin_qty,
final_price=final_price,
realized_pnl=realized_pnl,
total_fees=total_fees,
final_asset=final_asset,
total_return_pct=(final_asset - initial_cash) / initial_cash * 100
if initial_cash > 0
else 0.0,
trade_count=len(trades),
win_count=win_count if sell_trades else 0,
)
def print_backtest_report(result: SimResult) -> None:
fee_pct = TRADING_FEE_RATE * 100
print("\n" + "=" * 80)
print(
f"[{result.config_name}] 시작 {result.initial_cash:,.0f}원 | "
f"최소주문 {SIM_MIN_ORDER_KRW:,.0f}원 | 수수료 {fee_pct:.3f}%/쪽"
)
print("=" * 80)
if not result.trades:
print("체결 없음")
else:
print(
f"{'일시':<18} {'구분':<4} {'신호':<22} {'가격':>9} {'금액':>10} "
f"{'수수료':>8} {'수익':>10}"
)
print("-" * 80)
for t in result.trades:
pnl_s = f"{t.pnl:+,.0f}" if t.pnl is not None else "-"
print(
f"{t.dt.strftime('%Y-%m-%d %H:%M'):<18} {t.action:<4} {t.signal:<22} "
f"{t.price:>9,.2f} {t.krw:>10,.0f} {t.fee:>8,.0f} {pnl_s:>10}"
)
print("-" * 80)
sells = sum(1 for t in result.trades if t.action == "매도")
win_rate = result.win_count / sells * 100 if sells else 0.0
print(f"거래 횟수: {result.trade_count} (매도 {sells}회) | 승률: {win_rate:.1f}%")
print(f"수수료 합계: {result.total_fees:,.0f}")
print(f"실현 손익(수수료 반영): {result.realized_pnl:+,.0f}")
print(
f"최종 자산: {result.final_asset:,.0f}원 | "
f"총수익: {result.final_asset - result.initial_cash:+,.0f}"
f"({result.total_return_pct:+.2f}%)"
)
print("=" * 80)
def run_comparison(df_1d: pd.DataFrame, df_1h: pd.DataFrame, df_3m: pd.DataFrame) -> None:
"""기법 조합별 수익률 비교 (수수료 포함)."""
print(f"\n{'='*80}")
print(f"전략 조합 비교 — {SYMBOL} 3분 | {df_3m.index[0]} ~ {df_3m.index[-1]}")
print(f"시작 {SIM_INITIAL_CASH_KRW:,}원 | 수수료 {TRADING_FEE_RATE*100:.3f}%/매수·매도")
print(f"{'='*80}")
print(
f"{'순위':<4} {'조합':<22} {'수익률':>9} {'최종자산':>12} "
f"{'거래':>6} {'승률':>7} {'수수료':>10}"
)
print("-" * 80)
rows: list[tuple[SimResult, strategy.StrategyConfig]] = []
for cfg in strategy.comparison_presets():
df_sig = strategy.annotate_signals(
SYMBOL,
df_3m.copy(),
simulation=True,
df_1h=df_1h,
df_1d=df_1d,
config=cfg,
)
res = run_backtest(df_sig, df_1d, df_1h, config_name=cfg.name)
rows.append((res, cfg))
rows.sort(key=lambda x: x[0].total_return_pct, reverse=True)
for rank, (res, cfg) in enumerate(rows, 1):
sells = sum(1 for t in res.trades if t.action == "매도")
wr = res.win_count / sells * 100 if sells else 0.0
print(
f"{rank:<4} {res.config_name:<22} {res.total_return_pct:>+8.2f}% "
f"{res.final_asset:>12,.0f} {res.trade_count:>6} {wr:>6.1f}% "
f"{res.total_fees:>10,.0f}"
)
best_res, best_cfg = rows[0]
print("-" * 80)
print(f"1위: {best_cfg.name} ({best_res.total_return_pct:+.2f}%)")
print(
"실거래 적용: strategy.ACTIVE_CONFIG 를 1위 조합으로 맞추세요 "
"(현재 ACTIVE_CONFIG.name=%s)" % strategy.ACTIVE_CONFIG.name
)
print(f"{'='*80}\n")
class Simulation:
def render_plotly(self, symbol: str, interval_minutes: int, data: pd.DataFrame, inverseData: pd.DataFrame) -> None:
fig = subplots.make_subplots(
rows=3, cols=1,
subplot_titles=("캔들", "이격도/거래량", "장기 이격도"),
shared_xaxes=True, horizontal_spacing=0.03, vertical_spacing=0.03,
row_heights=[0.6, 0.2, 0.2]
)
# Row 1: 캔들 + 이동평균 + 볼린저
fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'], low=data['Low'], close=data['Close'], name='캔들'), row=1, col=1)
for ma_col, color in [('MA5','red'),('MA20','blue'),('MA40','green'),('MA120','purple'),('MA200','brown'),('MA240','darkred'),('MA720','cyan'),('MA1440','magenta')]:
if ma_col in data.columns:
fig.add_trace(go.Scatter(x=data.index, y=data[ma_col], name=ma_col, mode='lines', line=dict(color=color, width=1)), row=1, col=1)
if 'Lower' in data.columns and 'Upper' in data.columns:
fig.add_trace(go.Scatter(x=data.index, y=data['Lower'], name='볼린저 하단', mode='lines', line=dict(color='grey', width=1, dash='dot')), row=1, col=1)
fig.add_trace(go.Scatter(x=data.index, y=data['Upper'], name='볼린저 상단', mode='lines', line=dict(color='grey', width=1, dash='dot')), row=1, col=1)
# 매수 포인트
for sig, color in [('movingaverage','red'),('deviation40','orange'),('Deviation720','blue'),('deviation1440','purple'),('fall_6p','black')]:
pts = data[(data['point']==1) & (data['signal']==sig)]
if len(pts)>0:
fig.add_trace(go.Scatter(x=pts.index, y=pts['Close'], mode='markers', name=f'{sig} 매수', marker=dict(color=color, size=8, symbol='circle')), row=1, col=1)
# 매도 포인트: inverseData의 buy 신호 중 fall_6p, deviation40만 일반 그래프 가격축에 매도로 표시
inv_sell_pts = inverseData[(inverseData['point']==1) & (inverseData['signal'].isin(['deviation40','fall_6p']))]
if len(inv_sell_pts)>0:
idx = inv_sell_pts.index.intersection(data.index)
if len(idx)>0:
fig.add_trace(
go.Scatter(
x=idx,
y=data.loc[idx, 'Close'],
mode='markers',
name='매도',
marker=dict(color='orange', size=10, symbol='triangle-down')
),
row=1, col=1
)
# Row 2: 이격도 + 거래량
for dev_col, color, width in [('Deviation5','red',1),('Deviation20','blue',1),('Deviation40','green',2),('Deviation120','purple',1),('Deviation200','brown',1),('Deviation720','darkred',2),('Deviation720','cyan',1),('Deviation1440','magenta',1)]:
if dev_col in data.columns:
fig.add_trace(go.Scatter(x=data.index, y=data[dev_col], name=dev_col, mode='lines', line=dict(color=color, width=width)), row=2, col=1)
if 'Volume' in data.columns:
fig.add_trace(go.Bar(x=data.index, y=data['Volume'], name='거래량', marker_color='lightgray', opacity=0.5), row=2, col=1)
# Row 3: 장기 이격도 및 기준선
for dev_col, color in [('Deviation720','darkred'),('Deviation1440','magenta')]:
if dev_col in data.columns:
fig.add_trace(go.Scatter(x=data.index, y=data[dev_col], name=f'{dev_col}(장기)', mode='lines', line=dict(color=color, width=2)), row=3, col=1)
for h, color in [(90,'red'),(95,'green'),(100,'black')]:
fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1)
# ----------------- 인버스용 트레이스 (초기 숨김) -----------------
n_orig = len(fig.data)
# Row 1: 캔들/MA/볼린저 (inverseData)
fig.add_trace(go.Candlestick(x=inverseData.index, open=inverseData['Open'], high=inverseData['High'], low=inverseData['Low'], close=inverseData['Close'], name='캔들(인버스)', showlegend=True, visible=False), row=1, col=1)
for ma_col, color in [('MA5','red'),('MA20','blue'),('MA40','green'),('MA120','purple'),('MA200','brown'),('MA240','darkred'),('MA720','cyan'),('MA1440','magenta')]:
if ma_col in inverseData.columns:
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[ma_col], name=f'{ma_col}(인버스)', mode='lines', line=dict(color=color, width=1), showlegend=True, visible=False), row=1, col=1)
if 'Lower' in inverseData.columns and 'Upper' in inverseData.columns:
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData['Lower'], name='볼린저 하단(인버스)', mode='lines', line=dict(color='grey', width=1, dash='dot'), showlegend=True, visible=False), row=1, col=1)
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData['Upper'], name='볼린저 상단(인버스)', mode='lines', line=dict(color='grey', width=1, dash='dot'), showlegend=True, visible=False), row=1, col=1)
# 인버스 매수 포인트: fall_6p, deviation40만 표시
for sig, color in [('deviation40','orange'),('fall_6p','black')]:
pts_inv = inverseData[(inverseData['point']==1) & (inverseData['signal']==sig)]
if len(pts_inv)>0:
fig.add_trace(go.Scatter(x=pts_inv.index, y=inverseData.loc[pts_inv.index,'Close'], mode='markers', name=f'{sig} 매수(인버스)', marker=dict(color=color, size=8, symbol='circle'), showlegend=True, visible=False), row=1, col=1)
# 인버스 보기에서의 매도 포인트: 일반 그래프의 매수를 인버스 그래프의 매도로 표시 (모든 매수 신호 반영)
normal_to_inv_sell = data[(data['point']==1)]
if len(normal_to_inv_sell) > 0:
idx2 = normal_to_inv_sell.index.intersection(inverseData.index)
if len(idx2) > 0:
fig.add_trace(
go.Scatter(
x=idx2,
y=inverseData.loc[idx2, 'Close'],
mode='markers',
name='매도(일반→인버스)',
marker=dict(color='orange', size=10, symbol='triangle-down'),
showlegend=True,
visible=False
),
row=1, col=1
)
# Row 2: 이격도 + 거래량 (inverseData)
for dev_col, color, width in [('Deviation5','red',1),('Deviation20','blue',1),('Deviation40','green',2),('Deviation120','purple',1),('Deviation200','brown',1),('Deviation720','darkred',2),('Deviation720','cyan',1),('Deviation1440','magenta',1)]:
if dev_col in inverseData.columns:
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[dev_col], name=f'{dev_col}(인버스)', mode='lines', line=dict(color=color, width=width), showlegend=True, visible=False), row=2, col=1)
if 'Volume' in inverseData.columns:
fig.add_trace(go.Bar(x=inverseData.index, y=inverseData['Volume'], name='거래량(인버스)', marker_color='lightgray', opacity=0.5, showlegend=True, visible=False), row=2, col=1)
# Row 3: 장기 이격도 (inverseData)
for dev_col, color in [('Deviation720','darkred'),('Deviation1440','magenta')]:
if dev_col in inverseData.columns:
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[dev_col], name=f'{dev_col}(장기-인버스)', mode='lines', line=dict(color=color, width=2), showlegend=True, visible=False), row=3, col=1)
n_total = len(fig.data)
n_inv = n_total - n_orig
visible_orig = [True]*n_orig + [False]*n_inv
visible_inv = [False]*n_orig + [True]*n_inv
legendtitle_orig = {'text': '일반 그래프'}
legendtitle_inv = {'text': '인버스 그래프'}
fig.update_layout(
height=1000,
margin=dict(t=180, l=40, r=240, b=40),
title=dict(
text=f"{symbol}, {interval_minutes} 분봉, ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})",
x=0.5,
xanchor='center',
y=0.995,
yanchor='top',
pad=dict(t=10, b=12)
),
xaxis_rangeslider_visible=False,
xaxis1_rangeslider_visible=False,
xaxis2_rangeslider_visible=False,
legend=dict(orientation='v', yref='paper', yanchor='top', y=1.0, xref='paper', xanchor='left', x=1.02, title=legendtitle_orig),
dragmode='zoom',
updatemenus=[dict(
type='buttons',
direction='left',
x=0.0,
xanchor='left',
y=1.11,
yanchor='top',
pad=dict(t=0, r=10, b=0, l=0),
buttons=[
dict(
label='',
method='update',
args=[
{'visible': visible_orig},
{
'legend': {'title': legendtitle_orig},
'xaxis.autorange': True,
'xaxis2.autorange': True,
'xaxis3.autorange': True,
'yaxis.autorange': True,
'yaxis2.autorange': True,
'yaxis3.autorange': True,
}
],
execute=True
),
dict(
label='인버스',
method='update',
args=[
{'visible': visible_inv},
{'legend': {'title': legendtitle_inv, 'orientation': 'v', 'y': 1.0, 'yanchor': 'top', 'x': 1.02, 'xanchor': 'left'}}
],
args2=[
{'visible': visible_orig},
{'legend': {'title': legendtitle_orig, 'orientation': 'v', 'y': 1.0, 'yanchor': 'top', 'x': 1.02, 'xanchor': 'left'}}
],
execute=True
),
]
)]
)
fig.update_xaxes(title_text='시간', row=3, col=1)
fig.update_yaxes(title_text='가격 (KRW)', row=1, col=1)
fig.update_yaxes(title_text='이격도/거래량', row=2, col=1)
fig.update_yaxes(title_text='장기 이격도', row=3, col=1)
fig.show(config={'scrollZoom': True, 'displaylogo': False})
def __init__(self) -> None:
self.monitor = Monitor()
self.INTERVAL_MAP = {
60: "60m",
240: "4h",
}
self.monitor = Monitor(cooldown_file=None)
def detect_turnaround_signal(self, symbol, data, interval=0, params=None):
if len(data) < 7:
return None
current_data = data.iloc[-1]
if current_data.get('point', 0) == 1:
return {
'alert': True,
'details': f"매수신호: {current_data.get('signal', 'unknown')}"
}
return {'alert': False, 'details': "매수신호 없음"}
def load_mtf(self, symbol: str):
df_1d = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1D)
df_1h = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1H)
df_3m = self.monitor.get_coin_some_data(symbol, ENTRY_INTERVAL)
def fetch_price_history(self, symbol: str, interval_minutes: int, days: int = 30) -> pd.DataFrame:
if symbol in KR_COINS:
bong_count = 3000
return self.monitor.get_coin_more_data(symbol, interval_minutes, bong_count=bong_count)
if interval_minutes not in self.INTERVAL_MAP:
raise ValueError("interval must be 60 or 240")
interval_str = self.INTERVAL_MAP[interval_minutes]
df = yf.download(
tickers=symbol,
period=f"{days}d",
interval=interval_str,
progress=False,
if df_1d is None or df_1d.empty:
df_1d = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1D, bong_count=500)
if df_1h is None or df_1h.empty:
df_1h = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1H, bong_count=5000)
if df_3m is None or df_3m.empty:
df_3m = self.monitor.get_coin_more_data(
symbol, ENTRY_INTERVAL, bong_count=90000, verbose=True
)
df_1d = self.monitor.calculate_technical_indicators(df_1d)
df_1h = self.monitor.calculate_technical_indicators(df_1h)
df_3m = self.monitor.calculate_technical_indicators(df_3m)
return df_1d, df_1h, df_3m
def render_plotly(self, df_3m: pd.DataFrame, trend: str, result: SimResult) -> None:
cfg = strategy.ACTIVE_CONFIG.name
summary = (
f"[{cfg}] 시작 {result.initial_cash:,.0f} | 최종 {result.final_asset:,.0f} | "
f"{result.total_return_pct:+.2f}% | 수수료 {result.total_fees:,.0f}"
)
if df.empty:
raise RuntimeError("No data fetched. Check symbol or interval support.")
return df
fig = subplots.make_subplots(
rows=3,
cols=1,
subplot_titles=(
f"{COIN_NAME} 3분 BB — {trend}",
"RSI / BB폭(%)",
summary,
),
shared_xaxes=False,
vertical_spacing=0.06,
row_heights=[0.5, 0.18, 0.32],
specs=[[{"type": "xy"}], [{"type": "xy"}], [{"type": "table"}]],
)
fig.add_trace(
go.Candlestick(
x=df_3m.index,
open=df_3m["Open"],
high=df_3m["High"],
low=df_3m["Low"],
close=df_3m["Close"],
name="캔들",
showlegend=False,
),
row=1,
col=1,
)
for col, color in [("MA", "blue"), ("Upper", "gray"), ("Lower", "gray")]:
if col in df_3m.columns:
fig.add_trace(
go.Scatter(
x=df_3m.index,
y=df_3m[col],
name=col,
line=dict(color=color, dash="dot" if col != "MA" else "solid"),
showlegend=False,
),
row=1,
col=1,
)
def analyze_bottom_period(self, symbol: str, interval_minutes: int, days: int = 90):
data = self.fetch_price_history(symbol, interval_minutes, days)
data = self.monitor.calculate_technical_indicators(data)
data = self.monitor.annotate_signals(symbol, data, simulation=True)
print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}")
print(f"총 데이터 수: {len(data)}")
bottom_start = pd.Timestamp('2025-06-22')
bottom_end = pd.Timestamp('2025-07-09')
bottom_data = data[(data.index >= bottom_start) & (data.index <= bottom_end)]
if len(bottom_data) == 0:
print("저점 기간 데이터가 없습니다.")
return None, []
print(f"\n저점 기간 데이터: {bottom_data.index[0]} ~ {bottom_data.index[-1]}")
print(f"저점 기간 데이터 수: {len(bottom_data)}")
print("\n=== 저점 기간 기술적 지표 분석 ===")
min_price = bottom_data['Low'].min()
max_price = bottom_data['High'].max()
avg_price = bottom_data['Close'].mean()
print(f"최저가: {min_price:.4f}")
print(f"최고가: {max_price:.4f}")
print(f"평균가: {avg_price:.4f}")
print(f"가격 변동폭: {((max_price - min_price) / min_price * 100):.2f}%")
bb_lower_min = bottom_data['Lower'].min()
bb_upper_max = bottom_data['Upper'].max()
print(f"\n볼린저 밴드 분석:")
print(f"하단 밴드 최저: {bb_lower_min:.4f}")
print(f"상단 밴드 최고: {bb_upper_max:.4f}")
volume_avg = bottom_data['Volume'].mean()
volume_max = bottom_data['Volume'].max()
print(f"\n거래량 분석:")
print(f"평균 거래량: {volume_avg:.0f}")
print(f"최대 거래량: {volume_max:.0f}")
actual_bottom_idx = bottom_data['Low'].idxmin()
actual_bottom_price = bottom_data.loc[actual_bottom_idx, 'Low']
actual_bottom_date = actual_bottom_idx
print(f"\n실제 저점:")
print(f"날짜: {actual_bottom_date}")
print(f"가격: {actual_bottom_price:.4f}")
print(f"볼린저 하단 대비: {((actual_bottom_price - bottom_data.loc[actual_bottom_idx, 'Lower']) / bottom_data.loc[actual_bottom_idx, 'Lower'] * 100):.2f}%")
print(f"\n=== 매수 신호 분석 ===")
bottom_alerts = bottom_data[bottom_data['point'] == 1]
alerts = [(idx, row['Close']) for idx, row in bottom_alerts.iterrows()]
print(f"저점 기간 매수 신호 수: {len(alerts)}")
if alerts:
print("매수 신호 발생 시점:")
for date, price in alerts:
print(f" {date}: {price:.4f}")
return bottom_data, alerts
buy_trades = [t for t in result.trades if t.action == "매수"]
sell_trades = [t for t in result.trades if t.action == "매도"]
fig.add_trace(
go.Scatter(
x=[t.dt for t in buy_trades],
y=[t.price for t in buy_trades],
mode="markers",
name="매수",
legendgroup="trades",
showlegend=True,
marker=dict(
color="#22c55e",
size=11,
symbol="triangle-up",
line=dict(width=1, color="#166534"),
),
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=[t.dt for t in sell_trades],
y=[t.price for t in sell_trades],
mode="markers",
name="매도",
legendgroup="trades",
showlegend=True,
marker=dict(
color="#ef4444",
size=11,
symbol="triangle-down",
line=dict(width=1, color="#991b1b"),
),
),
row=1,
col=1,
)
if "RSI" in df_3m.columns:
fig.add_trace(
go.Scatter(
x=df_3m.index,
y=df_3m["RSI"],
name="RSI",
showlegend=False,
),
row=2,
col=1,
)
if "BB_Width" in df_3m.columns:
fig.add_trace(
go.Scatter(
x=df_3m.index,
y=df_3m["BB_Width"],
name="BB폭%",
showlegend=False,
),
row=2,
col=1,
)
if result.trades:
cells = [
[t.dt.strftime("%Y-%m-%d %H:%M") for t in result.trades],
[t.action for t in result.trades],
[t.signal for t in result.trades],
[f"{t.price:,.2f}" for t in result.trades],
[f"{t.krw:,.0f}" for t in result.trades],
[f"{t.fee:,.0f}" for t in result.trades],
[f"{t.pnl:+,.0f}" if t.pnl is not None else "-" for t in result.trades],
[f"{t.total_asset:,.0f}" for t in result.trades],
]
else:
cells = [["-"] * 8]
fig.add_trace(
go.Table(
header=dict(
values=[
"일시",
"구분",
"신호",
"가격",
"금액",
"수수료",
"수익",
"총자산",
],
fill_color="#e8e8e8",
),
cells=dict(values=cells),
),
row=3,
col=1,
)
fig.update_layout(
height=1100,
title=f"{SYMBOL} BB 타이밍 시뮬 (범례 클릭: 매수/매도 표시 토글)",
margin=dict(l=50, r=140, t=80, b=40),
dragmode="zoom",
legend=dict(
orientation="v",
yanchor="top",
y=0.99,
xanchor="left",
x=1.01,
bgcolor="rgba(255,255,255,0.9)",
bordercolor="#cccccc",
borderwidth=1,
font=dict(size=12),
title=dict(text="체결 (클릭 토글)", side="top"),
itemclick="toggle",
itemdoubleclick="toggleothers",
),
)
# Y축 고정·rangeslider 해제 → 세로 드래그/박스줌·휠 줌 가능
fig.update_xaxes(
rangeslider_visible=False,
fixedrange=False,
row=1,
col=1,
)
fig.update_xaxes(fixedrange=False, row=2, col=1)
fig.update_yaxes(
title_text="가격 (KRW)",
fixedrange=False,
scaleanchor=None,
scaleratio=None,
row=1,
col=1,
)
fig.update_yaxes(
fixedrange=False,
scaleanchor=None,
scaleratio=None,
row=2,
col=1,
)
fig.show(
config={
"scrollZoom": True,
"displaylogo": False,
"doubleClick": "reset",
"modeBarButtonsToAdd": ["zoom2d", "pan2d", "resetScale2d"],
}
)
def run_simulation(self, symbol: str, interval_minutes: int, days: int = 30):
data = self.fetch_price_history(symbol, interval_minutes)
def load_all_frames(self) -> dict[int, pd.DataFrame]:
"""discovered 규칙용 전 간격 로드."""
from mtf_bb import load_frames_from_db
inverseData = self.monitor.inverse_data(data)
inverseData = self.monitor.annotate_signals(symbol, inverseData, simulation=True)
return load_frames_from_db(self.monitor, SYMBOL)
data = self.monitor.calculate_technical_indicators(data)
data = self.monitor.annotate_signals(symbol, data, simulation=True)
print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}")
print(f"총 데이터 수: {len(data)}")
alerts = []
for i in range(len(data)):
if data['point'].iloc[i] == 1:
alerts.append((data.index[i], data['Close'].iloc[i]))
print(f"\n총 매수 신호 수: {len(alerts)}")
ma_signals = len(data[(data['point'] == 1) & (data['signal'] == 'movingaverage')])
dev40_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation40')])
dev240_signals = len(data[(data['point'] == 1) & (data['signal'] == 'Deviation720')])
dev1440_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation1440')])
print(f" - MA 신호: {ma_signals}")
print(f" - Dev40 신호: {dev40_signals}")
print(f" - Dev240 신호: {dev240_signals}")
print(f" - Dev1440 신호: {dev1440_signals}")
def _run_one_strategy(
self,
name: str,
df_1d: pd.DataFrame,
df_1h: pd.DataFrame,
df_3m: pd.DataFrame,
cfg: strategy.StrategyConfig,
frames: dict | None = None,
) -> tuple[pd.DataFrame, SimResult, int]:
"""한 전략으로 신호·백테스트. 반환: (df, result, 신호수)."""
df_sig = strategy.annotate_signals(
SYMBOL,
df_3m.copy(),
simulation=True,
df_1h=df_1h,
df_1d=df_1d,
config=cfg,
frames=frames,
)
n_sig = int((df_sig["point"] == 1).sum())
res = run_backtest(df_sig, df_1d, df_1h, config_name=name)
return df_sig, res, n_sig
# Plotly 기반 시각화로 전환
self.render_plotly(symbol, interval_minutes, data, inverseData)
def run(self, config: strategy.StrategyConfig | None = None) -> SimResult:
"""기본 BB vs 탐색 규칙 중 수익률·신호가 있는 쪽을 HTML에 표시."""
df_1d, df_1h, df_3m = self.load_mtf(SYMBOL)
trend = strategy.get_trend(df_1d, df_1h)
print(f"추세(최신): {trend}")
print(f"3분: {df_3m.index[0]} ~ {df_3m.index[-1]} ({len(df_3m)}봉)")
cfg_base = strategy.StrategyConfig(
name="01_기본_BB만",
use_discovered_rules=False,
use_regime_switch=False,
use_rsi_filter=False,
use_volume_filter=False,
use_squeeze_filter=False,
use_stop_loss=False,
)
df_base, res_base, n_base = self._run_one_strategy(
cfg_base.name, df_1d, df_1h, df_3m, cfg_base
)
print(f"\n[기본 BB] 신호 {n_base} | 수익 {res_base.total_return_pct:+.2f}% | 거래 {res_base.trade_count}")
candidates: list[tuple[str, pd.DataFrame, SimResult, int]] = [
(cfg_base.name, df_base, res_base, n_base),
]
try:
from rule_discovery import load_rules
rules = load_rules()
frames = self.load_all_frames()
if rules and frames:
cfg_disc = strategy.StrategyConfig(
name=rules.name,
use_discovered_rules=True,
use_regime_switch=False,
use_rsi_filter=False,
use_volume_filter=False,
use_squeeze_filter=False,
use_stop_loss=False,
)
df_disc, res_disc, n_disc = self._run_one_strategy(
cfg_disc.name, df_1d, df_1h, df_3m, cfg_disc, frames=frames
)
print(
f"[탐색 규칙] 신호 {n_disc} | 수익 {res_disc.total_return_pct:+.2f}% "
f"| 거래 {res_disc.trade_count}"
)
print(f" 매수: {rules.buy_all} | OR: {rules.buy_any}")
print(f" 매도: {rules.sell_all} | 손절: {rules.sell_stop}")
if n_disc > 0 and res_disc.trade_count > 0:
candidates.append((cfg_disc.name, df_disc, res_disc, n_disc))
except Exception as e:
print(f"[탐색 규칙] 스킵: {e}")
# 신호·거래 있는 후보 중 수익률 최대
valid = [c for c in candidates if c[3] > 0 and c[2].trade_count > 0]
if not valid:
valid = candidates
name, df_plot, result, n_sig = max(valid, key=lambda c: c[2].total_return_pct)
print(f"\n>>> HTML 적용: {name} (신호 {n_sig}, 거래 {result.trade_count}, {result.total_return_pct:+.2f}%)")
sigs = df_plot[df_plot["point"] == 1]
if len(sigs):
print(sigs["action"].value_counts().to_string())
print_backtest_report(result)
self.render_plotly(df_plot, trend, result)
return result
def run_mtf_analysis() -> None:
"""봉별 BB 백테스트 비교, 정책 저장, MTF 시뮬 차트."""
from mtf_bb import apply_policy, load_frames_from_db, run_interval_comparison, save_policy
monitor = Monitor()
policy, _ = run_interval_comparison(monitor)
save_policy(policy)
apply_policy(policy)
frames = load_frames_from_db(monitor, SYMBOL)
df_1d = frames.get(TREND_INTERVAL_1D)
if df_1d is None or df_1d.empty:
df_1d = frames[ENTRY_INTERVAL]
df_1h = frames.get(TREND_INTERVAL_1H)
if df_1h is None or df_1h.empty:
df_1h = frames[ENTRY_INTERVAL]
cfg = strategy.StrategyConfig(
name="MTF_BB",
use_mtf=True,
use_regime_switch=strategy.ACTIVE_CONFIG.use_regime_switch,
use_rsi_filter=False,
use_volume_filter=False,
use_squeeze_filter=False,
use_stop_loss=True,
)
df_sig = strategy.annotate_mtf_signals(SYMBOL, frames, df_1d, df_1h, policy, cfg)
trend = strategy.get_trend(df_1d, df_1h)
print(f"\nMTF 시뮬 ({policy.name}) | 추세: {trend}")
result = run_backtest(df_sig, df_1d, df_1h, config_name=policy.name)
print_backtest_report(result)
Simulation().render_plotly(df_sig, trend, result)
def run_discover() -> None:
"""모든 봉·캔들 특징으로 최적 규칙 탐색 후 JSON 저장."""
from rule_discovery import discover_rules, load_frames, save_rules
monitor = Monitor(cooldown_file=None)
frames = load_frames(monitor)
rules = discover_rules(frames)
save_rules(rules)
print(f"\n저장: discovered_rules.json")
print("HTML 차트: python simulation_1h.py")
def main() -> None:
sim = Simulation()
if len(sys.argv) > 1 and sys.argv[1] == "discover":
run_discover()
return
if len(sys.argv) > 1 and sys.argv[1] == "mtf":
run_mtf_analysis()
return
df_1d, df_1h, df_3m = sim.load_mtf(SYMBOL)
if len(sys.argv) > 1 and sys.argv[1] == "compare":
run_comparison(df_1d, df_1h, df_3m)
return
sim.run()
if __name__ == "__main__":
sim = Simulation()
interval = 60
days = 90
target_coins = KR_COINS
#target_coins = ['XRP']
show_graphs = True
for symbol in target_coins:
print(f"\n=== {symbol} 저점 기간 분석 시작 ===")
try:
bottom_data, alerts = sim.analyze_bottom_period(symbol, interval, days)
print(f"\n=== {symbol} 전체 기간 시뮬레이션 ===")
if show_graphs:
sim.run_simulation(symbol, interval, days)
else:
data = sim.fetch_price_history(symbol, interval, days)
inverseData = sim.monitor.inverse_data(data)
inverseData = sim.monitor.annotate_signals(symbol, inverseData, simulation=True)
data = sim.monitor.calculate_technical_indicators(data)
data = sim.monitor.annotate_signals(symbol, data, simulation=True)
total_signals = len(data[data['point'] == 1])
ma_signals = len(data[(data['point'] == 1) & (data['signal'] == 'movingaverage')])
dev40_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation40')])
dev240_signals = len(data[(data['point'] == 1) & (data['signal'] == 'Deviation720')])
dev1440_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation1440')])
print(f"총 매수 신호: {total_signals}")
print(f" - MA 신호: {ma_signals}")
print(f" - Dev40 신호: {dev40_signals}")
print(f" - Dev240 신호: {dev240_signals}")
print(f" - Dev1440 신호: {dev1440_signals}")
except Exception as e:
print(f"Error analyzing {symbol}: {str(e)}")
main()

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