전략 평가 이전종목, 다음종목 이동 기능
This commit is contained in:
@@ -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"
|
||||
/>,
|
||||
|
||||
Reference in New Issue
Block a user