실시간 차트 우측 상단 주가, 등락율 현재가로 반영
This commit is contained in:
+39
-3
@@ -569,6 +569,12 @@ html.theme-blue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upbit-market { font-weight: 700; color: var(--text); }
|
.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-price { font-weight: 700; font-size: 13px; }
|
||||||
.upbit-change.up { color: var(--up); font-weight: 600; }
|
.upbit-change.up { color: var(--up); font-weight: 600; }
|
||||||
.upbit-change.down { color: var(--down); font-weight: 600; }
|
.upbit-change.down { color: var(--down); font-weight: 600; }
|
||||||
@@ -844,14 +850,44 @@ html.theme-blue {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
z-index: 10;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
pointer-events: none;
|
min-width: 0;
|
||||||
font-size: 12px;
|
}
|
||||||
max-width: calc(100% - 80px);
|
.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 {
|
.tv-legend-sym {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+26
-17
@@ -100,6 +100,11 @@ import VerificationBoardPage from './components/VerificationBoardPage';
|
|||||||
import DashboardPage from './components/DashboardPage';
|
import DashboardPage from './components/DashboardPage';
|
||||||
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
|
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
|
||||||
import ChartLegendBar from './components/ChartLegendBar';
|
import ChartLegendBar from './components/ChartLegendBar';
|
||||||
|
import {
|
||||||
|
formatChartLiveChangePct,
|
||||||
|
formatChartLivePrice,
|
||||||
|
resolveChartLiveQuote,
|
||||||
|
} from './utils/chartLiveQuote';
|
||||||
import RightSidePanel from './components/RightSidePanel';
|
import RightSidePanel from './components/RightSidePanel';
|
||||||
import {
|
import {
|
||||||
type BacktestSettingsDto,
|
type BacktestSettingsDto,
|
||||||
@@ -1655,12 +1660,12 @@ function App() {
|
|||||||
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
||||||
/** 크로스헤어 OHLC·지표 범례 */
|
/** 크로스헤어 OHLC·지표 범례 */
|
||||||
const hoverBar = legend ?? lastKnownBar;
|
const hoverBar = legend ?? lastKnownBar;
|
||||||
/** 종목명 옆 시세 — 실시간 현재가만 */
|
/** 종목명 옆·우측 상단 시세 — 실시간 티커/최신 봉 (크로스헤어 무관) */
|
||||||
const quoteBar = lastKnownBar;
|
const quoteBar = lastKnownBar;
|
||||||
const isUp = quoteBar ? quoteBar.close >= quoteBar.open : true;
|
const liveQuote = useMemo(
|
||||||
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : quoteBar?.open ?? 0;
|
() => resolveChartLiveQuote(chartTicker, quoteBar, bars),
|
||||||
const dailyChange = quoteBar ? quoteBar.close - prevClose : 0;
|
[chartTicker, quoteBar, bars],
|
||||||
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
|
);
|
||||||
|
|
||||||
const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]);
|
const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]);
|
||||||
|
|
||||||
@@ -2074,15 +2079,20 @@ function App() {
|
|||||||
<span className="upbit-market">{getKoreanName(symbol)} <span style={{opacity:0.5, fontSize:'11px'}}>{symbol}</span></span>
|
<span className="upbit-market">{getKoreanName(symbol)} <span style={{opacity:0.5, fontSize:'11px'}}>{symbol}</span></span>
|
||||||
{isLoading && <span className="upbit-loading">데이터 로딩 중...</span>}
|
{isLoading && <span className="upbit-loading">데이터 로딩 중...</span>}
|
||||||
{error && <span className="upbit-error">⚠ {error}</span>}
|
{error && <span className="upbit-error">⚠ {error}</span>}
|
||||||
{!isLoading && !error && quoteBar && (
|
{!isLoading && !error && liveQuote.price != null && (
|
||||||
<>
|
<div className="upbit-quote-group">
|
||||||
<span className="upbit-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
<span
|
||||||
{quoteBar.close.toLocaleString()} KRW
|
className="upbit-price"
|
||||||
|
style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
|
||||||
|
>
|
||||||
|
{formatChartLivePrice(liveQuote.price)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`upbit-change ${isUp ? 'up' : 'down'}`}>
|
{liveQuote.changePct != null && (
|
||||||
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
<span className={`upbit-change ${liveQuote.isUp ? 'up' : 'down'}`}>
|
||||||
|
{formatChartLiveChangePct(liveQuote.changePct)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="upbit-hint">💡 업비트 마켓: KRW-BTC, KRW-ETH, KRW-XRP ...</span>
|
<span className="upbit-hint">💡 업비트 마켓: KRW-BTC, KRW-ETH, KRW-XRP ...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2196,6 +2206,7 @@ function App() {
|
|||||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||||
displayTimezone={displayTimezone}
|
displayTimezone={displayTimezone}
|
||||||
|
marketTickers={marketTickers}
|
||||||
compactMode
|
compactMode
|
||||||
chartVisible={chartVisible}
|
chartVisible={chartVisible}
|
||||||
/>
|
/>
|
||||||
@@ -2235,6 +2246,7 @@ function App() {
|
|||||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||||
displayTimezone={displayTimezone}
|
displayTimezone={displayTimezone}
|
||||||
|
marketTickers={marketTickers}
|
||||||
compactMode
|
compactMode
|
||||||
chartVisible={chartVisible}
|
chartVisible={chartVisible}
|
||||||
/>
|
/>
|
||||||
@@ -2253,10 +2265,7 @@ function App() {
|
|||||||
wsStatus={wsStatus}
|
wsStatus={wsStatus}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
currentBar={hoverBar}
|
currentBar={hoverBar}
|
||||||
quoteBar={quoteBar}
|
liveQuote={liveQuote}
|
||||||
isUp={isUp}
|
|
||||||
dailyChange={dailyChange}
|
|
||||||
dailyChangePct={dailyChangePct}
|
|
||||||
indicators={indicators}
|
indicators={indicators}
|
||||||
legend={legend}
|
legend={legend}
|
||||||
/>
|
/>
|
||||||
@@ -2626,7 +2635,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{showAlert && (
|
{showAlert && (
|
||||||
<AlertManager
|
<AlertManager
|
||||||
alerts={alerts} currentPrice={quoteBar?.close ?? 0}
|
alerts={alerts} currentPrice={liveQuote.price ?? quoteBar?.close ?? 0}
|
||||||
onAdd={(price, label) => { managerRef.current?.addAlert(price, label); setAlerts(prev => [...prev, { price, label }]); }}
|
onAdd={(price, label) => { 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)); }}
|
onRemove={price => { managerRef.current?.removeAlert(price); setAlerts(prev => prev.filter(a => Math.abs(a.price - price) > 0.0001)); }}
|
||||||
onClose={() => setShowAlert(false)}
|
onClose={() => setShowAlert(false)}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import type { WsStatus } from '../hooks/useUpbitData';
|
|||||||
import type { ChartMode, IndicatorConfig, LegendData } from '../types';
|
import type { ChartMode, IndicatorConfig, LegendData } from '../types';
|
||||||
import type { ChartLegendVisibility } from '../types/chartLegend';
|
import type { ChartLegendVisibility } from '../types/chartLegend';
|
||||||
import { formatPrice } from '../utils/dataGenerator';
|
import { formatPrice } from '../utils/dataGenerator';
|
||||||
|
import {
|
||||||
|
formatChartLiveChangePct,
|
||||||
|
formatChartLivePrice,
|
||||||
|
type ChartLiveQuote,
|
||||||
|
} from '../utils/chartLiveQuote';
|
||||||
import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry';
|
import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry';
|
||||||
import { formatBbLegendLabel } from '../utils/bollingerConfig';
|
import { formatBbLegendLabel } from '../utils/bollingerConfig';
|
||||||
|
|
||||||
@@ -28,13 +33,10 @@ export interface ChartLegendBarProps {
|
|||||||
useUpbit: boolean;
|
useUpbit: boolean;
|
||||||
wsStatus: WsStatus;
|
wsStatus: WsStatus;
|
||||||
mode: ChartMode;
|
mode: ChartMode;
|
||||||
/** 크로스헤어·OHLC 범례용 (없으면 quoteBar) */
|
/** 크로스헤어 OHLC·지표 값 */
|
||||||
currentBar: LegendData | null;
|
currentBar: LegendData | null;
|
||||||
/** 종목명 옆 시세·등락 — 실시간 현재가만 (크로스헤어 미반영) */
|
/** 우측 현재가·등락 (티커/최신 봉, 크로스헤어 무관) */
|
||||||
quoteBar?: LegendData | null;
|
liveQuote: ChartLiveQuote;
|
||||||
isUp: boolean;
|
|
||||||
dailyChange: number;
|
|
||||||
dailyChangePct: number;
|
|
||||||
indicators: IndicatorConfig[];
|
indicators: IndicatorConfig[];
|
||||||
legend: LegendData | null;
|
legend: LegendData | null;
|
||||||
}
|
}
|
||||||
@@ -46,52 +48,69 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
|||||||
useUpbit,
|
useUpbit,
|
||||||
wsStatus,
|
wsStatus,
|
||||||
mode,
|
mode,
|
||||||
currentBar,
|
currentBar: ohlcBar,
|
||||||
quoteBar: quoteBarProp,
|
liveQuote,
|
||||||
isUp,
|
|
||||||
dailyChange,
|
|
||||||
dailyChangePct,
|
|
||||||
indicators,
|
indicators,
|
||||||
legend,
|
legend,
|
||||||
}) => {
|
}) => {
|
||||||
const ohlcBar = currentBar;
|
const ohlcUp = ohlcBar ? ohlcBar.close >= ohlcBar.open : true;
|
||||||
const quoteBar = quoteBarProp ?? currentBar;
|
const showOhlc = ohlcBar && (v.open || v.high || v.low || v.close);
|
||||||
const showOhlc = ohlcBar && (v.open || v.high || v.low || v.close || v.change);
|
const showLiveQuote = (v.close || v.change) && liveQuote.price != null;
|
||||||
const showAny =
|
const showAny =
|
||||||
v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge)
|
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;
|
if (!showAny) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tv-legend">
|
<div className="tv-legend">
|
||||||
|
<div className="tv-legend-top">
|
||||||
|
<div className="tv-legend-meta">
|
||||||
|
{useUpbit && v.liveStatus && <WsBadge status={wsStatus} isUpbit={true} />}
|
||||||
{v.symbol && (
|
{v.symbol && (
|
||||||
<span className="tv-legend-sym">
|
<span className="tv-legend-sym">
|
||||||
<span className="tv-legend-dot" style={{ background: isUp ? 'var(--up)' : 'var(--down)' }} />
|
<span
|
||||||
|
className="tv-legend-dot"
|
||||||
|
style={{ background: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
|
||||||
|
/>
|
||||||
{symbol}
|
{symbol}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showLiveQuote && (
|
||||||
|
<div className="tv-legend-quote" aria-label="실시간 현재가">
|
||||||
|
{v.close && (
|
||||||
|
<span className={`tv-legend-quote-price ${liveQuote.isUp ? 'up' : 'down'}`}>
|
||||||
|
{formatChartLivePrice(liveQuote.price)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{v.change && liveQuote.changePct != null && (
|
||||||
|
<span className={`tv-legend-quote-chg ${liveQuote.isUp ? 'up' : 'down'}`}>
|
||||||
|
{formatChartLiveChangePct(liveQuote.changePct)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{v.timeframe && <span className="tv-legend-tf">· {timeframe}</span>}
|
{v.timeframe && <span className="tv-legend-tf">· {timeframe}</span>}
|
||||||
{useUpbit && v.liveStatus && <WsBadge status={wsStatus} isUpbit={true} />}
|
{mode === 'trading' && v.tradingBadge && (
|
||||||
{mode === 'trading' && v.tradingBadge && <span className="tv-legend-badge trading">✏</span>}
|
<span className="tv-legend-badge trading">✏</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(showOhlc || (v.indicators && indicators.length > 0)) && (
|
||||||
|
<div className="tv-legend-bottom">
|
||||||
{showOhlc && (
|
{showOhlc && (
|
||||||
<span className="tv-legend-ohlcv">
|
<span className="tv-legend-ohlcv">
|
||||||
{v.open && ohlcBar && (
|
{v.open && (
|
||||||
<span>O <b className={isUp ? 'up' : 'down'}>{formatPrice(ohlcBar.open)}</b></span>
|
<span>O <b className={ohlcUp ? 'up' : 'down'}>{formatPrice(ohlcBar!.open)}</b></span>
|
||||||
)}
|
)}
|
||||||
{v.high && ohlcBar && (
|
{v.high && (
|
||||||
<span>H <b className="up">{formatPrice(ohlcBar.high)}</b></span>
|
<span>H <b className="up">{formatPrice(ohlcBar!.high)}</b></span>
|
||||||
)}
|
)}
|
||||||
{v.low && ohlcBar && (
|
{v.low && (
|
||||||
<span>L <b className="down">{formatPrice(ohlcBar.low)}</b></span>
|
<span>L <b className="down">{formatPrice(ohlcBar!.low)}</b></span>
|
||||||
)}
|
)}
|
||||||
{v.close && quoteBar && (
|
{v.close && (
|
||||||
<span>C <b className={isUp ? 'up' : 'down'}>{formatPrice(quoteBar.close)}</b></span>
|
<span>C <b className={ohlcUp ? 'up' : 'down'}>{formatPrice(ohlcBar!.close)}</b></span>
|
||||||
)}
|
|
||||||
{v.change && (
|
|
||||||
<span className={`tv-legend-chg ${isUp ? 'up' : 'down'}`}>
|
|
||||||
{isUp ? '▲' : '▼'} {formatPrice(Math.abs(dailyChange))} ({Math.abs(dailyChangePct).toFixed(2)}%)
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -115,7 +134,9 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
|||||||
Number.isFinite(n) ? (
|
Number.isFinite(n) ? (
|
||||||
<b
|
<b
|
||||||
key={i}
|
key={i}
|
||||||
style={{ color: (plots[i]?.color ?? def.plots?.[i]?.color ?? '#42A5F5').slice(0, 7) }}
|
style={{
|
||||||
|
color: (plots[i]?.color ?? def.plots?.[i]?.color ?? '#42A5F5').slice(0, 7),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{fmtVal(n)}
|
{fmtVal(n)}
|
||||||
</b>
|
</b>
|
||||||
@@ -127,6 +148,8 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
|||||||
) : null;
|
) : null;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import MainChartSettingsModal from './MainChartSettingsModal';
|
|||||||
import { MarketSearchPanel } from './MarketSearchPanel';
|
import { MarketSearchPanel } from './MarketSearchPanel';
|
||||||
import { generateBars, formatPrice } from '../utils/dataGenerator';
|
import { generateBars, formatPrice } from '../utils/dataGenerator';
|
||||||
import { getKoreanName } from '../utils/marketNameCache';
|
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 { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
||||||
import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry';
|
import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry';
|
||||||
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig';
|
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig';
|
||||||
@@ -180,6 +186,8 @@ export interface ChartSlotProps {
|
|||||||
chartVisible?: boolean;
|
chartVisible?: boolean;
|
||||||
/** pane 구분선 */
|
/** pane 구분선 */
|
||||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||||
|
/** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */
|
||||||
|
marketTickers?: Map<string, TickerData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
||||||
@@ -204,6 +212,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
chartLiveReceiveHighlight = true,
|
chartLiveReceiveHighlight = true,
|
||||||
chartVisible = true,
|
chartVisible = true,
|
||||||
chartPaneSeparator,
|
chartPaneSeparator,
|
||||||
|
marketTickers,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||||
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||||
@@ -557,10 +566,11 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
|
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
|
||||||
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
||||||
const quoteBar = lastKnownBar;
|
const quoteBar = lastKnownBar;
|
||||||
const isUp = quoteBar ? quoteBar.close >= quoteBar.open : true;
|
const slotTicker = marketTickers?.get(symbol) ?? null;
|
||||||
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : quoteBar?.open ?? 0;
|
const liveQuote = useMemo(
|
||||||
const dailyChange = quoteBar ? quoteBar.close - prevClose : 0;
|
() => resolveChartLiveQuote(slotTicker, quoteBar, bars),
|
||||||
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
|
[slotTicker, quoteBar, bars],
|
||||||
|
);
|
||||||
|
|
||||||
const receiveSignal = useMemo(() => {
|
const receiveSignal = useMemo(() => {
|
||||||
if (!useUpbit || wsStatus !== 'connected' || !latestBar) return null;
|
if (!useUpbit || wsStatus !== 'connected' || !latestBar) return null;
|
||||||
@@ -671,8 +681,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
}, [compactMode, onActivate, showMarket]);
|
}, [compactMode, onActivate, showMarket]);
|
||||||
|
|
||||||
const displayKoName = compactKoName(symbol);
|
const displayKoName = compactKoName(symbol);
|
||||||
const trendUp = compactMode && quoteBar != null && dailyChangePct >= 0;
|
const trendUp = compactMode && liveQuote.price != null && liveQuote.isUp;
|
||||||
const trendDown = compactMode && quoteBar != null && dailyChangePct < 0;
|
const trendDown = compactMode && liveQuote.price != null && !liveQuote.isUp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -708,20 +718,22 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
<span className="slot-compact-name-text">{displayKoName}</span>
|
<span className="slot-compact-name-text">{displayKoName}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{quoteBar && (
|
{liveQuote.price != null && (
|
||||||
<div className="slot-compact-quote" aria-label="현재가">
|
<div className="slot-compact-quote" aria-label="현재가">
|
||||||
<span
|
<span
|
||||||
className="slot-compact-price"
|
className="slot-compact-price"
|
||||||
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
|
style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
|
||||||
>
|
>
|
||||||
{formatPrice(quoteBar.close)}
|
{formatChartLivePrice(liveQuote.price).replace(' KRW', '')}
|
||||||
</span>
|
</span>
|
||||||
|
{liveQuote.changePct != null && (
|
||||||
<span
|
<span
|
||||||
className="slot-compact-change"
|
className="slot-compact-change"
|
||||||
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
|
style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
|
||||||
>
|
>
|
||||||
{isUp ? '+' : '-'}{Math.abs(dailyChangePct).toFixed(2)}%
|
{formatChartLiveChangePct(liveQuote.changePct)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -772,12 +784,14 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{quoteBar && (
|
{liveQuote.price != null && (
|
||||||
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
<span className="slot-price" style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||||
{formatPrice(quoteBar.close)}
|
{formatChartLivePrice(liveQuote.price).replace(' KRW', '')}
|
||||||
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
{liveQuote.changePct != null && (
|
||||||
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
<span className="slot-change" style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||||
|
{formatChartLiveChangePct(liveQuote.changePct)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
|
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ export const CHART_LEGEND_SETTING_ITEMS: {
|
|||||||
{ key: 'timeframe', label: '타임프레임', desc: '현재 차트 주기(5m, 1D 등)를 표시합니다.' },
|
{ key: 'timeframe', label: '타임프레임', desc: '현재 차트 주기(5m, 1D 등)를 표시합니다.' },
|
||||||
{ key: 'liveStatus', label: '실시간 상태', desc: '업비트 WebSocket 연결 상태(실시간·재연결 등)를 표시합니다.' },
|
{ key: 'liveStatus', label: '실시간 상태', desc: '업비트 WebSocket 연결 상태(실시간·재연결 등)를 표시합니다.' },
|
||||||
{ key: 'tradingBadge', label: '매매 모드', desc: '매매(그리기) 모드일 때 편집 배지를 표시합니다.' },
|
{ key: 'tradingBadge', label: '매매 모드', desc: '매매(그리기) 모드일 때 편집 배지를 표시합니다.' },
|
||||||
{ key: 'open', label: '시가 (O)', desc: '현재(또는 크로스헤어) 봉의 시가를 표시합니다.' },
|
{ key: 'open', label: '시가 (O)', desc: '크로스헤어 위치 봉의 시가를 표시합니다.' },
|
||||||
{ key: 'high', label: '고가 (H)', desc: '현재(또는 크로스헤어) 봉의 고가를 표시합니다.' },
|
{ key: 'high', label: '고가 (H)', desc: '크로스헤어 위치 봉의 고가를 표시합니다.' },
|
||||||
{ key: 'low', label: '저가 (L)', desc: '현재(또는 크로스헤어) 봉의 저가를 표시합니다.' },
|
{ key: 'low', label: '저가 (L)', desc: '크로스헤어 위치 봉의 저가를 표시합니다.' },
|
||||||
{ key: 'close', label: '종가 (C)', desc: '현재(또는 크로스헤어) 봉의 종가를 표시합니다.' },
|
{ key: 'close', label: '현재가', desc: '차트 우측 상단 실시간 현재가(업비트 티커)를 표시합니다. 크로스헤어와 무관합니다.' },
|
||||||
{ key: 'change', label: '변동', desc: '전봉 대비 변동 금액과 비율을 표시합니다.' },
|
{ key: 'change', label: '등락률', desc: '전일 대비 등락률(%)을 현재가 옆에 표시합니다. 크로스헤어와 무관합니다.' },
|
||||||
{ key: 'indicators', label: '보조지표 이름', desc: '차트에 적용된 보조지표 이름 뱃지를 표시합니다.' },
|
{ key: 'indicators', label: '보조지표 이름', desc: '차트에 적용된 보조지표 이름 뱃지를 표시합니다.' },
|
||||||
{ key: 'indicatorValues', label: '보조지표 값', desc: '보조지표 이름 옆에 현재 수치를 표시합니다.' },
|
{ key: 'indicatorValues', label: '보조지표 값', desc: '보조지표 이름 옆에 현재 수치를 표시합니다.' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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)}%`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user