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
+152
View File
@@ -0,0 +1,152 @@
'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { Card, PageHeader, Input, Select, Button, Toggle, Banner } from '@/components/ui';
import { Send, Bell, Target, Droplet, BarChart3 } from 'lucide-react';
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT'];
const TFS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h'];
const TABS = [
{ key: 'tg', label: '텔레그램', icon: Send },
{ key: 'alert', label: '알림 / 모니터링', icon: Bell },
{ key: 'signal', label: '신호 임계값', icon: Target },
{ key: 'vol', label: '거래량 / 펀딩비', icon: Droplet },
{ key: 'chart', label: '차트', icon: BarChart3 },
];
export default function SettingsPage() {
const [s, setS] = useState<any>({});
const [tab, setTab] = useState('tg');
const [msg, setMsg] = useState<string | null>(null);
useEffect(() => { api.get('/api/settings').then(setS); }, []);
function set(k: string, v: any) { setS({ ...s, [k]: v }); }
function setTfs(arr: string[]) { setS({ ...s, alert_timeframes: arr.join(',') }); }
function tfList() { return (s.alert_timeframes || '').split(',').filter(Boolean); }
function toggleTf(tf: string) {
const list = tfList();
setTfs(list.includes(tf) ? list.filter(x => x !== tf) : [...list, tf]);
}
async function save() {
await api.put('/api/settings', { values: s });
setMsg('✅ 저장 완료. 다음 폴링부터 반영');
setTimeout(() => setMsg(null), 3000);
}
return (
<div>
<PageHeader title="⚙️ 시스템 설정" subtitle="DB 영속 저장 · 저장 즉시 알림 스레드 / 차트에 반영"
right={<Button onClick={save}>💾 </Button>} />
{msg && <div className="mb-4"><Banner level="success">{msg}</Banner></div>}
<Card>
<div className="flex gap-1 border-b border-slate-200 mb-5 -mx-1">
{TABS.map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition ${tab === t.key ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>
<t.icon size={14} /> {t.label}
</button>
))}
</div>
{tab === 'tg' && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label="Bot Token" value={s.telegram_token || ''} onChange={(e: any) => set('telegram_token', e.target.value)} placeholder="예: 1234567890:ABCDEF..." />
<Input label="Chat ID" value={s.telegram_chat_id || ''} onChange={(e: any) => set('telegram_chat_id', e.target.value)} placeholder="예: -1001234567890" />
</div>
<p className="text-xs text-slate-500"> Token plain text . DB .</p>
</div>
)}
{tab === 'alert' && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Select label="모니터링 심볼" value={s.alert_symbol || 'BTCUSDT'} onChange={(e: any) => set('alert_symbol', e.target.value)}>
{SYMBOLS.map(x => <option key={x} value={x}>{x}</option>)}
</Select>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1"> (multi)</label>
<div className="flex flex-wrap gap-1.5">
{TFS.map(tf => {
const on = tfList().includes(tf);
return (
<button key={tf} type="button" onClick={() => toggleTf(tf)}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${on ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`}>
{tf}
</button>
);
})}
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Input label="쿨다운(초)" type="number" value={s.alert_cooldown_sec || '600'} onChange={(e: any) => set('alert_cooldown_sec', e.target.value)} />
<Input label="손절(%)" type="number" step="0.05" value={parseFloat(s.stop_loss_pct || '0.0075') * 100} onChange={(e: any) => set('stop_loss_pct', (parseFloat(e.target.value) / 100).toFixed(6))} />
<Input label="폴링(초)" type="number" value={s.polling_interval_sec || '30'} onChange={(e: any) => set('polling_interval_sec', e.target.value)} />
<Input label="forming polls" type="number" min="1" max="10" value={s.forming_stable_polls || '2'} onChange={(e: any) => set('forming_stable_polls', e.target.value)} />
</div>
<div className="flex gap-6 pt-2">
<Toggle checked={s.alert_enabled === '1'} onChange={(v: boolean) => set('alert_enabled', v ? '1' : '0')} label="알림 활성화" />
<Toggle checked={s.daily_report_enabled === '1'} onChange={(v: boolean) => set('daily_report_enabled', v ? '1' : '0')} label="일일 리포트 활성화" />
</div>
</div>
)}
{tab === 'signal' && (
<div className="space-y-4">
<div>
<div className="text-xs font-semibold text-slate-700 mb-2">RSI </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Input label="일반 롱 RSI ≤" type="number" value={s.long_rsi_max || '75'} onChange={(e: any) => set('long_rsi_max', e.target.value)} />
<Input label="일반 숏 RSI ≥" type="number" value={s.short_rsi_min || '25'} onChange={(e: any) => set('short_rsi_min', e.target.value)} />
<Input label="강한 롱 RSI ≤" type="number" value={s.strong_long_rsi_max || '65'} onChange={(e: any) => set('strong_long_rsi_max', e.target.value)} />
<Input label="강한 숏 RSI ≥" type="number" value={s.strong_short_rsi_min || '35'} onChange={(e: any) => set('strong_short_rsi_min', e.target.value)} />
</div>
</div>
<div>
<div className="text-xs font-semibold text-slate-700 mb-2"> body / </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Input label="body 최소(%)" type="number" step="0.05" value={parseFloat(s.body_pct_min || '0.002') * 100} onChange={(e: any) => set('body_pct_min', (parseFloat(e.target.value) / 100).toFixed(6))} />
<Input label="추세 꺾임 body(%)" type="number" step="0.05" value={parseFloat(s.reversal_body_pct || '0.003') * 100} onChange={(e: any) => set('reversal_body_pct', (parseFloat(e.target.value) / 100).toFixed(6))} />
<Input label="추세 꺾임 vol 배수" type="number" step="0.1" value={s.reversal_vol_mult || '1.3'} onChange={(e: any) => set('reversal_vol_mult', e.target.value)} />
</div>
</div>
</div>
)}
{tab === 'vol' && (
<div className="space-y-4">
<div>
<div className="text-xs font-semibold text-slate-700 mb-2"> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Input label="Exhaustion 배수" type="number" step="0.5" value={s.vol_exhaustion_mult || '3.0'} onChange={(e: any) => set('vol_exhaustion_mult', e.target.value)} />
<Input label="vol Net 배수" type="number" step="0.1" value={s.vol_net_mult || '2.0'} onChange={(e: any) => set('vol_net_mult', e.target.value)} />
<Input label="OI 활성도(%)" type="number" step="0.05" value={parseFloat(s.oi_active_pct || '0.001') * 100} onChange={(e: any) => set('oi_active_pct', (parseFloat(e.target.value) / 100).toFixed(6))} />
</div>
</div>
<div>
<div className="text-xs font-semibold text-slate-700 mb-2"> (: %)</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Input label="롱 과열 FR (≥)" type="number" step="0.001" value={s.fr_long_overheat || '0.005'} onChange={(e: any) => set('fr_long_overheat', e.target.value)} />
<Input label="숏 경보 FR (≤)" type="number" step="0.001" value={s.fr_short_caution || '-0.005'} onChange={(e: any) => set('fr_short_caution', e.target.value)} />
<Input label="숏 주의 FR (≤)" type="number" step="0.001" value={s.fr_short_extreme || '-0.007'} onChange={(e: any) => set('fr_short_extreme', e.target.value)} />
</div>
</div>
</div>
)}
{tab === 'chart' && (
<div className="grid grid-cols-2 gap-3">
<Input label="데스크톱 캔들 수" type="number" value={s.candle_limit_desktop || '200'} onChange={(e: any) => set('candle_limit_desktop', e.target.value)} />
<Input label="모바일 캔들 수" type="number" value={s.candle_limit_mobile || '60'} onChange={(e: any) => set('candle_limit_mobile', e.target.value)} />
</div>
)}
</Card>
</div>
);
}