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:
28
.env.example
Normal file
28
.env.example
Normal 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
19
HTS2.py
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import pandas as pd
|
||||
import jwt
|
||||
import uuid
|
||||
@@ -7,22 +8,20 @@ import json
|
||||
import hashlib
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
class HTS:
|
||||
"""빗썸 Open API 래퍼 (시세 조회, 잔고, 주문)."""
|
||||
|
||||
bithumb = None
|
||||
accessKey = "a5d33ce55f598185d37cd26272341b7b965c31a59457f7" # 본인의 Connect Key를 입력한다.
|
||||
secretKey = "ODBiYWFmNWE2MTkwYjdhMTNhZTM1YjU5OGY4OGE2MGNkNDY2NzMzMjE2Nzc5NDVlMzBhMDk3NTNmM2M2Mg==" # 본인의 Secret Key를 입력한다.
|
||||
apiUrl = 'https://api.bithumb.com'
|
||||
accessKey = ""
|
||||
secretKey = ""
|
||||
apiUrl = "https://api.bithumb.com"
|
||||
|
||||
def __init__(self):
|
||||
#self.bithumb = pybithumb.Bithumb(self.con_key, self.sec_key)
|
||||
|
||||
self.bithumb = None
|
||||
self.accessKey = "a5d33ce55f598185d37cd26272341b7b965c31a59457f7" # 본인의 Connect Key를 입력한다.
|
||||
self.secretKey = "ODBiYWFmNWE2MTkwYjdhMTNhZTM1YjU5OGY4OGE2MGNkNDY2NzMzMjE2Nzc5NDVlMzBhMDk3NTNmM2M2Mg==" # 본인의 Secret Key를 입력한다.
|
||||
self.apiUrl = 'https://api.bithumb.com'
|
||||
|
||||
return
|
||||
self.accessKey = os.getenv("BITHUMB_ACCESS_KEY", "")
|
||||
self.secretKey = os.getenv("BITHUMB_SECRET_KEY", "")
|
||||
self.apiUrl = "https://api.bithumb.com"
|
||||
|
||||
def append(self, stock, df=None, data_1=None):
|
||||
if df is not None:
|
||||
|
||||
24
PROMPT.txt
24
PROMPT.txt
@@ -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
164
README.md
@@ -1,146 +1,50 @@
|
||||
# AssetMonitor 주식·코인 모니터링 시스템
|
||||
# DeepCoin — WLD 볼린저 MTF
|
||||
|
||||
## 개요
|
||||
`AssetMonitor`는 주식‧ETF 및 암호화폐 시장을 실시간으로 감시하여 Bollinger Band, RSI, MACD, 이동평균(Golden-Cross), 거래량 등을 종합 분석한 **매수 후보(signals)**를 텔레그램으로 통보하는 자동화 봇입니다.
|
||||
빗썸 KRW-WLD 현물 전용. **모든 봉**에 동일한 BB 규칙을 적용하고, 봉별 상태를 비교해 실행·확인 봉을 정합니다.
|
||||
|
||||
**주요 개선사항:**
|
||||
- **데이터 표준화**: 모든 코인에 동일한 기술적 분석 기준 적용
|
||||
- **순수 기술적 분석**: 날짜 기반 조건 제거, 기술적 지표만 사용
|
||||
- **강화된 기술적 지표**: 스토캐스틱, MFI, OBV, ATR 등 추가 지표 활용
|
||||
## BB 기본 규칙 (모든 간격 동일)
|
||||
|
||||
---
|
||||
|
||||
## 주요 구성 파일
|
||||
| 파일 | 설명 |
|
||||
| 구분 | 조건 |
|
||||
|------|------|
|
||||
| `config.py` | ✅ API 토큰, 텔레그램 채널 ID, 볼린저 밴드/임계값, 모니터링 자산 목록(KR_COINS, US_STOCKS, KR_ETFS) 등 전역 설정을 보관합니다. |
|
||||
| `stock_monitor.py` | 시스템의 핵심 로직이 담긴 실행 스크립트입니다. <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
156
candle_features.py
Normal 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
1
coins_buy_time.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
302
config.py
@@ -1,258 +1,68 @@
|
||||
"""
|
||||
전역 설정 (WLD 월드코인, 3분 BB MTF 전략).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# 텔레그램 설정
|
||||
COIN_TELEGRAM_BOT_TOKEN = "6435061393:AAHOh9wB5yGNGUdb3SfCYJrrWTBe7wgConM"
|
||||
COIN_TELEGRAM_CHAT_ID = '574661323'
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
STOCK_TELEGRAM_BOT_TOKEN = "6874078562:AAEHxGDavfc0ssAXPQIaW8JGYmTR7LNUJOw"
|
||||
STOCK_TELEGRAM_CHAT_ID = '574661323'
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 몇초 만에 다시 매수를 할 것인지 체크
|
||||
BUY_MINUTE_LIMIT = 900
|
||||
# --- API / 알림 ---
|
||||
COIN_TELEGRAM_BOT_TOKEN = os.getenv("COIN_TELEGRAM_BOT_TOKEN", "")
|
||||
COIN_TELEGRAM_CHAT_ID = os.getenv("COIN_TELEGRAM_CHAT_ID", "")
|
||||
|
||||
# 볼린저 밴드 설정
|
||||
BOLLINGER_PERIOD = 20 # 볼린저 밴드 기간
|
||||
BOLLINGER_STD = 2 # 표준편차 승수
|
||||
BOLLINGER_THRESHOLD = 0.10 # 하단 밴드 대비 10% 근접 시 알림
|
||||
BUY_THRESHOLD = 0.15
|
||||
BREAKOUT_LOOKBACK = 30 # U자 반등 후 돌파 판단에 사용할 과거 캔들 수 (4시간봉 기준 약 5일)
|
||||
BREAKOUT_WEEK_LOOKBACK = 42 # 4시간봉 1주일 ≒ 42개
|
||||
BREAKOUT_WEEK_LIMIT = 0.05 # 1주일 대비 5% 미만 상승 조건
|
||||
# --- 거래 대상 ---
|
||||
SYMBOL = "WLD"
|
||||
COIN_NAME = "월드코인"
|
||||
|
||||
# 볼린저 밴드 squeeze 탐지 임계값 (밴드폭/중심선)
|
||||
SQUEEZE_THRESHOLD = 0.04 # 4% 이하
|
||||
|
||||
# 장기간 저항선 돌파 감지 설정
|
||||
RESISTANCE_LOOKBACK = 120 # 저항선 판단을 위한 과거 캔들 수 (예: 120개)
|
||||
RESISTANCE_BREAK_THRESHOLD = 0.01 # 저항선 대비 1% 이상 돌파 시 신호
|
||||
|
||||
KR_COINS = {
|
||||
"ADA": "에이다",
|
||||
"APE": "에이프코인",
|
||||
"APT": "앱토스",
|
||||
"ARB": "아비트럼",
|
||||
"BONK": "봉크",
|
||||
"ENA": "에테나",
|
||||
"FANC": "팬시",
|
||||
"HBAR": "헤데라",
|
||||
"KAIA": "카이아",
|
||||
"LINK": "체인링크",
|
||||
"ONDO": "온도파이낸스",
|
||||
"PENGU": "펏지 펭귄",
|
||||
"PEPE": "페페",
|
||||
"POL": "폴리곤 에코시스템 토큰",
|
||||
"PYTH":"피스 네트워크",
|
||||
"SEI": "세이",
|
||||
"SHIB": "시바이누",
|
||||
"STORJ": "스토리지",
|
||||
"SUI": "수이",
|
||||
"TON": "톤코인",
|
||||
"TRX": "트론",
|
||||
"UXLINK": "유엑스링크",
|
||||
"VIRTUAL": "버추얼 프로토콜",
|
||||
"WLD": "월드코인",
|
||||
"XLM": "스텔라루멘",
|
||||
"XRP": "엑스알피"
|
||||
KR_COINS: dict[str, str] = {
|
||||
SYMBOL: COIN_NAME,
|
||||
}
|
||||
|
||||
KR_COINS_1 = {
|
||||
"ADA": "에이다",
|
||||
"APE": "에이프코인",
|
||||
"APT": "앱토스",
|
||||
"ARB": "아비트럼",
|
||||
"BONK": "봉크",
|
||||
"ENA": "에테나",
|
||||
"FANC": "팬시",
|
||||
"HBAR": "헤데라",
|
||||
"KAIA": "카이아",
|
||||
"LINK": "체인링크",
|
||||
"ONDO": "온도파이낸스",
|
||||
"PENGU": "펏지 펭귄",
|
||||
"PEPE": "페페",
|
||||
}
|
||||
# --- 타임프레임 (분) ---
|
||||
ENTRY_INTERVAL = 3
|
||||
TREND_INTERVAL_1H = 60
|
||||
TREND_INTERVAL_1D = 1440
|
||||
|
||||
KR_COINS_2 = {
|
||||
"POL": "폴리곤 에코시스템 토큰",
|
||||
"PYTH":"피스 네트워크",
|
||||
"SEI": "세이",
|
||||
"SHIB": "시바이누",
|
||||
"STORJ": "스토리지",
|
||||
"SUI": "수이",
|
||||
"TON": "톤코인",
|
||||
"TRX": "트론",
|
||||
"UXLINK": "유엑스링크",
|
||||
"VIRTUAL": "버추얼 프로토콜",
|
||||
"WLD": "월드코인",
|
||||
"XLM": "스텔라루멘",
|
||||
"XRP": "엑스알피"
|
||||
}
|
||||
# --- 쿨다운(초) ---
|
||||
BUY_COOLDOWN_SEC = int(os.getenv("BUY_COOLDOWN_SEC", "300"))
|
||||
SELL_COOLDOWN_SEC = int(os.getenv("SELL_COOLDOWN_SEC", "180"))
|
||||
BUY_MINUTE_LIMIT = BUY_COOLDOWN_SEC
|
||||
|
||||
# 주식 설정
|
||||
US_STOCKS = {
|
||||
'VOO': 'Vanguard S&P 500 ETF',
|
||||
'SQQQ': 'ProShares UltraPro Short QQQ',
|
||||
'QID': 'ProShares UltraShort QQQ',
|
||||
'PSQ': 'ProShares Short QQQ',
|
||||
'TQQQ': 'ProShares UltraPro QQQ',
|
||||
'QQQ': 'Invesco QQQ Trust',
|
||||
'SCO': 'ProShares UltraShort Bloomberg Crude Oil',
|
||||
'UCO': 'ProShares Ultra Bloomberg Crude Oil',
|
||||
'GLL': 'ProShares UltraShort Gold',
|
||||
'UGL': 'ProShares Ultra Gold',
|
||||
'SOXS': 'Direxion Daily Semiconductor Bear -3X Shares',
|
||||
'SOXL': 'Direxion Daily Semiconductor Bull 3X Shares',
|
||||
'FNGD': 'MicroSectors™ FANG+™ Index -3X Inverse Leveraged ETN',
|
||||
'FNGU': 'MicroSectors™ FANG+™ Index 3X Leveraged ETN',
|
||||
'FXI': 'iShares China Large-Cap ETF',
|
||||
# --- 볼린저 (3분봉, 20, 2σ) ---
|
||||
BB_PERIOD = 20
|
||||
BB_STD = 2
|
||||
BB_MIN_WIDTH_PCT = float(os.getenv("BB_MIN_WIDTH_PCT", "0.8"))
|
||||
|
||||
"AAPL": "Apple / AI 칩셋",
|
||||
"ACN": "Accenture",
|
||||
"ADBE": "Adobe",
|
||||
"AMD": "Advanced Micro Devices / AI 반도체",
|
||||
"AMZN": "Amazon / AI 로봇/클라우드",
|
||||
"ASML": "ASML Holding / EUV 리소그래피",
|
||||
"ASTS": "AST SpaceMobile / 위성통신",
|
||||
"AVGO": "Broadcom",
|
||||
"BABA": "Alibaba Group Holdings Ltd ADR",
|
||||
"BAC": "Bank of America",
|
||||
"BE": "Bloom Energy / 고체산화물 연료전지",
|
||||
"CAMT": "Camtek / 반도체 계측기기6",
|
||||
"CHWY": "Chewy / 애완용품 전자상거래",
|
||||
"COIN": "Coinbase / 암호화폐 거래소",
|
||||
"COST": "Costco Wholesale / 회원제 유통",
|
||||
"CPNG": "Coupang LLC",
|
||||
"CRM": "Salesforce.com",
|
||||
"CRWD": "CrowdStrike / AI 사이버보안",
|
||||
"CSCO": "Cisco",
|
||||
"CVX": "Chevron Corp",
|
||||
"DASH": "DoorDash / 배달 플랫폼",
|
||||
"DIS": "Walt Disney",
|
||||
"DQ": "Daqo New Energy Corp ADR",
|
||||
"DXCM": "DexCom / 지속형 혈당측정기",
|
||||
"EBAY": "eBay Inc",
|
||||
"ENPH": "Enphase Energy / 태양광 인버터",
|
||||
"GEO": "GEO Group / 교정시설 운영3",
|
||||
"GOOG": "Alphabet C",
|
||||
"GOOGL": "Alphabet (Google) / AI 검색/자율주행",
|
||||
"GRVY": "Gravity / 온라인 게임",
|
||||
"HD": "Home Depot",
|
||||
"HON": "Honeywell",
|
||||
"IBM": "IBM",
|
||||
"INTC": "Intel / 차세대 반도체",
|
||||
"ISRG": "Intuitive Surgical / 수술로봇",
|
||||
"JNJ": "Johnson & Johnson (JNJ)",
|
||||
"JPM": "JPMorgan",
|
||||
"KLAC": "KLA Corporation / 반도체 검사장비",
|
||||
"KO": "Coca-Cola",
|
||||
"LB": "LandBridge Co / 에너지 인프라3",
|
||||
"LCID": "Lucid Group / 고급 전기차",
|
||||
"LMT": "Lockheed Martin / 방위 시스템",
|
||||
"LRCX": "Lam Research / 반도체 장비",
|
||||
"MA": "Mastercard",
|
||||
"MELI": "MercadoLibre / 라틴아메리카 전자상거래",
|
||||
"META": "Meta Platforms / AI 메타버스",
|
||||
"MNMD": "Mind Medicine / 사이키델릭 치료제",
|
||||
"MS": "Morgan Stanley",
|
||||
"MSFT": "Microsoft / AI 클라우드",
|
||||
"NKE": "Nike",
|
||||
"NOC": "Northrop Grumman / 우주항공",
|
||||
"NTAP": "NetApp Inc",
|
||||
"NVDA": "NVIDIA / AI 반도체",
|
||||
"ORCL": "Oracle",
|
||||
"PLTR": "Palantir Technologies / AI 데이터 분석",
|
||||
"PLUG": "Plug Power / 수소연료전지",
|
||||
"QCOM": "Qualcomm / 모바일 칩셋",
|
||||
"REGN": "Regeneron Pharmaceuticals / 항체 치료제",
|
||||
"RIVN": "Rivian Automotive / 전기트럭",
|
||||
"RKLB": "Rocket Lab / 소형위성 발사체",
|
||||
"RTX": "RTX Corporation / 제트엔진/미사일",
|
||||
"SEDG": "SolarEdge Technologies / 태양광 시스템",
|
||||
"SNOW": "Snowflake / AI 데이터 플랫폼",
|
||||
"SOFI": "SoFi Technologies / 디지털 뱅킹",
|
||||
"SPCE": "Virgin Galactic / 우주관광",
|
||||
"T": "AT&T",
|
||||
"TCTZF": "Tencent Holdings",
|
||||
"TDOC": "Teladoc Health / 원격의료",
|
||||
"TGT": "Target / 오프라인 리테일 혁신",
|
||||
"TSLA": "Tesla / 전기차/에너지 저장",
|
||||
"TSM": "Taiwan Semiconductor",
|
||||
"UNH": "UnitedHealth",
|
||||
"UPST": "Upstart Holdings / AI 대출플랫폼",
|
||||
"V": "Visa A",
|
||||
"VRTX": "Vertex Pharmaceuticals / 난치병 치료제",
|
||||
"VZ": "Verizon",
|
||||
"WGS": "GeneDx Holdings / 유전체 분석3",
|
||||
"WMT": "Walmart",
|
||||
"X": "United States Steel Corporation",
|
||||
"XOM": "Exxon Mobil"
|
||||
}
|
||||
# --- RSI / 거래량 (조합 필터) ---
|
||||
RSI_PERIOD = 14
|
||||
RSI_BUY_MAX = float(os.getenv("RSI_BUY_MAX", "42"))
|
||||
VOLUME_BUY_RATIO = float(os.getenv("VOLUME_BUY_RATIO", "1.0"))
|
||||
|
||||
# 한국 ETF 설정
|
||||
KR_ETFS = {
|
||||
"251340.KS": 'KODEX 코스닥150선물인버스',
|
||||
"233740.KS": 'KODEX 코스닥150 레버리지',
|
||||
"252670.KS": 'KODEX 200선물인버스2X',
|
||||
"122630.KS": 'KODEX 레버리지',
|
||||
"114800.KS": 'KODEX 인버스',
|
||||
"283580.KS": 'KODEX 중국본토CSI300',
|
||||
"256750.KS": 'KODEX 심천ChiNext(합성)',
|
||||
"185680.KS": 'KODEX 미국S&P바이오(합성)',
|
||||
"218420.KS": 'KODEX 미국S&P에너지(합성)',
|
||||
"132030.KS": 'KODEX 골드선물(H)',
|
||||
"138920.KS": 'KODEX 콩선물(H)',
|
||||
"271060.KS": 'KODEX 3대농산물선물(H)',
|
||||
"117700.KS": 'KODEX 건설',
|
||||
"266420.KS": 'KODEX 헬스케어',
|
||||
"276990.KS": 'KODEX 글로벌4차산업로보틱스(합성)',
|
||||
"244580.KS": 'KODEX 바이오',
|
||||
"091160.KS": 'KODEX 반도체',
|
||||
"140700.KS": 'KODEX 보험',
|
||||
"266410.KS": 'KODEX 필수소비재',
|
||||
"305720.KS": 'KODEX 2차전지산업',
|
||||
"266390.KS": 'KODEX 경기소비재',
|
||||
"117680.KS": 'KODEX 철강',
|
||||
"117460.KS": 'KODEX 에너지화학',
|
||||
"091170.KS": 'KODEX 은행',
|
||||
"376410.KS": 'TIGER 탄소효율그린뉴딜',
|
||||
"005930.KS": "삼성전자 / 반도체,AI",
|
||||
"000660.KS": "SK하이닉스 / 반도체,AI",
|
||||
"035420.KS": "NAVER / 플랫폼,AI",
|
||||
"035720.KS": "카카오 / 플랫폼,AI,핀테크",
|
||||
"051910.KS": "LG화학 / 2차전지,소재",
|
||||
"373220.KS": "LG에너지솔루션 / 2차전지",
|
||||
"096770.KS": "SK이노베이션 / 2차전지,친환경",
|
||||
"066570.KS": "LG전자 / 전장,AI,가전",
|
||||
"003550.KS": "LG / 지주,전지,AI",
|
||||
"005380.KS": "현대차 / 전기차,수소차",
|
||||
"000270.KS": "기아 / 전기차,수소차",
|
||||
"086520.KS": "에코프로 / 2차전지 소재",
|
||||
"336370.KS": "솔루스첨단소재 / 2차전지,소재",
|
||||
"009150.KS": "삼성전기 / 전장,MLCC",
|
||||
"006400.KS": "삼성SDI / 2차전지",
|
||||
"011170.KS": "롯데케미칼 / 2차전지,소재",
|
||||
"010950.KS": "S-Oil / 친환경,정유",
|
||||
"034730.KS": "SK / 지주,AI,친환경",
|
||||
"028260.KS": "삼성물산 / 바이오,건설",
|
||||
"207940.KS": "삼성바이오로직스 / 바이오,CMO",
|
||||
"068270.KS": "셀트리온 / 바이오,항체치료제",
|
||||
"196170.KS": "알테오젠 / 바이오,바이오시밀러",
|
||||
"051900.KS": "LG생활건강 / 소비재,중국",
|
||||
"003490.KS": "대한항공 / 항공,물류",
|
||||
"005935.KS": "삼성전자우 / 반도체",
|
||||
"000810.KS": "삼성화재 / 보험,금융",
|
||||
"105560.KS": "KB금융 / 금융,디지털전환",
|
||||
"055550.KS": "신한지주 / 금융,디지털전환",
|
||||
"316140.KS": "우리금융지주 / 금융",
|
||||
"086790.KS": "하나금융지주 / 금융",
|
||||
"032830.KS": "삼성생명 / 보험",
|
||||
"003670.KS": "포스코홀딩스 / 2차전지,철강,수소",
|
||||
"036570.KS": "엔씨소프트 / 게임,AI",
|
||||
"011200.KS": "HMM / 해운,물류",
|
||||
"005940.KS": "NH투자증권 / 금융",
|
||||
"010130.KS": "고려아연 / 비철금속,2차전지",
|
||||
"001510.KS": "SK증권 / 금융",
|
||||
"017670.KS": "SK텔레콤 / 5G,AI",
|
||||
"030200.KS": "KT / 5G,AI",
|
||||
"033780.KS": "KT&G / 소비재,담배",
|
||||
"034020.KS": "두산에너빌리티 / 원전,친환경",
|
||||
}
|
||||
# --- 추세 / 레짐 ---
|
||||
TREND_RANGE_MA_GAP_PCT = 0.5
|
||||
|
||||
# --- 주문 ---
|
||||
DEFAULT_BUY_KRW = int(os.getenv("DEFAULT_BUY_KRW", "30000"))
|
||||
RANGE_BUY_KRW = int(os.getenv("RANGE_BUY_KRW", "15000"))
|
||||
|
||||
# --- 수수료 (매수·매도 각각 적용, 시뮬레이션) ---
|
||||
TRADING_FEE_RATE = float(os.getenv("TRADING_FEE_RATE", "0.0005"))
|
||||
|
||||
# --- coins.db (downloader.py 적재 간격, 분) ---
|
||||
# 빗썸 분봉 API: 1,3,5,10,15,30,60,240 / 일봉 1440
|
||||
DOWNLOAD_INTERVALS: tuple[int, ...] = (3, 10, 15, 30, 60, 240, 1440)
|
||||
DOWNLOAD_MONTHS = int(os.getenv("DOWNLOAD_MONTHS", "6"))
|
||||
DB_PATH = "coins.db"
|
||||
|
||||
# --- 시뮬레이션 ---
|
||||
SIM_INITIAL_CASH_KRW = int(os.getenv("SIM_INITIAL_CASH_KRW", "200000"))
|
||||
SIM_MIN_ORDER_KRW = int(os.getenv("SIM_MIN_ORDER_KRW", "5000"))
|
||||
|
||||
# --- 실행 ---
|
||||
MONITOR_LOOP_SLEEP_SEC = 10
|
||||
COOLDOWN_FILE = "coins_buy_time.json"
|
||||
|
||||
22
discovered_rules.json
Normal file
22
discovered_rules.json
Normal 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
|
||||
}
|
||||
346
downloader.py
346
downloader.py
@@ -1,76 +1,312 @@
|
||||
"""
|
||||
WLD 과거 봉을 빗썸 API에서 받아 coins.db에 저장합니다.
|
||||
|
||||
- 최초: 최근 N개월 전량 적재
|
||||
- 이후: DB 마지막 시각 **이후** 봉만 추가 (증분)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
from config import *
|
||||
from HTS2 import HTS
|
||||
from monitor_coin import MonitorCoin
|
||||
import pandas as pd
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
monitorCoin = MonitorCoin()
|
||||
hts = HTS()
|
||||
from config import (
|
||||
COIN_NAME,
|
||||
DB_PATH,
|
||||
DOWNLOAD_INTERVALS,
|
||||
DOWNLOAD_MONTHS,
|
||||
KR_COINS,
|
||||
SYMBOL,
|
||||
)
|
||||
from monitor import Monitor
|
||||
|
||||
BITHUMB_MINUTE_INTERVALS = {1, 3, 5, 10, 15, 30, 60, 240}
|
||||
# 증분 시 마지막 봉 재확인용 겹침 봉 수
|
||||
INCREMENTAL_OVERLAP_BARS = 3
|
||||
|
||||
|
||||
def inserData(symbol, interval, data):
|
||||
conn = sqlite3.connect('coins.db')
|
||||
def bong_count_for_months(interval_minutes: int, months: int) -> int:
|
||||
"""N개월치 봉 개수(여유분 포함)."""
|
||||
days = months * 30
|
||||
if interval_minutes >= 1440:
|
||||
return days + 20
|
||||
bars_per_day = (24 * 60) // interval_minutes
|
||||
return days * bars_per_day + 200
|
||||
|
||||
|
||||
def bong_count_since(
|
||||
interval_minutes: int, last_ts: pd.Timestamp, overlap: int = INCREMENTAL_OVERLAP_BARS
|
||||
) -> int:
|
||||
"""마지막 저장 시각 이후 필요한 API 봉 수(겹침 포함)."""
|
||||
now = pd.Timestamp.now()
|
||||
if last_ts.tzinfo is not None and now.tzinfo is None:
|
||||
last_ts = last_ts.tz_localize(None)
|
||||
delta_min = max(0, (now - last_ts).total_seconds() / 60)
|
||||
bars = int(delta_min / interval_minutes) + overlap + 10
|
||||
return max(bars, 50)
|
||||
|
||||
|
||||
def months_cutoff(months: int) -> pd.Timestamp:
|
||||
"""N개월 전 시각."""
|
||||
return pd.Timestamp(datetime.now() - relativedelta(months=months))
|
||||
|
||||
|
||||
def trim_to_recent_months(data: pd.DataFrame, months: int) -> pd.DataFrame:
|
||||
"""최근 N개월 구간만 남깁니다."""
|
||||
if data is None or data.empty:
|
||||
return data
|
||||
cutoff = months_cutoff(months)
|
||||
if not isinstance(data.index, pd.DatetimeIndex):
|
||||
data = data.copy()
|
||||
data.index = pd.to_datetime(data.index)
|
||||
return data[data.index >= cutoff].copy()
|
||||
|
||||
|
||||
def interval_label(interval: int) -> str:
|
||||
if interval >= 1440:
|
||||
return "일봉(1440)"
|
||||
return f"{interval}분봉"
|
||||
|
||||
|
||||
def download_jobs() -> list[tuple[int, str]]:
|
||||
labels = {
|
||||
3: "3분",
|
||||
10: "10분",
|
||||
15: "15분",
|
||||
30: "30분",
|
||||
60: "60분(1시간)",
|
||||
240: "240분(4시간)",
|
||||
1440: "1440분(1일)",
|
||||
}
|
||||
jobs = []
|
||||
for iv in DOWNLOAD_INTERVALS:
|
||||
if iv < 1440 and iv not in BITHUMB_MINUTE_INTERVALS:
|
||||
print(f"경고: {iv}분봉은 빗썸 API 미지원 — 건너뜀")
|
||||
continue
|
||||
jobs.append((iv, labels.get(iv, f"{iv}분")))
|
||||
return jobs
|
||||
|
||||
|
||||
def ensure_table(cursor, table_name: str) -> None:
|
||||
cursor.execute(
|
||||
f"CREATE TABLE IF NOT EXISTS {table_name} "
|
||||
"(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
|
||||
"Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
|
||||
)
|
||||
cursor.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
|
||||
)
|
||||
|
||||
|
||||
def get_last_timestamp(
|
||||
symbol: str, interval: int, db_path: str = DB_PATH
|
||||
) -> pd.Timestamp | None:
|
||||
"""테이블에 저장된 해당 심볼의 마지막 봉 시각."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
table_name = f"{symbol}_{interval}"
|
||||
ensure_table(cursor, table_name)
|
||||
cursor.execute(
|
||||
f"SELECT MAX(ymdhms) FROM {table_name} WHERE CODE = ?",
|
||||
(symbol,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return pd.Timestamp(row[0])
|
||||
return None
|
||||
|
||||
tableName = "{}_{}".format(symbol, str(interval))
|
||||
# 테이블/키 생성
|
||||
cursor.execute("CREATE TABLE IF NOT EXISTS {} (CODE text, NAME text, ymdhms datetime, ymd text, hms text, Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)".format(tableName))
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS {}_idx on {}(CODE, ymdhms)".format(tableName, tableName))
|
||||
|
||||
for i in range(len(data)):
|
||||
ymd = data.index[i].strftime('%Y%m%d')
|
||||
hms = data.index[i].strftime('%H%M%S')
|
||||
ymdhms = data.index[i].strftime('%Y-%m-%d %H:%M:%S')
|
||||
Open = data.Open.iloc[i]
|
||||
High = data.High.iloc[i]
|
||||
Low = data.Low.iloc[i]
|
||||
Close = data.Close.iloc[i]
|
||||
Volume = data.Volume.iloc[i]
|
||||
def get_row_count(symbol: str, interval: int, db_path: str = DB_PATH) -> int:
|
||||
"""저장된 봉 개수."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
table_name = f"{symbol}_{interval}"
|
||||
ensure_table(cursor, table_name)
|
||||
cursor.execute(
|
||||
f"SELECT COUNT(*) FROM {table_name} WHERE CODE = ?",
|
||||
(symbol,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
cursor.execute("SELECT * from {} where CODE = ? and ymdhms = ?".format(tableName), (symbol, ymdhms, ))
|
||||
arr = cursor.fetchone()
|
||||
if arr:
|
||||
cursor.execute("UPDATE {} SET Close=?, Open=?, High=?, Low=?, Volume=? where CODE=? and ymdhms=?".format(tableName), (Close, Open, High, Low, Volume, symbol, ymdhms))
|
||||
else:
|
||||
cursor.execute("INSERT INTO {} (CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".format(tableName), (symbol, KR_COINS[symbol], ymdhms, ymd, hms, Close, Open, High, Low, Volume))
|
||||
|
||||
def filter_after_last(
|
||||
data: pd.DataFrame, last_ts: pd.Timestamp | None
|
||||
) -> pd.DataFrame:
|
||||
"""마지막 저장 시각보다 이후(>)인 봉만 반환."""
|
||||
if data is None or data.empty or last_ts is None:
|
||||
return data
|
||||
if not isinstance(data.index, pd.DatetimeIndex):
|
||||
data = data.copy()
|
||||
data.index = pd.to_datetime(data.index)
|
||||
last = pd.Timestamp(last_ts)
|
||||
return data[data.index > last].copy()
|
||||
|
||||
|
||||
def prune_before_cutoff(
|
||||
symbol: str, interval: int, months: int, db_path: str = DB_PATH
|
||||
) -> int:
|
||||
"""N개월보다 오래된 봉 삭제 (DB 용량 유지)."""
|
||||
cutoff = months_cutoff(months).strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
table_name = f"{symbol}_{interval}"
|
||||
ensure_table(cursor, table_name)
|
||||
cursor.execute(
|
||||
f"DELETE FROM {table_name} WHERE CODE = ? AND ymdhms < ?",
|
||||
(symbol, cutoff),
|
||||
)
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return
|
||||
return deleted
|
||||
|
||||
def download():
|
||||
for symbol in KR_COINS:
|
||||
print(symbol)
|
||||
|
||||
# 1일
|
||||
interval = 1440
|
||||
data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=5000)
|
||||
if data is not None and not data.empty:
|
||||
try:
|
||||
inserData(symbol, interval, data)
|
||||
except Exception as e:
|
||||
print(f"Error processing data for {symbol}: {str(e)}")
|
||||
def append_data(
|
||||
symbol: str,
|
||||
interval: int,
|
||||
data: pd.DataFrame,
|
||||
last_ts: pd.Timestamp | None = None,
|
||||
db_path: str = DB_PATH,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
마지막 시각 이후 봉만 INSERT합니다. 기존 데이터는 삭제하지 않습니다.
|
||||
|
||||
# 1시간
|
||||
interval = 60
|
||||
data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=10000)
|
||||
if data is not None and not data.empty:
|
||||
try:
|
||||
inserData(symbol, interval, data)
|
||||
except Exception as e:
|
||||
print(f"Error processing data for {symbol}: {str(e)}")
|
||||
Args:
|
||||
last_ts: None이면 전체 data 적재, 있으면 index > last_ts 만 적재
|
||||
|
||||
# 5분
|
||||
interval = 5
|
||||
data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=10000)
|
||||
if data is not None and not data.empty:
|
||||
try:
|
||||
inserData(symbol, interval, data)
|
||||
except Exception as e:
|
||||
print(f"Error processing data for {symbol}: {str(e)}")
|
||||
Returns:
|
||||
(추가된 행 수, 스킵된 행 수)
|
||||
"""
|
||||
if data is None or data.empty:
|
||||
return 0, 0
|
||||
|
||||
total = len(data)
|
||||
to_save = data if last_ts is None else filter_after_last(data, last_ts)
|
||||
skipped = total - len(to_save)
|
||||
|
||||
if to_save.empty:
|
||||
return 0, skipped
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
table_name = f"{symbol}_{interval}"
|
||||
ensure_table(cursor, table_name)
|
||||
|
||||
records = []
|
||||
for i in range(len(to_save)):
|
||||
ts = to_save.index[i]
|
||||
if hasattr(ts, "to_pydatetime"):
|
||||
ts = ts.to_pydatetime()
|
||||
ymd = ts.strftime("%Y%m%d")
|
||||
hms = ts.strftime("%H%M%S")
|
||||
ymdhms = ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
records.append(
|
||||
(
|
||||
symbol,
|
||||
KR_COINS[symbol],
|
||||
ymdhms,
|
||||
ymd,
|
||||
hms,
|
||||
float(to_save["Open"].iloc[i]),
|
||||
float(to_save["High"].iloc[i]),
|
||||
float(to_save["Low"].iloc[i]),
|
||||
float(to_save["Close"].iloc[i]),
|
||||
float(to_save["Volume"].iloc[i]),
|
||||
)
|
||||
)
|
||||
|
||||
cursor.executemany(
|
||||
f"INSERT INTO {table_name} "
|
||||
"(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
records,
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return len(records), skipped
|
||||
|
||||
|
||||
def download_symbol(
|
||||
monitor: Monitor,
|
||||
symbol: str,
|
||||
interval: int,
|
||||
months: int,
|
||||
) -> None:
|
||||
"""한 간격의 봉을 API로 받아 증분 저장합니다."""
|
||||
label = interval_label(interval)
|
||||
last_ts = get_last_timestamp(symbol, interval)
|
||||
existing = get_row_count(symbol, interval)
|
||||
|
||||
if last_ts is None:
|
||||
target = bong_count_for_months(interval, months)
|
||||
mode = "초기 적재"
|
||||
else:
|
||||
target = min(
|
||||
bong_count_since(interval, last_ts),
|
||||
bong_count_for_months(interval, months),
|
||||
)
|
||||
mode = f"증분 (마지막 {last_ts.strftime('%Y-%m-%d %H:%M:%S')} 이후)"
|
||||
|
||||
print(f"\n[{symbol}] {label} — {mode}")
|
||||
print(f" DB 기존 {existing}행 | API 목표 약 {target}봉")
|
||||
|
||||
data = monitor.get_coin_more_data(
|
||||
symbol, interval, bong_count=target, verbose=True
|
||||
)
|
||||
if data is None or data.empty:
|
||||
print(" -> API 데이터 없음")
|
||||
return
|
||||
|
||||
data = trim_to_recent_months(data, months)
|
||||
if data.empty:
|
||||
print(" -> 최근 N개월 필터 후 데이터 없음")
|
||||
return
|
||||
|
||||
inserted, skipped = append_data(symbol, interval, data, last_ts=last_ts)
|
||||
pruned = prune_before_cutoff(symbol, interval, months)
|
||||
|
||||
new_last = get_last_timestamp(symbol, interval)
|
||||
total = get_row_count(symbol, interval)
|
||||
print(f" -> API {len(data)}봉 | 추가 {inserted}행 | 스킵(기존) {skipped}행")
|
||||
if pruned > 0:
|
||||
print(f" -> {months}개월 이전 {pruned}행 정리")
|
||||
if new_last is not None:
|
||||
print(f" -> DB 합계 {total}행 | {data.index[0]} ~ {new_last}")
|
||||
|
||||
|
||||
def download(months: int | None = None) -> None:
|
||||
"""
|
||||
WLD 다중 분봉·일봉을 coins.db에 증분 적재합니다.
|
||||
|
||||
간격: config.DOWNLOAD_INTERVALS
|
||||
"""
|
||||
months = months or DOWNLOAD_MONTHS
|
||||
monitor = Monitor(cooldown_file=None)
|
||||
jobs = download_jobs()
|
||||
|
||||
intervals_str = ", ".join(str(iv) for iv, _ in jobs)
|
||||
print(f"=== {COIN_NAME} ({SYMBOL}) -> {DB_PATH} (증분 INSERT) ===")
|
||||
print(f"보관 {months}개월 | 간격(분): {intervals_str}")
|
||||
started = datetime.now()
|
||||
|
||||
for interval, desc in jobs:
|
||||
print(f"\n--- {desc} ---")
|
||||
try:
|
||||
download_symbol(monitor, SYMBOL, interval, months)
|
||||
except Exception as e:
|
||||
print(f"오류 interval={interval}: {e}")
|
||||
|
||||
elapsed = datetime.now() - started
|
||||
print(f"\n완료 (소요: {elapsed})")
|
||||
|
||||
return
|
||||
|
||||
if __name__ == "__main__":
|
||||
download()
|
||||
|
||||
566
monitor.py
566
monitor.py
@@ -1,34 +1,40 @@
|
||||
import pandas as pd
|
||||
from HTS2 import HTS
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
import sqlite3
|
||||
import telegram
|
||||
import time
|
||||
|
||||
try:
|
||||
import telegram
|
||||
except ImportError:
|
||||
telegram = None # type: ignore
|
||||
import requests
|
||||
import json
|
||||
import asyncio
|
||||
from multiprocessing import Pool
|
||||
import FinanceDataReader as fdr
|
||||
import numpy as np
|
||||
import os
|
||||
|
||||
from config import *
|
||||
from HTS2 import HTS
|
||||
import strategy
|
||||
|
||||
class Monitor(HTS):
|
||||
"""자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스"""
|
||||
"""WLD 코인 모니터링 및 매매 실행."""
|
||||
|
||||
last_signal = None
|
||||
cooldown_file = None
|
||||
|
||||
def __init__(self, cooldown_file='coins_buy_time.json') -> None:
|
||||
self.hts = HTS()
|
||||
HTS.__init__(self)
|
||||
# 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장)
|
||||
self.last_signal: dict[str, str] = {}
|
||||
if cooldown_file is not None:
|
||||
self.cooldown_file = cooldown_file
|
||||
self.buy_cooldown = self._load_buy_cooldown()
|
||||
else:
|
||||
self.cooldown_file = None
|
||||
self.buy_cooldown = {}
|
||||
|
||||
# ------------- Persistence -------------
|
||||
def _load_buy_cooldown(self) -> dict:
|
||||
@@ -106,13 +112,12 @@ class Monitor(HTS):
|
||||
|
||||
# ------------- Telegram -------------
|
||||
def _send_coin_msg(self, text: str) -> None:
|
||||
if telegram is None:
|
||||
print(f"[telegram skip] {text}")
|
||||
return
|
||||
coin_client = telegram.Bot(token=COIN_TELEGRAM_BOT_TOKEN)
|
||||
asyncio.run(coin_client.send_message(chat_id=COIN_TELEGRAM_CHAT_ID, text=text))
|
||||
|
||||
def _send_stock_msg(self, text: str) -> None:
|
||||
stock_client = telegram.Bot(token=STOCK_TELEGRAM_BOT_TOKEN)
|
||||
asyncio.run(stock_client.send_message(chat_id=STOCK_TELEGRAM_CHAT_ID, text=text))
|
||||
|
||||
def sendMsg(self, msg):
|
||||
try:
|
||||
pool = Pool(12)
|
||||
@@ -133,18 +138,6 @@ class Monitor(HTS):
|
||||
pool = Pool(12)
|
||||
pool.map(self._send_coin_msg, [payload])
|
||||
|
||||
def send_stock_telegram_message(self, message_list: list[str], header: str) -> None:
|
||||
payload = header + "\n"
|
||||
for i, message in enumerate(message_list):
|
||||
payload += message + "\n"
|
||||
if i + 1 % 20 == 0:
|
||||
pool = Pool(12)
|
||||
pool.map(self._send_stock_msg, [payload])
|
||||
payload = ''
|
||||
if len(message_list) % 20 != 0:
|
||||
pool = Pool(12)
|
||||
pool.map(self._send_stock_msg, [payload])
|
||||
|
||||
# ------------- Indicators -------------
|
||||
def normalize_data(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
columns_to_normalize = ['Open', 'High', 'Low', 'Close', 'Volume']
|
||||
@@ -224,236 +217,169 @@ class Monitor(HTS):
|
||||
|
||||
return data
|
||||
|
||||
# ------------- Strategy -------------
|
||||
def buy_sell_ticker_1h(self, symbol: str, data: pd.DataFrame, balances=None, is_inverse: bool = False) -> bool:
|
||||
# ------------- Strategy (strategy.py에 구현) -------------
|
||||
def annotate_signals(self, symbol: str, data: pd.DataFrame, simulation: bool | None = None) -> pd.DataFrame:
|
||||
"""strategy.annotate_signals에 위임."""
|
||||
return strategy.annotate_signals(
|
||||
symbol, data, simulation=simulation, config=strategy.ACTIVE_CONFIG
|
||||
)
|
||||
|
||||
def _is_in_cooldown(self, symbol: str, side: str) -> bool:
|
||||
"""매수/매도 쿨다운 여부."""
|
||||
if self.cooldown_file is None:
|
||||
return False
|
||||
last_dt = self.buy_cooldown.get(symbol, {}).get(side, {}).get("datetime")
|
||||
if not last_dt:
|
||||
return False
|
||||
limit = BUY_COOLDOWN_SEC if side == "buy" else SELL_COOLDOWN_SEC
|
||||
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||
if elapsed < limit:
|
||||
print(f"{symbol}: {side} 쿨다운 중 (남은 시간: {limit - elapsed:.0f}초)")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _record_trade(self, symbol: str, side: str, signal: str) -> None:
|
||||
"""매매 기록 저장."""
|
||||
if self.cooldown_file is None:
|
||||
return
|
||||
current_time = datetime.now()
|
||||
self.last_signal[symbol] = signal
|
||||
self.buy_cooldown.setdefault(symbol, {})[side] = {
|
||||
"datetime": current_time,
|
||||
"signal": signal,
|
||||
}
|
||||
self._save_buy_cooldown()
|
||||
|
||||
def execute_trade_signal(
|
||||
self,
|
||||
symbol: str,
|
||||
trade: strategy.TradeSignal,
|
||||
balances: dict | None = None,
|
||||
) -> bool:
|
||||
"""TradeSignal 1건에 대해 현물 매수 또는 매도를 실행합니다."""
|
||||
try:
|
||||
# 신호 생성 및 최신 포인트 확인
|
||||
data = self.annotate_signals(symbol, data)
|
||||
if data['point'].iloc[-1] != 1:
|
||||
return False
|
||||
coin_name = KR_COINS.get(symbol, symbol)
|
||||
signal_name = trade.signal
|
||||
close = trade.close
|
||||
|
||||
if is_inverse:
|
||||
# BUY_MINUTE_LIMIT 이내라면 매수하지 않음
|
||||
current_time = datetime.now()
|
||||
last_buy_dt = self.buy_cooldown.get(symbol, {}).get('sell', {}).get('datetime')
|
||||
if last_buy_dt:
|
||||
time_diff = current_time - last_buy_dt
|
||||
if time_diff.total_seconds() < BUY_MINUTE_LIMIT:
|
||||
print(f"{symbol}: 매수 금지 중 (남은 시간: {BUY_MINUTE_LIMIT - time_diff.total_seconds():.0f}초)")
|
||||
return False
|
||||
|
||||
# 인버스 데이터: 매수 신호를 매도로 처리 (fall_6p, deviation40 만 허용)
|
||||
# 허용된 인버스 매도 신호만 처리
|
||||
last_signal = str(data['signal'].iloc[-1]) if 'signal' in data.columns else ''
|
||||
if last_signal not in ['fall_6p', 'deviation40']:
|
||||
if trade.action == "sell":
|
||||
if self._is_in_cooldown(symbol, "sell"):
|
||||
return False
|
||||
available_balance = 0
|
||||
try:
|
||||
if balances and symbol in balances:
|
||||
available_balance = float(balances[symbol].get('balance', 0))
|
||||
except Exception:
|
||||
available_balance = 0
|
||||
if available_balance <= 0:
|
||||
available = 0.0
|
||||
if balances and symbol in balances:
|
||||
available = float(balances[symbol].get("balance", 0))
|
||||
if available <= 0:
|
||||
print(f"{symbol}: 매도 신호({signal_name}) — 보유 없음, 스킵")
|
||||
return False
|
||||
sell_amount = available_balance * 0.7
|
||||
_ = self.hts.sellCoinMarket(symbol, 0, sell_amount)
|
||||
if self.cooldown_file is not None:
|
||||
try:
|
||||
self.last_signal[symbol] = str(data['signal'].iloc[-1])
|
||||
except Exception:
|
||||
self.last_signal[symbol] = ''
|
||||
self.buy_cooldown.setdefault(symbol, {})['sell'] = {'datetime': current_time, 'signal': str(data['signal'].iloc[-1])}
|
||||
self._save_buy_cooldown()
|
||||
|
||||
print(f"{KR_COINS[symbol]} ({symbol}) [{data['signal'].iloc[-1]} 매도], 현재가: {data['Close'].iloc[-1]:.4f}")
|
||||
self.sendMsg("[KRW-COIN]\n" + f"• 매도 [COIN] {KR_COINS[symbol]} ({symbol}): {data['signal'].iloc[-1]} ({'₩'}{data['Close'].iloc[-1]:.4f})")
|
||||
sell_amount = available * strategy.get_sell_ratio(symbol, signal_name)
|
||||
if sell_amount <= 0:
|
||||
return False
|
||||
self.sellCoinMarket(symbol, 0, sell_amount)
|
||||
self._record_trade(symbol, "sell", signal_name)
|
||||
print(f"{coin_name} ({symbol}) [매도 {signal_name}] ₩{close:.4f}, 수량 {sell_amount:.6f}")
|
||||
self.sendMsg(
|
||||
f"[KRW-COIN]\n• 매도 {coin_name} ({symbol}): {signal_name} ₩{close:.4f}"
|
||||
)
|
||||
return True
|
||||
|
||||
else:
|
||||
check_5_week_lowest = False
|
||||
|
||||
# BUY_MINUTE_LIMIT 이내라면 매수하지 않음
|
||||
current_time = datetime.now()
|
||||
last_buy_dt = self.buy_cooldown.get(symbol, {}).get('buy', {}).get('datetime')
|
||||
if last_buy_dt:
|
||||
time_diff = current_time - last_buy_dt
|
||||
if time_diff.total_seconds() < BUY_MINUTE_LIMIT:
|
||||
print(f"{symbol}: 매수 금지 중 (남은 시간: {BUY_MINUTE_LIMIT - time_diff.total_seconds():.0f}초)")
|
||||
return False
|
||||
|
||||
try:
|
||||
# 5주봉이 20주봉이나 40주봉보다 아래에 있는지 체크
|
||||
# Convert hourly data to week-based rolling periods (5, 20, 40 weeks)
|
||||
hours_in_week = 24 * 7 # 168 hours
|
||||
period_5w = 5 * hours_in_week # 840 hours
|
||||
period_20w = 20 * hours_in_week # 3,360 hours
|
||||
period_40w = 40 * hours_in_week # 6,720 hours
|
||||
|
||||
if len(data) >= period_40w:
|
||||
wma5 = data['Close'].rolling(window=period_5w).mean().iloc[-1]
|
||||
wma20 = data['Close'].rolling(window=period_20w).mean().iloc[-1]
|
||||
wma40 = data['Close'].rolling(window=period_40w).mean().iloc[-1]
|
||||
|
||||
# 5-week MA is the lowest among 5, 20, 40 week MAs
|
||||
if (wma5 < wma20) and (wma5 < wma40):
|
||||
check_5_week_lowest = True
|
||||
|
||||
except Exception:
|
||||
# Ignore errors in MA calculation so as not to block trading logic
|
||||
pass
|
||||
|
||||
# 체크: fall_6p
|
||||
buy_amount = 5100
|
||||
current_time = datetime.now()
|
||||
if data['signal'].iloc[-1] == 'fall_6p':
|
||||
if data['Close'].iloc[-1] > 100:
|
||||
buy_amount = 300000
|
||||
else:
|
||||
buy_amount = 150000
|
||||
elif data['signal'].iloc[-1] == 'movingaverage':
|
||||
buy_amount = 10000
|
||||
elif data['signal'].iloc[-1] == 'deviation40':
|
||||
buy_amount = 30000
|
||||
elif data['signal'].iloc[-1] == 'deviation240':
|
||||
buy_amount = 7000
|
||||
elif data['signal'].iloc[-1] == 'deviation1440':
|
||||
if symbol in ['BONK', 'PEPE', 'TON']:
|
||||
buy_amount = 50000
|
||||
else:
|
||||
buy_amount = 70000
|
||||
|
||||
if data['signal'].iloc[-1] in ['movingaverage', 'deviation40', 'deviation240', 'deviation1440']:
|
||||
if check_5_week_lowest:
|
||||
buy_amount *= 2
|
||||
|
||||
# 매수를 진행함
|
||||
buy_amount = self.hts.buyCoinMarket(symbol, buy_amount)
|
||||
|
||||
# 최근 매수 신호를 함께 기록하여 [신규] 포맷으로 저장
|
||||
if self.cooldown_file is not None:
|
||||
try:
|
||||
self.last_signal[symbol] = str(data['signal'].iloc[-1])
|
||||
except Exception:
|
||||
self.last_signal[symbol] = ''
|
||||
self.buy_cooldown.setdefault(symbol, {})['buy'] = {'datetime': current_time, 'signal': str(data['signal'].iloc[-1])}
|
||||
|
||||
# 매수를 저장함
|
||||
self._save_buy_cooldown()
|
||||
|
||||
print(f"{KR_COINS[symbol]} ({symbol}) [{data['signal'].iloc[-1]}], 현재가: {data['Close'].iloc[-1]:.4f}, {int(BUY_MINUTE_LIMIT/60)}분간 매수 금지 시작")
|
||||
self.sendMsg("{}".format(self.format_message(symbol, KR_COINS[symbol], data['Close'].iloc[-1], data['signal'].iloc[-1], buy_amount)))
|
||||
if self._is_in_cooldown(symbol, "buy"):
|
||||
return False
|
||||
buy_amount = strategy.get_buy_amount(
|
||||
symbol, signal_name, close, trend=trade.trend
|
||||
)
|
||||
if strategy.should_double_buy(symbol, signal_name, pd.DataFrame()):
|
||||
buy_amount *= 2
|
||||
executed = self.buyCoinMarket(symbol, buy_amount)
|
||||
self._record_trade(symbol, "buy", signal_name)
|
||||
print(
|
||||
f"{coin_name} ({symbol}) [매수 {signal_name}] ₩{close:.4f} "
|
||||
f"({buy_amount} KRW, 추세={trade.trend})"
|
||||
)
|
||||
self.sendMsg(
|
||||
self.format_message(
|
||||
symbol, coin_name, close, signal_name, executed or buy_amount
|
||||
)
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error buying {symbol}: {str(e)}")
|
||||
print(f"Error trading {symbol}: {str(e)}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def annotate_signals(self, symbol: str, data: pd.DataFrame, simulation: bool | None = None) -> pd.DataFrame:
|
||||
data = data.copy()
|
||||
data['signal'] = ''
|
||||
data['point'] = 0
|
||||
if data['point'].iloc[-1] != 1:
|
||||
for i in range(1, len(data)):
|
||||
if all(data[f'MA{n}'].iloc[i] < data['MA720'].iloc[i] for n in [5, 20, 40, 120, 200, 240]) and \
|
||||
all(data[f'MA{n}'].iloc[i] > data[f'MA{n}'].iloc[i - 1] for n in [5, 20, 40, 120, 200, 240]) and \
|
||||
data['MA720'].iloc[i] < data['MA1440'].iloc[i]:
|
||||
data.at[data.index[i], 'signal'] = 'movingaverage'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'movingaverage'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
def process_wld_mtf(self, symbol: str, balances: dict | None = None) -> None:
|
||||
"""
|
||||
WLD MTF: 모든 봉 BB 상태 비교 후 정책에 따라 매수/매도.
|
||||
|
||||
if data['Deviation40'].iloc[i - 1] < data['Deviation40'].iloc[i] and data['Deviation40'].iloc[i - 1] <= 90:
|
||||
data.at[data.index[i], 'signal'] = 'deviation40'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'deviation40'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
mtf_bb_policy.json 이 있으면 해당 정책, 없으면 ACTIVE_MTF_POLICY 사용.
|
||||
"""
|
||||
from mtf_bb import load_frames_from_db, load_policy, print_latest_states
|
||||
|
||||
if symbol not in ['BONK']:
|
||||
if symbol in ['TRX']:
|
||||
if data['Deviation240'].iloc[i - 1] < data['Deviation240'].iloc[i] and data['Deviation240'].iloc[i - 1] <= 98:
|
||||
data.at[data.index[i], 'signal'] = 'deviation240'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'deviation240'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
else:
|
||||
if data['Deviation240'].iloc[i - 1] < data['Deviation240'].iloc[i] and data['Deviation240'].iloc[i - 1] <= 90:
|
||||
data.at[data.index[i], 'signal'] = 'deviation240'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'deviation240'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
try:
|
||||
frames = load_frames_from_db(self, symbol)
|
||||
if not frames:
|
||||
print(f"Data for {symbol}: 로드된 봉 없음.")
|
||||
return
|
||||
|
||||
if symbol in ['TON']:
|
||||
if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 89:
|
||||
data.at[data.index[i], 'signal'] = 'deviation1440'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'deviation1440'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
elif symbol in ['XRP']:
|
||||
if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 90:
|
||||
data.at[data.index[i], 'signal'] = 'deviation1440'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'deviation1440'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
elif symbol in ['BONK']:
|
||||
if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 76:
|
||||
data.at[data.index[i], 'signal'] = 'deviation1440'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'deviation1440'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
else:
|
||||
if data['Deviation1440'].iloc[i - 1] < data['Deviation1440'].iloc[i] and data['Deviation1440'].iloc[i - 1] <= 80:
|
||||
data.at[data.index[i], 'signal'] = 'deviation1440'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'deviation1440'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
df_1d = frames.get(TREND_INTERVAL_1D)
|
||||
df_1h = frames.get(TREND_INTERVAL_1H)
|
||||
if df_1d is None or df_1d.empty:
|
||||
df_1d = frames.get(ENTRY_INTERVAL)
|
||||
if df_1h is None or df_1h.empty:
|
||||
df_1h = frames.get(ENTRY_INTERVAL)
|
||||
|
||||
# Deviation720 상향 돌파 매수 (92, 93)
|
||||
try:
|
||||
prev_d720 = data['Deviation720'].iloc[i - 1]
|
||||
curr_d720 = data['Deviation720'].iloc[i]
|
||||
# 92 상향 돌파
|
||||
if prev_d720 < 92 and curr_d720 >= 92:
|
||||
data.at[data.index[i], 'signal'] = 'Deviation720'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'Deviation720'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
# 93 상향 돌파
|
||||
if prev_d720 < 93 and curr_d720 >= 93:
|
||||
data.at[data.index[i], 'signal'] = 'Deviation720'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'Deviation720'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
except Exception:
|
||||
pass
|
||||
policy = load_policy() or strategy.ACTIVE_MTF_POLICY
|
||||
cfg = strategy.ACTIVE_CONFIG
|
||||
print_latest_states(frames, cfg)
|
||||
print(
|
||||
f"MTF 정책: {policy.name} | "
|
||||
f"매수={policy.buy_interval}분 | 매도={policy.sell_interval}분 | "
|
||||
f"확인={list(policy.buy_confirm_intervals)}"
|
||||
)
|
||||
|
||||
try:
|
||||
prev_low = data['Low'].iloc[i - 1]
|
||||
curr_close = data['Close'].iloc[i]
|
||||
curr_low = data['Low'].iloc[i]
|
||||
cond_close_drop = curr_close <= prev_low * 0.94
|
||||
cond_low_drop = curr_low <= prev_low * 0.94
|
||||
if cond_close_drop or cond_low_drop:
|
||||
data.at[data.index[i], 'signal'] = 'fall_6p'
|
||||
data.at[data.index[i], 'point'] = 1
|
||||
if not simulation and data['point'][-3:].sum() > 0:
|
||||
data.at[data.index[-1], 'signal'] = 'fall_6p'
|
||||
data.at[data.index[-1], 'point'] = 1
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
trend = strategy.get_trend(df_1d, df_1h)
|
||||
print(f"{symbol} 추세: {trend}")
|
||||
|
||||
entry = frames.get(ENTRY_INTERVAL)
|
||||
trade = strategy.evaluate(
|
||||
symbol,
|
||||
entry if entry is not None else frames[policy.buy_interval],
|
||||
df_1h,
|
||||
df_1d,
|
||||
config=cfg,
|
||||
frames=frames,
|
||||
policy=policy,
|
||||
)
|
||||
if trade is None:
|
||||
return
|
||||
self.execute_trade_signal(symbol, trade, balances=balances)
|
||||
except Exception as e:
|
||||
print(f"Error processing {symbol}: {str(e)}")
|
||||
|
||||
def process_symbol(
|
||||
self,
|
||||
symbol: str,
|
||||
interval: int | None = None,
|
||||
balances: dict | None = None,
|
||||
use_inverse: bool = False,
|
||||
) -> None:
|
||||
"""하위 호환: MTF 전략으로 위임 (use_inverse 무시)."""
|
||||
self.process_wld_mtf(symbol, balances=balances)
|
||||
|
||||
def load_balances_dict(self) -> dict:
|
||||
"""getBalances() 결과를 currency 키 dict로 변환."""
|
||||
tmps = self.getBalances()
|
||||
balances = {}
|
||||
for tmp in tmps:
|
||||
balances[tmp["currency"]] = {
|
||||
"balance": float(tmp["balance"]),
|
||||
"avg_buy_price": float(tmp["avg_buy_price"]),
|
||||
}
|
||||
return balances
|
||||
|
||||
# ------------- Formatting -------------
|
||||
def format_message(self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float) -> str:
|
||||
message = f"[매수] {symbol_name} ({symbol}): "
|
||||
def format_message(
|
||||
self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float
|
||||
) -> str:
|
||||
message = f"[매수] {symbol_name} ({symbol}) [{signal}]: "
|
||||
|
||||
if int(close) >= 100:
|
||||
message += f"₩{close}"
|
||||
@@ -472,12 +398,6 @@ class Monitor(HTS):
|
||||
message += f"[{signal}]"
|
||||
return message
|
||||
|
||||
def format_ma_message(self, info: dict, market_type: str) -> str:
|
||||
prefix = '상승 ' if info.get('alert') else ''
|
||||
message = prefix + f"[{market_type}] {info['name']} ({info['symbol']}) "
|
||||
message += f"{'$' if market_type == 'US' else '₩'}({info['price']:.4f}) \n"
|
||||
return message
|
||||
|
||||
# ------------- Data fetch -------------
|
||||
def get_coin_data(self, symbol: str, interval: int = 60, to: str | None = None, retries: int = 3) -> pd.DataFrame | None:
|
||||
for attempt in range(retries):
|
||||
@@ -520,96 +440,146 @@ class Monitor(HTS):
|
||||
continue
|
||||
return None
|
||||
|
||||
def get_coin_more_data(self, symbol: str, interval: int, bong_count: int = 3000) -> pd.DataFrame:
|
||||
def get_coin_more_data(
|
||||
self,
|
||||
symbol: str,
|
||||
interval: int,
|
||||
bong_count: int = 3000,
|
||||
verbose: bool = False,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
빗썸 API를 반복 호출해 bong_count개까지 과거 봉을 수집합니다.
|
||||
|
||||
Args:
|
||||
verbose: True면 수집 진행 상황을 출력합니다.
|
||||
"""
|
||||
to = datetime.now()
|
||||
data: pd.DataFrame | None = None
|
||||
step = 0
|
||||
while data is None or len(data) < bong_count:
|
||||
step += 1
|
||||
if data is None:
|
||||
data = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
chunk = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
data = chunk
|
||||
else:
|
||||
previous_count = len(data)
|
||||
df = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
data = pd.concat([data, df], ignore_index=True)
|
||||
if previous_count == len(data):
|
||||
if df is not None and not df.empty:
|
||||
data = pd.concat([data, df], ignore_index=True)
|
||||
if df is None or df.empty or previous_count == len(data):
|
||||
if verbose:
|
||||
print(f" API 추가 데이터 없음 (수집 {len(data)}봉)")
|
||||
break
|
||||
if verbose and (step == 1 or step % 5 == 0 or len(data) >= bong_count):
|
||||
label = "일봉" if interval >= 1440 else f"{interval}분"
|
||||
print(f" [{label}] 요청 {step}회 — 누적 {len(data)}/{bong_count}봉")
|
||||
time.sleep(0.3)
|
||||
to = to - relativedelta(minutes=interval * 200)
|
||||
data = data.set_index('datetime')
|
||||
if data is None or data.empty:
|
||||
return pd.DataFrame()
|
||||
data = data.set_index("datetime")
|
||||
data = data.sort_index()
|
||||
data = data.drop_duplicates(keep='first')
|
||||
data = data.drop_duplicates(keep="first")
|
||||
data["datetime"] = data.index
|
||||
return data
|
||||
|
||||
def get_coin_saved_data(self, symbol: str, interval: int, data: pd.DataFrame) -> pd.DataFrame:
|
||||
conn = sqlite3.connect('coins.db')
|
||||
def get_coin_saved_data(
|
||||
self, symbol: str, interval: int, data: pd.DataFrame, db_path: str = "coins.db"
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
coins.db에서 저장된 봉을 읽고, API로 받은 최신 봉을 DB에 반영합니다.
|
||||
|
||||
downloader.py로 미리 적재해 두면 장기 MA 계산에 유리합니다.
|
||||
"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
table_name = f"{symbol}_{interval}"
|
||||
cursor.execute(
|
||||
f"CREATE TABLE IF NOT EXISTS {table_name} "
|
||||
"(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
|
||||
"Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
|
||||
)
|
||||
cursor.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
|
||||
)
|
||||
|
||||
for i in range(1, len(data)):
|
||||
cursor.execute("SELECT * from {}_{} where CODE = ? and ymdhms = ?".format(symbol, str(interval)), (symbol, data['datetime'].iloc[-i].strftime('%Y-%m-%d %H:%M:%S')),)
|
||||
arr = cursor.fetchone()
|
||||
if not arr:
|
||||
ymdhms = data["datetime"].iloc[-i].strftime("%Y-%m-%d %H:%M:%S")
|
||||
cursor.execute(
|
||||
f"SELECT 1 FROM {table_name} WHERE CODE = ? AND ymdhms = ?",
|
||||
(symbol, ymdhms),
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
cursor.execute(
|
||||
"INSERT INTO {}_{} (CODE, NAME, ymdhms, ymd, hms, close, open, high, low, volume) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".format(symbol, interval),
|
||||
f"INSERT INTO {table_name} "
|
||||
"(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
symbol,
|
||||
KR_COINS[symbol],
|
||||
data['datetime'].iloc[-i].strftime('%Y-%m-%d %H:%M:%S'),
|
||||
data['datetime'].iloc[-i].strftime('%Y%m%d'),
|
||||
data['datetime'].iloc[-i].strftime('%H%M%S'),
|
||||
data['Close'].iloc[-i],
|
||||
data['Open'].iloc[-i],
|
||||
data['High'].iloc[-i],
|
||||
data['Low'].iloc[-i],
|
||||
data['Volume'].iloc[-i],
|
||||
ymdhms,
|
||||
data["datetime"].iloc[-i].strftime("%Y%m%d"),
|
||||
data["datetime"].iloc[-i].strftime("%H%M%S"),
|
||||
data["Close"].iloc[-i],
|
||||
data["Open"].iloc[-i],
|
||||
data["High"].iloc[-i],
|
||||
data["Low"].iloc[-i],
|
||||
data["Volume"].iloc[-i],
|
||||
),
|
||||
)
|
||||
else:
|
||||
break
|
||||
cursor.execute("select * from (SELECT Open,Close,High,Low,Volume,ymdhms as datetime from {}_{} order by ymdhms desc limit 7000) subquery order by datetime".format(symbol, str(interval)))
|
||||
|
||||
cursor.execute(
|
||||
f"SELECT Open, Close, High, Low, Volume, ymdhms AS datetime "
|
||||
f"FROM (SELECT Open, Close, High, Low, Volume, ymdhms "
|
||||
f"FROM {table_name} ORDER BY ymdhms DESC LIMIT 7000) "
|
||||
f"ORDER BY datetime"
|
||||
)
|
||||
result = cursor.fetchall()
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
df = pd.DataFrame(result)
|
||||
df.columns = ['Open', 'Close', 'High', 'Low', 'Volume', 'datetime']
|
||||
df = df.set_index('datetime')
|
||||
|
||||
if not result:
|
||||
return pd.DataFrame(
|
||||
columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
|
||||
)
|
||||
|
||||
df = pd.DataFrame(
|
||||
result, columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
|
||||
)
|
||||
df = df.set_index("datetime")
|
||||
df = df.sort_index()
|
||||
df['datetime'] = df.index
|
||||
df["datetime"] = df.index
|
||||
return df
|
||||
|
||||
def get_coin_some_data(self, symbol: str, interval: int) -> pd.DataFrame:
|
||||
"""
|
||||
WLD 시세: API 최신 봉 + coins.db 과거 봉 + 1분봉 최신 1개를 합칩니다.
|
||||
|
||||
DB가 비어 있으면 API·1분봉만 사용합니다. 과거 적재는 downloader.py 실행.
|
||||
"""
|
||||
data = self.get_coin_data(symbol, interval)
|
||||
if data is None or data.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
data_1 = self.get_coin_data(symbol, interval=1)
|
||||
data_1.at[data_1.index[-1], 'Volume'] = data_1['Volume'].iloc[-1] * 60
|
||||
if data_1 is not None and not data_1.empty:
|
||||
data_1 = data_1.copy()
|
||||
data_1.at[data_1.index[-1], "Volume"] = data_1["Volume"].iloc[-1] * 60
|
||||
|
||||
saved_data = self.get_coin_saved_data(symbol, interval, data)
|
||||
data = pd.concat([data, saved_data, data_1.iloc[[-1]]], ignore_index=True)
|
||||
data['datetime'] = pd.to_datetime(data['datetime'], format='%Y-%m-%d %H:%M:%S')
|
||||
data = data.set_index('datetime')
|
||||
data = data.sort_index()
|
||||
data = data.drop_duplicates(keep='first')
|
||||
data["datetime"] = data.index
|
||||
return data
|
||||
parts = [data]
|
||||
if saved_data is not None and not saved_data.empty:
|
||||
parts.append(saved_data)
|
||||
if data_1 is not None and not data_1.empty:
|
||||
parts.append(data_1.iloc[[-1]])
|
||||
|
||||
def get_kr_stock_data(self, symbol: str, retries: int = 3) -> pd.DataFrame | None:
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
end = datetime.now()
|
||||
start = end - timedelta(days=300)
|
||||
data = fdr.DataReader(symbol, start.strftime('%Y-%m-%d'), end.strftime('%Y-%m-%d'))
|
||||
if not data.empty:
|
||||
data = data.rename(columns={
|
||||
'Open': 'Open',
|
||||
'High': 'High',
|
||||
'Low': 'Low',
|
||||
'Close': 'Close',
|
||||
'Volume': 'Volume',
|
||||
})
|
||||
return data
|
||||
print(f"No data received for {symbol}, attempt {attempt + 1}")
|
||||
time.sleep(2)
|
||||
except Exception as e:
|
||||
print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}")
|
||||
if attempt < retries - 1:
|
||||
time.sleep(5)
|
||||
continue
|
||||
return None
|
||||
merged = pd.concat(parts, ignore_index=True)
|
||||
merged["datetime"] = pd.to_datetime(merged["datetime"], format="%Y-%m-%d %H:%M:%S")
|
||||
merged = merged.set_index("datetime")
|
||||
merged = merged.sort_index()
|
||||
merged = merged.drop_duplicates(keep="first")
|
||||
merged["datetime"] = merged.index
|
||||
return merged
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
110
monitor_stock.py
110
monitor_stock.py
@@ -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
222
mtf_bb.py
Normal 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
11
mtf_bb_policy.json
Normal 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"
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
yfinance
|
||||
pandas
|
||||
mplcursors
|
||||
numpy
|
||||
ccxt
|
||||
PyJWT
|
||||
pycurl
|
||||
schedule
|
||||
requests
|
||||
python-dateutil
|
||||
python-telegram-bot
|
||||
finance-datareader
|
||||
psutil
|
||||
mpld3
|
||||
plotly
|
||||
plotly
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"ADA": {
|
||||
"datetime": "2025-08-14T22:41:28.363958",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"APE": {
|
||||
"datetime": "2025-08-09T14:22:02.089619",
|
||||
"signal": "movingaverage"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"ARB": {
|
||||
"datetime": "2025-08-14T22:43:59.078775",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"BONK": {
|
||||
"datetime": "2025-08-14T22:41:42.247356",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"ENA": {
|
||||
"datetime": "2025-08-16T01:03:31.916209",
|
||||
"signal": "deviation240"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"HBAR": {
|
||||
"datetime": "2025-08-14T21:37:21.575425",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"KAIA": {
|
||||
"datetime": "2025-08-14T22:42:27.079125",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"LINK": {
|
||||
"datetime": "2025-08-14T22:42:38.780771",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"ONDO": {
|
||||
"datetime": "2025-08-14T22:04:53.097618",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"PENGU": {
|
||||
"datetime": "2025-08-16T07:53:40.994785",
|
||||
"signal": "deviation240"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"PEPE": {
|
||||
"datetime": "2025-08-14T22:06:10.012326",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"SAND": {
|
||||
"datetime": "2025-08-14T22:05:08.098364",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"SEI": {
|
||||
"datetime": "2025-08-14T21:36:00.600483",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"SHIB": {
|
||||
"datetime": "2025-08-14T22:05:20.734073",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"STORJ": {
|
||||
"datetime": "2025-08-14T23:32:08.979598",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"SUI": {
|
||||
"datetime": "2025-08-14T21:36:14.758922",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"UXLINK": {
|
||||
"datetime": "2025-08-14T22:05:36.242448",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"VIRTUAL": {
|
||||
"datetime": "2025-08-16T21:02:23.634183",
|
||||
"signal": "deviation1440"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"WLD": {
|
||||
"datetime": "2025-08-14T22:43:33.737340",
|
||||
"signal": "fall_6p"
|
||||
}
|
||||
}
|
||||
542
rule_discovery.py
Normal file
542
rule_discovery.py
Normal 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)
|
||||
962
simulation_1h.py
962
simulation_1h.py
@@ -1,333 +1,671 @@
|
||||
"""
|
||||
WLD 3분 BB 시뮬레이션.
|
||||
|
||||
기본: 하단 상향 돌파 매수, 상단 상향 돌파 매도.
|
||||
수수료 반영, 레짐/필터 조합 비교 지원.
|
||||
|
||||
python simulation_1h.py # discovered_rules HTML 차트 (기본)
|
||||
python simulation_1h.py discover # 모든 봉 특징 탐색 → discovered_rules.json
|
||||
python simulation_1h.py compare # 9종 조합 수익률 순위
|
||||
python simulation_1h.py mtf # 봉별 BB 비교 + MTF 시뮬
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
import plotly.graph_objs as go
|
||||
from plotly import subplots
|
||||
import plotly.io as pio
|
||||
from datetime import datetime
|
||||
pio.renderers.default = 'browser'
|
||||
from plotly import subplots
|
||||
|
||||
from config import *
|
||||
pio.renderers.default = "browser"
|
||||
|
||||
from config import (
|
||||
BUY_COOLDOWN_SEC,
|
||||
COIN_NAME,
|
||||
ENTRY_INTERVAL,
|
||||
SELL_COOLDOWN_SEC,
|
||||
SIM_INITIAL_CASH_KRW,
|
||||
SIM_MIN_ORDER_KRW,
|
||||
SYMBOL,
|
||||
TRADING_FEE_RATE,
|
||||
TREND_INTERVAL_1D,
|
||||
TREND_INTERVAL_1H,
|
||||
)
|
||||
from monitor import Monitor
|
||||
import strategy
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimTrade:
|
||||
dt: pd.Timestamp
|
||||
action: str
|
||||
signal: str
|
||||
price: float
|
||||
krw: float
|
||||
fee: float
|
||||
quantity: float
|
||||
pnl: float | None
|
||||
cash_after: float
|
||||
total_asset: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimResult:
|
||||
config_name: str
|
||||
trades: list[SimTrade]
|
||||
initial_cash: float
|
||||
final_cash: float
|
||||
final_coin_qty: float
|
||||
final_price: float
|
||||
realized_pnl: float
|
||||
total_fees: float
|
||||
final_asset: float
|
||||
total_return_pct: float
|
||||
trade_count: int
|
||||
win_count: int
|
||||
|
||||
|
||||
def run_backtest(
|
||||
df_3m: pd.DataFrame,
|
||||
df_1d: pd.DataFrame,
|
||||
df_1h: pd.DataFrame,
|
||||
config_name: str = "",
|
||||
initial_cash: float = SIM_INITIAL_CASH_KRW,
|
||||
min_order_krw: float = SIM_MIN_ORDER_KRW,
|
||||
fee_rate: float = TRADING_FEE_RATE,
|
||||
) -> SimResult:
|
||||
"""신호 순서대로 현물 매수/매도 시뮬레이션 (수수료 차감)."""
|
||||
cash = float(initial_cash)
|
||||
coin_qty = 0.0
|
||||
cost_basis = 0.0
|
||||
realized_pnl = 0.0
|
||||
total_fees = 0.0
|
||||
win_count = 0
|
||||
trades: list[SimTrade] = []
|
||||
last_buy_ts: pd.Timestamp | None = None
|
||||
last_sell_ts: pd.Timestamp | None = None
|
||||
|
||||
signals = df_3m[df_3m["point"] == 1].sort_index()
|
||||
|
||||
for ts, row in signals.iterrows():
|
||||
price = float(row["Close"])
|
||||
action = str(row.get("action", ""))
|
||||
signal_name = str(row.get("signal", ""))
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
trend_at = str(row.get("trend", "")) or strategy.get_trend_at(df_1d, df_1h, ts)
|
||||
if trend_at not in ("up", "down", "range"):
|
||||
trend_at = strategy.get_trend_at(df_1d, df_1h, ts)
|
||||
|
||||
if action == "buy":
|
||||
if last_buy_ts is not None:
|
||||
if (ts - last_buy_ts).total_seconds() < BUY_COOLDOWN_SEC:
|
||||
continue
|
||||
|
||||
buy_krw = float(
|
||||
strategy.get_buy_amount(SYMBOL, signal_name, price, trend_at)
|
||||
)
|
||||
buy_krw = max(min_order_krw, min(buy_krw, cash))
|
||||
fee = buy_krw * fee_rate
|
||||
total_cost = buy_krw + fee
|
||||
if buy_krw < min_order_krw or cash < total_cost:
|
||||
continue
|
||||
|
||||
qty = buy_krw / price
|
||||
cash -= total_cost
|
||||
total_fees += fee
|
||||
cost_basis += buy_krw
|
||||
coin_qty += qty
|
||||
last_buy_ts = ts
|
||||
|
||||
trades.append(
|
||||
SimTrade(
|
||||
dt=ts,
|
||||
action="매수",
|
||||
signal=signal_name,
|
||||
price=price,
|
||||
krw=buy_krw,
|
||||
fee=fee,
|
||||
quantity=qty,
|
||||
pnl=None,
|
||||
cash_after=cash,
|
||||
total_asset=cash + coin_qty * price,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if action == "sell":
|
||||
if coin_qty <= 0:
|
||||
continue
|
||||
if last_sell_ts is not None:
|
||||
if (ts - last_sell_ts).total_seconds() < SELL_COOLDOWN_SEC:
|
||||
continue
|
||||
|
||||
ratio = strategy.get_sell_ratio(SYMBOL, signal_name)
|
||||
sell_qty = min(coin_qty * ratio, coin_qty)
|
||||
sell_krw = sell_qty * price
|
||||
|
||||
if sell_krw < min_order_krw:
|
||||
if coin_qty * price < min_order_krw:
|
||||
continue
|
||||
sell_qty = coin_qty
|
||||
sell_krw = sell_qty * price
|
||||
|
||||
fee = sell_krw * fee_rate
|
||||
net = sell_krw - fee
|
||||
avg_cost = cost_basis / coin_qty
|
||||
sold_cost = avg_cost * sell_qty
|
||||
pnl = net - sold_cost
|
||||
|
||||
cash += net
|
||||
total_fees += fee
|
||||
cost_basis -= sold_cost
|
||||
coin_qty -= sell_qty
|
||||
realized_pnl += pnl
|
||||
if pnl > 0:
|
||||
win_count += 1
|
||||
if coin_qty < 1e-12:
|
||||
coin_qty = 0.0
|
||||
cost_basis = 0.0
|
||||
last_sell_ts = ts
|
||||
|
||||
trades.append(
|
||||
SimTrade(
|
||||
dt=ts,
|
||||
action="매도",
|
||||
signal=signal_name,
|
||||
price=price,
|
||||
krw=sell_krw,
|
||||
fee=fee,
|
||||
quantity=sell_qty,
|
||||
pnl=pnl,
|
||||
cash_after=cash,
|
||||
total_asset=cash + coin_qty * price,
|
||||
)
|
||||
)
|
||||
|
||||
final_price = float(df_3m["Close"].iloc[-1])
|
||||
final_asset = cash + coin_qty * final_price
|
||||
sell_trades = sum(1 for t in trades if t.action == "매도")
|
||||
|
||||
return SimResult(
|
||||
config_name=config_name,
|
||||
trades=trades,
|
||||
initial_cash=initial_cash,
|
||||
final_cash=cash,
|
||||
final_coin_qty=coin_qty,
|
||||
final_price=final_price,
|
||||
realized_pnl=realized_pnl,
|
||||
total_fees=total_fees,
|
||||
final_asset=final_asset,
|
||||
total_return_pct=(final_asset - initial_cash) / initial_cash * 100
|
||||
if initial_cash > 0
|
||||
else 0.0,
|
||||
trade_count=len(trades),
|
||||
win_count=win_count if sell_trades else 0,
|
||||
)
|
||||
|
||||
|
||||
def print_backtest_report(result: SimResult) -> None:
|
||||
fee_pct = TRADING_FEE_RATE * 100
|
||||
print("\n" + "=" * 80)
|
||||
print(
|
||||
f"[{result.config_name}] 시작 {result.initial_cash:,.0f}원 | "
|
||||
f"최소주문 {SIM_MIN_ORDER_KRW:,.0f}원 | 수수료 {fee_pct:.3f}%/쪽"
|
||||
)
|
||||
print("=" * 80)
|
||||
if not result.trades:
|
||||
print("체결 없음")
|
||||
else:
|
||||
print(
|
||||
f"{'일시':<18} {'구분':<4} {'신호':<22} {'가격':>9} {'금액':>10} "
|
||||
f"{'수수료':>8} {'수익':>10}"
|
||||
)
|
||||
print("-" * 80)
|
||||
for t in result.trades:
|
||||
pnl_s = f"{t.pnl:+,.0f}" if t.pnl is not None else "-"
|
||||
print(
|
||||
f"{t.dt.strftime('%Y-%m-%d %H:%M'):<18} {t.action:<4} {t.signal:<22} "
|
||||
f"{t.price:>9,.2f} {t.krw:>10,.0f} {t.fee:>8,.0f} {pnl_s:>10}"
|
||||
)
|
||||
print("-" * 80)
|
||||
sells = sum(1 for t in result.trades if t.action == "매도")
|
||||
win_rate = result.win_count / sells * 100 if sells else 0.0
|
||||
print(f"거래 횟수: {result.trade_count} (매도 {sells}회) | 승률: {win_rate:.1f}%")
|
||||
print(f"수수료 합계: {result.total_fees:,.0f}원")
|
||||
print(f"실현 손익(수수료 반영): {result.realized_pnl:+,.0f}원")
|
||||
print(
|
||||
f"최종 자산: {result.final_asset:,.0f}원 | "
|
||||
f"총수익: {result.final_asset - result.initial_cash:+,.0f}원 "
|
||||
f"({result.total_return_pct:+.2f}%)"
|
||||
)
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
def run_comparison(df_1d: pd.DataFrame, df_1h: pd.DataFrame, df_3m: pd.DataFrame) -> None:
|
||||
"""기법 조합별 수익률 비교 (수수료 포함)."""
|
||||
print(f"\n{'='*80}")
|
||||
print(f"전략 조합 비교 — {SYMBOL} 3분 | {df_3m.index[0]} ~ {df_3m.index[-1]}")
|
||||
print(f"시작 {SIM_INITIAL_CASH_KRW:,}원 | 수수료 {TRADING_FEE_RATE*100:.3f}%/매수·매도")
|
||||
print(f"{'='*80}")
|
||||
print(
|
||||
f"{'순위':<4} {'조합':<22} {'수익률':>9} {'최종자산':>12} "
|
||||
f"{'거래':>6} {'승률':>7} {'수수료':>10}"
|
||||
)
|
||||
print("-" * 80)
|
||||
|
||||
rows: list[tuple[SimResult, strategy.StrategyConfig]] = []
|
||||
for cfg in strategy.comparison_presets():
|
||||
df_sig = strategy.annotate_signals(
|
||||
SYMBOL,
|
||||
df_3m.copy(),
|
||||
simulation=True,
|
||||
df_1h=df_1h,
|
||||
df_1d=df_1d,
|
||||
config=cfg,
|
||||
)
|
||||
res = run_backtest(df_sig, df_1d, df_1h, config_name=cfg.name)
|
||||
rows.append((res, cfg))
|
||||
|
||||
rows.sort(key=lambda x: x[0].total_return_pct, reverse=True)
|
||||
|
||||
for rank, (res, cfg) in enumerate(rows, 1):
|
||||
sells = sum(1 for t in res.trades if t.action == "매도")
|
||||
wr = res.win_count / sells * 100 if sells else 0.0
|
||||
print(
|
||||
f"{rank:<4} {res.config_name:<22} {res.total_return_pct:>+8.2f}% "
|
||||
f"{res.final_asset:>12,.0f} {res.trade_count:>6} {wr:>6.1f}% "
|
||||
f"{res.total_fees:>10,.0f}"
|
||||
)
|
||||
|
||||
best_res, best_cfg = rows[0]
|
||||
print("-" * 80)
|
||||
print(f"1위: {best_cfg.name} ({best_res.total_return_pct:+.2f}%)")
|
||||
print(
|
||||
"실거래 적용: strategy.ACTIVE_CONFIG 를 1위 조합으로 맞추세요 "
|
||||
"(현재 ACTIVE_CONFIG.name=%s)" % strategy.ACTIVE_CONFIG.name
|
||||
)
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
|
||||
class Simulation:
|
||||
|
||||
def render_plotly(self, symbol: str, interval_minutes: int, data: pd.DataFrame, inverseData: pd.DataFrame) -> None:
|
||||
fig = subplots.make_subplots(
|
||||
rows=3, cols=1,
|
||||
subplot_titles=("캔들", "이격도/거래량", "장기 이격도"),
|
||||
shared_xaxes=True, horizontal_spacing=0.03, vertical_spacing=0.03,
|
||||
row_heights=[0.6, 0.2, 0.2]
|
||||
)
|
||||
|
||||
# Row 1: 캔들 + 이동평균 + 볼린저
|
||||
fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'], low=data['Low'], close=data['Close'], name='캔들'), row=1, col=1)
|
||||
for ma_col, color in [('MA5','red'),('MA20','blue'),('MA40','green'),('MA120','purple'),('MA200','brown'),('MA240','darkred'),('MA720','cyan'),('MA1440','magenta')]:
|
||||
if ma_col in data.columns:
|
||||
fig.add_trace(go.Scatter(x=data.index, y=data[ma_col], name=ma_col, mode='lines', line=dict(color=color, width=1)), row=1, col=1)
|
||||
if 'Lower' in data.columns and 'Upper' in data.columns:
|
||||
fig.add_trace(go.Scatter(x=data.index, y=data['Lower'], name='볼린저 하단', mode='lines', line=dict(color='grey', width=1, dash='dot')), row=1, col=1)
|
||||
fig.add_trace(go.Scatter(x=data.index, y=data['Upper'], name='볼린저 상단', mode='lines', line=dict(color='grey', width=1, dash='dot')), row=1, col=1)
|
||||
|
||||
# 매수 포인트
|
||||
for sig, color in [('movingaverage','red'),('deviation40','orange'),('Deviation720','blue'),('deviation1440','purple'),('fall_6p','black')]:
|
||||
pts = data[(data['point']==1) & (data['signal']==sig)]
|
||||
if len(pts)>0:
|
||||
fig.add_trace(go.Scatter(x=pts.index, y=pts['Close'], mode='markers', name=f'{sig} 매수', marker=dict(color=color, size=8, symbol='circle')), row=1, col=1)
|
||||
|
||||
# 매도 포인트: inverseData의 buy 신호 중 fall_6p, deviation40만 일반 그래프 가격축에 매도로 표시
|
||||
inv_sell_pts = inverseData[(inverseData['point']==1) & (inverseData['signal'].isin(['deviation40','fall_6p']))]
|
||||
if len(inv_sell_pts)>0:
|
||||
idx = inv_sell_pts.index.intersection(data.index)
|
||||
if len(idx)>0:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=idx,
|
||||
y=data.loc[idx, 'Close'],
|
||||
mode='markers',
|
||||
name='매도',
|
||||
marker=dict(color='orange', size=10, symbol='triangle-down')
|
||||
),
|
||||
row=1, col=1
|
||||
)
|
||||
|
||||
# Row 2: 이격도 + 거래량
|
||||
for dev_col, color, width in [('Deviation5','red',1),('Deviation20','blue',1),('Deviation40','green',2),('Deviation120','purple',1),('Deviation200','brown',1),('Deviation720','darkred',2),('Deviation720','cyan',1),('Deviation1440','magenta',1)]:
|
||||
if dev_col in data.columns:
|
||||
fig.add_trace(go.Scatter(x=data.index, y=data[dev_col], name=dev_col, mode='lines', line=dict(color=color, width=width)), row=2, col=1)
|
||||
if 'Volume' in data.columns:
|
||||
fig.add_trace(go.Bar(x=data.index, y=data['Volume'], name='거래량', marker_color='lightgray', opacity=0.5), row=2, col=1)
|
||||
|
||||
# Row 3: 장기 이격도 및 기준선
|
||||
for dev_col, color in [('Deviation720','darkred'),('Deviation1440','magenta')]:
|
||||
if dev_col in data.columns:
|
||||
fig.add_trace(go.Scatter(x=data.index, y=data[dev_col], name=f'{dev_col}(장기)', mode='lines', line=dict(color=color, width=2)), row=3, col=1)
|
||||
for h, color in [(90,'red'),(95,'green'),(100,'black')]:
|
||||
fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1)
|
||||
|
||||
# ----------------- 인버스용 트레이스 (초기 숨김) -----------------
|
||||
n_orig = len(fig.data)
|
||||
|
||||
# Row 1: 캔들/MA/볼린저 (inverseData)
|
||||
fig.add_trace(go.Candlestick(x=inverseData.index, open=inverseData['Open'], high=inverseData['High'], low=inverseData['Low'], close=inverseData['Close'], name='캔들(인버스)', showlegend=True, visible=False), row=1, col=1)
|
||||
for ma_col, color in [('MA5','red'),('MA20','blue'),('MA40','green'),('MA120','purple'),('MA200','brown'),('MA240','darkred'),('MA720','cyan'),('MA1440','magenta')]:
|
||||
if ma_col in inverseData.columns:
|
||||
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[ma_col], name=f'{ma_col}(인버스)', mode='lines', line=dict(color=color, width=1), showlegend=True, visible=False), row=1, col=1)
|
||||
if 'Lower' in inverseData.columns and 'Upper' in inverseData.columns:
|
||||
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData['Lower'], name='볼린저 하단(인버스)', mode='lines', line=dict(color='grey', width=1, dash='dot'), showlegend=True, visible=False), row=1, col=1)
|
||||
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData['Upper'], name='볼린저 상단(인버스)', mode='lines', line=dict(color='grey', width=1, dash='dot'), showlegend=True, visible=False), row=1, col=1)
|
||||
|
||||
# 인버스 매수 포인트: fall_6p, deviation40만 표시
|
||||
for sig, color in [('deviation40','orange'),('fall_6p','black')]:
|
||||
pts_inv = inverseData[(inverseData['point']==1) & (inverseData['signal']==sig)]
|
||||
if len(pts_inv)>0:
|
||||
fig.add_trace(go.Scatter(x=pts_inv.index, y=inverseData.loc[pts_inv.index,'Close'], mode='markers', name=f'{sig} 매수(인버스)', marker=dict(color=color, size=8, symbol='circle'), showlegend=True, visible=False), row=1, col=1)
|
||||
|
||||
# 인버스 보기에서의 매도 포인트: 일반 그래프의 매수를 인버스 그래프의 매도로 표시 (모든 매수 신호 반영)
|
||||
normal_to_inv_sell = data[(data['point']==1)]
|
||||
if len(normal_to_inv_sell) > 0:
|
||||
idx2 = normal_to_inv_sell.index.intersection(inverseData.index)
|
||||
if len(idx2) > 0:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=idx2,
|
||||
y=inverseData.loc[idx2, 'Close'],
|
||||
mode='markers',
|
||||
name='매도(일반→인버스)',
|
||||
marker=dict(color='orange', size=10, symbol='triangle-down'),
|
||||
showlegend=True,
|
||||
visible=False
|
||||
),
|
||||
row=1, col=1
|
||||
)
|
||||
|
||||
# Row 2: 이격도 + 거래량 (inverseData)
|
||||
for dev_col, color, width in [('Deviation5','red',1),('Deviation20','blue',1),('Deviation40','green',2),('Deviation120','purple',1),('Deviation200','brown',1),('Deviation720','darkred',2),('Deviation720','cyan',1),('Deviation1440','magenta',1)]:
|
||||
if dev_col in inverseData.columns:
|
||||
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[dev_col], name=f'{dev_col}(인버스)', mode='lines', line=dict(color=color, width=width), showlegend=True, visible=False), row=2, col=1)
|
||||
if 'Volume' in inverseData.columns:
|
||||
fig.add_trace(go.Bar(x=inverseData.index, y=inverseData['Volume'], name='거래량(인버스)', marker_color='lightgray', opacity=0.5, showlegend=True, visible=False), row=2, col=1)
|
||||
|
||||
# Row 3: 장기 이격도 (inverseData)
|
||||
for dev_col, color in [('Deviation720','darkred'),('Deviation1440','magenta')]:
|
||||
if dev_col in inverseData.columns:
|
||||
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[dev_col], name=f'{dev_col}(장기-인버스)', mode='lines', line=dict(color=color, width=2), showlegend=True, visible=False), row=3, col=1)
|
||||
|
||||
n_total = len(fig.data)
|
||||
n_inv = n_total - n_orig
|
||||
visible_orig = [True]*n_orig + [False]*n_inv
|
||||
visible_inv = [False]*n_orig + [True]*n_inv
|
||||
legendtitle_orig = {'text': '일반 그래프'}
|
||||
legendtitle_inv = {'text': '인버스 그래프'}
|
||||
|
||||
fig.update_layout(
|
||||
height=1000,
|
||||
margin=dict(t=180, l=40, r=240, b=40),
|
||||
title=dict(
|
||||
text=f"{symbol}, {interval_minutes} 분봉, ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})",
|
||||
x=0.5,
|
||||
xanchor='center',
|
||||
y=0.995,
|
||||
yanchor='top',
|
||||
pad=dict(t=10, b=12)
|
||||
),
|
||||
xaxis_rangeslider_visible=False,
|
||||
xaxis1_rangeslider_visible=False,
|
||||
xaxis2_rangeslider_visible=False,
|
||||
legend=dict(orientation='v', yref='paper', yanchor='top', y=1.0, xref='paper', xanchor='left', x=1.02, title=legendtitle_orig),
|
||||
dragmode='zoom',
|
||||
updatemenus=[dict(
|
||||
type='buttons',
|
||||
direction='left',
|
||||
x=0.0,
|
||||
xanchor='left',
|
||||
y=1.11,
|
||||
yanchor='top',
|
||||
pad=dict(t=0, r=10, b=0, l=0),
|
||||
buttons=[
|
||||
dict(
|
||||
label='홈',
|
||||
method='update',
|
||||
args=[
|
||||
{'visible': visible_orig},
|
||||
{
|
||||
'legend': {'title': legendtitle_orig},
|
||||
'xaxis.autorange': True,
|
||||
'xaxis2.autorange': True,
|
||||
'xaxis3.autorange': True,
|
||||
'yaxis.autorange': True,
|
||||
'yaxis2.autorange': True,
|
||||
'yaxis3.autorange': True,
|
||||
}
|
||||
],
|
||||
execute=True
|
||||
),
|
||||
dict(
|
||||
label='인버스',
|
||||
method='update',
|
||||
args=[
|
||||
{'visible': visible_inv},
|
||||
{'legend': {'title': legendtitle_inv, 'orientation': 'v', 'y': 1.0, 'yanchor': 'top', 'x': 1.02, 'xanchor': 'left'}}
|
||||
],
|
||||
args2=[
|
||||
{'visible': visible_orig},
|
||||
{'legend': {'title': legendtitle_orig, 'orientation': 'v', 'y': 1.0, 'yanchor': 'top', 'x': 1.02, 'xanchor': 'left'}}
|
||||
],
|
||||
execute=True
|
||||
),
|
||||
]
|
||||
)]
|
||||
)
|
||||
fig.update_xaxes(title_text='시간', row=3, col=1)
|
||||
fig.update_yaxes(title_text='가격 (KRW)', row=1, col=1)
|
||||
fig.update_yaxes(title_text='이격도/거래량', row=2, col=1)
|
||||
fig.update_yaxes(title_text='장기 이격도', row=3, col=1)
|
||||
|
||||
fig.show(config={'scrollZoom': True, 'displaylogo': False})
|
||||
def __init__(self) -> None:
|
||||
self.monitor = Monitor()
|
||||
self.INTERVAL_MAP = {
|
||||
60: "60m",
|
||||
240: "4h",
|
||||
}
|
||||
self.monitor = Monitor(cooldown_file=None)
|
||||
|
||||
def detect_turnaround_signal(self, symbol, data, interval=0, params=None):
|
||||
if len(data) < 7:
|
||||
return None
|
||||
current_data = data.iloc[-1]
|
||||
if current_data.get('point', 0) == 1:
|
||||
return {
|
||||
'alert': True,
|
||||
'details': f"매수신호: {current_data.get('signal', 'unknown')}"
|
||||
}
|
||||
return {'alert': False, 'details': "매수신호 없음"}
|
||||
def load_mtf(self, symbol: str):
|
||||
df_1d = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1D)
|
||||
df_1h = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1H)
|
||||
df_3m = self.monitor.get_coin_some_data(symbol, ENTRY_INTERVAL)
|
||||
|
||||
def fetch_price_history(self, symbol: str, interval_minutes: int, days: int = 30) -> pd.DataFrame:
|
||||
if symbol in KR_COINS:
|
||||
bong_count = 3000
|
||||
return self.monitor.get_coin_more_data(symbol, interval_minutes, bong_count=bong_count)
|
||||
if interval_minutes not in self.INTERVAL_MAP:
|
||||
raise ValueError("interval must be 60 or 240")
|
||||
interval_str = self.INTERVAL_MAP[interval_minutes]
|
||||
df = yf.download(
|
||||
tickers=symbol,
|
||||
period=f"{days}d",
|
||||
interval=interval_str,
|
||||
progress=False,
|
||||
if df_1d is None or df_1d.empty:
|
||||
df_1d = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1D, bong_count=500)
|
||||
if df_1h is None or df_1h.empty:
|
||||
df_1h = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1H, bong_count=5000)
|
||||
if df_3m is None or df_3m.empty:
|
||||
df_3m = self.monitor.get_coin_more_data(
|
||||
symbol, ENTRY_INTERVAL, bong_count=90000, verbose=True
|
||||
)
|
||||
|
||||
df_1d = self.monitor.calculate_technical_indicators(df_1d)
|
||||
df_1h = self.monitor.calculate_technical_indicators(df_1h)
|
||||
df_3m = self.monitor.calculate_technical_indicators(df_3m)
|
||||
return df_1d, df_1h, df_3m
|
||||
|
||||
def render_plotly(self, df_3m: pd.DataFrame, trend: str, result: SimResult) -> None:
|
||||
cfg = strategy.ACTIVE_CONFIG.name
|
||||
summary = (
|
||||
f"[{cfg}] 시작 {result.initial_cash:,.0f} | 최종 {result.final_asset:,.0f} | "
|
||||
f"{result.total_return_pct:+.2f}% | 수수료 {result.total_fees:,.0f}"
|
||||
)
|
||||
if df.empty:
|
||||
raise RuntimeError("No data fetched. Check symbol or interval support.")
|
||||
return df
|
||||
fig = subplots.make_subplots(
|
||||
rows=3,
|
||||
cols=1,
|
||||
subplot_titles=(
|
||||
f"{COIN_NAME} 3분 BB — {trend}",
|
||||
"RSI / BB폭(%)",
|
||||
summary,
|
||||
),
|
||||
shared_xaxes=False,
|
||||
vertical_spacing=0.06,
|
||||
row_heights=[0.5, 0.18, 0.32],
|
||||
specs=[[{"type": "xy"}], [{"type": "xy"}], [{"type": "table"}]],
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Candlestick(
|
||||
x=df_3m.index,
|
||||
open=df_3m["Open"],
|
||||
high=df_3m["High"],
|
||||
low=df_3m["Low"],
|
||||
close=df_3m["Close"],
|
||||
name="캔들",
|
||||
showlegend=False,
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
for col, color in [("MA", "blue"), ("Upper", "gray"), ("Lower", "gray")]:
|
||||
if col in df_3m.columns:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=df_3m.index,
|
||||
y=df_3m[col],
|
||||
name=col,
|
||||
line=dict(color=color, dash="dot" if col != "MA" else "solid"),
|
||||
showlegend=False,
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
|
||||
def analyze_bottom_period(self, symbol: str, interval_minutes: int, days: int = 90):
|
||||
data = self.fetch_price_history(symbol, interval_minutes, days)
|
||||
data = self.monitor.calculate_technical_indicators(data)
|
||||
data = self.monitor.annotate_signals(symbol, data, simulation=True)
|
||||
print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}")
|
||||
print(f"총 데이터 수: {len(data)}")
|
||||
bottom_start = pd.Timestamp('2025-06-22')
|
||||
bottom_end = pd.Timestamp('2025-07-09')
|
||||
bottom_data = data[(data.index >= bottom_start) & (data.index <= bottom_end)]
|
||||
if len(bottom_data) == 0:
|
||||
print("저점 기간 데이터가 없습니다.")
|
||||
return None, []
|
||||
print(f"\n저점 기간 데이터: {bottom_data.index[0]} ~ {bottom_data.index[-1]}")
|
||||
print(f"저점 기간 데이터 수: {len(bottom_data)}")
|
||||
print("\n=== 저점 기간 기술적 지표 분석 ===")
|
||||
min_price = bottom_data['Low'].min()
|
||||
max_price = bottom_data['High'].max()
|
||||
avg_price = bottom_data['Close'].mean()
|
||||
print(f"최저가: {min_price:.4f}")
|
||||
print(f"최고가: {max_price:.4f}")
|
||||
print(f"평균가: {avg_price:.4f}")
|
||||
print(f"가격 변동폭: {((max_price - min_price) / min_price * 100):.2f}%")
|
||||
bb_lower_min = bottom_data['Lower'].min()
|
||||
bb_upper_max = bottom_data['Upper'].max()
|
||||
print(f"\n볼린저 밴드 분석:")
|
||||
print(f"하단 밴드 최저: {bb_lower_min:.4f}")
|
||||
print(f"상단 밴드 최고: {bb_upper_max:.4f}")
|
||||
volume_avg = bottom_data['Volume'].mean()
|
||||
volume_max = bottom_data['Volume'].max()
|
||||
print(f"\n거래량 분석:")
|
||||
print(f"평균 거래량: {volume_avg:.0f}")
|
||||
print(f"최대 거래량: {volume_max:.0f}")
|
||||
actual_bottom_idx = bottom_data['Low'].idxmin()
|
||||
actual_bottom_price = bottom_data.loc[actual_bottom_idx, 'Low']
|
||||
actual_bottom_date = actual_bottom_idx
|
||||
print(f"\n실제 저점:")
|
||||
print(f"날짜: {actual_bottom_date}")
|
||||
print(f"가격: {actual_bottom_price:.4f}")
|
||||
print(f"볼린저 하단 대비: {((actual_bottom_price - bottom_data.loc[actual_bottom_idx, 'Lower']) / bottom_data.loc[actual_bottom_idx, 'Lower'] * 100):.2f}%")
|
||||
print(f"\n=== 매수 신호 분석 ===")
|
||||
bottom_alerts = bottom_data[bottom_data['point'] == 1]
|
||||
alerts = [(idx, row['Close']) for idx, row in bottom_alerts.iterrows()]
|
||||
print(f"저점 기간 매수 신호 수: {len(alerts)}")
|
||||
if alerts:
|
||||
print("매수 신호 발생 시점:")
|
||||
for date, price in alerts:
|
||||
print(f" {date}: {price:.4f}")
|
||||
return bottom_data, alerts
|
||||
buy_trades = [t for t in result.trades if t.action == "매수"]
|
||||
sell_trades = [t for t in result.trades if t.action == "매도"]
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[t.dt for t in buy_trades],
|
||||
y=[t.price for t in buy_trades],
|
||||
mode="markers",
|
||||
name="매수",
|
||||
legendgroup="trades",
|
||||
showlegend=True,
|
||||
marker=dict(
|
||||
color="#22c55e",
|
||||
size=11,
|
||||
symbol="triangle-up",
|
||||
line=dict(width=1, color="#166534"),
|
||||
),
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[t.dt for t in sell_trades],
|
||||
y=[t.price for t in sell_trades],
|
||||
mode="markers",
|
||||
name="매도",
|
||||
legendgroup="trades",
|
||||
showlegend=True,
|
||||
marker=dict(
|
||||
color="#ef4444",
|
||||
size=11,
|
||||
symbol="triangle-down",
|
||||
line=dict(width=1, color="#991b1b"),
|
||||
),
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
if "RSI" in df_3m.columns:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=df_3m.index,
|
||||
y=df_3m["RSI"],
|
||||
name="RSI",
|
||||
showlegend=False,
|
||||
),
|
||||
row=2,
|
||||
col=1,
|
||||
)
|
||||
if "BB_Width" in df_3m.columns:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=df_3m.index,
|
||||
y=df_3m["BB_Width"],
|
||||
name="BB폭%",
|
||||
showlegend=False,
|
||||
),
|
||||
row=2,
|
||||
col=1,
|
||||
)
|
||||
if result.trades:
|
||||
cells = [
|
||||
[t.dt.strftime("%Y-%m-%d %H:%M") for t in result.trades],
|
||||
[t.action for t in result.trades],
|
||||
[t.signal for t in result.trades],
|
||||
[f"{t.price:,.2f}" for t in result.trades],
|
||||
[f"{t.krw:,.0f}" for t in result.trades],
|
||||
[f"{t.fee:,.0f}" for t in result.trades],
|
||||
[f"{t.pnl:+,.0f}" if t.pnl is not None else "-" for t in result.trades],
|
||||
[f"{t.total_asset:,.0f}" for t in result.trades],
|
||||
]
|
||||
else:
|
||||
cells = [["-"] * 8]
|
||||
fig.add_trace(
|
||||
go.Table(
|
||||
header=dict(
|
||||
values=[
|
||||
"일시",
|
||||
"구분",
|
||||
"신호",
|
||||
"가격",
|
||||
"금액",
|
||||
"수수료",
|
||||
"수익",
|
||||
"총자산",
|
||||
],
|
||||
fill_color="#e8e8e8",
|
||||
),
|
||||
cells=dict(values=cells),
|
||||
),
|
||||
row=3,
|
||||
col=1,
|
||||
)
|
||||
fig.update_layout(
|
||||
height=1100,
|
||||
title=f"{SYMBOL} BB 타이밍 시뮬 (범례 클릭: 매수/매도 표시 토글)",
|
||||
margin=dict(l=50, r=140, t=80, b=40),
|
||||
dragmode="zoom",
|
||||
legend=dict(
|
||||
orientation="v",
|
||||
yanchor="top",
|
||||
y=0.99,
|
||||
xanchor="left",
|
||||
x=1.01,
|
||||
bgcolor="rgba(255,255,255,0.9)",
|
||||
bordercolor="#cccccc",
|
||||
borderwidth=1,
|
||||
font=dict(size=12),
|
||||
title=dict(text="체결 (클릭 토글)", side="top"),
|
||||
itemclick="toggle",
|
||||
itemdoubleclick="toggleothers",
|
||||
),
|
||||
)
|
||||
# Y축 고정·rangeslider 해제 → 세로 드래그/박스줌·휠 줌 가능
|
||||
fig.update_xaxes(
|
||||
rangeslider_visible=False,
|
||||
fixedrange=False,
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
fig.update_xaxes(fixedrange=False, row=2, col=1)
|
||||
fig.update_yaxes(
|
||||
title_text="가격 (KRW)",
|
||||
fixedrange=False,
|
||||
scaleanchor=None,
|
||||
scaleratio=None,
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
fig.update_yaxes(
|
||||
fixedrange=False,
|
||||
scaleanchor=None,
|
||||
scaleratio=None,
|
||||
row=2,
|
||||
col=1,
|
||||
)
|
||||
fig.show(
|
||||
config={
|
||||
"scrollZoom": True,
|
||||
"displaylogo": False,
|
||||
"doubleClick": "reset",
|
||||
"modeBarButtonsToAdd": ["zoom2d", "pan2d", "resetScale2d"],
|
||||
}
|
||||
)
|
||||
|
||||
def run_simulation(self, symbol: str, interval_minutes: int, days: int = 30):
|
||||
data = self.fetch_price_history(symbol, interval_minutes)
|
||||
def load_all_frames(self) -> dict[int, pd.DataFrame]:
|
||||
"""discovered 규칙용 전 간격 로드."""
|
||||
from mtf_bb import load_frames_from_db
|
||||
|
||||
inverseData = self.monitor.inverse_data(data)
|
||||
inverseData = self.monitor.annotate_signals(symbol, inverseData, simulation=True)
|
||||
return load_frames_from_db(self.monitor, SYMBOL)
|
||||
|
||||
data = self.monitor.calculate_technical_indicators(data)
|
||||
data = self.monitor.annotate_signals(symbol, data, simulation=True)
|
||||
print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}")
|
||||
print(f"총 데이터 수: {len(data)}")
|
||||
alerts = []
|
||||
for i in range(len(data)):
|
||||
if data['point'].iloc[i] == 1:
|
||||
alerts.append((data.index[i], data['Close'].iloc[i]))
|
||||
print(f"\n총 매수 신호 수: {len(alerts)}")
|
||||
ma_signals = len(data[(data['point'] == 1) & (data['signal'] == 'movingaverage')])
|
||||
dev40_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation40')])
|
||||
dev240_signals = len(data[(data['point'] == 1) & (data['signal'] == 'Deviation720')])
|
||||
dev1440_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation1440')])
|
||||
print(f" - MA 신호: {ma_signals}")
|
||||
print(f" - Dev40 신호: {dev40_signals}")
|
||||
print(f" - Dev240 신호: {dev240_signals}")
|
||||
print(f" - Dev1440 신호: {dev1440_signals}")
|
||||
def _run_one_strategy(
|
||||
self,
|
||||
name: str,
|
||||
df_1d: pd.DataFrame,
|
||||
df_1h: pd.DataFrame,
|
||||
df_3m: pd.DataFrame,
|
||||
cfg: strategy.StrategyConfig,
|
||||
frames: dict | None = None,
|
||||
) -> tuple[pd.DataFrame, SimResult, int]:
|
||||
"""한 전략으로 신호·백테스트. 반환: (df, result, 신호수)."""
|
||||
df_sig = strategy.annotate_signals(
|
||||
SYMBOL,
|
||||
df_3m.copy(),
|
||||
simulation=True,
|
||||
df_1h=df_1h,
|
||||
df_1d=df_1d,
|
||||
config=cfg,
|
||||
frames=frames,
|
||||
)
|
||||
n_sig = int((df_sig["point"] == 1).sum())
|
||||
res = run_backtest(df_sig, df_1d, df_1h, config_name=name)
|
||||
return df_sig, res, n_sig
|
||||
|
||||
# Plotly 기반 시각화로 전환
|
||||
self.render_plotly(symbol, interval_minutes, data, inverseData)
|
||||
def run(self, config: strategy.StrategyConfig | None = None) -> SimResult:
|
||||
"""기본 BB vs 탐색 규칙 중 수익률·신호가 있는 쪽을 HTML에 표시."""
|
||||
df_1d, df_1h, df_3m = self.load_mtf(SYMBOL)
|
||||
trend = strategy.get_trend(df_1d, df_1h)
|
||||
print(f"추세(최신): {trend}")
|
||||
print(f"3분: {df_3m.index[0]} ~ {df_3m.index[-1]} ({len(df_3m)}봉)")
|
||||
|
||||
cfg_base = strategy.StrategyConfig(
|
||||
name="01_기본_BB만",
|
||||
use_discovered_rules=False,
|
||||
use_regime_switch=False,
|
||||
use_rsi_filter=False,
|
||||
use_volume_filter=False,
|
||||
use_squeeze_filter=False,
|
||||
use_stop_loss=False,
|
||||
)
|
||||
df_base, res_base, n_base = self._run_one_strategy(
|
||||
cfg_base.name, df_1d, df_1h, df_3m, cfg_base
|
||||
)
|
||||
print(f"\n[기본 BB] 신호 {n_base} | 수익 {res_base.total_return_pct:+.2f}% | 거래 {res_base.trade_count}")
|
||||
|
||||
candidates: list[tuple[str, pd.DataFrame, SimResult, int]] = [
|
||||
(cfg_base.name, df_base, res_base, n_base),
|
||||
]
|
||||
|
||||
try:
|
||||
from rule_discovery import load_rules
|
||||
|
||||
rules = load_rules()
|
||||
frames = self.load_all_frames()
|
||||
if rules and frames:
|
||||
cfg_disc = strategy.StrategyConfig(
|
||||
name=rules.name,
|
||||
use_discovered_rules=True,
|
||||
use_regime_switch=False,
|
||||
use_rsi_filter=False,
|
||||
use_volume_filter=False,
|
||||
use_squeeze_filter=False,
|
||||
use_stop_loss=False,
|
||||
)
|
||||
df_disc, res_disc, n_disc = self._run_one_strategy(
|
||||
cfg_disc.name, df_1d, df_1h, df_3m, cfg_disc, frames=frames
|
||||
)
|
||||
print(
|
||||
f"[탐색 규칙] 신호 {n_disc} | 수익 {res_disc.total_return_pct:+.2f}% "
|
||||
f"| 거래 {res_disc.trade_count}"
|
||||
)
|
||||
print(f" 매수: {rules.buy_all} | OR: {rules.buy_any}")
|
||||
print(f" 매도: {rules.sell_all} | 손절: {rules.sell_stop}")
|
||||
if n_disc > 0 and res_disc.trade_count > 0:
|
||||
candidates.append((cfg_disc.name, df_disc, res_disc, n_disc))
|
||||
except Exception as e:
|
||||
print(f"[탐색 규칙] 스킵: {e}")
|
||||
|
||||
# 신호·거래 있는 후보 중 수익률 최대
|
||||
valid = [c for c in candidates if c[3] > 0 and c[2].trade_count > 0]
|
||||
if not valid:
|
||||
valid = candidates
|
||||
name, df_plot, result, n_sig = max(valid, key=lambda c: c[2].total_return_pct)
|
||||
|
||||
print(f"\n>>> HTML 적용: {name} (신호 {n_sig}, 거래 {result.trade_count}, {result.total_return_pct:+.2f}%)")
|
||||
sigs = df_plot[df_plot["point"] == 1]
|
||||
if len(sigs):
|
||||
print(sigs["action"].value_counts().to_string())
|
||||
|
||||
print_backtest_report(result)
|
||||
self.render_plotly(df_plot, trend, result)
|
||||
return result
|
||||
|
||||
|
||||
def run_mtf_analysis() -> None:
|
||||
"""봉별 BB 백테스트 비교, 정책 저장, MTF 시뮬 차트."""
|
||||
from mtf_bb import apply_policy, load_frames_from_db, run_interval_comparison, save_policy
|
||||
|
||||
monitor = Monitor()
|
||||
policy, _ = run_interval_comparison(monitor)
|
||||
save_policy(policy)
|
||||
apply_policy(policy)
|
||||
|
||||
frames = load_frames_from_db(monitor, SYMBOL)
|
||||
df_1d = frames.get(TREND_INTERVAL_1D)
|
||||
if df_1d is None or df_1d.empty:
|
||||
df_1d = frames[ENTRY_INTERVAL]
|
||||
df_1h = frames.get(TREND_INTERVAL_1H)
|
||||
if df_1h is None or df_1h.empty:
|
||||
df_1h = frames[ENTRY_INTERVAL]
|
||||
|
||||
cfg = strategy.StrategyConfig(
|
||||
name="MTF_BB",
|
||||
use_mtf=True,
|
||||
use_regime_switch=strategy.ACTIVE_CONFIG.use_regime_switch,
|
||||
use_rsi_filter=False,
|
||||
use_volume_filter=False,
|
||||
use_squeeze_filter=False,
|
||||
use_stop_loss=True,
|
||||
)
|
||||
df_sig = strategy.annotate_mtf_signals(SYMBOL, frames, df_1d, df_1h, policy, cfg)
|
||||
trend = strategy.get_trend(df_1d, df_1h)
|
||||
print(f"\nMTF 시뮬 ({policy.name}) | 추세: {trend}")
|
||||
result = run_backtest(df_sig, df_1d, df_1h, config_name=policy.name)
|
||||
print_backtest_report(result)
|
||||
Simulation().render_plotly(df_sig, trend, result)
|
||||
|
||||
|
||||
def run_discover() -> None:
|
||||
"""모든 봉·캔들 특징으로 최적 규칙 탐색 후 JSON 저장."""
|
||||
from rule_discovery import discover_rules, load_frames, save_rules
|
||||
|
||||
monitor = Monitor(cooldown_file=None)
|
||||
frames = load_frames(monitor)
|
||||
rules = discover_rules(frames)
|
||||
save_rules(rules)
|
||||
print(f"\n저장: discovered_rules.json")
|
||||
print("HTML 차트: python simulation_1h.py")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sim = Simulation()
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "discover":
|
||||
run_discover()
|
||||
return
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "mtf":
|
||||
run_mtf_analysis()
|
||||
return
|
||||
df_1d, df_1h, df_3m = sim.load_mtf(SYMBOL)
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "compare":
|
||||
run_comparison(df_1d, df_1h, df_3m)
|
||||
return
|
||||
sim.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sim = Simulation()
|
||||
interval = 60
|
||||
days = 90
|
||||
target_coins = KR_COINS
|
||||
#target_coins = ['XRP']
|
||||
show_graphs = True
|
||||
for symbol in target_coins:
|
||||
print(f"\n=== {symbol} 저점 기간 분석 시작 ===")
|
||||
try:
|
||||
bottom_data, alerts = sim.analyze_bottom_period(symbol, interval, days)
|
||||
print(f"\n=== {symbol} 전체 기간 시뮬레이션 ===")
|
||||
if show_graphs:
|
||||
sim.run_simulation(symbol, interval, days)
|
||||
else:
|
||||
data = sim.fetch_price_history(symbol, interval, days)
|
||||
|
||||
inverseData = sim.monitor.inverse_data(data)
|
||||
inverseData = sim.monitor.annotate_signals(symbol, inverseData, simulation=True)
|
||||
|
||||
data = sim.monitor.calculate_technical_indicators(data)
|
||||
data = sim.monitor.annotate_signals(symbol, data, simulation=True)
|
||||
|
||||
total_signals = len(data[data['point'] == 1])
|
||||
ma_signals = len(data[(data['point'] == 1) & (data['signal'] == 'movingaverage')])
|
||||
dev40_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation40')])
|
||||
dev240_signals = len(data[(data['point'] == 1) & (data['signal'] == 'Deviation720')])
|
||||
dev1440_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation1440')])
|
||||
print(f"총 매수 신호: {total_signals}")
|
||||
print(f" - MA 신호: {ma_signals}")
|
||||
print(f" - Dev40 신호: {dev40_signals}")
|
||||
print(f" - Dev240 신호: {dev240_signals}")
|
||||
print(f" - Dev1440 신호: {dev1440_signals}")
|
||||
except Exception as e:
|
||||
print(f"Error analyzing {symbol}: {str(e)}")
|
||||
main()
|
||||
|
||||
624
strategy.py
Normal file
624
strategy.py
Normal 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
|
||||
Reference in New Issue
Block a user