353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
import type {
|
|
DashboardChartTimeframe,
|
|
StockCandlePoint,
|
|
} from "@/features/trade/types/trade.types";
|
|
|
|
type DomesticChartRow = Record<string, unknown>;
|
|
|
|
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<string | undefined>) {
|
|
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<number | undefined>) {
|
|
return values.find((value) => value !== undefined);
|
|
}
|
|
|
|
export function firstDefinedString(...values: Array<string | undefined>) {
|
|
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<number, StockCandlePoint>();
|
|
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")}`;
|
|
}
|