Files
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

101 lines
5.5 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { Card, PageHeader, Input, Select, Button, Toggle } from '@/components/ui';
import { Trash2, Plus, KeyRound } from 'lucide-react';
export default function ExchangePage() {
const [creds, setCreds] = useState<any[]>([]);
const [exchanges, setExchanges] = useState<string[]>([]);
const [form, setForm] = useState({ exchange: 'binance', label: '', api_key: '', api_secret: '', passphrase: '', testnet: false });
const [msg, setMsg] = useState<string | null>(null);
async function load() {
const [c, e] = await Promise.all([api.get('/api/exchange/credentials'), api.get('/api/exchange/exchanges')]);
setCreds(c); setExchanges(e);
}
useEffect(() => { load(); }, []);
async function add(e: React.FormEvent) {
e.preventDefault();
if (!form.api_key || !form.api_secret) { setMsg('API Key / Secret 필수'); return; }
try {
await api.post('/api/exchange/credentials', { ...form, passphrase: form.passphrase || null });
setMsg('✅ 등록 완료');
setForm({ exchange: 'binance', label: '', api_key: '', api_secret: '', passphrase: '', testnet: false });
load();
} catch (err: any) { setMsg('❌ ' + err.message); }
}
async function toggle(c: any, field: 'enabled' | 'testnet', val: boolean) {
await api.put(`/api/exchange/credentials/${c.id}`, { [field]: val });
load();
}
async function del(id: number) {
if (!confirm('삭제하시겠습니까?')) return;
await api.delete(`/api/exchange/credentials/${id}`);
load();
}
return (
<div>
<PageHeader title="🔑 거래소 API 키" subtitle="API Key / Secret 은 Fernet 암호화로 PostgreSQL 에 저장. 자동매매 시 활성 키로 주문." />
<div className="grid lg:grid-cols-3 gap-5 mb-5">
<Card className="lg:col-span-1">
<div className="flex items-center gap-2 mb-3 text-blue-600">
<Plus size={16} /> <span className="font-bold text-slate-800 text-sm"> </span>
</div>
<form onSubmit={add} className="space-y-3">
<Select label="거래소" value={form.exchange} onChange={(e: any) => setForm({ ...form, exchange: e.target.value })}>
{exchanges.map(x => <option key={x} value={x}>{x.toUpperCase()}</option>)}
</Select>
<Input label="Label" value={form.label} onChange={(e: any) => setForm({ ...form, label: e.target.value })} placeholder="예: main / sub" />
<Input label="API Key" type="text" value={form.api_key} onChange={(e: any) => setForm({ ...form, api_key: e.target.value })} />
<Input label="API Secret" type="password" value={form.api_secret} onChange={(e: any) => setForm({ ...form, api_secret: e.target.value })} />
<Input label="Passphrase (OKX/Bitget 만)" type="password" value={form.passphrase} onChange={(e: any) => setForm({ ...form, passphrase: e.target.value })} />
<Toggle checked={form.testnet} onChange={(v: boolean) => setForm({ ...form, testnet: v })} label="Testnet" />
<Button type="submit" className="w-full"></Button>
{msg && <div className="text-xs text-slate-600 pt-2">{msg}</div>}
</form>
</Card>
<Card className="lg:col-span-2">
<div className="flex items-center gap-2 mb-3 text-blue-600">
<KeyRound size={16} /> <span className="font-bold text-slate-800 text-sm"> ({creds.length})</span>
</div>
{creds.length === 0 && <div className="text-slate-400 text-sm py-6 text-center"> </div>}
<div className="space-y-3">
{creds.map(c => (
<div key={c.id} className="border border-slate-200 rounded-lg p-3 hover:border-slate-300">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-slate-800">{c.exchange.toUpperCase()}</span>
<span className="text-xs text-slate-500">[{c.label || '-'}]</span>
<span className={`inline-block px-2 py-0.5 text-[10px] rounded ${c.testnet ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}`}>
{c.testnet ? 'TESTNET' : 'LIVE'}
</span>
<span className={`inline-block px-2 py-0.5 text-[10px] rounded ${c.enabled ? 'bg-blue-100 text-blue-800' : 'bg-slate-100 text-slate-600'}`}>
{c.enabled ? '활성' : '비활성'}
</span>
</div>
<Button size="sm" variant="danger" onClick={() => del(c.id)}><Trash2 size={12} /></Button>
</div>
<div className="grid grid-cols-2 gap-3 text-xs font-mono mb-2">
<div className="bg-slate-50 px-2 py-1 rounded">Key: {c.api_key_masked}</div>
<div className="bg-slate-50 px-2 py-1 rounded">Secret: {c.api_secret_masked}</div>
</div>
<div className="flex items-center gap-4 text-xs">
<Toggle checked={c.enabled} onChange={(v: boolean) => toggle(c, 'enabled', v)} label="활성" />
<Toggle checked={c.testnet} onChange={(v: boolean) => toggle(c, 'testnet', v)} label="Testnet" />
<span className="text-slate-400">id #{c.id}</span>
</div>
</div>
))}
</div>
</Card>
</div>
</div>
);
}