2026-02-10 11:16:39 +09:00
|
|
|
/**
|
|
|
|
|
* @file chart-utils.ts
|
|
|
|
|
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
|
|
|
|
|
*/
|
|
|
|
|
|
2026-02-11 11:18:15 +09:00
|
|
|
import type {
|
|
|
|
|
TickMarkType,
|
|
|
|
|
Time,
|
|
|
|
|
UTCTimestamp,
|
|
|
|
|
} from "lightweight-charts";
|
2026-02-10 11:16:39 +09:00
|
|
|
import type {
|
|
|
|
|
DashboardChartTimeframe,
|
2026-02-11 11:18:15 +09:00
|
|
|
DashboardRealtimeTradeTick,
|
2026-02-10 11:16:39 +09:00
|
|
|
StockCandlePoint,
|
2026-02-11 16:31:28 +09:00
|
|
|
} from "@/features/trade/types/trade.types";
|
2026-02-10 11:16:39 +09:00
|
|
|
|
|
|
|
|
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
2026-02-11 11:18:15 +09:00
|
|
|
const KST_TIME_ZONE = "Asia/Seoul";
|
|
|
|
|
const KST_TIME_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
|
|
|
|
timeZone: KST_TIME_ZONE,
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
hour12: false,
|
|
|
|
|
});
|
|
|
|
|
const KST_TIME_SECONDS_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
|
|
|
|
timeZone: KST_TIME_ZONE,
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
second: "2-digit",
|
|
|
|
|
hour12: false,
|
|
|
|
|
});
|
|
|
|
|
const KST_DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
|
|
|
|
timeZone: KST_TIME_ZONE,
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
});
|
|
|
|
|
const KST_MONTH_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
|
|
|
|
timeZone: KST_TIME_ZONE,
|
|
|
|
|
month: "short",
|
|
|
|
|
});
|
|
|
|
|
const KST_YEAR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
|
|
|
|
timeZone: KST_TIME_ZONE,
|
|
|
|
|
year: "numeric",
|
|
|
|
|
});
|
|
|
|
|
const KST_CROSSHAIR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
|
|
|
|
timeZone: KST_TIME_ZONE,
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
hour12: false,
|
|
|
|
|
});
|
2026-02-10 11:16:39 +09:00
|
|
|
|
|
|
|
|
// ─── 타입 ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export type ChartBar = {
|
|
|
|
|
time: UTCTimestamp;
|
|
|
|
|
open: number;
|
|
|
|
|
high: number;
|
|
|
|
|
low: number;
|
|
|
|
|
close: number;
|
|
|
|
|
volume: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── StockCandlePoint → ChartBar 변환 ─────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* candles 배열을 ChartBar 배열로 정규화 (무효값 필터 + 병합 + 정렬)
|
|
|
|
|
*/
|
|
|
|
|
export function normalizeCandles(
|
|
|
|
|
candles: StockCandlePoint[],
|
|
|
|
|
timeframe: DashboardChartTimeframe,
|
|
|
|
|
): ChartBar[] {
|
|
|
|
|
const rows = candles
|
|
|
|
|
.map((c) => convertCandleToBar(c, timeframe))
|
|
|
|
|
.filter((b): b is ChartBar => Boolean(b));
|
|
|
|
|
return mergeBars([], rows);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 단일 candle → ChartBar 변환. 유효하지 않으면 null
|
|
|
|
|
*/
|
|
|
|
|
export function convertCandleToBar(
|
|
|
|
|
candle: StockCandlePoint,
|
|
|
|
|
timeframe: DashboardChartTimeframe,
|
|
|
|
|
): ChartBar | null {
|
|
|
|
|
const close = candle.close ?? candle.price;
|
|
|
|
|
if (!Number.isFinite(close) || close <= 0) return null;
|
|
|
|
|
|
|
|
|
|
const open = candle.open ?? close;
|
|
|
|
|
const high = candle.high ?? Math.max(open, close);
|
|
|
|
|
const low = candle.low ?? Math.min(open, close);
|
|
|
|
|
const volume = candle.volume ?? 0;
|
|
|
|
|
const time = resolveBarTimestamp(candle, timeframe);
|
|
|
|
|
if (!time) return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
time,
|
|
|
|
|
open,
|
|
|
|
|
high: Math.max(high, open, close),
|
|
|
|
|
low: Math.min(low, open, close),
|
|
|
|
|
close,
|
|
|
|
|
volume,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 타임스탬프 해석/정렬 ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function resolveBarTimestamp(
|
|
|
|
|
candle: StockCandlePoint,
|
|
|
|
|
timeframe: DashboardChartTimeframe,
|
|
|
|
|
): UTCTimestamp | null {
|
|
|
|
|
// timestamp 필드가 있으면 우선 사용
|
|
|
|
|
if (
|
|
|
|
|
typeof candle.timestamp === "number" &&
|
|
|
|
|
Number.isFinite(candle.timestamp)
|
|
|
|
|
) {
|
|
|
|
|
return alignTimestamp(candle.timestamp, timeframe);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const text = typeof candle.time === "string" ? candle.time.trim() : "";
|
|
|
|
|
if (!text) return null;
|
|
|
|
|
|
|
|
|
|
// "MM/DD" 형식 (일봉)
|
|
|
|
|
if (/^\d{2}\/\d{2}$/.test(text)) {
|
|
|
|
|
const [mm, dd] = text.split("/");
|
|
|
|
|
const year = new Date().getFullYear();
|
|
|
|
|
const ts = Math.floor(
|
|
|
|
|
new Date(`${year}-${mm}-${dd}T09:00:00+09:00`).getTime() / 1000,
|
|
|
|
|
);
|
|
|
|
|
return alignTimestamp(ts, timeframe);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "HH:MM" 또는 "HH:MM:SS" 형식 (분봉)
|
|
|
|
|
if (/^\d{2}:\d{2}(:\d{2})?$/.test(text)) {
|
|
|
|
|
const [hh, mi, ss] = text.split(":");
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const y = now.getFullYear();
|
|
|
|
|
const m = `${now.getMonth() + 1}`.padStart(2, "0");
|
|
|
|
|
const d = `${now.getDate()}`.padStart(2, "0");
|
|
|
|
|
const ts = Math.floor(
|
|
|
|
|
new Date(`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`).getTime() /
|
|
|
|
|
1000,
|
|
|
|
|
);
|
|
|
|
|
return alignTimestamp(ts, timeframe);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 타임스탬프를 타임프레임 버킷 경계에 정렬
|
|
|
|
|
* - 1m: 그대로
|
|
|
|
|
* - 30m/1h: 분 단위를 버킷에 정렬
|
|
|
|
|
* - 1d: 00:00:00
|
|
|
|
|
* - 1w: 월요일 00:00:00
|
|
|
|
|
*/
|
|
|
|
|
function alignTimestamp(
|
|
|
|
|
timestamp: number,
|
|
|
|
|
timeframe: DashboardChartTimeframe,
|
|
|
|
|
): UTCTimestamp {
|
|
|
|
|
const d = new Date(timestamp * 1000);
|
|
|
|
|
|
|
|
|
|
if (timeframe === "30m" || timeframe === "1h") {
|
|
|
|
|
const bucket = timeframe === "30m" ? 30 : 60;
|
|
|
|
|
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
|
|
|
|
|
} else if (timeframe === "1d") {
|
|
|
|
|
d.setUTCHours(0, 0, 0, 0);
|
|
|
|
|
} else if (timeframe === "1w") {
|
|
|
|
|
const day = d.getUTCDay();
|
|
|
|
|
d.setUTCDate(d.getUTCDate() + (day === 0 ? -6 : 1 - day));
|
|
|
|
|
d.setUTCHours(0, 0, 0, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Math.floor(d.getTime() / 1000) as UTCTimestamp;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 봉 병합 ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 두 ChartBar 배열을 시간 기준으로 병합. 같은 시간대는 OHLCV 통합
|
|
|
|
|
*/
|
|
|
|
|
export function mergeBars(left: ChartBar[], right: ChartBar[]): ChartBar[] {
|
|
|
|
|
const map = new Map<number, ChartBar>();
|
|
|
|
|
for (const bar of [...left, ...right]) {
|
|
|
|
|
const prev = map.get(bar.time);
|
|
|
|
|
if (!prev) {
|
|
|
|
|
map.set(bar.time, bar);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
map.set(bar.time, {
|
|
|
|
|
time: bar.time,
|
|
|
|
|
open: prev.open,
|
|
|
|
|
high: Math.max(prev.high, bar.high),
|
|
|
|
|
low: Math.min(prev.low, bar.low),
|
|
|
|
|
close: bar.close,
|
|
|
|
|
volume: Math.max(prev.volume, bar.volume),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return [...map.values()].sort((a, b) => a.time - b.time);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 실시간 봉 업데이트: 같은 시간이면 기존 봉에 병합, 새 시간이면 추가
|
|
|
|
|
*/
|
|
|
|
|
export function upsertRealtimeBar(
|
|
|
|
|
prev: ChartBar[],
|
|
|
|
|
incoming: ChartBar,
|
|
|
|
|
): ChartBar[] {
|
|
|
|
|
if (prev.length === 0) return [incoming];
|
|
|
|
|
const last = prev[prev.length - 1];
|
|
|
|
|
|
|
|
|
|
if (incoming.time > last.time) return [...prev, incoming];
|
|
|
|
|
if (incoming.time < last.time) return prev;
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
...prev.slice(0, -1),
|
|
|
|
|
{
|
|
|
|
|
time: last.time,
|
|
|
|
|
open: last.open,
|
|
|
|
|
high: Math.max(last.high, incoming.high),
|
|
|
|
|
low: Math.min(last.low, incoming.low),
|
|
|
|
|
close: incoming.close,
|
|
|
|
|
volume: Math.max(last.volume, incoming.volume),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 11:18:15 +09:00
|
|
|
/**
|
|
|
|
|
* @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준)
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
|
|
|
|
|
* @see features/trade/components/chart/StockLineChart.tsx 실시간 캔들 반영
|
2026-02-11 11:18:15 +09:00
|
|
|
*/
|
|
|
|
|
export function toRealtimeTickBar(
|
|
|
|
|
tick: DashboardRealtimeTradeTick,
|
|
|
|
|
timeframe: DashboardChartTimeframe,
|
|
|
|
|
now = new Date(),
|
|
|
|
|
): ChartBar | null {
|
|
|
|
|
if (!Number.isFinite(tick.price) || tick.price <= 0) return null;
|
|
|
|
|
|
|
|
|
|
const hhmmss = normalizeTickTime(tick.tickTime);
|
|
|
|
|
if (!hhmmss) return null;
|
|
|
|
|
|
|
|
|
|
const ymd = getKstYmd(now);
|
|
|
|
|
const baseTimestamp = toKstTimestamp(ymd, hhmmss);
|
|
|
|
|
const alignedTimestamp = alignTimestamp(baseTimestamp, timeframe);
|
|
|
|
|
const minuteFrame = isMinuteTimeframe(timeframe);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
time: alignedTimestamp,
|
|
|
|
|
open: minuteFrame ? tick.price : Math.max(tick.open, tick.price),
|
|
|
|
|
high: minuteFrame ? tick.price : Math.max(tick.high, tick.price),
|
|
|
|
|
low: minuteFrame ? tick.price : Math.min(tick.low || tick.price, tick.price),
|
|
|
|
|
close: tick.price,
|
|
|
|
|
volume: minuteFrame
|
|
|
|
|
? Math.max(tick.tradeVolume, 0)
|
|
|
|
|
: Math.max(tick.accumulatedVolume, 0),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description lightweight-charts X축 라벨을 KST 기준으로 강제 포맷합니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter
|
2026-02-11 11:18:15 +09:00
|
|
|
*/
|
|
|
|
|
export function formatKstTickMark(time: Time, tickMarkType: TickMarkType) {
|
|
|
|
|
const date = toDateFromChartTime(time);
|
|
|
|
|
if (!date) return null;
|
|
|
|
|
|
|
|
|
|
if (tickMarkType === 0) return KST_YEAR_FORMATTER.format(date);
|
|
|
|
|
if (tickMarkType === 1) return KST_MONTH_FORMATTER.format(date);
|
|
|
|
|
if (tickMarkType === 2) return KST_DATE_FORMATTER.format(date);
|
|
|
|
|
if (tickMarkType === 4) return KST_TIME_SECONDS_FORMATTER.format(date);
|
|
|
|
|
return KST_TIME_FORMATTER.format(date);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description crosshair 시간 라벨을 KST로 포맷합니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter
|
2026-02-11 11:18:15 +09:00
|
|
|
*/
|
|
|
|
|
export function formatKstCrosshairTime(time: Time) {
|
|
|
|
|
const date = toDateFromChartTime(time);
|
|
|
|
|
if (!date) return "";
|
|
|
|
|
return KST_CROSSHAIR_FORMATTER.format(date);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 11:16:39 +09:00
|
|
|
// ─── 포맷터 ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export function formatPrice(value: number) {
|
|
|
|
|
return KRW_FORMATTER.format(Math.round(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function formatSignedPercent(value: number) {
|
|
|
|
|
const sign = value > 0 ? "+" : "";
|
|
|
|
|
return `${sign}${value.toFixed(2)}%`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 분봉 타임프레임인지 판별
|
|
|
|
|
*/
|
|
|
|
|
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
|
|
|
|
|
return tf === "1m" || tf === "30m" || tf === "1h";
|
|
|
|
|
}
|
2026-02-11 11:18:15 +09:00
|
|
|
|
|
|
|
|
function normalizeTickTime(value?: string) {
|
|
|
|
|
if (!value) return null;
|
|
|
|
|
const normalized = value.trim();
|
|
|
|
|
return /^\d{6}$/.test(normalized) ? normalized : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getKstYmd(now = new Date()) {
|
|
|
|
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
|
|
|
timeZone: KST_TIME_ZONE,
|
|
|
|
|
year: "numeric",
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
}).formatToParts(now);
|
|
|
|
|
|
|
|
|
|
const map = new Map(parts.map((part) => [part.type, part.value]));
|
|
|
|
|
return `${map.get("year")}${map.get("month")}${map.get("day")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
|
|
|
|
|
const y = Number(yyyymmdd.slice(0, 4));
|
|
|
|
|
const m = 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, m - 1, d, hh - 9, mm, ss) / 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toDateFromChartTime(time: Time) {
|
|
|
|
|
if (typeof time === "number" && Number.isFinite(time)) {
|
|
|
|
|
return new Date(time * 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof time === "string") {
|
|
|
|
|
const parsed = Date.parse(time);
|
|
|
|
|
return Number.isFinite(parsed) ? new Date(parsed) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (time && typeof time === "object" && "year" in time) {
|
|
|
|
|
const { year, month, day } = time;
|
|
|
|
|
return new Date(Date.UTC(year, month - 1, day));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|