백테스팅 화면 검색기능 추가
This commit is contained in:
@@ -55,6 +55,15 @@ export default function BacktestExecutionList({
|
|||||||
onSelectLive,
|
onSelectLive,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sort, setSort] = useState<BacktestListSort>('return');
|
const [sort, setSort] = useState<BacktestListSort>('return');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
/** 탭 전환 시 검색어 초기화 */
|
||||||
|
const handleTabChange = (next: ExecutionListTab) => {
|
||||||
|
setQuery('');
|
||||||
|
onTabChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
const sortedBacktests = useMemo(() => {
|
const sortedBacktests = useMemo(() => {
|
||||||
const list = [...backtestRecords];
|
const list = [...backtestRecords];
|
||||||
@@ -70,11 +79,72 @@ export default function BacktestExecutionList({
|
|||||||
return list;
|
return list;
|
||||||
}, [backtestRecords, sort]);
|
}, [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 (
|
return (
|
||||||
<div className="btd-exec-list">
|
<div className="btd-exec-list">
|
||||||
<div className="btd-exec-tabs" role="tablist" aria-label="실행 목록">
|
<div className="btd-exec-tabs" role="tablist" aria-label="실행 목록">
|
||||||
<button type="button" role="tab" aria-selected={tab === 'backtest'} className={`btd-exec-tab${tab === 'backtest' ? ' btd-exec-tab--on' : ''}`} onClick={() => onTabChange('backtest')}>백테스팅</button>
|
<button type="button" role="tab" aria-selected={tab === 'backtest'} className={`btd-exec-tab${tab === 'backtest' ? ' btd-exec-tab--on' : ''}`} onClick={() => handleTabChange('backtest')}>백테스팅</button>
|
||||||
<button type="button" role="tab" aria-selected={tab === 'live'} className={`btd-exec-tab${tab === 'live' ? ' btd-exec-tab--on' : ''}`} onClick={() => onTabChange('live')}>실시간 매매</button>
|
<button type="button" role="tab" aria-selected={tab === 'live'} className={`btd-exec-tab${tab === 'live' ? ' btd-exec-tab--on' : ''}`} onClick={() => handleTabChange('live')}>실시간 매매</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 입력창 */}
|
||||||
|
<div className="btd-exec-search">
|
||||||
|
<span className="btd-exec-search-icon">🔍</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="btd-exec-search-input"
|
||||||
|
placeholder={tab === 'backtest' ? '종목·전략·타임프레임 검색…' : '종목·전략 검색…'}
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
aria-label="목록 검색"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btd-exec-search-clear"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
aria-label="검색어 지우기"
|
||||||
|
>✕</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'backtest' && (
|
{tab === 'backtest' && (
|
||||||
@@ -85,15 +155,30 @@ export default function BacktestExecutionList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 필터 결과 카운트 */}
|
||||||
|
{normalizedQuery && (
|
||||||
|
<div className="btd-exec-search-count">
|
||||||
|
{filteredCount > 0
|
||||||
|
? `${filteredCount} / ${totalCount}건`
|
||||||
|
: `검색 결과 없음`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="btd-exec-scroll">
|
<div className="btd-exec-scroll">
|
||||||
{tab === 'backtest' ? (
|
{tab === 'backtest' ? (
|
||||||
sortedBacktests.length === 0 ? (
|
filteredBacktests.length === 0 ? (
|
||||||
<div className="btd-sidebar-empty">
|
<div className="btd-sidebar-empty">
|
||||||
|
{normalizedQuery ? (
|
||||||
|
<p>'{query}'에 해당하는 결과가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<p>백테스팅 이력이 없습니다.</p>
|
<p>백테스팅 이력이 없습니다.</p>
|
||||||
<p className="btd-sidebar-hint">실시간 차트에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
<p className="btd-sidebar-hint">실시간 차트에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sortedBacktests.map(r => {
|
filteredBacktests.map(r => {
|
||||||
const ret = r.totalReturn ?? 0;
|
const ret = r.totalReturn ?? 0;
|
||||||
const positive = ret >= 0;
|
const positive = ret >= 0;
|
||||||
const active = selectedBacktestId === r.id;
|
const active = selectedBacktestId === r.id;
|
||||||
@@ -118,19 +203,19 @@ export default function BacktestExecutionList({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
) : liveItems.length === 0 ? (
|
) : filteredLiveItems.length === 0 ? (
|
||||||
<div className="btd-sidebar-empty">
|
<div className="btd-sidebar-empty">
|
||||||
|
{normalizedQuery ? (
|
||||||
|
<p>'{query}'에 해당하는 결과가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<p>실시간 매매 이력이 없습니다.</p>
|
<p>실시간 매매 이력이 없습니다.</p>
|
||||||
<p className="btd-sidebar-hint">가상투자 화면에서 자동·수동<br />매매를 실행하세요.</p>
|
<p className="btd-sidebar-hint">가상투자 화면에서 자동·수동<br />매매를 실행하세요.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
[...liveItems]
|
filteredLiveItems.map(item => {
|
||||||
.sort((a, b) => {
|
|
||||||
const bySymbol = compareMarketSymbol(a.symbol, b.symbol);
|
|
||||||
if (bySymbol !== 0) return bySymbol;
|
|
||||||
return compareTimeAsc(a.createdAt, b.createdAt);
|
|
||||||
})
|
|
||||||
.map(item => {
|
|
||||||
const positive = item.totalReturnPct >= 0;
|
const positive = item.totalReturnPct >= 0;
|
||||||
const active = selectedLiveId === item.id;
|
const active = selectedLiveId === item.id;
|
||||||
const market = toUpbitMarket(item.symbol);
|
const market = toUpbitMarket(item.symbol);
|
||||||
|
|||||||
@@ -1115,6 +1115,73 @@
|
|||||||
|
|
||||||
/* ── 목록 카드 (참조 UI) ── */
|
/* ── 목록 카드 (참조 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 {
|
.btd-exec-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user