159 lines
4.2 KiB
TypeScript
159 lines
4.2 KiB
TypeScript
import { kisGet, kisPost } from "@/lib/kis/client";
|
|
import { KisCredentialInput } from "@/lib/kis/config";
|
|
import {
|
|
DashboardOrderSide,
|
|
DashboardOrderType,
|
|
} from "@/features/trade/types/trade.types";
|
|
|
|
/**
|
|
* @file lib/kis/trade.ts
|
|
* @description KIS 주식 주문/잔고 관련 API
|
|
*/
|
|
|
|
export interface KisOrderCashOutput {
|
|
KRX_FWDG_ORD_ORGNO?: string; // 한국거래소전송주문조직번호
|
|
ODNO?: string; // 주문번호
|
|
ORD_TMD?: string; // 주문시각
|
|
}
|
|
|
|
interface KisOrderCashBody {
|
|
CANO: string; // 종합계좌번호(8자리)
|
|
ACNT_PRDT_CD: string; // 계좌상품코드(2자리)
|
|
PDNO: string; // 종목코드
|
|
ORD_DVSN: string; // 주문구분(00:지정가, 01:시장가...)
|
|
ORD_QTY: string; // 주문수량
|
|
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; // 최대매수수량
|
|
}
|
|
|
|
/**
|
|
* 현금 주문(매수/매도) 실행
|
|
*/
|
|
export async function executeOrderCash(
|
|
params: {
|
|
symbol: string;
|
|
side: DashboardOrderSide;
|
|
orderType: DashboardOrderType;
|
|
quantity: number;
|
|
price: number;
|
|
accountNo: string;
|
|
accountProductCode: string;
|
|
},
|
|
credentials?: KisCredentialInput,
|
|
) {
|
|
const trId = resolveOrderTrId(params.side, credentials?.tradingEnv);
|
|
const ordDvsn = resolveOrderDivision(params.orderType);
|
|
|
|
const body: KisOrderCashBody = {
|
|
CANO: params.accountNo,
|
|
ACNT_PRDT_CD: params.accountProductCode,
|
|
PDNO: params.symbol,
|
|
ORD_DVSN: ordDvsn,
|
|
ORD_QTY: String(params.quantity),
|
|
ORD_UNPR: String(params.price),
|
|
};
|
|
|
|
const response = await kisPost<KisOrderCashOutput>(
|
|
"/uapi/domestic-stock/v1/trading/order-cash",
|
|
trId,
|
|
body,
|
|
credentials,
|
|
);
|
|
|
|
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") {
|
|
// 매수
|
|
return isMock ? "VTTC0802U" : "TTTC0802U";
|
|
} else {
|
|
// 매도
|
|
return isMock ? "VTTC0801U" : "TTTC0801U";
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|