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:
2026-03-10 09:28:10 +09:00
parent f42e94fe6c
commit 2baf06a5fd
10 changed files with 1789 additions and 5 deletions

View File

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

View File

@@ -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: '매매 로그' },

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