Files
tradeing/app_streamlit.py
chpark c4e6aab7b2 React + FastAPI 풀 마이그레이션 — Streamlit 제거
- backend/ — FastAPI + JWT + 모든 REST 엔드포인트
- frontend/ — Next.js 14 + Tailwind + 7페이지 (대시보드/트레이드/거래소/자동매매/설정/내정보/로그인)
- core_logic.py — 신호계산/알림 로직 분리 (기존 app_streamlit.py 에서 추출)
- users_db.py + bcrypt 인증, exchange_keys.py + Fernet 암호화
- trades_db.py — 진입/청산 lifecycle 추적, signal_events raw 로그
- settings_db.py — 모든 운영 파라미터 DB 영속 저장 (RSI/거래량/펀딩비 임계값 포함)
- docker-compose: frontend / backend / postgres + Traefik 라우팅
- assets/logo.svg — JUNGGOMOA 그라디언트 로고

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

1998 lines
97 KiB
Python
Raw Permalink 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="중고모아 트레이딩 대시보드",
layout="wide",
initial_sidebar_state="expanded"
)
# 라이트모드 강제 + 한글 폰트
st.markdown("""
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
html, body, [class*="css"], [data-testid="stAppViewContainer"], [data-testid="stHeader"], [data-testid="stSidebar"] {
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, -apple-system, sans-serif !important;
}
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)
# ──────────────────────────────────────────────
# 설정 — 모든 운영 파라미터는 DB (settings_db.py) 에서 동적으로 조회.
# .env 값은 최초 기동 시에만 DB 기본값으로 복사된다.
# ──────────────────────────────────────────────
import settings_db
import trades_db
import exchange_keys
import exchange_adapters
import users_db
settings_db.init_db_with_env_defaults()
trades_db.init_db()
exchange_keys.init_db()
users_db.init_db()
# ──────────────────────────────────────────────
# 로고 (SVG) — assets/logo.svg 가 있으면 그걸, 없으면 inline fallback
# ──────────────────────────────────────────────
def load_logo_svg(scale: float = 1.0) -> str:
try:
with open("assets/logo.svg", "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def TELEGRAM_TOKEN(): return settings_db.get("telegram_token", "")
def TELEGRAM_CHAT_ID(): return settings_db.get("telegram_chat_id", "")
def ALERT_COOLDOWN(): return settings_db.get_int("alert_cooldown_sec", 600)
def STOP_LOSS_PCT_v(): return settings_db.get_float("stop_loss_pct", 0.0075)
BASE = "https://fapi.binance.com"
KST = timedelta(hours=9)
# 호환용 상수 — 일부 함수에서 직접 참조. DB 값으로 매번 갱신.
STOP_LOSS_PCT = 0.0075 # runtime 에서 STOP_LOSS_PCT_v() 사용 권장
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):
token = TELEGRAM_TOKEN()
chat_id = TELEGRAM_CHAT_ID()
if not token or not chat_id:
print("[텔레그램] 토큰/chat_id 미설정 — 메시지 skip")
return
try:
url = f"https://api.telegram.org/bot{token}/sendMessage"
requests.post(url, data={"chat_id": 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:
trades_db.record_exit(symbol, interval, "long", ct,
float(row["close"]), "cancelled")
alert_state.long_entry[interval] = None
elif p["direction"] == "short" and se is not None and se.get("open_time") == ct:
trades_db.record_exit(symbol, interval, "short", ct,
float(row["close"]), "cancelled")
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 만 안정성 (N polls) 요구. 닫힌 캔들은 즉시 발사 (data 확정).
stable_min = settings_db.get_int("forming_stable_polls", 2)
if candle_time == forming_ct and count < stable_min:
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"]
trades_db.log_signal_events(symbol, interval, group)
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"])
sl_pct = STOP_LOSS_PCT_v()
if direction == "long":
stop_price = entry_price * (1 - sl_pct)
else:
stop_price = entry_price * (1 + sl_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 ""
opposite_direction = "short" if direction == "long" else "long"
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}"
)
trades_db.record_exit(symbol, opp_interval, opposite_direction,
opp_rec.get("open_time"), entry_price, "reversal")
opposite_dict[opp_interval] = None
if direction == "long":
alert_state.long_entry[interval] = entry_record
else:
alert_state.short_entry[interval] = entry_record
trades_db.record_entry(symbol, interval, direction,
[e["sig"] for e in group],
candle_time, entry_price, stop_price)
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}"
)
trades_db.record_exit(symbol, interval, "long", le.get("open_time"),
current_price, "stop_loss")
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}"
)
trades_db.record_exit(symbol, interval, "short", se.get("open_time"),
current_price, "stop_loss")
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"):
# 임계값들은 settings_db 에서 1회 조회 (rerun 마다 N개 변수 호출 비용 작음)
LONG_RSI_MAX = settings_db.get_float("long_rsi_max", 75.0)
SHORT_RSI_MIN = settings_db.get_float("short_rsi_min", 25.0)
SLONG_RSI_MAX = settings_db.get_float("strong_long_rsi_max", 65.0)
SSHORT_RSI_MIN = settings_db.get_float("strong_short_rsi_min", 35.0)
BODY_PCT_MIN = settings_db.get_float("body_pct_min", 0.002)
REV_BODY_PCT = settings_db.get_float("reversal_body_pct", 0.003)
REV_VOL_MULT = settings_db.get_float("reversal_vol_mult", 1.3)
VOL_EXH_MULT = settings_db.get_float("vol_exhaustion_mult", 3.0)
VOL_NET_MULT = settings_db.get_float("vol_net_mult", 2.0)
OI_ACTIVE_PCT = settings_db.get_float("oi_active_pct", 0.001)
FR_SHORT_EXTREME = settings_db.get_float("fr_short_extreme", -0.007)
# 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_range = (df["BB_upper"] - df["BB_lower"]).replace(0, float("nan"))
df["bb_pos"] = (df["close"] - df["BB_lower"]) / bb_range
body_pct = (df["close"] - df["open"]) / df["open"].replace(0, float("nan"))
df["long_signal"] = df["bull_ma_2"] & (df["RSI"] < LONG_RSI_MAX) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & (df["close"] > df["BB_mid"]) & (body_pct >= BODY_PCT_MIN)
df["short_signal"] = df["bear_ma_2"] & (df["RSI"] > SHORT_RSI_MIN) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & (df["close"] < df["BB_mid"]) & (body_pct <= -BODY_PCT_MIN)
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["oi_active"] = oi_series.pct_change().abs() > OI_ACTIVE_PCT
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"] < SLONG_RSI_MAX) & (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"] > SSHORT_RSI_MIN) & (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 * VOL_EXH_MULT
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 * VOL_NET_MULT) &
(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 * VOL_NET_MULT) &
(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"] <= FR_SHORT_EXTREME
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 * REV_VOL_MULT
rev_short_raw = was_up & (candle_body_pct < -REV_BODY_PCT) & vol_strong
rev_long_raw = was_down & (candle_body_pct > REV_BODY_PCT) & 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="'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif"),
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
def _alert_timeframes():
return settings_db.get_list("alert_timeframes", default=["5m", "15m", "30m", "1h"])
def _alert_loop():
while True:
poll = max(10, settings_db.get_int("polling_interval_sec", 30))
if not settings_db.get_bool("alert_enabled", True):
time.sleep(poll)
continue
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(poll)
# ──────────────────────────────────────────────
# 일일 리포트 (자정 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:
if not settings_db.get_bool("daily_report_enabled", True):
time.sleep(60)
continue
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 render_login_page():
"""로그인 화면 — 중앙 정렬 카드 (fito 스타일)."""
st.markdown("""
<style>
[data-testid="stSidebar"] { display: none !important; }
[data-testid="stSidebarCollapseButton"],
[data-testid="stSidebarCollapsed"] { display: none !important; }
[data-testid="stAppViewContainer"] {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%) !important;
}
[data-testid="stAppViewContainer"] section.main {
margin-left: 0 !important;
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
}
.login-card {
background: #ffffff;
border-radius: 14px;
padding: 40px 36px 30px 36px;
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
width: 380px;
margin: 0 auto;
}
.login-card .logo-wrap {
display: flex; justify-content: center; margin-bottom: 24px;
}
.login-card h1 {
font-size: 18px; font-weight: 700; color: #111827;
text-align: center; margin: 0 0 4px 0;
}
.login-card .subtitle {
font-size: 12px; color: #6b7280;
text-align: center; margin-bottom: 24px;
}
</style>
""", unsafe_allow_html=True)
col1, col2, col3 = st.columns([1, 2, 1])
with col2:
st.markdown('<div class="login-card">', unsafe_allow_html=True)
st.markdown(f'<div class="logo-wrap">{load_logo_svg()}</div>', unsafe_allow_html=True)
st.markdown('<h1>업무관리 시스템</h1>', unsafe_allow_html=True)
st.markdown('<div class="subtitle">로그인하여 시작하세요</div>', unsafe_allow_html=True)
with st.form("login_form", clear_on_submit=False):
username = st.text_input("아이디", placeholder="username", key="login_user")
password = st.text_input("비밀번호", type="password", placeholder="password", key="login_pw")
submitted = st.form_submit_button("로그인", use_container_width=True, type="primary")
if submitted:
user = users_db.authenticate(username.strip(), password)
if user:
st.session_state.user = user
st.rerun()
else:
st.error("아이디 또는 비밀번호가 올바르지 않습니다.")
st.markdown('</div>', unsafe_allow_html=True)
def render_my_info_page():
st.markdown(
'<div style="display:flex; align-items:flex-end; justify-content:space-between; '
'padding:0 0 8px 0; border-bottom:1px solid #e5e7eb; margin-bottom:14px;">'
'<div style="font-size:20px; font-weight:800; color:#111827;">👤 개인정보 수정</div>'
'<div style="font-size:11px; color:#6b7280;">비밀번호 변경</div>'
'</div>',
unsafe_allow_html=True,
)
user = st.session_state.get("user", {})
col_l, col_r = st.columns([2, 1], gap="medium")
with col_l:
with st.container(border=True):
st.markdown("###### 계정 정보")
cc1, cc2 = st.columns(2)
with cc1:
st.text_input("아이디", value=user.get("username", ""), disabled=True)
with cc2:
st.text_input("권한", value=user.get("role", ""), disabled=True)
st.caption(f"가입: {user.get('created_at', '-')} · 마지막 로그인: {user.get('last_login_at', '-')}")
with st.container(border=True):
st.markdown("###### 비밀번호 변경")
with st.form("change_pw_form", clear_on_submit=True):
old_pw = st.text_input("현재 비밀번호", type="password")
new_pw = st.text_input("새 비밀번호 (6자 이상)", type="password")
new_pw2 = st.text_input("새 비밀번호 확인", type="password")
submitted = st.form_submit_button("비밀번호 변경", type="primary",
use_container_width=True)
if submitted:
if new_pw != new_pw2:
st.error("새 비밀번호가 일치하지 않습니다.")
elif len(new_pw) < 6:
st.error("새 비밀번호는 6자 이상이어야 합니다.")
elif users_db.change_password(user.get("username", ""), old_pw, new_pw):
st.success("✅ 비밀번호 변경 완료. 다음 로그인부터 새 비밀번호 사용.")
else:
st.error("현재 비밀번호가 올바르지 않습니다.")
with col_r:
with st.container(border=True):
st.markdown("###### 등록된 사용자")
users = users_db.list_users()
if users:
df_u = pd.DataFrame(users)[["username", "role", "last_login_at"]]
st.dataframe(df_u, use_container_width=True, hide_index=True)
else:
st.info("사용자 목록을 불러오지 못함.")
def render_sidebar() -> str:
"""fito 스타일 다크 사이드바.
- full 모드: 240px, 헤더 + 아이콘+텍스트 메뉴 + 푸터
- mini 모드: 60px, 햄버거 + 정사각형 아이콘 버튼 (텍스트 없음, hover tooltip)
- Streamlit 기본 collapse 버튼은 숨김 (혼란 방지)
"""
try:
from streamlit_option_menu import option_menu
HAS_OPTION_MENU = True
except ImportError:
HAS_OPTION_MENU = False
if "sidebar_mini" not in st.session_state:
st.session_state.sidebar_mini = False
if "current_page" not in st.session_state:
st.session_state.current_page = "dashboard"
mini = st.session_state.sidebar_mini
sidebar_width = "64px" if mini else "260px"
st.markdown(f"""
<style>
/* Streamlit 1.57 sidebar collapse 토글 완전 무력화 — 우리 햄버거만 사용 */
[data-testid="stSidebarCollapseButton"],
[data-testid="stSidebarCollapsed"],
[data-testid="collapsedControl"],
button[kind="header"],
button[data-testid="baseButton-header"] {{ display: none !important; }}
/* 사이드바 — 모든 viewport 강제 표시 + 너비 고정 (mini/full) */
[data-testid="stSidebar"] {{
background-color: #1f2937 !important;
width: {sidebar_width} !important;
min-width: {sidebar_width} !important;
max-width: {sidebar_width} !important;
transform: none !important;
visibility: visible !important;
margin-left: 0 !important;
transition: width 0.2s ease;
position: relative !important;
}}
[data-testid="stSidebarContent"],
[data-testid="stSidebar"] > div:first-child {{
background-color: #1f2937 !important;
padding: 0 !important;
width: {sidebar_width} !important;
}}
[data-testid="stSidebar"] section {{ padding-top: 0 !important; }}
[data-testid="stSidebar"] * {{ color: #e5e7eb !important; }}
@media (max-width: 767px) {{
[data-testid="stAppViewContainer"] > section.main {{ margin-left: 0 !important; }}
}}
/* 헤더 영역 — full mode */
.sb-header-full {{
display: flex; align-items: center; justify-content: space-between;
padding: 14px 12px 12px 16px;
border-bottom: 1px solid #374151;
}}
.sb-header-full .brand {{
color: #60a5fa !important; font-weight: 800; font-size: 18px; letter-spacing: 1px;
line-height: 1.1;
}}
.sb-header-full .subtitle {{
color: #9ca3af !important; font-size: 11px; margin-top: 2px;
}}
/* 헤더 — mini mode (햄버거만) */
.sb-header-mini {{
display: flex; justify-content: center; align-items: center;
padding: 14px 0 12px 0;
border-bottom: 1px solid #374151;
}}
/* 토글 버튼 — 사이드바 안 stButton 스타일 */
[data-testid="stSidebar"] .stButton > button {{
background: transparent !important;
border: none !important;
color: #9ca3af !important;
font-size: 18px !important;
padding: 6px !important;
min-height: 36px !important;
border-radius: 6px !important;
}}
[data-testid="stSidebar"] .stButton > button:hover {{
background: #374151 !important;
color: #ffffff !important;
}}
/* mini mode 아이콘 버튼 정사각형 */
[data-testid="stSidebar"] .stButton > button p {{
font-size: 22px !important; line-height: 1 !important; margin: 0 !important;
}}
.mini-active > div > button {{
background: #2563eb !important;
color: #ffffff !important;
}}
.mini-active > div > button p {{
color: #ffffff !important;
}}
/* 푸터 */
.sb-footer {{
padding: 10px 16px; border-top: 1px solid #374151;
font-size: 11px; color: #6b7280 !important; margin-top: 14px;
}}
</style>
""", unsafe_allow_html=True)
labels = ["대시보드", "트레이드 이력", "거래소 API", "자동매매", "시스템 설정", "내 정보"]
keys = ["dashboard", "trades", "exchange_keys", "automation", "settings", "my_info"]
bs_icons = ["bar-chart-line", "graph-up-arrow", "key", "robot", "gear", "person-circle"]
emoji_icons = ["📊", "📈", "🔑", "🤖", "⚙️", "👤"]
with st.sidebar:
if mini:
# ── mini 헤더: 햄버거 토글만 (가운데) ──
st.markdown('<div class="sb-header-mini"></div>', unsafe_allow_html=True)
if st.button("", key="sidebar_toggle_mini",
use_container_width=True, help="메뉴 펼치기"):
st.session_state.sidebar_mini = False
st.rerun()
# ── mini 메뉴: 정사각형 아이콘 버튼 (텍스트 없음, hover tooltip) ──
for label, key, emoji in zip(labels, keys, emoji_icons):
active = (st.session_state.current_page == key)
if active:
st.markdown('<div class="mini-active">', unsafe_allow_html=True)
if st.button(emoji, key=f"mini_{key}",
use_container_width=True, help=label):
st.session_state.current_page = key
st.rerun()
if active:
st.markdown('</div>', unsafe_allow_html=True)
else:
# ── full 헤더: SVG 로고 (좌) + 햄버거 (우) ──
head_col1, head_col2 = st.columns([5, 1], gap="small")
with head_col1:
logo_svg = load_logo_svg()
if logo_svg:
st.markdown(
f'<div style="padding:8px 0 0 6px;">{logo_svg}</div>',
unsafe_allow_html=True,
)
else:
st.markdown(
'<div style="padding:8px 0 0 6px;">'
'<div style="color:#60a5fa; font-weight:800; font-size:18px;">JUNGGOMOA</div>'
'<div style="color:#9ca3af; font-size:11px;">트레이딩 시스템</div>'
'</div>',
unsafe_allow_html=True,
)
with head_col2:
st.markdown('<div style="padding-top:8px;"></div>', unsafe_allow_html=True)
if st.button("", key="sidebar_toggle_full", help="메뉴 접기"):
st.session_state.sidebar_mini = True
st.rerun()
st.markdown('<hr style="border:none; border-top:1px solid #374151; margin:6px 0 4px 0;"/>',
unsafe_allow_html=True)
# ── full 메뉴 ──
if HAS_OPTION_MENU:
try:
default_idx = keys.index(st.session_state.current_page)
except ValueError:
default_idx = 0
choice = option_menu(
menu_title=None,
options=labels,
icons=bs_icons,
default_index=default_idx,
key="full_menu",
styles={
"container": {"padding": "0", "background-color": "#1f2937"},
"icon": {"color": "#60a5fa", "font-size": "16px"},
"nav-link": {
"color": "#e5e7eb",
"font-size": "14px",
"text-align": "left",
"margin": "2px 6px",
"padding": "10px 12px",
"border-radius": "6px",
"--hover-color": "#374151",
"font-family": "'Noto Sans KR', sans-serif",
},
"nav-link-selected": {
"background-color": "#2563eb",
"color": "#ffffff",
"font-weight": "600",
},
},
)
st.session_state.current_page = keys[labels.index(choice)] if choice in labels else "dashboard"
else:
try:
idx = keys.index(st.session_state.current_page)
except ValueError:
idx = 0
choice = st.radio("menu", labels, index=idx, label_visibility="collapsed")
st.session_state.current_page = keys[labels.index(choice)]
# ── 푸터: 아바타 + username + 로그아웃 ──
user = st.session_state.get("user", {})
uname = user.get("username", "guest")
initial = uname[0].upper() if uname else "?"
st.markdown(
f'<div style="margin-top:18px; padding:12px 14px; border-top:1px solid #374151; '
f'display:flex; align-items:center; gap:10px;">'
f' <div style="width:30px; height:30px; border-radius:50%; '
f' background:linear-gradient(135deg, #3b82f6, #60a5fa); '
f' display:flex; align-items:center; justify-content:center; '
f' color:white; font-weight:700; font-size:13px;">{initial}</div>'
f' <div style="flex:1; min-width:0;">'
f' <div style="font-size:12px; color:#e5e7eb; font-weight:600; '
f' white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{uname}</div>'
f' <div style="font-size:10px; color:#9ca3af;">{user.get("role", "user")}</div>'
f' </div>'
f'</div>',
unsafe_allow_html=True,
)
if st.button("로그아웃", key="sidebar_logout", use_container_width=True):
st.session_state.pop("user", None)
st.session_state.current_page = "dashboard"
st.rerun()
return st.session_state.current_page
def render_trades_page():
st.markdown("## 📈 트레이드 이력")
st.caption("DB 에 기록된 진입 → 청산 lifecycle. 손절(stop_loss) / 반대신호(reversal) / 취소(cancelled) 별로 분석.")
if not trades_db._enabled():
st.warning("DATABASE_URL 미설정. PostgreSQL 컨테이너가 떠있어야 트레이드 이력이 기록됩니다.")
st.code("docker compose up -d postgres", language="bash")
return
rows = trades_db.fetch_trades(limit=500)
if not rows:
st.info("아직 기록된 트레이드 없음. 진입 신호가 발사되면 자동으로 누적됩니다.")
return
df = pd.DataFrame(rows)
# 정렬 / 표시 컬럼 정리
display_cols = ["entry_time", "symbol", "interval", "direction", "signal_types",
"entry_price", "stop_price", "status", "exit_time", "exit_price",
"exit_reason", "pnl_pct"]
display_cols = [c for c in display_cols if c in df.columns]
df_disp = df[display_cols].copy()
# ── 요약 메트릭 ──
closed = df[df["status"].isin(["stop_loss", "reversal", "cancelled"])]
open_count = int((df["status"] == "open").sum())
total = len(df)
if len(closed) > 0:
wins = int((closed["pnl_pct"] > 0).sum())
losses = int((closed["pnl_pct"] <= 0).sum())
win_rate = wins / len(closed) * 100
avg_pnl = float(closed["pnl_pct"].mean())
cum_pnl = float(closed["pnl_pct"].sum())
else:
wins = losses = 0
win_rate = avg_pnl = cum_pnl = 0.0
m1, m2, m3, m4, m5, m6 = st.columns(6)
m1.metric("총 트레이드", total)
m2.metric("진행 중", open_count)
m3.metric("종료", len(closed))
m4.metric("승률", f"{win_rate:.1f}%", f"{wins}W / {losses}L")
m5.metric("평균 PnL%", f"{avg_pnl:+.2f}%")
m6.metric("누적 PnL%", f"{cum_pnl:+.2f}%")
st.markdown("---")
# ── 누적 PnL 차트 ──
if len(closed) > 0:
c2 = closed.sort_values("exit_time").copy()
c2["cum_pnl"] = c2["pnl_pct"].cumsum()
fig = go.Figure()
fig.add_trace(go.Scatter(x=c2["exit_time"], y=c2["cum_pnl"],
mode="lines+markers",
line=dict(color="#2962ff", width=2),
marker=dict(
color=["#26a69a" if v > 0 else "#ef5350" for v in c2["pnl_pct"]],
size=6),
name="누적 PnL%"))
fig.add_hline(y=0, line=dict(color="#888", width=0.6, dash="dash"))
fig.update_layout(
height=320,
paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
font=dict(color="#131722", size=11,
family="'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif"),
margin=dict(l=40, r=20, t=30, b=30),
xaxis_title="청산 시각", yaxis_title="누적 PnL %",
)
st.plotly_chart(fig, use_container_width=True)
# ── 시간축 / 신호별 승률 ──
c3, c4 = st.columns(2)
with c3:
st.markdown("##### 시간축별 승률")
by_iv = closed.groupby("interval").agg(
n=("pnl_pct", "size"),
wins=("pnl_pct", lambda s: int((s > 0).sum())),
avg_pnl=("pnl_pct", "mean"),
sum_pnl=("pnl_pct", "sum"),
).reset_index()
by_iv["win_rate%"] = (by_iv["wins"] / by_iv["n"] * 100).round(1)
st.dataframe(by_iv, use_container_width=True, hide_index=True)
with c4:
st.markdown("##### 청산 사유별 분포")
by_reason = closed.groupby("exit_reason").agg(
n=("pnl_pct", "size"),
avg_pnl=("pnl_pct", "mean"),
sum_pnl=("pnl_pct", "sum"),
).reset_index()
st.dataframe(by_reason, use_container_width=True, hide_index=True)
# ── 시간축별 PnL 막대 ──
st.markdown("##### 시간축 × 방향별 누적 PnL%")
bar = closed.groupby(["interval", "direction"])["pnl_pct"].sum().reset_index()
fig2 = go.Figure()
for d, color in [("long", "#26a69a"), ("short", "#ef5350")]:
sub = bar[bar["direction"] == d]
fig2.add_trace(go.Bar(x=sub["interval"], y=sub["pnl_pct"],
name=d, marker_color=color))
fig2.update_layout(
barmode="group", height=300,
paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
font=dict(color="#131722", size=11,
family="'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif"),
margin=dict(l=40, r=20, t=10, b=30),
yaxis_title="누적 PnL %",
)
st.plotly_chart(fig2, use_container_width=True)
st.markdown("---")
st.markdown("### 🧾 최근 트레이드 (최대 500건)")
st.dataframe(df_disp, use_container_width=True, hide_index=True)
def _section_header(emoji: str, title: str, subtitle: str = ""):
sub = f' <span style="color:#9ca3af; font-size:12px; font-weight:400; margin-left:6px;">{subtitle}</span>' if subtitle else ""
st.markdown(
f'<div style="display:flex; align-items:center; margin:-4px 0 10px 0;">'
f'<div style="font-size:15px; font-weight:700; color:#1f2937;">{emoji} {title}</div>'
f'{sub}'
f'</div>',
unsafe_allow_html=True,
)
def render_settings_page():
# 페이지 헤더 — 컴팩트 (한 줄)
st.markdown(
'<div style="display:flex; align-items:flex-end; justify-content:space-between; '
'padding:0 0 8px 0; border-bottom:1px solid #e5e7eb; margin-bottom:10px;">'
'<div style="font-size:20px; font-weight:800; color:#111827;">⚙️ 시스템 설정</div>'
'<div style="font-size:11px; color:#6b7280;">DB 영속 저장 · 저장 즉시 반영</div>'
'</div>',
unsafe_allow_html=True,
)
cur = settings_db.all_settings()
with st.form("settings_form", clear_on_submit=False):
# 5개 탭으로 분리 — 한 번에 한 섹션만 보여 한 화면(1080) fit
tab_tg, tab_alert, tab_signal, tab_vol, tab_chart = st.tabs(
["📨 텔레그램", "🔔 알림 / 모니터링", "🎯 신호 임계값", "💧 거래량 / 펀딩비", "📊 차트"]
)
# ── 텔레그램 ──
with tab_tg:
st.markdown("###### Telegram Bot 설정")
col_a, col_b = st.columns(2)
with col_a:
# type=password 제거 — 사용자 요청대로 plain text 로 보이게
token = st.text_input("Bot Token", value=cur.get("telegram_token", ""),
placeholder="예: 1234567890:ABCDEF...")
with col_b:
chat_id = st.text_input("Chat ID", value=cur.get("telegram_chat_id", ""),
placeholder="예: -1001234567890 또는 본인 user id")
st.caption("⚠️ Token 은 plain text 로 표시됩니다 (DB 저장 후엔 다시 보임). 노출 주의.")
# ── 알림 / 모니터링 ──
with tab_alert:
col_c, col_d = st.columns(2)
with col_c:
symbol_default = cur.get("alert_symbol", "BTCUSDT")
symbol_options = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"]
if symbol_default not in symbol_options:
symbol_options.insert(0, symbol_default)
symbol = st.selectbox("모니터링 심볼", symbol_options,
index=symbol_options.index(symbol_default))
with col_d:
tf_options = ["1m", "3m", "5m", "15m", "30m", "1h", "4h"]
tf_current = [t for t in cur.get("alert_timeframes", "5m,15m,30m,1h").split(",") if t.strip()]
tf_selected = st.multiselect("알림 시간축", tf_options, default=tf_current)
col_e, col_f, col_g, col_j = st.columns(4)
with col_e:
cooldown = st.number_input("쿨다운(초)", 30, 3600,
int(cur.get("alert_cooldown_sec", "600") or 600))
with col_f:
sl_pct_pct = st.number_input("손절(%)", 0.05, 5.0,
float(cur.get("stop_loss_pct", "0.0075") or 0.0075) * 100, step=0.05)
with col_g:
poll_sec = st.number_input("폴링(초)", 10, 300,
int(cur.get("polling_interval_sec", "30") or 30))
with col_j:
forming_polls = st.number_input("forming polls", 1, 10,
int(cur.get("forming_stable_polls", "2") or 2))
col_h, col_i = st.columns(2)
with col_h:
alert_enabled = st.checkbox("✅ 알림 활성화", value=cur.get("alert_enabled", "1") == "1")
with col_i:
daily_enabled = st.checkbox("📅 일일 리포트 활성화", value=cur.get("daily_report_enabled", "1") == "1")
# ── 신호 임계값 ──
with tab_signal:
st.markdown("###### RSI 임계값")
c1, c2, c3, c4 = st.columns(4)
with c1:
long_rsi_max = st.number_input("일반 롱 RSI ≤", 30.0, 100.0,
float(cur.get("long_rsi_max", "75")))
with c2:
short_rsi_min = st.number_input("일반 숏 RSI ≥", 0.0, 70.0,
float(cur.get("short_rsi_min", "25")))
with c3:
slong_rsi_max = st.number_input("강한 롱 RSI ≤", 30.0, 100.0,
float(cur.get("strong_long_rsi_max", "65")))
with c4:
sshort_rsi_min = st.number_input("강한 숏 RSI ≥", 0.0, 70.0,
float(cur.get("strong_short_rsi_min", "35")))
st.markdown("###### 캔들 body / 추세 꺾임")
c5, c6, c7 = st.columns(3)
with c5:
body_pct_min = st.number_input("body 최소(%)", 0.0, 5.0,
float(cur.get("body_pct_min", "0.002")) * 100,
step=0.05) / 100
with c6:
rev_body_pct = st.number_input("추세 꺾임 body(%)", 0.0, 5.0,
float(cur.get("reversal_body_pct", "0.003")) * 100,
step=0.05) / 100
with c7:
rev_vol_mult = st.number_input("추세 꺾임 vol 배수", 1.0, 10.0,
float(cur.get("reversal_vol_mult", "1.3")), step=0.1)
# ── 거래량 / 펀딩비 ──
with tab_vol:
st.markdown("###### 거래량 배수")
c1, c2, c3 = st.columns(3)
with c1:
vol_exh = st.number_input("Exhaustion 배수", 1.5, 20.0,
float(cur.get("vol_exhaustion_mult", "3.0")), step=0.5)
with c2:
vol_net = st.number_input("vol Net 배수", 1.0, 10.0,
float(cur.get("vol_net_mult", "2.0")), step=0.1)
with c3:
oi_active = st.number_input("OI 활성도(%)", 0.0, 5.0,
float(cur.get("oi_active_pct", "0.001")) * 100,
step=0.05) / 100
st.markdown("###### 펀딩비 임계 (단위: %)")
c4, c5, c6 = st.columns(3)
with c4:
fr_overheat = st.number_input("롱 과열 FR (≥)", 0.0, 1.0,
float(cur.get("fr_long_overheat", "0.005")),
step=0.001, format="%.4f")
with c5:
fr_caution = st.number_input("숏 경보 FR (≤)", -1.0, 0.0,
float(cur.get("fr_short_caution", "-0.005")),
step=0.001, format="%.4f")
with c6:
fr_extreme = st.number_input("숏 주의 FR (≤)", -1.0, 0.0,
float(cur.get("fr_short_extreme", "-0.007")),
step=0.001, format="%.4f")
# ── 차트 ──
with tab_chart:
st.markdown("###### 한 화면 캔들 수")
c1, c2 = st.columns(2)
with c1:
cl_desktop = st.number_input("데스크톱", 10, 500,
int(cur.get("candle_limit_desktop", "53")))
with c2:
cl_mobile = st.number_input("모바일", 5, 200,
int(cur.get("candle_limit_mobile", "14")))
# ── 저장 / 테스트 버튼 (탭 밖 하단) ──
st.markdown('<div style="margin-top:14px;"></div>', unsafe_allow_html=True)
bcol1, bcol2, _ = st.columns([2, 1, 3])
with bcol1:
submitted = st.form_submit_button("💾 전체 설정 저장",
use_container_width=True, type="primary")
with bcol2:
test_msg = st.form_submit_button("🧪 텔레그램 테스트", use_container_width=True)
if submitted or test_msg:
saves = {
"telegram_token": token.strip(),
"telegram_chat_id": chat_id.strip(),
"alert_symbol": symbol,
"alert_timeframes": ",".join(tf_selected) if tf_selected else "5m,15m,30m,1h",
"alert_cooldown_sec": int(cooldown),
"stop_loss_pct": f"{sl_pct_pct/100:.6f}",
"polling_interval_sec": int(poll_sec),
"alert_enabled": "1" if alert_enabled else "0",
"daily_report_enabled": "1" if daily_enabled else "0",
"forming_stable_polls": int(forming_polls),
"long_rsi_max": long_rsi_max,
"short_rsi_min": short_rsi_min,
"strong_long_rsi_max": slong_rsi_max,
"strong_short_rsi_min": sshort_rsi_min,
"body_pct_min": f"{body_pct_min:.6f}",
"reversal_body_pct": f"{rev_body_pct:.6f}",
"reversal_vol_mult": rev_vol_mult,
"vol_exhaustion_mult": vol_exh,
"vol_net_mult": vol_net,
"oi_active_pct": f"{oi_active:.6f}",
"fr_long_overheat": f"{fr_overheat:.6f}",
"fr_short_caution": f"{fr_caution:.6f}",
"fr_short_extreme": f"{fr_extreme:.6f}",
"candle_limit_desktop": int(cl_desktop),
"candle_limit_mobile": int(cl_mobile),
}
for k, v in saves.items():
settings_db.set_value(k, v)
with alert_state.alert_lock:
alert_state.alert_symbol = symbol
if test_msg:
send_telegram("✅ junggomoa.com 대시보드 — 설정 저장 + 테스트 메시지")
st.toast("텔레그램 테스트 발송 + 설정 저장", icon="📨")
else:
st.toast(f"{len(saves)}개 항목 저장됨", icon="")
def render_exchange_keys_page():
st.markdown("## 🔑 거래소 API 키")
st.caption("거래소별 API Key / Secret 을 Fernet 으로 암호화하여 PostgreSQL 에 영속 저장. 자동매매 시 활성 키로 주문 발사.")
if not exchange_keys._enabled():
st.warning("DATABASE_URL 또는 cryptography 패키지 미설정. 컨테이너 재기동 / 의존성 확인 필요.")
return
creds = exchange_keys.list_credentials()
st.markdown("### 새 키 등록")
with st.form("new_cred", clear_on_submit=True):
c1, c2, c3 = st.columns([1, 1, 1])
with c1:
ex = st.selectbox("거래소", exchange_keys.SUPPORTED_EXCHANGES)
with c2:
label = st.text_input("Label", placeholder="예: main / sub / strategy_A")
with c3:
testnet = st.checkbox("Testnet", value=False)
c4, c5 = st.columns(2)
with c4:
api_key = st.text_input("API Key", type="password")
with c5:
api_secret = st.text_input("API Secret", type="password")
passphrase = st.text_input("Passphrase (OKX/Bitget 만 필요)", type="password",
placeholder="해당 거래소가 아니면 비워두세요")
submitted = st.form_submit_button("등록", use_container_width=True, type="primary")
if submitted:
if not api_key or not api_secret:
st.error("API Key / Secret 둘 다 입력 필수")
else:
cid = exchange_keys.add_credential(ex, label, api_key, api_secret,
passphrase or None, testnet, True)
if cid:
st.success(f"✅ 등록 완료 (id={cid}). 페이지 새로고침으로 목록에 반영.")
else:
st.error("등록 실패. 컨테이너 로그 확인.")
st.markdown("---")
st.markdown(f"### 📒 등록된 키 ({len(creds)})")
if not creds:
st.info("아직 등록된 키 없음.")
return
for c in creds:
with st.expander(
f"`#{c['id']}` **{c['exchange'].upper()}** [{c['label'] or '-'}] "
f"{'🧪TESTNET' if c['testnet'] else '🟢LIVE'} {'' if c['enabled'] else '⏸️'}",
):
cc1, cc2 = st.columns(2)
with cc1:
st.code(f"API Key: {c['api_key_masked']}")
with cc2:
st.code(f"Secret: {c['api_secret_masked']}")
if c.get("passphrase_masked"):
st.code(f"Passphrase: {c['passphrase_masked']}")
st.caption(f"등록: {c['created_at']} / 수정: {c['updated_at']}")
colx, coly, colz = st.columns(3)
with colx:
new_enabled = st.checkbox("활성", value=c["enabled"], key=f"en_{c['id']}")
if new_enabled != c["enabled"]:
if exchange_keys.update_credential(c["id"], enabled=new_enabled):
st.success("상태 변경 — 새로고침 시 반영")
with coly:
new_testnet = st.checkbox("Testnet", value=c["testnet"], key=f"tn_{c['id']}")
if new_testnet != c["testnet"]:
if exchange_keys.update_credential(c["id"], testnet=new_testnet):
st.success("Testnet 변경")
with colz:
if st.button("🗑️ 삭제", key=f"del_{c['id']}"):
if exchange_keys.delete_credential(c["id"]):
st.success("삭제 완료. 새로고침으로 목록 반영.")
def render_automation_page():
st.markdown("## 🤖 자동매매 설정")
st.caption("⚠️ 현재 어댑터는 **DRY-RUN 더미** (실제 거래소 주문 미연결). 인터페이스 / 설정 / 키 관리만 갖춰진 상태. "
"실 주문 연결은 추후 거래소별 SDK 어댑터 추가 후 활성화.")
if not exchange_keys._enabled():
st.warning("DATABASE_URL 또는 cryptography 미설정.")
return
cfg = exchange_keys.automation_all()
creds = exchange_keys.list_credentials()
cred_options = {f"#{c['id']} {c['exchange'].upper()} [{c['label'] or '-'}] {'🧪' if c['testnet'] else ''}": str(c["id"])
for c in creds if c["enabled"]}
cred_labels = list(cred_options.keys())
with st.form("automation_form"):
c1, c2, c3 = st.columns(3)
with c1:
enabled = st.checkbox("자동매매 ON", value=cfg.get("enabled", "0") == "1",
help="글로벌 킬스위치. OFF 면 시그널만 기록.")
with c2:
dry_run = st.checkbox("DRY-RUN (실 주문 X)", value=cfg.get("dry_run", "1") == "1",
help="ON 권장. 어댑터가 stdout 으로만 출력.")
with c3:
allowed_dirs = st.selectbox("허용 방향",
["long,short", "long", "short"],
index=["long,short", "long", "short"].index(cfg.get("allowed_directions", "long,short")) if cfg.get("allowed_directions", "long,short") in ["long,short", "long", "short"] else 0)
st.markdown("##### 활성 키")
if cred_labels:
cur_id = cfg.get("active_credential", "")
cur_label = next((k for k, v in cred_options.items() if v == cur_id), cred_labels[0])
active_label = st.selectbox("활성 거래소 키", cred_labels, index=cred_labels.index(cur_label))
active_id = cred_options[active_label]
else:
st.info("등록된 활성 키가 없습니다. '🔑 거래소 API' 페이지에서 먼저 등록하세요.")
active_id = ""
st.markdown("##### 포지션 / 리스크")
c4, c5, c6, c7 = st.columns(4)
with c4:
leverage = st.number_input("레버리지", 1, 125, int(cfg.get("leverage", "10")))
with c5:
pos_pct = st.number_input("포지션 크기 (잔고%)",
0.1, 100.0, float(cfg.get("position_size_pct", "1.0")), step=0.1)
with c6:
max_open = st.number_input("동시 진입 최대", 1, 20, int(cfg.get("max_open_trades", "3")))
with c7:
min_score = st.number_input("최소 신호 score", 1, 5, int(cfg.get("min_signal_score", "1")),
help="동시 발사된 신호 수가 N 이상일 때만 진입 (예: 2 = 강한+일반 동시)")
c8, _ = st.columns(2)
with c8:
tp_pct = st.number_input("Take Profit (%, 0=OFF)",
0.0, 100.0, float(cfg.get("tp_pct", "0.0")), step=0.1)
submitted = st.form_submit_button("💾 자동매매 설정 저장", use_container_width=True, type="primary")
if submitted:
settings = {
"enabled": "1" if enabled else "0",
"dry_run": "1" if dry_run else "0",
"active_credential": active_id,
"leverage": leverage,
"position_size_pct": pos_pct,
"max_open_trades": max_open,
"min_signal_score": min_score,
"allowed_directions": allowed_dirs,
"tp_pct": tp_pct,
}
for k, v in settings.items():
exchange_keys.automation_set(k, v)
st.success("✅ 저장 완료.")
st.markdown("---")
st.markdown("### 🧪 어댑터 테스트 (DRY-RUN)")
if st.button("get_balance 호출"):
if not active_id:
st.error("활성 키 미선택")
else:
cred = exchange_keys.get_credential(int(active_id))
adapter = exchange_adapters.make_adapter(cred, dry_run=True)
bal = adapter.get_balance("USDT")
st.code(f"adapter.get_balance('USDT') -> {bal}")
st.markdown("---")
st.markdown("### 📋 현재 자동매매 설정")
st.json(cfg)
def main():
# ── 로그인 게이트 (st.stop 으로 강제 중단 — 다른 위젯 렌더 차단) ──
if not st.session_state.get("user"):
render_login_page()
st.stop()
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
page = render_sidebar()
if page == "settings":
render_settings_page()
return
if page == "trades":
render_trades_page()
return
if page == "exchange_keys":
render_exchange_keys_page()
return
if page == "automation":
render_automation_page()
return
if page == "my_info":
render_my_info_page()
return
# 대시보드 진입 시 DB 의 alert_symbol 을 기본 심볼로 사용
if not alert_state.alert_symbol or alert_state.alert_symbol == "BTCUSDT":
alert_state.alert_symbol = settings_db.get("alert_symbol", "BTCUSDT")
col1, col2, col3, col4, col5 = st.columns([2, 1, 1, 1, 2])
with col1:
st.markdown("### 📊 선물 대시보드")
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)
cl_desktop = settings_db.get_int("candle_limit_desktop", 53)
cl_mobile = settings_db.get_int("candle_limit_mobile", 14)
candle_limit = cl_mobile if mobile_mode else cl_desktop
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]
fr_extreme = settings_db.get_float("fr_short_extreme", -0.007)
fr_caution = settings_db.get_float("fr_short_caution", -0.005)
fr_overheat = settings_db.get_float("fr_long_overheat", 0.005)
if rate <= fr_extreme:
st.error(f"🚨 극단적 숏스퀴즈 위험 | FR: {rate:.4f}% | 숏 신규진입 절대 금지")
elif rate <= fr_caution:
st.warning(f"⚠️ 숏스퀴즈 경보 구간 | FR: {rate:.4f}% | 숏 진입 시 청산가 재확인 필수")
elif rate >= fr_overheat:
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()