전략평가 목록 정렬

This commit is contained in:
Macbook
2026-06-23 00:49:18 +09:00
parent c63775bba6
commit 32be015b49
3 changed files with 130 additions and 2 deletions
@@ -47,6 +47,15 @@ import {
type BacktestRunTimeframeChoice, type BacktestRunTimeframeChoice,
} from '../utils/backtestRunTimeframe'; } from '../utils/backtestRunTimeframe';
import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals'; import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals';
import {
loadStrategyListSort,
saveStrategyListSort,
sortStrategies,
strategyListSortLabel,
toggleStrategyListSort,
type StrategyListSort,
type StrategyListSortField,
} from '../utils/strategyEvaluationListSort';
import type { OHLCVBar } from '../types'; import type { OHLCVBar } from '../types';
import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal'; import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal';
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal'; import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
@@ -132,6 +141,7 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
const [marketSearchOpen, setMarketSearchOpen] = useState(false); const [marketSearchOpen, setMarketSearchOpen] = useState(false);
const [strategies, setStrategies] = useState<StrategyDto[]>([]); const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [strategySearch, setStrategySearch] = useState(''); const [strategySearch, setStrategySearch] = useState('');
const [strategyListSort, setStrategyListSort] = useState<StrategyListSort>(loadStrategyListSort);
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null); const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null);
const [selectedStrategy, setSelectedStrategy] = useState<StrategyDto | null>(null); const [selectedStrategy, setSelectedStrategy] = useState<StrategyDto | null>(null);
@@ -416,6 +426,19 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
); );
}, [strategies, strategySearch]); }, [strategies, strategySearch]);
const sortedFilteredStrategies = useMemo(
() => sortStrategies(filteredStrategies, strategyListSort),
[filteredStrategies, strategyListSort],
);
const handleStrategyListSort = useCallback((field: StrategyListSortField) => {
setStrategyListSort(prev => {
const next = toggleStrategyListSort(prev, field);
saveStrategyListSort(next);
return next;
});
}, []);
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null; const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
useEffect(() => { useEffect(() => {
@@ -953,11 +976,31 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
</button> </button>
)} )}
</div> </div>
<div className="btd-exec-filters seval-strategy-list-sort" role="group" aria-label="전략 목록 정렬">
<button
type="button"
className={`btd-exec-filter${strategyListSort.field === 'name' ? ' btd-exec-filter--on' : ''}`}
title="이름순 — 클릭할 때마다 오름차순·내림차순 전환"
aria-pressed={strategyListSort.field === 'name'}
onClick={() => handleStrategyListSort('name')}
>
{strategyListSortLabel(strategyListSort, 'name')}
</button>
<button
type="button"
className={`btd-exec-filter${strategyListSort.field === 'createdAt' ? ' btd-exec-filter--on' : ''}`}
title="추가 일시 기준 — 클릭할 때마다 최신순·오래된순 전환"
aria-pressed={strategyListSort.field === 'createdAt'}
onClick={() => handleStrategyListSort('createdAt')}
>
{strategyListSortLabel(strategyListSort, 'createdAt')}
</button>
</div>
<div className="btd-exec-scroll"> <div className="btd-exec-scroll">
{filteredStrategies.length === 0 ? ( {sortedFilteredStrategies.length === 0 ? (
<p className="btd-sidebar-empty"> </p> <p className="btd-sidebar-empty"> </p>
) : ( ) : (
filteredStrategies.map(s => { sortedFilteredStrategies.map(s => {
const name = repairUtf8Mojibake(s.name ?? `전략 #${s.id}`); const name = repairUtf8Mojibake(s.name ?? `전략 #${s.id}`);
const active = selectedStrategyId === s.id; const active = selectedStrategyId === s.id;
return ( return (
@@ -327,6 +327,10 @@
min-width: 0; min-width: 0;
} }
.seval-strategy-list-sort {
padding: 0 10px 6px;
}
.seval-strategy-add-btn { .seval-strategy-add-btn {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -0,0 +1,81 @@
import type { StrategyDto } from './backendApi';
import { repairUtf8Mojibake } from './textEncoding';
export type StrategyListSortField = 'name' | 'createdAt';
export type StrategyListSortDir = 'asc' | 'desc';
export interface StrategyListSort {
field: StrategyListSortField;
dir: StrategyListSortDir;
}
const STORAGE_KEY = 'seval-strategy-list-sort';
const DEFAULT_SORT: StrategyListSort = { field: 'createdAt', dir: 'desc' };
export function loadStrategyListSort(): StrategyListSort {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_SORT;
const parsed = JSON.parse(raw) as Partial<StrategyListSort>;
const field = parsed.field === 'name' || parsed.field === 'createdAt' ? parsed.field : DEFAULT_SORT.field;
const dir = parsed.dir === 'asc' || parsed.dir === 'desc' ? parsed.dir : DEFAULT_SORT.dir;
return { field, dir };
} catch {
return DEFAULT_SORT;
}
}
export function saveStrategyListSort(sort: StrategyListSort): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sort));
} catch {
/* ignore quota */
}
}
/** 같은 필드 재클릭 시 방향 토글, 다른 필드는 해당 필드 기본 방향 */
export function toggleStrategyListSort(
prev: StrategyListSort,
field: StrategyListSortField,
): StrategyListSort {
if (prev.field !== field) {
return field === 'name'
? { field: 'name', dir: 'asc' }
: { field: 'createdAt', dir: 'desc' };
}
return { field, dir: prev.dir === 'asc' ? 'desc' : 'asc' };
}
function strategyCreatedTimeMs(s: StrategyDto): number {
if (s.createdAt) {
const t = Date.parse(s.createdAt);
if (Number.isFinite(t)) return t;
}
return s.id ?? 0;
}
export function sortStrategies(
list: StrategyDto[],
sort: StrategyListSort,
): StrategyDto[] {
const mul = sort.dir === 'asc' ? 1 : -1;
return [...list].sort((a, b) => {
if (sort.field === 'name') {
const na = repairUtf8Mojibake(a.name ?? '');
const nb = repairUtf8Mojibake(b.name ?? '');
return mul * na.localeCompare(nb, 'ko');
}
return mul * (strategyCreatedTimeMs(a) - strategyCreatedTimeMs(b));
});
}
export function strategyListSortLabel(sort: StrategyListSort, field: StrategyListSortField): string {
if (sort.field !== field) {
return field === 'name' ? '이름순' : '최신순';
}
if (field === 'name') {
return sort.dir === 'asc' ? '이름순 ↑' : '이름순 ↓';
}
return sort.dir === 'desc' ? '최신순 ↓' : '최신순 ↑';
}