@nomadamas/k-skill 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/bin/k-skill.js +64 -43
  2. package/package.json +2 -2
  3. package/skills/biz-health-check/instruction.md +2 -0
  4. package/skills/company-analysis/instruction.md +68 -0
  5. package/skills/company-analysis/references/source-contract.md +75 -0
  6. package/skills/company-analysis/scripts/dart-rows.js +52 -0
  7. package/skills/company-analysis/scripts/dart-rows.ts +48 -0
  8. package/skills/company-analysis/scripts/evidence.js +567 -0
  9. package/skills/company-analysis/scripts/evidence.ts +520 -0
  10. package/skills/company-analysis/scripts/fetch-public.js +85 -0
  11. package/skills/company-analysis/scripts/fetch-public.ts +75 -0
  12. package/skills/company-analysis/skill.json +6 -0
  13. package/skills/express-bus-booking/scripts/kobus_express_booking.py +46 -18
  14. package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +53 -3
  15. package/skills/g2b-order-plan-search/instruction.md +17 -0
  16. package/skills/g2b-sanctioned-supplier/instruction.md +10 -1
  17. package/skills/g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py +8 -4
  18. package/skills/g2b-sanctioned-supplier/scripts/test_g2b_sanctioned_supplier.py +19 -0
  19. package/skills/government-bond-analysis/instruction.md +216 -0
  20. package/skills/government-bond-analysis/references/methodology.md +63 -0
  21. package/skills/government-bond-analysis/scripts/cli.mjs +268 -0
  22. package/skills/government-bond-analysis/scripts/collect.mjs +171 -0
  23. package/skills/government-bond-analysis/scripts/core.mjs +224 -0
  24. package/skills/government-bond-analysis/scripts/dual.mjs +178 -0
  25. package/skills/government-bond-analysis/scripts/harness.mjs +406 -0
  26. package/skills/government-bond-analysis/scripts/modes.mjs +136 -0
  27. package/skills/government-bond-analysis/scripts/news.mjs +113 -0
  28. package/skills/government-bond-analysis/scripts/render.mjs +120 -0
  29. package/skills/government-bond-analysis/scripts/schedule.mjs +96 -0
  30. package/skills/government-bond-analysis/scripts/ust.mjs +204 -0
  31. package/skills/government-bond-analysis/skill.json +8 -0
  32. package/skills/korean-bond-search/instruction.md +122 -0
  33. package/skills/korean-bond-search/references/fields.md +48 -0
  34. package/skills/korean-bond-search/references/source-notes.md +11 -0
  35. package/skills/korean-bond-search/scripts/bond_search.py +906 -0
  36. package/skills/korean-bond-search/skill.json +9 -0
  37. package/skills/market-event-impact/instruction.md +150 -0
  38. package/skills/market-event-impact/references/event-taxonomy.md +74 -0
  39. package/skills/market-event-impact/references/methodology.md +98 -0
  40. package/skills/market-event-impact/references/research-contract.md +120 -0
  41. package/skills/market-event-impact/references/source-priority.md +25 -0
  42. package/skills/market-event-impact/scripts/adapters.py +32 -0
  43. package/skills/market-event-impact/scripts/asset_selector.py +120 -0
  44. package/skills/market-event-impact/scripts/calendars.py +179 -0
  45. package/skills/market-event-impact/scripts/event_resolver.py +260 -0
  46. package/skills/market-event-impact/scripts/event_study.py +171 -0
  47. package/skills/market-event-impact/scripts/market_data.py +204 -0
  48. package/skills/market-event-impact/scripts/market_event.py +1065 -0
  49. package/skills/market-event-impact/scripts/research.py +389 -0
  50. package/skills/market-event-impact/skill.json +9 -0
  51. package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
  52. package/src/analytics-policy.json +18 -0
  53. package/src/analytics.js +106 -0
  54. package/src/metrics.js +49 -0
@@ -0,0 +1,906 @@
1
+ #!/usr/bin/env python3
2
+ """Normalize and search user-exported SEIBro bond data using stdlib only."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import io
9
+ import json
10
+ import math
11
+ import os
12
+ import re
13
+ import sys
14
+ from decimal import Decimal, InvalidOperation
15
+ from datetime import date, datetime, timezone
16
+ from html.parser import HTMLParser
17
+ from pathlib import Path
18
+ from typing import Any, Iterable
19
+ from urllib.parse import urlsplit
20
+
21
+ SEIBRO_URL = "https://seibro.or.kr/websquare/control.jsp?w2xPath=/IPORTAL/user/bond/BIP_CNTS02001V.xml&menuNo=285"
22
+ OUTPUT_FORMAT = "chat"
23
+
24
+ if hasattr(sys.stdout, "reconfigure"):
25
+ sys.stdout.reconfigure(encoding="utf-8")
26
+ sys.stderr.reconfigure(encoding="utf-8")
27
+
28
+
29
+ class InputError(Exception):
30
+ def __init__(self, code: str, message: str):
31
+ super().__init__(message)
32
+ self.code = code
33
+
34
+
35
+ class JsonArgumentParser(argparse.ArgumentParser):
36
+ def error(self, message: str) -> None:
37
+ payload = {"schema_version": "1.0", "result": "input_failure", "message": message}
38
+ if "--format" in sys.argv and "json" in sys.argv:
39
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
40
+ else:
41
+ print(f"조회 조건을 확인해 주세요.\n\n- {message}")
42
+ raise SystemExit(2)
43
+
44
+
45
+ class TableParser(HTMLParser):
46
+ def __init__(self) -> None:
47
+ super().__init__()
48
+ self.tables: list[list[list[str]]] = []
49
+ self._table: list[list[str]] | None = None
50
+ self._row: list[str] | None = None
51
+ self._cell: list[str] | None = None
52
+
53
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
54
+ if tag.lower() == "table":
55
+ self._table = []
56
+ elif tag.lower() == "tr" and self._table is not None:
57
+ self._row = []
58
+ elif tag.lower() in {"th", "td"} and self._row is not None:
59
+ self._cell = []
60
+
61
+ def handle_data(self, data: str) -> None:
62
+ if self._cell is not None:
63
+ self._cell.append(data)
64
+
65
+ def handle_endtag(self, tag: str) -> None:
66
+ if tag.lower() in {"th", "td"} and self._cell is not None and self._row is not None:
67
+ self._row.append(" ".join("".join(self._cell).split()))
68
+ self._cell = None
69
+ elif tag.lower() == "tr" and self._row is not None and self._table is not None:
70
+ if any(self._row):
71
+ self._table.append(self._row)
72
+ self._row = None
73
+ elif tag.lower() == "table" and self._table is not None:
74
+ if self._table:
75
+ self.tables.append(self._table)
76
+ self._table = None
77
+
78
+
79
+ ALIASES: dict[str, tuple[str, ...]] = {
80
+ "isin": ("isin", "종목코드", "표준코드"),
81
+ "name": ("종목명", "채권명", "name"),
82
+ "issuer": ("발행자", "발행사", "발행기관", "issuer"),
83
+ "bond_type": ("채권분류", "채권유형", "bondtype"),
84
+ "currency": ("발행통화", "통화", "currency"),
85
+ "issue_date": ("발행일", "issuedate"),
86
+ "maturity_date": ("만기일", "상환일", "maturitydate"),
87
+ "coupon_rate_pct": ("표면금리", "이율", "쿠폰금리", "couponrate"),
88
+ "coupon_type": ("이자지급방식", "금리유형", "coupon_type"),
89
+ "issue_amount_krw": ("발행금액", "발행액", "issueamount"),
90
+ "outstanding_amount_krw": ("발행잔액", "잔액", "outstandingamount"),
91
+ "offering_type": ("공모/사모", "공모사모", "모집방법", "offeringtype"),
92
+ "seniority": ("선후순위", "선후순위 구분", "변제순위", "seniority"),
93
+ "guarantee": ("보증/무보증", "보증무보증", "보증구분", "guarantee"),
94
+ "listed": ("상장여부", "상장일", "listed"),
95
+ "option_schedule": ("옵션일정", "call/put일정", "callput일정", "optionschedule"),
96
+ "option": ("옵션", "option"),
97
+ "equity_linked": ("주식관련여부", "주식관련", "equitylinked"),
98
+ }
99
+
100
+ BOND_TYPE_NAMES = {"corporate": "회사채", "financial": "금융채", "government": "국채", "municipal": "지방채", "agency": "특수채", "abs": "ABS"}
101
+
102
+
103
+ def key_text(value: Any) -> str:
104
+ return re.sub(r"[\s_()\[\]{}·:/%-]+", "", str(value or "")).casefold()
105
+
106
+
107
+ def entry_for(row: dict[str, Any], canonical: str) -> tuple[str | None, Any]:
108
+ aliases = tuple(key_text(alias) for alias in ALIASES[canonical])
109
+ matches = [
110
+ (key, value) for key, value in row.items()
111
+ if value not in ("", None)
112
+ and (key_text(key) in aliases or (
113
+ canonical in {"issue_amount_krw", "outstanding_amount_krw"}
114
+ and any(key_text(key).startswith(alias) for alias in aliases)
115
+ ))
116
+ ]
117
+ if not matches:
118
+ return None, None
119
+
120
+ def comparable(key: str, value: Any) -> Any:
121
+ if canonical in {"issue_amount_krw", "outstanding_amount_krw"}:
122
+ parsed = parse_money(value, key)
123
+ elif canonical == "coupon_rate_pct":
124
+ parsed = parse_rate(value)
125
+ elif canonical in {"issue_date", "maturity_date"}:
126
+ parsed = parse_date(value)
127
+ elif canonical == "currency":
128
+ parsed = parse_currency(value)
129
+ elif canonical == "listed":
130
+ parsed = parse_bool(value)
131
+ if parsed is None and parse_date(value) is not None:
132
+ parsed = True
133
+ else:
134
+ parsed = key_text(value)
135
+ return ("parsed", parsed) if parsed is not None else ("raw", key_text(value))
136
+
137
+ if len({comparable(key, value) for key, value in matches}) > 1:
138
+ field_names = {
139
+ "isin": "ISIN", "name": "종목명", "issuer": "발행사",
140
+ "coupon_rate_pct": "표면금리", "issue_date": "발행일", "maturity_date": "만기일",
141
+ "issue_amount_krw": "발행금액", "outstanding_amount_krw": "발행잔액",
142
+ }
143
+ raise InputError("parse_failure", f"원천의 {field_names.get(canonical, canonical)} 별칭 필드에 서로 다른 값이 있습니다")
144
+
145
+ for alias in aliases:
146
+ match = next(((key, value) for key, value in matches if key_text(key) == alias), None)
147
+ if match:
148
+ return match
149
+ return matches[0]
150
+
151
+
152
+ def value_for(row: dict[str, Any], canonical: str) -> Any:
153
+ return entry_for(row, canonical)[1]
154
+
155
+
156
+ def parse_date(value: Any) -> str | None:
157
+ if value in (None, ""):
158
+ return None
159
+ text = str(value).strip()
160
+ for fmt in ("%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d", "%Y%m%d"):
161
+ try:
162
+ return datetime.strptime(text, fmt).date().isoformat()
163
+ except ValueError:
164
+ pass
165
+ return None
166
+
167
+
168
+ def parse_number(value: Any) -> float | None:
169
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
170
+ number = float(value)
171
+ return number if math.isfinite(number) else None
172
+ text = str(value or "").strip()
173
+ if not re.fullmatch(r"[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?", text):
174
+ return None
175
+ try:
176
+ number = float(text.replace(",", ""))
177
+ except ValueError:
178
+ return None
179
+ return number if math.isfinite(number) else None
180
+
181
+
182
+ def parse_rate(value: Any) -> float | None:
183
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
184
+ return parse_number(value)
185
+ text = str(value or "").strip()
186
+ match = re.fullmatch(r"([+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)\s*(%|bps?)?", text, re.I)
187
+ if not match:
188
+ return None
189
+ number = parse_number(match.group(1))
190
+ if number is None:
191
+ return None
192
+ return number / 100.0 if match.group(2) and match.group(2).casefold().startswith("bp") else number
193
+
194
+
195
+ def parse_money(value: Any, header: str | None = None) -> int | None:
196
+ units = (
197
+ ("조원", 1_000_000_000_000), ("조", 1_000_000_000_000),
198
+ ("천억원", 100_000_000_000), ("백억원", 10_000_000_000),
199
+ ("십억원", 1_000_000_000), ("억원", 100_000_000), ("억", 100_000_000),
200
+ ("천만원", 10_000_000), ("백만원", 1_000_000), ("십만원", 100_000),
201
+ ("만원", 10_000), ("만", 10_000), ("천원", 1_000), ("천", 1_000),
202
+ ("백원", 100), ("백", 100), ("십원", 10), ("십", 10), ("원", 1),
203
+ )
204
+
205
+ def decimal_value(text: str) -> Decimal | None:
206
+ cleaned = text.strip().replace(",", "")
207
+ if not re.fullmatch(r"[+-]?(?:\d+)(?:\.\d+)?", cleaned):
208
+ return None
209
+ try:
210
+ result = Decimal(cleaned)
211
+ except InvalidOperation:
212
+ return None
213
+ return result if result.is_finite() else None
214
+
215
+ def exact_won(amount: Decimal) -> int | None:
216
+ if amount < 0 or amount != amount.to_integral_value():
217
+ return None
218
+ return int(amount)
219
+
220
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
221
+ try:
222
+ amount = Decimal(str(value))
223
+ except InvalidOperation:
224
+ return None
225
+ if not amount.is_finite():
226
+ return None
227
+ normalized_header = key_text(header or "")
228
+ multiplier = next((factor for unit, factor in units if key_text(unit) in normalized_header), 1)
229
+ return exact_won(amount * multiplier)
230
+ else:
231
+ raw_text = str(value or "").strip()
232
+
233
+ # Support compound Korean amounts such as "1억 5천만원" without
234
+ # silently taking only the first number from arbitrary text.
235
+ component = re.compile(
236
+ r"\s*([+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)\s*("
237
+ + "|".join(re.escape(unit) for unit, _ in units)
238
+ + r")"
239
+ )
240
+ position = 0
241
+ total = Decimal(0)
242
+ found_unit = False
243
+ while position < len(raw_text):
244
+ match = component.match(raw_text, position)
245
+ if not match:
246
+ break
247
+ amount = decimal_value(match.group(1))
248
+ multiplier = dict(units)[match.group(2)]
249
+ if amount is None:
250
+ return None
251
+ total += amount * multiplier
252
+ position = match.end()
253
+ found_unit = True
254
+ if found_unit:
255
+ return exact_won(total) if not raw_text[position:].strip() else None
256
+
257
+ amount = decimal_value(raw_text)
258
+ if amount is None:
259
+ return None
260
+ normalized_header = key_text(header or "")
261
+ multiplier = next((factor for unit, factor in units if key_text(unit) in normalized_header), 1)
262
+ return exact_won(amount * multiplier)
263
+
264
+
265
+ def parse_bool(value: Any) -> bool | None:
266
+ if isinstance(value, bool):
267
+ return value
268
+ if isinstance(value, str) and value.strip().casefold() in {"n/a", "na", "-"}:
269
+ return None
270
+ text = key_text(value)
271
+ if not text:
272
+ return None
273
+ if text in {"y", "yes", "true", "1", "상장", "있음", "해당"}:
274
+ return True
275
+ if text in {"n", "no", "false", "0", "비상장", "없음", "미해당"}:
276
+ return False
277
+ return None
278
+
279
+
280
+ def parse_currency(value: Any) -> str | None:
281
+ if value in (None, ""):
282
+ return None
283
+ text = str(value).strip().upper()
284
+ return {"원": "KRW", "대한민국원": "KRW", "미국달러": "USD", "달러": "USD"}.get(text, text)
285
+
286
+
287
+ def classify_type(raw_type: Any, joined: str) -> str | None:
288
+ text = key_text(raw_type)
289
+ mapping = (("국채", "government"), ("지방채", "municipal"), ("특수채", "agency"),
290
+ ("금융채", "financial"), ("회사채", "corporate"), ("abs", "abs"))
291
+ for needle, result in mapping:
292
+ if needle in text:
293
+ return result
294
+ if any(token in joined for token in ("전환사채", "교환사채", "신주인수권부사채")):
295
+ return "corporate"
296
+ return str(raw_type).strip() or None if raw_type is not None else None
297
+
298
+
299
+ def option_flag(option_text: str, kind: str) -> bool | None:
300
+ if kind == "call":
301
+ positive = r"(?<![A-Z])CALL(?![A-Z])|콜옵션|매도청구권"
302
+ negative = r"(?:CALL|콜옵션|매도청구권)\s*(?:없음|해당\s*없음|미해당|미포함|없다|없습니다)"
303
+ else:
304
+ positive = r"(?<![A-Z])PUT(?![A-Z])|풋옵션|조기상환청구권|사채권자의\s*상환청구권"
305
+ negative = r"(?:PUT|풋옵션|조기상환청구권)\s*(?:없음|해당\s*없음|미해당|미포함|없다|없습니다)"
306
+ has_negative = re.search(negative, option_text, re.I) is not None
307
+ positive_text = re.sub(negative, " ", option_text, flags=re.I)
308
+ has_positive = re.search(positive, positive_text, re.I) is not None
309
+ generic_none = re.search(
310
+ r"^(?:없음|해당\s*없음|none|no|n/?a)$|옵션\s*(?:해당\s*)?없음",
311
+ option_text.strip(),
312
+ re.I,
313
+ ) is not None
314
+ if (has_positive and has_negative) or (has_positive and generic_none):
315
+ return None
316
+ if has_negative:
317
+ return False
318
+ if has_positive:
319
+ return True
320
+ if generic_none:
321
+ return False
322
+ return None
323
+
324
+
325
+ def equity_flag(joined: str, korean: str, acronym: str) -> bool | None:
326
+ return True if korean in joined or re.search(rf"(?<![A-Z]){acronym}(?![A-Z])", joined, re.I) else None
327
+
328
+
329
+ def reconcile_flag(explicit: bool | None, inferred: bool | None) -> bool | None:
330
+ if explicit is not None and inferred is not None and explicit != inferred:
331
+ return None
332
+ return explicit if explicit is not None else inferred
333
+
334
+
335
+ def normalize_schedule(value: Any) -> list[dict[str, Any]]:
336
+ if isinstance(value, dict):
337
+ return [value]
338
+ if isinstance(value, list):
339
+ return [
340
+ item if isinstance(item, dict) else {"raw": " ".join(str(item).split())}
341
+ for item in value
342
+ if isinstance(item, dict) or (isinstance(item, (str, int, float)) and not isinstance(item, bool))
343
+ ]
344
+ if not value:
345
+ return []
346
+ return [{"raw": " ".join(str(value).split())}]
347
+
348
+
349
+ def normalize(
350
+ row: dict[str, Any],
351
+ observed_at: str,
352
+ source_path: str,
353
+ observed_at_basis: str = "provided",
354
+ source_name: str = "입력 자료",
355
+ source_url: str | None = None,
356
+ ) -> dict[str, Any]:
357
+ normalized_keys = [key_text(key) for key in row]
358
+ if any(not key for key in normalized_keys) or len(set(normalized_keys)) != len(normalized_keys):
359
+ raise InputError("parse_failure", "입력 레코드에 비어 있거나 정규화 후 중복되는 필드명이 있습니다")
360
+ joined = " | ".join(str(v) for v in row.values() if v not in (None, ""))
361
+ option_parts = [str(value_for(row, key) or "").strip() for key in ("option", "option_schedule", "equity_linked")]
362
+ option_text = " | ".join(part for part in option_parts if part)
363
+ combined = f"{joined} | {option_text}"
364
+ listed_raw = value_for(row, "listed")
365
+ listed_value = parse_bool(listed_raw)
366
+ if listed_value is None and parse_date(listed_raw) is not None:
367
+ listed_value = True
368
+ isin_value = value_for(row, "isin")
369
+ try:
370
+ isin = parse_isin(str(isin_value)) if isin_value else None
371
+ except argparse.ArgumentTypeError as exc:
372
+ raise InputError("parse_failure", "입력 레코드에 형식 또는 체크디지트가 유효하지 않은 ISIN이 있습니다") from exc
373
+ issue_date = parse_date(value_for(row, "issue_date"))
374
+ maturity_date = parse_date(value_for(row, "maturity_date"))
375
+ if issue_date and maturity_date and issue_date > maturity_date:
376
+ raise InputError("parse_failure", "발행일이 만기일보다 늦은 레코드가 있습니다. 원천 날짜를 확인해 주세요")
377
+ issue_header, issue_value = entry_for(row, "issue_amount_krw")
378
+ outstanding_header, outstanding_value = entry_for(row, "outstanding_amount_krw")
379
+ currency = parse_currency(value_for(row, "currency"))
380
+ direct_flags = {name: parse_bool(row.get(name)) for name in ("callable", "putable", "convertible", "exchangeable", "warrant_attached")}
381
+ return {
382
+ "instrument_type": "bond",
383
+ "isin": str(isin).strip().upper() if isin else None,
384
+ "name": str(value_for(row, "name")).strip() if value_for(row, "name") else None,
385
+ "issuer": str(value_for(row, "issuer")).strip() if value_for(row, "issuer") else None,
386
+ "bond_type": classify_type(value_for(row, "bond_type"), key_text(combined)),
387
+ "currency": currency,
388
+ "issue_date": issue_date,
389
+ "maturity_date": maturity_date,
390
+ "coupon_rate_pct": parse_rate(value_for(row, "coupon_rate_pct")),
391
+ "coupon_type": str(value_for(row, "coupon_type")).strip() if value_for(row, "coupon_type") else None,
392
+ "coupon_frequency": None,
393
+ # These canonical fields are explicitly KRW. Do not relabel foreign or
394
+ # currency-unknown nominal amounts as won; the untouched values remain
395
+ # available in `raw` for source-aware follow-up.
396
+ "issue_amount_krw": parse_money(issue_value, issue_header) if currency == "KRW" else None,
397
+ "outstanding_amount_krw": parse_money(outstanding_value, outstanding_header) if currency == "KRW" else None,
398
+ "offering_type": str(value_for(row, "offering_type")).strip() if value_for(row, "offering_type") else None,
399
+ "seniority": str(value_for(row, "seniority")).strip() if value_for(row, "seniority") else None,
400
+ "guarantee": str(value_for(row, "guarantee")).strip() if value_for(row, "guarantee") else None,
401
+ "listed": listed_value,
402
+ "callable": reconcile_flag(direct_flags["callable"], option_flag(option_text, "call")),
403
+ "putable": reconcile_flag(direct_flags["putable"], option_flag(option_text, "put")),
404
+ "convertible": reconcile_flag(direct_flags["convertible"], equity_flag(combined, "전환사채", "CB")),
405
+ "exchangeable": reconcile_flag(direct_flags["exchangeable"], equity_flag(combined, "교환사채", "EB")),
406
+ "warrant_attached": reconcile_flag(direct_flags["warrant_attached"], equity_flag(combined, "신주인수권부사채", "BW")),
407
+ "option_schedule": normalize_schedule(value_for(row, "option_schedule")),
408
+ "source": {
409
+ "name": source_name, "url": source_url, "observed_at": observed_at,
410
+ "observed_at_basis": observed_at_basis, "input_file": Path(source_path).name,
411
+ },
412
+ "raw": row,
413
+ }
414
+
415
+
416
+ def decode_bytes(data: bytes) -> str:
417
+ for encoding in ("utf-8-sig", "cp949", "euc-kr"):
418
+ try:
419
+ return data.decode(encoding)
420
+ except UnicodeDecodeError:
421
+ pass
422
+ raise InputError("parse_failure", "입력 인코딩을 UTF-8/CP949/EUC-KR로 해석할 수 없습니다")
423
+
424
+
425
+ def unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
426
+ result = {}
427
+ for key, value in pairs:
428
+ if key in result:
429
+ raise ValueError(f"JSON 객체에 중복 키가 있습니다: {key}")
430
+ result[key] = value
431
+ return result
432
+
433
+
434
+ def reject_json_constant(value: str) -> None:
435
+ raise ValueError(f"JSON에서 허용되지 않는 숫자 상수입니다: {value}")
436
+
437
+
438
+ def finite_json_float(value: str) -> float:
439
+ number = float(value)
440
+ if not math.isfinite(number):
441
+ raise ValueError("JSON 숫자가 표현 가능한 범위를 벗어났습니다")
442
+ return number
443
+
444
+
445
+ def load_rows(path_text: str | None) -> tuple[list[dict[str, Any]], str]:
446
+ selected = path_text or os.environ.get("KSKILL_SEIBRO_EXPORT")
447
+ if not selected:
448
+ raise InputError("source_failure", "SEIBro 내보내기 파일이 필요합니다 (--input 또는 KSKILL_SEIBRO_EXPORT)")
449
+ path = Path(selected).expanduser()
450
+ if not path.is_file():
451
+ raise InputError("source_failure", "입력 파일을 찾을 수 없습니다. --input 경로 또는 KSKILL_SEIBRO_EXPORT 설정을 확인해 주세요")
452
+ try:
453
+ text = decode_bytes(path.read_bytes())
454
+ except PermissionError as exc:
455
+ raise InputError("source_failure", "입력 파일을 읽을 권한이 없습니다") from exc
456
+ except OSError as exc:
457
+ raise InputError("source_failure", "입력 파일을 읽지 못했습니다. 파일 경로와 접근 권한을 확인해 주세요") from exc
458
+ suffix = path.suffix.casefold()
459
+ try:
460
+ if suffix == ".json":
461
+ payload = json.loads(
462
+ text,
463
+ object_pairs_hook=unique_json_object,
464
+ parse_constant=reject_json_constant,
465
+ parse_float=finite_json_float,
466
+ )
467
+ rows = payload.get("items") if isinstance(payload, dict) else payload
468
+ if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
469
+ raise InputError("parse_failure", "JSON은 객체 배열 또는 items 객체 배열이어야 합니다")
470
+ return rows, str(path)
471
+ if suffix in {".csv", ".tsv"}:
472
+ dialect = "excel-tab" if suffix == ".tsv" else "excel"
473
+ reader = csv.DictReader(io.StringIO(text), dialect=dialect)
474
+ if not reader.fieldnames or any(not str(name or "").strip() for name in reader.fieldnames):
475
+ raise InputError("parse_failure", "CSV/TSV 헤더가 비어 있거나 없습니다")
476
+ normalized_headers = [key_text(name) for name in reader.fieldnames]
477
+ if len(set(normalized_headers)) != len(normalized_headers):
478
+ raise InputError("parse_failure", "CSV/TSV에 정규화 후 중복되는 헤더가 있습니다")
479
+ rows = list(reader)
480
+ if any(None in row or any(value is None for value in row.values()) for row in rows):
481
+ raise InputError("parse_failure", "CSV/TSV 행의 열 수가 헤더와 일치하지 않습니다")
482
+ return rows, str(path)
483
+ if suffix in {".html", ".htm"}:
484
+ parser = TableParser()
485
+ parser.feed(text)
486
+ candidates = [table for table in parser.tables if len(table) >= 2]
487
+ selected = next((table for table in candidates if any(key_text(cell) in {key_text(a) for a in ALIASES["isin"] + ALIASES["name"]} for cell in table[0])), None)
488
+ if selected is None:
489
+ raise InputError("parse_failure", "HTML에서 채권 식별 헤더와 데이터 행이 있는 표를 찾지 못했습니다")
490
+ header = selected[0]
491
+ normalized_headers = [key_text(cell) for cell in header]
492
+ if any(not key for key in normalized_headers) or len(set(normalized_headers)) != len(normalized_headers):
493
+ raise InputError("parse_failure", "HTML 표에 비어 있거나 정규화 후 중복되는 헤더가 있습니다")
494
+ data_rows = selected[1:]
495
+ if any(len(row) != len(header) for row in data_rows):
496
+ raise InputError("parse_failure", "HTML 표의 열 수가 일치하지 않습니다")
497
+ rows = [dict(zip(header, row)) for row in data_rows]
498
+ if not rows:
499
+ raise InputError("parse_failure", "HTML 표에 채권 데이터 행이 없습니다")
500
+ return rows, str(path)
501
+ except (csv.Error, ValueError) as exc:
502
+ raise InputError("parse_failure", str(exc)) from exc
503
+ raise InputError("parse_failure", "지원 형식은 .json, .csv, .tsv, .html입니다")
504
+
505
+
506
+ def matches(record: dict[str, Any], args: argparse.Namespace) -> bool:
507
+ def contains(field: str, query: str | None) -> bool:
508
+ return not query or key_text(query) in key_text(record.get(field))
509
+ if not contains("issuer", args.issuer) or not contains("name", args.name):
510
+ return False
511
+ if args.isin and key_text(record.get("isin")) != key_text(args.isin):
512
+ return False
513
+ maturity = record.get("maturity_date")
514
+ if args.maturity_from and (not maturity or maturity < args.maturity_from):
515
+ return False
516
+ if args.maturity_to and (not maturity or maturity > args.maturity_to):
517
+ return False
518
+ return True
519
+
520
+
521
+ def compact_record(record: dict[str, Any]) -> dict[str, Any]:
522
+ keys = (
523
+ "instrument_type", "isin", "name", "issuer", "bond_type", "currency",
524
+ "coupon_rate_pct", "maturity_date", "outstanding_amount_krw", "callable",
525
+ "putable", "convertible", "exchangeable", "warrant_attached", "source",
526
+ )
527
+ return {key: record.get(key) for key in keys}
528
+
529
+
530
+ def dedupe_identical(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
531
+ seen: set[str] = set()
532
+ result = []
533
+ for record in records:
534
+ comparable = {key: value for key, value in record.items() if key not in {"raw", "source"}}
535
+ marker = json.dumps(comparable, ensure_ascii=False, sort_keys=True)
536
+ if marker not in seen:
537
+ seen.add(marker)
538
+ result.append(record)
539
+ return result
540
+
541
+
542
+ def parse_iso_arg(value: str) -> str:
543
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
544
+ raise argparse.ArgumentTypeError("날짜는 YYYY-MM-DD 형식이어야 합니다")
545
+ try:
546
+ return date.fromisoformat(value).isoformat()
547
+ except ValueError as exc:
548
+ raise argparse.ArgumentTypeError("날짜는 YYYY-MM-DD 형식이어야 합니다") from exc
549
+
550
+
551
+ def parse_isin(value: str) -> str:
552
+ normalized = value.strip().upper()
553
+ if not re.fullmatch(r"[A-Z]{2}[A-Z0-9]{9}\d", normalized):
554
+ raise argparse.ArgumentTypeError("ISIN은 국가코드 2자를 포함한 12자리 형식이어야 합니다")
555
+ expanded = "".join(str(ord(char) - 55) if char.isalpha() else char for char in normalized)
556
+ total = 0
557
+ double = False
558
+ for digit in reversed(expanded):
559
+ number = int(digit)
560
+ if double:
561
+ number *= 2
562
+ if number > 9:
563
+ number -= 9
564
+ total += number
565
+ double = not double
566
+ if total % 10:
567
+ raise argparse.ArgumentTypeError("ISIN 체크디지트가 맞지 않습니다. 종목코드를 다시 확인해 주세요")
568
+ return normalized
569
+
570
+
571
+ def parse_timestamp(value: str) -> str:
572
+ try:
573
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
574
+ except ValueError as exc:
575
+ raise argparse.ArgumentTypeError("observed-at은 ISO 8601 형식이어야 합니다") from exc
576
+ if parsed.tzinfo is None:
577
+ raise argparse.ArgumentTypeError("observed-at에는 시간대가 있어야 합니다")
578
+ return parsed.isoformat()
579
+
580
+
581
+ def display(value: Any, suffix: str = "") -> str:
582
+ return "미확인" if value is None or value == "" else f"{value}{suffix}"
583
+
584
+
585
+ def display_bool(value: Any) -> str:
586
+ return "예" if value is True else "아니오" if value is False else "미확인"
587
+
588
+
589
+ def friendly_timestamp(value: Any) -> str:
590
+ if not value:
591
+ return "미확인"
592
+ try:
593
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
594
+ return parsed.strftime("%Y-%m-%d %H:%M:%S %z")
595
+ except ValueError:
596
+ return str(value)
597
+
598
+
599
+ def source_time_text(source: dict[str, Any]) -> str:
600
+ timestamp = friendly_timestamp(source.get("observed_at"))
601
+ if source.get("observed_at_basis") == "file_mtime_fallback":
602
+ return f"입력 파일 수정 시각 {timestamp} (원천 확인시각 미제공)"
603
+ if source.get("observed_at_basis") == "source_verified":
604
+ return f"원천 확인 시각 {timestamp}"
605
+ if source.get("observed_at_basis") == "provided":
606
+ return f"입력된 기준 시각 {timestamp} (원천 확인 여부 미검증)"
607
+ return "확인시각 미제공"
608
+
609
+
610
+ def source_line(source: Any) -> str:
611
+ if not isinstance(source, dict):
612
+ source = {}
613
+ name = str(source.get("name") or "입력 자료")
614
+ url = source.get("url")
615
+ label = f"[{name}]({url})" if isinstance(url, str) and url.startswith("https://") else name
616
+ return f"출처: {label} · {source_time_text(source)}"
617
+
618
+
619
+ def money_krw(value: Any) -> str:
620
+ if value is None:
621
+ return "미확인"
622
+ amount = int(value)
623
+ if amount < 0:
624
+ return "미확인"
625
+ parts = []
626
+ for unit, scale in (("조", 1_000_000_000_000), ("억", 100_000_000), ("만", 10_000)):
627
+ quotient, amount = divmod(amount, scale)
628
+ if quotient:
629
+ parts.append(f"{quotient:,}{unit}")
630
+ if amount or not parts:
631
+ parts.append(f"{amount:,}원")
632
+ return " ".join(parts)
633
+
634
+
635
+ def option_summary(item: dict[str, Any]) -> str:
636
+ labels = []
637
+ for key, label in (("callable", "CALL"), ("putable", "PUT")):
638
+ value = item.get(key)
639
+ labels.append(label if value is True else f"{label} 없음" if value is False else f"{label} 미확인")
640
+
641
+ structures = (("convertible", "CB"), ("exchangeable", "EB"), ("warrant_attached", "BW"))
642
+ present = [label for key, label in structures if item.get(key) is True]
643
+ absent = [label for key, label in structures if item.get(key) is False]
644
+ unknown = [label for key, label in structures if item.get(key) is None]
645
+ if present:
646
+ labels.extend(present)
647
+ if absent and not unknown and not present:
648
+ labels.append("CB/EB/BW 없음")
649
+ elif unknown and present:
650
+ labels.append("그 외 주식연계 구조 미확인")
651
+ elif unknown and absent:
652
+ labels.append("일부 주식연계 구조 미확인")
653
+ labels.extend(f"{label} 없음" for label in absent)
654
+ elif unknown and not present and not absent:
655
+ labels.append("CB/EB/BW 미확인")
656
+ return " · ".join(labels)
657
+
658
+
659
+ def schedule_item_text(item: Any) -> str:
660
+ if isinstance(item, dict):
661
+ raw = item.get("raw")
662
+ if isinstance(raw, str) and raw.strip():
663
+ return " ".join(raw.split())
664
+ labels = (
665
+ ("date", "일자"), ("start_date", "시작"), ("end_date", "종료"),
666
+ ("type", "종류"), ("option", "종류"), ("price", "가격"),
667
+ )
668
+ parts = [
669
+ f"{label} {item[key]}" for key, label in labels
670
+ if key in item and isinstance(item[key], (str, int, float)) and not isinstance(item[key], bool)
671
+ ]
672
+ return " · ".join(parts) if parts else "원문 일정 확인 필요"
673
+ return " ".join(str(item).split()) if item is not None else "원문 일정 확인 필요"
674
+
675
+
676
+ def render_chat(payload: dict[str, Any]) -> str:
677
+ result = payload.get("result")
678
+ if result == "ambiguous":
679
+ items = payload.get("items") or []
680
+ isins = sorted({str(item.get("isin")) for item in items if isinstance(item, dict) and item.get("isin")})
681
+ subject = f"ISIN `{isins[0]}`" if len(isins) == 1 else "아래 ISIN"
682
+ lines = [
683
+ "## 원천 자료의 발행조건 충돌", "",
684
+ f"{subject}에 서로 다른 발행조건 레코드가 있어 해당 종목을 임의로 선택하지 않았습니다.",
685
+ "내보내기 자료를 새로 확인한 뒤 다시 조회해 주세요.", "",
686
+ "| ISIN | 종목 | 발행사 | 만기 | 표면금리 | 발행잔액 |",
687
+ "| --- | --- | --- | --- | ---: | ---: |",
688
+ ]
689
+ for item in items[:5]:
690
+ lines.append(
691
+ f"| `{display(item.get('isin'))}` | {display(item.get('name'))} | {display(item.get('issuer'))} | "
692
+ f"{display(item.get('maturity_date'))} | {display(item.get('coupon_rate_pct'), '%')} | "
693
+ f"{money_krw(item.get('outstanding_amount_krw'))} |"
694
+ )
695
+ if len(items) > 5:
696
+ lines.extend(["", f"충돌 레코드 {len(items)}건 중 5건만 표시했습니다."])
697
+ source = items[0].get("source", {}) if items and isinstance(items[0], dict) else payload.get("source", {})
698
+ lines.extend(["", source_line(source)])
699
+ return "\n".join(lines)
700
+ if result not in {"ok", "empty"}:
701
+ lines = ["자료를 처리하지 못했습니다.", "", f"- 사유: {payload.get('message', '알 수 없는 오류')}"]
702
+ source = payload.get("source", {})
703
+ lines.extend(["", source_line(source)])
704
+ if result == "source_failure" and not (isinstance(source, dict) and source.get("url")):
705
+ lines.extend(["", f"자료는 [SEIBro 채권정보]({SEIBRO_URL})에서 확인한 뒤 다시 시도해 주세요."])
706
+ return "\n".join(lines)
707
+ if result == "empty":
708
+ source = payload.get("source", {})
709
+ return (
710
+ "조회 원천에서 조건에 맞는 채권을 확인하지 못했습니다.\n\n"
711
+ "이는 채권이 존재하지 않는다는 뜻은 아닙니다. 발행사명·종목명·ISIN 또는 만기 조건을 바꿔 다시 확인해 주세요.\n\n"
712
+ f"{source_line(source)}"
713
+ )
714
+ if "items" in payload:
715
+ items = payload["items"]
716
+ lines = [
717
+ "## 한국 채권 검색 결과",
718
+ "",
719
+ (
720
+ f"조건에 맞는 **{payload.get('count', len(items))}개 종목** 중 만기순 상위 {len(items)}개를 표시합니다."
721
+ if payload.get("truncated")
722
+ else f"조회 원천에서 **{payload.get('count', len(items))}개 종목**을 확인했습니다."
723
+ ),
724
+ "",
725
+ "| 종목 | ISIN | 발행사 | 유형 | 표면금리 | 만기 | 발행잔액 | 옵션·구조 |",
726
+ "| --- | --- | --- | --- | ---: | --- | ---: | --- |",
727
+ ]
728
+ for item in items:
729
+ rate = display(item.get("coupon_rate_pct"), "%")
730
+ lines.append(
731
+ f"| {display(item.get('name'))} | `{display(item.get('isin'))}` | {display(item.get('issuer'))} | "
732
+ f"{BOND_TYPE_NAMES.get(item.get('bond_type'), display(item.get('bond_type')))} | {rate} | "
733
+ f"{display(item.get('maturity_date'))} | {money_krw(item.get('outstanding_amount_krw'))} | {option_summary(item)} |"
734
+ )
735
+ if payload.get("truncated"):
736
+ lines.extend(["", "후보가 더 있습니다. 만기·회차·ISIN을 알려주시면 범위를 좁히겠습니다."])
737
+ elif len(items) > 1:
738
+ lines.extend(["", "상세히 볼 종목의 **ISIN 또는 회차**를 알려주세요."])
739
+ conflicts = payload.get("conflicts") or []
740
+ if conflicts:
741
+ conflict_isins = sorted({str(item.get("isin")) for item in conflicts if isinstance(item, dict) and item.get("isin")})
742
+ shown = ", ".join(f"`{isin}`" for isin in conflict_isins[:3])
743
+ more = f" 외 {len(conflict_isins) - 3}개" if len(conflict_isins) > 3 else ""
744
+ lines.extend(["", f"주의: 발행조건이 충돌한 ISIN {shown}{more}은 표에서 제외했습니다. 원천 자료를 다시 확인해 주세요."])
745
+ source = items[0].get("source", {}) if items else payload.get("source", {})
746
+ lines.extend(["", source_line(source)])
747
+ return "\n".join(lines)
748
+ if "item" not in payload and "isin" in payload:
749
+ lines = [
750
+ f"## 옵션 정보 — `{display(payload.get('isin'))}`", "",
751
+ f"- 옵션·구조: {option_summary(payload)}",
752
+ "- 옵션 일정:",
753
+ ]
754
+ schedule = payload.get("option_schedule") or []
755
+ lines.extend([f" - {schedule_item_text(item)}" for item in schedule] if schedule else [" - 확인되지 않음"])
756
+ source = payload.get("source", {})
757
+ lines.extend(["", source_line(source)])
758
+ return "\n".join(lines)
759
+ item = payload.get("item") or payload
760
+ lines = [
761
+ f"## {display(item.get('name'), '')}", "",
762
+ f"`{display(item.get('isin'))}` · {display(item.get('issuer'))}", "",
763
+ "| 항목 | 내용 |", "| --- | --- |",
764
+ f"| 유형 | {BOND_TYPE_NAMES.get(item.get('bond_type'), display(item.get('bond_type')))} |",
765
+ f"| 발행일 | {display(item.get('issue_date'))} |",
766
+ f"| 만기 | {display(item.get('maturity_date'))} |",
767
+ f"| 표면금리 | {display(item.get('coupon_rate_pct'), '%')} |",
768
+ f"| 금리 유형 | {display(item.get('coupon_type'))} |",
769
+ f"| 발행액 | {money_krw(item.get('issue_amount_krw'))} |",
770
+ f"| 발행잔액 | {money_krw(item.get('outstanding_amount_krw'))} |",
771
+ f"| 통화 | {display(item.get('currency'))} |",
772
+ f"| 선후순위 | {display(item.get('seniority'))} |",
773
+ f"| 보증 | {display(item.get('guarantee'))} |",
774
+ f"| 공모/사모 | {display(item.get('offering_type'))} |",
775
+ f"| 상장 여부 | {display_bool(item.get('listed'))} |",
776
+ f"| 옵션·구조 | {option_summary(item)} |", "",
777
+ ]
778
+ schedule = item.get("option_schedule") or []
779
+ if schedule:
780
+ lines.extend(["옵션 일정:", *[f"- {schedule_item_text(entry)}" for entry in schedule], ""])
781
+ source = item.get("source", {})
782
+ lines.append(source_line(source))
783
+ return "\n".join(lines)
784
+
785
+
786
+ def emit(payload: dict[str, Any], exit_code: int = 0) -> None:
787
+ if OUTPUT_FORMAT == "json":
788
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=False))
789
+ else:
790
+ print(render_chat(payload))
791
+ raise SystemExit(exit_code)
792
+
793
+
794
+ def build_parser() -> argparse.ArgumentParser:
795
+ parser = JsonArgumentParser(description=__doc__)
796
+ sub = parser.add_subparsers(dest="command", required=True)
797
+
798
+ def add_source_arguments(command_parser: argparse.ArgumentParser) -> None:
799
+ command_parser.add_argument("--observed-at", type=parse_timestamp)
800
+ command_parser.add_argument(
801
+ "--verified-source", action="store_true",
802
+ help="입력 자료와 조회시각을 이번 SEIBro 원천 화면에서 직접 대조했을 때만 사용",
803
+ )
804
+
805
+ search = sub.add_parser("search")
806
+ search.add_argument("--input")
807
+ search.add_argument("--issuer")
808
+ search.add_argument("--name")
809
+ search.add_argument("--isin", type=parse_isin)
810
+ search.add_argument("--maturity-from", type=parse_iso_arg)
811
+ search.add_argument("--maturity-to", type=parse_iso_arg)
812
+ search.add_argument("--limit", type=int, default=5, choices=range(1, 21), metavar="1..20")
813
+ search.add_argument("--format", choices=("chat", "json"), default="chat")
814
+ add_source_arguments(search)
815
+ for command in ("detail", "options"):
816
+ child = sub.add_parser(command)
817
+ child.add_argument("--input")
818
+ child.add_argument("--isin", required=True, type=parse_isin)
819
+ child.add_argument("--format", choices=("chat", "json"), default="chat")
820
+ add_source_arguments(child)
821
+ return parser
822
+
823
+
824
+ def main(argv: Iterable[str] | None = None) -> None:
825
+ global OUTPUT_FORMAT
826
+ parser = build_parser()
827
+ args = parser.parse_args(argv)
828
+ OUTPUT_FORMAT = args.format
829
+ if args.command == "search" and not any((args.issuer, args.name, args.isin, args.maturity_from, args.maturity_to)):
830
+ parser.error("search에는 issuer/name/isin/만기 조건 중 하나가 필요합니다")
831
+ if args.command == "search" and args.maturity_from and args.maturity_to and args.maturity_from > args.maturity_to:
832
+ parser.error("maturity-from은 maturity-to보다 늦을 수 없습니다")
833
+ if args.verified_source and not args.observed_at:
834
+ parser.error("verified-source에는 이번 원천 확인시각을 --observed-at으로 함께 지정해야 합니다")
835
+ source_name = "SEIBro" if args.verified_source else "입력 자료"
836
+ source_url = SEIBRO_URL if args.verified_source else None
837
+ try:
838
+ rows, source_path = load_rows(args.input)
839
+ if args.observed_at:
840
+ observed_at = args.observed_at
841
+ observed_at_basis = "source_verified" if args.verified_source else "provided"
842
+ else:
843
+ observed_at = datetime.fromtimestamp(Path(source_path).stat().st_mtime, timezone.utc).isoformat()
844
+ observed_at_basis = "file_mtime_fallback"
845
+ records = [normalize(row, observed_at, source_path, observed_at_basis, source_name, source_url) for row in rows]
846
+ records = dedupe_identical(records)
847
+ except InputError as exc:
848
+ emit({
849
+ "schema_version": "1.0", "result": exc.code, "message": str(exc),
850
+ "source": {"name": "입력 자료", "url": None},
851
+ }, 1)
852
+ except OSError:
853
+ emit({
854
+ "schema_version": "1.0", "result": "source_failure",
855
+ "message": "입력 파일을 읽는 중 오류가 발생했습니다. 경로와 접근 권한을 확인해 주세요",
856
+ "source": {"name": "입력 자료", "url": None},
857
+ }, 1)
858
+
859
+ if args.command == "search":
860
+ selected = [record for record in records if matches(record, args)]
861
+ selected.sort(key=lambda item: (
862
+ item.get("maturity_date") is None, item.get("maturity_date") or "9999",
863
+ item.get("name") or "", item.get("issuer") or "", item.get("isin") or "",
864
+ ))
865
+ by_isin: dict[str, list[dict[str, Any]]] = {}
866
+ for record in selected:
867
+ isin = record.get("isin")
868
+ if isin:
869
+ by_isin.setdefault(isin, []).append(record)
870
+ conflicts = [record for group in by_isin.values() if len(group) > 1 for record in group]
871
+ conflicting_isins = {record["isin"] for record in conflicts}
872
+ selected = [record for record in selected if record.get("isin") not in conflicting_isins]
873
+ if not selected:
874
+ if conflicts:
875
+ emit({
876
+ "schema_version": "1.0", "result": "ambiguous",
877
+ "items": [compact_record(item) for item in conflicts],
878
+ "source": conflicts[0]["source"],
879
+ }, 1)
880
+ emit({"schema_version": "1.0", "result": "empty", "items": [], "message": "제공된 조회 원천에서 확인되지 않았습니다", "source": {"name": source_name, "url": source_url, "observed_at": observed_at, "observed_at_basis": observed_at_basis}})
881
+ emit({
882
+ "schema_version": "1.0", "result": "ok", "count": len(selected),
883
+ "items": [compact_record(item) for item in selected[: args.limit]],
884
+ "truncated": len(selected) > args.limit,
885
+ "conflicts": [compact_record(item) for item in conflicts],
886
+ })
887
+
888
+ exact = [record for record in records if key_text(record.get("isin")) == key_text(args.isin)]
889
+ if not exact:
890
+ emit({
891
+ "schema_version": "1.0", "result": "empty", "items": [],
892
+ "message": "제공된 조회 원천에서 ISIN을 확인하지 못했습니다",
893
+ "source": {"name": source_name, "url": source_url, "observed_at": observed_at, "observed_at_basis": observed_at_basis},
894
+ })
895
+ if len(exact) > 1:
896
+ emit({"schema_version": "1.0", "result": "ambiguous", "items": [compact_record(item) for item in exact], "message": "동일 ISIN의 발행조건이 서로 충돌합니다"}, 1)
897
+ if args.command == "detail":
898
+ emit({"schema_version": "1.0", "result": "ok", "item": exact[0]})
899
+ emit({"schema_version": "1.0", "result": "ok", "isin": exact[0]["isin"], "callable": exact[0]["callable"],
900
+ "putable": exact[0]["putable"], "convertible": exact[0]["convertible"],
901
+ "exchangeable": exact[0]["exchangeable"], "warrant_attached": exact[0]["warrant_attached"],
902
+ "option_schedule": exact[0]["option_schedule"], "source": exact[0]["source"]})
903
+
904
+
905
+ if __name__ == "__main__":
906
+ main()