diff --git a/frontend/src/app/backtest/page.tsx b/frontend/src/app/backtest/page.tsx
index 716f9c8..28c8a99 100644
--- a/frontend/src/app/backtest/page.tsx
+++ b/frontend/src/app/backtest/page.tsx
@@ -18,7 +18,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import { AreaChart } from '@/components/charts/area-chart';
-import { BarChart } from '@/components/charts/bar-chart';
import { api } from '@/lib/api';
import { TrendingUp, TrendingDown, Activity, Target, Calendar, Settings } from 'lucide-react';
@@ -34,9 +33,14 @@ interface BacktestResult {
cagr: number | null;
mdd: number | null;
sharpe_ratio?: number | null;
+ result?: {
+ sharpe_ratio: number | null;
+ total_return: number | null;
+ cagr: number | null;
+ mdd: number | null;
+ } | null;
equity_curve?: Array<{ date: string; value: number }>;
drawdown_curve?: Array<{ date: string; value: number }>;
- yearly_returns?: Array<{ name: string; value: number }>;
}
interface BacktestListItem {
@@ -65,58 +69,13 @@ const periodOptions = [
{ value: 'annual', label: '연별' },
];
-// Mock result for demonstration when no real backtest result available
-const mockResult: BacktestResult = {
- id: 0,
- strategy_type: 'multi_factor',
- start_date: '2020-01-01',
- end_date: '2024-12-31',
- rebalance_period: 'quarterly',
- status: 'completed',
- created_at: new Date().toISOString(),
- total_return: 87.5,
- cagr: 13.4,
- mdd: -24.6,
- sharpe_ratio: 0.92,
- equity_curve: [
- { date: '2020-01', value: 100000000 },
- { date: '2020-06', value: 95000000 },
- { date: '2021-01', value: 115000000 },
- { date: '2021-06', value: 128000000 },
- { date: '2022-01', value: 142000000 },
- { date: '2022-06', value: 125000000 },
- { date: '2023-01', value: 148000000 },
- { date: '2023-06', value: 162000000 },
- { date: '2024-01', value: 175000000 },
- { date: '2024-06', value: 187500000 },
- ],
- drawdown_curve: [
- { date: '2020-01', value: 0 },
- { date: '2020-06', value: -12.5 },
- { date: '2021-01', value: -2.1 },
- { date: '2021-06', value: 0 },
- { date: '2022-01', value: -5.2 },
- { date: '2022-06', value: -24.6 },
- { date: '2023-01', value: -8.3 },
- { date: '2023-06', value: -3.1 },
- { date: '2024-01', value: -1.5 },
- { date: '2024-06', value: 0 },
- ],
- yearly_returns: [
- { name: '2020', value: 15.0 },
- { name: '2021', value: 23.5 },
- { name: '2022', value: -12.0 },
- { name: '2023', value: 28.4 },
- { name: '2024', value: 22.1 },
- ],
-};
export default function BacktestPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [backtests, setBacktests] = useState
([]);
- const [currentResult] = useState(null);
+ const [currentResult, setCurrentResult] = useState(null);
const [error, setError] = useState(null);
const [showHistory, setShowHistory] = useState(false);
@@ -157,6 +116,29 @@ export default function BacktestPage() {
try {
const data = await api.get('/api/backtest');
setBacktests(data);
+
+ // Load the latest completed backtest for display
+ const latestCompleted = data.find((bt) => bt.status === 'completed');
+ if (latestCompleted) {
+ try {
+ const detail = await api.get(`/api/backtest/${latestCompleted.id}`);
+ const [equityCurve] = await Promise.all([
+ api.get>(`/api/backtest/${latestCompleted.id}/equity-curve`),
+ ]);
+
+ setCurrentResult({
+ ...detail,
+ total_return: latestCompleted.total_return,
+ cagr: latestCompleted.cagr,
+ mdd: latestCompleted.mdd,
+ sharpe_ratio: detail.result?.sharpe_ratio ?? null,
+ equity_curve: equityCurve.map((p) => ({ date: p.date, value: p.portfolio_value })),
+ drawdown_curve: equityCurve.map((p) => ({ date: p.date, value: p.drawdown })),
+ });
+ } catch {
+ // Detail fetch failed, show list only
+ }
+ }
} catch (err) {
console.error('Failed to fetch backtests:', err);
}
@@ -240,8 +222,7 @@ export default function BacktestPage() {
}).format(value);
};
- // Use mock data for demonstration
- const displayResult = currentResult || mockResult;
+ const displayResult = currentResult;
if (loading) {
return (
@@ -584,124 +565,117 @@ export default function BacktestPage() {
{/* Right Side - Results */}
- {/* Summary Cards */}
-
+ {displayResult ? (
+ <>
+ {/* Summary Cards */}
+
+
+
+
+
+ CAGR
+
+ = 0 ? 'text-green-600' : 'text-red-600'}`}>
+ {formatNumber(displayResult.cagr)}%
+
+
+
+
+
+
+
+
+ MDD
+
+
+ {formatNumber(displayResult.mdd)}%
+
+
+
+
+
+
+
+
+ {formatNumber(displayResult.sharpe_ratio)}
+
+
+
+
+
+
+
+
+ 총 수익률
+
+ = 0 ? 'text-green-600' : 'text-red-600'}`}>
+ {formatNumber(displayResult.total_return)}%
+
+
+
+
+
+ {/* Equity Curve */}
+
+
+ 자산 추이
+
+
+ {displayResult.equity_curve && displayResult.equity_curve.length > 0 ? (
+ formatCurrency(v)}
+ formatXAxis={(v) => v.slice(5)}
+ />
+ ) : (
+
+ )}
+
+
+
+ {/* Drawdown Chart */}
+
+
+ 낙폭 (Drawdown)
+
+
+ {displayResult.drawdown_curve && displayResult.drawdown_curve.length > 0 ? (
+ `${v.toFixed(1)}%`}
+ formatXAxis={(v) => v.slice(5)}
+ />
+ ) : (
+
+ )}
+
+
+ >
+ ) : (
-
-
-
-
CAGR
+
+
+
+
백테스트 결과가 없습니다
+
좌측에서 전략을 설정하고 백테스트를 실행하세요.
+
완료된 백테스트의 결과가 여기에 표시됩니다.
- = 0 ? 'text-green-600' : 'text-red-600'}`}>
- {formatNumber(displayResult.cagr)}%
-
-
-
-
-
-
- MDD
-
-
- {formatNumber(displayResult.mdd)}%
-
-
-
-
-
-
-
-
- {formatNumber(displayResult.sharpe_ratio)}
-
-
-
-
-
-
-
-
- 총 수익률
-
- = 0 ? 'text-green-600' : 'text-red-600'}`}>
- {formatNumber(displayResult.total_return)}%
-
-
-
-
-
- {/* Equity Curve */}
-
-
- 자산 추이
-
-
- {displayResult.equity_curve ? (
- formatCurrency(v)}
- formatXAxis={(v) => v}
- />
- ) : (
-
- )}
-
-
-
- {/* Drawdown Chart */}
-
-
- 낙폭 (Drawdown)
-
-
- {displayResult.drawdown_curve ? (
- `${v.toFixed(1)}%`}
- formatXAxis={(v) => v}
- />
- ) : (
-
- )}
-
-
-
- {/* Yearly Returns */}
-
-
- 연간 수익률
-
-
- {displayResult.yearly_returns ? (
- `${v.toFixed(1)}%`}
- />
- ) : (
-
- )}
-
-
+ )}
)}
diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx
index 21cdd2b..15d24d3 100644
--- a/frontend/src/app/not-found.tsx
+++ b/frontend/src/app/not-found.tsx
@@ -1,9 +1,14 @@
+'use client';
+
import Link from 'next/link';
+import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { FileQuestion, Home, ArrowLeft } from 'lucide-react';
export default function NotFound() {
+ const router = useRouter();
+
return (
@@ -22,16 +27,14 @@ export default function NotFound() {
-
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 040b9f8..9a179ac 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,97 +1,39 @@
'use client';
+import { useEffect, useState } from 'react';
+import { 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 { Sparkline, AreaChart, DonutChart, BarChart } from '@/components/charts';
+import { DonutChart } from '@/components/charts';
import { Wallet, TrendingUp, Briefcase, RefreshCw } from 'lucide-react';
+import { api } from '@/lib/api';
-// Sample data for sparklines
-const totalAssetSparkline = [
- { value: 9500000 },
- { value: 9800000 },
- { value: 10000000 },
- { value: 9700000 },
- { value: 10200000 },
- { value: 10500000 },
- { value: 10800000 },
-];
+interface HoldingWithValue {
+ ticker: string;
+ current_ratio: number | null;
+ value: number | null;
+ profit_loss_ratio: number | null;
+}
-const returnSparkline = [
- { value: 2.5 },
- { value: 3.1 },
- { value: 2.8 },
- { value: 4.2 },
- { value: 5.1 },
- { value: 4.8 },
- { value: 5.5 },
-];
+interface PortfolioDetail {
+ id: number;
+ name: string;
+ portfolio_type: string;
+ total_value: number | null;
+ total_invested: number | null;
+ total_profit_loss: number | null;
+ holdings: HoldingWithValue[];
+}
-// Sample data for asset trend chart
-const assetTrendData = [
- { date: '2024-01', value: 10000000 },
- { date: '2024-02', value: 10500000 },
- { date: '2024-03', value: 10200000 },
- { date: '2024-04', value: 10800000 },
- { date: '2024-05', value: 11200000 },
- { date: '2024-06', value: 11000000 },
- { date: '2024-07', value: 11500000 },
- { date: '2024-08', value: 12000000 },
- { date: '2024-09', value: 11800000 },
- { date: '2024-10', value: 12500000 },
- { date: '2024-11', value: 13000000 },
- { date: '2024-12', value: 13500000 },
-];
+interface PortfolioSummary {
+ id: number;
+ name: string;
+ portfolio_type: string;
+}
-// Sample data for sector allocation
-const sectorData = [
- { name: '기술', value: 40, color: '#3b82f6' },
- { name: '금융', value: 30, color: '#10b981' },
- { name: '헬스케어', value: 20, color: '#f59e0b' },
- { name: '기타', value: 10, color: '#6b7280' },
-];
-
-// Sample data for portfolio comparison
-const portfolioComparisonData = [
- { name: '포트폴리오 A', value: 12.5 },
- { name: '포트폴리오 B', value: 8.3 },
- { name: '포트폴리오 C', value: -2.1 },
- { name: 'KOSPI', value: 5.7 },
- { name: 'S&P 500', value: 15.2 },
-];
-
-const summaryCards = [
- {
- title: '총 자산',
- value: '₩135,000,000',
- description: '전체 포트폴리오 가치',
- icon: Wallet,
- sparklineData: totalAssetSparkline,
- sparklineColor: '#3b82f6',
- },
- {
- title: '총 수익률',
- value: '+35.0%',
- description: '전체 수익률',
- icon: TrendingUp,
- sparklineData: returnSparkline,
- sparklineColor: '#10b981',
- },
- {
- title: '포트폴리오',
- value: '3',
- description: '활성 포트폴리오 수',
- icon: Briefcase,
- sparklineData: null,
- sparklineColor: null,
- },
- {
- title: '리밸런싱',
- value: '2',
- description: '예정된 리밸런싱',
- icon: RefreshCw,
- sparklineData: null,
- sparklineColor: null,
- },
+const CHART_COLORS = [
+ '#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#06b6d4',
];
const formatKRW = (value: number) => {
@@ -99,128 +41,257 @@ const formatKRW = (value: number) => {
return `${(value / 100000000).toFixed(1)}억`;
}
if (value >= 10000) {
- return `${(value / 10000).toFixed(0)}만`;
+ return `${Math.round(value / 10000)}만`;
}
return value.toLocaleString();
};
-const formatPercent = (value: number) => `${value.toFixed(1)}%`;
-
export default function DashboardPage() {
+ const router = useRouter();
+ const [loading, setLoading] = useState(true);
+ const [portfolios, setPortfolios] = useState([]);
+
+ useEffect(() => {
+ const init = async () => {
+ try {
+ await api.getCurrentUser();
+ const summaries = await api.get('/api/portfolios');
+
+ const details = await Promise.all(
+ summaries.map(async (p) => {
+ try {
+ return await api.get(`/api/portfolios/${p.id}/detail`);
+ } catch {
+ return {
+ ...p,
+ total_value: null,
+ total_invested: null,
+ total_profit_loss: null,
+ holdings: [],
+ } as PortfolioDetail;
+ }
+ })
+ );
+ setPortfolios(details);
+ } catch {
+ router.push('/login');
+ } finally {
+ setLoading(false);
+ }
+ };
+ init();
+ }, [router]);
+
+ if (loading) {
+ return null; // DashboardLayout handles skeleton
+ }
+
+ const totalValue = portfolios.reduce((sum, p) => sum + (p.total_value ?? 0), 0);
+ const totalInvested = portfolios.reduce((sum, p) => sum + (p.total_invested ?? 0), 0);
+ const totalProfitLoss = portfolios.reduce((sum, p) => sum + (p.total_profit_loss ?? 0), 0);
+ const totalReturnPercent = totalInvested > 0 ? (totalProfitLoss / totalInvested) * 100 : 0;
+
+ // Aggregate holdings for donut chart
+ const allHoldings = portfolios.flatMap((p) => p.holdings);
+ const holdingsByTicker: Record = {};
+ for (const h of allHoldings) {
+ if (h.value && h.value > 0) {
+ holdingsByTicker[h.ticker] = (holdingsByTicker[h.ticker] ?? 0) + h.value;
+ }
+ }
+ const donutData = Object.entries(holdingsByTicker)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 6)
+ .map(([ticker, value], i) => ({
+ name: ticker,
+ value: totalValue > 0 ? (value / totalValue) * 100 : 0,
+ color: CHART_COLORS[i % CHART_COLORS.length],
+ }));
+
+ // Add "기타" if there are more holdings
+ const topValue = donutData.reduce((s, d) => s + d.value, 0);
+ if (topValue < 100 && topValue > 0) {
+ donutData.push({ name: '기타', value: 100 - topValue, color: '#6b7280' });
+ }
+
+ // Portfolio comparison data
+ const portfolioComparison = portfolios
+ .filter((p) => p.total_invested && p.total_invested > 0)
+ .map((p) => ({
+ name: p.name,
+ value: ((p.total_profit_loss ?? 0) / (p.total_invested ?? 1)) * 100,
+ }));
+
return (
{/* Summary Cards */}
- {summaryCards.map((card) => {
- const Icon = card.icon;
- return (
-
-
-
- {card.title}
-
-
-
-
- {card.value}
-
- {card.description}
-
- {card.sparklineData && (
-
- )}
-
-
- );
- })}
-
-
- {/* Main Charts */}
-
-
- 포트폴리오 성과
+
+ 총 자산
+
-
+
+ {totalValue > 0 ? `₩${formatKRW(totalValue)}` : '-'}
+
+ 전체 포트폴리오 가치
+
+
+ 총 수익률
+
+
+
+ = 0 ? 'text-green-600' : 'text-red-600'}`}>
+ {totalInvested > 0 ? `${totalReturnPercent >= 0 ? '+' : ''}${totalReturnPercent.toFixed(1)}%` : '-'}
+
+
+ {totalProfitLoss !== 0 ? `${totalProfitLoss >= 0 ? '+' : ''}₩${formatKRW(Math.abs(totalProfitLoss))}` : '투자 후 확인 가능'}
+
+
+
+
+
+
+ 포트폴리오
+
+
+
+ {portfolios.length}
+ 활성 포트폴리오 수
+
+
+
+
+
+ 총 종목 수
+
+
+
+ {Object.keys(holdingsByTicker).length}
+ 보유 중인 종목
+
+
+
+
+ {/* Main Content */}
+
+ {/* Asset Allocation */}
자산 배분
-
+ {donutData.length > 0 ? (
+
+ ) : (
+
+
+
보유 종목이 없습니다
+
+ 포트폴리오 관리하기
+
+
+
+ )}
+
+
+
+ {/* Portfolio List */}
+
+
+ 포트폴리오 현황
+
+
+ {portfolios.length > 0 ? (
+
+ {portfolios.map((p) => {
+ const returnPct = p.total_invested && p.total_invested > 0
+ ? ((p.total_profit_loss ?? 0) / p.total_invested) * 100
+ : null;
+ return (
+
+
+
+
+
{p.name}
+
+ {p.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
+
+
+
+ {p.holdings.length}개 종목
+
+
+
+
+ {p.total_value ? `₩${formatKRW(p.total_value)}` : '-'}
+
+ {returnPct !== null && (
+
= 0 ? 'text-green-600' : 'text-red-600'}`}>
+ {returnPct >= 0 ? '+' : ''}{returnPct.toFixed(1)}%
+
+ )}
+
+
+
+ );
+ })}
+
+ ) : (
+
+
+
+
아직 포트폴리오가 없습니다
+
+ 첫 포트폴리오 만들기
+
+
+
+ )}
- {/* Secondary Charts */}
-
+ {/* Portfolio Comparison */}
+ {portfolioComparison.length > 1 && (
- 포트폴리오 비교
-
-
-
-
-
-
-
-
- 알림
+ 포트폴리오 수익률 비교
-
-
-
-
리밸런싱 예정
-
포트폴리오 A - 2일 후
+ {portfolioComparison.map((p, i) => (
+
+
{p.name}
+
+
= 0 ? 'bg-green-500' : 'bg-red-500'}`}
+ style={{ width: `${Math.min(Math.abs(p.value), 100)}%` }}
+ />
+
+
= 0 ? 'text-green-600' : 'text-red-600'}`}>
+ {p.value >= 0 ? '+' : ''}{p.value.toFixed(1)}%
+
-
-
-
-
-
목표 수익률 달성
-
포트폴리오 B - 오늘
-
-
-
+ ))}
-
+ )}
);
diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx
index 1394428..ca7f895 100644
--- a/frontend/src/app/portfolio/[id]/page.tsx
+++ b/frontend/src/app/portfolio/[id]/page.tsx
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TradingViewChart } from '@/components/charts/trading-view-chart';
import { DonutChart } from '@/components/charts/donut-chart';
+import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
import type { AreaData, Time } from 'lightweight-charts';
@@ -169,7 +170,20 @@ export default function PortfolioDetailPage() {
};
if (loading) {
- return null;
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
}
const chartData = portfolio ? generateChartData(portfolio.total_value) : [];
diff --git a/frontend/src/app/portfolio/page.tsx b/frontend/src/app/portfolio/page.tsx
index 3d6a975..eaa6b8b 100644
--- a/frontend/src/app/portfolio/page.tsx
+++ b/frontend/src/app/portfolio/page.tsx
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Button } from '@/components/ui/button';
import { PortfolioCard } from '@/components/portfolio/portfolio-card';
+import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
interface HoldingWithValue {
@@ -87,7 +88,19 @@ export default function PortfolioListPage() {
};
if (loading) {
- return null;
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ );
}
return (
diff --git a/frontend/src/app/strategy/multi-factor/page.tsx b/frontend/src/app/strategy/multi-factor/page.tsx
index df34f69..7185995 100644
--- a/frontend/src/app/strategy/multi-factor/page.tsx
+++ b/frontend/src/app/strategy/multi-factor/page.tsx
@@ -7,6 +7,7 @@ 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 { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
interface StockFactor {
@@ -95,7 +96,12 @@ export default function MultiFactorPage() {
};
if (initialLoading) {
- return null;
+ return (
+
+
+
+
+ );
}
return (
diff --git a/frontend/src/app/strategy/page.tsx b/frontend/src/app/strategy/page.tsx
index 21aee29..1cda2a6 100644
--- a/frontend/src/app/strategy/page.tsx
+++ b/frontend/src/app/strategy/page.tsx
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { StrategyCard } from '@/components/strategy/strategy-card';
import { api } from '@/lib/api';
+import { Skeleton } from '@/components/ui/skeleton';
import { BarChart3, Star, TrendingUp } from 'lucide-react';
const strategies = [
@@ -55,7 +56,19 @@ export default function StrategyListPage() {
}, [router]);
if (loading) {
- return null;
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ );
}
return (
diff --git a/frontend/src/app/strategy/quality/page.tsx b/frontend/src/app/strategy/quality/page.tsx
index c49ee33..9ea304f 100644
--- a/frontend/src/app/strategy/quality/page.tsx
+++ b/frontend/src/app/strategy/quality/page.tsx
@@ -7,6 +7,7 @@ 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 { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
interface StockFactor {
@@ -86,7 +87,12 @@ export default function QualityStrategyPage() {
};
if (initialLoading) {
- return null;
+ return (
+
+
+
+
+ );
}
return (
@@ -174,7 +180,7 @@ export default function QualityStrategyPage() {
{formatCurrency(stock.close_price)} |
{formatNumber(stock.per)} |
{formatNumber(stock.pbr)} |
- {formatNumber(stock.dividend_yield)}% |
+ {stock.dividend_yield !== null ? `${formatNumber(stock.dividend_yield)}%` : '-'} |
= 8 ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
diff --git a/frontend/src/app/strategy/value-momentum/page.tsx b/frontend/src/app/strategy/value-momentum/page.tsx
index 5fcf8c0..e1a2317 100644
--- a/frontend/src/app/strategy/value-momentum/page.tsx
+++ b/frontend/src/app/strategy/value-momentum/page.tsx
@@ -7,6 +7,7 @@ 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 { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
interface StockFactor {
@@ -89,7 +90,12 @@ export default function ValueMomentumPage() {
};
if (initialLoading) {
- return null;
+ return (
+
+
+
+
+ );
}
return (
|