kiwoom-mcp-server 0.12.0 → 0.16.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
@@ -23,8 +23,8 @@ allowance.
23
23
 
24
24
  | Tool | Description | Kiwoom TR |
25
25
  |---|---|---|
26
- | `search_stock` | Name → code search (KOSPI/KOSDAQ, incl. ETF/ETN) | ka10099 |
27
- | `get_stock_price` | Current price / change rate / volume / basic metrics | ka10001 |
26
+ | `search_stock` | Name → code search (KOSPI/KOSDAQ, incl. ETF/ETN) + trading-caution flags | ka10099 |
27
+ | `get_stock_price` | Current price / change rate / volume / basic metrics + sector, listing date, caution flags | ka10001, ka10099 |
28
28
  | `get_stock_chart` | Daily/weekly/monthly/yearly/minute/tick candles (adjusted price) | ka10079~83, ka10094 |
29
29
  | `get_orderbook` | 10-level ask/bid quotes and sizes | ka10004 |
30
30
  | `get_market_index` | KOSPI/KOSDAQ composite and sector indices | ka20003 |
@@ -32,18 +32,22 @@ allowance.
32
32
  | `get_sector_stocks` | Member stocks of a sector with quotes | ka20002 |
33
33
  | `get_ranking` | Top gainers / losers / volume / trading value | ka10027/30/32 |
34
34
  | `get_market_movers` | New highs / new lows / upper & lower limit / surges / plunges | ka10016/17/19 |
35
+ | `get_vi_stocks` | Today's volatility-interruption (VI) triggered stocks (trigger price / disparity / times) | ka10054 |
35
36
  | `get_investor_trend` | Retail / foreign / institutional net-buy trend (period sum + daily) | ka10059, ka10061 |
37
+ | `get_broker_activity` | Per-stock top-5 buying/selling brokers (member firms) | ka10002 |
36
38
  | `get_etf_info` | ETF tracking index / tax type / quote / NAV & disparity | ka40002, ka10001, ka40009 |
37
39
  | `get_etf_returns` | ETF period returns (1w/1m/6m/1y) vs a benchmark index | ka40001 |
38
40
  | `get_short_selling` | Per-stock daily short-selling trend (short volume / weight / avg price) | ka10014 |
41
+ | `get_stock_lending` | Securities-lending trend (contracted / repaid / change / balance) — per stock or market-wide | ka10068, ka20068 |
39
42
  | `get_foreign_holding` | Per-stock foreign holding trend (holdings / holding weight / limit-usage rate) | ka10008 |
43
+ | `get_program_trading` | Today's top program-trading net buys / sells (KOSPI/KOSDAQ) | ka90003 |
40
44
 
41
45
  **Watchlist** (interest-stock groups saved in the 영웅문 HTS client — read-only):
42
46
 
43
47
  | Tool | Description | Kiwoom TR |
44
48
  |---|---|---|
45
49
  | `get_watchlist_groups` | List of saved watchlist groups (code + name) | ka01300 |
46
- | `get_watchlist` | Stocks in a group (enriched with name / prev close / market) | ka01301, ka10099 |
50
+ | `get_watchlist` | Stocks in a group (enriched with name / prev close / market / caution flags) | ka01301, ka10099 |
47
51
 
48
52
  > The Kiwoom REST API exposes no watchlist **edit** (add/remove) TR — read-only.
49
53
 
package/README.md CHANGED
@@ -21,8 +21,8 @@ Claude Desktop / Claude Code에서 자연어로 국내 주식 시세·차트·
21
21
 
22
22
  | Tool | 설명 | 키움 TR |
23
23
  |---|---|---|
24
- | `search_stock` | 종목명→코드 검색 (코스피/코스닥, ETF/ETN 포함) | ka10099 |
25
- | `get_stock_price` | 현재가/등락률/거래량/기본지표 | ka10001 |
24
+ | `search_stock` | 종목명→코드 검색 (코스피/코스닥, ETF/ETN 포함) + 투자유의 표시 | ka10099 |
25
+ | `get_stock_price` | 현재가/등락률/거래량/기본지표 + 업종·상장일·투자유의 | ka10001, ka10099 |
26
26
  | `get_stock_chart` | 일/주/월/년/분/틱봉 캔들 차트 (수정주가 반영) | ka10079~83, ka10094 |
27
27
  | `get_orderbook` | 10단계 매도/매수 호가·잔량 | ka10004 |
28
28
  | `get_market_index` | 코스피/코스닥 종합·업종 지수 | ka20003 |
@@ -30,18 +30,22 @@ Claude Desktop / Claude Code에서 자연어로 국내 주식 시세·차트·
30
30
  | `get_sector_stocks` | 업종 구성 종목 시세 (현재가·등락률·거래량·고저가) | ka20002 |
31
31
  | `get_ranking` | 상승률/하락률/거래량/거래대금 상위 | ka10027/30/32 |
32
32
  | `get_market_movers` | 신고가/신저가/상한가/하한가/급등/급락 특이 종목 | ka10016/17/19 |
33
+ | `get_vi_stocks` | 당일 VI(변동성완화장치) 발동 종목 (발동가·괴리율·시각) | ka10054 |
33
34
  | `get_investor_trend` | 개인/외국인/기관 순매수 동향 (기간 합계 + 일별) | ka10059, ka10061 |
35
+ | `get_broker_activity` | 종목별 거래원(증권사) 매수/매도 상위 5 | ka10002 |
34
36
  | `get_etf_info` | ETF 추적지수·과세유형·시세·NAV/괴리율 | ka40002, ka10001, ka40009 |
35
37
  | `get_etf_returns` | ETF 기간별(1주/1개월/6개월/1년) 수익률 vs 대상지수 | ka40001 |
36
38
  | `get_short_selling` | 종목별 일자별 공매도 추이 (공매도량·비중·평균가) | ka10014 |
39
+ | `get_stock_lending` | 대차거래 추이 (체결·상환·증감·잔고) — 종목별 또는 시장 전체 | ka10068, ka20068 |
37
40
  | `get_foreign_holding` | 종목별 외국인 보유 추이 (보유주식수·보유비중·한도소진률) | ka10008 |
41
+ | `get_program_trading` | 당일 프로그램 매매 순매수/순매도 상위 (코스피/코스닥) | ka90003 |
38
42
 
39
43
  **관심종목** (영웅문 HTS에 저장한 관심 그룹, 읽기 전용):
40
44
 
41
45
  | Tool | 설명 | 키움 TR |
42
46
  |---|---|---|
43
47
  | `get_watchlist_groups` | HTS 관심종목 그룹 목록 (그룹코드+그룹명) | ka01300 |
44
- | `get_watchlist` | 그룹 내 종목 목록 (종목명·전일종가·시장 보강) | ka01301, ka10099 |
48
+ | `get_watchlist` | 그룹 내 종목 목록 (종목명·전일종가·시장·투자유의 보강) | ka01301, ka10099 |
45
49
 
46
50
  > 키움 REST API에는 관심종목 **편집(추가/삭제)** TR이 없어 조회만 가능합니다.
47
51
 
@@ -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, etfNavItemSchema, etfReturnItemSchema, foreignHoldingResponseSchema, investorDailyItemSchema, investorTotalItemSchema, limitStockItemSchema, minuteChartItemSchema, newHighLowItemSchema, orderbookResponseSchema, pendingOrdersResponseSchema, priceChangeRankItemSchema, priceJumpItemSchema, realizedPnlResponseSchema, sectorPriceResponseSchema, sectorStocksResponseSchema, shortSellingResponseSchema, stockInfoResponseSchema, stockListResponseSchema, themeGroupsResponseSchema, tradingJournalResponseSchema, themeStocksResponseSchema, transactionsResponseSchema, valueRankItemSchema, volumeRankItemSchema, watchlistGroupDetailResponseSchema, watchlistGroupsResponseSchema, } from "./types.js";
4
+ import { accountEvaluationResponseSchema, allIndexResponseSchema, brokerActivityResponseSchema, dailyChartItemSchema, depositResponseSchema, etfInfoResponseSchema, etfNavItemSchema, etfReturnItemSchema, foreignHoldingResponseSchema, investorDailyItemSchema, investorTotalItemSchema, lendingTrendResponseSchema, limitStockItemSchema, minuteChartItemSchema, newHighLowItemSchema, orderbookResponseSchema, pendingOrdersResponseSchema, priceChangeRankItemSchema, priceJumpItemSchema, programTradeItemSchema, realizedPnlResponseSchema, sectorPriceResponseSchema, sectorStocksResponseSchema, shortSellingResponseSchema, stockInfoResponseSchema, stockListResponseSchema, themeGroupsResponseSchema, tradingJournalResponseSchema, themeStocksResponseSchema, transactionsResponseSchema, valueRankItemSchema, viStockItemSchema, 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";
@@ -13,6 +13,7 @@ const WATCHLIST_PATH = "/api/dostk/watchlist";
13
13
  const THEME_PATH = "/api/dostk/thme";
14
14
  const SHORT_PATH = "/api/dostk/shsa";
15
15
  const FOREIGN_PATH = "/api/dostk/frgnistt";
16
+ const SLB_PATH = "/api/dostk/slb";
16
17
  /** Pulls an array out of an already-envelope-checked response body. */
17
18
  function parseArray(json, key, itemSchema) {
18
19
  const value = json[key];
@@ -439,6 +440,88 @@ export async function fetchForeignHolding(client, stockCode) {
439
440
  });
440
441
  return foreignHoldingResponseSchema.parse(res.json).stk_frgnr;
441
442
  }
443
+ const VI_DIRECTION_CODES = { all: "0", up: "1", down: "2" };
444
+ const VI_TYPE_CODES = { all: "0", static: "1", dynamic: "2" };
445
+ /**
446
+ * ka10054 변동성완화장치(VI) 발동종목 — mock-probed 2026-07-10. 필터 12종 중
447
+ * 거래량/거래대금 필터는 미사용("0"), skip_stk "000000000" = 전종목 포함.
448
+ * stockCode 지정 시 해당 종목의 당일 발동 내역만 (미발동이면 빈 배열).
449
+ */
450
+ export async function fetchViStocks(client, market, direction, viType, stockCode) {
451
+ const res = await client.call({
452
+ path: STOCK_INFO_PATH,
453
+ apiId: "ka10054",
454
+ body: {
455
+ mrkt_tp: RANKING_MARKET_CODES[market],
456
+ bf_mkrt_tp: "0",
457
+ stk_cd: stockCode ?? "",
458
+ motn_tp: VI_TYPE_CODES[viType],
459
+ skip_stk: "000000000",
460
+ trde_qty_tp: "0",
461
+ min_trde_qty: "0",
462
+ max_trde_qty: "0",
463
+ trde_prica_tp: "0",
464
+ min_trde_prica: "0",
465
+ max_trde_prica: "0",
466
+ motn_drc: VI_DIRECTION_CODES[direction],
467
+ stex_tp: "1",
468
+ },
469
+ });
470
+ return parseArray(res.json, "motn_stk", viStockItemSchema);
471
+ }
472
+ /** ka10002 주식거래원 — 상위 5 매도/매수 거래원 (flat 37 fields, mock-probed 2026-07-10). */
473
+ export async function fetchBrokerActivity(client, stockCode) {
474
+ const res = await client.call({
475
+ path: STOCK_INFO_PATH,
476
+ apiId: "ka10002",
477
+ body: { stk_cd: stockCode },
478
+ });
479
+ return brokerActivityResponseSchema.parse(res.json);
480
+ }
481
+ /**
482
+ * ka10068(전체)/ka20068(종목별) 대차거래추이 — securities-lending daily trend.
483
+ * 두 TR은 배열 키(dbrt_trde_trnsn)와 행 구조가 동일하다 (mock-probed 2026-07-10).
484
+ * stockCode 지정 시 ka20068 (all_tp "0" = 입력종목만), 미지정 시 ka10068 시장 전체
485
+ * (all_tp "1" = 전체표시). Rows newest-first.
486
+ */
487
+ export async function fetchLendingTrend(client, stockCode, fromDate, toDate) {
488
+ const res = await client.call(stockCode
489
+ ? {
490
+ path: SLB_PATH,
491
+ apiId: "ka20068",
492
+ body: { stk_cd: stockCode, all_tp: "0", strt_dt: fromDate, end_dt: toDate },
493
+ }
494
+ : {
495
+ path: SLB_PATH,
496
+ apiId: "ka10068",
497
+ body: { all_tp: "1", strt_dt: fromDate, end_dt: toDate },
498
+ });
499
+ return lendingTrendResponseSchema.parse(res.json).dbrt_trde_trnsn;
500
+ }
501
+ /** ka90003만 쓰는 P-접두 시장코드 — 전체(all) 옵션이 없는 TR이다. */
502
+ const PROGRAM_MARKET_CODES = {
503
+ kospi: "P00101",
504
+ kosdaq: "P10102",
505
+ };
506
+ /**
507
+ * ka90003 프로그램순매수상위50 — program-trading net buy/sell top-50 for the day.
508
+ * trde_upper_tp "1"=순매도, "2"=순매수; amt_qty_tp는 투자자 TR과 동일 코드
509
+ * (1=금액 백만원, 2=수량 주)이며 prm_* 필드의 단위가 이를 따른다. 장 시작 전에는
510
+ * rc=0 + 빈 배열이 온다 (mock-probed 2026-07-10 08:30 KST).
511
+ */
512
+ export async function fetchProgramTrades(client, direction, unit, market) {
513
+ const res = await client.call({
514
+ path: STOCK_INFO_PATH,
515
+ apiId: "ka90003",
516
+ body: {
517
+ trde_upper_tp: direction === "net_sell" ? "1" : "2",
518
+ amt_qty_tp: INVESTOR_UNIT_CODES[unit],
519
+ mrkt_tp: PROGRAM_MARKET_CODES[market],
520
+ stex_tp: "1",
521
+ },
522
+ });
523
+ return parseArray(res.json, "prm_netprps_upper_50", programTradeItemSchema);
524
+ }
442
525
  /**
443
526
  * ka10170 당일매매일지요청 — the day's trading journal (per-stock realized P&L). Account-scoped.
444
527
  * Returns the full response (period totals + return_msg) so callers can surface the
@@ -23,3 +23,30 @@ export async function loadMasterList(client) {
23
23
  export function clearMasterListCache() {
24
24
  cache = null;
25
25
  }
26
+ /**
27
+ * ka10099 orderWarning(투자유의종목여부) codes. "5" arrives on rows whose
28
+ * auditInfo is 투자경고 (mock-probed 2026-07-11), so the openapi description's
29
+ * "투자경과" is read as a typo for 투자경고. "1" is ETF-only.
30
+ */
31
+ const ORDER_WARNING_LABELS = {
32
+ "1": "ETF투자주의요망",
33
+ "2": "정리매매",
34
+ "3": "단기과열",
35
+ "4": "투자위험",
36
+ "5": "투자경고",
37
+ };
38
+ /**
39
+ * Abnormal-status labels for a master-list row — 감리구분(auditInfo)이 "정상"이
40
+ * 아니면 그 값을, orderWarning이 "0"이 아니면 라벨을 모은다. 둘이 같은 상태를
41
+ * 가리키는 경우(예: auditInfo "단기과열" + orderWarning "3")는 한 번만 담는다.
42
+ * 정상 종목은 빈 배열.
43
+ */
44
+ export function masterItemWarnings(item) {
45
+ const warnings = [];
46
+ if (item.auditInfo && item.auditInfo !== "정상")
47
+ warnings.push(item.auditInfo);
48
+ const orderLabel = ORDER_WARNING_LABELS[item.orderWarning];
49
+ if (orderLabel && !warnings.includes(orderLabel))
50
+ warnings.push(orderLabel);
51
+ return warnings;
52
+ }
@@ -134,10 +134,12 @@ export const stockListItemSchema = z.looseObject({
134
134
  lastPrice: str(), // 전일종가, zero-padded
135
135
  marketName: str(), // "거래소" | "코스닥" | "ETF" | ...
136
136
  upName: str(), // 업종명 (ETF/ETN은 빈 값)
137
- upSizeName: str(), // 대형주/중형주/소형주
137
+ upSizeName: str(), // 대형주/중형주/소형주 (우선주/ETF 등은 빈 값)
138
138
  state: str(), // "증거금20%|담보대출|신용가능"
139
- auditInfo: str(), // "정상" 등
140
- orderWarning: str(), // "0"=정상
139
+ auditInfo: str(), // "정상"/"거래정지"/"관리종목"/"투자주의환기종목"/"단기과열"/"투자경고"
140
+ orderWarning: str(), // 투자유의: "0"=해당없음, 나머지는 master-list.ts ORDER_WARNING_LABELS
141
+ regDay: str(), // 상장일 yyyyMMdd
142
+ companyClassName: str(), // 코스닥 전용 기업분류: 벤처기업/우량기업/스팩 등 (그 외 빈 값)
141
143
  });
142
144
  export const stockListResponseSchema = z.looseObject({
143
145
  ...envelope,
@@ -421,6 +423,80 @@ export const foreignHoldingResponseSchema = z.looseObject({
421
423
  ...envelope,
422
424
  stk_frgnr: z.array(foreignHoldingItemSchema).default([]),
423
425
  });
426
+ // ── ka10068(전체)/ka20068(종목별): 대차거래추이 — /api/dostk/slb ──
427
+ // Both TRs share the array key AND the row shape (mock-probed 2026-07-10).
428
+ // 당일 행은 집계 전이라 전부 "0"으로 올 수 있다.
429
+ export const lendingTrendItemSchema = z.looseObject({
430
+ dt: str(), // 일자 yyyyMMdd
431
+ dbrt_trde_cntrcnt: str(), // 대차거래 체결주수 (주)
432
+ dbrt_trde_rpy: str(), // 대차거래 상환주수 (주)
433
+ dbrt_trde_irds: str(), // 대차거래 증감 (주, 부호) = 체결 − 상환
434
+ rmnd: str(), // 잔고주수 (주)
435
+ remn_amt: str(), // 잔고금액 (백만원)
436
+ });
437
+ export const lendingTrendResponseSchema = z.looseObject({
438
+ ...envelope,
439
+ dbrt_trde_trnsn: z.array(lendingTrendItemSchema).default([]),
440
+ });
441
+ // ── ka10054: 변동성완화장치(VI) 발동종목 — /api/dostk/stkinfo ──
442
+ // 정적 VI 행은 dynm_* 필드가 "0"/"0.00"으로 온다; virelis_time "000000" = 미해제.
443
+ export const viStockItemSchema = z.looseObject({
444
+ stk_cd: str(),
445
+ stk_nm: str(),
446
+ acc_trde_qty: str(), // 누적거래량 (주)
447
+ motn_pric: str(), // 발동가격 (원)
448
+ dynm_dispty_rt: str(), // 동적괴리율(%)
449
+ trde_cntr_proc_time: str(), // 매매체결처리시각 HHmmss
450
+ virelis_time: str(), // VI해제시각 HHmmss ("000000" = 미해제)
451
+ viaplc_tp: str(), // VI적용구분 ("정적"/"동적" 등)
452
+ dynm_stdpc: str(), // 동적기준가격 (원)
453
+ static_stdpc: str(), // 정적기준가격 (원)
454
+ static_dispty_rt: str(), // 정적괴리율(%)
455
+ open_pric_pre_flu_rt: str(), // 시가대비등락률(%)
456
+ vimotn_cnt: str(), // VI발동횟수
457
+ });
458
+ // ── ka10002: 주식거래원 — /api/dostk/stkinfo (flat, 상위 5 매도/매수 거래원) ──
459
+ // 수량은 부호 접두 (매도 음수/매수 양수 — 방향 중복이라 표시할 땐 절대값).
460
+ export const brokerActivityResponseSchema = z.looseObject({
461
+ ...envelope,
462
+ stk_cd: str(),
463
+ stk_nm: str(),
464
+ cur_prc: str(), // 현재가 (부호 방향)
465
+ flu_rt: str(), // 등락률(%)
466
+ sel_trde_ori_nm_1: str(), // 매도거래원명1
467
+ sel_trde_qty_1: str(), // 매도거래량1 (주, 부호 접두)
468
+ sel_trde_ori_nm_2: str(),
469
+ sel_trde_qty_2: str(),
470
+ sel_trde_ori_nm_3: str(),
471
+ sel_trde_qty_3: str(),
472
+ sel_trde_ori_nm_4: str(),
473
+ sel_trde_qty_4: str(),
474
+ sel_trde_ori_nm_5: str(),
475
+ sel_trde_qty_5: str(),
476
+ buy_trde_ori_nm_1: str(), // 매수거래원명1
477
+ buy_trde_qty_1: str(), // 매수거래량1 (주, 부호 접두)
478
+ buy_trde_ori_nm_2: str(),
479
+ buy_trde_qty_2: str(),
480
+ buy_trde_ori_nm_3: str(),
481
+ buy_trde_qty_3: str(),
482
+ buy_trde_ori_nm_4: str(),
483
+ buy_trde_qty_4: str(),
484
+ buy_trde_ori_nm_5: str(),
485
+ buy_trde_qty_5: str(),
486
+ });
487
+ // ── ka90003: 프로그램순매수상위50 — /api/dostk/stkinfo ──
488
+ // prm_* 필드의 단위는 요청의 amt_qty_tp를 따른다 (1=금액 백만원, 2=수량 주).
489
+ export const programTradeItemSchema = z.looseObject({
490
+ rank: str(), // 순위
491
+ stk_cd: str(),
492
+ stk_nm: str(),
493
+ cur_prc: str(), // 현재가 (부호 방향)
494
+ flu_rt: str(), // 등락률(%)
495
+ acc_trde_qty: str(), // 누적거래량 (주)
496
+ prm_sell_amt: str(), // 프로그램 매도
497
+ prm_buy_amt: str(), // 프로그램 매수
498
+ prm_netprps_amt: str(), // 프로그램 순매수 (부호)
499
+ });
424
500
  // ── ka10170: 당일매매일지 — /api/dostk/acnt (live-verified 2026-07-07) ──
425
501
  // NOTE: an empty trading day returns ONE all-blank row (not an empty array) — callers
426
502
  // must filter blank rows (empty stk_cd/stk_nm). A base_dt beyond ~2 months returns
package/dist/server.js CHANGED
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { isIsaEnabled } from "./config.js";
3
3
  import { registerAccountBalanceTool } from "./tools/account-balance.js";
4
4
  import { registerAccountHoldingsTool } from "./tools/account-holdings.js";
5
+ import { registerBrokerActivityTool } from "./tools/broker-activity.js";
5
6
  import { registerEtfInfoTool } from "./tools/etf-info.js";
6
7
  import { registerEtfReturnsTool } from "./tools/etf-returns.js";
7
8
  import { registerForeignHoldingTool } from "./tools/foreign-holding.js";
@@ -12,18 +13,21 @@ import { registerMarketMoversTool } from "./tools/market-movers.js";
12
13
  import { registerOrderbookTool } from "./tools/orderbook.js";
13
14
  import { registerPendingOrdersTool } from "./tools/pending-orders.js";
14
15
  import { registerPingTool } from "./tools/ping.js";
16
+ import { registerProgramTradingTool } from "./tools/program-trading.js";
15
17
  import { registerRankingTool } from "./tools/ranking.js";
16
18
  import { registerSectorPriceTool, registerSectorStocksTool } from "./tools/sector.js";
17
19
  import { registerShortSellingTool } from "./tools/short-selling.js";
18
20
  import { registerStockChartTool } from "./tools/stock-chart.js";
21
+ import { registerStockLendingTool } from "./tools/stock-lending.js";
19
22
  import { registerStockPriceTool } from "./tools/stock-price.js";
20
23
  import { registerStockSearchTool } from "./tools/stock-search.js";
21
24
  import { registerThemeGroupsTool, registerThemeStocksTool } from "./tools/theme.js";
22
25
  import { registerTradingJournalTool } from "./tools/trading-journal.js";
23
26
  import { registerTransactionsTool } from "./tools/transactions.js";
27
+ import { registerViStocksTool } from "./tools/vi-stocks.js";
24
28
  import { registerWatchlistGroupsTool, registerWatchlistTool } from "./tools/watchlist.js";
25
29
  export const SERVER_NAME = "kiwoom-mcp-server";
26
- export const SERVER_VERSION = "0.12.0";
30
+ export const SERVER_VERSION = "0.16.0";
27
31
  export function createServer() {
28
32
  const server = new McpServer({
29
33
  name: SERVER_NAME,
@@ -40,11 +44,15 @@ export function createServer() {
40
44
  registerSectorStocksTool(server);
41
45
  registerRankingTool(server);
42
46
  registerMarketMoversTool(server);
47
+ registerViStocksTool(server);
43
48
  registerInvestorTrendTool(server);
49
+ registerBrokerActivityTool(server);
44
50
  registerEtfInfoTool(server);
45
51
  registerEtfReturnsTool(server);
46
52
  registerShortSellingTool(server);
53
+ registerStockLendingTool(server);
47
54
  registerForeignHoldingTool(server);
55
+ registerProgramTradingTool(server);
48
56
  // Watchlist (HTS 저장 관심종목 — read-only; ka01300/ka01301)
49
57
  registerWatchlistGroupsTool(server);
50
58
  registerWatchlistTool(server);
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchBrokerActivity } from "../kiwoom/api.js";
4
+ import { formatKRW, formatNumber, formatPercent, parseKiwoomNumber, parseKiwoomPrice } from "../utils/num.js";
5
+ import { runTool, textResult } from "./helpers.js";
6
+ export function formatBrokerActivity(data, stockCode, modeLabel) {
7
+ // (매수명, 매수량, 매도명, 매도량) 순위 1~5 — 이름이 전부 비면 데이터 없음.
8
+ const slots = [
9
+ [data.buy_trde_ori_nm_1, data.buy_trde_qty_1, data.sel_trde_ori_nm_1, data.sel_trde_qty_1],
10
+ [data.buy_trde_ori_nm_2, data.buy_trde_qty_2, data.sel_trde_ori_nm_2, data.sel_trde_qty_2],
11
+ [data.buy_trde_ori_nm_3, data.buy_trde_qty_3, data.sel_trde_ori_nm_3, data.sel_trde_qty_3],
12
+ [data.buy_trde_ori_nm_4, data.buy_trde_qty_4, data.sel_trde_ori_nm_4, data.sel_trde_qty_4],
13
+ [data.buy_trde_ori_nm_5, data.buy_trde_qty_5, data.sel_trde_ori_nm_5, data.sel_trde_qty_5],
14
+ ];
15
+ const filled = slots.filter(([buyNm, , selNm]) => buyNm || selNm);
16
+ if (filled.length === 0) {
17
+ return `[${modeLabel}] ${stockCode}의 거래원 정보가 없습니다. 종목코드를 확인해 주세요.`;
18
+ }
19
+ const n = parseKiwoomNumber;
20
+ const title = data.stk_nm ? `${data.stk_nm} (${stockCode})` : stockCode;
21
+ const lines = [
22
+ `[${modeLabel}] ${title} 거래원 상위 — 현재가 ${formatKRW(parseKiwoomPrice(data.cur_prc))} (${formatPercent(n(data.flu_rt))})`,
23
+ "",
24
+ "| 순위 | 매수 거래원 | 매수량(주) | 매도 거래원 | 매도량(주) |",
25
+ "|---:|---|---:|---|---:|",
26
+ ];
27
+ filled.forEach(([buyNm, buyQty, selNm, selQty], i) => {
28
+ // 수량 부호는 매수/매도 방향 중복이라 절대값으로 표시.
29
+ const cells = [
30
+ String(i + 1),
31
+ buyNm || "-",
32
+ formatNumber(parseKiwoomPrice(buyQty)),
33
+ selNm || "-",
34
+ formatNumber(parseKiwoomPrice(selQty)),
35
+ ];
36
+ lines.push(`| ${cells.join(" | ")} |`);
37
+ });
38
+ lines.push("", "※ 당일 거래원(증권사)별 누적 매수/매도 수량 상위 5개사입니다.");
39
+ return lines.join("\n");
40
+ }
41
+ export function registerBrokerActivityTool(server) {
42
+ server.registerTool("get_broker_activity", {
43
+ title: "거래원 동향 조회",
44
+ description: "특정 종목의 당일 거래원(증권사)별 매수/매도 상위 5개사를 조회합니다 (키움 ka10002). " +
45
+ "어느 증권사 창구에서 많이 사고팔았는지 보여줍니다. 종목코드를 모르면 search_stock으로 먼저 찾으세요.",
46
+ inputSchema: {
47
+ stock_code: z
48
+ .string()
49
+ .regex(/^\d{6}$/, "6자리 종목코드여야 합니다")
50
+ .describe("조회할 6자리 종목코드"),
51
+ },
52
+ }, async ({ stock_code }) => runTool(async () => {
53
+ const { client, config } = getKiwoomContext();
54
+ const data = await fetchBrokerActivity(client, stock_code);
55
+ return textResult(formatBrokerActivity(data, stock_code, config.modeLabel));
56
+ }));
57
+ }
@@ -2,10 +2,14 @@ import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
3
  import { fetchEtfInfo, fetchEtfNav, fetchStockInfo } from "../kiwoom/api.js";
4
4
  import { formatKRW, formatPercent, formatQuantity, formatRatioPercent, formatSigned, parseKiwoomNumber, parseKiwoomPrice, } from "../utils/num.js";
5
+ import { formatNonEtfNotice } from "./etf-returns.js";
5
6
  import { runTool, textResult } from "./helpers.js";
6
7
  export function formatEtfInfo(etf, quote, nav, stockCode, modeLabel) {
7
- if (!etf.stk_nm) {
8
- return `[${modeLabel}] ${stockCode}의 ETF 정보가 없습니다. ETF 종목코드인지 확인해 주세요.`;
8
+ // 비ETF/미존재 코드 가드 — get_etf_returns와 동일한 ka40002 판별자
9
+ // (비ETF는 stk_nm만 채워지고 추적지수명이 빈값, 없는 코드는 다 빈값).
10
+ // 이전에는 비ETF에 "-"투성이 카드가 나갔다 (2026-07-10 GUI 테스트 watch item).
11
+ if (!etf.stk_nm || !etf.etfobjt_idex_nm) {
12
+ return formatNonEtfNotice(etf.stk_nm, stockCode, modeLabel, "ETF 정보를");
9
13
  }
10
14
  const lines = [
11
15
  `[${modeLabel}] ${etf.stk_nm} (${stockCode}) ETF 정보`,
@@ -6,6 +6,20 @@ import { runTool, textResult } from "./helpers.js";
6
6
  import { sectorLabel } from "./sector.js";
7
7
  /** ka40001의 etfobjt_idex_cd는 필수 — 지정이 없으면 KOSPI200(201)을 기본 벤치마크로 쓴다. */
8
8
  const DEFAULT_BENCHMARK = "201";
9
+ /**
10
+ * 비ETF 가드 안내 (2026-07-10 GUI 테스트 발견 대응): ka40001은 일반 종목 코드에도
11
+ * 수익률을 돌려주므로 ka40002로 선별한다 — 비ETF는 stk_nm은 있지만 추적지수명이
12
+ * 빈값, 존재하지 않는 코드는 둘 다 빈값 (mock-probed 2026-07-10).
13
+ * get_etf_info도 같은 판별자를 쓴다 — featurePhrase는 조사까지 포함한 목적어구.
14
+ */
15
+ export function formatNonEtfNotice(stockName, stockCode, modeLabel, featurePhrase = "ETF 기간 수익률을") {
16
+ if (stockName) {
17
+ return (`[${modeLabel}] ${stockName} (${stockCode})은(는) ETF가 아니어서 ${featurePhrase} 제공하지 않습니다. ` +
18
+ `일반 종목 시세는 get_stock_price / get_stock_chart를 이용하세요.`);
19
+ }
20
+ return (`[${modeLabel}] ${stockCode}의 ETF 정보를 찾을 수 없습니다. ` +
21
+ `ETF 종목코드인지 확인해 주세요 (search_stock으로 검색 가능).`);
22
+ }
9
23
  export function formatEtfReturns(rows, etfName, stockCode, benchmarkCode, modeLabel) {
10
24
  const n = parseKiwoomNumber;
11
25
  if (rows.every((r) => r === null || n(r.etfprft_rt) === null)) {
@@ -50,11 +64,14 @@ export function registerEtfReturnsTool(server) {
50
64
  const { client, config } = getKiwoomContext();
51
65
  const code = stock_code.toUpperCase();
52
66
  const benchmark = benchmark_index_code ?? DEFAULT_BENCHMARK;
53
- // 4-period loop (~3.3s, same-TR rate limit) + name garnish in parallel.
54
- const [rows, etf] = await Promise.all([
55
- fetchEtfReturns(client, code, benchmark),
56
- fetchEtfInfo(client, code).catch(() => null),
57
- ]);
67
+ // Non-ETF guard BEFORE the 4-call loop best-effort: a flaky ka40002
68
+ // lookup must not block a real ETF, so only a successful blank-index
69
+ // response refuses (and it also saves the 4 wasted ka40001 calls).
70
+ const etf = await fetchEtfInfo(client, code).catch(() => null);
71
+ if (etf && !etf.etfobjt_idex_nm) {
72
+ return textResult(formatNonEtfNotice(etf.stk_nm, code, config.modeLabel));
73
+ }
74
+ const rows = await fetchEtfReturns(client, code, benchmark);
58
75
  return textResult(formatEtfReturns(rows, etf?.stk_nm || null, code, benchmark, config.modeLabel));
59
76
  }));
60
77
  }
@@ -0,0 +1,74 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchProgramTrades } from "../kiwoom/api.js";
4
+ import { formatNumber, formatPercent, formatSigned, 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 DIRECTION_LABELS = {
9
+ net_buy: "프로그램 순매수 상위",
10
+ net_sell: "프로그램 순매도 상위",
11
+ };
12
+ const MARKET_LABELS = {
13
+ kospi: "코스피",
14
+ kosdaq: "코스닥",
15
+ };
16
+ const UNIT_LABELS = {
17
+ amount: "백만원",
18
+ quantity: "주",
19
+ };
20
+ export function formatProgramTrades(items, direction, unit, market, top, modeLabel) {
21
+ const title = `${MARKET_LABELS[market]} ${DIRECTION_LABELS[direction]}`;
22
+ const shown = items.slice(0, top);
23
+ if (shown.length === 0) {
24
+ return `[${modeLabel}] ${title} 데이터가 없습니다 (장 시작 전이거나 당일 집계 전일 수 있습니다).`;
25
+ }
26
+ const n = parseKiwoomNumber;
27
+ const lines = [
28
+ `[${modeLabel}] ${title} (상위 ${shown.length}종목, 단위: ${UNIT_LABELS[unit]})`,
29
+ "",
30
+ "| 순위 | 종목명 | 코드 | 현재가 | 등락률 | 프로그램 매수 | 프로그램 매도 | 순매수 |",
31
+ "|---:|---|---|---:|---:|---:|---:|---:|",
32
+ ];
33
+ shown.forEach((item, i) => {
34
+ const cells = [
35
+ String(i + 1),
36
+ item.stk_nm,
37
+ item.stk_cd,
38
+ formatNumber(parseKiwoomPrice(item.cur_prc)),
39
+ formatPercent(n(item.flu_rt)),
40
+ formatNumber(n(item.prm_buy_amt)),
41
+ formatNumber(n(item.prm_sell_amt)),
42
+ formatSigned(n(item.prm_netprps_amt), 0),
43
+ ];
44
+ lines.push(`| ${cells.join(" | ")} |`);
45
+ });
46
+ return lines.join("\n");
47
+ }
48
+ export function registerProgramTradingTool(server) {
49
+ server.registerTool("get_program_trading", {
50
+ title: "프로그램 매매 상위 조회",
51
+ description: "당일 프로그램 매매 순매수/순매도 상위 종목을 조회합니다 (키움 ka90003). " +
52
+ "direction: net_buy(순매수, 기본)/net_sell(순매도). unit: amount(금액 백만원, 기본)/" +
53
+ "quantity(수량 주). market: kospi(기본)/kosdaq — 이 TR에는 전체(all) 옵션이 없습니다.",
54
+ inputSchema: {
55
+ direction: z.enum(["net_buy", "net_sell"]).optional().describe("순매수/순매도 (기본값: net_buy)"),
56
+ unit: z.enum(["amount", "quantity"]).optional().describe("금액/수량 기준 (기본값: amount)"),
57
+ market: z.enum(["kospi", "kosdaq"]).optional().describe("시장 구분 (기본값: kospi)"),
58
+ top: z
59
+ .number()
60
+ .int()
61
+ .min(1)
62
+ .max(MAX_TOP)
63
+ .optional()
64
+ .describe(`표시할 종목 수 (기본값 ${DEFAULT_TOP}, 최대 ${MAX_TOP})`),
65
+ },
66
+ }, async ({ direction, unit, market, top }) => runTool(async () => {
67
+ const { client, config } = getKiwoomContext();
68
+ const d = direction ?? "net_buy";
69
+ const u = unit ?? "amount";
70
+ const m = market ?? "kospi";
71
+ const items = await fetchProgramTrades(client, d, u, m);
72
+ return textResult(formatProgramTrades(items, d, u, m, top ?? DEFAULT_TOP, config.modeLabel));
73
+ }));
74
+ }
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchLendingTrend } from "../kiwoom/api.js";
4
+ import { assertDateRange, formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
5
+ import { formatNumber, formatSigned, parseKiwoomNumber } from "../utils/num.js";
6
+ import { runTool, textResult } from "./helpers.js";
7
+ const DEFAULT_LOOKBACK_DAYS = 30;
8
+ export function formatLendingTrend(rows, query, modeLabel) {
9
+ const scope = query.stockCode ? `종목 ${query.stockCode}` : "시장 전체";
10
+ const period = `${formatDateDashed(query.fromDate)} ~ ${formatDateDashed(query.toDate)}`;
11
+ if (rows.length === 0) {
12
+ return `[${modeLabel}] 대차거래 추이가 없습니다 (${scope}, ${period}).`;
13
+ }
14
+ const n = parseKiwoomNumber;
15
+ const lines = [
16
+ `[${modeLabel}] 대차거래 추이 — ${scope} (${period}, ${rows.length}일)`,
17
+ "",
18
+ "| 일자 | 체결(주) | 상환(주) | 증감(주) | 잔고(주) | 잔고금액(백만원) |",
19
+ "|---|---:|---:|---:|---:|---:|",
20
+ ];
21
+ for (const r of rows) {
22
+ const cells = [
23
+ formatDateDashed(r.dt),
24
+ formatNumber(n(r.dbrt_trde_cntrcnt)),
25
+ formatNumber(n(r.dbrt_trde_rpy)),
26
+ formatSigned(n(r.dbrt_trde_irds), 0),
27
+ formatNumber(n(r.rmnd)),
28
+ formatNumber(n(r.remn_amt)),
29
+ ];
30
+ lines.push(`| ${cells.join(" | ")} |`);
31
+ }
32
+ lines.push("", "※ 증감 = 체결 − 상환. 당일 행은 집계 확정 전이라 0으로 표시될 수 있습니다.");
33
+ return lines.join("\n");
34
+ }
35
+ export function registerStockLendingTool(server) {
36
+ server.registerTool("get_stock_lending", {
37
+ title: "대차거래 추이 조회",
38
+ description: "일자별 대차거래(주식 대여) 추이를 조회합니다 — 체결·상환·증감 주수와 대차잔고, 잔고금액 " +
39
+ "(키움 ka10068/ka20068). stock_code를 지정하면 해당 종목, 생략하면 시장 전체 집계입니다. " +
40
+ "기본 조회 기간은 최근 30일이며 from_date/to_date로 변경할 수 있습니다. " +
41
+ "공매도 흐름과 함께 보려면 get_short_selling을 참고하세요.",
42
+ inputSchema: {
43
+ stock_code: z
44
+ .string()
45
+ .regex(/^\d{6}$/, "6자리 종목코드여야 합니다")
46
+ .optional()
47
+ .describe("6자리 종목코드 (생략 시 시장 전체 대차 추이)"),
48
+ from_date: z
49
+ .string()
50
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
51
+ .optional()
52
+ .describe("조회 시작일 (기본값: 30일 전)"),
53
+ to_date: z
54
+ .string()
55
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
56
+ .optional()
57
+ .describe("조회 종료일 (기본값: 오늘)"),
58
+ },
59
+ }, async ({ stock_code, from_date, to_date }) => runTool(async () => {
60
+ const { client, config } = getKiwoomContext();
61
+ const query = {
62
+ stockCode: stock_code,
63
+ fromDate: (from_date ?? kstDaysAgo(DEFAULT_LOOKBACK_DAYS)).replaceAll("-", ""),
64
+ toDate: (to_date ?? todayInKst()).replaceAll("-", ""),
65
+ };
66
+ assertDateRange(query.fromDate, query.toDate);
67
+ const rows = await fetchLendingTrend(client, query.stockCode, query.fromDate, query.toDate);
68
+ return textResult(formatLendingTrend(rows, query, config.modeLabel));
69
+ }));
70
+ }
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
3
  import { fetchStockInfo } from "../kiwoom/api.js";
4
+ import { loadMasterList, masterItemWarnings } from "../kiwoom/master-list.js";
5
+ import { formatDateDashed } from "../utils/date.js";
4
6
  import { formatKRW, formatPercent, parseKiwoomNumber, parseKiwoomPrice, } from "../utils/num.js";
5
7
  import { runTool, textResult } from "./helpers.js";
6
8
  const PRE_SIG_LABELS = {
@@ -10,7 +12,7 @@ const PRE_SIG_LABELS = {
10
12
  "4": "하한",
11
13
  "5": "하락",
12
14
  };
13
- export function formatStockInfo(info, modeLabel) {
15
+ export function formatStockInfo(info, modeLabel, master) {
14
16
  const cur = parseKiwoomPrice(info.cur_prc);
15
17
  const change = parseKiwoomNumber(info.pred_pre);
16
18
  const fluRt = parseKiwoomNumber(info.flu_rt);
@@ -45,12 +47,28 @@ export function formatStockInfo(info, modeLabel) {
45
47
  if (fundamentals.length > 0) {
46
48
  lines.push(`- ${fundamentals.join(" / ")}`);
47
49
  }
50
+ // ka10099 마스터 캐시에서 얻는 부가 정보 — 조회 실패 시 이 블록만 조용히 빠진다.
51
+ if (master) {
52
+ const marketParts = [master.marketName, master.upName].filter(Boolean).join(" · ");
53
+ const sizeClass = [master.upSizeName, master.companyClassName].filter(Boolean).join(", ");
54
+ if (marketParts) {
55
+ lines.push(`- 시장/업종: ${marketParts}${sizeClass ? ` (${sizeClass})` : ""}`);
56
+ }
57
+ if (/^\d{8}$/.test(master.regDay)) {
58
+ lines.push(`- 상장일: ${formatDateDashed(master.regDay)}`);
59
+ }
60
+ const warnings = masterItemWarnings(master);
61
+ if (warnings.length > 0) {
62
+ lines.push(`- ⚠️ 투자유의: ${warnings.join(" · ")}`);
63
+ }
64
+ }
48
65
  return lines.join("\n");
49
66
  }
50
67
  export function registerStockPriceTool(server) {
51
68
  server.registerTool("get_stock_price", {
52
69
  title: "종목 현재가 조회",
53
70
  description: "6자리 종목코드로 국내 주식/ETF의 현재가, 등락률, 거래량, 기본 지표를 조회합니다 (키움 ka10001). " +
71
+ "업종·상장일과 거래정지/관리종목/투자경고 같은 투자유의 상태도 함께 표시됩니다. " +
54
72
  "종목명만 알고 있다면 search_stock으로 먼저 코드를 찾으세요.",
55
73
  inputSchema: {
56
74
  stock_code: z
@@ -60,7 +78,14 @@ export function registerStockPriceTool(server) {
60
78
  },
61
79
  }, async ({ stock_code }) => runTool(async () => {
62
80
  const { client, config } = getKiwoomContext();
63
- const info = await fetchStockInfo(client, stock_code);
64
- return textResult(formatStockInfo(info, config.modeLabel));
81
+ // 마스터 조회는 best-effort 부가정보 — 실패해도 시세 응답은 그대로 나간다.
82
+ // 캐시가 따뜻하면 추가 API 콜 없음(12h TTL), 콜드면 ka10099 2콜이 병렬로 얹힌다.
83
+ const [info, master] = await Promise.all([
84
+ fetchStockInfo(client, stock_code),
85
+ loadMasterList(client)
86
+ .then((items) => items.find((i) => i.code === stock_code))
87
+ .catch(() => undefined),
88
+ ]);
89
+ return textResult(formatStockInfo(info, config.modeLabel, master));
65
90
  }));
66
91
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
- import { loadMasterList } from "../kiwoom/master-list.js";
3
+ import { loadMasterList, masterItemWarnings } from "../kiwoom/master-list.js";
4
4
  import { formatKRW, parseKiwoomPrice } from "../utils/num.js";
5
5
  import { runTool, textResult } from "./helpers.js";
6
6
  const MAX_RESULTS = 10;
@@ -38,11 +38,12 @@ export function formatSearchResults(results, query, modeLabel) {
38
38
  const lines = [
39
39
  `[${modeLabel}] 종목 검색: "${query}" (${results.length}건${results.length >= MAX_RESULTS ? ", 상위만 표시" : ""})`,
40
40
  "",
41
- "| 코드 | 종목명 | 시장 | 전일종가 | 업종 |",
42
- "|---|---|---|---:|---|",
41
+ "| 코드 | 종목명 | 시장 | 전일종가 | 업종 | 비고 |",
42
+ "|---|---|---|---:|---|---|",
43
43
  ];
44
44
  for (const r of results) {
45
- lines.push(`| ${r.code} | ${r.name} | ${r.marketName || "-"} | ${formatKRW(parseKiwoomPrice(r.lastPrice))} | ${r.upName || "-"} |`);
45
+ const warnings = masterItemWarnings(r);
46
+ lines.push(`| ${r.code} | ${r.name} | ${r.marketName || "-"} | ${formatKRW(parseKiwoomPrice(r.lastPrice))} | ${r.upName || "-"} | ${warnings.join("·") || "-"} |`);
46
47
  }
47
48
  return lines.join("\n");
48
49
  }
@@ -51,6 +52,7 @@ export function registerStockSearchTool(server) {
51
52
  title: "종목 검색 (이름→코드)",
52
53
  description: "종목명(부분 일치)이나 6자리 코드로 코스피/코스닥 상장 종목(ETF/ETN 포함)을 검색해 " +
53
54
  "종목코드를 찾습니다 (키움 ka10099). 다른 tool에 넘길 종목코드를 모를 때 먼저 사용하세요. " +
55
+ "거래정지·관리종목·투자경고 같은 투자유의 상태는 비고 컬럼에 표시됩니다. " +
54
56
  "첫 호출은 종목 마스터를 내려받아 몇 초 걸리고, 이후 12시간 동안 캐시됩니다.",
55
57
  inputSchema: {
56
58
  query: z.string().min(1).describe("종목명 일부(예: '삼성전자', 'KODEX 미국') 또는 6자리 종목코드"),
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchViStocks } 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 MARKET_LABELS = {
9
+ all: "전체",
10
+ kospi: "코스피",
11
+ kosdaq: "코스닥",
12
+ };
13
+ const DIRECTION_LABELS = {
14
+ all: "",
15
+ up: "상승 ",
16
+ down: "하락 ",
17
+ };
18
+ const TYPE_LABELS = {
19
+ all: "",
20
+ static: "정적 ",
21
+ dynamic: "동적 ",
22
+ };
23
+ /** "133301" → "13:33:01"; "000000"(미해제)과 그 외 비정상 값은 "-". */
24
+ function formatHms(hms) {
25
+ if (hms.length !== 6 || hms === "000000")
26
+ return "-";
27
+ return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
28
+ }
29
+ export function formatViStocks(items, market, direction, viType, stockCode, top, modeLabel) {
30
+ const scope = stockCode
31
+ ? `종목 ${stockCode}`
32
+ : `${MARKET_LABELS[market]} ${DIRECTION_LABELS[direction]}${TYPE_LABELS[viType]}`.trimEnd();
33
+ const shown = items.slice(0, top);
34
+ if (shown.length === 0) {
35
+ return `[${modeLabel}] ${scope} VI 발동 내역이 없습니다 (당일 기준).`;
36
+ }
37
+ const n = parseKiwoomNumber;
38
+ const lines = [
39
+ `[${modeLabel}] ${scope} VI 발동 종목 (${shown.length}건)`,
40
+ "",
41
+ "| 종목명 | 코드 | 구분 | 발동가 | 괴리율 | 시가대비 | 발동시각 | 해제시각 | 횟수 |",
42
+ "|---|---|---|---:|---:|---:|---|---|---:|",
43
+ ];
44
+ for (const item of shown) {
45
+ // 정적 VI 행은 dynm_* 필드가 "0"으로 오므로 적용구분에 맞는 괴리율을 고른다.
46
+ const dispty = item.viaplc_tp.includes("동적") ? item.dynm_dispty_rt : item.static_dispty_rt;
47
+ const cells = [
48
+ item.stk_nm,
49
+ item.stk_cd,
50
+ item.viaplc_tp || "-",
51
+ formatNumber(parseKiwoomPrice(item.motn_pric)),
52
+ formatPercent(n(dispty)),
53
+ formatPercent(n(item.open_pric_pre_flu_rt)),
54
+ formatHms(item.trde_cntr_proc_time),
55
+ formatHms(item.virelis_time),
56
+ formatNumber(n(item.vimotn_cnt)),
57
+ ];
58
+ lines.push(`| ${cells.join(" | ")} |`);
59
+ }
60
+ lines.push("", "※ VI(변동성완화장치) = 급등락 시 단일가 매매로 전환되는 안전장치. 해제시각 '-'는 아직 해제되지 않았거나 기록이 없는 경우입니다.");
61
+ return lines.join("\n");
62
+ }
63
+ export function registerViStocksTool(server) {
64
+ server.registerTool("get_vi_stocks", {
65
+ title: "VI 발동 종목 조회",
66
+ description: "당일 변동성완화장치(VI)가 발동된 종목을 조회합니다 — 발동가격·괴리율·시가대비등락률·발동/해제 " +
67
+ "시각·발동횟수 (키움 ka10054). market: all(기본)/kospi/kosdaq, direction: all(기본)/up(상승)/" +
68
+ "down(하락), vi_type: all(기본)/static(정적)/dynamic(동적). stock_code를 지정하면 해당 종목의 " +
69
+ "당일 발동 내역만 조회합니다.",
70
+ inputSchema: {
71
+ market: z.enum(["all", "kospi", "kosdaq"]).optional().describe("시장 구분 (기본값: all)"),
72
+ direction: z.enum(["all", "up", "down"]).optional().describe("발동 방향 (기본값: all)"),
73
+ vi_type: z.enum(["all", "static", "dynamic"]).optional().describe("VI 유형 (기본값: all)"),
74
+ stock_code: z
75
+ .string()
76
+ .regex(/^\d{6}$/, "6자리 종목코드여야 합니다")
77
+ .optional()
78
+ .describe("특정 종목의 발동 내역만 조회 (생략 시 전체)"),
79
+ top: z
80
+ .number()
81
+ .int()
82
+ .min(1)
83
+ .max(MAX_TOP)
84
+ .optional()
85
+ .describe(`표시할 건수 (기본값 ${DEFAULT_TOP}, 최대 ${MAX_TOP})`),
86
+ },
87
+ }, async ({ market, direction, vi_type, stock_code, top }) => runTool(async () => {
88
+ const { client, config } = getKiwoomContext();
89
+ const m = market ?? "all";
90
+ const d = direction ?? "all";
91
+ const t = vi_type ?? "all";
92
+ const items = await fetchViStocks(client, m, d, t, stock_code);
93
+ return textResult(formatViStocks(items, m, d, t, stock_code, top ?? DEFAULT_TOP, config.modeLabel));
94
+ }));
95
+ }
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
3
  import { fetchWatchlistGroupDetail, fetchWatchlistGroups } from "../kiwoom/api.js";
4
4
  import { KiwoomApiError } from "../kiwoom/errors.js";
5
- import { loadMasterList } from "../kiwoom/master-list.js";
5
+ import { loadMasterList, masterItemWarnings } from "../kiwoom/master-list.js";
6
6
  import { normalizeStockCode, } from "../kiwoom/types.js";
7
7
  import { formatKRW, parseKiwoomPrice } from "../utils/num.js";
8
8
  import { runTool, textResult } from "./helpers.js";
@@ -39,7 +39,7 @@ export function formatWatchlist(group, stocks, nameIndex, modeLabel) {
39
39
  if (stocks.length === 0) {
40
40
  return `${title}\n\n(이 그룹에 등록된 종목이 없습니다.)`;
41
41
  }
42
- const lines = [title, "", "| 코드 | 종목명 | 시장 | 전일종가 |", "|---|---|---|---:|"];
42
+ const lines = [title, "", "| 코드 | 종목명 | 시장 | 전일종가 | 비고 |", "|---|---|---|---:|---|"];
43
43
  let missing = 0;
44
44
  for (const s of stocks) {
45
45
  const code = normalizeStockCode(s.cod2);
@@ -50,7 +50,8 @@ export function formatWatchlist(group, stocks, nameIndex, modeLabel) {
50
50
  const name = `${bookmark}${info?.name || "-"}`;
51
51
  const market = info?.marketName || "-";
52
52
  const price = info ? formatKRW(parseKiwoomPrice(info.lastPrice)) : "-";
53
- lines.push(`| ${code} | ${name} | ${market} | ${price} |`);
53
+ const flags = info ? masterItemWarnings(info).join("·") : "";
54
+ lines.push(`| ${code} | ${name} | ${market} | ${price} | ${flags || "-"} |`);
54
55
  }
55
56
  if (nameIndex.size === 0) {
56
57
  lines.push("", "※ 종목 마스터를 불러오지 못해 코드만 표시했습니다.");
@@ -91,7 +92,7 @@ export function registerWatchlistTool(server) {
91
92
  title: "관심종목 그룹 상세",
92
93
  description: "관심종목 그룹에 담긴 종목 목록을 조회합니다 (키움 ka01301, 읽기 전용). " +
93
94
  "그룹코드(예: '000') 또는 그룹명(예: 'etf')을 넘기세요. 그룹을 모르면 get_watchlist_groups로 먼저 확인하세요. " +
94
- "종목명·전일종가·시장은 종목 마스터에서 보강해 함께 표시합니다.",
95
+ "종목명·전일종가·시장과 거래정지/관리종목 같은 투자유의 상태를 종목 마스터에서 보강해 함께 표시합니다.",
95
96
  inputSchema: {
96
97
  group: z
97
98
  .string()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiwoom-mcp-server",
3
- "version": "0.12.0",
3
+ "version": "0.16.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",