feat: add strategy comparison UI for side-by-side multi-strategy analysis
Add a compare page at /strategy/compare that runs MultiFactor, Quality, and ValueMomentum strategies simultaneously and displays results side-by-side with common ticker highlighting and factor score comparison table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9249821a25
commit
01f86298c4
387
frontend/src/app/strategy/compare/page.tsx
Normal file
387
frontend/src/app/strategy/compare/page.tsx
Normal file
@ -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<Record<string, StrategyResult>>({});
|
||||
const [error, setError] = useState<string | null>(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<StrategyResult>(`/api/strategy/${s.key}`, s.payload)
|
||||
);
|
||||
const responses = await Promise.all(promises);
|
||||
const map: Record<string, StrategyResult> = {};
|
||||
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<string> => {
|
||||
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<string>();
|
||||
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<string> => {
|
||||
const resultKeys = Object.keys(results);
|
||||
if (resultKeys.length < 2) return new Set();
|
||||
|
||||
const tickerCount: Record<string, number> = {};
|
||||
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<string>();
|
||||
const overlapTickers = hasResults ? getOverlapTickers() : new Set<string>();
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-48 rounded-xl" />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">전략 비교</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
멀티팩터, 퀄리티, 밸류모멘텀 3개 전략 결과를 나란히 비교합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<Button onClick={runAll} disabled={loading} size="lg">
|
||||
{loading ? '3개 전략 실행 중...' : '전략 비교 실행'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasResults && (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{STRATEGIES.map((s) => {
|
||||
const r = results[s.key];
|
||||
return (
|
||||
<Card key={s.key}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">{s.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
기준일: {r.base_date}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
유니버스: {r.universe_count}개 / 결과: {r.result_count}개
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Common stocks highlight */}
|
||||
{commonTickers.size > 0 && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
공통 종목 ({commonTickers.size}개)
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
3개 전략 모두에 포함된 종목
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from(commonTickers).map((ticker) => {
|
||||
const stock = results[STRATEGIES[0].key].stocks.find(
|
||||
(s) => s.ticker === ticker
|
||||
);
|
||||
return (
|
||||
<Badge key={ticker} variant="default">
|
||||
{stock?.name || ticker}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Side-by-side tables */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{STRATEGIES.map((s) => {
|
||||
const r = results[s.key];
|
||||
return (
|
||||
<Card key={s.key}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{s.label} 상위 종목</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-2 text-left font-medium text-muted-foreground">#</th>
|
||||
<th scope="col" className="px-3 py-2 text-left font-medium text-muted-foreground">종목</th>
|
||||
<th scope="col" className="px-3 py-2 text-right font-medium text-muted-foreground">현재가</th>
|
||||
<th scope="col" className="px-3 py-2 text-right font-medium text-muted-foreground">종합</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{r.stocks.map((stock) => {
|
||||
const isCommon = commonTickers.has(stock.ticker);
|
||||
const isOverlap = overlapTickers.has(stock.ticker);
|
||||
return (
|
||||
<tr
|
||||
key={stock.ticker}
|
||||
className={
|
||||
isCommon
|
||||
? 'bg-primary/10'
|
||||
: isOverlap
|
||||
? 'bg-accent/50'
|
||||
: 'hover:bg-muted/50'
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{stock.rank}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-medium" title={stock.ticker}>
|
||||
{stock.name || stock.ticker}
|
||||
</span>
|
||||
{isCommon && (
|
||||
<Badge variant="default" className="ml-1 text-[10px] px-1 py-0">
|
||||
공통
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatCurrency(stock.close_price)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-medium tabular-nums">
|
||||
{formatNumber(stock.total_score)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Detailed comparison table */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>팩터 점수 비교</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
2개 이상 전략에 포함된 종목의 팩터 점수 비교
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left font-medium text-muted-foreground">종목</th>
|
||||
<th scope="col" className="px-4 py-3 text-right font-medium text-muted-foreground">시가총액(억)</th>
|
||||
{STRATEGIES.map((s) => (
|
||||
<th key={s.key} scope="col" className="px-4 py-3 text-center font-medium text-muted-foreground" colSpan={2}>
|
||||
{s.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-t">
|
||||
<th scope="col" className="px-4 py-1"></th>
|
||||
<th scope="col" className="px-4 py-1"></th>
|
||||
{STRATEGIES.map((s) => (
|
||||
<React.Fragment key={s.key}>
|
||||
<th scope="col" className="px-2 py-1 text-right text-xs text-muted-foreground">순위</th>
|
||||
<th scope="col" className="px-2 py-1 text-right text-xs text-muted-foreground">점수</th>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{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 (
|
||||
<tr key={ticker} className={isCommon ? 'bg-primary/10' : 'hover:bg-muted/50'}>
|
||||
<td className="px-4 py-2 font-medium">
|
||||
{anyStock?.name || ticker}
|
||||
{isCommon && (
|
||||
<Badge variant="default" className="ml-1 text-[10px] px-1 py-0">
|
||||
공통
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">
|
||||
{formatCurrency(anyStock?.market_cap ?? null)}
|
||||
</td>
|
||||
{stockData.map((stock, i) => (
|
||||
<React.Fragment key={STRATEGIES[i].key}>
|
||||
<td className="px-2 py-2 text-right tabular-nums">
|
||||
{stock ? stock.rank : '-'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right tabular-nums">
|
||||
{stock ? formatNumber(stock.total_score) : '-'}
|
||||
</td>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{overlapTickers.size === 0 && (
|
||||
<tr>
|
||||
<td colSpan={2 + STRATEGIES.length * 2} className="px-4 py-8 text-center text-muted-foreground">
|
||||
2개 이상 전략에 공통으로 포함된 종목이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
검증된 퀀트 전략을 선택하여 백테스트를 실행하세요
|
||||
</p>
|
||||
<Link href="/strategy/compare" className="inline-block mt-3">
|
||||
<Button variant="outline" size="sm">
|
||||
<GitCompareArrows className="h-4 w-4 mr-2" />
|
||||
전략 비교
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user