Files
tradeing/app_streamlit.py
T
ILSEON-RYU bdd2d66ea0 차트 fetch 200 보장 + 표시 시점에만 candle_limit 로 trim
## 사례
junggomoa.com 대시보드 RSI / StochRSI / MACD 패널이 비어있고 데이터
포인트가 거의 안 보이는 상황. 모바일 모드(14봉) 시 특히 심함.

## 원인
candle_limit (53/14) 를 그대로 get_klines 에 전달 → 14봉만 fetch.
지표 최소 윈도우 충족 못 함:
  RSI(14)  : 14봉 필요 → 마지막 1봉만 값 있음
  MACD     : 26봉 slow window 필요 → 전부 NaN
  BB(20)   : 전부 NaN
  MA25, MA99: 전부 NaN
결과적으로 차트 하단 패널들이 비어보임.

## 수정
build_chart 에서:
1. fetch_limit = max(candle_limit, 200) 로 충분히 받기
2. 모든 지표 / signal 계산 후, 표시 직전 (t = df["open_time"] 직전)
   df = df.tail(candle_limit) 로 잘라 차트는 사용자 지정 봉만 보여주기
지표는 200봉 history 로 계산되어 14봉만 표시해도 모든 값 정상.

## 검증 (candle_limit=14)
RSI/MACD_hist/StochRSI_k/BB_mid/MA25/MA99 모두 14/14 populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:21:31 +09:00

1095 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("""
<style>
html, body, [data-testid="stAppViewContainer"], [data-testid="stHeader"] {
background-color: #ffffff !important;
color: #131722 !important;
}
[data-testid="stSidebar"] { background-color: #f0f3fa !important; }
.stSelectbox > div > div { background-color: #ffffff !important; color: #131722 !important; }
.stButton > button { background-color: #238636 !important; color: #ffffff !important; border: none !important; }
header { background-color: #f0f0f0 !important; }
</style>
""", 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)]
seen_key = (interval, sig)
prev_seen = alert_state.signal_seen_count.get(seen_key)
if triggered.empty:
# 신호 사라짐 → 카운터 리셋 (다음 True 시점부터 다시 1회 카운트)
if prev_seen:
alert_state.signal_seen_count[seen_key] = {"candle_time": prev_seen["candle_time"], "count": 0}
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
# 연속 True polling 카운트 갱신
if prev_seen is None or prev_seen["candle_time"] != candle_time:
alert_state.signal_seen_count[seen_key] = {"candle_time": candle_time, "count": 1}
else:
alert_state.signal_seen_count[seen_key] = {"candle_time": candle_time, "count": prev_seen["count"] + 1}
count = alert_state.signal_seen_count[seen_key]["count"]
# forming candle 만 안정성 (2 polls) 요구. 닫힌 캔들은 즉시 발사 (data 확정).
if candle_time == forming_ct and count < 2:
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):
# 지표 계산은 충분한 history 필요 (MA99=99, MACD=26, BB=20, RSI=14 등).
# candle_limit 가 작아도 fetch 는 최소 200 으로 — 차트 표시 시점에만 candle_limit 로 잘라서 보여준다.
fetch_limit = max(candle_limit, 200)
df = get_klines(symbol, interval, fetch_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
# 지표 계산은 충분한 history 로 했고, 차트 표시는 사용자가 지정한 candle_limit 만큼만.
if len(df) > candle_limit:
df = df.tail(candle_limit).reset_index(drop=True)
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="<b>" + sig_name + "</b><br>신호: %{customdata[0]}<br>가격: %{customdata[1]:,.1f}<extra></extra>",
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 _count_reversal_outcomes(df, cutoff_kst, lookahead=3):
"""추세 꺾임 신호의 lookahead 봉 후 방향 일치 카운트.
- reversal_long: close[i+lookahead] > close[i] -> T (상승 지속)
- reversal_short: close[i+lookahead] < close[i] -> T (하락 지속)
반환: {sig_name: [total, failed]}
"""
result = {"reversal_long_signal": [0, 0], "reversal_short_signal": [0, 0]}
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 ("reversal_long_signal", "reversal_short_signal"):
if sig not in recent.columns:
continue
for i in range(len(recent) - lookahead):
row = recent.iloc[i]
if not bool(row.get(sig, False)):
continue
future = recent.iloc[i + lookahead]
entry_close = float(row["close"])
future_close = float(future["close"])
confirmed = (sig == "reversal_long_signal" and future_close > entry_close) or \
(sig == "reversal_short_signal" and future_close < entry_close)
result[sig][0] += 1
if not confirmed:
result[sig][1] += 1
return result
def _build_reversal_lines(dfs, cutoff_kst, now_kst, symbol):
lines = [
f"📊 추세 꺾임 감지 통계 ({symbol})",
f"기준: {now_kst.strftime('%Y-%m-%d %H:%M')} KST (3봉 후 방향 일치)",
]
for tf in DAILY_REPORT_TIMEFRAMES:
df = dfs.get(tf)
counts = _count_reversal_outcomes(df, cutoff_kst, lookahead=3)
lines.append("")
lines.append(f"[{TF_LABEL_MAP.get(tf, tf)}]")
total_all = 0
failed_all = 0
for sig, lbl in [("reversal_long_signal", "🔄 롱 추세 꺾임 감지"),
("reversal_short_signal", "🔄 숏 추세 꺾임 감지")]:
t, f = counts[sig]
passed = t - f
lines.append(f"{lbl}: {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 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)
msg_rev = _build_reversal_lines(dfs, cutoff_kst, now_kst, symbol)
send_telegram(msg_rev)
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"<div style='text-align:right; font-size:12px; color:#888; margin-bottom:2px;'>🕐 마지막 갱신: {now_kst.strftime('%Y-%m-%d %H:%M:%S')} KST</div>", 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)
# 데스크톱: 53 (기존 모바일 캔들 수). 모바일: 14 (= 53 × 53/200, 기존 비율 유지)
candle_limit = 14 if mobile_mode else 53
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()