사용자별 격리 시스템 + 사용자 관리 + 라이브 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
+20 -24
View File
@@ -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
+18 -1
View File
@@ -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])
+14 -2
View File
@@ -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 + 백그라운드 스레드 시작")
+1 -1
View File
@@ -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"),
+11 -9
View File
@@ -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}
+13 -10
View File
@@ -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}
+10 -9
View File
@@ -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)}
+43 -5
View File
@@ -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))
+85 -10
View File
@@ -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}
+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()
+100 -46
View File
@@ -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 []
+211
View File
@@ -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<User[]>([]);
const [form, setForm] = useState({ username: '', password: '', role: 'user' });
const [msg, setMsg] = useState<{ level: any; text: string } | null>(null);
const [resetTarget, setResetTarget] = useState<number | null>(null);
const [resetPw, setResetPw] = useState('');
useEffect(() => {
if (user && user.role !== 'admin') {
router.replace('/');
}
}, [user]);
async function load() {
try {
const data = await api.get<User[]>('/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 (
<div>
<Banner level="danger"> .</Banner>
</div>
);
}
return (
<div>
<PageHeader title="🛡️ 사용자 관리" subtitle={`등록된 사용자 ${users.length}명 · 관리자 전용`} />
{msg && <div className="mb-4"><Banner level={msg.level}>{msg.text}</Banner></div>}
<div className="grid lg:grid-cols-3 gap-5 mb-5">
{/* 새 사용자 추가 */}
<Card>
<div className="flex items-center gap-2 mb-3 text-blue-600">
<UserPlus size={16} /> <span className="font-bold text-slate-800 text-sm"> </span>
</div>
<form onSubmit={add} className="space-y-3">
<Input label="아이디" value={form.username}
onChange={(e: any) => setForm({ ...form, username: e.target.value })}
placeholder="username" required autoComplete="off" />
<Input label="비밀번호 (6자 이상)" type="password" value={form.password}
onChange={(e: any) => setForm({ ...form, password: e.target.value })}
required autoComplete="new-password" />
<Select label="권한" value={form.role}
onChange={(e: any) => setForm({ ...form, role: e.target.value })}>
<option value="user">user</option>
<option value="admin">admin</option>
</Select>
<Button type="submit" className="w-full"></Button>
</form>
</Card>
{/* 사용자 목록 */}
<Card className="lg:col-span-2">
<div className="flex items-center gap-2 mb-3 text-blue-600">
<ShieldCheck size={16} /> <span className="font-bold text-slate-800 text-sm"> </span>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
{['ID', '아이디', '권한', '가입', '마지막 로그인', '작업'].map(h => (
<th key={h} className="px-3 py-2 text-left font-semibold border-b border-slate-200">{h}</th>
))}
</tr>
</thead>
<tbody>
{users.length === 0 && <tr><td colSpan={6} className="p-4 text-center text-slate-400"> ...</td></tr>}
{users.map(u => {
const isMe = u.username === user?.username;
return (
<tr key={u.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 font-mono text-slate-500">#{u.id}</td>
<td className="px-3 py-2 font-semibold">
{u.username}
{isMe && <span className="ml-1 text-[10px] text-blue-600">()</span>}
</td>
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded font-semibold ${u.role === 'admin' ? 'bg-amber-100 text-amber-800' : 'bg-slate-100 text-slate-700'}`}>
{u.role === 'admin' && <Shield size={10} />}
{u.role}
</span>
</td>
<td className="px-3 py-2 font-mono text-slate-500">{(u.created_at || '').slice(0, 16).replace('T', ' ')}</td>
<td className="px-3 py-2 font-mono text-slate-500">{(u.last_login_at || '-').slice(0, 16).replace('T', ' ')}</td>
<td className="px-3 py-2">
<div className="flex gap-1 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => { setResetTarget(u.id); setResetPw(''); }}>
<KeyRound size={11} />
</Button>
{!isMe && (
<Button size="sm" variant="secondary"
onClick={() => changeRole(u, u.role === 'admin' ? 'user' : 'admin')}>
{u.role === 'admin' ? '↓ user' : '↑ admin'}
</Button>
)}
{!isMe && (
<Button size="sm" variant="danger" onClick={() => del(u)}>
<Trash2 size={11} />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
{/* 비번 리셋 모달 */}
{resetTarget != null && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setResetTarget(null)}>
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-sm mx-4" onClick={e => e.stopPropagation()}>
<h3 className="text-base font-bold mb-3"> </h3>
<p className="text-xs text-slate-500 mb-3">
<code>{users.find(u => u.id === resetTarget)?.username}</code> .
</p>
<form onSubmit={resetPassword} className="space-y-3">
<Input label="새 비밀번호 (6자 이상)" type="password" value={resetPw}
onChange={(e: any) => setResetPw(e.target.value)} autoFocus required />
<div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1"></Button>
<Button type="button" variant="secondary" className="flex-1" onClick={() => setResetTarget(null)}></Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+25 -8
View File
@@ -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<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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() {
<div>
<PageHeader
title="💰 돈복사 대시보드"
subtitle={`${symbol} · ${interval} · 마지막 갱신 ${now} KST · 돈이 복사되는 시스템`}
subtitle={`${symbol} · ${interval} · ${candleLimit} 캔들 · 마지막 갱신 ${now} KST`}
right={
<button onClick={() => 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">
<RefreshCw size={14} />
@@ -50,7 +63,7 @@ export default function DashboardPage() {
/>
<Card className="mb-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 items-end">
<Select label="심볼" value={symbol} onChange={(e: any) => setSymbol(e.target.value)}>
{SYMBOLS.map(s => <option key={s} value={s}>{s}</option>)}
</Select>
@@ -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" />
</div>
<div className="flex items-center"><Toggle checked={auto} onChange={setAuto} label="자동 갱신" /></div>
<div className="text-xs text-slate-500 text-right">
{loading ? '⏳ 로딩 중...' : data?.last_price ? `현재가: ${data.last_price.toLocaleString()}` : ''}
</div>
<div className="flex items-center"><Toggle checked={mobile} onChange={setMobile} label={`모바일 (${mobile ? CANDLES_MOBILE : CANDLES_DESKTOP})`} /></div>
<div className="flex items-center"><Toggle checked={showLegend} onChange={setShowLegend} label="범례" /></div>
</div>
{(loading || data?.last_price) && (
<div className="text-xs text-slate-500 text-right mt-2 pt-2 border-t border-slate-100">
{loading ? '⏳ 로딩 중...' : `현재가: ${data.last_price.toLocaleString()} ${symbol.replace('USDT', '/USDT')}`}
</div>
)}
</Card>
{data?.banner && (
@@ -79,7 +96,7 @@ export default function DashboardPage() {
{error && <Banner level="danger">{error}</Banner>}
<Card className="p-2 md:p-3">
<Chart rows={data?.rows || []} lastPrice={data?.last_price} />
<Chart rows={data?.rows || []} lastPrice={data?.last_price} mobile={mobile} showLegend={showLegend} />
</Card>
</div>
);
+64 -30
View File
@@ -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 (
<div>
<PageHeader title="📈 트레이드 이력" subtitle={`${rows.length}건 · 종료 ${closed.length} · 진행 중 ${open}`} />
<PageHeader title="📈 트레이드 이력" subtitle={`${rows.length}건 · 실거래 ${closed.length} · 진행 중 ${open} · 취소 ${cancelled}`} />
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 mb-5">
<Stat label="총 트레이드" value={rows.length} />
<Stat label="진행 중" value={open} />
<Stat label="종료" value={closed.length} />
<Stat label="승률" value={`${winRate}%`} hint={`${wins}W / ${losses}L`} />
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 mb-3">
<Stat label="총 트레이드" value={rows.length} hint={`취소 ${cancelled} 포함`} />
<Stat label="진행 중" value={open} hint={`실시간 ${openWithLive.length}건 추적`} />
<Stat label="실거래 종료" value={closed.length} hint="stop_loss + reversal" />
<Stat label="승률 (실거래)" value={`${winRate}%`} hint={`${wins}W / ${losses}L`} />
<Stat label="평균 PnL%" value={`${parseFloat(avgPnl) >= 0 ? '+' : ''}${avgPnl}%`} />
<Stat label="누적 PnL%" value={`${parseFloat(cumPnl) >= 0 ? '+' : ''}${cumPnl}%`} />
</div>
{/* 실시간 (open) 메트릭 - 별도 라인 */}
{open > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
<Stat label="🔴 진행 중 합산 PnL%" value={`${parseFloat(openLiveSumStr) >= 0 ? '+' : ''}${openLiveSumStr}%`}
hint="현재가 기준 (실시간)" />
<Stat label="🔴 진행 중 평균 PnL%" value={`${parseFloat(openLiveAvg) >= 0 ? '+' : ''}${openLiveAvg}%`}
hint={`${openWithLive.length}건 평균`} />
<Stat label="🔴 진행 중 W/L" value={`${openWins}W / ${openLosses}L`}
hint={openWithLive.length ? `현재 승률 ${(openWins / openWithLive.length * 100).toFixed(1)}%` : ''} />
<Stat label="🔴 진행 중 + 실거래 누적" value={`${(parseFloat(cumPnl) + openLiveSum).toFixed(2)}%`}
hint="실현 + 미실현" />
</div>
)}
{sorted.length > 0 && (
<Card className="mb-5">
<div className="text-sm font-bold text-slate-800 mb-2"> PnL %</div>
@@ -86,28 +111,37 @@ export default function TradesPage() {
<tbody>
{loading && <tr><td colSpan={12} className="p-4 text-center text-slate-400"> ...</td></tr>}
{!loading && rows.length === 0 && <tr><td colSpan={12} className="p-4 text-center text-slate-400"> </td></tr>}
{rows.map(r => (
<tr key={r.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 font-mono">{(r.entry_time || '').slice(0, 19).replace('T', ' ')}</td>
<td className="px-3 py-2">{r.symbol}</td>
<td className="px-3 py-2">{r.interval}</td>
<td className={`px-3 py-2 font-semibold ${r.direction === 'long' ? 'text-green-600' : 'text-red-600'}`}>{r.direction}</td>
<td className="px-3 py-2">{r.signal_types}</td>
<td className="px-3 py-2 font-mono text-right">{r.entry_price?.toLocaleString()}</td>
<td className="px-3 py-2 font-mono text-right">{r.stop_price?.toLocaleString()}</td>
<td className="px-3 py-2 font-mono">{(r.exit_time || '').slice(0, 19).replace('T', ' ')}</td>
<td className="px-3 py-2 font-mono text-right">{r.exit_price?.toLocaleString()}</td>
<td className="px-3 py-2">{r.exit_reason || '-'}</td>
<td className={`px-3 py-2 font-mono text-right font-bold ${(r.pnl_pct ?? 0) > 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)}%` : '-'}
</td>
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 text-[10px] rounded ${r.status === 'open' ? 'bg-blue-100 text-blue-800' : r.status === 'stop_loss' ? 'bg-red-100 text-red-800' : 'bg-slate-100 text-slate-700'}`}>
{r.status}
</span>
</td>
</tr>
))}
{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 (
<tr key={r.id} className={`border-b border-slate-100 hover:bg-slate-50 ${isOpen ? 'bg-blue-50/40' : ''}`}>
<td className="px-3 py-2 font-mono">{(r.entry_time || '').slice(0, 19).replace('T', ' ')}</td>
<td className="px-3 py-2">{r.symbol}</td>
<td className="px-3 py-2">{r.interval}</td>
<td className={`px-3 py-2 font-semibold ${r.direction === 'long' ? 'text-green-600' : 'text-red-600'}`}>{r.direction}</td>
<td className="px-3 py-2">{r.signal_types}</td>
<td className="px-3 py-2 font-mono text-right">{r.entry_price?.toLocaleString()}</td>
<td className="px-3 py-2 font-mono text-right">{r.stop_price?.toLocaleString()}</td>
<td className="px-3 py-2 font-mono">{(r.exit_time || '').slice(0, 19).replace('T', ' ')}</td>
<td className="px-3 py-2 font-mono text-right">
{isOpen
? (r.current_price != null ? <span className="text-blue-600 italic">{r.current_price.toLocaleString()} <span className="text-[10px]">()</span></span> : '-')
: (r.exit_price?.toLocaleString() ?? '-')}
</td>
<td className="px-3 py-2">{isOpen ? <span className="text-blue-500 italic"> </span> : (r.exit_reason || '-')}</td>
<td className={`px-3 py-2 font-mono text-right font-bold ${pnlClass} ${isOpen ? 'italic' : ''}`}>
{showPnl != null ? `${showPnl > 0 ? '+' : ''}${showPnl.toFixed(2)}%${isOpen ? ' *' : ''}` : '-'}
</td>
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 text-[10px] rounded ${isOpen ? 'bg-blue-100 text-blue-800' : r.status === 'stop_loss' ? 'bg-red-100 text-red-800' : 'bg-slate-100 text-slate-700'}`}>
{r.status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
+14 -12
View File
@@ -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 <div className="text-slate-400 text-sm py-8 text-center"> ...</div>;
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,
+10 -9
View File
@@ -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
{/* 메뉴 */}
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2">
{NAV.map((n) => {
{NAV.filter(n => !n.adminOnly || role === 'admin').map((n) => {
const active = pathname === n.href || (n.href !== '/' && pathname.startsWith(n.href));
return (
<Link
+9 -1
View File
@@ -26,7 +26,15 @@ async function request<T = any>(path: string, opts: RequestInit = {}): Promise<T
if (!res.ok) {
const text = await res.text();
let msg = text;
try { msg = JSON.parse(text).detail || text; } catch {}
try {
const j = JSON.parse(text);
const d = j.detail;
if (typeof d === 'string') msg = d;
else if (Array.isArray(d)) msg = d.map((e: any) => e?.msg || e?.detail || JSON.stringify(e)).join(', ');
else if (d && typeof d === 'object') msg = d.msg || JSON.stringify(d);
else msg = text;
} catch {}
if (typeof msg !== 'string') msg = String(msg);
throw new Error(msg || `${res.status}`);
}
if (res.status === 204) return undefined as any;
+82 -24
View File
@@ -12,7 +12,7 @@ SQLite 의 thread-safe 모드 (`check_same_thread=False`) 로 연다.
import os
import sqlite3
import threading
from typing import Any, Optional
from typing import Any, Optional, List
DB_PATH = os.environ.get("SETTINGS_DB_PATH", "/app/data/settings.db")
_lock = threading.RLock()
@@ -25,6 +25,7 @@ def _get_conn() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
_conn = sqlite3.connect(DB_PATH, check_same_thread=False, isolation_level=None)
_conn.execute("PRAGMA journal_mode=WAL")
# 글로벌 settings 테이블 (옛 호환 + 기본값 시드용)
_conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
@@ -34,6 +35,19 @@ def _get_conn() -> sqlite3.Connection:
)
"""
)
# 사용자별 settings 테이블
_conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, key)
)
"""
)
_conn.execute("CREATE INDEX IF NOT EXISTS idx_us_user ON user_settings(user_id)")
return _conn
@@ -74,7 +88,7 @@ DEFAULTS = {
def init_db_with_env_defaults():
"""최초 기동 시 .env 값을 DB 기본값으로 복사. 이미 존재하는 키는 건드리지 않음."""
"""최초 기동 시 글로벌 settings 의 .env 값 시드 (옛 호환용)."""
with _lock:
conn = _get_conn()
for k, default in DEFAULTS.items():
@@ -88,15 +102,33 @@ def init_db_with_env_defaults():
}
if k in env_map:
seed = os.environ.get(env_map[k], default) or default
conn.execute(
"INSERT INTO settings(key, value) VALUES (?, ?)",
(k, seed),
)
conn.execute("INSERT INTO settings(key, value) VALUES (?, ?)", (k, seed),)
def get(key: str, default: Any = None) -> str:
def ensure_user_defaults(user_id: int):
"""사용자가 처음 로그인 / 생성될 때 호출. 글로벌 DEFAULTS 를 그 사용자 키로 복사."""
if not user_id:
return
with _lock:
conn = _get_conn()
for k, default in DEFAULTS.items():
cur = conn.execute("SELECT value FROM user_settings WHERE user_id=? AND key=?", (user_id, k))
if cur.fetchone() is None:
conn.execute(
"INSERT INTO user_settings(user_id, key, value) VALUES (?, ?, ?)",
(user_id, k, default),
)
def get(key: str, default: Any = None, user_id: Optional[int] = None) -> str:
"""user_id 가 있으면 그 사용자 값. 없으면 글로벌 (옛 호환). 사용자 값 없으면 DEFAULTS."""
with _lock:
conn = _get_conn()
if user_id:
cur = conn.execute("SELECT value FROM user_settings WHERE user_id=? AND key=?", (user_id, key))
row = cur.fetchone()
if row is not None:
return row[0]
cur = conn.execute("SELECT value FROM settings WHERE key=?", (key,))
row = cur.fetchone()
if row is None:
@@ -104,46 +136,72 @@ def get(key: str, default: Any = None) -> str:
return row[0]
def get_int(key: str, default: int = 0) -> int:
def get_int(key: str, default: int = 0, user_id: Optional[int] = None) -> int:
try:
return int(get(key, default))
return int(get(key, default, user_id))
except (TypeError, ValueError):
return default
def get_float(key: str, default: float = 0.0) -> float:
def get_float(key: str, default: float = 0.0, user_id: Optional[int] = None) -> float:
try:
return float(get(key, default))
return float(get(key, default, user_id))
except (TypeError, ValueError):
return default
def get_bool(key: str, default: bool = False) -> bool:
v = get(key, "1" if default else "0")
def get_bool(key: str, default: bool = False, user_id: Optional[int] = None) -> bool:
v = get(key, "1" if default else "0", user_id)
return str(v).strip().lower() in ("1", "true", "yes", "on")
def get_list(key: str, default=None, sep: str = ",") -> list:
v = get(key, "")
def get_list(key: str, default=None, sep: str = ",", user_id: Optional[int] = None) -> list:
v = get(key, "", user_id)
if not v:
return list(default or [])
return [s.strip() for s in v.split(sep) if s.strip()]
def set_value(key: str, value: Any):
def set_value(key: str, value: Any, user_id: Optional[int] = None):
with _lock:
conn = _get_conn()
conn.execute(
"""
INSERT INTO settings(key, value, updated_at) VALUES(?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, str(value)),
)
if user_id:
conn.execute(
"""
INSERT INTO user_settings(user_id, key, value, updated_at)
VALUES(?, ?, ?, datetime('now'))
ON CONFLICT(user_id, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(user_id, key, str(value)),
)
else:
conn.execute(
"""
INSERT INTO settings(key, value, updated_at) VALUES(?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, str(value)),
)
def all_settings() -> dict:
def all_settings(user_id: Optional[int] = None) -> dict:
"""user_id 가 있으면 그 사용자의 설정 (없는 키는 글로벌 fallback + DEFAULTS).
없으면 글로벌 settings 만 반환 (옛 호환)."""
with _lock:
conn = _get_conn()
if user_id:
ensure_user_defaults(user_id)
cur = conn.execute("SELECT key, value FROM user_settings WHERE user_id=? ORDER BY key", (user_id,))
return dict(cur.fetchall())
cur = conn.execute("SELECT key, value FROM settings ORDER BY key")
return dict(cur.fetchall())
def list_user_ids_with_alerts_enabled() -> list:
"""모든 사용자 중 alert_enabled=1 인 user_id 목록. 알림 스레드용."""
with _lock:
conn = _get_conn()
cur = conn.execute(
"SELECT user_id FROM user_settings WHERE key='alert_enabled' AND value='1'"
)
return [r[0] for r in cur.fetchall()]
+33 -29
View File
@@ -84,9 +84,12 @@ def init_db():
pnl_pct DOUBLE PRECISION
)
""")
# 사용자별 격리
cur.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS user_id BIGINT NOT NULL DEFAULT 1")
cur.execute("CREATE INDEX IF NOT EXISTS idx_trades_user ON trades(user_id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_trades_entry_time ON trades(entry_time DESC)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_trades_lookup ON trades(symbol, interval, direction, candle_time)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_trades_lookup ON trades(user_id, symbol, interval, direction, candle_time)")
cur.execute("""
CREATE TABLE IF NOT EXISTS signal_events (
@@ -100,6 +103,8 @@ def init_db():
price DOUBLE PRECISION
)
""")
cur.execute("ALTER TABLE signal_events ADD COLUMN IF NOT EXISTS user_id BIGINT NOT NULL DEFAULT 1")
cur.execute("CREATE INDEX IF NOT EXISTS idx_signal_events_user ON signal_events(user_id, fired_at DESC)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_signal_events_fired ON signal_events(fired_at DESC)")
_init_done = True
print("[trades_db] init OK")
@@ -107,9 +112,8 @@ def init_db():
print(f"[trades_db] init 실패: {e}")
def log_signal_events(symbol: str, interval: str, group: List[Dict[str, Any]]):
"""알림 발사 직전에 호출. group 내 각 signal 을 signal_events 에 기록."""
if not _enabled():
def log_signal_events(user_id: int, symbol: str, interval: str, group: List[Dict[str, Any]]):
if not _enabled() or not user_id:
return
with _lock:
conn = _get_conn()
@@ -119,10 +123,10 @@ def log_signal_events(symbol: str, interval: str, group: List[Dict[str, Any]]):
with conn.cursor() as cur:
for e in group:
cur.execute(
"INSERT INTO signal_events(symbol, interval, signal_type, direction, candle_time, price) "
"VALUES (%s, %s, %s, %s, %s, %s)",
"INSERT INTO signal_events(user_id, symbol, interval, signal_type, direction, candle_time, price) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
symbol, interval, e["sig"], e["direction"],
user_id, symbol, interval, e["sig"], e["direction"],
_to_naive(e["candle_time"]),
float(e["row"]["open"]) if "row" in e and e["row"] is not None else None,
),
@@ -131,11 +135,9 @@ def log_signal_events(symbol: str, interval: str, group: List[Dict[str, Any]]):
print(f"[trades_db] log_signal_events 실패: {e}")
def record_entry(symbol: str, interval: str, direction: str, signal_types: List[str],
def record_entry(user_id: int, symbol: str, interval: str, direction: str, signal_types: List[str],
candle_time, entry_price: float, stop_price: float):
"""진입 신호 발사 시 호출. 이미 같은 (symbol, interval, direction, candle_time) open 트레이드가
있으면 무시 (중복 방지)."""
if not _enabled():
if not _enabled() or not user_id:
return
with _lock:
conn = _get_conn()
@@ -144,18 +146,18 @@ def record_entry(symbol: str, interval: str, direction: str, signal_types: List[
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM trades WHERE symbol=%s AND interval=%s AND direction=%s "
"SELECT id FROM trades WHERE user_id=%s AND symbol=%s AND interval=%s AND direction=%s "
"AND candle_time=%s AND status='open' LIMIT 1",
(symbol, interval, direction, _to_naive(candle_time)),
(user_id, symbol, interval, direction, _to_naive(candle_time)),
)
if cur.fetchone():
return
cur.execute(
"INSERT INTO trades(symbol, interval, direction, signal_types, candle_time, "
"INSERT INTO trades(user_id, symbol, interval, direction, signal_types, candle_time, "
"entry_price, stop_price, status) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, 'open')",
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'open')",
(
symbol, interval, direction, ",".join(signal_types),
user_id, symbol, interval, direction, ",".join(signal_types),
_to_naive(candle_time), float(entry_price), float(stop_price),
),
)
@@ -163,10 +165,9 @@ def record_entry(symbol: str, interval: str, direction: str, signal_types: List[
print(f"[trades_db] record_entry 실패: {e}")
def record_exit(symbol: str, interval: str, direction: str, candle_time,
def record_exit(user_id: int, symbol: str, interval: str, direction: str, candle_time,
exit_price: float, exit_reason: str):
"""진입 candle_time 매칭으로 open 트레이드를 close. 없으면 무시."""
if not _enabled():
if not _enabled() or not user_id:
return
with _lock:
conn = _get_conn()
@@ -175,9 +176,9 @@ def record_exit(symbol: str, interval: str, direction: str, candle_time,
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id, entry_price FROM trades WHERE symbol=%s AND interval=%s AND direction=%s "
"SELECT id, entry_price FROM trades WHERE user_id=%s AND symbol=%s AND interval=%s AND direction=%s "
"AND candle_time=%s AND status='open' ORDER BY id DESC LIMIT 1",
(symbol, interval, direction, _to_naive(candle_time)),
(user_id, symbol, interval, direction, _to_naive(candle_time)),
)
row = cur.fetchone()
if not row:
@@ -196,8 +197,8 @@ def record_exit(symbol: str, interval: str, direction: str, candle_time,
print(f"[trades_db] record_exit 실패: {e}")
def fetch_trades(limit: int = 500, status: Optional[str] = None) -> list:
if not _enabled():
def fetch_trades(user_id: int, limit: int = 500, status: Optional[str] = None) -> list:
if not _enabled() or not user_id:
return []
with _lock:
conn = _get_conn()
@@ -207,19 +208,19 @@ def fetch_trades(limit: int = 500, status: Optional[str] = None) -> list:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
if status:
cur.execute(
"SELECT * FROM trades WHERE status=%s ORDER BY entry_time DESC LIMIT %s",
(status, limit),
"SELECT * FROM trades WHERE user_id=%s AND status=%s ORDER BY entry_time DESC LIMIT %s",
(user_id, status, limit),
)
else:
cur.execute("SELECT * FROM trades ORDER BY entry_time DESC LIMIT %s", (limit,))
cur.execute("SELECT * FROM trades WHERE user_id=%s ORDER BY entry_time DESC LIMIT %s", (user_id, limit,))
return cur.fetchall()
except Exception as e:
print(f"[trades_db] fetch_trades 실패: {e}")
return []
def fetch_signal_events(limit: int = 1000) -> list:
if not _enabled():
def fetch_signal_events(user_id: int, limit: int = 1000) -> list:
if not _enabled() or not user_id:
return []
with _lock:
conn = _get_conn()
@@ -227,7 +228,10 @@ def fetch_signal_events(limit: int = 1000) -> list:
return []
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM signal_events ORDER BY fired_at DESC LIMIT %s", (limit,))
cur.execute(
"SELECT * FROM signal_events WHERE user_id=%s ORDER BY fired_at DESC LIMIT %s",
(user_id, limit,),
)
return cur.fetchall()
except Exception as e:
print(f"[trades_db] fetch_signal_events 실패: {e}")
+52
View File
@@ -168,6 +168,58 @@ def create_user(username: str, password: str, role: str = "user") -> Optional[in
return None
def delete_user(user_id: int) -> bool:
if not _enabled():
return False
with _lock:
conn = _get_conn()
if conn is None:
return False
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM users WHERE id=%s", (user_id,))
return cur.rowcount > 0
except Exception as e:
print(f"[users_db] delete_user 실패: {e}")
return False
def admin_reset_password(user_id: int, new_password: str) -> bool:
"""관리자가 다른 사용자 비번 초기화. 본인 비번 변경은 change_password() 사용."""
if not _enabled() or len(new_password) < 6:
return False
with _lock:
conn = _get_conn()
if conn is None:
return False
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE users SET password_hash=%s WHERE id=%s",
(_hash(new_password), user_id),
)
return cur.rowcount > 0
except Exception as e:
print(f"[users_db] admin_reset_password 실패: {e}")
return False
def set_role(user_id: int, role: str) -> bool:
if not _enabled() or role not in ("admin", "user"):
return False
with _lock:
conn = _get_conn()
if conn is None:
return False
try:
with conn.cursor() as cur:
cur.execute("UPDATE users SET role=%s WHERE id=%s", (role, user_id))
return cur.rowcount > 0
except Exception as e:
print(f"[users_db] set_role 실패: {e}")
return False
def list_users():
if not _enabled():
return []