전체적인 리팩토링

This commit is contained in:
2026-03-12 09:26:27 +09:00
parent 406af7408a
commit e51d767878
97 changed files with 13651 additions and 363 deletions

View File

@@ -35,6 +35,7 @@ interface KisBalanceOutput1Row {
pdno?: string;
prdt_name?: string;
hldg_qty?: string;
ord_psbl_qty?: string;
pchs_avg_pric?: string;
pchs_amt?: string;
prpr?: string;
@@ -72,6 +73,45 @@ interface KisIndexOutputRow {
prdy_vrss_sign?: string;
}
interface KisFluctuationOutputRow {
data_rank?: string;
stck_shrn_iscd?: string;
hts_kor_isnm?: string;
stck_prpr?: string;
prdy_vrss?: string;
prdy_vrss_sign?: string;
prdy_ctrt?: string;
acml_vol?: string;
acml_tr_pbmn?: string;
}
interface KisVolumeRankOutputRow {
data_rank?: string;
mksc_shrn_iscd?: string;
stck_shrn_iscd?: string;
hts_kor_isnm?: string;
stck_prpr?: string;
prdy_vrss?: string;
prdy_vrss_sign?: string;
prdy_ctrt?: string;
acml_vol?: string;
acml_tr_pbmn?: string;
}
interface KisNewsTitleOutputRow {
cntt_usiq_srno?: string;
data_dt?: string;
data_tm?: string;
hts_pbnt_titl_cntt?: string;
dorg?: string;
news_ofer_entp_code?: string;
iscd1?: string;
iscd2?: string;
iscd3?: string;
iscd4?: string;
iscd5?: string;
}
interface KisDailyCcldOutput1Row {
ord_dt?: string;
ord_tmd?: string;
@@ -133,6 +173,7 @@ export interface DomesticHoldingItem {
name: string;
market: "KOSPI" | "KOSDAQ";
quantity: number;
sellableQuantity: number;
averagePrice: number;
currentPrice: number;
evaluationAmount: number;
@@ -154,6 +195,44 @@ export interface DomesticMarketIndexResult {
changeRate: number;
}
export interface DomesticMarketRankItem {
rank: number;
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
price: number;
change: number;
changeRate: number;
volume: number;
tradingValue: number;
}
export interface DomesticNewsHeadlineItem {
id: string;
title: string;
source: string;
publishedAt: string;
symbols: string[];
}
export interface DomesticMarketPulse {
gainersCount: number;
losersCount: number;
popularByVolumeCount: number;
popularByValueCount: number;
newsCount: number;
}
export interface DomesticDashboardMarketHubResult {
gainers: DomesticMarketRankItem[];
losers: DomesticMarketRankItem[];
popularByVolume: DomesticMarketRankItem[];
popularByValue: DomesticMarketRankItem[];
news: DomesticNewsHeadlineItem[];
pulse: DomesticMarketPulse;
warnings: string[];
}
export interface DomesticOrderHistoryItem {
orderDate: string;
orderTime: string;
@@ -217,6 +296,7 @@ const INDEX_TARGETS: Array<{
const DASHBOARD_ORDER_LOOKBACK_DAYS = 30;
const DASHBOARD_JOURNAL_LOOKBACK_DAYS = 90;
const DASHBOARD_MARKET_HUB_LIMIT = 10;
interface DashboardBalanceInquirePreset {
inqrDvsn: "01" | "02";
@@ -263,6 +343,7 @@ export async function getDomesticDashboardBalance(
const symbol = (row.pdno ?? "").trim();
if (!/^\d{6}$/.test(symbol)) return null;
const quantity = toNumber(row.hldg_qty);
const sellableQuantity = Math.max(0, Math.floor(toNumber(row.ord_psbl_qty)));
const averagePrice = toNumber(row.pchs_avg_pric);
const currentPrice = toNumber(row.prpr);
const purchaseAmountBase = pickPreferredAmount(
@@ -287,6 +368,7 @@ export async function getDomesticDashboardBalance(
name: (row.prdt_name ?? "").trim() || symbol,
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
quantity,
sellableQuantity,
averagePrice,
currentPrice,
evaluationAmount,
@@ -296,14 +378,18 @@ export async function getDomesticDashboardBalance(
} satisfies DomesticHoldingItem & { purchaseAmountBase: number };
})
.filter(
(item): item is DomesticHoldingItem & { purchaseAmountBase: number } =>
Boolean(item),
(item): item is DomesticHoldingItem & { purchaseAmountBase: number } => {
if (!item) return false;
// [Step 3] 전량 매도 후 잔존(수량 0) 레코드는 대시보드 보유종목에서 제외합니다.
return item.quantity > 0;
},
);
const holdings = normalizedHoldings.map((item) => ({
symbol: item.symbol,
name: item.name,
market: item.market,
quantity: item.quantity,
sellableQuantity: item.sellableQuantity,
averagePrice: item.averagePrice,
currentPrice: item.currentPrice,
evaluationAmount: item.evaluationAmount,
@@ -518,6 +604,367 @@ export async function getDomesticDashboardIndices(
return results;
}
/**
* 급등/인기/뉴스 시장 허브 데이터를 조회합니다.
* @param credentials 사용자 입력 키(선택)
* @returns 시장 허브 목록/요약/경고
* @remarks UI 흐름: /dashboard 시장 탭 진입 -> market-hub API -> 카드별 리스트 렌더링
* @see app/api/kis/domestic/market-hub/route.ts 시장 허브 API 응답 생성
*/
export async function getDomesticDashboardMarketHub(
credentials?: KisCredentialInput,
): Promise<DomesticDashboardMarketHubResult> {
const [
gainersResult,
losersResult,
popularVolumeResult,
popularValueResult,
newsResult,
] =
await Promise.allSettled([
getDomesticTopGainers(credentials),
getDomesticTopLosers(credentials),
getDomesticVolumeRank(credentials, "0"),
getDomesticVolumeRank(credentials, "3"),
getDomesticNewsHeadlines(credentials),
]);
const warnings: string[] = [];
const gainers =
gainersResult.status === "fulfilled" ? gainersResult.value : [];
if (gainersResult.status === "rejected") {
warnings.push(
gainersResult.reason instanceof Error
? `급등주식 조회 실패: ${gainersResult.reason.message}`
: "급등주식 조회에 실패했습니다.",
);
}
const losers = losersResult.status === "fulfilled" ? losersResult.value : [];
if (losersResult.status === "rejected") {
warnings.push(
losersResult.reason instanceof Error
? `급락주식 조회 실패: ${losersResult.reason.message}`
: "급락주식 조회에 실패했습니다.",
);
}
const popularByVolume =
popularVolumeResult.status === "fulfilled" ? popularVolumeResult.value : [];
if (popularVolumeResult.status === "rejected") {
warnings.push(
popularVolumeResult.reason instanceof Error
? `인기종목(거래량) 조회 실패: ${popularVolumeResult.reason.message}`
: "인기종목(거래량) 조회에 실패했습니다.",
);
}
const popularByValue =
popularValueResult.status === "fulfilled" ? popularValueResult.value : [];
if (popularValueResult.status === "rejected") {
warnings.push(
popularValueResult.reason instanceof Error
? `거래대금 상위 조회 실패: ${popularValueResult.reason.message}`
: "거래대금 상위 조회에 실패했습니다.",
);
}
const news = newsResult.status === "fulfilled" ? newsResult.value : [];
if (newsResult.status === "rejected") {
warnings.push(
newsResult.reason instanceof Error
? `주요 뉴스 조회 실패: ${newsResult.reason.message}`
: "주요 뉴스 조회에 실패했습니다.",
);
}
if (
gainersResult.status === "rejected" &&
losersResult.status === "rejected" &&
popularVolumeResult.status === "rejected" &&
popularValueResult.status === "rejected" &&
newsResult.status === "rejected"
) {
throw new Error("시장 허브 데이터를 모두 조회하지 못했습니다.");
}
return {
gainers,
losers,
popularByVolume,
popularByValue,
news,
pulse: {
gainersCount: gainers.length,
losersCount: losers.length,
popularByVolumeCount: popularByVolume.length,
popularByValueCount: popularByValue.length,
newsCount: news.length,
},
warnings,
};
}
async function getDomesticTopGainers(credentials?: KisCredentialInput) {
const fluctuationRows = await getDomesticFluctuationRows(credentials);
const fluctuationMapped = fluctuationRows
.map((row, index) => normalizeMarketRankItemFromFluctuation(row, index))
.filter((item): item is DomesticMarketRankItem => Boolean(item))
.filter((item) => item.changeRate > 0)
.sort((a, b) => b.changeRate - a.changeRate);
if (fluctuationMapped.length >= DASHBOARD_MARKET_HUB_LIMIT) {
return fluctuationMapped.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
// 변동률 순위 API가 빈 응답일 때 거래량 상위 중 상승률 상위로 폴백합니다.
const fallbackRows = await getDomesticVolumeRank(credentials, "0");
const fallbackGainers = fallbackRows
.filter((item) => item.changeRate > 0)
.sort((a, b) => b.changeRate - a.changeRate);
return dedupeMarketRankItems([...fluctuationMapped, ...fallbackGainers]).slice(
0,
DASHBOARD_MARKET_HUB_LIMIT,
);
}
async function getDomesticTopLosers(credentials?: KisCredentialInput) {
const fluctuationRows = await getDomesticFluctuationRows(credentials);
const fluctuationMapped = fluctuationRows
.map((row, index) => normalizeMarketRankItemFromFluctuation(row, index))
.filter((item): item is DomesticMarketRankItem => Boolean(item))
.filter((item) => item.changeRate < 0)
.sort((a, b) => a.changeRate - b.changeRate);
if (fluctuationMapped.length >= DASHBOARD_MARKET_HUB_LIMIT) {
return fluctuationMapped.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
const fallbackRows = await getDomesticVolumeRank(credentials, "0");
const fallbackLosers = fallbackRows
.filter((item) => item.changeRate < 0)
.sort((a, b) => a.changeRate - b.changeRate);
return dedupeMarketRankItems([...fluctuationMapped, ...fallbackLosers]).slice(
0,
DASHBOARD_MARKET_HUB_LIMIT,
);
}
async function getDomesticVolumeRank(
credentials: KisCredentialInput | undefined,
sortClassCode: "0" | "3",
) {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/volume-rank",
"FHPST01710000",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_COND_SCR_DIV_CODE: "20171",
FID_INPUT_ISCD: "0000",
FID_DIV_CLS_CODE: "0",
FID_BLNG_CLS_CODE: sortClassCode,
FID_TRGT_CLS_CODE: "111111111",
FID_TRGT_EXLS_CLS_CODE: "0000000000",
FID_INPUT_PRICE_1: "",
FID_INPUT_PRICE_2: "",
FID_VOL_CNT: "",
FID_INPUT_DATE_1: "",
},
credentials,
);
const rows = parseRows<KisVolumeRankOutputRow>(response.output);
return rows
.map((row, index) => normalizeMarketRankItemFromVolume(row, index))
.filter((item): item is DomesticMarketRankItem => Boolean(item))
.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
async function getDomesticNewsHeadlines(credentials?: KisCredentialInput) {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/news-title",
"FHKST01011800",
{
FID_NEWS_OFER_ENTP_CODE: "",
FID_COND_MRKT_CLS_CODE: "",
FID_INPUT_ISCD: "",
FID_TITL_CNTT: "",
FID_INPUT_DATE_1: "",
FID_INPUT_HOUR_1: "",
FID_RANK_SORT_CLS_CODE: "",
FID_INPUT_SRNO: "",
},
credentials,
);
const rows = parseRows<KisNewsTitleOutputRow>(response.output);
return rows
.map((row, index) => normalizeNewsHeadlineItem(row, index))
.filter((item): item is DomesticNewsHeadlineItem => Boolean(item))
.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
async function getDomesticFluctuationRows(credentials?: KisCredentialInput) {
const presets = [
{
fid_rank_sort_cls_code: "0000",
fid_trgt_cls_code: "0",
fid_trgt_exls_cls_code: "0",
fid_rsfl_rate1: "",
fid_rsfl_rate2: "",
},
{
fid_rank_sort_cls_code: "0",
fid_trgt_cls_code: "111111111",
fid_trgt_exls_cls_code: "0000000000",
fid_rsfl_rate1: "",
fid_rsfl_rate2: "",
},
] as const;
const errors: string[] = [];
for (const preset of presets) {
try {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/ranking/fluctuation",
"FHPST01700000",
{
fid_cond_mrkt_div_code: "J",
fid_cond_scr_div_code: "20170",
fid_input_iscd: "0000",
fid_rank_sort_cls_code: preset.fid_rank_sort_cls_code,
fid_input_cnt_1: "0",
fid_prc_cls_code: "0",
fid_input_price_1: "",
fid_input_price_2: "",
fid_vol_cnt: "",
fid_trgt_cls_code: preset.fid_trgt_cls_code,
fid_trgt_exls_cls_code: preset.fid_trgt_exls_cls_code,
fid_div_cls_code: "0",
fid_rsfl_rate1: preset.fid_rsfl_rate1,
fid_rsfl_rate2: preset.fid_rsfl_rate2,
},
credentials,
);
const rows = parseRows<KisFluctuationOutputRow>(response.output);
if (rows.length > 0) {
return rows;
}
} catch (error) {
errors.push(error instanceof Error ? error.message : "호출 실패");
}
}
if (errors.length > 0) {
throw new Error(errors.join(" | "));
}
return [] as KisFluctuationOutputRow[];
}
function normalizeMarketRankItemFromFluctuation(
row: KisFluctuationOutputRow,
index: number,
) {
const symbol = (row.stck_shrn_iscd ?? "").trim();
if (!/^\d{6}$/.test(symbol)) return null;
const signedChange = normalizeSignedValue(
toNumber(row.prdy_vrss),
row.prdy_vrss_sign,
);
const signedChangeRate = normalizeSignedValue(
toNumber(row.prdy_ctrt),
row.prdy_vrss_sign,
);
return {
rank: resolveRank(row.data_rank, index),
symbol,
name: (row.hts_kor_isnm ?? "").trim() || symbol,
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
price: toNumber(row.stck_prpr),
change: signedChange,
changeRate: signedChangeRate,
volume: toNumber(row.acml_vol),
tradingValue: toNumber(row.acml_tr_pbmn),
} satisfies DomesticMarketRankItem;
}
function normalizeMarketRankItemFromVolume(
row: KisVolumeRankOutputRow,
index: number,
) {
const symbol = (row.mksc_shrn_iscd ?? row.stck_shrn_iscd ?? "").trim();
if (!/^\d{6}$/.test(symbol)) return null;
const signedChange = normalizeSignedValue(
toNumber(row.prdy_vrss),
row.prdy_vrss_sign,
);
const signedChangeRate = normalizeSignedValue(
toNumber(row.prdy_ctrt),
row.prdy_vrss_sign,
);
return {
rank: resolveRank(row.data_rank, index),
symbol,
name: (row.hts_kor_isnm ?? "").trim() || symbol,
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
price: toNumber(row.stck_prpr),
change: signedChange,
changeRate: signedChangeRate,
volume: toNumber(row.acml_vol),
tradingValue: toNumber(row.acml_tr_pbmn),
} satisfies DomesticMarketRankItem;
}
function normalizeNewsHeadlineItem(row: KisNewsTitleOutputRow, index: number) {
const title = (row.hts_pbnt_titl_cntt ?? "").trim();
if (!title) return null;
const id = (row.cntt_usiq_srno ?? "").trim();
const symbols = [row.iscd1, row.iscd2, row.iscd3, row.iscd4, row.iscd5]
.map((value) => (value ?? "").trim())
.filter((value, position, all) => /^\d{6}$/.test(value) && all.indexOf(value) === position);
return {
id: id || `news-${index + 1}`,
title,
source: (row.dorg ?? row.news_ofer_entp_code ?? "").trim() || "KIS",
publishedAt: formatNewsTimestamp(row.data_dt, row.data_tm),
symbols,
} satisfies DomesticNewsHeadlineItem;
}
function dedupeMarketRankItems(items: DomesticMarketRankItem[]) {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.symbol)) return false;
seen.add(item.symbol);
return true;
});
}
function resolveRank(rawRank: string | undefined, fallbackIndex: number) {
const parsed = Math.floor(toNumber(rawRank));
if (parsed > 0) return parsed;
return fallbackIndex + 1;
}
function formatNewsTimestamp(rawDate?: string, rawTime?: string) {
const dateDigits = toDigits(rawDate);
if (dateDigits.length !== 8) return "-";
const timeDigits = normalizeTimeDigits(rawTime);
return `${formatDateLabel(dateDigits)} ${formatTimeLabel(timeDigits)}`;
}
/**
* 대시보드 하단의 주문내역/매매일지 데이터를 조회합니다.
* @param account KIS 계좌번호(8-2) 파트

View File

@@ -328,6 +328,9 @@ export function nowHmsInKst() {
}
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;

View File

@@ -459,6 +459,7 @@ export async function getDomesticDailyTimeChart(
* - 일봉/주봉: inquire-daily-itemchartprice (FHKST03010100), 과거 페이징 지원
* - 분봉 (오늘): inquire-time-itemchartprice (FHKST03010200)
* - 분봉 (과거): inquire-time-dailychartprice (FHKST03010230)
* - 지원 분봉: 1m/5m/10m/15m/30m/1h (서버에서 minute bucket 집계)
*/
export async function getDomesticChart(
symbol: string,
@@ -505,7 +506,7 @@ export async function getDomesticChart(
return { candles: parsed, hasMore: Boolean(nextCursor), nextCursor };
}
// ── 분봉 (1m / 30m / 1h) ──
// ── 분봉 (1m / 5m / 10m / 15m / 30m / 1h) ──
const minuteBucket = minutesForTimeframe(timeframe);
let rawRows: Array<Record<string, unknown>> = [];
let nextCursor: string | null = null;
@@ -522,44 +523,40 @@ export async function getDomesticChart(
);
// 다음 커서 계산
// 데이터가 있으면 가장 오래된 시간 - 1분? 혹은 해당 날짜의 09:00 도달 시 전일로 이동
// API가 시간 역순으로 데이터를 준다고 가정 (output[0]이 가장 최신, output[last]가 가장 과거)
// 실제 KIS API는 보통 최신순 정렬
if (rawRows.length > 0) {
// 가장 과거 데이터의 시간 확인
const oldestRow = rawRows[rawRows.length - 1]; // 마지막이 가장 과거라 가정
const oldestTime = readRowString(oldestRow, "stck_cntg_hour");
// 09:00:00보다 크면 계속 같은 날짜 페이징 (단, KIS가 120건씩 주므로)
// 만약 09시 근처라면 전일로 이동
// 간단히: 가져온 데이터 중 090000이 포함되어 있거나, 더 가져올 게 없어 보이면 전일로
// 여기서는 단순히 전일 153000으로 넘어가는 로직을 사용하거나,
// 현재 날짜에서 시간을 줄여서 재요청해야 함.
// KIS API가 '다음 커서'를 주지 않으므로, 마지막 데이터 시간을 기준으로 다음 요청
const oldestRow = rawRows[rawRows.length - 1];
const oldestTimeRaw = oldestRow
? readRowString(oldestRow, "stck_cntg_hour", "STCK_CNTG_HOUR")
: "";
const oldestDateRaw = oldestRow
? readRowString(oldestRow, "stck_bsop_date", "STCK_BSOP_DATE")
: "";
const oldestTime = /^\d{6}$/.test(oldestTimeRaw)
? oldestTimeRaw
: /^\d{4}$/.test(oldestTimeRaw)
? `${oldestTimeRaw}00`
: "";
const oldestDate = /^\d{8}$/.test(oldestDateRaw)
? oldestDateRaw
: targetDate;
if (oldestTime && Number(oldestTime) > 90000) {
// 같은 날짜, 시간만 조정 (1분 전)
// HHMMSS -> number -> subtract -> string
// 편의상 120개 꽉 찼으면 마지막 시간 사용, 아니면 전일로
if (rawRows.length >= 120) {
nextCursor = targetDate + oldestTime; // 다음 요청 시 이 시간 '이전'을 달라고 해야 함 (Inclusive 여부 확인 필요)
// 만약 Inclusive라면 -1분 해야 함. 안전하게 -1분 처리
// 시간 연산이 복잡하므로, 단순히 전일로 넘어가는 게 나을 수도 있으나,
// 하루치 분봉이 380개라 120개로는 부족함.
// 따라서 시간 연산 필요.
nextCursor = targetDate + subOneMinute(oldestTime);
} else {
// 120개 미만이면 장 시작까지 다 가져왔다고 가정 -> 전일로
nextCursor = shiftYmd(targetDate, -1) + "153000";
}
// 120건을 꽉 채웠으면 같은 날짜에서 더 과거 시간을, 아니면 전일로 이동합니다.
if (rawRows.length >= 120) {
const nextTime = subOneMinute(oldestTime);
nextCursor =
nextTime === targetTime
? shiftYmd(oldestDate, -1) + "153000"
: oldestDate + nextTime;
} else {
nextCursor = shiftYmd(oldestDate, -1) + "153000";
}
} else {
// 09:00 도달 -> 전일로
nextCursor = shiftYmd(targetDate, -1) + "153000";
nextCursor = shiftYmd(oldestDate, -1) + "153000";
}
} else {
// 데이터 없음 (휴장일 등) -> 전일로 계속 시도 (최대 5일? 무한 루프 방지 필요하나 UI에서 제어)
nextCursor = shiftYmd(targetDate, -1) + "153000";
// 너무 과거(1년)면 중단? 일단 생략
// 데이터 없음(휴장일 등)인 경우 전일 기준으로 한 번 더 탐색합니다.
nextCursor = shiftYmd(targetDate, -1) + "153000";
}
} else {

View File

@@ -1,4 +1,4 @@
import { kisPost } from "@/lib/kis/client";
import { kisGet, kisPost } from "@/lib/kis/client";
import { KisCredentialInput } from "@/lib/kis/config";
import {
DashboardOrderSide,
@@ -25,6 +25,14 @@ interface KisOrderCashBody {
ORD_UNPR: string; // 주문단가
}
interface KisInquirePsblOrderOutput {
ord_psbl_cash?: string; // 주문가능현금
nrcvb_buy_amt?: string; // 미수없는매수금액
max_buy_amt?: string; // 최대매수금액
nrcvb_buy_qty?: string; // 미수없는매수수량
max_buy_qty?: string; // 최대매수수량
}
/**
* 현금 주문(매수/매도) 실행
*/
@@ -62,6 +70,57 @@ export async function executeOrderCash(
return response.output ?? {};
}
/**
* 매수가능금액(주문가능현금) 조회
*/
export async function executeInquireOrderableCash(
params: {
symbol: string;
price: number;
orderType: DashboardOrderType;
accountNo: string;
accountProductCode: string;
},
credentials?: KisCredentialInput,
) {
const trId = resolveInquireOrderableTrId(credentials?.tradingEnv);
const ordDvsn = resolveOrderDivision(params.orderType);
const ordUnpr = Math.max(1, Math.floor(params.price || 0));
const response = await kisGet<KisInquirePsblOrderOutput | KisInquirePsblOrderOutput[]>(
"/uapi/domestic-stock/v1/trading/inquire-psbl-order",
trId,
{
CANO: params.accountNo,
ACNT_PRDT_CD: params.accountProductCode,
PDNO: params.symbol,
ORD_UNPR: String(ordUnpr),
ORD_DVSN: ordDvsn,
CMA_EVLU_AMT_ICLD_YN: "N",
OVRS_ICLD_YN: "N",
},
credentials,
);
const rawRow = Array.isArray(response.output)
? (response.output[0] ?? {})
: (response.output ?? {});
const orderableCash = pickFirstPositive(
toSafeNumber(rawRow.nrcvb_buy_amt),
toSafeNumber(rawRow.ord_psbl_cash),
toSafeNumber(rawRow.max_buy_amt),
);
return {
orderableCash,
noReceivableBuyAmount: toSafeNumber(rawRow.nrcvb_buy_amt),
maxBuyAmount: toSafeNumber(rawRow.max_buy_amt),
noReceivableBuyQuantity: Math.floor(toSafeNumber(rawRow.nrcvb_buy_qty)),
maxBuyQuantity: Math.floor(toSafeNumber(rawRow.max_buy_qty)),
};
}
function resolveOrderTrId(side: DashboardOrderSide, env?: "real" | "mock") {
const isMock = env === "mock";
if (side === "buy") {
@@ -73,8 +132,27 @@ function resolveOrderTrId(side: DashboardOrderSide, env?: "real" | "mock") {
}
}
function resolveInquireOrderableTrId(env?: "real" | "mock") {
return env === "mock" ? "VTTC8908R" : "TTTC8908R";
}
function resolveOrderDivision(type: DashboardOrderType) {
// 00: 지정가, 01: 시장가
if (type === "market") return "01";
return "00";
}
function toSafeNumber(value?: string) {
if (!value) return 0;
const parsed = Number(value.replaceAll(",", "").trim());
if (!Number.isFinite(parsed)) return 0;
return parsed;
}
function pickFirstPositive(...values: number[]) {
for (const value of values) {
if (value > 0) return value;
}
return 0;
}