import sys import os sys.stdout.reconfigure(line_buffering=True, encoding="utf-8") sys.stderr.reconfigure(line_buffering=True, encoding="utf-8") os.environ["PYTHONUNBUFFERED"] = "1" os.environ["PYTHONIOENCODING"] = "utf-8" import time import requests from dotenv import load_dotenv load_dotenv() import pandas as pd import numpy as np from datetime import datetime, timezone, timedelta import threading import streamlit as st import plotly.graph_objects as go from plotly.subplots import make_subplots import ta import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # ────────────────────────────────────────────── # 페이지 설정 (반드시 최상단) # ────────────────────────────────────────────── st.set_page_config( page_title="BTC/ETH Futures Dashboard", layout="wide", initial_sidebar_state="collapsed" ) # 라이트모드 강제 CSS st.markdown(""" """, unsafe_allow_html=True) # ────────────────────────────────────────────── # 설정 # ────────────────────────────────────────────── TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "") TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") ALERT_COOLDOWN = 600 BASE = "https://fapi.binance.com" KST = timedelta(hours=9) STOP_LOSS_PCT = 0.0075 # 10x 레버리지 기준 ROI -7.5% (= 가격 0.75% 역방향) LONG_SIGNALS = {"strong_long_signal", "long_signal", "vol_long_signal", "reversal_long_signal"} SHORT_SIGNALS = {"strong_short_signal", "short_signal", "vol_short_signal", "reversal_short_signal"} TF_LABEL_MAP = { "1m": "1분봉", "3m": "3분봉", "5m": "5분봉", "15m": "15분봉", "30m": "30분봉", "1h": "1시간봉", "4h": "4시간봉", "12h": "12시간봉", "1d": "1일봉", "3d": "3일봉", "1M": "1개월봉", } # Streamlit 은 매 rerun 마다 메인 스크립트를 새 namespace 에서 재실행해 # globals() 가드도 우회된다. 알림 mutable 상태는 별도 모듈에 두어 sys.modules # 캐싱으로 process lifetime 보존되도록 한다 (alert_state.py 참조). import alert_state # ────────────────────────────────────────────── # 텔레그램 # ────────────────────────────────────────────── def send_telegram(message: str): try: url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage" requests.post(url, data={"chat_id": TELEGRAM_CHAT_ID, "text": message}, timeout=10) except Exception as e: print(f"[텔레그램 오류] {e}") SIG_DEFS = [ ("strong_long_signal", "strong_long", "🟢 강한 롱", "long"), ("strong_short_signal", "strong_short", "🔴 강한 숏", "short"), ("long_signal", "long", "🔼 일반 롱", "long"), ("short_signal", "short", "🔽 일반 숏", "short"), ("vol_long_signal", "vol_long", "🔼 볼륨 롱", "long"), ("vol_short_signal", "vol_short", "🔽 볼륨 숏", "short"), ("reversal_long_signal", "rev_long", "🔄 롱 전환", "long"), ("reversal_short_signal","rev_short", "🔄 숏 전환", "short"), ("short_caution_signal", "short_caution","⚠️ 숏 주의", "caution"), ] def check_and_alert(df, symbol, interval): now = time.time() if df is None or df.empty: return forming_ct = df.iloc[-1]["open_time"] # Phase 1 — pending_groups 검증. forming candle 이라도 매 polling 마다 신호 # 상태 확인. 사라지면 즉시 [취소 알림] (캔들 마감까지 기다리지 않음). new_pending = [] for p in alert_state.pending_groups: if p["interval"] != interval: new_pending.append(p) continue ct = p["candle_time"] row_match = df[df["open_time"] == ct] if row_match.empty: continue # 캔들이 df 윈도우 밖 — 검증 포기, drop row = row_match.iloc[0] any_still_true = any(bool(row.get(s, False)) for s in p["sig_cols"]) if any_still_true: if ct == forming_ct: # forming 중 + 신호 살아있음 → 계속 감시 new_pending.append(p) # closed + 신호 살아있음 → 확정, pending 에서 제거 else: # 신호 사라짐 (forming/closed 무관) → 즉시 취소 알림 send_telegram(f"[취소 알림]\n{p['msg']}") le = alert_state.long_entry.get(interval) se = alert_state.short_entry.get(interval) if p["direction"] == "long" and le is not None and le.get("open_time") == ct: alert_state.long_entry[interval] = None elif p["direction"] == "short" and se is not None and se.get("open_time") == ct: alert_state.short_entry[interval] = None alert_state.pending_groups = new_pending # Phase 2 — 신호 검사 + 알림 발사. # 5분봉: forming candle 포함 (반응성 우선, 깜빡임은 a9ad52f 의 즉시 취소로 대응) # 15m / 30m / 1h: forming candle 제외 (forming 동안 신호 깜빡으로 인한 가짜 # 알림 자체를 차단. 알림은 캔들 마감 후 ~30초 이내 발사 — 신뢰성 우선) if interval in ("15m", "30m", "1h"): recent = df.iloc[:-1].tail(3) else: recent = df.tail(3) eligible = [] for sig, key, sub_label, direction in SIG_DEFS: if sig not in recent.columns: continue triggered = recent[recent[sig].fillna(False)] if triggered.empty: continue candle_time = triggered.iloc[-1]["open_time"] state_key = (interval, key) if candle_time == alert_state.last_fired_candle.get(state_key): continue if now - alert_state.last_alert.get(state_key, 0) <= ALERT_COOLDOWN: continue eligible.append({ "sig": sig, "key": key, "sub_label": sub_label, "direction": direction, "candle_time": candle_time, "row": triggered.iloc[-1], }) if not eligible: groups = {} else: groups = {"long": [], "short": [], "caution": []} for e in eligible: groups[e["direction"]].append(e) tf_label = TF_LABEL_MAP.get(interval, interval) def _send_group(group): if not group: return candle_time = group[0]["candle_time"] candle_time_str = pd.Timestamp(candle_time).strftime("%Y-%m-%d %H:%M") sub_labels = " + ".join(e["sub_label"] for e in group) direction = group[0]["direction"] if direction == "caution": msg = ( f"{sub_labels} 신호\n{symbol} {tf_label}\n" f"시간: {candle_time_str}" ) send_telegram(msg) else: entry_price = float(group[0]["row"]["open"]) if direction == "long": stop_price = entry_price * (1 - STOP_LOSS_PCT) else: stop_price = entry_price * (1 + STOP_LOSS_PCT) msg = ( f"{sub_labels} 진입 신호\n{symbol} {tf_label}\n" f"시간: {candle_time_str}\n" f"진입가: {entry_price:,.2f}\n" f"손절가: {stop_price:,.2f}" ) entry_record = {"price": entry_price, "stop": stop_price, "open_time": candle_time, "entry_msg": msg} if direction == "long": alert_state.long_entry[interval] = entry_record else: alert_state.short_entry[interval] = entry_record send_telegram(msg) for e in group: alert_state.last_alert[(interval, e["key"])] = now alert_state.last_fired_candle[(interval, e["key"])] = e["candle_time"] if candle_time == forming_ct: alert_state.pending_groups.append({ "interval": interval, "direction": direction, "candle_time": candle_time, "msg": msg, "sig_cols": [e["sig"] for e in group], }) _send_group(groups.get("long", [])) _send_group(groups.get("short", [])) _send_group(groups.get("caution", [])) current_price = float(df.iloc[-1]["close"]) le = alert_state.long_entry.get(interval) se = alert_state.short_entry.get(interval) if le is not None and current_price <= le["stop"]: send_telegram( f"[손절가알림]\n{le['entry_msg']}\n" f"현재가: {current_price:,.2f}" ) alert_state.long_entry[interval] = None if se is not None and current_price >= se["stop"]: send_telegram( f"[손절가알림]\n{se['entry_msg']}\n" f"현재가: {current_price:,.2f}" ) alert_state.short_entry[interval] = None # ────────────────────────────────────────────── # 데이터 수집 # ────────────────────────────────────────────── def get_klines(symbol="BTCUSDT", interval="5m", limit=375): url = f"{BASE}/fapi/v1/klines" r = requests.get(url, params={"symbol": symbol, "interval": interval, "limit": limit}, timeout=10, verify=False) df = pd.DataFrame(r.json(), columns=[ "open_time","open","high","low","close","volume", "close_time","quote_vol","trades","taker_buy_vol","taker_sell_vol","ignore" ]) for c in ["open","high","low","close","volume","taker_buy_vol","taker_sell_vol"]: df[c] = df[c].astype(float) df["taker_sell_vol"] = df["volume"] - df["taker_buy_vol"] df["open_time"] = pd.to_datetime(df["open_time"], unit="ms") + KST return df def get_funding_rate(symbol="BTCUSDT", limit=100): url = f"{BASE}/fapi/v1/fundingRate" r = requests.get(url, params={"symbol": symbol, "limit": limit}, timeout=10, verify=False) df = pd.DataFrame(r.json()) df["fundingRate"] = df["fundingRate"].astype(float) * 100 df["fundingTime"] = pd.to_datetime(df["fundingTime"], unit="ms") + KST return df def get_open_interest_history(symbol="BTCUSDT", period="5m", limit=100): url = f"{BASE}/futures/data/openInterestHist" r = requests.get(url, params={"symbol": symbol, "period": period, "limit": limit}, timeout=10, verify=False) df = pd.DataFrame(r.json()) df["sumOpenInterest"] = df["sumOpenInterest"].astype(float) df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") + KST return df def get_long_short_ratio(symbol="BTCUSDT", period="5m", limit=500): url = f"{BASE}/futures/data/topLongShortPositionRatio" r = requests.get(url, params={"symbol": symbol, "period": period, "limit": limit}, timeout=10, verify=False) df = pd.DataFrame(r.json()) df["longShortRatio"] = df["longShortRatio"].astype(float) df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") + KST return df def get_taker_buy_sell_ratio(symbol="BTCUSDT", period="5m", limit=100): url = f"{BASE}/futures/data/takerlongshortRatio" r = requests.get(url, params={"symbol": symbol, "period": period, "limit": limit}, timeout=10, verify=False) df = pd.DataFrame(r.json()) df["buySellRatio"] = df["buySellRatio"].astype(float) df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") + KST return df # ────────────────────────────────────────────── # 지표 계산 # ────────────────────────────────────────────── def compute_indicators(df, interval="5m"): c = df["close"] df["MA7"] = c.rolling(7).mean() df["MA25"] = c.rolling(25).mean() df["MA99"] = c.rolling(99).mean() df["MA200"] = c.rolling(200).mean() df["BB_mid"] = c.rolling(20).mean() df["BB_std"] = c.rolling(20).std() df["BB_upper"] = df["BB_mid"] + 2 * df["BB_std"] df["BB_lower"] = df["BB_mid"] - 2 * df["BB_std"] df["RSI"] = ta.momentum.RSIIndicator(c, window=14).rsi() macd = ta.trend.MACD(c, window_slow=26, window_fast=12, window_sign=9) df["MACD"] = macd.macd() df["MACD_signal"] = macd.macd_signal() df["MACD_hist"] = macd.macd_diff() stoch = ta.momentum.StochRSIIndicator(c, window=14, smooth1=3, smooth2=3) df["StochRSI_k"] = stoch.stochrsi_k() * 100 df["StochRSI_d"] = stoch.stochrsi_d() * 100 df["ATR"] = ta.volatility.AverageTrueRange(df["high"], df["low"], df["close"], window=14).average_true_range() df = compute_signals(df, interval) return df def compute_signals(df, interval="5m"): df["bull_ma_2"] = ( (df["close"] > df["MA7"]) & (df["MA7"] > df["MA25"]) ) df["bear_ma_2"] = ( (df["close"] < df["MA7"]) & (df["MA7"] < df["MA25"]) ) df["bull_ma"] = ( (df["close"] > df["MA7"]) & (df["MA7"] > df["MA25"]) ) df["bear_ma"] = ( (df["close"] < df["MA7"]) & (df["MA7"] < df["MA25"]) ) # BB position 보존 (디버그 / 차트 hover 용도) — 신호 정의에는 미사용. bb_range = (df["BB_upper"] - df["BB_lower"]).replace(0, float("nan")) df["bb_pos"] = (df["close"] - df["BB_lower"]) / bb_range # 현재 캔들 자체의 방향이 신호 방향과 일치해야 발사. # 늦은 진입 (반등 중인 녹색 캔들에 short 등) 차단 + 현재 진행 중인 breakdown # (빨간 거대 캔들에 short) 은 통과 시킴. df["long_signal"] = df["bull_ma_2"] & (df["RSI"] < 60) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & (df["close"] > df["BB_mid"]) & (df["close"] > df["open"]) df["short_signal"] = df["bear_ma_2"] & (df["RSI"] > 35) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & (df["close"] < df["BB_mid"]) & (df["close"] < df["open"]) df["long_signal"] = df["long_signal"] & (df["long_signal"].rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0) df["short_signal"] = df["short_signal"] & (df["short_signal"].rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0) if "sumOpenInterest" in df.columns and df["sumOpenInterest"].notna().sum() > 5: oi_series = df["sumOpenInterest"].ffill() else: oi_series = df["close"] * df["volume"] df["oi_up"] = oi_series > oi_series.shift(1) df["oi_down"] = oi_series < oi_series.shift(1) df["oi_up_2"] = df["oi_up"] & df["oi_up"].shift(1).fillna(False) df["oi_down_2"] = df["oi_down"] & df["oi_down"].shift(1).fillna(False) # OI 활성도: 방향 무관, 의미 있는 변동만 통과 (0.1% 이상). 신규 진입과 # 청산 모두 캡처하기 위함. vol_short / vol_long 신호의 OI 필터로 사용. df["oi_active"] = oi_series.pct_change().abs() > 0.001 df["taker_buy_dom"] = df["taker_buy_vol"] > df["taker_sell_vol"] df["taker_sell_dom"] = df["taker_sell_vol"] > df["taker_buy_vol"] df["taker_buy_2"] = df["taker_buy_dom"] & df["taker_buy_dom"].shift(1).fillna(False) df["taker_sell_2"] = df["taker_sell_dom"] & df["taker_sell_dom"].shift(1).fillna(False) df["fr_long_favor"] = df["taker_buy_vol"].rolling(3).mean() > df["taker_sell_vol"].rolling(3).mean() df["fr_short_favor"] = df["taker_sell_vol"].rolling(3).mean() > df["taker_buy_vol"].rolling(3).mean() df["strong_long_signal"] = df["bull_ma_2"] & (df["RSI"] < 65) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & df["oi_up_2"] & df["taker_buy_2"] & df["fr_long_favor"] & (df["close"] > df["open"]) df["strong_short_signal"] = df["bear_ma_2"] & (df["RSI"] > 35) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & df["oi_down_2"] & df["taker_sell_2"] & df["fr_short_favor"] & (df["close"] < df["open"]) df["strong_long_signal"] = df["strong_long_signal"] & (df["strong_long_signal"].rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0) df["strong_short_signal"] = df["strong_short_signal"] & (df["strong_short_signal"].rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0) vol_avg = df["volume"].rolling(10).mean() spike = df["volume"] > vol_avg * 3 buy_spike = spike & (df["taker_buy_vol"] > df["taker_sell_vol"]) sell_spike = spike & (df["taker_sell_vol"] > df["taker_buy_vol"]) df["exhaustion_short"] = buy_spike.shift(1).fillna(False) df["exhaustion_long"] = sell_spike.shift(1).fillna(False) _vol_min_map = {"1m": 33, "3m": 100, "5m": 100, "15m": 300, "30m": 600, "1h": 1200, "2h": 2400, "4h": 4800, "12h": 14400, "1d": 28800, "3d": 86400, "1M": 864000} _vol_min = _vol_min_map.get(interval, 100) df["sell_net"] = df["taker_sell_vol"] - df["taker_buy_vol"] sell_net_avg = df["sell_net"].rolling(10).mean() sell_spike_strong = ( (df["sell_net"] > sell_net_avg * 2) & (df["sell_net"] > 0) & (df["taker_sell_vol"] > _vol_min) & df["oi_active"] ) cooldown_vol_short = sell_spike_strong.rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0 df["vol_short_signal"] = sell_spike_strong & cooldown_vol_short df["buy_net"] = df["taker_buy_vol"] - df["taker_sell_vol"] buy_net_avg = df["buy_net"].rolling(10).mean() buy_spike_strong = ( (df["buy_net"] > buy_net_avg * 2) & (df["buy_net"] > 0) & (df["taker_buy_vol"] > _vol_min) & df["oi_active"] ) cooldown_vol_long = buy_spike_strong.rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0 df["vol_long_signal"] = buy_spike_strong & cooldown_vol_long if "fundingRate" in df.columns and "sumOpenInterest" in df.columns: fr_extreme = df["fundingRate"] <= -0.007 raw_signal = df["oi_down_2"] & fr_extreme cooldown_mask = raw_signal.rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0 df["short_caution_signal"] = raw_signal & cooldown_mask else: df["short_caution_signal"] = False # 방향 전환 감지: 직전 5봉의 추세 방향과 현재 캔들 방향이 반대 + 강한 폭 + 거래량 동반. # - 추세 판단: close[t-1] vs close[t-5] (현재 캔들 제외, 직전까지의 흐름) # - 현재 캔들 강도: |close-open|/open >= 0.3% (작은 캔들 노이즈 차단) # - 거래량: 직전 5봉 평균의 1.3배 이상 (확신) prior_close = df["close"].shift(1) prior_close_5 = df["close"].shift(5) was_up = prior_close > prior_close_5 was_down = prior_close < prior_close_5 candle_body_pct = (df["close"] - df["open"]) / df["open"].replace(0, float("nan")) vol_avg5 = df["volume"].rolling(5).mean().shift(1) vol_strong = df["volume"] > vol_avg5 * 1.3 rev_short_raw = was_up & (candle_body_pct < -0.003) & vol_strong rev_long_raw = was_down & (candle_body_pct > 0.003) & vol_strong # 5봉 쿨다운 (연쇄 발화 방지) df["reversal_short_signal"] = rev_short_raw & (rev_short_raw.rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0) df["reversal_long_signal"] = rev_long_raw & (rev_long_raw.rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0) return df # ────────────────────────────────────────────── # 차트 빌드 # ────────────────────────────────────────────── COLORS = { "bg": "#ffffff", "grid": "#e0e3eb", "text": "#131722", "green": "#26a69a", "red": "#ef5350", "yellow":"#f5ce05", "blue": "#2962ff", "purple":"#9c27b0", "orange":"#ff9800", "MA7": "#f5ce05", "MA25": "#ef5350", "MA99": "#9c27b0", "MA200": "#2962ff", "BB": "rgba(41,98,255,0.1)", } def _to_floor_freq(period): return {"1m":"1min","3m":"3min","5m":"5min","15m":"15min","30m":"30min","1h":"1h","4h":"4h","12h":"12h","1d":"1D","3d":"3D","1M":"1ME"}.get(period, period) def build_chart(symbol, interval, candle_limit=200): df = get_klines(symbol, interval, candle_limit) oi_period = interval if interval in ["5m","15m","30m","1h","4h","12h","1d","3d","1M"] else "5m" try: oi = get_open_interest_history(symbol, oi_period, 200) if not oi.empty: oi_m = oi[["timestamp","sumOpenInterest"]].rename(columns={"timestamp":"open_time"}) df["open_time_r"] = df["open_time"].dt.floor(_to_floor_freq(oi_period)) oi_m["open_time"] = oi_m["open_time"].dt.floor(_to_floor_freq(oi_period)) df = df.merge(oi_m, left_on="open_time_r", right_on="open_time", how="left", suffixes=("","_oi")) df = df.drop(columns=["open_time_r","open_time_oi"], errors="ignore") df["sumOpenInterest"] = df["sumOpenInterest"].ffill() except: pass try: fr = get_funding_rate(symbol, 200) if not fr.empty: fr_m = fr[["fundingTime","fundingRate"]].rename(columns={"fundingTime":"open_time"}) fr_m["open_time"] = fr_m["open_time"].dt.floor(_to_floor_freq("1h")) df["open_time_r2"] = df["open_time"].dt.floor(_to_floor_freq("1h")) df = df.merge(fr_m, left_on="open_time_r2", right_on="open_time", how="left", suffixes=("","_fr")) df = df.drop(columns=["open_time_r2","open_time_fr"], errors="ignore") df["fundingRate"] = df["fundingRate"].ffill().fillna(0) except: pass try: ls = get_long_short_ratio(symbol, oi_period, 200) if not ls.empty: ls_m = ls[["timestamp","longShortRatio"]].rename(columns={"timestamp":"open_time"}) df["open_time_r3"] = df["open_time"].dt.floor(_to_floor_freq(oi_period)) ls_m["open_time"] = ls_m["open_time"].dt.floor(_to_floor_freq(oi_period)) df = df.merge(ls_m, left_on="open_time_r3", right_on="open_time", how="left", suffixes=("","_ls")) df = df.drop(columns=["open_time_r3","open_time_ls"], errors="ignore") df["longShortRatio"] = df["longShortRatio"].ffill() except: pass df = compute_indicators(df, interval) try: if interval != "1h": df_1h = get_klines(symbol, "1h", 150) df_1h["MA7"] = df_1h["close"].rolling(7).mean() df_1h["MA25"] = df_1h["close"].rolling(25).mean() df_1h["MA99"] = df_1h["close"].rolling(99).mean() df_1h["MA200"] = df_1h["close"].rolling(200).mean() df_1h["h1_bull_2"] = ( (df_1h["close"] > df_1h["MA7"]) & (df_1h["MA7"] > df_1h["MA25"]) ) df_1h["h1_bear_2"] = ( (df_1h["close"] < df_1h["MA7"]) & (df_1h["MA7"] < df_1h["MA25"]) ) df_1h["h1_bull"] = ( (df_1h["close"] > df_1h["MA7"]) & (df_1h["MA7"] > df_1h["MA25"]) & (df_1h["MA25"] > df_1h["MA99"]) ) df_1h["h1_bear"] = ( (df_1h["close"] < df_1h["MA7"]) & (df_1h["MA7"] < df_1h["MA25"]) & (df_1h["MA25"] < df_1h["MA99"]) ) df_1h_m = df_1h[["open_time","h1_bull","h1_bear","h1_bull_2","h1_bear_2"]].copy() df["open_time_1h"] = df["open_time"].dt.floor("1h") df_1h_m["open_time"] = df_1h_m["open_time"].dt.floor("1h") df = df.merge(df_1h_m, left_on="open_time_1h", right_on="open_time", how="left", suffixes=("","_1h")) df = df.drop(columns=["open_time_1h","open_time_1h_x","open_time_1h_y"], errors="ignore") df["_date"] = df["open_time"].dt.date for col in ["h1_bull","h1_bear","h1_bull_2","h1_bear_2"]: df[col] = df.groupby("_date")[col].transform(lambda x: x.ffill()) df.drop(columns=["_date"], inplace=True) df["h1_bull"] = df["h1_bull"].fillna(False) df["h1_bear"] = df["h1_bear"].fillna(False) df["h1_bull_2"] = df["h1_bull_2"].fillna(False) df["h1_bear_2"] = df["h1_bear_2"].fillna(False) else: df["h1_bull"] = df["bull_ma"] df["h1_bear"] = df["bear_ma"] df["h1_bull_2"] = df["bull_ma_2"] df["h1_bear_2"] = df["bear_ma_2"] except: df["h1_bull"] = False df["h1_bear"] = False df["long_signal"] = df["long_signal"] & df["taker_buy_dom"] df["short_signal"] = df["short_signal"] & df["taker_sell_dom"] df["strong_long_signal"] = df["strong_long_signal"] df["strong_short_signal"] = df["strong_short_signal"] df["short_caution_signal"]= df["short_caution_signal"] df["long_exhaustion_warn"] = False t = df["open_time"] fig = make_subplots( rows=7, cols=1, shared_xaxes=True, row_heights=[0.38, 0.10, 0.10, 0.10, 0.10, 0.11, 0.11], vertical_spacing=0.01, subplot_titles=["", "Taker Buy/Sell Volume", "Open Interest", "Funding Rate (%)", "Long/Short Ratio (탑트레이더)", "RSI / StochRSI", "MACD"] ) fig.add_trace(go.Candlestick( x=t, open=df["open"], high=df["high"], low=df["low"], close=df["close"], increasing_line_color=COLORS["green"], decreasing_line_color=COLORS["red"], increasing_fillcolor=COLORS["green"], decreasing_fillcolor=COLORS["red"], name="캔들", line=dict(width=1) ), row=1, col=1) fig.add_trace(go.Scatter(x=t, y=df["BB_upper"], line=dict(color=COLORS["BB"].replace("0.1","0.6"), width=0.8), name="BB상단", showlegend=False), row=1, col=1) fig.add_trace(go.Scatter(x=t, y=df["BB_lower"], line=dict(color=COLORS["BB"].replace("0.1","0.6"), width=0.8), fill="tonexty", fillcolor=COLORS["BB"], name="BB하단", showlegend=False), row=1, col=1) for ma, col in [("MA200", COLORS["MA200"]), ("MA99", COLORS["MA99"]), ("MA25", COLORS["MA25"]), ("MA7", COLORS["MA7"])]: fig.add_trace(go.Scatter(x=t, y=df[ma], line=dict(color=col, width=1.2), name=ma), row=1, col=1) if "longShortRatio" in df.columns: fig.add_trace(go.Scatter(x=t, y=df["longShortRatio"], line=dict(color=COLORS["orange"], width=1), name="탑트레이더 L/S"), row=1, col=1) if "fundingRate" in df.columns: fig.add_trace(go.Scatter(x=t, y=df["fundingRate"], line=dict(color=COLORS["purple"], width=1), name="Funding Rate"), row=1, col=1) if "sumOpenInterest" in df.columns: fig.add_trace(go.Scatter(x=t, y=df["sumOpenInterest"], line=dict(color=COLORS["blue"], width=1), name="OI"), row=1, col=1) fig.add_trace(go.Scatter(x=t, y=df["taker_sell_vol"], mode="markers", marker=dict(color=COLORS["red"], size=3), name="Taker Sell"), row=1, col=1) fig.add_trace(go.Scatter(x=t, y=df["taker_buy_vol"], mode="markers", marker=dict(color=COLORS["green"], size=3), name="Taker Buy"), row=1, col=1) for mask, sym, color, sig_name in [ (df["exhaustion_short"], "star", COLORS["red"], "매수소진(숏)"), (df["exhaustion_long"], "star", COLORS["green"], "매도소진(롱)"), (df.get("long_exhaustion_warn", pd.Series([False]*len(df), index=df.index)), "x", COLORS["orange"], "롱소진경고(숏전환)"), (df["strong_short_signal"],"triangle-down", COLORS["red"], "강한 숏 진입 신호"), (df.get("vol_short_signal", pd.Series([False]*len(df), index=df.index)), "triangle-down", COLORS["orange"], "볼륨급등 숏 신호"), (df.get("vol_long_signal", pd.Series([False]*len(df), index=df.index)), "triangle-up", "#00bfff", "볼륨급등 롱 신호"), (df["strong_long_signal"], "triangle-up", COLORS["green"], "강한 롱 진입 신호"), (df["short_signal"], "triangle-down", COLORS["orange"], "숏 진입 신호"), (df["long_signal"], "triangle-up", COLORS["blue"], "롱 진입 신호"), (df.get("short_caution_signal", pd.Series([False]*len(df), index=df.index)), "diamond", "#ff00ff", "숏 진입(주의)"), ]: d = df[mask] if not d.empty: cd = list(zip(d["open_time"].dt.strftime("%m/%d %H:%M").tolist(), d["open"].tolist())) _long_sigs = ["강한 롱 진입 신호", "볼륨급등 롱 신호", "롱 진입 신호", "매도소진(롱)"] _short_sigs = ["강한 숏 진입 신호", "볼륨급등 숏 신호", "숏 진입 신호", "매수소진(숏)", "롱소진경고(숏전환)", "숏 진입(주의)"] if sig_name in _long_sigs: y_val = d["low"] * 0.9998 elif sig_name in _short_sigs: y_val = d["high"] * 1.0002 else: y_val = d["close"] fig.add_trace(go.Scatter( x=d["open_time"], y=y_val, mode="markers", marker=dict(symbol=sym, color=color, size=10), name=sig_name, customdata=cd, hovertemplate="" + sig_name + "
신호: %{customdata[0]}
가격: %{customdata[1]:,.1f}", showlegend=True, ), row=1, col=1) else: fig.add_trace(go.Scatter( x=[None], y=[None], mode="markers", marker=dict(symbol=sym, color=color, size=10), name=sig_name, showlegend=True, ), row=1, col=1) buy_vol = df["taker_buy_vol"] - df["taker_sell_vol"] colors_v = [COLORS["green"] if v >= 0 else COLORS["red"] for v in buy_vol] fig.add_trace(go.Bar(x=t, y=buy_vol, marker_color=colors_v, name="Taker Net"), row=2, col=1) if "sumOpenInterest" not in df.columns: df["spike_avg"] = df["volume"].rolling(10).mean() fig.add_trace(go.Scatter(x=t, y=df["spike_avg"] * 3, line=dict(color=COLORS["yellow"], width=0.8, dash="dot"), name="스파이크 기준(3x)"), row=2, col=1) if "sumOpenInterest" in df.columns: fig.add_trace(go.Scatter(x=t, y=df["sumOpenInterest"], line=dict(color=COLORS["purple"], width=1.5), fill="tozeroy", fillcolor="rgba(156,39,176,0.15)", name="OI"), row=3, col=1) if "fundingRate" in df.columns: fr_colors = [COLORS["red"] if v < 0 else COLORS["green"] for v in df["fundingRate"]] fig.add_trace(go.Bar(x=t, y=df["fundingRate"], marker_color=fr_colors, name="FR"), row=4, col=1) fig.add_hline(y=0.005, line=dict(color=COLORS["orange"], width=1, dash="dash"), row=4, col=1) fig.add_hline(y=-0.005, line=dict(color=COLORS["orange"], width=1, dash="dash"), row=4, col=1) fig.add_hline(y=-0.007, line=dict(color=COLORS["red"], width=1, dash="dash"), row=4, col=1) if "longShortRatio" in df.columns: fig.add_trace(go.Scatter(x=t, y=df["longShortRatio"], line=dict(color=COLORS["orange"], width=1.5), name="탑트레이더 L/S"), row=5, col=1) fig.add_hline(y=1.0, line=dict(color=COLORS["grid"], width=0.8, dash="dash"), row=5, col=1) fig.add_trace(go.Scatter(x=t, y=df["RSI"], line=dict(color=COLORS["blue"], width=1.5), name="RSI(14)"), row=6, col=1) fig.add_trace(go.Scatter(x=t, y=df["StochRSI_k"],line=dict(color=COLORS["red"], width=1.5), name="StochRSI K"), row=6, col=1) fig.add_trace(go.Scatter(x=t, y=df["StochRSI_d"],line=dict(color=COLORS["orange"], width=1.0, dash="dot"), name="StochRSI D"), row=6, col=1) for lvl in [20, 50, 80]: fig.add_hline(y=lvl, line=dict(color=COLORS["grid"], width=0.6, dash="dash"), row=6, col=1) hist_colors = [COLORS["green"] if v >= 0 else COLORS["red"] for v in df["MACD_hist"].fillna(0)] fig.add_trace(go.Bar(x=t, y=df["MACD_hist"], marker_color=hist_colors, name="MACD Hist"), row=7, col=1) fig.add_trace(go.Scatter(x=t, y=df["MACD"], line=dict(color=COLORS["blue"], width=1.2), name="MACD"), row=7, col=1) fig.add_trace(go.Scatter(x=t, y=df["MACD_signal"], line=dict(color=COLORS["orange"], width=1.2), name="Signal"), row=7, col=1) fig.add_hline(y=0, line=dict(color=COLORS["grid"], width=0.5), row=7, col=1) last_price = df["close"].iloc[-1] fig.add_hline(y=last_price, line=dict(color=COLORS["yellow"], width=1, dash="dash"), row=1, col=1) fig.add_annotation( x=df["open_time"].iloc[-1], y=last_price, text=f"▶ {last_price:,.1f}", showarrow=False, font=dict(color=COLORS["yellow"], size=12), xanchor="left", yanchor="middle", bgcolor="rgba(245,206,5,0.15)", bordercolor=COLORS["yellow"], borderwidth=1, row=1, col=1 ) fig.update_layout( height=1600, paper_bgcolor="#ffffff", plot_bgcolor="#ffffff", font=dict(color=COLORS["text"], size=11, family="monospace"), legend=dict(bgcolor="rgba(255,255,255,0.95)", bordercolor=COLORS["grid"], borderwidth=1, orientation="h", x=0, y=1.02, yanchor="bottom", font=dict(size=10)), xaxis_rangeslider_visible=False, margin=dict(l=60, r=100, t=60, b=20), hovermode="x unified", dragmode="pan", showlegend=False, ) for i in range(1, 8): fig.update_xaxes(showgrid=True, gridcolor=COLORS["grid"], zeroline=False, row=i, col=1, showline=True, linecolor=COLORS["grid"]) fig.update_yaxes(showgrid=True, gridcolor=COLORS["grid"], zeroline=False, row=i, col=1, showline=True, linecolor=COLORS["grid"]) def tight(series, pad=0.03): s = series.dropna() if s.empty: return None, None lo, hi = s.min(), s.max() margin = (hi - lo) * pad if (hi - lo) > 0 else abs(lo) * pad + 1 return lo - margin, hi + margin lo, hi = tight(pd.concat([df["low"], df["high"]]), pad=0.02) if lo: fig.update_yaxes(range=[lo, hi], row=1, col=1) buy_vol = df["taker_buy_vol"] - df["taker_sell_vol"] abs_max = buy_vol.abs().quantile(0.98) * 1.5 if abs_max > 0: fig.update_yaxes(range=[-abs_max, abs_max], row=2, col=1) if "sumOpenInterest" in df.columns: lo, hi = tight(df["sumOpenInterest"], pad=0.05) if lo: fig.update_yaxes(range=[lo, hi], row=3, col=1) if "fundingRate" in df.columns: lo, hi = tight(df["fundingRate"], pad=0.2) if lo: fig.update_yaxes(range=[lo, hi], row=4, col=1) if "longShortRatio" in df.columns: lo, hi = tight(df["longShortRatio"], pad=0.05) if lo: fig.update_yaxes(range=[lo, hi], row=5, col=1) fig.update_yaxes(range=[0, 100], row=6, col=1) macd_all = pd.concat([df["MACD"], df["MACD_signal"], df["MACD_hist"]]).dropna() lo, hi = tight(macd_all, pad=0.1) if lo: fig.update_yaxes(range=[lo, hi], row=7, col=1) return fig, df # ────────────────────────────────────────────── # 알림 스레드 # ────────────────────────────────────────────── # 알림 스레드용 mutable state 는 alert_state 모듈에 보관 (위 import 참조). def _build_signal_df(symbol, interval, klines_limit=200): df = get_klines(symbol, interval, klines_limit) oi_period = interval if interval in ["5m","15m","30m","1h","4h","12h","1d","3d","1M"] else "5m" try: oi = get_open_interest_history(symbol, oi_period, min(klines_limit, 500)) if not oi.empty: oi_m = oi[["timestamp","sumOpenInterest"]].rename(columns={"timestamp":"open_time"}) df["open_time_r"] = df["open_time"].dt.floor(_to_floor_freq(oi_period)) oi_m["open_time"] = oi_m["open_time"].dt.floor(_to_floor_freq(oi_period)) df = df.merge(oi_m, left_on="open_time_r", right_on="open_time", how="left", suffixes=("","_oi")) df = df.drop(columns=["open_time_r","open_time_oi"], errors="ignore") df["sumOpenInterest"] = df["sumOpenInterest"].ffill() except: pass try: fr = get_funding_rate(symbol, 200) if not fr.empty: fr_m = fr[["fundingTime","fundingRate"]].rename(columns={"fundingTime":"open_time"}) fr_m["open_time"] = fr_m["open_time"].dt.floor(_to_floor_freq("1h")) df["open_time_r2"] = df["open_time"].dt.floor(_to_floor_freq("1h")) df = df.merge(fr_m, left_on="open_time_r2", right_on="open_time", how="left", suffixes=("","_fr")) df = df.drop(columns=["open_time_r2","open_time_fr"], errors="ignore") df["fundingRate"] = df["fundingRate"].ffill().fillna(0) except: pass df = compute_indicators(df, interval) return df ALERT_TIMEFRAMES = ["5m", "15m", "30m", "1h"] def _alert_loop(): while True: with alert_state.alert_lock: symbol = alert_state.alert_symbol for interval in ALERT_TIMEFRAMES: try: df = _build_signal_df(symbol, interval, 200) check_and_alert(df, symbol, interval) except Exception as e: print(f"[알림스레드 오류] {interval}: {e}") time.sleep(30) # ────────────────────────────────────────────── # 일일 리포트 (자정 KST) # ────────────────────────────────────────────── DAILY_REPORT_TIMEFRAMES = ["5m", "15m", "30m", "1h", "4h"] DAILY_REPORT_KLINES_LIMIT = {"5m": 500, "15m": 250, "30m": 200, "1h": 200, "4h": 200} DAILY_REPORT_PAIRS = [ ("strong_long_signal", "strong_short_signal"), ("long_signal", "short_signal"), ("vol_long_signal", "vol_short_signal"), ] DAILY_REPORT_SIGNAL_LABELS = [ ("strong_long_signal", "강한 롱"), ("strong_short_signal", "강한 숏"), ("long_signal", "일반 롱"), ("short_signal", "일반 숏"), ("vol_long_signal", "볼륨 롱"), ("vol_short_signal", "볼륨 숏"), ] def _count_daily_signals_per_type(df, cutoff_kst, offset=1): result = {sig: [0, 0] for sig, _ in DAILY_REPORT_SIGNAL_LABELS} if df is None or df.empty or "open_time" not in df.columns: return result recent = df[df["open_time"] >= cutoff_kst].reset_index(drop=True) if len(recent) <= offset: return result for long_sig, short_sig in DAILY_REPORT_PAIRS: if long_sig not in recent.columns or short_sig not in recent.columns: continue for i in range(len(recent) - offset): row = recent.iloc[i] future = recent.iloc[i + offset] if bool(row.get(long_sig, False)): result[long_sig][0] += 1 if bool(future.get(short_sig, False)): result[long_sig][1] += 1 if bool(row.get(short_sig, False)): result[short_sig][0] += 1 if bool(future.get(long_sig, False)): result[short_sig][1] += 1 return result def _build_daily_report_lines(dfs, cutoff_kst, now_kst, symbol, offset, header_suffix): lines = [ f"📊 24시간 신호 통계 ({symbol}) - {header_suffix}", f"기준: {now_kst.strftime('%Y-%m-%d %H:%M')} KST", ] for tf in DAILY_REPORT_TIMEFRAMES: df = dfs.get(tf) counts = _count_daily_signals_per_type(df, cutoff_kst, offset=offset) lines.append("") lines.append(f"[{TF_LABEL_MAP.get(tf, tf)}]") total_all = 0 failed_all = 0 for sig, sig_label in DAILY_REPORT_SIGNAL_LABELS: t, f = counts.get(sig, [0, 0]) passed = t - f lines.append(f"{sig_label}: {passed}T {f}F") total_all += t failed_all += f passed_all = total_all - failed_all rate = (passed_all / total_all * 100) if total_all > 0 else 0.0 lines.append(f"합계: {passed_all}T {failed_all}F (승률 {rate:.2f}%)") return "\n".join(lines) def _count_stop_touches_per_type(df, cutoff_kst, lookahead=3): """ 각 진입 신호 캔들 (1번째 캔들) 기준으로 그 후 lookahead 개 캔들 동안 (즉 1번째 캔들 시작가 ~ (lookahead+1) 번째 캔들 시작가 구간) 손절가를 터치했는지 카운트. 롱은 low <= stop, 숏은 high >= stop. 반환: {signal_name: [touch_count, total_count]} """ result = {sig: [0, 0] for sig, _ in DAILY_REPORT_SIGNAL_LABELS} if df is None or df.empty or "open_time" not in df.columns: return result recent = df[df["open_time"] >= cutoff_kst].reset_index(drop=True) if len(recent) <= lookahead: return result for sig, _ in DAILY_REPORT_SIGNAL_LABELS: if sig not in recent.columns: continue if sig in LONG_SIGNALS: direction = "long" elif sig in SHORT_SIGNALS: direction = "short" else: continue # short_caution_signal — 진입 신호 아님, 손절가 추적 X for i in range(len(recent) - lookahead): row = recent.iloc[i] if not bool(row.get(sig, False)): continue entry = float(row["open"]) window = recent.iloc[i:i + lookahead] result[sig][1] += 1 if direction == "long": stop = entry * (1 - STOP_LOSS_PCT) if float(window["low"].min()) <= stop: result[sig][0] += 1 else: stop = entry * (1 + STOP_LOSS_PCT) if float(window["high"].max()) >= stop: result[sig][0] += 1 return result def _build_stop_touch_lines(dfs, cutoff_kst, now_kst, symbol): lines = [ f"[손절가 터치 횟수 알림(시간봉 *3배기준)] ({symbol})", f"기준: {now_kst.strftime('%Y-%m-%d %H:%M')} KST", f"손절 비율: ±{STOP_LOSS_PCT*100:.2f}% (10x 레버리지 기준 ROI ±{STOP_LOSS_PCT*100*10:.1f}%)", ] for tf in DAILY_REPORT_TIMEFRAMES: df = dfs.get(tf) counts = _count_stop_touches_per_type(df, cutoff_kst, lookahead=3) lines.append("") lines.append(f"[{TF_LABEL_MAP.get(tf, tf)}]") touch_all = 0 total_all = 0 for sig, sig_label in DAILY_REPORT_SIGNAL_LABELS: if sig == "short_caution_signal": continue touch, total = counts.get(sig, [0, 0]) lines.append(f"{sig_label}: {touch}/{total}") touch_all += touch total_all += total rate = (touch_all / total_all * 100) if total_all > 0 else 0.0 lines.append(f"합계: {touch_all}/{total_all} (터치율 {rate:.2f}%)") return "\n".join(lines) def send_daily_report(symbol="BTCUSDT"): now_kst = (datetime.now(timezone.utc) + KST).replace(tzinfo=None) cutoff_kst = now_kst - timedelta(hours=24) dfs = {} for tf in DAILY_REPORT_TIMEFRAMES: try: dfs[tf] = _build_signal_df(symbol, tf, DAILY_REPORT_KLINES_LIMIT[tf]) except Exception as e: print(f"[일일리포트 {tf} 데이터 오류] {e}") dfs[tf] = None msg_1x = _build_daily_report_lines(dfs, cutoff_kst, now_kst, symbol, offset=1, header_suffix="1배 시간 (다음 봉 검증)") send_telegram(msg_1x) msg_2x = _build_daily_report_lines(dfs, cutoff_kst, now_kst, symbol, offset=2, header_suffix="2배 시간 (2번째 봉 검증)") send_telegram(msg_2x) msg_touch = _build_stop_touch_lines(dfs, cutoff_kst, now_kst, symbol) send_telegram(msg_touch) def _daily_report_loop(): while True: try: now_kst = (datetime.now(timezone.utc) + KST).replace(tzinfo=None) today_str = now_kst.strftime("%Y-%m-%d") if alert_state.last_report_date is None: alert_state.last_report_date = today_str print(f"[일일리포트] 스레드 기동 -- 다음 자정({today_str} 24:00 KST) 까지 대기") elif alert_state.last_report_date != today_str: print(f"[일일리포트] 자정 통과 감지 -> 발송 ({today_str})") with alert_state.alert_lock: symbol = alert_state.alert_symbol send_daily_report(symbol) alert_state.last_report_date = today_str except Exception as e: print(f"[일일리포트 스레드 오류] {e}") time.sleep(60) # ────────────────────────────────────────────── # 메인 UI # ────────────────────────────────────────────── def main(): if not alert_state.alert_started: t = threading.Thread(target=_alert_loop, daemon=True) t.start() alert_state.alert_started = True if not alert_state.daily_report_started: dr = threading.Thread(target=_daily_report_loop, daemon=True) dr.start() alert_state.daily_report_started = True col1, col2, col3, col4, col5 = st.columns([2, 1, 1, 1, 2]) with col1: st.markdown("### 📊 Futures Dashboard") with col2: symbol = st.selectbox("심볼", ["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT"], index=0, label_visibility="collapsed") with col3: interval = st.selectbox("시간축", ["1m","3m","5m","15m","30m","1h","4h","12h","1d","3d","1M"], index=2, label_visibility="collapsed") with col4: refresh_sec = st.number_input("갱신(초)", min_value=10, max_value=300, value=30) with col5: now_kst = datetime.now(timezone.utc) + KST st.markdown(f"
🕐 마지막 갱신: {now_kst.strftime('%Y-%m-%d %H:%M:%S')} KST
", unsafe_allow_html=True) col5a, col5b, col5c, col5d = st.columns(4) with col5a: refresh_btn = st.button("🔄 새로고침") with col5b: auto = st.checkbox("자동갱신", value=True) with col5c: show_legend = st.checkbox("범례", value=False) with col5d: mobile_mode = st.checkbox("모바일", value=False) candle_limit = 53 if mobile_mode else 200 with alert_state.alert_lock: alert_state.alert_symbol = symbol alert_state.alert_interval = interval try: with st.spinner("데이터 로딩 중..."): fig, df = build_chart(symbol, interval, candle_limit) try: fr_df = get_funding_rate(symbol, 1) if not fr_df.empty: rate = fr_df["fundingRate"].iloc[-1] if rate <= -0.007: st.error(f"🚨 극단적 숏스퀴즈 위험 | FR: {rate:.4f}% | 숏 신규진입 절대 금지") elif rate <= -0.005: st.warning(f"⚠️ 숏스퀴즈 경보 구간 | FR: {rate:.4f}% | 숏 진입 시 청산가 재확인 필수") elif rate >= 0.005: st.info(f"📈 롱 과열 구간 | FR: {rate:.4f}% | 롱스퀴즈 주의") else: st.success(f"✅ FR 정상 | {rate:.4f}%") except: pass fig.update_layout(showlegend=show_legend) st.plotly_chart(fig, use_container_width=True, config={ "scrollZoom": True, "doubleClick": "reset", "displayModeBar": True, "modeBarButtonsToRemove": ["lasso2d", "select2d"], }) except Exception as e: st.error(f"데이터 로드 오류: {e}") import traceback st.code(traceback.format_exc()) if auto: time.sleep(refresh_sec) st.rerun() if __name__ == "__main__": main()