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:
머니페니 2026-03-18 20:57:38 +09:00
parent 9249821a25
commit 01f86298c4
2 changed files with 396 additions and 1 deletions

View 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>
);
}

View File

@ -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">