전략 평가 이전종목, 다음종목 이동 기능
This commit is contained in:
@@ -12,6 +12,13 @@ import {
|
|||||||
import { useIsCompactDevice } from '../hooks/useMediaQuery';
|
import { useIsCompactDevice } from '../hooks/useMediaQuery';
|
||||||
import { safeToFixed } from '../utils/safeFormat';
|
import { safeToFixed } from '../utils/safeFormat';
|
||||||
import { VirtualScroll } from './common/VirtualScroll';
|
import { VirtualScroll } from './common/VirtualScroll';
|
||||||
|
import {
|
||||||
|
buildSortedMarketRows,
|
||||||
|
filterMarketRows,
|
||||||
|
marketInfosToRows,
|
||||||
|
type MarketSortDir,
|
||||||
|
type MarketSortKey,
|
||||||
|
} from '../utils/marketSearchList';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
interface MarketRow {
|
interface MarketRow {
|
||||||
@@ -30,29 +37,10 @@ function formatVol(vol: number | string | null | undefined): string {
|
|||||||
return v.toLocaleString();
|
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[] {
|
function hookInfosToRows(infos: HookMarketInfo[]): MarketRow[] {
|
||||||
return infos.map(m => ({
|
return marketInfosToRows(infos) as MarketRow[];
|
||||||
market: m.market,
|
|
||||||
korean_name: m.koreanName,
|
|
||||||
english_name: m.englishName,
|
|
||||||
symbol: m.market.replace('KRW-', ''),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = 'name' | 'price' | 'change' | 'volume';
|
|
||||||
type SortDir = 'asc' | 'desc';
|
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
interface MarketSearchPanelProps {
|
interface MarketSearchPanelProps {
|
||||||
currentMarket: string;
|
currentMarket: string;
|
||||||
@@ -91,6 +79,11 @@ interface MarketSearchPanelProps {
|
|||||||
tickerLoadFull?: boolean;
|
tickerLoadFull?: boolean;
|
||||||
/** 내부 훅 우선 종목 (전략평가 차트 종목 등) */
|
/** 내부 훅 우선 종목 (전략평가 차트 종목 등) */
|
||||||
tickerPriorityMarkets?: string[];
|
tickerPriorityMarkets?: string[];
|
||||||
|
/** 정렬 상태 공유 (전략평가 이전/다음 종목 등) */
|
||||||
|
sortKey?: MarketSortKey | null;
|
||||||
|
sortDir?: MarketSortDir;
|
||||||
|
onSortKeyChange?: (key: MarketSortKey | null) => void;
|
||||||
|
onSortDirChange?: (dir: MarketSortDir) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||||
@@ -99,6 +92,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false,
|
actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false,
|
||||||
tickers: tickersProp, marketInfos: marketInfosProp, marketLoading: marketLoadingProp,
|
tickers: tickersProp, marketInfos: marketInfosProp, marketLoading: marketLoadingProp,
|
||||||
refreshAllTickers: refreshProp, tickerLoadFull = true, tickerPriorityMarkets,
|
refreshAllTickers: refreshProp, tickerLoadFull = true, tickerPriorityMarkets,
|
||||||
|
sortKey: sortKeyProp, sortDir: sortDirProp, onSortKeyChange, onSortDirChange,
|
||||||
}) => {
|
}) => {
|
||||||
const embedded = variant === 'embedded';
|
const embedded = variant === 'embedded';
|
||||||
const isCompact = useIsCompactDevice();
|
const isCompact = useIsCompactDevice();
|
||||||
@@ -124,8 +118,12 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
onQueryChange?.(next);
|
onQueryChange?.(next);
|
||||||
}, [controlledQuery, onQueryChange]);
|
}, [controlledQuery, onQueryChange]);
|
||||||
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
|
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
|
||||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
const [internalSortKey, setInternalSortKey] = useState<MarketSortKey | null>(null);
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
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(() => {
|
const refreshFavs = useCallback(() => {
|
||||||
setFavs(new Set(getFavorites()));
|
setFavs(new Set(getFavorites()));
|
||||||
@@ -193,27 +191,24 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
tryAddMarket(market);
|
tryAddMarket(market);
|
||||||
}, [tryAddMarket]);
|
}, [tryAddMarket]);
|
||||||
|
|
||||||
const applySortState = useCallback((key: SortKey) => {
|
const applySortState = useCallback((key: MarketSortKey) => {
|
||||||
setSortKey(prev => {
|
if (sortKey === key) {
|
||||||
if (prev === key) {
|
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
|
||||||
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));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSortDir('desc');
|
||||||
|
setSortKey(key);
|
||||||
|
}, [sortKey, sortDir, setSortKey, setSortDir]);
|
||||||
|
|
||||||
|
const handleSort = useCallback((key: MarketSortKey) => {
|
||||||
applySortState(key);
|
applySortState(key);
|
||||||
|
const needsRefresh = key === 'change' || key === 'price' || key === 'volume';
|
||||||
|
if (needsRefresh) {
|
||||||
|
void refreshAllTickers();
|
||||||
|
}
|
||||||
}, [refreshAllTickers, applySortState]);
|
}, [refreshAllTickers, applySortState]);
|
||||||
|
|
||||||
const renderSortHeader = (key: SortKey, label: string, className: string) => {
|
const renderSortHeader = (key: MarketSortKey, label: string, className: string) => {
|
||||||
const active = sortKey === key;
|
const active = sortKey === key;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -229,54 +224,12 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
const filtered = markets.filter(m => {
|
const filtered = filterMarketRows(markets, q);
|
||||||
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 sorted = useMemo(() => {
|
const sorted = useMemo(
|
||||||
if (!sortKey) {
|
() => buildSortedMarketRows(filtered, tickers, favs, sortKey, sortDir),
|
||||||
return [...filtered].sort((a, b) => {
|
[filtered, sortKey, sortDir, favs, tickers],
|
||||||
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 HEADER_H = 30;
|
const HEADER_H = 30;
|
||||||
const MIN_W = 260;
|
const MIN_W = 260;
|
||||||
|
|||||||
@@ -238,8 +238,8 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
|
|||||||
/** 종목 탭·종목검색 전용 ticker — 차트 OHLCV·scan-signals 와 분리 (upbitWsBroker ticker 채널) */
|
/** 종목 탭·종목검색 전용 ticker — 차트 OHLCV·scan-signals 와 분리 (upbitWsBroker ticker 채널) */
|
||||||
const marketTabActive = leftOpen && leftTab === 'market';
|
const marketTabActive = leftOpen && leftTab === 'market';
|
||||||
const marketTickerOpts = useMemo(() => ({
|
const marketTickerOpts = useMemo(() => ({
|
||||||
enabled: marketTabActive || marketSearchOpen,
|
enabled: true,
|
||||||
loadFull: marketTabActive,
|
loadFull: marketTabActive || marketSearchOpen,
|
||||||
priorityMarkets: [market],
|
priorityMarkets: [market],
|
||||||
}), [marketTabActive, marketSearchOpen, market]);
|
}), [marketTabActive, marketSearchOpen, market]);
|
||||||
const marketFeed = useMarketTicker(marketTickerOpts);
|
const marketFeed = useMarketTicker(marketTickerOpts);
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ import {
|
|||||||
type BacktestRunTimeframeChoice,
|
type BacktestRunTimeframeChoice,
|
||||||
} from '../../utils/backtestRunTimeframe';
|
} from '../../utils/backtestRunTimeframe';
|
||||||
import { timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
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 {
|
export interface StrategyEvaluationWindowMeta {
|
||||||
evaluationBarCount: number;
|
evaluationBarCount: number;
|
||||||
@@ -177,6 +187,9 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||||
const [showMarketSearch, setShowMarketSearch] = useState(false);
|
const [showMarketSearch, setShowMarketSearch] = useState(false);
|
||||||
const [marketQuery, setMarketQuery] = useState('');
|
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 [signals, setSignals] = useState<BacktestSignal[]>([]);
|
||||||
const [backtestRunning, setBacktestRunning] = useState(false);
|
const [backtestRunning, setBacktestRunning] = useState(false);
|
||||||
const [backtestError, setBacktestError] = useState<string | null>(null);
|
const [backtestError, setBacktestError] = useState<string | null>(null);
|
||||||
@@ -676,6 +689,41 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
onMarketSearchOpenChange?.(showMarketSearch);
|
onMarketSearchOpenChange?.(showMarketSearch);
|
||||||
}, [showMarketSearch, onMarketSearchOpenChange]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
onChartLoadingChange?.(loading);
|
onChartLoadingChange?.(loading);
|
||||||
}, [loading, onChartLoadingChange]);
|
}, [loading, onChartLoadingChange]);
|
||||||
@@ -782,6 +830,32 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="seval-chart-toolbar-actions">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btd-analysis-tool seval-chart-fullscreen-btn${chartFullscreen ? ' seval-chart-fullscreen-btn--active' : ''}`}
|
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);
|
setShowMarketSearch(false);
|
||||||
setMarketQuery('');
|
setMarketQuery('');
|
||||||
}}
|
}}
|
||||||
|
sortKey={marketSortKey}
|
||||||
|
sortDir={marketSortDir}
|
||||||
|
onSortKeyChange={setMarketSortKey}
|
||||||
|
onSortDirChange={setMarketSortDir}
|
||||||
anchorRect={marketBtnRef.current?.getBoundingClientRect() ?? null}
|
anchorRect={marketBtnRef.current?.getBoundingClientRect() ?? null}
|
||||||
anchorPlacement="dropdown"
|
anchorPlacement="dropdown"
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -477,6 +477,33 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-chart-toolbar-actions .seval-market-nav {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-toolbar-actions .seval-market-nav-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 13px;
|
||||||
|
min-height: 13px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-toolbar-actions .seval-market-nav-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--se-accent, #3d9be9);
|
||||||
|
color: var(--se-accent, #3d9be9);
|
||||||
|
background: color-mix(in srgb, var(--se-accent, #3d9be9) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-toolbar-actions .seval-market-nav-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-chart-strategy-select {
|
.seval-chart-strategy-select {
|
||||||
flex: 1 1 140px;
|
flex: 1 1 140px;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { MarketInfo, TickerData } from '../hooks/useMarketTicker';
|
||||||
|
|
||||||
|
export type MarketSortKey = 'name' | 'price' | 'change' | 'volume';
|
||||||
|
export type MarketSortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export interface MarketSearchRow {
|
||||||
|
market: string;
|
||||||
|
korean_name: string;
|
||||||
|
english_name: string;
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marketInfosToRows(infos: MarketInfo[]): MarketSearchRow[] {
|
||||||
|
return infos.map(m => ({
|
||||||
|
market: m.market,
|
||||||
|
korean_name: m.koreanName,
|
||||||
|
english_name: m.englishName,
|
||||||
|
symbol: m.market.replace('KRW-', ''),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterMarketRows(rows: MarketSearchRow[], query: string): MarketSearchRow[] {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return rows;
|
||||||
|
const upper = q.toUpperCase();
|
||||||
|
const lower = q.toLowerCase();
|
||||||
|
return rows.filter(m =>
|
||||||
|
m.korean_name.includes(q)
|
||||||
|
|| m.symbol.toUpperCase().includes(upper)
|
||||||
|
|| m.english_name.toLowerCase().includes(lower)
|
||||||
|
|| m.market.toUpperCase().includes(upper),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MarketSearchPanel 과 동일 — 관심종목 우선·거래대금 또는 사용자 정렬 */
|
||||||
|
export function buildSortedMarketRows(
|
||||||
|
rows: MarketSearchRow[],
|
||||||
|
tickers: Map<string, TickerData>,
|
||||||
|
favs: Set<string>,
|
||||||
|
sortKey: MarketSortKey | null,
|
||||||
|
sortDir: MarketSortDir,
|
||||||
|
): MarketSearchRow[] {
|
||||||
|
if (!sortKey) {
|
||||||
|
return [...rows].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 [...rows].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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSortedMarketCodes(
|
||||||
|
marketInfos: MarketInfo[],
|
||||||
|
tickers: Map<string, TickerData>,
|
||||||
|
favs: Set<string>,
|
||||||
|
query: string,
|
||||||
|
sortKey: MarketSortKey | null,
|
||||||
|
sortDir: MarketSortDir,
|
||||||
|
): string[] {
|
||||||
|
const rows = filterMarketRows(marketInfosToRows(marketInfos), query);
|
||||||
|
return buildSortedMarketRows(rows, tickers, favs, sortKey, sortDir).map(r => r.market);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjacentMarketInList(
|
||||||
|
orderedMarkets: string[],
|
||||||
|
currentMarket: string,
|
||||||
|
direction: 'prev' | 'next',
|
||||||
|
): string | null {
|
||||||
|
if (orderedMarkets.length === 0) return null;
|
||||||
|
let idx = orderedMarkets.indexOf(currentMarket);
|
||||||
|
if (idx < 0) {
|
||||||
|
idx = direction === 'next' ? -1 : orderedMarkets.length;
|
||||||
|
}
|
||||||
|
const nextIdx = direction === 'prev' ? idx - 1 : idx + 1;
|
||||||
|
if (nextIdx < 0 || nextIdx >= orderedMarkets.length) return null;
|
||||||
|
return orderedMarkets[nextIdx] ?? null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user