kiwoom-mcp-server 0.8.0 → 0.9.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/README.en.md CHANGED
@@ -29,6 +29,7 @@ allowance.
29
29
  | `get_orderbook` | 10-level ask/bid quotes and sizes | ka10004 |
30
30
  | `get_market_index` | KOSPI/KOSDAQ composite and sector indices | ka20003 |
31
31
  | `get_ranking` | Top gainers / losers / volume / trading value | ka10027/30/32 |
32
+ | `get_market_movers` | New highs / new lows / upper & lower limit / surges / plunges | ka10016/17/19 |
32
33
  | `get_investor_trend` | Retail / foreign / institutional net-buy trend (period sum + daily) | ka10059, ka10061 |
33
34
  | `get_etf_info` | ETF tracking index / tax type / quote | ka40002, ka10001 |
34
35
  | `get_short_selling` | Per-stock daily short-selling trend (short volume / weight / avg price) | ka10014 |
@@ -112,11 +113,14 @@ npm test # unit tests (no network required)
112
113
  | `KIWOOM_APP_KEY` | ✅ | Kiwoom REST API app key |
113
114
  | `KIWOOM_APP_SECRET` | ✅ | Kiwoom REST API app secret |
114
115
  | `KIWOOM_MODE` | | `VIRTUAL` (paper trading, default) or `REAL` (live trading) |
115
- | `ISA_TYPE` | | `GENERAL` (general, 2,000,000 KRW limit, default) or `SEOMIN` (low-income/farmer-fisher, 4,000,000 KRW) |
116
- | `ISA_OPENED_ON` | | ISA account opening date `yyyy-MM-dd` default aggregation start for `calc_isa_tax_status` |
117
-
118
- If the account is not an ISA, leave `ISA_TYPE`/`ISA_OPENED_ON` blank — only
119
- `calc_isa_tax_status` becomes unavailable; every other tool still works.
116
+ | `ISA_ENABLED` | | `true` registers the `calc_isa_tax_status` tool. Default `false` (general-account-first) |
117
+ | `ISA_TYPE` | | `GENERAL` (general, 2,000,000 KRW limit, default) or `SEOMIN` (low-income/farmer-fisher, 4,000,000 KRW). Only used when `ISA_ENABLED=true` |
118
+ | `ISA_OPENED_ON` | | ISA account opening date `yyyy-MM-dd` — default aggregation start for `calc_isa_tax_status`. Only used when `ISA_ENABLED=true` |
119
+
120
+ The default is **general-account-first**: without extra config, only the market-data
121
+ and account-inquiry tools are exposed. To use the tax-free-limit tool on an ISA
122
+ account, set `ISA_ENABLED=true` and fill in `ISA_TYPE`/`ISA_OPENED_ON`. When off,
123
+ `calc_isa_tax_status` is not registered and every other tool still works.
120
124
 
121
125
  `.env` is resolved from the project root first, so the server works even when
122
126
  launched from an arbitrary working directory (as Claude Desktop does).
package/README.md CHANGED
@@ -27,6 +27,7 @@ Claude Desktop / Claude Code에서 자연어로 국내 주식 시세·차트·
27
27
  | `get_orderbook` | 10단계 매도/매수 호가·잔량 | ka10004 |
28
28
  | `get_market_index` | 코스피/코스닥 종합·업종 지수 | ka20003 |
29
29
  | `get_ranking` | 상승률/하락률/거래량/거래대금 상위 | ka10027/30/32 |
30
+ | `get_market_movers` | 신고가/신저가/상한가/하한가/급등/급락 특이 종목 | ka10016/17/19 |
30
31
  | `get_investor_trend` | 개인/외국인/기관 순매수 동향 (기간 합계 + 일별) | ka10059, ka10061 |
31
32
  | `get_etf_info` | ETF 추적지수·과세유형·시세 | ka40002, ka10001 |
32
33
  | `get_short_selling` | 종목별 일자별 공매도 추이 (공매도량·비중·평균가) | ka10014 |
@@ -104,11 +105,14 @@ npm test # 단위 테스트 (네트워크 불필요)
104
105
  | `KIWOOM_APP_KEY` | ✅ | 키움 REST API 앱키 |
105
106
  | `KIWOOM_APP_SECRET` | ✅ | 키움 REST API 앱 시크릿 |
106
107
  | `KIWOOM_MODE` | | `VIRTUAL`(모의투자, 기본값) 또는 `REAL`(실전투자) |
107
- | `ISA_TYPE` | | `GENERAL`(일반형, 한도 200만원, 기본값) 또는 `SEOMIN`(서민형/농어민형, 400만원) |
108
- | `ISA_OPENED_ON` | | ISA 계좌 개설일 `yyyy-MM-dd` `calc_isa_tax_status` 집계 시작일 기본값 |
109
-
110
- ISA 계좌가 아니라면 `ISA_TYPE`/`ISA_OPENED_ON`은 비워 두면 됩니다 —
111
- `calc_isa_tax_status`만 사용할 없고 나머지 tool은 모두 동작합니다.
108
+ | `ISA_ENABLED` | | `true`면 `calc_isa_tax_status` tool 활성화. 기본값 `false`(일반 계좌 기준) |
109
+ | `ISA_TYPE` | | `GENERAL`(일반형, 한도 200만원, 기본값) 또는 `SEOMIN`(서민형/농어민형, 400만원). `ISA_ENABLED=true`일 때만 사용 |
110
+ | `ISA_OPENED_ON` | | ISA 계좌 개설일 `yyyy-MM-dd` — `calc_isa_tax_status` 집계 시작일 기본값. `ISA_ENABLED=true`일 때만 사용 |
111
+
112
+ 기본값은 **일반(비-ISA) 계좌 기준**입니다 별도 설정이 없으면 시장·계좌 조회 tool만
113
+ 노출됩니다. ISA 계좌를 연결해 비과세 한도 tool을 쓰려면 `ISA_ENABLED=true`로 켜고
114
+ `ISA_TYPE`/`ISA_OPENED_ON`을 채우세요. 끄면 `calc_isa_tax_status`가 등록되지 않고
115
+ 나머지 tool은 모두 그대로 동작합니다.
112
116
 
113
117
  `.env`는 프로젝트 루트에서 먼저 찾기 때문에 (Claude Desktop처럼) 임의의 작업
114
118
  디렉터리에서 실행돼도 동작합니다.
package/dist/config.js CHANGED
@@ -1,16 +1,36 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { z } from "zod";
4
+ /**
5
+ * Treat an empty / whitespace-only env value the same as "unset", so a blank
6
+ * line in `.env` (e.g. the shipped `.env.example`'s `ISA_OPENED_ON=`, or a
7
+ * mock/general account that never uses ISA) falls back to the field's default /
8
+ * optional instead of failing validation. Without this, one stray empty value
9
+ * would throw at config load and break every tool, not just the ISA one.
10
+ */
11
+ const emptyToUndefined = (v) => typeof v === "string" && v.trim() === "" ? undefined : v;
12
+ /**
13
+ * Parse a boolean-ish env string. Only an explicit truthy token
14
+ * ("true"/"1"/"yes"/"on", case-insensitive) is true — so unset, blank, and
15
+ * "false" all mean disabled. Keeps ISA opt-in unambiguous.
16
+ */
17
+ export const parseBool = (v) => typeof v === "string" && ["true", "1", "yes", "on"].includes(v.trim().toLowerCase());
4
18
  const envSchema = z.object({
5
19
  KIWOOM_APP_KEY: z.string().min(1),
6
20
  KIWOOM_APP_SECRET: z.string().min(1),
7
- KIWOOM_MODE: z.enum(["VIRTUAL", "REAL"]).default("VIRTUAL"),
8
- ISA_TYPE: z.enum(["GENERAL", "SEOMIN"]).default("GENERAL"),
21
+ KIWOOM_MODE: z.preprocess(emptyToUndefined, z.enum(["VIRTUAL", "REAL"]).default("VIRTUAL")),
22
+ /**
23
+ * Opt-in switch for the ISA tax tool. Off by default so the server is
24
+ * general-account-first; ISA users set ISA_ENABLED=true (+ the two fields
25
+ * below). ISA_TYPE / ISA_OPENED_ON are only consulted when this is true.
26
+ */
27
+ ISA_ENABLED: z.preprocess((v) => parseBool(v), z.boolean()),
28
+ ISA_TYPE: z.preprocess(emptyToUndefined, z.enum(["GENERAL", "SEOMIN"]).default("GENERAL")),
9
29
  /** ISA account opening date — default aggregation start for tax-status math. */
10
- ISA_OPENED_ON: z
30
+ ISA_OPENED_ON: z.preprocess(emptyToUndefined, z
11
31
  .string()
12
32
  .regex(/^\d{4}-?\d{2}-?\d{2}$/)
13
- .optional(),
33
+ .optional()),
14
34
  });
15
35
  const BASE_URLS = {
16
36
  VIRTUAL: "https://mockapi.kiwoom.com",
@@ -35,27 +55,40 @@ function loadDotEnv() {
35
55
  }
36
56
  }
37
57
  }
38
- export function getConfig() {
39
- if (cached)
40
- return cached;
41
- loadDotEnv();
42
- const parsed = envSchema.safeParse(process.env);
58
+ /** Pure env → config mapping (no file I/O), so it is unit-testable. */
59
+ export function buildConfig(env) {
60
+ const parsed = envSchema.safeParse(env);
43
61
  if (!parsed.success) {
44
- const problems = parsed.error.issues
45
- .map((issue) => issue.path.join("."))
46
- .join(", ");
62
+ const problems = parsed.error.issues.map((issue) => issue.path.join(".")).join(", ");
47
63
  throw new Error(`환경설정이 없거나 잘못되었습니다: ${problems}. ` +
48
64
  `프로젝트 루트의 .env.example을 .env로 복사한 뒤 키움 REST API 앱키를 입력해 주세요.`);
49
65
  }
50
- const env = parsed.data;
51
- cached = {
52
- appKey: env.KIWOOM_APP_KEY,
53
- appSecret: env.KIWOOM_APP_SECRET,
54
- mode: env.KIWOOM_MODE,
55
- modeLabel: MODE_LABELS[env.KIWOOM_MODE],
56
- isaType: env.ISA_TYPE,
57
- isaOpenedOn: env.ISA_OPENED_ON?.replaceAll("-", ""),
58
- baseUrl: BASE_URLS[env.KIWOOM_MODE],
66
+ const e = parsed.data;
67
+ return {
68
+ appKey: e.KIWOOM_APP_KEY,
69
+ appSecret: e.KIWOOM_APP_SECRET,
70
+ mode: e.KIWOOM_MODE,
71
+ modeLabel: MODE_LABELS[e.KIWOOM_MODE],
72
+ isaEnabled: e.ISA_ENABLED,
73
+ isaType: e.ISA_TYPE,
74
+ isaOpenedOn: e.ISA_OPENED_ON?.replaceAll("-", ""),
75
+ baseUrl: BASE_URLS[e.KIWOOM_MODE],
59
76
  };
77
+ }
78
+ export function getConfig() {
79
+ if (cached)
80
+ return cached;
81
+ loadDotEnv();
82
+ cached = buildConfig(process.env);
60
83
  return cached;
61
84
  }
85
+ /**
86
+ * Lightweight startup check used to decide whether to register the optional ISA
87
+ * tax tool. Loads `.env` if present and reads only the opt-in flag — it does NOT
88
+ * validate credentials, so the server still starts (and `ping` works) without a
89
+ * full config. Defaults to false → general-account-first.
90
+ */
91
+ export function isIsaEnabled() {
92
+ loadDotEnv();
93
+ return parseBool(process.env.ISA_ENABLED);
94
+ }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { sleep } from "../utils/sleep.js";
3
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";
4
+ import { accountEvaluationResponseSchema, allIndexResponseSchema, dailyChartItemSchema, depositResponseSchema, etfInfoResponseSchema, foreignHoldingResponseSchema, investorDailyItemSchema, investorTotalItemSchema, limitStockItemSchema, minuteChartItemSchema, newHighLowItemSchema, orderbookResponseSchema, pendingOrdersResponseSchema, priceChangeRankItemSchema, priceJumpItemSchema, realizedPnlResponseSchema, shortSellingResponseSchema, stockInfoResponseSchema, stockListResponseSchema, themeGroupsResponseSchema, tradingJournalResponseSchema, themeStocksResponseSchema, transactionsResponseSchema, valueRankItemSchema, volumeRankItemSchema, watchlistGroupDetailResponseSchema, watchlistGroupsResponseSchema, } from "./types.js";
5
5
  const STOCK_INFO_PATH = "/api/dostk/stkinfo";
6
6
  const ACCOUNT_PATH = "/api/dostk/acnt";
7
7
  const CHART_PATH = "/api/dostk/chart";
@@ -370,3 +370,73 @@ export async function fetchTradingJournal(client, baseDate) {
370
370
  });
371
371
  return tradingJournalResponseSchema.parse(res.json);
372
372
  }
373
+ /**
374
+ * ka10016 신고저가요청 — stocks marking a new period high/low (mock-verified
375
+ * 2026-07-08; REAL not yet probed). ntl_tp "1"=신고가, "2"=신저가; dt = lookback
376
+ * days (5/10/20/60/250); array key `ntl_pric`. Filter params fixed to "all".
377
+ */
378
+ export async function fetchNewHighLow(client, market, direction, days) {
379
+ const res = await client.call({
380
+ path: STOCK_INFO_PATH,
381
+ apiId: "ka10016",
382
+ body: {
383
+ mrkt_tp: RANKING_MARKET_CODES[market],
384
+ ntl_tp: direction === "high" ? "1" : "2",
385
+ high_low_close_tp: "1",
386
+ stk_cnd: "0",
387
+ trde_qty_tp: "00000",
388
+ crd_cnd: "0",
389
+ updown_incls: "0",
390
+ dt: days,
391
+ stex_tp: "1",
392
+ },
393
+ });
394
+ return parseArray(res.json, "ntl_pric", newHighLowItemSchema);
395
+ }
396
+ /**
397
+ * ka10017 상하한가요청 — stocks at the daily upper/lower price limit
398
+ * (mock-verified 2026-07-08; REAL not yet probed). updown_tp "1"=상한, "4"=하한;
399
+ * sort_tp "3"=등락률순; array key `updown_pric`. `cnt` = 연속 도달 횟수.
400
+ */
401
+ export async function fetchLimitStocks(client, market, direction) {
402
+ const res = await client.call({
403
+ path: STOCK_INFO_PATH,
404
+ apiId: "ka10017",
405
+ body: {
406
+ mrkt_tp: RANKING_MARKET_CODES[market],
407
+ updown_tp: direction === "upper" ? "1" : "4",
408
+ sort_tp: "3",
409
+ stk_cnd: "0",
410
+ trde_qty_tp: "00000",
411
+ crd_cnd: "0",
412
+ trde_gold_tp: "0",
413
+ stex_tp: "1",
414
+ },
415
+ });
416
+ return parseArray(res.json, "updown_pric", limitStockItemSchema);
417
+ }
418
+ /**
419
+ * ka10019 가격급등락요청 — sharp movers vs the previous day (mock-verified
420
+ * 2026-07-08; REAL not yet probed). flu_tp "1"=급등, "2"=급락; tm_tp "2"/tm "1"
421
+ * fixes the baseline to 1 trading day ago; array key `pric_jmpflu`.
422
+ * `jmp_rt` = 기준가(base_pric) 대비 급등/급락률.
423
+ */
424
+ export async function fetchPriceJumps(client, market, direction) {
425
+ const res = await client.call({
426
+ path: STOCK_INFO_PATH,
427
+ apiId: "ka10019",
428
+ body: {
429
+ mrkt_tp: RANKING_MARKET_CODES[market],
430
+ flu_tp: direction === "surge" ? "1" : "2",
431
+ tm_tp: "2",
432
+ tm: "1",
433
+ trde_qty_tp: "00000",
434
+ stk_cnd: "0",
435
+ crd_cnd: "0",
436
+ pric_cnd: "0",
437
+ updown_incls: "1",
438
+ stex_tp: "1",
439
+ },
440
+ });
441
+ return parseArray(res.json, "pric_jmpflu", priceJumpItemSchema);
442
+ }
@@ -379,6 +379,35 @@ export const tradingJournalResponseSchema = z.looseObject({
379
379
  tot_prft_rt: str(), // 총수익률(%)
380
380
  tdy_trde_diary: z.array(tradingJournalItemSchema).default([]),
381
381
  });
382
+ // ── ka10016/ka10017/ka10019: 신고저가/상하한가/가격급등락 (mock-verified 2026-07-08) ──
383
+ export const newHighLowItemSchema = z.looseObject({
384
+ stk_cd: str(),
385
+ stk_nm: str(),
386
+ cur_prc: str(), // 부호 포함
387
+ pred_pre: str(), // 전일대비 (부호)
388
+ flu_rt: str(), // 등락률 (부호)
389
+ trde_qty: str(), // 거래량
390
+ high_pric: str(), // 기간 고가
391
+ low_pric: str(), // 기간 저가
392
+ });
393
+ export const limitStockItemSchema = z.looseObject({
394
+ stk_cd: str(),
395
+ stk_nm: str(),
396
+ cur_prc: str(),
397
+ pred_pre: str(),
398
+ flu_rt: str(),
399
+ trde_qty: str(),
400
+ cnt: str(), // 연속 횟수
401
+ });
402
+ export const priceJumpItemSchema = z.looseObject({
403
+ stk_cd: str(),
404
+ stk_nm: str(),
405
+ cur_prc: str(),
406
+ flu_rt: str(), // 전일 대비 등락률 (부호)
407
+ base_pric: str(), // 기준가 (급등락 산정 기준시점 가격)
408
+ jmp_rt: str(), // 기준 대비 급등/급락률 (부호)
409
+ trde_qty: str(),
410
+ });
382
411
  /** Strips Kiwoom's asset-class prefix (e.g. "A005930" → "005930"). */
383
412
  export function normalizeStockCode(code) {
384
413
  return code.replace(/^[A-Z]/, "");
package/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { isIsaEnabled } from "./config.js";
2
3
  import { registerAccountBalanceTool } from "./tools/account-balance.js";
3
4
  import { registerAccountHoldingsTool } from "./tools/account-holdings.js";
4
5
  import { registerEtfInfoTool } from "./tools/etf-info.js";
@@ -6,6 +7,7 @@ import { registerForeignHoldingTool } from "./tools/foreign-holding.js";
6
7
  import { registerInvestorTrendTool } from "./tools/investor-trend.js";
7
8
  import { registerIsaTaxStatusTool } from "./tools/isa-tax-status.js";
8
9
  import { registerMarketIndexTool } from "./tools/market-index.js";
10
+ import { registerMarketMoversTool } from "./tools/market-movers.js";
9
11
  import { registerOrderbookTool } from "./tools/orderbook.js";
10
12
  import { registerPendingOrdersTool } from "./tools/pending-orders.js";
11
13
  import { registerPingTool } from "./tools/ping.js";
@@ -19,7 +21,7 @@ import { registerTradingJournalTool } from "./tools/trading-journal.js";
19
21
  import { registerTransactionsTool } from "./tools/transactions.js";
20
22
  import { registerWatchlistGroupsTool, registerWatchlistTool } from "./tools/watchlist.js";
21
23
  export const SERVER_NAME = "kiwoom-mcp-server";
22
- export const SERVER_VERSION = "0.8.0";
24
+ export const SERVER_VERSION = "0.9.0";
23
25
  export function createServer() {
24
26
  const server = new McpServer({
25
27
  name: SERVER_NAME,
@@ -33,6 +35,7 @@ export function createServer() {
33
35
  registerOrderbookTool(server);
34
36
  registerMarketIndexTool(server);
35
37
  registerRankingTool(server);
38
+ registerMarketMoversTool(server);
36
39
  registerInvestorTrendTool(server);
37
40
  registerEtfInfoTool(server);
38
41
  registerShortSellingTool(server);
@@ -49,6 +52,10 @@ export function createServer() {
49
52
  registerTransactionsTool(server);
50
53
  registerPendingOrdersTool(server);
51
54
  registerTradingJournalTool(server);
52
- registerIsaTaxStatusTool(server);
55
+ // ISA tax tool — opt-in, general-account-first. Enable with ISA_ENABLED=true
56
+ // (see .env.example). A non-ISA / general account simply won't see this tool.
57
+ if (isIsaEnabled()) {
58
+ registerIsaTaxStatusTool(server);
59
+ }
53
60
  return server;
54
61
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
3
  import { fetchInvestorDaily, fetchInvestorTotal } from "../kiwoom/api.js";
4
- import { formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
4
+ import { assertDateRange, formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
5
5
  import { formatNumber, formatSigned, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
6
6
  import { runTool, textResult } from "./helpers.js";
7
7
  const DEFAULT_LOOKBACK_DAYS = 30;
@@ -66,6 +66,7 @@ export function registerInvestorTrendTool(server) {
66
66
  const code = stock_code.toUpperCase();
67
67
  const fromDate = (from_date ?? kstDaysAgo(DEFAULT_LOOKBACK_DAYS)).replaceAll("-", "");
68
68
  const toDate = (to_date ?? todayInKst()).replaceAll("-", "");
69
+ assertDateRange(fromDate, toDate);
69
70
  const u = unit ?? "amount";
70
71
  // ka10061 and ka10059 are different TRs — per-TR rate limits allow parallel calls.
71
72
  const [total, daily] = await Promise.all([
@@ -7,7 +7,7 @@ import { computeIsaTaxStatus, ISA_LIMITS } from "../isa/tax.js";
7
7
  import { fetchAccountEvaluation, fetchRealizedPnlSummary, fetchTransactions } from "../kiwoom/api.js";
8
8
  import { loadMasterList } from "../kiwoom/master-list.js";
9
9
  import { normalizeStockCode } from "../kiwoom/types.js";
10
- import { formatDateDashed, todayInKst } from "../utils/date.js";
10
+ import { assertDateRange, formatDateDashed, todayInKst } from "../utils/date.js";
11
11
  import { formatKRW, formatSignedKRW, parseKiwoomNumber } from "../utils/num.js";
12
12
  import { runTool, textResult } from "./helpers.js";
13
13
  const DIVIDEND_PATTERN = /배당|분배|이자/;
@@ -144,7 +144,10 @@ export function registerIsaTaxStatusTool(server) {
144
144
  .describe("집계 기간 중 수령한 배당·분배금·이자 총액(원) — 자동 감지분에 더해짐"),
145
145
  overrides: z
146
146
  .array(z.object({
147
- stock_code: z.string().describe("6자리 종목코드"),
147
+ stock_code: z
148
+ .string()
149
+ .regex(/^\d{6}$/, "6자리 숫자 종목코드여야 합니다")
150
+ .describe("6자리 종목코드"),
148
151
  tax_type: z
149
152
  .enum(["TAXABLE", "DOMESTIC_EQUITY"])
150
153
  .describe("TAXABLE=과세대상(해외/채권형 등), DOMESTIC_EQUITY=국내주식형(매매차익 비과세)"),
@@ -160,6 +163,7 @@ export function registerIsaTaxStatusTool(server) {
160
163
  "from_date 인자를 전달해 주세요.");
161
164
  }
162
165
  const toDate = todayInKst();
166
+ assertDateRange(fromDate, toDate);
163
167
  const overrideMap = new Map((overrides ?? []).map((o) => [o.stock_code, o.tax_type]));
164
168
  // Distinct TRs — per-TR rate limits allow parallel calls. loadEtfCodeSet
165
169
  // (ka10099) is best-effort and never rejects, so Promise.all is safe.
@@ -212,6 +216,17 @@ export function registerIsaTaxStatusTool(server) {
212
216
  unrealized: unrealizedEntries,
213
217
  });
214
218
  const warnings = [...reconstruction.warnings];
219
+ // 존재 검증: 실현·보유 어디에도 없는 종목의 override는 조용히 무시되므로
220
+ // (오타 가능성) 경고로 표면화한다.
221
+ const knownCodes = new Set([
222
+ ...realizedEntries.map((e) => e.code),
223
+ ...unrealizedEntries.map((e) => e.code),
224
+ ]);
225
+ const unusedOverrides = [...overrideMap.keys()].filter((code) => !knownCodes.has(code));
226
+ if (unusedOverrides.length > 0) {
227
+ warnings.push(`overrides에 지정한 종목 ${unusedOverrides.join(", ")}이(가) 집계 기간의 실현손익·보유 ` +
228
+ `내역에 없어 반영되지 않았습니다 — 종목코드를 확인해 주세요.`);
229
+ }
215
230
  if (transactions.truncated) {
216
231
  warnings.push("거래내역이 조회 상한에 도달해 일부 오래된 거래가 누락되었을 수 있습니다 — " +
217
232
  "집계 시작일(from_date)을 좁혀 재확인하세요. 실현손익·배당 집계가 불완전할 수 있습니다.");
@@ -0,0 +1,108 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchLimitStocks, fetchNewHighLow, fetchPriceJumps, } 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 DEFAULT_DAYS = "5";
9
+ const DAYS_VALUES = ["5", "10", "20", "60", "250"];
10
+ const SIGNAL_LABELS = {
11
+ new_high: "신고가",
12
+ new_low: "신저가",
13
+ upper_limit: "상한가",
14
+ lower_limit: "하한가",
15
+ surge: "급등",
16
+ plunge: "급락",
17
+ };
18
+ const MARKET_LABELS = {
19
+ all: "전체",
20
+ kospi: "코스피",
21
+ kosdaq: "코스닥",
22
+ };
23
+ const row = (cells) => `| ${cells.join(" | ")} |`;
24
+ function commonCells(rank, item) {
25
+ return [
26
+ String(rank),
27
+ item.stk_nm,
28
+ item.stk_cd,
29
+ formatNumber(parseKiwoomPrice(item.cur_prc)),
30
+ formatPercent(parseKiwoomNumber(item.flu_rt)),
31
+ ];
32
+ }
33
+ export function formatMarketMovers(signal, market, items, top, modeLabel, days) {
34
+ const shown = items.slice(0, top);
35
+ const daysSuffix = signal === "new_high" || signal === "new_low" ? ` (${days ?? DEFAULT_DAYS}일 기준)` : "";
36
+ const title = `${MARKET_LABELS[market]} ${SIGNAL_LABELS[signal]} 종목${daysSuffix}`;
37
+ if (shown.length === 0) {
38
+ return `[${modeLabel}] ${title} — 해당 종목이 없습니다.`;
39
+ }
40
+ const lines = [`[${modeLabel}] ${title} (${shown.length}종목)`, ""];
41
+ if (signal === "new_high" || signal === "new_low") {
42
+ lines.push("| 순위 | 종목명 | 코드 | 현재가 | 등락률 | 거래량 | 기간고가 | 기간저가 |", "|---:|---|---|---:|---:|---:|---:|---:|");
43
+ shown.forEach((item, i) => {
44
+ lines.push(row([
45
+ ...commonCells(i + 1, item),
46
+ formatNumber(parseKiwoomNumber(item.trde_qty)),
47
+ formatNumber(parseKiwoomPrice(item.high_pric)),
48
+ formatNumber(parseKiwoomPrice(item.low_pric)),
49
+ ]));
50
+ });
51
+ }
52
+ else if (signal === "upper_limit" || signal === "lower_limit") {
53
+ lines.push("| 순위 | 종목명 | 코드 | 현재가 | 등락률 | 거래량 | 연속 |", "|---:|---|---|---:|---:|---:|---:|");
54
+ shown.forEach((item, i) => {
55
+ lines.push(row([
56
+ ...commonCells(i + 1, item),
57
+ formatNumber(parseKiwoomNumber(item.trde_qty)),
58
+ `${parseKiwoomNumber(item.cnt) ?? "-"}회`,
59
+ ]));
60
+ });
61
+ }
62
+ else {
63
+ lines.push("| 순위 | 종목명 | 코드 | 현재가 | 등락률 | 급등락률(기준가 대비) | 거래량 |", "|---:|---|---|---:|---:|---:|---:|");
64
+ shown.forEach((item, i) => {
65
+ lines.push(row([
66
+ ...commonCells(i + 1, item),
67
+ formatPercent(parseKiwoomNumber(item.jmp_rt)),
68
+ formatNumber(parseKiwoomNumber(item.trde_qty)),
69
+ ]));
70
+ });
71
+ }
72
+ return lines.join("\n");
73
+ }
74
+ export function registerMarketMoversTool(server) {
75
+ server.registerTool("get_market_movers", {
76
+ title: "시장 특이 종목 조회",
77
+ description: "시장 특이 종목을 조회합니다 (키움 ka10016/ka10017/ka10019). signal: " +
78
+ "new_high(신고가)/new_low(신저가)/upper_limit(상한가)/lower_limit(하한가)/" +
79
+ "surge(급등)/plunge(급락). market: all(전체, 기본)/kospi/kosdaq. " +
80
+ "신고/신저는 days(5/10/20/60/250일, 기본 5일) 기준, 급등/급락은 전일 대비입니다.",
81
+ inputSchema: {
82
+ signal: z.enum(["new_high", "new_low", "upper_limit", "lower_limit", "surge", "plunge"]).describe("특이 신호 종류"),
83
+ market: z.enum(["all", "kospi", "kosdaq"]).optional().describe("시장 구분 (기본값: all)"),
84
+ days: z
85
+ .enum(DAYS_VALUES)
86
+ .optional()
87
+ .describe(`신고/신저 기준 기간(일) — new_high/new_low에서만 사용 (기본값 ${DEFAULT_DAYS})`),
88
+ top: z
89
+ .number()
90
+ .int()
91
+ .min(1)
92
+ .max(MAX_TOP)
93
+ .optional()
94
+ .describe(`표시할 종목 수 (기본값 ${DEFAULT_TOP}, 최대 ${MAX_TOP})`),
95
+ },
96
+ }, async ({ signal, market, days, top }) => runTool(async () => {
97
+ const { client, config } = getKiwoomContext();
98
+ const m = market ?? "all";
99
+ const count = top ?? DEFAULT_TOP;
100
+ const d = days ?? DEFAULT_DAYS;
101
+ const items = signal === "new_high" || signal === "new_low"
102
+ ? await fetchNewHighLow(client, m, signal === "new_high" ? "high" : "low", d)
103
+ : signal === "upper_limit" || signal === "lower_limit"
104
+ ? await fetchLimitStocks(client, m, signal === "upper_limit" ? "upper" : "lower")
105
+ : await fetchPriceJumps(client, m, signal);
106
+ return textResult(formatMarketMovers(signal, m, items, count, config.modeLabel, d));
107
+ }));
108
+ }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
3
  import { fetchShortSelling } from "../kiwoom/api.js";
4
- import { formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
4
+ import { assertDateRange, formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
5
5
  import { formatKRW, formatPercent, formatQuantity, formatRatioPercent, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
6
6
  import { runTool, textResult } from "./helpers.js";
7
7
  const DEFAULT_LOOKBACK_DAYS = 30;
@@ -60,6 +60,7 @@ export function registerShortSellingTool(server) {
60
60
  fromDate: (from_date ?? kstDaysAgo(DEFAULT_LOOKBACK_DAYS)).replaceAll("-", ""),
61
61
  toDate: (to_date ?? todayInKst()).replaceAll("-", ""),
62
62
  };
63
+ assertDateRange(query.fromDate, query.toDate);
63
64
  const rows = await fetchShortSelling(client, query.stockCode, query.fromDate, query.toDate);
64
65
  return textResult(formatShortSelling(rows, query, config.modeLabel));
65
66
  }));
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
3
  import { fetchTransactions } from "../kiwoom/api.js";
4
4
  import { normalizeStockCode } from "../kiwoom/types.js";
5
- import { formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
5
+ import { assertDateRange, formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
6
6
  import { formatKRW, parseKiwoomNumber } from "../utils/num.js";
7
7
  import { runTool, textResult } from "./helpers.js";
8
8
  const DEFAULT_LOOKBACK_DAYS = 30;
@@ -74,6 +74,7 @@ export function registerTransactionsTool(server) {
74
74
  toDate: (to_date ?? todayInKst()).replaceAll("-", ""),
75
75
  stockCode: stock_code,
76
76
  };
77
+ assertDateRange(query.fromDate, query.toDate);
77
78
  const { rows, truncated } = await fetchTransactions(client, query.fromDate, query.toDate);
78
79
  return textResult(formatTransactions(rows, query, config.modeLabel, truncated));
79
80
  }));
@@ -13,3 +13,14 @@ export function kstDaysAgo(days) {
13
13
  export function formatDateDashed(yyyymmdd) {
14
14
  return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
15
15
  }
16
+ /**
17
+ * Sanity-check a date range (both yyyyMMdd, so string order == date order).
18
+ * Kiwoom returns confusing empty results for an inverted range — fail early
19
+ * with a readable Korean error instead.
20
+ */
21
+ export function assertDateRange(fromDate, toDate) {
22
+ if (fromDate > toDate) {
23
+ throw new Error(`조회 기간이 잘못되었습니다: 시작일(${formatDateDashed(fromDate)})이 ` +
24
+ `종료일(${formatDateDashed(toDate)})보다 늦습니다. from_date/to_date를 확인해 주세요.`);
25
+ }
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiwoom-mcp-server",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Read-only MCP server exposing the Kiwoom Securities REST API (market data, account inquiry, ISA tax-allowance calculator) to Claude Desktop / Claude Code.",
5
5
  "private": false,
6
6
  "type": "module",