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