diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index c6e4916..40c47c7 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,7 +1,7 @@ """ Admin API for data collection management. """ -from typing import List +from typing import List, Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query @@ -45,7 +45,7 @@ class CollectResponse(BaseModel): async def collect_stocks( current_user: CurrentUser, db: Session = Depends(get_db), - biz_day: str = Query(None, pattern=r"^\d{8}$", description="Business day in YYYYMMDD format"), + biz_day: Optional[str] = Query(None, pattern=r"^\d{8}$", description="Business day in YYYYMMDD format"), ): """Collect stock master data from KRX.""" try: @@ -63,7 +63,7 @@ async def collect_stocks( async def collect_sectors( current_user: CurrentUser, db: Session = Depends(get_db), - biz_day: str = Query(None, pattern=r"^\d{8}$", description="Business day in YYYYMMDD format"), + biz_day: Optional[str] = Query(None, pattern=r"^\d{8}$", description="Business day in YYYYMMDD format"), ): """Collect sector classification data from WISEindex.""" try: @@ -81,8 +81,8 @@ async def collect_sectors( async def collect_prices( current_user: CurrentUser, db: Session = Depends(get_db), - start_date: str = Query(None, pattern=r"^\d{8}$", description="Start date in YYYYMMDD format"), - end_date: str = Query(None, pattern=r"^\d{8}$", description="End date in YYYYMMDD format"), + start_date: Optional[str] = Query(None, pattern=r"^\d{8}$", description="Start date in YYYYMMDD format"), + end_date: Optional[str] = Query(None, pattern=r"^\d{8}$", description="End date in YYYYMMDD format"), ): """Collect price data using pykrx.""" try: @@ -100,7 +100,7 @@ async def collect_prices( async def collect_valuations( current_user: CurrentUser, db: Session = Depends(get_db), - biz_day: str = Query(None, pattern=r"^\d{8}$", description="Business day in YYYYMMDD format"), + biz_day: Optional[str] = Query(None, pattern=r"^\d{8}$", description="Business day in YYYYMMDD format"), ): """Collect valuation data from KRX.""" try: @@ -135,8 +135,8 @@ async def collect_etfs( async def collect_etf_prices( current_user: CurrentUser, db: Session = Depends(get_db), - start_date: str = Query(None, pattern=r"^\d{8}$", description="Start date in YYYYMMDD format"), - end_date: str = Query(None, pattern=r"^\d{8}$", description="End date in YYYYMMDD format"), + start_date: Optional[str] = Query(None, pattern=r"^\d{8}$", description="Start date in YYYYMMDD format"), + end_date: Optional[str] = Query(None, pattern=r"^\d{8}$", description="End date in YYYYMMDD format"), ): """Collect ETF price data using pykrx.""" try: diff --git a/backend/app/api/data_explorer.py b/backend/app/api/data_explorer.py index ae17c27..6283df9 100644 --- a/backend/app/api/data_explorer.py +++ b/backend/app/api/data_explorer.py @@ -2,7 +2,6 @@ Data explorer API endpoints for viewing collected stock/ETF data. """ from datetime import date -from decimal import Decimal from typing import Optional, List from fastapi import APIRouter, Depends, Query @@ -13,6 +12,7 @@ from pydantic import BaseModel from app.core.database import get_db from app.api.deps import CurrentUser from app.models.stock import Stock, ETF, Price, ETFPrice, Sector, Valuation +from app.schemas.portfolio import FloatDecimal router = APIRouter(prefix="/api/data", tags=["data-explorer"]) @@ -30,7 +30,7 @@ class StockItem(BaseModel): ticker: str name: str market: str - close_price: Decimal | None = None + close_price: FloatDecimal | None = None market_cap: int | None = None class Config: @@ -42,7 +42,7 @@ class ETFItem(BaseModel): name: str asset_class: str market: str - expense_ratio: Decimal | None = None + expense_ratio: FloatDecimal | None = None class Config: from_attributes = True @@ -50,10 +50,10 @@ class ETFItem(BaseModel): class PriceItem(BaseModel): date: date - open: Decimal | None = None - high: Decimal | None = None - low: Decimal | None = None - close: Decimal + open: FloatDecimal | None = None + high: FloatDecimal | None = None + low: FloatDecimal | None = None + close: FloatDecimal volume: int | None = None class Config: @@ -62,8 +62,8 @@ class PriceItem(BaseModel): class ETFPriceItem(BaseModel): date: date - close: Decimal - nav: Decimal | None = None + close: FloatDecimal + nav: FloatDecimal | None = None volume: int | None = None class Config: @@ -83,11 +83,11 @@ class SectorItem(BaseModel): class ValuationItem(BaseModel): ticker: str base_date: date - per: Decimal | None = None - pbr: Decimal | None = None - psr: Decimal | None = None - pcr: Decimal | None = None - dividend_yield: Decimal | None = None + per: FloatDecimal | None = None + pbr: FloatDecimal | None = None + psr: FloatDecimal | None = None + pcr: FloatDecimal | None = None + dividend_yield: FloatDecimal | None = None class Config: from_attributes = True diff --git a/backend/app/api/market.py b/backend/app/api/market.py index 89ea92e..a3bbc72 100644 --- a/backend/app/api/market.py +++ b/backend/app/api/market.py @@ -87,8 +87,8 @@ async def get_stock_prices( @router.get("/search", response_model=List[StockSearchResult]) async def search_stocks( + current_user: CurrentUser, q: str = Query(..., min_length=1), - current_user: CurrentUser = None, db: Session = Depends(get_db), limit: int = Query(default=20, ge=1, le=100), ): diff --git a/docker-compose.yml b/docker-compose.yml index 4ad4617..d0b2db6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword} POSTGRES_DB: ${DB_NAME:-galaxy_po} volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql ports: - "5432:5432" healthcheck: diff --git a/frontend/src/app/admin/data/explorer/page.tsx b/frontend/src/app/admin/data/explorer/page.tsx index f667df0..f578669 100644 --- a/frontend/src/app/admin/data/explorer/page.tsx +++ b/frontend/src/app/admin/data/explorer/page.tsx @@ -140,7 +140,17 @@ export default function DataExplorerPage() { const totalPages = data ? Math.ceil(data.total / data.size) : 0; - if (loading) return null; + if (loading) { + return ( + +
+
+
+
+
+ + ); + } return ( diff --git a/frontend/src/app/backtest/[id]/page.tsx b/frontend/src/app/backtest/[id]/page.tsx index 2fc3b26..f51079b 100644 --- a/frontend/src/app/backtest/[id]/page.tsx +++ b/frontend/src/app/backtest/[id]/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useParams } from 'next/navigation'; import { DashboardLayout } from '@/components/layout/dashboard-layout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; +import { AreaChart } from '@/components/charts/area-chart'; import { api } from '@/lib/api'; interface BacktestMetrics { @@ -287,24 +288,49 @@ export default function BacktestDetailPage() { 자산 추이 -
- {equityCurve.length > 0 ? ( -
-
- 시작: {formatCurrency(equityCurve[0]?.portfolio_value || 0)}원 - 종료: {formatCurrency(equityCurve[equityCurve.length - 1]?.portfolio_value || 0)}원 -
-
- (차트 라이브러리 연동 필요 - {equityCurve.length}개 데이터 포인트) -
-
- ) : ( - '데이터 없음' - )} -
+ {equityCurve.length > 0 ? ( + ({ + date: p.date, + value: p.portfolio_value, + benchmark: p.benchmark_value, + }))} + height={280} + color="#3b82f6" + showLegend={false} + formatValue={(v) => `${formatCurrency(v)}원`} + formatXAxis={(v) => v.slice(5)} + /> + ) : ( +
+ 데이터 없음 +
+ )}
+ {/* Drawdown Chart */} + {equityCurve.some((p) => p.drawdown !== 0) && ( + + + 낙폭 (Drawdown) + + + ({ + date: p.date, + value: p.drawdown, + }))} + height={200} + color="#ef4444" + showLegend={false} + formatValue={(v) => `${v.toFixed(1)}%`} + formatXAxis={(v) => v.slice(5)} + /> + + + )} + {/* Tabs */}
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 - 오늘

-
-
-
- -
-

배당금 입금

-

삼성전자 - 3일 후

-
-
+ ))}
-
+ )}
); 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 (