@mseep/korean-dart-mcp 0.9.3

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +660 -0
  3. package/build/cli.d.ts +2 -0
  4. package/build/cli.js +22 -0
  5. package/build/index.d.ts +2 -0
  6. package/build/index.js +36 -0
  7. package/build/lib/corp-code.d.ts +53 -0
  8. package/build/lib/corp-code.js +235 -0
  9. package/build/lib/dart-client.d.ts +25 -0
  10. package/build/lib/dart-client.js +71 -0
  11. package/build/lib/dart-xml.d.ts +26 -0
  12. package/build/lib/dart-xml.js +187 -0
  13. package/build/lib/xbrl-parser.d.ts +145 -0
  14. package/build/lib/xbrl-parser.js +673 -0
  15. package/build/server/mcp-server.d.ts +7 -0
  16. package/build/server/mcp-server.js +40 -0
  17. package/build/setup.d.ts +8 -0
  18. package/build/setup.js +264 -0
  19. package/build/tools/_helpers.d.ts +28 -0
  20. package/build/tools/_helpers.js +35 -0
  21. package/build/tools/buffett-quality-snapshot.d.ts +10 -0
  22. package/build/tools/buffett-quality-snapshot.js +261 -0
  23. package/build/tools/disclosure-anomaly.d.ts +14 -0
  24. package/build/tools/disclosure-anomaly.js +231 -0
  25. package/build/tools/download-document.d.ts +14 -0
  26. package/build/tools/download-document.js +89 -0
  27. package/build/tools/get-attachments.d.ts +15 -0
  28. package/build/tools/get-attachments.js +339 -0
  29. package/build/tools/get-company.d.ts +7 -0
  30. package/build/tools/get-company.js +32 -0
  31. package/build/tools/get-corporate-event.d.ts +14 -0
  32. package/build/tools/get-corporate-event.js +180 -0
  33. package/build/tools/get-executive-compensation.d.ts +13 -0
  34. package/build/tools/get-executive-compensation.js +89 -0
  35. package/build/tools/get-financials.d.ts +15 -0
  36. package/build/tools/get-financials.js +127 -0
  37. package/build/tools/get-major-holdings.d.ts +10 -0
  38. package/build/tools/get-major-holdings.js +117 -0
  39. package/build/tools/get-periodic-report.d.ts +7 -0
  40. package/build/tools/get-periodic-report.js +100 -0
  41. package/build/tools/get-shareholders.d.ts +13 -0
  42. package/build/tools/get-shareholders.js +87 -0
  43. package/build/tools/get-xbrl.d.ts +12 -0
  44. package/build/tools/get-xbrl.js +96 -0
  45. package/build/tools/index.d.ts +38 -0
  46. package/build/tools/index.js +66 -0
  47. package/build/tools/insider-signal.d.ts +15 -0
  48. package/build/tools/insider-signal.js +208 -0
  49. package/build/tools/resolve-corp-code.d.ts +7 -0
  50. package/build/tools/resolve-corp-code.js +40 -0
  51. package/build/tools/search-disclosures.d.ts +14 -0
  52. package/build/tools/search-disclosures.js +300 -0
  53. package/build/utils/safe-zip.d.ts +47 -0
  54. package/build/utils/safe-zip.js +215 -0
  55. package/build/version.d.ts +2 -0
  56. package/build/version.js +2 -0
  57. package/package.json +67 -0
@@ -0,0 +1,40 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
3
+ import { DartClient } from "../lib/dart-client.js";
4
+ import { CorpCodeResolver } from "../lib/corp-code.js";
5
+ import { TOOL_REGISTRY } from "../tools/index.js";
6
+ import { SERVER_NAME, VERSION } from "../version.js";
7
+ export function createServer(opts) {
8
+ const client = new DartClient({ apiKey: opts.apiKey });
9
+ const resolver = new CorpCodeResolver({
10
+ cacheDir: opts.cacheDir,
11
+ forceRefresh: opts.forceRefresh,
12
+ });
13
+ const ctx = { client, resolver };
14
+ // corp_code 덤프 선적재: 첫 도구 호출 전에 끝나있어야 함.
15
+ // 실패하면 각 도구가 호출될 때마다 재시도 (init() 은 동일 promise 를 재사용).
16
+ const initReady = resolver.init(client).catch((err) => {
17
+ console.error("[korean-dart-mcp] corp_code 초기화 실패:", err);
18
+ throw err;
19
+ });
20
+ const server = new Server({ name: SERVER_NAME, version: VERSION }, { capabilities: { tools: {} } });
21
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
22
+ tools: TOOL_REGISTRY.map((t) => ({
23
+ name: t.name,
24
+ description: t.description,
25
+ inputSchema: t.inputSchema,
26
+ })),
27
+ }));
28
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
29
+ const tool = TOOL_REGISTRY.find((t) => t.name === req.params.name);
30
+ if (!tool) {
31
+ throw new Error(`unknown tool: ${req.params.name}`);
32
+ }
33
+ await initReady;
34
+ const result = await tool.handler(req.params.arguments ?? {}, ctx);
35
+ return {
36
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
37
+ };
38
+ });
39
+ return server;
40
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * 대화형 설치 마법사 — korean-dart-mcp
3
+ *
4
+ * `npx korean-dart-mcp setup` 으로 실행.
5
+ * OpenDART 인증키를 입력받고, 선택한 AI 클라이언트 설정 파일에 MCP 서버를 자동 등록합니다.
6
+ * Windows / macOS / Linux 공용.
7
+ */
8
+ export declare function runSetup(): Promise<void>;
package/build/setup.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * 대화형 설치 마법사 — korean-dart-mcp
3
+ *
4
+ * `npx korean-dart-mcp setup` 으로 실행.
5
+ * OpenDART 인증키를 입력받고, 선택한 AI 클라이언트 설정 파일에 MCP 서버를 자동 등록합니다.
6
+ * Windows / macOS / Linux 공용.
7
+ */
8
+ import { createInterface } from "node:readline/promises";
9
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
10
+ import { existsSync } from "node:fs";
11
+ import { resolve, dirname } from "node:path";
12
+ import { homedir, platform } from "node:os";
13
+ import { stdin, stdout } from "node:process";
14
+ function detectClients() {
15
+ const home = homedir();
16
+ const os = platform();
17
+ const clients = [];
18
+ const claudePaths = {
19
+ darwin: resolve(home, "Library/Application Support/Claude/claude_desktop_config.json"),
20
+ win32: resolve(process.env["APPDATA"] ?? resolve(home, "AppData/Roaming"), "Claude/claude_desktop_config.json"),
21
+ linux: resolve(home, ".config/Claude/claude_desktop_config.json"),
22
+ };
23
+ const claudePath = claudePaths[os];
24
+ if (claudePath) {
25
+ clients.push({ name: "Claude Desktop", configPath: claudePath, format: "mcpServers" });
26
+ }
27
+ clients.push({
28
+ name: "Claude Code (현재 디렉토리)",
29
+ configPath: resolve(process.cwd(), ".mcp.json"),
30
+ format: "mcpServers",
31
+ });
32
+ clients.push({
33
+ name: "Cursor",
34
+ configPath: resolve(home, ".cursor/mcp.json"),
35
+ format: "mcpServers",
36
+ });
37
+ clients.push({
38
+ name: "VS Code (현재 디렉토리)",
39
+ configPath: resolve(process.cwd(), ".vscode/mcp.json"),
40
+ format: "servers",
41
+ });
42
+ clients.push({
43
+ name: "Windsurf",
44
+ configPath: resolve(home, ".codeium/windsurf/mcp_config.json"),
45
+ format: "mcpServers",
46
+ });
47
+ clients.push({
48
+ name: "Gemini CLI",
49
+ configPath: resolve(home, ".gemini/settings.json"),
50
+ format: "mcpServers",
51
+ });
52
+ const zedPaths = {
53
+ darwin: resolve(home, ".zed/settings.json"),
54
+ linux: resolve(home, ".config/zed/settings.json"),
55
+ win32: resolve(home, ".zed/settings.json"),
56
+ };
57
+ const zedPath = zedPaths[os];
58
+ if (zedPath) {
59
+ clients.push({ name: "Zed", configPath: zedPath, format: "context_servers" });
60
+ }
61
+ clients.push({
62
+ name: "Antigravity",
63
+ configPath: resolve(home, ".gemini/antigravity/mcp_config.json"),
64
+ format: "mcpServers",
65
+ });
66
+ return clients;
67
+ }
68
+ async function readJsonFile(path) {
69
+ if (!existsSync(path))
70
+ return {};
71
+ const raw = await readFile(path, "utf-8");
72
+ return JSON.parse(raw);
73
+ }
74
+ async function writeJsonFile(path, data) {
75
+ const dir = dirname(path);
76
+ if (!existsSync(dir)) {
77
+ await mkdir(dir, { recursive: true });
78
+ }
79
+ await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
80
+ }
81
+ /**
82
+ * Windows 에선 npx → npx.cmd 를 거치며 Claude Desktop 이 .cmd 를 못 찾는 이슈가 있어
83
+ * `cmd /c` 래핑이 필요하다. README 에도 동일 가이드 있음.
84
+ */
85
+ function buildServerEntry(apiKey) {
86
+ const env = {};
87
+ if (apiKey)
88
+ env.DART_API_KEY = apiKey;
89
+ if (platform() === "win32") {
90
+ return {
91
+ command: "cmd",
92
+ args: ["/c", "npx", "-y", "korean-dart-mcp"],
93
+ env,
94
+ };
95
+ }
96
+ return {
97
+ command: "npx",
98
+ args: ["-y", "korean-dart-mcp"],
99
+ env,
100
+ };
101
+ }
102
+ function buildZedEntry(apiKey) {
103
+ const env = {};
104
+ if (apiKey)
105
+ env.DART_API_KEY = apiKey;
106
+ const base = platform() === "win32"
107
+ ? { path: "cmd", args: ["/c", "npx", "-y", "korean-dart-mcp"], env }
108
+ : { path: "npx", args: ["-y", "korean-dart-mcp"], env };
109
+ return { command: base };
110
+ }
111
+ // ─── ANSI ─────────────────────────────────────────────────────────────
112
+ const ESC = "\x1b[";
113
+ const c = {
114
+ reset: `${ESC}0m`,
115
+ bold: `${ESC}1m`,
116
+ dim: `${ESC}2m`,
117
+ cyan: `${ESC}36m`,
118
+ green: `${ESC}32m`,
119
+ yellow: `${ESC}33m`,
120
+ red: `${ESC}31m`,
121
+ white: `${ESC}37m`,
122
+ };
123
+ function rgb(r, g, b) {
124
+ return `${ESC}38;2;${r};${g};${b}m`;
125
+ }
126
+ function sleep(ms) {
127
+ return new Promise((res) => setTimeout(res, ms));
128
+ }
129
+ async function typewrite(text, delay = 15) {
130
+ for (const ch of text) {
131
+ process.stdout.write(ch);
132
+ await sleep(delay);
133
+ }
134
+ console.log();
135
+ }
136
+ async function printBanner() {
137
+ const gradients = [
138
+ rgb(0, 220, 180), rgb(0, 200, 200), rgb(0, 180, 220),
139
+ rgb(30, 150, 240), rgb(60, 120, 250), rgb(100, 100, 255),
140
+ ];
141
+ const logo = [
142
+ " _ __ ____ _ ",
143
+ " | |/ /___ _ __ ___ __ _ _ __ | _ \\ __ _ _ __| |_ ",
144
+ " | ' // _ \\| '__/ _ \\/ _` | '_ \\ | | | |/ _` | '__| __|",
145
+ " | . \\ (_) | | | __/ (_| | | | | | |_| | (_| | | | |_ ",
146
+ " |_|\\_\\___/|_| \\___|\\__,_|_| |_| |____/ \\__,_|_| \\__|",
147
+ ];
148
+ console.log();
149
+ for (let i = 0; i < logo.length; i++) {
150
+ const color = gradients[i % gradients.length];
151
+ console.log(`${color}${c.bold}${logo[i]}${c.reset}`);
152
+ await sleep(60);
153
+ }
154
+ console.log();
155
+ await typewrite(`${c.dim} MCP Server ━━ OpenDART 83개 API → 15개 도구${c.reset}`, 12);
156
+ console.log();
157
+ console.log(`${c.cyan} ${"━".repeat(52)}${c.reset}`);
158
+ console.log();
159
+ }
160
+ function stepHeader(step, total, title) {
161
+ const dots = `${c.dim}${"·".repeat(Math.max(0, 40 - title.length))}${c.reset}`;
162
+ console.log(` ${c.cyan}${c.bold}[${step}/${total}]${c.reset} ${c.white}${c.bold}${title}${c.reset} ${dots}`);
163
+ console.log();
164
+ }
165
+ function successLine(label, detail) {
166
+ console.log(` ${c.green}${c.bold}+${c.reset} ${c.white}${label}${c.reset}${c.dim} ${detail}${c.reset}`);
167
+ }
168
+ function failLine(label, detail) {
169
+ console.log(` ${c.red}${c.bold}x${c.reset} ${c.white}${label}${c.reset}${c.dim} ${detail}${c.reset}`);
170
+ }
171
+ async function printComplete(apiKey) {
172
+ console.log();
173
+ const box = [
174
+ ` ${c.green}${c.bold}╔${"═".repeat(50)}╗${c.reset}`,
175
+ ` ${c.green}${c.bold}║${c.reset}${" ".repeat(14)}${c.green}${c.bold}Setup Complete!${c.reset}${" ".repeat(22)}${c.green}${c.bold}║${c.reset}`,
176
+ ` ${c.green}${c.bold}╚${"═".repeat(50)}╝${c.reset}`,
177
+ ];
178
+ for (const line of box) {
179
+ console.log(line);
180
+ await sleep(40);
181
+ }
182
+ console.log();
183
+ if (!apiKey) {
184
+ console.log(` ${c.yellow}!${c.reset} API 키 미설정 — 환경변수 ${c.bold}DART_API_KEY${c.reset} 또는 설정 파일의 ${c.bold}env.DART_API_KEY${c.reset} 수정`);
185
+ console.log();
186
+ }
187
+ console.log(` ${c.dim}클라이언트를 재시작하면 'korean-dart' MCP 서버가 활성화됩니다.${c.reset}`);
188
+ console.log();
189
+ }
190
+ export async function runSetup() {
191
+ const rl = createInterface({ input: stdin, output: stdout });
192
+ try {
193
+ await printBanner();
194
+ stepHeader(1, 3, "OpenDART 인증키");
195
+ console.log(` ${c.dim}발급: https://opendart.fss.or.kr/ (가입 → 인증키 신청, 무료/일 20,000건)${c.reset}`);
196
+ console.log(` ${c.dim}Enter로 건너뛰기 — 나중에 환경변수로 설정 가능${c.reset}`);
197
+ console.log();
198
+ const apiKey = (await rl.question(` ${c.cyan}>${c.reset} API 키: `)).trim();
199
+ if (apiKey) {
200
+ console.log(` ${c.green}+${c.reset} 키 등록됨`);
201
+ }
202
+ else {
203
+ console.log(` ${c.yellow}-${c.reset} 건너뜀`);
204
+ }
205
+ console.log();
206
+ stepHeader(2, 3, "MCP 클라이언트 선택");
207
+ const clients = detectClients();
208
+ clients.forEach((cl, i) => {
209
+ const exists = existsSync(cl.configPath);
210
+ const badge = exists ? `${c.green} [감지됨]${c.reset}` : "";
211
+ const num = `${c.cyan}${String(i + 1).padStart(2)}${c.reset}`;
212
+ console.log(` ${num}) ${c.white}${cl.name}${c.reset}${badge}`);
213
+ });
214
+ console.log();
215
+ const clientInput = (await rl.question(` ${c.cyan}>${c.reset} 번호 (예: 1,3): `)).trim();
216
+ if (!clientInput) {
217
+ console.log(`\n ${c.yellow}선택 없음${c.reset} — 수동 설정 안내:`);
218
+ printManualConfig(apiKey);
219
+ return;
220
+ }
221
+ const indices = clientInput
222
+ .split(",")
223
+ .map((s) => parseInt(s.trim(), 10) - 1)
224
+ .filter((i) => i >= 0 && i < clients.length);
225
+ if (indices.length === 0) {
226
+ console.log(`\n ${c.yellow}유효한 선택 없음${c.reset} — 수동 설정 안내:`);
227
+ printManualConfig(apiKey);
228
+ return;
229
+ }
230
+ console.log();
231
+ stepHeader(3, 3, "설정 파일 업데이트");
232
+ const entry = buildServerEntry(apiKey);
233
+ for (const idx of indices) {
234
+ const client = clients[idx];
235
+ await sleep(150);
236
+ try {
237
+ const config = await readJsonFile(client.configPath);
238
+ const key = client.format;
239
+ const serverEntry = key === "context_servers" ? buildZedEntry(apiKey) : entry;
240
+ const servers = (config[key] ?? {});
241
+ servers["korean-dart"] = serverEntry;
242
+ config[key] = servers;
243
+ await writeJsonFile(client.configPath, config);
244
+ successLine(client.name, client.configPath);
245
+ }
246
+ catch (err) {
247
+ const msg = err instanceof Error ? err.message : String(err);
248
+ failLine(client.name, msg);
249
+ }
250
+ }
251
+ await printComplete(apiKey);
252
+ }
253
+ finally {
254
+ rl.close();
255
+ }
256
+ }
257
+ function printManualConfig(apiKey) {
258
+ const entry = buildServerEntry(apiKey);
259
+ console.log();
260
+ console.log(` ${c.dim}아래 JSON을 설정 파일의 mcpServers에 추가하세요:${c.reset}`);
261
+ console.log();
262
+ console.log(` ${c.cyan}"korean-dart"${c.reset}: ${JSON.stringify(entry, null, 4)}`);
263
+ console.log();
264
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 툴 공용 유틸
3
+ * - defineTool: zod 스키마를 JSON Schema 로 자동 변환하며 핸들러에 파싱된 입력을 전달
4
+ * - resolveCorp: 회사명·종목코드·corp_code 중 무엇이 와도 단일 CorpRecord 로 해석
5
+ * - normalizeDate: YYYY-MM-DD / YYYYMMDD / YYYY.MM.DD → YYYYMMDD
6
+ */
7
+ import { z } from "zod";
8
+ import type { CorpCodeResolver, CorpRecord } from "../lib/corp-code.js";
9
+ import type { DartClient } from "../lib/dart-client.js";
10
+ export interface ToolCtx {
11
+ client: DartClient;
12
+ resolver: CorpCodeResolver;
13
+ }
14
+ export interface ToolDef {
15
+ name: string;
16
+ description: string;
17
+ inputSchema: Record<string, unknown>;
18
+ handler: (args: unknown, ctx: ToolCtx) => Promise<unknown>;
19
+ }
20
+ export declare function defineTool<S extends z.ZodType>(spec: {
21
+ name: string;
22
+ description: string;
23
+ input: S;
24
+ handler: (ctx: ToolCtx, args: z.infer<S>) => Promise<unknown>;
25
+ }): ToolDef;
26
+ export declare function resolveCorp(resolver: CorpCodeResolver, input: string): CorpRecord;
27
+ /** OpenDART 는 YYYYMMDD 포맷만 허용. */
28
+ export declare function normalizeDate(input: string): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 툴 공용 유틸
3
+ * - defineTool: zod 스키마를 JSON Schema 로 자동 변환하며 핸들러에 파싱된 입력을 전달
4
+ * - resolveCorp: 회사명·종목코드·corp_code 중 무엇이 와도 단일 CorpRecord 로 해석
5
+ * - normalizeDate: YYYY-MM-DD / YYYYMMDD / YYYY.MM.DD → YYYYMMDD
6
+ */
7
+ import { z } from "zod";
8
+ export function defineTool(spec) {
9
+ return {
10
+ name: spec.name,
11
+ description: spec.description,
12
+ // io:"input" → default() 가 있는 필드는 required 에서 제외되어 LLM 가 빈 인자로도 호출 가능
13
+ inputSchema: z.toJSONSchema(spec.input, { io: "input" }),
14
+ handler: async (args, ctx) => {
15
+ const parsed = spec.input.parse(args ?? {});
16
+ return spec.handler(ctx, parsed);
17
+ },
18
+ };
19
+ }
20
+ export function resolveCorp(resolver, input) {
21
+ const record = resolver.resolve(input);
22
+ if (!record) {
23
+ throw new Error(`회사를 찾을 수 없습니다: "${input}". ` +
24
+ `resolve_corp_code 로 먼저 정확한 이름을 확인하세요.`);
25
+ }
26
+ return record;
27
+ }
28
+ /** OpenDART 는 YYYYMMDD 포맷만 허용. */
29
+ export function normalizeDate(input) {
30
+ const digits = input.replace(/\D/g, "");
31
+ if (!/^\d{8}$/.test(digits)) {
32
+ throw new Error(`날짜 형식 오류: "${input}" (YYYY-MM-DD 또는 YYYYMMDD)`);
33
+ }
34
+ return digits;
35
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * buffett_quality_snapshot — 버핏·그레이엄 식 퀄리티 체크리스트
3
+ *
4
+ * 기업 1개 → N년 시계열 + 체크리스트 + CAGR
5
+ * 기업 2+ → 기업별 스냅샷 + 지표별 랭킹 (기존 quality_compare 통합)
6
+ *
7
+ * DART 주요계정(fnlttSinglAcnt) 를 base year 3년 간격으로 호출해 요율 절약.
8
+ * 여러 기업은 기업별 병렬 실행.
9
+ */
10
+ export declare const buffettQualitySnapshotTool: import("./_helpers.js").ToolDef;
@@ -0,0 +1,261 @@
1
+ /**
2
+ * buffett_quality_snapshot — 버핏·그레이엄 식 퀄리티 체크리스트
3
+ *
4
+ * 기업 1개 → N년 시계열 + 체크리스트 + CAGR
5
+ * 기업 2+ → 기업별 스냅샷 + 지표별 랭킹 (기존 quality_compare 통합)
6
+ *
7
+ * DART 주요계정(fnlttSinglAcnt) 를 base year 3년 간격으로 호출해 요율 절약.
8
+ * 여러 기업은 기업별 병렬 실행.
9
+ */
10
+ import { z } from "zod";
11
+ import { defineTool, resolveCorp } from "./_helpers.js";
12
+ const Input = z.object({
13
+ corps: z
14
+ .array(z.string().min(1))
15
+ .min(1)
16
+ .max(10)
17
+ .describe("회사 1~10개. 1개면 시계열+체크리스트, 2+면 비교+랭킹 추가"),
18
+ years: z.number().int().min(3).max(15).default(10).describe("과거 N년 (기본 10)"),
19
+ end_year: z.number().int().min(2016).optional().describe("기준연도 (미지정=작년)"),
20
+ prefer_consolidated: z.boolean().default(true).describe("연결재무제표 우선"),
21
+ });
22
+ function parseAmount(v) {
23
+ if (!v)
24
+ return null;
25
+ const cleaned = v.replace(/[,\s]/g, "");
26
+ if (cleaned === "" || cleaned === "-")
27
+ return null;
28
+ const n = Number(cleaned);
29
+ return Number.isFinite(n) ? n : null;
30
+ }
31
+ const ACCOUNT_MATCHERS = {
32
+ revenue: [/^(매출액|영업수익|수익\(매출액\))$/, /^수익$/, /매출/],
33
+ operating_income: [/^영업이익(\([손실]*\))?$/, /영업이익/],
34
+ net_income: [/^당기순이익(\([손실]*\))?$/, /당기순이익/],
35
+ assets: [/^자산총계$/],
36
+ liabilities: [/^부채총계$/],
37
+ equity: [/^자본총계$/],
38
+ };
39
+ function pickAccount(items, key, period) {
40
+ for (const pat of ACCOUNT_MATCHERS[key]) {
41
+ const hit = items.find((it) => pat.test(it.account_nm ?? ""));
42
+ if (hit) {
43
+ const field = `${period}_amount`;
44
+ return parseAmount(hit[field]);
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+ function computeRatios(m) {
50
+ if (m.net_income != null && m.equity && m.equity !== 0) {
51
+ m.roe_pct = Number(((m.net_income / m.equity) * 100).toFixed(2));
52
+ }
53
+ if (m.operating_income != null && m.revenue && m.revenue !== 0) {
54
+ m.op_margin_pct = Number(((m.operating_income / m.revenue) * 100).toFixed(2));
55
+ }
56
+ if (m.liabilities != null && m.equity && m.equity !== 0) {
57
+ m.debt_to_equity_pct = Number(((m.liabilities / m.equity) * 100).toFixed(2));
58
+ }
59
+ return m;
60
+ }
61
+ function filterFsDiv(items, preferConsolidated) {
62
+ const primary = preferConsolidated ? "CFS" : "OFS";
63
+ const secondary = preferConsolidated ? "OFS" : "CFS";
64
+ const hitPrimary = items.filter((i) => i.fs_div === primary);
65
+ if (hitPrimary.length)
66
+ return hitPrimary;
67
+ const hitSecondary = items.filter((i) => i.fs_div === secondary);
68
+ if (hitSecondary.length)
69
+ return hitSecondary;
70
+ return items;
71
+ }
72
+ function cagr(first, last, periods) {
73
+ if (first == null || last == null || first <= 0 || last <= 0 || periods < 1)
74
+ return null;
75
+ return Number(((Math.pow(last / first, 1 / periods) - 1) * 100).toFixed(2));
76
+ }
77
+ function stddev(nums) {
78
+ if (nums.length < 2)
79
+ return 0;
80
+ const mean = nums.reduce((a, b) => a + b, 0) / nums.length;
81
+ const variance = nums.reduce((s, n) => s + (n - mean) ** 2, 0) / nums.length;
82
+ return Math.sqrt(variance);
83
+ }
84
+ async function computeSnapshot(ctx, corp, years, endYearArg, preferConsolidated) {
85
+ const record = resolveCorp(ctx.resolver, corp);
86
+ const endYear = endYearArg ?? new Date().getFullYear() - 1;
87
+ const startYear = endYear - years + 1;
88
+ const baseYears = [];
89
+ for (let y = endYear; y >= startYear; y -= 3)
90
+ baseYears.push(y);
91
+ const responses = await Promise.all(baseYears.map(async (year) => {
92
+ try {
93
+ const raw = await ctx.client.getJson("fnlttSinglAcnt.json", {
94
+ corp_code: record.corp_code,
95
+ bsns_year: String(year),
96
+ reprt_code: "11011",
97
+ });
98
+ return { base_year: year, items: raw.status === "000" ? raw.list ?? [] : [] };
99
+ }
100
+ catch {
101
+ return { base_year: year, items: [] };
102
+ }
103
+ }));
104
+ const byYear = new Map();
105
+ for (const r of responses.sort((a, b) => a.base_year - b.base_year)) {
106
+ if (!r.items.length)
107
+ continue;
108
+ const filtered = filterFsDiv(r.items, preferConsolidated);
109
+ const periods = [
110
+ ["thstrm", r.base_year],
111
+ ["frmtrm", r.base_year - 1],
112
+ ["bfefrmtrm", r.base_year - 2],
113
+ ];
114
+ for (const [period, y] of periods) {
115
+ if (y < startYear || y > endYear)
116
+ continue;
117
+ const m = {
118
+ year: y,
119
+ source_base_year: r.base_year,
120
+ source_period: period,
121
+ revenue: pickAccount(filtered, "revenue", period),
122
+ operating_income: pickAccount(filtered, "operating_income", period),
123
+ net_income: pickAccount(filtered, "net_income", period),
124
+ assets: pickAccount(filtered, "assets", period),
125
+ liabilities: pickAccount(filtered, "liabilities", period),
126
+ equity: pickAccount(filtered, "equity", period),
127
+ roe_pct: null,
128
+ op_margin_pct: null,
129
+ debt_to_equity_pct: null,
130
+ };
131
+ computeRatios(m);
132
+ byYear.set(y, m);
133
+ }
134
+ }
135
+ const series = Array.from(byYear.values()).sort((a, b) => a.year - b.year);
136
+ const roes = series.map((s) => s.roe_pct).filter((v) => v != null);
137
+ const debtRatios = series
138
+ .map((s) => s.debt_to_equity_pct)
139
+ .filter((v) => v != null);
140
+ const first = series[0];
141
+ const last = series[series.length - 1];
142
+ const periodsLen = series.length > 1 ? last.year - first.year : 0;
143
+ const revenue_cagr = first && last ? cagr(first.revenue, last.revenue, periodsLen) : null;
144
+ const net_income_cagr = first && last ? cagr(first.net_income, last.net_income, periodsLen) : null;
145
+ const avg_roe = roes.length ? Number((roes.reduce((a, b) => a + b, 0) / roes.length).toFixed(2)) : null;
146
+ const latest_debt = last?.debt_to_equity_pct ?? null;
147
+ const checklist = {
148
+ consistent_high_roe: {
149
+ pass: roes.length >= 3 && roes.every((r) => r >= 15),
150
+ rule: "모든 연도 ROE ≥ 15%",
151
+ evidence: { roes, years_observed: roes.length },
152
+ },
153
+ low_debt: {
154
+ pass: latest_debt != null && latest_debt <= 100,
155
+ rule: "최근 부채비율 ≤ 100%",
156
+ evidence: { latest_debt_to_equity_pct: latest_debt },
157
+ },
158
+ growing_revenue: {
159
+ pass: revenue_cagr != null && revenue_cagr >= 5,
160
+ rule: "매출 CAGR ≥ 5%",
161
+ evidence: { revenue_cagr_pct: revenue_cagr, periods: periodsLen },
162
+ },
163
+ growing_earnings: {
164
+ pass: net_income_cagr != null && net_income_cagr >= 5,
165
+ rule: "순이익 CAGR ≥ 5%",
166
+ evidence: { net_income_cagr_pct: net_income_cagr, periods: periodsLen },
167
+ },
168
+ };
169
+ const passed = Object.values(checklist).filter((c) => c.pass).length;
170
+ return {
171
+ resolved: record,
172
+ window: { start_year: startYear, end_year: endYear, years },
173
+ fs_preference: preferConsolidated ? "CFS>OFS" : "OFS>CFS",
174
+ api_calls: baseYears.length,
175
+ series,
176
+ ratios: {
177
+ avg_roe_pct: avg_roe,
178
+ min_roe_pct: roes.length ? Math.min(...roes) : null,
179
+ max_roe_pct: roes.length ? Math.max(...roes) : null,
180
+ roe_stddev: Number(stddev(roes).toFixed(2)),
181
+ latest_debt_to_equity_pct: latest_debt,
182
+ avg_debt_to_equity_pct: debtRatios.length
183
+ ? Number((debtRatios.reduce((a, b) => a + b, 0) / debtRatios.length).toFixed(2))
184
+ : null,
185
+ revenue_cagr_pct: revenue_cagr,
186
+ net_income_cagr_pct: net_income_cagr,
187
+ },
188
+ checklist,
189
+ overall_score: `${passed}/4`,
190
+ };
191
+ }
192
+ function rankBy(rows, key, dir) {
193
+ const withValue = rows
194
+ .map((r) => ({ r, v: r[key] }))
195
+ .filter((x) => typeof x.v === "number" && Number.isFinite(x.v));
196
+ withValue.sort((a, b) => (dir === "desc" ? b.v - a.v : a.v - b.v));
197
+ return withValue.map((x) => `${x.r.corp_name}(${x.v})`);
198
+ }
199
+ export const buffettQualitySnapshotTool = defineTool({
200
+ name: "buffett_quality_snapshot",
201
+ description: "버핏 퀄리티 체크리스트. corps 1개 → N년 ROE/부채/CAGR 시계열 + 체크 4종. " +
202
+ "corps 2~10개 → 각 기업 스냅샷 + 5지표별 랭킹 (기존 quality_compare 통합). 기업별 병렬 실행.",
203
+ input: Input,
204
+ handler: async (ctx, args) => {
205
+ const snapshots = await Promise.all(args.corps.map(async (corp) => {
206
+ try {
207
+ const snap = await computeSnapshot(ctx, corp, args.years, args.end_year, args.prefer_consolidated);
208
+ return { corp_input: corp, snap, error: null };
209
+ }
210
+ catch (e) {
211
+ return {
212
+ corp_input: corp,
213
+ snap: null,
214
+ error: e instanceof Error ? e.message : String(e),
215
+ };
216
+ }
217
+ }));
218
+ if (args.corps.length === 1) {
219
+ const r = snapshots[0];
220
+ if (!r.snap)
221
+ throw new Error(`snapshot 실패 [${r.corp_input}]: ${r.error}`);
222
+ return { mode: "single", ...r.snap };
223
+ }
224
+ const successful = snapshots.filter((s) => s.snap !== null);
225
+ const rows = successful.map((s) => ({
226
+ corp_name: s.snap.resolved.corp_name,
227
+ corp_code: s.snap.resolved.corp_code,
228
+ window: s.snap.window,
229
+ avg_roe_pct: s.snap.ratios.avg_roe_pct,
230
+ roe_stddev: s.snap.ratios.roe_stddev,
231
+ latest_debt_to_equity_pct: s.snap.ratios.latest_debt_to_equity_pct,
232
+ revenue_cagr_pct: s.snap.ratios.revenue_cagr_pct,
233
+ net_income_cagr_pct: s.snap.ratios.net_income_cagr_pct,
234
+ overall_score: s.snap.overall_score,
235
+ checklist_pass: Object.entries(s.snap.checklist)
236
+ .filter(([, v]) => v.pass)
237
+ .map(([k]) => k),
238
+ }));
239
+ const errors = snapshots.filter((s) => s.error).map((s) => ({
240
+ corp_input: s.corp_input,
241
+ error: s.error,
242
+ }));
243
+ return {
244
+ mode: "compare",
245
+ inputs: args.corps,
246
+ years: args.years,
247
+ end_year: args.end_year ?? new Date().getFullYear() - 1,
248
+ rows,
249
+ rankings: {
250
+ by_avg_roe_desc: rankBy(rows, "avg_roe_pct", "desc"),
251
+ by_debt_ratio_asc: rankBy(rows, "latest_debt_to_equity_pct", "asc"),
252
+ by_revenue_cagr_desc: rankBy(rows, "revenue_cagr_pct", "desc"),
253
+ by_net_income_cagr_desc: rankBy(rows, "net_income_cagr_pct", "desc"),
254
+ by_roe_stability_asc: rankBy(rows, "roe_stddev", "asc"),
255
+ },
256
+ individuals: successful.map((s) => ({ corp_input: s.corp_input, snapshot: s.snap })),
257
+ errors,
258
+ note: "체크리스트는 휴리스틱. 업종·경기 고려 필수. insider_signal·disclosure_anomaly 로 질적 시그널 보완.",
259
+ };
260
+ },
261
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * disclosure_anomaly — 공시·회계 이상 징후 탐지 (킬러 포인트)
3
+ *
4
+ * 공시 이력과 감사인·감사의견 이력을 교차해 회계·거버넌스 위험 신호를 점수화.
5
+ *
6
+ * 시그널:
7
+ * 1. 정정공시 비율 — report_nm 에 "[기재정정]"/"[첨부정정]" 포함 비율
8
+ * 2. 감사인 교체 — 지정 연도 범위의 감사인 이름이 바뀌면 +
9
+ * 3. 감사의견 비적정 — "적정" 이외 의견(한정/부적정/의견거절)
10
+ * 4. 자본 스트레스 — 유상증자·CB·자사주 처분 공시 빈도
11
+ *
12
+ * 점수 0-100. 해석은 LLM 에게 위임하되, 핵심 flag 와 evidence 를 구조화해서 제공.
13
+ */
14
+ export declare const disclosureAnomalyTool: import("./_helpers.js").ToolDef;