From d16456cb925c6b6f5f0d42f854df3a2fb0cf8b87 Mon Sep 17 00:00:00 2001 From: chpark Date: Fri, 22 May 2026 12:14:23 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EB=B3=84=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20+=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B4=80=EB=A6=AC=20+=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20PnL%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 사용자별 격리 - 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) --- alert_state.py | 44 +++-- backend/app/auth.py | 19 +- backend/app/main.py | 16 +- backend/app/routes/auth_route.py | 2 +- backend/app/routes/automation_route.py | 20 ++- backend/app/routes/exchange_route.py | 23 +-- backend/app/routes/settings_route.py | 19 +- backend/app/routes/trades_route.py | 48 ++++- backend/app/routes/users_route.py | 95 ++++++++-- core_logic.py | 233 +++++++++++++------------ exchange_keys.py | 146 +++++++++++----- frontend/app/admin/users/page.tsx | 211 ++++++++++++++++++++++ frontend/app/page.tsx | 33 +++- frontend/app/trades/page.tsx | 94 ++++++---- frontend/components/Chart.tsx | 26 +-- frontend/components/sidebar.tsx | 19 +- frontend/lib/api.ts | 10 +- settings_db.py | 106 ++++++++--- trades_db.py | 62 ++++--- users_db.py | 52 ++++++ 20 files changed, 934 insertions(+), 344 deletions(-) create mode 100644 frontend/app/admin/users/page.tsx diff --git a/alert_state.py b/alert_state.py index b11ce47..802c8eb 100644 --- a/alert_state.py +++ b/alert_state.py @@ -1,46 +1,42 @@ """ -Streamlit 의 매 rerun 마다 메인 스크립트는 새 namespace 에서 재실행되어 -모듈 최상단의 mutable state 가 모두 초기화된다 (`globals()` 가드도 우회됨). +알림 스레드용 mutable state — 사용자별 격리. +모든 key 는 (user_id, ...) tuple 로 시작. 사용자별 alert_symbol 도 별도 dict. -이 파일은 별도 모듈로 단 한 번만 import 되므로 (Python 의 sys.modules 캐싱) -state 가 process lifetime 동안 보존된다. 알림 dedup, 진입 추적, 스레드 -기동 가드 등 다중 rerun 환경에서 살아남아야 하는 모든 mutable 상태는 -여기에 둔다. - -알림 스레드는 multi-TF (1m/3m/5m/15m/30m/1h) 동시 모니터링 모드라 -dedup 과 진입 추적은 모두 (interval, key) 또는 interval 별로 분리된다. +FastAPI 프로세스 lifetime 동안 sys.modules 캐싱으로 보존. +옛 글로벌 변수 (alert_symbol, last_report_date) 는 backward-compat 으로 남김. """ import threading -# (interval, key) 별 마지막 알림 시각 (cooldown 용). default 0. -# 키가 없으면 dict.get 으로 0 fallback. +# (user_id, interval, key) 별 마지막 알림 시각 (cooldown) last_alert = {} -# (interval, key) 별 마지막으로 알림 보낸 candle open_time (per-candle dedup). +# (user_id, interval, key) 별 마지막 발사 candle open_time last_fired_candle = {} -# interval 별 진입 추적. value = entry_record dict 또는 None. +# (user_id, interval) 별 진입 추적 — value = entry_record dict or None long_entry = {} short_entry = {} -# forming candle 에서 발사된 알림은 캔들 마감 후 신호 재검증을 받는다. -# 마감 시점에 신호가 사라졌으면 [취소 알림] 을 보낸다. -# 항목 형식: {"interval", "direction", "candle_time", "msg", "sig_cols"} +# 사용자별 pending forming candle 감시 — element = {"user_id", "interval", "direction", ...} pending_groups = [] -# 재시작 후 첫 polling 시 alert_state 가 비어있어 tail(3) 의 역사적 신호들이 -# 한꺼번에 발사되는 burst 차단. 첫 처리 시 dedup 상태만 동기화하고 알림은 -# skip. interval 별로 각각 한 번씩 sync. +# (user_id, interval) tuple 셋 — 초기 silent sync 완료 표시 synced_intervals = set() -# (interval, sig) 별 forming candle 의 연속 True polling 카운트. -# 진입 신호가 forming 중 1회 깜빡으로 발사되는 false alert 차단용. -# 항목 형식: {(interval, sig): {"candle_time": ts, "count": int}} +# (user_id, interval, sig) 별 forming candle 연속 True 카운트 signal_seen_count = {} +# 사용자별 monitored symbol — { user_id: "BTCUSDT" } +alert_symbol_by_user = {} + +# 사용자별 마지막 일일 리포트 발송 일자 — { user_id: "YYYY-MM-DD" } +last_report_date_by_user = {} + +# 옛 글로벌 (legacy, 미사용 — 일부 옛 코드 참조 위해 남김) alert_symbol = "BTCUSDT" -alert_interval = "5m" # UI 표시용; 알림 스레드는 multi-TF 모니터링이라 무시 +alert_interval = "5m" +last_report_date = None + alert_lock = threading.Lock() alert_started = False daily_report_started = False -last_report_date = None diff --git a/backend/app/auth.py b/backend/app/auth.py index 3fac45f..a912e13 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -15,9 +15,10 @@ JWT_EXP_HOURS = 24 * 7 oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) -def create_token(username: str, role: str) -> str: +def create_token(user_id: int, username: str, role: str) -> str: payload = { "sub": username, + "uid": int(user_id), "role": role, "exp": datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS), "iat": datetime.utcnow(), @@ -25,6 +26,22 @@ def create_token(username: str, role: str) -> str: return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG) +def get_uid(payload: dict) -> int: + """JWT payload 에서 user_id 추출. 옛 토큰 호환 — uid 없으면 username 으로 lookup.""" + if "uid" in payload: + return int(payload["uid"]) + # 옛 토큰 fallback + try: + import users_db + username = payload.get("sub") + for u in users_db.list_users(): + if u.get("username") == username: + return int(u["id"]) + except Exception: + pass + return 0 + + def decode_token(token: str) -> Optional[dict]: try: return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) diff --git a/backend/app/main.py b/backend/app/main.py index 5c02e5d..f5eadee 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,8 +32,20 @@ def on_startup(): trades_db.init_db() exchange_keys.init_db() users_db.init_db() - # alert_symbol DB 에서 동기화 - alert_state.alert_symbol = settings_db.get("alert_symbol", "BTCUSDT") + # 모든 기존 사용자에 default settings 시드 (없으면) + for u in users_db.list_users(): + settings_db.ensure_user_defaults(u["id"]) + # admin (id=1) 의 글로벌 settings 를 그 user_settings 로 복사 (마이그레이션) + try: + admin_us = settings_db.all_settings(user_id=1) + if not admin_us.get("telegram_token"): + globals_dict = settings_db.all_settings() + for k, v in globals_dict.items(): + if not admin_us.get(k): + settings_db.set_value(k, v, user_id=1) + print("[migration] admin (id=1) 으로 글로벌 settings 복사") + except Exception as e: + print(f"[migration] settings 마이그레이션 실패: {e}") # 백그라운드 알림 / 일일 리포트 시작 core_logic.start_background_threads() print("[FastAPI] 모든 모듈 init OK + 백그라운드 스레드 시작") diff --git a/backend/app/routes/auth_route.py b/backend/app/routes/auth_route.py index 75ea84f..dbed856 100644 --- a/backend/app/routes/auth_route.py +++ b/backend/app/routes/auth_route.py @@ -22,7 +22,7 @@ def login(body: LoginIn): user = users_db.authenticate(body.username.strip(), body.password) if not user: raise HTTPException(status_code=401, detail="아이디 또는 비밀번호가 올바르지 않습니다.") - token = create_token(user["username"], user.get("role", "user")) + token = create_token(user["id"], user["username"], user.get("role", "user")) # datetime 직렬화 위해 string 변환 user_safe = { "id": user.get("id"), diff --git a/backend/app/routes/automation_route.py b/backend/app/routes/automation_route.py index a8fa26a..13b4f6d 100644 --- a/backend/app/routes/automation_route.py +++ b/backend/app/routes/automation_route.py @@ -3,14 +3,15 @@ from pydantic import BaseModel from typing import Dict, Any import exchange_keys import exchange_adapters -from ..auth import require_user +from ..auth import require_user, get_uid router = APIRouter() @router.get("") -def get_config(_: dict = Depends(require_user)): - return exchange_keys.automation_all() +def get_config(payload: dict = Depends(require_user)): + uid = get_uid(payload) + return exchange_keys.automation_all(uid) class UpdateIn(BaseModel): @@ -18,20 +19,21 @@ class UpdateIn(BaseModel): @router.put("") -def update_config(body: UpdateIn, _: dict = Depends(require_user)): +def update_config(body: UpdateIn, payload: dict = Depends(require_user)): + uid = get_uid(payload) for k, v in body.values.items(): - exchange_keys.automation_set(k, v) + exchange_keys.automation_set(k, v, uid) return {"ok": True, "saved": len(body.values)} @router.post("/test/balance") -def test_balance(_: dict = Depends(require_user)): - """활성 키로 DryRun 어댑터 호출 — 동작 검증.""" - cfg = exchange_keys.automation_all() +def test_balance(payload: dict = Depends(require_user)): + uid = get_uid(payload) + cfg = exchange_keys.automation_all(uid) cred_id = cfg.get("active_credential", "") if not cred_id: return {"ok": False, "error": "활성 키 미선택"} - cred = exchange_keys.get_credential(int(cred_id)) + cred = exchange_keys.get_credential(int(cred_id), uid) adapter = exchange_adapters.make_adapter(cred, dry_run=True) bal = adapter.get_balance("USDT") return {"ok": True, "balance": bal, "exchange": adapter.exchange} diff --git a/backend/app/routes/exchange_route.py b/backend/app/routes/exchange_route.py index a3ee0c6..0c45715 100644 --- a/backend/app/routes/exchange_route.py +++ b/backend/app/routes/exchange_route.py @@ -2,8 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional import exchange_keys -import exchange_adapters -from ..auth import require_user +from ..auth import require_user, get_uid router = APIRouter() @@ -36,14 +35,16 @@ def _serialize_cred(c): @router.get("/credentials") -def list_creds(_: dict = Depends(require_user)): - return [_serialize_cred(c) for c in exchange_keys.list_credentials()] +def list_creds(payload: dict = Depends(require_user)): + uid = get_uid(payload) + return [_serialize_cred(c) for c in exchange_keys.list_credentials(uid)] @router.post("/credentials") -def add_cred(body: CredIn, _: dict = Depends(require_user)): +def add_cred(body: CredIn, payload: dict = Depends(require_user)): + uid = get_uid(payload) cid = exchange_keys.add_credential( - body.exchange, body.label, body.api_key, body.api_secret, + uid, body.exchange, body.label, body.api_key, body.api_secret, body.passphrase, body.testnet, True, ) if not cid: @@ -52,18 +53,20 @@ def add_cred(body: CredIn, _: dict = Depends(require_user)): @router.put("/credentials/{cred_id}") -def update_cred(cred_id: int, body: CredUpdateIn, _: dict = Depends(require_user)): +def update_cred(cred_id: int, body: CredUpdateIn, payload: dict = Depends(require_user)): + uid = get_uid(payload) fields = {k: v for k, v in body.dict().items() if v is not None} if not fields: raise HTTPException(status_code=400, detail="변경 항목 없음") - if not exchange_keys.update_credential(cred_id, **fields): + if not exchange_keys.update_credential(cred_id, uid, **fields): raise HTTPException(status_code=404, detail="not found") return {"ok": True} @router.delete("/credentials/{cred_id}") -def delete_cred(cred_id: int, _: dict = Depends(require_user)): - if not exchange_keys.delete_credential(cred_id): +def delete_cred(cred_id: int, payload: dict = Depends(require_user)): + uid = get_uid(payload) + if not exchange_keys.delete_credential(cred_id, uid): raise HTTPException(status_code=404, detail="not found") return {"ok": True} diff --git a/backend/app/routes/settings_route.py b/backend/app/routes/settings_route.py index 33bf84c..33a18ca 100644 --- a/backend/app/routes/settings_route.py +++ b/backend/app/routes/settings_route.py @@ -4,15 +4,15 @@ from typing import Dict, Any import settings_db import alert_state -from ..auth import require_user +from ..auth import require_user, get_uid router = APIRouter() @router.get("") -def get_all(_: dict = Depends(require_user)): - safe = settings_db.all_settings() - return safe +def get_all(payload: dict = Depends(require_user)): + uid = get_uid(payload) + return settings_db.all_settings(user_id=uid) class UpdateIn(BaseModel): @@ -20,11 +20,12 @@ class UpdateIn(BaseModel): @router.put("") -def update_settings(body: UpdateIn, _: dict = Depends(require_user)): +def update_settings(body: UpdateIn, payload: dict = Depends(require_user)): + uid = get_uid(payload) for k, v in body.values.items(): - settings_db.set_value(k, v) - # alert symbol 동기화 - sym = settings_db.get("alert_symbol", "BTCUSDT") + settings_db.set_value(k, v, user_id=uid) + # 사용자별 alert symbol 갱신 + sym = settings_db.get("alert_symbol", "BTCUSDT", user_id=uid) with alert_state.alert_lock: - alert_state.alert_symbol = sym + alert_state.alert_symbol_by_user[uid] = sym return {"ok": True, "saved": len(body.values)} diff --git a/backend/app/routes/trades_route.py b/backend/app/routes/trades_route.py index e22e2ab..aa66e7d 100644 --- a/backend/app/routes/trades_route.py +++ b/backend/app/routes/trades_route.py @@ -1,6 +1,7 @@ +import requests from fastapi import APIRouter, Depends, Query import trades_db -from ..auth import require_user +from ..auth import require_user, get_uid router = APIRouter() @@ -16,11 +17,48 @@ def _serialize(rows): return out +def _get_all_tickers() -> dict: + try: + r = requests.get("https://fapi.binance.com/fapi/v1/ticker/price", timeout=5, verify=False) + return {t["symbol"]: float(t["price"]) for t in r.json()} + except Exception as e: + print(f"[trades_route] tickers fetch fail: {e}") + return {} + + +def _enrich_open_with_live(rows: list) -> list: + has_open = any(r.get("status") == "open" for r in rows) + if not has_open: + return rows + tickers = _get_all_tickers() + if not tickers: + return rows + for r in rows: + if r.get("status") != "open": + continue + sym = r.get("symbol") + cur = tickers.get(sym) + if cur is None or not r.get("entry_price"): + continue + entry = float(r["entry_price"]) + if r.get("direction") == "long": + pnl = (cur - entry) / entry * 100.0 + else: + pnl = (entry - cur) / entry * 100.0 + r["current_price"] = cur + r["live_pnl_pct"] = round(pnl, 4) + return rows + + @router.get("") -def list_trades(limit: int = Query(500, ge=1, le=2000), _: dict = Depends(require_user)): - return _serialize(trades_db.fetch_trades(limit=limit)) +def list_trades(limit: int = Query(500, ge=1, le=2000), payload: dict = Depends(require_user)): + uid = get_uid(payload) + rows = _serialize(trades_db.fetch_trades(uid, limit=limit)) + rows = _enrich_open_with_live(rows) + return rows @router.get("/signals") -def list_signal_events(limit: int = Query(1000, ge=1, le=5000), _: dict = Depends(require_user)): - return _serialize(trades_db.fetch_signal_events(limit=limit)) +def list_signal_events(limit: int = Query(1000, ge=1, le=5000), payload: dict = Depends(require_user)): + uid = get_uid(payload) + return _serialize(trades_db.fetch_signal_events(uid, limit=limit)) diff --git a/backend/app/routes/users_route.py b/backend/app/routes/users_route.py index b9ed10c..43fd3b9 100644 --- a/backend/app/routes/users_route.py +++ b/backend/app/routes/users_route.py @@ -1,18 +1,93 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional import users_db from ..auth import require_user, require_admin router = APIRouter() +def _serialize(r): + return { + "id": r["id"], "username": r["username"], "role": r["role"], + "created_at": str(r["created_at"]) if r.get("created_at") else None, + "last_login_at": str(r["last_login_at"]) if r.get("last_login_at") else None, + } + + @router.get("") def list_users(_: dict = Depends(require_admin)): - rows = users_db.list_users() - return [ - { - "id": r["id"], "username": r["username"], "role": r["role"], - "created_at": str(r["created_at"]) if r.get("created_at") else None, - "last_login_at": str(r["last_login_at"]) if r.get("last_login_at") else None, - } - for r in rows - ] + return [_serialize(r) for r in users_db.list_users()] + + +class CreateUserIn(BaseModel): + username: str + password: str + role: str = "user" + + +@router.post("") +def create_user(body: CreateUserIn, _: dict = Depends(require_admin)): + if body.role not in ("admin", "user"): + raise HTTPException(status_code=400, detail="role 은 admin / user") + if len(body.password) < 6: + raise HTTPException(status_code=400, detail="비밀번호 6자 이상") + if not body.username.strip(): + raise HTTPException(status_code=400, detail="username 필수") + uid = users_db.create_user(body.username.strip(), body.password, body.role) + if uid is None: + raise HTTPException(status_code=400, detail="생성 실패 (이미 존재할 수 있음)") + return {"id": uid, "username": body.username.strip(), "role": body.role} + + +@router.delete("/{user_id}") +def delete_user(user_id: int, payload: dict = Depends(require_admin)): + me = payload.get("sub") + # 본인 삭제 방지 + target = next((u for u in users_db.list_users() if u["id"] == user_id), None) + if target and target["username"] == me: + raise HTTPException(status_code=400, detail="본인 계정은 삭제 불가") + # admin 이 단 한명이면 삭제 방지 + if target and target["role"] == "admin": + admin_count = sum(1 for u in users_db.list_users() if u["role"] == "admin") + if admin_count <= 1: + raise HTTPException(status_code=400, detail="마지막 admin 은 삭제 불가") + if not users_db.delete_user(user_id): + raise HTTPException(status_code=404, detail="not found") + return {"ok": True} + + +class ResetPasswordIn(BaseModel): + new_password: str + + +@router.put("/{user_id}/password") +def reset_password(user_id: int, body: ResetPasswordIn, _: dict = Depends(require_admin)): + if len(body.new_password) < 6: + raise HTTPException(status_code=400, detail="비밀번호 6자 이상") + if not users_db.admin_reset_password(user_id, body.new_password): + raise HTTPException(status_code=404, detail="not found") + return {"ok": True} + + +class SetRoleIn(BaseModel): + role: str + + +@router.put("/{user_id}/role") +def set_role(user_id: int, body: SetRoleIn, payload: dict = Depends(require_admin)): + if body.role not in ("admin", "user"): + raise HTTPException(status_code=400, detail="role 은 admin / user") + me = payload.get("sub") + target = next((u for u in users_db.list_users() if u["id"] == user_id), None) + # 본인 role 강등 방지 + if target and target["username"] == me and body.role != "admin": + raise HTTPException(status_code=400, detail="본인 role 변경 불가") + # 마지막 admin 강등 방지 + if target and target["role"] == "admin" and body.role != "admin": + admin_count = sum(1 for u in users_db.list_users() if u["role"] == "admin") + if admin_count <= 1: + raise HTTPException(status_code=400, detail="마지막 admin role 변경 불가") + if not users_db.set_role(user_id, body.role): + raise HTTPException(status_code=404, detail="not found") + return {"ok": True} diff --git a/core_logic.py b/core_logic.py index 864e565..731bf26 100644 --- a/core_logic.py +++ b/core_logic.py @@ -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() diff --git a/exchange_keys.py b/exchange_keys.py index 49f05a0..817e7a2 100644 --- a/exchange_keys.py +++ b/exchange_keys.py @@ -110,42 +110,74 @@ def init_db(): updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """) + # 사용자별 격리 — user_id 컬럼 추가 (없으면) + cur.execute("ALTER TABLE exchange_credentials ADD COLUMN IF NOT EXISTS user_id BIGINT NOT NULL DEFAULT 1") + cur.execute("CREATE INDEX IF NOT EXISTS idx_excred_user ON exchange_credentials(user_id)") cur.execute("CREATE INDEX IF NOT EXISTS idx_excred_exchange ON exchange_credentials(exchange)") cur.execute(""" CREATE TABLE IF NOT EXISTS automation_config ( - key TEXT PRIMARY KEY, + key TEXT NOT NULL, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """) - # 기본 자동매매 설정 (없으면 시드) - _seed_automation(cur) + # automation_config 마이그레이션 — 옛 (key PRIMARY KEY) → (user_id, key) + cur.execute("ALTER TABLE automation_config ADD COLUMN IF NOT EXISTS user_id BIGINT NOT NULL DEFAULT 1") + # 옛 PRIMARY KEY 제거 후 (user_id, key) composite PK + cur.execute(""" + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname='automation_config_pkey') THEN + ALTER TABLE automation_config DROP CONSTRAINT automation_config_pkey; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='automation_config_user_key') THEN + ALTER TABLE automation_config ADD CONSTRAINT automation_config_user_key UNIQUE (user_id, key); + END IF; + END $$; + """) + _seed_automation_default() _init_done = True print("[exchange_keys] init OK") except Exception as e: print(f"[exchange_keys] init 실패: {e}") +def _seed_automation_default(): + """글로벌 default 시드 — 새 사용자가 처음 자동매매 페이지 열면 ensure_user_automation() 호출.""" + pass + + AUTOMATION_DEFAULTS = { - "enabled": "0", # 자동매매 ON/OFF (글로벌 킬스위치) - "dry_run": "1", # 1 = 실제 주문 X, 시그널만 기록 (안전) - "active_credential": "", # exchange_credentials.id (활성 키) + "enabled": "0", + "dry_run": "1", + "active_credential": "", "leverage": "10", - "position_size_pct": "1.0", # 잔고 대비 % + "position_size_pct": "1.0", "max_open_trades": "3", - "min_signal_score": "1", # 이 신호 수 이상일 때만 진입 (예: 강한 + 일반 동시) - "allowed_directions": "long,short", # long_only / short_only / long,short - "tp_pct": "0.0", # take profit (%, 0 = OFF) + "min_signal_score": "1", + "allowed_directions": "long,short", + "tp_pct": "0.0", } -def _seed_automation(cur): - for k, v in AUTOMATION_DEFAULTS.items(): - cur.execute( - "INSERT INTO automation_config(key, value) VALUES (%s, %s) ON CONFLICT (key) DO NOTHING", - (k, v), - ) +def ensure_user_automation(user_id: int): + """사용자가 처음 자동매매 페이지 열 때 default 시드.""" + if not _enabled() or not user_id: + return + with _lock: + conn = _get_conn() + if conn is None: + return + try: + with conn.cursor() as cur: + for k, v in AUTOMATION_DEFAULTS.items(): + cur.execute( + "INSERT INTO automation_config(user_id, key, value) VALUES (%s, %s, %s) " + "ON CONFLICT (user_id, key) DO NOTHING", + (user_id, k, v), + ) + except Exception as e: + print(f"[exchange_keys] ensure_user_automation 실패: {e}") # ────────────────────────────────────────────── @@ -186,8 +218,8 @@ def _mask(s: Optional[str]) -> str: SUPPORTED_EXCHANGES = ["binance", "bybit", "okx", "bitget", "upbit", "bithumb"] -def list_credentials() -> List[Dict[str, Any]]: - if not _enabled(): +def list_credentials(user_id: int) -> List[Dict[str, Any]]: + if not _enabled() or not user_id: return [] with _lock: conn = _get_conn() @@ -198,7 +230,8 @@ def list_credentials() -> List[Dict[str, Any]]: cur.execute( "SELECT id, exchange, label, testnet, enabled, created_at, updated_at, " "api_key_enc, api_secret_enc, passphrase_enc " - "FROM exchange_credentials ORDER BY id DESC" + "FROM exchange_credentials WHERE user_id=%s ORDER BY id DESC", + (user_id,), ) rows = cur.fetchall() for r in rows: @@ -212,9 +245,9 @@ def list_credentials() -> List[Dict[str, Any]]: return [] -def get_credential(cred_id: int) -> Optional[Dict[str, Any]]: - """복호화된 키를 그대로 반환. 자동매매 어댑터에서 호출.""" - if not _enabled(): +def get_credential(cred_id: int, user_id: int) -> Optional[Dict[str, Any]]: + """user_id 소유 cred 만 반환 (다른 사용자 키 접근 차단).""" + if not _enabled() or not user_id: return None with _lock: conn = _get_conn() @@ -222,7 +255,7 @@ def get_credential(cred_id: int) -> Optional[Dict[str, Any]]: return None try: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - cur.execute("SELECT * FROM exchange_credentials WHERE id=%s", (cred_id,)) + cur.execute("SELECT * FROM exchange_credentials WHERE id=%s AND user_id=%s", (cred_id, user_id)) row = cur.fetchone() if row is None: return None @@ -235,9 +268,9 @@ def get_credential(cred_id: int) -> Optional[Dict[str, Any]]: return None -def add_credential(exchange: str, label: str, api_key: str, api_secret: str, +def add_credential(user_id: int, exchange: str, label: str, api_key: str, api_secret: str, passphrase: Optional[str] = None, testnet: bool = False, enabled: bool = True) -> Optional[int]: - if not _enabled(): + if not _enabled() or not user_id: return None if not api_key or not api_secret: return None @@ -248,10 +281,10 @@ def add_credential(exchange: str, label: str, api_key: str, api_secret: str, try: with conn.cursor() as cur: cur.execute( - "INSERT INTO exchange_credentials(exchange, label, api_key_enc, api_secret_enc, " - "passphrase_enc, testnet, enabled) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id", + "INSERT INTO exchange_credentials(user_id, exchange, label, api_key_enc, api_secret_enc, " + "passphrase_enc, testnet, enabled) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id", ( - exchange, label or "", + user_id, exchange, label or "", _encrypt(api_key), _encrypt(api_secret), _encrypt(passphrase), bool(testnet), bool(enabled), ), @@ -262,8 +295,8 @@ def add_credential(exchange: str, label: str, api_key: str, api_secret: str, return None -def update_credential(cred_id: int, **fields) -> bool: - if not _enabled() or not fields: +def update_credential(cred_id: int, user_id: int, **fields) -> bool: + if not _enabled() or not user_id or not fields: return False set_parts = [] values = [] @@ -280,22 +313,25 @@ def update_credential(cred_id: int, **fields) -> bool: if not set_parts: return False set_parts.append("updated_at=now()") - values.append(cred_id) + values.extend([cred_id, user_id]) with _lock: conn = _get_conn() if conn is None: return False try: with conn.cursor() as cur: - cur.execute(f"UPDATE exchange_credentials SET {', '.join(set_parts)} WHERE id=%s", tuple(values)) + cur.execute( + f"UPDATE exchange_credentials SET {', '.join(set_parts)} WHERE id=%s AND user_id=%s", + tuple(values), + ) return cur.rowcount > 0 except Exception as e: print(f"[exchange_keys] update_credential 실패: {e}") return False -def delete_credential(cred_id: int) -> bool: - if not _enabled(): +def delete_credential(cred_id: int, user_id: int) -> bool: + if not _enabled() or not user_id: return False with _lock: conn = _get_conn() @@ -303,7 +339,7 @@ def delete_credential(cred_id: int) -> bool: return False try: with conn.cursor() as cur: - cur.execute("DELETE FROM exchange_credentials WHERE id=%s", (cred_id,)) + cur.execute("DELETE FROM exchange_credentials WHERE id=%s AND user_id=%s", (cred_id, user_id)) return cur.rowcount > 0 except Exception as e: print(f"[exchange_keys] delete_credential 실패: {e}") @@ -313,8 +349,8 @@ def delete_credential(cred_id: int) -> bool: # ────────────────────────────────────────────── # Automation config # ────────────────────────────────────────────── -def automation_get(key: str, default: str = "") -> str: - if not _enabled(): +def automation_get(key: str, user_id: int, default: str = "") -> str: + if not _enabled() or not user_id: return AUTOMATION_DEFAULTS.get(key, default) with _lock: conn = _get_conn() @@ -322,7 +358,7 @@ def automation_get(key: str, default: str = "") -> str: return AUTOMATION_DEFAULTS.get(key, default) try: with conn.cursor() as cur: - cur.execute("SELECT value FROM automation_config WHERE key=%s", (key,)) + cur.execute("SELECT value FROM automation_config WHERE user_id=%s AND key=%s", (user_id, key)) row = cur.fetchone() if row is None: return AUTOMATION_DEFAULTS.get(key, default) @@ -332,8 +368,8 @@ def automation_get(key: str, default: str = "") -> str: return AUTOMATION_DEFAULTS.get(key, default) -def automation_set(key: str, value: Any) -> bool: - if not _enabled(): +def automation_set(key: str, value: Any, user_id: int) -> bool: + if not _enabled() or not user_id: return False with _lock: conn = _get_conn() @@ -342,9 +378,9 @@ def automation_set(key: str, value: Any) -> bool: try: with conn.cursor() as cur: cur.execute( - "INSERT INTO automation_config(key, value) VALUES (%s, %s) " - "ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()", - (key, str(value)), + "INSERT INTO automation_config(user_id, key, value) VALUES (%s, %s, %s) " + "ON CONFLICT (user_id, key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()", + (user_id, key, str(value)), ) return True except Exception as e: @@ -352,16 +388,17 @@ def automation_set(key: str, value: Any) -> bool: return False -def automation_all() -> Dict[str, str]: - if not _enabled(): +def automation_all(user_id: int) -> Dict[str, str]: + if not _enabled() or not user_id: return dict(AUTOMATION_DEFAULTS) + ensure_user_automation(user_id) with _lock: conn = _get_conn() if conn is None: return dict(AUTOMATION_DEFAULTS) try: with conn.cursor() as cur: - cur.execute("SELECT key, value FROM automation_config") + cur.execute("SELECT key, value FROM automation_config WHERE user_id=%s", (user_id,)) rows = cur.fetchall() d = dict(AUTOMATION_DEFAULTS) d.update(dict(rows)) @@ -369,3 +406,20 @@ def automation_all() -> Dict[str, str]: except Exception as e: print(f"[exchange_keys] automation_all 실패: {e}") return dict(AUTOMATION_DEFAULTS) + + +def list_users_with_auto_enabled() -> List[int]: + """자동매매 활성 사용자 ID 리스트 (자동매매 워커용).""" + if not _enabled(): + return [] + with _lock: + conn = _get_conn() + if conn is None: + return [] + try: + with conn.cursor() as cur: + cur.execute("SELECT user_id FROM automation_config WHERE key='enabled' AND value='1'") + return [r[0] for r in cur.fetchall()] + except Exception as e: + print(f"[exchange_keys] list_users_with_auto_enabled 실패: {e}") + return [] diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx new file mode 100644 index 0000000..2fcac24 --- /dev/null +++ b/frontend/app/admin/users/page.tsx @@ -0,0 +1,211 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { api } from '@/lib/api'; +import { useAuth } from '@/lib/auth'; +import { Card, PageHeader, Input, Select, Button, Banner } from '@/components/ui'; +import { UserPlus, KeyRound, Trash2, Shield, ShieldCheck } from 'lucide-react'; + +interface User { + id: number; + username: string; + role: string; + created_at: string; + last_login_at: string | null; +} + +export default function AdminUsersPage() { + const router = useRouter(); + const { user } = useAuth(); + const [users, setUsers] = useState([]); + const [form, setForm] = useState({ username: '', password: '', role: 'user' }); + const [msg, setMsg] = useState<{ level: any; text: string } | null>(null); + const [resetTarget, setResetTarget] = useState(null); + const [resetPw, setResetPw] = useState(''); + + useEffect(() => { + if (user && user.role !== 'admin') { + router.replace('/'); + } + }, [user]); + + async function load() { + try { + const data = await api.get('/api/users'); + setUsers(data); + } catch (e: any) { + setMsg({ level: 'danger', text: e.message }); + } + } + useEffect(() => { load(); }, []); + + async function add(e: React.FormEvent) { + e.preventDefault(); + setMsg(null); + try { + await api.post('/api/users', form); + setMsg({ level: 'success', text: `✅ ${form.username} 생성 완료` }); + setForm({ username: '', password: '', role: 'user' }); + load(); + } catch (e: any) { + setMsg({ level: 'danger', text: e.message }); + } + } + + async function del(u: User) { + if (!confirm(`'${u.username}' 계정을 삭제하시겠습니까?`)) return; + try { + await api.delete(`/api/users/${u.id}`); + setMsg({ level: 'success', text: `🗑️ ${u.username} 삭제됨` }); + load(); + } catch (e: any) { + setMsg({ level: 'danger', text: e.message }); + } + } + + async function changeRole(u: User, newRole: string) { + if (!confirm(`'${u.username}' role 을 ${newRole} 로 변경하시겠습니까?`)) return; + try { + await api.put(`/api/users/${u.id}/role`, { role: newRole }); + setMsg({ level: 'success', text: `🔄 ${u.username} → ${newRole}` }); + load(); + } catch (e: any) { + setMsg({ level: 'danger', text: e.message }); + } + } + + async function resetPassword(e: React.FormEvent) { + e.preventDefault(); + if (resetTarget == null) return; + if (resetPw.length < 6) { + setMsg({ level: 'danger', text: '비밀번호 6자 이상' }); + return; + } + try { + await api.put(`/api/users/${resetTarget}/password`, { new_password: resetPw }); + setMsg({ level: 'success', text: '🔑 비밀번호 변경 완료' }); + setResetTarget(null); + setResetPw(''); + } catch (e: any) { + setMsg({ level: 'danger', text: e.message }); + } + } + + if (user && user.role !== 'admin') { + return ( +
+ 관리자 전용 페이지입니다. +
+ ); + } + + return ( +
+ + + {msg &&
{msg.text}
} + +
+ {/* 새 사용자 추가 */} + +
+ 새 사용자 추가 +
+
+ setForm({ ...form, username: e.target.value })} + placeholder="username" required autoComplete="off" /> + setForm({ ...form, password: e.target.value })} + required autoComplete="new-password" /> + + +
+
+ + {/* 사용자 목록 */} + +
+ 사용자 목록 +
+
+ + + + {['ID', '아이디', '권한', '가입', '마지막 로그인', '작업'].map(h => ( + + ))} + + + + {users.length === 0 && } + {users.map(u => { + const isMe = u.username === user?.username; + return ( + + + + + + + + + ); + })} + +
{h}
로딩 중...
#{u.id} + {u.username} + {isMe && (나)} + + + {u.role === 'admin' && } + {u.role} + + {(u.created_at || '').slice(0, 16).replace('T', ' ')}{(u.last_login_at || '-').slice(0, 16).replace('T', ' ')} +
+ + {!isMe && ( + + )} + {!isMe && ( + + )} +
+
+
+
+
+ + {/* 비번 리셋 모달 */} + {resetTarget != null && ( +
setResetTarget(null)}> +
e.stopPropagation()}> +

비밀번호 초기화

+

+ {users.find(u => u.id === resetTarget)?.username} 의 새 비밀번호를 설정합니다. +

+
+ setResetPw(e.target.value)} autoFocus required /> +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 095a155..4c7492b 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -7,27 +7,40 @@ import { RefreshCw } from 'lucide-react'; const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT']; const INTERVALS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '12h', '1d']; +const CANDLES_DESKTOP = 200; +const CANDLES_MOBILE = 60; export default function DashboardPage() { const [symbol, setSymbol] = useState('BTCUSDT'); const [interval, setIntervalV] = useState('5m'); const [auto, setAuto] = useState(true); const [refresh, setRefresh] = useState(30); + const [showLegend, setShowLegend] = useState(false); + const [mobile, setMobile] = useState(false); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [tick, setTick] = useState(0); + // 첫 로드 시 viewport 자동 감지 (모바일이면 mobile 모드 default ON) + useEffect(() => { + if (typeof window !== 'undefined' && window.innerWidth < 768) { + setMobile(true); + } + }, []); + + const candleLimit = mobile ? CANDLES_MOBILE : CANDLES_DESKTOP; + useEffect(() => { let cancelled = false; setLoading(true); setError(null); - api.get(`/api/market/dashboard?symbol=${symbol}&interval=${interval}&limit=200`) + api.get(`/api/market/dashboard?symbol=${symbol}&interval=${interval}&limit=${candleLimit}`) .then(d => { if (!cancelled) setData(d); }) .catch(e => { if (!cancelled) setError(e.message); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [symbol, interval, tick]); + }, [symbol, interval, tick, candleLimit]); useEffect(() => { if (!auto) return; @@ -41,7 +54,7 @@ export default function DashboardPage() {
setTick(t => t + 1)} className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-md shadow-sm"> 새로고침 @@ -50,7 +63,7 @@ export default function DashboardPage() { /> -
+
@@ -64,10 +77,14 @@ export default function DashboardPage() { className="w-full px-3 py-2 text-sm rounded-md border border-slate-300 bg-slate-50" />
-
- {loading ? '⏳ 로딩 중...' : data?.last_price ? `현재가: ${data.last_price.toLocaleString()}` : ''} -
+
+
+ {(loading || data?.last_price) && ( +
+ {loading ? '⏳ 로딩 중...' : `현재가: ${data.last_price.toLocaleString()} ${symbol.replace('USDT', '/USDT')}`} +
+ )}
{data?.banner && ( @@ -79,7 +96,7 @@ export default function DashboardPage() { {error && {error}} - +
); diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx index 95e80a7..163dee3 100644 --- a/frontend/app/trades/page.tsx +++ b/frontend/app/trades/page.tsx @@ -13,14 +13,25 @@ export default function TradesPage() { api.get('/api/trades?limit=500').then(setRows).finally(() => setLoading(false)); }, []); - const closed = rows.filter(r => ['stop_loss', 'reversal', 'cancelled'].includes(r.status)); - const open = rows.filter(r => r.status === 'open').length; + // 실현된 트레이드 (실거래 결과만): stop_loss + reversal — cancelled 는 진입 취소(실거래 X) + const closed = rows.filter(r => ['stop_loss', 'reversal'].includes(r.status)); + const cancelled = rows.filter(r => r.status === 'cancelled').length; + const openRows = rows.filter(r => r.status === 'open'); + const open = openRows.length; const wins = closed.filter(r => (r.pnl_pct ?? 0) > 0).length; const losses = closed.length - wins; const winRate = closed.length ? (wins / closed.length * 100).toFixed(1) : '0.0'; const cumPnl = closed.reduce((s, r) => s + (r.pnl_pct ?? 0), 0).toFixed(2); const avgPnl = closed.length ? (closed.reduce((s, r) => s + (r.pnl_pct ?? 0), 0) / closed.length).toFixed(2) : '0.00'; + // open 트레이드 실시간 PnL% (backend 가 live_pnl_pct 채워줌) + const openWithLive = openRows.filter(r => r.live_pnl_pct != null); + const openLiveSum = openWithLive.reduce((s, r) => s + (r.live_pnl_pct ?? 0), 0); + const openLiveAvg = openWithLive.length ? (openLiveSum / openWithLive.length).toFixed(2) : '0.00'; + const openLiveSumStr = openLiveSum.toFixed(2); + const openWins = openWithLive.filter(r => (r.live_pnl_pct ?? 0) > 0).length; + const openLosses = openWithLive.filter(r => (r.live_pnl_pct ?? 0) <= 0).length; + // 누적 PnL 시계열 const sorted = [...closed].sort((a, b) => (a.exit_time ?? '').localeCompare(b.exit_time ?? '')); let cum = 0; @@ -34,17 +45,31 @@ export default function TradesPage() { return (
- + -
- - - - +
+ + + + = 0 ? '+' : ''}${avgPnl}%`} /> = 0 ? '+' : ''}${cumPnl}%`} />
+ {/* 실시간 (open) 메트릭 - 별도 라인 */} + {open > 0 && ( +
+ = 0 ? '+' : ''}${openLiveSumStr}%`} + hint="현재가 기준 (실시간)" /> + = 0 ? '+' : ''}${openLiveAvg}%`} + hint={`${openWithLive.length}건 평균`} /> + + +
+ )} + {sorted.length > 0 && (
누적 PnL %
@@ -86,28 +111,37 @@ export default function TradesPage() { {loading && 로딩 중...} {!loading && rows.length === 0 && 아직 기록된 트레이드 없음} - {rows.map(r => ( - - {(r.entry_time || '').slice(0, 19).replace('T', ' ')} - {r.symbol} - {r.interval} - {r.direction} - {r.signal_types} - {r.entry_price?.toLocaleString()} - {r.stop_price?.toLocaleString()} - {(r.exit_time || '').slice(0, 19).replace('T', ' ')} - {r.exit_price?.toLocaleString()} - {r.exit_reason || '-'} - 0 ? 'text-green-600' : (r.pnl_pct ?? 0) < 0 ? 'text-red-600' : 'text-slate-500'}`}> - {r.pnl_pct != null ? `${r.pnl_pct > 0 ? '+' : ''}${r.pnl_pct.toFixed(2)}%` : '-'} - - - - {r.status} - - - - ))} + {rows.map(r => { + const isOpen = r.status === 'open'; + const showPnl = isOpen ? r.live_pnl_pct : r.pnl_pct; + const pnlClass = (showPnl ?? 0) > 0 ? 'text-green-600' : (showPnl ?? 0) < 0 ? 'text-red-600' : 'text-slate-500'; + return ( + + {(r.entry_time || '').slice(0, 19).replace('T', ' ')} + {r.symbol} + {r.interval} + {r.direction} + {r.signal_types} + {r.entry_price?.toLocaleString()} + {r.stop_price?.toLocaleString()} + {(r.exit_time || '').slice(0, 19).replace('T', ' ')} + + {isOpen + ? (r.current_price != null ? {r.current_price.toLocaleString()} (현재) : '-') + : (r.exit_price?.toLocaleString() ?? '-')} + + {isOpen ? 진행 중 : (r.exit_reason || '-')} + + {showPnl != null ? `${showPnl > 0 ? '+' : ''}${showPnl.toFixed(2)}%${isOpen ? ' *' : ''}` : '-'} + + + + {r.status} + + + + ); + })}
diff --git a/frontend/components/Chart.tsx b/frontend/components/Chart.tsx index f0c88f3..c75d6fd 100644 --- a/frontend/components/Chart.tsx +++ b/frontend/components/Chart.tsx @@ -37,7 +37,7 @@ const SIG_MARKER = [ { col: 'exhaustion_short', sym: 'star', color: C.red, name: '매수소진', side: 'high' }, ]; -export default function Chart({ rows, lastPrice }: { rows: Row[]; lastPrice?: number | null }) { +export default function Chart({ rows, lastPrice, mobile = false, showLegend = false }: { rows: Row[]; lastPrice?: number | null; mobile?: boolean; showLegend?: boolean }) { if (!rows || rows.length === 0) return
데이터 로딩 중...
; const t = rows.map(r => r.open_time); @@ -114,23 +114,25 @@ export default function Chart({ rows, lastPrice }: { rows: Row[]; lastPrice?: nu data.push({ type: 'scatter', x: t, y: rows.map(r => r.MACD), line: { color: C.blue, width: 1.2 }, name: 'MACD', yaxis: 'y7', showlegend: false }); data.push({ type: 'scatter', x: t, y: rows.map(r => r.MACD_signal), line: { color: C.orange, width: 1.2 }, name: 'Signal', yaxis: 'y7', showlegend: false }); + // 모바일: 메인 차트 더 크게 + 하단 row 컴팩트 (RSI 만 강조, 나머지 작게) const layout: any = { - height: 1400, + height: mobile ? 760 : 1400, paper_bgcolor: '#ffffff', plot_bgcolor: '#ffffff', - font: { color: C.text, size: 11, family: 'Pretendard, Noto Sans KR, sans-serif' }, - margin: { l: 60, r: 70, t: 20, b: 20 }, + font: { color: C.text, size: mobile ? 10 : 11, family: 'Pretendard, Noto Sans KR, sans-serif' }, + margin: { l: mobile ? 42 : 60, r: mobile ? 16 : 70, t: 16, b: 16 }, hovermode: 'x unified', dragmode: 'pan', - showlegend: false, + showlegend: showLegend, + legend: { orientation: 'h', x: 0, y: 1.02, yanchor: 'bottom', font: { size: 10 }, bgcolor: 'rgba(255,255,255,0.95)' }, xaxis: { rangeslider: { visible: false }, gridcolor: C.grid, showspikes: false }, - yaxis: { domain: [0.62, 1.0], gridcolor: C.grid, title: { text: '가격' } }, - yaxis2: { domain: [0.52, 0.61], gridcolor: C.grid, title: { text: 'Taker' } }, - yaxis3: { domain: [0.42, 0.51], gridcolor: C.grid, title: { text: 'OI' } }, - yaxis4: { domain: [0.32, 0.41], gridcolor: C.grid, title: { text: 'FR' } }, - yaxis5: { domain: [0.22, 0.31], gridcolor: C.grid, title: { text: 'L/S' } }, - yaxis6: { domain: [0.11, 0.21], gridcolor: C.grid, title: { text: 'RSI' }, range: [0, 100] }, - yaxis7: { domain: [0.0, 0.10], gridcolor: C.grid, title: { text: 'MACD' } }, + yaxis: { domain: mobile ? [0.55, 1.0] : [0.62, 1.0], gridcolor: C.grid }, + yaxis2: { domain: mobile ? [0.46, 0.54] : [0.52, 0.61], gridcolor: C.grid, title: { text: 'Taker' } }, + yaxis3: { domain: mobile ? [0.37, 0.45] : [0.42, 0.51], gridcolor: C.grid, title: { text: 'OI' } }, + yaxis4: { domain: mobile ? [0.28, 0.36] : [0.32, 0.41], gridcolor: C.grid, title: { text: 'FR' } }, + yaxis5: { domain: mobile ? [0.19, 0.27] : [0.22, 0.31], gridcolor: C.grid, title: { text: 'L/S' } }, + yaxis6: { domain: mobile ? [0.10, 0.18] : [0.11, 0.21], gridcolor: C.grid, title: { text: 'RSI' }, range: [0, 100] }, + yaxis7: { domain: mobile ? [0.0, 0.09] : [0.0, 0.10], gridcolor: C.grid, title: { text: 'MACD' } }, shapes: lastPrice ? [{ type: 'line', xref: 'paper', yref: 'y', x0: 0, x1: 1, y0: lastPrice, y1: lastPrice, diff --git a/frontend/components/sidebar.tsx b/frontend/components/sidebar.tsx index 32eca3b..fc0d3e5 100644 --- a/frontend/components/sidebar.tsx +++ b/frontend/components/sidebar.tsx @@ -4,17 +4,18 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useAuth } from '@/lib/auth'; import { - LayoutDashboard, TrendingUp, KeyRound, Bot, Settings, User, LogOut, Menu, + LayoutDashboard, TrendingUp, KeyRound, Bot, Settings, User, LogOut, Menu, ShieldCheck, } from 'lucide-react'; import { cn } from '@/lib/cn'; -const NAV = [ - { href: '/', label: '대시보드', icon: LayoutDashboard }, - { href: '/trades', label: '트레이드 이력', icon: TrendingUp }, - { href: '/exchange', label: '거래소 API', icon: KeyRound }, - { href: '/automation', label: '자동매매', icon: Bot }, - { href: '/settings', label: '시스템 설정', icon: Settings }, - { href: '/profile', label: '내 정보', icon: User }, +const NAV: { href: string; label: string; icon: any; adminOnly?: boolean }[] = [ + { href: '/', label: '대시보드', icon: LayoutDashboard }, + { href: '/trades', label: '트레이드 이력', icon: TrendingUp }, + { href: '/exchange', label: '거래소 API', icon: KeyRound }, + { href: '/automation', label: '자동매매', icon: Bot }, + { href: '/settings', label: '시스템 설정', icon: Settings }, + { href: '/profile', label: '내 정보', icon: User }, + { href: '/admin/users', label: '사용자 관리', icon: ShieldCheck, adminOnly: true }, ]; const Logo = ({ mini = false }: { mini?: boolean }) => { @@ -146,7 +147,7 @@ function SidebarInner({ mini, setMini, pathname, initial, username, role, logout {/* 메뉴 */}