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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +227 -0
  3. package/README.md +217 -0
  4. package/dist/config.js +61 -0
  5. package/dist/context.js +16 -0
  6. package/dist/index.js +15 -0
  7. package/dist/isa/classify-etf.js +84 -0
  8. package/dist/isa/classify.js +198 -0
  9. package/dist/isa/realized.js +55 -0
  10. package/dist/isa/tax.js +53 -0
  11. package/dist/kiwoom/api.js +372 -0
  12. package/dist/kiwoom/auth.js +89 -0
  13. package/dist/kiwoom/client.js +104 -0
  14. package/dist/kiwoom/errors.js +15 -0
  15. package/dist/kiwoom/master-list.js +25 -0
  16. package/dist/kiwoom/types.js +385 -0
  17. package/dist/server.js +54 -0
  18. package/dist/tools/account-balance.js +36 -0
  19. package/dist/tools/account-holdings.js +52 -0
  20. package/dist/tools/etf-info.js +44 -0
  21. package/dist/tools/foreign-holding.js +61 -0
  22. package/dist/tools/helpers.js +19 -0
  23. package/dist/tools/investor-trend.js +77 -0
  24. package/dist/tools/isa-tax-status.js +245 -0
  25. package/dist/tools/market-index.js +41 -0
  26. package/dist/tools/orderbook.js +50 -0
  27. package/dist/tools/pending-orders.js +67 -0
  28. package/dist/tools/ping.js +14 -0
  29. package/dist/tools/ranking.js +83 -0
  30. package/dist/tools/short-selling.js +66 -0
  31. package/dist/tools/stock-chart.js +94 -0
  32. package/dist/tools/stock-price.js +66 -0
  33. package/dist/tools/stock-search.js +63 -0
  34. package/dist/tools/theme.js +120 -0
  35. package/dist/tools/trading-journal.js +61 -0
  36. package/dist/tools/transactions.js +80 -0
  37. package/dist/tools/watchlist.js +115 -0
  38. package/dist/utils/date.js +15 -0
  39. package/dist/utils/num.js +67 -0
  40. package/dist/utils/redact.js +14 -0
  41. package/dist/utils/sleep.js +3 -0
  42. package/package.json +55 -0
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchPendingOrders } from "../kiwoom/api.js";
4
+ import { normalizeStockCode } from "../kiwoom/types.js";
5
+ import { formatKRW, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
6
+ import { runTool, textResult } from "./helpers.js";
7
+ /** HHmmss → HH:MM:SS; passes through empty/unknown formats unchanged. */
8
+ function formatTime(raw) {
9
+ const t = raw.trim();
10
+ if (/^\d{6}$/.test(t))
11
+ return `${t.slice(0, 2)}:${t.slice(2, 4)}:${t.slice(4, 6)}`;
12
+ return t || "-";
13
+ }
14
+ export function formatPendingOrders(rows, modeLabel, stockCode) {
15
+ const scope = stockCode ? `, 종목 ${stockCode}` : "";
16
+ // stock_code는 서버(ka10075)에서도 걸리지만, 예상과 다를 때를 대비해 한 번 더 필터한다.
17
+ const filtered = stockCode
18
+ ? rows.filter((r) => normalizeStockCode(r.stk_cd) === stockCode)
19
+ : rows;
20
+ if (filtered.length === 0) {
21
+ return `[${modeLabel}] 미체결 주문이 없습니다${stockCode ? ` (종목 ${stockCode})` : ""}.`;
22
+ }
23
+ const n = parseKiwoomNumber;
24
+ const lines = [
25
+ `[${modeLabel}] 미체결 주문 (${filtered.length}건${scope})`,
26
+ "",
27
+ "| 주문번호 | 종목 | 구분 | 상태 | 주문수량 | 미체결수량 | 주문가격 | 현재가 | 시간 |",
28
+ "|---|---|---|---|---:|---:|---:|---:|---|",
29
+ ];
30
+ for (const r of filtered) {
31
+ const code = normalizeStockCode(r.stk_cd);
32
+ const stock = r.stk_nm ? `${r.stk_nm} (${code})` : code || "-";
33
+ const cells = [
34
+ r.ord_no || "-",
35
+ stock,
36
+ r.io_tp_nm || r.trde_tp || "-",
37
+ r.ord_stt || "-",
38
+ n(r.ord_qty)?.toLocaleString("ko-KR") ?? "-",
39
+ n(r.oso_qty)?.toLocaleString("ko-KR") ?? "-",
40
+ formatKRW(parseKiwoomPrice(r.ord_pric)),
41
+ formatKRW(parseKiwoomPrice(r.cur_prc)),
42
+ formatTime(r.tm),
43
+ ];
44
+ lines.push(`| ${cells.join(" | ")} |`);
45
+ }
46
+ lines.push("", "※ 미체결 = 아직 체결되지 않은 주문(정정·부분체결 포함). 주문가격 0원은 시장가 주문일 수 있습니다.");
47
+ return lines.join("\n");
48
+ }
49
+ export function registerPendingOrdersTool(server) {
50
+ server.registerTool("get_pending_orders", {
51
+ title: "미체결 주문 조회",
52
+ description: "계좌의 미체결(아직 체결되지 않은) 주문 목록을 조회합니다 — 주문번호, 종목, 매수/매도 구분, " +
53
+ "주문상태, 주문수량, 미체결수량, 주문가격, 현재가 (키움 ka10075). stock_code로 특정 종목만 " +
54
+ "필터링할 수 있습니다. 조회 전용이며 주문 실행 기능은 제공하지 않습니다.",
55
+ inputSchema: {
56
+ stock_code: z
57
+ .string()
58
+ .regex(/^\d{6}$/, "6자리 종목코드여야 합니다")
59
+ .optional()
60
+ .describe("특정 종목만 조회할 때의 6자리 종목코드"),
61
+ },
62
+ }, async ({ stock_code }) => runTool(async () => {
63
+ const { client, config } = getKiwoomContext();
64
+ const rows = await fetchPendingOrders(client, stock_code);
65
+ return textResult(formatPendingOrders(rows, config.modeLabel, stock_code));
66
+ }));
67
+ }
@@ -0,0 +1,14 @@
1
+ export function registerPingTool(server) {
2
+ server.registerTool("ping", {
3
+ title: "Ping",
4
+ description: "Health check for the Kiwoom MCP server. Takes no arguments and returns a fixed message. " +
5
+ "Use this to verify the server is connected.",
6
+ }, async () => ({
7
+ content: [
8
+ {
9
+ type: "text",
10
+ text: "pong — kiwoom-mcp-server 연결 정상 (이 tool은 키움 API를 호출하지 않아 앱키 없이도 동작합니다)",
11
+ },
12
+ ],
13
+ }));
14
+ }
@@ -0,0 +1,83 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchPriceChangeRanking, fetchValueRanking, fetchVolumeRanking, } from "../kiwoom/api.js";
4
+ import { formatNumber, formatPercent, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
5
+ import { runTool, textResult } from "./helpers.js";
6
+ const DEFAULT_TOP = 20;
7
+ const MAX_TOP = 50;
8
+ const KIND_LABELS = {
9
+ rise: "상승률 상위",
10
+ fall: "하락률 상위",
11
+ volume: "거래량 상위",
12
+ value: "거래대금 상위",
13
+ };
14
+ const MARKET_LABELS = {
15
+ all: "전체",
16
+ kospi: "코스피",
17
+ kosdaq: "코스닥",
18
+ };
19
+ const row = (cells) => `| ${cells.join(" | ")} |`;
20
+ function commonCells(rank, item) {
21
+ return [
22
+ String(rank),
23
+ item.stk_nm,
24
+ item.stk_cd,
25
+ formatNumber(parseKiwoomPrice(item.cur_prc)),
26
+ formatPercent(parseKiwoomNumber(item.flu_rt)),
27
+ ];
28
+ }
29
+ export function formatRanking(kind, market, items, top, modeLabel) {
30
+ const shown = items.slice(0, top);
31
+ if (shown.length === 0) {
32
+ return `[${modeLabel}] ${MARKET_LABELS[market]} ${KIND_LABELS[kind]} 데이터가 없습니다.`;
33
+ }
34
+ const lines = [`[${modeLabel}] ${MARKET_LABELS[market]} ${KIND_LABELS[kind]} (상위 ${shown.length}종목)`, ""];
35
+ const withValue = kind === "volume" || kind === "value";
36
+ lines.push(withValue
37
+ ? "| 순위 | 종목명 | 코드 | 현재가 | 등락률 | 거래량 | 거래대금(백만원) |"
38
+ : "| 순위 | 종목명 | 코드 | 현재가 | 등락률 | 거래량 |", withValue ? "|---:|---|---|---:|---:|---:|---:|" : "|---:|---|---|---:|---:|---:|");
39
+ shown.forEach((item, i) => {
40
+ const cells = commonCells(i + 1, item);
41
+ if (kind === "volume") {
42
+ const v = item;
43
+ cells.push(formatNumber(parseKiwoomNumber(v.trde_qty)), formatNumber(parseKiwoomNumber(v.trde_amt)));
44
+ }
45
+ else if (kind === "value") {
46
+ const v = item;
47
+ cells.push(formatNumber(parseKiwoomNumber(v.now_trde_qty)), formatNumber(parseKiwoomNumber(v.trde_prica)));
48
+ }
49
+ else {
50
+ cells.push(formatNumber(parseKiwoomNumber(item.now_trde_qty)));
51
+ }
52
+ lines.push(row(cells));
53
+ });
54
+ return lines.join("\n");
55
+ }
56
+ export function registerRankingTool(server) {
57
+ server.registerTool("get_ranking", {
58
+ title: "시장 순위 조회",
59
+ description: "당일 시장 순위를 조회합니다 (키움 ka10027/ka10030/ka10032). type: rise(상승률)/" +
60
+ "fall(하락률)/volume(거래량)/value(거래대금). market: all(전체, 기본)/kospi/kosdaq.",
61
+ inputSchema: {
62
+ type: z.enum(["rise", "fall", "volume", "value"]).describe("순위 종류"),
63
+ market: z.enum(["all", "kospi", "kosdaq"]).optional().describe("시장 구분 (기본값: all)"),
64
+ top: z
65
+ .number()
66
+ .int()
67
+ .min(1)
68
+ .max(MAX_TOP)
69
+ .optional()
70
+ .describe(`표시할 종목 수 (기본값 ${DEFAULT_TOP}, 최대 ${MAX_TOP})`),
71
+ },
72
+ }, async ({ type, market, top }) => runTool(async () => {
73
+ const { client, config } = getKiwoomContext();
74
+ const m = market ?? "all";
75
+ const count = top ?? DEFAULT_TOP;
76
+ const items = type === "volume"
77
+ ? await fetchVolumeRanking(client, m)
78
+ : type === "value"
79
+ ? await fetchValueRanking(client, m)
80
+ : await fetchPriceChangeRanking(client, m, type);
81
+ return textResult(formatRanking(type, m, items, count, config.modeLabel));
82
+ }));
83
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchShortSelling } from "../kiwoom/api.js";
4
+ import { formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
5
+ import { formatKRW, formatPercent, formatQuantity, formatRatioPercent, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
6
+ import { runTool, textResult } from "./helpers.js";
7
+ const DEFAULT_LOOKBACK_DAYS = 30;
8
+ export function formatShortSelling(rows, query, modeLabel) {
9
+ const period = `${formatDateDashed(query.fromDate)} ~ ${formatDateDashed(query.toDate)}`;
10
+ if (rows.length === 0) {
11
+ return `[${modeLabel}] 공매도 추이가 없습니다 (종목 ${query.stockCode}, ${period}).`;
12
+ }
13
+ const n = parseKiwoomNumber;
14
+ const lines = [
15
+ `[${modeLabel}] 공매도 추이 — 종목 ${query.stockCode} (${period}, ${rows.length}일)`,
16
+ "",
17
+ "| 일자 | 종가 | 등락률 | 거래량 | 공매도량 | 공매도비중 | 공매도평균가 |",
18
+ "|---|---:|---:|---:|---:|---:|---:|",
19
+ ];
20
+ for (const r of rows) {
21
+ const cells = [
22
+ formatDateDashed(r.dt),
23
+ formatKRW(parseKiwoomPrice(r.close_pric)),
24
+ formatPercent(n(r.flu_rt)),
25
+ formatQuantity(n(r.trde_qty)),
26
+ formatQuantity(n(r.shrts_qty)),
27
+ formatRatioPercent(n(r.trde_wght)),
28
+ formatKRW(parseKiwoomPrice(r.shrts_avg_pric)),
29
+ ];
30
+ lines.push(`| ${cells.join(" | ")} |`);
31
+ }
32
+ lines.push("", "※ 공매도비중 = 공매도량 / 거래량. 종가·등락률의 부호는 전일 대비 방향입니다.");
33
+ return lines.join("\n");
34
+ }
35
+ export function registerShortSellingTool(server) {
36
+ server.registerTool("get_short_selling", {
37
+ title: "공매도 추이 조회",
38
+ description: "특정 종목의 일자별 공매도 추이를 조회합니다 — 종가, 등락률, 거래량, 공매도량, 공매도비중, " +
39
+ "공매도평균가 (키움 ka10014). 기본 조회 기간은 최근 30일이며 from_date/to_date로 변경할 수 있습니다.",
40
+ inputSchema: {
41
+ stock_code: z
42
+ .string()
43
+ .regex(/^\d{6}$/, "6자리 종목코드여야 합니다")
44
+ .describe("조회할 6자리 종목코드"),
45
+ from_date: z
46
+ .string()
47
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
48
+ .optional()
49
+ .describe("조회 시작일 (기본값: 30일 전)"),
50
+ to_date: z
51
+ .string()
52
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
53
+ .optional()
54
+ .describe("조회 종료일 (기본값: 오늘)"),
55
+ },
56
+ }, async ({ stock_code, from_date, to_date }) => runTool(async () => {
57
+ const { client, config } = getKiwoomContext();
58
+ const query = {
59
+ stockCode: stock_code,
60
+ fromDate: (from_date ?? kstDaysAgo(DEFAULT_LOOKBACK_DAYS)).replaceAll("-", ""),
61
+ toDate: (to_date ?? todayInKst()).replaceAll("-", ""),
62
+ };
63
+ const rows = await fetchShortSelling(client, query.stockCode, query.fromDate, query.toDate);
64
+ return textResult(formatShortSelling(rows, query, config.modeLabel));
65
+ }));
66
+ }
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchDailyChart, fetchMinuteChart } from "../kiwoom/api.js";
4
+ import { formatDateDashed, todayInKst } from "../utils/date.js";
5
+ import { formatNumber, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
6
+ import { runTool, textResult } from "./helpers.js";
7
+ const DEFAULT_COUNT = 30;
8
+ const MAX_COUNT = 200;
9
+ const PERIOD_LABELS = {
10
+ day: "일봉",
11
+ week: "주봉",
12
+ month: "월봉",
13
+ minute: "분봉",
14
+ };
15
+ /** "20260703153000" → "2026-07-03 15:30" */
16
+ function formatMinuteTime(cntrTm) {
17
+ if (cntrTm.length < 12)
18
+ return cntrTm;
19
+ return `${formatDateDashed(cntrTm)} ${cntrTm.slice(8, 10)}:${cntrTm.slice(10, 12)}`;
20
+ }
21
+ function candleRow(time, item, volume) {
22
+ const p = (raw) => formatNumber(parseKiwoomPrice(raw));
23
+ return `| ${time} | ${p(item.open_pric)} | ${p(item.high_pric)} | ${p(item.low_pric)} | ${p(item.cur_prc)} | ${volume} |`;
24
+ }
25
+ export function formatDailyChart(items, stockCode, period, count, modeLabel) {
26
+ if (items.length === 0) {
27
+ return `[${modeLabel}] ${stockCode} ${PERIOD_LABELS[period]} 데이터가 없습니다. 종목코드를 확인해 주세요.`;
28
+ }
29
+ // API answers newest-first; display oldest→newest for trend reading.
30
+ const shown = items.slice(0, count).reverse();
31
+ const lines = [
32
+ `[${modeLabel}] ${stockCode} ${PERIOD_LABELS[period]} 차트 (최근 ${shown.length}개, 수정주가 반영)`,
33
+ "",
34
+ "| 일자 | 시가 | 고가 | 저가 | 종가 | 거래량 |",
35
+ "|---|---:|---:|---:|---:|---:|",
36
+ ...shown.map((i) => candleRow(formatDateDashed(i.dt), i, formatNumber(parseKiwoomNumber(i.trde_qty)))),
37
+ ];
38
+ return lines.join("\n");
39
+ }
40
+ export function formatMinuteChart(items, stockCode, ticScope, count, modeLabel) {
41
+ if (items.length === 0) {
42
+ return `[${modeLabel}] ${stockCode} 분봉 데이터가 없습니다. 종목코드를 확인해 주세요.`;
43
+ }
44
+ const shown = items.slice(0, count).reverse();
45
+ const lines = [
46
+ `[${modeLabel}] ${stockCode} ${ticScope}분봉 차트 (최근 ${shown.length}개, 수정주가 반영)`,
47
+ "",
48
+ "| 시각 | 시가 | 고가 | 저가 | 종가 | 거래량 |",
49
+ "|---|---:|---:|---:|---:|---:|",
50
+ ...shown.map((i) => candleRow(formatMinuteTime(i.cntr_tm), i, formatNumber(parseKiwoomNumber(i.trde_qty)))),
51
+ ];
52
+ return lines.join("\n");
53
+ }
54
+ export function registerStockChartTool(server) {
55
+ server.registerTool("get_stock_chart", {
56
+ title: "주식 차트 조회 (일/주/월/분봉)",
57
+ description: "종목의 캔들 차트 데이터를 조회합니다 (키움 ka10080~ka10083, 수정주가 반영). " +
58
+ "period: day(일봉, 기본)/week(주봉)/month(월봉)/minute(분봉). 분봉은 minute_scope로 " +
59
+ "분 단위를 지정합니다. 종목코드를 모르면 search_stock으로 먼저 찾으세요.",
60
+ inputSchema: {
61
+ stock_code: z
62
+ .string()
63
+ .regex(/^[0-9A-Z]{6}$/i, "6자리 종목코드여야 합니다")
64
+ .describe("6자리 종목코드 (예: 005930)"),
65
+ period: z
66
+ .enum(["day", "week", "month", "minute"])
67
+ .optional()
68
+ .describe("봉 주기 (기본값: day)"),
69
+ count: z
70
+ .number()
71
+ .int()
72
+ .min(1)
73
+ .max(MAX_COUNT)
74
+ .optional()
75
+ .describe(`캔들 개수 (기본값 ${DEFAULT_COUNT}, 최대 ${MAX_COUNT})`),
76
+ minute_scope: z
77
+ .enum(["1", "3", "5", "10", "15", "30", "45", "60"])
78
+ .optional()
79
+ .describe("period=minute일 때 분 단위 (기본값: 5)"),
80
+ },
81
+ }, async ({ stock_code, period, count, minute_scope }) => runTool(async () => {
82
+ const { client, config } = getKiwoomContext();
83
+ const code = stock_code.toUpperCase();
84
+ const n = count ?? DEFAULT_COUNT;
85
+ if (period === "minute") {
86
+ const scope = minute_scope ?? "5";
87
+ const items = await fetchMinuteChart(client, code, scope);
88
+ return textResult(formatMinuteChart(items, code, scope, n, config.modeLabel));
89
+ }
90
+ const p = period ?? "day";
91
+ const items = await fetchDailyChart(client, code, p, todayInKst());
92
+ return textResult(formatDailyChart(items, code, p, n, config.modeLabel));
93
+ }));
94
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchStockInfo } from "../kiwoom/api.js";
4
+ import { formatKRW, formatPercent, parseKiwoomNumber, parseKiwoomPrice, } from "../utils/num.js";
5
+ import { runTool, textResult } from "./helpers.js";
6
+ const PRE_SIG_LABELS = {
7
+ "1": "상한",
8
+ "2": "상승",
9
+ "3": "보합",
10
+ "4": "하한",
11
+ "5": "하락",
12
+ };
13
+ export function formatStockInfo(info, modeLabel) {
14
+ const cur = parseKiwoomPrice(info.cur_prc);
15
+ const change = parseKiwoomNumber(info.pred_pre);
16
+ const fluRt = parseKiwoomNumber(info.flu_rt);
17
+ const volume = parseKiwoomNumber(info.trde_qty);
18
+ const sigLabel = PRE_SIG_LABELS[info.pre_sig] ?? "";
19
+ const changeText = change === null
20
+ ? "-"
21
+ : `${sigLabel ? `${sigLabel} ` : ""}${formatKRW(Math.abs(change))} (${formatPercent(fluRt)})`.trim();
22
+ const lines = [
23
+ `[${modeLabel}] ${info.stk_nm} (${info.stk_cd}) 시세`,
24
+ "",
25
+ `- 현재가: ${formatKRW(cur)}`,
26
+ `- 전일대비: ${changeText}`,
27
+ `- 기준가(전일종가): ${formatKRW(parseKiwoomPrice(info.base_pric))}`,
28
+ `- 시가/고가/저가: ${formatKRW(parseKiwoomPrice(info.open_pric))} / ${formatKRW(parseKiwoomPrice(info.high_pric))} / ${formatKRW(parseKiwoomPrice(info.low_pric))}`,
29
+ `- 거래량: ${volume === null ? "-" : `${volume.toLocaleString("ko-KR")}주`}`,
30
+ `- 250일 최고/최저: ${formatKRW(parseKiwoomPrice(info["250hgst"]))} / ${formatKRW(parseKiwoomPrice(info["250lwst"]))}`,
31
+ ];
32
+ const per = parseKiwoomNumber(info.per);
33
+ const eps = parseKiwoomNumber(info.eps);
34
+ const pbr = parseKiwoomNumber(info.pbr);
35
+ const mac = parseKiwoomNumber(info.mac);
36
+ const fundamentals = [];
37
+ if (per !== null)
38
+ fundamentals.push(`PER ${per}`);
39
+ if (eps !== null)
40
+ fundamentals.push(`EPS ${formatKRW(eps)}`);
41
+ if (pbr !== null)
42
+ fundamentals.push(`PBR ${pbr}`);
43
+ if (mac !== null)
44
+ fundamentals.push(`시가총액 ${mac.toLocaleString("ko-KR")}억원`);
45
+ if (fundamentals.length > 0) {
46
+ lines.push(`- ${fundamentals.join(" / ")}`);
47
+ }
48
+ return lines.join("\n");
49
+ }
50
+ export function registerStockPriceTool(server) {
51
+ server.registerTool("get_stock_price", {
52
+ title: "종목 현재가 조회",
53
+ description: "6자리 종목코드로 국내 주식/ETF의 현재가, 등락률, 거래량, 기본 지표를 조회합니다 (키움 ka10001). " +
54
+ "종목명만 알고 있다면 search_stock으로 먼저 코드를 찾으세요.",
55
+ inputSchema: {
56
+ stock_code: z
57
+ .string()
58
+ .regex(/^\d{6}$/, "6자리 숫자 종목코드여야 합니다 (예: 005930)")
59
+ .describe("6자리 종목코드 (예: 삼성전자 005930, KODEX 200 069500)"),
60
+ },
61
+ }, async ({ stock_code }) => runTool(async () => {
62
+ const { client, config } = getKiwoomContext();
63
+ const info = await fetchStockInfo(client, stock_code);
64
+ return textResult(formatStockInfo(info, config.modeLabel));
65
+ }));
66
+ }
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { loadMasterList } from "../kiwoom/master-list.js";
4
+ import { formatKRW, parseKiwoomPrice } from "../utils/num.js";
5
+ import { runTool, textResult } from "./helpers.js";
6
+ const MAX_RESULTS = 10;
7
+ /** Test hook — clears the shared master-list cache. */
8
+ export { clearMasterListCache as clearStockListCache } from "../kiwoom/master-list.js";
9
+ const normalize = (s) => s.replaceAll(/\s+/g, "").toLowerCase();
10
+ export function searchStockItems(items, query, limit = MAX_RESULTS) {
11
+ const q = normalize(query);
12
+ if (q === "")
13
+ return [];
14
+ if (/^[0-9a-z]{6}$/.test(q)) {
15
+ const byCode = items.filter((i) => i.code.toLowerCase() === q);
16
+ if (byCode.length > 0)
17
+ return byCode.slice(0, limit);
18
+ }
19
+ const exact = [];
20
+ const prefix = [];
21
+ const partial = [];
22
+ for (const item of items) {
23
+ const name = normalize(item.name);
24
+ if (name === q)
25
+ exact.push(item);
26
+ else if (name.startsWith(q))
27
+ prefix.push(item);
28
+ else if (name.includes(q))
29
+ partial.push(item);
30
+ }
31
+ return [...exact, ...prefix, ...partial].slice(0, limit);
32
+ }
33
+ export function formatSearchResults(results, query, modeLabel) {
34
+ if (results.length === 0) {
35
+ return (`[${modeLabel}] "${query}"에 해당하는 종목을 찾지 못했습니다. ` +
36
+ `다른 키워드나 정확한 종목명으로 다시 검색해 보세요.`);
37
+ }
38
+ const lines = [
39
+ `[${modeLabel}] 종목 검색: "${query}" (${results.length}건${results.length >= MAX_RESULTS ? ", 상위만 표시" : ""})`,
40
+ "",
41
+ "| 코드 | 종목명 | 시장 | 전일종가 | 업종 |",
42
+ "|---|---|---|---:|---|",
43
+ ];
44
+ for (const r of results) {
45
+ lines.push(`| ${r.code} | ${r.name} | ${r.marketName || "-"} | ${formatKRW(parseKiwoomPrice(r.lastPrice))} | ${r.upName || "-"} |`);
46
+ }
47
+ return lines.join("\n");
48
+ }
49
+ export function registerStockSearchTool(server) {
50
+ server.registerTool("search_stock", {
51
+ title: "종목 검색 (이름→코드)",
52
+ description: "종목명(부분 일치)이나 6자리 코드로 코스피/코스닥 상장 종목(ETF/ETN 포함)을 검색해 " +
53
+ "종목코드를 찾습니다 (키움 ka10099). 다른 tool에 넘길 종목코드를 모를 때 먼저 사용하세요. " +
54
+ "첫 호출은 종목 마스터를 내려받아 몇 초 걸리고, 이후 12시간 동안 캐시됩니다.",
55
+ inputSchema: {
56
+ query: z.string().min(1).describe("종목명 일부(예: '삼성전자', 'KODEX 미국') 또는 6자리 종목코드"),
57
+ },
58
+ }, async ({ query }) => runTool(async () => {
59
+ const { client, config } = getKiwoomContext();
60
+ const items = await loadMasterList(client);
61
+ return textResult(formatSearchResults(searchStockItems(items, query), query, config.modeLabel));
62
+ }));
63
+ }
@@ -0,0 +1,120 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchThemeGroups, fetchThemeStocks } from "../kiwoom/api.js";
4
+ import { normalizeStockCode } from "../kiwoom/types.js";
5
+ import { formatKRW, formatPercent, formatQuantity, formatSignedKRW, parseKiwoomNumber, parseKiwoomPrice, } from "../utils/num.js";
6
+ import { runTool, textResult } from "./helpers.js";
7
+ const DEFAULT_GROUP_LIMIT = 30;
8
+ export function formatThemeGroups(groups, modeLabel, opts) {
9
+ if (groups.length === 0) {
10
+ return opts.stockCode
11
+ ? `[${modeLabel}] 종목 ${opts.stockCode}이(가) 편입된 테마가 없습니다.`
12
+ : `[${modeLabel}] 테마 그룹을 찾을 수 없습니다.`;
13
+ }
14
+ // 종목 검색(qry_tp 2)은 결과가 적어 전부, 전체 목록은 등락률 상위 limit개만 표시.
15
+ const shown = opts.stockCode ? groups : groups.slice(0, opts.limit);
16
+ const n = parseKiwoomNumber;
17
+ const heading = opts.stockCode
18
+ ? `[${modeLabel}] 종목 ${opts.stockCode} 편입 테마 (${groups.length}개)`
19
+ : `[${modeLabel}] 테마 그룹 — 등락률 상위 ${shown.length}개 (기간수익률 10일 기준)`;
20
+ const lines = [
21
+ heading,
22
+ "",
23
+ "| 테마 | 코드 | 종목수 | 등락률 | 상승/하락 | 기간수익률 | 주요종목 |",
24
+ "|---|---|---:|---:|---:|---:|---|",
25
+ ];
26
+ for (const g of shown) {
27
+ const cells = [
28
+ g.thema_nm || "-",
29
+ g.thema_grp_cd || "-",
30
+ n(g.stk_num)?.toLocaleString("ko-KR") ?? "-",
31
+ formatPercent(n(g.flu_rt)),
32
+ `${n(g.rising_stk_num) ?? "-"}/${n(g.fall_stk_num) ?? "-"}`,
33
+ formatPercent(n(g.dt_prft_rt)),
34
+ g.main_stk || "-",
35
+ ];
36
+ lines.push(`| ${cells.join(" | ")} |`);
37
+ }
38
+ if (!opts.stockCode && groups.length > shown.length) {
39
+ lines.push("", `※ 등락률 상위 ${shown.length}개만 표시했습니다 (limit으로 최대 100개까지 조정 가능).`);
40
+ }
41
+ lines.push("", "※ 특정 테마의 구성종목은 get_theme_stocks에 '코드' 값을 넣어 조회하세요.");
42
+ return lines.join("\n");
43
+ }
44
+ export function formatThemeStocks(response, themeCode, modeLabel) {
45
+ const stocks = response.thema_comp_stk;
46
+ if (stocks.length === 0) {
47
+ return `[${modeLabel}] 테마 ${themeCode}의 구성종목이 없습니다 (테마 코드를 확인하세요).`;
48
+ }
49
+ const n = parseKiwoomNumber;
50
+ const themeRt = n(response.flu_rt);
51
+ const themePrft = n(response.dt_prft_rt);
52
+ const aggregate = themeRt !== null || themePrft !== null
53
+ ? ` — 테마 등락률 ${formatPercent(themeRt)} · 기간수익률 ${formatPercent(themePrft)}`
54
+ : "";
55
+ const lines = [
56
+ `[${modeLabel}] 테마 구성종목 (코드 ${themeCode}, ${stocks.length}종목)${aggregate}`,
57
+ "",
58
+ "| 종목 | 현재가 | 전일대비 | 등락률 | 거래량 | 기간수익률 |",
59
+ "|---|---:|---:|---:|---:|---:|",
60
+ ];
61
+ for (const s of stocks) {
62
+ const code = normalizeStockCode(s.stk_cd);
63
+ const cells = [
64
+ s.stk_nm ? `${s.stk_nm} (${code})` : code || "-",
65
+ formatKRW(parseKiwoomPrice(s.cur_prc)),
66
+ formatSignedKRW(n(s.pred_pre)),
67
+ formatPercent(n(s.flu_rt)),
68
+ formatQuantity(n(s.acc_trde_qty)),
69
+ formatPercent(n(s.dt_prft_rt_n)),
70
+ ];
71
+ lines.push(`| ${cells.join(" | ")} |`);
72
+ }
73
+ return lines.join("\n");
74
+ }
75
+ export function registerThemeGroupsTool(server) {
76
+ server.registerTool("get_theme_groups", {
77
+ title: "테마 그룹 조회",
78
+ description: "키움 테마 그룹 목록을 조회합니다 — 테마명, 종목수, 등락률, 상승/하락 종목수, 기간수익률(10일), " +
79
+ "주요종목 (키움 ka90001). 기본은 등락률 상위 테마를 보여주며, stock_code를 주면 해당 종목이 " +
80
+ "편입된 테마를 검색합니다. 특정 테마의 구성종목은 get_theme_stocks로 조회하세요.",
81
+ inputSchema: {
82
+ stock_code: z
83
+ .string()
84
+ .regex(/^\d{6}$/, "6자리 종목코드여야 합니다")
85
+ .optional()
86
+ .describe("특정 종목이 편입된 테마만 검색할 6자리 종목코드"),
87
+ limit: z
88
+ .number()
89
+ .int()
90
+ .min(1)
91
+ .max(100)
92
+ .optional()
93
+ .describe("표시할 테마 개수 (기본 30, 최대 100; 등락률 상위순). 종목 검색 시에는 무시됩니다."),
94
+ },
95
+ }, async ({ stock_code, limit }) => runTool(async () => {
96
+ const { client, config } = getKiwoomContext();
97
+ const groups = await fetchThemeGroups(client, stock_code);
98
+ return textResult(formatThemeGroups(groups, config.modeLabel, {
99
+ stockCode: stock_code,
100
+ limit: limit ?? DEFAULT_GROUP_LIMIT,
101
+ }));
102
+ }));
103
+ }
104
+ export function registerThemeStocksTool(server) {
105
+ server.registerTool("get_theme_stocks", {
106
+ title: "테마 구성종목 조회",
107
+ description: "특정 테마 그룹의 구성종목과 시세를 조회합니다 — 종목별 현재가, 전일대비, 등락률, 거래량, " +
108
+ "기간수익률 (키움 ka90002). theme_code는 get_theme_groups가 돌려주는 '코드' 값입니다.",
109
+ inputSchema: {
110
+ theme_code: z
111
+ .string()
112
+ .regex(/^\d{1,6}$/, "테마 코드는 숫자여야 합니다")
113
+ .describe("테마 그룹 코드 (get_theme_groups의 '코드' 열 값)"),
114
+ },
115
+ }, async ({ theme_code }) => runTool(async () => {
116
+ const { client, config } = getKiwoomContext();
117
+ const response = await fetchThemeStocks(client, theme_code);
118
+ return textResult(formatThemeStocks(response, theme_code, config.modeLabel));
119
+ }));
120
+ }
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchTradingJournal } from "../kiwoom/api.js";
4
+ import { normalizeStockCode } from "../kiwoom/types.js";
5
+ import { formatDateDashed, todayInKst } from "../utils/date.js";
6
+ import { formatKRW, formatPercent, formatSignedKRW, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
7
+ import { runTool, textResult } from "./helpers.js";
8
+ export function formatTradingJournal(response, baseDate, modeLabel) {
9
+ // 매매 없는 날은 전 필드가 빈 placeholder 1행을 돌려주므로 실제 종목행만 남긴다.
10
+ const rows = response.tdy_trde_diary.filter((r) => r.stk_cd.trim() !== "" || r.stk_nm.trim() !== "");
11
+ // 2개월 초과 조회는 return_code 0 + return_msg 안내 + 빈 데이터로 온다.
12
+ const notice = (response.return_msg ?? "").includes("2개월") ? `\n⚠️ ${response.return_msg?.trim()}` : "";
13
+ const dateLabel = formatDateDashed(baseDate);
14
+ if (rows.length === 0) {
15
+ return `[${modeLabel}] ${dateLabel} 당일 매매 내역이 없습니다.${notice}`;
16
+ }
17
+ const n = parseKiwoomNumber;
18
+ const lines = [
19
+ `[${modeLabel}] 당일매매일지 — ${dateLabel} (${rows.length}종목)`,
20
+ `합계 — 매도 ${formatKRW(n(response.tot_sell_amt))} / 매수 ${formatKRW(n(response.tot_buy_amt))} / ` +
21
+ `손익 ${formatSignedKRW(n(response.tot_pl_amt))} (${formatPercent(n(response.tot_prft_rt))}) · ` +
22
+ `수수료·세금 ${formatKRW(n(response.tot_cmsn_tax))}`,
23
+ "",
24
+ "| 종목 | 매수평균가 | 매수수량 | 매도평균가 | 매도수량 | 손익금액 | 수익률 |",
25
+ "|---|---:|---:|---:|---:|---:|---:|",
26
+ ];
27
+ for (const r of rows) {
28
+ const code = normalizeStockCode(r.stk_cd);
29
+ const cells = [
30
+ r.stk_nm ? `${r.stk_nm} (${code})` : code || "-",
31
+ formatKRW(parseKiwoomPrice(r.buy_avg_pric)),
32
+ n(r.buy_qty)?.toLocaleString("ko-KR") ?? "-",
33
+ formatKRW(parseKiwoomPrice(r.sel_avg_pric)),
34
+ n(r.sell_qty)?.toLocaleString("ko-KR") ?? "-",
35
+ formatSignedKRW(n(r.pl_amt)),
36
+ formatPercent(n(r.prft_rt)),
37
+ ];
38
+ lines.push(`| ${cells.join(" | ")} |`);
39
+ }
40
+ lines.push("", "※ 당일 매수·매도가 모두 있는 종목의 실현손익입니다 (수수료·세금 반영).");
41
+ return lines.join("\n") + notice;
42
+ }
43
+ export function registerTradingJournalTool(server) {
44
+ server.registerTool("get_trading_journal", {
45
+ title: "당일매매일지 조회",
46
+ description: "특정일의 당일매매일지를 조회합니다 — 종목별 매수/매도 평균가·수량, 손익금액, 수익률과 총손익·총수익률 " +
47
+ "(키움 ka10170). base_date를 생략하면 오늘 기준이며, 최근 2개월 이내 날짜만 조회할 수 있습니다.",
48
+ inputSchema: {
49
+ base_date: z
50
+ .string()
51
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
52
+ .optional()
53
+ .describe("조회 기준일 (기본값: 오늘, 최근 2개월 이내)"),
54
+ },
55
+ }, async ({ base_date }) => runTool(async () => {
56
+ const { client, config } = getKiwoomContext();
57
+ const baseDate = (base_date ?? todayInKst()).replaceAll("-", "");
58
+ const response = await fetchTradingJournal(client, baseDate);
59
+ return textResult(formatTradingJournal(response, baseDate, config.modeLabel));
60
+ }));
61
+ }