Files
auto-trade/features/trade/components/chart/chart-utils.ts

351 lines
10 KiB
TypeScript

/**
* @file chart-utils.ts
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
*/
import type {
TickMarkType,
Time,
UTCTimestamp,
} from "lightweight-charts";
import type {
DashboardChartTimeframe,
DashboardRealtimeTradeTick,
StockCandlePoint,
} from "@/features/trade/types/trade.types";
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
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,
});
// ─── 타입 ──────────────────────────────────────────────────
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 === "1m") {
d.setUTCSeconds(0, 0);
} else 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),
},
];
}
/**
* @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준)
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
* @see features/trade/components/chart/StockLineChart.tsx 실시간 캔들 반영
*/
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 기준으로 강제 포맷합니다.
* @see features/trade/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter
*/
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로 포맷합니다.
* @see features/trade/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter
*/
export function formatKstCrosshairTime(time: Time) {
const date = toDateFromChartTime(time);
if (!date) return "";
return KST_CROSSHAIR_FORMATTER.format(date);
}
// ─── 포맷터 ───────────────────────────────────────────────
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";
}
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;
}