종목목록 전일대비 정렬문제
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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회로 합침, 캐시·백엔드 우선.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user