commit 453b288a95f99c356564a849afd58dce1f75fcb3 Author: ILSEON-RYU Date: Sat Apr 25 21:19:25 2026 +0900 init diff --git a/.env b/.env new file mode 100644 index 0000000..8c6cb74 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +TELEGRAM_TOKEN=8642481485:AAEv4fox0zmz60h77htRfNpV5w2uWLdHbrU +TELEGRAM_CHAT_ID=5604587238 \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..23baf58 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# 디폴트 무시된 파일 +/shelf/ +/workspace.xml diff --git a/.idea/CoinRichProject.iml b/.idea/CoinRichProject.iml new file mode 100644 index 0000000..85582ca --- /dev/null +++ b/.idea/CoinRichProject.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..4a9bc6f --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..2010ae5 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app_streamlit.py b/app_streamlit.py new file mode 100644 index 0000000..d4332cc --- /dev/null +++ b/app_streamlit.py @@ -0,0 +1,694 @@ +""" +BTC/ETH Futures Trading Dashboard — Streamlit 버전 +""" + +import sys +import os +sys.stdout.reconfigure(line_buffering=True) +os.environ["PYTHONUNBUFFERED"] = "1" + +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 +from pyngrok import ngrok + +# ────────────────────────────────────────────── +# 페이지 설정 (반드시 최상단) +# ────────────────────────────────────────────── +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 = 30 + +BASE = "https://fapi.binance.com" +KST = timedelta(hours=9) + +_last_alert = {"strong_long": 0, "strong_short": 0, "long": 0, "short": 0, "vol_long": 0, "vol_short": 0, "short_caution": 0} + +# ────────────────────────────────────────────── +# 텔레그램 +# ────────────────────────────────────────────── +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=5) + except Exception as e: + print(f"[텔레그램 오류] {e}") + +def check_and_alert(df, symbol, interval): + now = time.time() + last = df.iloc[-1] + for sig, key, msg in [ + ("strong_long_signal", "strong_long", f"🟢 강한 롱 진입 신호\n{symbol} {interval}"), + ("strong_short_signal", "strong_short", f"🔴 강한 숏 진입 신호\n{symbol} {interval}"), + ("long_signal", "long", f"🔼 롱 진입 신호\n{symbol} {interval}"), + ("short_signal", "short", f"🔽 숏 진입 신호\n{symbol} {interval}"), + ("vol_long_signal", "vol_long", f"🔼 볼륨급등 롱 신호\n{symbol} {interval}"), + ("vol_short_signal", "vol_short", f"🔽 볼륨급등 숏 신호\n{symbol} {interval}"), + ("short_caution_signal","short_caution",f"⚠️ 숏 진입 주의 신호\n{symbol} {interval}"), + ]: + if last.get(sig, False) and now - _last_alert[key] > ALERT_COOLDOWN: + send_telegram(msg) + _last_alert[key] = now + +# ────────────────────────────────────────────── +# 데이터 수집 +# ────────────────────────────────────────────── +def get_klines(symbol="BTCUSDT", interval="5m", limit=1500): + url = f"{BASE}/fapi/v1/klines" + r = requests.get(url, params={"symbol": symbol, "interval": interval, "limit": limit}, timeout=10) + 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) + 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) + 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) + 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) + 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"): + # 일반 신호용 — MA7/MA25 2개 기준 + df["bull_ma_2"] = ( + (df["close"] > df["MA7"]) & (df["MA7"] > df["MA25"]) + ) + df["bear_ma_2"] = ( + (df["close"] < df["MA7"]) & (df["MA7"] < df["MA25"]) + ) + # 강한 신호용 — MA7/MA25/MA99 3개 기준 + df["bull_ma"] = ( + (df["close"] > df["MA7"]) & (df["MA7"] > df["MA25"]) & + (df["MA25"] > df["MA99"]) + ) + df["bear_ma"] = ( + (df["close"] < df["MA7"]) & (df["MA7"] < df["MA25"]) & + (df["MA25"] < df["MA99"]) + ) + df["long_signal"] = df["bull_ma_2"] & (df["RSI"] < 60) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & (df["close"] > df["BB_mid"]) & (df["StochRSI_k"] < 80) + df["short_signal"] = df["bear_ma_2"] & (df["RSI"] > 40) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & (df["close"] < df["BB_mid"]) & (df["StochRSI_k"] > 20) + # 쿨다운: 직전 5캔들 내 같은 신호 있으면 억제 + 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) + + 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"] & (df["RSI"] < 65) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & df["oi_up_2"] & df["taker_buy_2"] & df["fr_long_favor"] + df["strong_short_signal"] = df["bear_ma"] & (df["RSI"] > 35) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & df["oi_down_2"] & df["taker_sell_2"] & df["fr_short_favor"] + # 강한 신호 쿨다운: 직전 10캔들 내 억제 + 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) + + # ── Taker Sell Net(Sell-Buy) 극값 강한 숏 신호 (MA배열 무관, 볼륨 우선) ── + # 시간봉별 볼륨 임계값 (5분봉 기준 100, 비례 적용) + _vol_min_map = {"1m": 33, "3m": 100, "5m": 100, "15m": 300, "30m": 600, "1h": 1200, "2h": 2400, "4h": 4800, "1d": 28800} + _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_up"] + ) + 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 + + # ── Taker Buy Net(Buy-Sell) 극값 강한 롱 신호 (MA배열 무관, 볼륨 우선) ── + 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_up"] + ) + 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 + + # ── OI 2캔들 하락 + FR ≤ -0.007% → 숏 주의 신호 (쿨다운 5캔들) ── + if "fundingRate" in df.columns and "sumOpenInterest" in df.columns: + fr_extreme = df["fundingRate"] <= -0.007 + raw_signal = df["oi_down_2"] & fr_extreme + # 쿨다운: 직전 5캔들 내 이미 신호 있으면 억제 + 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 + + 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"}.get(period, period) + +def build_chart(symbol, interval): + df = get_klines(symbol, interval, 1500) + oi_period = interval if interval in ["5m","15m","30m","1h","4h"] else "5m" + + try: + oi = get_open_interest_history(symbol, oi_period, 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, 500) + 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, 500) + 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) + + # ── 1시간봉 MA 배열 — 각 캔들 시점별로 소급 체크 ── + try: + if interval != "1h": + df_1h = get_klines(symbol, "1h", 300) + 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() + # 일반 신호용 2개 기준 + 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"]) + ) + # 강한 신호용 3개 기준 + 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") + # ffill은 당일(KST 날짜) 내에서만 허용 — 전날 신호 전이 방지 + 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 + + # 각 캔들 시점의 1시간봉 배열로 신호 억제 + # 일반 신호 — 1h 2개 기준 + h1_bull_2 = df.get("h1_bull_2", df["h1_bull"]) + h1_bear_2 = df.get("h1_bear_2", df["h1_bear"]) + df["long_signal"] = df["long_signal"] & h1_bull_2 & df["taker_buy_dom"] + df["short_signal"] = df["short_signal"] & h1_bear_2 & df["taker_sell_dom"] + # 강한 신호 — 1h 3개 기준 + df["strong_long_signal"] = df["strong_long_signal"] & df["h1_bull"] + df["strong_short_signal"]= df["strong_short_signal"]& df["h1_bear"] + df["short_caution_signal"]= df["short_caution_signal"] & h1_bear_2 + + 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) + + # ── BB ── + 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) + + # ── MA ── + 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) + + # ── 탑트레이더 L/S ── + 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) + + # ── FR 선 ── + 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) + + # ── OI 선 ── + 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) + + # ── Taker sell/buy scatter ── + 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 = [[v] for v in d["open_time"].dt.strftime("%m/%d %H:%M").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]}
가격: %{y:,.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) + + # ── Taker Volume 바 ── + 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() + spike_mask = df["volume"] > df["spike_avg"] * 3 + 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) + + # ── OI ── + 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) + + # ── FR ── + 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) + + # ── L/S Ratio ── + 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) + + # ── RSI / StochRSI ── + 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["yellow"], width=1.2, dash="dot"), 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) + + # ── MACD ── + 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 + + # 캔들 Y축 + lo, hi = tight(pd.concat([df["low"], df["high"]]), pad=0.02) + if lo: fig.update_yaxes(range=[lo, hi], row=1, col=1) + + # Taker Volume — 98% 분위수 기준 대칭 범위 + 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) + + # OI + 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) + + # FR + 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) + + # L/S + 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) + + # RSI + fig.update_yaxes(range=[0, 100], row=6, col=1) + + # MACD + 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 + +# ────────────────────────────────────────────── +# 알림 스레드 +# ────────────────────────────────────────────── +_alert_symbol = "BTCUSDT" +_alert_interval = "5m" +_alert_lock = threading.Lock() +_alert_started = False + +def _alert_loop(): + while True: + try: + with _alert_lock: + symbol = _alert_symbol + interval = _alert_interval + df = get_klines(symbol, interval, 50) + oi_period = interval if interval in ["5m","15m","30m","1h","4h"] else "5m" + try: + oi = get_open_interest_history(symbol, oi_period, 50) + 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 + df = compute_indicators(df, interval) + check_and_alert(df, symbol, interval) + except Exception as e: + print(f"[알림스레드 오류] {e}") + time.sleep(30) + +def _to_floor_freq(period): + return {"1m":"1min","3m":"3min","5m":"5min","15m":"15min","30m":"30min","1h":"1h","4h":"4h"}.get(period, period) + +# ────────────────────────────────────────────── +# 메인 UI +# ────────────────────────────────────────────── +def main(): + global _alert_started, _alert_symbol, _alert_interval + + # 알림 스레드 시작 (1회만) + if not _alert_started: + t = threading.Thread(target=_alert_loop, daemon=True) + t.start() + _alert_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"], index=2, label_visibility="collapsed") + with col4: + refresh_sec = st.number_input("갱신(초)", min_value=10, max_value=300, value=30) + with col5: + col5a, col5b, col5c = st.columns(3) + with col5a: + refresh_btn = st.button("🔄 새로고침") + with col5b: + auto = st.checkbox("자동갱신", value=True) + with col5c: + show_legend = st.checkbox("범례", value=False) + + # 알림 타겟 업데이트 + with _alert_lock: + _alert_symbol = symbol + _alert_interval = interval + + # 데이터 로드 & 차트 + try: + with st.spinner("데이터 로딩 중..."): + fig, df = build_chart(symbol, interval) + + # FR 배너 + 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, + "displayModeBar": True, + "modeBarButtonsToRemove": ["lasso2d", "select2d"], + }) + + st.caption(f"마지막 갱신: {datetime.now().strftime('%H:%M:%S')}") + + 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__": + try: + public_url = ngrok.connect(8501) + print(f"핸드폰: {public_url}") + except Exception as e: + print(f"ngrok 오류: {e}") + + main() diff --git a/assets/override.css b/assets/override.css new file mode 100644 index 0000000..fd0bcc7 --- /dev/null +++ b/assets/override.css @@ -0,0 +1,17 @@ +:root { + color-scheme: light only !important; +} + +html, body, #react-entry-point { + background-color: #ffffff !important; + color: #131722 !important; +} + +/* Dash 4.x 다크모드 CSS 변수 완전 덮어쓰기 */ +:root { + --Dash-Fill-Inverse-Strong: #ffffff !important; + --Dash-Text-Strong: #131722 !important; + --Dash-Text-Primary: rgba(0, 18, 77, 0.87) !important; + --Dash-Stroke-Strong: rgba(0, 18, 77, 0.45) !important; + --Dash-Fill-Disabled: rgba(0, 24, 102, 0.1) !important; +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..db09bf8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +ccxt>=4.2.0 +pandas>=2.0.0 +plotly>=5.18.0 +dash>=2.14.0 +ta>=0.11.0 +requests>=2.31.0 +numpy>=1.24.0 +python-dotenv \ No newline at end of file