diff --git a/frontend/src/app/signals/page.tsx b/frontend/src/app/signals/page.tsx new file mode 100644 index 0000000..f91004e --- /dev/null +++ b/frontend/src/app/signals/page.tsx @@ -0,0 +1,360 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { DashboardLayout } from '@/components/layout/dashboard-layout'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { api } from '@/lib/api'; +import { Radio, History, RefreshCw, ArrowUpCircle, ArrowDownCircle, MinusCircle } from 'lucide-react'; + +interface Signal { + id: number; + date: string; + ticker: string; + name: string | null; + signal_type: string; + entry_price: number | null; + target_price: number | null; + stop_loss_price: number | null; + reason: string | null; + status: string; + created_at: string; +} + +const signalTypeConfig: Record = { + buy: { + label: '매수', + style: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + icon: ArrowUpCircle, + }, + sell: { + label: '매도', + style: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + icon: ArrowDownCircle, + }, + partial_sell: { + label: '부분매도', + style: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + icon: MinusCircle, + }, +}; + +const statusConfig: Record = { + active: { + label: '활성', + style: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + }, + executed: { + label: '실행됨', + style: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + }, + expired: { + label: '만료', + style: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200', + }, +}; + +const formatPrice = (value: number | null | undefined) => { + if (value === null || value === undefined) return '-'; + return new Intl.NumberFormat('ko-KR').format(value); +}; + +export default function SignalsPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [todaySignals, setTodaySignals] = useState([]); + const [historySignals, setHistorySignals] = useState([]); + const [showHistory, setShowHistory] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + // History filter state + const [filterTicker, setFilterTicker] = useState(''); + const [filterStartDate, setFilterStartDate] = useState(''); + const [filterEndDate, setFilterEndDate] = useState(''); + + useEffect(() => { + const init = async () => { + try { + await api.getCurrentUser(); + await fetchTodaySignals(); + } catch { + router.push('/login'); + } finally { + setLoading(false); + } + }; + init(); + }, [router]); + + const fetchTodaySignals = async () => { + try { + const data = await api.get('/api/signal/kjb/today'); + setTodaySignals(data); + } catch (err) { + console.error('Failed to fetch today signals:', err); + } + }; + + const fetchHistorySignals = async () => { + try { + const params = new URLSearchParams(); + if (filterStartDate) params.set('start_date', filterStartDate); + if (filterEndDate) params.set('end_date', filterEndDate); + if (filterTicker) params.set('ticker', filterTicker); + const query = params.toString(); + const url = `/api/signal/kjb/history${query ? `?${query}` : ''}`; + const data = await api.get(url); + setHistorySignals(data); + } catch (err) { + console.error('Failed to fetch signal history:', err); + } + }; + + const handleRefresh = async () => { + setRefreshing(true); + try { + if (showHistory) { + await fetchHistorySignals(); + } else { + await fetchTodaySignals(); + } + } finally { + setRefreshing(false); + } + }; + + const handleShowHistory = async () => { + if (!showHistory && historySignals.length === 0) { + await fetchHistorySignals(); + } + setShowHistory(!showHistory); + }; + + const handleFilterSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await fetchHistorySignals(); + }; + + const renderSignalTable = (signals: Signal[]) => ( +
+ + + + + + + + + + + + + + + + {signals.map((signal) => { + const typeConf = signalTypeConfig[signal.signal_type] || { + label: signal.signal_type, + style: 'bg-muted', + icon: MinusCircle, + }; + const statConf = statusConfig[signal.status] || { + label: signal.status, + style: 'bg-muted', + }; + const TypeIcon = typeConf.icon; + + return ( + + + + + + + + + + + + ); + })} + {signals.length === 0 && ( + + + + )} + +
날짜종목코드종목명신호진입가목표가손절가사유상태
{signal.date}{signal.ticker}{signal.name || '-'} + + + {typeConf.label} + + {formatPrice(signal.entry_price)}{formatPrice(signal.target_price)}{formatPrice(signal.stop_loss_price)} + {signal.reason || '-'} + + {statConf.label} +
+ 신호가 없습니다. +
+
+ ); + + if (loading) { + return ( + +
+ + +
+
+ ); + } + + return ( + +
+
+

KJB 매매 신호

+

+ KJB 전략 기반 매매 신호를 확인하세요 +

+
+
+ + +
+
+ + {/* Summary Cards */} + {!showHistory && ( +
+ + +
+ + 매수 신호 +
+

+ {todaySignals.filter((s) => s.signal_type === 'buy').length} +

+
+
+ + +
+ + 매도 신호 +
+

+ {todaySignals.filter((s) => s.signal_type === 'sell').length} +

+
+
+ + +
+ + 부분매도 신호 +
+

+ {todaySignals.filter((s) => s.signal_type === 'partial_sell').length} +

+
+
+
+ )} + + {showHistory ? ( +
+ {/* History Filters */} + + +
+
+ + setFilterStartDate(e.target.value)} + className="w-40" + /> +
+
+ + setFilterEndDate(e.target.value)} + className="w-40" + /> +
+
+ + setFilterTicker(e.target.value)} + className="w-36" + /> +
+ +
+
+
+ + {/* History Table */} + + + 신호 이력 + + + {renderSignalTable(historySignals)} + + +
+ ) : ( + + + 오늘의 매매 신호 + + + {renderSignalTable(todaySignals)} + + + )} +
+ ); +} diff --git a/frontend/src/components/layout/new-sidebar.tsx b/frontend/src/components/layout/new-sidebar.tsx index e8abf58..6685c1c 100644 --- a/frontend/src/components/layout/new-sidebar.tsx +++ b/frontend/src/components/layout/new-sidebar.tsx @@ -10,6 +10,7 @@ import { FlaskConical, Database, Search, + Radio, ChevronLeft, ChevronRight, } from 'lucide-react'; @@ -28,6 +29,7 @@ const navItems = [ { href: '/portfolio', label: '포트폴리오', icon: Briefcase }, { href: '/strategy', label: '전략', icon: TrendingUp }, { href: '/backtest', label: '백테스트', icon: FlaskConical }, + { href: '/signals', label: '매매 신호', icon: Radio }, { href: '/admin/data', label: '데이터 수집', icon: Database }, { href: '/admin/data/explorer', label: '데이터 탐색', icon: Search }, ];