@nomadamas/k-skill 0.9.1 → 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/multi-asset-morning-briefing/instruction.md +117 -0
- package/skills/multi-asset-morning-briefing/references/format-rules.md +236 -0
- package/skills/multi-asset-morning-briefing/scripts/market_data.py +1003 -0
- package/skills/multi-asset-morning-briefing/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,389 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate reproducible narrative research inputs for arbitrary market events."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from datetime import date, datetime, time as dt_time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from calendars import valid_timezone
|
|
14
|
+
from event_resolver import EVENT_TYPES, infer_event_type, stable_event_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResearchError(ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _source(value: Any, context: str) -> dict[str, str]:
|
|
22
|
+
if not isinstance(value, dict) or not str(value.get("name") or "").strip() or not str(value.get("url") or "").strip():
|
|
23
|
+
raise ResearchError(f"{context} needs source.name and source.url")
|
|
24
|
+
url = str(value["url"]).strip()
|
|
25
|
+
parsed = urlparse(url)
|
|
26
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
27
|
+
raise ResearchError(f"{context}.source.url must be an http(s) URL")
|
|
28
|
+
return {"name": str(value["name"]).strip(), "url": url}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _text(value: Any, context: str) -> str:
|
|
32
|
+
if not isinstance(value, str) or not value.strip():
|
|
33
|
+
raise ResearchError(f"{context} must be a non-empty string")
|
|
34
|
+
return value.strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _claim_list(value: Any, field: str) -> list[dict[str, Any]]:
|
|
38
|
+
if value is None:
|
|
39
|
+
return []
|
|
40
|
+
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
41
|
+
raise ResearchError(f"{field} must be an object array")
|
|
42
|
+
result = []
|
|
43
|
+
for index, item in enumerate(value):
|
|
44
|
+
context = f"{field}[{index}]"
|
|
45
|
+
result.append({
|
|
46
|
+
"channel": _text(item.get("channel"), f"{context}.channel"),
|
|
47
|
+
"finding": _text(item.get("finding"), f"{context}.finding"),
|
|
48
|
+
"significance": _text(item.get("significance"), f"{context}.significance"),
|
|
49
|
+
"source": _source(item.get("source"), context),
|
|
50
|
+
})
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _catalysts(value: Any) -> list[dict[str, Any]]:
|
|
55
|
+
if value is None:
|
|
56
|
+
return []
|
|
57
|
+
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
58
|
+
raise ResearchError("catalysts must be an object array")
|
|
59
|
+
result = []
|
|
60
|
+
for index, item in enumerate(value):
|
|
61
|
+
context = f"catalysts[{index}]"
|
|
62
|
+
try:
|
|
63
|
+
event_date = date.fromisoformat(str(item.get("date"))).isoformat()
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
raise ResearchError(f"{context}.date must be YYYY-MM-DD") from exc
|
|
66
|
+
time_local = item.get("time_local")
|
|
67
|
+
timezone_name = item.get("timezone")
|
|
68
|
+
if bool(time_local) != bool(timezone_name):
|
|
69
|
+
raise ResearchError(f"{context}.time_local and timezone must be provided together")
|
|
70
|
+
if time_local:
|
|
71
|
+
try:
|
|
72
|
+
parsed_time = dt_time.fromisoformat(str(time_local))
|
|
73
|
+
if parsed_time.tzinfo is not None or not valid_timezone(str(timezone_name), date.fromisoformat(event_date)):
|
|
74
|
+
raise ValueError
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise ResearchError(f"{context}.time_local requires local time and valid IANA timezone") from exc
|
|
77
|
+
time_local = parsed_time.isoformat(timespec="minutes" if parsed_time.second == 0 else "seconds")
|
|
78
|
+
result.append({
|
|
79
|
+
"date": event_date,
|
|
80
|
+
"time_local": time_local,
|
|
81
|
+
"timezone": timezone_name,
|
|
82
|
+
"event": _text(item.get("event"), f"{context}.event"),
|
|
83
|
+
"why_it_matters": _text(item.get("why_it_matters"), f"{context}.why_it_matters"),
|
|
84
|
+
"source": _source(item.get("source"), context),
|
|
85
|
+
})
|
|
86
|
+
result.sort(key=lambda item: (item["date"], item["time_local"] or ""))
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _transmission(value: Any) -> list[dict[str, Any]]:
|
|
91
|
+
if value is None:
|
|
92
|
+
return []
|
|
93
|
+
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
94
|
+
raise ResearchError("transmission must be an object array")
|
|
95
|
+
allowed = {"CONFIRMED", "CONSISTENT", "MIXED", "CONTRADICTED", "UNVERIFIED"}
|
|
96
|
+
result = []
|
|
97
|
+
for index, item in enumerate(value):
|
|
98
|
+
context = f"transmission[{index}]"
|
|
99
|
+
raw_status = str(item.get("status", "")).strip().casefold()
|
|
100
|
+
status = {"confirmed": "CONFIRMED", "consistent": "CONSISTENT", "limited": "MIXED", "mixed": "MIXED", "contradicted": "CONTRADICTED", "unverified": "UNVERIFIED"}.get(raw_status, "")
|
|
101
|
+
if status not in allowed:
|
|
102
|
+
raise ResearchError(f"{context}.status must be one of {sorted(allowed)}")
|
|
103
|
+
origin = _text(item.get("from"), f"{context}.from")
|
|
104
|
+
destination = _text(item.get("to"), f"{context}.to")
|
|
105
|
+
if origin.casefold() == destination.casefold():
|
|
106
|
+
raise ResearchError(f"{context}.from and .to must differ")
|
|
107
|
+
result.append({
|
|
108
|
+
"from": origin,
|
|
109
|
+
"to": destination,
|
|
110
|
+
"status": status,
|
|
111
|
+
"evidence": _text(item.get("evidence"), f"{context}.evidence"),
|
|
112
|
+
"source": _source(item.get("source"), context),
|
|
113
|
+
})
|
|
114
|
+
keys = [(item["from"].casefold(), item["to"].casefold()) for item in result]
|
|
115
|
+
if len(keys) != len(set(keys)):
|
|
116
|
+
raise ResearchError("transmission contains duplicate steps")
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _history(value: Any) -> dict[str, Any]:
|
|
121
|
+
if value is None:
|
|
122
|
+
return {"include": False, "reason": None, "comparisons": []}
|
|
123
|
+
if not isinstance(value, dict):
|
|
124
|
+
raise ResearchError("historical_context must be an object")
|
|
125
|
+
include_raw = value.get("include", False)
|
|
126
|
+
if not isinstance(include_raw, bool):
|
|
127
|
+
raise ResearchError("historical_context.include must be true or false")
|
|
128
|
+
include = include_raw
|
|
129
|
+
comparisons = value.get("comparisons", [])
|
|
130
|
+
if not isinstance(comparisons, list) or not all(isinstance(item, dict) for item in comparisons):
|
|
131
|
+
raise ResearchError("historical_context.comparisons must be an object array")
|
|
132
|
+
normalized = []
|
|
133
|
+
for index, item in enumerate(comparisons):
|
|
134
|
+
context = f"historical_context.comparisons[{index}]"
|
|
135
|
+
try:
|
|
136
|
+
event_date = date.fromisoformat(str(item.get("date"))).isoformat()
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise ResearchError(f"{context}.date must be YYYY-MM-DD") from exc
|
|
139
|
+
normalized.append({
|
|
140
|
+
"date": event_date,
|
|
141
|
+
"event": _text(item.get("event"), f"{context}.event"),
|
|
142
|
+
"matching_basis": _text(item.get("matching_basis") or item.get("similarity"), f"{context}.matching_basis"),
|
|
143
|
+
"similarity": _text(item.get("similarity"), f"{context}.similarity"),
|
|
144
|
+
"difference": _text(item.get("difference"), f"{context}.difference"),
|
|
145
|
+
"source": _source(item.get("source"), context),
|
|
146
|
+
})
|
|
147
|
+
if include and not 3 <= len(normalized) <= 5:
|
|
148
|
+
raise ResearchError("historical_context.include=true needs 3 to 5 comparisons")
|
|
149
|
+
if not include and normalized:
|
|
150
|
+
raise ResearchError("historical_context comparisons require include=true")
|
|
151
|
+
return {
|
|
152
|
+
"include": include,
|
|
153
|
+
"reason": _text(value.get("reason"), "historical_context.reason") if include else None,
|
|
154
|
+
"comparisons": sorted(normalized, key=lambda item: item["date"]),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _tokens(value: Any, context: str) -> list[str]:
|
|
159
|
+
if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
|
|
160
|
+
raise ResearchError(f"{context} must be a non-empty-string array")
|
|
161
|
+
return list(dict.fromkeys(item.strip().casefold() for item in value))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _shock_pattern(value: Any, context: str) -> dict[str, str]:
|
|
165
|
+
allowed = {"up", "down", "mixed", "flat", "wider", "tighter"}
|
|
166
|
+
if not isinstance(value, dict) or not value:
|
|
167
|
+
raise ResearchError(f"{context} must be a non-empty object")
|
|
168
|
+
result = {}
|
|
169
|
+
for raw_key, raw_direction in value.items():
|
|
170
|
+
key = _text(raw_key, f"{context} key").casefold()
|
|
171
|
+
direction = _text(raw_direction, f"{context}.{key}").casefold()
|
|
172
|
+
if direction not in allowed:
|
|
173
|
+
raise ResearchError(f"{context}.{key} must be one of {sorted(allowed)}")
|
|
174
|
+
result[key] = direction
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _event_features(value: Any, context: str = "event_features") -> dict[str, Any]:
|
|
179
|
+
if value is None:
|
|
180
|
+
return {"shock_mechanisms": [], "affected_channels": [], "market_shock_pattern": {}}
|
|
181
|
+
if not isinstance(value, dict):
|
|
182
|
+
raise ResearchError(f"{context} must be an object")
|
|
183
|
+
mechanisms = _tokens(value.get("shock_mechanisms", []), f"{context}.shock_mechanisms") if value.get("shock_mechanisms") else []
|
|
184
|
+
channels = _tokens(value.get("affected_channels", []), f"{context}.affected_channels") if value.get("affected_channels") else []
|
|
185
|
+
pattern = _shock_pattern(value["market_shock_pattern"], f"{context}.market_shock_pattern") if value.get("market_shock_pattern") else {}
|
|
186
|
+
return {"shock_mechanisms": mechanisms, "affected_channels": channels, "market_shock_pattern": pattern}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _analogue_pool(value: Any, event_date: str) -> list[dict[str, Any]]:
|
|
190
|
+
if value is None:
|
|
191
|
+
return []
|
|
192
|
+
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
193
|
+
raise ResearchError("historical_analogue_pool must be an object array")
|
|
194
|
+
if len(value) > 50:
|
|
195
|
+
raise ResearchError("historical_analogue_pool supports at most 50 candidates")
|
|
196
|
+
result = []
|
|
197
|
+
keys = set()
|
|
198
|
+
for index, item in enumerate(value):
|
|
199
|
+
context = f"historical_analogue_pool[{index}]"
|
|
200
|
+
try:
|
|
201
|
+
candidate_date = date.fromisoformat(str(item.get("date"))).isoformat()
|
|
202
|
+
except ValueError as exc:
|
|
203
|
+
raise ResearchError(f"{context}.date must be YYYY-MM-DD") from exc
|
|
204
|
+
if candidate_date >= event_date:
|
|
205
|
+
raise ResearchError(f"{context}.date must be before the research event_date")
|
|
206
|
+
event_type = _text(item.get("event_type"), f"{context}.event_type").casefold().replace(" ", "_")
|
|
207
|
+
if event_type not in EVENT_TYPES:
|
|
208
|
+
raise ResearchError(f"{context}.event_type is unsupported")
|
|
209
|
+
features = _event_features(item, context)
|
|
210
|
+
if not features["shock_mechanisms"] or not features["affected_channels"] or not features["market_shock_pattern"]:
|
|
211
|
+
raise ResearchError(f"{context} needs shock_mechanisms, affected_channels, and market_shock_pattern")
|
|
212
|
+
candidate = {
|
|
213
|
+
"date": candidate_date,
|
|
214
|
+
"event": _text(item.get("event"), f"{context}.event"),
|
|
215
|
+
"event_type": event_type,
|
|
216
|
+
**features,
|
|
217
|
+
"similarity": _text(item.get("similarity"), f"{context}.similarity"),
|
|
218
|
+
"difference": _text(item.get("difference"), f"{context}.difference"),
|
|
219
|
+
"source": _source(item.get("source"), context),
|
|
220
|
+
}
|
|
221
|
+
key = (candidate_date, candidate["event"].casefold())
|
|
222
|
+
if key in keys:
|
|
223
|
+
raise ResearchError("historical_analogue_pool contains duplicate events")
|
|
224
|
+
keys.add(key)
|
|
225
|
+
result.append(candidate)
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _overlap(left: set[str], right: set[str]) -> float:
|
|
230
|
+
union = left | right
|
|
231
|
+
return len(left & right) / len(union) if union else 0.0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _rank_analogues(current: dict[str, Any], candidates: list[dict[str, Any]], event_type: str) -> dict[str, Any]:
|
|
235
|
+
ranked = []
|
|
236
|
+
for candidate in candidates:
|
|
237
|
+
mechanism_overlap = _overlap(set(current["shock_mechanisms"]), set(candidate["shock_mechanisms"]))
|
|
238
|
+
channel_overlap = _overlap(set(current["affected_channels"]), set(candidate["affected_channels"]))
|
|
239
|
+
current_pattern = current["market_shock_pattern"]
|
|
240
|
+
candidate_pattern = candidate["market_shock_pattern"]
|
|
241
|
+
shared_keys = set(current_pattern) & set(candidate_pattern)
|
|
242
|
+
pattern_union = set(current_pattern) | set(candidate_pattern)
|
|
243
|
+
pattern_overlap = (
|
|
244
|
+
sum(current_pattern[key] == candidate_pattern[key] for key in shared_keys) / len(pattern_union)
|
|
245
|
+
if pattern_union else 0.0
|
|
246
|
+
)
|
|
247
|
+
if mechanism_overlap == 0 or pattern_overlap == 0:
|
|
248
|
+
continue
|
|
249
|
+
type_match = event_type == candidate["event_type"]
|
|
250
|
+
score = round(2.0 * type_match + 3.0 * mechanism_overlap + 2.0 * channel_overlap + 4.0 * pattern_overlap, 6)
|
|
251
|
+
matching_basis = (
|
|
252
|
+
f"사건유형 {'일치' if type_match else '불일치'}, 충격 메커니즘 중복률 {mechanism_overlap:.0%}, "
|
|
253
|
+
f"영향 채널 중복률 {channel_overlap:.0%}, 관측된 시장충격 방향 일치율 {pattern_overlap:.0%}"
|
|
254
|
+
)
|
|
255
|
+
ranked.append((score, candidate["date"], candidate, matching_basis))
|
|
256
|
+
ranked.sort(key=lambda item: (-item[0], item[1], item[2]["event"].casefold()))
|
|
257
|
+
if len(ranked) < 3:
|
|
258
|
+
return {
|
|
259
|
+
"include": False,
|
|
260
|
+
"reason": "특성과 시장충격 방향을 함께 대조할 검증된 과거 후보가 3개 미만입니다.",
|
|
261
|
+
"comparisons": [],
|
|
262
|
+
}
|
|
263
|
+
comparisons = []
|
|
264
|
+
for score, _, candidate, matching_basis in ranked[:5]:
|
|
265
|
+
comparisons.append({
|
|
266
|
+
"date": candidate["date"],
|
|
267
|
+
"event": candidate["event"],
|
|
268
|
+
"matching_basis": matching_basis,
|
|
269
|
+
"similarity": candidate["similarity"],
|
|
270
|
+
"difference": candidate["difference"],
|
|
271
|
+
"score": score,
|
|
272
|
+
"source": candidate["source"],
|
|
273
|
+
})
|
|
274
|
+
return {
|
|
275
|
+
"include": True,
|
|
276
|
+
"reason": "사건 특성과 실제 시장충격 방향을 함께 점수화해 과거 사례를 정렬했습니다.",
|
|
277
|
+
"comparisons": comparisons,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def normalize_research(item: dict[str, Any]) -> dict[str, Any]:
|
|
282
|
+
try:
|
|
283
|
+
event_date = date.fromisoformat(str(item.get("event_date"))).isoformat()
|
|
284
|
+
except ValueError as exc:
|
|
285
|
+
raise ResearchError("research event_date must be YYYY-MM-DD") from exc
|
|
286
|
+
region = str(item.get("region", "")).upper()
|
|
287
|
+
if not re.fullmatch(r"[A-Z]{2,6}", region):
|
|
288
|
+
raise ResearchError("research region must be a 2-6 letter market code")
|
|
289
|
+
overview = item.get("overview") or item.get("decision")
|
|
290
|
+
if not isinstance(overview, dict):
|
|
291
|
+
raise ResearchError("research overview must be an object")
|
|
292
|
+
if overview.get("expected") is not None and not isinstance(overview.get("expected"), bool):
|
|
293
|
+
raise ResearchError("overview.expected must be true, false, or null")
|
|
294
|
+
event_name = _text(item.get("event_name") or item.get("event_family"), "research.event_name")
|
|
295
|
+
event_id = str(item.get("event_id") or "").strip().casefold() or stable_event_id(event_name)
|
|
296
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,95}", event_id):
|
|
297
|
+
raise ResearchError("research.event_id must use 1-96 lowercase letters, numbers, dots, underscores, or hyphens")
|
|
298
|
+
raw_event_type = item.get("event_type")
|
|
299
|
+
event_type = (_text(raw_event_type, "research.event_type").casefold().replace(" ", "_")
|
|
300
|
+
if raw_event_type is not None else infer_event_type(f"{event_name} {item.get('event_family') or ''}")[0])
|
|
301
|
+
if not event_id:
|
|
302
|
+
raise ResearchError("research.event_id is required when it cannot be derived from event_name")
|
|
303
|
+
if event_type not in EVENT_TYPES:
|
|
304
|
+
raise ResearchError(f"unsupported research event_type: {event_type}")
|
|
305
|
+
research_observed_at = item.get("observed_at")
|
|
306
|
+
if research_observed_at is not None:
|
|
307
|
+
try:
|
|
308
|
+
parsed_research_observed_at = datetime.fromisoformat(str(research_observed_at).replace("Z", "+00:00"))
|
|
309
|
+
except ValueError as exc:
|
|
310
|
+
raise ResearchError("research observed_at must be ISO 8601 with a timezone") from exc
|
|
311
|
+
if parsed_research_observed_at.tzinfo is None:
|
|
312
|
+
raise ResearchError("research observed_at must be ISO 8601 with a timezone")
|
|
313
|
+
research_observed_at = parsed_research_observed_at.isoformat()
|
|
314
|
+
normalized = {
|
|
315
|
+
"region": region,
|
|
316
|
+
"event_id": event_id,
|
|
317
|
+
"event_name": event_name,
|
|
318
|
+
"event_family": str(item.get("event_family") or "").strip().upper().replace(" ", "_") or None,
|
|
319
|
+
"event_type": event_type,
|
|
320
|
+
"event_date": event_date,
|
|
321
|
+
"observed_at": research_observed_at,
|
|
322
|
+
"event_features": _event_features(item.get("event_features")),
|
|
323
|
+
"historical_analogue_pool": _analogue_pool(item.get("historical_analogue_pool"), event_date),
|
|
324
|
+
"overview": {
|
|
325
|
+
"summary": _text(overview.get("summary"), "overview.summary"),
|
|
326
|
+
"expected": overview.get("expected") if isinstance(overview.get("expected"), bool) else None,
|
|
327
|
+
"surprise_assessment": _text(overview.get("surprise_assessment"), "overview.surprise_assessment"),
|
|
328
|
+
"classification": _text(overview.get("classification") or overview.get("event_type"), "overview.classification"),
|
|
329
|
+
"source": _source(overview.get("source"), "overview"),
|
|
330
|
+
},
|
|
331
|
+
"what_changed": _claim_list(item.get("what_changed"), "what_changed"),
|
|
332
|
+
"adapter_details": _claim_list(item.get("adapter_details"), "adapter_details"),
|
|
333
|
+
"market_interpretation": _claim_list(item.get("market_interpretation"), "market_interpretation"),
|
|
334
|
+
"transmission": _transmission(item.get("transmission")),
|
|
335
|
+
"catalysts": _catalysts(item.get("catalysts")),
|
|
336
|
+
"historical_context": _history(item.get("historical_context")),
|
|
337
|
+
}
|
|
338
|
+
if not normalized["historical_context"]["include"] and normalized["historical_analogue_pool"]:
|
|
339
|
+
normalized["historical_context"] = _rank_analogues(
|
|
340
|
+
normalized["event_features"], normalized["historical_analogue_pool"], event_type
|
|
341
|
+
)
|
|
342
|
+
if not normalized["what_changed"]:
|
|
343
|
+
raise ResearchError("research needs at least one what_changed claim")
|
|
344
|
+
if not normalized["market_interpretation"]:
|
|
345
|
+
raise ResearchError("research needs at least one market_interpretation claim")
|
|
346
|
+
if not normalized["transmission"]:
|
|
347
|
+
raise ResearchError("research needs at least one transmission step; use unverified when evidence is unavailable")
|
|
348
|
+
if len(normalized["catalysts"]) < 2:
|
|
349
|
+
raise ResearchError("research needs at least two relevant catalysts")
|
|
350
|
+
if len(normalized["catalysts"]) > 8:
|
|
351
|
+
raise ResearchError("research supports at most eight relevant catalysts")
|
|
352
|
+
catalyst_keys = [(item["date"], item["event"].casefold()) for item in normalized["catalysts"]]
|
|
353
|
+
if len(catalyst_keys) != len(set(catalyst_keys)):
|
|
354
|
+
raise ResearchError("research contains duplicate catalysts")
|
|
355
|
+
if any(item["date"] <= event_date for item in normalized["catalysts"]):
|
|
356
|
+
raise ResearchError("catalysts must be after the research event_date")
|
|
357
|
+
history = normalized["historical_context"]
|
|
358
|
+
if any(item["date"] >= event_date for item in history["comparisons"]):
|
|
359
|
+
raise ResearchError("historical comparisons must be before the research event_date")
|
|
360
|
+
return normalized
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def load_research_file(path_text: str) -> list[dict[str, Any]]:
|
|
364
|
+
path = Path(path_text).expanduser()
|
|
365
|
+
if not path.is_file():
|
|
366
|
+
raise ResearchError(f"research file not found: {path}")
|
|
367
|
+
try:
|
|
368
|
+
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
369
|
+
except json.JSONDecodeError as exc:
|
|
370
|
+
raise ResearchError(f"invalid research JSON: {exc}") from exc
|
|
371
|
+
items = payload.get("research") if isinstance(payload, dict) else payload
|
|
372
|
+
if not isinstance(items, list) or not all(isinstance(item, dict) for item in items):
|
|
373
|
+
raise ResearchError("research file must be an array or contain a research array")
|
|
374
|
+
normalized = [normalize_research(item) for item in items]
|
|
375
|
+
keys = [(item["event_id"], item["event_date"]) for item in normalized]
|
|
376
|
+
if len(keys) != len(set(keys)):
|
|
377
|
+
raise ResearchError("research file contains duplicate event keys")
|
|
378
|
+
return normalized
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def select_research(items: list[dict[str, Any]], event: dict[str, Any]) -> dict[str, Any]:
|
|
382
|
+
matches = [item for item in items if item["event_id"] == event["event_id"] and item["event_date"] == event["event_date"]]
|
|
383
|
+
if not matches:
|
|
384
|
+
matches = [item for item in items if item["event_name"].casefold() == event["event_name"].casefold() and item["event_date"] == event["event_date"]]
|
|
385
|
+
if not matches and event.get("event_family"):
|
|
386
|
+
matches = [item for item in items if item.get("event_family") == event.get("event_family") and item["region"] == event["region"] and item["event_date"] == event["event_date"]]
|
|
387
|
+
if len(matches) != 1:
|
|
388
|
+
raise ResearchError(f"research file must contain exactly one matching event; found {len(matches)}")
|
|
389
|
+
return matches[0]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "market-event-impact",
|
|
3
|
+
"description": "GS Quant의 event_study·event_impact_analysis·CalendarAlignment 구조에서 영감을 받아, 정례 발표와 비정형 사건의 핵심 변화 및 시장 해석을 공식자료·당시 보도·검증 가격으로 정리한다. 교차자산 전달경로를 살피고 이벤트 스터디는 보조 증거로 제시하며, 유사사례와 다음 촉매를 한국어로 설명한다. Use for named market-moving events. Not for live quotes, forecasts, or investment advice.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"lookup",
|
|
6
|
+
"browser"
|
|
7
|
+
],
|
|
8
|
+
"frontmatter": "name: market-event-impact\ndescription: GS Quant의 event_study·event_impact_analysis·CalendarAlignment 구조에서 영감을 받아, 정례 발표와 비정형 사건의 핵심 변화 및 시장 해석을 공식자료·당시 보도·검증 가격으로 정리한다. 교차자산 전달경로를 살피고 이벤트 스터디는 보조 증거로 제시하며, 유사사례와 다음 촉매를 한국어로 설명한다. Use for named market-moving events. Not for live quotes, forecasts, or investment advice.\nlicense: MIT\nmetadata:\n category: finance\n locale: ko-KR\n phase: v1"
|
|
9
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# 멀티에셋 모닝 브리핑
|
|
2
|
+
|
|
3
|
+
## What this skill does
|
|
4
|
+
|
|
5
|
+
서울 기준 아침에 끝난 가장 최근 미국·한국 세션을 각각 확인하고, 글로벌 자산의 움직임과 한국시장 전달 경로를 근거 링크가 있는 한국어 리서치 메모로 작성한다. 조회·작성 전용이며 실시간 호가, 매매 신호, 투자 권유는 제공하지 않는다.
|
|
6
|
+
|
|
7
|
+
## When to use
|
|
8
|
+
|
|
9
|
+
- 모닝 브리핑, Morning Call, 오버나이트 시황, 데일리 마켓 브리핑 작성
|
|
10
|
+
- 기존 브리핑의 숫자·세션·출처·인과관계 검수
|
|
11
|
+
|
|
12
|
+
## Workflow
|
|
13
|
+
|
|
14
|
+
### 1. 기준일과 세션
|
|
15
|
+
|
|
16
|
+
`briefing-date`는 서울 기준 브리핑 날짜다. 미국 오버나잇과 한국 전일은 같은 달력 날짜라고 가정하지 않는다.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx -y @nomadamas/k-skill@0 exec multi-asset-morning-briefing scripts/market_data.py -- snapshot --briefing-date 2026-09-17
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`sessions.us_overnight`는 Cboe VIX 관측일, `sessions.korea_previous`는 ECOS KOSPI 관측일로 독립 확인한다. 한 원천이 실패하면 다른 시장의 결과는 유지하고 해당 세션만 `null`로 둔다. 브리핑 시점 이후 관측값, 장중 값, 다음 거래일 값은 사용하지 않는다.
|
|
23
|
+
|
|
24
|
+
개별 공식 원천을 다시 확인할 때:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx -y @nomadamas/k-skill@0 exec multi-asset-morning-briefing scripts/market_data.py -- yields --last 5 --session 2026-09-16
|
|
28
|
+
npx -y @nomadamas/k-skill@0 exec multi-asset-morning-briefing scripts/market_data.py -- cboe-vix --last 5 --session 2026-09-16
|
|
29
|
+
npx -y @nomadamas/k-skill@0 exec multi-asset-morning-briefing scripts/market_data.py -- ecb-fx --last 5 --session 2026-09-16
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
helper의 `observed`, `session`, `basis`, `unit`, `stale`, `warnings`, `failures`를 확인한다. `stale: true`인 값은 목표 세션 값으로 쓰지 않는다.
|
|
33
|
+
|
|
34
|
+
### 2. 공식 원천 우선 조사
|
|
35
|
+
|
|
36
|
+
helper는 전체 브리핑이 아니라 검증 가능한 기초 사실 집합이다. browser/web 조사로 다음을 보강한다.
|
|
37
|
+
|
|
38
|
+
1. 중앙은행·통계기관·재무부·거래소·기업 IR/공시
|
|
39
|
+
2. 지수·선물·원자재 거래소 또는 신뢰할 수 있는 시세 제공처
|
|
40
|
+
3. Reuters, Bloomberg, CNBC, 연합인포맥스, 연합뉴스 등 원문 기사
|
|
41
|
+
|
|
42
|
+
최소 확인 범위:
|
|
43
|
+
|
|
44
|
+
- S&P 500, Nasdaq, Dow, Russell 2000, SOX, VIX와 주요 업종
|
|
45
|
+
- 미국 국채 2Y·10Y·30Y, 2s10s, 핵심 거시지표 실제·예상·이전치
|
|
46
|
+
- DXY, USD/JPY, EUR/USD, GBP/USD, USD/KRW 현물 또는 NDF
|
|
47
|
+
- WTI·Brent(`USD/bbl`), 금(`USD/oz`), 구리(`USD/lb` 또는 `USD/metric ton`)
|
|
48
|
+
- 지수 기여도가 크거나 촉매가 확인된 개별 종목
|
|
49
|
+
- KOSPI·KOSDAQ, 국내 3Y·10Y, EWY·야간선물·ADR 등 한국 선행 신호
|
|
50
|
+
|
|
51
|
+
가격은 `종목/계약월 · 세션 · 종가/정산가/장중 · 값 · 등락률`을 한 묶음으로 확인한다. 숫자나 방향의 차이를 세션·계약월·단위로 설명하지 못하면 제외한다.
|
|
52
|
+
|
|
53
|
+
### 3. 뉴스와 기준시각
|
|
54
|
+
|
|
55
|
+
브리핑 날짜의 `07:00 KST`를 기본 cutoff로 사용하되 사용자가 다른 작성 시각을 주면 그 시각을 따른다. 발행시각이 cutoff 이후인 기사와 장 마감 뒤 기사는 해당 브리핑 근거에서 제외한다.
|
|
56
|
+
|
|
57
|
+
제목 부분 문자열만으로 섹션을 분류하지 않는다. 기사 원문을 열어 대상 자산과 촉매를 확인한다. 예를 들어 `공원화`는 원화 기사로, 단순 달러 금액은 FX 기사로 분류하지 않는다.
|
|
58
|
+
|
|
59
|
+
### 4. 향후 5거래일 일정
|
|
60
|
+
|
|
61
|
+
Investing.com HTML 자동 파싱을 사용하지 않는다. 브리핑일부터 시작하는 **한국 거래일 5개**를 먼저 계산하고, Fed·BLS·BEA·U.S. Treasury·ECB·BOJ·한국은행·통계청·기업 IR 등 공식 일정에서 날짜와 시간을 확인한다. 공식 일정이 불명확할 때만 신뢰 가능한 경제 캘린더로 교차 확인한다.
|
|
62
|
+
|
|
63
|
+
각 일정에는 날짜, KST 시간(확인 가능할 때), 이벤트, 예상/이전치(확보 시), 시장이 주목할 이유를 적고 공식 일정 링크를 붙인다.
|
|
64
|
+
|
|
65
|
+
### 5. 분석과 출처
|
|
66
|
+
|
|
67
|
+
- 사실·수치: 원천 수치와 기준시점이 확인된 경우만 사용
|
|
68
|
+
- 원인: 기사나 공식 발언이 직접 설명했거나 뉴스와 가격 반응이 함께 뒷받침할 때만 단정
|
|
69
|
+
- 전망: `~할 경우`, `확인할 변수`, `부담 요인`처럼 조건부로 표시
|
|
70
|
+
- 한국시장: 업종·스타일·외국인 수급·환율·금리 경로와 반대 위험을 함께 제시
|
|
71
|
+
|
|
72
|
+
모든 사실·수치·해석 bullet 끝에 직접 근거가 되는 Markdown 링크를 하나 이상 붙인다. 검색 결과 제목이나 스니펫은 출처로 쓰지 않는다.
|
|
73
|
+
|
|
74
|
+
세부 산술·단위·문체 검수 규칙은 필요할 때 읽는다.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx -y @nomadamas/k-skill@0 read multi-asset-morning-briefing references/format-rules.md
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Output contract
|
|
81
|
+
|
|
82
|
+
파일을 만들지 않고 대화에만 출력한다. 첫 줄과 7개 섹션 이름은 정확히 다음과 같다.
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
YYYY.MM.DD Morning Market Briefing
|
|
86
|
+
|
|
87
|
+
1. Summary
|
|
88
|
+
2. Rates
|
|
89
|
+
3. FX
|
|
90
|
+
4. Commodity
|
|
91
|
+
5. Equity, Vol
|
|
92
|
+
6. 한국 증시
|
|
93
|
+
7. 주요 일정
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- 각 항목은 `•`로 시작하고 핵심 수치/주장과 의미를 한 문장 안에서 연결한다.
|
|
97
|
+
- 문장 끝은 `상승`, `하락`, `확대`, `축소`, `부담`, `긍정적`, `핵심`, `변수`, `확인 필요` 같은 리서치 메모형으로 맞춘다.
|
|
98
|
+
- 작성자 이름과 `by` 표기를 넣지 않는다.
|
|
99
|
+
- 항목을 못 구했으면 그 항목만 `확보하지 못함`으로 표시한다. 근거가 전혀 없는 섹션만 `확보 실패`로 표시한다.
|
|
100
|
+
|
|
101
|
+
## Done when
|
|
102
|
+
|
|
103
|
+
- 미국·한국 세션이 독립적으로 확인됨
|
|
104
|
+
- 7개 섹션과 향후 5개 한국 거래일 일정이 모두 존재함
|
|
105
|
+
- 핵심 자산의 값·단위·세션·가격 기준이 명시됨
|
|
106
|
+
- 모든 사실·수치·해석에 직접 근거 링크가 있음
|
|
107
|
+
- 확인되지 않은 값, cutoff 이후 기사, 서술형 종결, 작성자 표기가 없음
|
|
108
|
+
|
|
109
|
+
## Failure modes
|
|
110
|
+
|
|
111
|
+
| 상황 | 동작 |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| 한 원천의 HTTP·파싱 실패 | `failures[]`에 격리하고 다른 원천·섹션 계속 진행 |
|
|
114
|
+
| `stale: true` 또는 세션 불일치 | 대체 원천 확인, 해결되지 않으면 값 제외 |
|
|
115
|
+
| 공식 일정 접근 실패 | 다른 공식 기관 일정 → 신뢰 가능한 캘린더 순으로 교차 확인 |
|
|
116
|
+
| 출처 간 값 불일치 | 세션·계약월·단위·종가/정산가를 대조; 해결되지 않으면 제외 |
|
|
117
|
+
| 핵심 근거 전체 실패 | 기억이나 추정으로 채우지 않고 확보 실패를 명시 |
|