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,36 @@
1
+ import { getKiwoomContext } from "../context.js";
2
+ import { fetchAccountEvaluation, fetchDeposit } from "../kiwoom/api.js";
3
+ import { formatKRW, formatPercent, formatSignedKRW, parseKiwoomNumber } from "../utils/num.js";
4
+ import { runTool, textResult } from "./helpers.js";
5
+ export function formatBalance(deposit, evaluation, modeLabel) {
6
+ const n = parseKiwoomNumber;
7
+ return [
8
+ `[${modeLabel}] 계좌 잔고 요약`,
9
+ "",
10
+ "■ 예수금 (kt00001)",
11
+ `- 예수금: ${formatKRW(n(deposit.entr))}`,
12
+ `- D+1 추정예수금: ${formatKRW(n(deposit.d1_entra))} / D+2 추정예수금: ${formatKRW(n(deposit.d2_entra))}`,
13
+ `- 주문가능금액: ${formatKRW(n(deposit.ord_alow_amt))} / 출금가능금액: ${formatKRW(n(deposit.pymn_alow_amt))}`,
14
+ "",
15
+ "■ 평가 현황 (kt00018)",
16
+ `- 총매입금액: ${formatKRW(n(evaluation.tot_pur_amt))}`,
17
+ `- 총평가금액: ${formatKRW(n(evaluation.tot_evlt_amt))}`,
18
+ `- 총평가손익: ${formatSignedKRW(n(evaluation.tot_evlt_pl))} (${formatPercent(n(evaluation.tot_prft_rt))})`,
19
+ `- 추정예탁자산: ${formatKRW(n(evaluation.prsm_dpst_aset_amt))}`,
20
+ ].join("\n");
21
+ }
22
+ export function registerAccountBalanceTool(server) {
23
+ server.registerTool("get_account_balance", {
24
+ title: "계좌 잔고 조회",
25
+ description: "계좌의 예수금(주문가능/출금가능 포함)과 총매입금액, 총평가금액, 총평가손익, " +
26
+ "추정예탁자산을 조회합니다 (키움 kt00001 + kt00018). 인자가 필요 없습니다.",
27
+ }, async () => runTool(async () => {
28
+ const { client, config } = getKiwoomContext();
29
+ // Different TRs — the per-TR rate limit allows these in parallel.
30
+ const [deposit, evaluation] = await Promise.all([
31
+ fetchDeposit(client),
32
+ fetchAccountEvaluation(client, "1"),
33
+ ]);
34
+ return textResult(formatBalance(deposit, evaluation, config.modeLabel));
35
+ }));
36
+ }
@@ -0,0 +1,52 @@
1
+ import { getKiwoomContext } from "../context.js";
2
+ import { fetchAccountEvaluation } from "../kiwoom/api.js";
3
+ import { normalizeStockCode } from "../kiwoom/types.js";
4
+ import { formatKRW, formatPercent, formatSignedKRW, parseKiwoomNumber, parseKiwoomPrice, } from "../utils/num.js";
5
+ import { runTool, textResult } from "./helpers.js";
6
+ export function formatHoldings(evaluation, modeLabel, truncated = false) {
7
+ const n = parseKiwoomNumber;
8
+ const holdings = evaluation.acnt_evlt_remn_indv_tot;
9
+ if (holdings.length === 0) {
10
+ return `[${modeLabel}] 보유 종목이 없습니다.`;
11
+ }
12
+ const header = [
13
+ `[${modeLabel}] 보유 종목 (${holdings.length}종목, KRX 기준)`,
14
+ "",
15
+ "| 종목명 | 코드 | 보유수량 | 평균단가 | 현재가 | 평가금액 | 평가손익 | 수익률 | 비중 |",
16
+ "|---|---|---:|---:|---:|---:|---:|---:|---:|",
17
+ ];
18
+ const rows = holdings.map((h) => {
19
+ const cells = [
20
+ h.stk_nm || "-",
21
+ normalizeStockCode(h.stk_cd) || "-",
22
+ n(h.rmnd_qty)?.toLocaleString("ko-KR") ?? "-",
23
+ formatKRW(parseKiwoomPrice(h.pur_pric)),
24
+ formatKRW(parseKiwoomPrice(h.cur_prc)),
25
+ formatKRW(n(h.evlt_amt)),
26
+ formatSignedKRW(n(h.evltv_prft)),
27
+ formatPercent(n(h.prft_rt)),
28
+ n(h.poss_rt) === null ? "-" : `${n(h.poss_rt)}%`,
29
+ ];
30
+ return `| ${cells.join(" | ")} |`;
31
+ });
32
+ const footer = [
33
+ "",
34
+ `합계 — 매입 ${formatKRW(n(evaluation.tot_pur_amt))} / 평가 ${formatKRW(n(evaluation.tot_evlt_amt))} / ` +
35
+ `평가손익 ${formatSignedKRW(n(evaluation.tot_evlt_pl))} (${formatPercent(n(evaluation.tot_prft_rt))})`,
36
+ ];
37
+ if (truncated) {
38
+ footer.push("", "⚠️ 보유 종목이 많아 조회 상한에 도달했습니다 — 일부 종목이 누락되었을 수 있습니다.");
39
+ }
40
+ return [...header, ...rows, ...footer].join("\n");
41
+ }
42
+ export function registerAccountHoldingsTool(server) {
43
+ server.registerTool("get_account_holdings", {
44
+ title: "보유 종목 조회",
45
+ description: "계좌의 보유 종목 목록을 조회합니다 — 종목별 수량, 평균단가, 현재가, 평가금액, " +
46
+ "평가손익, 수익률, 보유비중 (키움 kt00018). 인자가 필요 없습니다.",
47
+ }, async () => runTool(async () => {
48
+ const { client, config } = getKiwoomContext();
49
+ const evaluation = await fetchAccountEvaluation(client, "2");
50
+ return textResult(formatHoldings(evaluation, config.modeLabel, evaluation.truncated));
51
+ }));
52
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchEtfInfo, fetchStockInfo } from "../kiwoom/api.js";
4
+ import { formatKRW, formatPercent, formatQuantity, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
5
+ import { runTool, textResult } from "./helpers.js";
6
+ export function formatEtfInfo(etf, quote, stockCode, modeLabel) {
7
+ if (!etf.stk_nm) {
8
+ return `[${modeLabel}] ${stockCode}의 ETF 정보가 없습니다. ETF 종목코드인지 확인해 주세요.`;
9
+ }
10
+ const lines = [
11
+ `[${modeLabel}] ${etf.stk_nm} (${stockCode}) ETF 정보`,
12
+ "",
13
+ `- 추적지수: ${etf.etfobjt_idex_nm || "-"}`,
14
+ `- 과세유형: ${etf.etftxon_type || "-"}`,
15
+ ];
16
+ if (quote) {
17
+ lines.push(`- 현재가: ${formatKRW(parseKiwoomPrice(quote.cur_prc))} (${formatPercent(parseKiwoomNumber(quote.flu_rt))})`, `- 거래량: ${formatQuantity(parseKiwoomNumber(quote.trde_qty))}`);
18
+ }
19
+ lines.push("", "※ 과세유형은 키움 표기 기준입니다. 매매차익 과세 방식(국내주식형 비과세 vs 배당소득 과세)은 " +
20
+ "계좌 유형에 따라 달라질 수 있습니다.");
21
+ return lines.join("\n");
22
+ }
23
+ export function registerEtfInfoTool(server) {
24
+ server.registerTool("get_etf_info", {
25
+ title: "ETF 정보 조회",
26
+ description: "ETF의 추적지수, 과세유형, 현재 시세를 조회합니다 (키움 ka40002+ka10001). " +
27
+ "종목코드를 모르면 search_stock으로 먼저 찾으세요.",
28
+ inputSchema: {
29
+ stock_code: z
30
+ .string()
31
+ .regex(/^[0-9A-Z]{6}$/i, "6자리 종목코드여야 합니다")
32
+ .describe("6자리 ETF 종목코드 (예: 069500)"),
33
+ },
34
+ }, async ({ stock_code }) => runTool(async () => {
35
+ const { client, config } = getKiwoomContext();
36
+ const code = stock_code.toUpperCase();
37
+ // Different TRs — parallel is fine; the quote is best-effort garnish.
38
+ const [etf, quote] = await Promise.all([
39
+ fetchEtfInfo(client, code),
40
+ fetchStockInfo(client, code).catch(() => null),
41
+ ]);
42
+ return textResult(formatEtfInfo(etf, quote, code, config.modeLabel));
43
+ }));
44
+ }
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchForeignHolding } from "../kiwoom/api.js";
4
+ import { formatDateDashed } from "../utils/date.js";
5
+ import { formatKRW, formatQuantity, formatRatioPercent, formatSigned, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
6
+ import { runTool, textResult } from "./helpers.js";
7
+ const DEFAULT_DISPLAY_DAYS = 15;
8
+ export function formatForeignHolding(rows, stockCode, modeLabel, limit) {
9
+ if (rows.length === 0) {
10
+ return `[${modeLabel}] 외국인 보유 추이가 없습니다 (종목 ${stockCode}).`;
11
+ }
12
+ const shown = rows.slice(0, limit);
13
+ const n = parseKiwoomNumber;
14
+ const lines = [
15
+ `[${modeLabel}] 외국인 보유 추이 — 종목 ${stockCode} (최근 ${shown.length}일)`,
16
+ "",
17
+ "| 일자 | 종가 | 거래량 | 외국인순변동 | 보유주식수 | 보유비중 | 한도소진률 |",
18
+ "|---|---:|---:|---:|---:|---:|---:|",
19
+ ];
20
+ for (const r of shown) {
21
+ const cells = [
22
+ formatDateDashed(r.dt),
23
+ formatKRW(parseKiwoomPrice(r.close_pric)),
24
+ formatQuantity(n(r.trde_qty)),
25
+ `${formatSigned(n(r.chg_qty), 0)}주`,
26
+ formatQuantity(n(r.poss_stkcnt)),
27
+ formatRatioPercent(n(r.wght)),
28
+ formatRatioPercent(n(r.limit_exh_rt)),
29
+ ];
30
+ lines.push(`| ${cells.join(" | ")} |`);
31
+ }
32
+ if (rows.length > shown.length) {
33
+ lines.push("", `※ 최근 ${shown.length}일만 표시했습니다 (limit으로 최대 50일까지 조정 가능).`);
34
+ }
35
+ lines.push("", "※ 보유비중 = 외국인 보유주식수 / 상장주식수. 한도소진률 = 보유 / 외국인 한도. 종가 부호는 전일 대비 방향.");
36
+ return lines.join("\n");
37
+ }
38
+ export function registerForeignHoldingTool(server) {
39
+ server.registerTool("get_foreign_holding", {
40
+ title: "외국인 보유 추이 조회",
41
+ description: "특정 종목의 일자별 외국인 보유 동향을 조회합니다 — 종가, 거래량, 외국인 순변동수량, 보유주식수, " +
42
+ "보유비중, 한도소진률 (키움 ka10008). 최신순으로 기본 15일 표시하며 limit으로 최대 50일까지 조정할 수 있습니다.",
43
+ inputSchema: {
44
+ stock_code: z
45
+ .string()
46
+ .regex(/^\d{6}$/, "6자리 종목코드여야 합니다")
47
+ .describe("조회할 6자리 종목코드"),
48
+ limit: z
49
+ .number()
50
+ .int()
51
+ .min(1)
52
+ .max(50)
53
+ .optional()
54
+ .describe("표시할 일수 (기본 15, 최대 50; 최신순)"),
55
+ },
56
+ }, async ({ stock_code, limit }) => runTool(async () => {
57
+ const { client, config } = getKiwoomContext();
58
+ const rows = await fetchForeignHolding(client, stock_code);
59
+ return textResult(formatForeignHolding(rows, stock_code, config.modeLabel, limit ?? DEFAULT_DISPLAY_DAYS));
60
+ }));
61
+ }
@@ -0,0 +1,19 @@
1
+ export function textResult(text) {
2
+ return { content: [{ type: "text", text }] };
3
+ }
4
+ /**
5
+ * Wraps a tool body so every failure returns a readable MCP error result
6
+ * (isError: true) instead of a raw protocol exception.
7
+ */
8
+ export async function runTool(fn) {
9
+ try {
10
+ return await fn();
11
+ }
12
+ catch (error) {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ return {
15
+ isError: true,
16
+ content: [{ type: "text", text: `⚠️ ${message}` }],
17
+ };
18
+ }
19
+ }
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchInvestorDaily, fetchInvestorTotal } from "../kiwoom/api.js";
4
+ import { formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
5
+ import { formatNumber, formatSigned, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
6
+ import { runTool, textResult } from "./helpers.js";
7
+ const DEFAULT_LOOKBACK_DAYS = 30;
8
+ const DAILY_ROWS = 10;
9
+ /** amt_qty_tp cross-checked live: amount mode is 백만원, quantity mode is 주. */
10
+ const UNIT_LABELS = { amount: "백만원", quantity: "주" };
11
+ const ORGN_DETAIL = [
12
+ { key: "fnnc_invt", label: "금융투자" },
13
+ { key: "insrnc", label: "보험" },
14
+ { key: "invtrt", label: "투신" },
15
+ { key: "bank", label: "은행" },
16
+ { key: "penfnd_etc", label: "연기금등" },
17
+ { key: "samo_fund", label: "사모펀드" },
18
+ { key: "etc_fnnc", label: "기타금융" },
19
+ ];
20
+ const s = (raw) => formatSigned(parseKiwoomNumber(raw));
21
+ export function formatInvestorTrend(total, daily, stockCode, fromDate, toDate, unit, modeLabel) {
22
+ const period = `${formatDateDashed(fromDate)} ~ ${formatDateDashed(toDate)}`;
23
+ if (!total && daily.length === 0) {
24
+ return `[${modeLabel}] ${stockCode} 투자자 매매동향 데이터가 없습니다 (${period}). 종목코드를 확인해 주세요.`;
25
+ }
26
+ const lines = [
27
+ `[${modeLabel}] ${stockCode} 투자자별 매매동향 (${period}, 순매수, 단위: ${UNIT_LABELS[unit]})`,
28
+ ];
29
+ if (total) {
30
+ lines.push("", "■ 기간 합계", `- 개인: ${s(total.ind_invsr)} / 외국인: ${s(total.frgnr_invsr)} / 기관계: ${s(total.orgn)}`, ` · 기관 세부: ${ORGN_DETAIL.map((d) => `${d.label} ${s(total[d.key])}`).join(", ")}`, `- 기타법인: ${s(total.etc_corp)} / 내외국인: ${s(total.natfor)} / 국가: ${s(total.natn)}`);
31
+ }
32
+ if (daily.length > 0) {
33
+ lines.push("", `■ 최근 ${Math.min(daily.length, DAILY_ROWS)}거래일`, "| 일자 | 종가 | 전일대비 | 개인 | 외국인 | 기관계 |", "|---|---:|---:|---:|---:|---:|");
34
+ for (const d of daily.slice(0, DAILY_ROWS)) {
35
+ lines.push(`| ${formatDateDashed(d.dt)} | ${formatNumber(parseKiwoomPrice(d.cur_prc))} | ${s(d.pred_pre)} | ` +
36
+ `${s(d.ind_invsr)} | ${s(d.frgnr_invsr)} | ${s(d.orgn)} |`);
37
+ }
38
+ }
39
+ return lines.join("\n");
40
+ }
41
+ export function registerInvestorTrendTool(server) {
42
+ server.registerTool("get_investor_trend", {
43
+ title: "투자자별 매매동향 조회",
44
+ description: "종목의 개인/외국인/기관 순매수 동향을 조회합니다 (키움 ka10059+ka10061). 기간 합계와 " +
45
+ "최근 거래일별 내역을 함께 보여줍니다. unit: amount(금액, 백만원, 기본)/quantity(수량, 주). " +
46
+ "종목코드를 모르면 search_stock으로 먼저 찾으세요.",
47
+ inputSchema: {
48
+ stock_code: z
49
+ .string()
50
+ .regex(/^[0-9A-Z]{6}$/i, "6자리 종목코드여야 합니다")
51
+ .describe("6자리 종목코드 (예: 005930)"),
52
+ from_date: z
53
+ .string()
54
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
55
+ .optional()
56
+ .describe("합계 기간 시작일 (기본값: 30일 전)"),
57
+ to_date: z
58
+ .string()
59
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
60
+ .optional()
61
+ .describe("합계 기간 종료일 (기본값: 오늘)"),
62
+ unit: z.enum(["amount", "quantity"]).optional().describe("단위 (기본값: amount=백만원)"),
63
+ },
64
+ }, async ({ stock_code, from_date, to_date, unit }) => runTool(async () => {
65
+ const { client, config } = getKiwoomContext();
66
+ const code = stock_code.toUpperCase();
67
+ const fromDate = (from_date ?? kstDaysAgo(DEFAULT_LOOKBACK_DAYS)).replaceAll("-", "");
68
+ const toDate = (to_date ?? todayInKst()).replaceAll("-", "");
69
+ const u = unit ?? "amount";
70
+ // ka10061 and ka10059 are different TRs — per-TR rate limits allow parallel calls.
71
+ const [total, daily] = await Promise.all([
72
+ fetchInvestorTotal(client, code, fromDate, toDate, u),
73
+ fetchInvestorDaily(client, code, toDate, u),
74
+ ]);
75
+ return textResult(formatInvestorTrend(total, daily, code, fromDate, toDate, u, config.modeLabel));
76
+ }));
77
+ }
@@ -0,0 +1,245 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { classifyInstrument } from "../isa/classify.js";
4
+ import { classifyInstruments } from "../isa/classify-etf.js";
5
+ import { reconstructRealizedPnl } from "../isa/realized.js";
6
+ import { computeIsaTaxStatus, ISA_LIMITS } from "../isa/tax.js";
7
+ import { fetchAccountEvaluation, fetchRealizedPnlSummary, fetchTransactions } from "../kiwoom/api.js";
8
+ import { loadMasterList } from "../kiwoom/master-list.js";
9
+ import { normalizeStockCode } from "../kiwoom/types.js";
10
+ import { formatDateDashed, todayInKst } from "../utils/date.js";
11
+ import { formatKRW, formatSignedKRW, parseKiwoomNumber } from "../utils/num.js";
12
+ import { runTool, textResult } from "./helpers.js";
13
+ const DIVIDEND_PATTERN = /배당|분배|이자/;
14
+ export function scanDividends(rows) {
15
+ const scan = { total: 0, rows: [], otherNonTradeRows: 0 };
16
+ for (const row of rows) {
17
+ if (row.trde_kind_nm === "매매")
18
+ continue;
19
+ const label = `${row.trde_kind_nm} ${row.rmrk_nm}`.trim();
20
+ if (DIVIDEND_PATTERN.test(label)) {
21
+ const amount = parseKiwoomNumber(row.exct_amt) ?? parseKiwoomNumber(row.trde_amt) ?? 0;
22
+ scan.total += amount;
23
+ scan.rows.push({ date: row.trde_dt, label, amount });
24
+ }
25
+ else {
26
+ scan.otherNonTradeRows += 1;
27
+ }
28
+ }
29
+ return scan;
30
+ }
31
+ export function toTradeEvents(rows) {
32
+ const events = [];
33
+ const skipped = [];
34
+ rows.forEach((row, index) => {
35
+ if (row.trde_kind_nm !== "매매")
36
+ return;
37
+ const side = row.io_tp_nm.includes("매수") ? "BUY" : row.io_tp_nm.includes("매도") ? "SELL" : null;
38
+ const quantity = parseKiwoomNumber(row.trde_qty_jwa_cnt);
39
+ const netAmount = parseKiwoomNumber(row.exct_amt);
40
+ if (!side || quantity === null || netAmount === null) {
41
+ skipped.push(`${row.trde_dt} ${row.stk_nm} (${row.io_tp_nm || "구분 불명"})`);
42
+ return;
43
+ }
44
+ events.push({
45
+ code: normalizeStockCode(row.stk_cd),
46
+ name: row.stk_nm,
47
+ side,
48
+ quantity,
49
+ netAmount,
50
+ date: row.trde_dt,
51
+ seq: parseKiwoomNumber(row.trde_no) ?? index,
52
+ });
53
+ });
54
+ return { events, skipped };
55
+ }
56
+ function entryLines(entries) {
57
+ return entries.map((e) => ` · ${e.name} (${e.code}): ${formatSignedKRW(Math.round(e.amount))}` +
58
+ `${e.incomplete ? " ⚠️이력 불완전" : ""}${e.confident ? "" : " ※분류 추정"}`);
59
+ }
60
+ export function formatTaxReport(report) {
61
+ const s = report.status;
62
+ const round = Math.round;
63
+ const usedPct = s.limit > 0 ? Math.max(0, s.confirmedNet) / s.limit : 0;
64
+ const lines = [
65
+ `[${report.modeLabel}] ISA 비과세 한도 현황 — ${report.isaTypeLabel} (한도 ${formatKRW(s.limit)})`,
66
+ `집계 기간: ${formatDateDashed(report.fromDate)} ~ ${formatDateDashed(report.toDate)}`,
67
+ "",
68
+ "■ 확정 손익통산 (실현 기준)",
69
+ `- 과세대상 실현손익: ${formatSignedKRW(round(s.taxableRealized))}`,
70
+ ...entryLines(report.realizedEntries.filter((e) => e.taxType === "TAXABLE")),
71
+ `- 국내주식형 실현손익: ${formatSignedKRW(round(s.domesticRealizedNet))} → ` +
72
+ (s.domesticLossDeduction < 0
73
+ ? `순손실 ${formatKRW(round(-s.domesticLossDeduction))} 통산 차감`
74
+ : "이익은 비과세라 통산 제외 (차감 0원)"),
75
+ ...entryLines(report.realizedEntries.filter((e) => e.taxType === "DOMESTIC_EQUITY")),
76
+ `- 배당·분배금·이자: ${formatKRW(round(s.dividends))}` +
77
+ ` (자동 감지 ${report.dividendScan.rows.length}건` +
78
+ `${report.manualDividends !== undefined ? ` + 수동 입력 ${formatKRW(report.manualDividends)}` : ""})`,
79
+ ...report.dividendScan.rows.map((d) => ` · ${formatDateDashed(d.date)} ${d.label}: ${formatKRW(round(d.amount))}`),
80
+ `= 통산 순이익(확정): ${formatSignedKRW(round(s.confirmedNet))}`,
81
+ "",
82
+ "■ 비과세 한도",
83
+ ];
84
+ if (s.confirmedNet < 0) {
85
+ lines.push(`- 현재 순손실 상태 — 한도 ${formatKRW(s.limit)} 전액 잔여. ` +
86
+ `손실 ${formatKRW(round(-s.confirmedNet))}은 만기까지 발생하는 이익과 상계됩니다.`);
87
+ }
88
+ else {
89
+ lines.push(`- 사용: ${formatKRW(round(s.confirmedNet))} / ${formatKRW(s.limit)} (${(usedPct * 100).toFixed(1)}%)`, `- 잔여: ${formatKRW(round(s.remainingAllowance))}`, `- 한도 초과분 예상 세금(9.9%): ${formatKRW(round(s.estimatedTaxNow))}`);
90
+ }
91
+ lines.push("", "■ 시나리오: 현재 보유분 전량 매도 가정 (미실현 포함)", `- 과세대상 미실현: ${formatSignedKRW(round(s.taxableUnrealized))}`, ...entryLines(report.unrealizedEntries.filter((e) => e.taxType === "TAXABLE")), `- 국내주식형 미실현: ${formatSignedKRW(round(s.domesticUnrealizedNet))}` +
92
+ (Math.min(0, s.domesticUnrealizedNet) < 0 ? " (순손실만 차감 반영)" : " (이익은 비과세 — 미반영)"), ...entryLines(report.unrealizedEntries.filter((e) => e.taxType === "DOMESTIC_EQUITY")), `= 시나리오 통산 순이익: ${formatSignedKRW(round(s.scenarioNet))}`, `- 시나리오 잔여 한도: ${formatKRW(round(s.scenarioRemaining))} / 예상 세금: ${formatKRW(round(s.scenarioEstimatedTax))}`);
93
+ const kiwoom = report.kiwoomRealizedTotal;
94
+ if (kiwoom !== null) {
95
+ const diff = round(report.reconstructedTotal - kiwoom);
96
+ const tolerance = Math.max(1_000, Math.abs(kiwoom) * 0.01);
97
+ lines.push("", `※ 검증: 자체 재구성 실현손익 합계 ${formatSignedKRW(round(report.reconstructedTotal))} vs ` +
98
+ `키움 실현손익 TR 합계 ${formatSignedKRW(round(kiwoom))} (차이 ${formatSignedKRW(diff)}` +
99
+ `${Math.abs(diff) > tolerance ? " — ⚠️ 허용 오차 초과, 결과 신뢰도 주의" : " — 정상 범위"})`);
100
+ }
101
+ for (const warning of report.warnings) {
102
+ lines.push(`⚠️ ${warning}`);
103
+ }
104
+ lines.push("", "※ 이 계산은 참고용입니다. 배당 자동 감지는 아직 실데이터로 검증되지 않았고(미수령), " +
105
+ "과세유형 분류는 ETF는 키움 과세유형 기준이고 그 외(개별주식 등)는 종목명 기반 추정이며, " +
106
+ "손익통산 세부 규정(국내주식형 손실 차감 방식 등)은 " +
107
+ "증권사 정산과 다를 수 있습니다. 실제 과세는 만기·해지 시점에 확정되므로 정확한 금액은 " +
108
+ "증권사 안내를 기준으로 하세요.");
109
+ return lines.join("\n");
110
+ }
111
+ const ISA_TYPE_LABELS = { GENERAL: "일반형", SEOMIN: "서민형" };
112
+ /**
113
+ * ka10099 마스터리스트에서 ETF(`marketName == "ETF"`)로 표시된 코드 집합을 만든다.
114
+ * 브랜드 접두어가 없는 ETF도 ka40002 확정 분류를 받게 하기 위한 게이트. 마스터리스트
115
+ * 조회가 실패하면 빈 집합을 반환해 종목명 휴리스틱으로 폴백한다(계산은 계속 진행).
116
+ */
117
+ async function loadEtfCodeSet(client) {
118
+ try {
119
+ const master = await loadMasterList(client);
120
+ return new Set(master.filter((s) => s.marketName === "ETF").map((s) => s.code));
121
+ }
122
+ catch {
123
+ return new Set();
124
+ }
125
+ }
126
+ export function registerIsaTaxStatusTool(server) {
127
+ server.registerTool("calc_isa_tax_status", {
128
+ title: "ISA 비과세 한도 현황 계산",
129
+ description: "ISA 계좌의 손익통산 순이익을 계산해 비과세 한도(일반형 200만원/서민형 400만원) 대비 " +
130
+ "사용량·잔여량을 보여줍니다. 실현손익(거래내역 재구성)과 배당을 확정분으로, 보유 종목 " +
131
+ "미실현 손익을 '전량 매도 가정' 시나리오로 함께 제공합니다. 집계 시작일은 ISA_OPENED_ON " +
132
+ "환경변수(계좌 개설일) 또는 from_date 인자로 지정합니다. 배당 수령액이 거래내역에서 " +
133
+ "감지되지 않으면 dividends_received로 수동 입력하세요.",
134
+ inputSchema: {
135
+ from_date: z
136
+ .string()
137
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
138
+ .optional()
139
+ .describe("집계 시작일 (기본값: ISA_OPENED_ON 환경변수 = 계좌 개설일)"),
140
+ dividends_received: z
141
+ .number()
142
+ .min(0)
143
+ .optional()
144
+ .describe("집계 기간 중 수령한 배당·분배금·이자 총액(원) — 자동 감지분에 더해짐"),
145
+ overrides: z
146
+ .array(z.object({
147
+ stock_code: z.string().describe("6자리 종목코드"),
148
+ tax_type: z
149
+ .enum(["TAXABLE", "DOMESTIC_EQUITY"])
150
+ .describe("TAXABLE=과세대상(해외/채권형 등), DOMESTIC_EQUITY=국내주식형(매매차익 비과세)"),
151
+ }))
152
+ .optional()
153
+ .describe("자동 과세유형 분류가 틀린 종목의 수동 지정"),
154
+ },
155
+ }, async ({ from_date, dividends_received, overrides }) => runTool(async () => {
156
+ const { client, config } = getKiwoomContext();
157
+ const fromDate = (from_date ?? config.isaOpenedOn)?.replaceAll("-", "");
158
+ if (!fromDate) {
159
+ throw new Error("집계 시작일이 없습니다. .env에 ISA_OPENED_ON=yyyy-MM-dd(계좌 개설일)를 설정하거나 " +
160
+ "from_date 인자를 전달해 주세요.");
161
+ }
162
+ const toDate = todayInKst();
163
+ const overrideMap = new Map((overrides ?? []).map((o) => [o.stock_code, o.tax_type]));
164
+ // Distinct TRs — per-TR rate limits allow parallel calls. loadEtfCodeSet
165
+ // (ka10099) is best-effort and never rejects, so Promise.all is safe.
166
+ const [transactions, evaluation, realizedSummary, etfCodes] = await Promise.all([
167
+ fetchTransactions(client, fromDate, toDate),
168
+ fetchAccountEvaluation(client, "2"),
169
+ fetchRealizedPnlSummary(client, fromDate, toDate),
170
+ loadEtfCodeSet(client),
171
+ ]);
172
+ const { events, skipped } = toTradeEvents(transactions.rows);
173
+ const reconstruction = reconstructRealizedPnl(events);
174
+ const dividendScan = scanDividends(transactions.rows);
175
+ const holdingCodes = evaluation.acnt_evlt_remn_indv_tot.map((h) => ({
176
+ code: normalizeStockCode(h.stk_cd),
177
+ name: h.stk_nm,
178
+ }));
179
+ // 실현·미실현 종목을 한 번에 dedup 분류: ETF는 ka40002 과세유형으로 확정,
180
+ // 그 외는 종목명 휴리스틱으로 폴백한다.
181
+ const classifications = await classifyInstruments(client, [...reconstruction.perStock.map((r) => ({ code: r.code, name: r.name })), ...holdingCodes], overrideMap, etfCodes);
182
+ const classifyOf = (code, name) => classifications.get(code) ?? classifyInstrument(code, name, overrideMap);
183
+ const realizedEntries = reconstruction.perStock.map((r) => {
184
+ const c = classifyOf(r.code, r.name);
185
+ return {
186
+ code: r.code,
187
+ name: r.name,
188
+ amount: r.realized,
189
+ taxType: c.taxType,
190
+ confident: c.confident,
191
+ reason: c.reason,
192
+ incomplete: r.incompleteHistory,
193
+ };
194
+ });
195
+ const unrealizedEntries = evaluation.acnt_evlt_remn_indv_tot.map((h) => {
196
+ const code = normalizeStockCode(h.stk_cd);
197
+ const c = classifyOf(code, h.stk_nm);
198
+ return {
199
+ code,
200
+ name: h.stk_nm,
201
+ amount: parseKiwoomNumber(h.evltv_prft) ?? 0,
202
+ taxType: c.taxType,
203
+ confident: c.confident,
204
+ reason: c.reason,
205
+ };
206
+ });
207
+ const dividends = dividendScan.total + (dividends_received ?? 0);
208
+ const status = computeIsaTaxStatus({
209
+ limit: ISA_LIMITS[config.isaType],
210
+ dividends,
211
+ realized: realizedEntries,
212
+ unrealized: unrealizedEntries,
213
+ });
214
+ const warnings = [...reconstruction.warnings];
215
+ if (transactions.truncated) {
216
+ warnings.push("거래내역이 조회 상한에 도달해 일부 오래된 거래가 누락되었을 수 있습니다 — " +
217
+ "집계 시작일(from_date)을 좁혀 재확인하세요. 실현손익·배당 집계가 불완전할 수 있습니다.");
218
+ }
219
+ if (evaluation.truncated) {
220
+ warnings.push("보유 종목이 조회 상한에 도달해 일부가 누락되었을 수 있습니다 — 미실현 시나리오가 불완전할 수 있습니다.");
221
+ }
222
+ if (skipped.length > 0) {
223
+ warnings.push(`매수/매도로 분류하지 못해 제외한 거래 ${skipped.length}건: ${skipped.join(", ")}`);
224
+ }
225
+ if (dividendScan.otherNonTradeRows > 0) {
226
+ warnings.push(`배당으로 분류되지 않은 비매매 거래 ${dividendScan.otherNonTradeRows}건이 있습니다. ` +
227
+ `필요 시 거래내역을 직접 확인하세요.`);
228
+ }
229
+ const report = {
230
+ modeLabel: config.modeLabel,
231
+ isaTypeLabel: ISA_TYPE_LABELS[config.isaType],
232
+ fromDate,
233
+ toDate,
234
+ status,
235
+ realizedEntries,
236
+ unrealizedEntries,
237
+ dividendScan,
238
+ manualDividends: dividends_received,
239
+ kiwoomRealizedTotal: parseKiwoomNumber(realizedSummary.rlzt_pl),
240
+ reconstructedTotal: reconstruction.total,
241
+ warnings,
242
+ };
243
+ return textResult(formatTaxReport(report));
244
+ }));
245
+ }
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchAllIndices } from "../kiwoom/api.js";
4
+ import { formatNumber, formatPercent, formatSigned, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
5
+ import { runTool, textResult } from "./helpers.js";
6
+ const MARKET_LABELS = { kospi: "코스피", kosdaq: "코스닥" };
7
+ export function formatIndices(items, market, modeLabel) {
8
+ if (items.length === 0) {
9
+ return `[${modeLabel}] ${MARKET_LABELS[market]} 지수 데이터가 없습니다.`;
10
+ }
11
+ const lines = [
12
+ `[${modeLabel}] ${MARKET_LABELS[market]} 업종 지수 (${items.length}개)`,
13
+ "",
14
+ "| 지수 | 현재 | 전일대비 | 등락률 | 상승/보합/하락 |",
15
+ "|---|---:|---:|---:|---:|",
16
+ ];
17
+ for (const item of items) {
18
+ lines.push(`| ${item.stk_nm} | ${formatNumber(parseKiwoomPrice(item.cur_prc))} | ` +
19
+ `${formatSigned(parseKiwoomNumber(item.pred_pre))} | ${formatPercent(parseKiwoomNumber(item.flu_rt))} | ` +
20
+ `${parseKiwoomNumber(item.rising) ?? "-"}/${parseKiwoomNumber(item.stdns) ?? "-"}/${parseKiwoomNumber(item.fall) ?? "-"} |`);
21
+ }
22
+ return lines.join("\n");
23
+ }
24
+ export function registerMarketIndexTool(server) {
25
+ server.registerTool("get_market_index", {
26
+ title: "시장 지수 조회",
27
+ description: "코스피/코스닥 종합지수와 업종별 지수를 조회합니다 (키움 ka20003). " +
28
+ "첫 행이 시장 종합지수, 이후는 업종 지수입니다.",
29
+ inputSchema: {
30
+ market: z
31
+ .enum(["kospi", "kosdaq"])
32
+ .optional()
33
+ .describe("시장 구분 (기본값: kospi)"),
34
+ },
35
+ }, async ({ market }) => runTool(async () => {
36
+ const { client, config } = getKiwoomContext();
37
+ const m = market ?? "kospi";
38
+ const items = await fetchAllIndices(client, m === "kospi" ? "001" : "101");
39
+ return textResult(formatIndices(items, m, config.modeLabel));
40
+ }));
41
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchOrderbook } from "../kiwoom/api.js";
4
+ import { formatNumber, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
5
+ import { runTool, textResult } from "./helpers.js";
6
+ /** Levels 2-10 sit in the loose passthrough under sel_/buy_{n}th_pre_* keys. */
7
+ function passthroughField(book, key) {
8
+ const value = book[key];
9
+ return typeof value === "string" ? value : null;
10
+ }
11
+ export function formatOrderbook(book, stockCode, modeLabel) {
12
+ const price = (raw) => formatNumber(parseKiwoomPrice(raw));
13
+ const qty = (raw) => formatNumber(parseKiwoomNumber(raw));
14
+ const baseTm = book.bid_req_base_tm;
15
+ const baseTmLabel = baseTm.length === 6 ? `${baseTm.slice(0, 2)}:${baseTm.slice(2, 4)}:${baseTm.slice(4, 6)}` : baseTm;
16
+ const lines = [
17
+ `[${modeLabel}] ${stockCode} 호가 (기준시각 ${baseTmLabel})`,
18
+ "",
19
+ "| 구분 | 호가 | 잔량 |",
20
+ "|---|---:|---:|",
21
+ ];
22
+ for (let level = 10; level >= 2; level--) {
23
+ lines.push(`| 매도${level} | ${price(passthroughField(book, `sel_${level}th_pre_bid`))} | ${qty(passthroughField(book, `sel_${level}th_pre_req`))} |`);
24
+ }
25
+ lines.push(`| 매도1 | ${price(book.sel_fpr_bid)} | ${qty(book.sel_fpr_req)} |`);
26
+ lines.push(`| 매수1 | ${price(book.buy_fpr_bid)} | ${qty(book.buy_fpr_req)} |`);
27
+ for (let level = 2; level <= 10; level++) {
28
+ lines.push(`| 매수${level} | ${price(passthroughField(book, `buy_${level}th_pre_bid`))} | ${qty(passthroughField(book, `buy_${level}th_pre_req`))} |`);
29
+ }
30
+ lines.push("", `총잔량 — 매도 ${qty(book.tot_sel_req)} / 매수 ${qty(book.tot_buy_req)}`);
31
+ return lines.join("\n");
32
+ }
33
+ export function registerOrderbookTool(server) {
34
+ server.registerTool("get_orderbook", {
35
+ title: "호가 조회",
36
+ description: "종목의 10단계 매도/매수 호가와 잔량을 조회합니다 (키움 ka10004). " +
37
+ "종목코드를 모르면 search_stock으로 먼저 찾으세요.",
38
+ inputSchema: {
39
+ stock_code: z
40
+ .string()
41
+ .regex(/^[0-9A-Z]{6}$/i, "6자리 종목코드여야 합니다")
42
+ .describe("6자리 종목코드 (예: 005930)"),
43
+ },
44
+ }, async ({ stock_code }) => runTool(async () => {
45
+ const { client, config } = getKiwoomContext();
46
+ const code = stock_code.toUpperCase();
47
+ const book = await fetchOrderbook(client, code);
48
+ return textResult(formatOrderbook(book, code, config.modeLabel));
49
+ }));
50
+ }