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>
101 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|