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"]
# 재시작 후 첫 polling — 역사적 신호 burst 차단을 위해 dedup 만 silent sync
if interval not in alert_state.synced_intervals:
for sig, key, _, _ in SIG_DEFS:
if sig not in df.columns:
continue
triggered = df[df[sig].fillna(False)]
if not triggered.empty:
alert_state.last_fired_candle[(interval, key)] = triggered.iloc[-1]["open_time"]
alert_state.synced_intervals.add(interval)
print(f"[알림스레드] {interval} 초기 sync 완료 — 이후 polling 부터 새 신호만 발사")
return
# 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 — 신호 검사 + 알림 발사 (모든 TF forming candle 포함).
# 30초 polling 으로 매 사이클마다 forming candle 의 신호 상태 재검증 →
# 신호 사라지면 즉시 [취소 알림] 발사 (Phase 1 로직). 5m=2.5m, 15m=7.5m,
# 30m=15m, 1h=30m 의 절반 시간보다 훨씬 빠른 검증 주기.
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}
# 청산 권고는 30m / 1h 의 새 진입 신호만 트리거 (5m / 15m opposite
# 은 노이즈가 많아 청산권고로 부적합 — 변동성 큰 날 폭주 방지).
if interval in ("30m", "1h"):
opposite_dict = alert_state.short_entry if direction == "long" else alert_state.long_entry
opposite_label = "숏" if direction == "long" else "롱"
for opp_interval, opp_rec in list(opposite_dict.items()):
if opp_rec is None:
continue
send_telegram(
f"[반대 신호 감지 - {opposite_label} 청산 권장]\n"
f"--- 기존 진입 ---\n{opp_rec['entry_msg']}\n"
f"--- 반대 신호 ---\n{msg}"
)
opposite_dict[opp_interval] = None
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"):
# close 가 MA7, MA25 양쪽 위/아래에 있는 것 만 요구. MA끼리 정렬 (MA7>MA25)은
# 추세 반전 직후엔 늦게 형성되어 양봉/음봉 신호를 차단하는 부작용 있어 제거.
df["bull_ma_2"] = (
(df["close"] > df["MA7"]) & (df["close"] > df["MA25"])
)
df["bear_ma_2"] = (
(df["close"] < df["MA7"]) & (df["close"] < 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) 은 통과 시킴.
# BB 상/하단 차단 제거 + RSI 임계 완화 (60→75 / 35→25). 캔들 body 최소 0.2%
# 요구해 작은 노이즈 캔들 차단 (close vs open 만으로는 -0.1% 같은 미미한 음봉도
# 신호로 잡히던 문제 해결).
body_pct = (df["close"] - df["open"]) / df["open"].replace(0, float("nan"))
df["long_signal"] = df["bull_ma_2"] & (df["RSI"] < 75) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & (df["close"] > df["BB_mid"]) & (body_pct >= 0.002)
df["short_signal"] = df["bear_ma_2"] & (df["RSI"] > 25) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & (df["close"] < df["BB_mid"]) & (body_pct <= -0.002)
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
# 추세 꺾임 감지: 직전 3봉의 추세 방향과 현재 캔들 방향이 반대 + 강한 폭 + 거래량 동반.
# - 추세 판단: close[t-1] vs close[t-3] (현재 캔들 제외, 직전까지의 흐름)
# - 현재 캔들 강도: |close-open|/open >= 0.3% (작은 캔들 노이즈 차단)
# - 거래량: 직전 3봉 평균의 1.3배 이상 (확신)
# - 쿨다운: 3봉
prior_close = df["close"].shift(1)
prior_close_3 = df["close"].shift(3)
was_up = prior_close > prior_close_3
was_down = prior_close < prior_close_3
candle_body_pct = (df["close"] - df["open"]) / df["open"].replace(0, float("nan"))
vol_avg3 = df["volume"].rolling(3).mean().shift(1)
vol_strong = df["volume"] > vol_avg3 * 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
df["reversal_short_signal"] = rev_short_raw & (rev_short_raw.rolling(3, min_periods=1).sum().shift(1).fillna(0) == 0)
df["reversal_long_signal"] = rev_long_raw & (rev_long_raw.rolling(3, 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}