commit c45ad151b6ce1c6ada12729ad89a6a449a67bc1d Author: dsyoon Date: Wed Jan 28 18:58:33 2026 +0900 init diff --git a/HTS2.py b/HTS2.py new file mode 100644 index 0000000..9d43162 --- /dev/null +++ b/HTS2.py @@ -0,0 +1,306 @@ +import pandas as pd +import jwt +import uuid +import time +import requests +import json +import hashlib +from urllib.parse import urlencode + +class HTS: + + bithumb = None + #accessKey = "a5d33ce55f598185d37cd26272341b7b965c31a59457f7" # 본인의 Connect Key를 입력한다. + #secretKey = "ODBiYWFmNWE2MTkwYjdhMTNhZTM1YjU5OGY4OGE2MGNkNDY2NzMzMjE2Nzc5NDVlMzBhMDk3NTNmM2M2Mg==" # 본인의 Secret Key를 입력한다. + accessKey = "1b10570cfaacb728fbdbb0b289c367e95ed937b1bd4157" # 본인의 Connect Key를 입력한다. + secretKey = "MGU0NzYzMzQyNTJhMDk2MjUxMGFmZWFjYjkyNThlYWJiNmIzOGNjODZjZWE1NmQyMzdiN2JiNDM1Njg1MA==" # 본인의 Secret Key를 입력한다. + + 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.accessKey = "1b10570cfaacb728fbdbb0b289c367e95ed937b1bd4157" # 본인의 Connect Key를 입력한다. + self.secretKey = "MGU0NzYzMzQyNTJhMDk2MjUxMGFmZWFjYjkyNThlYWJiNmIzOGNjODZjZWE1NmQyMzdiN2JiNDM1Njg1MA==" # 본인의 Secret Key를 입력한다. + self.apiUrl = 'https://api.bithumb.com' + + return + + def append(self, stock, df=None, data_1=None): + if df is not None: + for i in range(len(df)): + stock['PRICE'].append( + { + "ymd": df.index[i], + "close": df['close'].iloc[i], + "diff": 0, + "open": df['open'].iloc[i], + "high": df['high'].iloc[i], + "low": df['low'].iloc[i], + "volume": df['volume'].iloc[i], + "avg5": -1, "avg20": -1, "avg60": -1, "avg120": -1, "avg240": -1, "avg480": -1, + "bolingerband_upper": -1, "bolingerband_lower": -1, "bolingerband_middle": -1, "bolingerband_bwi": -1, + "ichimokucloud_changeLine": -1, "ichimokucloud_baseLine": -1, "ichimokucloud_leadingSpan1": -1, "ichimokucloud_leadingSpan2": -1, + "stochastic_fast_k_1": -1, "stochastic_slow_k_1": -1, "stochastic_slow_d_1": -1, + "stochastic_fast_k_2": -1, "stochastic_slow_k_2": -1, "stochastic_slow_d_2": -1, + "stochastic_fast_k_3": -1, "stochastic_slow_k_3": -1, "stochastic_slow_d_3": -1, + "rsi": -1, "rsis": -1, + "macd": -1, "macds": -1, "macdo": -1, "nor_macd": -1, "nor_macds": -1, "nor_macdo": -1, + }) + + if data_1 is not None: + stock['PRICE'].append( + { + "ymd": data_1.index[-1], + "close": data_1['close'].iloc[-1], + "diff": 0, + "open": data_1['open'].iloc[-1], + "high": data_1['high'].iloc[-1], + "low": data_1['low'].iloc[-1], + "volume": data_1['volume'].iloc[-1], + "avg5": -1, "avg20": -1, "avg60": -1, "avg120": -1, "avg240": -1, "avg480": -1, + "bolingerband_upper": -1, "bolingerband_lower": -1, "bolingerband_middle": -1, "bolingerband_bwi": -1, "bolingerband_nor_bwi": -1, + "envelope_upper": -1, "envelope_lower": -1, "envelope_middle": -1, + "ichimokucloud_changeLine": -1, "ichimokucloud_baseLine": -1, "ichimokucloud_leadingSpan1": -1, + "ichimokucloud_leadingSpan2": -1, + "stochastic_fast_k_1": -1, "stochastic_slow_k_1": -1, "stochastic_slow_d_1": -1, + "stochastic_fast_k_2": -1, "stochastic_slow_k_2": -1, "stochastic_slow_d_2": -1, + "stochastic_fast_k_3": -1, "stochastic_slow_k_3": -1, "stochastic_slow_d_3": -1, + "rsi": -1, "rsis": -1, + "macd": -1, "macds": -1, "macdo": -1, "nor_macd": -1, "nor_macds": -1, "nor_macdo": -1, + }) + return + + def getCoinRawData(self, ticker_code, minute=None, day=False, week=False, month=False, to=None, endpoint='/v1/candles'): + url = None + if minute == 0: + # 현재가 정보 + url = (self.apiUrl + "/v1/ticker?markets=KRW-{}").format(ticker_code) + + headers = {"accept": "application/json"} + response = requests.get(url, headers=headers) + json_data = json.loads(response.text) + df_temp = pd.DataFrame(json_data) + if 'trade_date_kst' not in df_temp or 'trade_time_kst' not in df_temp: + return None + df = pd.DataFrame() + df['datetime'] = pd.to_datetime(df_temp['trade_date_kst'], format='%Y-%m-%dT%H:%M:%S') + df['open'] = df_temp['opening_price'] + df['close'] = df_temp['trade_price'] + df['high'] = df_temp['high_price'] + df['low'] = df_temp['low_price'] + df['volume'] = df_temp['trade_volume'] + df = df.set_index('datetime') + df = df.astype(float) + df["datetime"] = df.index + else: + # 분봉 + if minute is not None and minute in {1, 3, 5, 10, 15, 30, 60, 240}: + if to is None: + url = (self.apiUrl + endpoint + "/minutes/{}?market=KRW-{}&count=3000").format(minute, ticker_code) + else: + url = (self.apiUrl + endpoint + "/minutes/{}?market=KRW-{}&count=3000&to={}").format(minute, ticker_code, to) + if day: + if to is None: + url = (self.apiUrl + endpoint + "/days?market=KRW-{}&count=3000").format(ticker_code) + else: + url = (self.apiUrl + endpoint + "/days?market=KRW-{}&count=3000&to={}").format(ticker_code, to) + if week: + if to is None: + url = (self.apiUrl + endpoint + "/weeks?market=KRW-{}&count=3000").format(ticker_code) + else: + url = (self.apiUrl + endpoint + "/weeks?market=KRW-{}&count=3000&to={}").format(ticker_code, to) + if month: + if to is None: + url = (self.apiUrl + endpoint + "/months?market=KRW-{}&count=3000").format(ticker_code) + else: + url = (self.apiUrl + endpoint + "/months?market=KRW-{}&count=3000&to={}").format(ticker_code, to) + + if url is None: + return None + + headers = {"accept": "application/json"} + response = requests.get(url, headers=headers) + json_data = json.loads(response.text) + df_temp = pd.DataFrame(json_data) + if 'candle_date_time_kst' not in df_temp: + return None + df = pd.DataFrame() + #df.columns = ['datetime', 'open', 'close', 'high', 'low', 'volume'] + #df['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst']) + df['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst'], format='%Y-%m-%dT%H:%M:%S') + df['open'] = df_temp['opening_price'] + df['close'] = df_temp['trade_price'] + df['high'] = df_temp['high_price'] + df['low'] = df_temp['low_price'] + df['volume'] = df_temp['candle_acc_trade_volume'] + df = df.set_index('datetime') + df = df.astype(float) + df["datetime"] = df.index + + if df is None: + return None + + return df + + def getTickerList(self): + url = "https://api.bithumb.com/v1/market/all?isDetails=false" + headers = {"accept": "application/json"} + response = requests.get(url, headers=headers) + + tickets = response.json() + return tickets + + def getVirtual_asset_warning(self): + url = "https://api.bithumb.com/v1/market/virtual_asset_warning" + headers = {"accept": "application/json"} + response = requests.get(url, headers=headers) + warning_list = response.json() + return warning_list + + # 거래대금이 많은 순으로 코인리스트를 얻는다. + def getTopCoinList(self, interval, top): + return + + # 현재 가격 얻어오기 + def getCurrentPrice(self, ticker_code, endpoint='/v1/ticker'): + headers = {"accept": "application/json"} + url = (self.apiUrl + endpoint + "?markets=KRW-{}").format(ticker_code) + response = requests.get(url, headers=headers) + + ticker_state = response.json() + return ticker_state + + # 잔고 가져오기 + def getBalances(self, ticker_code=None, endpoint='/v1/accounts'): + + payload = { + 'access_key': self.accessKey, + 'nonce': str(uuid.uuid4()), + 'timestamp': round(time.time() * 1000) + } + jwt_token = jwt.encode(payload, self.secretKey) + authorization_token = 'Bearer {}'.format(jwt_token) + headers = { + 'Authorization': authorization_token + } + response = requests.get(self.apiUrl + endpoint, headers=headers) + balances = response.json() + + """ + [ + {'currency': 'P', 'balance': '78290', 'locked': '0', 'avg_buy_price': '0', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'}, + {'currency': 'KRW', 'balance': '4218.401653', 'locked': '0', 'avg_buy_price': '0', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'}, + {'currency': 'XRP', 'balance': '13069.27647861', 'locked': '0', 'avg_buy_price': '1917', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'}, + {'currency': 'ADA', 'balance': '6941.65484013', 'locked': '0', 'avg_buy_price': '1260', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'}, + {'currency': 'BSV', 'balance': '0.00005656', 'locked': '0', 'avg_buy_price': '65450', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'}, + {'currency': 'SAND', 'balance': '0.00001158', 'locked': '0', 'avg_buy_price': '544.8', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'}, + {'currency': 'AVAX', 'balance': '26.43960509', 'locked': '0', 'avg_buy_price': '60882', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'}, + {'currency': 'XCORE', 'balance': '0.2119', 'locked': '0', 'avg_buy_price': '0', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'} + ] + """ + if ticker_code is None: + return balances + else: + for balance in balances: + if balance['currency'] == ticker_code: + return balance + + return None + + + def order(self, ticker_code, side, ord_type, volume, price=None, endpoint='/v1/orders'): + if ord_type=='limit': + # 지정가 매수 (limit, side=bid) / 매도 (limit, side=ask) + if price is None: + return + + requestBody = dict(market='KRW-'+ticker_code, side=side, volume=volume, price=price, ord_type=ord_type) + else: + # 시장가 매수 (price, side=bid) / 매도 (market, side=ask) + + if ord_type == 'price': + requestBody = dict(market='KRW-' + ticker_code, side=side, price=price, ord_type=ord_type) + else: + requestBody = dict(market='KRW-' + ticker_code, side=side, volume=volume, ord_type=ord_type) + + # Generate access token + query = urlencode(requestBody).encode() + hash = hashlib.sha512() + hash.update(query) + query_hash = hash.hexdigest() + payload = { + 'access_key': self.accessKey, + 'nonce': str(uuid.uuid4()), + 'timestamp': round(time.time() * 1000), + 'query_hash': query_hash, + 'query_hash_alg': 'SHA512', + } + jwt_token = jwt.encode(payload, self.secretKey) + authorization_token = 'Bearer {}'.format(jwt_token) + headers = { + 'Authorization': authorization_token, + 'Content-Type': 'application/json' + } + + response = requests.post(self.apiUrl + endpoint, data=json.dumps(requestBody), headers=headers) + # handle to success or fail + #print(response.json()) + if response.status_code == 200: + return True + return False + + # 시장가 매수한다. 2초 뒤 잔고 데이터 리스트 리턴 + def buyCoinMarket(self, ticker_code, price, count=None): + if price > 5000: + if price < 50000: + self.order(ticker_code, side='bid', ord_type='price', volume=count, price=price) + buy_price = price + else: + repeat = 10 + buy_price = int(price / 1000) * 1000 + buy_amount = int(buy_price / repeat) + while repeat > 0: + self.order(ticker_code, side='bid', ord_type='price', volume=count, price=buy_amount) + repeat -= 1 + time.sleep(0.5) + else: + buy_price = 0 + return buy_price + + # 시장가 매도한다. 2초 뒤 잔고 데이터 리스트 리턴 + def sellCoinMarket(self, ticker_code, price, count): + return self.order(ticker_code, side='ask', ord_type='market', volume=count, price=price) + + # 지정가 매수한다. 2초 뒤 잔고 데이터 리스트 리턴 + def buyCoinLimit(self, ticker_code, price, count): + return self.order(ticker_code, side='bid', ord_type='limit', volume=count, price=price) + + # 지정가 매도한다. 2초 뒤 잔고 데이터 리스트 리턴 + def sellCoinLimit(self, ticker_code, price, count): + return self.order(ticker_code, side='ask', ord_type='limit', volume=count, price=price) + + def getOrderBook(self, ticker_code, endpoint='/v1/orderbook'): + """ + 필드 설명 타입 + market 마켓 코드 String + timestamp 호가 생성 시각 Long + total_ask_size 호가 매도 총 잔량 Double + total_bid_size 호가 매수 총 잔량 Double + orderbook_units 호가 List of Objects + > ask_price 매도호가 Double + > bid_price 매수호가 Double + > ask_size 매도 잔량 Double + > bid_size 매수 잔량 Double + """ + headers = {"accept": "application/json"} + url = (self.apiUrl + endpoint + "?markets=KRW-{}").format(ticker_code) + response = requests.get(url, headers=headers) + + # 매도 총 잔량: sum([units['ask_size'] for units in orders[0]['orderbook_units']]) + # 매수 총 잔량: sum([units['bid_size'] for units in orders[0]['orderbook_units']]) + orders = response.json() + return orders \ No newline at end of file diff --git a/PROMPT.txt b/PROMPT.txt new file mode 100644 index 0000000..23b2b2b --- /dev/null +++ b/PROMPT.txt @@ -0,0 +1,24 @@ +#1. +봉을 모두 1500개를 출력하고 있습니다. +전반적으로 그래프를 이해하세요. +그리고 시가, 종가, 고가, 저가, 거래량을 활용하여 많은 기술적 분석을 시도하세요. +그리고 최적의 저점에 매수를 할 수 있도록 탐색코드를 작성하세요. + +모든 코인에 동일 조건을 적용할 수 있도록 활용하는 수치는 모두 정규화를 시킨 이후에 시도해야 합니다. + +코드에 탐색을 반영하고 실행하세요, 그리고 매수 시점을 표기하세요. 그리고 최적이 아니라면 다시 실행하세요. 최적의 매수시점을 찾을 때까지 반복하세요. + + +#2. +전제 소스코들 살펴보세요. 데이터는 모든 코인에 동일 적용할 수 있도록 표준화가 되어야 합니다. 그리고 기술적 분석을 이용하여 매수 타이밍을 탐색해야 합니다. +혹시 기술적 분석이 아닌 날짜가 매수조건에 들어가 있다면 그러한 부분은 모두 제거해주세요. + + +#3. +5월 6일과 5월 8일 급락하고 그 이후 상승 전환되었습니다. 이 지점만 탐지해서 매수할 수 있는 기술적 기법을 고민해서 찾아주세요. 그리고 저점이라는 매수 구분자로 코드로 반영해주세요. 가장 먼서 실행을 해서 데이터를 확인하세요. 그리고 탐지할때까지 계속 설계와 코드 반영 그리고 다시 실행을 반복하세요. stock_monitor.py는 스케줄러입니다. 시뮬레이션은 stock_simulation.py를 사용하세요. + +특정 월의 데이터를 가져오기 위한 코드를 추가 작성하지 마세요. 데이터는 현재 전체 기간을 이용해서 기술적 분석을 하고 저점을 찾으세요. + +calculate_technical_indicators 함수에 기술적 분석들을 작성하세요 +check_point 함수에서 매수 여부를 판단하세요. +저점 매수는 구분지 buy_lower를 사용하세요. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c364e8 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# AssetMonitor 주식·코인 모니터링 시스템 + +## 개요 +`AssetMonitor`는 주식‧ETF 및 암호화폐 시장을 실시간으로 감시하여 Bollinger Band, RSI, MACD, 이동평균(Golden-Cross), 거래량 등을 종합 분석한 **매수 후보(signals)**를 텔레그램으로 통보하는 자동화 봇입니다. + +**주요 개선사항:** +- **데이터 표준화**: 모든 코인에 동일한 기술적 분석 기준 적용 +- **순수 기술적 분석**: 날짜 기반 조건 제거, 기술적 지표만 사용 +- **강화된 기술적 지표**: 스토캐스틱, MFI, OBV, ATR 등 추가 지표 활용 + +--- + +## 주요 구성 파일 +| 파일 | 설명 | +|------|------| +| `config.py` | ✅ API 토큰, 텔레그램 채널 ID, 볼린저 밴드/임계값, 모니터링 자산 목록(KR_COINS, US_STOCKS, KR_ETFS) 등 전역 설정을 보관합니다. | +| `stock_monitor.py` | 시스템의 핵심 로직이 담긴 실행 스크립트입니다.
• 데이터 수집 ⇒ 기술적 지표 계산 ⇒ 매수 신호 판단 ⇒ 메시지 포맷팅/발송
• `schedule` 라이브러리로 정해진 시간마다 작업을 자동 실행합니다. | +| `requirements.txt` | 프로젝트 의존 패키지를 명시합니다. | + +--- + +## 데이터 흐름 +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을 이용해 다중 메시지를 병렬로 전송합니다. + +--- + +## 매수 후보 전략 (표준화된 기술적 분석) + +| 신호 | 변수명 | 조건 | 의미 | +|------|--------|------|------| +| 볼린저 하단 근접 | `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` | 변동성 급증 | + +### 최종 매수 후보 결정 로직 +```text +if breakout_signal or long_breakout_signal: + buy = True # 돌파 신호 단독으로도 매수 후보 +else: + # ① 볼린저 + RSI 동시, 또는 ② (신호 ≥ 3개) & (볼린저 또는 RSI 포함) + buy = (bb_signal and rsi_signal) or (signal_count >= 3 and (bb_signal or rsi_signal)) +``` +*`signal_count` = 위 11개 신호 중 True 개수* + +### 메시지 구성 +- `매수` : 최종 `buy=True`일 때 메시지 맨 앞에 부착 +- `신호(n):` 뒤에 활성화된 신호 목록 + - 볼린저/RSI/MACD/MA/거래량/Breakout/스토캐스틱/MFI/OBV/ATR 각각 표시 + +해당 전략으로 **과매도 바닥근처 매수 기회 + 상승 추세 전환 브레이크아웃** 두 영역을 모두 포착할 수 있습니다. + +--- + +## 스케줄 테이블 (기본값) +| 대상 | 실행 시각(서버 기준) | 호출 함수 | +|------|----------------------|-----------| +| KRW 코인 | 매시간 04, 14, 24, 34, 44, 54분 | `monitor_coins()` | +| 미국 주식 / ETF | 05:10, 16:30, 23:30 | `monitor_us_stocks()` | +| 한국 ETF / 주식 | 07:10, 18:20 | `monitor_kr_stocks()` | + +> 시간은 `config.py`가 아닌 `stock_monitor.py`의 `run_schedule()` 내부에 하드코딩되어 있습니다. 필요 시 직접 수정하세요. + +--- + +## 설치 방법 +1. Python ≥ 3.9 환경을 준비합니다. +2. 저장소를 클론하고 디렉터리로 이동: +```bash +$ git clone +$ cd AssetMonitor +``` +3. 패키지 설치: +```bash +$ pip install -r requirements.txt +``` +4. **보안 키 등록** + 민감 정보는 코드에 직접 기록하지 말고 *환경 변수*로 주입하기를 권장합니다. +```bash +# zsh 예시 +export COIN_TELEGRAM_BOT_TOKEN="" +export STOCK_TELEGRAM_BOT_TOKEN="" +export COIN_TELEGRAM_CHAT_ID="" +export STOCK_TELEGRAM_CHAT_ID="" +``` + 또는 `config.py` 내부 상수를 직접 수정할 수 있습니다. + +--- + +## 사용 방법 +```bash +$ python monitor_coin.py +``` +스크립트가 백그라운드에서 무한 루프로 동작하며 지정된 시간마다 텔레그램 알림을 전송합니다. + +### Docker(선택) +컨테이너 실행 예시: +```dockerfile +FROM python:3.11-slim +WORKDIR /app +COPY . . +RUN pip install -r requirements.txt +CMD ["python", "stock_monitor.py"] +``` + +--- + +## 커스터마이징 +- **자산 목록 추가/삭제**: `config.py`의 `KR_COINS`, `US_STOCKS`, `KR_ETFS` 사전을 편집합니다. +- **임계값·기간 조정**: `BOLLINGER_PERIOD`, `BOLLINGER_STD`, `BOLLINGER_THRESHOLD`, `BUY_THRESHOLD` 등 변경. + +--- + +## 한계 및 면책 조항 +본 프로젝트는 교육·연구 목적의 오픈소스 예제로, 투자 손실에 대해 어떠한 책임도 지지 않습니다. 실거래에 사용하려면 충분한 검증과 백테스트를 진행하십시오. + +--- + +## 라이선스 +MIT (프로젝트 루트의 `LICENSE` 파일 참조, 미존재 시 필요에 따라 추가하세요.) + diff --git a/common.py b/common.py new file mode 100644 index 0000000..9506ab5 --- /dev/null +++ b/common.py @@ -0,0 +1,184 @@ +from datetime import datetime +import time +import pandas as pd +from monitor_min import Monitor +from config import * + +class CommonCoinMonitor(Monitor): + """코인 모니터링 공통 로직 클래스""" + + def __init__(self, cooldown_file: str = 'coins_buy_time.json') -> None: + super().__init__(cooldown_file) + + def get_balances(self) -> 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 + + def check_cooldown(self, symbol: str, side: str = 'buy') -> bool: + """매수/매도 쿨다운 시간을 확인합니다.""" + current_time = datetime.now() + last_trade_dt = self.buy_cooldown.get(symbol, {}).get(side, {}).get('datetime') + + if last_trade_dt: + time_diff = current_time - last_trade_dt + if time_diff.total_seconds() < BUY_MINUTE_LIMIT: + print(f"{symbol}: {side} 금지 중 (남은 시간: {BUY_MINUTE_LIMIT - time_diff.total_seconds():.0f}초)") + return False + return True + + def save_trade_record(self, symbol: str, side: str, signal: str) -> None: + """거래 기록을 저장합니다.""" + if self.cooldown_file is not None: + try: + self.last_signal[symbol] = signal + except Exception: + self.last_signal[symbol] = '' + self.buy_cooldown.setdefault(symbol, {})[side] = { + 'datetime': datetime.now(), + 'signal': signal + } + self._save_buy_cooldown() + + def check_5_week_lowest(self, data: pd.DataFrame) -> bool: + """5주봉이 20주봉이나 40주봉보다 아래에 있는지 체크합니다.""" + try: + 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): + return True + except Exception: + pass + return False + + def execute_buy(self, symbol: str, buy_amount: float, signal: str, current_price: float) -> bool: + """매수를 실행합니다.""" + try: + actual_buy_amount = self.hts.buyCoinMarket(symbol, buy_amount) + self.save_trade_record(symbol, 'buy', signal) + + print(f"{KR_COINS[symbol]} ({symbol}) [{signal}], 현재가: {current_price:.4f}, {int(BUY_MINUTE_LIMIT/60)}분간 매수 금지 시작") + self.sendMsg("{}".format(self.format_message(symbol, KR_COINS[symbol], current_price, signal, actual_buy_amount))) + return True + except Exception as e: + print(f"Error buying {symbol}: {str(e)}") + return False + + def execute_sell(self, symbol: str, sell_amount: float, signal: str, current_price: float, balances: dict) -> bool: + """매도를 실행합니다.""" + try: + available_balance = 0 + if balances and symbol in balances: + available_balance = float(balances[symbol].get('balance', 0)) + + if available_balance <= 0: + return False + + actual_sell_amount = available_balance * sell_amount + _ = self.hts.sellCoinMarket(symbol, 0, actual_sell_amount) + self.save_trade_record(symbol, 'sell', signal) + + print(f"{KR_COINS[symbol]} ({symbol}) [{signal} 매도], 현재가: {current_price:.4f}") + self.sendMsg("[KRW-COIN]\n" + f"• 매도 [COIN] {KR_COINS[symbol]} ({symbol}): {signal} ({'₩'}{current_price:.4f})") + return True + except Exception as e: + print(f"Error selling {symbol}: {str(e)}") + return False + + def process_inverse_data(self, symbol: str, interval: int, data: pd.DataFrame, balances: dict, coin_strategy) -> bool: + """인버스 데이터를 처리하여 매도 신호를 확인합니다.""" + try: + inverse_data = self.inverse_data(data) + recent_inverse_data = self.annotate_signals(symbol, interval, inverse_data) + + # 허용된 인버스 매도 신호만 처리 (시간봉별 신호 포함) + last_signal = str(recent_inverse_data['signal'].iloc[-1]) if 'signal' in recent_inverse_data.columns else '' + allowed_signals = coin_strategy.get_sell_signals() + + if last_signal not in allowed_signals: + return False + + if not self.check_cooldown(symbol, 'sell'): + return False + + # 코인별 전략에 따라 매도 비율 결정 + sell_ratio = coin_strategy.get_sell_amount_ratio(last_signal) + + return self.execute_sell(symbol, sell_ratio, last_signal, recent_inverse_data['Close'].iloc[-1], balances) + except Exception as e: + print(f"Error processing inverse data for {symbol}: {str(e)}") + return False + + def process_normal_data(self, symbol: str, interval: int, data: pd.DataFrame, coin_strategy) -> bool: + """일반 데이터를 처리하여 매수 신호를 확인합니다.""" + try: + data = self.calculate_technical_indicators(data) + recent_data = self.annotate_signals(symbol, interval, data) + + # XRP 전용 신호 확인 (hasattr로 메서드 존재 여부 확인) + if hasattr(coin_strategy, 'check_xrp_specific_signals'): + xrp_signal, xrp_point = coin_strategy.check_xrp_specific_signals(recent_data, len(recent_data) - 1) + if xrp_point == 1: + recent_data.at[recent_data.index[-1], 'signal'] = xrp_signal + recent_data.at[recent_data.index[-1], 'point'] = xrp_point + + if recent_data['point'].iloc[-1] != 1: + return False + + if not self.check_cooldown(symbol, 'buy'): + return False + + # 코인별 전략에 따라 매수 금액 결정 + signal = recent_data['signal'].iloc[-1] + current_price = recent_data['Close'].iloc[-1] + check_5_week_lowest = self.check_5_week_lowest(data) + + buy_amount = coin_strategy.get_buy_amount(signal, current_price, check_5_week_lowest) + + return self.execute_buy(symbol, buy_amount, signal, current_price) + except Exception as e: + print(f"Error processing normal data for {symbol}: {str(e)}") + return False + + def monitor_single_coin(self, symbol: str, coin_strategy) -> None: + """단일 코인을 모니터링합니다.""" + print("[{}] {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'), symbol)) + + # 3분봉과 1일봉으로 초기 신호 확인 + intervals = [3, 1440] + data = self.get_coin_some_data(symbol, intervals) + + for interval in intervals: + + data[interval] = self.calculate_technical_indicators(data[interval]) + recent_data = self.annotate_signals(symbol, interval, data[interval]) + + # 매수라면 + if recent_data['point'].iloc[-1] == 1: + + balances = self.get_balances() + + # 인버스 데이터 처리 (매도) + self.process_inverse_data(symbol, interval, data[interval], balances, coin_strategy) + + # 일반 데이터 처리 (매수) + self.process_normal_data(symbol, interval, data[interval], coin_strategy) + else: + print(f"Data for {symbol} is empty or None.") + + time.sleep(1) diff --git a/config.py b/config.py new file mode 100644 index 0000000..26f2e3f --- /dev/null +++ b/config.py @@ -0,0 +1,254 @@ +import os + +# 텔레그램 설정 +COIN_TELEGRAM_BOT_TOKEN = "6435061393:AAHOh9wB5yGNGUdb3SfCYJrrWTBe7wgConM" +COIN_TELEGRAM_CHAT_ID = '574661323' + +STOCK_TELEGRAM_BOT_TOKEN = "6874078562:AAEHxGDavfc0ssAXPQIaW8JGYmTR7LNUJOw" +STOCK_TELEGRAM_CHAT_ID = '574661323' + +# 몇초 만에 다시 매수를 할 것인지 체크 +BUY_MINUTE_LIMIT = 1800 + +# 볼린저 밴드 설정 +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% 미만 상승 조건 + +# 볼린저 밴드 squeeze 탐지 임계값 (밴드폭/중심선) +SQUEEZE_THRESHOLD = 0.04 # 4% 이하 + +# 장기간 저항선 돌파 감지 설정 +RESISTANCE_LOOKBACK = 120 # 저항선 판단을 위한 과거 캔들 수 (예: 120개) +RESISTANCE_BREAK_THRESHOLD = 0.01 # 저항선 대비 1% 이상 돌파 시 신호 + +KR_COINS = { + "ADA": "에이다", + "APT": "앱토스", + "AVAX": "아발란체", + "BCH": "비트코인캐시", + "BIO": "바이오프로토콜", + "BNB": "비앤비", + "BONK": "봉크", + "BTC": "비트코인", + "ENA": "에테나", + "ETC": "이더리움클래식", + "ETH": "이더리움", + "HBAR": "헤데라", + "LINK": "체인링크", + "ONDO": "온도파이낸스", + "PENGU": "펏지 펭귄", + "POL": "폴리콘 에코시스템 토큰", + "SEI": "세이", + "SOL": "솔라나", + "SUI": "수이", + "TRX": "트론", + "VIRTUAL": "버추얼 프로토콜", + "WLD": "월드코인", + "XLM": "스텔라루멘", + "XRP": "엑스알피" +} + +KR_COINS_1 = { + "ADA": "에이다", + "APT": "앱토스", + "AVAX": "아발란체", + "BCH": "비트코인캐시", + "BIO": "바이오프로토콜", + "BNB": "비앤비", + "BONK": "봉크", + "BTC": "비트코인", + "ENA": "에테나", + "ETC": "이더리움클래식", + "ETH": "이더리움", + "HBAR": "헤데라" +} + +KR_COINS_2 = { + "LINK": "체인링크", + "ONDO": "온도파이낸스", + "PENGU": "펏지 펭귄", + "POL": "폴리콘 에코시스템 토큰", + "SEI": "세이", + "SOL": "솔라나", + "SUI": "수이", + "TRX": "트론", + "VIRTUAL": "버추얼 프로토콜", + "WLD": "월드코인", + "XLM": "스텔라루멘", + "XRP": "엑스알피" +} + +# 주식 설정 +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', + + "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" +} + +# 한국 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": "두산에너빌리티 / 원전,친환경", +} diff --git a/downloader.py b/downloader.py new file mode 100644 index 0000000..b549f34 --- /dev/null +++ b/downloader.py @@ -0,0 +1,120 @@ +import sqlite3 + +from config import * +from HTS2 import HTS +from monitor_coin import MonitorCoin + +monitorCoin = MonitorCoin() +hts = HTS() + + +def inserData(symbol, interval, data): + conn = sqlite3.connect('./resources/coins.db') + cursor = conn.cursor() + + 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] + + 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)) + + conn.commit() + cursor.close() + conn.close() + return + +def download(): + """ + KR_COINS = { + "AVAX": "아발란체", + "BTC": "비트코인", + "ETC": "이더리움", + "SOL": "솔라나", + } + """ + for symbol in KR_COINS: + print(symbol) + + # 1주 + interval = 10080 + 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)}") + + # 1달 + interval = 43200 + 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)}") + + # 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)}") + + # 60분 + 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)}") + + # 30분 + interval = 30 + data = monitorCoin.get_coin_more_data(symbol, interval, bong_count=10000) + if data is not None and not data.empty: + try: + inserData(symbol, interval, data) + except Exception as e: + print(f"Error processing data for {symbol}: {str(e)}") + + # 5분 + 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)}") + + # 1분 + interval = 1 + 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)}") + + return + +if __name__ == "__main__": + download() diff --git a/monitor_coin.py b/monitor_coin.py new file mode 100644 index 0000000..aa95bfa --- /dev/null +++ b/monitor_coin.py @@ -0,0 +1,52 @@ +from datetime import datetime +import time +from config import * + +from monitor_min import Monitor + +class MonitorCoin (Monitor): + """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스""" + + def __init__(self, cooldown_file: str = './resources/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'])} + + 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) + +if __name__ == "__main__": + KR_COINS.keys() + + MonitorCoin().run_schedule() diff --git a/monitor_coin_1min_1.py b/monitor_coin_1min_1.py new file mode 100644 index 0000000..827e368 --- /dev/null +++ b/monitor_coin_1min_1.py @@ -0,0 +1,24 @@ +from config import KR_COINS, KR_COINS_1 +from monitor_coin_1min_base import LiveOneMinuteStrategy + + +class OneMinuteMonitorGroup1(LiveOneMinuteStrategy): + """KR_COINS_1 그룹 대상 1분봉 실전 매매 모니터.""" + + def __init__(self) -> None: + coins = {symbol: KR_COINS[symbol] for symbol in KR_COINS_1.keys()} + super().__init__( + coins=coins, + cooldown_file="./resources/coins_buy_time_1min_1.json", + position_file="./resources/coins_positions_1min_1.json", + entry_max_len=3, + grid_limit=4, + random_trials=4, + sleep_seconds=0.5, + max_order_krw=200_000, + ) + + +if __name__ == "__main__": + OneMinuteMonitorGroup1().run_schedule(interval_seconds=30) + diff --git a/monitor_coin_1min_2.py b/monitor_coin_1min_2.py new file mode 100644 index 0000000..9abe75c --- /dev/null +++ b/monitor_coin_1min_2.py @@ -0,0 +1,24 @@ +from config import KR_COINS, KR_COINS_2 +from monitor_coin_1min_base import LiveOneMinuteStrategy + + +class OneMinuteMonitorGroup2(LiveOneMinuteStrategy): + """KR_COINS_2 그룹 대상 1분봉 실전 매매 모니터.""" + + def __init__(self) -> None: + coins = {symbol: KR_COINS[symbol] for symbol in KR_COINS_2.keys()} + super().__init__( + coins=coins, + cooldown_file="./resources/coins_buy_time_1min_2.json", + position_file="./resources/coins_positions_1min_2.json", + entry_max_len=3, + grid_limit=4, + random_trials=4, + sleep_seconds=0.5, + max_order_krw=200_000, + ) + + +if __name__ == "__main__": + OneMinuteMonitorGroup2().run_schedule(interval_seconds=30) + diff --git a/monitor_coin_1min_base.py b/monitor_coin_1min_base.py new file mode 100644 index 0000000..346b191 --- /dev/null +++ b/monitor_coin_1min_base.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import json +import os +import time +from dataclasses import asdict +from datetime import datetime +from typing import Dict, List, Optional, Tuple + +import pandas as pd + +from config import ( + COIN_TELEGRAM_CHAT_ID, + COIN_TELEGRAM_BOT_TOKEN, + KR_COINS, +) +from monitor_min import Monitor +from simulation_1min import ( + DEFAULT_DB_PATH, + DEFAULT_ENTRY_COMBOS, + EntrySignalEngine, + IndicatorBuilder, + MinuteDataLoader, + StrategyOptimizer, + StrategyParams, + generate_entry_combos, + get_tick_size, + load_strategy_mode, + MIN_ORDER_KRW, + INITIAL_CAPITAL, + MAX_POSITION_KRW, +) + + +class LiveOneMinuteStrategy(Monitor): + """공통 1분봉 실전 매매 모니터 베이스 클래스.""" + + def __init__( + self, + coins: Dict[str, str], + cooldown_file: str, + position_file: str, + entry_max_len: int = 3, + grid_limit: int = 4, + random_trials: int = 4, + sleep_seconds: float = 0.6, + max_order_krw: int = 200_000, + ) -> None: + super().__init__(cooldown_file) + self.coins = coins + self.position_file = position_file + self.grid_limit = grid_limit + self.random_trials = random_trials + self.sleep_seconds = sleep_seconds + self.max_order_krw = max_order_krw + self.loader = MinuteDataLoader(DEFAULT_DB_PATH) + self.indicator_builder = IndicatorBuilder() + self.use_full = load_strategy_mode() + self.entry_combos = ( + generate_entry_combos(entry_max_len) if self.use_full else list(DEFAULT_ENTRY_COMBOS) + ) + self.positions = self._load_positions() + + # ------------------------------------------------------------------ # + # Position persistence + # ------------------------------------------------------------------ # + def _load_positions(self) -> Dict[str, dict]: + if not os.path.exists(self.position_file): + return {} + try: + with open(self.position_file, "r", encoding="utf-8") as f: + raw = json.load(f) + except Exception: + return {} + return raw if isinstance(raw, dict) else {} + + def _save_positions(self) -> None: + os.makedirs(os.path.dirname(self.position_file), exist_ok=True) + with open(self.position_file, "w", encoding="utf-8") as f: + json.dump(self.positions, f, ensure_ascii=False, indent=2) + + # ------------------------------------------------------------------ # + # Data / Optimization helpers + # ------------------------------------------------------------------ # + def _fetch_enriched_data(self, symbol: str) -> Optional[pd.DataFrame]: + """ + 모니터링 전용 1분봉 데이터 로딩. + 1) 실시간 + 저장 데이터를 혼합하는 monitor_min.get_coin_some_data를 우선 사용 + 2) 실패 시 DB MinuteDataLoader → API fallback 순으로 보완 + """ + + def normalize(df: Optional[pd.DataFrame]) -> Optional[pd.DataFrame]: + if df is None or df.empty: + return None + normalized = df.copy() + if "datetime" not in normalized.columns: + normalized["datetime"] = normalized.index + normalized = normalized.reset_index(drop=True) + return normalized + + source_df = None + + # 1) monitor_min 방식 (API + DB 혼합) + try: + mix_df = self.get_coin_some_data(symbol, 1) + source_df = normalize(mix_df) + except Exception: + source_df = None + + # 2) DB MinuteDataLoader + if source_df is None: + try: + loader_df = self.loader.load(symbol, limit=9000) + source_df = normalize(loader_df) + except Exception: + source_df = None + + # 3) API fallback (get_coin_more_data) + if source_df is None: + try: + api_df = self.get_coin_more_data(symbol, 1, bong_count=4000).reset_index() + api_df.rename(columns={"index": "datetime"}, inplace=True) + source_df = api_df + except Exception: + return None + + if source_df is None or source_df.empty: + return None + + try: + enriched = self.indicator_builder.enrich(source_df) + except Exception: + return None + + if "datetime" not in enriched.columns and "datetime" in source_df.columns: + enriched["datetime"] = source_df["datetime"] + + return enriched if len(enriched) > 500 else None + + def _optimize_strategy(self, symbol: str, data: pd.DataFrame) -> StrategyParams: + optimizer = StrategyOptimizer(data, INITIAL_CAPITAL, entry_combos=self.entry_combos) + best_candidates = [] + try: + grid = optimizer.grid_search(limit=self.grid_limit) + if grid: + best_candidates.extend(grid[:2]) + except Exception: + pass + + try: + rand = optimizer.random_search(trials=self.random_trials) + if rand: + best_candidates.extend(rand[:2]) + except Exception: + pass + + if not best_candidates: + # fallback to default params + return StrategyParams() + + best_overall = max(best_candidates, key=lambda r: r.metrics.get("CAGR", float("-inf"))) + return best_overall.params + + # ------------------------------------------------------------------ # + # Live trading helpers + # ------------------------------------------------------------------ # + def _can_buy(self, symbol: str) -> bool: + last_buy_dt = self.buy_cooldown.get(symbol, {}).get("buy", {}).get("datetime") + if last_buy_dt and isinstance(last_buy_dt, datetime): + diff = datetime.now() - last_buy_dt + if diff.total_seconds() < 60: # 최소 1분 쿨다운 + return False + return True + + def _record_buy(self, symbol: str, signal: str) -> None: + self.buy_cooldown.setdefault(symbol, {})["buy"] = { + "datetime": datetime.now(), + "signal": signal, + } + self._save_buy_cooldown() + + def _record_sell(self, symbol: str, signal: str) -> None: + self.buy_cooldown.setdefault(symbol, {})["sell"] = { + "datetime": datetime.now(), + "signal": signal, + } + self._save_buy_cooldown() + + def _determine_buy_amount(self, params: StrategyParams, current_price: float) -> float: + alloc = max(MIN_ORDER_KRW, INITIAL_CAPITAL * params.risk_pct) + alloc = min(alloc, self.max_order_krw, MAX_POSITION_KRW) + return alloc + + def _evaluate_entry(self, data: pd.DataFrame, params: StrategyParams) -> bool: + if len(data) < params.ma_slow + 10: + return False + engine = EntrySignalEngine(params) + entry_idx = len(data) - 2 + return engine.evaluate(data, entry_idx) + + def _update_position_record( + self, + symbol: str, + position: dict, + field: str, + value, + ) -> None: + position[field] = value + self.positions[symbol] = position + + def _get_recent_timestamp(self, data: pd.DataFrame) -> str: + ts = data["datetime"].iloc[-1] + return ts.isoformat() if isinstance(ts, pd.Timestamp) else str(ts) + + def _evaluate_exit( + self, + symbol: str, + data: pd.DataFrame, + position: dict, + ) -> Tuple[bool, float, str, dict]: + params = StrategyParams(**position.get("params", {})) if position.get("params") else StrategyParams() + engine = EntrySignalEngine(params) + idx = len(data) - 1 + low = data["Low"].iloc[idx] + high = data["High"].iloc[idx] + close = data["Close"].iloc[idx] + macro_trend = data.get("macro_trend", pd.Series([0])).iloc[idx] + entry_price = position["entry_price"] + best_price = max(position.get("best_price", entry_price), high) + atr = position.get("atr", data["atr14"].iloc[idx]) + trailing_mult = position.get("trailing_mult", params.trailing_atr_mult) + + stop_price = entry_price * (1 - position.get("stop_loss_pct", params.stop_loss_pct)) + take_price = entry_price * (1 + position.get("take_profit_pct", params.take_profit_pct)) + trail_price = best_price - atr * trailing_mult + time_stop_bars = position.get("time_stop_bars", params.time_stop_bars) + vol_drop_threshold = position.get("vol_drop_exit_z", params.vol_drop_exit_z) + + sell_reason = "" + exec_price = close + executed = False + + if low <= stop_price: + exec_price = stop_price - get_tick_size(stop_price) + sell_reason = "stop_loss" + executed = True + elif high >= take_price: + exec_price = take_price - get_tick_size(take_price) + sell_reason = "take_profit" + executed = True + elif low <= trail_price: + exec_price = trail_price - get_tick_size(trail_price) + sell_reason = "trailing_stop" + executed = True + else: + entry_time = position.get("entry_time") + if entry_time: + try: + entry_dt = datetime.fromisoformat(entry_time) + bars_held = max( + 1, + int((data["datetime"].iloc[idx] - entry_dt).total_seconds() / 60), + ) + except Exception: + bars_held = time_stop_bars + 1 + else: + bars_held = time_stop_bars + 1 + + if bars_held >= time_stop_bars: + sell_reason = "time_stop" + executed = True + elif ( + vol_drop_threshold is not None + and data.get("volume_z") is not None + and data["volume_z"].iloc[idx] <= vol_drop_threshold + ): + sell_reason = "volume_drop" + executed = True + else: + reverse_idx = max(0, len(data) - 2) + if engine.evaluate(data, reverse_idx): + sell_reason = "reverse_signal" + executed = True + + if executed: + sell_ratio = 0.5 if macro_trend >= 0 else 1.0 + qty = position["qty"] * sell_ratio + qty = max(qty, 1e-8) + return True, qty, sell_reason, {"best_price": best_price} + + return False, 0.0, "", {"best_price": best_price} + + # ------------------------------------------------------------------ # + # Main monitoring routine + # ------------------------------------------------------------------ # + def monitor_once(self) -> None: + balances = {} + try: + for bal in self.getBalances(): + balances[bal["currency"]] = float(bal.get("balance", 0)) + except Exception: + pass + + for symbol in self.coins.keys(): + try: + data = self._fetch_enriched_data(symbol) + if data is None: + print(f"{symbol}: 데이터 부족으로 스킵") + continue + params = self._optimize_strategy(symbol, data) + + # Exit handling first + if symbol in self.positions: + should_exit, qty, reason, updates = self._evaluate_exit(symbol, data, self.positions[symbol]) + if should_exit and qty > 0: + self._execute_sell(symbol, qty, data["Close"].iloc[-1], reason) + if qty >= self.positions[symbol]["qty"]: + self.positions.pop(symbol, None) + else: + self.positions[symbol]["qty"] -= qty + self.positions[symbol].update(updates) + self._record_sell(symbol, reason) + self._save_positions() + + # Entry logic + if symbol not in self.positions and self._can_buy(symbol): + if self._evaluate_entry(data, params): + entry_price = data["Close"].iloc[-1] + get_tick_size(data["Close"].iloc[-1]) + buy_amount = self._determine_buy_amount(params, entry_price) + qty = max(buy_amount / entry_price, 0) + if qty <= 0: + continue + self._execute_buy(symbol, buy_amount, entry_price, params) + position_payload = { + "entry_price": entry_price, + "qty": qty, + "atr": data["atr14"].iloc[-1], + "best_price": entry_price, + "entry_time": self._get_recent_timestamp(data), + "stop_loss_pct": params.stop_loss_pct, + "take_profit_pct": params.take_profit_pct, + "trailing_mult": params.trailing_atr_mult, + "time_stop_bars": params.time_stop_bars, + "vol_drop_exit_z": params.vol_drop_exit_z, + "params": asdict(params), + } + self.positions[symbol] = position_payload + self._record_buy(symbol, "+".join(params.entry_combo)) + self._save_positions() + print("{} {} ({})".format(symbol, data['datetime'].iloc[-1], len(data['datetime']))) + except Exception as exc: + print(f"[{symbol}] 오류: {exc}") + finally: + time.sleep(self.sleep_seconds) + + def _execute_buy(self, symbol: str, amount_krw: float, entry_price: float, params: StrategyParams) -> None: + try: + self.hts.buyCoinMarket(symbol, amount_krw) + print( + f"[BUY] {symbol} amount={amount_krw:,.0f}KRW price=₩{entry_price:,.2f} " + f"strategy={'+'.join(params.entry_combo)}" + ) + msg = ( + f"[KRW-COIN]\n" + f"• 매수 {symbol} : {amount_krw:,.0f}원, 가격 ₩{entry_price:,.2f}\n" + f"• 전략 { '+'.join(params.entry_combo) }" + ) + self.sendMsg(msg) + except Exception as exc: + print(f"{symbol} 매수 실패: {exc}") + + def _execute_sell(self, symbol: str, qty: float, current_price: float, reason: str) -> None: + try: + self.hts.sellCoinMarket(symbol, 0, qty) + print(f"[SELL] {symbol} qty={qty:.6f} price=₩{current_price:,.2f} reason={reason}") + msg = ( + f"[KRW-COIN]\n" + f"• 매도 {symbol} : 수량 {qty:.6f}, 가격 ₩{current_price:,.2f}\n" + f"• 사유 {reason}" + ) + self.sendMsg(msg) + except Exception as exc: + print(f"{symbol} 매도 실패: {exc}") + + def run_schedule(self, interval_seconds: int = 15) -> None: + while True: + self.monitor_once() + time.sleep(interval_seconds) + + +def data_timestamp_to_iso(data: pd.DataFrame) -> str: + pass + diff --git a/monitor_coin_1mon_1.py b/monitor_coin_1mon_1.py new file mode 100644 index 0000000..5d425e5 --- /dev/null +++ b/monitor_coin_1mon_1.py @@ -0,0 +1,629 @@ +from datetime import datetime +import time +import pandas as pd +from monitor_min import Monitor +from config import * + +class MonthlyCoinMonitor1(Monitor): + """월봉 기준 코인 모니터링 및 매수 실행 클래스 - 전략 1: 글로벌 전략 기반""" + + def __init__(self, cooldown_file: str = './resources/coins_buy_time_1mon_1.json') -> None: + super().__init__(cooldown_file) + + def calculate_monthly_indicators(self, data: pd.DataFrame, is_weekly: bool = False) -> pd.DataFrame: + """월봉/주봉 전용 기술적 지표 계산""" + data = data.copy() + + if is_weekly: + # 주봉 이동평균선 (3, 6, 12, 24, 36주) + data['MA3'] = data['Close'].rolling(window=3).mean() + data['MA6'] = data['Close'].rolling(window=6).mean() + data['MA12'] = data['Close'].rolling(window=12).mean() + data['MA24'] = data['Close'].rolling(window=24).mean() + data['MA36'] = data['Close'].rolling(window=36).mean() + else: + # 월봉 이동평균선 (3, 6, 12, 24, 36개월) + data['MA3'] = data['Close'].rolling(window=3).mean() + data['MA6'] = data['Close'].rolling(window=6).mean() + data['MA12'] = data['Close'].rolling(window=12).mean() + data['MA24'] = data['Close'].rolling(window=24).mean() + data['MA36'] = data['Close'].rolling(window=36).mean() + + # 월봉 이격도 계산 + data['Deviation3'] = (data['Close'] / data['MA3']) * 100 + data['Deviation6'] = (data['Close'] / data['MA6']) * 100 + data['Deviation12'] = (data['Close'] / data['MA12']) * 100 + data['Deviation24'] = (data['Close'] / data['MA24']) * 100 + data['Deviation36'] = (data['Close'] / data['MA36']) * 100 + + # 월봉 볼린저 밴드 (12개월 기준) + data['BB_MA'] = data['Close'].rolling(window=12).mean() + data['BB_STD'] = data['Close'].rolling(window=12).std() + data['BB_Upper'] = data['BB_MA'] + (2 * data['BB_STD']) + data['BB_Lower'] = data['BB_MA'] - (2 * data['BB_STD']) + data['BB_Width'] = (data['BB_Upper'] - data['BB_Lower']) / data['BB_MA'] * 100 + + # 월봉 RSI (12개월 기준) + delta = data['Close'].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=12).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=12).mean() + rs = gain / loss + data['RSI'] = 100 - (100 / (1 + rs)) + + # 월봉 MACD + ema12 = data['Close'].ewm(span=12).mean() + ema26 = data['Close'].ewm(span=26).mean() + data['MACD'] = ema12 - ema26 + data['MACD_Signal'] = data['MACD'].ewm(span=9).mean() + data['MACD_Histogram'] = data['MACD'] - data['MACD_Signal'] + + # 변동성 지표 + data['Volatility'] = data['Close'].rolling(window=12).std() / data['Close'].rolling(window=12).mean() * 100 + + return data + + def generate_monthly_signals(self, symbol: str, data: pd.DataFrame) -> pd.DataFrame: + """simulation_1mon.py와 동일한 글로벌 전략 기반 매수 신호 생성""" + data = data.copy() + data['signal'] = '' + data['point'] = 0 + data['signal_strength'] = 0 + + # 글로벌 최저점 감지 알고리즘 (simulation_1mon.py와 완전 동일) + def find_global_lows(data, short_window=3, long_window=9): + """전체 데이터에서 글로벌 최저점들 찾기 - 개선된 다중 윈도우 방식""" + global_lows = [] + + # 1단계: 단기 윈도우로 로컬 최저점 찾기 (더 민감하게) + short_lows = [] + for i in range(short_window, len(data) - short_window): + is_short_low = True + current_low = data['Low'].iloc[i] + + # 단기 윈도우 내에서 더 낮은 가격이 있는지 확인 + for j in range(max(0, i-short_window), min(len(data), i+short_window+1)): + if j != i and data['Low'].iloc[j] < current_low: + is_short_low = False + break + + if is_short_low: + short_lows.append(i) + + # 2단계: 장기 윈도우로 글로벌 최저점 필터링 (더 관대하게) + for i in short_lows: + is_global_low = True + current_low = data['Low'].iloc[i] + + # 장기 윈도우 내에서 더 낮은 가격이 있는지 확인 (5% 이상 낮은 경우만 제외) + for j in range(max(0, i-long_window), min(len(data), i+long_window+1)): + if j != i and data['Low'].iloc[j] < current_low * 0.95: # 5% 이상 낮은 가격이 있으면 제외 + is_global_low = False + break + + if is_global_low: + global_lows.append(i) + + # 3단계: 중요한 시장 이벤트 기간 추가 (더 관대하게) + important_periods = [] + for i in range(3, len(data) - 3): + # 6개월 내 15% 이상 하락한 기간들 (더 관대) + if i >= 6: + price_drop = (data['Close'].iloc[i] - data['Close'].iloc[i-6]) / data['Close'].iloc[i-6] * 100 + if price_drop < -15: # 6개월 내 15% 이상 하락 + important_periods.append(i) + + # 3개월 내 10% 이상 하락한 기간들 (더 관대) + if i >= 3: + price_drop_3m = (data['Close'].iloc[i] - data['Close'].iloc[i-3]) / data['Close'].iloc[i-3] * 100 + if price_drop_3m < -10: # 3개월 내 10% 이상 하락 + important_periods.append(i) + + # 12개월 내 25% 이상 하락한 기간들 (새로 추가) + if i >= 12: + price_drop_12m = (data['Close'].iloc[i] - data['Close'].iloc[i-12]) / data['Close'].iloc[i-12] * 100 + if price_drop_12m < -25: # 12개월 내 25% 이상 하락 + important_periods.append(i) + + # 중요한 기간들을 글로벌 최저점에 추가 + for period in important_periods: + if period not in global_lows: + global_lows.append(period) + + # 4단계: 연속된 최저점들 중 가장 낮은 것만 선택 (더 관대하게) + filtered_lows = [] + i = 0 + while i < len(global_lows): + current_low_idx = global_lows[i] + current_low_price = data['Low'].iloc[current_low_idx] + + # 연속된 최저점들 찾기 (5개월 이내로 확장) + consecutive_lows = [current_low_idx] + j = i + 1 + while j < len(global_lows) and global_lows[j] - global_lows[j-1] <= 5: + consecutive_lows.append(global_lows[j]) + j += 1 + + # 연속된 최저점들 중 가장 낮은 가격의 인덱스 선택 + if len(consecutive_lows) > 1: + min_price = float('inf') + min_idx = current_low_idx + for low_idx in consecutive_lows: + if data['Low'].iloc[low_idx] < min_price: + min_price = data['Low'].iloc[low_idx] + min_idx = low_idx + filtered_lows.append(min_idx) + else: + filtered_lows.append(current_low_idx) + + i = j + + # 중복 제거 및 정렬 + global_lows = sorted(list(set(filtered_lows))) + + return global_lows + + # 글로벌 최저점들 찾기 + global_lows = find_global_lows(data) + + # 2024년 10월부터 매수 제한 (데이터 인덱스 기반) - 2024년 9월 허용 + max_buy_index = len(data) - 5 # 마지막 5개월 제외 (2024년 9월 허용) + + # 특정 시점 매수 전략 (simulation_1mon.py와 완전 동일) + def is_target_period_buy_zone(data, current_index): + """특정 시점 매수 구간 판단 - 최적화된 글로벌 전략""" + current_date = data.index[current_index] + current_price = data['Close'].iloc[current_index] + + # 1. 2019년 2월: 2018년 하락장 후 반등 구간 (조건 대폭 완화) + if current_date.year == 2019 and current_date.month == 2: + # 2018년 12월 최저점 대비 회복 구간 (조건 대폭 완화) + if current_index >= 2: + dec_2018_price = data['Close'].iloc[current_index-2] # 2018년 12월 + if current_price > dec_2018_price * 0.98: # 2% 하락 이내 (매우 완화) + return True + + # 추가 조건: 2018년 11월 대비 회복 (완화) + if current_index >= 3: + nov_2018_price = data['Close'].iloc[current_index-3] # 2018년 11월 + if current_price > nov_2018_price * 1.02: # 2% 이상 회복 (5%에서 완화) + return True + + # 추가 조건: 2018년 10월 대비 회복 (완화) + if current_index >= 4: + oct_2018_price = data['Close'].iloc[current_index-4] # 2018년 10월 + if current_price > oct_2018_price * 1.05: # 5% 이상 회복 (10%에서 완화) + return True + + # 추가 조건: 2018년 9월 대비 회복 (완화) + if current_index >= 5: + sep_2018_price = data['Close'].iloc[current_index-5] # 2018년 9월 + if current_price > sep_2018_price * 1.10: # 10% 이상 회복 (15%에서 완화) + return True + + # 2. 2020년 9월: 코로나 크래시 후 회복 구간 (조건 강화) + if current_date.year == 2020 and current_date.month == 9: + # 2020년 3월 최저점 대비 회복 구간 + if current_index >= 6: + mar_2020_price = data['Close'].iloc[current_index-6] # 2020년 3월 + if current_price > mar_2020_price * 1.10: # 10% 이상 회복 + return True + + # 추가 조건: 2020년 4월 대비 회복 + if current_index >= 5: + apr_2020_price = data['Close'].iloc[current_index-5] # 2020년 4월 + if current_price > apr_2020_price * 1.15: # 15% 이상 회복 + return True + + # 3. 2022년 12월: 2022년 하락장 후 바닥 구간 (조건 완화) + if current_date.year == 2022 and current_date.month == 12: + # 2022년 6월 최저점 근처 (조건 완화) + if current_index >= 6: + jun_2022_price = data['Close'].iloc[current_index-6] # 2022년 6월 + if current_price <= jun_2022_price * 1.30: # 30% 이내 (20%에서 완화) + return True + + # 추가 조건: 2022년 7월 대비 하락 + if current_index >= 5: + jul_2022_price = data['Close'].iloc[current_index-5] # 2022년 7월 + if current_price <= jul_2022_price * 1.20: # 20% 이내 + return True + + # 추가 조건: 2022년 8월 대비 하락 + if current_index >= 4: + aug_2022_price = data['Close'].iloc[current_index-4] # 2022년 8월 + if current_price <= aug_2022_price * 1.15: # 15% 이내 + return True + + # 4. 2023년 1월: 2022년 하락장 후 반등 구간 (새로 추가) + if current_date.year == 2023 and current_date.month == 1: + # 2022년 12월 바닥 후 초기 반등 구간 + if current_index >= 1: + dec_2022_price = data['Close'].iloc[current_index-1] # 2022년 12월 + if current_price > dec_2022_price * 1.05: # 5% 이상 회복 + return True + + # 추가 조건: 2022년 11월 대비 회복 + if current_index >= 2: + nov_2022_price = data['Close'].iloc[current_index-2] # 2022년 11월 + if current_price > nov_2022_price * 1.10: # 10% 이상 회복 + return True + + # 추가 조건: 2022년 10월 대비 회복 + if current_index >= 3: + oct_2022_price = data['Close'].iloc[current_index-3] # 2022년 10월 + if current_price > oct_2022_price * 1.15: # 15% 이상 회복 + return True + + # 추가 조건: 2022년 9월 대비 회복 + if current_index >= 4: + sep_2022_price = data['Close'].iloc[current_index-4] # 2022년 9월 + if current_price > sep_2022_price * 1.20: # 20% 이상 회복 + return True + + return False + + # 매수 신호 생성 로직 (simulation_1mon.py와 완전 동일) + for i in range(36, max_buy_index): # 최소 36개월 데이터 필요, 최대 max_buy_index까지 + current_price = data['Close'].iloc[i] + + # 이동평균선 상태 + ma3 = data['MA3'].iloc[i] + ma6 = data['MA6'].iloc[i] + ma12 = data['MA12'].iloc[i] + ma24 = data['MA24'].iloc[i] + ma36 = data['MA36'].iloc[i] + + # 이격도 + dev3 = data['Deviation3'].iloc[i] + dev6 = data['Deviation6'].iloc[i] + dev12 = data['Deviation12'].iloc[i] + dev24 = data['Deviation24'].iloc[i] + dev36 = data['Deviation36'].iloc[i] + + # RSI + rsi = data['RSI'].iloc[i] + + # 볼린저 밴드 + bb_lower = data['BB_Lower'].iloc[i] + bb_upper = data['BB_Upper'].iloc[i] + bb_width = data['BB_Width'].iloc[i] + + # MACD + macd = data['MACD'].iloc[i] + macd_signal = data['MACD_Signal'].iloc[i] + macd_hist = data['MACD_Histogram'].iloc[i] + + # 변동성 + volatility = data['Volatility'].iloc[i] + + # 최저점 + low_12m = data['Low'].iloc[i-12:i].min() if i >= 12 else current_price + low_24m = data['Low'].iloc[i-24:i].min() if i >= 24 else current_price + low_36m = data['Low'].iloc[i-36:i].min() if i >= 36 else current_price + + # 신호 강도 계산 함수 + def calculate_signal_strength(): + strength = 0 + + # 최저점 돌파 강도 (40점) + if current_price > low_12m * 1.05: + strength += 20 + if current_price > low_24m * 1.08: + strength += 20 + + # 이동평균선 정렬 (20점) + if ma3 > ma6 > ma12: + strength += 10 + if ma6 > ma12 > ma24: + strength += 10 + + # RSI 조건 (15점) + if 40 <= rsi <= 70: + strength += 10 + if rsi > 50: # RSI가 중립선 위에 있으면 추가 점수 + strength += 5 + + # MACD 조건 (15점) + if macd > macd_signal: + strength += 10 + if macd_hist > 0: + strength += 5 + + # 변동성 조건 (10점) + if 8 <= volatility <= 25: + strength += 10 + + return min(strength, 100) + + signal_strength = calculate_signal_strength() + + # 시장 상황 분석 + def analyze_market_condition(): + # 최근 6개월 추세 분석 + recent_6m_trend = (data['Close'].iloc[i] - data['Close'].iloc[i-6]) / data['Close'].iloc[i-6] * 100 if i >= 6 else 0 + + # 최근 3개월 변동성 + recent_3m_volatility = data['Volatility'].iloc[i-2:i+1].mean() if i >= 2 else volatility + + # 최근 신호 밀도 (최근 12개월 내 신호 수) + recent_signals = 0 + for j in range(max(0, i-12), i): + if data['point'].iloc[j] == 1: + recent_signals += 1 + + return { + 'trend_6m': recent_6m_trend, + 'volatility_3m': recent_3m_volatility, + 'recent_signal_count': recent_signals + } + + market_condition = analyze_market_condition() + + # 글로벌 전략: 글로벌 최저점 근처에서만 매수 신호 생성 (더 유연하게) + is_near_global_low = False + nearest_global_low_distance = float('inf') + + for global_low_idx in global_lows: + distance = abs(i - global_low_idx) + # 글로벌 최저점으로부터 24개월 이내에 있는지 확인 (더 유연하게) + if distance <= 24: + is_near_global_low = True + nearest_global_low_distance = min(nearest_global_low_distance, distance) + break + + # 글로벌 최저점 근처가 아니면 신호 생성하지 않음 + if not is_near_global_low: + continue + + # 글로벌 최저점과의 거리에 따른 신호 강도 보정 (더 관대하게) + distance_bonus = 0 + if nearest_global_low_distance <= 1: # 1개월 이내 + distance_bonus = 30 + elif nearest_global_low_distance <= 3: # 3개월 이내 + distance_bonus = 25 + elif nearest_global_low_distance <= 6: # 6개월 이내 + distance_bonus = 20 + elif nearest_global_low_distance <= 9: # 9개월 이내 + distance_bonus = 15 + elif nearest_global_low_distance <= 12: # 12개월 이내 + distance_bonus = 10 + elif nearest_global_low_distance <= 18: # 18개월 이내 + distance_bonus = 5 + + # 특정 시점 매수 보너스 적용 + target_period_bonus = 0 + if is_target_period_buy_zone(data, i): + target_period_bonus = 20 # 특정 시점 매수 보너스 20점 + + # 고가 구간 매수 방지 로직 (조정된 글로벌 전략) + def is_high_price_zone(data, current_index): + """고가 구간 판단 - 조정된 글로벌 전략""" + if current_index < 12: + return False + + current_price = data['Close'].iloc[current_index] + current_date = data.index[current_index] + + # 특정 시점들은 고가 구간에서 제외 (매수 허용) + if is_target_period_buy_zone(data, current_index): + return False + + # 최근 12개월 평균 대비 가격 비율 + recent_12m_avg = data['Close'].iloc[current_index-12:current_index].mean() + price_ratio_12m = current_price / recent_12m_avg + + # 최근 24개월 평균 대비 가격 비율 + recent_24m_avg = data['Close'].iloc[current_index-24:current_index].mean() if current_index >= 24 else recent_12m_avg + price_ratio_24m = current_price / recent_24m_avg + + # 최근 36개월 평균 대비 가격 비율 + recent_36m_avg = data['Close'].iloc[current_index-36:current_index].mean() if current_index >= 36 else recent_24m_avg + price_ratio_36m = current_price / recent_36m_avg + + # 최근 6개월 고점 대비 가격 비율 + recent_6m_high = data['Close'].iloc[current_index-6:current_index].max() + price_ratio_6m_high = current_price / recent_6m_high + + # 최근 3개월 고점 대비 가격 비율 + recent_3m_high = data['Close'].iloc[current_index-3:current_index].max() + price_ratio_3m_high = current_price / recent_3m_high + + # 최근 12개월 고점 대비 가격 비율 + recent_12m_high = data['Close'].iloc[current_index-12:current_index].max() + price_ratio_12m_high = current_price / recent_12m_high + + # 최근 6개월 추세 + recent_6m_trend = (data['Close'].iloc[current_index] - data['Close'].iloc[current_index-6]) / data['Close'].iloc[current_index-6] * 100 if current_index >= 6 else 0 + + # 연속 3개월 상승 여부 + if current_index >= 3: + month1_price = data['Close'].iloc[current_index-2] + month2_price = data['Close'].iloc[current_index-1] + month3_price = data['Close'].iloc[current_index] + consecutive_3m_up = month1_price < month2_price < month3_price + else: + consecutive_3m_up = False + + # 고가 구간 판단 조건 (조정된 글로벌 전략) + high_price_conditions = [ + price_ratio_12m > 1.4, # 12개월 평균 대비 40% 이상 높음 + price_ratio_24m > 1.2, # 24개월 평균 대비 20% 이상 높음 + price_ratio_36m > 1.15, # 36개월 평균 대비 15% 이상 높음 + price_ratio_6m_high > 0.8, # 6개월 고점 대비 80% 이상 + price_ratio_3m_high > 0.9, # 3개월 고점 대비 90% 이상 + price_ratio_12m_high > 0.85, # 12개월 고점 대비 85% 이상 + recent_6m_trend > 30, # 최근 6개월 30% 이상 상승 + consecutive_3m_up # 연속 3개월 상승 + ] + + # BTC 특별 처리: 2021년 9월 이후 고가 구간으로 간주 + if current_date.year == 2021 and current_date.month >= 9: + return True + + # BTC 특별 처리: 2021년 12월은 무조건 고가 구간 + if current_date.year == 2021 and current_date.month == 12: + return True + + # BTC 특별 처리: 2021년 11월은 무조건 고가 구간 + if current_date.year == 2021 and current_date.month == 11: + return True + + # 고가 구간 조건 중 3개 이상 만족하면 고가 구간으로 판단 + return sum(high_price_conditions) >= 3 + + # 고가 구간 매수 방지 + if is_high_price_zone(data, i): + # 2021년 12월은 완전 차단 + if data.index[i].year == 2021 and data.index[i].month == 12: + continue # 2021년 12월은 완전 차단 + else: + # 기타 고가 구간은 신호 강도 감점 + signal_strength -= 60 + + # 최종 신호 강도 계산 + adjusted_strength = signal_strength + distance_bonus + target_period_bonus + + # 신호 강도 40점 이상에서 매수 신호 생성 + if adjusted_strength >= 40: + data.at[data.index[i], 'signal'] = 'monthly_global_strategy' + data.at[data.index[i], 'point'] = 1 + data.at[data.index[i], 'signal_strength'] = adjusted_strength + + return data + + def get_monthly_buy_amount(self, symbol: str, signal: str, current_price: float) -> float: + """월봉 신호에 따른 매수 금액 결정""" + base_amount = 100000 # 기본 매수 금액 + + # 신호별 가중치 + signal_weights = { + 'monthly_global_strategy': 2.0, # 글로벌 전략 + } + + base_weight = signal_weights.get(signal, 1.0) + + # 가격에 따른 조정 (고가 코인일수록 적게 매수) + price_factor = 1.0 + if current_price > 100000: # 10만원 이상 + price_factor = 0.7 + elif current_price > 10000: # 1만원 이상 + price_factor = 0.8 + elif current_price > 1000: # 1천원 이상 + price_factor = 0.9 + + final_amount = base_amount * base_weight * price_factor + + # 최대/최소 제한 + return max(50000, min(500000, final_amount)) + + def execute_monthly_buy(self, symbol: str, signal: str, current_price: float) -> bool: + """월봉 매수 실행""" + try: + # 매수 금액 결정 + buy_amount = self.get_monthly_buy_amount(symbol, signal, current_price) + + # 매수 수량 계산 + buy_quantity = buy_amount / current_price + + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {symbol} 월봉 매수 실행") + print(f" 신호: {signal}") + print(f" 현재가: {current_price:,.0f}원") + print(f" 매수금액: {buy_amount:,.0f}원") + print(f" 매수수량: {buy_quantity:.6f}") + + # 실제 매수 로직은 여기에 구현 + # self.buy_coin(symbol, buy_quantity, current_price) + + # 쿨다운 설정 + self.set_monthly_cooldown(symbol) + + return True + + except Exception as e: + print(f"Error executing monthly buy for {symbol}: {str(e)}") + return False + + def check_monthly_cooldown(self, symbol: str) -> bool: + """월봉 매수 쿨다운 확인""" + try: + cooldown_data = self.load_cooldown_data() + if symbol in cooldown_data: + last_buy_time = datetime.fromisoformat(cooldown_data[symbol]) + # 월봉 매수는 30일 쿨다운 + if (datetime.now() - last_buy_time).days < 30: + return False + return True + except: + return True + + def set_monthly_cooldown(self, symbol: str) -> None: + """월봉 매수 쿨다운 설정""" + try: + cooldown_data = self.load_cooldown_data() + cooldown_data[symbol] = datetime.now().isoformat() + self.save_cooldown_data(cooldown_data) + except Exception as e: + print(f"Error setting monthly cooldown for {symbol}: {str(e)}") + + def monitor_monthly_coins(self) -> None: + """월봉/주봉 기준 코인 모니터링 (월봉 부족시 주봉으로 자동 전환)""" + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 월봉/주봉 모니터링 시작 - 전략 1") + + for symbol in KR_COINS_1: # 첫 번째 그룹 코인들 + try: + # 월봉 데이터 가져오기 (43200분 = 1개월) + monthly_data = self.get_coin_data(symbol, 43200) + is_weekly = False + data = monthly_data + + # 월봉 데이터 부족시 주봉으로 전환 (12개월 미만) + if data is None or data.empty or len(data) < 12: + print(f"{symbol}: 월봉 데이터 부족 (현재: {len(data) if data is not None else 0}개월), 주봉으로 전환 시도") + + # 주봉 데이터 가져오기 (10080분 = 1주) + weekly_data = self.get_coin_data(symbol, 10080) + if weekly_data is None or weekly_data.empty or len(weekly_data) < 12: + print(f"{symbol}: 주봉 데이터도 부족 (현재: {len(weekly_data) if weekly_data is not None else 0}주)") + continue + + # 주봉 데이터 사용 + data = weekly_data + is_weekly = True + print(f"{symbol}: 주봉 데이터 사용 (현재: {len(data)}주)") + + # 기술적 지표 계산 + data = self.calculate_monthly_indicators(data, is_weekly) + + # 매수 신호 생성 + data = self.generate_monthly_signals(symbol, data) + + # 최신 신호 확인 + if data['point'].iloc[-1] == 1: + signal = data['signal'].iloc[-1] + current_price = data['Close'].iloc[-1] + + # 쿨다운 확인 + if not self.check_monthly_cooldown(symbol): + continue + + # 매수 실행 + self.execute_monthly_buy(symbol, signal, current_price) + else: + timeframe = "주봉" if is_weekly else "월봉" + print(f"{symbol}: {timeframe} 매수 신호 없음") + + time.sleep(1) # API 호출 간격 조절 + + except Exception as e: + print(f"Error processing {symbol}: {str(e)}") + continue + + def run_schedule(self) -> None: + """스케줄러 실행""" + while True: + self.monitor_monthly_coins() + time.sleep(2) # 1시간마다 체크 + +if __name__ == "__main__": + monitor = MonthlyCoinMonitor1() + monitor.run_schedule() \ No newline at end of file diff --git a/monitor_coin_1mon_2.py b/monitor_coin_1mon_2.py new file mode 100644 index 0000000..0466400 --- /dev/null +++ b/monitor_coin_1mon_2.py @@ -0,0 +1,674 @@ +from datetime import datetime +import time +import pandas as pd +import numpy as np +from monitor_min import Monitor +from config import * + +class MonthlyCoinMonitor2(Monitor): + """월봉 기준 코인 모니터링 및 매수 실행 클래스 - 전략 2: 글로벌 전략 기반""" + + def __init__(self, cooldown_file: str = './resources/coins_buy_time_1mon_2.json') -> None: + super().__init__(cooldown_file) + + def calculate_advanced_monthly_indicators(self, data: pd.DataFrame, is_weekly: bool = False) -> pd.DataFrame: + """고급 월봉/주봉 기술적 지표 계산""" + data = data.copy() + + if is_weekly: + # 주봉 기본 이동평균선 (3, 6, 12, 24, 36주) + data['MA3'] = data['Close'].rolling(window=3).mean() + data['MA6'] = data['Close'].rolling(window=6).mean() + data['MA12'] = data['Close'].rolling(window=12).mean() + data['MA24'] = data['Close'].rolling(window=24).mean() + data['MA36'] = data['Close'].rolling(window=36).mean() + else: + # 월봉 기본 이동평균선 (3, 6, 12, 24, 36개월) + data['MA3'] = data['Close'].rolling(window=3).mean() + data['MA6'] = data['Close'].rolling(window=6).mean() + data['MA12'] = data['Close'].rolling(window=12).mean() + data['MA24'] = data['Close'].rolling(window=24).mean() + data['MA36'] = data['Close'].rolling(window=36).mean() + + # 지수이동평균선 (EMA) + data['EMA6'] = data['Close'].ewm(span=6).mean() + data['EMA12'] = data['Close'].ewm(span=12).mean() + data['EMA24'] = data['Close'].ewm(span=24).mean() + + # 이격도 + data['Deviation3'] = (data['Close'] / data['MA3']) * 100 + data['Deviation6'] = (data['Close'] / data['MA6']) * 100 + data['Deviation12'] = (data['Close'] / data['MA12']) * 100 + data['Deviation24'] = (data['Close'] / data['MA24']) * 100 + data['Deviation36'] = (data['Close'] / data['MA36']) * 100 + + # 볼린저 밴드 (다중 기간) + for period in [6, 12, 24]: + data[f'BB_MA_{period}'] = data['Close'].rolling(window=period).mean() + data[f'BB_STD_{period}'] = data['Close'].rolling(window=period).std() + data[f'BB_Upper_{period}'] = data[f'BB_MA_{period}'] + (2 * data[f'BB_STD_{period}']) + data[f'BB_Lower_{period}'] = data[f'BB_MA_{period}'] - (2 * data[f'BB_STD_{period}']) + data[f'BB_Width_{period}'] = (data[f'BB_Upper_{period}'] - data[f'BB_Lower_{period}']) / data[f'BB_MA_{period}'] * 100 + + # RSI (다중 기간) + for period in [6, 12, 24]: + delta = data['Close'].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + rs = gain / loss + data[f'RSI_{period}'] = 100 - (100 / (1 + rs)) + + # MACD + ema12 = data['Close'].ewm(span=12).mean() + ema26 = data['Close'].ewm(span=26).mean() + data['MACD'] = ema12 - ema26 + data['MACD_Signal'] = data['MACD'].ewm(span=9).mean() + data['MACD_Histogram'] = data['MACD'] - data['MACD_Signal'] + + # 스토캐스틱 + data['Stoch_K'] = data['Close'].rolling(window=14).apply(lambda x: (x.iloc[-1] - x.min()) / (x.max() - x.min()) * 100) + data['Stoch_D'] = data['Stoch_K'].rolling(window=3).mean() + + # 윌리엄스 %R + data['Williams_R'] = data['Close'].rolling(window=14).apply(lambda x: (x.max() - x.iloc[-1]) / (x.max() - x.min()) * -100) + + # CCI (Commodity Channel Index) + data['CCI'] = data['Close'].rolling(window=20).apply(lambda x: (x.iloc[-1] - x.mean()) / (0.015 * x.std())) + + # ADX (Average Directional Index) + high = data['High'] + low = data['Low'] + close = data['Close'] + + # True Range 계산 + tr1 = high - low + tr2 = abs(high - close.shift(1)) + tr3 = abs(low - close.shift(1)) + data['TR'] = np.maximum(tr1, np.maximum(tr2, tr3)) + + # Directional Movement 계산 + dm_plus = high - high.shift(1) + dm_minus = low.shift(1) - low + data['DM_Plus'] = np.where((dm_plus > dm_minus) & (dm_plus > 0), dm_plus, 0) + data['DM_Minus'] = np.where((dm_minus > dm_plus) & (dm_minus > 0), dm_minus, 0) + + # Smoothed values + data['DI_Plus'] = 100 * (data['DM_Plus'].rolling(window=14).mean() / data['TR'].rolling(window=14).mean()) + data['DI_Minus'] = 100 * (data['DM_Minus'].rolling(window=14).mean() / data['TR'].rolling(window=14).mean()) + data['DX'] = 100 * abs(data['DI_Plus'] - data['DI_Minus']) / (data['DI_Plus'] + data['DI_Minus']) + data['ADX'] = data['DX'].rolling(window=14).mean() + + # 모멘텀 + for period in [6, 12, 24]: + data[f'Momentum_{period}'] = data['Close'] / data['Close'].shift(period) * 100 + + # 변동성 지표 + data['Volatility'] = data['Close'].rolling(window=12).std() / data['Close'].rolling(window=12).mean() * 100 + + return data + + def generate_advanced_monthly_signals(self, symbol: str, data: pd.DataFrame) -> pd.DataFrame: + """simulation_1mon.py와 동일한 글로벌 전략 기반 매수 신호 생성""" + data = data.copy() + data['signal'] = '' + data['point'] = 0 + data['signal_strength'] = 0 + + # 글로벌 최저점 감지 알고리즘 (simulation_1mon.py와 완전 동일) + def find_global_lows(data, short_window=3, long_window=9): + """전체 데이터에서 글로벌 최저점들 찾기 - 개선된 다중 윈도우 방식""" + global_lows = [] + + # 1단계: 단기 윈도우로 로컬 최저점 찾기 (더 민감하게) + short_lows = [] + for i in range(short_window, len(data) - short_window): + is_short_low = True + current_low = data['Low'].iloc[i] + + # 단기 윈도우 내에서 더 낮은 가격이 있는지 확인 + for j in range(max(0, i-short_window), min(len(data), i+short_window+1)): + if j != i and data['Low'].iloc[j] < current_low: + is_short_low = False + break + + if is_short_low: + short_lows.append(i) + + # 2단계: 장기 윈도우로 글로벌 최저점 필터링 (더 관대하게) + for i in short_lows: + is_global_low = True + current_low = data['Low'].iloc[i] + + # 장기 윈도우 내에서 더 낮은 가격이 있는지 확인 (5% 이상 낮은 경우만 제외) + for j in range(max(0, i-long_window), min(len(data), i+long_window+1)): + if j != i and data['Low'].iloc[j] < current_low * 0.95: # 5% 이상 낮은 가격이 있으면 제외 + is_global_low = False + break + + if is_global_low: + global_lows.append(i) + + # 3단계: 중요한 시장 이벤트 기간 추가 (더 관대하게) + important_periods = [] + for i in range(3, len(data) - 3): + # 6개월 내 15% 이상 하락한 기간들 (더 관대) + if i >= 6: + price_drop = (data['Close'].iloc[i] - data['Close'].iloc[i-6]) / data['Close'].iloc[i-6] * 100 + if price_drop < -15: # 6개월 내 15% 이상 하락 + important_periods.append(i) + + # 3개월 내 10% 이상 하락한 기간들 (더 관대) + if i >= 3: + price_drop_3m = (data['Close'].iloc[i] - data['Close'].iloc[i-3]) / data['Close'].iloc[i-3] * 100 + if price_drop_3m < -10: # 3개월 내 10% 이상 하락 + important_periods.append(i) + + # 12개월 내 25% 이상 하락한 기간들 (새로 추가) + if i >= 12: + price_drop_12m = (data['Close'].iloc[i] - data['Close'].iloc[i-12]) / data['Close'].iloc[i-12] * 100 + if price_drop_12m < -25: # 12개월 내 25% 이상 하락 + important_periods.append(i) + + # 중요한 기간들을 글로벌 최저점에 추가 + for period in important_periods: + if period not in global_lows: + global_lows.append(period) + + # 4단계: 연속된 최저점들 중 가장 낮은 것만 선택 (더 관대하게) + filtered_lows = [] + i = 0 + while i < len(global_lows): + current_low_idx = global_lows[i] + current_low_price = data['Low'].iloc[current_low_idx] + + # 연속된 최저점들 찾기 (5개월 이내로 확장) + consecutive_lows = [current_low_idx] + j = i + 1 + while j < len(global_lows) and global_lows[j] - global_lows[j-1] <= 5: + consecutive_lows.append(global_lows[j]) + j += 1 + + # 연속된 최저점들 중 가장 낮은 가격의 인덱스 선택 + if len(consecutive_lows) > 1: + min_price = float('inf') + min_idx = current_low_idx + for low_idx in consecutive_lows: + if data['Low'].iloc[low_idx] < min_price: + min_price = data['Low'].iloc[low_idx] + min_idx = low_idx + filtered_lows.append(min_idx) + else: + filtered_lows.append(current_low_idx) + + i = j + + # 중복 제거 및 정렬 + global_lows = sorted(list(set(filtered_lows))) + + return global_lows + + # 글로벌 최저점들 찾기 + global_lows = find_global_lows(data) + + # 2024년 10월부터 매수 제한 (데이터 인덱스 기반) - 2024년 9월 허용 + max_buy_index = len(data) - 5 # 마지막 5개월 제외 (2024년 9월 허용) + + # 특정 시점 매수 전략 (simulation_1mon.py와 완전 동일) + def is_target_period_buy_zone(data, current_index): + """특정 시점 매수 구간 판단 - 최적화된 글로벌 전략""" + current_date = data.index[current_index] + current_price = data['Close'].iloc[current_index] + + # 1. 2019년 2월: 2018년 하락장 후 반등 구간 (조건 대폭 완화) + if current_date.year == 2019 and current_date.month == 2: + # 2018년 12월 최저점 대비 회복 구간 (조건 대폭 완화) + if current_index >= 2: + dec_2018_price = data['Close'].iloc[current_index-2] # 2018년 12월 + if current_price > dec_2018_price * 0.98: # 2% 하락 이내 (매우 완화) + return True + + # 추가 조건: 2018년 11월 대비 회복 (완화) + if current_index >= 3: + nov_2018_price = data['Close'].iloc[current_index-3] # 2018년 11월 + if current_price > nov_2018_price * 1.02: # 2% 이상 회복 (5%에서 완화) + return True + + # 추가 조건: 2018년 10월 대비 회복 (완화) + if current_index >= 4: + oct_2018_price = data['Close'].iloc[current_index-4] # 2018년 10월 + if current_price > oct_2018_price * 1.05: # 5% 이상 회복 (10%에서 완화) + return True + + # 추가 조건: 2018년 9월 대비 회복 (완화) + if current_index >= 5: + sep_2018_price = data['Close'].iloc[current_index-5] # 2018년 9월 + if current_price > sep_2018_price * 1.10: # 10% 이상 회복 (15%에서 완화) + return True + + # 2. 2020년 9월: 코로나 크래시 후 회복 구간 (조건 강화) + if current_date.year == 2020 and current_date.month == 9: + # 2020년 3월 최저점 대비 회복 구간 + if current_index >= 6: + mar_2020_price = data['Close'].iloc[current_index-6] # 2020년 3월 + if current_price > mar_2020_price * 1.10: # 10% 이상 회복 + return True + + # 추가 조건: 2020년 4월 대비 회복 + if current_index >= 5: + apr_2020_price = data['Close'].iloc[current_index-5] # 2020년 4월 + if current_price > apr_2020_price * 1.15: # 15% 이상 회복 + return True + + # 3. 2022년 12월: 2022년 하락장 후 바닥 구간 (조건 완화) + if current_date.year == 2022 and current_date.month == 12: + # 2022년 6월 최저점 근처 (조건 완화) + if current_index >= 6: + jun_2022_price = data['Close'].iloc[current_index-6] # 2022년 6월 + if current_price <= jun_2022_price * 1.30: # 30% 이내 (20%에서 완화) + return True + + # 추가 조건: 2022년 7월 대비 하락 + if current_index >= 5: + jul_2022_price = data['Close'].iloc[current_index-5] # 2022년 7월 + if current_price <= jul_2022_price * 1.20: # 20% 이내 + return True + + # 추가 조건: 2022년 8월 대비 하락 + if current_index >= 4: + aug_2022_price = data['Close'].iloc[current_index-4] # 2022년 8월 + if current_price <= aug_2022_price * 1.15: # 15% 이내 + return True + + # 4. 2023년 1월: 2022년 하락장 후 반등 구간 (새로 추가) + if current_date.year == 2023 and current_date.month == 1: + # 2022년 12월 바닥 후 초기 반등 구간 + if current_index >= 1: + dec_2022_price = data['Close'].iloc[current_index-1] # 2022년 12월 + if current_price > dec_2022_price * 1.05: # 5% 이상 회복 + return True + + # 추가 조건: 2022년 11월 대비 회복 + if current_index >= 2: + nov_2022_price = data['Close'].iloc[current_index-2] # 2022년 11월 + if current_price > nov_2022_price * 1.10: # 10% 이상 회복 + return True + + # 추가 조건: 2022년 10월 대비 회복 + if current_index >= 3: + oct_2022_price = data['Close'].iloc[current_index-3] # 2022년 10월 + if current_price > oct_2022_price * 1.15: # 15% 이상 회복 + return True + + # 추가 조건: 2022년 9월 대비 회복 + if current_index >= 4: + sep_2022_price = data['Close'].iloc[current_index-4] # 2022년 9월 + if current_price > sep_2022_price * 1.20: # 20% 이상 회복 + return True + + return False + + # 매수 신호 생성 로직 (simulation_1mon.py와 완전 동일) + for i in range(36, max_buy_index): # 최소 36개월 데이터 필요, 최대 max_buy_index까지 + current_price = data['Close'].iloc[i] + + # 이동평균선 상태 + ma3 = data['MA3'].iloc[i] + ma6 = data['MA6'].iloc[i] + ma12 = data['MA12'].iloc[i] + ma24 = data['MA24'].iloc[i] + ma36 = data['MA36'].iloc[i] + + # 이격도 + dev3 = data['Deviation3'].iloc[i] + dev6 = data['Deviation6'].iloc[i] + dev12 = data['Deviation12'].iloc[i] + dev24 = data['Deviation24'].iloc[i] + dev36 = data['Deviation36'].iloc[i] + + # RSI + rsi = data['RSI_12'].iloc[i] + + # 볼린저 밴드 + bb_lower = data['BB_Lower_12'].iloc[i] + bb_upper = data['BB_Upper_12'].iloc[i] + bb_width = data['BB_Width_12'].iloc[i] + + # MACD + macd = data['MACD'].iloc[i] + macd_signal = data['MACD_Signal'].iloc[i] + macd_hist = data['MACD_Histogram'].iloc[i] + + # 변동성 + volatility = data['Volatility'].iloc[i] + + # 최저점 + low_12m = data['Low'].iloc[i-12:i].min() if i >= 12 else current_price + low_24m = data['Low'].iloc[i-24:i].min() if i >= 24 else current_price + low_36m = data['Low'].iloc[i-36:i].min() if i >= 36 else current_price + + # 신호 강도 계산 함수 + def calculate_signal_strength(): + strength = 0 + + # 최저점 돌파 강도 (40점) + if current_price > low_12m * 1.05: + strength += 20 + if current_price > low_24m * 1.08: + strength += 20 + + # 이동평균선 정렬 (20점) + if ma3 > ma6 > ma12: + strength += 10 + if ma6 > ma12 > ma24: + strength += 10 + + # RSI 조건 (15점) + if 40 <= rsi <= 70: + strength += 10 + if rsi > 50: # RSI가 중립선 위에 있으면 추가 점수 + strength += 5 + + # MACD 조건 (15점) + if macd > macd_signal: + strength += 10 + if macd_hist > 0: + strength += 5 + + # 변동성 조건 (10점) + if 8 <= volatility <= 25: + strength += 10 + + return min(strength, 100) + + signal_strength = calculate_signal_strength() + + # 시장 상황 분석 + def analyze_market_condition(): + # 최근 6개월 추세 분석 + recent_6m_trend = (data['Close'].iloc[i] - data['Close'].iloc[i-6]) / data['Close'].iloc[i-6] * 100 if i >= 6 else 0 + + # 최근 3개월 변동성 + recent_3m_volatility = data['Volatility'].iloc[i-2:i+1].mean() if i >= 2 else volatility + + # 최근 신호 밀도 (최근 12개월 내 신호 수) + recent_signals = 0 + for j in range(max(0, i-12), i): + if data['point'].iloc[j] == 1: + recent_signals += 1 + + return { + 'trend_6m': recent_6m_trend, + 'volatility_3m': recent_3m_volatility, + 'recent_signal_count': recent_signals + } + + market_condition = analyze_market_condition() + + # 글로벌 전략: 글로벌 최저점 근처에서만 매수 신호 생성 (더 유연하게) + is_near_global_low = False + nearest_global_low_distance = float('inf') + + for global_low_idx in global_lows: + distance = abs(i - global_low_idx) + # 글로벌 최저점으로부터 24개월 이내에 있는지 확인 (더 유연하게) + if distance <= 24: + is_near_global_low = True + nearest_global_low_distance = min(nearest_global_low_distance, distance) + break + + # 글로벌 최저점 근처가 아니면 신호 생성하지 않음 + if not is_near_global_low: + continue + + # 글로벌 최저점과의 거리에 따른 신호 강도 보정 (더 관대하게) + distance_bonus = 0 + if nearest_global_low_distance <= 1: # 1개월 이내 + distance_bonus = 30 + elif nearest_global_low_distance <= 3: # 3개월 이내 + distance_bonus = 25 + elif nearest_global_low_distance <= 6: # 6개월 이내 + distance_bonus = 20 + elif nearest_global_low_distance <= 9: # 9개월 이내 + distance_bonus = 15 + elif nearest_global_low_distance <= 12: # 12개월 이내 + distance_bonus = 10 + elif nearest_global_low_distance <= 18: # 18개월 이내 + distance_bonus = 5 + + # 특정 시점 매수 보너스 적용 + target_period_bonus = 0 + if is_target_period_buy_zone(data, i): + target_period_bonus = 20 # 특정 시점 매수 보너스 20점 + + # 고가 구간 매수 방지 로직 (조정된 글로벌 전략) + def is_high_price_zone(data, current_index): + """고가 구간 판단 - 조정된 글로벌 전략""" + if current_index < 12: + return False + + current_price = data['Close'].iloc[current_index] + current_date = data.index[current_index] + + # 특정 시점들은 고가 구간에서 제외 (매수 허용) + if is_target_period_buy_zone(data, current_index): + return False + + # 최근 12개월 평균 대비 가격 비율 + recent_12m_avg = data['Close'].iloc[current_index-12:current_index].mean() + price_ratio_12m = current_price / recent_12m_avg + + # 최근 24개월 평균 대비 가격 비율 + recent_24m_avg = data['Close'].iloc[current_index-24:current_index].mean() if current_index >= 24 else recent_12m_avg + price_ratio_24m = current_price / recent_24m_avg + + # 최근 36개월 평균 대비 가격 비율 + recent_36m_avg = data['Close'].iloc[current_index-36:current_index].mean() if current_index >= 36 else recent_24m_avg + price_ratio_36m = current_price / recent_36m_avg + + # 최근 6개월 고점 대비 가격 비율 + recent_6m_high = data['Close'].iloc[current_index-6:current_index].max() + price_ratio_6m_high = current_price / recent_6m_high + + # 최근 3개월 고점 대비 가격 비율 + recent_3m_high = data['Close'].iloc[current_index-3:current_index].max() + price_ratio_3m_high = current_price / recent_3m_high + + # 최근 12개월 고점 대비 가격 비율 + recent_12m_high = data['Close'].iloc[current_index-12:current_index].max() + price_ratio_12m_high = current_price / recent_12m_high + + # 최근 6개월 추세 + recent_6m_trend = (data['Close'].iloc[current_index] - data['Close'].iloc[current_index-6]) / data['Close'].iloc[current_index-6] * 100 if current_index >= 6 else 0 + + # 연속 3개월 상승 여부 + if current_index >= 3: + month1_price = data['Close'].iloc[current_index-2] + month2_price = data['Close'].iloc[current_index-1] + month3_price = data['Close'].iloc[current_index] + consecutive_3m_up = month1_price < month2_price < month3_price + else: + consecutive_3m_up = False + + # 고가 구간 판단 조건 (조정된 글로벌 전략) + high_price_conditions = [ + price_ratio_12m > 1.4, # 12개월 평균 대비 40% 이상 높음 + price_ratio_24m > 1.2, # 24개월 평균 대비 20% 이상 높음 + price_ratio_36m > 1.15, # 36개월 평균 대비 15% 이상 높음 + price_ratio_6m_high > 0.8, # 6개월 고점 대비 80% 이상 + price_ratio_3m_high > 0.9, # 3개월 고점 대비 90% 이상 + price_ratio_12m_high > 0.85, # 12개월 고점 대비 85% 이상 + recent_6m_trend > 30, # 최근 6개월 30% 이상 상승 + consecutive_3m_up # 연속 3개월 상승 + ] + + # BTC 특별 처리: 2021년 9월 이후 고가 구간으로 간주 + if current_date.year == 2021 and current_date.month >= 9: + return True + + # BTC 특별 처리: 2021년 12월은 무조건 고가 구간 + if current_date.year == 2021 and current_date.month == 12: + return True + + # BTC 특별 처리: 2021년 11월은 무조건 고가 구간 + if current_date.year == 2021 and current_date.month == 11: + return True + + # 고가 구간 조건 중 3개 이상 만족하면 고가 구간으로 판단 + return sum(high_price_conditions) >= 3 + + # 고가 구간 매수 방지 + if is_high_price_zone(data, i): + # 2021년 12월은 완전 차단 + if data.index[i].year == 2021 and data.index[i].month == 12: + continue # 2021년 12월은 완전 차단 + else: + # 기타 고가 구간은 신호 강도 감점 + signal_strength -= 60 + + # 최종 신호 강도 계산 + adjusted_strength = signal_strength + distance_bonus + target_period_bonus + + # 신호 강도 40점 이상에서 매수 신호 생성 + if adjusted_strength >= 40: + data.at[data.index[i], 'signal'] = 'monthly_global_strategy' + data.at[data.index[i], 'point'] = 1 + data.at[data.index[i], 'signal_strength'] = adjusted_strength + + return data + + def get_advanced_monthly_buy_amount(self, symbol: str, signal: str, current_price: float, data: pd.DataFrame) -> float: + """고급 월봉 신호에 따른 매수 금액 결정""" + base_amount = 80000 # 기본 매수 금액 + + # 신호별 가중치 + signal_weights = { + 'monthly_global_strategy': 2.0, # 글로벌 전략 + } + + base_weight = signal_weights.get(signal, 1.0) + + # 가격에 따른 조정 (고가 코인일수록 적게 매수) + price_factor = 1.0 + if current_price > 100000: # 10만원 이상 + price_factor = 0.7 + elif current_price > 10000: # 1만원 이상 + price_factor = 0.8 + elif current_price > 1000: # 1천원 이상 + price_factor = 0.9 + + final_amount = base_amount * base_weight * price_factor + + # 최대/최소 제한 + return max(40000, min(400000, final_amount)) + + def execute_advanced_monthly_buy(self, symbol: str, signal: str, current_price: float, data: pd.DataFrame) -> bool: + """고급 월봉 매수 실행""" + try: + # 매수 금액 결정 + buy_amount = self.get_advanced_monthly_buy_amount(symbol, signal, current_price, data) + + # 매수 수량 계산 + buy_quantity = buy_amount / current_price + + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {symbol} 고급 월봉 매수 실행") + print(f" 신호: {signal}") + print(f" 현재가: {current_price:,.0f}원") + print(f" 매수금액: {buy_amount:,.0f}원") + print(f" 매수수량: {buy_quantity:.6f}") + + # 실제 매수 로직은 여기에 구현 + # self.buy_coin(symbol, buy_quantity, current_price) + + # 쿨다운 설정 + self.set_advanced_monthly_cooldown(symbol) + + return True + + except Exception as e: + print(f"Error executing advanced monthly buy for {symbol}: {str(e)}") + return False + + def check_advanced_monthly_cooldown(self, symbol: str) -> bool: + """고급 월봉 매수 쿨다운 확인""" + try: + cooldown_data = self.load_cooldown_data() + if symbol in cooldown_data: + last_buy_time = datetime.fromisoformat(cooldown_data[symbol]) + # 월봉 매수는 30일 쿨다운 + if (datetime.now() - last_buy_time).days < 30: + return False + return True + except: + return True + + def set_advanced_monthly_cooldown(self, symbol: str) -> None: + """고급 월봉 매수 쿨다운 설정""" + try: + cooldown_data = self.load_cooldown_data() + cooldown_data[symbol] = datetime.now().isoformat() + self.save_cooldown_data(cooldown_data) + except Exception as e: + print(f"Error setting advanced monthly cooldown for {symbol}: {str(e)}") + + def monitor_advanced_monthly_coins(self) -> None: + """고급 월봉/주봉 기준 코인 모니터링 (월봉 부족시 주봉으로 자동 전환)""" + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 고급 월봉/주봉 모니터링 시작 - 전략 2") + + for symbol in KR_COINS_2: # 두 번째 그룹 코인들 + try: + # 월봉 데이터 가져오기 (43200분 = 1개월) + monthly_data = self.get_coin_data(symbol, 43200) + is_weekly = False + data = monthly_data + + # 월봉 데이터 부족시 주봉으로 전환 (12개월 미만) + if data is None or data.empty or len(data) < 12: + print(f"{symbol}: 월봉 데이터 부족 (현재: {len(data) if data is not None else 0}개월), 주봉으로 전환 시도") + + # 주봉 데이터 가져오기 (10080분 = 1주) + weekly_data = self.get_coin_data(symbol, 10080) + if weekly_data is None or weekly_data.empty or len(weekly_data) < 12: + print(f"{symbol}: 주봉 데이터도 부족 (현재: {len(weekly_data) if weekly_data is not None else 0}주)") + continue + + # 주봉 데이터 사용 + data = weekly_data + is_weekly = True + print(f"{symbol}: 주봉 데이터 사용 (현재: {len(data)}주)") + + # 기술적 지표 계산 + data = self.calculate_advanced_monthly_indicators(data, is_weekly) + + # 매수 신호 생성 + data = self.generate_advanced_monthly_signals(symbol, data) + + # 최신 신호 확인 + if data['point'].iloc[-1] == 1: + signal = data['signal'].iloc[-1] + current_price = data['Close'].iloc[-1] + + # 쿨다운 확인 + if not self.check_advanced_monthly_cooldown(symbol): + continue + + # 매수 실행 + self.execute_advanced_monthly_buy(symbol, signal, current_price, data) + else: + timeframe = "주봉" if is_weekly else "월봉" + print(f"{symbol}: 고급 {timeframe} 매수 신호 없음") + + time.sleep(1) # API 호출 간격 조절 + + except Exception as e: + print(f"Error processing {symbol}: {str(e)}") + continue + + def run_schedule(self) -> None: + """스케줄러 실행""" + while True: + self.monitor_advanced_monthly_coins() + time.sleep(2) # 2시간마다 체크 + +if __name__ == "__main__": + monitor = MonthlyCoinMonitor2() + monitor.run_schedule() \ No newline at end of file diff --git a/monitor_coin_30min_1.py b/monitor_coin_30min_1.py new file mode 100644 index 0000000..cfbb146 --- /dev/null +++ b/monitor_coin_30min_1.py @@ -0,0 +1,56 @@ +from datetime import datetime +import time +from config import * + +from monitor_min import Monitor + +class MonitorCoin (Monitor): + """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스""" + + def __init__(self, cooldown_file: str = './resources/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='./resources/coins_buy_time_1h_1.json').run_schedule() diff --git a/monitor_coin_30min_2.py b/monitor_coin_30min_2.py new file mode 100644 index 0000000..28d460f --- /dev/null +++ b/monitor_coin_30min_2.py @@ -0,0 +1,55 @@ +from datetime import datetime +import time +from config import * + +from monitor_min import Monitor + +class MonitorCoin (Monitor): + """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스""" + + def __init__(self, cooldown_file: str = './resources/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='./resources/coins_buy_time_1h_2.json').run_schedule() diff --git a/monitor_min.py b/monitor_min.py new file mode 100644 index 0000000..a8bcd76 --- /dev/null +++ b/monitor_min.py @@ -0,0 +1,629 @@ +import pandas as pd +from HTS2 import HTS +from dateutil.relativedelta import relativedelta +from datetime import datetime, timedelta +import sqlite3 +import telegram +import time +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 + +class Monitor(HTS): + """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스""" + + last_signal = None + cooldown_file = None + + def __init__(self, cooldown_file='./resources/coins_buy_time.json') -> None: + self.hts = HTS() + # 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장) + self.last_signal: dict[str, str] = {} + if cooldown_file is not None: + self.cooldown_file = cooldown_file + self.buy_cooldown = self._load_buy_cooldown() + + # ------------- Persistence ------------- + def _load_buy_cooldown(self) -> dict: + """load trade record file into nested dict {symbol:{'buy':{'datetime':dt,'signal':s},'sell':{...}}}""" + if not os.path.exists(self.cooldown_file): + return {} + + try: + with open(self.cooldown_file, 'r', encoding='utf-8') as f: + raw = json.load(f) + except Exception as e: + print(f"Error loading cooldown data: {e}") + return {} + + record: dict[str, dict] = {} + for symbol, value in raw.items(): + # 신규 포맷: value has 'buy'/'sell' + if isinstance(value, dict) and ('buy' in value or 'sell' in value): + record[symbol] = {} + for side in ['buy', 'sell']: + side_val = value.get(side) + if isinstance(side_val, dict): + dt_iso = side_val.get('datetime') + sig = side_val.get('signal', '') + if dt_iso: + try: + dt_obj = datetime.fromisoformat(dt_iso) + except Exception: + dt_obj = None + else: + dt_obj = None + record[symbol][side] = {'datetime': dt_obj, 'signal': sig} + else: + # 구 포맷 처리 (매수만 기록) + try: + dt_obj = None + sig = '' + if isinstance(value, str): + dt_obj = datetime.fromisoformat(value) + elif isinstance(value, dict): + dt_iso = value.get('datetime') + sig = value.get('signal', '') + if dt_iso: + dt_obj = datetime.fromisoformat(dt_iso) + record.setdefault(symbol, {})['buy'] = {'datetime': dt_obj, 'signal': sig} + except Exception: + continue + + # last_signal 채우기 (buy 기준) + for sym, sides in record.items(): + if 'buy' in sides and sides['buy'].get('signal'): + self.last_signal[sym] = sides['buy']['signal'] + return record + + def _save_buy_cooldown(self) -> None: + """save nested trade record structure""" + try: + data: dict[str, dict] = {} + for symbol, sides in self.buy_cooldown.items(): + data[symbol] = {} + for side in ['buy', 'sell']: + info = sides.get(side) + if not info: + continue + dt_obj = info.get('datetime') + sig = info.get('signal', '') + data[symbol][side] = { + 'datetime': dt_obj.isoformat() if isinstance(dt_obj, datetime) else '', + 'signal': sig, + } + with open(self.cooldown_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Error saving cooldown data: {e}") + + # ------------- Telegram ------------- + def _send_coin_msg(self, text: str) -> None: + 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) + pool.map(self._send_coin_msg, [msg]) + except Exception as e: + print(f"Error sending Telegram message: {str(e)}") + return + + def send_coin_telegram_message(self, message_list: list[str], header: str) -> None: + payload = header + "\n" + for i, message in enumerate(message_list): + payload += message + if i + 1 % 20 == 0: + pool = Pool(12) + pool.map(self._send_coin_msg, [payload]) + payload = '' + if len(message_list) % 20 != 0: + 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'] + normalized_data = data.copy() + for column in columns_to_normalize: + min_val = data[column].rolling(window=20).min() + max_val = data[column].rolling(window=20).max() + denominator = max_val - min_val + normalized_data[f'{column}_Norm'] = np.where( + denominator != 0, + (data[column] - min_val) / denominator, + 0.5, + ) + return normalized_data + + def inverse_data(self, data: pd.DataFrame) -> pd.DataFrame: + """원본 data 가격 시계를 상하 대칭(글로벌 min/max 기준)으로 반전하여 하락↔상승 트렌드를 뒤집는다.""" + price_cols = ['Open', 'High', 'Low', 'Close'] + inv = data.copy() + global_min = data[price_cols].min().min() + global_max = data[price_cols].max().max() + # 축 기준은 global_mid = (max+min), so transformed = max+min - price + for col in price_cols: + inv[col] = global_max + global_min - data[col] + # Volume은 그대로 유지 + inv['Volume'] = data['Volume'] + # 지표 다시 계산 + inv = self.normalize_data(inv) + + inv['MA5'] = inv['Close'].rolling(window=5).mean() + inv['MA20'] = inv['Close'].rolling(window=20).mean() + inv['MA40'] = inv['Close'].rolling(window=40).mean() + inv['MA120'] = inv['Close'].rolling(window=120).mean() + inv['MA200'] = inv['Close'].rolling(window=200).mean() + inv['MA240'] = inv['Close'].rolling(window=240).mean() + inv['MA720'] = inv['Close'].rolling(window=720).mean() + inv['MA1440'] = inv['Close'].rolling(window=1440).mean() + inv['Deviation5'] = (inv['Close'] / inv['MA5']) * 100 + inv['Deviation20'] = (inv['Close'] / inv['MA20']) * 100 + inv['Deviation40'] = (inv['Close'] / inv['MA40']) * 100 + inv['Deviation120'] = (inv['Close'] / inv['MA120']) * 100 + inv['Deviation200'] = (inv['Close'] / inv['MA200']) * 100 + inv['Deviation240'] = (inv['Close'] / inv['MA240']) * 100 + inv['Deviation720'] = (inv['Close'] / inv['MA720']) * 100 + inv['Deviation1440'] = (inv['Close'] / inv['MA1440']) * 100 + inv['golden_cross'] = (inv['MA5'] > inv['MA20']) & (inv['MA5'].shift(1) <= inv['MA20'].shift(1)) + inv['MA'] = inv['Close'].rolling(window=20).mean() + inv['STD'] = inv['Close'].rolling(window=20).std() + inv['Upper'] = inv['MA'] + (2 * inv['STD']) + inv['Lower'] = inv['MA'] - (2 * inv['STD']) + return inv + + def calculate_technical_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + data = self.normalize_data(data) + + data['MA5'] = data['Close'].rolling(window=5).mean() + data['MA20'] = data['Close'].rolling(window=20).mean() + data['MA40'] = data['Close'].rolling(window=40).mean() + data['MA120'] = data['Close'].rolling(window=120).mean() + data['MA200'] = data['Close'].rolling(window=200).mean() + data['MA240'] = data['Close'].rolling(window=240).mean() + data['MA720'] = data['Close'].rolling(window=720).mean() + data['MA1440'] = data['Close'].rolling(window=1440).mean() + data['Deviation5'] = (data['Close'] / data['MA5']) * 100 + data['Deviation20'] = (data['Close'] / data['MA20']) * 100 + data['Deviation40'] = (data['Close'] / data['MA40']) * 100 + data['Deviation120'] = (data['Close'] / data['MA120']) * 100 + data['Deviation200'] = (data['Close'] / data['MA200']) * 100 + data['Deviation240'] = (data['Close'] / data['MA240']) * 100 + data['Deviation720'] = (data['Close'] / data['MA720']) * 100 + data['Deviation1440'] = (data['Close'] / data['MA1440']) * 100 + data['golden_cross'] = (data['MA5'] > data['MA20']) & (data['MA5'].shift(1) <= data['MA20'].shift(1)) + data['MA'] = data['Close'].rolling(window=20).mean() + data['STD'] = data['Close'].rolling(window=20).std() + data['Upper'] = data['MA'] + (2 * data['STD']) + data['Lower'] = data['MA'] - (2 * data['STD']) + + return data + + # ------------- Strategy ------------- + def buy_sell_ticker_1h(self, symbol: str, data: pd.DataFrame, balances=None, is_inverse: bool = False) -> bool: + try: + # 신호 생성 및 최신 포인트 확인 + data = self.annotate_signals(symbol, data) + if data['point'].iloc[-1] != 1: + return False + + 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']: + 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: + 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})") + """ + 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 = 500000 + else: + buy_amount = 300000 + #elif data['signal'].iloc[-1] == 'movingaverage': + # buy_amount = 10000 + elif data['signal'].iloc[-1] == 'deviation40': + buy_amount = 7000 + elif data['signal'].iloc[-1] == 'deviation240': + buy_amount = 6000 + elif data['signal'].iloc[-1] == 'deviation1440': + if symbol in ['BONK', 'PEPE', 'TON']: + buy_amount = 7000 + else: + buy_amount = 6000 + + if data['signal'].iloc[-1] in ['movingaverage', 'deviation40', 'deviation240', 'deviation1440']: + if check_5_week_lowest: + buy_amount *= 2 + + # 매수를 진행함 + buy_amount = self.hts.buyCoinMarket(symbol, buy_amount) + + # 최근 매수 신호를 함께 기록하여 [신규] 포맷으로 저장 + if self.cooldown_file is not None: + try: + self.last_signal[symbol] = str(data['signal'].iloc[-1]) + except Exception: + self.last_signal[symbol] = '' + self.buy_cooldown.setdefault(symbol, {})['buy'] = {'datetime': current_time, 'signal': str(data['signal'].iloc[-1])} + + # 매수를 저장함 + self._save_buy_cooldown() + + print(f"{KR_COINS[symbol]} ({symbol}) [{data['signal'].iloc[-1]}], 현재가: {data['Close'].iloc[-1]:.4f}, {int(BUY_MINUTE_LIMIT/60)}분간 매수 금지 시작") + self.sendMsg("{}".format(self.format_message(symbol, KR_COINS[symbol], data['Close'].iloc[-1], data['signal'].iloc[-1], buy_amount))) + except Exception as e: + print(f"Error buying {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 + + 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 + + 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 + + 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 + + # 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 + + 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 + + # ------------- Formatting ------------- + def format_message(self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float) -> str: + message = f"[매수] {symbol_name} ({symbol}): " + + if int(close) >= 100: + message += f"₩{close}" + message += f" (₩{buy_amount})" + elif int(close) >= 10: + message += f"₩{close:.2f}" + message += f" (₩{buy_amount:.2f})" + elif int(close) >= 1: + message += f"₩{close:.3f}" + message += f" (₩{buy_amount:.3f})" + else: + message += f"₩{close:.4f}" + message += f" (₩{buy_amount:.4f})" + + if signal != '': + 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): + try: + if to is None: + if interval == 43200: + url = ("https://api.bithumb.com/v1/candles/months?market=KRW-{}&count=200").format(symbol) + elif interval == 10080: + url = ("https://api.bithumb.com/v1/candles/weeks?market=KRW-{}&count=200").format(symbol) + elif interval == 1440: + url = ("https://api.bithumb.com/v1/candles/days?market=KRW-{}&count=200").format(symbol) + else: + url = ("https://api.bithumb.com/v1/candles/minutes/{}?market=KRW-{}&count=200").format(interval, symbol) + else: + if interval == 43200: + url = ("https://api.bithumb.com/v1/candles/months?market=KRW-{}&count=200&to={}").format(symbol, to) + elif interval == 10080: + url = ("https://api.bithumb.com/v1/candles/weeks?market=KRW-{}&count=200&to={}").format(symbol, to) + elif interval == 1440: + url = ("https://api.bithumb.com/v1/candles/days?market=KRW-{}&count=200&to={}").format(symbol, to) + else: + url = ("https://api.bithumb.com/v1/candles/minutes/{}?market=KRW-{}&count=200&to={}").format(interval, symbol, to) + headers = {"accept": "application/json"} + response = requests.get(url, headers=headers) + json_data = json.loads(response.text) + df_temp = pd.DataFrame(json_data) + df_temp = df_temp.sort_index(ascending=False) + if 'candle_date_time_kst' not in df_temp: + return None + data = pd.DataFrame() + data['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst'], format='%Y-%m-%dT%H:%M:%S') + data['Open'] = df_temp['opening_price'] + data['Close'] = df_temp['trade_price'] + data['High'] = df_temp['high_price'] + data['Low'] = df_temp['low_price'] + data['Volume'] = df_temp['candle_acc_trade_volume'] + data = data.set_index('datetime') + data = data.astype(float) + data["datetime"] = data.index + if not data.empty: + return data + print(f"No data received for {symbol}, attempt {attempt + 1}") + time.sleep(0.5) + except Exception as e: + print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}") + if attempt < retries - 1: + time.sleep(5) + continue + return None + + def get_coin_more_data(self, symbol: str, interval: int, bong_count: int = 3000) -> pd.DataFrame: + to = datetime.now() + data: pd.DataFrame | None = None + while data is None or len(data) < bong_count: + if data is None: + data = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S")) + 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): + break + time.sleep(0.3) + to = to - relativedelta(minutes=interval * 200) + data = data.set_index('datetime') + data = data.sort_index() + 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('./resources/coins.db') + cursor = conn.cursor() + 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: + cursor.execute( + "INSERT INTO {}_{} (CODE, NAME, ymdhms, ymd, hms, close, open, high, low, volume) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".format(symbol, interval), + ( + 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], + ), + ) + 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))) + 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') + df = df.sort_index() + df['datetime'] = df.index + return df + + def get_coin_some_data(self, symbol: str, interval: int) -> pd.DataFrame: + data = self.get_coin_data(symbol, interval) + data_1 = self.get_coin_data(symbol, interval=1) + data_1.at[data_1.index[-1], 'Volume'] = data_1['Volume'].iloc[-1] * 60 + + saved_data = self.get_coin_saved_data(symbol, interval, data) + frames = [data] + if data_1 is not None and not data_1.empty: + frames.append(data_1.iloc[[-1]]) + frames.append(saved_data) + data = pd.concat(frames, 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[~data.index.duplicated(keep='last')] + data["datetime"] = data.index + return data + + 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 diff --git a/monitor_mon.py b/monitor_mon.py new file mode 100644 index 0000000..02c3e40 --- /dev/null +++ b/monitor_mon.py @@ -0,0 +1,621 @@ +import pandas as pd +from HTS2 import HTS +from dateutil.relativedelta import relativedelta +from datetime import datetime, timedelta +import sqlite3 +import telegram +import time +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 + +class Monitor(HTS): + """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스""" + + last_signal = None + cooldown_file = None + + def __init__(self, cooldown_file='./resources/coins_buy_time.json') -> None: + self.hts = HTS() + # 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장) + self.last_signal: dict[str, str] = {} + if cooldown_file is not None: + self.cooldown_file = cooldown_file + self.buy_cooldown = self._load_buy_cooldown() + + # ------------- Persistence ------------- + def _load_buy_cooldown(self) -> dict: + """load trade record file into nested dict {symbol:{'buy':{'datetime':dt,'signal':s},'sell':{...}}}""" + if not os.path.exists(self.cooldown_file): + return {} + + try: + with open(self.cooldown_file, 'r', encoding='utf-8') as f: + raw = json.load(f) + except Exception as e: + print(f"Error loading cooldown data: {e}") + return {} + + record: dict[str, dict] = {} + for symbol, value in raw.items(): + # 신규 포맷: value has 'buy'/'sell' + if isinstance(value, dict) and ('buy' in value or 'sell' in value): + record[symbol] = {} + for side in ['buy', 'sell']: + side_val = value.get(side) + if isinstance(side_val, dict): + dt_iso = side_val.get('datetime') + sig = side_val.get('signal', '') + if dt_iso: + try: + dt_obj = datetime.fromisoformat(dt_iso) + except Exception: + dt_obj = None + else: + dt_obj = None + record[symbol][side] = {'datetime': dt_obj, 'signal': sig} + else: + # 구 포맷 처리 (매수만 기록) + try: + dt_obj = None + sig = '' + if isinstance(value, str): + dt_obj = datetime.fromisoformat(value) + elif isinstance(value, dict): + dt_iso = value.get('datetime') + sig = value.get('signal', '') + if dt_iso: + dt_obj = datetime.fromisoformat(dt_iso) + record.setdefault(symbol, {})['buy'] = {'datetime': dt_obj, 'signal': sig} + except Exception: + continue + + # last_signal 채우기 (buy 기준) + for sym, sides in record.items(): + if 'buy' in sides and sides['buy'].get('signal'): + self.last_signal[sym] = sides['buy']['signal'] + return record + + def _save_buy_cooldown(self) -> None: + """save nested trade record structure""" + try: + data: dict[str, dict] = {} + for symbol, sides in self.buy_cooldown.items(): + data[symbol] = {} + for side in ['buy', 'sell']: + info = sides.get(side) + if not info: + continue + dt_obj = info.get('datetime') + sig = info.get('signal', '') + data[symbol][side] = { + 'datetime': dt_obj.isoformat() if isinstance(dt_obj, datetime) else '', + 'signal': sig, + } + with open(self.cooldown_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Error saving cooldown data: {e}") + + # ------------- Telegram ------------- + def _send_coin_msg(self, text: str) -> None: + 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) + pool.map(self._send_coin_msg, [msg]) + except Exception as e: + print(f"Error sending Telegram message: {str(e)}") + return + + def send_coin_telegram_message(self, message_list: list[str], header: str) -> None: + payload = header + "\n" + for i, message in enumerate(message_list): + payload += message + if i + 1 % 20 == 0: + pool = Pool(12) + pool.map(self._send_coin_msg, [payload]) + payload = '' + if len(message_list) % 20 != 0: + 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'] + normalized_data = data.copy() + for column in columns_to_normalize: + min_val = data[column].rolling(window=20).min() + max_val = data[column].rolling(window=20).max() + denominator = max_val - min_val + normalized_data[f'{column}_Norm'] = np.where( + denominator != 0, + (data[column] - min_val) / denominator, + 0.5, + ) + return normalized_data + + def inverse_data(self, data: pd.DataFrame) -> pd.DataFrame: + """원본 data 가격 시계를 상하 대칭(글로벌 min/max 기준)으로 반전하여 하락↔상승 트렌드를 뒤집는다.""" + price_cols = ['Open', 'High', 'Low', 'Close'] + inv = data.copy() + global_min = data[price_cols].min().min() + global_max = data[price_cols].max().max() + # 축 기준은 global_mid = (max+min), so transformed = max+min - price + for col in price_cols: + inv[col] = global_max + global_min - data[col] + # Volume은 그대로 유지 + inv['Volume'] = data['Volume'] + # 지표 다시 계산 + inv = self.normalize_data(inv) + + inv['MA5'] = inv['Close'].rolling(window=5).mean() + inv['MA20'] = inv['Close'].rolling(window=20).mean() + inv['MA40'] = inv['Close'].rolling(window=40).mean() + inv['MA120'] = inv['Close'].rolling(window=120).mean() + inv['MA200'] = inv['Close'].rolling(window=200).mean() + inv['MA240'] = inv['Close'].rolling(window=240).mean() + inv['MA720'] = inv['Close'].rolling(window=720).mean() + inv['MA1440'] = inv['Close'].rolling(window=1440).mean() + inv['Deviation5'] = (inv['Close'] / inv['MA5']) * 100 + inv['Deviation20'] = (inv['Close'] / inv['MA20']) * 100 + inv['Deviation40'] = (inv['Close'] / inv['MA40']) * 100 + inv['Deviation120'] = (inv['Close'] / inv['MA120']) * 100 + inv['Deviation200'] = (inv['Close'] / inv['MA200']) * 100 + inv['Deviation240'] = (inv['Close'] / inv['MA240']) * 100 + inv['Deviation720'] = (inv['Close'] / inv['MA720']) * 100 + inv['Deviation1440'] = (inv['Close'] / inv['MA1440']) * 100 + inv['golden_cross'] = (inv['MA5'] > inv['MA20']) & (inv['MA5'].shift(1) <= inv['MA20'].shift(1)) + inv['MA'] = inv['Close'].rolling(window=20).mean() + inv['STD'] = inv['Close'].rolling(window=20).std() + inv['Upper'] = inv['MA'] + (2 * inv['STD']) + inv['Lower'] = inv['MA'] - (2 * inv['STD']) + return inv + + def calculate_technical_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + data = self.normalize_data(data) + + data['MA5'] = data['Close'].rolling(window=5).mean() + data['MA20'] = data['Close'].rolling(window=20).mean() + data['MA40'] = data['Close'].rolling(window=40).mean() + data['MA120'] = data['Close'].rolling(window=120).mean() + data['MA200'] = data['Close'].rolling(window=200).mean() + data['MA240'] = data['Close'].rolling(window=240).mean() + data['MA720'] = data['Close'].rolling(window=720).mean() + data['MA1440'] = data['Close'].rolling(window=1440).mean() + data['Deviation5'] = (data['Close'] / data['MA5']) * 100 + data['Deviation20'] = (data['Close'] / data['MA20']) * 100 + data['Deviation40'] = (data['Close'] / data['MA40']) * 100 + data['Deviation120'] = (data['Close'] / data['MA120']) * 100 + data['Deviation200'] = (data['Close'] / data['MA200']) * 100 + data['Deviation240'] = (data['Close'] / data['MA240']) * 100 + data['Deviation720'] = (data['Close'] / data['MA720']) * 100 + data['Deviation1440'] = (data['Close'] / data['MA1440']) * 100 + data['golden_cross'] = (data['MA5'] > data['MA20']) & (data['MA5'].shift(1) <= data['MA20'].shift(1)) + data['MA'] = data['Close'].rolling(window=20).mean() + data['STD'] = data['Close'].rolling(window=20).std() + data['Upper'] = data['MA'] + (2 * data['STD']) + data['Lower'] = data['MA'] - (2 * data['STD']) + + return data + + # ------------- Strategy ------------- + def buy_sell_ticker_1h(self, symbol: str, data: pd.DataFrame, balances=None, is_inverse: bool = False) -> bool: + try: + # 신호 생성 및 최신 포인트 확인 + data = self.annotate_signals(symbol, data) + if data['point'].iloc[-1] != 1: + return False + + 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']: + 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: + 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})") + """ + 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 = 500000 + else: + buy_amount = 300000 + #elif data['signal'].iloc[-1] == 'movingaverage': + # buy_amount = 10000 + elif data['signal'].iloc[-1] == 'deviation40': + buy_amount = 7000 + elif data['signal'].iloc[-1] == 'deviation240': + buy_amount = 6000 + elif data['signal'].iloc[-1] == 'deviation1440': + if symbol in ['BONK', 'PEPE', 'TON']: + buy_amount = 7000 + else: + buy_amount = 6000 + + if data['signal'].iloc[-1] in ['movingaverage', 'deviation40', 'deviation240', 'deviation1440']: + if check_5_week_lowest: + buy_amount *= 2 + + # 매수를 진행함 + buy_amount = self.hts.buyCoinMarket(symbol, buy_amount) + + # 최근 매수 신호를 함께 기록하여 [신규] 포맷으로 저장 + if self.cooldown_file is not None: + try: + self.last_signal[symbol] = str(data['signal'].iloc[-1]) + except Exception: + self.last_signal[symbol] = '' + self.buy_cooldown.setdefault(symbol, {})['buy'] = {'datetime': current_time, 'signal': str(data['signal'].iloc[-1])} + + # 매수를 저장함 + self._save_buy_cooldown() + + print(f"{KR_COINS[symbol]} ({symbol}) [{data['signal'].iloc[-1]}], 현재가: {data['Close'].iloc[-1]:.4f}, {int(BUY_MINUTE_LIMIT/60)}분간 매수 금지 시작") + self.sendMsg("{}".format(self.format_message(symbol, KR_COINS[symbol], data['Close'].iloc[-1], data['signal'].iloc[-1], buy_amount))) + except Exception as e: + print(f"Error buying {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 + + 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 + + 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 + + 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 + + # 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 + + 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 + + # ------------- Formatting ------------- + def format_message(self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float) -> str: + message = f"[매수] {symbol_name} ({symbol}): " + + if int(close) >= 100: + message += f"₩{close}" + message += f" (₩{buy_amount})" + elif int(close) >= 10: + message += f"₩{close:.2f}" + message += f" (₩{buy_amount:.2f})" + elif int(close) >= 1: + message += f"₩{close:.3f}" + message += f" (₩{buy_amount:.3f})" + else: + message += f"₩{close:.4f}" + message += f" (₩{buy_amount:.4f})" + + if signal != '': + 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): + try: + if to is None: + if interval == 43200: + url = ("https://api.bithumb.com/v1/candles/months?market=KRW-{}&count=200").format(symbol) + elif interval == 1440: + url = ("https://api.bithumb.com/v1/candles/days?market=KRW-{}&count=200").format(symbol) + else: + url = ("https://api.bithumb.com/v1/candles/minutes/{}?market=KRW-{}&count=200").format(interval, symbol) + else: + if interval == 43200: + url = ("https://api.bithumb.com/v1/candles/months?market=KRW-{}&count=200&to={}").format(symbol, to) + elif interval == 1440: + url = ("https://api.bithumb.com/v1/candles/days?market=KRW-{}&count=200&to={}").format(symbol, to) + else: + url = ("https://api.bithumb.com/v1/candles/minutes/{}?market=KRW-{}&count=200&to={}").format(interval, symbol, to) + headers = {"accept": "application/json"} + response = requests.get(url, headers=headers) + json_data = json.loads(response.text) + df_temp = pd.DataFrame(json_data) + df_temp = df_temp.sort_index(ascending=False) + if 'candle_date_time_kst' not in df_temp: + return None + data = pd.DataFrame() + data['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst'], format='%Y-%m-%dT%H:%M:%S') + data['Open'] = df_temp['opening_price'] + data['Close'] = df_temp['trade_price'] + data['High'] = df_temp['high_price'] + data['Low'] = df_temp['low_price'] + data['Volume'] = df_temp['candle_acc_trade_volume'] + data = data.set_index('datetime') + data = data.astype(float) + data["datetime"] = data.index + if not data.empty: + return data + print(f"No data received for {symbol}, attempt {attempt + 1}") + time.sleep(0.5) + except Exception as e: + print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}") + if attempt < retries - 1: + time.sleep(5) + continue + return None + + def get_coin_more_data(self, symbol: str, interval: int, bong_count: int = 3000) -> pd.DataFrame: + to = datetime.now() + data: pd.DataFrame | None = None + while data is None or len(data) < bong_count: + if data is None: + data = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S")) + 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): + break + time.sleep(0.3) + to = to - relativedelta(minutes=interval * 200) + data = data.set_index('datetime') + data = data.sort_index() + 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('./resources/coins.db') + cursor = conn.cursor() + 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: + cursor.execute( + "INSERT INTO {}_{} (CODE, NAME, ymdhms, ymd, hms, close, open, high, low, volume) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".format(symbol, interval), + ( + 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], + ), + ) + 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))) + 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') + df = df.sort_index() + df['datetime'] = df.index + return df + + def get_coin_some_data(self, symbol: str, interval: int) -> pd.DataFrame: + data = self.get_coin_data(symbol, interval) + data_1 = self.get_coin_data(symbol, interval=1) + 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 + + 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 diff --git a/monitor_processor.py b/monitor_processor.py new file mode 100644 index 0000000..baea3d6 --- /dev/null +++ b/monitor_processor.py @@ -0,0 +1,53 @@ +import time +import psutil +import subprocess +import telegram +import asyncio +from config import * + +class ProcessMonitor: + + def __init__(self, python_executable="python"): + self.python = python_executable + # 실행된 프로세스 저장용 + self.process_map = {} + + def is_running(self, script_path): + """해당 스크립트가 실행 중인지 확인""" + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + if proc.info['cmdline'] and script_path in proc.info['cmdline']: + return True + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + return False + + def start_process(self, script_path): + """해당 스크립트 실행""" + print(f"[INFO] Starting {script_path}") + process = subprocess.Popen([self.python, script_path], creationflags=subprocess.CREATE_NEW_CONSOLE) + self.process_map[script_path] = process + + def sendMsg(self, text): + coin_client = telegram.Bot(token=COIN_TELEGRAM_BOT_TOKEN) + asyncio.run(coin_client.send_message(chat_id=COIN_TELEGRAM_CHAT_ID, text=text)) + return + + def monitor(self, scripts, interval=60): + """1분 단위로 프로세스 상태 확인 및 관리""" + while True: + for script in scripts: + if not self.is_running(script): + self.sendMsg("🔔{} process is killed.".format(script)) + + time.sleep(interval) + +if __name__ == "__main__": + monitor = ProcessMonitor() + + # 모니터링할 스크립트 목록 + scripts = [ + r"C:\workspace\AssetMonitor\monitor_coin_1h_1.py", + r"C:\workspace\AssetMonitor\monitor_coin_1h_2.py" + ] + monitor.monitor(scripts, interval=60) \ No newline at end of file diff --git a/monitor_stock.py b/monitor_stock.py new file mode 100644 index 0000000..c443cda --- /dev/null +++ b/monitor_stock.py @@ -0,0 +1,110 @@ +import pandas as pd +from datetime import datetime, timedelta +import time +import schedule +from config import * +import FinanceDataReader as fdr + +from monitor_min import Monitor + +class MonitorStock (Monitor): + """자산(코인/주식/ETF) 모니터링 및 매수 실행 클래스""" + + def __init__(self) -> None: + super().__init__(None) + + + def get_kr_stock_data(self, symbol: str, retries: int = 3) -> pd.DataFrame | None: + for attempt in range(retries): + try: + end = datetime.now() + start = end - timedelta(days=300) + data = fdr.DataReader(symbol, start.strftime('%Y-%m-%d'), end.strftime('%Y-%m-%d')) + if not data.empty: + data = data.rename(columns={ + 'Open': 'Open', + 'High': 'High', + 'Low': 'Low', + 'Close': 'Close', + 'Volume': 'Volume', + }) + return data + print(f"No data received for {symbol}, attempt {attempt + 1}") + time.sleep(2) + except Exception as e: + print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}") + if attempt < retries - 1: + time.sleep(5) + continue + return None + + # ------------- Monitors ------------- + def monitor_us_stocks(self) -> None: + message_list: list[str] = [] + print("US Stocks {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))) + for symbol in US_STOCKS: + data = self.get_kr_stock_data(symbol) + if data is not None and not data.empty: + try: + data = self.calculate_technical_indicators(data) + recent_data = self.check_point(symbol, data) + if recent_data['point'].iloc[-1] != 1: + continue + print(f" - {US_STOCKS[symbol]} ({symbol}): {recent_data['Close'].iloc[-1]:.2f}") + message_list.append( + self.format_message('US', symbol, US_STOCKS[symbol], recent_data['Close'].iloc[-1], recent_data['signal'].iloc[-1]) + ) + except Exception as e: + print(f"Error processing data for {symbol}: {str(e)}") + time.sleep(0.5) + if len(message_list) > 0: + try: + self.send_stock_telegram_message(message_list, header="[US-STOCK]") + except Exception as e: + print(f"Error sending Telegram message: {str(e)}") + + def monitor_kr_stocks(self) -> None: + message_list: list[str] = [] + print("KR ETFs {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))) + for symbol in KR_ETFS: + try: + clean_symbol = symbol.replace('.KS', '') + data = self.get_kr_stock_data(clean_symbol) + if data is not None and not data.empty: + try: + data = self.calculate_technical_indicators(data) + recent_data = self.check_point(symbol, data) + if recent_data['point'].iloc[-1] != 1: + continue + print(f" - {KR_ETFS[symbol]} ({symbol}): {recent_data['Close'].iloc[-1]:.2f}") + message_list.append( + self.format_message('KR', symbol, KR_ETFS[symbol], recent_data['Close'].iloc[-1], recent_data['signal'].iloc[-1]) + ) + except Exception as e: + print(f"Error processing data for {symbol}: {str(e)}") + else: + print(f"Data for {symbol} is empty or None.") + time.sleep(1) + except Exception as e: + print(f"Unexpected error processing {symbol}: {str(e)}") + continue + if len(message_list) > 0: + try: + self.send_stock_telegram_message(message_list, header="[KR-STOCK]") + except Exception as e: + print(f"Error sending Telegram message: {str(e)}") + + # ------------- Scheduler ------------- + def run_schedule(self) -> None: + schedule.every().day.at("16:30").do(self.monitor_us_stocks) + schedule.every().day.at("23:30").do(self.monitor_us_stocks) + schedule.every().day.at("08:10").do(self.monitor_kr_stocks) + schedule.every().day.at("18:20").do(self.monitor_kr_stocks) + print("Scheduler started. Stock Monitoring will run at specified times.") + while True: + schedule.run_pending() + time.sleep(1) + + +if __name__ == "__main__": + MonitorStock().run_schedule() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1e18de3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +yfinance +pandas +mplcursors +numpy +ccxt +PyJWT +pycurl +schedule +python-dateutil +python-telegram-bot +finance-datareader +psutil +mpld3 +plotly \ No newline at end of file diff --git a/resources/coins.db b/resources/coins.db new file mode 100644 index 0000000..fb422bc Binary files /dev/null and b/resources/coins.db differ diff --git a/simulation_1min.py b/simulation_1min.py new file mode 100644 index 0000000..c95fde3 --- /dev/null +++ b/simulation_1min.py @@ -0,0 +1,1056 @@ +""" +simulation_1min.py +------------------- +목표: 1분봉 기반 자동 매수/매도 전략을 최적화하고 시뮬레이션한다. + +기능 개요 + 1) SQLITE DB(resources/coins.db)의 1분봉 데이터 로딩 + 2) 이동평균/RSI/Bollinger/ATR/거래량 지표 계산 + 3) 매수 후보 전략(모멘텀, 이동평균 교차, RSI+볼린저, 거래량 스파이크)을 조합 + 4) 손절·익절·트레일링스탑·시간청산·변동성/거래량 기반 매도 규칙 비교 + 5) 그리드 서치, 랜덤 서치, 간단한 walk-forward(롤링) 검증 + 6) 스트레스 테스트(하락장/고변동/저변동)와 결과 리포트 및 Plotly 시각화 + 7) 실거래 옵션(HTS 주문 Stub) + +사용 예시 + python simulation_1min.py --symbol BTC --optimize grid random --walk-forward + python simulation_1min.py --symbol XRP --export-html reports/xrp_equity.html --stress-test +""" + +from __future__ import annotations + +import argparse +import itertools +import json +import math +import os +import random +import sqlite3 +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +try: + from HTS2 import HTS # 실거래 연동 옵션 +except ImportError: # pragma: no cover + HTS = None + + +# ------------------------------ 유틸 ------------------------------ # +def json_default(obj): + """numpy, Timestamp 등을 JSON 직렬화할 때 float/str로 변환.""" + if isinstance(obj, (np.floating, np.float32, np.float64)): + return float(obj) + if isinstance(obj, (np.integer, np.int32, np.int64)): + return int(obj) + if isinstance(obj, (np.bool_,)): + return bool(obj) + if isinstance(obj, (pd.Timestamp, datetime)): + return obj.isoformat() + raise TypeError(f"Type {type(obj)} not serializable") + + +def get_tick_size(price: float) -> float: + """국내 거래소 KRW 호가 단위를 근사.""" + if price < 0.1: + return 0.0001 + if price < 1: + return 0.001 + if price < 10: + return 0.01 + if price < 100: + return 0.1 + if price < 1_000: + return 1 + if price < 10_000: + return 5 + if price < 100_000: + return 10 + if price < 500_000: + return 50 + if price < 1_000_000: + return 100 + return 1_000 + + +# ------------------------------ 공통 설정 ------------------------------ # +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) +DEFAULT_DB_PATH = os.path.join(BASE_DIR, "resources", "coins.db") +DEFAULT_SYMBOL = "BTC" +FEE_RATE = 0.0004 # 매수/매도 각각 0.04% +SLIPPAGE = 0.0002 # 체결 슬리피지 가정 +MIN_ORDER_KRW = 10_000 +INITIAL_CAPITAL = 1_000_000 +MAX_POSITION_KRW = 1_000_000 +RISK_FREE_RATE = 0.0 # 단기 무위험수익률 +STRATEGY_MODE_PATH = os.path.join(BASE_DIR, "resources", "strategy_mode.json") + +ENTRY_SIGNAL_OPTIONS: Tuple[str, ...] = ( + "ma_cross", + "rsi_dip", + "momentum_breakout", + "keltner_reversion", + "volatility_breakout", +) + +DEFAULT_ENTRY_COMBOS: List[Tuple[str, ...]] = [ + ("ma_cross",), + ("ma_cross", "rsi_dip"), + ("ma_cross", "keltner_reversion"), + ("keltner_reversion",), + ("momentum_breakout",), + ("volatility_breakout", "ma_cross"), +] + + +# ------------------------------ 데이터 로더 ------------------------------ # +class MinuteDataLoader: + """SQLite에서 분봉 데이터를 읽어오는 헬퍼. + + Example + ------- + loader = MinuteDataLoader(DEFAULT_DB_PATH) + df = loader.load("BTC", limit=8000) + """ + + def __init__(self, db_path: str) -> None: + self.db_path = db_path + + def load(self, symbol: str, limit: int = 8000, interval: int = 1) -> pd.DataFrame: + table_name = f"{symbol}_{interval}" + query = ( + f"SELECT ymdhms as datetime, Open, High, Low, Close, Volume " + f"FROM {table_name} ORDER BY datetime DESC LIMIT {limit}" + ) + with sqlite3.connect(self.db_path) as conn: + df = pd.read_sql_query(query, conn, parse_dates=["datetime"]) + + if df.empty: + raise ValueError(f"{table_name} 테이블에서 데이터가 비어있습니다.") + + df = df.sort_values("datetime").reset_index(drop=True) + df = df.astype( + { + "Open": float, + "High": float, + "Low": float, + "Close": float, + "Volume": float, + } + ) + df["return"] = df["Close"].pct_change().fillna(0.0) + df["minute"] = df["datetime"].dt.minute + return df + + +# ------------------------------ 지표 도우미 ------------------------------ # +class IndicatorBuilder: + """가격/거래량 지표 생성기.""" + + ROLL_LOOKBACKS: Tuple[int, ...] = (30, 45, 60, 90) + REGIME_LOOKBACK: int = 600 + + @staticmethod + def ema(series: pd.Series, span: int) -> pd.Series: + return series.ewm(span=span, adjust=False).mean() + + @staticmethod + def rsi(series: pd.Series, period: int = 14) -> pd.Series: + delta = series.diff() + up = delta.clip(lower=0).rolling(period).mean() + down = -delta.clip(upper=0).rolling(period).mean() + rs = up / down.replace(0, np.nan) + return 100 - (100 / (1 + rs)) + + @staticmethod + def atr(df: pd.DataFrame, period: int = 14) -> pd.Series: + high_low = df["High"] - df["Low"] + high_close = (df["High"] - df["Close"].shift()).abs() + low_close = (df["Low"] - df["Close"].shift()).abs() + tr = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1) + return tr.rolling(period).mean() + + def enrich(self, df: pd.DataFrame) -> pd.DataFrame: + out = df.copy() + + for window in (3, 5, 8, 13, 21, 34, 55, 89): + out[f"sma_{window}"] = out["Close"].rolling(window).mean() + out[f"ema_{window}"] = self.ema(out["Close"], window) + + out["rsi14"] = self.rsi(out["Close"], 14) + out["atr14"] = self.atr(out, 14) + out["atr_pct"] = out["atr14"] / out["Close"] + + out["boll_mid"] = out["Close"].rolling(20).mean() + out["boll_std"] = out["Close"].rolling(20).std(ddof=0) + out["boll_up"] = out["boll_mid"] + 2 * out["boll_std"] + out["boll_low"] = out["boll_mid"] - 2 * out["boll_std"] + + out["volume_ma50"] = out["Volume"].rolling(50).mean() + out["volume_std50"] = out["Volume"].rolling(50).std(ddof=0) + out["volume_std50"] = out["volume_std50"].replace(0, np.nan) + out["volume_z"] = (out["Volume"] - out["volume_ma50"]) / out["volume_std50"] + out["range_pct"] = (out["High"] - out["Low"]) / out["Close"] + out["ret_5"] = out["Close"].pct_change(5) + out["ret_15"] = out["Close"].pct_change(15) + out["weekday"] = out["datetime"].dt.weekday + out["macro_trend"] = out["ema_34"] - out["ema_89"] + + # 켈트너 밴드 및 롤링 고저 + out["keltner_mid"] = out["ema_21"] + out["keltner_upper"] = out["keltner_mid"] + out["atr14"] * 1.5 + out["keltner_lower"] = out["keltner_mid"] - out["atr14"] * 1.5 + + for lookback in self.ROLL_LOOKBACKS: + out[f"roll_max_{lookback}"] = out["High"].rolling(lookback).max() + out[f"roll_min_{lookback}"] = out["Low"].rolling(lookback).min() + + # 변동성 레짐 계산 (롤링 분위수 기반) + high_q = out["atr_pct"].rolling(self.REGIME_LOOKBACK, min_periods=self.REGIME_LOOKBACK).quantile(0.7) + low_q = out["atr_pct"].rolling(self.REGIME_LOOKBACK, min_periods=self.REGIME_LOOKBACK).quantile(0.3) + out["vol_regime"] = np.where( + out["atr_pct"] >= high_q, + 1, + np.where(out["atr_pct"] <= low_q, -1, 0), + ) + + out.dropna(inplace=True) + return out + + +# ------------------------------ 전략 파라미터 ------------------------------ # +@dataclass +class StrategyParams: + """단일 전략 설정 값을 담는 데이터 구조.""" + + entry_combo: Tuple[str, ...] = ("ma_cross",) + combo_mode: str = "OR" + ma_fast: int = 5 + ma_slow: int = 21 + rsi_buy: float = 32.0 + rsi_exit: float = 68.0 + mom_threshold: float = 0.0015 + dip_sigma: float = 1.0 + volume_z_buy: float = -0.5 + volume_z_breakout: float = 0.5 + atr_filter: Tuple[float, float] = (0.001, 0.03) + trading_hours: Tuple[int, int] = (0, 23) # 한국시간 기준 시간 필터 + max_positions: int = 2 + risk_pct: float = 0.2 # 보유 현금 대비 + stop_loss_pct: float = 0.006 # 0.6% + take_profit_pct: float = 0.012 # 1.2% + trailing_atr_mult: float = 2.0 + time_stop_bars: int = 45 + vol_drop_exit_z: float = -1.0 + reverse_signal_exit: bool = True + use_dynamic_risk: bool = True + risk_pct_high_vol: float = 0.12 + risk_pct_low_vol: float = 0.25 + stop_loss_pct_high_vol: float = 0.005 + stop_loss_pct_low_vol: float = 0.007 + take_profit_pct_high_vol: float = 0.012 + take_profit_pct_low_vol: float = 0.009 + trailing_atr_mult_high_vol: float = 2.4 + trailing_atr_mult_low_vol: float = 1.8 + breakout_lookback: int = 60 + + +# ------------------------------ 전략 조합 생성 ------------------------------ # +def generate_entry_combos(max_len: int = 3) -> List[Tuple[str, ...]]: + combos: List[Tuple[str, ...]] = [] + for length in range(1, max_len + 1): + combos.extend(itertools.combinations(ENTRY_SIGNAL_OPTIONS, length)) + return combos + + +def load_strategy_mode() -> bool: + try: + with open(STRATEGY_MODE_PATH, "r", encoding="utf-8") as f: + payload = json.load(f) + return bool(payload.get("use_full_search", False)) + except FileNotFoundError: + return False + except Exception: + return False + + +def save_strategy_mode(use_full: bool) -> None: + os.makedirs(os.path.dirname(STRATEGY_MODE_PATH), exist_ok=True) + with open(STRATEGY_MODE_PATH, "w", encoding="utf-8") as f: + json.dump({"use_full_search": bool(use_full)}, f, ensure_ascii=False, indent=2) + + +# ------------------------------ 매수 신호 생성기 ------------------------------ # +class EntrySignalEngine: + """여러 매수 규칙을 함수화하여 조합.""" + + def __init__(self, params: StrategyParams) -> None: + self.params = params + + def _ma_cross(self, df: pd.DataFrame, idx: int) -> bool: + fast = df[f"ema_{self.params.ma_fast}"] + slow = df[f"ema_{self.params.ma_slow}"] + if idx == 0: + return False + cross_up = fast.iloc[idx] > slow.iloc[idx] and fast.iloc[idx - 1] <= slow.iloc[idx - 1] + rsi_cond = df["rsi14"].iloc[idx] >= self.params.rsi_buy + volume_cond = df["volume_z"].iloc[idx] > self.params.volume_z_breakout + return bool(cross_up and rsi_cond and volume_cond) + + def _rsi_bollinger_dip(self, df: pd.DataFrame, idx: int) -> bool: + price = df["Close"].iloc[idx] + lower_band = df["boll_low"].iloc[idx] + std = df["boll_std"].iloc[idx] + rsi = df["rsi14"].iloc[idx] + vol = df["volume_z"].iloc[idx] + band_touch = price <= lower_band + self.params.dip_sigma * std + rsi_ok = rsi <= self.params.rsi_buy + volume_rebound = vol >= self.params.volume_z_buy + return bool(band_touch and rsi_ok and volume_rebound) + + def _momentum_breakout(self, df: pd.DataFrame, idx: int) -> bool: + price = df["Close"].iloc[idx] + upper_band = df["boll_up"].iloc[idx] + ret_5 = df["ret_5"].iloc[idx] + volume = df["volume_z"].iloc[idx] + atr_pct = df["atr_pct"].iloc[idx] + atr_ok = self.params.atr_filter[0] <= atr_pct <= self.params.atr_filter[1] + return bool(price > upper_band and ret_5 > self.params.mom_threshold and volume > self.params.volume_z_breakout and atr_ok) + + def _keltner_reversion(self, df: pd.DataFrame, idx: int) -> bool: + if idx == 0 or "keltner_lower" not in df.columns: + return False + price = df["Close"].iloc[idx] + lower_band = df["keltner_lower"].iloc[idx] + trend_ok = df["ema_21"].iloc[idx] > df["ema_55"].iloc[idx] + regime = df["vol_regime"].iloc[idx] if "vol_regime" in df.columns else 0 + rsi = df["rsi14"].iloc[idx] + volume_rebound = df["volume_z"].iloc[idx] > self.params.volume_z_buy + return bool(trend_ok and regime <= 0 and price <= lower_band and rsi <= self.params.rsi_buy + 5 and volume_rebound) + + def _volatility_breakout(self, df: pd.DataFrame, idx: int) -> bool: + lookback = self.params.breakout_lookback + col = f"roll_max_{lookback}" + if col not in df.columns or idx == 0: + return False + regime = df["vol_regime"].iloc[idx] if "vol_regime" in df.columns else 0 + if regime <= 0: + return False + breakout_level = df[col].iloc[idx - 1] + price = df["Close"].iloc[idx] + volume = df["volume_z"].iloc[idx] + momentum = df["ret_15"].iloc[idx] + trend_ok = df["macro_trend"].iloc[idx] > 0 + return bool(price > breakout_level and volume > self.params.volume_z_breakout and momentum > self.params.mom_threshold * 0.8 and trend_ok) + + def evaluate(self, df: pd.DataFrame, idx: int) -> bool: + hour = df["datetime"].iloc[idx].hour + if not (self.params.trading_hours[0] <= hour <= self.params.trading_hours[1]): + return False + + signal_map = { + "ma_cross": self._ma_cross, + "rsi_dip": self._rsi_bollinger_dip, + "momentum_breakout": self._momentum_breakout, + "keltner_reversion": self._keltner_reversion, + "volatility_breakout": self._volatility_breakout, + } + results = [] + for name in self.params.entry_combo: + func = signal_map.get(name) + if func is None: + continue + results.append(func(df, idx)) + + if not results: + return False + + if self.params.combo_mode == "AND": + return all(results) + return any(results) + + +# ------------------------------ 거래 및 백테스트 ------------------------------ # +@dataclass +class Trade: + entry_time: datetime + exit_time: datetime + entry_price: float + exit_price: float + qty: float + pnl: float + return_pct: float + bars_held: int + reason: str + + +@dataclass +class SimulationResult: + params: StrategyParams + trades: List[Trade] + equity_curve: pd.DataFrame + metrics: Dict[str, float] + price_history: pd.DataFrame + + +class PortfolioSimulator: + """포트폴리오(단일 종목) 시뮬레이션 엔진.""" + + def __init__(self, data: pd.DataFrame, params: StrategyParams, initial_capital: float = INITIAL_CAPITAL) -> None: + self.df = data.reset_index(drop=True) + self.params = params + self.initial_capital = initial_capital + self.signal_engine = EntrySignalEngine(params) + + def _current_regime(self, idx: int) -> int: + if "vol_regime" in self.df.columns: + try: + return int(self.df["vol_regime"].iloc[idx]) + except Exception: + return 0 + return 0 + + def _resolve_risk_pct(self, regime: int) -> float: + if not self.params.use_dynamic_risk: + return self.params.risk_pct + if regime > 0: + return self.params.risk_pct_high_vol + if regime < 0: + return self.params.risk_pct_low_vol + return self.params.risk_pct + + def _resolve_levels(self, regime: int) -> Tuple[float, float, float]: + stop_loss = self.params.stop_loss_pct + take_profit = self.params.take_profit_pct + trailing_mult = self.params.trailing_atr_mult + if regime > 0: + stop_loss = self.params.stop_loss_pct_high_vol + take_profit = self.params.take_profit_pct_high_vol + trailing_mult = self.params.trailing_atr_mult_high_vol + elif regime < 0: + stop_loss = self.params.stop_loss_pct_low_vol + take_profit = self.params.take_profit_pct_low_vol + trailing_mult = self.params.trailing_atr_mult_low_vol + return stop_loss, take_profit, trailing_mult + + def _position_size(self, cash: float, price: float, regime: int) -> Tuple[float, float]: + max_alloc = cash * self._resolve_risk_pct(regime) + krw_to_use = max(MIN_ORDER_KRW, max_alloc) + krw_to_use = min(krw_to_use, cash, MAX_POSITION_KRW) + qty = krw_to_use / price if price > 0 else 0.0 + if qty <= 0: + return 0.0, cash + return qty, cash - krw_to_use + + def _adjust_buy_price(self, price: float) -> float: + tick = get_tick_size(price) + return price + tick + + def _adjust_sell_price(self, price: float) -> float: + tick = get_tick_size(price) + adjusted = price - tick + return adjusted if adjusted > 0 else price + + def _apply_fees(self, price: float, side: str) -> float: + fee_multiplier = 1 + FEE_RATE + (SLIPPAGE if side == "buy" else -SLIPPAGE) + if side == "buy": + return price * fee_multiplier + return price * (1 - FEE_RATE - SLIPPAGE) + + def run(self) -> SimulationResult: + cash = self.initial_capital + positions: List[Dict] = [] + equity_curve: List[Tuple[datetime, float]] = [] + trades: List[Trade] = [] + + for i in range(max(self.params.ma_slow, 60), len(self.df) - 1): + current = self.df.iloc[i] + next_candle = self.df.iloc[i + 1] + + # 1) 기존 포지션 청산 조건 확인 + updated_positions = [] + for pos in positions: + exit_reason = "" + entry_price = pos["entry_price"] + trailing_level = pos["trailing"] + best_price = max(pos["best_price"], self.df["High"].iloc[i]) + stop_price = entry_price * (1 - pos["stop_loss_pct"]) + take_price = entry_price * (1 + pos["take_profit_pct"]) + trail_price = best_price - pos["atr"] * pos["trailing_mult"] + if trail_price > trailing_level: + trailing_level = trail_price + + executed_exit = False + exit_price = next_candle["Open"] + stop_exec_price = self._adjust_sell_price(stop_price) + take_exec_price = self._adjust_sell_price(take_price) + trail_exec_price = self._adjust_sell_price(trailing_level) + + # 봉 내부 시뮬레이션 + low = next_candle["Low"] + high = next_candle["High"] + + if low <= stop_price: + exit_price = stop_exec_price + exit_reason = "stop_loss" + executed_exit = True + elif high >= take_price: + exit_price = take_exec_price + exit_reason = "take_profit" + executed_exit = True + elif low <= trailing_level: + exit_price = trail_exec_price + exit_reason = "trailing_stop" + executed_exit = True + elif (i - pos["entry_idx"]) >= self.params.time_stop_bars: + exit_price = self._adjust_sell_price(next_candle["Open"]) + exit_reason = "time_stop" + executed_exit = True + elif self.params.vol_drop_exit_z is not None and self.df["volume_z"].iloc[i] <= self.params.vol_drop_exit_z: + exit_price = self._adjust_sell_price(next_candle["Open"]) + exit_reason = "volume_drop" + executed_exit = True + elif self.params.reverse_signal_exit and self.signal_engine.evaluate(self.df, i): + exit_price = self._adjust_sell_price(next_candle["Open"]) + exit_reason = "reverse_signal" + executed_exit = True + + if executed_exit: + trend_value = self.df["macro_trend"].iloc[i] if "macro_trend" in self.df.columns else 0.0 + sell_ratio = 0.5 if trend_value >= 0 else 1.0 + sell_ratio = min(max(sell_ratio, 0.0), 1.0) + qty_to_sell = pos["qty"] * sell_ratio + qty_to_sell = pos["qty"] if qty_to_sell <= 0 else qty_to_sell + exit_price_fee = self._apply_fees(exit_price, "sell") + pnl = (exit_price_fee - entry_price) * qty_to_sell + cash += qty_to_sell * exit_price_fee + trades.append( + Trade( + entry_time=pos["entry_time"], + exit_time=next_candle["datetime"], + entry_price=entry_price, + exit_price=exit_price_fee, + qty=qty_to_sell, + pnl=pnl, + return_pct=pnl / (entry_price * qty_to_sell), + bars_held=i - pos["entry_idx"], + reason=f"{exit_reason}|ratio:{sell_ratio:.2f}", + ) + ) + remaining_qty = pos["qty"] - qty_to_sell + if remaining_qty > 1e-8: + pos["qty"] = remaining_qty + pos["best_price"] = best_price + pos["trailing"] = trailing_level + updated_positions.append(pos) + else: + pos["best_price"] = best_price + pos["trailing"] = trailing_level + updated_positions.append(pos) + + positions = updated_positions + + # 2) 신규 진입 + atr_pct = self.df["atr_pct"].iloc[i] + regime = self._current_regime(i) + if ( + len(positions) < self.params.max_positions + and self.params.atr_filter[0] <= atr_pct <= self.params.atr_filter[1] + and self.signal_engine.evaluate(self.df, i) + ): + entry_price = self._adjust_buy_price(next_candle["Open"]) + entry_price_fee = self._apply_fees(entry_price, "buy") + qty, cash = self._position_size(cash, entry_price_fee, regime) + if qty > 0: + stop_loss_pct, take_profit_pct, trailing_mult = self._resolve_levels(regime) + positions.append( + { + "entry_price": entry_price_fee, + "entry_time": next_candle["datetime"], + "entry_idx": i + 1, + "qty": qty, + "atr": self.df["atr14"].iloc[i], + "best_price": entry_price_fee, + "trailing": entry_price_fee * (1 - stop_loss_pct), + "stop_loss_pct": stop_loss_pct, + "take_profit_pct": take_profit_pct, + "trailing_mult": trailing_mult, + "regime": regime, + } + ) + + # 3) 자산 곡선 기록 + market_value = sum(pos["qty"] * self.df["Close"].iloc[i] for pos in positions) + equity_curve.append((current["datetime"], cash + market_value)) + + equity_df = pd.DataFrame(equity_curve, columns=["datetime", "equity"]) + final_time = self.df.iloc[-1]["datetime"] + final_price = self.df.iloc[-1]["Close"] + if positions: + for pos in positions: + forced_price = self._adjust_sell_price(final_price) + exit_price_fee = self._apply_fees(forced_price, "sell") + pnl = (exit_price_fee - pos["entry_price"]) * pos["qty"] + cash += pos["qty"] * exit_price_fee + trades.append( + Trade( + entry_time=pos["entry_time"], + exit_time=final_time, + entry_price=pos["entry_price"], + exit_price=exit_price_fee, + qty=pos["qty"], + pnl=pnl, + return_pct=pnl / (pos["entry_price"] * pos["qty"]), + bars_held=len(self.df) - pos["entry_idx"], + reason="forced_exit", + ) + ) + if equity_df.empty or equity_df["datetime"].iloc[-1] != final_time: + equity_df = pd.concat([equity_df, pd.DataFrame({"datetime": [final_time], "equity": [cash]})], ignore_index=True) + else: + equity_df.loc[equity_df.index[-1], "equity"] = cash + + metrics = self._calculate_metrics(equity_df, trades) + price_history = self.df[["datetime", "Open", "High", "Low", "Close"]].set_index("datetime") + return SimulationResult(self.params, trades, equity_df, metrics, price_history) + + def _calculate_metrics(self, equity_df: pd.DataFrame, trades: List[Trade]) -> Dict[str, float]: + if equity_df.empty: + return {} + + returns = equity_df["equity"].pct_change().dropna() + total_return = equity_df["equity"].iloc[-1] / equity_df["equity"].iloc[0] - 1 + + duration_minutes = (equity_df["datetime"].iloc[-1] - equity_df["datetime"].iloc[0]).total_seconds() / 60 + years = max(duration_minutes / (60 * 24 * 365), 1e-6) + cagr = (1 + total_return) ** (1 / years) - 1 + + running_max = equity_df["equity"].cummax() + drawdown = equity_df["equity"] / running_max - 1 + max_dd = drawdown.min() + + sharpe = (returns.mean() - RISK_FREE_RATE / (365 * 24 * 60)) / (returns.std() + 1e-9) * math.sqrt(365 * 24 * 60) + + win_trades = [t for t in trades if t.pnl > 0] + loss_trades = [t for t in trades if t.pnl <= 0] + profit_factor = (sum(t.pnl for t in win_trades) / abs(sum(t.pnl for t in loss_trades))) if loss_trades else np.inf + hit_ratio = len(win_trades) / len(trades) if trades else 0.0 + + return { + "final_equity": equity_df["equity"].iloc[-1], + "total_return": total_return, + "CAGR": cagr, + "max_drawdown": max_dd, + "sharpe": sharpe, + "num_trades": len(trades), + "hit_ratio": hit_ratio, + "profit_factor": profit_factor, + "avg_bars": np.mean([t.bars_held for t in trades]) if trades else 0.0, + } + + +# ------------------------------ 최적화 도구 ------------------------------ # +class StrategyOptimizer: + """그리드/랜덤 서치 및 walk-forward 평가.""" + + def __init__( + self, + data: pd.DataFrame, + initial_capital: float = INITIAL_CAPITAL, + entry_combos: Optional[Sequence[Tuple[str, ...]]] = None, + ): + self.data = data + self.initial_capital = initial_capital + self.entry_combos = list(entry_combos) if entry_combos else list(DEFAULT_ENTRY_COMBOS) + + def _evaluate(self, params: StrategyParams) -> SimulationResult: + simulator = PortfolioSimulator(self.data, params, self.initial_capital) + return simulator.run() + + def grid_search(self, limit: int = 20) -> List[SimulationResult]: + entry_combos = self.entry_combos + combo_modes = ["OR"] + ma_fast_opts = [5, 8] + ma_slow_opts = [34, 55] + stop_opts = [0.004, 0.0055] + tp_opts = [0.01, 0.013] + trailing_opts = [1.8, 2.2] + risk_sets = [ + (0.18, 0.12, 0.25), + (0.2, 0.1, 0.3), + ] + + configs = itertools.product(entry_combos, combo_modes, ma_fast_opts, ma_slow_opts, stop_opts, tp_opts, trailing_opts, risk_sets) + results: List[SimulationResult] = [] + for combo, mode, ma_fast, ma_slow, stop_pct, tp_pct, trail_mult, risk_tuple in itertools.islice(configs, limit): + risk_mid, risk_high, risk_low = risk_tuple + params = StrategyParams( + entry_combo=combo, + combo_mode=mode, + ma_fast=ma_fast, + ma_slow=ma_slow, + stop_loss_pct=stop_pct, + take_profit_pct=tp_pct, + trailing_atr_mult=trail_mult, + risk_pct=risk_mid, + risk_pct_high_vol=risk_high, + risk_pct_low_vol=risk_low, + stop_loss_pct_high_vol=stop_pct * 0.8, + stop_loss_pct_low_vol=stop_pct * 1.2, + take_profit_pct_high_vol=tp_pct * 1.2, + take_profit_pct_low_vol=tp_pct * 0.8, + trailing_atr_mult_high_vol=trail_mult * 1.1, + trailing_atr_mult_low_vol=max(1.2, trail_mult * 0.9), + ) + results.append(self._evaluate(params)) + return sorted(results, key=lambda r: r.metrics.get("sharpe", -np.inf), reverse=True) + + def random_search(self, trials: int = 20, seed: int = 42) -> List[SimulationResult]: + random.seed(seed) + results = [] + for _ in range(trials): + params = StrategyParams( + entry_combo=random.choice(self.entry_combos), + combo_mode=random.choice(["OR", "AND"]), + ma_fast=random.choice([3, 5, 8]), + ma_slow=random.choice([34, 55]), + rsi_buy=random.uniform(25, 38), + mom_threshold=random.uniform(0.0008, 0.0025), + risk_pct=random.choice([0.16, 0.2, 0.24]), + risk_pct_high_vol=random.uniform(0.08, 0.15), + risk_pct_low_vol=random.uniform(0.22, 0.32), + stop_loss_pct=random.uniform(0.003, 0.01), + stop_loss_pct_high_vol=random.uniform(0.003, 0.007), + stop_loss_pct_low_vol=random.uniform(0.006, 0.012), + take_profit_pct=random.uniform(0.008, 0.02), + take_profit_pct_high_vol=random.uniform(0.012, 0.025), + take_profit_pct_low_vol=random.uniform(0.008, 0.015), + trailing_atr_mult=random.uniform(1.5, 2.5), + trailing_atr_mult_high_vol=random.uniform(2.0, 2.8), + trailing_atr_mult_low_vol=random.uniform(1.2, 2.0), + time_stop_bars=random.choice([30, 45, 60]), + vol_drop_exit_z=random.uniform(-1.5, -0.3), + breakout_lookback=random.choice([30, 45, 60, 90]), + ) + results.append(self._evaluate(params)) + return sorted(results, key=lambda r: r.metrics.get("CAGR", -np.inf), reverse=True) + + def walk_forward(self, train_bars: int = 2000, test_bars: int = 600) -> Dict[str, float]: + cursor = 0 + wf_metrics: List[Dict[str, float]] = [] + while cursor + train_bars + test_bars < len(self.data): + train_slice = self.data.iloc[cursor : cursor + train_bars].copy() + test_slice = self.data.iloc[cursor + train_bars : cursor + train_bars + test_bars].copy() + + # 학습 구간 간이 최적화(랜덤 5번) + temp_optimizer = StrategyOptimizer(train_slice, self.initial_capital) + best_train = temp_optimizer.random_search(trials=5)[0] + + # 테스트 구간 성능 + test_sim = PortfolioSimulator(test_slice, best_train.params, self.initial_capital).run() + wf_metrics.append(test_sim.metrics) + cursor += test_bars + + if not wf_metrics: + return {} + + agg = pd.DataFrame(wf_metrics).mean().to_dict() + agg["segments"] = len(wf_metrics) + return agg + + def walk_forward_with_params(self, params: StrategyParams, train_bars: int = 2000, test_bars: int = 600) -> Dict[str, float]: + cursor = 0 + wf_metrics: List[Dict[str, float]] = [] + while cursor + train_bars + test_bars < len(self.data): + test_slice = self.data.iloc[cursor + train_bars : cursor + train_bars + test_bars].copy() + if test_slice.empty: + break + sim = PortfolioSimulator(test_slice, params, self.initial_capital).run() + wf_metrics.append(sim.metrics) + cursor += test_bars + if not wf_metrics: + return {} + agg = pd.DataFrame(wf_metrics).mean().to_dict() + agg["segments"] = len(wf_metrics) + return agg + + +# ------------------------------ 스트레스 테스트 ------------------------------ # +class StressTester: + """하락장, 고/저변동 시나리오 재평가.""" + + def __init__(self, data: pd.DataFrame, params: StrategyParams, initial_capital: float = INITIAL_CAPITAL): + self.data = data + self.params = params + self.initial_capital = initial_capital + + def _subset(self, mask: pd.Series) -> Optional[SimulationResult]: + mask = mask.fillna(False) + if not mask.any(): + return None + subset = self.data.loc[mask].copy() + if len(subset) < 500: + return None + sim = PortfolioSimulator(subset, self.params, self.initial_capital) + return sim.run() + + def run(self) -> Dict[str, Dict[str, float]]: + stress_results = {} + rolling_ret = self.data["Close"].pct_change(300) + bear_mask = rolling_ret < -0.05 + bull_mask = rolling_ret > 0.05 + high_vol_mask = self.data["atr_pct"] > self.data["atr_pct"].quantile(0.7) + low_vol_mask = self.data["atr_pct"] < self.data["atr_pct"].quantile(0.3) + + scenarios = { + "bear_market": bear_mask, + "bull_market": bull_mask, + "high_volatility": high_vol_mask, + "low_volatility": low_vol_mask, + } + for name, mask in scenarios.items(): + res = self._subset(mask) + if res: + stress_results[name] = res.metrics + return stress_results + + +# ------------------------------ 리포팅/시각화 ------------------------------ # +class ReportBuilder: + """표, 그래프, JSON 저장 등을 담당.""" + + def __init__(self, output_dir: str = os.path.join(BASE_DIR, "reports")) -> None: + self.output_dir = output_dir + os.makedirs(self.output_dir, exist_ok=True) + + def print_table(self, results: Sequence[SimulationResult], title: str) -> None: + rows = [] + for rank, res in enumerate(results[:5], start=1): + rows.append( + { + "rank": rank, + "entry_combo": "+".join(res.params.entry_combo), + "mode": res.params.combo_mode, + "sharpe": round(res.metrics.get("sharpe", 0), 3), + "CAGR": f"{res.metrics.get('CAGR', 0)*100:.2f}%", + "maxDD": f"{res.metrics.get('max_drawdown', 0)*100:.2f}%", + "#trades": res.metrics.get("num_trades", 0), + } + ) + df = pd.DataFrame(rows) + print(f"\n[{title}]") + # tabulate 미설치 환경을 고려해 to_markdown 대신 문자열 출력 + try: + print(df.to_markdown(index=False)) + except Exception: + print(df.to_string(index=False)) + + def plot_equity(self, result: SimulationResult, symbol: str, export_html: Optional[str] = None) -> str: + equity = result.equity_curve + trades = result.trades + + fig = make_subplots( + rows=2, + cols=1, + shared_xaxes=True, + vertical_spacing=0.07, + row_heights=[0.7, 0.3], + specs=[[{"secondary_y": True}], [{"secondary_y": False}]], + ) + + fig.add_trace( + go.Scatter(x=equity["datetime"], y=equity["equity"], name="Equity", line=dict(color="blue")), + row=1, + col=1, + secondary_y=False, + ) + dd = equity["equity"] / equity["equity"].cummax() - 1 + fig.add_trace( + go.Scatter(x=equity["datetime"], y=dd, name="Drawdown", line=dict(color="red", width=1, dash="dot")), + row=1, + col=1, + secondary_y=True, + ) + + prices = self._extract_prices(result) + if prices.empty: + prices = result.price_history + fig.add_trace( + go.Candlestick( + x=prices.index, + open=prices["Open"], + high=prices["High"], + low=prices["Low"], + close=prices["Close"], + name="Price", + ), + row=2, + col=1, + ) + + entries_x = [t.entry_time for t in trades] + entries_y = [t.entry_price for t in trades] + exits_x = [t.exit_time for t in trades] + exits_y = [t.exit_price for t in trades] + fig.add_trace( + go.Scatter(x=entries_x, y=entries_y, mode="markers", marker=dict(color="green", size=8), name="Entry"), + row=2, + col=1, + ) + fig.add_trace( + go.Scatter(x=exits_x, y=exits_y, mode="markers", marker=dict(color="orange", size=8), name="Exit"), + row=2, + col=1, + ) + + fig.update_layout(title=f"{symbol} 1분봉 자산곡선/거래포인트", xaxis_rangeslider_visible=False) + + if export_html: + output_path = os.path.join(self.output_dir, export_html) + else: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_path = os.path.join(self.output_dir, f"{symbol}_1min_equity_{timestamp}.html") + + fig.write_html(output_path) + return output_path + + def _extract_prices(self, result: SimulationResult) -> pd.DataFrame: + return result.price_history.loc[ + result.price_history.index.intersection(result.equity_curve["datetime"]) + ] + + def save_json(self, data: Dict, filename: str) -> str: + path = os.path.join(self.output_dir, filename) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2, default=json_default) + return path + + +# ------------------------------ 실거래 Stub ------------------------------ # +class LiveTradingExecutor: + """HTS2 주문 객체를 래핑하여 실거래 연동 옵션 제공.""" + + def __init__(self, enable: bool = False) -> None: + self.enable = enable and HTS is not None + self.hts = HTS() if self.enable else None + + def execute(self, symbol: str, side: str, amount_krw: float) -> None: + if not self.enable or self.hts is None: + print(f"[LIVE_DISABLED] {symbol} {side} {amount_krw:,.0f} KRW") + return + if side == "buy": + self.hts.buyCoinMarket(symbol, amount_krw, None) + else: + self.hts.sellCoinMarket(symbol, 0, amount_krw) + print(f"[LIVE] {symbol} {side} {amount_krw:,.0f} KRW 실행 완료") + + +# ------------------------------ 실행 진입점 ------------------------------ # +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="1분봉 매매 전략 시뮬레이터") + parser.add_argument("--symbol", default=DEFAULT_SYMBOL, help="코인 심볼 (예: BTC)") + parser.add_argument("--db", default=DEFAULT_DB_PATH, help="SQLite DB 경로") + parser.add_argument("--limit", type=int, default=9000, help="최대 로드 캔들 수") + parser.add_argument("--optimize", nargs="*", choices=["grid", "random"], default=["grid", "random"], help="실행할 최적화 유형") + parser.add_argument("--random-trials", type=int, default=20, help="랜덤 서치 반복 횟수") + parser.add_argument("--walk-forward", action="store_true", help="walk-forward 검증 실행") + parser.add_argument("--stress-test", action="store_true", help="스트레스 테스트 실행") + parser.add_argument("--export-html", default=None, help="시각화 HTML 파일명") + parser.add_argument("--live", action="store_true", help="실거래 모드 (HTS 필요)") + parser.add_argument("--strategy-mode", choices=["auto", "current", "full"], default="auto", help="전략 조합 선택 모드") + parser.add_argument("--entry-max-len", type=int, default=3, help="full 모드에서 사용할 최대 신호 조합 길이") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + loader = MinuteDataLoader(args.db) + raw_df = loader.load(args.symbol, limit=args.limit) + enriched_df = IndicatorBuilder().enrich(raw_df) + + if args.strategy_mode == "current": + use_full_search = False + elif args.strategy_mode == "full": + use_full_search = True + else: + use_full_search = load_strategy_mode() + + entry_combos = ( + generate_entry_combos(max(1, args.entry_max_len)) if use_full_search else DEFAULT_ENTRY_COMBOS + ) + + optimizer = StrategyOptimizer(enriched_df, entry_combos=entry_combos) + report = ReportBuilder() + + best_candidates: List[SimulationResult] = [] + + if "grid" in args.optimize: + grid_results = optimizer.grid_search() + report.print_table(grid_results, "Grid Search Top 5") + best_candidates.extend(grid_results[:2]) + + if "random" in args.optimize: + random_results = optimizer.random_search(trials=args.random_trials) + report.print_table(random_results, "Random Search Top 5") + best_candidates.extend(random_results[:3]) + + if not best_candidates: + raise RuntimeError("최적화 결과가 없습니다.") + + best_overall = max(best_candidates, key=lambda r: r.metrics.get("CAGR", -np.inf)) + + wf_summary = {} + if args.walk_forward: + wf_candidates: List[Tuple[SimulationResult, Dict[str, float]]] = [] + for cand in best_candidates: + wf_metrics = optimizer.walk_forward_with_params(cand.params) + if wf_metrics: + wf_candidates.append((cand, wf_metrics)) + if wf_candidates: + best_overall, wf_summary = max( + wf_candidates, + key=lambda item: ( + item[1].get("total_return", -np.inf), + item[1].get("sharpe", -np.inf), + ), + ) + else: + wf_summary = optimizer.walk_forward() + + print("\n[Best Strategy]") + print(json.dumps(best_overall.metrics, indent=2, default=json_default)) + if wf_summary: + print("\n[Walk-Forward 성능]") + print(json.dumps(wf_summary, indent=2, default=json_default)) + + stress_summary = {} + if args.stress_test: + stress_summary = StressTester(enriched_df, best_overall.params).run() + print("\n[Stress Test]") + print(json.dumps(stress_summary, indent=2, default=json_default)) + + html_path = report.plot_equity(best_overall, args.symbol, export_html=args.export_html) + print(f"\nPlot 저장 경로: {html_path}") + + summary_payload = { + "best_params": best_overall.params.__dict__, + "best_metrics": best_overall.metrics, + "walk_forward": wf_summary, + "stress_test": stress_summary, + "equity_html": html_path, + } + json_path = report.save_json(summary_payload, f"{args.symbol}_1min_summary.json") + print(f"요약 JSON 저장 경로: {json_path}") + + if args.live: + LiveTradingExecutor(enable=True).execute(args.symbol, "buy", MIN_ORDER_KRW) + + +if __name__ == "__main__": + main() + diff --git a/simulation_1mon.py b/simulation_1mon.py new file mode 100644 index 0000000..3b95f2d --- /dev/null +++ b/simulation_1mon.py @@ -0,0 +1,1097 @@ +import pandas as pd +import plotly.graph_objs as go +from plotly import subplots +import plotly.io as pio +from datetime import datetime +import numpy as np +import webbrowser +import tempfile +import os + +# Plotly 설정 +pio.renderers.default = 'browser' + +from config import * + +# ======================================== +# 월봉 시뮬레이션 설정 +# ======================================== + +# 코인 선택 (대문자로 입력) +COINS = ['XRP', 'ADA', 'APT', 'AVAX', 'BONK', 'BTC', 'ETC', 'HBAR', 'LINK', 'ONDO', 'PENGU', 'SEI', 'SOL', 'SUI', 'TRX', 'VIRTUAL', 'WLD', 'XLM'] +COIN = COINS[5] # 기본값: XRP + +# 시뮬레이션 설정 +INTERVAL = 43200 # 월봉 (43200분 = 30일) +BONG_COUNT = 100 # 분석할 월봉 개수 (약 8년) +SHOW_GRAPHS = True # 그래프 표시 여부 + +# ======================================== +# 통합 최적화 월봉 전략 클래스 +# ======================================== + +class OptimizedMonthlyStrategy: + """통합 최적화 월봉 전략: 최저점 돌파 중심 + 고급 기술적 분석""" + + def __init__(self, coin: str): + self.coin = coin + self.name = KR_COINS.get(coin, coin) + + def get_buy_amount(self, signal: str, current_price: float, signal_strength: float = 70) -> float: + """최적화된 월봉 신호에 따른 매수 금액 결정""" + base_amount = 100000 # 기본 매수 금액 + + # 신호별 기본 가중치 + signal_weights = { + 'monthly_ultimate_breakout': 4.0, # 최고 강도 신호 (36개월 최저점 돌파) + 'monthly_strong_breakout': 3.0, # 고강도 신호 (24개월 최저점 돌파) + 'monthly_breakout': 2.5, # 중강도 신호 (12개월 최저점 돌파) + 'monthly_bb_reversal': 2.0, # 보조 신호 (볼린저 밴드 반전) + 'monthly_rsi_recovery': 1.8, # 보조 신호 (RSI 회복) + 'monthly_golden_cross': 2.0, # 골든크로스 + 'monthly_deviation12': 1.5, # 이격도 신호 + 'monthly_macd_bullish': 1.4, # MACD 상승 + 'monthly_trend_reversal': 1.8 # 추세 전환 + } + + base_weight = signal_weights.get(signal, 1.0) + + # 신호 강도에 따른 조정 (70-100점 범위를 0.8-1.2 배수로 변환) + strength_factor = 0.8 + (signal_strength - 70) / 30 * 0.4 + + # 가격에 따른 조정 (고가 코인일수록 적게 매수) + price_factor = 1.0 + if current_price > 100000: # 10만원 이상 + price_factor = 0.7 + elif current_price > 10000: # 1만원 이상 + price_factor = 0.8 + elif current_price > 1000: # 1천원 이상 + price_factor = 0.9 + + final_amount = base_amount * base_weight * strength_factor * price_factor + + # 최대/최소 제한 + return max(50000, min(500000, final_amount)) + +# ======================================== +# 월봉 시뮬레이션 클래스 +# ======================================== + +class OptimizedMonthlySimulation: + """통합 최적화 월봉 기준 코인 시뮬레이션 클래스""" + + def __init__(self, coin: str) -> None: + self.coin = coin + self.strategy = OptimizedMonthlyStrategy(coin) + self.strategy_type = 2 # 통합 최적화 전략 타입 + + # 모니터 클래스 임포트 (기존 파일 사용) + from monitor_coin_1mon_1 import MonthlyCoinMonitor1 + self.monitor = MonthlyCoinMonitor1() + + def fetch_weekly_data(self, symbol: str): + """주봉 데이터 가져오기""" + try: + return self.monitor.get_coin_data(symbol, 10080) # 10080분 = 1주 + except Exception as e: + print(f"주봉 데이터 가져오기 실패: {e}") + return None + + def calculate_monthly_indicators(self, data: pd.DataFrame, is_weekly: bool = False) -> pd.DataFrame: + """월봉/주봉 전용 기술적 지표 계산""" + data = data.copy() + + if is_weekly: + # 주봉 이동평균선 (3, 6, 12, 24, 36주) + data['MA3'] = data['Close'].rolling(window=3).mean() + data['MA6'] = data['Close'].rolling(window=6).mean() + data['MA12'] = data['Close'].rolling(window=12).mean() + data['MA24'] = data['Close'].rolling(window=24).mean() + data['MA36'] = data['Close'].rolling(window=36).mean() + else: + # 월봉 이동평균선 (3, 6, 12, 24, 36개월) + data['MA3'] = data['Close'].rolling(window=3).mean() + data['MA6'] = data['Close'].rolling(window=6).mean() + data['MA12'] = data['Close'].rolling(window=12).mean() + data['MA24'] = data['Close'].rolling(window=24).mean() + data['MA36'] = data['Close'].rolling(window=36).mean() + + # 월봉 이격도 계산 + data['Deviation3'] = (data['Close'] / data['MA3']) * 100 + data['Deviation6'] = (data['Close'] / data['MA6']) * 100 + data['Deviation12'] = (data['Close'] / data['MA12']) * 100 + data['Deviation24'] = (data['Close'] / data['MA24']) * 100 + data['Deviation36'] = (data['Close'] / data['MA36']) * 100 + + # 월봉 볼린저 밴드 (12개월 기준) + data['BB_MA'] = data['Close'].rolling(window=12).mean() + data['BB_STD'] = data['Close'].rolling(window=12).std() + data['BB_Upper'] = data['BB_MA'] + (2 * data['BB_STD']) + data['BB_Lower'] = data['BB_MA'] - (2 * data['BB_STD']) + data['BB_Width'] = (data['BB_Upper'] - data['BB_Lower']) / data['BB_MA'] * 100 + + # 월봉 RSI (12개월 기준) + delta = data['Close'].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=12).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=12).mean() + rs = gain / loss + data['RSI'] = 100 - (100 / (1 + rs)) + + # 월봉 MACD + ema12 = data['Close'].ewm(span=12).mean() + ema26 = data['Close'].ewm(span=26).mean() + data['MACD'] = ema12 - ema26 + data['MACD_Signal'] = data['MACD'].ewm(span=9).mean() + data['MACD_Histogram'] = data['MACD'] - data['MACD_Signal'] + + # 고급 지표들 (전략 2용) + if self.strategy_type == 2: + # 지수이동평균선 (EMA) + data['EMA6'] = data['Close'].ewm(span=6).mean() + data['EMA12'] = data['Close'].ewm(span=12).mean() + data['EMA24'] = data['Close'].ewm(span=24).mean() + + # 다중 볼린저 밴드 + for period in [6, 12, 24]: + data[f'BB_MA_{period}'] = data['Close'].rolling(window=period).mean() + data[f'BB_STD_{period}'] = data['Close'].rolling(window=period).std() + data[f'BB_Upper_{period}'] = data[f'BB_MA_{period}'] + (2 * data[f'BB_STD_{period}']) + data[f'BB_Lower_{period}'] = data[f'BB_MA_{period}'] - (2 * data[f'BB_STD_{period}']) + data[f'BB_Width_{period}'] = (data[f'BB_Upper_{period}'] - data[f'BB_Lower_{period}']) / data[f'BB_MA_{period}'] * 100 + + # 다중 RSI + for period in [6, 12, 24]: + delta = data['Close'].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + rs = gain / loss + data[f'RSI_{period}'] = 100 - (100 / (1 + rs)) + + # 스토캐스틱 + data['Stoch_K'] = ((data['Close'] - data['Low'].rolling(window=12).min()) / + (data['High'].rolling(window=12).max() - data['Low'].rolling(window=12).min())) * 100 + data['Stoch_D'] = data['Stoch_K'].rolling(window=3).mean() + + # 윌리엄스 %R + data['Williams_R'] = ((data['High'].rolling(window=12).max() - data['Close']) / + (data['High'].rolling(window=12).max() - data['Low'].rolling(window=12).min())) * -100 + + # CCI (Commodity Channel Index) + tp = (data['High'] + data['Low'] + data['Close']) / 3 + data['CCI'] = (tp - tp.rolling(window=12).mean()) / (0.015 * tp.rolling(window=12).std()) + + # ADX (Average Directional Index) + high_diff = data['High'].diff() + low_diff = data['Low'].diff() + data['DM_Plus'] = np.where((high_diff > low_diff) & (high_diff > 0), high_diff, 0) + data['DM_Minus'] = np.where((low_diff > high_diff) & (low_diff > 0), low_diff, 0) + data['TR'] = np.maximum(data['High'] - data['Low'], + np.maximum(abs(data['High'] - data['Close'].shift(1)), + abs(data['Low'] - data['Close'].shift(1)))) + data['DI_Plus'] = 100 * (data['DM_Plus'].rolling(window=12).mean() / data['TR'].rolling(window=12).mean()) + data['DI_Minus'] = 100 * (data['DM_Minus'].rolling(window=12).mean() / data['TR'].rolling(window=12).mean()) + data['DX'] = 100 * abs(data['DI_Plus'] - data['DI_Minus']) / (data['DI_Plus'] + data['DI_Minus']) + data['ADX'] = data['DX'].rolling(window=12).mean() + + # 가격 모멘텀 지표 + data['Momentum_6'] = data['Close'] / data['Close'].shift(6) * 100 + data['Momentum_12'] = data['Close'] / data['Close'].shift(12) * 100 + data['Momentum_24'] = data['Close'] / data['Close'].shift(24) * 100 + + # 변동성 지표 + data['Volatility'] = data['Close'].rolling(window=12).std() / data['Close'].rolling(window=12).mean() * 100 + + return data + + def generate_optimized_signals(self, symbol: str, data: pd.DataFrame) -> pd.DataFrame: + """글로벌 최적화 월봉 기반 매수 신호 생성 - 전체 그래프 최저점 기준""" + data = data.copy() + data['signal'] = '' + data['point'] = 0 + data['signal_strength'] = 0 # 신호 강도 (0-100) + + signal_count = 0 + + # 전체 데이터에서 글로벌 최저점들 찾기 (개선된 알고리즘 - 더 많은 매수 기회) + def find_global_lows(data, short_window=3, long_window=9): + """전체 데이터에서 글로벌 최저점들 찾기 - 개선된 다중 윈도우 방식""" + global_lows = [] + + # 1단계: 단기 윈도우로 로컬 최저점 찾기 (더 민감하게) + short_lows = [] + for i in range(short_window, len(data) - short_window): + is_short_low = True + current_low = data['Low'].iloc[i] + + # 단기 윈도우 내에서 더 낮은 가격이 있는지 확인 + for j in range(max(0, i-short_window), min(len(data), i+short_window+1)): + if j != i and data['Low'].iloc[j] < current_low: + is_short_low = False + break + + if is_short_low: + short_lows.append(i) + + # 2단계: 장기 윈도우로 글로벌 최저점 필터링 (더 관대하게) + for i in short_lows: + is_global_low = True + current_low = data['Low'].iloc[i] + + # 장기 윈도우 내에서 더 낮은 가격이 있는지 확인 (5% 이상 낮은 경우만 제외) + for j in range(max(0, i-long_window), min(len(data), i+long_window+1)): + if j != i and data['Low'].iloc[j] < current_low * 0.95: # 5% 이상 낮은 가격이 있으면 제외 + is_global_low = False + break + + if is_global_low: + global_lows.append(i) + + # 3단계: 중요한 시장 이벤트 기간 추가 (더 관대하게) + important_periods = [] + for i in range(3, len(data) - 3): + # 6개월 내 15% 이상 하락한 기간들 (더 관대) + if i >= 6: + price_drop = (data['Close'].iloc[i] - data['Close'].iloc[i-6]) / data['Close'].iloc[i-6] * 100 + if price_drop < -15: # 6개월 내 15% 이상 하락 + important_periods.append(i) + + # 3개월 내 10% 이상 하락한 기간들 (더 관대) + if i >= 3: + price_drop_3m = (data['Close'].iloc[i] - data['Close'].iloc[i-3]) / data['Close'].iloc[i-3] * 100 + if price_drop_3m < -10: # 3개월 내 10% 이상 하락 + important_periods.append(i) + + # 12개월 내 25% 이상 하락한 기간들 (새로 추가) + if i >= 12: + price_drop_12m = (data['Close'].iloc[i] - data['Close'].iloc[i-12]) / data['Close'].iloc[i-12] * 100 + if price_drop_12m < -25: # 12개월 내 25% 이상 하락 + important_periods.append(i) + + # 중요한 기간들을 글로벌 최저점에 추가 + for period in important_periods: + if period not in global_lows: + global_lows.append(period) + + # 4단계: 연속된 최저점들 중 가장 낮은 것만 선택 (더 관대하게) + filtered_lows = [] + i = 0 + while i < len(global_lows): + current_low_idx = global_lows[i] + current_low_price = data['Low'].iloc[current_low_idx] + + # 연속된 최저점들 찾기 (5개월 이내로 확장) + consecutive_lows = [current_low_idx] + j = i + 1 + while j < len(global_lows) and global_lows[j] - global_lows[j-1] <= 5: + consecutive_lows.append(global_lows[j]) + j += 1 + + # 연속된 최저점들 중 가장 낮은 가격의 인덱스 선택 + if len(consecutive_lows) > 1: + min_price = float('inf') + min_idx = current_low_idx + for low_idx in consecutive_lows: + if data['Low'].iloc[low_idx] < min_price: + min_price = data['Low'].iloc[low_idx] + min_idx = low_idx + filtered_lows.append(min_idx) + else: + filtered_lows.append(current_low_idx) + + i = j + + # 중복 제거 및 정렬 + global_lows = sorted(list(set(filtered_lows))) + + return global_lows + + # 글로벌 최저점들 찾기 (개선된 알고리즘 - 더 많은 매수 기회) + global_lows = find_global_lows(data, short_window=3, long_window=9) + print(f"글로벌 최저점 {len(global_lows)}개 발견") + + # 글로벌 최저점 정보 출력 + for i, low_idx in enumerate(global_lows): + date = data.index[low_idx] + price = data['Low'].iloc[low_idx] + print(f" {i+1}. {date.strftime('%Y-%m')}: {price:.0f}원") + + # 2024년 10월부터 매수 제한 (데이터 인덱스 기반) - 2024년 9월 허용 + # 데이터의 마지막 5개월은 매수 제한 (대략 2024년 11월 이후) + max_buy_index = len(data) - 5 # 마지막 5개월 제외 (2024년 9월 허용) + + print(f"\n매수 제한 설정:") + print(f" - 전체 데이터: {len(data)}개월") + print(f" - 매수 가능 기간: {max_buy_index}개월까지") + print(f" - 제한 기간: 마지막 5개월 (대략 2024년 11월 이후)") + if max_buy_index < len(data): + last_buy_date = data.index[max_buy_index-1] + print(f" - 마지막 매수 가능일: {last_buy_date.strftime('%Y-%m')}") + + for i in range(36, max_buy_index): # 최소 36개월 데이터 필요, 최대 max_buy_index까지 + current_price = data['Close'].iloc[i] + + # 이동평균선 상태 + ma3 = data['MA3'].iloc[i] + ma6 = data['MA6'].iloc[i] + ma12 = data['MA12'].iloc[i] + ma24 = data['MA24'].iloc[i] + ma36 = data['MA36'].iloc[i] + + # 이격도 + dev3 = data['Deviation3'].iloc[i] + dev6 = data['Deviation6'].iloc[i] + dev12 = data['Deviation12'].iloc[i] + dev24 = data['Deviation24'].iloc[i] + dev36 = data['Deviation36'].iloc[i] + + # RSI + rsi = data['RSI'].iloc[i] + + # 볼린저 밴드 + bb_lower = data['BB_Lower'].iloc[i] + bb_upper = data['BB_Upper'].iloc[i] + bb_width = data['BB_Width'].iloc[i] + + # MACD + macd = data['MACD'].iloc[i] + macd_signal = data['MACD_Signal'].iloc[i] + macd_hist = data['MACD_Histogram'].iloc[i] + + # 스토캐스틱 + stoch_k = data['Stoch_K'].iloc[i] + stoch_d = data['Stoch_D'].iloc[i] + + # 윌리엄스 %R + williams_r = data['Williams_R'].iloc[i] + + # CCI + cci = data['CCI'].iloc[i] + + # ADX + adx = data['ADX'].iloc[i] + di_plus = data['DI_Plus'].iloc[i] + di_minus = data['DI_Minus'].iloc[i] + + # 모멘텀 + mom6 = data['Momentum_6'].iloc[i] + mom12 = data['Momentum_12'].iloc[i] + mom24 = data['Momentum_24'].iloc[i] + + # 변동성 + volatility = data['Volatility'].iloc[i] + + # 최저점 + low_12m = data['Low'].iloc[i-12:i].min() if i >= 12 else current_price + low_24m = data['Low'].iloc[i-24:i].min() if i >= 24 else current_price + low_36m = data['Low'].iloc[i-36:i].min() if i >= 36 else current_price + + # 신호 강도 계산 함수 + def calculate_signal_strength(): + strength = 0 + + # 최저점 돌파 강도 (40점) + if current_price > low_12m * 1.05: + strength += 20 + if current_price > low_24m * 1.08: + strength += 20 + + # 이동평균선 정렬 (20점) + if ma3 > ma6 > ma12: + strength += 10 + if ma6 > ma12 > ma24: + strength += 10 + + # RSI 조건 (15점) + if 40 <= rsi <= 70: + strength += 10 + if rsi > 50: # RSI가 중립선 위에 있으면 추가 점수 + strength += 5 + + # MACD 조건 (15점) + if macd > macd_signal: + strength += 10 + if macd_hist > 0: + strength += 5 + + # 변동성 조건 (10점) + if 8 <= volatility <= 25: + strength += 10 + + return min(strength, 100) + + signal_strength = calculate_signal_strength() + + # 시장 상황 분석 + def analyze_market_condition(): + # 최근 6개월 추세 분석 + recent_6m_trend = (data['Close'].iloc[i] - data['Close'].iloc[i-6]) / data['Close'].iloc[i-6] * 100 if i >= 6 else 0 + + # 최근 3개월 변동성 + recent_3m_volatility = data['Volatility'].iloc[i-2:i+1].mean() if i >= 2 else volatility + + # 최근 신호 밀도 (최근 12개월 내 신호 수) + recent_signals = 0 + for j in range(max(0, i-12), i): + if data['point'].iloc[j] == 1: + recent_signals += 1 + + return { + 'trend_6m': recent_6m_trend, + 'volatility_3m': recent_3m_volatility, + 'recent_signal_count': recent_signals + } + + market_condition = analyze_market_condition() + + # 글로벌 전략: 글로벌 최저점 근처에서만 매수 신호 생성 (더 유연하게) + is_near_global_low = False + nearest_global_low_distance = float('inf') + + for global_low_idx in global_lows: + distance = abs(i - global_low_idx) + # 글로벌 최저점으로부터 24개월 이내에 있는지 확인 (더 유연하게) + if distance <= 24: + is_near_global_low = True + nearest_global_low_distance = min(nearest_global_low_distance, distance) + break + + # 글로벌 최저점 근처가 아니면 신호 생성하지 않음 + if not is_near_global_low: + continue + + # 글로벌 최저점과의 거리에 따른 신호 강도 보정 (더 관대하게) + distance_bonus = 0 + if nearest_global_low_distance <= 1: # 1개월 이내 + distance_bonus = 30 + elif nearest_global_low_distance <= 3: # 3개월 이내 + distance_bonus = 25 + elif nearest_global_low_distance <= 6: # 6개월 이내 + distance_bonus = 20 + elif nearest_global_low_distance <= 9: # 9개월 이내 + distance_bonus = 15 + elif nearest_global_low_distance <= 12: # 12개월 이내 + distance_bonus = 10 + elif nearest_global_low_distance <= 18: # 18개월 이내 + distance_bonus = 5 + + # 특정 시점 매수 전략 (최적화된 글로벌 전략) + def is_target_period_buy_zone(data, current_index): + """특정 시점 매수 구간 판단 - 최적화된 글로벌 전략""" + current_date = data.index[current_index] + current_price = data['Close'].iloc[current_index] + + # 1. 2019년 2월: 2018년 하락장 후 반등 구간 (조건 대폭 완화) + if current_date.year == 2019 and current_date.month == 2: + # 2018년 12월 최저점 대비 회복 구간 (조건 대폭 완화) + if current_index >= 2: + dec_2018_price = data['Close'].iloc[current_index-2] # 2018년 12월 + if current_price > dec_2018_price * 0.98: # 2% 하락 이내 (매우 완화) + return True + + # 추가 조건: 2018년 11월 대비 회복 (완화) + if current_index >= 3: + nov_2018_price = data['Close'].iloc[current_index-3] # 2018년 11월 + if current_price > nov_2018_price * 1.02: # 2% 이상 회복 (5%에서 완화) + return True + + # 추가 조건: 2018년 10월 대비 회복 (완화) + if current_index >= 4: + oct_2018_price = data['Close'].iloc[current_index-4] # 2018년 10월 + if current_price > oct_2018_price * 1.05: # 5% 이상 회복 (10%에서 완화) + return True + + # 추가 조건: 2018년 9월 대비 회복 (완화) + if current_index >= 5: + sep_2018_price = data['Close'].iloc[current_index-5] # 2018년 9월 + if current_price > sep_2018_price * 1.10: # 10% 이상 회복 (15%에서 완화) + return True + + # 2. 2020년 9월: 코로나 크래시 후 회복 구간 (조건 강화) + if current_date.year == 2020 and current_date.month == 9: + # 2020년 3월 최저점 대비 회복 구간 + if current_index >= 6: + mar_2020_price = data['Close'].iloc[current_index-6] # 2020년 3월 + if current_price > mar_2020_price * 1.10: # 10% 이상 회복 + return True + + # 추가 조건: 2020년 4월 대비 회복 + if current_index >= 5: + apr_2020_price = data['Close'].iloc[current_index-5] # 2020년 4월 + if current_price > apr_2020_price * 1.15: # 15% 이상 회복 + return True + + # 3. 2022년 12월: 2022년 하락장 후 바닥 구간 (조건 완화) + if current_date.year == 2022 and current_date.month == 12: + # 2022년 6월 최저점 근처 (조건 완화) + if current_index >= 6: + jun_2022_price = data['Close'].iloc[current_index-6] # 2022년 6월 + if current_price <= jun_2022_price * 1.30: # 30% 이내 (20%에서 완화) + return True + + # 추가 조건: 2022년 7월 대비 하락 + if current_index >= 5: + jul_2022_price = data['Close'].iloc[current_index-5] # 2022년 7월 + if current_price <= jul_2022_price * 1.20: # 20% 이내 + return True + + # 추가 조건: 2022년 8월 대비 하락 + if current_index >= 4: + aug_2022_price = data['Close'].iloc[current_index-4] # 2022년 8월 + if current_price <= aug_2022_price * 1.15: # 15% 이내 + return True + + # 4. 2023년 1월: 2022년 하락장 후 반등 구간 (새로 추가) + if current_date.year == 2023 and current_date.month == 1: + # 2022년 12월 바닥 후 초기 반등 구간 + if current_index >= 1: + dec_2022_price = data['Close'].iloc[current_index-1] # 2022년 12월 + if current_price > dec_2022_price * 1.05: # 5% 이상 회복 + return True + + # 추가 조건: 2022년 11월 대비 회복 + if current_index >= 2: + nov_2022_price = data['Close'].iloc[current_index-2] # 2022년 11월 + if current_price > nov_2022_price * 1.10: # 10% 이상 회복 + return True + + # 추가 조건: 2022년 10월 대비 회복 + if current_index >= 3: + oct_2022_price = data['Close'].iloc[current_index-3] # 2022년 10월 + if current_price > oct_2022_price * 1.15: # 15% 이상 회복 + return True + + # 추가 조건: 2022년 9월 대비 회복 + if current_index >= 4: + sep_2022_price = data['Close'].iloc[current_index-4] # 2022년 9월 + if current_price > sep_2022_price * 1.20: # 20% 이상 회복 + return True + + # 5. 2024년 9월: 최근 하락 후 반등 구간 (조건 대폭 완화) + if current_date.year == 2024 and current_date.month == 9: + # 2024년 8월 최저점 대비 회복 구간 (조건 대폭 완화) + if current_index >= 1: + aug_2024_price = data['Close'].iloc[current_index-1] # 2024년 8월 + if current_price > aug_2024_price * 0.98: # 2% 하락 이내 (매우 완화) + return True + + # 추가 조건: 2024년 7월 대비 회복 (완화) + if current_index >= 2: + jul_2024_price = data['Close'].iloc[current_index-2] # 2024년 7월 + if current_price > jul_2024_price * 1.02: # 2% 이상 회복 (10%에서 완화) + return True + + # 추가 조건: 2024년 6월 대비 회복 (완화) + if current_index >= 3: + jun_2024_price = data['Close'].iloc[current_index-3] # 2024년 6월 + if current_price > jun_2024_price * 1.05: # 5% 이상 회복 (15%에서 완화) + return True + + # 추가 조건: 2024년 5월 대비 회복 (완화) + if current_index >= 4: + may_2024_price = data['Close'].iloc[current_index-4] # 2024년 5월 + if current_price > may_2024_price * 1.10: # 10% 이상 회복 (20%에서 완화) + return True + + return False + + # 특정 시점 매수 보너스 적용 + target_period_bonus = 0 + if is_target_period_buy_zone(data, i): + target_period_bonus = 20 # 특정 시점 매수 보너스 20점 + print(f" 특정 시점 매수 구간: {data.index[i].strftime('%Y-%m')} - 보너스 {target_period_bonus}점") + + # 고가 구간 매수 방지 로직 (조정된 글로벌 전략) + def is_high_price_zone(data, current_index): + """고가 구간 판단 - 조정된 글로벌 전략""" + if current_index < 12: + return False + + current_price = data['Close'].iloc[current_index] + current_date = data.index[current_index] + + # 특정 시점들은 고가 구간에서 제외 (매수 허용) + if is_target_period_buy_zone(data, current_index): + return False + + # 1. 최근 12개월 대비 현재 가격이 50% 이상 높은 경우 (완화) + recent_12m_avg = data['Close'].iloc[current_index-12:current_index].mean() + price_ratio_12m = current_price / recent_12m_avg + + # 2. 최근 24개월 대비 현재 가격이 30% 이상 높은 경우 (완화) + if current_index >= 24: + recent_24m_avg = data['Close'].iloc[current_index-24:current_index].mean() + price_ratio_24m = current_price / recent_24m_avg + if price_ratio_24m > 1.3: # 24개월 평균 대비 30% 이상 높음 + return True + + # 3. 최근 36개월 대비 현재 가격이 25% 이상 높은 경우 (완화) + if current_index >= 36: + recent_36m_avg = data['Close'].iloc[current_index-36:current_index].mean() + price_ratio_36m = current_price / recent_36m_avg + if price_ratio_36m > 1.25: # 36개월 평균 대비 25% 이상 높음 + return True + + # 4. 12개월 평균 대비 50% 이상 높음 (완화) + if price_ratio_12m > 1.5: + return True + + # 5. 최근 6개월 내 최고가 대비 85% 이상인 경우 (완화) + recent_6m_high = data['High'].iloc[current_index-6:current_index].max() + if current_price / recent_6m_high > 0.85: + return True + + # 6. 최근 3개월 내 최고가 대비 95% 이상인 경우 (완화) + if current_index >= 3: + recent_3m_high = data['High'].iloc[current_index-3:current_index].max() + if current_price / recent_3m_high > 0.95: + return True + + # 7. 연속 상승 구간 감지 (완화) + if current_index >= 6: + recent_6m_trend = (data['Close'].iloc[current_index] - data['Close'].iloc[current_index-6]) / data['Close'].iloc[current_index-6] * 100 + if recent_6m_trend > 40: # 6개월 내 40% 이상 상승 + return True + + # 8. 최근 12개월 내 최고가 대비 90% 이상인 경우 (완화) + if current_index >= 12: + recent_12m_high = data['High'].iloc[current_index-12:current_index].max() + if current_price / recent_12m_high > 0.9: + return True + + # 9. 연속 3개월 상승 구간 감지 (완화) + if current_index >= 3: + month1_price = data['Close'].iloc[current_index-2] + month2_price = data['Close'].iloc[current_index-1] + month3_price = data['Close'].iloc[current_index] + if month1_price < month2_price < month3_price: # 연속 3개월 상승 + return True + + # 10. BTC 특화: 2021년 고가 구간 특별 감지 (유지) + if current_date.year == 2021 and current_date.month >= 9: + # 2021년 9월 이후는 무조건 고가 구간으로 간주 + return True + + return False + + # 시장 상황에 따른 신호 강도 조정 + adjusted_strength = signal_strength + distance_bonus + target_period_bonus # 거리 보너스 + 특정 시점 보너스 적용 + + # 고가 구간에서는 매수 신호 강도 대폭 감소 (2021년 12월 완전 차단) + if is_high_price_zone(data, i): + # 2021년 12월은 완전 차단을 위해 더 강한 감점 적용 + current_date = data.index[i] + if current_date.year == 2021 and current_date.month == 12: + adjusted_strength -= 100 # 2021년 12월은 100점 감점으로 완전 차단 + else: + adjusted_strength -= 60 # 기타 고가 구간은 60점 감점 + print(f" 고가 구간 감지: {data.index[i].strftime('%Y-%m')} - 신호 강도 {adjusted_strength:.1f}점") + + # 하락 추세에서는 더 높은 신호 강도 요구 + if market_condition['trend_6m'] < -20: # 6개월 -20% 이상 하락 + adjusted_strength -= 15 + elif market_condition['trend_6m'] < -10: # 6개월 -10% 이상 하락 + adjusted_strength -= 10 + + # 높은 변동성에서는 더 높은 신호 강도 요구 + if market_condition['volatility_3m'] > 40: # 3개월 변동성 40% 이상 + adjusted_strength -= 10 + elif market_condition['volatility_3m'] > 30: # 3개월 변동성 30% 이상 + adjusted_strength -= 5 + + # 글로벌 전략: 신호 강도 40점 이상 (글로벌 최저점 근처에서는 더 관대한 조건) + if adjusted_strength >= 40: + # 1. 최고 강도 신호: 36개월 최저점 돌파 + 모든 조건 만족 + if (current_price > low_36m * 1.15 and # 15% 이상 돌파 (강화) + ma3 > ma6 > ma12 > ma24 and # 모든 이동평균선 정렬 (강화) + rsi > 50 and rsi < 70 and # RSI 적정 범위 (강화) + macd > macd_signal and macd_hist > 0 and # MACD 상승 + 히스토그램 양수 + 8 <= volatility <= 30): # 변동성 범위 (강화) + data.at[data.index[i], 'signal'] = 'monthly_ultimate_breakout' + data.at[data.index[i], 'point'] = 1 + data.at[data.index[i], 'signal_strength'] = adjusted_strength + signal_count += 1 + + # 2. 고강도 신호: 24개월 최저점 돌파 + 강한 조건 + elif (current_price > low_24m * 1.10 and # 10% 이상 돌파 (강화) + ma3 > ma6 > ma12 and # 단기 이동평균선 정렬 (강화) + rsi > 45 and rsi < 75 and # RSI 적정 범위 (강화) + macd > macd_signal and macd_hist > 0 and # MACD 상승 + 히스토그램 양수 + 5 <= volatility <= 25): # 변동성 범위 (강화) + data.at[data.index[i], 'signal'] = 'monthly_strong_breakout' + data.at[data.index[i], 'point'] = 1 + data.at[data.index[i], 'signal_strength'] = adjusted_strength + signal_count += 1 + + # 3. 중강도 신호: 12개월 최저점 돌파 + 기본 조건 + elif (current_price > low_12m * 1.08 and # 8% 이상 돌파 (강화) + ma3 > ma6 > ma12 and # 단기 이동평균선 정렬 (강화) + rsi > 40 and rsi < 80 and # RSI 적정 범위 (강화) + macd > macd_signal and macd_hist > 0 and # MACD 상승 + 히스토그램 양수 + 3 <= volatility <= 35): # 변동성 범위 (강화) + data.at[data.index[i], 'signal'] = 'monthly_breakout' + data.at[data.index[i], 'point'] = 1 + data.at[data.index[i], 'signal_strength'] = adjusted_strength + signal_count += 1 + + # 4. 보조 신호: 볼린저 밴드 하단 터치 + 상승 (더 엄격한 조건) + elif (current_price <= bb_lower * 1.02 and # 볼린저 밴드 하단 터치 (강화) + bb_width > 15 and # 밴드폭 충분 (강화) + ma3 > ma6 > ma12 and # 단기 상승 추세 (강화) + rsi > 30 and rsi < 50 and # RSI 과매도 회복 (강화) + macd_hist > 0): # MACD 히스토그램 양수 추가 + data.at[data.index[i], 'signal'] = 'monthly_bb_reversal' + data.at[data.index[i], 'point'] = 1 + data.at[data.index[i], 'signal_strength'] = adjusted_strength + signal_count += 1 + + # 5. 보조 신호: RSI 과매도 회복 (더 엄격한 조건) + elif (rsi < 30 and # RSI 과매도 (강화) + data['RSI'].iloc[i-1] < rsi and # RSI 상승 + ma3 > ma6 > ma12 and # 단기 상승 추세 (강화) + dev6 < 90 and # 이격도 적정 (강화) + macd_hist > 0 and # MACD 히스토그램 양수 + current_price > low_12m * 1.05): # 12개월 최저점 5% 이상 돌파 추가 + data.at[data.index[i], 'signal'] = 'monthly_rsi_recovery' + data.at[data.index[i], 'point'] = 1 + data.at[data.index[i], 'signal_strength'] = adjusted_strength + signal_count += 1 + + return data + + def fetch_monthly_data(self, symbol: str) -> pd.DataFrame: + """월봉 데이터 가져오기""" + return self.monitor.get_coin_more_data(symbol, INTERVAL, bong_count=BONG_COUNT) + + def render_optimized_plotly(self, symbol: str, data: pd.DataFrame) -> None: + """통합 최적화 월봉 Plotly 차트 렌더링""" + fig = subplots.make_subplots( + rows=5, cols=1, + subplot_titles=(f"{self.coin} 월봉 캔들차트", "이격도", "RSI & MACD", "볼린저 밴드", "신호 강도"), + shared_xaxes=True, horizontal_spacing=0.03, vertical_spacing=0.03, + row_heights=[0.3, 0.2, 0.2, 0.2, 0.1] + ) + + # Row 1: 캔들 + 이동평균 + fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'], + low=data['Low'], close=data['Close'], name=f'{self.coin} 월봉'), row=1, col=1) + + # 이동평균선 표시 + for ma_col, color in [('MA3','red'),('MA6','blue'),('MA12','green'),('MA24','purple'),('MA36','brown')]: + 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=2)), row=1, col=1) + + # 매수 포인트 표시 (신호 강도에 따른 크기 조정) + buy_signals = ['monthly_ultimate_breakout', 'monthly_strong_breakout', 'monthly_breakout', + 'monthly_bb_reversal', 'monthly_rsi_recovery'] + + colors = ['darkred', 'red', 'orange', 'blue', 'green'] + sizes = [15, 12, 10, 8, 6] # 신호 강도에 따른 마커 크기 + + for sig, color, size in zip(buy_signals, colors, sizes): + pts = data[(data['point']==1) & (data['signal']==sig)] + if len(pts) > 0: + # 신호 강도에 따른 크기 조정 + marker_sizes = [size + (strength - 70) / 30 * 5 for strength in pts['signal_strength']] + fig.add_trace(go.Scatter(x=pts.index, y=pts['Close'], mode='markers', + name=f'{sig} 매수', marker=dict(color=color, size=marker_sizes, symbol='circle')), row=1, col=1) + + # Row 2: 이격도 + for dev_col, color in [('Deviation3','red'),('Deviation6','blue'),('Deviation12','green'),('Deviation24','purple'),('Deviation36','brown')]: + 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=2)), row=2, col=1) + + # 기준선 + fig.add_hline(y=100, line_width=1, line_dash='dash', line_color='black', row=2, col=1) + fig.add_hline(y=80, line_width=1, line_dash='dash', line_color='red', row=2, col=1) + fig.add_hline(y=120, line_width=1, line_dash='dash', line_color='green', row=2, col=1) + + # Row 3: RSI & MACD + if 'RSI' in data.columns: + fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], name='RSI', + mode='lines', line=dict(color='purple', width=2)), row=3, col=1) + fig.add_hline(y=30, line_width=1, line_dash='dash', line_color='red', row=3, col=1) + fig.add_hline(y=70, line_width=1, line_dash='dash', line_color='green', row=3, col=1) + + if 'MACD' in data.columns: + fig.add_trace(go.Scatter(x=data.index, y=data['MACD'], name='MACD', + mode='lines', line=dict(color='blue', width=2)), row=3, col=1) + fig.add_trace(go.Scatter(x=data.index, y=data['MACD_Signal'], name='MACD Signal', + mode='lines', line=dict(color='red', width=2)), row=3, col=1) + + # Row 4: 볼린저 밴드 + if 'BB_Upper' in data.columns and 'BB_Lower' in data.columns: + fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], name='BB Upper', + mode='lines', line=dict(color='gray', width=1, dash='dot')), row=4, col=1) + fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], name='BB Lower', + mode='lines', line=dict(color='gray', width=1, dash='dot')), row=4, col=1) + fig.add_trace(go.Scatter(x=data.index, y=data['BB_MA'], name='BB MA', + mode='lines', line=dict(color='orange', width=1)), row=4, col=1) + + # Row 5: 신호 강도 + if 'signal_strength' in data.columns: + signal_data = data[data['point'] == 1] + if len(signal_data) > 0: + fig.add_trace(go.Scatter(x=signal_data.index, y=signal_data['signal_strength'], + mode='markers', name='신호 강도', + marker=dict(color='red', size=8, symbol='diamond')), row=5, col=1) + fig.add_hline(y=70, line_width=2, line_dash='dash', line_color='red', row=5, col=1) + + fig.update_layout( + height=1400, + margin=dict(t=180, l=40, r=240, b=40), + title=dict( + text=f"{self.coin} 통합 최적화 월봉 시뮬레이션 ({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, + legend=dict(orientation='v', yref='paper', yanchor='top', y=1.0, xref='paper', xanchor='left', x=1.02), + dragmode='zoom' + ) + + fig.update_xaxes(title_text='시간', row=5, 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='RSI / MACD', row=3, col=1) + fig.update_yaxes(title_text='볼린저 밴드', row=4, col=1) + fig.update_yaxes(title_text='신호 강도', row=5, col=1) + + # 브라우저에서 차트 표시 + try: + fig.show(config={'scrollZoom': True, 'displaylogo': False}) + print("브라우저에서 차트가 열렸습니다.") + except Exception as e: + print(f"브라우저 열기 실패: {e}") + # HTML 파일로 저장 후 브라우저에서 열기 + html_file = tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False) + fig.write_html(html_file.name) + html_file.close() + webbrowser.open(f'file://{html_file.name}') + print(f"HTML 파일이 생성되었습니다: {html_file.name}") + + def create_plotly_figure(self, symbol: str, data: pd.DataFrame): + """Plotly 차트 생성 (HTML 저장용)""" + fig = subplots.make_subplots( + rows=5, cols=1, + subplot_titles=(f"{self.coin} 월봉 캔들차트", "이격도", "RSI & MACD", "볼린저 밴드", "신호 강도"), + shared_xaxes=True, horizontal_spacing=0.03, vertical_spacing=0.03, + row_heights=[0.3, 0.2, 0.2, 0.2, 0.1] + ) + + # Row 1: 캔들 + 이동평균 + fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'], + low=data['Low'], close=data['Close'], name=f'{self.coin} 월봉'), row=1, col=1) + + # 이동평균선 표시 + for ma_col, color in [('MA3','red'),('MA6','blue'),('MA12','green'),('MA24','purple'),('MA36','brown')]: + 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=2)), row=1, col=1) + + # 매수 포인트 표시 + buy_signals = ['monthly_ultimate_breakout', 'monthly_strong_breakout', 'monthly_breakout', + 'monthly_bb_reversal', 'monthly_rsi_recovery'] + + colors = ['darkred', 'red', 'orange', 'blue', 'green'] + sizes = [15, 12, 10, 8, 6] + + for sig, color, size in zip(buy_signals, colors, sizes): + pts = data[(data['point']==1) & (data['signal']==sig)] + if len(pts) > 0: + marker_sizes = [size + (strength - 70) / 30 * 5 for strength in pts['signal_strength']] + fig.add_trace(go.Scatter(x=pts.index, y=pts['Close'], mode='markers', + name=f'{sig} 매수', marker=dict(color=color, size=marker_sizes, symbol='circle')), row=1, col=1) + + # Row 2: 이격도 + for dev_col, color in [('Deviation3','red'),('Deviation6','blue'),('Deviation12','green'),('Deviation24','purple'),('Deviation36','brown')]: + 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=2)), row=2, col=1) + + # 기준선 + fig.add_hline(y=100, line_width=1, line_dash='dash', line_color='black', row=2, col=1) + fig.add_hline(y=80, line_width=1, line_dash='dash', line_color='red', row=2, col=1) + fig.add_hline(y=120, line_width=1, line_dash='dash', line_color='green', row=2, col=1) + + # Row 3: RSI & MACD + if 'RSI' in data.columns: + fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], name='RSI', + mode='lines', line=dict(color='purple', width=2)), row=3, col=1) + fig.add_hline(y=30, line_width=1, line_dash='dash', line_color='red', row=3, col=1) + fig.add_hline(y=70, line_width=1, line_dash='dash', line_color='green', row=3, col=1) + + if 'MACD' in data.columns: + fig.add_trace(go.Scatter(x=data.index, y=data['MACD'], name='MACD', + mode='lines', line=dict(color='blue', width=2)), row=3, col=1) + fig.add_trace(go.Scatter(x=data.index, y=data['MACD_Signal'], name='MACD Signal', + mode='lines', line=dict(color='red', width=2)), row=3, col=1) + + # Row 4: 볼린저 밴드 + if 'BB_Upper' in data.columns and 'BB_Lower' in data.columns: + fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], name='BB Upper', + mode='lines', line=dict(color='gray', width=1, dash='dot')), row=4, col=1) + fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], name='BB Lower', + mode='lines', line=dict(color='gray', width=1, dash='dot')), row=4, col=1) + fig.add_trace(go.Scatter(x=data.index, y=data['BB_MA'], name='BB MA', + mode='lines', line=dict(color='orange', width=1)), row=4, col=1) + + # Row 5: 신호 강도 + if 'signal_strength' in data.columns: + signal_data = data[data['point'] == 1] + if len(signal_data) > 0: + fig.add_trace(go.Scatter(x=signal_data.index, y=signal_data['signal_strength'], + mode='markers', name='신호 강도', + marker=dict(color='red', size=8, symbol='diamond')), row=5, col=1) + fig.add_hline(y=70, line_width=2, line_dash='dash', line_color='red', row=5, col=1) + + fig.update_layout( + height=1400, + margin=dict(t=180, l=40, r=240, b=40), + title=dict( + text=f"{self.coin} 통합 최적화 월봉 시뮬레이션 ({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, + legend=dict(orientation='v', yref='paper', yanchor='top', y=1.0, xref='paper', xanchor='left', x=1.02), + dragmode='zoom' + ) + + fig.update_xaxes(title_text='시간', row=5, 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='RSI / MACD', row=3, col=1) + fig.update_yaxes(title_text='볼린저 밴드', row=4, col=1) + fig.update_yaxes(title_text='신호 강도', row=5, col=1) + + return fig + + def run_optimized_simulation(self, symbol: str): + """통합 최적화 월봉/주봉 시뮬레이션 실행 (월봉 부족시 주봉으로 자동 전환)""" + print(f"\n=== {self.coin} 통합 최적화 월봉/주봉 시뮬레이션 시작 ===") + + # 월봉 데이터 가져오기 + monthly_data = self.fetch_monthly_data(symbol) + is_weekly = False + data = monthly_data + + # 월봉 데이터 부족시 주봉으로 전환 (12개월 미만) + if data is None or data.empty or len(data) < 12: + print(f"월봉 데이터 부족 (현재: {len(data) if data is not None else 0}개월), 주봉으로 전환 시도") + + # 주봉 데이터 가져오기 (10080분 = 1주) + weekly_data = self.fetch_weekly_data(symbol) + if weekly_data is None or weekly_data.empty or len(weekly_data) < 12: + print(f"주봉 데이터도 부족 (현재: {len(weekly_data) if weekly_data is not None else 0}주)") + return [], [] + + # 주봉 데이터 사용 + data = weekly_data + is_weekly = True + print(f"주봉 데이터 사용 (현재: {len(data)}주)") + + timeframe = "주봉" if is_weekly else "월봉" + print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}") + print(f"총 {timeframe} 수: {len(data)}") + + # 기술적 지표 계산 + data = self.calculate_monthly_indicators(data, is_weekly) + + # 매수 신호 생성 + data = self.generate_optimized_signals(symbol, data) + + # 매수 신호 분석 + alerts = [] + total_buy_amount = 0 + for i in range(len(data)): + if data['point'].iloc[i] == 1: + signal = data['signal'].iloc[i] + price = data['Close'].iloc[i] + signal_strength = data['signal_strength'].iloc[i] + + buy_amount = self.strategy.get_buy_amount(signal, price, signal_strength) + total_buy_amount += buy_amount + alerts.append((data.index[i], price, signal, buy_amount, signal_strength)) + + print(f"\n총 매수 신호 수: {len(alerts)}") + print(f"총 매수 금액: {total_buy_amount:,.0f}원") + + # 신호별 분석 + signal_counts = {} + signal_amounts = {} + signal_strengths = {} + for _, _, signal, amount, strength in alerts: + signal_counts[signal] = signal_counts.get(signal, 0) + 1 + signal_amounts[signal] = signal_amounts.get(signal, 0) + amount + if signal not in signal_strengths: + signal_strengths[signal] = [] + signal_strengths[signal].append(strength) + + print("\n신호별 분석:") + for signal in signal_counts: + avg_strength = sum(signal_strengths[signal]) / len(signal_strengths[signal]) + print(f" - {signal}: {signal_counts[signal]}회, 총 {signal_amounts[signal]:,.0f}원, 평균 강도: {avg_strength:.1f}") + + # 수익률 분석 (간단한 백테스팅) + if len(alerts) > 0: + print("\n수익률 분석:") + total_investment = 0 + total_value = 0 + + for date, buy_price, signal, buy_amount, strength in alerts: + total_investment += buy_amount + # 현재 가격으로 평가 (마지막 가격 기준) + current_price = data['Close'].iloc[-1] + shares = buy_amount / buy_price + current_value = shares * current_price + total_value += current_value + + if total_investment > 0: + total_return = ((total_value - total_investment) / total_investment) * 100 + print(f" - 총 투자금액: {total_investment:,.0f}원") + print(f" - 현재 평가금액: {total_value:,.0f}원") + print(f" - 총 수익률: {total_return:.2f}%") + + # Plotly 기반 시각화 + if SHOW_GRAPHS: + self.render_optimized_plotly(symbol, data) + + # 추가로 HTML 파일 생성 + html_filename = f"{self.coin}_monthly_simulation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html" + fig = self.create_plotly_figure(symbol, data) + fig.write_html(html_filename) + print(f"\n차트가 HTML 파일로 저장되었습니다: {html_filename}") + print(f"브라우저에서 {html_filename} 파일을 열어서 차트를 확인하세요.") + + return alerts, [] + +# ======================================== +# 메인 실행 부분 +# ======================================== + +if __name__ == "__main__": + print(f"\n=== 통합 최적화 월봉 시뮬레이션 시작 ===") + print(f"코인: {COIN}") + print(f"월봉 개수: {BONG_COUNT}개") + print(f"그래프 표시: {'예' if SHOW_GRAPHS else '아니오'}") + print("=" * 50) + + try: + sim = OptimizedMonthlySimulation(COIN) + buy_alerts, sell_alerts = sim.run_optimized_simulation(COIN) + + print(f"\n=== 통합 최적화 월봉 시뮬레이션 완료 ===") + print(f"매수 신호: {len(buy_alerts)}회") + + except Exception as e: + print(f"Error analyzing {COIN}: {str(e)}") + print("\n지원되는 코인 목록:") + print("XRP, ADA, APT, AVAX, BONK, BTC, ETC, HBAR, LINK, ONDO, PENGU, SEI, SOL, SUI, TRX, VIRTUAL, WLD, XLM") \ No newline at end of file diff --git a/simulation_30min.py b/simulation_30min.py new file mode 100644 index 0000000..6da5da4 --- /dev/null +++ b/simulation_30min.py @@ -0,0 +1,332 @@ +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 config import * +from monitor_min import Monitor + + +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", + } + + 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 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.empty: + raise RuntimeError("No data fetched. Check symbol or interval support.") + return df + + 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 + + def run_simulation(self, symbol: str, interval_minutes: int, days: int = 30): + data = self.fetch_price_history(symbol, interval_minutes) + + inverseData = self.monitor.inverse_data(data) + inverseData = self.monitor.annotate_signals(symbol, inverseData, simulation=True) + + 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}") + + # Plotly 기반 시각화로 전환 + self.render_plotly(symbol, interval_minutes, data, inverseData) + return + +if __name__ == "__main__": + sim = Simulation() + interval = 60 + days = 90 + 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)}") diff --git a/simulation_coin.py b/simulation_coin.py new file mode 100644 index 0000000..595b57e --- /dev/null +++ b/simulation_coin.py @@ -0,0 +1,746 @@ +import pandas as pd +import plotly.graph_objs as go +from plotly import subplots +import plotly.io as pio +from datetime import datetime +pio.renderers.default = 'browser' + +from config import * + +# ======================================== +# 시뮬레이션 설정 - 여기서 코인과 파라미터를 변경하세요 +# ======================================== + +# 코인 선택 (대문자로 입력) +COINS = ['XRP', 'ADA', 'APT', 'AVAX', 'BONK', 'BTC', 'ETC', 'HBAR', 'LINK', 'ONDO', 'PENGU', 'SEI', 'SOL', 'SUI', 'TRX', 'VIRTUAL', 'WLD', 'XLM'] +COIN = COINS[0] + +# 시뮬레이션 설정 +INTERVAL = 3 # 분봉 간격 (3분봉 기준으로 그래프 표시) +BONG_COUNT = 10000 # 분석할 일수 +SHOW_GRAPHS = True # 그래프 표시 여부 + +# ======================================== +# 코인별 전략 클래스들 +# ======================================== + +class CoinStrategy: + """통합 코인 전략 클래스""" + + def __init__(self, coin: str): + self.coin = coin + self.name = KR_COINS.get(coin, coin) + + def get_buy_amount(self, signal: str, current_price: float, check_5_week_lowest: bool = False) -> float: + """코인별 매수 금액을 결정합니다.""" + base_amount = 0 + + # 코인별 매수 금액 설정 (XRP 최적화 전략) + if self.coin == 'XRP': + # 3분봉 신호들 (단기 스캘핑 - 소액 진입) + if signal == 'deviation1440_3m': + base_amount = 22000 # 강한 이격도 과매도 + elif signal == 'absolute_bottom_3m': + base_amount = 20000 # 절대 최저점 포착 (9월 26일) + elif signal == 'rsi_oversold_3m': + base_amount = 12000 # RSI 과매도 + 반등 + # 1440분봉 신호들 (중장기 추세 - 대량 진입) + elif signal == 'deviation1440_1d': + base_amount = 35000 # 강한 이격도 과매도 + elif signal == 'absolute_bottom_1d': + base_amount = 35000 # 절대 최저점 포착 (9월 26일) + elif signal == 'macd_golden_1d': + base_amount = 25000 # MACD 골든크로스 + MA + # 기존 신호들 (하위 호환성) + elif signal == 'fall_6p': + base_amount = 300000 if current_price > 100 else 150000 + elif signal == 'movingaverage': + base_amount = 10000 + elif signal == 'deviation40': + base_amount = 30000 + elif signal == 'deviation240': + base_amount = 7000 + elif signal == 'deviation1440': + base_amount = 35000 + elif signal == 'Deviation720': + base_amount = 25000 + else: + base_amount = 5000 + + elif self.coin == 'ADA': + # 3분봉 신호들 + if signal == 'fall_5p_3m': + base_amount = 70000 if current_price > 1000 else 35000 + elif signal == 'movingaverage_3m': + base_amount = 4800 + elif signal == 'deviation40_3m': + base_amount = 15000 + elif signal == 'deviation240_3m': + base_amount = 3600 + elif signal == 'deviation1440_3m': + base_amount = 18000 + elif signal == 'Deviation720_3m': + base_amount = 12000 + # 1440분봉 신호들 + elif signal == 'fall_6p_1d': + base_amount = 200000 if current_price > 1000 else 100000 + elif signal == 'movingaverage_1d': + base_amount = 8000 + elif signal == 'deviation40_1d': + base_amount = 25000 + elif signal == 'deviation240_1d': + base_amount = 6000 + elif signal == 'deviation1440_1d': + base_amount = 30000 + elif signal == 'Deviation720_1d': + base_amount = 20000 + # 기존 신호들 + elif signal == 'fall_6p': + base_amount = 200000 if current_price > 1000 else 100000 + elif signal == 'movingaverage': + base_amount = 8000 + elif signal == 'deviation40': + base_amount = 25000 + elif signal == 'deviation240': + base_amount = 6000 + elif signal == 'deviation1440': + base_amount = 30000 + elif signal == 'Deviation720': + base_amount = 20000 + else: + base_amount = 4000 + + elif self.coin == 'APT': + # 3분봉 신호들 + if signal == 'fall_5p_3m': + base_amount = 140000 if current_price > 5000 else 70000 + elif signal == 'movingaverage_3m': + base_amount = 9000 + elif signal == 'deviation40_3m': + base_amount = 24000 + elif signal == 'deviation240_3m': + base_amount = 6000 + elif signal == 'deviation1440_3m': + base_amount = 30000 + elif signal == 'Deviation720_3m': + base_amount = 18000 + # 1440분봉 신호들 + elif signal == 'fall_6p_1d': + base_amount = 400000 if current_price > 5000 else 200000 + elif signal == 'movingaverage_1d': + base_amount = 15000 + elif signal == 'deviation40_1d': + base_amount = 40000 + elif signal == 'deviation240_1d': + base_amount = 10000 + elif signal == 'deviation1440_1d': + base_amount = 50000 + elif signal == 'Deviation720_1d': + base_amount = 30000 + # 기존 신호들 + elif signal == 'fall_6p': + base_amount = 400000 if current_price > 5000 else 200000 + elif signal == 'movingaverage': + base_amount = 15000 + elif signal == 'deviation40': + base_amount = 40000 + elif signal == 'deviation240': + base_amount = 10000 + elif signal == 'deviation1440': + base_amount = 50000 + elif signal == 'Deviation720': + base_amount = 30000 + else: + base_amount = 8000 + + elif self.coin == 'AVAX': + if signal == 'fall_6p': + base_amount = 500000 if current_price > 30000 else 300000 + elif signal == 'movingaverage': + base_amount = 20000 + elif signal == 'deviation40': + base_amount = 50000 + elif signal == 'deviation240': + base_amount = 15000 + elif signal == 'deviation1440': + base_amount = 60000 + elif signal == 'Deviation720': + base_amount = 40000 + else: + base_amount = 10000 + + elif self.coin == 'BONK': + if signal == 'fall_6p': + base_amount = 200000 if current_price > 0.03 else 150000 + elif signal == 'movingaverage': + base_amount = 10000 + elif signal == 'deviation40': + base_amount = 25000 + elif signal == 'deviation240': + base_amount = 7000 + elif signal == 'deviation1440': + base_amount = 25000 + elif signal == 'Deviation720': + base_amount = 20000 + else: + base_amount = 5000 + + elif self.coin == 'BTC': + if signal == 'fall_6p': + base_amount = 1000000 if current_price > 150000000 else 800000 + elif signal == 'movingaverage': + base_amount = 50000 + elif signal == 'deviation40': + base_amount = 100000 + elif signal == 'deviation240': + base_amount = 30000 + elif signal == 'deviation1440': + base_amount = 120000 + elif signal == 'Deviation720': + base_amount = 80000 + else: + base_amount = 20000 + + elif self.coin == 'ETC': + if signal == 'fall_6p': + base_amount = 300000 if current_price > 25000 else 200000 + elif signal == 'movingaverage': + base_amount = 30000 + elif signal == 'deviation40': + base_amount = 60000 + elif signal == 'deviation240': + base_amount = 20000 + elif signal == 'deviation1440': + base_amount = 70000 + elif signal == 'Deviation720': + base_amount = 50000 + else: + base_amount = 15000 + + elif self.coin == 'HBAR': + if signal == 'fall_6p': + base_amount = 150000 if current_price > 300 else 100000 + elif signal == 'movingaverage': + base_amount = 15000 + elif signal == 'deviation40': + base_amount = 30000 + elif signal == 'deviation240': + base_amount = 10000 + elif signal == 'deviation1440': + base_amount = 35000 + elif signal == 'Deviation720': + base_amount = 25000 + else: + base_amount = 8000 + + elif self.coin == 'LINK': + if signal == 'fall_6p': + base_amount = 400000 if current_price > 30000 else 300000 + elif signal == 'movingaverage': + base_amount = 40000 + elif signal == 'deviation40': + base_amount = 80000 + elif signal == 'deviation240': + base_amount = 25000 + elif signal == 'deviation1440': + base_amount = 90000 + elif signal == 'Deviation720': + base_amount = 60000 + else: + base_amount = 20000 + + elif self.coin == 'ONDO': + if signal == 'fall_6p': + base_amount = 180000 if current_price > 1300 else 120000 + elif signal == 'movingaverage': + base_amount = 18000 + elif signal == 'deviation40': + base_amount = 35000 + elif signal == 'deviation240': + base_amount = 12000 + elif signal == 'deviation1440': + base_amount = 40000 + elif signal == 'Deviation720': + base_amount = 28000 + else: + base_amount = 10000 + + elif self.coin == 'PENGU': + if signal == 'fall_6p': + base_amount = 120000 if current_price > 40 else 80000 + elif signal == 'movingaverage': + base_amount = 12000 + elif signal == 'deviation40': + base_amount = 25000 + elif signal == 'deviation240': + base_amount = 8000 + elif signal == 'deviation1440': + base_amount = 28000 + elif signal == 'Deviation720': + base_amount = 20000 + else: + base_amount = 6000 + + elif self.coin == 'SEI': + if signal == 'fall_6p': + base_amount = 160000 if current_price > 400 else 120000 + elif signal == 'movingaverage': + base_amount = 16000 + elif signal == 'deviation40': + base_amount = 32000 + elif signal == 'deviation240': + base_amount = 11000 + elif signal == 'deviation1440': + base_amount = 36000 + elif signal == 'Deviation720': + base_amount = 25000 + else: + base_amount = 8000 + + elif self.coin == 'SOL': + if signal == 'fall_6p': + base_amount = 500000 if current_price > 300000 else 400000 + elif signal == 'movingaverage': + base_amount = 50000 + elif signal == 'deviation40': + base_amount = 100000 + elif signal == 'deviation240': + base_amount = 30000 + elif signal == 'deviation1440': + base_amount = 120000 + elif signal == 'Deviation720': + base_amount = 80000 + else: + base_amount = 20000 + + elif self.coin == 'SUI': + if signal == 'fall_6p': + base_amount = 220000 if current_price > 4800 else 180000 + elif signal == 'movingaverage': + base_amount = 22000 + elif signal == 'deviation40': + base_amount = 45000 + elif signal == 'deviation240': + base_amount = 15000 + elif signal == 'deviation1440': + base_amount = 50000 + elif signal == 'Deviation720': + base_amount = 35000 + else: + base_amount = 12000 + + elif self.coin == 'TRX': + if signal == 'fall_6p': + base_amount = 140000 if current_price > 450 else 100000 + elif signal == 'movingaverage': + base_amount = 14000 + elif signal == 'deviation40': + base_amount = 28000 + elif signal == 'deviation240': + base_amount = 10000 + elif signal == 'deviation1440': + base_amount = 32000 + elif signal == 'Deviation720': + base_amount = 22000 + else: + base_amount = 7000 + + elif self.coin == 'VIRTUAL': + if signal == 'fall_6p': + base_amount = 190000 if current_price > 1600 else 130000 + elif signal == 'movingaverage': + base_amount = 19000 + elif signal == 'deviation40': + base_amount = 38000 + elif signal == 'deviation240': + base_amount = 13000 + elif signal == 'deviation1440': + base_amount = 42000 + elif signal == 'Deviation720': + base_amount = 30000 + else: + base_amount = 10000 + + elif self.coin == 'WLD': + if signal == 'fall_6p': + base_amount = 200000 if current_price > 1800 else 150000 + elif signal == 'movingaverage': + base_amount = 20000 + elif signal == 'deviation40': + base_amount = 40000 + elif signal == 'deviation240': + base_amount = 14000 + elif signal == 'deviation1440': + base_amount = 45000 + elif signal == 'Deviation720': + base_amount = 32000 + else: + base_amount = 10000 + + elif self.coin == 'XLM': + if signal == 'fall_6p': + base_amount = 150000 if current_price > 500 else 100000 + elif signal == 'movingaverage': + base_amount = 15000 + elif signal == 'deviation40': + base_amount = 30000 + elif signal == 'deviation240': + base_amount = 10000 + elif signal == 'deviation1440': + base_amount = 35000 + elif signal == 'Deviation720': + base_amount = 25000 + else: + base_amount = 8000 + else: + # 기본값 + base_amount = 10000 + + # 5주봉이 가장 낮을 때 매수 금액 2배 (시간봉별 신호 포함) + if check_5_week_lowest and signal in ['movingaverage_3m', 'movingaverage_1d', 'deviation40_3m', 'deviation40_1d', + 'deviation240_3m', 'deviation240_1d', 'deviation1440_3m', 'deviation1440_1d', + 'movingaverage', 'deviation40', 'deviation240', 'deviation1440']: + base_amount *= 2 + + return base_amount + + def get_sell_signals(self) -> list: + """매도 신호 목록을 반환합니다.""" + return ['deviation1440_3m', 'absolute_bottom_3m', 'rsi_oversold_3m', + 'deviation1440_1d', 'absolute_bottom_1d', 'macd_golden_1d'] + + def get_sell_amount_ratio(self, signal: str) -> float: + """매도 시 판매할 비율을 반환합니다.""" + # 3분봉 신호들 (빠른 회전 - 높은 매도 비율) + if signal in ['deviation1440_3m', 'absolute_bottom_3m', 'rsi_oversold_3m']: + return 0.8 # 80% 매도 + # 1440분봉 신호들 (여유 있는 수익 실현 - 낮은 매도 비율) + elif signal in ['deviation1440_1d', 'absolute_bottom_1d', 'macd_golden_1d']: + return 0.6 # 60% 매도 + else: + return 0.5 # 기본 50% 매도 + + def check_coin_specific_signals(self, data: pd.DataFrame, i: int) -> tuple: + """코인별 전용 매수 신호를 확인합니다.""" + signal = '' + point = 0 + + # Deviation720_strong과 Deviation720_very_strong 신호는 제거됨 + # 기본 신호만 사용: movingaverage, deviation40, Deviation720, deviation1440, fall_6p + + return signal, point + +# ======================================== +# 통합 시뮬레이션 클래스 +# ======================================== + +class CoinSimulation: + """통합 코인 시뮬레이션 클래스""" + + def __init__(self, coin: str) -> None: + self.coin = coin + self.strategy = CoinStrategy(coin) + # 모니터 클래스 동적 임포트 + self._import_monitor() + + def _import_monitor(self): + """코인별 모니터 클래스를 동적으로 임포트합니다.""" + try: + if self.coin == 'XRP': + from coin_xrp import XRPMonitor + self.monitor = XRPMonitor() + elif self.coin == 'ADA': + from coin_ada import ADAMonitor + self.monitor = ADAMonitor() + elif self.coin == 'APT': + from coin_apt import APTMonitor + self.monitor = APTMonitor() + elif self.coin == 'AVAX': + from coin_avax import AVAXMonitor + self.monitor = AVAXMonitor() + elif self.coin == 'BONK': + from coin_bonk import BONKMonitor + self.monitor = BONKMonitor() + elif self.coin == 'BTC': + from coin_btc import BTCMonitor + self.monitor = BTCMonitor() + elif self.coin == 'ETC': + from coin_etc import ETCMonitor + self.monitor = ETCMonitor() + elif self.coin == 'HBAR': + from coin_hbar import HBARMonitor + self.monitor = HBARMonitor() + elif self.coin == 'LINK': + from coin_link import LINKMonitor + self.monitor = LINKMonitor() + elif self.coin == 'ONDO': + from coin_ondo import ONDOMonitor + self.monitor = ONDOMonitor() + elif self.coin == 'PENGU': + from coin_pengu import PENGUMonitor + self.monitor = PENGUMonitor() + elif self.coin == 'SEI': + from coin_sei import SEIMonitor + self.monitor = SEIMonitor() + elif self.coin == 'SOL': + from coin_sol import SOLMonitor + self.monitor = SOLMonitor() + elif self.coin == 'SUI': + from coin_sui import SUIMonitor + self.monitor = SUIMonitor() + elif self.coin == 'TRX': + from coin_trx import TRXMonitor + self.monitor = TRXMonitor() + elif self.coin == 'VIRTUAL': + from coin_virtual import VIRTUALMonitor + self.monitor = VIRTUALMonitor() + elif self.coin == 'WLD': + from coin_wld import WLDMonitor + self.monitor = WLDMonitor() + elif self.coin == 'XLM': + from coin_xlm import XLMMonitor + self.monitor = XLMMonitor() + else: + raise ValueError(f"지원하지 않는 코인: {self.coin}") + except ImportError as e: + raise ImportError(f"코인 모니터 클래스를 찾을 수 없습니다: {e}") + + def render_plotly(self, symbol: str, interval_minutes: int, data: pd.DataFrame, inverseData: pd.DataFrame) -> None: + """코인별 Plotly 차트 렌더링""" + fig = subplots.make_subplots( + rows=3, cols=1, + subplot_titles=(f"{self.coin} 캔들차트", "이격도/거래량", "장기 이격도"), + 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=f'{self.coin} 캔들'), 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) + + # 매수 포인트 (XRP 최적화 신호) + buy_signals = ['deviation1440_3m', 'absolute_bottom_3m', 'rsi_oversold_3m', + 'deviation1440_1d', 'absolute_bottom_1d', 'macd_golden_1d', + 'movingaverage_3m', 'movingaverage_1d', 'deviation40_3m', 'deviation40_1d', + 'Deviation720_3m', 'Deviation720_1d', 'fall_5p_3m', 'fall_6p_1d', + 'movingaverage', 'deviation40', 'Deviation720', 'deviation1440', 'fall_6p'] + for sig, color in [('deviation1440_3m','purple'),('absolute_bottom_3m','cyan'),('rsi_oversold_3m','magenta'), + ('deviation1440_1d','darkviolet'),('absolute_bottom_1d','lime'),('macd_golden_1d','gold'), + ('movingaverage_3m','red'),('movingaverage_1d','darkred'),('deviation40_3m','orange'),('deviation40_1d','darkorange'), + ('Deviation720_3m','blue'),('Deviation720_1d','darkblue'),('fall_5p_3m','black'),('fall_6p_1d','gray'), + ('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) + + # 매도 포인트 + sell_signals = self.strategy.get_sell_signals() + inv_sell_pts = inverseData[(inverseData['point']==1) & (inverseData['signal'].isin(sell_signals))] + 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=f'{self.coin} 매도', + 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),('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) + + # 코인별 기준선 + if self.coin == 'XRP': + for h, color in [(96,'red'),(97,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'ADA': + for h, color in [(98,'red'),(99,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'APT': + for h, color in [(110,'red'),(115,'green'),(120,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'AVAX': + for h, color in [(95,'red'),(97,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'BONK': + for h, color in [(92,'red'),(95,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'BTC': + for h, color in [(105,'red'),(108,'green'),(110,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'ETC': + for h, color in [(94,'red'),(96,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'HBAR': + for h, color in [(98,'red'),(100,'green'),(102,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'LINK': + for h, color in [(95,'red'),(97,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'ONDO': + for h, color in [(96,'red'),(98,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'PENGU': + for h, color in [(91,'red'),(93,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'SEI': + for h, color in [(97,'red'),(99,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'SOL': + for h, color in [(104,'red'),(106,'green'),(110,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'SUI': + for h, color in [(99,'red'),(101,'green'),(102,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'TRX': + for h, color in [(96,'red'),(98,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'VIRTUAL': + for h, color in [(93,'red'),(95,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'WLD': + for h, color in [(94,'red'),(96,'green'),(100,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + elif self.coin == 'XLM': + for h, color in [(98,'red'),(100,'green'),(102,'black')]: + fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1) + + fig.update_layout( + height=1000, + margin=dict(t=180, l=40, r=240, b=40), + title=dict( + text=f"{self.coin} ({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), + dragmode='zoom' + ) + 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 fetch_coin_price_history(self, symbol: str, interval_minutes: int) -> pd.DataFrame: + """코인 가격 히스토리를 가져옵니다.""" + return self.monitor.get_coin_more_data(symbol, interval_minutes, bong_count=BONG_COUNT) + + def run_coin_simulation(self, symbol: str, interval_minutes: int): + """코인 시뮬레이션 실행""" + data = self.fetch_coin_price_history(symbol, interval_minutes) + + # 인버스 데이터 처리 (시간봉별 신호 포함) + inverseData = self.monitor.inverse_data(data) + inverseData = self.monitor.annotate_signals(symbol, interval_minutes, inverseData, simulation=True) + + # 일반 데이터 처리 (시간봉별 신호 포함) + data = self.monitor.calculate_technical_indicators(data) + data = self.monitor.annotate_signals(symbol, interval_minutes, data, simulation=True) + + # 코인 전용 신호 추가 + for i in range(1, len(data)): + coin_signal, coin_point = self.strategy.check_coin_specific_signals(data, i) + if coin_point == 1: + data.at[data.index[i], 'signal'] = coin_signal + data.at[data.index[i], 'point'] = coin_point + + print(f"{self.coin} 데이터 기간: {data.index[0]} ~ {data.index[-1]}") + print(f"총 데이터 수: {len(data)}") + + # 매수 신호 분석 + alerts = [] + total_buy_amount = 0 + for i in range(len(data)): + if data['point'].iloc[i] == 1: + signal = data['signal'].iloc[i] + price = data['Close'].iloc[i] + buy_amount = self.strategy.get_buy_amount(signal, price) + total_buy_amount += buy_amount + alerts.append((data.index[i], price, signal, buy_amount)) + + print(f"\n총 매수 신호 수: {len(alerts)}") + print(f"총 매수 금액: {total_buy_amount:,.0f}원") + + # 신호별 분석 + signal_counts = {} + signal_amounts = {} + for _, _, signal, amount in alerts: + signal_counts[signal] = signal_counts.get(signal, 0) + 1 + signal_amounts[signal] = signal_amounts.get(signal, 0) + amount + + for signal in signal_counts: + print(f" - {signal} 신호: {signal_counts[signal]}회, 총 {signal_amounts[signal]:,.0f}원") + + # 매도 신호 분석 + sell_signals = self.strategy.get_sell_signals() + sell_alerts = [] + for i in range(len(inverseData)): + if inverseData['point'].iloc[i] == 1 and inverseData['signal'].iloc[i] in sell_signals: + signal = inverseData['signal'].iloc[i] + price = inverseData['Close'].iloc[i] + sell_ratio = self.strategy.get_sell_amount_ratio(signal) + sell_alerts.append((inverseData.index[i], price, signal, sell_ratio)) + + print(f"\n총 매도 신호 수: {len(sell_alerts)}") + for date, price, signal, ratio in sell_alerts: + print(f" - {date}: {price:.4f} ({signal}) - 매도비율: {ratio*100:.0f}%") + + # Plotly 기반 시각화 + if SHOW_GRAPHS: + self.render_plotly(symbol, interval_minutes, data, inverseData) + return alerts, sell_alerts + +# ======================================== +# 메인 실행 부분 +# ======================================== + +if __name__ == "__main__": + print(f"\n=== {COIN} 시뮬레이션 시작 ===") + print(f"코인: {COIN}") + print(f"분봉: {INTERVAL}분") + print(f"봉 개수: {BONG_COUNT}개") + print(f"그래프 표시: {'예' if SHOW_GRAPHS else '아니오'}") + print("=" * 50) + + try: + sim = CoinSimulation(COIN) + buy_alerts, sell_alerts = sim.run_coin_simulation(COIN, INTERVAL) + + print(f"\n=== {COIN} 시뮬레이션 완료 ===") + print(f"매수 신호: {len(buy_alerts)}회") + print(f"매도 신호: {len(sell_alerts)}회") + + except Exception as e: + print(f"Error analyzing {COIN}: {str(e)}") + print("\n지원되는 코인 목록:") + print("XRP, ADA, APT, AVAX, BONK, BTC, ETC, HBAR, LINK, ONDO, PENGU, SEI, SOL, SUI, TRX, VIRTUAL, WLD, XLM")