전략 평가 이전종목, 다음종목 이동 기능

This commit is contained in:
Macbook
2026-06-23 00:35:56 +09:00
parent 6e86ec7ecb
commit c63775bba6
5 changed files with 262 additions and 87 deletions
+38 -85
View File
@@ -12,6 +12,13 @@ import {
import { useIsCompactDevice } from '../hooks/useMediaQuery';
import { safeToFixed } from '../utils/safeFormat';
import { VirtualScroll } from './common/VirtualScroll';
import {
buildSortedMarketRows,
filterMarketRows,
marketInfosToRows,
type MarketSortDir,
type MarketSortKey,
} from '../utils/marketSearchList';
// ─── Types ───────────────────────────────────────────────────────────────────
interface MarketRow {
@@ -30,29 +37,10 @@ function formatVol(vol: number | string | null | undefined): string {
return v.toLocaleString();
}
function compareNullableNum(
a: number | null | undefined,
b: number | null | undefined,
mul: number,
): number {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return mul * (a - b);
}
function hookInfosToRows(infos: HookMarketInfo[]): MarketRow[] {
return infos.map(m => ({
market: m.market,
korean_name: m.koreanName,
english_name: m.englishName,
symbol: m.market.replace('KRW-', ''),
}));
return marketInfosToRows(infos) as MarketRow[];
}
type SortKey = 'name' | 'price' | 'change' | 'volume';
type SortDir = 'asc' | 'desc';
// ─── Component ───────────────────────────────────────────────────────────────
interface MarketSearchPanelProps {
currentMarket: string;
@@ -91,6 +79,11 @@ interface MarketSearchPanelProps {
tickerLoadFull?: boolean;
/** 내부 훅 우선 종목 (전략평가 차트 종목 등) */
tickerPriorityMarkets?: string[];
/** 정렬 상태 공유 (전략평가 이전/다음 종목 등) */
sortKey?: MarketSortKey | null;
sortDir?: MarketSortDir;
onSortKeyChange?: (key: MarketSortKey | null) => void;
onSortDirChange?: (dir: MarketSortDir) => void;
}
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
@@ -99,6 +92,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false,
tickers: tickersProp, marketInfos: marketInfosProp, marketLoading: marketLoadingProp,
refreshAllTickers: refreshProp, tickerLoadFull = true, tickerPriorityMarkets,
sortKey: sortKeyProp, sortDir: sortDirProp, onSortKeyChange, onSortDirChange,
}) => {
const embedded = variant === 'embedded';
const isCompact = useIsCompactDevice();
@@ -124,8 +118,12 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
onQueryChange?.(next);
}, [controlledQuery, onQueryChange]);
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [internalSortKey, setInternalSortKey] = useState<MarketSortKey | null>(null);
const [internalSortDir, setInternalSortDir] = useState<MarketSortDir>('desc');
const sortKey = sortKeyProp !== undefined ? sortKeyProp : internalSortKey;
const sortDir = sortDirProp ?? internalSortDir;
const setSortKey = onSortKeyChange ?? setInternalSortKey;
const setSortDir = onSortDirChange ?? setInternalSortDir;
const refreshFavs = useCallback(() => {
setFavs(new Set(getFavorites()));
@@ -193,27 +191,24 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
tryAddMarket(market);
}, [tryAddMarket]);
const applySortState = useCallback((key: SortKey) => {
setSortKey(prev => {
if (prev === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
return key;
}
setSortDir('desc');
return key;
});
}, []);
const handleSort = useCallback((key: SortKey) => {
const needsRefresh = key === 'change' || key === 'price' || key === 'volume';
if (needsRefresh) {
void refreshAllTickers().then(() => applySortState(key));
const applySortState = useCallback((key: MarketSortKey) => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
return;
}
setSortDir('desc');
setSortKey(key);
}, [sortKey, sortDir, setSortKey, setSortDir]);
const handleSort = useCallback((key: MarketSortKey) => {
applySortState(key);
const needsRefresh = key === 'change' || key === 'price' || key === 'volume';
if (needsRefresh) {
void refreshAllTickers();
}
}, [refreshAllTickers, applySortState]);
const renderSortHeader = (key: SortKey, label: string, className: string) => {
const renderSortHeader = (key: MarketSortKey, label: string, className: string) => {
const active = sortKey === key;
return (
<button
@@ -229,54 +224,12 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
};
const q = query.trim();
const filtered = markets.filter(m => {
if (!q) return true;
return (
m.korean_name.includes(q) ||
m.symbol.toUpperCase().includes(q.toUpperCase()) ||
m.english_name.toLowerCase().includes(q.toLowerCase()) ||
m.market.toUpperCase().includes(q.toUpperCase())
);
});
const filtered = filterMarketRows(markets, q);
const sorted = useMemo(() => {
if (!sortKey) {
return [...filtered].sort((a, b) => {
const fa = favs.has(a.market) ? 1 : 0;
const fb = favs.has(b.market) ? 1 : 0;
if (fb !== fa) return fb - fa;
const va = tickers.get(a.market)?.accTradePrice24 ?? 0;
const vb = tickers.get(b.market)?.accTradePrice24 ?? 0;
return vb - va;
});
}
const mul = sortDir === 'asc' ? 1 : -1;
return [...filtered].sort((a, b) => {
switch (sortKey) {
case 'name':
return mul * a.korean_name.localeCompare(b.korean_name, 'ko');
case 'price':
return compareNullableNum(
tickers.get(a.market)?.tradePrice,
tickers.get(b.market)?.tradePrice,
mul,
);
case 'change':
return compareNullableNum(
tickers.get(a.market)?.changeRate,
tickers.get(b.market)?.changeRate,
mul,
);
case 'volume':
return mul * (
(tickers.get(a.market)?.accTradePrice24 ?? 0)
- (tickers.get(b.market)?.accTradePrice24 ?? 0)
);
default:
return 0;
}
});
}, [filtered, sortKey, sortDir, favs, tickers]);
const sorted = useMemo(
() => buildSortedMarketRows(filtered, tickers, favs, sortKey, sortDir),
[filtered, sortKey, sortDir, favs, tickers],
);
const HEADER_H = 30;
const MIN_W = 260;
@@ -238,8 +238,8 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
/** 종목 탭·종목검색 전용 ticker — 차트 OHLCV·scan-signals 와 분리 (upbitWsBroker ticker 채널) */
const marketTabActive = leftOpen && leftTab === 'market';
const marketTickerOpts = useMemo(() => ({
enabled: marketTabActive || marketSearchOpen,
loadFull: marketTabActive,
enabled: true,
loadFull: marketTabActive || marketSearchOpen,
priorityMarkets: [market],
}), [marketTabActive, marketSearchOpen, market]);
const marketFeed = useMarketTicker(marketTickerOpts);
@@ -53,6 +53,16 @@ import {
type BacktestRunTimeframeChoice,
} from '../../utils/backtestRunTimeframe';
import { timeframeBarSeconds } from '../../utils/backtestUiUtils';
import {
getFavorites,
FAVORITES_CHANGED_EVENT,
} from '../../utils/marketStorage';
import {
adjacentMarketInList,
buildSortedMarketCodes,
type MarketSortDir,
type MarketSortKey,
} from '../../utils/marketSearchList';
export interface StrategyEvaluationWindowMeta {
evaluationBarCount: number;
@@ -177,6 +187,9 @@ const StrategyEvaluationChart: React.FC<Props> = ({
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
const [showMarketSearch, setShowMarketSearch] = useState(false);
const [marketQuery, setMarketQuery] = useState('');
const [marketSortKey, setMarketSortKey] = useState<MarketSortKey | null>(null);
const [marketSortDir, setMarketSortDir] = useState<MarketSortDir>('desc');
const [marketFavs, setMarketFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [signals, setSignals] = useState<BacktestSignal[]>([]);
const [backtestRunning, setBacktestRunning] = useState(false);
const [backtestError, setBacktestError] = useState<string | null>(null);
@@ -676,6 +689,41 @@ const StrategyEvaluationChart: React.FC<Props> = ({
onMarketSearchOpenChange?.(showMarketSearch);
}, [showMarketSearch, onMarketSearchOpenChange]);
useEffect(() => {
const refreshFavs = () => setMarketFavs(new Set(getFavorites()));
refreshFavs();
window.addEventListener(FAVORITES_CHANGED_EVENT, refreshFavs);
return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, refreshFavs);
}, []);
const sortedMarketCodes = useMemo(() => buildSortedMarketCodes(
marketInfos ?? [],
marketTickers ?? new Map(),
marketFavs,
showMarketSearch ? marketQuery : '',
marketSortKey,
marketSortDir,
), [
marketInfos,
marketTickers,
marketFavs,
showMarketSearch,
marketQuery,
marketSortKey,
marketSortDir,
]);
const marketNavIndex = sortedMarketCodes.indexOf(market);
const canMarketPrev = marketNavIndex > 0
|| (marketNavIndex < 0 && sortedMarketCodes.length > 0);
const canMarketNext = (marketNavIndex >= 0 && marketNavIndex < sortedMarketCodes.length - 1)
|| (marketNavIndex < 0 && sortedMarketCodes.length > 0);
const stepMarket = useCallback((direction: 'prev' | 'next') => {
const next = adjacentMarketInList(sortedMarketCodes, market, direction);
if (next) onMarketChange(next);
}, [sortedMarketCodes, market, onMarketChange]);
useEffect(() => {
onChartLoadingChange?.(loading);
}, [loading, onChartLoadingChange]);
@@ -782,6 +830,32 @@ const StrategyEvaluationChart: React.FC<Props> = ({
)}
</div>
<div className="seval-chart-toolbar-actions">
<div className="seval-market-nav" role="group" aria-label="종목 이전·다음">
<button
type="button"
className="btd-analysis-tool seval-market-nav-btn"
title="이전 종목"
aria-label="이전 종목"
disabled={!canMarketPrev || sortedMarketCodes.length === 0}
onClick={() => stepMarket('prev')}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<button
type="button"
className="btd-analysis-tool seval-market-nav-btn"
title="다음 종목"
aria-label="다음 종목"
disabled={!canMarketNext || sortedMarketCodes.length === 0}
onClick={() => stepMarket('next')}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
<button
type="button"
className={`btd-analysis-tool seval-chart-fullscreen-btn${chartFullscreen ? ' seval-chart-fullscreen-btn--active' : ''}`}
@@ -1040,6 +1114,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
setShowMarketSearch(false);
setMarketQuery('');
}}
sortKey={marketSortKey}
sortDir={marketSortDir}
onSortKeyChange={setMarketSortKey}
onSortDirChange={setMarketSortDir}
anchorRect={marketBtnRef.current?.getBoundingClientRect() ?? null}
anchorPlacement="dropdown"
/>,