@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,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()
|