kiwoom-mcp-server 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +227 -0
- package/README.md +217 -0
- package/dist/config.js +61 -0
- package/dist/context.js +16 -0
- package/dist/index.js +15 -0
- package/dist/isa/classify-etf.js +84 -0
- package/dist/isa/classify.js +198 -0
- package/dist/isa/realized.js +55 -0
- package/dist/isa/tax.js +53 -0
- package/dist/kiwoom/api.js +372 -0
- package/dist/kiwoom/auth.js +89 -0
- package/dist/kiwoom/client.js +104 -0
- package/dist/kiwoom/errors.js +15 -0
- package/dist/kiwoom/master-list.js +25 -0
- package/dist/kiwoom/types.js +385 -0
- package/dist/server.js +54 -0
- package/dist/tools/account-balance.js +36 -0
- package/dist/tools/account-holdings.js +52 -0
- package/dist/tools/etf-info.js +44 -0
- package/dist/tools/foreign-holding.js +61 -0
- package/dist/tools/helpers.js +19 -0
- package/dist/tools/investor-trend.js +77 -0
- package/dist/tools/isa-tax-status.js +245 -0
- package/dist/tools/market-index.js +41 -0
- package/dist/tools/orderbook.js +50 -0
- package/dist/tools/pending-orders.js +67 -0
- package/dist/tools/ping.js +14 -0
- package/dist/tools/ranking.js +83 -0
- package/dist/tools/short-selling.js +66 -0
- package/dist/tools/stock-chart.js +94 -0
- package/dist/tools/stock-price.js +66 -0
- package/dist/tools/stock-search.js +63 -0
- package/dist/tools/theme.js +120 -0
- package/dist/tools/trading-journal.js +61 -0
- package/dist/tools/transactions.js +80 -0
- package/dist/tools/watchlist.js +115 -0
- package/dist/utils/date.js +15 -0
- package/dist/utils/num.js +67 -0
- package/dist/utils/redact.js +14 -0
- package/dist/utils/sleep.js +3 -0
- package/package.json +55 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISA 손익통산을 위한 종목 과세유형 분류.
|
|
3
|
+
*
|
|
4
|
+
* - DOMESTIC_EQUITY: 국내 상장주식·국내주식형 ETF — 매매차익은 비과세(통산 이익
|
|
5
|
+
* 미포함), 매매손실은 통산 시 차감.
|
|
6
|
+
* - TAXABLE: 그 외(해외지수·채권·원자재·파생형 ETF 등) — 매매손익이 배당소득으로
|
|
7
|
+
* 과세되어 통산에 포함.
|
|
8
|
+
*
|
|
9
|
+
* 과세유형은 API가 제공하지 않으므로 종목명 휴리스틱으로 추정한다. 분류가 틀릴 수
|
|
10
|
+
* 있으므로 반드시 `confident` 플래그를 함께 전달하고, 호출자(tool)는 overrides로
|
|
11
|
+
* 수동 지정을 받을 수 있어야 한다.
|
|
12
|
+
*/
|
|
13
|
+
/** 국내 상장 ETF/ETN 브랜드 접두어 (2026 기준 주요 운용사). */
|
|
14
|
+
const ETF_BRANDS = [
|
|
15
|
+
"KODEX",
|
|
16
|
+
"TIGER",
|
|
17
|
+
"KIWOOM",
|
|
18
|
+
"RISE",
|
|
19
|
+
"KBSTAR",
|
|
20
|
+
"SOL",
|
|
21
|
+
"ACE",
|
|
22
|
+
"PLUS",
|
|
23
|
+
"ARIRANG",
|
|
24
|
+
"HANARO",
|
|
25
|
+
"KOSEF",
|
|
26
|
+
"WON",
|
|
27
|
+
"1Q",
|
|
28
|
+
"TIMEFOLIO",
|
|
29
|
+
"FOCUS",
|
|
30
|
+
"UNICORN",
|
|
31
|
+
"KTOP",
|
|
32
|
+
"TREX",
|
|
33
|
+
"파워",
|
|
34
|
+
"마이다스",
|
|
35
|
+
"BNK",
|
|
36
|
+
"HK",
|
|
37
|
+
"ITF",
|
|
38
|
+
"KCGI",
|
|
39
|
+
"DAISHIN343",
|
|
40
|
+
"히어로즈",
|
|
41
|
+
];
|
|
42
|
+
/** 이름에 이 키워드가 있으면 과세대상(기타형)으로 판단. 도메스틱 키워드보다 우선. */
|
|
43
|
+
const TAXABLE_KEYWORDS = [
|
|
44
|
+
// 해외 지역/지수
|
|
45
|
+
"미국",
|
|
46
|
+
"나스닥",
|
|
47
|
+
"S&P",
|
|
48
|
+
"다우",
|
|
49
|
+
"필라델피아",
|
|
50
|
+
"글로벌",
|
|
51
|
+
"월드",
|
|
52
|
+
"선진",
|
|
53
|
+
"신흥",
|
|
54
|
+
"차이나",
|
|
55
|
+
"중국",
|
|
56
|
+
"항셍",
|
|
57
|
+
"일본",
|
|
58
|
+
"닛케이",
|
|
59
|
+
"인도",
|
|
60
|
+
"베트남",
|
|
61
|
+
"대만",
|
|
62
|
+
"유럽",
|
|
63
|
+
"유로",
|
|
64
|
+
"독일",
|
|
65
|
+
// 채권/금리/통화
|
|
66
|
+
"채권",
|
|
67
|
+
"국채",
|
|
68
|
+
"국고채",
|
|
69
|
+
"회사채",
|
|
70
|
+
"단기채",
|
|
71
|
+
"종합채",
|
|
72
|
+
"금리",
|
|
73
|
+
"KOFR",
|
|
74
|
+
"SOFR",
|
|
75
|
+
"CD금리",
|
|
76
|
+
"머니마켓",
|
|
77
|
+
"MMF",
|
|
78
|
+
"달러",
|
|
79
|
+
"엔화",
|
|
80
|
+
"엔선물",
|
|
81
|
+
"위안",
|
|
82
|
+
// 원자재/대체
|
|
83
|
+
"골드",
|
|
84
|
+
"금현물",
|
|
85
|
+
"금선물",
|
|
86
|
+
"은현물",
|
|
87
|
+
"은선물",
|
|
88
|
+
"원유",
|
|
89
|
+
"원자재",
|
|
90
|
+
"구리",
|
|
91
|
+
"니켈",
|
|
92
|
+
"리츠",
|
|
93
|
+
"부동산",
|
|
94
|
+
"인프라",
|
|
95
|
+
"하이일드",
|
|
96
|
+
// 파생/구조형 (국내 기초자산이어도 과세대상)
|
|
97
|
+
"레버리지",
|
|
98
|
+
"인버스",
|
|
99
|
+
"커버드콜",
|
|
100
|
+
"합성",
|
|
101
|
+
"(H)",
|
|
102
|
+
"TDF",
|
|
103
|
+
"TRF",
|
|
104
|
+
];
|
|
105
|
+
/** 국내주식형 ETF로 판단하는 키워드 (TAXABLE 키워드에 안 걸렸을 때만). */
|
|
106
|
+
const DOMESTIC_KEYWORDS = [
|
|
107
|
+
"코스피",
|
|
108
|
+
"코스닥",
|
|
109
|
+
"KRX",
|
|
110
|
+
"200",
|
|
111
|
+
"150",
|
|
112
|
+
"100",
|
|
113
|
+
"300",
|
|
114
|
+
"TOP",
|
|
115
|
+
"배당",
|
|
116
|
+
"그룹",
|
|
117
|
+
"밸류",
|
|
118
|
+
"성장",
|
|
119
|
+
"모멘텀",
|
|
120
|
+
"로우볼",
|
|
121
|
+
"삼성",
|
|
122
|
+
"반도체",
|
|
123
|
+
"자동차",
|
|
124
|
+
"은행",
|
|
125
|
+
"증권",
|
|
126
|
+
"보험",
|
|
127
|
+
"바이오",
|
|
128
|
+
"헬스케어",
|
|
129
|
+
"2차전지",
|
|
130
|
+
"소프트웨어",
|
|
131
|
+
"게임",
|
|
132
|
+
"엔터",
|
|
133
|
+
"방산",
|
|
134
|
+
"조선",
|
|
135
|
+
];
|
|
136
|
+
/**
|
|
137
|
+
* 이름이 국내 상장 ETF/ETN 브랜드로 시작하는지 여부. `classifyInstrument`의 ETF
|
|
138
|
+
* 판정과 동일한 기준이며, ka40002 조회 대상(브랜드 ETF)을 고르는 게이트로도 쓴다.
|
|
139
|
+
*/
|
|
140
|
+
export function isLikelyEtf(name) {
|
|
141
|
+
const upper = name.toUpperCase().trim();
|
|
142
|
+
return ETF_BRANDS.some((brand) => upper.startsWith(brand.toUpperCase()));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 키움 ka40002 `etftxon_type`(ETF 과세유형)을 ISA 손익통산 분류로 매핑한다.
|
|
146
|
+
* 권위 있는 값이 있을 때만 사용하고, 알 수 없거나 빈 값이면 null을 반환해
|
|
147
|
+
* 호출자가 종목명 휴리스틱으로 폴백하도록 한다.
|
|
148
|
+
*
|
|
149
|
+
* - "비과세" → DOMESTIC_EQUITY (국내주식형 — 매매차익 비과세, 손실만 통산 차감)
|
|
150
|
+
* - "보유기간과세" → TAXABLE (기타형 — 매매손익이 배당소득 과세, 통산 포함)
|
|
151
|
+
*/
|
|
152
|
+
export function mapEtfTaxonType(raw) {
|
|
153
|
+
const t = raw.trim();
|
|
154
|
+
if (t.includes("보유기간과세"))
|
|
155
|
+
return "TAXABLE";
|
|
156
|
+
if (t.includes("비과세"))
|
|
157
|
+
return "DOMESTIC_EQUITY";
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* @param isEtfHint ETF 여부를 외부(ka10099 마스터리스트 `marketName == "ETF"`)에서 알 때
|
|
162
|
+
* 전달한다. 브랜드 접두어가 없는 ETF도 ETF로 취급해 개별주식 오분류를 막는다.
|
|
163
|
+
* 생략하면 종목명 브랜드 접두어(`isLikelyEtf`)로 판정한다.
|
|
164
|
+
*/
|
|
165
|
+
export function classifyInstrument(code, name, overrides, isEtfHint) {
|
|
166
|
+
const override = overrides?.get(code);
|
|
167
|
+
if (override) {
|
|
168
|
+
return { taxType: override, confident: true, reason: "수동 지정" };
|
|
169
|
+
}
|
|
170
|
+
const upper = name.toUpperCase().trim();
|
|
171
|
+
const isEtf = isEtfHint ?? isLikelyEtf(name);
|
|
172
|
+
if (!isEtf) {
|
|
173
|
+
return {
|
|
174
|
+
taxType: "DOMESTIC_EQUITY",
|
|
175
|
+
confident: true,
|
|
176
|
+
reason: "국내 개별주식 — 매매차익 비과세, 손실은 통산 차감",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (TAXABLE_KEYWORDS.some((k) => upper.includes(k.toUpperCase()))) {
|
|
180
|
+
return {
|
|
181
|
+
taxType: "TAXABLE",
|
|
182
|
+
confident: true,
|
|
183
|
+
reason: "해외/채권/원자재/파생형 ETF — 매매손익 통산 포함",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (DOMESTIC_KEYWORDS.some((k) => upper.includes(k.toUpperCase()))) {
|
|
187
|
+
return {
|
|
188
|
+
taxType: "DOMESTIC_EQUITY",
|
|
189
|
+
confident: true,
|
|
190
|
+
reason: "국내주식형 ETF — 매매차익 비과세, 손실은 통산 차감",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
taxType: "TAXABLE",
|
|
195
|
+
confident: false,
|
|
196
|
+
reason: "유형 미확인 — 보수적으로 과세대상 가정 (overrides로 수정 가능)",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kt00015 매매 내역으로 종목별 실현손익을 이동평균 원가법으로 재구성한다.
|
|
3
|
+
*
|
|
4
|
+
* 배경(라이브 검증): 키움 REST의 종목별 실현손익 TR(ka10072/ka10073)이 이 계좌
|
|
5
|
+
* 유형에서 빈 결과만 반환해, 계좌 전체 합계 TR(ka10074)을 검증값으로 두고 거래
|
|
6
|
+
* 내역에서 직접 재구성한다. 매수 정산금액(exct_amt)은 수수료 포함 원가, 매도
|
|
7
|
+
* 정산금액은 수수료 차감 후 실수령액이므로 증권사 정산과 같은 기준이 된다.
|
|
8
|
+
*/
|
|
9
|
+
export function reconstructRealizedPnl(events) {
|
|
10
|
+
const ordered = [...events].sort((a, b) => a.date === b.date ? a.seq - b.seq : a.date.localeCompare(b.date));
|
|
11
|
+
const positions = new Map();
|
|
12
|
+
const results = new Map();
|
|
13
|
+
const warnings = [];
|
|
14
|
+
const resultOf = (event) => {
|
|
15
|
+
let r = results.get(event.code);
|
|
16
|
+
if (!r) {
|
|
17
|
+
r = { code: event.code, name: event.name, realized: 0, soldQuantity: 0, incompleteHistory: false };
|
|
18
|
+
results.set(event.code, r);
|
|
19
|
+
}
|
|
20
|
+
return r;
|
|
21
|
+
};
|
|
22
|
+
for (const event of ordered) {
|
|
23
|
+
if (event.quantity <= 0)
|
|
24
|
+
continue;
|
|
25
|
+
const position = positions.get(event.code) ?? { quantity: 0, cost: 0 };
|
|
26
|
+
positions.set(event.code, position);
|
|
27
|
+
if (event.side === "BUY") {
|
|
28
|
+
position.quantity += event.quantity;
|
|
29
|
+
position.cost += event.netAmount;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
// SELL
|
|
33
|
+
const result = resultOf(event);
|
|
34
|
+
const covered = Math.min(event.quantity, position.quantity);
|
|
35
|
+
if (covered < event.quantity) {
|
|
36
|
+
result.incompleteHistory = true;
|
|
37
|
+
warnings.push(`${event.name}(${event.code}): 집계 시작일 이전 취득분 매도가 감지되어 실현손익이 불완전합니다. ` +
|
|
38
|
+
`from_date가 계좌 개설일을 포함하는지 확인하세요.`);
|
|
39
|
+
}
|
|
40
|
+
if (covered > 0) {
|
|
41
|
+
const avgCost = position.cost / position.quantity;
|
|
42
|
+
const coveredProceeds = event.netAmount * (covered / event.quantity);
|
|
43
|
+
result.realized += coveredProceeds - avgCost * covered;
|
|
44
|
+
result.soldQuantity += covered;
|
|
45
|
+
position.quantity -= covered;
|
|
46
|
+
position.cost -= avgCost * covered;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const perStock = [...results.values()];
|
|
50
|
+
return {
|
|
51
|
+
perStock,
|
|
52
|
+
total: perStock.reduce((sum, r) => sum + r.realized, 0),
|
|
53
|
+
warnings,
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/isa/tax.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** 조특법상 ISA 비과세 한도 (원). */
|
|
2
|
+
export const ISA_LIMITS = {
|
|
3
|
+
GENERAL: 2_000_000,
|
|
4
|
+
SEOMIN: 4_000_000,
|
|
5
|
+
};
|
|
6
|
+
/** 한도 초과분 분리과세율 (지방소득세 포함 9.9%). */
|
|
7
|
+
export const ISA_TAX_RATE = 0.099;
|
|
8
|
+
function sumByType(items, taxType) {
|
|
9
|
+
return items.filter((i) => i.taxType === taxType).reduce((sum, i) => sum + i.amount, 0);
|
|
10
|
+
}
|
|
11
|
+
function settle(net, limit) {
|
|
12
|
+
const used = Math.max(0, net);
|
|
13
|
+
return {
|
|
14
|
+
remaining: Math.max(0, limit - used),
|
|
15
|
+
tax: Math.max(0, used - limit) * ISA_TAX_RATE,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* ISA 손익통산 계산.
|
|
20
|
+
*
|
|
21
|
+
* 규칙 (조특법 제91조의18 기준, 단순화):
|
|
22
|
+
* - 과세대상(기타형) 상품의 실현손익은 이익·손실 모두 통산에 포함.
|
|
23
|
+
* - 국내주식형 상품의 실현이익은 애초에 비과세라 통산에서 제외하되,
|
|
24
|
+
* 클래스 합산이 순손실이면 그 손실은 과세소득에서 차감.
|
|
25
|
+
* - 배당·분배금·이자는 전액 통산 포함.
|
|
26
|
+
* - 미실현 손익은 통산에 포함되지 않으며, "전량 매도 시" 시나리오로만 제공.
|
|
27
|
+
*/
|
|
28
|
+
export function computeIsaTaxStatus(input) {
|
|
29
|
+
const taxableRealized = sumByType(input.realized, "TAXABLE");
|
|
30
|
+
const domesticRealizedNet = sumByType(input.realized, "DOMESTIC_EQUITY");
|
|
31
|
+
const domesticLossDeduction = Math.min(0, domesticRealizedNet);
|
|
32
|
+
const confirmedNet = input.dividends + taxableRealized + domesticLossDeduction;
|
|
33
|
+
const confirmed = settle(confirmedNet, input.limit);
|
|
34
|
+
const taxableUnrealized = sumByType(input.unrealized, "TAXABLE");
|
|
35
|
+
const domesticUnrealizedNet = sumByType(input.unrealized, "DOMESTIC_EQUITY");
|
|
36
|
+
const scenarioNet = confirmedNet + taxableUnrealized + Math.min(0, domesticUnrealizedNet);
|
|
37
|
+
const scenario = settle(scenarioNet, input.limit);
|
|
38
|
+
return {
|
|
39
|
+
limit: input.limit,
|
|
40
|
+
dividends: input.dividends,
|
|
41
|
+
taxableRealized,
|
|
42
|
+
domesticRealizedNet,
|
|
43
|
+
domesticLossDeduction,
|
|
44
|
+
confirmedNet,
|
|
45
|
+
remainingAllowance: confirmed.remaining,
|
|
46
|
+
estimatedTaxNow: confirmed.tax,
|
|
47
|
+
taxableUnrealized,
|
|
48
|
+
domesticUnrealizedNet,
|
|
49
|
+
scenarioNet,
|
|
50
|
+
scenarioRemaining: scenario.remaining,
|
|
51
|
+
scenarioEstimatedTax: scenario.tax,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { sleep } from "../utils/sleep.js";
|
|
3
|
+
import { KiwoomApiError } from "./errors.js";
|
|
4
|
+
import { accountEvaluationResponseSchema, allIndexResponseSchema, dailyChartItemSchema, depositResponseSchema, etfInfoResponseSchema, foreignHoldingResponseSchema, investorDailyItemSchema, investorTotalItemSchema, minuteChartItemSchema, orderbookResponseSchema, pendingOrdersResponseSchema, priceChangeRankItemSchema, realizedPnlResponseSchema, shortSellingResponseSchema, stockInfoResponseSchema, stockListResponseSchema, themeGroupsResponseSchema, tradingJournalResponseSchema, themeStocksResponseSchema, transactionsResponseSchema, valueRankItemSchema, volumeRankItemSchema, watchlistGroupDetailResponseSchema, watchlistGroupsResponseSchema, } from "./types.js";
|
|
5
|
+
const STOCK_INFO_PATH = "/api/dostk/stkinfo";
|
|
6
|
+
const ACCOUNT_PATH = "/api/dostk/acnt";
|
|
7
|
+
const CHART_PATH = "/api/dostk/chart";
|
|
8
|
+
const MRKCOND_PATH = "/api/dostk/mrkcond";
|
|
9
|
+
const SECTOR_PATH = "/api/dostk/sect";
|
|
10
|
+
const RANK_PATH = "/api/dostk/rkinfo";
|
|
11
|
+
const ETF_PATH = "/api/dostk/etf";
|
|
12
|
+
const WATCHLIST_PATH = "/api/dostk/watchlist";
|
|
13
|
+
const THEME_PATH = "/api/dostk/thme";
|
|
14
|
+
const SHORT_PATH = "/api/dostk/shsa";
|
|
15
|
+
const FOREIGN_PATH = "/api/dostk/frgnistt";
|
|
16
|
+
/** Pulls an array out of an already-envelope-checked response body. */
|
|
17
|
+
function parseArray(json, key, itemSchema) {
|
|
18
|
+
const value = json[key];
|
|
19
|
+
return z.array(itemSchema).parse(Array.isArray(value) ? value : []);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Safety cap for cont-yn pagination. Bounds worst-case latency (each page is
|
|
23
|
+
* ~1.1s apart, so up to ~22s). A typical retail/ISA account never comes close,
|
|
24
|
+
* but a heavy account could — when the cap is hit, callers surface a
|
|
25
|
+
* "results may be truncated" warning instead of silently dropping data.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_PAGES = 20;
|
|
28
|
+
/** Per-TR rate limit is ~1 req/s — space out continuation pages. */
|
|
29
|
+
const PAGE_INTERVAL_MS = 1_100;
|
|
30
|
+
/** ka10001 주식기본정보요청 */
|
|
31
|
+
export async function fetchStockInfo(client, stockCode) {
|
|
32
|
+
const res = await client.call({
|
|
33
|
+
path: STOCK_INFO_PATH,
|
|
34
|
+
apiId: "ka10001",
|
|
35
|
+
body: { stk_cd: stockCode },
|
|
36
|
+
});
|
|
37
|
+
const info = stockInfoResponseSchema.parse(res.json);
|
|
38
|
+
if (!info.stk_nm && !info.cur_prc) {
|
|
39
|
+
throw new KiwoomApiError(`종목코드 ${stockCode}에 대한 시세 정보가 없습니다. 코드를 확인해 주세요.`, {
|
|
40
|
+
apiId: "ka10001",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return info;
|
|
44
|
+
}
|
|
45
|
+
/** kt00001 예수금상세현황요청 (qry_tp: 3=추정조회, 2=일반조회) */
|
|
46
|
+
export async function fetchDeposit(client, qryTp = "3") {
|
|
47
|
+
const res = await client.call({
|
|
48
|
+
path: ACCOUNT_PATH,
|
|
49
|
+
apiId: "kt00001",
|
|
50
|
+
body: { qry_tp: qryTp },
|
|
51
|
+
});
|
|
52
|
+
return depositResponseSchema.parse(res.json);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* kt00018 계좌평가잔고내역요청 (qry_tp: 1=합산, 2=개별).
|
|
56
|
+
* Follows cont-yn/next-key continuation headers and merges all holding rows.
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchAccountEvaluation(client, qryTp) {
|
|
59
|
+
const body = { qry_tp: qryTp, dmst_stex_tp: "KRX" };
|
|
60
|
+
let res = await client.call({ path: ACCOUNT_PATH, apiId: "kt00018", body });
|
|
61
|
+
const first = accountEvaluationResponseSchema.parse(res.json);
|
|
62
|
+
const holdings = [...first.acnt_evlt_remn_indv_tot];
|
|
63
|
+
let pages = 1;
|
|
64
|
+
while (res.hasNext && pages < MAX_PAGES) {
|
|
65
|
+
await sleep(PAGE_INTERVAL_MS);
|
|
66
|
+
res = await client.call({ path: ACCOUNT_PATH, apiId: "kt00018", body, contYn: "Y", nextKey: res.nextKey });
|
|
67
|
+
const page = accountEvaluationResponseSchema.parse(res.json);
|
|
68
|
+
holdings.push(...page.acnt_evlt_remn_indv_tot);
|
|
69
|
+
pages += 1;
|
|
70
|
+
}
|
|
71
|
+
// 남은 페이지가 있는데 상한에서 멈췄으면 데이터가 잘렸다는 뜻.
|
|
72
|
+
return { ...first, acnt_evlt_remn_indv_tot: holdings, truncated: res.hasNext };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* kt00015 위탁종합거래내역요청 — all transactions in [fromDate, toDate] (yyyyMMdd).
|
|
76
|
+
* NOTE (live-verified): only 매매 rows are returned for this account type; cash
|
|
77
|
+
* deposit rows never appeared under any tp/gds_tp combination. Dividend rows
|
|
78
|
+
* are therefore unverified — callers must handle their possible absence.
|
|
79
|
+
*/
|
|
80
|
+
export async function fetchTransactions(client, fromDate, toDate) {
|
|
81
|
+
const body = { strt_dt: fromDate, end_dt: toDate, tp: "0", gds_tp: "0", dmst_stex_tp: "KRX" };
|
|
82
|
+
let res = await client.call({ path: ACCOUNT_PATH, apiId: "kt00015", body });
|
|
83
|
+
const rows = [...transactionsResponseSchema.parse(res.json).trst_ovrl_trde_prps_array];
|
|
84
|
+
let pages = 1;
|
|
85
|
+
while (res.hasNext && pages < MAX_PAGES) {
|
|
86
|
+
await sleep(PAGE_INTERVAL_MS);
|
|
87
|
+
res = await client.call({ path: ACCOUNT_PATH, apiId: "kt00015", body, contYn: "Y", nextKey: res.nextKey });
|
|
88
|
+
rows.push(...transactionsResponseSchema.parse(res.json).trst_ovrl_trde_prps_array);
|
|
89
|
+
pages += 1;
|
|
90
|
+
}
|
|
91
|
+
// 남은 페이지가 있는데 상한에서 멈췄으면 오래된 거래가 잘렸다는 뜻.
|
|
92
|
+
return { rows, truncated: res.hasNext };
|
|
93
|
+
}
|
|
94
|
+
/** ka10074 일자별실현손익요청 — account-wide realized P&L summary for the period. */
|
|
95
|
+
export async function fetchRealizedPnlSummary(client, fromDate, toDate) {
|
|
96
|
+
const res = await client.call({
|
|
97
|
+
path: ACCOUNT_PATH,
|
|
98
|
+
apiId: "ka10074",
|
|
99
|
+
body: { strt_dt: fromDate, end_dt: toDate },
|
|
100
|
+
});
|
|
101
|
+
return realizedPnlResponseSchema.parse(res.json);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* ka10075 미체결요청 — currently open (unfilled) orders. Read-only.
|
|
105
|
+
* body: all_stk_tp "0"=전체/"1"=종목, trde_tp "0"=전체, stex_tp "0"=통합.
|
|
106
|
+
* Array key `oso` live-verified 2026-07-07 (empty — no open orders on the
|
|
107
|
+
* verification account); item fields are wrapper-sourced, not live-verified. Open orders are
|
|
108
|
+
* few by nature, so only the first page is fetched (no pagination).
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchPendingOrders(client, stockCode) {
|
|
111
|
+
const res = await client.call({
|
|
112
|
+
path: ACCOUNT_PATH,
|
|
113
|
+
apiId: "ka10075",
|
|
114
|
+
body: {
|
|
115
|
+
all_stk_tp: stockCode ? "1" : "0",
|
|
116
|
+
trde_tp: "0",
|
|
117
|
+
stk_cd: stockCode ?? "",
|
|
118
|
+
stex_tp: "0",
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return pendingOrdersResponseSchema.parse(res.json).oso;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* ka10099 종목정보요약 — full instrument list for one market (stocks + ETF/ETN).
|
|
125
|
+
* mrkt_tp: "0"=코스피(거래소), "10"=코스닥. Single page (live-verified ~2500 rows).
|
|
126
|
+
*/
|
|
127
|
+
export async function fetchStockList(client, marketCode) {
|
|
128
|
+
const res = await client.call({
|
|
129
|
+
path: STOCK_INFO_PATH,
|
|
130
|
+
apiId: "ka10099",
|
|
131
|
+
body: { mrkt_tp: marketCode },
|
|
132
|
+
});
|
|
133
|
+
return stockListResponseSchema.parse(res.json).list;
|
|
134
|
+
}
|
|
135
|
+
const CHART_TRS = {
|
|
136
|
+
day: { apiId: "ka10081", arrayKey: "stk_dt_pole_chart_qry" },
|
|
137
|
+
week: { apiId: "ka10082", arrayKey: "stk_stk_pole_chart_qry" },
|
|
138
|
+
month: { apiId: "ka10083", arrayKey: "stk_mth_pole_chart_qry" },
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* ka10081/82/83 일·주·월봉 — newest first; first page (240~600 rows) only,
|
|
142
|
+
* which is plenty for LLM consumption and avoids pagination rate-limit cost.
|
|
143
|
+
* upd_stkpc_tp "1" = 수정주가 반영.
|
|
144
|
+
*/
|
|
145
|
+
export async function fetchDailyChart(client, stockCode, period, baseDate) {
|
|
146
|
+
const { apiId, arrayKey } = CHART_TRS[period];
|
|
147
|
+
const res = await client.call({
|
|
148
|
+
path: CHART_PATH,
|
|
149
|
+
apiId,
|
|
150
|
+
body: { stk_cd: stockCode, base_dt: baseDate, upd_stkpc_tp: "1" },
|
|
151
|
+
});
|
|
152
|
+
return parseArray(res.json, arrayKey, dailyChartItemSchema);
|
|
153
|
+
}
|
|
154
|
+
/** ka10080 분봉 — tic_scope in minutes ("1"|"3"|"5"|"10"|"15"|"30"|"45"|"60"). */
|
|
155
|
+
export async function fetchMinuteChart(client, stockCode, ticScope) {
|
|
156
|
+
const res = await client.call({
|
|
157
|
+
path: CHART_PATH,
|
|
158
|
+
apiId: "ka10080",
|
|
159
|
+
body: { stk_cd: stockCode, tic_scope: ticScope, upd_stkpc_tp: "1" },
|
|
160
|
+
});
|
|
161
|
+
return parseArray(res.json, "stk_min_pole_chart_qry", minuteChartItemSchema);
|
|
162
|
+
}
|
|
163
|
+
/** ka10004 주식호가 — 10-level orderbook; levels 2-10 live in the loose passthrough. */
|
|
164
|
+
export async function fetchOrderbook(client, stockCode) {
|
|
165
|
+
const res = await client.call({
|
|
166
|
+
path: MRKCOND_PATH,
|
|
167
|
+
apiId: "ka10004",
|
|
168
|
+
body: { stk_cd: stockCode },
|
|
169
|
+
});
|
|
170
|
+
return orderbookResponseSchema.parse(res.json);
|
|
171
|
+
}
|
|
172
|
+
/** ka20003 전업종지수 — inds_cd "001"=코스피 그룹(31개), "101"=코스닥 그룹(34개). */
|
|
173
|
+
export async function fetchAllIndices(client, indsCd) {
|
|
174
|
+
const res = await client.call({
|
|
175
|
+
path: SECTOR_PATH,
|
|
176
|
+
apiId: "ka20003",
|
|
177
|
+
body: { inds_cd: indsCd },
|
|
178
|
+
});
|
|
179
|
+
return allIndexResponseSchema.parse(res.json).all_inds_idex;
|
|
180
|
+
}
|
|
181
|
+
/** amt_qty_tp: "1"=금액(백만원), "2"=수량(주) — 순매수(trde_tp "0") 기준. */
|
|
182
|
+
const INVESTOR_UNIT_CODES = { amount: "1", quantity: "2" };
|
|
183
|
+
/** ka10061 종목별투자자기관종합 — investor net-buy totals over a date range. */
|
|
184
|
+
export async function fetchInvestorTotal(client, stockCode, fromDate, toDate, unit) {
|
|
185
|
+
const res = await client.call({
|
|
186
|
+
path: STOCK_INFO_PATH,
|
|
187
|
+
apiId: "ka10061",
|
|
188
|
+
body: {
|
|
189
|
+
stk_cd: stockCode,
|
|
190
|
+
strt_dt: fromDate,
|
|
191
|
+
end_dt: toDate,
|
|
192
|
+
amt_qty_tp: INVESTOR_UNIT_CODES[unit],
|
|
193
|
+
trde_tp: "0",
|
|
194
|
+
unit_tp: "1",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return parseArray(res.json, "stk_invsr_orgn_tot", investorTotalItemSchema)[0];
|
|
198
|
+
}
|
|
199
|
+
/** ka10059 종목별투자자기관 — daily investor net-buy rows, newest first from `date`. */
|
|
200
|
+
export async function fetchInvestorDaily(client, stockCode, date, unit) {
|
|
201
|
+
const res = await client.call({
|
|
202
|
+
path: STOCK_INFO_PATH,
|
|
203
|
+
apiId: "ka10059",
|
|
204
|
+
body: {
|
|
205
|
+
dt: date,
|
|
206
|
+
stk_cd: stockCode,
|
|
207
|
+
amt_qty_tp: INVESTOR_UNIT_CODES[unit],
|
|
208
|
+
trde_tp: "0",
|
|
209
|
+
unit_tp: "1",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
return parseArray(res.json, "stk_invsr_orgn", investorDailyItemSchema);
|
|
213
|
+
}
|
|
214
|
+
/** mrkt_tp codes shared by the rank TRs (live-verified 000/001; 101 per legacy convention). */
|
|
215
|
+
const RANKING_MARKET_CODES = {
|
|
216
|
+
all: "000",
|
|
217
|
+
kospi: "001",
|
|
218
|
+
kosdaq: "101",
|
|
219
|
+
};
|
|
220
|
+
/** ka10027 전일대비상위 — sort_tp "1"=상승률, "3"=하락률 (live-verified). */
|
|
221
|
+
export async function fetchPriceChangeRanking(client, market, direction) {
|
|
222
|
+
const res = await client.call({
|
|
223
|
+
path: RANK_PATH,
|
|
224
|
+
apiId: "ka10027",
|
|
225
|
+
body: {
|
|
226
|
+
mrkt_tp: RANKING_MARKET_CODES[market],
|
|
227
|
+
sort_tp: direction === "rise" ? "1" : "3",
|
|
228
|
+
trde_qty_cnd: "0000",
|
|
229
|
+
stk_cnd: "0",
|
|
230
|
+
crd_cnd: "0",
|
|
231
|
+
updown_incls: "1",
|
|
232
|
+
pric_cnd: "0",
|
|
233
|
+
trde_prica_cnd: "0",
|
|
234
|
+
stex_tp: "1",
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
return parseArray(res.json, "pred_pre_flu_rt_upper", priceChangeRankItemSchema);
|
|
238
|
+
}
|
|
239
|
+
/** ka10030 당일거래량상위. */
|
|
240
|
+
export async function fetchVolumeRanking(client, market) {
|
|
241
|
+
const res = await client.call({
|
|
242
|
+
path: RANK_PATH,
|
|
243
|
+
apiId: "ka10030",
|
|
244
|
+
body: {
|
|
245
|
+
mrkt_tp: RANKING_MARKET_CODES[market],
|
|
246
|
+
sort_tp: "1",
|
|
247
|
+
mang_stk_incls: "0",
|
|
248
|
+
crd_tp: "0",
|
|
249
|
+
trde_qty_tp: "0",
|
|
250
|
+
pric_tp: "0",
|
|
251
|
+
trde_prica_tp: "0",
|
|
252
|
+
mrkt_open_tp: "0",
|
|
253
|
+
stex_tp: "1",
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
return parseArray(res.json, "tdy_trde_qty_upper", volumeRankItemSchema);
|
|
257
|
+
}
|
|
258
|
+
/** ka10032 거래대금상위. */
|
|
259
|
+
export async function fetchValueRanking(client, market) {
|
|
260
|
+
const res = await client.call({
|
|
261
|
+
path: RANK_PATH,
|
|
262
|
+
apiId: "ka10032",
|
|
263
|
+
body: {
|
|
264
|
+
mrkt_tp: RANKING_MARKET_CODES[market],
|
|
265
|
+
mang_stk_incls: "0",
|
|
266
|
+
stex_tp: "1",
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
return parseArray(res.json, "trde_prica_upper", valueRankItemSchema);
|
|
270
|
+
}
|
|
271
|
+
/** ka40002 ETF종목정보 — 추적지수·과세유형 등 ETF 기본정보. */
|
|
272
|
+
export async function fetchEtfInfo(client, stockCode) {
|
|
273
|
+
const res = await client.call({
|
|
274
|
+
path: ETF_PATH,
|
|
275
|
+
apiId: "ka40002",
|
|
276
|
+
body: { stk_cd: stockCode },
|
|
277
|
+
});
|
|
278
|
+
return etfInfoResponseSchema.parse(res.json);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* ka01300 관심종목 그룹 리스트 조회 — HTS에 저장된 관심종목 그룹 목록 (읽기 전용).
|
|
282
|
+
* 응답 배열 키는 nofi. 편집(추가/삭제) TR은 키움 REST에 존재하지 않는다.
|
|
283
|
+
*/
|
|
284
|
+
export async function fetchWatchlistGroups(client) {
|
|
285
|
+
const res = await client.call({ path: WATCHLIST_PATH, apiId: "ka01300", body: {} });
|
|
286
|
+
return watchlistGroupsResponseSchema.parse(res.json).nofi;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* ka01301 관심종목 그룹 상세 조회 — 그룹(arn_grp_id = ka01300의 gcod) 내 종목 목록.
|
|
290
|
+
* 응답 배열 키는 nofj (종목코드 cod2 + 북마크 필드만 반환; 종목명/시세는 미포함).
|
|
291
|
+
*/
|
|
292
|
+
export async function fetchWatchlistGroupDetail(client, groupCode) {
|
|
293
|
+
const res = await client.call({
|
|
294
|
+
path: WATCHLIST_PATH,
|
|
295
|
+
apiId: "ka01301",
|
|
296
|
+
body: { arn_grp_id: groupCode },
|
|
297
|
+
});
|
|
298
|
+
return watchlistGroupDetailResponseSchema.parse(res.json).nofj;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* ka90001 테마그룹별요청 — theme groups (all fields live-verified 2026-07-07).
|
|
302
|
+
* `stockCode` set → qry_tp "2" (themes the stock belongs to); else qry_tp "0"
|
|
303
|
+
* (all themes, sorted by change rate). First page only (100/page); open-ended
|
|
304
|
+
* "all" mode paginates but page-1 top rows are what a caller wants.
|
|
305
|
+
* date_tp = 기간수익률 산정 일수, flu_pl_amt_tp "1" = 등락률 상위순, stex_tp "1" = KRX.
|
|
306
|
+
*/
|
|
307
|
+
export async function fetchThemeGroups(client, stockCode) {
|
|
308
|
+
const res = await client.call({
|
|
309
|
+
path: THEME_PATH,
|
|
310
|
+
apiId: "ka90001",
|
|
311
|
+
body: {
|
|
312
|
+
qry_tp: stockCode ? "2" : "0",
|
|
313
|
+
stk_cd: stockCode ?? "",
|
|
314
|
+
date_tp: "10",
|
|
315
|
+
flu_pl_amt_tp: "1",
|
|
316
|
+
stex_tp: "1",
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
return themeGroupsResponseSchema.parse(res.json).thema_grp;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* ka90002 테마구성종목요청 — member stocks of one theme group (live-verified).
|
|
323
|
+
* Returns the full response so the theme's aggregate flu_rt/dt_prft_rt (top-level
|
|
324
|
+
* fields) are available to the caller. themeCode = ka90001 `thema_grp_cd`.
|
|
325
|
+
*/
|
|
326
|
+
export async function fetchThemeStocks(client, themeCode) {
|
|
327
|
+
const res = await client.call({
|
|
328
|
+
path: THEME_PATH,
|
|
329
|
+
apiId: "ka90002",
|
|
330
|
+
body: { date_tp: "2", thema_grp_cd: themeCode, stex_tp: "1" },
|
|
331
|
+
});
|
|
332
|
+
return themeStocksResponseSchema.parse(res.json);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* ka10014 공매도추이요청 — per-stock daily short-selling trend over [fromDate, toDate].
|
|
336
|
+
* All fields live-verified 2026-07-07. tm_tp "1" = 일별. Rows newest-first.
|
|
337
|
+
*/
|
|
338
|
+
export async function fetchShortSelling(client, stockCode, fromDate, toDate) {
|
|
339
|
+
const res = await client.call({
|
|
340
|
+
path: SHORT_PATH,
|
|
341
|
+
apiId: "ka10014",
|
|
342
|
+
body: { stk_cd: stockCode, tm_tp: "1", strt_dt: fromDate, end_dt: toDate },
|
|
343
|
+
});
|
|
344
|
+
return shortSellingResponseSchema.parse(res.json).shrts_trnsn;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* ka10008 주식외국인종목별매매동향 — per-stock daily foreign holding trend
|
|
348
|
+
* (보유주식수/보유비중/한도소진률). Live-verified 2026-07-07. Paginates (50/page,
|
|
349
|
+
* `hasNext`); first page only — recent days are what a caller wants.
|
|
350
|
+
*/
|
|
351
|
+
export async function fetchForeignHolding(client, stockCode) {
|
|
352
|
+
const res = await client.call({
|
|
353
|
+
path: FOREIGN_PATH,
|
|
354
|
+
apiId: "ka10008",
|
|
355
|
+
body: { stk_cd: stockCode },
|
|
356
|
+
});
|
|
357
|
+
return foreignHoldingResponseSchema.parse(res.json).stk_frgnr;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* ka10170 당일매매일지요청 — the day's trading journal (per-stock realized P&L). Account-scoped.
|
|
361
|
+
* Returns the full response (period totals + return_msg) so callers can surface the
|
|
362
|
+
* "최근 2개월 이내" notice. base_dt yyyyMMdd; ottks_tp "1" = 단주 포함, ch_crd_tp "0" = 전체.
|
|
363
|
+
* NOTE: an empty day returns one all-blank row — callers must filter blank rows.
|
|
364
|
+
*/
|
|
365
|
+
export async function fetchTradingJournal(client, baseDate) {
|
|
366
|
+
const res = await client.call({
|
|
367
|
+
path: ACCOUNT_PATH,
|
|
368
|
+
apiId: "ka10170",
|
|
369
|
+
body: { base_dt: baseDate, ottks_tp: "1", ch_crd_tp: "0" },
|
|
370
|
+
});
|
|
371
|
+
return tradingJournalResponseSchema.parse(res.json);
|
|
372
|
+
}
|