@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.
- package/bin/k-skill.js +64 -43
- package/package.json +2 -2
- package/skills/biz-health-check/instruction.md +2 -0
- package/skills/company-analysis/instruction.md +68 -0
- package/skills/company-analysis/references/source-contract.md +75 -0
- package/skills/company-analysis/scripts/dart-rows.js +52 -0
- package/skills/company-analysis/scripts/dart-rows.ts +48 -0
- package/skills/company-analysis/scripts/evidence.js +567 -0
- package/skills/company-analysis/scripts/evidence.ts +520 -0
- package/skills/company-analysis/scripts/fetch-public.js +85 -0
- package/skills/company-analysis/scripts/fetch-public.ts +75 -0
- package/skills/company-analysis/skill.json +6 -0
- package/skills/express-bus-booking/scripts/kobus_express_booking.py +46 -18
- package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +53 -3
- package/skills/g2b-order-plan-search/instruction.md +17 -0
- package/skills/g2b-sanctioned-supplier/instruction.md +10 -1
- package/skills/g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py +8 -4
- package/skills/g2b-sanctioned-supplier/scripts/test_g2b_sanctioned_supplier.py +19 -0
- package/skills/government-bond-analysis/instruction.md +216 -0
- package/skills/government-bond-analysis/references/methodology.md +63 -0
- package/skills/government-bond-analysis/scripts/cli.mjs +268 -0
- package/skills/government-bond-analysis/scripts/collect.mjs +171 -0
- package/skills/government-bond-analysis/scripts/core.mjs +224 -0
- package/skills/government-bond-analysis/scripts/dual.mjs +178 -0
- package/skills/government-bond-analysis/scripts/harness.mjs +406 -0
- package/skills/government-bond-analysis/scripts/modes.mjs +136 -0
- package/skills/government-bond-analysis/scripts/news.mjs +113 -0
- package/skills/government-bond-analysis/scripts/render.mjs +120 -0
- package/skills/government-bond-analysis/scripts/schedule.mjs +96 -0
- package/skills/government-bond-analysis/scripts/ust.mjs +204 -0
- package/skills/government-bond-analysis/skill.json +8 -0
- package/skills/korean-bond-search/instruction.md +122 -0
- package/skills/korean-bond-search/references/fields.md +48 -0
- package/skills/korean-bond-search/references/source-notes.md +11 -0
- package/skills/korean-bond-search/scripts/bond_search.py +906 -0
- package/skills/korean-bond-search/skill.json +9 -0
- package/skills/market-event-impact/instruction.md +150 -0
- package/skills/market-event-impact/references/event-taxonomy.md +74 -0
- package/skills/market-event-impact/references/methodology.md +98 -0
- package/skills/market-event-impact/references/research-contract.md +120 -0
- package/skills/market-event-impact/references/source-priority.md +25 -0
- package/skills/market-event-impact/scripts/adapters.py +32 -0
- package/skills/market-event-impact/scripts/asset_selector.py +120 -0
- package/skills/market-event-impact/scripts/calendars.py +179 -0
- package/skills/market-event-impact/scripts/event_resolver.py +260 -0
- package/skills/market-event-impact/scripts/event_study.py +171 -0
- package/skills/market-event-impact/scripts/market_data.py +204 -0
- package/skills/market-event-impact/scripts/market_event.py +1065 -0
- package/skills/market-event-impact/scripts/research.py +389 -0
- package/skills/market-event-impact/skill.json +9 -0
- package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
- package/src/analytics-policy.json +18 -0
- package/src/analytics.js +106 -0
- 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()
|