d16456cb92
# 사용자별 격리 - 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>
249 lines
9.6 KiB
Python
249 lines
9.6 KiB
Python
"""
|
|
PostgreSQL 기반 트레이드 이력 저장.
|
|
|
|
DATABASE_URL 환경변수 미설정 시 전체 silent no-op (로컬 개발에서 streamlit run
|
|
직접 실행해도 에러 안 남). 도커 환경에선 docker-compose 가 자동 주입.
|
|
|
|
- trades: 진입 → 청산 lifecycle. status = 'open' / 'stopped' / 'reversal' / 'cancelled'
|
|
- signal_events: 발사된 모든 알림 raw log (디버그/통계용)
|
|
|
|
스레드 안전: 모든 ops 가 _lock 으로 보호. 알림 스레드 + Streamlit UI 동시 접근.
|
|
"""
|
|
import os
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
try:
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
HAS_PG = True
|
|
except ImportError:
|
|
HAS_PG = False
|
|
|
|
DATABASE_URL = os.environ.get("DATABASE_URL", "")
|
|
_lock = threading.RLock()
|
|
_conn = None
|
|
_init_done = False
|
|
|
|
|
|
def _enabled() -> bool:
|
|
return HAS_PG and bool(DATABASE_URL)
|
|
|
|
|
|
def _get_conn():
|
|
global _conn
|
|
if not _enabled():
|
|
return None
|
|
if _conn is not None:
|
|
try:
|
|
with _conn.cursor() as cur:
|
|
cur.execute("SELECT 1")
|
|
return _conn
|
|
except Exception:
|
|
try:
|
|
_conn.close()
|
|
except Exception:
|
|
pass
|
|
_conn = None
|
|
try:
|
|
_conn = psycopg2.connect(DATABASE_URL, connect_timeout=5)
|
|
_conn.autocommit = True
|
|
except Exception as e:
|
|
print(f"[trades_db] connect 실패: {e}")
|
|
_conn = None
|
|
return _conn
|
|
|
|
|
|
def init_db():
|
|
"""앱 기동 시 1회. 테이블 없으면 생성."""
|
|
global _init_done
|
|
if _init_done or not _enabled():
|
|
return
|
|
with _lock:
|
|
conn = _get_conn()
|
|
if conn is None:
|
|
return
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
CREATE TABLE IF NOT EXISTS trades (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
symbol TEXT NOT NULL,
|
|
interval TEXT NOT NULL,
|
|
direction TEXT NOT NULL,
|
|
signal_types TEXT NOT NULL,
|
|
candle_time TIMESTAMP NOT NULL,
|
|
entry_time TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
entry_price DOUBLE PRECISION NOT NULL,
|
|
stop_price DOUBLE PRECISION NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
exit_time TIMESTAMPTZ,
|
|
exit_price DOUBLE PRECISION,
|
|
exit_reason TEXT,
|
|
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(user_id, symbol, interval, direction, candle_time)")
|
|
|
|
cur.execute("""
|
|
CREATE TABLE IF NOT EXISTS signal_events (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
symbol TEXT NOT NULL,
|
|
interval TEXT NOT NULL,
|
|
signal_type TEXT NOT NULL,
|
|
direction TEXT NOT NULL,
|
|
candle_time TIMESTAMP NOT NULL,
|
|
fired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
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")
|
|
except Exception as e:
|
|
print(f"[trades_db] init 실패: {e}")
|
|
|
|
|
|
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()
|
|
if conn is None:
|
|
return
|
|
try:
|
|
with conn.cursor() as cur:
|
|
for e in group:
|
|
cur.execute(
|
|
"INSERT INTO signal_events(user_id, symbol, interval, signal_type, direction, candle_time, price) "
|
|
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
(
|
|
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,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
print(f"[trades_db] log_signal_events 실패: {e}")
|
|
|
|
|
|
def record_entry(user_id: int, symbol: str, interval: str, direction: str, signal_types: List[str],
|
|
candle_time, entry_price: float, stop_price: float):
|
|
if not _enabled() or not user_id:
|
|
return
|
|
with _lock:
|
|
conn = _get_conn()
|
|
if conn is None:
|
|
return
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"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",
|
|
(user_id, symbol, interval, direction, _to_naive(candle_time)),
|
|
)
|
|
if cur.fetchone():
|
|
return
|
|
cur.execute(
|
|
"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, %s, 'open')",
|
|
(
|
|
user_id, symbol, interval, direction, ",".join(signal_types),
|
|
_to_naive(candle_time), float(entry_price), float(stop_price),
|
|
),
|
|
)
|
|
except Exception as e:
|
|
print(f"[trades_db] record_entry 실패: {e}")
|
|
|
|
|
|
def record_exit(user_id: int, symbol: str, interval: str, direction: str, candle_time,
|
|
exit_price: float, exit_reason: str):
|
|
if not _enabled() or not user_id:
|
|
return
|
|
with _lock:
|
|
conn = _get_conn()
|
|
if conn is None:
|
|
return
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"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",
|
|
(user_id, symbol, interval, direction, _to_naive(candle_time)),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return
|
|
trade_id, entry_price = row
|
|
if direction == "long":
|
|
pnl = (float(exit_price) - float(entry_price)) / float(entry_price) * 100.0
|
|
else:
|
|
pnl = (float(entry_price) - float(exit_price)) / float(entry_price) * 100.0
|
|
cur.execute(
|
|
"UPDATE trades SET status=%s, exit_time=now(), exit_price=%s, exit_reason=%s, pnl_pct=%s "
|
|
"WHERE id=%s",
|
|
(exit_reason, float(exit_price), exit_reason, pnl, trade_id),
|
|
)
|
|
except Exception as e:
|
|
print(f"[trades_db] record_exit 실패: {e}")
|
|
|
|
|
|
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()
|
|
if conn is None:
|
|
return []
|
|
try:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
if status:
|
|
cur.execute(
|
|
"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 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(user_id: int, limit: int = 1000) -> list:
|
|
if not _enabled() or not user_id:
|
|
return []
|
|
with _lock:
|
|
conn = _get_conn()
|
|
if conn is None:
|
|
return []
|
|
try:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
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}")
|
|
return []
|
|
|
|
|
|
def _to_naive(ts):
|
|
if ts is None:
|
|
return None
|
|
if hasattr(ts, "to_pydatetime"):
|
|
ts = ts.to_pydatetime()
|
|
if isinstance(ts, datetime) and ts.tzinfo is not None:
|
|
ts = ts.replace(tzinfo=None)
|
|
return ts
|