fix: resolve multiple frontend/backend bugs and add missing functionality
All checks were successful
Deploy to Production / deploy (push) Successful in 2m7s

Backend:
- Fix Decimal serialization in data_explorer.py (Decimal → FloatDecimal)
- Fix Optional type hints for query parameters in admin.py
- Fix authentication bypass in market.py search_stocks endpoint

Frontend:
- Fix 404 page: link to "/" instead of "/dashboard", proper back button
- Rewrite dashboard with real API data instead of hardcoded samples
- Implement actual equity curve and drawdown charts in backtest detail
- Remove mock data from backtest list, load real results from API
- Fix null dividend_yield display in quality strategy page
- Add skeleton loading states to 7 pages that returned null during load

Infrastructure:
- Fix PostgreSQL 18 volume mount compatibility in docker-compose.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-13 22:49:17 +09:00
parent 752db2ef1a
commit 3d5e695559
15 changed files with 532 additions and 390 deletions

View File

@ -1,7 +1,7 @@
""" """
Admin API for data collection management. Admin API for data collection management.
""" """
from typing import List from typing import List, Optional
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -45,7 +45,7 @@ class CollectResponse(BaseModel):
async def collect_stocks( async def collect_stocks(
current_user: CurrentUser, current_user: CurrentUser,
db: Session = Depends(get_db), 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.""" """Collect stock master data from KRX."""
try: try:
@ -63,7 +63,7 @@ async def collect_stocks(
async def collect_sectors( async def collect_sectors(
current_user: CurrentUser, current_user: CurrentUser,
db: Session = Depends(get_db), 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.""" """Collect sector classification data from WISEindex."""
try: try:
@ -81,8 +81,8 @@ async def collect_sectors(
async def collect_prices( async def collect_prices(
current_user: CurrentUser, current_user: CurrentUser,
db: Session = Depends(get_db), db: Session = Depends(get_db),
start_date: str = Query(None, pattern=r"^\d{8}$", description="Start date in YYYYMMDD format"), start_date: Optional[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"), end_date: Optional[str] = Query(None, pattern=r"^\d{8}$", description="End date in YYYYMMDD format"),
): ):
"""Collect price data using pykrx.""" """Collect price data using pykrx."""
try: try:
@ -100,7 +100,7 @@ async def collect_prices(
async def collect_valuations( async def collect_valuations(
current_user: CurrentUser, current_user: CurrentUser,
db: Session = Depends(get_db), 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.""" """Collect valuation data from KRX."""
try: try:
@ -135,8 +135,8 @@ async def collect_etfs(
async def collect_etf_prices( async def collect_etf_prices(
current_user: CurrentUser, current_user: CurrentUser,
db: Session = Depends(get_db), db: Session = Depends(get_db),
start_date: str = Query(None, pattern=r"^\d{8}$", description="Start date in YYYYMMDD format"), start_date: Optional[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"), end_date: Optional[str] = Query(None, pattern=r"^\d{8}$", description="End date in YYYYMMDD format"),
): ):
"""Collect ETF price data using pykrx.""" """Collect ETF price data using pykrx."""
try: try:

View File

@ -2,7 +2,6 @@
Data explorer API endpoints for viewing collected stock/ETF data. Data explorer API endpoints for viewing collected stock/ETF data.
""" """
from datetime import date from datetime import date
from decimal import Decimal
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
@ -13,6 +12,7 @@ from pydantic import BaseModel
from app.core.database import get_db from app.core.database import get_db
from app.api.deps import CurrentUser from app.api.deps import CurrentUser
from app.models.stock import Stock, ETF, Price, ETFPrice, Sector, Valuation 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"]) router = APIRouter(prefix="/api/data", tags=["data-explorer"])
@ -30,7 +30,7 @@ class StockItem(BaseModel):
ticker: str ticker: str
name: str name: str
market: str market: str
close_price: Decimal | None = None close_price: FloatDecimal | None = None
market_cap: int | None = None market_cap: int | None = None
class Config: class Config:
@ -42,7 +42,7 @@ class ETFItem(BaseModel):
name: str name: str
asset_class: str asset_class: str
market: str market: str
expense_ratio: Decimal | None = None expense_ratio: FloatDecimal | None = None
class Config: class Config:
from_attributes = True from_attributes = True
@ -50,10 +50,10 @@ class ETFItem(BaseModel):
class PriceItem(BaseModel): class PriceItem(BaseModel):
date: date date: date
open: Decimal | None = None open: FloatDecimal | None = None
high: Decimal | None = None high: FloatDecimal | None = None
low: Decimal | None = None low: FloatDecimal | None = None
close: Decimal close: FloatDecimal
volume: int | None = None volume: int | None = None
class Config: class Config:
@ -62,8 +62,8 @@ class PriceItem(BaseModel):
class ETFPriceItem(BaseModel): class ETFPriceItem(BaseModel):
date: date date: date
close: Decimal close: FloatDecimal
nav: Decimal | None = None nav: FloatDecimal | None = None
volume: int | None = None volume: int | None = None
class Config: class Config:
@ -83,11 +83,11 @@ class SectorItem(BaseModel):
class ValuationItem(BaseModel): class ValuationItem(BaseModel):
ticker: str ticker: str
base_date: date base_date: date
per: Decimal | None = None per: FloatDecimal | None = None
pbr: Decimal | None = None pbr: FloatDecimal | None = None
psr: Decimal | None = None psr: FloatDecimal | None = None
pcr: Decimal | None = None pcr: FloatDecimal | None = None
dividend_yield: Decimal | None = None dividend_yield: FloatDecimal | None = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@ -87,8 +87,8 @@ async def get_stock_prices(
@router.get("/search", response_model=List[StockSearchResult]) @router.get("/search", response_model=List[StockSearchResult])
async def search_stocks( async def search_stocks(
current_user: CurrentUser,
q: str = Query(..., min_length=1), q: str = Query(..., min_length=1),
current_user: CurrentUser = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
limit: int = Query(default=20, ge=1, le=100), limit: int = Query(default=20, ge=1, le=100),
): ):

View File

@ -7,7 +7,7 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword} POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword}
POSTGRES_DB: ${DB_NAME:-galaxy_po} POSTGRES_DB: ${DB_NAME:-galaxy_po}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:

View File

@ -140,7 +140,17 @@ export default function DataExplorerPage() {
const totalPages = data ? Math.ceil(data.total / data.size) : 0; const totalPages = data ? Math.ceil(data.total / data.size) : 0;
if (loading) return null; if (loading) {
return (
<DashboardLayout>
<div className="flex items-center justify-between mb-6">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-10 w-28 bg-muted animate-pulse rounded" />
</div>
<div className="h-96 bg-muted animate-pulse rounded-xl" />
</DashboardLayout>
);
}
return ( return (
<DashboardLayout> <DashboardLayout>

View File

@ -5,6 +5,7 @@ import { useRouter, useParams } from 'next/navigation';
import { DashboardLayout } from '@/components/layout/dashboard-layout'; import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { AreaChart } from '@/components/charts/area-chart';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface BacktestMetrics { interface BacktestMetrics {
@ -287,24 +288,49 @@ export default function BacktestDetailPage() {
<CardTitle> </CardTitle> <CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-64 flex items-center justify-center text-muted-foreground">
{equityCurve.length > 0 ? ( {equityCurve.length > 0 ? (
<div className="w-full"> <AreaChart
<div className="flex justify-between text-sm text-muted-foreground mb-2"> data={equityCurve.map((p) => ({
<span>: {formatCurrency(equityCurve[0]?.portfolio_value || 0)}</span> date: p.date,
<span>: {formatCurrency(equityCurve[equityCurve.length - 1]?.portfolio_value || 0)}</span> value: p.portfolio_value,
</div> benchmark: p.benchmark_value,
<div className="text-center text-muted-foreground"> }))}
( - {equityCurve.length} ) height={280}
</div> color="#3b82f6"
</div> showLegend={false}
formatValue={(v) => `${formatCurrency(v)}`}
formatXAxis={(v) => v.slice(5)}
/>
) : ( ) : (
'데이터 없음' <div className="h-64 flex items-center justify-center text-muted-foreground">
)}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Drawdown Chart */}
{equityCurve.some((p) => p.drawdown !== 0) && (
<Card className="mb-6">
<CardHeader>
<CardTitle> (Drawdown)</CardTitle>
</CardHeader>
<CardContent>
<AreaChart
data={equityCurve.map((p) => ({
date: p.date,
value: p.drawdown,
}))}
height={200}
color="#ef4444"
showLegend={false}
formatValue={(v) => `${v.toFixed(1)}%`}
formatXAxis={(v) => v.slice(5)}
/>
</CardContent>
</Card>
)}
{/* Tabs */} {/* Tabs */}
<Card> <Card>
<div className="border-b border-border"> <div className="border-b border-border">

View File

@ -18,7 +18,6 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { AreaChart } from '@/components/charts/area-chart'; import { AreaChart } from '@/components/charts/area-chart';
import { BarChart } from '@/components/charts/bar-chart';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { TrendingUp, TrendingDown, Activity, Target, Calendar, Settings } from 'lucide-react'; import { TrendingUp, TrendingDown, Activity, Target, Calendar, Settings } from 'lucide-react';
@ -34,9 +33,14 @@ interface BacktestResult {
cagr: number | null; cagr: number | null;
mdd: number | null; mdd: number | null;
sharpe_ratio?: 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 }>; equity_curve?: Array<{ date: string; value: number }>;
drawdown_curve?: Array<{ date: string; value: number }>; drawdown_curve?: Array<{ date: string; value: number }>;
yearly_returns?: Array<{ name: string; value: number }>;
} }
interface BacktestListItem { interface BacktestListItem {
@ -65,58 +69,13 @@ const periodOptions = [
{ value: 'annual', label: '연별' }, { 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() { export default function BacktestPage() {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [backtests, setBacktests] = useState<BacktestListItem[]>([]); const [backtests, setBacktests] = useState<BacktestListItem[]>([]);
const [currentResult] = useState<BacktestResult | null>(null); const [currentResult, setCurrentResult] = useState<BacktestResult | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
@ -157,6 +116,29 @@ export default function BacktestPage() {
try { try {
const data = await api.get<BacktestListItem[]>('/api/backtest'); const data = await api.get<BacktestListItem[]>('/api/backtest');
setBacktests(data); 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<BacktestResult>(`/api/backtest/${latestCompleted.id}`);
const [equityCurve] = await Promise.all([
api.get<Array<{ date: string; portfolio_value: number; drawdown: number }>>(`/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) { } catch (err) {
console.error('Failed to fetch backtests:', err); console.error('Failed to fetch backtests:', err);
} }
@ -240,8 +222,7 @@ export default function BacktestPage() {
}).format(value); }).format(value);
}; };
// Use mock data for demonstration const displayResult = currentResult;
const displayResult = currentResult || mockResult;
if (loading) { if (loading) {
return ( return (
@ -584,6 +565,8 @@ export default function BacktestPage() {
{/* Right Side - Results */} {/* Right Side - Results */}
<div className="lg:col-span-3 space-y-6"> <div className="lg:col-span-3 space-y-6">
{displayResult ? (
<>
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card> <Card>
@ -641,18 +624,18 @@ export default function BacktestPage() {
<CardTitle className="text-base"> </CardTitle> <CardTitle className="text-base"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{displayResult.equity_curve ? ( {displayResult.equity_curve && displayResult.equity_curve.length > 0 ? (
<AreaChart <AreaChart
data={displayResult.equity_curve} data={displayResult.equity_curve}
height={280} height={280}
color="#3b82f6" color="#3b82f6"
showLegend={false} showLegend={false}
formatValue={(v) => formatCurrency(v)} formatValue={(v) => formatCurrency(v)}
formatXAxis={(v) => v} formatXAxis={(v) => v.slice(5)}
/> />
) : ( ) : (
<div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg"> <div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg">
<p className="text-muted-foreground"> </p> <p className="text-muted-foreground"> </p>
</div> </div>
)} )}
</CardContent> </CardContent>
@ -664,14 +647,14 @@ export default function BacktestPage() {
<CardTitle className="text-base"> (Drawdown)</CardTitle> <CardTitle className="text-base"> (Drawdown)</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{displayResult.drawdown_curve ? ( {displayResult.drawdown_curve && displayResult.drawdown_curve.length > 0 ? (
<AreaChart <AreaChart
data={displayResult.drawdown_curve} data={displayResult.drawdown_curve}
height={200} height={200}
color="#ef4444" color="#ef4444"
showLegend={false} showLegend={false}
formatValue={(v) => `${v.toFixed(1)}%`} formatValue={(v) => `${v.toFixed(1)}%`}
formatXAxis={(v) => v} formatXAxis={(v) => v.slice(5)}
/> />
) : ( ) : (
<div className="flex items-center justify-center h-48 bg-muted/50 rounded-lg"> <div className="flex items-center justify-center h-48 bg-muted/50 rounded-lg">
@ -680,28 +663,19 @@ export default function BacktestPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</>
{/* Yearly Returns */} ) : (
<Card> <Card>
<CardHeader> <CardContent className="p-12">
<CardTitle className="text-base"> </CardTitle> <div className="flex flex-col items-center justify-center text-center text-muted-foreground">
</CardHeader> <Activity className="h-12 w-12 mb-4 text-muted-foreground/30" />
<CardContent> <h3 className="text-lg font-medium mb-2"> </h3>
{displayResult.yearly_returns ? ( <p className="text-sm mb-1"> .</p>
<BarChart <p className="text-xs"> .</p>
data={displayResult.yearly_returns}
height={250}
layout="vertical"
colorByValue={true}
formatValue={(v) => `${v.toFixed(1)}%`}
/>
) : (
<div className="flex items-center justify-center h-48 bg-muted/50 rounded-lg">
<p className="text-muted-foreground"> </p>
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -1,9 +1,14 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { FileQuestion, Home, ArrowLeft } from 'lucide-react'; import { FileQuestion, Home, ArrowLeft } from 'lucide-react';
export default function NotFound() { export default function NotFound() {
const router = useRouter();
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background p-4"> <div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md text-center"> <Card className="w-full max-w-md text-center">
@ -22,16 +27,14 @@ export default function NotFound() {
</p> </p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-center"> <div className="flex flex-col gap-2 sm:flex-row sm:justify-center">
<Button asChild> <Button asChild>
<Link href="/dashboard"> <Link href="/">
<Home className="mr-2 h-4 w-4" /> <Home className="mr-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>
<Button variant="outline" asChild> <Button variant="outline" onClick={() => router.back()}>
<Link href="javascript:history.back()">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
</Link>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@ -1,97 +1,39 @@
'use client'; '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 { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { Wallet, TrendingUp, Briefcase, RefreshCw } from 'lucide-react';
import { api } from '@/lib/api';
// Sample data for sparklines interface HoldingWithValue {
const totalAssetSparkline = [ ticker: string;
{ value: 9500000 }, current_ratio: number | null;
{ value: 9800000 }, value: number | null;
{ value: 10000000 }, profit_loss_ratio: number | null;
{ value: 9700000 }, }
{ value: 10200000 },
{ value: 10500000 },
{ value: 10800000 },
];
const returnSparkline = [ interface PortfolioDetail {
{ value: 2.5 }, id: number;
{ value: 3.1 }, name: string;
{ value: 2.8 }, portfolio_type: string;
{ value: 4.2 }, total_value: number | null;
{ value: 5.1 }, total_invested: number | null;
{ value: 4.8 }, total_profit_loss: number | null;
{ value: 5.5 }, holdings: HoldingWithValue[];
]; }
// Sample data for asset trend chart interface PortfolioSummary {
const assetTrendData = [ id: number;
{ date: '2024-01', value: 10000000 }, name: string;
{ date: '2024-02', value: 10500000 }, portfolio_type: string;
{ 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 },
];
// Sample data for sector allocation const CHART_COLORS = [
const sectorData = [ '#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#06b6d4',
{ 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 formatKRW = (value: number) => { const formatKRW = (value: number) => {
@ -99,128 +41,257 @@ const formatKRW = (value: number) => {
return `${(value / 100000000).toFixed(1)}`; return `${(value / 100000000).toFixed(1)}`;
} }
if (value >= 10000) { if (value >= 10000) {
return `${(value / 10000).toFixed(0)}`; return `${Math.round(value / 10000)}`;
} }
return value.toLocaleString(); return value.toLocaleString();
}; };
const formatPercent = (value: number) => `${value.toFixed(1)}%`;
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [portfolios, setPortfolios] = useState<PortfolioDetail[]>([]);
useEffect(() => {
const init = async () => {
try {
await api.getCurrentUser();
const summaries = await api.get<PortfolioSummary[]>('/api/portfolios');
const details = await Promise.all(
summaries.map(async (p) => {
try {
return await api.get<PortfolioDetail>(`/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<string, number> = {};
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 ( return (
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{summaryCards.map((card) => { <Card>
const Icon = card.icon;
return (
<Card key={card.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium"> </CardTitle>
{card.title} <Wallet className="h-4 w-4 text-muted-foreground" />
</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{card.value}</div> <div className="text-2xl font-bold">
<p className="text-xs text-muted-foreground"> {totalValue > 0 ? `${formatKRW(totalValue)}` : '-'}
{card.description} </div>
</p> <p className="text-xs text-muted-foreground"> </p>
{card.sparklineData && ( </CardContent>
<Sparkline </Card>
data={card.sparklineData}
color={card.sparklineColor || '#3b82f6'} <Card>
height={32} <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
className="mt-2" <CardTitle className="text-sm font-medium"> </CardTitle>
/> <TrendingUp className="h-4 w-4 text-muted-foreground" />
)} </CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${totalReturnPercent >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{totalInvested > 0 ? `${totalReturnPercent >= 0 ? '+' : ''}${totalReturnPercent.toFixed(1)}%` : '-'}
</div>
<p className="text-xs text-muted-foreground">
{totalProfitLoss !== 0 ? `${totalProfitLoss >= 0 ? '+' : ''}${formatKRW(Math.abs(totalProfitLoss))}` : '투자 후 확인 가능'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Briefcase className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{portfolios.length}</div>
<p className="text-xs text-muted-foreground"> </p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<RefreshCw className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{Object.keys(holdingsByTicker).length}</div>
<p className="text-xs text-muted-foreground"> </p>
</CardContent> </CardContent>
</Card> </Card>
);
})}
</div> </div>
{/* Main Charts */} {/* Main Content */}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Card> {/* Asset Allocation */}
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<AreaChart
data={assetTrendData}
height={280}
color="#3b82f6"
formatValue={formatKRW}
showLegend={false}
/>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> </CardTitle> <CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{donutData.length > 0 ? (
<DonutChart <DonutChart
data={sectorData} data={donutData}
height={280} height={280}
innerRadius={50} innerRadius={50}
outerRadius={90} outerRadius={90}
/> />
) : (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<div className="text-center">
<p className="mb-2"> </p>
<Link href="/portfolio" className="text-primary hover:underline text-sm">
</Link>
</div>
</div>
)}
</CardContent>
</Card>
{/* Portfolio List */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
{portfolios.length > 0 ? (
<div className="space-y-4">
{portfolios.map((p) => {
const returnPct = p.total_invested && p.total_invested > 0
? ((p.total_profit_loss ?? 0) / p.total_invested) * 100
: null;
return (
<Link key={p.id} href={`/portfolio/${p.id}`} className="block">
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{p.name}</p>
<span className={`text-xs px-1.5 py-0.5 rounded ${
p.portfolio_type === 'pension'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-muted text-muted-foreground'
}`}>
{p.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{p.holdings.length}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">
{p.total_value ? `${formatKRW(p.total_value)}` : '-'}
</p>
{returnPct !== null && (
<p className={`text-xs ${returnPct >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{returnPct >= 0 ? '+' : ''}{returnPct.toFixed(1)}%
</p>
)}
</div>
</div>
</Link>
);
})}
</div>
) : (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<div className="text-center">
<Briefcase className="h-8 w-8 mx-auto mb-2 text-muted-foreground/50" />
<p className="mb-2"> </p>
<Link href="/portfolio/new" className="text-primary hover:underline text-sm">
</Link>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Secondary Charts */} {/* Portfolio Comparison */}
<div className="grid gap-4 md:grid-cols-2"> {portfolioComparison.length > 1 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> </CardTitle> <CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<BarChart
data={portfolioComparisonData}
height={240}
layout="horizontal"
formatValue={formatPercent}
colorByValue={true}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"> {portfolioComparison.map((p, i) => (
<RefreshCw className="h-4 w-4 text-blue-500" /> <div key={i} className="flex items-center gap-4">
<div className="flex-1"> <span className="text-sm w-32 truncate">{p.name}</span>
<p className="text-sm font-medium"> </p> <div className="flex-1 bg-muted rounded-full h-4 overflow-hidden">
<p className="text-xs text-muted-foreground"> A - 2 </p> <div
</div> className={`h-full rounded-full ${p.value >= 0 ? 'bg-green-500' : 'bg-red-500'}`}
</div> style={{ width: `${Math.min(Math.abs(p.value), 100)}%` }}
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"> />
<TrendingUp className="h-4 w-4 text-green-500" />
<div className="flex-1">
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> B - </p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Briefcase className="h-4 w-4 text-amber-500" />
<div className="flex-1">
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> - 3 </p>
</div> </div>
<span className={`text-sm font-medium w-16 text-right ${p.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{p.value >= 0 ? '+' : ''}{p.value.toFixed(1)}%
</span>
</div> </div>
))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> )}
</div> </div>
</DashboardLayout> </DashboardLayout>
); );

View File

@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TradingViewChart } from '@/components/charts/trading-view-chart'; import { TradingViewChart } from '@/components/charts/trading-view-chart';
import { DonutChart } from '@/components/charts/donut-chart'; import { DonutChart } from '@/components/charts/donut-chart';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { AreaData, Time } from 'lightweight-charts'; import type { AreaData, Time } from 'lightweight-charts';
@ -169,7 +170,20 @@ export default function PortfolioDetailPage() {
}; };
if (loading) { if (loading) {
return null; return (
<DashboardLayout>
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-28" />
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-80 rounded-xl" />
</DashboardLayout>
);
} }
const chartData = portfolio ? generateChartData(portfolio.total_value) : []; const chartData = portfolio ? generateChartData(portfolio.total_value) : [];

View File

@ -6,6 +6,7 @@ import Link from 'next/link';
import { DashboardLayout } from '@/components/layout/dashboard-layout'; import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PortfolioCard } from '@/components/portfolio/portfolio-card'; import { PortfolioCard } from '@/components/portfolio/portfolio-card';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface HoldingWithValue { interface HoldingWithValue {
@ -87,7 +88,19 @@ export default function PortfolioListPage() {
}; };
if (loading) { if (loading) {
return null; return (
<DashboardLayout>
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-10 w-32" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-48 rounded-xl" />
))}
</div>
</DashboardLayout>
);
} }
return ( return (

View File

@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface StockFactor { interface StockFactor {
@ -95,7 +96,12 @@ export default function MultiFactorPage() {
}; };
if (initialLoading) { if (initialLoading) {
return null; return (
<DashboardLayout>
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-48 rounded-xl" />
</DashboardLayout>
);
} }
return ( return (

View File

@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { DashboardLayout } from '@/components/layout/dashboard-layout'; import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { StrategyCard } from '@/components/strategy/strategy-card'; import { StrategyCard } from '@/components/strategy/strategy-card';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Skeleton } from '@/components/ui/skeleton';
import { BarChart3, Star, TrendingUp } from 'lucide-react'; import { BarChart3, Star, TrendingUp } from 'lucide-react';
const strategies = [ const strategies = [
@ -55,7 +56,19 @@ export default function StrategyListPage() {
}, [router]); }, [router]);
if (loading) { if (loading) {
return null; return (
<DashboardLayout>
<div className="mb-6">
<Skeleton className="h-8 w-40 mb-2" />
<Skeleton className="h-4 w-64" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-64 rounded-xl" />
))}
</div>
</DashboardLayout>
);
} }
return ( return (

View File

@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface StockFactor { interface StockFactor {
@ -86,7 +87,12 @@ export default function QualityStrategyPage() {
}; };
if (initialLoading) { if (initialLoading) {
return null; return (
<DashboardLayout>
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-48 rounded-xl" />
</DashboardLayout>
);
} }
return ( return (
@ -174,7 +180,7 @@ export default function QualityStrategyPage() {
<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">{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.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.pbr)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.dividend_yield)}%</td> <td className="px-4 py-3 text-sm text-right">{stock.dividend_yield !== null ? `${formatNumber(stock.dividend_yield)}%` : '-'}</td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<span className={`px-2 py-1 rounded text-xs ${ <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) >= 8 ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :

View File

@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface StockFactor { interface StockFactor {
@ -89,7 +90,12 @@ export default function ValueMomentumPage() {
}; };
if (initialLoading) { if (initialLoading) {
return null; return (
<DashboardLayout>
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-48 rounded-xl" />
</DashboardLayout>
);
} }
return ( return (