/** * 업비트 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에서 문자열로 올 수 있는 숫자를 안전하게 처리 */ export function coerceFiniteNumber(v: unknown): number | null { if (v == null || v === '') return null; if (typeof v === 'number') return Number.isFinite(v) ? v : null; if (typeof v === 'string') { const trimmed = v.trim().replace(/,/g, ''); if (!trimmed) return null; const n = Number(trimmed); return Number.isFinite(n) ? n : null; } const n = Number(v); return Number.isFinite(n) ? n : null; } /** toFixed 크래시 방지 — 숫자가 아니면 '—' */ export function safeToFixed(v: unknown, digits = 2, fallback = '—'): string { const n = coerceFiniteNumber(v); if (n == null) return fallback; try { return n.toFixed(digits); } catch { return fallback; } } export function safePercentFromRate(v: unknown, digits = 2): string { const n = coerceFiniteNumber(v); if (n == null) return '—'; const sign = n >= 0 ? '+' : ''; return `${sign}${safeToFixed(n * 100, digits, '0.00')}%`; }