206 lines
5.9 KiB
TypeScript
206 lines
5.9 KiB
TypeScript
/**
|
|
* @file chart-utils.ts
|
|
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
|
|
*/
|
|
|
|
import type { UTCTimestamp } from "lightweight-charts";
|
|
import type {
|
|
DashboardChartTimeframe,
|
|
StockCandlePoint,
|
|
} from "@/features/dashboard/types/dashboard.types";
|
|
|
|
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
|
|
|
// ─── 타입 ──────────────────────────────────────────────────
|
|
|
|
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),
|
|
},
|
|
];
|
|
}
|
|
|
|
// ─── 포맷터 ───────────────────────────────────────────────
|
|
|
|
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";
|
|
}
|