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>
153 lines
9.1 KiB
TypeScript
153 lines
9.1 KiB
TypeScript
'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>
|
||
);
|
||
}
|