Files
tradeing/exchange_keys.py
T
chpark c4e6aab7b2 React + FastAPI 풀 마이그레이션 — Streamlit 제거
- backend/ — FastAPI + JWT + 모든 REST 엔드포인트
- frontend/ — Next.js 14 + Tailwind + 7페이지 (대시보드/트레이드/거래소/자동매매/설정/내정보/로그인)
- core_logic.py — 신호계산/알림 로직 분리 (기존 app_streamlit.py 에서 추출)
- users_db.py + bcrypt 인증, exchange_keys.py + Fernet 암호화
- trades_db.py — 진입/청산 lifecycle 추적, signal_events raw 로그
- settings_db.py — 모든 운영 파라미터 DB 영속 저장 (RSI/거래량/펀딩비 임계값 포함)
- docker-compose: frontend / backend / postgres + Traefik 라우팅
- assets/logo.svg — JUNGGOMOA 그라디언트 로고

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:27:11 +09:00

372 lines
13 KiB
Python

"""
거래소 API 키 영속 저장 (PostgreSQL + Fernet 대칭 암호화).
마스터 키는 ENCRYPTION_KEY 환경변수 (또는 /app/data/.encryption_key 자동 생성).
컨테이너 재기동 / 백업 시 마스터 키 분실하면 저장된 API 키들 복호화 불가하므로
.env 에 명시 보관 권장 (docker-compose 의 .env).
테이블: exchange_credentials
id, exchange, label, api_key_enc, api_secret_enc, passphrase_enc,
testnet, enabled, created_at, updated_at
자동매매 설정도 단일 row 로 자동 저장 (settings_db 와 분리: PostgreSQL 일원화).
테이블: automation_config (key, value)
"""
import os
import threading
from typing import List, Dict, Optional, Any
try:
import psycopg2
import psycopg2.extras
HAS_PG = True
except ImportError:
HAS_PG = False
try:
from cryptography.fernet import Fernet, InvalidToken
HAS_CRYPTO = True
except ImportError:
HAS_CRYPTO = False
DATABASE_URL = os.environ.get("DATABASE_URL", "")
KEY_FILE = "/app/data/.encryption_key"
_lock = threading.RLock()
_conn = None
_init_done = False
_fernet: Optional["Fernet"] = None
def _enabled() -> bool:
return HAS_PG and HAS_CRYPTO and bool(DATABASE_URL)
def _get_fernet():
global _fernet
if _fernet is not None:
return _fernet
if not HAS_CRYPTO:
return None
key = os.environ.get("ENCRYPTION_KEY", "").strip()
if not key:
if os.path.exists(KEY_FILE):
with open(KEY_FILE, "rb") as f:
key = f.read().strip().decode()
else:
os.makedirs(os.path.dirname(KEY_FILE), exist_ok=True)
new_key = Fernet.generate_key()
with open(KEY_FILE, "wb") as f:
f.write(new_key)
os.chmod(KEY_FILE, 0o600)
key = new_key.decode()
print(f"[exchange_keys] 마스터 키 자동 생성됨: {KEY_FILE}. .env 의 ENCRYPTION_KEY 로 옮겨 보관 권장.")
_fernet = Fernet(key.encode() if isinstance(key, str) else key)
return _fernet
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: pass
_conn = None
try:
_conn = psycopg2.connect(DATABASE_URL, connect_timeout=5)
_conn.autocommit = True
except Exception as e:
print(f"[exchange_keys] connect 실패: {e}")
_conn = None
return _conn
def init_db():
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 exchange_credentials (
id BIGSERIAL PRIMARY KEY,
exchange TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
api_key_enc TEXT NOT NULL,
api_secret_enc TEXT NOT NULL,
passphrase_enc TEXT,
testnet BOOLEAN NOT NULL DEFAULT FALSE,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
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,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
# 기본 자동매매 설정 (없으면 시드)
_seed_automation(cur)
_init_done = True
print("[exchange_keys] init OK")
except Exception as e:
print(f"[exchange_keys] init 실패: {e}")
AUTOMATION_DEFAULTS = {
"enabled": "0", # 자동매매 ON/OFF (글로벌 킬스위치)
"dry_run": "1", # 1 = 실제 주문 X, 시그널만 기록 (안전)
"active_credential": "", # exchange_credentials.id (활성 키)
"leverage": "10",
"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)
}
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),
)
# ──────────────────────────────────────────────
# Encryption helpers
# ──────────────────────────────────────────────
def _encrypt(plaintext: Optional[str]) -> Optional[str]:
if plaintext is None or plaintext == "":
return None
f = _get_fernet()
if f is None:
return None
return f.encrypt(plaintext.encode()).decode()
def _decrypt(ciphertext: Optional[str]) -> Optional[str]:
if not ciphertext:
return None
f = _get_fernet()
if f is None:
return None
try:
return f.decrypt(ciphertext.encode()).decode()
except InvalidToken:
return None
def _mask(s: Optional[str]) -> str:
if not s:
return ""
if len(s) <= 8:
return "*" * len(s)
return s[:4] + "" + s[-4:]
# ──────────────────────────────────────────────
# Exchange credentials CRUD
# ──────────────────────────────────────────────
SUPPORTED_EXCHANGES = ["binance", "bybit", "okx", "bitget", "upbit", "bithumb"]
def list_credentials() -> List[Dict[str, Any]]:
if not _enabled():
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 id, exchange, label, testnet, enabled, created_at, updated_at, "
"api_key_enc, api_secret_enc, passphrase_enc "
"FROM exchange_credentials ORDER BY id DESC"
)
rows = cur.fetchall()
for r in rows:
r["api_key_masked"] = _mask(_decrypt(r.pop("api_key_enc", None)))
r["api_secret_masked"] = _mask(_decrypt(r.pop("api_secret_enc", None)))
pp = _decrypt(r.pop("passphrase_enc", None))
r["passphrase_masked"] = _mask(pp) if pp else ""
return rows
except Exception as e:
print(f"[exchange_keys] list_credentials 실패: {e}")
return []
def get_credential(cred_id: int) -> Optional[Dict[str, Any]]:
"""복호화된 키를 그대로 반환. 자동매매 어댑터에서 호출."""
if not _enabled():
return None
with _lock:
conn = _get_conn()
if conn is None:
return None
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM exchange_credentials WHERE id=%s", (cred_id,))
row = cur.fetchone()
if row is None:
return None
row["api_key"] = _decrypt(row.pop("api_key_enc", None))
row["api_secret"] = _decrypt(row.pop("api_secret_enc", None))
row["passphrase"] = _decrypt(row.pop("passphrase_enc", None))
return row
except Exception as e:
print(f"[exchange_keys] get_credential 실패: {e}")
return None
def add_credential(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():
return None
if not api_key or not api_secret:
return None
with _lock:
conn = _get_conn()
if conn is None:
return None
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",
(
exchange, label or "",
_encrypt(api_key), _encrypt(api_secret), _encrypt(passphrase),
bool(testnet), bool(enabled),
),
)
return cur.fetchone()[0]
except Exception as e:
print(f"[exchange_keys] add_credential 실패: {e}")
return None
def update_credential(cred_id: int, **fields) -> bool:
if not _enabled() or not fields:
return False
set_parts = []
values = []
for k, v in fields.items():
if k in ("api_key", "api_secret", "passphrase"):
set_parts.append(f"{k}_enc=%s")
values.append(_encrypt(v) if v else None)
elif k in ("exchange", "label"):
set_parts.append(f"{k}=%s")
values.append(v)
elif k in ("testnet", "enabled"):
set_parts.append(f"{k}=%s")
values.append(bool(v))
if not set_parts:
return False
set_parts.append("updated_at=now()")
values.append(cred_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))
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():
return False
with _lock:
conn = _get_conn()
if conn is None:
return False
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM exchange_credentials WHERE id=%s", (cred_id,))
return cur.rowcount > 0
except Exception as e:
print(f"[exchange_keys] delete_credential 실패: {e}")
return False
# ──────────────────────────────────────────────
# Automation config
# ──────────────────────────────────────────────
def automation_get(key: str, default: str = "") -> str:
if not _enabled():
return AUTOMATION_DEFAULTS.get(key, default)
with _lock:
conn = _get_conn()
if conn is None:
return AUTOMATION_DEFAULTS.get(key, default)
try:
with conn.cursor() as cur:
cur.execute("SELECT value FROM automation_config WHERE key=%s", (key,))
row = cur.fetchone()
if row is None:
return AUTOMATION_DEFAULTS.get(key, default)
return row[0]
except Exception as e:
print(f"[exchange_keys] automation_get 실패: {e}")
return AUTOMATION_DEFAULTS.get(key, default)
def automation_set(key: str, value: Any) -> 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(
"INSERT INTO automation_config(key, value) VALUES (%s, %s) "
"ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()",
(key, str(value)),
)
return True
except Exception as e:
print(f"[exchange_keys] automation_set 실패: {e}")
return False
def automation_all() -> Dict[str, str]:
if not _enabled():
return dict(AUTOMATION_DEFAULTS)
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")
rows = cur.fetchall()
d = dict(AUTOMATION_DEFAULTS)
d.update(dict(rows))
return d
except Exception as e:
print(f"[exchange_keys] automation_all 실패: {e}")
return dict(AUTOMATION_DEFAULTS)