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,89 @@
|
|
|
1
|
+
import { redactSecrets } from "../utils/redact.js";
|
|
2
|
+
import { KiwoomAuthError } from "./errors.js";
|
|
3
|
+
import { tokenResponseSchema } from "./types.js";
|
|
4
|
+
/** Refresh this long before the reported expiry to avoid using a token mid-expiration. */
|
|
5
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
6
|
+
const TOKEN_TIMEOUT_MS = 10_000;
|
|
7
|
+
/**
|
|
8
|
+
* Parses Kiwoom's expires_dt ("yyyyMMddHHmmss", KST) into a unix epoch (ms).
|
|
9
|
+
* Returns null when the format is unrecognized.
|
|
10
|
+
*/
|
|
11
|
+
export function parseExpiresDt(expiresDt) {
|
|
12
|
+
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(expiresDt.trim());
|
|
13
|
+
if (!match)
|
|
14
|
+
return null;
|
|
15
|
+
const [, y, mo, d, h, mi, s] = match;
|
|
16
|
+
// KST is UTC+9 with no DST; subtract 9h to convert to UTC.
|
|
17
|
+
return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h) - 9, Number(mi), Number(s));
|
|
18
|
+
}
|
|
19
|
+
export class TokenManager {
|
|
20
|
+
config;
|
|
21
|
+
token = null;
|
|
22
|
+
expiresAt = 0;
|
|
23
|
+
inflight = null;
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
}
|
|
27
|
+
async getToken() {
|
|
28
|
+
if (this.token && Date.now() < this.expiresAt - EXPIRY_MARGIN_MS) {
|
|
29
|
+
return this.token;
|
|
30
|
+
}
|
|
31
|
+
// Concurrent tool calls share a single issuance request.
|
|
32
|
+
this.inflight ??= this.issueToken().finally(() => {
|
|
33
|
+
this.inflight = null;
|
|
34
|
+
});
|
|
35
|
+
return this.inflight;
|
|
36
|
+
}
|
|
37
|
+
/** Drops the cached token, forcing re-issuance on the next call (e.g. after a 401). */
|
|
38
|
+
invalidate() {
|
|
39
|
+
this.token = null;
|
|
40
|
+
this.expiresAt = 0;
|
|
41
|
+
}
|
|
42
|
+
async issueToken() {
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetch(`${this.config.baseUrl}/oauth2/token`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
grant_type: "client_credentials",
|
|
50
|
+
appkey: this.config.appKey,
|
|
51
|
+
secretkey: this.config.appSecret,
|
|
52
|
+
}),
|
|
53
|
+
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new KiwoomAuthError(`키움 토큰 발급 요청이 실패했습니다 (네트워크 오류 또는 시간 초과): ${this.describe(error)}`);
|
|
58
|
+
}
|
|
59
|
+
const rawBody = await response.text();
|
|
60
|
+
let parsedJson;
|
|
61
|
+
try {
|
|
62
|
+
parsedJson = JSON.parse(rawBody);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new KiwoomAuthError(`키움 토큰 발급 응답을 해석할 수 없습니다 (HTTP ${response.status}): ${this.snippet(rawBody)}`);
|
|
66
|
+
}
|
|
67
|
+
const body = tokenResponseSchema.parse(parsedJson);
|
|
68
|
+
const returnCode = Number(body.return_code ?? (response.ok ? 0 : -1));
|
|
69
|
+
if (!response.ok || returnCode !== 0 || !body.token) {
|
|
70
|
+
const reason = body.return_msg?.trim() || `HTTP ${response.status}`;
|
|
71
|
+
throw new KiwoomAuthError(`키움 인증에 실패했습니다: ${reason}. .env의 KIWOOM_APP_KEY/KIWOOM_APP_SECRET과 ` +
|
|
72
|
+
`KIWOOM_MODE(현재 ${this.config.mode})가 앱 등록 정보와 일치하는지 확인해 주세요.`);
|
|
73
|
+
}
|
|
74
|
+
this.token = body.token;
|
|
75
|
+
this.expiresAt = body.expires_dt ? (parseExpiresDt(body.expires_dt) ?? this.fallbackExpiry()) : this.fallbackExpiry();
|
|
76
|
+
return this.token;
|
|
77
|
+
}
|
|
78
|
+
/** Conservative fallback when expires_dt is missing or malformed. */
|
|
79
|
+
fallbackExpiry() {
|
|
80
|
+
return Date.now() + 10 * 60_000;
|
|
81
|
+
}
|
|
82
|
+
describe(error) {
|
|
83
|
+
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
84
|
+
return redactSecrets(text, [this.config.appKey, this.config.appSecret, this.token]);
|
|
85
|
+
}
|
|
86
|
+
snippet(rawBody) {
|
|
87
|
+
return redactSecrets(rawBody.slice(0, 200), [this.config.appKey, this.config.appSecret, this.token]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { redactSecrets } from "../utils/redact.js";
|
|
2
|
+
import { sleep } from "../utils/sleep.js";
|
|
3
|
+
import { KiwoomApiError } from "./errors.js";
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
5
|
+
/** Kiwoom enforces ~1 req/s per TR (burst 2); 429 backoff must exceed 1s. */
|
|
6
|
+
const RETRY_429_BASE_MS = 1_300;
|
|
7
|
+
const RETRY_5XX_BASE_MS = 800;
|
|
8
|
+
const MAX_RETRIES = 2;
|
|
9
|
+
export class KiwoomClient {
|
|
10
|
+
config;
|
|
11
|
+
tokens;
|
|
12
|
+
constructor(config, tokens) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.tokens = tokens;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Calls one Kiwoom REST TR. All TRs this server uses are read-only queries,
|
|
18
|
+
* so retrying on transient failures (network, 429, 5xx) is safe.
|
|
19
|
+
*/
|
|
20
|
+
async call(request) {
|
|
21
|
+
let tokenRetryUsed = false;
|
|
22
|
+
let retries = 0;
|
|
23
|
+
while (true) {
|
|
24
|
+
const token = await this.tokens.getToken();
|
|
25
|
+
let response;
|
|
26
|
+
try {
|
|
27
|
+
response = await fetch(`${this.config.baseUrl}${request.path}`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
31
|
+
authorization: `Bearer ${token}`,
|
|
32
|
+
"api-id": request.apiId,
|
|
33
|
+
...(request.contYn ? { "cont-yn": request.contYn, "next-key": request.nextKey ?? "" } : {}),
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify(request.body),
|
|
36
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (retries < 1) {
|
|
41
|
+
retries += 1;
|
|
42
|
+
await sleep(RETRY_5XX_BASE_MS);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw new KiwoomApiError(`키움 API 요청이 실패했습니다 (네트워크 오류 또는 시간 초과, ${request.apiId}): ${this.describe(error, token)}`, { apiId: request.apiId });
|
|
46
|
+
}
|
|
47
|
+
if (response.status === 401 && !tokenRetryUsed) {
|
|
48
|
+
tokenRetryUsed = true;
|
|
49
|
+
this.tokens.invalidate();
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if ((response.status === 429 || response.status >= 500) && retries < MAX_RETRIES) {
|
|
53
|
+
retries += 1;
|
|
54
|
+
const base = response.status === 429 ? RETRY_429_BASE_MS : RETRY_5XX_BASE_MS;
|
|
55
|
+
await sleep(base * retries);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new KiwoomApiError(this.httpErrorMessage(response.status, request.apiId), {
|
|
60
|
+
httpStatus: response.status,
|
|
61
|
+
apiId: request.apiId,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const rawBody = await response.text();
|
|
65
|
+
let json;
|
|
66
|
+
try {
|
|
67
|
+
json = JSON.parse(rawBody);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new KiwoomApiError(`키움 API 응답을 해석할 수 없습니다 (${request.apiId}): ${redactSecrets(rawBody.slice(0, 200), [token])}`, { apiId: request.apiId });
|
|
71
|
+
}
|
|
72
|
+
const envelope = json;
|
|
73
|
+
const returnCode = Number(envelope.return_code ?? 0);
|
|
74
|
+
if (returnCode !== 0) {
|
|
75
|
+
const reason = envelope.return_msg?.trim() || "원인 미상";
|
|
76
|
+
throw new KiwoomApiError(`키움 API 오류 (${request.apiId}, code ${returnCode}): ${reason}`, {
|
|
77
|
+
returnCode,
|
|
78
|
+
apiId: request.apiId,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
json,
|
|
83
|
+
hasNext: response.headers.get("cont-yn") === "Y",
|
|
84
|
+
nextKey: response.headers.get("next-key") ?? "",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
httpErrorMessage(status, apiId) {
|
|
89
|
+
if (status === 401 || status === 403) {
|
|
90
|
+
return `키움 API 인증이 거부되었습니다 (${apiId}, HTTP ${status}). 앱키 상태와 KIWOOM_MODE를 확인해 주세요.`;
|
|
91
|
+
}
|
|
92
|
+
if (status === 429) {
|
|
93
|
+
return `키움 API 요청 한도를 초과했습니다 (${apiId}). 잠시 후 다시 시도해 주세요.`;
|
|
94
|
+
}
|
|
95
|
+
if (status >= 500) {
|
|
96
|
+
return `키움 서버 오류입니다 (${apiId}, HTTP ${status}). 잠시 후 다시 시도해 주세요.`;
|
|
97
|
+
}
|
|
98
|
+
return `키움 API 요청이 거부되었습니다 (${apiId}, HTTP ${status}).`;
|
|
99
|
+
}
|
|
100
|
+
describe(error, token) {
|
|
101
|
+
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
102
|
+
return redactSecrets(text, [this.config.appKey, this.config.appSecret, token]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Error from the Kiwoom REST API, already translated to a human-readable message. */
|
|
2
|
+
export class KiwoomApiError extends Error {
|
|
3
|
+
details;
|
|
4
|
+
constructor(message, details = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "KiwoomApiError";
|
|
7
|
+
this.details = details;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class KiwoomAuthError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "KiwoomAuthError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { sleep } from "../utils/sleep.js";
|
|
2
|
+
import { fetchStockList } from "./api.js";
|
|
3
|
+
/**
|
|
4
|
+
* Shared in-process cache of the ka10099 종목 마스터 list (KOSPI + KOSDAQ,
|
|
5
|
+
* ETF/ETN included). Both `search_stock` and the watchlist tools read it to
|
|
6
|
+
* resolve a bare 종목코드 to a name/전일종가 without extra per-stock API calls.
|
|
7
|
+
* The list changes at most daily (listings/delistings), so a 12h TTL is ample.
|
|
8
|
+
*/
|
|
9
|
+
const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
10
|
+
/** ka10099 is one TR — space the two market calls to respect the ~1 req/s limit. */
|
|
11
|
+
const MARKET_FETCH_GAP_MS = 1_100;
|
|
12
|
+
let cache = null;
|
|
13
|
+
export async function loadMasterList(client) {
|
|
14
|
+
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS)
|
|
15
|
+
return cache.items;
|
|
16
|
+
const kospi = await fetchStockList(client, "0");
|
|
17
|
+
await sleep(MARKET_FETCH_GAP_MS);
|
|
18
|
+
const kosdaq = await fetchStockList(client, "10");
|
|
19
|
+
cache = { fetchedAt: Date.now(), items: [...kospi, ...kosdaq] };
|
|
20
|
+
return cache.items;
|
|
21
|
+
}
|
|
22
|
+
/** Test hook — clears the module-level master-list cache. */
|
|
23
|
+
export function clearMasterListCache() {
|
|
24
|
+
cache = null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Field names follow the Kiwoom REST API spec verbatim (snake_case, Korean
|
|
4
|
+
* abbreviations). Only fields this server consumes are declared; every schema
|
|
5
|
+
* is loose so unknown fields pass through untouched.
|
|
6
|
+
*/
|
|
7
|
+
const envelope = {
|
|
8
|
+
return_code: z.union([z.string(), z.number()]).optional(),
|
|
9
|
+
return_msg: z.string().optional(),
|
|
10
|
+
};
|
|
11
|
+
const str = () => z.string().default("");
|
|
12
|
+
// ── au10001: 접근토큰 발급 ──
|
|
13
|
+
export const tokenResponseSchema = z.looseObject({
|
|
14
|
+
...envelope,
|
|
15
|
+
token: z.string().optional(),
|
|
16
|
+
token_type: z.string().optional(),
|
|
17
|
+
/** yyyyMMddHHmmss, KST */
|
|
18
|
+
expires_dt: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
// ── ka10001: 주식기본정보 (subset) ──
|
|
21
|
+
export const stockInfoResponseSchema = z.looseObject({
|
|
22
|
+
...envelope,
|
|
23
|
+
stk_cd: str(),
|
|
24
|
+
stk_nm: str(),
|
|
25
|
+
cur_prc: str(), // 현재가 (부호 접두 가능)
|
|
26
|
+
pred_pre: str(), // 전일대비
|
|
27
|
+
pre_sig: str(), // 대비기호 1상한 2상승 3보합 4하한 5하락
|
|
28
|
+
flu_rt: str(), // 등락률(%)
|
|
29
|
+
trde_qty: str(), // 거래량
|
|
30
|
+
open_pric: str(),
|
|
31
|
+
high_pric: str(),
|
|
32
|
+
low_pric: str(),
|
|
33
|
+
base_pric: str(), // 기준가(전일종가)
|
|
34
|
+
"250hgst": str(), // 250일 최고
|
|
35
|
+
"250lwst": str(), // 250일 최저
|
|
36
|
+
per: str(),
|
|
37
|
+
eps: str(),
|
|
38
|
+
pbr: str(),
|
|
39
|
+
bps: str(),
|
|
40
|
+
mac: str(), // 시가총액(억원)
|
|
41
|
+
});
|
|
42
|
+
// ── kt00001: 예수금상세현황 (subset) ──
|
|
43
|
+
export const depositResponseSchema = z.looseObject({
|
|
44
|
+
...envelope,
|
|
45
|
+
entr: str(), // 예수금
|
|
46
|
+
d1_entra: str(), // D+1 추정예수금
|
|
47
|
+
d2_entra: str(), // D+2 추정예수금
|
|
48
|
+
ord_alow_amt: str(), // 주문가능금액
|
|
49
|
+
pymn_alow_amt: str(), // 출금가능금액
|
|
50
|
+
});
|
|
51
|
+
// ── kt00018: 계좌평가잔고내역 ──
|
|
52
|
+
export const holdingItemSchema = z.looseObject({
|
|
53
|
+
stk_cd: str(), // 종목코드 — "A005930"처럼 A 접두가 붙어 올 수 있음
|
|
54
|
+
stk_nm: str(),
|
|
55
|
+
rmnd_qty: str(), // 보유수량
|
|
56
|
+
trde_able_qty: str(), // 매매가능수량
|
|
57
|
+
pur_pric: str(), // 매입가(평균단가)
|
|
58
|
+
cur_prc: str(), // 현재가
|
|
59
|
+
pur_amt: str(), // 매입금액
|
|
60
|
+
evlt_amt: str(), // 평가금액
|
|
61
|
+
evltv_prft: str(), // 평가손익 (부호 유의미)
|
|
62
|
+
prft_rt: str(), // 수익률(%)
|
|
63
|
+
poss_rt: str(), // 보유비중(%)
|
|
64
|
+
pred_close_pric: str(), // 전일종가
|
|
65
|
+
});
|
|
66
|
+
export const accountEvaluationResponseSchema = z.looseObject({
|
|
67
|
+
...envelope,
|
|
68
|
+
tot_pur_amt: str(), // 총매입금액
|
|
69
|
+
tot_evlt_amt: str(), // 총평가금액
|
|
70
|
+
tot_evlt_pl: str(), // 총평가손익 (부호 유의미)
|
|
71
|
+
tot_prft_rt: str(), // 총수익률(%)
|
|
72
|
+
prsm_dpst_aset_amt: str(), // 추정예탁자산
|
|
73
|
+
acnt_evlt_remn_indv_tot: z.array(holdingItemSchema).default([]),
|
|
74
|
+
});
|
|
75
|
+
// ── kt00015: 위탁종합거래내역 (subset) ──
|
|
76
|
+
// NOTE: trde_dt is the settlement date (D+2), not the trade date.
|
|
77
|
+
export const transactionRowSchema = z.looseObject({
|
|
78
|
+
trde_dt: str(), // 거래일자(결제 기준, yyyyMMdd)
|
|
79
|
+
cntr_dt: str(), // 체결일자(실제 매매일, yyyyMMdd) — live-verified 2026-07
|
|
80
|
+
trde_unit: str(), // 단가 — 쉼표 포함 포맷("20,190")으로 옴
|
|
81
|
+
trde_no: str(),
|
|
82
|
+
trde_kind_nm: str(), // 거래종류명 — "매매" 등
|
|
83
|
+
rmrk_nm: str(), // 적요명 — "장내매수"/"장내매도" 등
|
|
84
|
+
io_tp_nm: str(), // 입출구분명 — "매수"/"매도" 등
|
|
85
|
+
stk_cd: str(),
|
|
86
|
+
stk_nm: str(),
|
|
87
|
+
trde_qty_jwa_cnt: str(), // 거래수량(좌수)
|
|
88
|
+
trde_amt: str(), // 거래금액
|
|
89
|
+
exct_amt: str(), // 정산금액 (수수료 반영된 실제 입출금액)
|
|
90
|
+
cmsn: str(), // 수수료
|
|
91
|
+
});
|
|
92
|
+
export const transactionsResponseSchema = z.looseObject({
|
|
93
|
+
...envelope,
|
|
94
|
+
trst_ovrl_trde_prps_array: z.array(transactionRowSchema).default([]),
|
|
95
|
+
});
|
|
96
|
+
// ── ka10074: 일자별실현손익 (subset — summary only) ──
|
|
97
|
+
export const realizedPnlResponseSchema = z.looseObject({
|
|
98
|
+
...envelope,
|
|
99
|
+
tot_buy_amt: str(),
|
|
100
|
+
tot_sell_amt: str(),
|
|
101
|
+
rlzt_pl: str(), // 기간 실현손익 합계 (수수료·세금 반영)
|
|
102
|
+
trde_cmsn: str(),
|
|
103
|
+
trde_tax: str(),
|
|
104
|
+
});
|
|
105
|
+
// ── ka10075: 미체결 주문 (미체결요청) ──
|
|
106
|
+
// The array key `oso` is live-verified (2026-07-07 — an empty array, since the
|
|
107
|
+
// verification account had no open orders). The item fields are wrapper-sourced
|
|
108
|
+
// (dongbin300 KiwoomRestApi.Net model) and NOT live-verified, because no pending
|
|
109
|
+
// order existed to observe — treat the item shape as provisional, like dividends.
|
|
110
|
+
// Only the consumed subset is declared. `cur_prc`/`ord_pric` encode direction in
|
|
111
|
+
// their sign → read magnitude via parseKiwoomPrice.
|
|
112
|
+
export const pendingOrderItemSchema = z.looseObject({
|
|
113
|
+
ord_no: str(), // 주문번호
|
|
114
|
+
stk_cd: str(), // 종목코드 ("A005930"처럼 접두 가능)
|
|
115
|
+
stk_nm: str(), // 종목명
|
|
116
|
+
ord_stt: str(), // 주문상태 ("접수"/"확인" 등)
|
|
117
|
+
io_tp_nm: str(), // 주문구분 ("매수"/"매도"/"정정" 등)
|
|
118
|
+
ord_qty: str(), // 주문수량
|
|
119
|
+
ord_pric: str(), // 주문가격
|
|
120
|
+
oso_qty: str(), // 미체결수량
|
|
121
|
+
cntr_qty: str(), // 체결량
|
|
122
|
+
cur_prc: str(), // 현재가 (부호 유의미)
|
|
123
|
+
tm: str(), // 시간 (HHmmss)
|
|
124
|
+
trde_tp: str(), // 매매구분
|
|
125
|
+
});
|
|
126
|
+
export const pendingOrdersResponseSchema = z.looseObject({
|
|
127
|
+
...envelope,
|
|
128
|
+
oso: z.array(pendingOrderItemSchema).default([]),
|
|
129
|
+
});
|
|
130
|
+
// ── ka10099: 종목정보요약 — NOTE: this TR answers in camelCase (live-verified) ──
|
|
131
|
+
export const stockListItemSchema = z.looseObject({
|
|
132
|
+
code: str(),
|
|
133
|
+
name: str(),
|
|
134
|
+
lastPrice: str(), // 전일종가, zero-padded
|
|
135
|
+
marketName: str(), // "거래소" | "코스닥" | "ETF" | ...
|
|
136
|
+
upName: str(), // 업종명 (ETF/ETN은 빈 값)
|
|
137
|
+
upSizeName: str(), // 대형주/중형주/소형주
|
|
138
|
+
state: str(), // "증거금20%|담보대출|신용가능"
|
|
139
|
+
auditInfo: str(), // "정상" 등
|
|
140
|
+
orderWarning: str(), // "0"=정상
|
|
141
|
+
});
|
|
142
|
+
export const stockListResponseSchema = z.looseObject({
|
|
143
|
+
...envelope,
|
|
144
|
+
list: z.array(stockListItemSchema).default([]),
|
|
145
|
+
});
|
|
146
|
+
// ── ka10081/ka10082/ka10083: 일/주/월봉 차트 (same item shape, different array key) ──
|
|
147
|
+
export const dailyChartItemSchema = z.looseObject({
|
|
148
|
+
dt: str(), // yyyyMMdd
|
|
149
|
+
open_pric: str(),
|
|
150
|
+
high_pric: str(),
|
|
151
|
+
low_pric: str(),
|
|
152
|
+
cur_prc: str(), // 종가
|
|
153
|
+
trde_qty: str(),
|
|
154
|
+
trde_prica: str(), // 거래대금(백만원)
|
|
155
|
+
pred_pre: str(),
|
|
156
|
+
pred_pre_sig: str(),
|
|
157
|
+
});
|
|
158
|
+
// ── ka10080: 분봉 차트 ──
|
|
159
|
+
export const minuteChartItemSchema = z.looseObject({
|
|
160
|
+
cntr_tm: str(), // yyyyMMddHHmmss
|
|
161
|
+
open_pric: str(),
|
|
162
|
+
high_pric: str(),
|
|
163
|
+
low_pric: str(),
|
|
164
|
+
cur_prc: str(),
|
|
165
|
+
trde_qty: str(), // 해당 분봉 거래량
|
|
166
|
+
acc_trde_qty: str(),
|
|
167
|
+
});
|
|
168
|
+
// ── ka10004: 주식호가 — level 1 uses *_fpr_* keys, levels 2-10 use sel_/buy_Nth_pre_* ──
|
|
169
|
+
// (live-verified key shapes; levels 2-10 are read via the loose passthrough)
|
|
170
|
+
export const orderbookResponseSchema = z.looseObject({
|
|
171
|
+
...envelope,
|
|
172
|
+
bid_req_base_tm: str(), // 호가 기준시각 HHmmss
|
|
173
|
+
sel_fpr_bid: str(), // 매도최우선호가
|
|
174
|
+
sel_fpr_req: str(), // 매도최우선잔량
|
|
175
|
+
buy_fpr_bid: str(),
|
|
176
|
+
buy_fpr_req: str(),
|
|
177
|
+
tot_sel_req: str(),
|
|
178
|
+
tot_buy_req: str(),
|
|
179
|
+
});
|
|
180
|
+
// ── ka20003: 전업종지수 (inds_cd 001=코스피 그룹, 101=코스닥 그룹) ──
|
|
181
|
+
export const indexItemSchema = z.looseObject({
|
|
182
|
+
stk_cd: str(), // 업종코드 ("001" 종합 등)
|
|
183
|
+
stk_nm: str(),
|
|
184
|
+
cur_prc: str(), // 지수 (소수점 포함)
|
|
185
|
+
pre_sig: str(),
|
|
186
|
+
pred_pre: str(),
|
|
187
|
+
flu_rt: str(),
|
|
188
|
+
trde_qty: str(), // 천주
|
|
189
|
+
trde_prica: str(), // 백만원
|
|
190
|
+
rising: str(),
|
|
191
|
+
stdns: str(), // 보합
|
|
192
|
+
fall: str(),
|
|
193
|
+
});
|
|
194
|
+
export const allIndexResponseSchema = z.looseObject({
|
|
195
|
+
...envelope,
|
|
196
|
+
all_inds_idex: z.array(indexItemSchema).default([]),
|
|
197
|
+
});
|
|
198
|
+
// ── ka10059/ka10061: 종목별 투자자·기관 (13-field investor breakdown) ──
|
|
199
|
+
// amt_qty_tp "1"=금액(백만원), "2"=수량(주) — cross-checked live against volume.
|
|
200
|
+
const investorFields = {
|
|
201
|
+
ind_invsr: str(), // 개인
|
|
202
|
+
frgnr_invsr: str(), // 외국인
|
|
203
|
+
orgn: str(), // 기관계
|
|
204
|
+
fnnc_invt: str(), // 금융투자
|
|
205
|
+
insrnc: str(), // 보험
|
|
206
|
+
invtrt: str(), // 투신
|
|
207
|
+
etc_fnnc: str(), // 기타금융
|
|
208
|
+
bank: str(),
|
|
209
|
+
penfnd_etc: str(), // 연기금등
|
|
210
|
+
samo_fund: str(), // 사모펀드
|
|
211
|
+
natn: str(), // 국가
|
|
212
|
+
etc_corp: str(), // 기타법인
|
|
213
|
+
natfor: str(), // 내외국인
|
|
214
|
+
};
|
|
215
|
+
export const investorTotalItemSchema = z.looseObject(investorFields);
|
|
216
|
+
export const investorDailyItemSchema = z.looseObject({
|
|
217
|
+
dt: str(),
|
|
218
|
+
cur_prc: str(),
|
|
219
|
+
pre_sig: str(),
|
|
220
|
+
pred_pre: str(),
|
|
221
|
+
acc_trde_qty: str(),
|
|
222
|
+
...investorFields,
|
|
223
|
+
});
|
|
224
|
+
// ── ka10027/ka10030/ka10032: 순위 TR item shapes ──
|
|
225
|
+
export const priceChangeRankItemSchema = z.looseObject({
|
|
226
|
+
stk_cd: str(),
|
|
227
|
+
stk_nm: str(),
|
|
228
|
+
cur_prc: str(),
|
|
229
|
+
pred_pre_sig: str(),
|
|
230
|
+
pred_pre: str(),
|
|
231
|
+
flu_rt: str(),
|
|
232
|
+
now_trde_qty: str(),
|
|
233
|
+
});
|
|
234
|
+
export const volumeRankItemSchema = z.looseObject({
|
|
235
|
+
stk_cd: str(),
|
|
236
|
+
stk_nm: str(),
|
|
237
|
+
cur_prc: str(),
|
|
238
|
+
pred_pre_sig: str(),
|
|
239
|
+
pred_pre: str(),
|
|
240
|
+
flu_rt: str(),
|
|
241
|
+
trde_qty: str(), // NOTE: Kiwoom caps this at uint32 max (4294967295) — live-observed
|
|
242
|
+
trde_amt: str(), // 백만원
|
|
243
|
+
});
|
|
244
|
+
export const valueRankItemSchema = z.looseObject({
|
|
245
|
+
stk_cd: str(),
|
|
246
|
+
stk_nm: str(),
|
|
247
|
+
cur_prc: str(),
|
|
248
|
+
pred_pre_sig: str(),
|
|
249
|
+
pred_pre: str(),
|
|
250
|
+
flu_rt: str(),
|
|
251
|
+
now_trde_qty: str(),
|
|
252
|
+
trde_prica: str(), // 백만원
|
|
253
|
+
});
|
|
254
|
+
// ── ka40002: ETF종목정보 (flat) ──
|
|
255
|
+
export const etfInfoResponseSchema = z.looseObject({
|
|
256
|
+
...envelope,
|
|
257
|
+
stk_nm: str(),
|
|
258
|
+
etfobjt_idex_nm: str(), // 추적지수명
|
|
259
|
+
etftxon_type: str(), // ETF 과세유형 ("비과세" 등)
|
|
260
|
+
etntxon_type: str(),
|
|
261
|
+
});
|
|
262
|
+
// ── ka01300/ka01301: 관심종목 그룹 (HTS 저장 그룹, live-verified 2026-07-06) ──
|
|
263
|
+
// NOTE: array keys are nofi(그룹)/nofj(종목); item fields are terse (gcod/name,
|
|
264
|
+
// cod2/bgb) and differ from the usual snake_case TRs. The response also carries
|
|
265
|
+
// a legacy `rtcd:"S"` flag alongside the standard return_code envelope.
|
|
266
|
+
export const watchlistGroupItemSchema = z.looseObject({
|
|
267
|
+
gcod: str(), // 그룹코드 (예: "000")
|
|
268
|
+
name: str(), // 그룹명
|
|
269
|
+
});
|
|
270
|
+
export const watchlistGroupsResponseSchema = z.looseObject({
|
|
271
|
+
...envelope,
|
|
272
|
+
nofi: z.array(watchlistGroupItemSchema).default([]),
|
|
273
|
+
});
|
|
274
|
+
export const watchlistStockItemSchema = z.looseObject({
|
|
275
|
+
cod2: str(), // 종목코드
|
|
276
|
+
bgb: str(), // 북마크 구분 ("0"=없음)
|
|
277
|
+
bgb_clr: str(), // 북마크 컬러
|
|
278
|
+
});
|
|
279
|
+
export const watchlistGroupDetailResponseSchema = z.looseObject({
|
|
280
|
+
...envelope,
|
|
281
|
+
nofj: z.array(watchlistStockItemSchema).default([]),
|
|
282
|
+
});
|
|
283
|
+
// ── ka90001/ka90002: 테마 (테마그룹별 / 테마구성종목) — /api/dostk/thme ──
|
|
284
|
+
// All fields live-verified 2026-07-07 on REAL. ka90001 paginates (100/page,
|
|
285
|
+
// sorted by flu_pl_amt_tp); this server reads page 1 only. flu_rt/dt_prft_rt are
|
|
286
|
+
// sign-prefixed percentages; cur_prc/pred_pre sign-encode direction. Counts
|
|
287
|
+
// (stk_num/rising/fall) arrive as plain numeric strings.
|
|
288
|
+
export const themeGroupItemSchema = z.looseObject({
|
|
289
|
+
thema_grp_cd: str(), // 테마그룹코드
|
|
290
|
+
thema_nm: str(), // 테마명
|
|
291
|
+
stk_num: str(), // 종목수
|
|
292
|
+
flu_sig: str(), // 등락기호 (1상한 2상승 3보합 4하한 5하락)
|
|
293
|
+
flu_rt: str(), // 등락률(%)
|
|
294
|
+
rising_stk_num: str(), // 상승종목수
|
|
295
|
+
fall_stk_num: str(), // 하락종목수
|
|
296
|
+
dt_prft_rt: str(), // 기간수익률(%) — date_tp 일수 기준
|
|
297
|
+
main_stk: str(), // 주요종목 (쉼표 구분)
|
|
298
|
+
});
|
|
299
|
+
export const themeGroupsResponseSchema = z.looseObject({
|
|
300
|
+
...envelope,
|
|
301
|
+
thema_grp: z.array(themeGroupItemSchema).default([]),
|
|
302
|
+
});
|
|
303
|
+
export const themeStockItemSchema = z.looseObject({
|
|
304
|
+
stk_cd: str(),
|
|
305
|
+
stk_nm: str(),
|
|
306
|
+
cur_prc: str(), // 현재가 (부호 유의미)
|
|
307
|
+
flu_sig: str(),
|
|
308
|
+
pred_pre: str(), // 전일대비 (부호 유의미)
|
|
309
|
+
flu_rt: str(), // 등락률(%)
|
|
310
|
+
acc_trde_qty: str(), // 누적거래량
|
|
311
|
+
dt_prft_rt_n: str(), // 기간수익률(%)
|
|
312
|
+
});
|
|
313
|
+
export const themeStocksResponseSchema = z.looseObject({
|
|
314
|
+
...envelope,
|
|
315
|
+
flu_rt: str(), // 테마 전체 등락률(%)
|
|
316
|
+
dt_prft_rt: str(), // 테마 전체 기간수익률(%)
|
|
317
|
+
thema_comp_stk: z.array(themeStockItemSchema).default([]),
|
|
318
|
+
});
|
|
319
|
+
// ── ka10014: 공매도추이 — /api/dostk/shsa (all fields live-verified 2026-07-07) ──
|
|
320
|
+
export const shortSellingItemSchema = z.looseObject({
|
|
321
|
+
dt: str(), // 일자 yyyyMMdd
|
|
322
|
+
close_pric: str(), // 종가 (부호 방향)
|
|
323
|
+
pred_pre: str(), // 전일대비 (부호 유의미)
|
|
324
|
+
flu_rt: str(), // 등락률(%)
|
|
325
|
+
trde_qty: str(), // 거래량
|
|
326
|
+
shrts_qty: str(), // 공매도량
|
|
327
|
+
ovr_shrts_qty: str(), // 누적공매도량
|
|
328
|
+
trde_wght: str(), // 공매도 비중(%) — 비방향성 비율
|
|
329
|
+
shrts_trde_prica: str(), // 공매도 거래대금
|
|
330
|
+
shrts_avg_pric: str(), // 공매도 평균가
|
|
331
|
+
});
|
|
332
|
+
export const shortSellingResponseSchema = z.looseObject({
|
|
333
|
+
...envelope,
|
|
334
|
+
shrts_trnsn: z.array(shortSellingItemSchema).default([]),
|
|
335
|
+
});
|
|
336
|
+
// ── ka10008: 주식외국인 종목별 매매동향 — /api/dostk/frgnistt (live-verified) ──
|
|
337
|
+
export const foreignHoldingItemSchema = z.looseObject({
|
|
338
|
+
dt: str(), // 일자 yyyyMMdd
|
|
339
|
+
close_pric: str(), // 종가 (부호)
|
|
340
|
+
pred_pre: str(), // 전일대비 (부호)
|
|
341
|
+
trde_qty: str(), // 거래량
|
|
342
|
+
chg_qty: str(), // 외국인 변동수량 (부호)
|
|
343
|
+
poss_stkcnt: str(), // 외국인 보유주식수
|
|
344
|
+
wght: str(), // 외국인 보유비중(%) — 비방향성 비율
|
|
345
|
+
gain_pos_stkcnt: str(), // 취득가능주식수
|
|
346
|
+
frgnr_limit: str(), // 외국인 한도
|
|
347
|
+
limit_exh_rt: str(), // 한도소진률(%) — 비방향성 비율
|
|
348
|
+
});
|
|
349
|
+
export const foreignHoldingResponseSchema = z.looseObject({
|
|
350
|
+
...envelope,
|
|
351
|
+
stk_frgnr: z.array(foreignHoldingItemSchema).default([]),
|
|
352
|
+
});
|
|
353
|
+
// ── ka10170: 당일매매일지 — /api/dostk/acnt (live-verified 2026-07-07) ──
|
|
354
|
+
// NOTE: an empty trading day returns ONE all-blank row (not an empty array) — callers
|
|
355
|
+
// must filter blank rows (empty stk_cd/stk_nm). A base_dt beyond ~2 months returns
|
|
356
|
+
// return_code 0 with a "최근 2개월 이내" notice in return_msg and blank data. Item field
|
|
357
|
+
// NAMES are live-confirmed (blank-row keys + dongbin300 .NET model); non-empty item VALUES
|
|
358
|
+
// were NOT observable (no day-trade on this account), so treat them as provisional.
|
|
359
|
+
export const tradingJournalItemSchema = z.looseObject({
|
|
360
|
+
stk_cd: str(),
|
|
361
|
+
stk_nm: str(),
|
|
362
|
+
buy_avg_pric: str(), // 매수평균가
|
|
363
|
+
buy_qty: str(), // 매수수량
|
|
364
|
+
sel_avg_pric: str(), // 매도평균가
|
|
365
|
+
sell_qty: str(), // 매도수량
|
|
366
|
+
cmsn_alm_tax: str(), // 수수료+제세금
|
|
367
|
+
pl_amt: str(), // 손익금액 (부호)
|
|
368
|
+
sell_amt: str(), // 매도금액
|
|
369
|
+
buy_amt: str(), // 매수금액
|
|
370
|
+
prft_rt: str(), // 수익률(%)
|
|
371
|
+
});
|
|
372
|
+
export const tradingJournalResponseSchema = z.looseObject({
|
|
373
|
+
...envelope,
|
|
374
|
+
tot_sell_amt: str(), // 총매도금액
|
|
375
|
+
tot_buy_amt: str(), // 총매수금액
|
|
376
|
+
tot_cmsn_tax: str(), // 총수수료+세금
|
|
377
|
+
tot_exct_amt: str(), // 총정산금액
|
|
378
|
+
tot_pl_amt: str(), // 총손익금액 (부호)
|
|
379
|
+
tot_prft_rt: str(), // 총수익률(%)
|
|
380
|
+
tdy_trde_diary: z.array(tradingJournalItemSchema).default([]),
|
|
381
|
+
});
|
|
382
|
+
/** Strips Kiwoom's asset-class prefix (e.g. "A005930" → "005930"). */
|
|
383
|
+
export function normalizeStockCode(code) {
|
|
384
|
+
return code.replace(/^[A-Z]/, "");
|
|
385
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { registerAccountBalanceTool } from "./tools/account-balance.js";
|
|
3
|
+
import { registerAccountHoldingsTool } from "./tools/account-holdings.js";
|
|
4
|
+
import { registerEtfInfoTool } from "./tools/etf-info.js";
|
|
5
|
+
import { registerForeignHoldingTool } from "./tools/foreign-holding.js";
|
|
6
|
+
import { registerInvestorTrendTool } from "./tools/investor-trend.js";
|
|
7
|
+
import { registerIsaTaxStatusTool } from "./tools/isa-tax-status.js";
|
|
8
|
+
import { registerMarketIndexTool } from "./tools/market-index.js";
|
|
9
|
+
import { registerOrderbookTool } from "./tools/orderbook.js";
|
|
10
|
+
import { registerPendingOrdersTool } from "./tools/pending-orders.js";
|
|
11
|
+
import { registerPingTool } from "./tools/ping.js";
|
|
12
|
+
import { registerRankingTool } from "./tools/ranking.js";
|
|
13
|
+
import { registerShortSellingTool } from "./tools/short-selling.js";
|
|
14
|
+
import { registerStockChartTool } from "./tools/stock-chart.js";
|
|
15
|
+
import { registerStockPriceTool } from "./tools/stock-price.js";
|
|
16
|
+
import { registerStockSearchTool } from "./tools/stock-search.js";
|
|
17
|
+
import { registerThemeGroupsTool, registerThemeStocksTool } from "./tools/theme.js";
|
|
18
|
+
import { registerTradingJournalTool } from "./tools/trading-journal.js";
|
|
19
|
+
import { registerTransactionsTool } from "./tools/transactions.js";
|
|
20
|
+
import { registerWatchlistGroupsTool, registerWatchlistTool } from "./tools/watchlist.js";
|
|
21
|
+
export const SERVER_NAME = "kiwoom-mcp-server";
|
|
22
|
+
export const SERVER_VERSION = "0.8.0";
|
|
23
|
+
export function createServer() {
|
|
24
|
+
const server = new McpServer({
|
|
25
|
+
name: SERVER_NAME,
|
|
26
|
+
version: SERVER_VERSION,
|
|
27
|
+
});
|
|
28
|
+
registerPingTool(server);
|
|
29
|
+
// Market data (account-independent)
|
|
30
|
+
registerStockSearchTool(server);
|
|
31
|
+
registerStockPriceTool(server);
|
|
32
|
+
registerStockChartTool(server);
|
|
33
|
+
registerOrderbookTool(server);
|
|
34
|
+
registerMarketIndexTool(server);
|
|
35
|
+
registerRankingTool(server);
|
|
36
|
+
registerInvestorTrendTool(server);
|
|
37
|
+
registerEtfInfoTool(server);
|
|
38
|
+
registerShortSellingTool(server);
|
|
39
|
+
registerForeignHoldingTool(server);
|
|
40
|
+
// Watchlist (HTS 저장 관심종목 — read-only; ka01300/ka01301)
|
|
41
|
+
registerWatchlistGroupsTool(server);
|
|
42
|
+
registerWatchlistTool(server);
|
|
43
|
+
// Theme (테마 그룹 + 구성종목; ka90001/ka90002)
|
|
44
|
+
registerThemeGroupsTool(server);
|
|
45
|
+
registerThemeStocksTool(server);
|
|
46
|
+
// Account (bound to the app key)
|
|
47
|
+
registerAccountBalanceTool(server);
|
|
48
|
+
registerAccountHoldingsTool(server);
|
|
49
|
+
registerTransactionsTool(server);
|
|
50
|
+
registerPendingOrdersTool(server);
|
|
51
|
+
registerTradingJournalTool(server);
|
|
52
|
+
registerIsaTaxStatusTool(server);
|
|
53
|
+
return server;
|
|
54
|
+
}
|