@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,1065 @@
1
+ #!/usr/bin/env python3
2
+ """Generic, reproducible market event research and event-study CLI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import html
8
+ import json
9
+ import math
10
+ import re
11
+ import sys
12
+ import urllib.error
13
+ import urllib.request
14
+ from datetime import date, datetime, time as dt_time, timedelta, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Iterable
17
+ from urllib.parse import urlparse
18
+
19
+ from adapters import adapter_for_event
20
+ from asset_selector import select_assets
21
+ from calendars import MARKET_CLOSES, market_alignment_for_series, valid_timezone, zone_for_date
22
+ from event_resolver import EventResolutionError, infer_event_type, normalize_event, resolve_from_events
23
+ from event_study import analyze_intraday_reaction, analyze_series, classify_curve, threshold_events
24
+ from market_data import SeriesError, load_series, series_as_of
25
+ from research import ResearchError, load_research_file, select_research
26
+
27
+ SOURCES = {
28
+ ("US", "FOMC"): ("Federal Reserve", "https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm", "America/New_York"),
29
+ ("EU", "ECB"): ("European Central Bank", "https://www.ecb.europa.eu/press/calendars/mgcgc/html/index.en.html", "Europe/Frankfurt"),
30
+ ("KR", "BOK_MPC"): ("Bank of Korea", "https://www.bok.or.kr/portal/singl/baseRate/list.do?dataSeCd=01&menuNo=200643", "Asia/Seoul"),
31
+ }
32
+ MONTHS = {name: index for index, name in enumerate(("", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December")) if name}
33
+ OUTPUT_FORMAT = "chat"
34
+
35
+ if hasattr(sys.stdout, "reconfigure"):
36
+ sys.stdout.reconfigure(encoding="utf-8")
37
+ sys.stderr.reconfigure(encoding="utf-8")
38
+
39
+
40
+ class JsonArgumentParser(argparse.ArgumentParser):
41
+ def error(self, message: str) -> None:
42
+ payload = {"schema_version": "1.0", "result": "input_failure", "message": message}
43
+ if "--format" in sys.argv and "json" in sys.argv:
44
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
45
+ else:
46
+ print(f"분석 조건을 확인해 주세요.\n\n- {message}")
47
+ raise SystemExit(2)
48
+
49
+
50
+ def show(value: Any) -> str:
51
+ return "미확인" if value is None or value == "" else str(value)
52
+
53
+
54
+ def event_value(value: Any) -> str:
55
+ mapping = {"hold": "동결", "hike": "인상", "cut": "인하"}
56
+ return mapping.get(str(value).casefold(), show(value))
57
+
58
+
59
+ def curve_name(value: Any) -> str:
60
+ return {
61
+ "Bear Flattening": "베어 플래트닝",
62
+ "Bear Steepening": "베어 스티프닝",
63
+ "Bull Flattening": "불 플래트닝",
64
+ "Bull Steepening": "불 스티프닝",
65
+ "Bear Parallel": "베어 평행이동",
66
+ "Bull Parallel": "불 평행이동",
67
+ "Mixed": "혼조",
68
+ }.get(str(value), show(value))
69
+
70
+
71
+ def friendly_timestamp(value: Any) -> str:
72
+ if not value:
73
+ return "미확인"
74
+ try:
75
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
76
+ return parsed.strftime("%Y-%m-%d %H:%M:%S %z")
77
+ except ValueError:
78
+ return str(value)
79
+
80
+
81
+ def signed(value: Any, unit: str) -> str:
82
+ if value is None:
83
+ return "미확인"
84
+ return f"{float(value):+.2f}{unit}"
85
+
86
+
87
+ def window_change(response: dict[str, Any], key: str) -> str:
88
+ point = response.get("window", {}).get(key, {})
89
+ unit = point.get("change_unit")
90
+ suffix = "bp" if unit == "bp" else "%"
91
+ return signed(point.get("change_from_d_minus_1"), suffix)
92
+
93
+
94
+ def observation_value(response: dict[str, Any], key: str) -> str:
95
+ point = response.get("window", {}).get(key, {})
96
+ value = point.get("value")
97
+ if value is None:
98
+ return "미확인"
99
+ unit = str(response.get("unit") or "").casefold()
100
+ if unit == "pct":
101
+ return f"{float(value):,.2f}%"
102
+ if unit in {"usd", "us_dollar", "dollar"}:
103
+ precision = 4 if response.get("asset_class") == "fx" else 2
104
+ return f"${float(value):,.{precision}f}"
105
+ if unit == "krw_per_usd":
106
+ return f"{float(value):,.2f}원/USD"
107
+ if unit in {"index", "idx", "points", "point"}:
108
+ return f"{float(value):,.2f}pt"
109
+ return f"{float(value):,.4f} {unit}".strip()
110
+
111
+
112
+ def window_observation(response: dict[str, Any], key: str) -> str:
113
+ point = response.get("window", {}).get(key, {})
114
+ observed_date = point.get("date")
115
+ if not observed_date:
116
+ return "미확인"
117
+ if key == "d-1":
118
+ return f"{observed_date} · {observation_value(response, key)}"
119
+ return f"{observed_date} · {observation_value(response, key)} ({window_change(response, key)})"
120
+
121
+
122
+ def source_time_label(response: dict[str, Any]) -> str:
123
+ provenance = response.get("observation_provenance") or {}
124
+ timezone_name = provenance.get("observation_timezone")
125
+ observation_time = provenance.get("observation_time")
126
+ market_timezone = provenance.get("market_timezone")
127
+ price_type = provenance.get("price_type")
128
+ if not timezone_name or not observation_time:
129
+ basis = response.get("alignment_basis")
130
+ return "관측 시각 미제공 · 시장 마감시각 대리정렬" if basis in {"market_close_proxy", "region_market_close_proxy"} else "관측 시각 미제공 · 지역 거래일 기준 대리정렬"
131
+ label = f"{observation_time} {timezone_name}"
132
+ if market_timezone and market_timezone != timezone_name:
133
+ label += f" · 시장 {market_timezone}"
134
+ if price_type:
135
+ label += f" · {price_type}"
136
+ return label.replace("|", "\\|")
137
+
138
+
139
+ def alignment_label(response: dict[str, Any]) -> str:
140
+ mode = response.get("alignment")
141
+ basis = response.get("alignment_basis")
142
+ provenance = response.get("observation_provenance") or {}
143
+ if basis == "source_observation_time":
144
+ prefix = "다음 원천 관측일 · " if mode == "next_strict" else "원천 관측시각 기준 · "
145
+ return f"{prefix}{provenance.get('observation_time')} {provenance.get('observation_timezone')}"
146
+ if basis == "market_close_proxy":
147
+ prefix = "다음 거래일 · " if mode == "next_strict" else "사건 당일 시장 · "
148
+ return prefix + "시장 마감시각 추정"
149
+ if basis == "region_market_close_proxy":
150
+ return "다음 거래일 · 지역 시장 마감시각 추정" if mode == "next_strict" else "지역 시장 마감시각 추정"
151
+ if basis == "region_calendar_proxy":
152
+ if mode == "next_strict":
153
+ return "다음 거래일(교차시장) · 지역 달력 대리정렬"
154
+ return "지역 거래일 기준 대리정렬(원천 시각 미확인)"
155
+ return f"사용자 지정 · {show(mode)}"
156
+
157
+
158
+ def event_time_summary(event: dict[str, Any], responses: list[dict[str, Any]], intraday_responses: list[dict[str, Any]]) -> list[str]:
159
+ quality = event.get("event_timestamp_quality") or ("exact_timestamp" if event.get("event_time_local") else "date_only")
160
+ quality_label = "A · 정확 시각" if quality == "exact_timestamp" else "B · 날짜만 확인"
161
+ structure = event.get("event_structure", "point_event")
162
+ structure_label = {
163
+ "point_event": "단일 시점 사건",
164
+ "scheduled_event": "예정된 이벤트",
165
+ "developing_event": "C · 다일 진행 사건",
166
+ }.get(structure, structure)
167
+ timestamp = event.get("event_time_local")
168
+ zone = event.get("event_timezone")
169
+ when = f"{event.get('event_date')} {timestamp} {zone}" if timestamp and zone else f"{event.get('event_date')} (시각 미확인)"
170
+ lines = ["**사건 시점과 D0 해석**", f"- 기준 사건: {when} · 시간 정밀도 {quality_label} · 구조 {structure_label}"]
171
+ has_intraday = any(item.get("status") == "ok" for item in intraday_responses)
172
+ if has_intraday:
173
+ lines.append("- 장중 자료가 있는 시계열은 이벤트 직전·+1시간·첫 후속 세션의 마지막 제공 바를 별도로 표시합니다. 장중 바가 공식 종가인지 확인되지 않으면 종가로 부르지 않습니다.")
174
+ else:
175
+ lines.append("- 일별 자료만으로 계산한 D0는 기준 관측값부터 첫 적격 이벤트일 관측값까지의 일별 변동입니다. 사건 이후 구간만 분리하지 못하므로 인과효과로 해석하지 않습니다.")
176
+ if structure == "developing_event":
177
+ lines.append("- 다일 진행 사건의 D+1/D+5에는 타임라인의 후속 충격·정책대응이 포함될 수 있으며 최초 사건 하나의 효과로 귀속하지 않습니다.")
178
+ if event.get("event_structure_inferred"):
179
+ lines.append("- 진행 형태는 입력에서 명시되지 않아 기본값으로 추정했습니다. 비정형·다일 사건이면 사건 파일에서 확인해 수정해야 합니다.")
180
+ sequence = event.get("event_sequence") or []
181
+ if sequence:
182
+ lines.append("- 사건 타임라인:")
183
+ for step in sequence:
184
+ step_when = step["date"]
185
+ if step.get("time_local"):
186
+ step_when += f" {step['time_local']} {step['timezone']}"
187
+ source = step["source"]
188
+ lines.append(f" - {step_when} · {step['event']} ([{source['name']}]({source['url']}))")
189
+ missing_time = [item["symbol"] for item in responses if item.get("alignment_basis") in {"region_calendar_proxy", "region_market_close_proxy", "market_close_proxy"}]
190
+ if missing_time:
191
+ lines.append(f"- 원천의 관측 시각·시간대가 없어 지역 시장 마감시각 또는 거래일 달력으로 대리 정렬한 시계열: {', '.join(missing_time)}. 일별 시점이 다를 수 있습니다.")
192
+ return lines
193
+
194
+
195
+ def intraday_value_text(response: dict[str, Any], point: dict[str, Any] | None) -> str:
196
+ if not point:
197
+ return "미확인"
198
+ unit = str(response.get("unit") or "").casefold()
199
+ value = float(point["value"])
200
+ if unit == "pct":
201
+ return f"{value:,.2f}%"
202
+ if unit in {"usd", "us_dollar", "dollar"}:
203
+ precision = 4 if response.get("asset_class") == "fx" else 2
204
+ return f"${value:,.{precision}f}"
205
+ if unit == "krw_per_usd":
206
+ return f"{value:,.2f}원/USD"
207
+ if unit in {"index", "idx", "points", "point"}:
208
+ return f"{value:,.2f}pt"
209
+ return f"{value:,.4f} {unit}".strip()
210
+
211
+
212
+ def intraday_change_text(point: dict[str, Any] | None) -> str:
213
+ if not point or point.get("change_from_pre_event") is None:
214
+ return "변화 미확인"
215
+ suffix = "bp" if point.get("change_unit") == "bp" else "%"
216
+ return signed(point["change_from_pre_event"], suffix)
217
+
218
+
219
+ def render_events(payload: dict[str, Any]) -> str:
220
+ events = payload.get("events", [])
221
+ if not events:
222
+ return "조건에 맞는 공식 일정을 확인하지 못했습니다.\n\n조회 실패 항목은 원천별로 다시 확인해야 합니다."
223
+ lines = ["## 확인된 공식 일정", "", "| 날짜 | 지역 | 이벤트 | 현지 시각 |", "| --- | --- | --- | --- |"]
224
+ for item in events:
225
+ lines.append(f"| {item['event_date']} | {item['region']} | {show(item.get('event_name') or item.get('event_family'))} | {show(item.get('event_time_local'))} |")
226
+ lines.extend(["", f"확인 시각: {friendly_timestamp(payload.get('observed_at'))}"])
227
+ return "\n".join(lines)
228
+
229
+
230
+ def render_analysis(payload: dict[str, Any]) -> str:
231
+ event = payload.get("event", {})
232
+ responses = payload.get("market_response", [])
233
+ intraday_responses = payload.get("intraday_response", [])
234
+ curves = payload.get("curves", [])
235
+ research = payload.get("research")
236
+ adapter = payload.get("adapter")
237
+ selection = payload.get("asset_selection", [])
238
+ title_suffix = "" if research else " — 이벤트 스터디만 제공"
239
+ lines = [f"## {show(event.get('event_name') or event.get('event_family'))} 시장 영향 분석 — {show(event.get('event_date'))}{title_suffix}", ""]
240
+
241
+ lines.extend(["### 1. 무슨 일이 있었나", ""])
242
+ if research:
243
+ overview = research["overview"]
244
+ lines.append(overview["summary"])
245
+ lines.append("")
246
+ lines.append(overview["surprise_assessment"])
247
+ lines.append("")
248
+ lines.append(f"이번 이벤트의 성격은 **{overview['classification']}**입니다.")
249
+ lines.extend(["", "핵심 변화:"])
250
+ for claim in research["what_changed"]:
251
+ lines.append(
252
+ f"- **{claim['channel']}**: {claim['finding']} {claim['significance']} "
253
+ f"([{claim['source']['name']}]({claim['source']['url']}))"
254
+ )
255
+ if adapter:
256
+ lines.extend(["", f"**{adapter['label']} 어댑터 확인 항목**: {', '.join(adapter['channels'])}"])
257
+ for claim in research.get("adapter_details", []):
258
+ lines.append(
259
+ f"- **{claim['channel']}**: {claim['finding']} {claim['significance']} "
260
+ f"([{claim['source']['name']}]({claim['source']['url']}))"
261
+ )
262
+ else:
263
+ lines.append("사건을 설명할 직접 발표자료와 시장 맥락 자료가 없어 사건의 배경·변화·서프라이즈를 검증하지 못했습니다.")
264
+ lines.append("아래 가격 계산만으로는 완성된 이벤트 리서치가 아닙니다.")
265
+ lines.extend([""] + event_time_summary(event, responses, intraday_responses))
266
+
267
+ lines.extend(["", "### 2. 시장은 왜 반응했나", ""])
268
+ if research:
269
+ for claim in research["market_interpretation"]:
270
+ lines.append(
271
+ f"- **{claim['channel']}**: {claim['finding']} {claim['significance']} "
272
+ f"([{claim['source']['name']}]({claim['source']['url']}))"
273
+ )
274
+ else:
275
+ lines.append("공식발표와 당일 시장 해석을 연결할 근거가 없어 인과관계를 판단하지 않았습니다.")
276
+
277
+ lines.extend(["", "### 3. 교차자산 반응", ""])
278
+ if selection:
279
+ lines.append("분석 대상으로 선택한 자산:")
280
+ for item in selection:
281
+ roles = ", ".join(item.get("matched_roles", []))
282
+ lines.append(f"- {item['symbol']}: {item['reason']}" + (f" ({roles})" if roles else ""))
283
+ lines.append("")
284
+ groups = {
285
+ "금리": {"rates", "policy", "spread"}, "신용": {"credit"}, "외환": {"fx"}, "주식": {"equity"},
286
+ "원자재": {"commodity"}, "변동성": {"volatility"}, "기타": {"price"},
287
+ }
288
+ for group_name, classes in groups.items():
289
+ items = [item for item in responses if item.get("asset_class") in classes]
290
+ if not items:
291
+ continue
292
+ lines.append(f"**{group_name}**")
293
+ for item in items:
294
+ lines.append(f"- {item['symbol']}: D0 {window_change(item, 'd0')}, D+1 {window_change(item, 'd+1')} · {alignment_label(item)}")
295
+ lines.append("")
296
+ if curves:
297
+ curve = curves[0]
298
+ lines.append(
299
+ f"금리곡선은 **{curve_name(curve['classification'])}**입니다. "
300
+ f"{curve['short']}/{curve['long']} 스프레드는 {signed(curve.get('spread_change_bp'), 'bp')} 변했습니다."
301
+ )
302
+
303
+ lines.extend(["", "### 4. 전달경로", ""])
304
+ if research and research["transmission"]:
305
+ status_names = {
306
+ "CONFIRMED": "확인됨", "CONSISTENT": "가격과 일관", "MIXED": "반응 혼재/제한",
307
+ "CONTRADICTED": "전형적 경로와 반대", "UNVERIFIED": "미검증",
308
+ }
309
+ for step in research["transmission"]:
310
+ lines.append(
311
+ f"- **{step['from']} → {step['to']}** · `{step['status']}` ({status_names[step['status']]}): {step['evidence']} "
312
+ f"([{step['source']['name']}]({step['source']['url']}))"
313
+ )
314
+ else:
315
+ lines.append("전달경로를 판정할 검증 자료가 없습니다. 전형적인 경로를 실제 반응처럼 쓰지 않았습니다.")
316
+
317
+ lines.extend(["", "### 5. 이벤트 스터디 — Supporting Evidence", ""])
318
+ if intraday_responses:
319
+ lines.extend(["**장중 반응**", ""])
320
+ market_by_symbol = {item["symbol"]: item for item in responses}
321
+ for item in intraday_responses:
322
+ if item.get("status") != "ok":
323
+ lines.append(f"- {item['symbol']}: 장중 분석 불가 — 사건의 정확 시각이 확인되지 않았습니다.")
324
+ continue
325
+ market = market_by_symbol.get(item["symbol"], {})
326
+ before = item.get("pre_event")
327
+ plus_1h = item.get("plus_1h")
328
+ session_end = item.get("session_end")
329
+ close_label = "거래소 마감 시각 바" if item.get("session_end_label") == "market_close" else "해당 세션 마지막 제공 바(공식 종가 미확인)"
330
+ lines.append(
331
+ f"- **{item['symbol']}**: 직전 {show(before.get('timestamp') if before else None)} {intraday_value_text(market, before)} → "
332
+ f"+1h 이후 {show(plus_1h.get('timestamp') if plus_1h else None)} {intraday_value_text(market, plus_1h)} "
333
+ f"({intraday_change_text(plus_1h)}) → {close_label} {show(session_end.get('timestamp') if session_end else None)} "
334
+ f"{intraday_value_text(market, session_end)} ({intraday_change_text(session_end)})"
335
+ )
336
+ for warning in item.get("warnings", []):
337
+ if warning == "intraday_session_close_unconfirmed":
338
+ lines.append(f" - {item['symbol']}: 원천의 해당 세션 마지막 바는 거래소 공식 종가 시각과 일치하지 않습니다.")
339
+ lines.append("")
340
+ lines.extend([
341
+ "일별 자료의 변화는 표에 표시한 D-1 관측값을 기준으로 계산했습니다. 각 자산의 첫 적격 관측일까지의 움직임이며, 장중 반응이나 인과효과와 동일하지 않습니다.", "",
342
+ "| 자산 | D-1 기준값 | D0 관측값 (변화) | D+1 관측값 (변화) | D+5 관측값 (변화) | 정렬 | 관측 기준 |",
343
+ "| --- | --- | --- | --- | --- | --- | --- |",
344
+ ])
345
+ for item in responses:
346
+ alignment = alignment_label(item)
347
+ lines.append(
348
+ f"| {item['symbol']} | {window_observation(item, 'd-1')} | {window_observation(item, 'd0')} | "
349
+ f"{window_observation(item, 'd+1')} | {window_observation(item, 'd+5')} | {alignment} | {source_time_label(item)} |"
350
+ )
351
+
352
+ history = research.get("historical_context") if research else None
353
+ lines.extend(["", "### 6. 과거 유사 사례", ""])
354
+ if history and history.get("include"):
355
+ lines.extend([history["reason"], ""])
356
+ for comparison in history["comparisons"]:
357
+ lines.append(
358
+ f"- **{comparison['date']} · {comparison['event']}**: 매칭 근거는 {comparison['matching_basis']}입니다. 공통점은 {comparison['similarity']} 차이점은 {comparison['difference']} "
359
+ f"([{comparison['source']['name']}]({comparison['source']['url']}))"
360
+ )
361
+ else:
362
+ reason = history.get("reason") if history else None
363
+ lines.append(reason or "검증된 후보 이벤트의 충격 특성과 시장 반응 자료가 없어 유사사례 비교를 생성하지 않았습니다. 이름만 비슷한 사건으로 비교하지 않습니다.")
364
+
365
+ catalysts = research.get("catalysts", []) if research else []
366
+ observed_date = None
367
+ observed_instant = None
368
+ try:
369
+ observed_instant = datetime.fromisoformat(str(payload.get("observed_at")).replace("Z", "+00:00"))
370
+ event_zone = event.get("event_timezone") or MARKET_CLOSES.get(event.get("region"), ("UTC",))[0]
371
+ observed_date = observed_instant.astimezone(zone_for_date(event_zone, observed_instant.date())).date()
372
+ except ValueError:
373
+ pass
374
+ catalyst_statuses = []
375
+ for item in catalysts:
376
+ item_date = date.fromisoformat(item["date"])
377
+ if observed_date is None or item_date > observed_date:
378
+ status = "예정"
379
+ elif item_date < observed_date:
380
+ status = "경과"
381
+ elif item.get("time_local") and item.get("timezone") and observed_instant:
382
+ item_instant = datetime.combine(item_date, dt_time.fromisoformat(item["time_local"]),
383
+ zone_for_date(item["timezone"], item_date))
384
+ status = "경과" if item_instant <= observed_instant else "예정"
385
+ else:
386
+ status = "당일·시각 미확인"
387
+ catalyst_statuses.append(status)
388
+ if catalysts and all(status == "경과" for status in catalyst_statuses):
389
+ catalyst_heading = "### 7. 사건 당시 후속 촉매 — 모두 경과"
390
+ elif any(status != "예정" for status in catalyst_statuses):
391
+ catalyst_heading = "### 7. 후속 촉매"
392
+ else:
393
+ catalyst_heading = "### 7. 앞으로 볼 것"
394
+ lines.extend(["", catalyst_heading, ""])
395
+ if catalysts and all(status == "경과" for status in catalyst_statuses):
396
+ lines.append(f"아래는 확인 시점({observed_date.isoformat()}) 기준으로 이미 지난 일정이며, 사건 당시 감시 대상의 시간순 기록입니다.")
397
+ lines.append("")
398
+ if catalysts:
399
+ for index, catalyst in enumerate(catalysts):
400
+ status = catalyst_statuses[index]
401
+ when = f"{catalyst['date']} {catalyst['time_local']} {catalyst['timezone']}" if catalyst.get("time_local") else catalyst["date"]
402
+ lines.append(
403
+ f"- **{status} · {when} · {catalyst['event']}**: {catalyst['why_it_matters']} "
404
+ f"([{catalyst['source']['name']}]({catalyst['source']['url']}))"
405
+ )
406
+ else:
407
+ next_events = payload.get("next_events", [])
408
+ if next_events:
409
+ for next_event in next_events:
410
+ lines.append(f"- {next_event['event_date']} · {next_event['event_family']} · {show(next_event.get('event_time_local'))} {show(next_event.get('event_timezone'))}")
411
+ else:
412
+ lines.append("현재 해석을 확인하거나 뒤집을 후속 공식 일정을 확보하지 못했습니다.")
413
+
414
+ warnings = [(item["symbol"], warning) for item in responses for warning in item.get("warnings", [])]
415
+ if warnings or payload.get("failures") or not research:
416
+ lines.extend(["", "### 데이터 확인 사항", ""])
417
+ if not research:
418
+ lines.append("- Research layer 미확보: `--research-file`로 검증된 공식발표·시장해석·촉매 자료를 제공해야 합니다.")
419
+ response_by_symbol = {item["symbol"]: item for item in responses}
420
+ for symbol, warning in warnings:
421
+ if warning == "changes_unavailable_without_d_minus_1":
422
+ continue
423
+ if warning == "observation_time_basis_missing":
424
+ basis = response_by_symbol.get(symbol, {}).get("alignment_basis")
425
+ proxy = "시장 마감시각" if basis in {"market_close_proxy", "region_market_close_proxy"} else "지역 거래일 달력"
426
+ lines.append(f"- {symbol}: 원천 관측 시각·시간대가 없어 {proxy}으로 대리 정렬했습니다. 자산 간 장중 기준시각 비교에는 한계가 있습니다.")
427
+ continue
428
+ if warning.startswith("pending_"):
429
+ latest = response_by_symbol.get(symbol, {}).get("latest_observation_date")
430
+ window_name = warning.removeprefix("pending_").upper()
431
+ lines.append(f"- {symbol}: 확인 시각까지 {window_name} 확정 관측값을 확보하지 못했습니다. 미도래인지 원천 범위 부족인지는 별도 확인이 필요합니다. 최신 관측일은 {show(latest)}입니다.")
432
+ elif warning == "missing_d-1":
433
+ lines.append(f"- {symbol}: D-1 기준 관측이 없어 이벤트 변화량을 계산하지 못했습니다.")
434
+ else:
435
+ window_name = warning.removeprefix("missing_").upper()
436
+ lines.append(f"- {symbol}: {window_name} 확정 관측값이 원천에 없습니다.")
437
+ for failure in payload.get("failures", []):
438
+ if failure.get("code") == "research_context_missing":
439
+ continue
440
+ failure_names = {
441
+ "curve_series_missing": "금리곡선 계산에 필요한 시계열 미확보",
442
+ "invalid_curve": "금리곡선 입력 오류",
443
+ "series_missing": "요청한 시계열 미확보",
444
+ "asset_selection_empty": "사건 전달경로와 일치하는 시계열 미확보",
445
+ "series_starts_after_event": "첫 관측일이 사건일 이후라 반응 계산에서 제외",
446
+ }
447
+ message = failure.get("message") or failure_names.get(failure.get("code"), "일부 분석 항목 미확보")
448
+ lines.append(f"- {failure.get('symbol', '분석 항목')}: {message}")
449
+
450
+ lines.extend(["", "### 출처 및 재현 기준", ""])
451
+ event_source = event.get("source", {})
452
+ lines.append(f"- 이벤트: [{show(event_source.get('name'))}]({show(event_source.get('url'))})")
453
+ seen: set[tuple[Any, Any]] = set()
454
+ for item in responses:
455
+ source = item.get("source", {})
456
+ key = (source.get("name"), source.get("url"))
457
+ if key not in seen:
458
+ seen.add(key)
459
+ lines.append(f"- {item['symbol']}: [{show(source.get('name'))}]({show(source.get('url'))})")
460
+ lines.append(f"- 분석 확인 시각: {friendly_timestamp(payload.get('observed_at'))}")
461
+ return "\n".join(lines)
462
+
463
+
464
+ def render_threshold(payload: dict[str, Any]) -> str:
465
+ events = payload.get("events", [])
466
+ if not events:
467
+ return f"## {show(payload.get('trigger'))} 임계치 이벤트\n\n조건을 충족한 관측일이 없습니다."
468
+ lines = [f"## {show(payload.get('trigger'))} 임계치 이벤트", "", f"조건을 충족한 관측일은 **{len(events)}개**입니다.", ""]
469
+ for item in events:
470
+ metric = item.get("metric")
471
+ unit = "bp" if metric == "bp_change" else "%"
472
+ lines.append(f"- **{show(item.get('date'))}**: {signed(item.get('value'), unit)} · 기준 {show(item.get('threshold'))}{unit}")
473
+ for response in item.get("responses", []):
474
+ if "changes_unavailable_without_d_minus_1" in response.get("warnings", []):
475
+ lines.append(f" - {response.get('symbol')}: D-1 기준 관측이 없어 변화율 계산 불가")
476
+ elif response.get("failure"):
477
+ lines.append(f" - {response.get('symbol')}: 이벤트일과 정렬할 관측값 없음")
478
+ else:
479
+ lines.append(f" - {response.get('symbol')}: D0 {window_change(response, 'd0')}, D+1 {window_change(response, 'd+1')}")
480
+ for failure in item.get("failures", []):
481
+ lines.append(f" - {failure.get('symbol')}: 요청한 반응 시계열 미확보")
482
+ source = payload.get("trigger_source", {})
483
+ lines.extend([
484
+ "",
485
+ f"출처: [{show(source.get('name'))}]({show(source.get('url'))})",
486
+ f"확인 시각: {friendly_timestamp(payload.get('observed_at'))}",
487
+ ])
488
+ return "\n".join(lines)
489
+
490
+
491
+ def render_chat(payload: dict[str, Any]) -> str:
492
+ result = payload.get("result")
493
+ if result in {"input_failure", "source_failure", "parse_failure"}:
494
+ return f"분석을 완료하지 못했습니다.\n\n- 사유: {show(payload.get('message'))}"
495
+ if "market_response" in payload:
496
+ return render_analysis(payload)
497
+ if "resolution" in payload or payload.get("result") in {"verification_required", "ambiguous"}:
498
+ if payload.get("result") == "ok":
499
+ event = payload["event"]
500
+ return (
501
+ f"## 사건 식별 결과\n\n- 사건: {event['event_name']}\n- 유형: {event['event_type']}\n"
502
+ f"- 기준일: {event['event_date']}\n- 지역: {event['region']}\n- 확인 방식: {payload['resolution']}"
503
+ )
504
+ candidates = payload.get("candidates", [])
505
+ candidate_text = "\n".join(f"- {item['event_name']} · {item['event_date']}" for item in candidates)
506
+ return f"사건을 확정하지 못했습니다.\n\n- 사유: {show(payload.get('message'))}" + (f"\n\n후보:\n{candidate_text}" if candidate_text else "")
507
+ if "events" in payload and "trigger" in payload:
508
+ return render_threshold(payload)
509
+ if "events" in payload:
510
+ return render_events(payload)
511
+ return f"분석 결과: {show(result)}"
512
+
513
+
514
+ def emit(payload: dict[str, Any], code: int = 0) -> None:
515
+ normalized = {"schema_version": payload.pop("schema_version", "1.0"), **payload}
516
+ if OUTPUT_FORMAT == "json":
517
+ print(json.dumps(normalized, ensure_ascii=False, indent=2))
518
+ else:
519
+ print(render_chat(normalized))
520
+ raise SystemExit(code)
521
+
522
+
523
+ def fetch_text(url: str) -> str:
524
+ request = urllib.request.Request(url, headers={"User-Agent": "k-skill/1.0 (+https://github.com/NomaDamas/k-skill)"})
525
+ try:
526
+ with urllib.request.urlopen(request, timeout=30) as response:
527
+ data = response.read(2_000_000)
528
+ return data.decode(response.headers.get_content_charset() or "utf-8", errors="replace")
529
+ except (OSError, urllib.error.URLError) as exc:
530
+ raise RuntimeError(str(exc)) from exc
531
+
532
+
533
+ def plain_text(raw_html: str) -> str:
534
+ without_scripts = re.sub(r"<(script|style)\b[\s\S]*?</\1>", " ", raw_html, flags=re.I)
535
+ return " ".join(html.unescape(re.sub(r"<[^>]+>", " ", without_scripts)).split())
536
+
537
+
538
+ def parse_fomc(raw_html: str, year: int) -> list[dict[str, Any]]:
539
+ marker = f">{year} FOMC Meetings<"
540
+ start = raw_html.find(marker)
541
+ if start < 0:
542
+ text = plain_text(raw_html)
543
+ plain_marker = f"{year} FOMC Meetings"
544
+ if plain_marker not in text:
545
+ return []
546
+ section = text.split(plain_marker, 1)[1]
547
+ pattern = re.compile(r"(" + "|".join(MONTHS) + r")\s+(\d{1,2})(?:-(\d{1,2}))?\*?")
548
+ else:
549
+ next_heading = raw_html.find("FOMC Meetings<", start + len(marker))
550
+ section = raw_html[start:next_heading if next_heading >= 0 else len(raw_html)]
551
+ pattern = re.compile(
552
+ r'fomc-meeting__month[^>]*>\s*<strong>(' + "|".join(MONTHS) +
553
+ r')</strong>[\s\S]{0,500}?fomc-meeting__date[^>]*>\s*(\d{1,2})(?:-(\d{1,2}))?\*?\s*</div>',
554
+ re.I,
555
+ )
556
+ if not section:
557
+ return []
558
+ matches = list(pattern.finditer(section))
559
+ if not matches and "fomc-meeting__month" not in section:
560
+ pattern = re.compile(r"(" + "|".join(MONTHS) + r")\s+(\d{1,2})(?:-(\d{1,2}))?\*?", re.I)
561
+ matches = list(pattern.finditer(plain_text(section)))
562
+ found = []
563
+ seen: set[str] = set()
564
+ for match in matches:
565
+ month = MONTHS[match.group(1).title()]
566
+ day = int(match.group(3) or match.group(2))
567
+ try:
568
+ event_date = date(year, month, day).isoformat()
569
+ except ValueError:
570
+ continue
571
+ if event_date in seen:
572
+ continue
573
+ seen.add(event_date)
574
+ found.append(event_object("US", "FOMC", event_date))
575
+ return found[:8]
576
+
577
+
578
+ def parse_ecb(raw_html: str, year: int) -> list[dict[str, Any]]:
579
+ entry_pattern = re.compile(r"<dt>\s*(\d{2})/(\d{2})/(%d)\s*</dt>\s*<dd>([\s\S]*?)</dd>" % year, re.I)
580
+ events = []
581
+ for day, month, found_year, body in entry_pattern.findall(raw_html):
582
+ description = plain_text(body)
583
+ if "Governing Council of the ECB: monetary policy meeting" not in description or "(Day 2)" not in description:
584
+ continue
585
+ events.append(event_object("EU", "ECB", date(int(found_year), int(month), int(day)).isoformat()))
586
+ return events
587
+
588
+
589
+ def event_object(region: str, family: str, event_date: str, **extra: Any) -> dict[str, Any]:
590
+ source_name, source_url, event_timezone = SOURCES.get((region, family), (None, None, None))
591
+ item = {
592
+ "event_id": extra.pop("event_id", f"{family.casefold().replace('_', '-')}-{event_date}"),
593
+ "event_name": extra.pop("event_name", family.replace("_", " ")),
594
+ "event_type": extra.pop("event_type", "monetary_policy"),
595
+ "region": region,
596
+ "event_family": family,
597
+ "event_date": event_date,
598
+ "event_time_local": extra.pop("event_time_local", None),
599
+ "event_timezone": extra.pop("event_timezone", event_timezone),
600
+ "actual": extra.pop("actual", None),
601
+ "consensus": extra.pop("consensus", None),
602
+ "previous": extra.pop("previous", None),
603
+ "policy_before": extra.pop("policy_before", None),
604
+ "policy_after": extra.pop("policy_after", None),
605
+ "source": extra.pop("source", {"name": source_name, "url": source_url}),
606
+ }
607
+ item.update(extra)
608
+ return item
609
+
610
+
611
+ def load_events_file(path_text: str) -> list[dict[str, Any]]:
612
+ path = Path(path_text).expanduser()
613
+ if not path.is_file():
614
+ raise ValueError(f"events file not found: {path}")
615
+ payload = json.loads(path.read_text(encoding="utf-8-sig"))
616
+ items = payload.get("events") if isinstance(payload, dict) else payload
617
+ if not isinstance(items, list) or not all(isinstance(item, dict) for item in items):
618
+ raise ValueError("events file must be an array or contain an events array")
619
+ items = [normalize_event(item) for item in items]
620
+ keys = [(item["event_id"], item["event_date"]) for item in items]
621
+ if len(keys) != len(set(keys)):
622
+ raise ValueError("events file contains duplicate event keys")
623
+ return items
624
+
625
+
626
+ def discover_events(region: str, family: str, year: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
627
+ key = (region, family)
628
+ if key not in SOURCES:
629
+ return [], [{"region": region, "event_family": family, "code": "unsupported_live_schedule"}]
630
+ name, url, _ = SOURCES[key]
631
+ if key == ("KR", "BOK_MPC"):
632
+ return [], [{"region": region, "event_family": family, "code": "browser_required", "source": {"name": name, "url": url}}]
633
+ try:
634
+ raw = fetch_text(url)
635
+ items = parse_fomc(raw, year) if key == ("US", "FOMC") else parse_ecb(raw, year)
636
+ except RuntimeError as exc:
637
+ return [], [{"region": region, "event_family": family, "code": "source_failure", "message": str(exc), "source": {"name": name, "url": url}}]
638
+ if not items:
639
+ return [], [{"region": region, "event_family": family, "code": "parse_failure", "source": {"name": name, "url": url}}]
640
+ return items, []
641
+
642
+
643
+ def numeric_surprise(actual: Any, consensus: Any) -> float | None:
644
+ try:
645
+ result = float(actual) - float(consensus)
646
+ return round(result, 10) if math.isfinite(result) else None
647
+ except (TypeError, ValueError):
648
+ return None
649
+
650
+
651
+ def event_from_args(args: argparse.Namespace) -> dict[str, Any]:
652
+ family = str(args.event or "").upper().replace(" ", "_") or None
653
+ if args.event_file:
654
+ if args.event_time_local or args.event_timezone:
655
+ raise ValueError("event time overrides cannot be combined with --event-file")
656
+ events = load_events_file(args.event_file)
657
+ if args.query:
658
+ resolved = resolve_from_events(args.query, events)
659
+ if resolved["result"] != "ok":
660
+ raise ValueError(resolved["message"])
661
+ event = dict(resolved["event"])
662
+ else:
663
+ if not args.date:
664
+ raise ValueError("--date is required when --query is omitted")
665
+ candidates = [item for item in events if item["event_date"] == args.date]
666
+ if args.event_id:
667
+ candidates = [item for item in candidates if item["event_id"] == args.event_id]
668
+ if args.region:
669
+ candidates = [item for item in candidates if item["region"] == args.region.upper()]
670
+ if family:
671
+ candidates = [item for item in candidates if item.get("event_family") == family]
672
+ if len(candidates) != 1:
673
+ raise ValueError(f"event-file must contain exactly one matching event; found {len(candidates)}")
674
+ event = dict(candidates[0])
675
+ else:
676
+ if args.query:
677
+ raise ValueError("free-form --query requires --event-file with a verified event date and source")
678
+ if bool(args.event_time_local) != bool(args.event_timezone):
679
+ raise ValueError("event-time-local and event-timezone must be provided together")
680
+ if not args.date or not args.region or not (args.event_name or args.event) or not args.event_type:
681
+ raise ValueError("direct input requires --event-name (or --event), --event-type, --region, and --date")
682
+ source = None
683
+ if args.event_source_name or args.event_source_url:
684
+ if not args.event_source_name or not args.event_source_url:
685
+ raise ValueError("event-source-name and event-source-url must be provided together")
686
+ source = {"name": args.event_source_name, "url": args.event_source_url}
687
+ if source is None:
688
+ raise ValueError("event source is required; use --event-file or event-source-name/event-source-url")
689
+ event = normalize_event({
690
+ "event_id": args.event_id,
691
+ "event_name": args.event_name or args.event,
692
+ "event_family": family,
693
+ "event_type": args.event_type,
694
+ "region": args.region,
695
+ "event_date": args.date,
696
+ "actual": args.actual,
697
+ "consensus": args.consensus,
698
+ "previous": args.previous,
699
+ "event_time_local": args.event_time_local,
700
+ "event_timezone": args.event_timezone,
701
+ "source": source,
702
+ })
703
+ return event
704
+
705
+
706
+ def cmd_resolve(args: argparse.Namespace) -> None:
707
+ if not args.events_file:
708
+ event_type, _ = infer_event_type(args.query)
709
+ emit({
710
+ "result": "verification_required",
711
+ "query": args.query,
712
+ "inferred_event_type": event_type,
713
+ "message": "사건 날짜와 직접 출처를 확인한 events 파일이 필요합니다.",
714
+ "resolution": "unverified_query",
715
+ }, 1)
716
+ result = resolve_from_events(args.query, load_events_file(args.events_file))
717
+ result["resolution"] = result.get("resolution", "unresolved")
718
+ emit(result, 0 if result["result"] == "ok" else 1)
719
+
720
+
721
+ def cmd_events(args: argparse.Namespace) -> None:
722
+ if args.events_file:
723
+ items = load_events_file(args.events_file)
724
+ family = args.event.upper().replace(" ", "_")
725
+ items = [item for item in items if item["region"] == args.region and str(item.get("event_family") or "").upper().replace(" ", "_") == family and item["event_date"].startswith(str(args.year))]
726
+ emit({"result": "ok" if items else "empty", "events": items, "failures": [], "observed_at": args.observed_at})
727
+ family = args.event.upper().replace(" ", "_")
728
+ items, failures = discover_events(args.region, family, args.year)
729
+ emit({"result": "ok" if items else "partial_failure", "events": items, "failures": failures, "observed_at": args.observed_at}, 0 if items else 1)
730
+
731
+
732
+ def cmd_upcoming(args: argparse.Namespace) -> None:
733
+ start = date.fromisoformat(args.as_of)
734
+ end = start + timedelta(days=args.days)
735
+ regions = list(args.region)
736
+ items: list[dict[str, Any]] = []
737
+ failures: list[dict[str, Any]] = []
738
+ if args.events_file:
739
+ items = load_events_file(args.events_file)
740
+ else:
741
+ families = {"KR": "BOK_MPC", "US": "FOMC", "EU": "ECB"}
742
+ for region in regions:
743
+ for year in sorted({start.year, end.year}):
744
+ found, failed = discover_events(region, families.get(region, ""), year)
745
+ items.extend(found)
746
+ failures.extend(failed)
747
+ failures = list({(item.get("region"), item.get("event_family"), item.get("code")): item for item in failures}.values())
748
+ items = list({(item["event_id"], item["event_date"]): item for item in items}.values())
749
+ selected = [item for item in items if item["region"] in regions and start <= date.fromisoformat(item["event_date"]) <= end]
750
+ selected.sort(key=lambda item: item["event_date"])
751
+ emit({"result": "ok" if selected and not failures else ("partial_failure" if failures else "empty"), "as_of": start.isoformat(), "through": end.isoformat(), "events": selected, "failures": failures, "observed_at": args.observed_at})
752
+
753
+
754
+ def cmd_analyze(args: argparse.Namespace) -> None:
755
+ try:
756
+ series = load_series(args.series_file)
757
+ except (OSError, ValueError, json.JSONDecodeError, SeriesError) as exc:
758
+ emit({"result": "input_failure", "message": str(exc)}, 1)
759
+ event = event_from_args(args)
760
+ cutoff = datetime.fromisoformat(args.observed_at).astimezone(timezone.utc)
761
+ event_zone_name = event.get("event_timezone") or MARKET_CLOSES.get(event["region"], ("UTC",))[0]
762
+ event_local_cutoff = cutoff.astimezone(zone_for_date(event_zone_name, cutoff.date())).date()
763
+ if date.fromisoformat(event["event_date"]) > event_local_cutoff:
764
+ raise ValueError("event date is after --observed-at")
765
+ if event.get("event_time_local") and event.get("event_timezone"):
766
+ event_day = date.fromisoformat(event["event_date"])
767
+ event_instant = datetime.combine(event_day, dt_time.fromisoformat(event["event_time_local"]),
768
+ zone_for_date(event["event_timezone"], event_day)).astimezone(timezone.utc)
769
+ if event_instant > cutoff:
770
+ raise ValueError("event timestamp is after --observed-at")
771
+ event["surprise"] = numeric_surprise(event.get("actual"), event.get("consensus"))
772
+ research = None
773
+ if args.research_file:
774
+ research = select_research(load_research_file(args.research_file), event)
775
+ research_cutoff = research.get("observed_at")
776
+ if research_cutoff and datetime.fromisoformat(research_cutoff).astimezone(timezone.utc) > cutoff:
777
+ raise ValueError("research observed_at is after analysis --observed-at")
778
+ next_events: list[dict[str, Any]] = []
779
+ if args.event_file:
780
+ recurrence_key = event.get("recurrence_key") or event.get("event_family")
781
+ next_events = [
782
+ item for item in load_events_file(args.event_file)
783
+ if recurrence_key
784
+ and (item.get("recurrence_key") or item.get("event_family")) == recurrence_key
785
+ and item["event_date"] > event["event_date"]
786
+ ]
787
+ next_events.sort(key=lambda item: item["event_date"])
788
+ cutoff_series = [series_as_of(item, args.observed_at) for item in series]
789
+ for item in cutoff_series:
790
+ if not item["observations"]:
791
+ item["selection_usable"] = False
792
+ continue
793
+ selection_alignment = args.alignment
794
+ selection_date = event["event_date"]
795
+ if args.alignment == "next":
796
+ selection_alignment, _, selection_date = market_alignment_for_series(event, item)
797
+ candidate = analyze_series(item, event["event_date"], selection_alignment, (-1, 0), selection_date)
798
+ window = candidate.get("window", {})
799
+ item["selection_usable"] = (
800
+ candidate.get("failure") is None
801
+ and window.get("d-1", {}).get("value") is not None
802
+ and window.get("d0", {}).get("value") is not None
803
+ )
804
+ selected_series, asset_selection, missing_symbols = select_assets(cutoff_series, event, args.symbols, args.max_assets)
805
+ responses = []
806
+ intraday_responses = []
807
+ failures = []
808
+ if not selected_series:
809
+ failures.append({"code": "asset_selection_empty", "message": "no available series matched the event transmission profile"})
810
+ if research is None and not args.study_only:
811
+ failures.append({"code": "research_context_missing", "message": "full event research requires --research-file"})
812
+ failures.extend({"symbol": symbol, "code": "series_missing"} for symbol in missing_symbols)
813
+ for item in selected_series:
814
+ if item["original_first_observation_date"] > event["event_date"]:
815
+ failures.append({
816
+ "symbol": item["symbol"],
817
+ "code": "series_starts_after_event",
818
+ "message": f"first observation {item['original_first_observation_date']} is after event date {event['event_date']}",
819
+ })
820
+ continue
821
+ if not item["observations"]:
822
+ failures.append({"symbol": item["symbol"], "code": "series_no_observation_asof",
823
+ "message": "확인 시각 이전의 확정 관측값이 없습니다"})
824
+ continue
825
+ series_alignment = args.alignment
826
+ alignment_basis = "user_requested_alignment"
827
+ alignment_date = event["event_date"]
828
+ if args.alignment == "next":
829
+ series_alignment, alignment_basis, alignment_date = market_alignment_for_series(event, item)
830
+ analysis_item = {**item, "alignment_basis": alignment_basis}
831
+ response = analyze_series(
832
+ analysis_item, event["event_date"], series_alignment, (-1, 0, 1, args.window), alignment_date
833
+ )
834
+ if "failure" in response:
835
+ failures.append({"symbol": item["symbol"], "code": response.pop("failure")})
836
+ responses.append(response)
837
+ if item.get("intraday_observations"):
838
+ intraday_responses.append(analyze_intraday_reaction(item, event))
839
+ curves = []
840
+ by_symbol = {item["symbol"]: item for item in responses}
841
+ if args.curve:
842
+ short_symbol, long_symbol = args.curve
843
+ try:
844
+ short_response = by_symbol[short_symbol]
845
+ long_response = by_symbol[long_symbol]
846
+ if short_response["asset_class"] not in {"rates", "policy", "spread"} or long_response["asset_class"] not in {"rates", "policy", "spread"}:
847
+ raise ValueError("curve requires rates, policy, or spread series")
848
+ if short_response.get("region") != long_response.get("region"):
849
+ raise ValueError("curve series must belong to the same region")
850
+ if short_response["window"]["d0"]["date"] != long_response["window"]["d0"]["date"]:
851
+ raise ValueError("curve series must share the same D0 observation date")
852
+ short_clock = short_response.get("observation_provenance") or {}
853
+ long_clock = long_response.get("observation_provenance") or {}
854
+ if (short_clock.get("observation_timezone"), short_clock.get("observation_time")) != (
855
+ long_clock.get("observation_timezone"), long_clock.get("observation_time")
856
+ ):
857
+ raise ValueError("curve series must share the same observation clock")
858
+ short = short_response.get("window", {}).get("d0", {}).get("change_from_d_minus_1")
859
+ long = long_response.get("window", {}).get("d0", {}).get("change_from_d_minus_1")
860
+ if short is None or long is None:
861
+ raise ValueError("curve D0 response or D-1 anchor is unavailable")
862
+ curves.append({"short": short_symbol, "long": long_symbol, **classify_curve(short, long)})
863
+ except KeyError:
864
+ failures.append({"code": "curve_series_missing", "symbols": [short_symbol, long_symbol]})
865
+ except ValueError as exc:
866
+ failures.append({"code": "invalid_curve", "symbols": [short_symbol, long_symbol], "message": str(exc)})
867
+ emit({
868
+ "result": "partial_failure" if failures else ("ok" if responses else "empty"),
869
+ "event": event,
870
+ "analysis_contract": {
871
+ "research_first": True,
872
+ "anchor": "D-1",
873
+ "offsets": [-1, 0, 1, args.window],
874
+ "event_timestamp_quality": event.get("event_timestamp_quality"),
875
+ "event_structure": event.get("event_structure"),
876
+ "daily_d0_is_causal_effect": False,
877
+ "cross_market_alignment": "source_observation_time_then_market_close_proxy_then_region_market_close_proxy_then_region_calendar_proxy",
878
+ },
879
+ "research": research,
880
+ "adapter": adapter_for_event(event),
881
+ "asset_selection": asset_selection,
882
+ "market_response": responses,
883
+ "intraday_response": intraday_responses,
884
+ "curves": curves,
885
+ "next_events": next_events[:3],
886
+ "failures": failures,
887
+ "observed_at": args.observed_at,
888
+ })
889
+
890
+
891
+ def cmd_threshold(args: argparse.Namespace) -> None:
892
+ try:
893
+ series = load_series(args.series_file)
894
+ except (OSError, ValueError, json.JSONDecodeError, SeriesError) as exc:
895
+ emit({"result": "input_failure", "message": str(exc)}, 1)
896
+ by_symbol = {item["symbol"]: item for item in series}
897
+ if args.trigger not in by_symbol:
898
+ emit({"result": "empty", "message": f"trigger series not found: {args.trigger}"}, 1)
899
+ try:
900
+ matches = threshold_events(by_symbol[args.trigger], args.metric, args.direction, args.threshold)
901
+ except ValueError as exc:
902
+ emit({"result": "input_failure", "message": str(exc)}, 1)
903
+ responses = [value.strip() for value in (args.response or "").split(",") if value.strip()]
904
+ for item in matches:
905
+ item["responses"] = [analyze_series(by_symbol[symbol], item["date"], args.alignment) for symbol in responses if symbol in by_symbol]
906
+ missing = [symbol for symbol in responses if symbol not in by_symbol]
907
+ if missing:
908
+ item["failures"] = [{"symbol": symbol, "code": "series_missing"} for symbol in missing]
909
+ emit({"result": "ok" if matches else "empty", "trigger": args.trigger, "trigger_source": by_symbol[args.trigger]["source"], "events": matches, "observed_at": args.observed_at})
910
+
911
+
912
+ def parse_iso_arg(value: str) -> str:
913
+ try:
914
+ return date.fromisoformat(value).isoformat()
915
+ except ValueError as exc:
916
+ raise argparse.ArgumentTypeError("date must be YYYY-MM-DD") from exc
917
+
918
+
919
+ def parse_year(value: str) -> int:
920
+ year = int(value)
921
+ if not 2000 <= year <= 2100:
922
+ raise argparse.ArgumentTypeError("year must be between 2000 and 2100")
923
+ return year
924
+
925
+
926
+ def parse_days(value: str) -> int:
927
+ days = int(value)
928
+ if not 0 <= days <= 366:
929
+ raise argparse.ArgumentTypeError("days must be between 0 and 366")
930
+ return days
931
+
932
+
933
+ def parse_window(value: str) -> int:
934
+ window = int(value)
935
+ if not 2 <= window <= 30:
936
+ raise argparse.ArgumentTypeError("window must be between 2 and 30")
937
+ return window
938
+
939
+
940
+ def parse_asset_limit(value: str) -> int:
941
+ limit = int(value)
942
+ if not 1 <= limit <= 20:
943
+ raise argparse.ArgumentTypeError("max-assets must be between 1 and 20")
944
+ return limit
945
+
946
+
947
+ def parse_curve(value: str) -> tuple[str, str]:
948
+ parts = [part.strip() for part in value.split(",") if part.strip()]
949
+ if len(parts) != 2 or parts[0] == parts[1]:
950
+ raise argparse.ArgumentTypeError("curve must contain two distinct symbols: short,long")
951
+ return parts[0], parts[1]
952
+
953
+
954
+ def parse_regions(value: str) -> tuple[str, ...]:
955
+ regions = tuple(dict.fromkeys(part.strip().upper() for part in value.split(",") if part.strip()))
956
+ invalid = [region for region in regions if not re.fullmatch(r"[A-Z]{2,6}", region)]
957
+ if not regions or invalid:
958
+ raise argparse.ArgumentTypeError("region must contain 2-6 letter market codes")
959
+ return regions
960
+
961
+
962
+ def parse_symbols(value: str) -> tuple[str, ...]:
963
+ symbols = tuple(dict.fromkeys(part.strip() for part in value.split(",") if part.strip()))
964
+ if not symbols:
965
+ raise argparse.ArgumentTypeError("symbols must contain at least one symbol")
966
+ return symbols
967
+
968
+
969
+ def parse_timestamp(value: str) -> str:
970
+ try:
971
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
972
+ except ValueError as exc:
973
+ raise argparse.ArgumentTypeError("observed-at must be ISO 8601") from exc
974
+ if parsed.tzinfo is None:
975
+ raise argparse.ArgumentTypeError("observed-at must include a timezone")
976
+ return parsed.isoformat()
977
+
978
+
979
+ def parse_local_time(value: str) -> str:
980
+ try:
981
+ parsed = dt_time.fromisoformat(value)
982
+ except ValueError as exc:
983
+ raise argparse.ArgumentTypeError("event time must be HH:MM or HH:MM:SS") from exc
984
+ if parsed.tzinfo is not None:
985
+ raise argparse.ArgumentTypeError("event time must not include a timezone offset")
986
+ return parsed.isoformat(timespec="minutes" if parsed.second == 0 and parsed.microsecond == 0 else "seconds")
987
+
988
+
989
+ def build_parser() -> argparse.ArgumentParser:
990
+ parser = JsonArgumentParser(description=__doc__)
991
+ sub = parser.add_subparsers(dest="command", required=True)
992
+ resolve = sub.add_parser("resolve")
993
+ resolve.add_argument("--query", required=True)
994
+ resolve.add_argument("--events-file")
995
+ resolve.add_argument("--format", choices=("chat", "json"), default="chat")
996
+ resolve.set_defaults(func=cmd_resolve)
997
+ events = sub.add_parser("events")
998
+ events.add_argument("--region", required=True, choices=("KR", "US", "EU"))
999
+ events.add_argument("--event", required=True)
1000
+ events.add_argument("--year", required=True, type=parse_year)
1001
+ events.add_argument("--events-file")
1002
+ events.add_argument("--observed-at", type=parse_timestamp, default=datetime.now(timezone.utc).isoformat())
1003
+ events.add_argument("--format", choices=("chat", "json"), default="chat")
1004
+ events.set_defaults(func=cmd_events)
1005
+ upcoming = sub.add_parser("upcoming")
1006
+ upcoming.add_argument("--region", type=parse_regions, default=("KR", "US", "EU"))
1007
+ upcoming.add_argument("--days", type=parse_days, default=30)
1008
+ upcoming.add_argument("--as-of", type=parse_iso_arg, default=date.today().isoformat())
1009
+ upcoming.add_argument("--events-file")
1010
+ upcoming.add_argument("--observed-at", type=parse_timestamp, default=datetime.now(timezone.utc).isoformat())
1011
+ upcoming.add_argument("--format", choices=("chat", "json"), default="chat")
1012
+ upcoming.set_defaults(func=cmd_upcoming)
1013
+ analyze = sub.add_parser("analyze")
1014
+ analyze.add_argument("--query", help="free-form event name; requires a verified --event-file")
1015
+ analyze.add_argument("--event-id")
1016
+ analyze.add_argument("--event-name")
1017
+ analyze.add_argument("--event-type")
1018
+ analyze.add_argument("--region")
1019
+ analyze.add_argument("--event", help="legacy event family or event-name alias")
1020
+ analyze.add_argument("--date", type=parse_iso_arg)
1021
+ analyze.add_argument("--window", type=parse_window, default=5)
1022
+ analyze.add_argument("--alignment", choices=("same", "next", "previous"), default="next")
1023
+ analyze.add_argument("--series-file", required=True)
1024
+ analyze.add_argument("--symbols", type=parse_symbols)
1025
+ analyze.add_argument("--max-assets", type=parse_asset_limit, default=8)
1026
+ analyze.add_argument("--curve", type=parse_curve, help="short,long symbols")
1027
+ analyze.add_argument("--event-file")
1028
+ analyze.add_argument("--research-file", help="verified decision, communication, interpretation, transmission, and catalyst research JSON")
1029
+ analyze.add_argument("--study-only", action="store_true", help="allow event-window output without a research layer")
1030
+ analyze.add_argument("--actual")
1031
+ analyze.add_argument("--consensus")
1032
+ analyze.add_argument("--previous")
1033
+ analyze.add_argument("--event-source-name")
1034
+ analyze.add_argument("--event-source-url")
1035
+ analyze.add_argument("--event-time-local", type=parse_local_time)
1036
+ analyze.add_argument("--event-timezone")
1037
+ analyze.add_argument("--observed-at", type=parse_timestamp, default=datetime.now(timezone.utc).isoformat())
1038
+ analyze.add_argument("--format", choices=("chat", "json"), default="chat")
1039
+ analyze.set_defaults(func=cmd_analyze)
1040
+ threshold = sub.add_parser("threshold")
1041
+ threshold.add_argument("--series-file", required=True)
1042
+ threshold.add_argument("--trigger", required=True)
1043
+ threshold.add_argument("--metric", choices=("bp_change", "pct_change"), required=True)
1044
+ threshold.add_argument("--direction", choices=("above", "below"), required=True)
1045
+ threshold.add_argument("--threshold", type=float, required=True)
1046
+ threshold.add_argument("--response")
1047
+ threshold.add_argument("--alignment", choices=("same", "next", "previous"), default="next")
1048
+ threshold.add_argument("--observed-at", type=parse_timestamp, default=datetime.now(timezone.utc).isoformat())
1049
+ threshold.add_argument("--format", choices=("chat", "json"), default="chat")
1050
+ threshold.set_defaults(func=cmd_threshold)
1051
+ return parser
1052
+
1053
+
1054
+ def main(argv: Iterable[str] | None = None) -> None:
1055
+ global OUTPUT_FORMAT
1056
+ args = build_parser().parse_args(argv)
1057
+ OUTPUT_FORMAT = args.format
1058
+ try:
1059
+ args.func(args)
1060
+ except (OSError, ValueError, json.JSONDecodeError, SeriesError, ResearchError, EventResolutionError) as exc:
1061
+ emit({"result": "input_failure", "message": str(exc)}, 1)
1062
+
1063
+
1064
+ if __name__ == "__main__":
1065
+ main()