diff --git a/frontend/src/components/backtest/BacktestExecutionList.tsx b/frontend/src/components/backtest/BacktestExecutionList.tsx index b72751f..4004e18 100644 --- a/frontend/src/components/backtest/BacktestExecutionList.tsx +++ b/frontend/src/components/backtest/BacktestExecutionList.tsx @@ -55,6 +55,15 @@ export default function BacktestExecutionList({ onSelectLive, }: Props) { const [sort, setSort] = useState('return'); + const [query, setQuery] = useState(''); + + /** 탭 전환 시 검색어 초기화 */ + const handleTabChange = (next: ExecutionListTab) => { + setQuery(''); + onTabChange(next); + }; + + const normalizedQuery = query.trim().toLowerCase(); const sortedBacktests = useMemo(() => { const list = [...backtestRecords]; @@ -70,11 +79,72 @@ export default function BacktestExecutionList({ return list; }, [backtestRecords, sort]); + /** 검색어 필터 적용된 백테스팅 목록 */ + const filteredBacktests = useMemo(() => { + if (!normalizedQuery) return sortedBacktests; + return sortedBacktests.filter(r => { + const market = toUpbitMarket(r.symbol); + const ko = getKoreanName(market).toLowerCase(); + const code = r.symbol.replace(/^KRW-/i, '').toLowerCase(); + const strategy = (r.strategyName ?? '').toLowerCase(); + const tf = r.timeframe?.toLowerCase() ?? ''; + return ko.includes(normalizedQuery) + || code.includes(normalizedQuery) + || strategy.includes(normalizedQuery) + || tf.includes(normalizedQuery); + }); + }, [sortedBacktests, normalizedQuery]); + + /** 검색어 필터 적용된 실시간 매매 목록 */ + const filteredLiveItems = useMemo(() => { + const sorted = [...liveItems].sort((a, b) => { + const bySymbol = compareMarketSymbol(a.symbol, b.symbol); + if (bySymbol !== 0) return bySymbol; + return compareTimeAsc(a.createdAt, b.createdAt); + }); + if (!normalizedQuery) return sorted; + return sorted.filter(item => { + const market = toUpbitMarket(item.symbol); + const ko = getKoreanName(market).toLowerCase(); + const code = item.symbol.replace(/^KRW-/i, '').toLowerCase(); + const strategy = item.strategyLabel.toLowerCase(); + const source = item.sourceLabel.toLowerCase(); + return ko.includes(normalizedQuery) + || code.includes(normalizedQuery) + || strategy.includes(normalizedQuery) + || source.includes(normalizedQuery); + }); + }, [liveItems, normalizedQuery]); + + const totalCount = tab === 'backtest' ? sortedBacktests.length : liveItems.length; + const filteredCount = tab === 'backtest' ? filteredBacktests.length : filteredLiveItems.length; + return (
- - + + +
+ + {/* 검색 입력창 */} +
+ 🔍 + setQuery(e.target.value)} + aria-label="목록 검색" + /> + {query && ( + + )}
{tab === 'backtest' && ( @@ -85,15 +155,30 @@ export default function BacktestExecutionList({
)} + {/* 필터 결과 카운트 */} + {normalizedQuery && ( +
+ {filteredCount > 0 + ? `${filteredCount} / ${totalCount}건` + : `검색 결과 없음`} +
+ )} +
{tab === 'backtest' ? ( - sortedBacktests.length === 0 ? ( + filteredBacktests.length === 0 ? (
-

백테스팅 이력이 없습니다.

-

실시간 차트에서 전략을 선택하고
백테스팅을 실행하세요.

+ {normalizedQuery ? ( +

'{query}'에 해당하는 결과가 없습니다.

+ ) : ( + <> +

백테스팅 이력이 없습니다.

+

실시간 차트에서 전략을 선택하고
백테스팅을 실행하세요.

+ + )}
) : ( - sortedBacktests.map(r => { + filteredBacktests.map(r => { const ret = r.totalReturn ?? 0; const positive = ret >= 0; const active = selectedBacktestId === r.id; @@ -118,19 +203,19 @@ export default function BacktestExecutionList({ ); }) ) - ) : liveItems.length === 0 ? ( + ) : filteredLiveItems.length === 0 ? (
-

실시간 매매 이력이 없습니다.

-

가상투자 화면에서 자동·수동
매매를 실행하세요.

+ {normalizedQuery ? ( +

'{query}'에 해당하는 결과가 없습니다.

+ ) : ( + <> +

실시간 매매 이력이 없습니다.

+

가상투자 화면에서 자동·수동
매매를 실행하세요.

+ + )}
) : ( - [...liveItems] - .sort((a, b) => { - const bySymbol = compareMarketSymbol(a.symbol, b.symbol); - if (bySymbol !== 0) return bySymbol; - return compareTimeAsc(a.createdAt, b.createdAt); - }) - .map(item => { + filteredLiveItems.map(item => { const positive = item.totalReturnPct >= 0; const active = selectedLiveId === item.id; const market = toUpbitMarket(item.symbol); diff --git a/frontend/src/styles/backtestDashboard.css b/frontend/src/styles/backtestDashboard.css index 10dff67..6581e7c 100644 --- a/frontend/src/styles/backtestDashboard.css +++ b/frontend/src/styles/backtestDashboard.css @@ -1115,6 +1115,73 @@ /* ── 목록 카드 (참조 UI) ── */ +/* ── 검색 입력창 ──────────────────────────────────────────────────── */ +.btd-exec-search { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border: 1px solid var(--btd-divider); + border-radius: 7px; + background: var(--btd-surface); + flex-shrink: 0; + transition: border-color 0.15s; +} + +.btd-exec-search:focus-within { + border-color: color-mix(in srgb, var(--btd-gold) 55%, var(--btd-divider)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--btd-gold) 20%, transparent); +} + +.btd-exec-search-icon { + font-size: 0.65rem; + opacity: 0.6; + flex-shrink: 0; +} + +.btd-exec-search-input { + flex: 1; + border: none; + background: transparent; + color: var(--se-text, var(--text)); + font-size: 0.78rem; + outline: none; + min-width: 0; + line-height: 1.5; +} + +.btd-exec-search-input::placeholder { + color: var(--se-text-muted, var(--text2)); + opacity: 0.7; +} + +.btd-exec-search-clear { + border: none; + background: transparent; + color: var(--se-text-muted, var(--text2)); + font-size: 0.65rem; + cursor: pointer; + padding: 1px 3px; + border-radius: 4px; + line-height: 1; + flex-shrink: 0; + opacity: 0.7; +} + +.btd-exec-search-clear:hover { + opacity: 1; + background: var(--btd-gold-bg); + color: var(--btd-gold); +} + +.btd-exec-search-count { + font-size: 0.62rem; + color: var(--se-text-muted, var(--text2)); + padding: 0 2px 4px; + flex-shrink: 0; + text-align: right; +} + .btd-exec-filters { display: flex; gap: 4px;