import type { DashboardChartTimeframe, StockCandlePoint, } from "@/features/trade/types/trade.types"; type DomesticChartRow = Record; type OhlcvTuple = { open: number; high: number; low: number; close: number; volume: number; }; /** * @description 문자열 숫자를 안전하게 number로 변환합니다. * @see lib/kis/domestic.ts 국내 시세/차트 숫자 필드 파싱 */ export function toNumber(value?: string) { if (!value) return 0; const normalized = value.replace(/,/g, "").trim(); if (!normalized) return 0; const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : 0; } /** * @description 숫자 문자열을 optional number로 변환합니다. * @see lib/kis/domestic.ts 현재가 소스 선택 시 값 존재 여부 판단 */ export function toOptionalNumber(value?: string) { if (!value) return undefined; const normalized = value.replace(/,/g, "").trim(); if (!normalized) return undefined; const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : undefined; } /** * @description KIS 부호 코드를 실제 부호로 반영합니다. * @see lib/kis/domestic.ts 지수/시세 변동값 정규화 */ export function normalizeSignedValue(value: number, signCode?: string) { const abs = Math.abs(value); if (signCode === "4" || signCode === "5") return -abs; if (signCode === "1" || signCode === "2") return abs; return value; } /** * @description 시장명을 코스피/코스닥으로 정규화합니다. * @see lib/kis/domestic.ts 종목 overview 응답 market 값 결정 */ export function resolveMarket(...values: Array) { const merged = values.filter(Boolean).join(" "); if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ")) { return "KOSDAQ" as const; } return "KOSPI" as const; } /** * @description 일봉 output을 차트 공통 candle 포맷으로 변환합니다. * @see lib/kis/domestic.ts getDomesticOverview candles 생성 */ export function toCandles( rows: Array<{ stck_bsop_date?: string; stck_oprc?: string; stck_hgpr?: string; stck_lwpr?: string; stck_clpr?: string; acml_vol?: string; }>, currentPrice: number, ): StockCandlePoint[] { const parsed = rows .map((row) => ({ date: row.stck_bsop_date ?? "", open: toNumber(row.stck_oprc), high: toNumber(row.stck_hgpr), low: toNumber(row.stck_lwpr), close: toNumber(row.stck_clpr), volume: toNumber(row.acml_vol), })) .filter((item) => item.date.length === 8 && item.close > 0) .sort((a, b) => a.date.localeCompare(b.date)) .slice(-80) .map((item) => ({ time: formatDate(item.date), price: item.close, open: item.open > 0 ? item.open : item.close, high: item.high > 0 ? item.high : item.close, low: item.low > 0 ? item.low : item.close, close: item.close, volume: item.volume, })); if (parsed.length > 0) return parsed; const now = new Date(); const mm = `${now.getMonth() + 1}`.padStart(2, "0"); const dd = `${now.getDate()}`.padStart(2, "0"); const safePrice = Math.max(currentPrice, 0); return [ { time: `${mm}/${dd}`, timestamp: Math.floor(now.getTime() / 1000), price: safePrice, open: safePrice, high: safePrice, low: safePrice, close: safePrice, volume: 0, }, ]; } export function formatDate(date: string) { return `${date.slice(4, 6)}/${date.slice(6, 8)}`; } export function firstDefinedNumber(...values: Array) { return values.find((value) => value !== undefined); } export function firstDefinedString(...values: Array) { return values.find((value) => Boolean(value)); } /** * @description 장중/시간외에 따라 현재가 우선 소스를 결정합니다. * @see lib/kis/domestic.ts getDomesticOverview priceSource 계산 */ export function resolveCurrentPriceSource( marketPhase: "regular" | "afterHours", overtime: { ovtm_untp_prpr?: string }, ccnl: { stck_prpr?: string }, quote: { stck_prpr?: string }, ): "inquire-price" | "inquire-ccnl" | "inquire-overtime-price" { const hasOvertimePrice = toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined; const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined; const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined; if (marketPhase === "afterHours") { if (hasOvertimePrice) return "inquire-overtime-price"; if (hasCcnlPrice) return "inquire-ccnl"; return "inquire-price"; } if (hasCcnlPrice) return "inquire-ccnl"; if (hasQuotePrice) return "inquire-price"; return "inquire-price"; } export function firstPositive(...values: number[]) { return values.find((value) => value > 0) ?? 0; } /** * @description KIS 차트 응답에서 output2 배열을 안전하게 추출합니다. * @see lib/kis/domestic.ts getDomesticDailyTimeChart/getDomesticChart */ export function parseOutput2Rows(envelope: { output2?: unknown; output1?: unknown; output?: unknown; }) { if (Array.isArray(envelope.output2)) return envelope.output2 as DomesticChartRow[]; if (Array.isArray(envelope.output)) return envelope.output as DomesticChartRow[]; for (const key of ["output2", "output", "output1"] as const) { const value = envelope[key]; if (value && typeof value === "object" && !Array.isArray(value)) { return [value as DomesticChartRow]; } } return [] as DomesticChartRow[]; } export function readRowString(row: DomesticChartRow, ...keys: string[]) { for (const key of keys) { const value = row[key]; if (typeof value === "string" && value.trim()) return value.trim(); } return ""; } export function readOhlcv(row: DomesticChartRow): OhlcvTuple | null { const close = toNumber( readRowString(row, "stck_clpr", "STCK_CLPR") || readRowString(row, "stck_prpr", "STCK_PRPR"), ); if (close <= 0) return null; const open = toNumber(readRowString(row, "stck_oprc", "STCK_OPRC")) || close; const high = toNumber(readRowString(row, "stck_hgpr", "STCK_HGPR")) || Math.max(open, close); const low = toNumber(readRowString(row, "stck_lwpr", "STCK_LWPR")) || Math.min(open, close); const volume = toNumber( readRowString(row, "acml_vol", "ACML_VOL") || readRowString(row, "cntg_vol", "CNTG_VOL"), ); return { open, high, low, close, volume }; } export function parseDayCandleRow(row: DomesticChartRow): StockCandlePoint | null { const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE"); if (!/^\d{8}$/.test(date)) return null; const ohlcv = readOhlcv(row); if (!ohlcv) return null; return { time: formatDate(date), timestamp: toKstTimestamp(date, "090000"), price: ohlcv.close, ...ohlcv, }; } export function parseMinuteCandleRow( row: DomesticChartRow, minuteBucket: number, ): StockCandlePoint | null { let date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE"); const rawTime = readRowString(row, "stck_cntg_hour", "STCK_CNTG_HOUR"); const time = /^\d{6}$/.test(rawTime) ? rawTime : /^\d{4}$/.test(rawTime) ? `${rawTime}00` : ""; if (!/^\d{8}$/.test(date)) date = nowYmdInKst(); if (!/^\d{8}$/.test(date) || !/^\d{6}$/.test(time)) return null; const ohlcv = readOhlcv(row); if (!ohlcv) return null; const bucketed = alignTimeToMinuteBucket(time, minuteBucket); return { time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`, timestamp: toKstTimestamp(date, bucketed), price: ohlcv.close, ...ohlcv, }; } export function mergeCandlesByTimestamp(rows: StockCandlePoint[]) { const map = new Map(); for (const row of rows) { if (!row.timestamp) continue; const prev = map.get(row.timestamp); if (!prev) { map.set(row.timestamp, row); continue; } map.set(row.timestamp, { ...prev, price: row.close ?? row.price, close: row.close ?? row.price, high: Math.max(prev.high ?? prev.price, row.high ?? row.price), low: Math.min(prev.low ?? prev.price, row.low ?? row.price), volume: (prev.volume ?? 0) + (row.volume ?? 0), }); } return Array.from(map.values()).sort( (a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0), ); } export function alignTimeToMinuteBucket(hhmmss: string, bucket: number) { if (/^\d{4}$/.test(hhmmss)) hhmmss = `${hhmmss}00`; if (bucket <= 1) return hhmmss; const hh = Number(hhmmss.slice(0, 2)); const mm = Number(hhmmss.slice(2, 4)); const aligned = Math.floor(mm / bucket) * bucket; return `${hh.toString().padStart(2, "0")}${aligned.toString().padStart(2, "0")}00`; } export function toKstTimestamp(yyyymmdd: string, hhmmss: string) { const y = Number(yyyymmdd.slice(0, 4)); const mo = Number(yyyymmdd.slice(4, 6)); const d = Number(yyyymmdd.slice(6, 8)); const hh = Number(hhmmss.slice(0, 2)); const mm = Number(hhmmss.slice(2, 4)); const ss = Number(hhmmss.slice(4, 6)); return Math.floor(Date.UTC(y, mo - 1, d, hh - 9, mm, ss) / 1000); } export function shiftYmd(ymd: string, days: number) { const utc = new Date( Date.UTC( Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)), ), ); utc.setUTCDate(utc.getUTCDate() + days); return toYmd(utc); } export function nowYmdInKst() { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul", year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(new Date()); const map = new Map(parts.map((part) => [part.type, part.value])); return `${map.get("year")}${map.get("month")}${map.get("day")}`; } export function nowHmsInKst() { const parts = new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Seoul", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }).formatToParts(new Date()); const map = new Map(parts.map((part) => [part.type, part.value])); return `${map.get("hour")}${map.get("minute")}${map.get("second")}`; } export function minutesForTimeframe(tf: DashboardChartTimeframe) { if (tf === "5m") return 5; if (tf === "10m") return 10; if (tf === "15m") return 15; if (tf === "30m") return 30; if (tf === "1h") return 60; return 1; } export function subOneMinute(hhmmss: string) { const hh = Number(hhmmss.slice(0, 2)); const mm = Number(hhmmss.slice(2, 4)); let totalMin = hh * 60 + mm - 1; if (totalMin < 0) totalMin = 0; const hour = Math.floor(totalMin / 60); const minute = totalMin % 60; return `${String(hour).padStart(2, "0")}${String(minute).padStart(2, "0")}00`; } function toYmd(date: Date) { return `${date.getUTCFullYear()}${`${date.getUTCMonth() + 1}`.padStart(2, "0")}${`${date.getUTCDate()}`.padStart(2, "0")}`; }