import pandas as pd from deepcoin.api.bithumb import HTS from dateutil.relativedelta import relativedelta from datetime import datetime import sqlite3 import time try: import telegram except ImportError: telegram = None # type: ignore import requests import json import asyncio from multiprocessing import Pool import numpy as np import os from config import * from deepcoin.data.candle_intervals import ( candle_api_segment, interval_display_label, pagination_step, ) class Monitor(HTS): """WLD 코인 데이터·지표·시장 상태 출력.""" last_signal = None cooldown_file = None def __init__(self, cooldown_file: str | None = None) -> None: HTS.__init__(self) # 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장) self.last_signal: dict[str, str] = {} if cooldown_file is not None: self.cooldown_file = cooldown_file self.buy_cooldown = self._load_buy_cooldown() else: self.cooldown_file = None self.buy_cooldown = {} # ------------- Persistence ------------- def _load_buy_cooldown(self) -> dict: """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: if telegram is None: print(f"[telegram skip] {text}") return coin_client = telegram.Bot(token=COIN_TELEGRAM_BOT_TOKEN) asyncio.run(coin_client.send_message(chat_id=COIN_TELEGRAM_CHAT_ID, text=text)) def 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 % MONITOR_TELEGRAM_BATCH_SIZE == 0: pool = Pool(MONITOR_POOL_WORKERS) pool.map(self._send_coin_msg, [payload]) payload = '' if len(message_list) % MONITOR_TELEGRAM_BATCH_SIZE != 0: pool = Pool(MONITOR_POOL_WORKERS) pool.map(self._send_coin_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=MONITOR_NORM_WINDOW).min() max_val = data[column].rolling(window=MONITOR_NORM_WINDOW).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) for w in MONITOR_MA_WINDOWS: inv[f"MA{w}"] = inv["Close"].rolling(window=w).mean() inv[f"Deviation{w}"] = (inv["Close"] / inv[f"MA{w}"]) * 100 if len(MONITOR_MA_WINDOWS) >= 2: w_fast, w_slow = MONITOR_MA_WINDOWS[0], MONITOR_MA_WINDOWS[1] inv["golden_cross"] = (inv[f"MA{w_fast}"] > inv[f"MA{w_slow}"]) & ( inv[f"MA{w_fast}"].shift(1) <= inv[f"MA{w_slow}"].shift(1) ) inv["MA"] = inv["Close"].rolling(window=BB_PERIOD).mean() inv["STD"] = inv["Close"].rolling(window=BB_PERIOD).std() inv["Upper"] = inv["MA"] + (BB_STD * inv["STD"]) inv["Lower"] = inv["MA"] - (BB_STD * inv["STD"]) return inv def calculate_technical_indicators(self, data: pd.DataFrame) -> pd.DataFrame: data = self.normalize_data(data) for w in MONITOR_MA_WINDOWS: data[f"MA{w}"] = data["Close"].rolling(window=w).mean() data[f"Deviation{w}"] = (data["Close"] / data[f"MA{w}"]) * 100 if len(MONITOR_MA_WINDOWS) >= 2: w_fast, w_slow = MONITOR_MA_WINDOWS[0], MONITOR_MA_WINDOWS[1] data["golden_cross"] = (data[f"MA{w_fast}"] > data[f"MA{w_slow}"]) & ( data[f"MA{w_fast}"].shift(1) <= data[f"MA{w_slow}"].shift(1) ) data["MA"] = data["Close"].rolling(window=BB_PERIOD).mean() data["STD"] = data["Close"].rolling(window=BB_PERIOD).std() data["Upper"] = data["MA"] + (BB_STD * data["STD"]) data["Lower"] = data["MA"] - (BB_STD * data["STD"]) from deepcoin.common.indicators import add_macd, add_stochastic data = add_macd(data) data = add_stochastic(data) return data def process_wld_market_status(self, symbol: str) -> None: """ WLD: 전 봉 BB·일목 위치·추세만 출력 (자동 매매 없음). """ from deepcoin.common.candle_features import describe_latest_position from deepcoin.common.indicators import get_trend from deepcoin.data.mtf_bb import load_frames_from_db try: frames = load_frames_from_db(self, symbol) if not frames: print(f"Data for {symbol}: 로드된 봉 없음.") return df_1d = frames.get(TREND_INTERVAL_1D) df_1h = frames.get(TREND_INTERVAL_1H) if df_1d is None or df_1d.empty: df_1d = frames.get(ENTRY_INTERVAL) if df_1h is None or df_1h.empty: df_1h = frames.get(ENTRY_INTERVAL) trend = get_trend(df_1d, df_1h) print(f"{symbol} 추세(참고): {trend}") print("--- 봉별 BB·일목 위치 ---") for iv in sorted(frames.keys()): pos = describe_latest_position(frames[iv], iv) macd_s = "" if pos.get("macd_hist") is not None: macd_s = f" | MACD {pos.get('macd_state', '-')} h={pos['macd_hist']}" stoch_s = "" if pos.get("stoch_k") is not None: stoch_s = ( f" | Stoch K={pos['stoch_k']} D={pos.get('stoch_d')} " f"{pos.get('stoch_zone', '')}" ) disp_s = "" if pos.get("disparity"): parts = [f"{p}={v:.1f}" for p, v in sorted(pos["disparity"].items())] disp_s = " | D.I. " + " ".join(parts) print( f" {pos['label']:>6} | BB {pos['bb_zone']} {pos['bb_state']:>16} | " f"일목 {pos['ichi_position']} TK={pos['ichi_tk']}" f"{macd_s}{stoch_s}{disp_s}" ) except Exception as e: print(f"Error processing {symbol}: {str(e)}") def process_symbol( self, symbol: str, interval: int | None = None, balances: dict | None = None, use_inverse: bool = False, ) -> None: """하위 호환: 시장 상태 출력으로 위임.""" self.process_wld_market_status(symbol) def load_balances_dict(self) -> dict: """getBalances() 결과를 currency 키 dict로 변환.""" tmps = self.getBalances() balances = {} for tmp in tmps: balances[tmp["currency"]] = { "balance": float(tmp["balance"]), "avg_buy_price": float(tmp["avg_buy_price"]), } return balances # ------------- Formatting ------------- def format_message( self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float ) -> str: message = f"[매수] {symbol_name} ({symbol}) [{signal}]: " 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 # ------------- Data fetch ------------- def get_coin_data( self, symbol: str, interval: int = MONITOR_DEFAULT_INTERVAL, to: str | None = None, retries: int = MONITOR_API_RETRIES, ) -> pd.DataFrame | None: base = BITHUMB_API_URL.rstrip("/") count = BITHUMB_API_CANDLE_COUNT segment = candle_api_segment(interval) for attempt in range(retries): try: path = f"/v1/candles/{segment}" if to is None: url = f"{base}{path}?market=KRW-{symbol}&count={count}" else: url = f"{base}{path}?market=KRW-{symbol}&count={count}&to={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(MONITOR_SLEEP_AFTER_REQUEST_SEC) except Exception as e: print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}") if attempt < retries - 1: time.sleep(MONITOR_SLEEP_RATE_LIMIT_SEC) continue return None def get_coin_more_data( self, symbol: str, interval: int, bong_count: int = MONITOR_API_BONG_COUNT, verbose: bool = False, ) -> pd.DataFrame: """ 빗썸 API를 반복 호출해 bong_count개까지 과거 봉을 수집합니다. Args: verbose: True면 수집 진행 상황을 출력합니다. """ to = datetime.now() data: pd.DataFrame | None = None step = 0 while data is None or len(data) < bong_count: step += 1 if data is None: chunk = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S")) data = chunk else: previous_count = len(data) df = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S")) if df is not None and not df.empty: data = pd.concat([data, df], ignore_index=True) if df is None or df.empty or previous_count == len(data): if verbose: print(f" API 추가 데이터 없음 (수집 {len(data)}봉)") break if verbose and (step == 1 or step % 5 == 0 or len(data) >= bong_count): label = interval_display_label(interval) print(f" [{label}] 요청 {step}회 — 누적 {len(data)}/{bong_count}봉") time.sleep(MONITOR_SLEEP_BETWEEN_CHUNKS_SEC) to = to - pagination_step(interval, MONITOR_API_CHUNK_BARS) if data is None or data.empty: return pd.DataFrame() data = data.set_index("datetime") data = data.sort_index() data = data.drop_duplicates(keep="first") data["datetime"] = data.index return data @staticmethod def db_row_limit_for_interval(interval: int, lookback_days: int) -> int: """ lookback_days 구간 + 지표 워밍업을 담을 SQLite LIMIT(봉 개수)을 계산합니다. Args: interval: 봉 간격(분). 1440이면 일봉. lookback_days: 과거 조회 일수. Returns: LIMIT에 넣을 최대 행 수. """ from config import MONTH_INTERVAL_MIN, WEEK_INTERVAL_MIN if interval == WEEK_INTERVAL_MIN: return max(lookback_days // 7 + 10, DB_ROW_MIN_DAILY_BARS) if interval == MONTH_INTERVAL_MIN: return max(lookback_days // 30 + 6, DB_ROW_MIN_DAILY_BARS) if interval >= DAILY_INTERVAL_MIN: return max( lookback_days + DB_ROW_DAILY_PADDING_DAYS, DB_ROW_MIN_DAILY_BARS, ) bars_per_day = max((24 * 60) // max(interval, 1), 1) return bars_per_day * lookback_days + DB_ROW_WARMUP_BARS def persist_api_candles_to_db( self, symbol: str, interval: int, data: pd.DataFrame, db_path: str = DB_PATH, ) -> tuple[int, int]: """ API로 받은 봉을 coins.db에 증분 INSERT합니다 (01_download.append_data와 동일). 05·06·live_eval이 load_frames_from_db 할 때마다 최신 봉이 쌓입니다. Returns: (추가 행 수, 스킵 행 수) """ if not MONITOR_PERSIST_CANDLES or data is None or data.empty: return 0, 0 from deepcoin.data.downloader import ( append_data, get_last_timestamp, months_for_interval, prune_before_cutoff, ) if not isinstance(data.index, pd.DatetimeIndex): data = data.copy() data.index = pd.to_datetime(data.index) data = data.sort_index() last_ts = get_last_timestamp(symbol, interval, db_path=db_path) inserted, skipped = append_data( symbol, interval, data, last_ts=last_ts, db_path=db_path ) if inserted > 0: months = months_for_interval(interval, DOWNLOAD_MONTHS) prune_before_cutoff(symbol, interval, months, db_path=db_path) return inserted, skipped def read_candles_from_db( self, symbol: str, interval: int, db_path: str = DB_PATH, max_rows: int = DB_READ_LIMIT_DEFAULT, ) -> pd.DataFrame: """ coins.db에서 저장된 봉을 읽습니다. scripts/01_download.py 또는 persist_api_candles_to_db로 적재된 데이터. """ conn = sqlite3.connect(db_path) cursor = conn.cursor() table_name = f"{symbol}_{interval}" cursor.execute( f"CREATE TABLE IF NOT EXISTS {table_name} " "(CODE text, NAME text, ymdhms datetime, ymd text, hms text, " "Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)" ) cursor.execute( f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)" ) cursor.execute( f"SELECT Open, Close, High, Low, Volume, ymdhms AS datetime " f"FROM (SELECT Open, Close, High, Low, Volume, ymdhms " f"FROM {table_name} ORDER BY ymdhms DESC LIMIT {int(max_rows)}) " f"ORDER BY datetime" ) result = cursor.fetchall() conn.commit() cursor.close() conn.close() if not result: return pd.DataFrame( columns=["Open", "Close", "High", "Low", "Volume", "datetime"] ) df = pd.DataFrame( result, columns=["Open", "Close", "High", "Low", "Volume", "datetime"] ) df = df.set_index("datetime") df = df.sort_index() df["datetime"] = df.index return df def get_coin_saved_data( self, symbol: str, interval: int, data: pd.DataFrame, db_path: str = DB_PATH, max_rows: int = DB_READ_LIMIT_DEFAULT, ) -> pd.DataFrame: """하위 호환: API 봉 저장 후 DB에서 읽기.""" self.persist_api_candles_to_db(symbol, interval, data, db_path=db_path) return self.read_candles_from_db( symbol, interval, db_path=db_path, max_rows=max_rows ) def get_coin_some_data( self, symbol: str, interval: int, db_max_rows: int | None = None ) -> pd.DataFrame: """ WLD 시세: API 최신 봉 + coins.db 과거 봉을 합칩니다. MONITOR_PERSIST_CANDLES=1 이면 API 청크를 즉시 coins.db에 INSERT합니다. 1분봉은 다운로드·병합하지 않습니다. """ data = self.get_coin_data(symbol, interval) if data is None or data.empty: return pd.DataFrame() self.persist_api_candles_to_db(symbol, interval, data) row_limit = DB_READ_LIMIT_DEFAULT if db_max_rows is None else int(db_max_rows) saved_data = self.read_candles_from_db( symbol, interval, max_rows=row_limit ) parts = [data] if saved_data is not None and not saved_data.empty: parts.append(saved_data) merged = pd.concat(parts, ignore_index=True) merged["datetime"] = pd.to_datetime(merged["datetime"], format="%Y-%m-%d %H:%M:%S") merged = merged.set_index("datetime") merged = merged.sort_index() merged = merged.drop_duplicates(keep="first") merged["datetime"] = merged.index return merged