490 lines
25 KiB
TypeScript
490 lines
25 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
TrendingUp, TrendingDown, Target, AlertTriangle, CheckCircle, Info,
|
|
Play, RefreshCw, BookOpen, Zap, ChevronDown, ChevronUp
|
|
} from 'lucide-react'
|
|
import api from '../lib/api'
|
|
|
|
// ── 타입 정의 ─────────────────────────────────────────────────────────────────
|
|
|
|
interface PerformanceSummary {
|
|
total_trades: number
|
|
win_trades: number
|
|
loss_trades: number
|
|
win_rate: number
|
|
avg_win_pct: number
|
|
avg_loss_pct: number
|
|
profit_factor: number
|
|
gross_profit: number
|
|
gross_loss: number
|
|
net_pnl: number
|
|
max_consecutive_loss: number
|
|
risk_reward_ratio: number
|
|
}
|
|
|
|
interface PerformanceData {
|
|
summary: PerformanceSummary
|
|
by_strategy: Record<string, { total: number; win_rate: number; avg_return: number }>
|
|
by_exit_type: Record<string, { count: number; avg_return: number; win_rate: number }>
|
|
worst_stocks: { stock_code: string; net_pnl: number }[]
|
|
best_stocks: { stock_code: string; net_pnl: number }[]
|
|
period_days: number
|
|
total_trades: number
|
|
generated_at: string
|
|
}
|
|
|
|
interface Insight {
|
|
level: 'info' | 'warning' | 'danger'
|
|
message: string
|
|
}
|
|
|
|
interface OptimizationLog {
|
|
id: number
|
|
run_date: string
|
|
trigger: string
|
|
best_sharpe: number | null
|
|
current_sharpe: number | null
|
|
sharpe_improvement_pct: number | null
|
|
best_win_rate: number | null
|
|
best_mdd: number | null
|
|
applied: boolean
|
|
apply_reason: string
|
|
best_params: Record<string, unknown> | null
|
|
previous_params: Record<string, unknown> | null
|
|
total_combinations: number | null
|
|
created_at: string
|
|
}
|
|
|
|
// ── 유틸 함수 ─────────────────────────────────────────────────────────────────
|
|
|
|
const fmt = (n: number | null | undefined, digits = 2) =>
|
|
n == null ? '-' : n.toFixed(digits)
|
|
|
|
const fmtKRW = (n: number | null | undefined) =>
|
|
n == null ? '-' : `${n >= 0 ? '+' : ''}${n.toLocaleString('ko-KR')}원`
|
|
|
|
// ── 서브 컴포넌트: 통계 카드 ──────────────────────────────────────────────────
|
|
|
|
const StatCard = ({
|
|
label, value, sub, color = 'blue', icon: Icon,
|
|
}: {
|
|
label: string
|
|
value: string | number
|
|
sub?: string
|
|
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
|
|
icon?: React.ElementType
|
|
}) => {
|
|
const colorMap = {
|
|
blue: 'border-blue-500/30 bg-blue-500/5',
|
|
green: 'border-green-500/30 bg-green-500/5',
|
|
red: 'border-red-500/30 bg-red-500/5',
|
|
yellow: 'border-yellow-500/30 bg-yellow-500/5',
|
|
purple: 'border-purple-500/30 bg-purple-500/5',
|
|
}
|
|
const textMap = {
|
|
blue: 'text-blue-400', green: 'text-green-400', red: 'text-red-400',
|
|
yellow: 'text-yellow-400', purple: 'text-purple-400',
|
|
}
|
|
return (
|
|
<div className={`rounded-lg border p-4 ${colorMap[color]}`}>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{Icon && <Icon size={14} className={textMap[color]} />}
|
|
<span className="text-xs text-gray-400">{label}</span>
|
|
</div>
|
|
<div className={`text-2xl font-bold ${textMap[color]}`}>{value}</div>
|
|
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 서브 컴포넌트: 인사이트 배지 ─────────────────────────────────────────────
|
|
|
|
const InsightBadge = ({ insight }: { insight: Insight }) => {
|
|
const map = {
|
|
info: { bg: 'bg-blue-500/10 border-blue-500/20', text: 'text-blue-300', Icon: Info },
|
|
warning: { bg: 'bg-yellow-500/10 border-yellow-500/20', text: 'text-yellow-300', Icon: AlertTriangle },
|
|
danger: { bg: 'bg-red-500/10 border-red-500/20', text: 'text-red-300', Icon: AlertTriangle },
|
|
}
|
|
const { bg, text, Icon } = map[insight.level]
|
|
return (
|
|
<div className={`flex items-start gap-2 p-3 rounded-lg border ${bg} text-sm`}>
|
|
<Icon size={14} className={`${text} mt-0.5 flex-shrink-0`} />
|
|
<span className={text}>{insight.message}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
|
|
|
|
export default function Analysis() {
|
|
const queryClient = useQueryClient()
|
|
const [days, setDays] = useState(30)
|
|
const [expandedOptLog, setExpandedOptLog] = useState<number | null>(null)
|
|
|
|
// 성과 데이터
|
|
const { data: perf, isLoading: perfLoading } = useQuery<PerformanceData>({
|
|
queryKey: ['analysis-performance', days],
|
|
queryFn: async () => (await api.get(`/analysis/performance?days=${days}`)).data,
|
|
refetchInterval: 60_000,
|
|
})
|
|
|
|
// 인사이트
|
|
const { data: insightsData, isLoading: insightsLoading } = useQuery<{
|
|
insights: Insight[]
|
|
last_optimization_date: string | null
|
|
}>({
|
|
queryKey: ['analysis-insights', days],
|
|
queryFn: async () => (await api.get(`/analysis/insights?days=${days}`)).data,
|
|
refetchInterval: 60_000,
|
|
})
|
|
|
|
// 최적화 이력
|
|
const { data: optHistory = [], isLoading: optHistLoading } = useQuery<OptimizationLog[]>({
|
|
queryKey: ['optimization-history'],
|
|
queryFn: async () => (await api.get('/analysis/optimization-history?limit=10')).data,
|
|
refetchInterval: 30_000,
|
|
})
|
|
|
|
// 최적화 상태
|
|
const { data: optStatus } = useQuery<{ running: boolean }>({
|
|
queryKey: ['optimization-status'],
|
|
queryFn: async () => (await api.get('/analysis/optimization-status')).data,
|
|
refetchInterval: 5_000,
|
|
})
|
|
|
|
// 일지 생성 뮤테이션
|
|
const generateJournals = useMutation({
|
|
mutationFn: () => api.post('/analysis/generate-journals?days=7'),
|
|
onSuccess: () => {
|
|
alert('일지 생성을 시작했습니다. 잠시 후 매매 로그에서 확인하세요.')
|
|
},
|
|
})
|
|
|
|
// 전략 최적화 뮤테이션
|
|
const optimizeStrategy = useMutation({
|
|
mutationFn: () =>
|
|
api.post(`/analysis/optimize-strategy?days=${days}&period_days=90`),
|
|
onSuccess: () => {
|
|
alert('전략 최적화를 시작했습니다. 수 분 후 최적화 이력에서 결과를 확인하세요.')
|
|
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['optimization-history'] }), 10_000)
|
|
},
|
|
})
|
|
|
|
const summary = perf?.summary
|
|
const insights = insightsData?.insights || []
|
|
const isOptimizing = optStatus?.running || optimizeStrategy.isPending
|
|
|
|
return (
|
|
<div className="p-6 space-y-6 max-w-6xl mx-auto">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">성과 분석</h1>
|
|
<p className="text-sm text-gray-400 mt-1">매매 일지 자동 생성 & 전략 자동 최적화</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<select
|
|
value={days}
|
|
onChange={(e) => setDays(Number(e.target.value))}
|
|
className="bg-gray-800 border border-gray-700 text-gray-300 rounded-md px-3 py-1.5 text-sm"
|
|
>
|
|
<option value={7}>최근 7일</option>
|
|
<option value={30}>최근 30일</option>
|
|
<option value={90}>최근 90일</option>
|
|
</select>
|
|
<button
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['analysis-performance', days] })}
|
|
className="p-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
<RefreshCw size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 성과 요약 카드 */}
|
|
{perfLoading ? (
|
|
<div className="text-gray-500 text-sm">성과 데이터 로딩 중...</div>
|
|
) : summary && summary.total_trades > 0 ? (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<StatCard
|
|
label="승률"
|
|
value={`${fmt(summary.win_rate)}%`}
|
|
sub={`${summary.win_trades}승 / ${summary.loss_trades}패`}
|
|
color={summary.win_rate >= 50 ? 'green' : summary.win_rate >= 40 ? 'yellow' : 'red'}
|
|
icon={Target}
|
|
/>
|
|
<StatCard
|
|
label="Profit Factor"
|
|
value={fmt(summary.profit_factor)}
|
|
sub="총수익 / 총손실"
|
|
color={summary.profit_factor >= 1.5 ? 'green' : summary.profit_factor >= 1.0 ? 'yellow' : 'red'}
|
|
icon={TrendingUp}
|
|
/>
|
|
<StatCard
|
|
label="손익비"
|
|
value={fmt(summary.risk_reward_ratio)}
|
|
sub={`평균수익 ${fmt(summary.avg_win_pct)}% / 평균손실 ${fmt(summary.avg_loss_pct)}%`}
|
|
color={summary.risk_reward_ratio >= 1.5 ? 'green' : summary.risk_reward_ratio >= 1.0 ? 'yellow' : 'red'}
|
|
icon={TrendingDown}
|
|
/>
|
|
<StatCard
|
|
label="순손익"
|
|
value={fmtKRW(summary.net_pnl)}
|
|
sub={`최대연속손실 ${summary.max_consecutive_loss}회`}
|
|
color={summary.net_pnl >= 0 ? 'green' : 'red'}
|
|
icon={summary.net_pnl >= 0 ? TrendingUp : TrendingDown}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border border-gray-700 bg-gray-800/30 p-8 text-center text-gray-400">
|
|
최근 {days}일 내 완료된 매도 거래가 없습니다.
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* 전략별 성과 */}
|
|
<div className="rounded-xl border border-gray-700 bg-gray-800/30 p-5">
|
|
<h2 className="text-base font-semibold text-white mb-4">전략별 성과</h2>
|
|
{perf && Object.keys(perf.by_strategy).length > 0 ? (
|
|
<div className="space-y-2">
|
|
{Object.entries(perf.by_strategy).map(([strat, stats]) => (
|
|
<div key={strat} className="flex items-center justify-between py-2 border-b border-gray-700/50 last:border-0">
|
|
<div>
|
|
<span className="text-sm text-gray-200 font-medium">{strat}</span>
|
|
<span className="ml-2 text-xs text-gray-500">{stats.total}건</span>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm">
|
|
<span className={stats.win_rate >= 50 ? 'text-green-400' : 'text-red-400'}>
|
|
승률 {fmt(stats.win_rate)}%
|
|
</span>
|
|
<span className={stats.avg_return >= 0 ? 'text-green-400' : 'text-red-400'}>
|
|
평균 {stats.avg_return >= 0 ? '+' : ''}{fmt(stats.avg_return)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 text-sm">데이터 없음</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 청산 유형별 */}
|
|
<div className="rounded-xl border border-gray-700 bg-gray-800/30 p-5">
|
|
<h2 className="text-base font-semibold text-white mb-4">청산 유형별 통계</h2>
|
|
{perf && Object.keys(perf.by_exit_type).length > 0 ? (
|
|
<div className="space-y-2">
|
|
{Object.entries(perf.by_exit_type).map(([etype, stats]) => (
|
|
<div key={etype} className="flex items-center justify-between py-2 border-b border-gray-700/50 last:border-0">
|
|
<div>
|
|
<span className="text-sm text-gray-200 font-medium">{etype}</span>
|
|
<span className="ml-2 text-xs text-gray-500">{stats.count}건</span>
|
|
</div>
|
|
<span className={`text-sm ${stats.avg_return >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
|
평균 {stats.avg_return >= 0 ? '+' : ''}{fmt(stats.avg_return)}%
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 text-sm">데이터 없음</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 인사이트 */}
|
|
<div className="rounded-xl border border-gray-700 bg-gray-800/30 p-5">
|
|
<h2 className="text-base font-semibold text-white mb-4 flex items-center gap-2">
|
|
<AlertTriangle size={16} className="text-yellow-400" />
|
|
전략 인사이트
|
|
</h2>
|
|
{insightsLoading ? (
|
|
<p className="text-gray-500 text-sm">분석 중...</p>
|
|
) : insights.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{insights.map((ins, i) => <InsightBadge key={i} insight={ins} />)}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 text-sm">분석할 데이터가 없습니다.</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 매매일지 */}
|
|
<div className="rounded-xl border border-gray-700 bg-gray-800/30 p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
|
<BookOpen size={16} className="text-blue-400" />
|
|
매매일지 자동 생성
|
|
</h2>
|
|
<button
|
|
onClick={() => generateJournals.mutate()}
|
|
disabled={generateJournals.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 text-white text-sm rounded-lg transition-colors"
|
|
>
|
|
{generateJournals.isPending ? (
|
|
<RefreshCw size={14} className="animate-spin" />
|
|
) : (
|
|
<Play size={14} />
|
|
)}
|
|
일지 자동 생성
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-gray-400">
|
|
최근 7일 내 일지가 없는 완료 거래에 대해 템플릿 기반 매매일지를 자동 생성합니다.
|
|
장 종료 후 16:00에 자동으로도 실행됩니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 전략 자동 최적화 */}
|
|
<div className="rounded-xl border border-gray-700 bg-gray-800/30 p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
|
<Zap size={16} className="text-purple-400" />
|
|
전략 자동 최적화
|
|
</h2>
|
|
{insightsData?.last_optimization_date && (
|
|
<p className="text-xs text-gray-500 mt-0.5">
|
|
마지막 최적화: {new Date(insightsData.last_optimization_date).toLocaleDateString('ko-KR')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => optimizeStrategy.mutate()}
|
|
disabled={isOptimizing}
|
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-700 text-white text-sm rounded-lg transition-colors"
|
|
>
|
|
{isOptimizing ? (
|
|
<>
|
|
<RefreshCw size={14} className="animate-spin" />
|
|
최적화 중...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap size={14} />
|
|
지금 최적화 실행
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-gray-400 mb-4">
|
|
RSI, 손절선, ATR 배수, 볼린저 밴드 파라미터를 그리드 서치로 최적화합니다.
|
|
Sharpe Ratio 10% 이상 개선 시 자동으로 전략 설정에 적용됩니다.
|
|
(매일 16:00 KST 자동 실행, 최근 7일 거래 5건 이상 시)
|
|
</p>
|
|
|
|
{/* 최적화 이력 */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-300 mb-3">최적화 이력</h3>
|
|
{optHistLoading ? (
|
|
<p className="text-gray-500 text-sm">로딩 중...</p>
|
|
) : optHistory.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{optHistory.map((log) => (
|
|
<div key={log.id} className="border border-gray-700 rounded-lg overflow-hidden">
|
|
<button
|
|
onClick={() => setExpandedOptLog(expandedOptLog === log.id ? null : log.id)}
|
|
className="w-full flex items-center justify-between p-3 hover:bg-gray-700/30 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{log.applied ? (
|
|
<CheckCircle size={14} className="text-green-400" />
|
|
) : (
|
|
<Info size={14} className="text-gray-500" />
|
|
)}
|
|
<span className="text-sm text-gray-200">
|
|
{log.run_date}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
{log.trigger === 'auto' ? '자동' : '수동'}
|
|
</span>
|
|
{log.applied && (
|
|
<span className="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">
|
|
자동 적용됨
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm">
|
|
{log.sharpe_improvement_pct != null && (
|
|
<span className={log.sharpe_improvement_pct >= 0 ? 'text-green-400' : 'text-red-400'}>
|
|
Sharpe {log.sharpe_improvement_pct >= 0 ? '+' : ''}{fmt(log.sharpe_improvement_pct)}%
|
|
</span>
|
|
)}
|
|
{expandedOptLog === log.id
|
|
? <ChevronUp size={14} className="text-gray-500" />
|
|
: <ChevronDown size={14} className="text-gray-500" />
|
|
}
|
|
</div>
|
|
</button>
|
|
|
|
{expandedOptLog === log.id && (
|
|
<div className="p-3 border-t border-gray-700 bg-gray-900/30 text-xs space-y-2">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<span className="text-gray-500">최적 Sharpe</span>
|
|
<p className="text-white">{fmt(log.best_sharpe, 3)}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">최적 승률</span>
|
|
<p className="text-white">{fmt(log.best_win_rate, 1)}%</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">최적 MDD</span>
|
|
<p className="text-white">{fmt(log.best_mdd, 1)}%</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">사유: </span>
|
|
<span className="text-gray-300">{log.apply_reason || '-'}</span>
|
|
</div>
|
|
{log.best_params && (
|
|
<div>
|
|
<span className="text-gray-500 block mb-1">최적 파라미터:</span>
|
|
<pre className="text-gray-300 bg-gray-800 p-2 rounded text-xs overflow-x-auto">
|
|
{JSON.stringify(log.best_params, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 text-sm">최적화 이력이 없습니다.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 최악/최고 종목 */}
|
|
{perf && (perf.worst_stocks.length > 0 || perf.best_stocks.length > 0) && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="rounded-xl border border-gray-700 bg-gray-800/30 p-5">
|
|
<h2 className="text-base font-semibold text-red-400 mb-3">최대 손실 종목 Top5</h2>
|
|
<div className="space-y-2">
|
|
{perf.worst_stocks.map((s) => (
|
|
<div key={s.stock_code} className="flex justify-between text-sm">
|
|
<span className="text-gray-200">{s.stock_code}</span>
|
|
<span className={s.net_pnl < 0 ? "text-red-400" : "text-green-400"}>{fmtKRW(s.net_pnl)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-xl border border-gray-700 bg-gray-800/30 p-5">
|
|
<h2 className="text-base font-semibold text-green-400 mb-3">최대 수익 종목 Top5</h2>
|
|
<div className="space-y-2">
|
|
{perf.best_stocks.map((s) => (
|
|
<div key={s.stock_code} className="flex justify-between text-sm">
|
|
<span className="text-gray-200">{s.stock_code}</span>
|
|
<span className={s.net_pnl > 0 ? "text-green-400" : "text-red-400"}>{fmtKRW(s.net_pnl)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|