백테스트 수정중

This commit is contained in:
2026-03-07 13:16:01 +09:00
parent ea355eb60c
commit 7c9b596098
13 changed files with 908 additions and 303 deletions

View File

@@ -20,7 +20,9 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, T
// ── ML 학습 데이터 생성 탭 ───────────────────────────────────────────────
function MLTrainingTab() {
const [mode, setMode] = useState<'manual' | 'universe'>('manual')
const [stockCodes, setStockCodes] = useState('005930,000660,035720')
const [universePreset, setUniversePreset] = useState('large_cap')
const [startDate, setStartDate] = useState('2023-01-01')
const [endDate, setEndDate] = useState('2025-12-31')
const [forwardDays, setForwardDays] = useState(5)
@@ -28,17 +30,34 @@ function MLTrainingTab() {
const [downThreshold, setDownThreshold] = useState(-3.0)
const [result, setResult] = useState<any>(null)
const { data: universePresets } = useQuery({
queryKey: ['universe-presets'],
queryFn: async () => (await api.get('/backtest/universe-presets')).data,
})
const generateMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/backtest/generate-training-data', {
stock_codes: stockCodes.split(',').map(c => c.trim()),
start_date: startDate,
end_date: endDate,
forward_days: forwardDays,
up_threshold: upThreshold / 100,
down_threshold: downThreshold / 100,
})
return res.data
if (mode === 'universe') {
const res = await api.post('/backtest/generate-training-data-universe', {
universe_preset: universePreset,
start_date: startDate,
end_date: endDate,
forward_days: forwardDays,
up_threshold: upThreshold / 100,
down_threshold: downThreshold / 100,
})
return res.data
} else {
const res = await api.post('/backtest/generate-training-data', {
stock_codes: stockCodes.split(',').map(c => c.trim()),
start_date: startDate,
end_date: endDate,
forward_days: forwardDays,
up_threshold: upThreshold / 100,
down_threshold: downThreshold / 100,
})
return res.data
}
},
onSuccess: (data) => {
setResult(data)
@@ -71,16 +90,61 @@ function MLTrainingTab() {
<Database size={20} className="text-blue-400" />
ML 학습 데이터 생성 설정
</h3>
{/* 종목 선택 모드 토글 */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setMode('manual')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
mode === 'manual'
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-400 hover:text-white border border-slate-700'
}`}
>
직접 입력
</button>
<button
onClick={() => setMode('universe')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 ${
mode === 'universe'
? 'bg-orange-600 text-white'
: 'bg-slate-800 text-slate-400 hover:text-white border border-slate-700'
}`}
>
<Globe size={12} />
전체 종목 (유니버스)
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-slate-400 mb-1">종목 코드 (쉼표 구분)</label>
<input
type="text"
value={stockCodes}
onChange={(e) => setStockCodes(e.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
placeholder="005930,000660,035720"
/>
{mode === 'manual' ? (
<>
<label className="block text-xs text-slate-400 mb-1">종목 코드 (쉼표 구분)</label>
<input
type="text"
value={stockCodes}
onChange={(e) => setStockCodes(e.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
placeholder="005930,000660,035720"
/>
</>
) : (
<>
<label className="block text-xs text-slate-400 mb-1">종목 유니버스</label>
<select
value={universePreset}
onChange={(e) => setUniversePreset(e.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
>
{universePresets && Object.entries(universePresets).map(([key, preset]: any) => (
<option key={key} value={key}>
{preset.description || key}
</option>
))}
</select>
</>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
@@ -140,7 +204,11 @@ function MLTrainingTab() {
<button
onClick={() => generateMutation.mutate()}
disabled={generateMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium disabled:opacity-50"
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg font-medium disabled:opacity-50 ${
mode === 'universe'
? 'bg-orange-600 hover:bg-orange-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
<Play size={16} />
{generateMutation.isPending ? '생성 중...' : '데이터 생성'}
@@ -176,6 +244,12 @@ function MLTrainingTab() {
<p className="text-xs text-slate-400">총 샘플 수</p>
<p className="text-2xl font-bold text-white mt-1">{result.total_samples?.toLocaleString()}</p>
</div>
{result.total_stocks_requested && (
<div className="bg-slate-800 rounded-lg p-4">
<p className="text-xs text-slate-400">대상 종목 수</p>
<p className="text-2xl font-bold text-orange-400 mt-1">{result.total_stocks_requested}개</p>
</div>
)}
{Object.entries(result.label_distribution || {}).map(([label, count]: any) => (
<div key={label} className="bg-slate-800 rounded-lg p-4">
<p className="text-xs text-slate-400">{label}</p>
@@ -198,49 +272,68 @@ function MLTrainingTab() {
// ── 전략 성과 테스트 탭 ──────────────────────────────────────────────────────
const PRESET_CHART_CONFIG = [
{ key: 'conservative', label: '보수적', color: 'rgb(34, 197, 94)', bg: 'rgba(34, 197, 94, 0.1)' },
{ key: 'balanced', label: '균형형', color: 'rgb(59, 130, 246)', bg: 'rgba(59, 130, 246, 0.1)' },
{ key: 'aggressive', label: '공격적', color: 'rgb(239, 68, 68)', bg: 'rgba(239, 68, 68, 0.1)' },
{ key: 'current', label: '현재 설정', color: 'rgb(168, 85, 247)', bg: 'rgba(168, 85, 247, 0.1)' },
]
function StrategyTestTab() {
const [mode, setMode] = useState<'manual' | 'universe'>('manual')
const [stockCodes, setStockCodes] = useState('005930,000660,035720')
const [universePreset, setUniversePreset] = useState('large_cap')
const [startDate, setStartDate] = useState('2024-01-01')
const [endDate, setEndDate] = useState('2024-12-31')
const [includeCurrentSettings, setIncludeCurrentSettings] = useState(false)
const [compareResults, setCompareResults] = useState<any>(null)
const { data: universePresets } = useQuery({
queryKey: ['universe-presets'],
queryFn: async () => (await api.get('/backtest/universe-presets')).data,
})
const compareMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/backtest/compare-presets', {
stock_codes: stockCodes.split(',').map(c => c.trim()),
start_date: startDate,
end_date: endDate,
presets: ['conservative', 'balanced', 'aggressive'],
})
return res.data
const presets = ['conservative', 'balanced', 'aggressive', ...(includeCurrentSettings ? ['current'] : [])]
if (mode === 'universe') {
const res = await api.post('/backtest/compare-presets-universe', {
universe_preset: universePreset,
start_date: startDate,
end_date: endDate,
presets,
})
return res.data
} else {
const res = await api.post('/backtest/compare-presets', {
stock_codes: stockCodes.split(',').map(c => c.trim()),
start_date: startDate,
end_date: endDate,
presets,
})
return res.data
}
},
onSuccess: (data) => {
setCompareResults(data)
},
})
const allCurves = compareResults
? PRESET_CHART_CONFIG.map(cfg => compareResults.presets?.[cfg.key]?.equity_curve).filter(Boolean)
: []
const longestCurve = allCurves.reduce((a, b) => ((a?.length ?? 0) >= (b?.length ?? 0) ? a : b), [])
const chartData = compareResults ? {
labels: compareResults.presets?.conservative?.equity_curve?.map((p: any) => p.date) || [],
datasets: [
{
label: '보수적',
data: compareResults.presets?.conservative?.equity_curve?.map((p: any) => p.return_pct * 100) || [],
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
},
{
label: '균형형',
data: compareResults.presets?.balanced?.equity_curve?.map((p: any) => p.return_pct * 100) || [],
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
{
label: '공격적',
data: compareResults.presets?.aggressive?.equity_curve?.map((p: any) => p.return_pct * 100) || [],
borderColor: 'rgb(239, 68, 68)',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
},
],
labels: longestCurve?.map((p: any) => p.date) || [],
datasets: PRESET_CHART_CONFIG
.filter(cfg => compareResults.presets?.[cfg.key])
.map(cfg => ({
label: cfg.label,
data: compareResults.presets[cfg.key].equity_curve?.map((p: any) => p.return_pct * 100) || [],
borderColor: cfg.color,
backgroundColor: cfg.bg,
})),
} : null
return (
@@ -251,15 +344,61 @@ function StrategyTestTab() {
<BarChart3 size={20} className="text-purple-400" />
프리셋 비교 백테스트
</h3>
{/* 종목 선택 모드 토글 */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setMode('manual')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
mode === 'manual'
? 'bg-purple-600 text-white'
: 'bg-slate-800 text-slate-400 hover:text-white border border-slate-700'
}`}
>
직접 입력
</button>
<button
onClick={() => setMode('universe')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 ${
mode === 'universe'
? 'bg-orange-600 text-white'
: 'bg-slate-800 text-slate-400 hover:text-white border border-slate-700'
}`}
>
<Globe size={12} />
전체 종목 (유니버스)
</button>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-slate-400 mb-1">종목 코드</label>
<input
type="text"
value={stockCodes}
onChange={(e) => setStockCodes(e.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
/>
{mode === 'manual' ? (
<>
<label className="block text-xs text-slate-400 mb-1">종목 코드</label>
<input
type="text"
value={stockCodes}
onChange={(e) => setStockCodes(e.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
placeholder="005930,000660,035720"
/>
</>
) : (
<>
<label className="block text-xs text-slate-400 mb-1">종목 유니버스</label>
<select
value={universePreset}
onChange={(e) => setUniversePreset(e.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
>
{universePresets && Object.entries(universePresets).map(([key, preset]: any) => (
<option key={key} value={key}>
{preset.description || key}
</option>
))}
</select>
</>
)}
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">시작일</label>
@@ -280,19 +419,39 @@ function StrategyTestTab() {
/>
</div>
</div>
<button
onClick={() => compareMutation.mutate()}
disabled={compareMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium mt-4 disabled:opacity-50"
>
<Play size={16} />
{compareMutation.isPending ? '실행 중...' : '백테스트 실행'}
</button>
<div className="flex items-center gap-4 mt-4">
<button
onClick={() => compareMutation.mutate()}
disabled={compareMutation.isPending}
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg font-medium disabled:opacity-50 ${
mode === 'universe'
? 'bg-orange-600 hover:bg-orange-700'
: 'bg-purple-600 hover:bg-purple-700'
}`}
>
<Play size={16} />
{compareMutation.isPending ? '실행 중...' : '백테스트 실행'}
</button>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={includeCurrentSettings}
onChange={(e) => setIncludeCurrentSettings(e.target.checked)}
className="w-4 h-4 rounded accent-purple-500"
/>
<span className="text-sm text-slate-300">현재 설정도 비교에 포함</span>
</label>
</div>
</div>
{/* 결과 비교 */}
{compareResults && (
<>
{compareResults.universe_preset && (
<div className="px-4 py-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-xs text-orange-400">
유니버스: {compareResults.universe_preset} — {compareResults.stock_count}종목 대상
</div>
)}
<div className="grid grid-cols-3 gap-4">
{Object.entries(compareResults.presets || {}).map(([key, result]: any) => (
<div key={key} className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
@@ -330,7 +489,7 @@ function StrategyTestTab() {
{/* 차트 */}
{chartData && (
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
<h3 className="text-lg font-bold text-white mb-4">수익 곱선 비교</h3>
<h3 className="text-lg font-bold text-white mb-4">수익 곡선 비교</h3>
<Line
data={chartData}
options={{
@@ -419,6 +578,7 @@ function UniverseBacktestTab() {
<option value="conservative">보수적</option>
<option value="balanced">균형형</option>
<option value="aggressive">공격적</option>
<option value="current">현재 설정 (DB)</option>
</select>
</div>
</div>
@@ -468,6 +628,16 @@ function UniverseBacktestTab() {
<>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
<h3 className="text-lg font-bold text-white mb-4">테스트 개요</h3>
{result.total_tested === 0 && (
<div className="mb-4 px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-400 text-sm">
선택한 유니버스의 종목들이 DB에 가격 데이터가 없습니다. 해당 기간 동안 시스템이 모니터링한 종목만 백테스트가 가능합니다.
</div>
)}
{result.no_data_count > 0 && result.total_tested > 0 && (
<div className="mb-4 px-4 py-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-400 text-xs">
DB 데이터 없음: {result.no_data_count}종목 제외됨 (실제 테스트: {result.total_tested}종목)
</div>
)}
<div className="grid grid-cols-4 gap-4">
<div className="bg-slate-800 rounded-lg p-4">
<p className="text-xs text-slate-400">테스트 종목 수</p>

View File

@@ -52,11 +52,21 @@ const GROUP_ICONS: Record<string, string> = {
// 핵심 설정 파라미터 (기본 확장)
const CORE_PARAMS = [
'MEAN_REVERSION_STOP_LOSS_PCT',
'MEAN_REVERSION_TAKE_PROFIT_PCT',
'MAX_TOTAL_POSITIONS',
// 리스크
'STOP_LOSS_PERCENT',
'TAKE_PROFIT_PERCENT',
'MAX_HOLDINGS_COUNT',
'MAX_POSITION_SIZE',
'MAX_DAILY_TRADES',
'MDD_PAUSE_THRESHOLD',
'MDD_FORCE_THRESHOLD',
// 전략
'MEAN_REVERSION_RSI_THRESHOLD',
'RSI_OVERSOLD_LEVEL',
'ATR_STOP_MULTIPLIER',
// 기능 토글
'DAY_TRADE_ENABLED',
'ML_ENSEMBLE_ENABLED',
]
// ── 프리셋 카드 컴포넌트 ────────────────────────────────────────

View File

@@ -1,15 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { format } from 'date-fns'
import { format, differenceInMinutes } from 'date-fns'
import { ko } from 'date-fns/locale'
import { TrendingUp, TrendingDown, Clock, CheckCircle, XCircle } from 'lucide-react'
import { AlertCircle, List, BarChart2 } from 'lucide-react'
import HtsPage from '../components/HtsPage'
import api from '../lib/api'
import { useState, Fragment } from 'react'
interface Trade {
id: string
created_at: string
executed_at?: string
status: 'EXECUTED' | 'FAILED' | 'PENDING' | string
status: string
stock_code: string
stock_name?: string
order_type: 'BUY' | 'SELL'
@@ -26,176 +27,433 @@ interface Trade {
profit_loss_rate?: number | null
}
const TradeHistory = () => {
const {
data: trades = [],
isLoading,
isError,
} = useQuery<Trade[]>({
queryKey: ['trade-history'],
queryFn: async () => {
const response = await api.get('/trades', {
params: { limit: 100, days: 30 },
})
return response.data
},
refetchInterval: 10000,
})
// exit_reason 텍스트에서 손익 파싱 (예: "손익: 24,000원", "손익: -5,000원")
function parseProfitFromReason(exitReason: string | null | undefined): { amount: number | null; rate: number | null } {
if (!exitReason) return { amount: null, rate: null }
const amountMatch = exitReason.match(/손익:\s*([-\d,]+)원/)
const rateMatch = exitReason.match(/\(([-+]?\d+\.?\d*)%/)
return {
amount: amountMatch ? parseInt(amountMatch[1].replace(/,/g, ''), 10) : null,
rate: rateMatch ? parseFloat(rateMatch[1]) : null,
}
}
const getStatusIcon = (status: Trade['status']) => {
switch (status) {
case 'EXECUTED':
return <CheckCircle className="text-success" size={20} />
case 'FAILED':
return <XCircle className="text-danger" size={20} />
case 'PENDING':
return <Clock className="text-warning" size={20} />
default:
return null
}
const fmtKRW = (v: number) =>
new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW', maximumFractionDigits: 0 }).format(v)
const fmtPrice = (v: number | null | string) => {
const n = v == null ? null : Number(v)
return n == null ? '-' : fmtKRW(n)
}
// ── 종목별 뷰: 매수→매도 페어 매칭 ──────────────────────────────
interface TradePair {
stockCode: string
stockName: string
buy: Trade
sell: Trade | null // null = 미청산
holdMinutes: number | null
pl: { amount: number | null; rate: number | null }
}
function buildPairs(trades: Trade[]): TradePair[] {
// 종목별로 분리, 시간순 정렬
const byStock: Record<string, Trade[]> = {}
for (const t of [...trades].reverse()) { // 오래된 순
if (!byStock[t.stock_code]) byStock[t.stock_code] = []
byStock[t.stock_code].push(t)
}
const formatCurrency = (value: number | null) => {
if (value == null) {
return '시장가'
}
const pairs: TradePair[] = []
for (const [code, list] of Object.entries(byStock)) {
const buys = list.filter(t => t.order_type === 'BUY')
const sells = list.filter(t => t.order_type === 'SELL')
const name = list[0].stock_name ?? code
const usedSells = new Set<string>()
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
}).format(value)
for (const buy of buys) {
// 매수 이후 가장 가까운 매도 찾기
const sell = sells.find(s =>
!usedSells.has(s.id) &&
new Date(s.created_at) >= new Date(buy.created_at)
) ?? null
if (sell) usedSells.add(sell.id)
const holdMinutes = sell
? differenceInMinutes(new Date(sell.created_at), new Date(buy.created_at))
: null
const pl = sell
? (sell.profit_loss != null
? { amount: Number(sell.profit_loss), rate: sell.profit_loss_rate != null ? Number(sell.profit_loss_rate) : null }
: parseProfitFromReason(sell.exit_reason))
: { amount: null, rate: null }
pairs.push({ stockCode: code, stockName: name, buy, sell, holdMinutes, pl })
}
}
// 최신 매수 기준 내림차순
return pairs.sort((a, b) =>
new Date(b.buy.created_at).getTime() - new Date(a.buy.created_at).getTime()
)
}
function fmtHold(minutes: number | null) {
if (minutes == null) return '보유중'
if (minutes < 60) return `${minutes}분`
if (minutes < 1440) return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분`
return `${Math.floor(minutes / 1440)}일`
}
// ── 종목별 페어 테이블 ────────────────────────────────────────────
function PairView({ pairs }: { pairs: TradePair[] }) {
if (!pairs.length) return <div className="text-center py-16 text-slate-500 text-sm">거래 이력이 없습니다.</div>
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-800 bg-slate-900/80">
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">종목</th>
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">매수</th>
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs">매수가</th>
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">매도</th>
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs">매도가</th>
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs">수량</th>
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs">보유기간</th>
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs w-28">손익</th>
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">매도 이유</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{pairs.map((p) => {
const isOpen = p.sell === null
const plColor = p.pl.amount == null ? '' : p.pl.amount >= 0 ? 'text-emerald-400' : 'text-red-400'
return (
<tr key={p.buy.id} className="hover:bg-slate-800/40 transition-colors border-l-2 border-l-blue-500/40">
<td className="py-2.5 px-3">
<div className="font-semibold text-white">
{p.stockName ? `${p.stockName}(${p.stockCode})` : p.stockCode}
</div>
</td>
<td className="py-2.5 px-3 text-xs text-blue-300 whitespace-nowrap">
{format(new Date(p.buy.created_at), 'MM/dd HH:mm', { locale: ko })}
</td>
<td className="py-2.5 px-3 text-right text-white tabular-nums">
{fmtPrice(p.buy.executed_price ?? p.buy.order_price)}
</td>
<td className="py-2.5 px-3 text-xs whitespace-nowrap">
{isOpen
? <span className="text-yellow-400 font-medium">미청산</span>
: <span className="text-red-300">{format(new Date(p.sell!.created_at), 'MM/dd HH:mm', { locale: ko })}</span>
}
</td>
<td className="py-2.5 px-3 text-right tabular-nums text-slate-300">
{isOpen ? '-' : fmtPrice(p.sell!.executed_price ?? p.sell!.order_price)}
</td>
<td className="py-2.5 px-3 text-right text-slate-300 tabular-nums">
{(p.buy.executed_quantity ?? p.buy.order_quantity)?.toLocaleString()}주
</td>
<td className="py-2.5 px-3 text-right text-slate-400 text-xs tabular-nums">
{fmtHold(p.holdMinutes)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums">
{!isOpen && p.pl.amount != null ? (
<div>
<div className={`font-bold ${plColor}`}>
{p.pl.amount >= 0 ? '+' : ''}{fmtKRW(p.pl.amount)}
</div>
{p.pl.rate != null && (
<div className={`text-[11px] ${p.pl.rate >= 0 ? 'text-emerald-500' : 'text-red-500'}`}>
({p.pl.rate >= 0 ? '+' : ''}{p.pl.rate.toFixed(2)}%)
</div>
)}
</div>
) : (
<span className="text-slate-600">{isOpen ? '보유중' : '-'}</span>
)}
</td>
<td className="py-2.5 px-3 max-w-xs">
<span className="text-[11px] text-slate-400 line-clamp-2">
{isOpen
? (p.buy.entry_reason || p.buy.message || '-')
: (p.sell!.exit_reason || p.sell!.message || '-')
}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)
}
// ── 일별 총합 뷰 ─────────────────────────────────────────────────
interface DailySummary {
date: string
buyCount: number
sellCount: number
totalPnl: number
wins: number
losses: number
stocks: string[]
}
function buildDailySummary(trades: Trade[]): DailySummary[] {
const byDate: Record<string, DailySummary> = {}
for (const t of trades) {
const date = t.created_at.slice(0, 10)
if (!byDate[date]) byDate[date] = { date, buyCount: 0, sellCount: 0, totalPnl: 0, wins: 0, losses: 0, stocks: [] }
const s = byDate[date]
if (t.order_type === 'BUY') {
s.buyCount++
} else {
s.sellCount++
const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount
if (pl != null) { s.totalPnl += pl; if (pl > 0) s.wins++; else if (pl < 0) s.losses++ }
}
if (!s.stocks.includes(t.stock_code)) s.stocks.push(t.stock_code)
}
return Object.values(byDate).sort((a, b) => b.date.localeCompare(a.date))
}
function DailySummaryView({ trades }: { trades: Trade[] }) {
const [expandedDate, setExpandedDate] = useState<string | null>(null)
const summaries = buildDailySummary(trades)
if (!summaries.length) return <div className="text-center py-16 text-slate-500 text-sm">거래 이력이 없습니다.</div>
const getStockBreakdown = (date: string) => {
const byStock: Record<string, { name: string; buys: number; sells: number; pnl: number; hasPnl: boolean }> = {}
for (const t of trades.filter(t => t.created_at.slice(0, 10) === date)) {
const code = t.stock_code
if (!byStock[code]) byStock[code] = { name: t.stock_name ?? code, buys: 0, sells: 0, pnl: 0, hasPnl: false }
if (t.order_type === 'BUY') {
byStock[code].buys++
} else {
byStock[code].sells++
const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount
if (pl != null) { byStock[code].pnl += pl; byStock[code].hasPnl = true }
}
}
return Object.entries(byStock).map(([code, v]) => ({ code, ...v }))
}
return (
<HtsPage contentClassName="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold text-white">매매 로그</h2>
{isLoading && <span className="text-sm text-slate-400">데이터 갱신 중...</span>}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-800 bg-slate-900/80">
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">날짜</th>
<th className="text-left py-2.5 px-3 text-slate-500 font-medium text-xs">거래 종목 (클릭하여 상세보기)</th>
<th className="text-center py-2.5 px-3 text-slate-500 font-medium text-xs">매수</th>
<th className="text-center py-2.5 px-3 text-slate-500 font-medium text-xs">매도</th>
<th className="text-center py-2.5 px-3 text-slate-500 font-medium text-xs">승 / 패</th>
<th className="text-center py-2.5 px-3 text-slate-500 font-medium text-xs">승률</th>
<th className="text-right py-2.5 px-3 text-slate-500 font-medium text-xs w-36">실현 손익</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{summaries.map(s => {
const total = s.wins + s.losses
const wr = total > 0 ? ((s.wins / total) * 100).toFixed(0) + '%' : '-'
const pnlColor = s.totalPnl > 0 ? 'text-emerald-400' : s.totalPnl < 0 ? 'text-red-400' : 'text-slate-400'
const hasPnl = s.wins > 0 || s.losses > 0
const isExpanded = expandedDate === s.date
return (
<Fragment key={s.date}>
<tr className={`transition-colors ${isExpanded ? 'bg-slate-800/30' : 'hover:bg-slate-800/40'}`}>
<td className="py-3 px-3 text-slate-300 whitespace-nowrap text-xs font-medium">
{format(new Date(s.date + 'T00:00:00'), 'yyyy/MM/dd (EEE)', { locale: ko })}
</td>
<td
className="py-3 px-3 text-xs cursor-pointer select-none group"
onClick={() => setExpandedDate(isExpanded ? null : s.date)}
>
<span className="text-white font-medium group-hover:text-blue-400 transition-colors">
{s.stocks.length}종목 <span className="text-slate-500 text-[10px]">{isExpanded ? '▲' : '▼'}</span>
</span>
<span className="ml-2 text-slate-500">
{s.stocks.slice(0, 5).join(', ')}{s.stocks.length > 5 ? ` 외 ${s.stocks.length - 5}` : ''}
</span>
</td>
<td className="py-3 px-3 text-center text-blue-400 tabular-nums">{s.buyCount}건</td>
<td className="py-3 px-3 text-center text-red-400 tabular-nums">{s.sellCount}건</td>
<td className="py-3 px-3 text-center tabular-nums text-xs">
<span className="text-emerald-400">{s.wins}승</span>
<span className="text-slate-600 mx-1">/</span>
<span className="text-red-400">{s.losses}패</span>
</td>
<td className="py-3 px-3 text-center text-yellow-400 tabular-nums text-xs">{wr}</td>
<td className={`py-3 px-3 text-right font-bold tabular-nums ${pnlColor}`}>
{!hasPnl ? '-' : (s.totalPnl >= 0 ? '+' : '') + fmtKRW(s.totalPnl)}
</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={7} className="bg-slate-950/70 px-6 py-4 border-b border-slate-800">
<div className="grid grid-cols-4 gap-3">
{getStockBreakdown(s.date).map(st => {
const pnlC = st.pnl > 0 ? 'text-emerald-400' : st.pnl < 0 ? 'text-red-400' : 'text-slate-500'
return (
<div key={st.code} className="bg-slate-800/60 border border-slate-700/50 rounded-lg px-3 py-2.5">
<div className="font-semibold text-white text-xs mb-2 truncate">
{st.name !== st.code ? `${st.name}(${st.code})` : st.code}
</div>
<div className="flex gap-2 text-[11px] mb-1.5">
<span className="text-blue-400">매수 {st.buys}건</span>
<span className="text-slate-600">·</span>
<span className="text-red-400">매도 {st.sells}건</span>
</div>
<div className={`font-bold text-xs ${pnlC}`}>
{!st.hasPnl ? <span className="text-slate-600">-</span> : (st.pnl >= 0 ? '+' : '') + fmtKRW(st.pnl)}
</div>
</div>
)
})}
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
)
}
const TradeHistory = () => {
const [viewMode, setViewMode] = useState<'log' | 'pair'>('pair')
const today = format(new Date(), 'yyyy-MM-dd')
const [dateFrom, setDateFrom] = useState<string>(today)
const [dateTo, setDateTo] = useState<string>(today)
const { data: trades = [], isLoading, isError } = useQuery<Trade[]>({
queryKey: ['trade-history'],
queryFn: async () => (await api.get('/trades', { params: { limit: 200, days: 30 } })).data,
refetchInterval: 10000,
})
// 날짜 범위 필터
const filteredTrades = trades.filter(t => {
const d = t.created_at.slice(0, 10)
if (dateFrom && d < dateFrom) return false
if (dateTo && d > dateTo) return false
return true
})
// 요약 통계
const sells = filteredTrades.filter(t => t.order_type === 'SELL')
const buys = filteredTrades.filter(t => t.order_type === 'BUY')
const totalPnl = sells.reduce((acc, t) => {
const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount
return acc + (pl ?? 0)
}, 0)
const wins = sells.filter(t => {
const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount
return pl != null && pl > 0
}).length
const winRate = sells.length > 0 ? ((wins / sells.length) * 100).toFixed(1) : '-'
const pairs = buildPairs(filteredTrades)
return (
<HtsPage contentClassName="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-white">매매 로그</h2>
{isLoading && <span className="text-xs text-slate-500">갱신 중...</span>}
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* 뷰 토글 */}
<div className="flex items-center bg-slate-800 rounded-lg p-0.5 gap-0.5">
<button
onClick={() => setViewMode('pair')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors ${
viewMode === 'pair' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
<BarChart2 size={13} /> 종목별
</button>
<button
onClick={() => setViewMode('log')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors ${
viewMode === 'log' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
<List size={13} /> 전체 로그
</button>
</div>
</div>
</div>
{isError && (
<div className="card border border-danger/40 bg-danger/10 text-danger">
최근 거래 내역을 불러오는 중 문제가 발생했습니다.
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-red-400 text-sm">
<AlertCircle size={15} /> 거래 내역을 불러오는 중 오류가 발생했습니다.
</div>
)}
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-700">
<th className="text-left py-3 px-4 text-slate-400">시간</th>
<th className="text-left py-3 px-4 text-slate-400">상태</th>
<th className="text-left py-3 px-4 text-slate-400">종목</th>
<th className="text-center py-3 px-4 text-slate-400">구분</th>
<th className="text-right py-3 px-4 text-slate-400">가격</th>
<th className="text-right py-3 px-4 text-slate-400">수량</th>
<th className="text-right py-3 px-4 text-slate-400">총액</th>
<th className="text-right py-3 px-4 text-slate-400">손익</th>
<th className="text-left py-3 px-4 text-slate-400">이유/신호</th>
</tr>
</thead>
<tbody>
{trades.map((trade) => (
<tr key={trade.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="py-3 px-4 text-slate-300 text-sm whitespace-nowrap">
{format(new Date(trade.created_at), 'MM/dd HH:mm:ss', { locale: ko })}
</td>
<td className="py-3 px-4">
<div className="flex items-center space-x-2">
{getStatusIcon(trade.status)}
<span className="text-sm text-slate-300">{trade.status}</span>
</div>
</td>
<td className="py-3 px-4">
<div>
<div className="text-white font-medium">{trade.stock_code}</div>
{trade.stock_name && (
<div className="text-xs text-slate-400">{trade.stock_name}</div>
)}
</div>
</td>
<td className="py-3 px-4 text-center">
<span
className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${trade.order_type === 'BUY'
? 'bg-primary/20 text-primary'
: 'bg-danger/20 text-danger'
}`}
>
{trade.order_type === 'BUY' ? (
<>
<TrendingUp size={12} className="mr-1" />
매수
</>
) : (
<>
<TrendingDown size={12} className="mr-1" />
매도
</>
)}
</span>
</td>
<td className="py-3 px-4 text-right text-white">
{formatCurrency(trade.executed_price || trade.order_price)}
</td>
<td className="py-3 px-4 text-right text-white">
{(trade.executed_quantity || trade.order_quantity).toLocaleString()}주
</td>
<td className="py-3 px-4 text-right text-white font-medium">
{trade.total_amount != null ? formatCurrency(trade.total_amount) : '-'}
</td>
<td className="py-3 px-4 text-right">
{trade.order_type === 'SELL' && trade.profit_loss != null ? (
<div className="space-y-0.5">
<div className={`font-medium ${Number(trade.profit_loss) >= 0 ? 'text-primary' : 'text-danger'
}`}>
{Number(trade.profit_loss) >= 0 ? '+' : ''}{formatCurrency(Number(trade.profit_loss))}
</div>
{trade.profit_loss_rate != null && (
<div className={`text-xs ${Number(trade.profit_loss_rate) >= 0 ? 'text-primary' : 'text-danger'
}`}>
({Number(trade.profit_loss_rate) >= 0 ? '+' : ''}{Number(trade.profit_loss_rate).toFixed(2)}%)
</div>
)}
</div>
) : (
<span className="text-slate-500">-</span>
)}
</td>
<td className="py-3 px-4 text-slate-400 text-sm max-w-md">
{trade.order_type === 'BUY' && trade.entry_reason && (
<div className="space-y-1">
<div className="text-primary text-xs font-medium">📈 진입 이유</div>
<div>{trade.entry_reason}</div>
{trade.signals && trade.signals.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{trade.signals.slice(0, 3).map((signal, idx) => (
<span key={idx} className="text-xs bg-slate-700 px-2 py-0.5 rounded">
{signal}
</span>
))}
</div>
)}
</div>
)}
{trade.order_type === 'SELL' && trade.exit_reason && (
<div className="space-y-1">
<div className="text-danger text-xs font-medium">📉 매도 이유</div>
<div>{trade.exit_reason}</div>
</div>
)}
{!trade.entry_reason && !trade.exit_reason && (trade.message || '-')}
</td>
</tr>
))}
</tbody>
</table>
{/* 요약 카드 */}
<div className="grid grid-cols-5 gap-3">
{[
{ label: '총 거래', value: filteredTrades.length + '건', cls: 'text-white' },
{ label: '매수', value: buys.length + '건', cls: 'text-blue-400' },
{ label: '매도', value: sells.length + '건', cls: 'text-red-400' },
{
label: '실현 손익',
value: totalPnl === 0 ? '-' : (totalPnl > 0 ? '+' : '') + fmtKRW(totalPnl),
cls: totalPnl > 0 ? 'text-emerald-400' : totalPnl < 0 ? 'text-red-400' : 'text-slate-400',
},
{ label: '승률', value: winRate === '-' ? '-' : winRate + '%', cls: 'text-yellow-400' },
].map(({ label, value, cls }) => (
<div key={label} className="bg-slate-900/60 border border-slate-800 rounded-xl px-4 py-3">
<div className="text-xs text-slate-500 mb-1">{label}</div>
<div className={`text-lg font-bold ${cls}`}>{value}</div>
</div>
))}
</div>
{!trades.length && !isLoading && (
<div className="text-center py-12">
<p className="text-slate-400">거래 이력이 없습니다.</p>
</div>
{/* 검색 필터 */}
<div className="bg-slate-900/60 border border-slate-800 rounded-xl px-4 py-3 flex items-center gap-3">
<span className="text-xs text-slate-500 whitespace-nowrap">기간 조회</span>
<div className="flex items-center gap-1.5">
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="bg-slate-800 text-white text-xs rounded px-2 py-1.5 border border-slate-700 outline-none focus:border-slate-500"
/>
<span className="text-slate-500 text-xs">~</span>
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="bg-slate-800 text-white text-xs rounded px-2 py-1.5 border border-slate-700 outline-none focus:border-slate-500"
/>
{(dateFrom || dateTo) && (
<button
onClick={() => { setDateFrom(''); setDateTo('') }}
className="text-xs text-slate-400 hover:text-white transition-colors px-2 py-1 rounded bg-slate-800 border border-slate-700"
>초기화</button>
)}
</div>
{(dateFrom || dateTo) && (
<span className="text-xs text-slate-500">
{filteredTrades.length}건 검색됨
</span>
)}
</div>
{/* 테이블 */}
<div className="bg-slate-900/60 border border-slate-800 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
{viewMode === 'pair' ? (
<PairView pairs={pairs} />
) : (
<DailySummaryView trades={filteredTrades} />
)}
</div>
</div>