feat(frontend): apply DashboardLayout to all pages
- Portfolio pages updated with DashboardLayout and shadcn/ui Card components - Strategy pages updated (multi-factor, quality, value-momentum) - Backtest pages updated with consistent styling - Admin data management page updated - Login page improved with shadcn/ui Card, Input, Button, Label - All pages now support dark mode via CSS variables - Removed old Sidebar/Header imports, using unified DashboardLayout - Added shadcn/ui input and label components Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3e733ec1b8
commit
eb3ce0e6e7
47
frontend/package-lock.json
generated
47
frontend/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@ -1563,6 +1564,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
|
||||
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface JobLog {
|
||||
@ -16,12 +17,6 @@ interface JobLog {
|
||||
error_msg: string | null;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const collectors = [
|
||||
{ key: 'stocks', label: '종목 마스터', description: 'KRX에서 종목 정보 수집' },
|
||||
{ key: 'sectors', label: '섹터 정보', description: 'WISEindex에서 섹터 분류 수집' },
|
||||
@ -31,7 +26,6 @@ const collectors = [
|
||||
|
||||
export default function DataManagementPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [jobs, setJobs] = useState<JobLog[]>([]);
|
||||
const [collecting, setCollecting] = useState<string | null>(null);
|
||||
@ -41,8 +35,7 @@ export default function DataManagementPage() {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const userData = await api.getCurrentUser() as User;
|
||||
setUser(userData);
|
||||
await api.getCurrentUser();
|
||||
await fetchJobs();
|
||||
} catch {
|
||||
router.push('/login');
|
||||
@ -91,115 +84,107 @@ export default function DataManagementPage() {
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
success: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
running: 'bg-yellow-100 text-yellow-800',
|
||||
success: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
running: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||
return colors[status] || 'bg-muted text-muted-foreground';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header username={user?.username} />
|
||||
<main className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">데이터 수집 관리</h1>
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">데이터 수집 관리</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">수집 작업</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{collectors.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="border rounded-lg p-4 flex flex-col"
|
||||
>
|
||||
<h3 className="font-medium text-gray-800">{col.label}</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">{col.description}</p>
|
||||
<button
|
||||
onClick={() => runCollector(col.key)}
|
||||
disabled={collecting !== null}
|
||||
aria-label={`${col.label} 수집 실행`}
|
||||
className="mt-auto px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
|
||||
>
|
||||
{collecting === col.key ? '수집 중...' : '실행'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold">최근 작업 이력</h2>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 disabled:text-gray-400"
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>수집 작업</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{collectors.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="border border-border rounded-lg p-4 flex flex-col"
|
||||
>
|
||||
{refreshing ? '새로고침 중...' : '새로고침'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">작업명</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">상태</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">시작 시간</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">건수</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">에러</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id}>
|
||||
<td className="px-4 py-3 text-sm">{job.job_name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs ${getStatusBadge(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{new Date(job.started_at).toLocaleString('ko-KR')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{job.records_count ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-red-600 truncate max-w-xs">
|
||||
{job.error_msg || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
아직 수집 이력이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h3 className="font-medium text-foreground">{col.label}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{col.description}</p>
|
||||
<Button
|
||||
onClick={() => runCollector(col.key)}
|
||||
disabled={collecting !== null}
|
||||
aria-label={`${col.label} 수집 실행`}
|
||||
className="mt-auto"
|
||||
>
|
||||
{collecting === col.key ? '수집 중...' : '실행'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>최근 작업 이력</CardTitle>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="text-sm text-primary hover:text-primary/80 disabled:text-muted-foreground"
|
||||
>
|
||||
{refreshing ? '새로고침 중...' : '새로고침'}
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">작업명</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">상태</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">시작 시간</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">건수</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">에러</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id}>
|
||||
<td className="px-4 py-3 text-sm">{job.job_name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs ${getStatusBadge(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{new Date(job.started_at).toLocaleString('ko-KR')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{job.records_count ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-destructive truncate max-w-xs">
|
||||
{job.error_msg || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||
아직 수집 이력이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,15 +2,11 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface BacktestMetrics {
|
||||
total_return: number;
|
||||
cagr: number;
|
||||
@ -87,7 +83,6 @@ export default function BacktestDetailPage() {
|
||||
const params = useParams();
|
||||
const backtestId = params.id as string;
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [backtest, setBacktest] = useState<BacktestDetail | null>(null);
|
||||
const [equityCurve, setEquityCurve] = useState<EquityCurvePoint[]>([]);
|
||||
@ -122,8 +117,7 @@ export default function BacktestDetailPage() {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const userData = await api.getCurrentUser() as User;
|
||||
setUser(userData);
|
||||
await api.getCurrentUser();
|
||||
await fetchBacktest();
|
||||
} catch {
|
||||
router.push('/login');
|
||||
@ -156,10 +150,10 @@ export default function BacktestDetailPage() {
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
running: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: '대기중',
|
||||
@ -168,261 +162,269 @@ export default function BacktestDetailPage() {
|
||||
failed: '실패',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs ${styles[status] || 'bg-gray-100'}`}>
|
||||
<span className={`px-2 py-1 rounded text-xs ${styles[status] || 'bg-muted'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!backtest) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Backtest not found</div>
|
||||
</div>
|
||||
<DashboardLayout>
|
||||
<div className="text-center text-muted-foreground">Backtest not found</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedHoldings = holdings.find((h) => h.rebalance_date === selectedRebalance);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header username={user?.username} />
|
||||
<main className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{strategyLabels[backtest.strategy_type] || backtest.strategy_type} 백테스트
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{backtest.start_date} ~ {backtest.end_date} | {periodLabels[backtest.rebalance_period]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusBadge(backtest.status)}
|
||||
</div>
|
||||
<DashboardLayout>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{strategyLabels[backtest.strategy_type] || backtest.strategy_type} 백테스트
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{backtest.start_date} ~ {backtest.end_date} | {periodLabels[backtest.rebalance_period]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusBadge(backtest.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{backtest.status === 'pending' && (
|
||||
<div className="bg-yellow-100 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-3 rounded mb-6">
|
||||
백테스트가 대기 중입니다...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backtest.status === 'running' && (
|
||||
<div className="bg-blue-100 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200 px-4 py-3 rounded mb-6">
|
||||
백테스트가 실행 중입니다... 잠시만 기다려주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backtest.status === 'failed' && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-6">
|
||||
백테스트 실패: {backtest.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results (only show when completed) */}
|
||||
{backtest.status === 'completed' && backtest.result && (
|
||||
<>
|
||||
{/* Metrics Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">총 수익률</div>
|
||||
<div className={`text-xl font-bold ${backtest.result.total_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatNumber(backtest.result.total_return)}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">CAGR</div>
|
||||
<div className={`text-xl font-bold ${backtest.result.cagr >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatNumber(backtest.result.cagr)}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">MDD</div>
|
||||
<div className="text-xl font-bold text-red-600">
|
||||
{formatNumber(backtest.result.mdd)}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">샤프 비율</div>
|
||||
<div className="text-xl font-bold text-foreground">
|
||||
{formatNumber(backtest.result.sharpe_ratio)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">변동성</div>
|
||||
<div className="text-xl font-bold text-foreground">
|
||||
{formatNumber(backtest.result.volatility)}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">벤치마크</div>
|
||||
<div className={`text-xl font-bold ${backtest.result.benchmark_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatNumber(backtest.result.benchmark_return)}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">초과 수익</div>
|
||||
<div className={`text-xl font-bold ${backtest.result.excess_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatNumber(backtest.result.excess_return)}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{backtest.status === 'pending' && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded mb-6">
|
||||
백테스트가 대기 중입니다...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backtest.status === 'running' && (
|
||||
<div className="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded mb-6">
|
||||
백테스트가 실행 중입니다... 잠시만 기다려주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backtest.status === 'failed' && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded mb-6">
|
||||
백테스트 실패: {backtest.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results (only show when completed) */}
|
||||
{backtest.status === 'completed' && backtest.result && (
|
||||
<>
|
||||
{/* Metrics Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">총 수익률</div>
|
||||
<div className={`text-xl font-bold ${backtest.result.total_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatNumber(backtest.result.total_return)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">CAGR</div>
|
||||
<div className={`text-xl font-bold ${backtest.result.cagr >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatNumber(backtest.result.cagr)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">MDD</div>
|
||||
<div className="text-xl font-bold text-red-600">
|
||||
{formatNumber(backtest.result.mdd)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">샤프 비율</div>
|
||||
<div className="text-xl font-bold text-gray-800">
|
||||
{formatNumber(backtest.result.sharpe_ratio)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">변동성</div>
|
||||
<div className="text-xl font-bold text-gray-800">
|
||||
{formatNumber(backtest.result.volatility)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">벤치마크</div>
|
||||
<div className={`text-xl font-bold ${backtest.result.benchmark_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatNumber(backtest.result.benchmark_return)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">초과 수익</div>
|
||||
<div className={`text-xl font-bold ${backtest.result.excess_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatNumber(backtest.result.excess_return)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equity Curve */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">자산 추이</h2>
|
||||
<div className="h-64 flex items-center justify-center text-gray-400">
|
||||
{equityCurve.length > 0 ? (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||
<span>시작: {formatCurrency(equityCurve[0]?.portfolio_value || 0)}원</span>
|
||||
<span>종료: {formatCurrency(equityCurve[equityCurve.length - 1]?.portfolio_value || 0)}원</span>
|
||||
</div>
|
||||
<div className="text-center text-gray-400">
|
||||
(차트 라이브러리 연동 필요 - {equityCurve.length}개 데이터 포인트)
|
||||
</div>
|
||||
{/* Equity Curve */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>자산 추이</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64 flex items-center justify-center text-muted-foreground">
|
||||
{equityCurve.length > 0 ? (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>시작: {formatCurrency(equityCurve[0]?.portfolio_value || 0)}원</span>
|
||||
<span>종료: {formatCurrency(equityCurve[equityCurve.length - 1]?.portfolio_value || 0)}원</span>
|
||||
</div>
|
||||
) : (
|
||||
'데이터 없음'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="border-b">
|
||||
<nav className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('holdings')}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === 'holdings'
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
리밸런싱 이력
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('transactions')}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === 'transactions'
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
거래 내역
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Holdings Tab */}
|
||||
{activeTab === 'holdings' && (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="rebalance-date" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
리밸런싱 날짜
|
||||
</label>
|
||||
<select
|
||||
id="rebalance-date"
|
||||
value={selectedRebalance || ''}
|
||||
onChange={(e) => setSelectedRebalance(e.target.value)}
|
||||
className="px-3 py-2 border rounded"
|
||||
>
|
||||
{holdings.map((h) => (
|
||||
<option key={h.rebalance_date} value={h.rebalance_date}>
|
||||
{h.rebalance_date}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-center text-muted-foreground">
|
||||
(차트 라이브러리 연동 필요 - {equityCurve.length}개 데이터 포인트)
|
||||
</div>
|
||||
{selectedHoldings && (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">비중</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">가격</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{selectedHoldings.holdings.map((h) => (
|
||||
<tr key={h.ticker} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{h.ticker}</div>
|
||||
<div className="text-xs text-gray-500">{h.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(h.weight)}%</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(h.shares)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(h.price)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
'데이터 없음'
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transactions Tab */}
|
||||
{activeTab === 'transactions' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">날짜</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600">구분</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">가격</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">수수료</th>
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<div className="border-b border-border">
|
||||
<nav className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('holdings')}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === 'holdings'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
리밸런싱 이력
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('transactions')}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === 'transactions'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
거래 내역
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Holdings Tab */}
|
||||
{activeTab === 'holdings' && (
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-4 space-y-2">
|
||||
<Label htmlFor="rebalance-date">리밸런싱 날짜</Label>
|
||||
<select
|
||||
id="rebalance-date"
|
||||
value={selectedRebalance || ''}
|
||||
onChange={(e) => setSelectedRebalance(e.target.value)}
|
||||
className="flex h-10 w-auto rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{holdings.map((h) => (
|
||||
<option key={h.rebalance_date} value={h.rebalance_date}>
|
||||
{h.rebalance_date}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedHoldings && (
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">비중</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">가격</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{selectedHoldings.holdings.map((h) => (
|
||||
<tr key={h.ticker} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{h.ticker}</div>
|
||||
<div className="text-xs text-muted-foreground">{h.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(h.weight)}%</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(h.shares)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(h.price)}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{transactions.map((t) => (
|
||||
<tr key={t.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm">{t.date}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">{t.ticker}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
t.action === 'buy' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{t.action === 'buy' ? '매수' : '매도'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.shares)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.commission)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transactions.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
거래 내역이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Transactions Tab */}
|
||||
{activeTab === 'transactions' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">날짜</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">구분</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">가격</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">수수료</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{transactions.map((t) => (
|
||||
<tr key={t.id} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-3 text-sm">{t.date}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">{t.ticker}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
t.action === 'buy'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{t.action === 'buy' ? '매수' : '매도'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.shares)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.commission)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transactions.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
거래 내역이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,15 +3,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface BacktestListItem {
|
||||
id: number;
|
||||
strategy_type: string;
|
||||
@ -40,7 +38,6 @@ const periodOptions = [
|
||||
|
||||
export default function BacktestPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [backtests, setBacktests] = useState<BacktestListItem[]>([]);
|
||||
@ -68,8 +65,7 @@ export default function BacktestPage() {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const userData = await api.getCurrentUser() as User;
|
||||
setUser(userData);
|
||||
await api.getCurrentUser();
|
||||
await fetchBacktests();
|
||||
} catch {
|
||||
router.push('/login');
|
||||
@ -136,10 +132,10 @@ export default function BacktestPage() {
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
running: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: '대기중',
|
||||
@ -148,7 +144,7 @@ export default function BacktestPage() {
|
||||
failed: '실패',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs ${styles[status] || 'bg-gray-100'}`}>
|
||||
<span className={`px-2 py-1 rounded text-xs ${styles[status] || 'bg-muted'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
@ -160,339 +156,293 @@ export default function BacktestPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header username={user?.username} />
|
||||
<main className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">백테스트</h1>
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">백테스트</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Backtest Form */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>새 백테스트</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="strategy">전략</Label>
|
||||
<select
|
||||
id="strategy"
|
||||
value={strategyType}
|
||||
onChange={(e) => setStrategyType(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{strategyOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="start-date">시작일</Label>
|
||||
<Input
|
||||
id="start-date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="end-date">종료일</Label>
|
||||
<Input
|
||||
id="end-date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Backtest Form */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">새 백테스트</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="period">리밸런싱 주기</Label>
|
||||
<select
|
||||
id="period"
|
||||
value={rebalancePeriod}
|
||||
onChange={(e) => setRebalancePeriod(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{periodOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capital">초기 자본금</Label>
|
||||
<Input
|
||||
id="capital"
|
||||
type="number"
|
||||
value={initialCapital}
|
||||
onChange={(e) => setInitialCapital(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="top-n">종목 수</Label>
|
||||
<Input
|
||||
id="top-n"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategy-specific params */}
|
||||
{strategyType === 'multi_factor' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="strategy" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
전략
|
||||
</label>
|
||||
<select
|
||||
id="strategy"
|
||||
value={strategyType}
|
||||
onChange={(e) => setStrategyType(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
{strategyOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="start-date" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
시작일
|
||||
</label>
|
||||
<input
|
||||
id="start-date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="value-weight">밸류 가중치</Label>
|
||||
<Input
|
||||
id="value-weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={valueWeight}
|
||||
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="end-date" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
종료일
|
||||
</label>
|
||||
<input
|
||||
id="end-date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quality-weight">퀄리티 가중치</Label>
|
||||
<Input
|
||||
id="quality-weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={qualityWeight}
|
||||
onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="momentum-weight">모멘텀 가중치</Label>
|
||||
<Input
|
||||
id="momentum-weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={momentumWeight}
|
||||
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="period" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
리밸런싱 주기
|
||||
</label>
|
||||
<select
|
||||
id="period"
|
||||
value={rebalancePeriod}
|
||||
onChange={(e) => setRebalancePeriod(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
{periodOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="capital" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
초기 자본금
|
||||
</label>
|
||||
<input
|
||||
id="capital"
|
||||
type="number"
|
||||
value={initialCapital}
|
||||
onChange={(e) => setInitialCapital(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
종목 수
|
||||
</label>
|
||||
<input
|
||||
id="top-n"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategy-specific params */}
|
||||
{strategyType === 'multi_factor' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
밸류 가중치
|
||||
</label>
|
||||
<input
|
||||
id="value-weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={valueWeight}
|
||||
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="quality-weight" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
퀄리티 가중치
|
||||
</label>
|
||||
<input
|
||||
id="quality-weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={qualityWeight}
|
||||
onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
모멘텀 가중치
|
||||
</label>
|
||||
<input
|
||||
id="momentum-weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={momentumWeight}
|
||||
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{strategyType === 'quality' && (
|
||||
<div className="mb-4">
|
||||
<label htmlFor="min-fscore" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
최소 F-Score
|
||||
</label>
|
||||
<input
|
||||
{strategyType === 'quality' && (
|
||||
<div className="mb-4">
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<Label htmlFor="min-fscore">최소 F-Score</Label>
|
||||
<Input
|
||||
id="min-fscore"
|
||||
type="number"
|
||||
min="0"
|
||||
max="9"
|
||||
value={minFscore}
|
||||
onChange={(e) => setMinFscore(parseInt(e.target.value))}
|
||||
className="w-32 px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{strategyType === 'value_momentum' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="vm-value" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
밸류 가중치
|
||||
</label>
|
||||
<input
|
||||
id="vm-value"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={vmValueWeight}
|
||||
onChange={(e) => setVmValueWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="vm-momentum" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
모멘텀 가중치
|
||||
</label>
|
||||
<input
|
||||
id="vm-momentum"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={vmMomentumWeight}
|
||||
onChange={(e) => setVmMomentumWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced options */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{showAdvanced ? '고급 옵션 숨기기' : '고급 옵션 보기'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="commission" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
수수료율
|
||||
</label>
|
||||
<input
|
||||
id="commission"
|
||||
type="number"
|
||||
step="0.00001"
|
||||
value={commissionRate}
|
||||
onChange={(e) => setCommissionRate(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="slippage" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
슬리피지율
|
||||
</label>
|
||||
<input
|
||||
id="slippage"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={slippageRate}
|
||||
onChange={(e) => setSlippageRate(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
{strategyType === 'value_momentum' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vm-value">밸류 가중치</Label>
|
||||
<Input
|
||||
id="vm-value"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={vmValueWeight}
|
||||
onChange={(e) => setVmValueWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vm-momentum">모멘텀 가중치</Label>
|
||||
<Input
|
||||
id="vm-momentum"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={vmMomentumWeight}
|
||||
onChange={(e) => setVmMomentumWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced options */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{submitting ? '실행 중...' : '백테스트 실행'}
|
||||
{showAdvanced ? '고급 옵션 숨기기' : '고급 옵션 보기'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backtest List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">백테스트 목록</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">전략</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">기간</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">주기</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">수익률</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">CAGR</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">MDD</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600">상태</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">생성일</th>
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="commission">수수료율</Label>
|
||||
<Input
|
||||
id="commission"
|
||||
type="number"
|
||||
step="0.00001"
|
||||
value={commissionRate}
|
||||
onChange={(e) => setCommissionRate(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slippage">슬리피지율</Label>
|
||||
<Input
|
||||
id="slippage"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={slippageRate}
|
||||
onChange={(e) => setSlippageRate(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? '실행 중...' : '백테스트 실행'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Backtest List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>백테스트 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">전략</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">기간</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">주기</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">수익률</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">CAGR</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">MDD</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">상태</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">생성일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{backtests.map((bt) => (
|
||||
<tr key={bt.id} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/backtest/${bt.id}`} className="text-primary hover:underline">
|
||||
{strategyOptions.find((s) => s.value === bt.strategy_type)?.label || bt.strategy_type}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{bt.start_date} ~ {bt.end_date}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{periodOptions.find((p) => p.value === bt.rebalance_period)?.label || bt.rebalance_period}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-right ${(bt.total_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{bt.total_return !== null ? `${formatNumber(bt.total_return)}%` : '-'}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-right ${(bt.cagr ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{bt.cagr !== null ? `${formatNumber(bt.cagr)}%` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-red-600">
|
||||
{bt.mdd !== null ? `${formatNumber(bt.mdd)}%` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{getStatusBadge(bt.status)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{new Date(bt.created_at).toLocaleDateString('ko-KR')}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{backtests.map((bt) => (
|
||||
<tr key={bt.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/backtest/${bt.id}`} className="text-blue-600 hover:underline">
|
||||
{strategyOptions.find((s) => s.value === bt.strategy_type)?.label || bt.strategy_type}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{bt.start_date} ~ {bt.end_date}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{periodOptions.find((p) => p.value === bt.rebalance_period)?.label || bt.rebalance_period}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-right ${(bt.total_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{bt.total_return !== null ? `${formatNumber(bt.total_return)}%` : '-'}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-right ${(bt.cagr ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{bt.cagr !== null ? `${formatNumber(bt.cagr)}%` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-red-600">
|
||||
{bt.mdd !== null ? `${formatNumber(bt.mdd)}%` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{getStatusBadge(bt.status)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(bt.created_at).toLocaleDateString('ko-KR')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{backtests.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">
|
||||
아직 백테스트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
{backtests.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-muted-foreground">
|
||||
아직 백테스트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function LoginPage() {
|
||||
@ -27,62 +31,50 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
||||
<h1 className="text-2xl font-bold text-center text-gray-800 mb-8">
|
||||
Galaxy-PO
|
||||
</h1>
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center space-y-1">
|
||||
<CardTitle className="text-2xl font-bold">Galaxy-PO</CardTitle>
|
||||
<CardDescription>포트폴리오 최적화 플랫폼에 오신 것을 환영합니다</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">사용자명</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="사용자명을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
사용자명
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
|
||||
>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface SnapshotItem {
|
||||
id: number;
|
||||
@ -52,13 +55,13 @@ export default function PortfolioHistoryPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"snapshots" | "returns">("snapshots");
|
||||
const [activeTab, setActiveTab] = useState<'snapshots' | 'returns'>('snapshots');
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
router.push("/login");
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -70,7 +73,7 @@ export default function PortfolioHistoryPage() {
|
||||
]);
|
||||
|
||||
if (!snapshotsRes.ok || !returnsRes.ok) {
|
||||
throw new Error("Failed to fetch data");
|
||||
throw new Error('Failed to fetch data');
|
||||
}
|
||||
|
||||
const [snapshotsData, returnsData] = await Promise.all([
|
||||
@ -81,7 +84,7 @@ export default function PortfolioHistoryPage() {
|
||||
setSnapshots(snapshotsData);
|
||||
setReturns(returnsData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -97,26 +100,26 @@ export default function PortfolioHistoryPage() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(
|
||||
`http://localhost:8000/api/portfolios/${portfolioId}/snapshots`,
|
||||
{
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || "Failed to create snapshot");
|
||||
throw new Error(data.detail || 'Failed to create snapshot');
|
||||
}
|
||||
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@ -124,7 +127,7 @@ export default function PortfolioHistoryPage() {
|
||||
|
||||
const handleViewSnapshot = async (snapshotId: number) => {
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(
|
||||
`http://localhost:8000/api/portfolios/${portfolioId}/snapshots/${snapshotId}`,
|
||||
{
|
||||
@ -133,369 +136,369 @@ export default function PortfolioHistoryPage() {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch snapshot");
|
||||
throw new Error('Failed to fetch snapshot');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setSelectedSnapshot(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSnapshot = async (snapshotId: number) => {
|
||||
if (!confirm("이 스냅샷을 삭제하시겠습니까?")) return;
|
||||
if (!confirm('이 스냅샷을 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(
|
||||
`http://localhost:8000/api/portfolios/${portfolioId}/snapshots/${snapshotId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to delete snapshot");
|
||||
throw new Error('Failed to delete snapshot');
|
||||
}
|
||||
|
||||
setSelectedSnapshot(null);
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (value: string | number) => {
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
return new Intl.NumberFormat("ko-KR", {
|
||||
style: "currency",
|
||||
currency: "KRW",
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: 'KRW',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
const formatPercent = (value: string | number | null) => {
|
||||
if (value === null) return "-";
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
return `${num >= 0 ? "+" : ""}${num.toFixed(2)}%`;
|
||||
if (value === null) return '-';
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
return new Date(dateStr).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href={`/portfolio/${portfolioId}`}
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
← 포트폴리오로 돌아가기
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">
|
||||
포트폴리오 히스토리
|
||||
</h1>
|
||||
<DashboardLayout>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href={`/portfolio/${portfolioId}`}
|
||||
className="text-primary hover:underline text-sm"
|
||||
>
|
||||
← 포트폴리오로 돌아가기
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground mt-2">
|
||||
포트폴리오 히스토리
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
{returns && returns.total_return !== null && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">총 수익률</div>
|
||||
{/* Summary Cards */}
|
||||
{returns && returns.total_return !== null && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">총 수익률</div>
|
||||
<div
|
||||
className={`text-2xl font-bold ${
|
||||
parseFloat(returns.total_return || "0") >= 0
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
parseFloat(returns.total_return || '0') >= 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatPercent(returns.total_return)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">CAGR</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">CAGR</div>
|
||||
<div
|
||||
className={`text-2xl font-bold ${
|
||||
parseFloat(returns.cagr || "0") >= 0
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
parseFloat(returns.cagr || '0') >= 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatPercent(returns.cagr)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">시작일</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{returns.start_date ? formatDate(returns.start_date) : "-"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">시작일</div>
|
||||
<div className="text-lg font-semibold text-foreground">
|
||||
{returns.start_date ? formatDate(returns.start_date) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">종료일</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{returns.end_date ? formatDate(returns.end_date) : "-"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">종료일</div>
|
||||
<div className="text-lg font-semibold text-foreground">
|
||||
{returns.end_date ? formatDate(returns.end_date) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab("snapshots")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === "snapshots"
|
||||
? "border-b-2 border-blue-500 text-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
스냅샷 목록
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("returns")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === "returns"
|
||||
? "border-b-2 border-blue-500 text-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
수익률 추이
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === "snapshots" && (
|
||||
<div>
|
||||
{/* Create Snapshot Button */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={handleCreateSnapshot}
|
||||
disabled={creating}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{creating ? "생성 중..." : "스냅샷 생성"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Snapshots Table */}
|
||||
{snapshots.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
스냅샷이 없습니다. 첫 번째 스냅샷을 생성해보세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
날짜
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
총 평가금액
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">
|
||||
작업
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatDate(snapshot.snapshot_date)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900">
|
||||
{formatCurrency(snapshot.total_value)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
|
||||
<button
|
||||
onClick={() => handleViewSnapshot(snapshot.id)}
|
||||
className="text-blue-600 hover:underline mr-4"
|
||||
>
|
||||
상세
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSnapshot(snapshot.id)}
|
||||
className="text-red-600 hover:underline"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "returns" && (
|
||||
<div>
|
||||
{returns && returns.data.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
날짜
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
평가금액
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
일간 수익률
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
누적 수익률
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{returns.data.map((point, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatDate(point.date)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900">
|
||||
{formatCurrency(point.total_value)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-6 py-4 whitespace-nowrap text-sm text-right ${
|
||||
parseFloat(point.daily_return || "0") >= 0
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{formatPercent(point.daily_return)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-6 py-4 whitespace-nowrap text-sm text-right font-medium ${
|
||||
parseFloat(point.cumulative_return || "0") >= 0
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{formatPercent(point.cumulative_return)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
수익률 데이터가 없습니다. 스냅샷을 먼저 생성해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<div className="border-b border-border">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('snapshots')}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === 'snapshots'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
스냅샷 목록
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('returns')}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === 'returns'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
수익률 추이
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snapshot Detail Modal */}
|
||||
{selectedSnapshot && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
스냅샷 상세
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatDate(selectedSnapshot.snapshot_date)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedSnapshot(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="text-sm text-gray-500">총 평가금액</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(selectedSnapshot.total_value)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-medium text-gray-900 mb-2">보유 종목</h3>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
종목
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
수량
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
가격
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
평가금액
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
비중
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{selectedSnapshot.holdings.map((holding) => (
|
||||
<tr key={holding.ticker}>
|
||||
<td className="px-4 py-2 text-sm text-gray-900">
|
||||
{holding.ticker}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right text-gray-900">
|
||||
{holding.quantity.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right text-gray-900">
|
||||
{formatCurrency(holding.price)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right font-medium text-gray-900">
|
||||
{formatCurrency(holding.value)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right text-gray-900">
|
||||
{parseFloat(holding.current_ratio).toFixed(2)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<CardContent className="p-6">
|
||||
{activeTab === 'snapshots' && (
|
||||
<div>
|
||||
{/* Create Snapshot Button */}
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
onClick={handleCreateSnapshot}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? '생성 중...' : '스냅샷 생성'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Snapshots Table */}
|
||||
{snapshots.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
스냅샷이 없습니다. 첫 번째 스냅샷을 생성해보세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
날짜
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||
총 평가금액
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-muted-foreground uppercase">
|
||||
작업
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-background divide-y divide-border">
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id} className="hover:bg-muted/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
||||
{formatDate(snapshot.snapshot_date)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-foreground">
|
||||
{formatCurrency(snapshot.total_value)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
|
||||
<button
|
||||
onClick={() => handleViewSnapshot(snapshot.id)}
|
||||
className="text-primary hover:underline mr-4"
|
||||
>
|
||||
상세
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSnapshot(snapshot.id)}
|
||||
className="text-destructive hover:underline"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'returns' && (
|
||||
<div>
|
||||
{returns && returns.data.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
날짜
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||
평가금액
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||
일간 수익률
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||
누적 수익률
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-background divide-y divide-border">
|
||||
{returns.data.map((point, index) => (
|
||||
<tr key={index} className="hover:bg-muted/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
||||
{formatDate(point.date)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-foreground">
|
||||
{formatCurrency(point.total_value)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-6 py-4 whitespace-nowrap text-sm text-right ${
|
||||
parseFloat(point.daily_return || '0') >= 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatPercent(point.daily_return)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-6 py-4 whitespace-nowrap text-sm text-right font-medium ${
|
||||
parseFloat(point.cumulative_return || '0') >= 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatPercent(point.cumulative_return)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
수익률 데이터가 없습니다. 스냅샷을 먼저 생성해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Snapshot Detail Modal */}
|
||||
{selectedSnapshot && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<Card className="max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>스냅샷 상세</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(selectedSnapshot.snapshot_date)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedSnapshot(null)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<div className="text-sm text-muted-foreground">총 평가금액</div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{formatCurrency(selectedSnapshot.total_value)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-medium text-foreground mb-2">보유 종목</h3>
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
종목
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||
수량
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||
가격
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||
평가금액
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||
비중
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{selectedSnapshot.holdings.map((holding) => (
|
||||
<tr key={holding.ticker}>
|
||||
<td className="px-4 py-2 text-sm text-foreground">
|
||||
{holding.ticker}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right text-foreground">
|
||||
{holding.quantity.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right text-foreground">
|
||||
{formatCurrency(holding.price)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right font-medium text-foreground">
|
||||
{formatCurrency(holding.value)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right text-foreground">
|
||||
{parseFloat(holding.current_ratio).toFixed(2)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface HoldingWithValue {
|
||||
@ -85,119 +86,114 @@ export default function PortfolioDetailPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header />
|
||||
<main className="p-6">
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
<DashboardLayout>
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{portfolio && (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{portfolio.name}</h1>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
portfolio.portfolio_type === 'pension'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button asChild>
|
||||
<Link href={`/portfolio/${portfolioId}/rebalance`}>리밸런싱</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{portfolio && (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">{portfolio.name}</h1>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
portfolio.portfolio_type === 'pension'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/portfolio/${portfolioId}/rebalance`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
리밸런싱
|
||||
</Link>
|
||||
</div>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground mb-1">총 평가금액</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{formatCurrency(portfolio.total_value)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground mb-1">총 투자금액</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{formatCurrency(portfolio.total_invested)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground mb-1">총 손익</p>
|
||||
<p className={`text-2xl font-bold ${
|
||||
(portfolio.total_profit_loss ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{formatCurrency(portfolio.total_profit_loss)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 mb-1">총 평가금액</p>
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{formatCurrency(portfolio.total_value)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 mb-1">총 투자금액</p>
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{formatCurrency(portfolio.total_invested)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 mb-1">총 손익</p>
|
||||
<p className={`text-2xl font-bold ${
|
||||
(portfolio.total_profit_loss ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{formatCurrency(portfolio.total_profit_loss)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holdings Table */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">보유 자산</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">평균단가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">평가금액</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">비중</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">손익률</th>
|
||||
{/* Holdings Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>보유 자산</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">평균단가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">현재가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">평가금액</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">비중</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">손익률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{portfolio.holdings.map((holding) => (
|
||||
<tr key={holding.ticker}>
|
||||
<td className="px-4 py-3 text-sm font-medium">{holding.ticker}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{holding.quantity.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.avg_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.current_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.value)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{holding.current_ratio?.toFixed(2)}%</td>
|
||||
<td className={`px-4 py-3 text-sm text-right ${
|
||||
(holding.profit_loss_ratio ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{formatPercent(holding.profit_loss_ratio)}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{portfolio.holdings.map((holding) => (
|
||||
<tr key={holding.ticker}>
|
||||
<td className="px-4 py-3 text-sm font-medium">{holding.ticker}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{holding.quantity.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.avg_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.current_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.value)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{holding.current_ratio?.toFixed(2)}%</td>
|
||||
<td className={`px-4 py-3 text-sm text-right ${
|
||||
(holding.profit_loss_ratio ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{formatPercent(holding.profit_loss_ratio)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{portfolio.holdings.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
|
||||
보유 자산이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
{portfolio.holdings.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
|
||||
보유 자산이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,8 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface RebalanceItem {
|
||||
@ -95,9 +98,9 @@ export default function RebalancePage() {
|
||||
|
||||
const getActionBadge = (action: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
buy: 'bg-green-100 text-green-800',
|
||||
sell: 'bg-red-100 text-red-800',
|
||||
hold: 'bg-gray-100 text-gray-800',
|
||||
buy: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
sell: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
hold: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
buy: '매수',
|
||||
@ -112,138 +115,128 @@ export default function RebalancePage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header />
|
||||
<main className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">리밸런싱 계산</h1>
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">리밸런싱 계산</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simulation Input */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="additional-amount" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
추가 입금액 (시뮬레이션)
|
||||
</label>
|
||||
<input
|
||||
id="additional-amount"
|
||||
type="number"
|
||||
value={additionalAmount}
|
||||
onChange={(e) => setAdditionalAmount(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="예: 1000000"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={simulate}
|
||||
disabled={!additionalAmount || simulating}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
|
||||
>
|
||||
{simulating ? '계산 중...' : '시뮬레이션'}
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchRebalance}
|
||||
className="px-4 py-2 border rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
{/* Simulation Input */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="additional-amount">추가 입금액 (시뮬레이션)</Label>
|
||||
<Input
|
||||
id="additional-amount"
|
||||
type="number"
|
||||
value={additionalAmount}
|
||||
onChange={(e) => setAdditionalAmount(e.target.value)}
|
||||
placeholder="예: 1000000"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={simulate}
|
||||
disabled={!additionalAmount || simulating}
|
||||
>
|
||||
{simulating ? '계산 중...' : '시뮬레이션'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={fetchRebalance}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{rebalance && (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">현재 총액</p>
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)}
|
||||
</p>
|
||||
</div>
|
||||
{'additional_amount' in rebalance && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">추가 입금</p>
|
||||
<p className="text-xl font-bold text-blue-600">
|
||||
+{formatCurrency(rebalance.additional_amount)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">새 총액</p>
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency(rebalance.new_total)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{rebalance && (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">현재 총액</p>
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)}
|
||||
</p>
|
||||
</div>
|
||||
{'additional_amount' in rebalance && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">추가 입금</p>
|
||||
<p className="text-xl font-bold text-blue-600">
|
||||
+{formatCurrency(rebalance.additional_amount)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">새 총액</p>
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency(rebalance.new_total)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rebalance Table */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">리밸런싱 내역</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">목표 비중</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재 비중</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재 수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">조정 금액</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">조정 수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600">액션</th>
|
||||
{/* Rebalance Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>리밸런싱 내역</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">목표 비중</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">현재 비중</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">현재 수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">조정 금액</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">조정 수량</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rebalance.items.map((item) => (
|
||||
<tr key={item.ticker}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{item.ticker}</div>
|
||||
{item.name && <div className="text-xs text-muted-foreground">{item.name}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{item.target_ratio.toFixed(2)}%</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{item.current_ratio.toFixed(2)}%</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{item.current_quantity.toLocaleString()}</td>
|
||||
<td className={`px-4 py-3 text-sm text-right ${
|
||||
item.diff_value > 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : ''
|
||||
}`}>
|
||||
{item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-right font-medium ${
|
||||
item.diff_quantity > 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : ''
|
||||
}`}>
|
||||
{item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{getActionBadge(item.action)}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rebalance.items.map((item) => (
|
||||
<tr key={item.ticker}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{item.ticker}</div>
|
||||
{item.name && <div className="text-xs text-gray-500">{item.name}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{item.target_ratio.toFixed(2)}%</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{item.current_ratio.toFixed(2)}%</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{item.current_quantity.toLocaleString()}</td>
|
||||
<td className={`px-4 py-3 text-sm text-right ${
|
||||
item.diff_value > 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : ''
|
||||
}`}>
|
||||
{item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-right font-medium ${
|
||||
item.diff_quantity > 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : ''
|
||||
}`}>
|
||||
{item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{getActionBadge(item.action)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,8 +2,11 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function NewPortfolioPage() {
|
||||
@ -33,71 +36,68 @@ export default function NewPortfolioPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header />
|
||||
<main className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">새 포트폴리오</h1>
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">새 포트폴리오</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-md">
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="portfolio-name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
포트폴리오 이름
|
||||
</label>
|
||||
<input
|
||||
<div className="max-w-md">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>포트폴리오 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portfolio-name">포트폴리오 이름</Label>
|
||||
<Input
|
||||
id="portfolio-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="예: 퇴직연금 포트폴리오"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="portfolio-type" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
유형
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portfolio-type">유형</Label>
|
||||
<select
|
||||
id="portfolio-type"
|
||||
value={portfolioType}
|
||||
onChange={(e) => setPortfolioType(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="general">일반</option>
|
||||
<option value="pension">퇴직연금</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => router.back()}
|
||||
className="flex-1 px-4 py-2 border rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !name}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? '생성 중...' : '생성'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface Portfolio {
|
||||
@ -15,15 +16,8 @@ interface Portfolio {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default function PortfolioListPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -31,8 +25,7 @@ export default function PortfolioListPage() {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const userData = await api.getCurrentUser() as User;
|
||||
setUser(userData);
|
||||
await api.getCurrentUser();
|
||||
await fetchPortfolios();
|
||||
} catch {
|
||||
router.push('/login');
|
||||
@ -59,68 +52,55 @@ export default function PortfolioListPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header username={user?.username} />
|
||||
<main className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">포트폴리오</h1>
|
||||
<Link
|
||||
href="/portfolio/new"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
새 포트폴리오
|
||||
</Link>
|
||||
</div>
|
||||
<DashboardLayout>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">포트폴리오</h1>
|
||||
<Button asChild>
|
||||
<Link href="/portfolio/new">새 포트폴리오</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{portfolios.map((portfolio) => (
|
||||
<Link
|
||||
key={portfolio.id}
|
||||
href={`/portfolio/${portfolio.id}`}
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
{portfolio.name}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{portfolios.map((portfolio) => (
|
||||
<Link key={portfolio.id} href={`/portfolio/${portfolio.id}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-lg">{portfolio.name}</CardTitle>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
portfolio.portfolio_type === 'pension'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{getTypeLabel(portfolio.portfolio_type)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
생성일: {new Date(portfolio.created_at).toLocaleDateString('ko-KR')}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{portfolios.length === 0 && !error && (
|
||||
<div className="col-span-full text-center py-12 text-gray-500">
|
||||
아직 포트폴리오가 없습니다. 새 포트폴리오를 생성해보세요.
|
||||
</div>
|
||||
)}
|
||||
{portfolios.length === 0 && !error && (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
아직 포트폴리오가 없습니다. 새 포트폴리오를 생성해보세요.
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,15 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface StockFactor {
|
||||
ticker: string;
|
||||
name: string;
|
||||
@ -37,7 +35,6 @@ interface StrategyResult {
|
||||
|
||||
export default function MultiFactorPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [result, setResult] = useState<StrategyResult | null>(null);
|
||||
@ -52,8 +49,7 @@ export default function MultiFactorPage() {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const userData = await api.getCurrentUser() as User;
|
||||
setUser(userData);
|
||||
await api.getCurrentUser();
|
||||
} catch {
|
||||
router.push('/login');
|
||||
} finally {
|
||||
@ -99,152 +95,132 @@ export default function MultiFactorPage() {
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header username={user?.username} />
|
||||
<main className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">멀티 팩터 전략</h1>
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">멀티 팩터 전략</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">전략 설정</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
밸류 가중치
|
||||
</label>
|
||||
<input
|
||||
id="value-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={valueWeight}
|
||||
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="quality-weight" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
퀄리티 가중치
|
||||
</label>
|
||||
<input
|
||||
id="quality-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={qualityWeight}
|
||||
onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
모멘텀 가중치
|
||||
</label>
|
||||
<input
|
||||
id="momentum-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={momentumWeight}
|
||||
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
상위 종목 수
|
||||
</label>
|
||||
<input
|
||||
id="top-n"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
{/* Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>전략 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="value-weight">밸류 가중치</Label>
|
||||
<Input
|
||||
id="value-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={valueWeight}
|
||||
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quality-weight">퀄리티 가중치</Label>
|
||||
<Input
|
||||
id="quality-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={qualityWeight}
|
||||
onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="momentum-weight">모멘텀 가중치</Label>
|
||||
<Input
|
||||
id="momentum-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={momentumWeight}
|
||||
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="top-n">상위 종목 수</Label>
|
||||
<Input
|
||||
id="top-n"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={runStrategy}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
|
||||
>
|
||||
{loading ? '실행 중...' : '전략 실행'}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={runStrategy} disabled={loading}>
|
||||
{loading ? '실행 중...' : '전략 실행'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">
|
||||
결과 ({result.result_count}/{result.universe_count} 종목)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">기준일: {result.base_date}</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">순위</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">섹터</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">시가총액(억)</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">밸류</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">퀄리티</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">모멘텀</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">종합</th>
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
결과 ({result.result_count}/{result.universe_count} 종목)
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">기준일: {result.base_date}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">순위</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">섹터</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">시가총액(억)</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">현재가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PER</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PBR</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">밸류</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">퀄리티</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">모멘텀</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">종합</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{result.stocks.map((stock) => (
|
||||
<tr key={stock.ticker} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{stock.ticker}</div>
|
||||
<div className="text-xs text-muted-foreground">{stock.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.quality_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{result.stocks.map((stock) => (
|
||||
<tr key={stock.ticker} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{stock.ticker}</div>
|
||||
<div className="text-xs text-gray-500">{stock.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.quality_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,15 +3,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const strategies = [
|
||||
{
|
||||
id: 'multi-factor',
|
||||
@ -35,14 +30,12 @@ const strategies = [
|
||||
|
||||
export default function StrategyListPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const userData = await api.getCurrentUser() as User;
|
||||
setUser(userData);
|
||||
await api.getCurrentUser();
|
||||
} catch {
|
||||
router.push('/login');
|
||||
} finally {
|
||||
@ -53,38 +46,28 @@ export default function StrategyListPage() {
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header username={user?.username} />
|
||||
<main className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">퀀트 전략</h1>
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">퀀트 전략</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{strategies.map((strategy) => (
|
||||
<Link
|
||||
key={strategy.id}
|
||||
href={`/strategy/${strategy.id}`}
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{strategies.map((strategy) => (
|
||||
<Link key={strategy.id} href={`/strategy/${strategy.id}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<div className="text-4xl mb-4">{strategy.icon}</div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-2">
|
||||
{strategy.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">{strategy.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
<CardTitle>{strategy.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{strategy.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,15 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface StockFactor {
|
||||
ticker: string;
|
||||
name: string;
|
||||
@ -36,7 +34,6 @@ interface StrategyResult {
|
||||
|
||||
export default function QualityStrategyPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [result, setResult] = useState<StrategyResult | null>(null);
|
||||
@ -48,8 +45,7 @@ export default function QualityStrategyPage() {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const userData = await api.getCurrentUser() as User;
|
||||
setUser(userData);
|
||||
await api.getCurrentUser();
|
||||
} catch {
|
||||
router.push('/login');
|
||||
} finally {
|
||||
@ -90,127 +86,113 @@ export default function QualityStrategyPage() {
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header username={user?.username} />
|
||||
<main className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">슈퍼 퀄리티 전략</h1>
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">슈퍼 퀄리티 전략</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">전략 설정</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="min-fscore" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
최소 F-Score
|
||||
</label>
|
||||
<input
|
||||
id="min-fscore"
|
||||
type="number"
|
||||
min="0"
|
||||
max="9"
|
||||
value={minFscore}
|
||||
onChange={(e) => setMinFscore(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
상위 종목 수
|
||||
</label>
|
||||
<input
|
||||
id="top-n"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
{/* Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>전략 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="min-fscore">최소 F-Score</Label>
|
||||
<Input
|
||||
id="min-fscore"
|
||||
type="number"
|
||||
min="0"
|
||||
max="9"
|
||||
value={minFscore}
|
||||
onChange={(e) => setMinFscore(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="top-n">상위 종목 수</Label>
|
||||
<Input
|
||||
id="top-n"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={runStrategy}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
|
||||
>
|
||||
{loading ? '실행 중...' : '전략 실행'}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={runStrategy} disabled={loading}>
|
||||
{loading ? '실행 중...' : '전략 실행'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">
|
||||
결과 ({result.result_count}/{result.universe_count} 종목)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">기준일: {result.base_date}</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">순위</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">섹터</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">시가총액(억)</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">배당률</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600">F-Score</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">퀄리티</th>
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
결과 ({result.result_count}/{result.universe_count} 종목)
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">기준일: {result.base_date}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">순위</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">섹터</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">시가총액(억)</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">현재가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PER</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PBR</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">배당률</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">F-Score</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">퀄리티</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{result.stocks.map((stock) => (
|
||||
<tr key={stock.ticker} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{stock.ticker}</div>
|
||||
<div className="text-xs text-muted-foreground">{stock.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.dividend_yield)}%</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
(stock.fscore ?? 0) >= 8 ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
|
||||
(stock.fscore ?? 0) >= 6 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
|
||||
'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{stock.fscore}/9
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.quality_score)}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{result.stocks.map((stock) => (
|
||||
<tr key={stock.ticker} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{stock.ticker}</div>
|
||||
<div className="text-xs text-gray-500">{stock.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.dividend_yield)}%</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
(stock.fscore ?? 0) >= 8 ? 'bg-green-100 text-green-800' :
|
||||
(stock.fscore ?? 0) >= 6 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{stock.fscore}/9
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.quality_score)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,15 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface StockFactor {
|
||||
ticker: string;
|
||||
name: string;
|
||||
@ -37,7 +35,6 @@ interface StrategyResult {
|
||||
|
||||
export default function ValueMomentumPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [result, setResult] = useState<StrategyResult | null>(null);
|
||||
@ -50,8 +47,7 @@ export default function ValueMomentumPage() {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const userData = await api.getCurrentUser() as User;
|
||||
setUser(userData);
|
||||
await api.getCurrentUser();
|
||||
} catch {
|
||||
router.push('/login');
|
||||
} finally {
|
||||
@ -93,135 +89,118 @@ export default function ValueMomentumPage() {
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1">
|
||||
<Header username={user?.username} />
|
||||
<main className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">밸류 모멘텀 전략</h1>
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">밸류 모멘텀 전략</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">전략 설정</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
밸류 가중치
|
||||
</label>
|
||||
<input
|
||||
id="value-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={valueWeight}
|
||||
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
모멘텀 가중치
|
||||
</label>
|
||||
<input
|
||||
id="momentum-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={momentumWeight}
|
||||
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
상위 종목 수
|
||||
</label>
|
||||
<input
|
||||
id="top-n"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
{/* Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>전략 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="value-weight">밸류 가중치</Label>
|
||||
<Input
|
||||
id="value-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={valueWeight}
|
||||
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="momentum-weight">모멘텀 가중치</Label>
|
||||
<Input
|
||||
id="momentum-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={momentumWeight}
|
||||
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="top-n">상위 종목 수</Label>
|
||||
<Input
|
||||
id="top-n"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={runStrategy}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
|
||||
>
|
||||
{loading ? '실행 중...' : '전략 실행'}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={runStrategy} disabled={loading}>
|
||||
{loading ? '실행 중...' : '전략 실행'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">
|
||||
결과 ({result.result_count}/{result.universe_count} 종목)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">기준일: {result.base_date}</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">순위</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">섹터</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">시가총액(억)</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">밸류</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">모멘텀</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">종합</th>
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
결과 ({result.result_count}/{result.universe_count} 종목)
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">기준일: {result.base_date}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">순위</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">섹터</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">시가총액(억)</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">현재가</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PER</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PBR</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">밸류</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">모멘텀</th>
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">종합</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{result.stocks.map((stock) => (
|
||||
<tr key={stock.ticker} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{stock.ticker}</div>
|
||||
<div className="text-xs text-muted-foreground">{stock.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{result.stocks.map((stock) => (
|
||||
<tr key={stock.ticker} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{stock.ticker}</div>
|
||||
<div className="text-xs text-gray-500">{stock.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
26
frontend/src/components/ui/label.tsx
Normal file
26
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
Loading…
x
Reference in New Issue
Block a user