c4e6aab7b2
- 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>
372 lines
13 KiB
Python
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)
|