diff --git a/frontend/src/components/DashboardPage.tsx b/frontend/src/components/DashboardPage.tsx index 0e21ea9..2e603f8 100644 --- a/frontend/src/components/DashboardPage.tsx +++ b/frontend/src/components/DashboardPage.tsx @@ -10,6 +10,7 @@ import { type ProcessMonitorDto, type SystemMonitorDto, } from '../utils/backendApi'; +import { formatUpbitKrwPrice } from '../utils/safeFormat'; import DashboardGauge from './dashboard/DashboardGauge'; import DashboardSegmentBar from './dashboard/DashboardSegmentBar'; import DashboardSparkChart from './dashboard/DashboardSparkChart'; @@ -339,7 +340,7 @@ const DashboardPage: React.FC = ({ theme, onGoChart }) => { {s.createdAt?.slice(11, 16) ?? ''} diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx index 3a19954..9e0ee13 100644 --- a/frontend/src/components/DrawingCanvas.tsx +++ b/frontend/src/components/DrawingCanvas.tsx @@ -2,6 +2,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react'; import type { ChartManager } from '../utils/ChartManager'; import type { Drawing, DrawingPoint, DrawingToolId, Theme } from '../types'; import { formatUnixDateTime } from '../utils/timezone'; +import { formatUpbitKrwPrice } from '../utils/safeFormat'; // ─── Hit-test ────────────────────────────────────────────────────────────── const HIT_RADIUS = 7; // px (CSS 좌표) @@ -1088,7 +1089,7 @@ function renderInfoLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManag ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke(); const price = d.points[0].price; - const label = price >= 1000 ? price.toLocaleString('ko-KR') : price.toFixed(4); + const label = formatUpbitKrwPrice(price); ctx.font = 'bold 10px sans-serif'; const tw = ctx.measureText(label).width + 8; ctx.fillStyle = d.color; diff --git a/frontend/src/components/MarketPanel.tsx b/frontend/src/components/MarketPanel.tsx index d928e27..54a1d17 100644 --- a/frontend/src/components/MarketPanel.tsx +++ b/frontend/src/components/MarketPanel.tsx @@ -13,6 +13,7 @@ import { getFavorites, saveFavorites, toggleFavorite, getHoldings, addHolding, removeHolding, } from '../utils/marketStorage'; +import { formatUpbitKrwPrice } from '../utils/safeFormat'; // ─── 타입 ────────────────────────────────────────────────────────────────── type Tab = 'krw' | 'btc' | 'hold' | 'fav'; type SortKey = 'name' | 'price' | 'change'; @@ -29,12 +30,7 @@ export interface MarketPanelProps { // ─── 가격 포맷 헬퍼 ──────────────────────────────────────────────────────── function fmtKrw(price: number | null | undefined): string { - if (price == null || !isFinite(price)) return '-'; - if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); - if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); - if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); - if (price >= 0.01) return price.toFixed(4); - return price.toFixed(8); + return formatUpbitKrwPrice(price, '-'); } function fmtUsd(krwPrice: number | null | undefined, rate: number): string { diff --git a/frontend/src/components/OrderbookPanel.tsx b/frontend/src/components/OrderbookPanel.tsx index 42f1228..aa8ddc4 100644 --- a/frontend/src/components/OrderbookPanel.tsx +++ b/frontend/src/components/OrderbookPanel.tsx @@ -7,12 +7,11 @@ import { formatNowClock, useDisplayTimezone } from '../utils/timezone'; import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook'; import type { RecentTrade } from '../hooks/useUpbitRecentTrades'; import { getKoreanName } from '../utils/marketNameCache'; +import { formatUpbitKrwPrice } from '../utils/safeFormat'; function fmtPrice(p: number): string { if (p == null || !isFinite(p)) return '-'; - if (p >= 1_000) return p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); - if (p >= 1) return p.toFixed(2); - return p.toFixed(6); + return formatUpbitKrwPrice(p, '-'); } function fmtSize(s: number): string { diff --git a/frontend/src/components/TradeAlertModal.tsx b/frontend/src/components/TradeAlertModal.tsx index 6ae422d..95d4cc2 100644 --- a/frontend/src/components/TradeAlertModal.tsx +++ b/frontend/src/components/TradeAlertModal.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { placePaperOrder, placeLiveOrder, loadLiveSummary } from '../utils/backendApi'; import { formatSignalTime } from '../utils/tradeSignalDisplay'; +import { formatUpbitKrwPrice } from '../utils/safeFormat'; import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../utils/tradeSignalColors'; import { useTradeAlertTimeFormat } from '../utils/tradeAlertTimeFormat'; @@ -30,7 +31,7 @@ interface Props { } const fmt = (n: number): string => - n > 0 ? n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : '0'; + n > 0 ? formatUpbitKrwPrice(n, '0') : '0'; function execLabel(type?: string, candleType?: string): string { diff --git a/frontend/src/components/TradeOrderPanel.tsx b/frontend/src/components/TradeOrderPanel.tsx index c198fad..dad99cf 100644 --- a/frontend/src/components/TradeOrderPanel.tsx +++ b/frontend/src/components/TradeOrderPanel.tsx @@ -7,7 +7,7 @@ import { getKoreanName } from '../utils/marketNameCache'; import { MarketSearchPanel } from './MarketSearchPanel'; import type { TradeOrderFillRequest } from '../types'; import { placePaperOrder } from '../utils/backendApi'; -import { coerceFiniteNumber } from '../utils/safeFormat'; +import { coerceFiniteNumber, formatUpbitKrwPrice, upbitKrwTickSize } from '../utils/safeFormat'; export type OrderKind = 'limit' | 'market' | 'stop_limit'; @@ -39,7 +39,7 @@ export interface TradeOrderPanelProps { export function fmtKrw(n: number): string { if (!Number.isFinite(n)) return '0'; - return Math.round(n).toLocaleString('ko-KR'); + return formatUpbitKrwPrice(n, '0'); } function parseNum(s: string): number { @@ -47,11 +47,19 @@ function parseNum(s: string): number { return Number.isFinite(v) ? v : 0; } -/** KRW 가격: 숫자만 허용, 천 단위 쉼표 표시 */ +/** KRW 가격: 숫자·소수점(필요 시)만 허용, 업비트 규칙으로 표시 */ function sanitizePriceInput(raw: string): string { - const digits = raw.replace(/\D/g, ''); - if (!digits) return ''; - return Number(digits).toLocaleString('ko-KR'); + const cleaned = raw.replace(/[^\d.]/g, ''); + if (!cleaned) return ''; + const n = Number(cleaned); + if (!Number.isFinite(n)) return ''; + return formatUpbitKrwPrice(n, cleaned); +} + +/** 현재 가격에 맞는 호가 단위만큼 증감 */ +export function stepPriceByTick(price: number, direction: 1 | -1): number { + const tick = upbitKrwTickSize(price); + return Math.round((price + direction * tick) / tick) * tick; } /** 주문 수량: 숫자와 소수점(1개)만 허용 */ diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 3809938..1d5ecf3 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -2,6 +2,7 @@ import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from import type { MouseEventParams, Time } from 'lightweight-charts'; import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types'; import { ChartManager } from '../utils/ChartManager'; +import { formatUpbitKrwPrice } from '../utils/safeFormat'; import { useChartTimeFormat } from '../utils/chartTimeFormat'; import { setIndicatorChartContext } from '../utils/indicatorRegistry'; import { DISPLAY_COUNT } from '../hooks/useUpbitData'; @@ -1675,8 +1676,7 @@ const TradingChart: React.FC = ({ container.style.cursor = isHit ? 'pointer' : ''; }, []); - const fmtCtxPrice = (p: number) => - p >= 1000 ? Math.round(p).toLocaleString('ko-KR') : p.toFixed(p >= 1 ? 2 : 6); + const fmtCtxPrice = (p: number) => formatUpbitKrwPrice(p); const closeCtxAndRequest = useCallback((side: 'buy' | 'sell') => { if (!ctxMenu || !market) return; diff --git a/frontend/src/components/analysisReport/AnalysisReportViews.tsx b/frontend/src/components/analysisReport/AnalysisReportViews.tsx index d42bccf..38a2e13 100644 --- a/frontend/src/components/analysisReport/AnalysisReportViews.tsx +++ b/frontend/src/components/analysisReport/AnalysisReportViews.tsx @@ -19,17 +19,18 @@ import { drawdownSeries, } from '../../utils/analysisReportMetrics'; import { repairUtf8Mojibake } from '../../utils/textEncoding'; +import { formatUpbitKrwPrice } from '../../utils/safeFormat'; /* ── 포맷 유틸 ── */ const pct = (v: number, dec = 2) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%`; const pctAbs= (v: number, dec = 1) => `${(v * 100).toFixed(dec)}%`; const num = (v: number, dec = 2) => (isFinite(v) ? v.toFixed(dec) : '—'); -const wonFmt= (v: number) => { +const wonFmt = (v: number) => { const abs = Math.abs(v); const sign = v < 0 ? '-' : v > 0 ? '+' : ''; if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(1)}억`; if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(0)}만`; - return `${sign}${abs.toLocaleString('ko-KR')}원`; + return `${sign}${formatUpbitKrwPrice(abs, '0')}원`; }; const colorCls = (v: number) => (v > 0 ? 'up' : v < 0 ? 'down' : ''); @@ -617,8 +618,8 @@ function LogsView({ ctx }: { ctx: AnalysisReportContext }) { {r.dateTime} {r.symbol.replace('KRW-', 'KRW-')} {r.side} - {r.entryPrice.toLocaleString('ko-KR')} - {r.exitPrice.toLocaleString('ko-KR')} + {formatUpbitKrwPrice(r.entryPrice)} + {formatUpbitKrwPrice(r.exitPrice)} {r.qty.toFixed(3)} = 0 ? 'up' : 'down'}>{r.pnlPct >= 0 ? '+' : ''}{r.pnlPct.toFixed(1)}% {r.durationSec > 0 ? fmtDuration(r.durationSec) : '—'} @@ -658,7 +659,7 @@ function AllocationView({ ctx }: { ctx: AnalysisReportContext }) { {sl.symbol.replace('KRW-', 'KRW-')} {(sl.pct * 100).toFixed(2)}% - ₩{Math.round(sl.capitalKrw).toLocaleString('ko-KR')} + ₩{formatUpbitKrwPrice(sl.capitalKrw)} ))} diff --git a/frontend/src/components/backtest/BacktestKpiPanel.tsx b/frontend/src/components/backtest/BacktestKpiPanel.tsx index fe23787..8e7b40f 100644 --- a/frontend/src/components/backtest/BacktestKpiPanel.tsx +++ b/frontend/src/components/backtest/BacktestKpiPanel.tsx @@ -3,6 +3,7 @@ import type { BacktestAnalysis } from '../../utils/backendApi'; import BacktestSparkline from './BacktestSparkline'; import type { EquityPoint } from '../../utils/backtestEquity'; import { krw, pct, pctAbs } from '../../utils/backtestUiUtils'; +import { formatUpbitKrwPrice } from '../../utils/safeFormat'; interface Props { analysis: BacktestAnalysis | null; @@ -21,7 +22,7 @@ interface Props { const num = (v: number, dec = 2) => (isFinite(v) ? v.toFixed(dec) : '–'); const fmtWon = (v: number) => - isFinite(v) ? `₩${Math.round(v).toLocaleString('ko-KR')}` : '–'; + isFinite(v) ? `₩${formatUpbitKrwPrice(v, '–')}` : '–'; function MetricCell({ label, diff --git a/frontend/src/components/paper/PaperCompactOrderbook.tsx b/frontend/src/components/paper/PaperCompactOrderbook.tsx index dafc85f..b3b6014 100644 --- a/frontend/src/components/paper/PaperCompactOrderbook.tsx +++ b/frontend/src/components/paper/PaperCompactOrderbook.tsx @@ -1,6 +1,6 @@ import React, { memo } from 'react'; import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook'; -import { coerceFiniteNumber, safeToFixed } from '../../utils/safeFormat'; +import { coerceFiniteNumber, safeToFixed, formatUpbitKrwPrice } from '../../utils/safeFormat'; interface Props { market: string; @@ -14,8 +14,7 @@ interface Props { function fmtPrice(p: unknown): string { const n = coerceFiniteNumber(p); - if (n == null) return '-'; - return n >= 1000 ? n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : safeToFixed(n, 4); + return formatUpbitKrwPrice(n, '-'); } function fmtSize(s: unknown): string { diff --git a/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx b/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx index b5c36f9..21394d3 100644 --- a/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx +++ b/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; import type { TrendSearchResultDto } from '../../utils/trendSearchApi'; +import { formatUpbitKrwPrice } from '../../utils/safeFormat'; interface Props { results: TrendSearchResultDto[]; @@ -10,9 +11,7 @@ interface Props { } function fmtPrice(n: number): string { - if (n >= 1_000_000) return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); - if (n >= 100) return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); - return n.toLocaleString('ko-KR', { maximumFractionDigits: 4 }); + return formatUpbitKrwPrice(n, '-'); } function fmtPct(n: number): string { diff --git a/frontend/src/components/virtual/VirtualTargetQuote.tsx b/frontend/src/components/virtual/VirtualTargetQuote.tsx index b63aba9..c43a49e 100644 --- a/frontend/src/components/virtual/VirtualTargetQuote.tsx +++ b/frontend/src/components/virtual/VirtualTargetQuote.tsx @@ -1,15 +1,10 @@ import React, { memo, useEffect, useRef, useState } from 'react'; import type { ChangeDir, TickerData } from '../../hooks/useMarketTicker'; -import { coerceFiniteNumber, safePercentFromRate, safeToFixed } from '../../utils/safeFormat'; +import { coerceFiniteNumber, safePercentFromRate, formatUpbitKrwPrice } from '../../utils/safeFormat'; function fmtKrw(price: number | null | undefined | unknown): string { const p = coerceFiniteNumber(price); - if (p == null) return '-'; - if (p >= 1_000_000) return p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); - if (p >= 1_000) return p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); - if (p >= 1) return p.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); - if (p >= 0.01) return safeToFixed(p, 4, '-'); - return safeToFixed(p, 8, '-'); + return formatUpbitKrwPrice(p, '-'); } function fmtPct(rate: number | null | undefined | unknown): string { diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 0226511..011d0c9 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -25,6 +25,7 @@ import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive'; import { resolveBbBandBackground } from './bollingerConfig'; import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types'; import { formatChartAxisPrice, formatIndicatorAxisPrice } from './dataGenerator'; +import { formatUpbitKrwPrice } from './safeFormat'; import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors'; import { DEFAULT_CHART_TIME_FORMAT, @@ -1271,7 +1272,7 @@ export class ChartManager { : buy ? '매수' : '매도'; const text = showPrice - ? `${label} : ${s.price.toLocaleString()}` + ? `${label} : ${formatUpbitKrwPrice(s.price)}` : label; return { time: s.time, @@ -1304,7 +1305,7 @@ export class ChartManager { const buy = m.signal === 'BUY'; const label = buy ? '매수' : '매도'; const text = showPrice - ? `${label} : ${m.price.toLocaleString()}` + ? `${label} : ${formatUpbitKrwPrice(m.price)}` : label; return { time: m.time, @@ -1988,7 +1989,7 @@ export class ChartManager { if (!this.mainSeries) return; const line = this.mainSeries.createPriceLine({ price, color: '#FFB300', lineWidth: 1, lineStyle: LineStyle.Dashed, - axisLabelVisible: true, title: label || `Alert ${price.toFixed(2)}`, + axisLabelVisible: true, title: label || `Alert ${formatUpbitKrwPrice(price)}`, }); this.alertEntries.push({ price, label, line }); } @@ -2051,7 +2052,7 @@ export class ChartManager { this.drawingPoints = []; } else if (tool === 'trendline' && this.drawingPoints.length === 2) { const [p1, p2] = this.drawingPoints; - const l = this.mainSeries.createPriceLine({ price: (p1.price + p2.price) / 2, color: '#2962FF', lineWidth: 1, lineStyle: LineStyle.Solid, axisLabelVisible: false, title: `${p1.price.toFixed(2)}→${p2.price.toFixed(2)}` }); + const l = this.mainSeries.createPriceLine({ price: (p1.price + p2.price) / 2, color: '#2962FF', lineWidth: 1, lineStyle: LineStyle.Solid, axisLabelVisible: false, title: `${formatUpbitKrwPrice(p1.price)}→${formatUpbitKrwPrice(p2.price)}` }); this.drawingLines.push(l); this.drawingPoints = []; } else if (tool === 'measure' && this.drawingPoints.length === 2) { diff --git a/frontend/src/utils/backtestUiUtils.ts b/frontend/src/utils/backtestUiUtils.ts index 5498c09..92cbefa 100644 --- a/frontend/src/utils/backtestUiUtils.ts +++ b/frontend/src/utils/backtestUiUtils.ts @@ -1,4 +1,5 @@ import type { ChartType, Timeframe } from '../types'; +import { formatUpbitKrwPrice } from './safeFormat'; export function toUpbitMarket(symbol: string): string { return symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`; @@ -79,8 +80,11 @@ export const pct = (v: number, dec = 1) => export const pctAbs = (v: number, dec = 0) => isFinite(v) ? `${(v * 100).toFixed(dec)}%` : '0%'; -export const krw = (v: number) => - isFinite(v) ? `${v >= 0 ? '+' : ''}₩${Math.round(v).toLocaleString()}` : '–'; +export const krw = (v: number) => { + if (!isFinite(v)) return '–'; + const sign = v >= 0 ? '+' : '-'; + return `${sign}₩${formatUpbitKrwPrice(Math.abs(v), '0')}`; +}; const TF_SEC: Record = { '1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800, diff --git a/frontend/src/utils/chartLiveQuote.ts b/frontend/src/utils/chartLiveQuote.ts index 48f11b4..6f3f922 100644 --- a/frontend/src/utils/chartLiveQuote.ts +++ b/frontend/src/utils/chartLiveQuote.ts @@ -3,6 +3,7 @@ */ import type { TickerData } from '../hooks/useMarketTicker'; import type { OHLCVBar } from '../types'; +import { formatUpbitKrwPrice } from './safeFormat'; export interface ChartLiveQuote { price: number | null; @@ -41,7 +42,7 @@ export function resolveChartLiveQuote( export function formatChartLivePrice(price: number | null): string { if (price == null || !Number.isFinite(price)) return '—'; - return `${Math.round(price).toLocaleString('ko-KR')} KRW`; + return `${formatUpbitKrwPrice(price)} KRW`; } export function formatChartLiveChangePct(changePct: number | null): string { diff --git a/frontend/src/utils/dataGenerator.ts b/frontend/src/utils/dataGenerator.ts index 85deb92..06de338 100644 --- a/frontend/src/utils/dataGenerator.ts +++ b/frontend/src/utils/dataGenerator.ts @@ -1,5 +1,6 @@ import type { OHLCVBar, Timeframe } from '../types'; import { formatUnixForChart } from './timezone'; +import { formatUpbitKrwPrice } from './safeFormat'; export const SYMBOLS = [ 'BTCUSD','ETHUSD','BNBUSD','SOLUSD','ADAUSD', @@ -82,25 +83,22 @@ export function generateBars(symbol: string, timeframe: Timeframe): OHLCVBar[] { } export function formatPrice(price: number): string { - if (price >= 10000) return price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); - if (price >= 100) return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - if (price >= 1) return price.toLocaleString('en-US', { minimumFractionDigits: 3, maximumFractionDigits: 4 }); - return price.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 }); + return formatUpbitKrwPrice(price, String(price)); } -/** 실시간 차트 우측 가격축·크로스헤어·시리즈 라벨 (KRW 주가 — 소수점 없음) */ +/** + * 실시간 차트 우측 가격축·크로스헤어·시리즈 라벨 — 업비트 KRW 가격 표시 규칙 적용. + * LightweightCharts formatter 콜백으로 사용되므로 항상 string 반환. + */ export function formatChartAxisPrice(price: number): string { if (!Number.isFinite(price)) return String(price); - return Math.round(price).toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }); + return formatUpbitKrwPrice(price, String(price)); } /** 보조지표 pane 우측 가격축·시리즈 라벨 (소수점 2자리 고정) */ export function formatIndicatorAxisPrice(price: number): string { if (!Number.isFinite(price)) return String(price); - return price.toLocaleString('en-US', { + return price.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2, }); diff --git a/frontend/src/utils/safeFormat.ts b/frontend/src/utils/safeFormat.ts index a9b299f..dd4b4e6 100644 --- a/frontend/src/utils/safeFormat.ts +++ b/frontend/src/utils/safeFormat.ts @@ -1,3 +1,96 @@ +/** + * 업비트 KRW 마켓 가격 표시 규칙 (2024년 개정 기준) + * + * 가격대 별 호가 단위: + * ≥ 2,000,000원 : 1,000원 단위 (소수점 없음) 예) 92,133,000 + * ≥ 200,000원 : 100원 단위 (소수점 없음) 예) 234,100 + * ≥ 10,000원 : 10원 단위 (소수점 없음) 예) 23,450 + * ≥ 100원 : 1원 단위 (소수점 없음) 예) 1,234 / 452 + * ≥ 10원 : 0.1원 단위 (소수점 1자리) 예) 45.3 + * ≥ 1원 : 0.01원 단위 (소수점 2자리) 예) 4.53 + * ≥ 0.1원 : 0.001원 단위 (소수점 3자리) 예) 0.453 + * < 0.1원 : 0.0001원 단위 (소수점 4자리) 예) 0.0123 + * + * 주의: 100~1,000원 구간은 2024년 이후 1원 단위(소수점 없음)로 환원됨. + */ +export function formatUpbitKrwPrice( + price: number | null | undefined, + fallback = '—', +): string { + if (price == null || !Number.isFinite(price)) return fallback; + const abs = Math.abs(price); + + if (abs >= 2_000_000) { + // 1,000원 단위 + return (Math.round(price / 1000) * 1000).toLocaleString('ko-KR', { + maximumFractionDigits: 0, + }); + } + if (abs >= 200_000) { + // 100원 단위 + return (Math.round(price / 100) * 100).toLocaleString('ko-KR', { + maximumFractionDigits: 0, + }); + } + if (abs >= 10_000) { + // 10원 단위 + return (Math.round(price / 10) * 10).toLocaleString('ko-KR', { + maximumFractionDigits: 0, + }); + } + if (abs >= 100) { + // 1원 단위 (소수점 없음) — 100~10,000원 구간 + return Math.round(price).toLocaleString('ko-KR', { + maximumFractionDigits: 0, + }); + } + if (abs >= 10) { + // 0.1원 단위 (소수점 1자리) + return (Math.round(price * 10) / 10).toLocaleString('ko-KR', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + } + if (abs >= 1) { + // 0.01원 단위 (소수점 2자리) + return (Math.round(price * 100) / 100).toLocaleString('ko-KR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + } + if (abs >= 0.1) { + // 0.001원 단위 (소수점 3자리) + return (Math.round(price * 1000) / 1000).toLocaleString('ko-KR', { + minimumFractionDigits: 3, + maximumFractionDigits: 3, + }); + } + if (abs > 0) { + // 0.0001원 단위 (소수점 4자리) + return (Math.round(price * 10000) / 10000).toLocaleString('ko-KR', { + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }); + } + return '0'; +} + +/** + * 업비트 KRW 가격의 최소 호가 단위(tick size)를 반환한다. + * LightweightCharts priceFormat.minMove 또는 주문 입력 스텝 계산에 사용. + */ +export function upbitKrwTickSize(price: number): number { + const abs = Math.abs(price); + if (abs >= 2_000_000) return 1000; + if (abs >= 200_000) return 100; + if (abs >= 10_000) return 10; + if (abs >= 100) return 1; + if (abs >= 10) return 0.1; + if (abs >= 1) return 0.01; + if (abs >= 0.1) return 0.001; + return 0.0001; +} + /** * API·JSON·WebSocket에서 문자열로 올 수 있는 숫자를 안전하게 처리 */ diff --git a/frontend/src/utils/tradeSignalDisplay.ts b/frontend/src/utils/tradeSignalDisplay.ts index c655564..7b42c0d 100644 --- a/frontend/src/utils/tradeSignalDisplay.ts +++ b/frontend/src/utils/tradeSignalDisplay.ts @@ -1,6 +1,7 @@ /** * 매매 시그널 알림 표시용 포맷·라벨 */ +import { formatUpbitKrwPrice } from './safeFormat'; import { formatUnixWithChartPattern, splitChartTimePattern } from './chartTimeFormat'; import { getKoreanName } from './marketNameCache'; import { getTradeAlertTimeFormat } from './tradeAlertTimeFormat'; @@ -27,7 +28,7 @@ export function parseMarket(market: string) { export function formatSignalPrice(price: number): string { if (!Number.isFinite(price) || price <= 0) return '—'; - return `₩${price.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}`; + return `₩${formatUpbitKrwPrice(price, '—')}`; } export function formatSignalTime(unixSec: number, withDate = true): string {