종목목록 전일대비 정렬문제

This commit is contained in:
Macbook
2026-06-16 08:53:35 +09:00
parent 615e1164a6
commit 518b69cdc6
8 changed files with 232 additions and 142 deletions
+17
View File
@@ -109,6 +109,8 @@ export interface ChartWorkspaceViewProps {
marketInfos: import('../hooks/useMarketTicker').MarketInfo[];
marketLoading: boolean;
usdRate: number;
refreshAllTickers: () => Promise<void>;
setMarketSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
formalLogin: boolean;
requireFormalLogin: () => void;
chartVisible: boolean;
@@ -324,6 +326,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch,
showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel,
mobileRightOpen, mobileRightTab, marketTickers, marketInfos, marketLoading, usdRate,
refreshAllTickers, setMarketSearchOpen,
formalLogin, requireFormalLogin, chartVisible, menuPage, isMobile,
indicators, effectiveIndicators, mainChartStyle, showMainChartModal,
showBulkIndSettings, settingsModalId, ctxToolbar, drawingCtxToolbar,
@@ -453,6 +456,11 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
return next;
});
}}
marketTickers={marketTickers}
marketInfos={marketInfos}
marketLoading={marketLoading}
refreshAllTickers={refreshAllTickers}
onMarketSearchOpenChange={setMarketSearchOpen}
backtestActive={btMatchesChart}
backtestRunning={btRunning}
backtestRunningId={undefined}
@@ -539,6 +547,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
marketInfos={marketInfos}
loading={marketLoading}
usdRate={usdRate}
refreshAllTickers={refreshAllTickers}
activeSymbol={layoutDef.count > 1 ? activeSlotSymbol : symbol}
onSymbolSelect={s => {
if (layoutDef.count > 1) {
@@ -644,6 +653,10 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone}
marketTickers={marketTickers}
marketInfos={marketInfos}
marketLoading={marketLoading}
refreshAllTickers={refreshAllTickers}
onMarketSearchOpenChange={setMarketSearchOpen}
compactMode
chartVisible={chartVisible}
chartConfigReady={chartConfigReady}
@@ -690,6 +703,10 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone}
marketTickers={marketTickers}
marketInfos={marketInfos}
marketLoading={marketLoading}
refreshAllTickers={refreshAllTickers}
onMarketSearchOpenChange={setMarketSearchOpen}
compactMode
chartVisible={chartVisible}
chartConfigReady={chartConfigReady}
+12 -3
View File
@@ -254,6 +254,8 @@ export function useChartWorkspace({
const [showGrid, setShowGrid] = useState(true);
const [magnifierActive, setMagnifierActive] = useState(false);
const [showMarketPanel, setShowMarketPanel] = useState(false);
/** 종목검색 패널 열림 — 전 종목 ticker loadFull 트리거 */
const [marketSearchOpen, setMarketSearchOpen] = useState(false);
const [mobileRightOpen, setMobileRightOpen] = useState(false);
const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade');
const isMobile = useIsMobile();
@@ -587,10 +589,16 @@ export function useChartWorkspace({
const marketTickerOpts = useMemo(() => ({
enabled: tickerFeedActive,
lite: isCompactDevice,
loadFull: showMarketPanel && tickerFeedActive,
loadFull: tickerFeedActive && (showMarketPanel || marketSearchOpen),
getPriorityMarkets,
}), [tickerFeedActive, isCompactDevice, showMarketPanel, getPriorityMarkets]);
const { tickers: marketTickers, marketInfos, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts);
}), [tickerFeedActive, isCompactDevice, showMarketPanel, marketSearchOpen, getPriorityMarkets]);
const {
tickers: marketTickers,
marketInfos,
loading: marketLoading,
usdRate,
refreshAllTickers,
} = useMarketTicker(marketTickerOpts);
const chartTicker = marketTickers.get(chartSymbol);
const orderbookPrevClose = chartTicker
? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0)
@@ -1977,6 +1985,7 @@ export function useChartWorkspace({
snapToIndicator, keepLockedOnDelete, legend, showStats, showWatch,
showAlert, showObjectTree, percentScale, magnifierActive, showMarketPanel,
mobileRightOpen, mobileRightTab, marketTickers, marketInfos, marketLoading, usdRate,
refreshAllTickers, setMarketSearchOpen,
formalLogin, requireFormalLogin, chartVisible, menuPage,
isMobile, indicators, effectiveIndicators, mainChartStyle,
showMainChartModal, showBulkIndSettings, settingsModalId, ctxToolbar,
+26 -4
View File
@@ -221,6 +221,10 @@ export interface ChartSlotProps {
onTogglePriceExtremeLabels?: () => void;
/** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */
marketTickers?: Map<string, TickerData>;
marketInfos?: import('../hooks/useMarketTicker').MarketInfo[];
marketLoading?: boolean;
refreshAllTickers?: () => Promise<void>;
onMarketSearchOpenChange?: (open: boolean) => void;
}
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
@@ -253,6 +257,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
chartPriceExtremeLabelsVisible = false,
onTogglePriceExtremeLabels,
marketTickers,
marketInfos,
marketLoading,
refreshAllTickers,
onMarketSearchOpenChange,
}, ref) {
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
@@ -817,8 +825,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
setSymbol(s);
setLegend(null);
setShowMarket(false);
onMarketSearchOpenChange?.(false);
setSlotAnchorRect(null);
}, []);
}, [onMarketSearchOpenChange]);
const openMarketSearch = useCallback((e?: React.MouseEvent) => {
e?.stopPropagation();
@@ -828,11 +837,16 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
? compactNameRef.current
: (slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null);
setSlotAnchorRect(anchorEl?.getBoundingClientRect() ?? null);
void refreshAllTickers?.();
} else {
setSlotAnchorRect(null);
}
setShowMarket(v => !v);
}, [compactMode, onActivate, showMarket]);
setShowMarket(v => {
const next = !v;
onMarketSearchOpenChange?.(next);
return next;
});
}, [compactMode, onActivate, showMarket, onMarketSearchOpenChange, refreshAllTickers]);
const displayKoName = compactKoName(symbol);
const trendUp = compactMode && liveQuote.price != null && liveQuote.isUp;
@@ -957,9 +971,17 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
<MarketSearchPanel
currentMarket={symbol}
onSelect={s => { handleSymbolSelect(s); }}
onClose={() => { setShowMarket(false); setSlotAnchorRect(null); }}
onClose={() => {
setShowMarket(false);
onMarketSearchOpenChange?.(false);
setSlotAnchorRect(null);
}}
anchorRect={slotAnchorRect}
anchorPlacement={compactMode ? 'dropdown' : 'slot'}
tickers={marketTickers}
marketInfos={marketInfos}
marketLoading={marketLoading}
refreshAllTickers={refreshAllTickers}
/>,
document.body,
)}
+27 -4
View File
@@ -28,6 +28,8 @@ export interface MarketPanelProps {
usdRate: number; // KRW per 1 USD (from KRW-USDT)
activeSymbol: string;
onSymbolSelect: (symbol: string) => void;
/** 정렬 전 전 종목 시세 재조회 (useMarketTicker.refreshAllTickers) */
refreshAllTickers?: () => Promise<void>;
}
// ─── 가격 포맷 헬퍼 ────────────────────────────────────────────────────────
@@ -50,6 +52,17 @@ function fmtPct(rate: number | null | undefined): string {
return `${sign}${(rate * 100).toFixed(2)}%`;
}
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);
}
// ─── 방향 지시자 삼각형 ────────────────────────────────────────────────────
const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) {
if (change === 'RISE') return <span className="mp-arrow mp-arrow--rise"></span>;
@@ -155,6 +168,7 @@ const MarketRow = memo(function MarketRow({
// ─── 메인 패널 ─────────────────────────────────────────────────────────────
export const MarketPanel: React.FC<MarketPanelProps> = ({
open, tickers, marketInfos, loading, usdRate, activeSymbol, onSymbolSelect,
refreshAllTickers,
}) => {
const [tab, setTab] = useState<Tab>('krw');
const [search, setSearch] = useState('');
@@ -172,8 +186,8 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
// 검색 초기화 (탭 전환 시)
const handleTab = useCallback((t: Tab) => { setTab(t); setSearch(''); }, []);
// 컬럼 정렬 토글
const handleSort = useCallback((key: SortKey) => {
// 컬럼 정렬 토글 (시세 기반 정렬 전 전 종목 REST 재조회)
const applySortState = useCallback((key: SortKey) => {
setSortKey(prev => {
if (prev === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
@@ -184,6 +198,15 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
});
}, []);
const handleSort = useCallback((key: SortKey) => {
const needsRefresh = key === 'change' || key === 'price';
if (needsRefresh && refreshAllTickers) {
void refreshAllTickers().then(() => applySortState(key));
return;
}
applySortState(key);
}, [refreshAllTickers, applySortState]);
// 즐겨찾기 토글
const handleToggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
e.stopPropagation();
@@ -266,9 +289,9 @@ export const MarketPanel: React.FC<MarketPanelProps> = ({
return mul * na.localeCompare(nb, 'ko');
}
case 'price':
return mul * ((ta?.tradePrice ?? 0) - (tb?.tradePrice ?? 0));
return compareNullableNum(ta?.tradePrice, tb?.tradePrice, mul);
case 'change':
return mul * ((ta?.changeRate ?? 0) - (tb?.changeRate ?? 0));
return compareNullableNum(ta?.changeRate, tb?.changeRate, mul);
default:
return 0;
}
+97 -125
View File
@@ -1,35 +1,26 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { setMarketNames } from '../utils/marketNameCache';
import {
getFavorites,
toggleFavorite,
FAVORITES_CHANGED_EVENT,
} from '../utils/marketStorage';
import { API_BASE } from '../utils/backendApi';
import { UPBIT_API } from '../utils/upbitApi';
import { fetchTickersThrottled } from '../utils/upbitTickerFetch';
import {
useMarketTicker,
type TickerData,
type MarketInfo as HookMarketInfo,
} from '../hooks/useMarketTicker';
import { useIsCompactDevice } from '../hooks/useMediaQuery';
import { safeToFixed } from '../utils/safeFormat';
import { VirtualScroll } from './common/VirtualScroll';
// ─── Types ───────────────────────────────────────────────────────────────────
interface MarketInfo {
market: string; // "KRW-BTC"
korean_name: string; // "비트코인"
english_name: string; // "Bitcoin"
symbol: string; // "BTC"
interface MarketRow {
market: string;
korean_name: string;
english_name: string;
symbol: string;
}
interface TickerInfo {
trade_price: number;
signed_change_rate: number; // 전일 대비 변화율 (소수, e.g. 0.015 = +1.5%)
acc_trade_price_24h: number; // 24h 거래대금 (KRW)
}
// ─── Constants ───────────────────────────────────────────────────────────────
const TICKER_TTL_DESKTOP = 5000;
const TICKER_TTL_COMPACT = 25_000;
function formatVol(vol: number | string | null | undefined): string {
const v = Number(vol);
if (!Number.isFinite(v)) return '-';
@@ -39,10 +30,24 @@ function formatVol(vol: number | string | null | undefined): string {
return v.toLocaleString();
}
function formatChangeRate(raw: number | string | null | undefined): number | null {
const rate = Number(raw);
if (!Number.isFinite(rate)) return null;
return rate * 100;
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-', ''),
}));
}
type SortKey = 'name' | 'price' | 'change' | 'volume';
@@ -77,18 +82,36 @@ interface MarketSearchPanelProps {
addedMarkets?: Set<string>;
/** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */
maxTargetsReached?: boolean;
/** useMarketTicker 공유 시세 (차트 워크스페이스). 미전달 시 패널 내부 훅 사용 */
tickers?: Map<string, TickerData>;
marketInfos?: HookMarketInfo[];
marketLoading?: boolean;
refreshAllTickers?: () => Promise<void>;
}
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '',
variant = 'overlay', query: controlledQuery, onQueryChange,
actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false,
tickers: tickersProp, marketInfos: marketInfosProp, marketLoading: marketLoadingProp,
refreshAllTickers: refreshProp,
}) => {
const embedded = variant === 'embedded';
const isCompact = useIsCompactDevice();
const tickerTtl = isCompact ? TICKER_TTL_COMPACT : TICKER_TTL_DESKTOP;
const [markets, setMarkets] = useState<MarketInfo[]>([]);
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
const useExternalFeed = tickersProp != null;
const internalFeed = useMarketTicker({
enabled: !useExternalFeed,
loadFull: true,
});
const tickers = tickersProp ?? internalFeed.tickers;
const marketInfos = marketInfosProp ?? internalFeed.marketInfos;
const loading = marketLoadingProp ?? internalFeed.loading;
const refreshAllTickers = refreshProp ?? internalFeed.refreshAllTickers;
const markets = useMemo(() => hookInfosToRows(marketInfos), [marketInfos]);
const [internalQuery, setInternalQuery] = useState(initialQuery);
const query = controlledQuery ?? internalQuery;
const setQuery = useCallback((next: string) => {
@@ -96,7 +119,6 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
onQueryChange?.(next);
}, [controlledQuery, onQueryChange]);
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [loading, setLoading] = useState(true);
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('desc');
@@ -104,83 +126,29 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
setFavs(new Set(getFavorites()));
}, []);
const inputRef = useRef<HTMLInputElement>(null);
const tickerTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const marketCodesRef = useRef<string>('');
// ── 1. 마켓 목록 로드 ─────────────────────────────────────────────────────
useEffect(() => {
setLoading(true);
fetch(`${API_BASE}/markets/all?isDetails=false`)
.then(r => {
if (!r.ok) return fetch(`${UPBIT_API}/market/all?isDetails=false`);
return r;
})
.then(r => {
if (!r.ok) throw new Error(`market/all ${r.status}`);
return r.json();
})
.then((data: Array<{ market: string; korean_name: string; english_name: string }>) => {
const krw = data
.filter(m => m.market.startsWith('KRW-'))
.map(m => ({ ...m, symbol: m.market.replace('KRW-', '') }));
setMarkets(krw);
marketCodesRef.current = krw.map(m => m.market).join(',');
// 한글명 캐시 저장 (Toolbar 등에서 사용)
const nameMap: Record<string, string> = {};
for (const m of krw) nameMap[m.market] = m.korean_name;
setMarketNames(nameMap);
setLoading(false);
})
.catch(() => setLoading(false));
if (!embedded) setTimeout(() => inputRef.current?.focus(), 50);
refreshFavs();
}, [refreshFavs, embedded]);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (controlledQuery === undefined) setInternalQuery(initialQuery);
}, [initialQuery, controlledQuery]);
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
useEffect(() => {
if (!embedded) setTimeout(() => inputRef.current?.focus(), 50);
refreshFavs();
}, [refreshFavs, embedded]);
useEffect(() => {
const onChanged = () => refreshFavs();
window.addEventListener(FAVORITES_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onChanged);
}, [refreshFavs]);
// ── 2. 시세 데이터 폴링 (100개씩 배치 요청) ─────────────────────────────
const fetchTickers = useCallback(async () => {
const codes = marketCodesRef.current;
if (!codes) return;
const allCodes = codes.split(',').filter(Boolean);
const rawList = await fetchTickersThrottled(allCodes);
setTickers(prev => {
const map: Record<string, TickerInfo> = { ...prev };
for (const t of rawList) {
const market = (t.market ?? t.code) as string;
if (!market) continue;
map[market] = {
trade_price: t.trade_price,
signed_change_rate: t.signed_change_rate,
acc_trade_price_24h: t.acc_trade_price_24h,
};
}
return map;
});
// 패널 마운트 시 전 종목 시세 1회 보장
useEffect(() => {
void refreshAllTickers();
// eslint-disable-next-line react-hooks/exhaustive-deps -- 마운트 1회
}, []);
useEffect(() => {
if (markets.length === 0) return;
fetchTickers();
tickerTimer.current = setInterval(fetchTickers, tickerTtl);
return () => { if (tickerTimer.current) clearInterval(tickerTimer.current); };
}, [markets, fetchTickers, tickerTtl]);
// ── 3. 즐겨찾기 토글 ─────────────────────────────────────────────────────
const toggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
e.stopPropagation();
const row = markets.find(m => m.market === market);
@@ -220,7 +188,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
tryAddMarket(market);
}, [tryAddMarket]);
const handleSort = useCallback((key: SortKey) => {
const applySortState = useCallback((key: SortKey) => {
setSortKey(prev => {
if (prev === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
@@ -231,6 +199,15 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
});
}, []);
const handleSort = useCallback((key: SortKey) => {
const needsRefresh = key === 'change' || key === 'price' || key === 'volume';
if (needsRefresh) {
void refreshAllTickers().then(() => applySortState(key));
return;
}
applySortState(key);
}, [refreshAllTickers, applySortState]);
const renderSortHeader = (key: SortKey, label: string, className: string) => {
const active = sortKey === key;
return (
@@ -246,7 +223,6 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
);
};
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
const q = query.trim();
const filtered = markets.filter(m => {
if (!q) return true;
@@ -264,8 +240,8 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
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[a.market]?.acc_trade_price_24h ?? 0;
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
const va = tickers.get(a.market)?.accTradePrice24 ?? 0;
const vb = tickers.get(b.market)?.accTradePrice24 ?? 0;
return vb - va;
});
}
@@ -274,36 +250,32 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
switch (sortKey) {
case 'name':
return mul * a.korean_name.localeCompare(b.korean_name, 'ko');
case 'price': {
const pa = tickers[a.market]?.trade_price ?? 0;
const pb = tickers[b.market]?.trade_price ?? 0;
return mul * (pa - pb);
}
case 'change': {
const ca = tickers[a.market]?.signed_change_rate ?? 0;
const cb = tickers[b.market]?.signed_change_rate ?? 0;
return mul * (ca - cb);
}
case 'volume': {
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
return mul * (va - vb);
}
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]);
// ── Render ────────────────────────────────────────────────────────────────
// anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다.
const HEADER_H = 30; // 슬롯 헤더 높이 (약 30px)
const HEADER_H = 30;
const MIN_W = 260;
// LWC v5 canvas 레이어가 내부적으로 높은 z-index를 사용하므로
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
const PANEL_Z = 99999;
const DROPDOWN_H = 480;
const isCompactDropdown = anchorPlacement === 'dropdown' && !embedded;
@@ -336,9 +308,8 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
color: 'var(--text)',
border: '1px solid var(--border)',
}
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
: undefined;
// backdrop: dropdown=전체 화면, slot=해당 슬롯 영역만
const backdropStyle: React.CSSProperties | undefined = anchorRect
? anchorPlacement === 'dropdown'
? {
@@ -430,7 +401,6 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
)}
</div>
{/* 목록 — 가상 스크롤 (업비트 전 종목) */}
{loading ? (
<div className="msp-list">
<div className="msp-empty"> ...</div>
@@ -446,12 +416,14 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
aria-label="종목 검색 결과"
>
{m => {
const tk = tickers[m.market];
const tk = tickers.get(m.market);
const isFav = favs.has(m.market);
const isAdded = addedMarkets?.has(m.market) ?? false;
const addBlocked = maxTargetsReached && !isAdded;
const isActive = m.market === currentMarket;
const rate = tk ? formatChangeRate(tk.signed_change_rate) : null;
const rate = tk?.changeRate != null && Number.isFinite(tk.changeRate)
? tk.changeRate * 100
: null;
const isUp = rate !== null && rate >= 0;
return (
@@ -508,8 +480,8 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
{!embedded && (
<>
<span className="msp-col msp-col-price">
{tk && Number.isFinite(Number(tk.trade_price))
? Number(tk.trade_price).toLocaleString()
{tk?.tradePrice != null && Number.isFinite(tk.tradePrice)
? tk.tradePrice.toLocaleString()
: '-'}
</span>
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
@@ -519,7 +491,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
</span>
{!isCompactDropdown && (
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
{tk?.accTradePrice24 ? formatVol(tk.accTradePrice24) : '-'}
</span>
)}
</>
+27 -4
View File
@@ -13,6 +13,7 @@ import LayoutPicker from './LayoutPicker';
import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
import { loadStrategies } from '../utils/backendApi';
import type { TickerData, MarketInfo } from '../hooks/useMarketTicker';
interface BtStrategy { id?: number; name: string; source: 'db'; buyCondition?: unknown; sellCondition?: unknown; }
@@ -81,6 +82,12 @@ export interface ToolbarProps {
marketPanelActive?: boolean;
/** 마켓 패널 토글 */
onToggleMarketPanel?: () => void;
/** useMarketTicker 공유 시세 (종목검색·마켓패널 통합) */
marketTickers?: Map<string, TickerData>;
marketInfos?: MarketInfo[];
marketLoading?: boolean;
refreshAllTickers?: () => Promise<void>;
onMarketSearchOpenChange?: (open: boolean) => void;
/** 백테스팅 드롭다운 표시 여부 */
backtestActive?: boolean;
/** 백테스팅 실행 콜백 */
@@ -294,6 +301,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
layoutId = '1', syncOptions = DEFAULT_SYNC, onLayoutSelect, onSyncChange,
magnifierActive = false, onToggleMagnifier,
marketPanelActive = false, onToggleMarketPanel,
marketTickers, marketInfos, marketLoading, refreshAllTickers,
onMarketSearchOpenChange,
backtestActive = false, onRunBacktest,
backtestRunning = false, backtestRunningId,
onOpenBtSettings,
@@ -371,11 +380,21 @@ const Toolbar: React.FC<ToolbarProps> = ({
}, [showLayout]);
const openMarketSearch = useCallback(() => {
setShowMarket(v => !v);
setShowMarket(v => {
const next = !v;
onMarketSearchOpenChange?.(next);
if (next) void refreshAllTickers?.();
return next;
});
setShowTfMenu(false);
setShowCtMenu(false);
setShowIndMenu(false);
}, []);
}, [onMarketSearchOpenChange, refreshAllTickers]);
const closeMarketSearch = useCallback(() => {
setShowMarket(false);
onMarketSearchOpenChange?.(false);
}, [onMarketSearchOpenChange]);
return (
<>
@@ -383,8 +402,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
{showMarket && (
<MarketSearchPanel
currentMarket={symbol}
onSelect={s => { onSymbol(s); setShowMarket(false); }}
onClose={() => setShowMarket(false)}
onSelect={s => { onSymbol(s); closeMarketSearch(); }}
onClose={closeMarketSearch}
tickers={marketTickers}
marketInfos={marketInfos}
marketLoading={marketLoading}
refreshAllTickers={refreshAllTickers}
/>
)}
+16 -2
View File
@@ -10,7 +10,11 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { setMarketNames } from '../utils/marketNameCache';
import { API_BASE } from '../utils/backendApi';
import { UPBIT_API } from '../utils/upbitApi';
import { fetchTickersThrottled, type UpbitTickerRaw } from '../utils/upbitTickerFetch';
import {
fetchTickersThrottled,
invalidateTickerCache,
type UpbitTickerRaw,
} from '../utils/upbitTickerFetch';
import { subscribeUpbitWs } from '../utils/upbitWsBroker';
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
@@ -218,6 +222,16 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
return rawList;
}, [applyRawList, enabled]);
/** 전 종목 REST 재조회 (정렬·검색 패널 열림 등) — WS 우선종목과 병행 */
const refreshAllTickers = useCallback(async (): Promise<void> => {
if (!enabled) return;
const all = marketListRef.current;
if (all.length === 0) return;
invalidateTickerCache(all);
await loadTickersFor(all);
if (mountedRef.current) fullLoadedRef.current = true;
}, [enabled, loadTickersFor]);
const runFullTickerLoad = useCallback(async () => {
if (!enabled) return;
const all = marketListRef.current;
@@ -338,5 +352,5 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
return () => clearTimeout(t);
}, [enabled, priorityKey, loadTickersFor, attachWs, priorityList]);
return { tickers, marketInfos, loading, usdRate };
return { tickers, marketInfos, loading, usdRate, refreshAllTickers };
}
+10
View File
@@ -195,6 +195,16 @@ async function fetchTickersCore(markets: string[]): Promise<UpbitTickerRaw[]> {
return stale.length > 0 ? stale : [];
}
/** 정렬·패널 열림 등 최신 시세가 필요할 때 캐시 무효화 */
export function invalidateTickerCache(markets?: string[]): void {
if (!markets || markets.length === 0) {
memoryCache.delete('__all__');
return;
}
memoryCache.delete(cacheKey(markets));
memoryCache.delete('__all__');
}
/**
* ticker 조회 — 동일 markets 동시 요청 1회로 합침, 캐시·백엔드 우선.
*/