diff --git a/frontend/src/App.css b/frontend/src/App.css index 802d6a9..6e95e60 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -569,6 +569,12 @@ html.theme-blue { } .upbit-market { font-weight: 700; color: var(--text); } +.upbit-quote-group { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} .upbit-price { font-weight: 700; font-size: 13px; } .upbit-change.up { color: var(--up); font-weight: 600; } .upbit-change.down { color: var(--down); font-weight: 600; } @@ -844,14 +850,44 @@ html.theme-blue { position: absolute; top: 6px; left: 8px; + right: 8px; z-index: 10; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + pointer-events: none; + font-size: 12px; +} +.tv-legend-top { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; +} +.tv-legend-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; - pointer-events: none; - font-size: 12px; - max-width: calc(100% - 80px); + min-width: 0; +} +.tv-legend-quote { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + font-weight: 700; +} +.tv-legend-quote-price { font-size: 13px; } +.tv-legend-quote-chg { font-size: 12px; font-weight: 600; } +.tv-legend-bottom { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + max-width: calc(100% - 72px); } .tv-legend-sym { display: flex; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2c586fa..9a0acb6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -100,6 +100,11 @@ import VerificationBoardPage from './components/VerificationBoardPage'; import DashboardPage from './components/DashboardPage'; import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi'; import ChartLegendBar from './components/ChartLegendBar'; +import { + formatChartLiveChangePct, + formatChartLivePrice, + resolveChartLiveQuote, +} from './utils/chartLiveQuote'; import RightSidePanel from './components/RightSidePanel'; import { type BacktestSettingsDto, @@ -1655,12 +1660,12 @@ function App() { : (bars.length > 0 ? bars[bars.length - 1] : null); /** 크로스헤어 OHLC·지표 범례 */ const hoverBar = legend ?? lastKnownBar; - /** 종목명 옆 시세 — 실시간 현재가만 */ + /** 종목명 옆·우측 상단 시세 — 실시간 티커/최신 봉 (크로스헤어 무관) */ const quoteBar = lastKnownBar; - const isUp = quoteBar ? quoteBar.close >= quoteBar.open : true; - const prevClose = bars.length > 1 ? bars[bars.length - 2].close : quoteBar?.open ?? 0; - const dailyChange = quoteBar ? quoteBar.close - prevClose : 0; - const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0; + const liveQuote = useMemo( + () => resolveChartLiveQuote(chartTicker, quoteBar, bars), + [chartTicker, quoteBar, bars], + ); const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]); @@ -2074,15 +2079,20 @@ function App() { {getKoreanName(symbol)} {symbol} {isLoading && 데이터 로딩 중...} {error && ⚠ {error}} - {!isLoading && !error && quoteBar && ( - <> - - {quoteBar.close.toLocaleString()} KRW + {!isLoading && !error && liveQuote.price != null && ( +
+ + {formatChartLivePrice(liveQuote.price)} - - {isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}% - - + {liveQuote.changePct != null && ( + + {formatChartLiveChangePct(liveQuote.changePct)} + + )} +
)} 💡 업비트 마켓: KRW-BTC, KRW-ETH, KRW-XRP ... @@ -2196,6 +2206,7 @@ function App() { chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} + marketTickers={marketTickers} compactMode chartVisible={chartVisible} /> @@ -2235,6 +2246,7 @@ function App() { chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} + marketTickers={marketTickers} compactMode chartVisible={chartVisible} /> @@ -2253,10 +2265,7 @@ function App() { wsStatus={wsStatus} mode={mode} currentBar={hoverBar} - quoteBar={quoteBar} - isUp={isUp} - dailyChange={dailyChange} - dailyChangePct={dailyChangePct} + liveQuote={liveQuote} indicators={indicators} legend={legend} /> @@ -2626,7 +2635,7 @@ function App() { )} {showAlert && ( { managerRef.current?.addAlert(price, label); setAlerts(prev => [...prev, { price, label }]); }} onRemove={price => { managerRef.current?.removeAlert(price); setAlerts(prev => prev.filter(a => Math.abs(a.price - price) > 0.0001)); }} onClose={() => setShowAlert(false)} diff --git a/frontend/src/components/ChartLegendBar.tsx b/frontend/src/components/ChartLegendBar.tsx index 65826e9..56e6f62 100644 --- a/frontend/src/components/ChartLegendBar.tsx +++ b/frontend/src/components/ChartLegendBar.tsx @@ -6,6 +6,11 @@ import type { WsStatus } from '../hooks/useUpbitData'; import type { ChartMode, IndicatorConfig, LegendData } from '../types'; import type { ChartLegendVisibility } from '../types/chartLegend'; import { formatPrice } from '../utils/dataGenerator'; +import { + formatChartLiveChangePct, + formatChartLivePrice, + type ChartLiveQuote, +} from '../utils/chartLiveQuote'; import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry'; import { formatBbLegendLabel } from '../utils/bollingerConfig'; @@ -28,13 +33,10 @@ export interface ChartLegendBarProps { useUpbit: boolean; wsStatus: WsStatus; mode: ChartMode; - /** 크로스헤어·OHLC 범례용 (없으면 quoteBar) */ + /** 크로스헤어 OHLC·지표 값 */ currentBar: LegendData | null; - /** 종목명 옆 시세·등락 — 실시간 현재가만 (크로스헤어 미반영) */ - quoteBar?: LegendData | null; - isUp: boolean; - dailyChange: number; - dailyChangePct: number; + /** 우측 현재가·등락 (티커/최신 봉, 크로스헤어 무관) */ + liveQuote: ChartLiveQuote; indicators: IndicatorConfig[]; legend: LegendData | null; } @@ -46,86 +48,107 @@ export const ChartLegendBar: React.FC = ({ useUpbit, wsStatus, mode, - currentBar, - quoteBar: quoteBarProp, - isUp, - dailyChange, - dailyChangePct, + currentBar: ohlcBar, + liveQuote, indicators, legend, }) => { - const ohlcBar = currentBar; - const quoteBar = quoteBarProp ?? currentBar; - const showOhlc = ohlcBar && (v.open || v.high || v.low || v.close || v.change); + const ohlcUp = ohlcBar ? ohlcBar.close >= ohlcBar.open : true; + const showOhlc = ohlcBar && (v.open || v.high || v.low || v.close); + const showLiveQuote = (v.close || v.change) && liveQuote.price != null; const showAny = v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge) - || showOhlc || (v.indicators && indicators.length > 0); + || showLiveQuote || showOhlc || (v.indicators && indicators.length > 0); if (!showAny) return null; return (
- {v.symbol && ( - - - {symbol} - - )} - {v.timeframe && · {timeframe}} - {useUpbit && v.liveStatus && } - {mode === 'trading' && v.tradingBadge && } - {showOhlc && ( - - {v.open && ohlcBar && ( - O {formatPrice(ohlcBar.open)} - )} - {v.high && ohlcBar && ( - H {formatPrice(ohlcBar.high)} - )} - {v.low && ohlcBar && ( - L {formatPrice(ohlcBar.low)} - )} - {v.close && quoteBar && ( - C {formatPrice(quoteBar.close)} - )} - {v.change && ( - - {isUp ? '▲' : '▼'} {formatPrice(Math.abs(dailyChange))} ({Math.abs(dailyChangePct).toFixed(2)}%) +
+
+ {useUpbit && v.liveStatus && } + {v.symbol && ( + + + {symbol} )} - - )} - {v.indicators && indicators.map(ind => { - const def = getIndicatorDef(ind.type); - const vals = legend?.indicatorValues?.[ind.type]; - const plots = ind.plots ?? def?.plots ?? []; - const label = ind.type === 'BollingerBands' - ? formatBbLegendLabel(ind.params ?? {}) - : getIndicatorChartTitle(ind.type); - const fmtVal = (n: number) => - Math.abs(n) >= 1000 - ? n.toLocaleString('ko-KR', { maximumFractionDigits: 1 }) - : n.toFixed(2); - return def ? ( - - {label} - {v.indicatorValues && vals && vals.length > 0 && ( - - {vals.map((n, i) => - Number.isFinite(n) ? ( - - {fmtVal(n)} - - ) : null, + {showLiveQuote && ( +
+ {v.close && ( + + {formatChartLivePrice(liveQuote.price)} + + )} + {v.change && liveQuote.changePct != null && ( + + {formatChartLiveChangePct(liveQuote.changePct)} + + )} +
+ )} + {v.timeframe && · {timeframe}} + {mode === 'trading' && v.tradingBadge && ( + + )} +
+
+ {(showOhlc || (v.indicators && indicators.length > 0)) && ( +
+ {showOhlc && ( + + {v.open && ( + O {formatPrice(ohlcBar!.open)} + )} + {v.high && ( + H {formatPrice(ohlcBar!.high)} + )} + {v.low && ( + L {formatPrice(ohlcBar!.low)} + )} + {v.close && ( + C {formatPrice(ohlcBar!.close)} + )} + + )} + {v.indicators && indicators.map(ind => { + const def = getIndicatorDef(ind.type); + const vals = legend?.indicatorValues?.[ind.type]; + const plots = ind.plots ?? def?.plots ?? []; + const label = ind.type === 'BollingerBands' + ? formatBbLegendLabel(ind.params ?? {}) + : getIndicatorChartTitle(ind.type); + const fmtVal = (n: number) => + Math.abs(n) >= 1000 + ? n.toLocaleString('ko-KR', { maximumFractionDigits: 1 }) + : n.toFixed(2); + return def ? ( + + {label} + {v.indicatorValues && vals && vals.length > 0 && ( + + {vals.map((n, i) => + Number.isFinite(n) ? ( + + {fmtVal(n)} + + ) : null, + )} + )} - )} - - ) : null; - })} + ) : null; + })} +
+ )}
); }; diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 40c97d5..6638c69 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -14,6 +14,12 @@ import MainChartSettingsModal from './MainChartSettingsModal'; import { MarketSearchPanel } from './MarketSearchPanel'; import { generateBars, formatPrice } from '../utils/dataGenerator'; import { getKoreanName } from '../utils/marketNameCache'; +import type { TickerData } from '../hooks/useMarketTicker'; +import { + formatChartLiveChangePct, + formatChartLivePrice, + resolveChartLiveQuote, +} from '../utils/chartLiveQuote'; import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage'; import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry'; import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig'; @@ -180,6 +186,8 @@ export interface ChartSlotProps { chartVisible?: boolean; /** pane 구분선 */ chartPaneSeparator?: ChartPaneSeparatorOptions; + /** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */ + marketTickers?: Map; } const ChartSlot = forwardRef(function ChartSlot({ @@ -204,6 +212,7 @@ const ChartSlot = forwardRef(function ChartSlot chartLiveReceiveHighlight = true, chartVisible = true, chartPaneSeparator, + marketTickers, }, ref) { // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings(); @@ -557,10 +566,11 @@ const ChartSlot = forwardRef(function ChartSlot ? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null)) : (bars.length > 0 ? bars[bars.length - 1] : null); const quoteBar = lastKnownBar; - const isUp = quoteBar ? quoteBar.close >= quoteBar.open : true; - const prevClose = bars.length > 1 ? bars[bars.length - 2].close : quoteBar?.open ?? 0; - const dailyChange = quoteBar ? quoteBar.close - prevClose : 0; - const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0; + const slotTicker = marketTickers?.get(symbol) ?? null; + const liveQuote = useMemo( + () => resolveChartLiveQuote(slotTicker, quoteBar, bars), + [slotTicker, quoteBar, bars], + ); const receiveSignal = useMemo(() => { if (!useUpbit || wsStatus !== 'connected' || !latestBar) return null; @@ -671,8 +681,8 @@ const ChartSlot = forwardRef(function ChartSlot }, [compactMode, onActivate, showMarket]); const displayKoName = compactKoName(symbol); - const trendUp = compactMode && quoteBar != null && dailyChangePct >= 0; - const trendDown = compactMode && quoteBar != null && dailyChangePct < 0; + const trendUp = compactMode && liveQuote.price != null && liveQuote.isUp; + const trendDown = compactMode && liveQuote.price != null && !liveQuote.isUp; return (
(function ChartSlot {displayKoName} - {quoteBar && ( + {liveQuote.price != null && (
- {formatPrice(quoteBar.close)} - - - {isUp ? '+' : '-'}{Math.abs(dailyChangePct).toFixed(2)}% + {formatChartLivePrice(liveQuote.price).replace(' KRW', '')} + {liveQuote.changePct != null && ( + + {formatChartLiveChangePct(liveQuote.changePct)} + + )}
)} @@ -772,12 +784,14 @@ const ChartSlot = forwardRef(function ChartSlot ))}
- {quoteBar && ( - - {formatPrice(quoteBar.close)} - - {isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}% - + {liveQuote.price != null && ( + + {formatChartLivePrice(liveQuote.price).replace(' KRW', '')} + {liveQuote.changePct != null && ( + + {formatChartLiveChangePct(liveQuote.changePct)} + + )} )} {useUpbit && } diff --git a/frontend/src/types/chartLegend.ts b/frontend/src/types/chartLegend.ts index 66c5392..6335651 100644 --- a/frontend/src/types/chartLegend.ts +++ b/frontend/src/types/chartLegend.ts @@ -55,11 +55,11 @@ export const CHART_LEGEND_SETTING_ITEMS: { { key: 'timeframe', label: '타임프레임', desc: '현재 차트 주기(5m, 1D 등)를 표시합니다.' }, { key: 'liveStatus', label: '실시간 상태', desc: '업비트 WebSocket 연결 상태(실시간·재연결 등)를 표시합니다.' }, { key: 'tradingBadge', label: '매매 모드', desc: '매매(그리기) 모드일 때 편집 배지를 표시합니다.' }, - { key: 'open', label: '시가 (O)', desc: '현재(또는 크로스헤어) 봉의 시가를 표시합니다.' }, - { key: 'high', label: '고가 (H)', desc: '현재(또는 크로스헤어) 봉의 고가를 표시합니다.' }, - { key: 'low', label: '저가 (L)', desc: '현재(또는 크로스헤어) 봉의 저가를 표시합니다.' }, - { key: 'close', label: '종가 (C)', desc: '현재(또는 크로스헤어) 봉의 종가를 표시합니다.' }, - { key: 'change', label: '변동', desc: '전봉 대비 변동 금액과 비율을 표시합니다.' }, + { key: 'open', label: '시가 (O)', desc: '크로스헤어 위치 봉의 시가를 표시합니다.' }, + { key: 'high', label: '고가 (H)', desc: '크로스헤어 위치 봉의 고가를 표시합니다.' }, + { key: 'low', label: '저가 (L)', desc: '크로스헤어 위치 봉의 저가를 표시합니다.' }, + { key: 'close', label: '현재가', desc: '차트 우측 상단 실시간 현재가(업비트 티커)를 표시합니다. 크로스헤어와 무관합니다.' }, + { key: 'change', label: '등락률', desc: '전일 대비 등락률(%)을 현재가 옆에 표시합니다. 크로스헤어와 무관합니다.' }, { key: 'indicators', label: '보조지표 이름', desc: '차트에 적용된 보조지표 이름 뱃지를 표시합니다.' }, { key: 'indicatorValues', label: '보조지표 값', desc: '보조지표 이름 옆에 현재 수치를 표시합니다.' }, ]; diff --git a/frontend/src/utils/chartLiveQuote.ts b/frontend/src/utils/chartLiveQuote.ts new file mode 100644 index 0000000..48f11b4 --- /dev/null +++ b/frontend/src/utils/chartLiveQuote.ts @@ -0,0 +1,51 @@ +/** + * 실시간 차트 상단·우측 현재가·등락률 (크로스헤어와 분리, 업비트 티커 우선) + */ +import type { TickerData } from '../hooks/useMarketTicker'; +import type { OHLCVBar } from '../types'; + +export interface ChartLiveQuote { + price: number | null; + /** 전일 대비 등락률 (%) */ + changePct: number | null; + isUp: boolean; +} + +export function resolveChartLiveQuote( + ticker: TickerData | undefined | null, + lastBar: OHLCVBar | null, + bars: OHLCVBar[], +): ChartLiveQuote { + const price = + ticker?.tradePrice != null && Number.isFinite(ticker.tradePrice) + ? ticker.tradePrice + : lastBar?.close ?? null; + + if (ticker?.changeRate != null && Number.isFinite(ticker.changeRate)) { + const changePct = ticker.changeRate * 100; + return { price, changePct, isUp: ticker.changeRate >= 0 }; + } + + if (lastBar && bars.length > 1) { + const prevClose = bars[bars.length - 2].close; + if (prevClose > 0) { + const ch = lastBar.close - prevClose; + const changePct = (ch / prevClose) * 100; + return { price, changePct, isUp: ch >= 0 }; + } + } + + const isUp = lastBar ? lastBar.close >= lastBar.open : true; + return { price, changePct: null, isUp }; +} + +export function formatChartLivePrice(price: number | null): string { + if (price == null || !Number.isFinite(price)) return '—'; + return `${Math.round(price).toLocaleString('ko-KR')} KRW`; +} + +export function formatChartLiveChangePct(changePct: number | null): string { + if (changePct == null || !Number.isFinite(changePct)) return ''; + const up = changePct >= 0; + return `${up ? '▲' : '▼'} ${Math.abs(changePct).toFixed(2)}%`; +}