diff --git a/frontend/src/chart/ChartWorkspaceView.tsx b/frontend/src/chart/ChartWorkspaceView.tsx index 2af46e5..d84dd66 100644 --- a/frontend/src/chart/ChartWorkspaceView.tsx +++ b/frontend/src/chart/ChartWorkspaceView.tsx @@ -109,6 +109,8 @@ export interface ChartWorkspaceViewProps { marketInfos: import('../hooks/useMarketTicker').MarketInfo[]; marketLoading: boolean; usdRate: number; + refreshAllTickers: () => Promise; + setMarketSearchOpen: React.Dispatch>; 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} diff --git a/frontend/src/chart/useChartWorkspace.ts b/frontend/src/chart/useChartWorkspace.ts index 61dd182..dd32625 100644 --- a/frontend/src/chart/useChartWorkspace.ts +++ b/frontend/src/chart/useChartWorkspace.ts @@ -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, diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index befb24b..ea0c1f9 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -221,6 +221,10 @@ export interface ChartSlotProps { onTogglePriceExtremeLabels?: () => void; /** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */ marketTickers?: Map; + marketInfos?: import('../hooks/useMarketTicker').MarketInfo[]; + marketLoading?: boolean; + refreshAllTickers?: () => Promise; + onMarketSearchOpenChange?: (open: boolean) => void; } const ChartSlot = forwardRef(function ChartSlot({ @@ -253,6 +257,10 @@ const ChartSlot = forwardRef(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(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(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(function ChartSlot { 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, )} diff --git a/frontend/src/components/MarketPanel.tsx b/frontend/src/components/MarketPanel.tsx index 5ea01de..975d874 100644 --- a/frontend/src/components/MarketPanel.tsx +++ b/frontend/src/components/MarketPanel.tsx @@ -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; } // ─── 가격 포맷 헬퍼 ──────────────────────────────────────────────────────── @@ -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 ; @@ -155,6 +168,7 @@ const MarketRow = memo(function MarketRow({ // ─── 메인 패널 ───────────────────────────────────────────────────────────── export const MarketPanel: React.FC = ({ open, tickers, marketInfos, loading, usdRate, activeSymbol, onSymbolSelect, + refreshAllTickers, }) => { const [tab, setTab] = useState('krw'); const [search, setSearch] = useState(''); @@ -172,8 +186,8 @@ export const MarketPanel: React.FC = ({ // 검색 초기화 (탭 전환 시) 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 = ({ }); }, []); + 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 = ({ 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; } diff --git a/frontend/src/components/MarketSearchPanel.tsx b/frontend/src/components/MarketSearchPanel.tsx index 4269264..281aaf7 100644 --- a/frontend/src/components/MarketSearchPanel.tsx +++ b/frontend/src/components/MarketSearchPanel.tsx @@ -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; /** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */ maxTargetsReached?: boolean; + /** useMarketTicker 공유 시세 (차트 워크스페이스). 미전달 시 패널 내부 훅 사용 */ + tickers?: Map; + marketInfos?: HookMarketInfo[]; + marketLoading?: boolean; + refreshAllTickers?: () => Promise; } export const MarketSearchPanel: React.FC = ({ 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([]); - const [tickers, setTickers] = useState>({}); + 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 = ({ onQueryChange?.(next); }, [controlledQuery, onQueryChange]); const [favs, setFavs] = useState>(() => new Set(getFavorites())); - const [loading, setLoading] = useState(true); const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState('desc'); @@ -104,83 +126,29 @@ export const MarketSearchPanel: React.FC = ({ setFavs(new Set(getFavorites())); }, []); - const inputRef = useRef(null); - const tickerTimer = useRef | null>(null); - const marketCodesRef = useRef(''); - - // ── 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 = {}; - 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(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 = { ...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 = ({ 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 = ({ }); }, []); + 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 = ({ ); }; - // ── 4. 필터 + 정렬 ─────────────────────────────────────────────────────── const q = query.trim(); const filtered = markets.filter(m => { if (!q) return true; @@ -264,8 +240,8 @@ export const MarketSearchPanel: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ )} - {/* 목록 — 가상 스크롤 (업비트 전 종목) */} {loading ? (
마켓 목록 로딩 중...
@@ -446,12 +416,14 @@ export const MarketSearchPanel: React.FC = ({ 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 = ({ {!embedded && ( <> - {tk && Number.isFinite(Number(tk.trade_price)) - ? Number(tk.trade_price).toLocaleString() + {tk?.tradePrice != null && Number.isFinite(tk.tradePrice) + ? tk.tradePrice.toLocaleString() : '-'} @@ -519,7 +491,7 @@ export const MarketSearchPanel: React.FC = ({ {!isCompactDropdown && ( - {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} + {tk?.accTradePrice24 ? formatVol(tk.accTradePrice24) : '-'} )} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 8f0b68b..29895e7 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -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; + marketInfos?: MarketInfo[]; + marketLoading?: boolean; + refreshAllTickers?: () => Promise; + onMarketSearchOpenChange?: (open: boolean) => void; /** 백테스팅 드롭다운 표시 여부 */ backtestActive?: boolean; /** 백테스팅 실행 콜백 */ @@ -294,6 +301,8 @@ const Toolbar: React.FC = ({ 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 = ({ }, [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 = ({ {showMarket && ( { onSymbol(s); setShowMarket(false); }} - onClose={() => setShowMarket(false)} + onSelect={s => { onSymbol(s); closeMarketSearch(); }} + onClose={closeMarketSearch} + tickers={marketTickers} + marketInfos={marketInfos} + marketLoading={marketLoading} + refreshAllTickers={refreshAllTickers} /> )} diff --git a/frontend/src/hooks/useMarketTicker.ts b/frontend/src/hooks/useMarketTicker.ts index eb1ef6b..b46fa7a 100644 --- a/frontend/src/hooks/useMarketTicker.ts +++ b/frontend/src/hooks/useMarketTicker.ts @@ -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 => { + 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 }; } diff --git a/frontend/src/utils/upbitTickerFetch.ts b/frontend/src/utils/upbitTickerFetch.ts index 51a5a56..3fee0db 100644 --- a/frontend/src/utils/upbitTickerFetch.ts +++ b/frontend/src/utils/upbitTickerFetch.ts @@ -195,6 +195,16 @@ async function fetchTickersCore(markets: string[]): Promise { 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회로 합침, 캐시·백엔드 우선. */