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:
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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)}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
]
|
||||
Reference in New Issue
Block a user