feat: 매매일지 자동 생성 + 전략 자동 최적화 시스템 추가
- trade_analyzer.py: 거래 성과 분석(승률/손익비/전략별 패턴), 템플릿 기반 일지 자동 생성
- strategy_optimizer.py: 그리드 서치 기반 파라미터 최적화, Sharpe 10% 개선 시 자동 적용
- analysis.py: /performance /insights /generate-journals /optimize-strategy /optimization-history 엔드포인트
- journals.py: POST /generate/{trade_id}, GET /stats 엔드포인트 추가
- models.py: StrategyOptimizationLog 테이블 추가
- main.py: post_market_analyzer_scheduler (16:00 KST 자동 실행)
- frontend: Analysis 페이지 추가 (성과 요약/인사이트/일지 생성/최적화 이력)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import TradeHistory from './pages/TradeHistory'
|
||||
import MCPStatus from './pages/MCPStatus'
|
||||
import StrategySettings from './pages/StrategySettings'
|
||||
import Backtest from './pages/Backtest'
|
||||
import Analysis from './pages/Analysis'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
function App() {
|
||||
@@ -15,6 +16,7 @@ function App() {
|
||||
<Route path="/mcp" element={<MCPStatus />} />
|
||||
<Route path="/strategy" element={<StrategySettings />} />
|
||||
<Route path="/backtest" element={<Backtest />} />
|
||||
<Route path="/analysis" element={<Analysis />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3 } from 'lucide-react'
|
||||
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3, TrendingUp } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
@@ -20,6 +20,7 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: LayoutDashboard, label: '대시보드' },
|
||||
{ path: '/analysis', icon: TrendingUp, label: '성과 분석' },
|
||||
{ path: '/strategy', icon: SlidersHorizontal, label: '전략 설정' },
|
||||
{ path: '/backtest', icon: BarChart3, label: '백테스트' },
|
||||
{ path: '/trades', icon: History, label: '매매 로그' },
|
||||
|
||||
489
frontend/src/pages/Analysis.tsx
Normal file
489
frontend/src/pages/Analysis.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
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="text-red-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="text-green-400">{fmtKRW(s.net_pnl)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user