diff --git a/frontend/src/app/strategy/compare/page.tsx b/frontend/src/app/strategy/compare/page.tsx new file mode 100644 index 0000000..8c62bb4 --- /dev/null +++ b/frontend/src/app/strategy/compare/page.tsx @@ -0,0 +1,387 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { DashboardLayout } from '@/components/layout/dashboard-layout'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { api } from '@/lib/api'; + +interface StockFactor { + ticker: string; + name: string; + market: string; + sector_name: string | null; + market_cap: number | null; + close_price: number | null; + per: number | null; + pbr: number | null; + value_score: number | null; + quality_score: number | null; + momentum_score: number | null; + total_score: number | null; + rank: number | null; +} + +interface StrategyResult { + strategy_name: string; + base_date: string; + universe_count: number; + result_count: number; + stocks: StockFactor[]; +} + +const STRATEGIES = [ + { + key: 'multi-factor', + label: '멀티팩터', + payload: { + universe: { markets: ['KOSPI', 'KOSDAQ'], exclude_stock_types: ['spac', 'preferred', 'reit'] }, + top_n: 30, + weights: { value: 0.3, quality: 0.3, momentum: 0.2, low_vol: 0.2 }, + }, + }, + { + key: 'quality', + label: '퀄리티', + payload: { + universe: { markets: ['KOSPI', 'KOSDAQ'], exclude_stock_types: ['spac', 'preferred', 'reit'] }, + top_n: 30, + min_fscore: 7, + }, + }, + { + key: 'value-momentum', + label: '밸류모멘텀', + payload: { + universe: { markets: ['KOSPI', 'KOSDAQ'], exclude_stock_types: ['spac', 'preferred', 'reit'] }, + top_n: 30, + value_weight: 0.5, + momentum_weight: 0.5, + }, + }, +] as const; + +type StrategyKey = (typeof STRATEGIES)[number]['key']; + +export default function StrategyComparePage() { + const router = useRouter(); + const [initialLoading, setInitialLoading] = useState(true); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState>({}); + const [error, setError] = useState(null); + + useEffect(() => { + const init = async () => { + try { + await api.getCurrentUser(); + } catch { + router.push('/login'); + } finally { + setInitialLoading(false); + } + }; + init(); + }, [router]); + + const runAll = async () => { + setLoading(true); + setError(null); + try { + const promises = STRATEGIES.map((s) => + api.post(`/api/strategy/${s.key}`, s.payload) + ); + const responses = await Promise.all(promises); + const map: Record = {}; + STRATEGIES.forEach((s, i) => { + map[s.key] = responses[i]; + }); + setResults(map); + } catch (err) { + setError(err instanceof Error ? err.message : '전략 실행 실패'); + } finally { + setLoading(false); + } + }; + + const formatNumber = (value: number | null, decimals = 2) => { + if (value === null) return '-'; + return value.toFixed(decimals); + }; + + const formatCurrency = (value: number | null) => { + if (value === null) return '-'; + return new Intl.NumberFormat('ko-KR').format(value); + }; + + // Find common tickers across all results + const getCommonTickers = (): Set => { + const resultKeys = Object.keys(results); + if (resultKeys.length < 2) return new Set(); + + const tickerSets = resultKeys.map( + (key) => new Set(results[key].stocks.map((s) => s.ticker)) + ); + + const common = new Set(); + tickerSets[0].forEach((ticker) => { + if (tickerSets.every((set) => set.has(ticker))) { + common.add(ticker); + } + }); + return common; + }; + + // Find tickers that appear in at least 2 strategies + const getOverlapTickers = (): Set => { + const resultKeys = Object.keys(results); + if (resultKeys.length < 2) return new Set(); + + const tickerCount: Record = {}; + resultKeys.forEach((key) => { + results[key].stocks.forEach((s) => { + tickerCount[s.ticker] = (tickerCount[s.ticker] || 0) + 1; + }); + }); + + return new Set( + Object.entries(tickerCount) + .filter(([, count]) => count >= 2) + .map(([ticker]) => ticker) + ); + }; + + const hasResults = Object.keys(results).length === STRATEGIES.length; + const commonTickers = hasResults ? getCommonTickers() : new Set(); + const overlapTickers = hasResults ? getOverlapTickers() : new Set(); + + if (initialLoading) { + return ( + + + + + ); + } + + return ( + +
+

전략 비교

+

+ 멀티팩터, 퀄리티, 밸류모멘텀 3개 전략 결과를 나란히 비교합니다 +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ + {hasResults && ( + <> + {/* Summary */} +
+ {STRATEGIES.map((s) => { + const r = results[s.key]; + return ( + + + {s.label} + + +

+ 기준일: {r.base_date} +

+

+ 유니버스: {r.universe_count}개 / 결과: {r.result_count}개 +

+
+
+ ); + })} +
+ + {/* Common stocks highlight */} + {commonTickers.size > 0 && ( + + + + 공통 종목 ({commonTickers.size}개) + + 3개 전략 모두에 포함된 종목 + + + + +
+ {Array.from(commonTickers).map((ticker) => { + const stock = results[STRATEGIES[0].key].stocks.find( + (s) => s.ticker === ticker + ); + return ( + + {stock?.name || ticker} + + ); + })} +
+
+
+ )} + + {/* Side-by-side tables */} +
+ {STRATEGIES.map((s) => { + const r = results[s.key]; + return ( + + + {s.label} 상위 종목 + + +
+ + + + + + + + + + + {r.stocks.map((stock) => { + const isCommon = commonTickers.has(stock.ticker); + const isOverlap = overlapTickers.has(stock.ticker); + return ( + + + + + + + ); + })} + +
#종목현재가종합
{stock.rank} + + {stock.name || stock.ticker} + + {isCommon && ( + + 공통 + + )} + + {formatCurrency(stock.close_price)} + + {formatNumber(stock.total_score)} +
+
+
+
+ ); + })} +
+ + {/* Detailed comparison table */} + + + 팩터 점수 비교 +

+ 2개 이상 전략에 포함된 종목의 팩터 점수 비교 +

+
+ +
+ + + + + + {STRATEGIES.map((s) => ( + + ))} + + + + + {STRATEGIES.map((s) => ( + + + + + ))} + + + + {Array.from(overlapTickers).map((ticker) => { + const stockData = STRATEGIES.map((s) => { + return results[s.key].stocks.find((st) => st.ticker === ticker) || null; + }); + const anyStock = stockData.find((s) => s !== null); + const isCommon = commonTickers.has(ticker); + return ( + + + + {stockData.map((stock, i) => ( + + + + + ))} + + ); + })} + {overlapTickers.size === 0 && ( + + + + )} + +
종목시가총액(억) + {s.label} +
순위점수
+ {anyStock?.name || ticker} + {isCommon && ( + + 공통 + + )} + + {formatCurrency(anyStock?.market_cap ?? null)} + + {stock ? stock.rank : '-'} + + {stock ? formatNumber(stock.total_score) : '-'} +
+ 2개 이상 전략에 공통으로 포함된 종목이 없습니다 +
+
+
+
+ + )} +
+ ); +} diff --git a/frontend/src/app/strategy/page.tsx b/frontend/src/app/strategy/page.tsx index c699d74..fb8e4b3 100644 --- a/frontend/src/app/strategy/page.tsx +++ b/frontend/src/app/strategy/page.tsx @@ -6,7 +6,9 @@ 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, Zap } from 'lucide-react'; +import { BarChart3, Star, TrendingUp, Zap, GitCompareArrows } from 'lucide-react'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; const strategies = [ { @@ -87,6 +89,12 @@ export default function StrategyListPage() {

검증된 퀀트 전략을 선택하여 백테스트를 실행하세요

+ + +