사용자별 격리 시스템 + 사용자 관리 + 라이브 PnL%

# 사용자별 격리
- JWT 토큰에 uid 추가 (auth.get_uid 헬퍼)
- PostgreSQL — exchange_credentials/automation_config/trades/signal_events 에 user_id BIGINT
- SQLite user_settings 테이블 신설 (글로벌 settings 는 옛 호환)
- 모든 DB 함수 시그니처에 user_id 인자 추가 — 다른 사용자 데이터 절대 접근 불가
- alert_state — 모든 dict key 가 (user_id, ...) tuple 로 계층화
- core_logic alert_loop — 활성 사용자 순회 + 각자 settings/symbol/텔레그램 적용
- ensure_user_defaults() / ensure_user_automation() — 첫 사용 시 자동 시드

# 사용자 관리 (admin only)
- users_db: delete_user / admin_reset_password / set_role
- /api/users POST DELETE PUT password PUT role (본인 강등 / 마지막 admin 보호)
- /admin/users 페이지 — 등록/삭제/role 토글/비번 reset 모달
- 사이드바 adminOnly 필터 — admin role 만 메뉴 노출

# 대시보드 개선
- 모바일 / 범례 토글 (모바일 60 캔들, 데스크톱 200)
- 트레이드 이력: open 트레이드 실시간 PnL% (Binance ticker 호출 + 방향별 계산)
- 메트릭 카드 분리 (실거래 vs 실시간 open)

# 안정성
- api.ts: error.detail array/object 안전 처리 ([object Object] 방지)
- Chart.tsx: Plotly yaxis title 객체 형태 + 모바일 height 동적 조정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-22 12:14:23 +09:00
parent c330647453
commit d16456cb92
20 changed files with 934 additions and 344 deletions
+119 -114
View File
@@ -1,11 +1,9 @@
"""
Streamlit 의존 없는 핵심 비즈니스 로직.
- Binance Futures API 데이터 수집
- 지표 / 신호 계산
- 알림 (텔레그램) + 트레이드 lifecycle 기록
- 알림 / 일일 리포트 백그라운드 루프
Streamlit 의존 없는 핵심 비즈니스 로직 — 사용자별 격리 버전.
모든 알림 / 진입 추적 / 텔레그램 / 일일 리포트는 user_id 단위로 동작.
기존 app_streamlit.py 에서 그대로 추출. FastAPI 에서도 그대로 import.
알림 루프는 사용자 순회 — alert_enabled=1 인 모든 사용자의 settings 를
각자 적용해서 신호 계산 / 알림 / DB 기록.
"""
import os
import time
@@ -49,28 +47,27 @@ SIG_DEFS = [
]
# ── 설정 helpers ──
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)
# ── 사용자별 설정 helpers ──
def TELEGRAM_TOKEN(user_id): return settings_db.get("telegram_token", "", user_id=user_id)
def TELEGRAM_CHAT_ID(user_id): return settings_db.get("telegram_chat_id", "", user_id=user_id)
def ALERT_COOLDOWN(user_id): return settings_db.get_int("alert_cooldown_sec", 600, user_id=user_id)
def STOP_LOSS_PCT_v(user_id): return settings_db.get_float("stop_loss_pct", 0.0075, user_id=user_id)
# ── 텔레그램 ──
def send_telegram(message: str):
token = TELEGRAM_TOKEN()
chat_id = TELEGRAM_CHAT_ID()
def send_telegram(user_id: int, message: str):
token = TELEGRAM_TOKEN(user_id)
chat_id = TELEGRAM_CHAT_ID(user_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}")
print(f"[텔레그램 오류 user={user_id}] {e}")
# ── Binance Futures fetch ──
# ── Binance Futures fetch (글로벌, 사용자 무관) ──
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)
@@ -122,8 +119,8 @@ 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 compute_indicators(df, interval="5m"):
# ── 지표 + 신호 (사용자별 임계값 적용) ──
def compute_indicators(df, interval="5m", user_id=None):
c = df["close"]
df["MA7"] = c.rolling(7).mean()
df["MA25"] = c.rolling(25).mean()
@@ -142,22 +139,22 @@ def compute_indicators(df, interval="5m"):
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)
df = compute_signals(df, interval, user_id=user_id)
return df
def compute_signals(df, interval="5m"):
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)
def compute_signals(df, interval="5m", user_id=None):
LONG_RSI_MAX = settings_db.get_float("long_rsi_max", 75.0, user_id=user_id)
SHORT_RSI_MIN = settings_db.get_float("short_rsi_min", 25.0, user_id=user_id)
SLONG_RSI_MAX = settings_db.get_float("strong_long_rsi_max", 65.0, user_id=user_id)
SSHORT_RSI_MIN = settings_db.get_float("strong_short_rsi_min", 35.0, user_id=user_id)
BODY_PCT_MIN = settings_db.get_float("body_pct_min", 0.002, user_id=user_id)
REV_BODY_PCT = settings_db.get_float("reversal_body_pct", 0.003, user_id=user_id)
REV_VOL_MULT = settings_db.get_float("reversal_vol_mult", 1.3, user_id=user_id)
VOL_EXH_MULT = settings_db.get_float("vol_exhaustion_mult", 3.0, user_id=user_id)
VOL_NET_MULT = settings_db.get_float("vol_net_mult", 2.0, user_id=user_id)
OI_ACTIVE_PCT = settings_db.get_float("oi_active_pct", 0.001, user_id=user_id)
FR_SHORT_EXTREME = settings_db.get_float("fr_short_extreme", -0.007, user_id=user_id)
df["bull_ma_2"] = (df["close"] > df["MA7"]) & (df["close"] > df["MA25"])
df["bear_ma_2"] = (df["close"] < df["MA7"]) & (df["close"] < df["MA25"])
@@ -249,8 +246,7 @@ def compute_signals(df, interval="5m"):
return df
def build_signal_df(symbol, interval, klines_limit=200):
"""알림 / API 공용 - klines + OI + FR 머지 + 지표/신호 계산"""
def build_signal_df(symbol, interval, klines_limit=200, user_id=None):
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:
@@ -273,31 +269,32 @@ def build_signal_df(symbol, interval, klines_limit=200):
df = df.drop(columns=["open_time_r2","open_time_fr"], errors="ignore")
df["fundingRate"] = df["fundingRate"].ffill().fillna(0)
except Exception: pass
df = compute_indicators(df, interval)
df = compute_indicators(df, interval, user_id=user_id)
return df
# ── 알림 코어 ──
def check_and_alert(df, symbol, interval):
# ── 알림 코어 (사용자별) ──
def check_and_alert(user_id: int, df, symbol, interval):
now = time.time()
if df is None or df.empty:
return
forming_ct = df.iloc[-1]["open_time"]
if interval not in alert_state.synced_intervals:
sync_key = (user_id, interval)
if sync_key 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 완료")
alert_state.last_fired_candle[(user_id, interval, key)] = triggered.iloc[-1]["open_time"]
alert_state.synced_intervals.add(sync_key)
print(f"[user={user_id}] {interval} 초기 sync 완료")
return
new_pending = []
for p in alert_state.pending_groups:
if p["interval"] != interval:
if p.get("user_id") != user_id or p["interval"] != interval:
new_pending.append(p)
continue
ct = p["candle_time"]
@@ -310,15 +307,15 @@ def check_and_alert(df, symbol, interval):
if ct == forming_ct:
new_pending.append(p)
else:
send_telegram(f"[취소 알림]\n{p['msg']}")
le = alert_state.long_entry.get(interval)
se = alert_state.short_entry.get(interval)
send_telegram(user_id, f"[취소 알림]\n{p['msg']}")
le = alert_state.long_entry.get((user_id, interval))
se = alert_state.short_entry.get((user_id, 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
trades_db.record_exit(user_id, symbol, interval, "long", ct, float(row["close"]), "cancelled")
alert_state.long_entry[(user_id, 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
trades_db.record_exit(user_id, symbol, interval, "short", ct, float(row["close"]), "cancelled")
alert_state.short_entry[(user_id, interval)] = None
alert_state.pending_groups = new_pending
recent = df.tail(3)
@@ -327,24 +324,24 @@ def check_and_alert(df, symbol, interval):
if sig not in recent.columns:
continue
triggered = recent[recent[sig].fillna(False)]
seen_key = (interval, sig)
seen_key = (user_id, interval, sig)
prev_seen = alert_state.signal_seen_count.get(seen_key)
if triggered.empty:
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)
state_key = (user_id, 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():
if now - alert_state.last_alert.get(state_key, 0) <= ALERT_COOLDOWN(user_id):
continue
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"]
stable_min = settings_db.get_int("forming_stable_polls", 2)
stable_min = settings_db.get_int("forming_stable_polls", 2, user_id=user_id)
if candle_time == forming_ct and count < stable_min:
continue
eligible.append({
@@ -365,13 +362,13 @@ def check_and_alert(df, symbol, interval):
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)
trades_db.log_signal_events(user_id, symbol, interval, group)
if direction == "caution":
msg = f"{sub_labels} 신호\n{symbol} {tf_label}\n시간: {candle_time_str}"
send_telegram(msg)
send_telegram(user_id, msg)
else:
entry_price = float(group[0]["row"]["open"])
sl_pct = STOP_LOSS_PCT_v()
sl_pct = STOP_LOSS_PCT_v(user_id)
stop_price = entry_price * (1 - sl_pct) if direction == "long" else entry_price * (1 + sl_pct)
msg = (
f"{sub_labels} 진입 신호\n{symbol} {tf_label}\n"
@@ -379,35 +376,42 @@ def check_and_alert(df, symbol, interval):
)
entry_record = {"price": entry_price, "stop": stop_price, "open_time": candle_time, "entry_msg": msg}
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:
# 반대 방향 진입 청산 권고
if direction == "long":
opp_key = "short"
opp_dict = alert_state.short_entry
opposite_label = ""
else:
opp_key = "long"
opp_dict = alert_state.long_entry
opposite_label = ""
for (u2, opp_interval), opp_rec in list(opp_dict.items()):
if u2 != user_id or opp_rec is None:
continue
send_telegram(
send_telegram(user_id,
f"[반대 신호 감지 - {opposite_label} 청산 권장]\n"
f"--- 기존 진입 ---\n{opp_rec['entry_msg']}\n"
f"--- 반대 신호 ---\n{msg}"
)
trades_db.record_exit(symbol, opp_interval, opposite_direction,
trades_db.record_exit(user_id, symbol, opp_interval, opp_key,
opp_rec.get("open_time"), entry_price, "reversal")
opposite_dict[opp_interval] = None
opp_dict[(u2, opp_interval)] = None
if direction == "long":
alert_state.long_entry[interval] = entry_record
alert_state.long_entry[(user_id, interval)] = entry_record
else:
alert_state.short_entry[interval] = entry_record
trades_db.record_entry(symbol, interval, direction,
alert_state.short_entry[(user_id, interval)] = entry_record
trades_db.record_entry(user_id, symbol, interval, direction,
[e["sig"] for e in group],
candle_time, entry_price, stop_price)
send_telegram(msg)
send_telegram(user_id, msg)
for e in group:
alert_state.last_alert[(interval, e["key"])] = now
alert_state.last_fired_candle[(interval, e["key"])] = e["candle_time"]
alert_state.last_alert[(user_id, interval, e["key"])] = now
alert_state.last_fired_candle[(user_id, 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],
"user_id": user_id, "interval": interval, "direction": direction,
"candle_time": candle_time, "msg": msg,
"sig_cols": [e["sig"] for e in group],
})
_send_group(groups.get("long", []))
@@ -415,69 +419,70 @@ def check_and_alert(df, symbol, interval):
_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)
le = alert_state.long_entry.get((user_id, interval))
se = alert_state.short_entry.get((user_id, interval))
if le is not None and current_price <= le["stop"]:
send_telegram(f"[손절가알림]\n{le['entry_msg']}\n현재가: {current_price:,.2f}")
trades_db.record_exit(symbol, interval, "long", le.get("open_time"), current_price, "stop_loss")
alert_state.long_entry[interval] = None
send_telegram(user_id, f"[손절가알림]\n{le['entry_msg']}\n현재가: {current_price:,.2f}")
trades_db.record_exit(user_id, symbol, interval, "long", le.get("open_time"), current_price, "stop_loss")
alert_state.long_entry[(user_id, interval)] = None
if se is not None and current_price >= se["stop"]:
send_telegram(f"[손절가알림]\n{se['entry_msg']}\n현재가: {current_price:,.2f}")
trades_db.record_exit(symbol, interval, "short", se.get("open_time"), current_price, "stop_loss")
alert_state.short_entry[interval] = None
send_telegram(user_id, f"[손절가알림]\n{se['entry_msg']}\n현재가: {current_price:,.2f}")
trades_db.record_exit(user_id, symbol, interval, "short", se.get("open_time"), current_price, "stop_loss")
alert_state.short_entry[(user_id, interval)] = None
# ── 백그라운드 루프 (FastAPI startup 에서 호출) ──
def alert_timeframes():
return settings_db.get_list("alert_timeframes", default=["5m", "15m", "30m", "1h"])
# ── 알림 루프 — 모든 활성 사용자 순회 ──
def alert_timeframes(user_id):
return settings_db.get_list("alert_timeframes", default=["5m", "15m", "30m", "1h"], user_id=user_id)
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}")
# 가장 짧은 폴링 주기 사용 (사용자가 다 달라도 OK — 폴링 마다 각자 적용)
poll = 30
try:
uids = settings_db.list_user_ids_with_alerts_enabled()
for uid in uids:
try:
sym = settings_db.get("alert_symbol", "BTCUSDT", user_id=uid) or "BTCUSDT"
tfs = alert_timeframes(uid)
user_poll = max(10, settings_db.get_int("polling_interval_sec", 30, user_id=uid))
poll = min(poll, user_poll)
for interval in tfs:
try:
df = build_signal_df(sym, interval, 200, user_id=uid)
check_and_alert(uid, df, sym, interval)
except Exception as e:
print(f"[알림스레드 user={uid} {interval}] {e}")
except Exception as e:
print(f"[알림스레드 user={uid}] {e}")
except Exception as e:
print(f"[알림스레드 outer] {e}")
time.sleep(poll)
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})")
# send_daily_report 는 app_streamlit.py 안에 있는 그대로 사용 (없어도 silent skip)
try:
from app_streamlit import send_daily_report
with alert_state.alert_lock:
symbol = alert_state.alert_symbol
send_daily_report(symbol)
except Exception as e:
print(f"[일일리포트 호출 실패] {e}")
alert_state.last_report_date = today_str
uids = settings_db.list_user_ids_with_alerts_enabled()
for uid in uids:
if not settings_db.get_bool("daily_report_enabled", True, user_id=uid):
continue
last = alert_state.last_report_date_by_user.get(uid)
if last is None:
alert_state.last_report_date_by_user[uid] = today_str
elif last != today_str:
send_telegram(uid, f"📊 일일 리포트 ({today_str})\n(상세 통계 추후 확장)")
alert_state.last_report_date_by_user[uid] = today_str
except Exception as e:
print(f"[일일리포트 스레드 오류] {e}")
print(f"[일일리포트 스레드] {e}")
time.sleep(60)
def start_background_threads():
"""FastAPI startup 에서 호출. 한 번만 시작."""
if not alert_state.alert_started:
t = threading.Thread(target=alert_loop, daemon=True, name="alert_loop")
t.start()