차트 수정

This commit is contained in:
2026-02-11 11:18:15 +09:00
parent e5a518b211
commit 89bad1d141
13 changed files with 927 additions and 333 deletions

View File

@@ -3,13 +3,53 @@
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
*/
import type { UTCTimestamp } from "lightweight-charts";
import type {
TickMarkType,
Time,
UTCTimestamp,
} from "lightweight-charts";
import type {
DashboardChartTimeframe,
DashboardRealtimeTradeTick,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.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,
});
// ─── 타입 ──────────────────────────────────────────────────
@@ -186,6 +226,63 @@ export function upsertRealtimeBar(
];
}
/**
* @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준)
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
* @see features/dashboard/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/dashboard/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/dashboard/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) {
@@ -203,3 +300,49 @@ export function formatSignedPercent(value: number) {
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;
}