c4e6aab7b2
- 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>
1998 lines
97 KiB
Python
1998 lines
97 KiB
Python
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()
|