백테스트 수정중
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
// ── 프리셋 카드 컴포넌트 ────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user