전략평가 목록 정렬
This commit is contained in:
@@ -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' ? '최신순 ↓' : '최신순 ↑';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user