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>
This commit is contained in:
chpark
2026-05-06 17:27:11 +09:00
parent bdd2d66ea0
commit c4e6aab7b2
55 changed files with 5192 additions and 46 deletions
View File
+47
View File
@@ -0,0 +1,47 @@
"""JWT 인증."""
import os
from datetime import datetime, timedelta
from typing import Optional
from fastapi import HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
import users_db
JWT_SECRET = os.environ.get("JWT_SECRET", "change-me-in-production-please")
JWT_ALG = "HS256"
JWT_EXP_HOURS = 24 * 7
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def create_token(username: str, role: str) -> str:
payload = {
"sub": username,
"role": role,
"exp": datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS),
"iat": datetime.utcnow(),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
except JWTError:
return None
def require_user(token: Optional[str] = Depends(oauth2_scheme)) -> dict:
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="not authenticated")
payload = decode_token(token)
if not payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
return payload
def require_admin(payload: dict = Depends(require_user)) -> dict:
if payload.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin only")
return payload
+54
View File
@@ -0,0 +1,54 @@
"""
FastAPI 메인. 모든 라우터 + CORS + 백그라운드 알림 스레드 시작.
기존 Python 모듈 (users_db, settings_db, trades_db, exchange_keys, core_logic) 그대로 사용.
"""
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import users_db
import settings_db
import trades_db
import exchange_keys
import core_logic
import alert_state
from .routes import auth_route, settings_route, market_route, trades_route, exchange_route, automation_route, users_route
app = FastAPI(title="JUNGGOMOA Trading API", version="1.0", redirect_slashes=False)
# CORS — 같은 도메인이라도 허용 (개발 편의)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
def on_startup():
settings_db.init_db_with_env_defaults()
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")
# 백그라운드 알림 / 일일 리포트 시작
core_logic.start_background_threads()
print("[FastAPI] 모든 모듈 init OK + 백그라운드 스레드 시작")
@app.get("/api/health")
def health():
return {"ok": True}
# 라우터 등록
app.include_router(auth_route.router, prefix="/api/auth", tags=["auth"])
app.include_router(users_route.router, prefix="/api/users", tags=["users"])
app.include_router(settings_route.router, prefix="/api/settings", tags=["settings"])
app.include_router(market_route.router, prefix="/api/market", tags=["market"])
app.include_router(trades_route.router, prefix="/api/trades", tags=["trades"])
app.include_router(exchange_route.router, prefix="/api/exchange", tags=["exchange"])
app.include_router(automation_route.router, prefix="/api/automation", tags=["automation"])
View File
+54
View File
@@ -0,0 +1,54 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
import users_db
from ..auth import create_token, require_user
router = APIRouter()
class LoginIn(BaseModel):
username: str
password: str
class LoginOut(BaseModel):
access_token: str
token_type: str = "bearer"
user: dict
@router.post("/login", response_model=LoginOut)
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"))
# datetime 직렬화 위해 string 변환
user_safe = {
"id": user.get("id"),
"username": user.get("username"),
"role": user.get("role"),
"created_at": str(user.get("created_at")) if user.get("created_at") else None,
"last_login_at": str(user.get("last_login_at")) if user.get("last_login_at") else None,
}
return {"access_token": token, "user": user_safe}
@router.get("/me")
def me(payload: dict = Depends(require_user)):
return {"username": payload.get("sub"), "role": payload.get("role")}
class ChangePasswordIn(BaseModel):
old_password: str
new_password: str
@router.put("/password")
def change_password(body: ChangePasswordIn, payload: dict = Depends(require_user)):
username = payload.get("sub")
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="새 비밀번호는 6자 이상")
if not users_db.change_password(username, body.old_password, body.new_password):
raise HTTPException(status_code=400, detail="현재 비밀번호 불일치")
return {"ok": True}
+37
View File
@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Dict, Any
import exchange_keys
import exchange_adapters
from ..auth import require_user
router = APIRouter()
@router.get("")
def get_config(_: dict = Depends(require_user)):
return exchange_keys.automation_all()
class UpdateIn(BaseModel):
values: Dict[str, Any]
@router.put("")
def update_config(body: UpdateIn, _: dict = Depends(require_user)):
for k, v in body.values.items():
exchange_keys.automation_set(k, v)
return {"ok": True, "saved": len(body.values)}
@router.post("/test/balance")
def test_balance(_: dict = Depends(require_user)):
"""활성 키로 DryRun 어댑터 호출 — 동작 검증."""
cfg = exchange_keys.automation_all()
cred_id = cfg.get("active_credential", "")
if not cred_id:
return {"ok": False, "error": "활성 키 미선택"}
cred = exchange_keys.get_credential(int(cred_id))
adapter = exchange_adapters.make_adapter(cred, dry_run=True)
bal = adapter.get_balance("USDT")
return {"ok": True, "balance": bal, "exchange": adapter.exchange}
+73
View File
@@ -0,0 +1,73 @@
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
router = APIRouter()
class CredIn(BaseModel):
exchange: str
label: str = ""
api_key: str
api_secret: str
passphrase: Optional[str] = None
testnet: bool = False
class CredUpdateIn(BaseModel):
exchange: Optional[str] = None
label: Optional[str] = None
api_key: Optional[str] = None
api_secret: Optional[str] = None
passphrase: Optional[str] = None
testnet: Optional[bool] = None
enabled: Optional[bool] = None
def _serialize_cred(c):
d = dict(c)
for k in ["created_at", "updated_at"]:
if d.get(k) is not None:
d[k] = str(d[k])
return d
@router.get("/credentials")
def list_creds(_: dict = Depends(require_user)):
return [_serialize_cred(c) for c in exchange_keys.list_credentials()]
@router.post("/credentials")
def add_cred(body: CredIn, _: dict = Depends(require_user)):
cid = exchange_keys.add_credential(
body.exchange, body.label, body.api_key, body.api_secret,
body.passphrase, body.testnet, True,
)
if not cid:
raise HTTPException(status_code=400, detail="등록 실패")
return {"id": cid}
@router.put("/credentials/{cred_id}")
def update_cred(cred_id: int, body: CredUpdateIn, _: dict = Depends(require_user)):
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):
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):
raise HTTPException(status_code=404, detail="not found")
return {"ok": True}
@router.get("/exchanges")
def supported_exchanges(_: dict = Depends(require_user)):
return exchange_keys.SUPPORTED_EXCHANGES
+94
View File
@@ -0,0 +1,94 @@
"""
시장 데이터 + 신호 — 차트용.
프론트는 /api/market/dashboard?symbol=&interval=&limit= 한 번 호출로
모든 데이터 (캔들 + 지표 + 신호 + OI + FR + L/S + 펀딩비 banner) 받음.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
import math
import pandas as pd
from datetime import datetime
import core_logic
import settings_db
from ..auth import require_user
router = APIRouter()
def _to_jsonable(v):
if v is None:
return None
if isinstance(v, float) and (math.isnan(v) or math.isinf(v)):
return None
if isinstance(v, (pd.Timestamp, datetime)):
return v.isoformat()
if isinstance(v, bool):
return bool(v)
return v
def _serialize_df(df: pd.DataFrame, columns: list) -> list:
out = []
cols = [c for c in columns if c in df.columns]
for _, row in df.iterrows():
out.append({c: _to_jsonable(row[c]) for c in cols})
return out
@router.get("/dashboard")
def dashboard(
symbol: str = Query("BTCUSDT"),
interval: str = Query("5m"),
limit: int = Query(200, ge=10, le=500),
_: dict = Depends(require_user),
):
try:
df = core_logic.build_signal_df(symbol, interval, limit)
except Exception as e:
raise HTTPException(status_code=502, detail=f"upstream binance: {e}")
# 펀딩비 배너 처리
banner = None
try:
fr_df = core_logic.get_funding_rate(symbol, 1)
if not fr_df.empty:
rate = float(fr_df["fundingRate"].iloc[-1])
fr_extreme = settings_db.get_float("fr_short_extreme", -0.007)
fr_caution = settings_db.get_float("fr_short_caution", -0.005)
fr_overheat = settings_db.get_float("fr_long_overheat", 0.005)
if rate <= fr_extreme:
banner = {"level": "danger", "text": f"극단적 숏스퀴즈 위험 | FR: {rate:.4f}% | 숏 신규진입 절대 금지", "rate": rate}
elif rate <= fr_caution:
banner = {"level": "warning", "text": f"숏스퀴즈 경보 | FR: {rate:.4f}% | 숏 진입 시 청산가 재확인", "rate": rate}
elif rate >= fr_overheat:
banner = {"level": "info", "text": f"롱 과열 | FR: {rate:.4f}% | 롱스퀴즈 주의", "rate": rate}
else:
banner = {"level": "success", "text": f"FR 정상 | {rate:.4f}%", "rate": rate}
except Exception:
pass
cols = ["open_time", "open", "high", "low", "close", "volume",
"taker_buy_vol", "taker_sell_vol",
"MA7", "MA25", "MA99", "MA200", "BB_upper", "BB_lower", "BB_mid",
"RSI", "StochRSI_k", "StochRSI_d", "MACD", "MACD_signal", "MACD_hist",
"sumOpenInterest", "fundingRate", "longShortRatio",
"long_signal", "short_signal", "strong_long_signal", "strong_short_signal",
"vol_long_signal", "vol_short_signal", "reversal_long_signal", "reversal_short_signal",
"exhaustion_long", "exhaustion_short", "short_caution_signal"]
rows = _serialize_df(df, cols)
last_price = float(df.iloc[-1]["close"]) if not df.empty else None
return {
"symbol": symbol,
"interval": interval,
"rows": rows,
"last_price": last_price,
"banner": banner,
}
@router.get("/funding")
def funding(symbol: str = Query("BTCUSDT"), limit: int = Query(100, ge=1, le=500),
_: dict = Depends(require_user)):
df = core_logic.get_funding_rate(symbol, limit)
return _serialize_df(df, ["fundingTime", "fundingRate"])
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Dict, Any
import settings_db
import alert_state
from ..auth import require_user
router = APIRouter()
@router.get("")
def get_all(_: dict = Depends(require_user)):
safe = settings_db.all_settings()
return safe
class UpdateIn(BaseModel):
values: Dict[str, Any]
@router.put("")
def update_settings(body: UpdateIn, _: dict = Depends(require_user)):
for k, v in body.values.items():
settings_db.set_value(k, v)
# alert symbol 동기화
sym = settings_db.get("alert_symbol", "BTCUSDT")
with alert_state.alert_lock:
alert_state.alert_symbol = sym
return {"ok": True, "saved": len(body.values)}
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends, Query
import trades_db
from ..auth import require_user
router = APIRouter()
def _serialize(rows):
out = []
for r in rows:
d = dict(r)
for k in ["entry_time", "exit_time", "candle_time", "fired_at"]:
if d.get(k) is not None:
d[k] = str(d[k])
out.append(d)
return out
@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))
@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))
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
import users_db
from ..auth import require_user, require_admin
router = APIRouter()
@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
]