@nomadamas/k-skill 0.9.0 → 0.9.2

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.
@@ -0,0 +1,1003 @@
1
+ #!/usr/bin/env python3
2
+ """멀티에셋 모닝 브리핑용 공개 시장 데이터 helper.
3
+
4
+ 미국 재무부·Cboe·ECB·한국은행 ECOS의 공개 read-only 원천을 우선한다.
5
+ API 키·프록시·로그인이 필요 없으므로 사용자 머신에서 직접 호출한다.
6
+
7
+ 핵심 원칙:
8
+ - 관측일(observation date)을 항상 함께 보고한다. 목표 세션보다 오래된 값은 stale로 표시한다.
9
+ - 결측은 보간하지 않는다. 없는 값은 None으로 남긴다.
10
+ - bp 변화는 원자료로 계산한다. 반올림된 표시값으로 다시 계산하지 않는다.
11
+ """
12
+
13
+ import argparse
14
+ import csv
15
+ import io
16
+ import json
17
+ import re
18
+ import sys
19
+ import urllib.error
20
+ import urllib.request
21
+ import zipfile
22
+ from datetime import date, datetime, timedelta, timezone
23
+
24
+ DEFAULT_TIMEOUT = 30
25
+ FETCH_ATTEMPTS = 2
26
+ MAX_RESPONSE_BYTES = 8 * 1024 * 1024
27
+ TIMEOUT_HINT = (
28
+ "네트워크가 느리거나 차단된 환경일 수 있습니다. --timeout을 늘리거나, "
29
+ "확보 실패로 처리하고 해당 자산군을 본문에서 빼세요."
30
+ )
31
+ USER_AGENT = "multi-asset-morning-briefing/1.0 (+https://github.com/NomaDamas/k-skill)"
32
+
33
+ TREASURY_URL = (
34
+ "https://home.treasury.gov/resource-center/data-chart-center/interest-rates/"
35
+ "daily-treasury-rates.csv/{year}/all?type=daily_treasury_yield_curve"
36
+ "&field_tdr_date_value={year}&page&_format=csv"
37
+ )
38
+ FRED_URL = "https://fred.stlouisfed.org/graph/fredgraph.csv?id={ids}"
39
+ CBOE_VIX_URL = "https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX_History.csv"
40
+ ECB_FX_URL = (
41
+ "https://data-api.ecb.europa.eu/service/data/EXR/"
42
+ "D.USD+JPY+GBP.EUR.SP00.A?format=csvdata&startPeriod={start}&endPeriod={end}"
43
+ )
44
+ ECOS_URL = (
45
+ "https://ecos.bok.or.kr/api/StatisticSearch/sample/json/kr/1/10/"
46
+ "802Y001/D/{start}/{end}"
47
+ )
48
+
49
+ TREASURY_COLUMNS = (("2 Yr", "y2"), ("10 Yr", "y10"), ("30 Yr", "y30"))
50
+ CURVE_EPSILON_BP = 0.05
51
+
52
+
53
+ class DataError(Exception):
54
+ """수집·파싱 실패. code/message/source를 오류 봉투에 담는다."""
55
+
56
+ def __init__(self, code, message, source):
57
+ super().__init__(message)
58
+ self.code = code
59
+ self.message = message
60
+ self.source = source
61
+
62
+
63
+ # --------------------------------------------------------------------------
64
+ # 네트워크
65
+ # --------------------------------------------------------------------------
66
+
67
+
68
+ def fetch_bytes(url, timeout=DEFAULT_TIMEOUT):
69
+ """URL 바이트를 가져온다. 테스트는 이 함수를 대체한다."""
70
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
71
+ with urllib.request.urlopen(request, timeout=timeout) as response:
72
+ declared = response.headers.get("Content-Length")
73
+ if declared and int(declared) > MAX_RESPONSE_BYTES:
74
+ raise DataError("LIMIT", "원격 응답이 허용 크기를 초과했습니다", "http")
75
+ chunks = []
76
+ total = 0
77
+ while True:
78
+ chunk = response.read(min(64 * 1024, MAX_RESPONSE_BYTES - total + 1))
79
+ if not chunk:
80
+ break
81
+ total += len(chunk)
82
+ if total > MAX_RESPONSE_BYTES:
83
+ raise DataError("LIMIT", "원격 응답이 허용 크기를 초과했습니다", "http")
84
+ chunks.append(chunk)
85
+ return b"".join(chunks)
86
+
87
+
88
+ def fetch_with_retry(url, source, timeout=DEFAULT_TIMEOUT, attempts=FETCH_ATTEMPTS):
89
+ last_error = None
90
+ for _ in range(max(1, attempts)):
91
+ try:
92
+ return fetch_bytes(url, timeout)
93
+ except urllib.error.HTTPError as error:
94
+ last_error = DataError(
95
+ "HTTP", "HTTP {} 응답을 받았습니다: {}".format(error.code, url), source
96
+ )
97
+ except urllib.error.URLError as error:
98
+ if "timed out" in str(error.reason).lower():
99
+ last_error = DataError(
100
+ "TIMEOUT", _timeout_message(url, error.reason), source
101
+ )
102
+ else:
103
+ last_error = DataError(
104
+ "HTTP", "{}: {}".format(url, error.reason), source
105
+ )
106
+ except TimeoutError as error:
107
+ last_error = DataError("TIMEOUT", _timeout_message(url, error), source)
108
+ except OSError as error:
109
+ last_error = DataError("HTTP", "{}: {}".format(url, error), source)
110
+ raise last_error
111
+
112
+
113
+ def _timeout_message(url, detail):
114
+ return "{}: {} {}".format(url, detail, TIMEOUT_HINT)
115
+
116
+
117
+ # --------------------------------------------------------------------------
118
+ # 파싱
119
+ # --------------------------------------------------------------------------
120
+
121
+
122
+ def _parse_us_date(raw):
123
+ """MM/DD/YYYY -> YYYY-MM-DD."""
124
+ return datetime.strptime(raw.strip(), "%m/%d/%Y").date().isoformat()
125
+
126
+
127
+ def _parse_iso(raw):
128
+ return date.fromisoformat(raw.strip())
129
+
130
+
131
+ def _to_float(raw):
132
+ cell = raw.strip()
133
+ if not cell or cell in {".", "N/A", "NA", "null"}:
134
+ return None
135
+ try:
136
+ return float(cell.replace(",", ""))
137
+ except ValueError:
138
+ return None
139
+
140
+
141
+ def parse_treasury_csv(text):
142
+ """재무부 CSV 텍스트 -> 날짜 오름차순 레코드 목록."""
143
+ reader = csv.reader(io.StringIO(text))
144
+ try:
145
+ header = [column.strip() for column in next(reader)]
146
+ except StopIteration:
147
+ raise DataError("PARSE", "재무부 CSV가 비어 있습니다", "us-treasury")
148
+
149
+ index = {name: position for position, name in enumerate(header)}
150
+ if "Date" not in index:
151
+ raise DataError("PARSE", "재무부 CSV에 Date 컬럼이 없습니다", "us-treasury")
152
+ for column, _key in TREASURY_COLUMNS:
153
+ if column not in index:
154
+ raise DataError(
155
+ "PARSE",
156
+ "재무부 CSV에 '{}' 컬럼이 없습니다".format(column),
157
+ "us-treasury",
158
+ )
159
+
160
+ records = []
161
+ for row in reader:
162
+ if not row:
163
+ continue
164
+ raw_date = row[index["Date"]] if index["Date"] < len(row) else ""
165
+ if not raw_date.strip():
166
+ continue
167
+ try:
168
+ iso_date = _parse_us_date(raw_date)
169
+ except ValueError:
170
+ continue
171
+ record = {"date": iso_date}
172
+ for column, key in TREASURY_COLUMNS:
173
+ position = index[column]
174
+ record[key] = _to_float(row[position]) if position < len(row) else None
175
+ records.append(record)
176
+
177
+ if not records:
178
+ raise DataError(
179
+ "EMPTY", "재무부 CSV에서 유효한 행을 찾지 못했습니다", "us-treasury"
180
+ )
181
+ records.sort(key=lambda item: item["date"])
182
+ return records
183
+
184
+
185
+ def extract_fred_payloads(raw):
186
+ """FRED 응답에서 CSV 텍스트 목록을 꺼낸다.
187
+
188
+ FRED가 ZIP으로 응답하면 시리즈가 여러 CSV로 쪼개져 있으므로 전부 돌려준다.
189
+ 하나만 고르면 나머지 시리즈가 조용히 사라진다.
190
+ """
191
+ if not raw.startswith(b"PK\x03\x04"):
192
+ return [("<plain>", raw.decode("utf-8", errors="replace"))], False
193
+ try:
194
+ with zipfile.ZipFile(io.BytesIO(raw)) as archive:
195
+ payloads = []
196
+ for name in sorted(archive.namelist()):
197
+ if not name.lower().endswith(".csv"):
198
+ continue
199
+ payloads.append(
200
+ (name, archive.read(name).decode("utf-8", errors="replace"))
201
+ )
202
+ except zipfile.BadZipFile:
203
+ raise DataError("PARSE", "FRED ZIP 응답을 해석하지 못했습니다", "fred")
204
+ if not payloads:
205
+ raise DataError("PARSE", "FRED ZIP 응답에 CSV가 없습니다", "fred")
206
+ return payloads, True
207
+
208
+
209
+ def merge_fred_tables(tables):
210
+ """FRED가 시리즈별 CSV로 쪼개 응답한 경우 날짜 기준으로 병합한다."""
211
+ ordered_ids = []
212
+ buckets = {}
213
+ for ids, rows in tables:
214
+ for series_id in ids:
215
+ if series_id not in ordered_ids:
216
+ ordered_ids.append(series_id)
217
+ for row in rows:
218
+ bucket = buckets.setdefault(row["date"], {})
219
+ for series_id, value in row["values"].items():
220
+ if value is not None or series_id not in bucket:
221
+ bucket[series_id] = value
222
+ merged = [
223
+ {
224
+ "date": day,
225
+ "values": {
226
+ series_id: buckets[day].get(series_id) for series_id in ordered_ids
227
+ },
228
+ }
229
+ for day in sorted(buckets)
230
+ ]
231
+ return ordered_ids, merged
232
+
233
+
234
+ def parse_fred_csv(text):
235
+ """FRED CSV 텍스트 -> (series ids, 날짜 오름차순 rows)."""
236
+ reader = csv.reader(io.StringIO(text))
237
+ try:
238
+ header = [column.strip() for column in next(reader)]
239
+ except StopIteration:
240
+ raise DataError("PARSE", "FRED CSV가 비어 있습니다", "fred")
241
+
242
+ if not header or header[0] != "observation_date" or len(header) < 2:
243
+ raise DataError(
244
+ "PARSE", "FRED CSV 헤더가 observation_date로 시작하지 않습니다", "fred"
245
+ )
246
+
247
+ ids = header[1:]
248
+ rows = []
249
+ for row in reader:
250
+ if not row or not row[0].strip():
251
+ continue
252
+ raw_date = row[0].strip()
253
+ if not re.match(r"^\d{4}-\d{2}-\d{2}$", raw_date):
254
+ continue
255
+ values = {}
256
+ for position, series_id in enumerate(ids):
257
+ cell = row[position + 1] if position + 1 < len(row) else ""
258
+ values[series_id] = _to_float(cell)
259
+ rows.append({"date": raw_date, "values": values})
260
+
261
+ if not rows:
262
+ raise DataError("EMPTY", "FRED CSV에서 유효한 행을 찾지 못했습니다", "fred")
263
+ rows.sort(key=lambda item: item["date"])
264
+ return ids, rows
265
+
266
+
267
+ def parse_cboe_csv(text):
268
+ """Cboe VIX daily CSV -> 날짜/종가 레코드."""
269
+ reader = csv.DictReader(io.StringIO(text.lstrip("\ufeff")))
270
+ if not reader.fieldnames or not {"DATE", "CLOSE"}.issubset(
271
+ {name.strip().upper() for name in reader.fieldnames}
272
+ ):
273
+ raise DataError("PARSE", "Cboe VIX CSV 필수 컬럼이 없습니다", "cboe-vix")
274
+ rows = []
275
+ for raw in reader:
276
+ normalized = {str(key).strip().upper(): value for key, value in raw.items()}
277
+ try:
278
+ observed = _parse_us_date(normalized.get("DATE", ""))
279
+ except ValueError:
280
+ continue
281
+ value = _to_float(normalized.get("CLOSE", ""))
282
+ if value is not None:
283
+ rows.append({"date": observed, "value": value})
284
+ if not rows:
285
+ raise DataError("EMPTY", "Cboe VIX CSV에 유효한 행이 없습니다", "cboe-vix")
286
+ return sorted(rows, key=lambda row: row["date"])
287
+
288
+
289
+ def parse_ecb_csv(text):
290
+ """ECB EXR csvdata -> 날짜별 USD/JPY/GBP의 EUR 기준 환율."""
291
+ reader = csv.DictReader(io.StringIO(text.lstrip("\ufeff")))
292
+ required = {"TIME_PERIOD", "OBS_VALUE", "CURRENCY"}
293
+ if not reader.fieldnames or not required.issubset(set(reader.fieldnames)):
294
+ raise DataError("PARSE", "ECB CSV 필수 컬럼이 없습니다", "ecb-fx")
295
+ buckets = {}
296
+ for raw in reader:
297
+ observed = (raw.get("TIME_PERIOD") or "").strip()
298
+ currency = (raw.get("CURRENCY") or "").strip()
299
+ value = _to_float(raw.get("OBS_VALUE") or "")
300
+ if (
301
+ re.match(r"^\d{4}-\d{2}-\d{2}$", observed)
302
+ and currency
303
+ and value is not None
304
+ ):
305
+ buckets.setdefault(observed, {})[currency] = value
306
+ rows = [{"date": day, "values": buckets[day]} for day in sorted(buckets)]
307
+ if not rows:
308
+ raise DataError("EMPTY", "ECB CSV에 유효한 행이 없습니다", "ecb-fx")
309
+ return rows
310
+
311
+
312
+ def parse_ecos_json(text):
313
+ """ECOS JSON -> KOSPI/KOSDAQ 관측치. JSON 오류도 typed failure로 변환한다."""
314
+ try:
315
+ payload = json.loads(text)
316
+ except json.JSONDecodeError as error:
317
+ raise DataError("PARSE", "ECOS JSON 파싱 실패: {}".format(error.msg), "ecos")
318
+ if isinstance(payload, dict) and "RESULT" in payload:
319
+ result = payload["RESULT"]
320
+ raise DataError(
321
+ result.get("CODE", "ECOS"), result.get("MESSAGE", "ECOS 오류"), "ecos"
322
+ )
323
+ rows = []
324
+ for raw in payload.get("StatisticSearch", {}).get("row", []):
325
+ compact = str(raw.get("TIME") or "")
326
+ if not re.match(r"^\d{8}$", compact):
327
+ continue
328
+ label = str(raw.get("ITEM_NAME1") or raw.get("ITEM_CODE1") or "")
329
+ value = _to_float(str(raw.get("DATA_VALUE") or ""))
330
+ if value is not None and (
331
+ "KOSPI" in label.upper() or "KOSDAQ" in label.upper()
332
+ ):
333
+ rows.append(
334
+ {
335
+ "date": "{}-{}-{}".format(compact[:4], compact[4:6], compact[6:]),
336
+ "symbol": "KOSDAQ" if "KOSDAQ" in label.upper() else "KOSPI",
337
+ "value": value,
338
+ }
339
+ )
340
+ if not rows:
341
+ raise DataError("EMPTY", "ECOS 응답에 KOSPI/KOSDAQ 관측치가 없습니다", "ecos")
342
+ return sorted(rows, key=lambda row: row["date"])
343
+
344
+
345
+ def latest_before(rows, cutoff):
346
+ candidates = [row for row in rows if row["date"] < cutoff]
347
+ return candidates[-1] if candidates else None
348
+
349
+
350
+ def ecb_crosses(row):
351
+ values = row["values"]
352
+ usd, jpy, gbp = values.get("USD"), values.get("JPY"), values.get("GBP")
353
+ result = {}
354
+ if usd is not None:
355
+ result["EUR/USD"] = {"value": round(usd, 6), "unit": "USD/EUR"}
356
+ if usd and jpy is not None:
357
+ result["USD/JPY"] = {"value": round(jpy / usd, 6), "unit": "JPY/USD"}
358
+ if gbp and usd is not None:
359
+ result["GBP/USD"] = {"value": round(usd / gbp, 6), "unit": "USD/GBP"}
360
+ return result
361
+
362
+
363
+ # --------------------------------------------------------------------------
364
+ # 계산
365
+ # --------------------------------------------------------------------------
366
+
367
+
368
+ def classify_curve(delta_2y_bp, delta_10y_bp):
369
+ """2Y·10Y bp 변화로 curve를 판정한다."""
370
+ if delta_2y_bp is None or delta_10y_bp is None:
371
+ return None, None
372
+
373
+ if abs(delta_2y_bp) < CURVE_EPSILON_BP and abs(delta_10y_bp) < CURVE_EPSILON_BP:
374
+ return "flat", "Flat"
375
+ if abs(delta_2y_bp - delta_10y_bp) < CURVE_EPSILON_BP:
376
+ return "flat", "Flat"
377
+ if delta_2y_bp > 0 and delta_10y_bp > 0:
378
+ if delta_2y_bp > delta_10y_bp:
379
+ return "bear_flattening", "Bear Flattening"
380
+ return "bear_steepening", "Bear Steepening"
381
+ if delta_2y_bp < 0 and delta_10y_bp < 0:
382
+ if abs(delta_2y_bp) > abs(delta_10y_bp):
383
+ return "bull_steepening", "Bull Steepening"
384
+ return "bull_flattening", "Bull Flattening"
385
+ return "mixed", "Mixed"
386
+
387
+
388
+ def compute_yield_rows(records):
389
+ """원자료 레코드에 spread·bp 변화·curve 판정을 붙인다."""
390
+ rows = []
391
+ previous = None
392
+ for record in records:
393
+ y2 = record.get("y2")
394
+ y10 = record.get("y10")
395
+ row = {
396
+ "date": record["date"],
397
+ "y2": _round(y2, 3),
398
+ "y10": _round(y10, 3),
399
+ "y30": _round(record.get("y30"), 3),
400
+ }
401
+
402
+ if y2 is not None and y10 is not None:
403
+ row["spread_2s10s_bp"] = _round((y10 - y2) * 100, 1)
404
+ else:
405
+ row["spread_2s10s_bp"] = None
406
+
407
+ previous_y2 = previous.get("y2") if previous else None
408
+ previous_y10 = previous.get("y10") if previous else None
409
+ if None not in (y2, y10, previous_y2, previous_y10):
410
+ delta_2y = (y2 - previous_y2) * 100
411
+ delta_10y = (y10 - previous_y10) * 100
412
+ curve, label = classify_curve(delta_2y, delta_10y)
413
+ row["d_y2_bp"] = _round(delta_2y, 1)
414
+ row["d_y10_bp"] = _round(delta_10y, 1)
415
+ row["d_spread_2s10s_bp"] = _round(delta_10y - delta_2y, 1)
416
+ row["curve"] = curve
417
+ row["curve_label"] = label
418
+ else:
419
+ row["d_y2_bp"] = None
420
+ row["d_y10_bp"] = None
421
+ row["d_spread_2s10s_bp"] = None
422
+ row["curve"] = None
423
+ row["curve_label"] = None
424
+
425
+ rows.append(row)
426
+ previous = record
427
+ return rows
428
+
429
+
430
+ def assess_staleness(latest_date, session):
431
+ """관측일이 목표 세션보다 이전이면 (True, 지연일수)."""
432
+ if not latest_date or not session:
433
+ return False, 0
434
+ try:
435
+ lag = (_parse_iso(session) - _parse_iso(latest_date)).days
436
+ except ValueError:
437
+ return False, 0
438
+ if lag > 0:
439
+ return True, lag
440
+ return False, 0
441
+
442
+
443
+ def trim_rows(rows, start=None, end=None, last=None):
444
+ selected = rows
445
+ if start:
446
+ selected = [row for row in selected if row["date"] >= start]
447
+ if end:
448
+ selected = [row for row in selected if row["date"] <= end]
449
+ if last is not None and last > 0:
450
+ selected = selected[-last:]
451
+ return selected
452
+
453
+
454
+ def summarize_series(rows, series_id, session):
455
+ latest_date = None
456
+ latest_value = None
457
+ for row in rows:
458
+ value = row["values"].get(series_id)
459
+ if value is not None:
460
+ latest_date = row["date"]
461
+ latest_value = value
462
+ stale, lag_days = assess_staleness(latest_date, session)
463
+ return {
464
+ "id": series_id,
465
+ "latest_date": latest_date,
466
+ "latest_value": _round(latest_value, 4),
467
+ "stale": stale,
468
+ "lag_days": lag_days,
469
+ }
470
+
471
+
472
+ def _round(value, digits):
473
+ return None if value is None else round(value, digits)
474
+
475
+
476
+ def _now_utc():
477
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
478
+
479
+
480
+ # --------------------------------------------------------------------------
481
+ # 출력
482
+ # --------------------------------------------------------------------------
483
+
484
+
485
+ def render_yields_text(envelope):
486
+ lines = ["미 국채 par yield curve (출처: U.S. Department of the Treasury)", ""]
487
+ if envelope["session"]:
488
+ lines.append("목표 세션: {}".format(envelope["session"]))
489
+ lines.append("")
490
+ lines.append(
491
+ "날짜 2Y 10Y 30Y 2s10s(bp) Δ2Y Δ10Y Δ2s10s curve"
492
+ )
493
+ for row in envelope["rows"]:
494
+ lines.append(
495
+ "{date} {y2:>6} {y10:>6} {y30:>6} {spread:>9} {d2:>6} {d10:>6} {ds:>7} {curve}".format(
496
+ date=row["date"],
497
+ y2=_fmt(row["y2"], 3),
498
+ y10=_fmt(row["y10"], 3),
499
+ y30=_fmt(row["y30"], 3),
500
+ spread=_fmt(row["spread_2s10s_bp"], 1),
501
+ d2=_fmt(row["d_y2_bp"], 1),
502
+ d10=_fmt(row["d_y10_bp"], 1),
503
+ ds=_fmt(row["d_spread_2s10s_bp"], 1),
504
+ curve=row["curve_label"] or "-",
505
+ )
506
+ )
507
+ _append_warnings(lines, envelope["warnings"])
508
+ return "\n".join(lines)
509
+
510
+
511
+ def render_series_text(envelope):
512
+ lines = ["FRED 공개 시계열 (출처: Federal Reserve Bank of St. Louis)", ""]
513
+ if envelope["session"]:
514
+ lines.append("목표 세션: {}".format(envelope["session"]))
515
+ lines.append("")
516
+ lines.append("시리즈 최신 관측일 최신값 신선도")
517
+ for item in envelope["series"]:
518
+ freshness = (
519
+ "OK" if not item["stale"] else "STALE ({}일 지연)".format(item["lag_days"])
520
+ )
521
+ lines.append(
522
+ "{id:<12} {date:<12} {value:>12} {freshness}".format(
523
+ id=item["id"],
524
+ date=item["latest_date"] or "-",
525
+ value=_fmt(item["latest_value"], 4),
526
+ freshness=freshness,
527
+ )
528
+ )
529
+ lines.append("")
530
+ lines.append(
531
+ "날짜 "
532
+ + " ".join("{:>12}".format(item["id"]) for item in envelope["series"])
533
+ )
534
+ for row in envelope["rows"]:
535
+ cells = " ".join(
536
+ "{:>12}".format(_fmt(row["values"].get(item["id"]), 4))
537
+ for item in envelope["series"]
538
+ )
539
+ lines.append("{} {}".format(row["date"], cells))
540
+ _append_warnings(lines, envelope["warnings"])
541
+ return "\n".join(lines)
542
+
543
+
544
+ def _fmt(value, digits):
545
+ return "-" if value is None else ("{:." + str(digits) + "f}").format(value)
546
+
547
+
548
+ def _append_warnings(lines, warnings):
549
+ if warnings:
550
+ lines.append("")
551
+ lines.append("경고:")
552
+ for warning in warnings:
553
+ lines.append("- {}".format(warning))
554
+
555
+
556
+ def emit(envelope, as_text, text_renderer):
557
+ if as_text:
558
+ print(text_renderer(envelope))
559
+ else:
560
+ print(json.dumps(envelope, ensure_ascii=False, indent=2))
561
+
562
+
563
+ # --------------------------------------------------------------------------
564
+ # 커맨드
565
+ # --------------------------------------------------------------------------
566
+
567
+
568
+ def build_yields_envelope(args):
569
+ year = args.year or date.today().year
570
+ url = TREASURY_URL.format(year=year)
571
+ raw = fetch_with_retry(url, "us-treasury", timeout=args.timeout)
572
+ records = parse_treasury_csv(raw.decode("utf-8", errors="replace"))
573
+ rows = compute_yield_rows(records)
574
+ warnings = []
575
+ if args.session:
576
+ latest = rows[-1]["date"]
577
+ stale, lag_days = assess_staleness(latest, args.session)
578
+ if stale:
579
+ warnings.append(
580
+ "최신 관측일 {}이(가) 목표 세션 {}보다 {}일 이전입니다. 이 값을 해당 세션 값으로 쓰지 마세요.".format(
581
+ latest, args.session, lag_days
582
+ )
583
+ )
584
+ trimmed = trim_rows(rows, start=args.start, end=args.end, last=args.last)
585
+ if not trimmed:
586
+ raise DataError(
587
+ "EMPTY", "지정한 기간에 해당하는 금리 관측치가 없습니다", "us-treasury"
588
+ )
589
+ return {
590
+ "ok": True,
591
+ "command": "yields",
592
+ "session": args.session,
593
+ "source": {"name": "us-treasury", "url": url, "fetched_at": _now_utc()},
594
+ "rows": trimmed,
595
+ "series": [],
596
+ "warnings": warnings,
597
+ }
598
+
599
+
600
+ def build_series_envelope(args):
601
+ ids = [item.strip() for item in args.ids.split(",") if item.strip()]
602
+ if not ids:
603
+ raise DataError(
604
+ "USAGE", "--ids에 시리즈 ID를 하나 이상 지정해야 합니다", "fred"
605
+ )
606
+
607
+ url = FRED_URL.format(ids=",".join(ids))
608
+ raw = fetch_with_retry(url, "fred", timeout=args.timeout)
609
+ payloads, was_zipped = extract_fred_payloads(raw)
610
+ parsed_ids, rows = merge_fred_tables(
611
+ [parse_fred_csv(text) for _name, text in payloads]
612
+ )
613
+
614
+ warnings = []
615
+ if was_zipped:
616
+ warnings.append(
617
+ "FRED 응답이 ZIP이어서 내부 CSV {}개를 병합했습니다.".format(len(payloads))
618
+ )
619
+ missing = [series_id for series_id in ids if series_id not in parsed_ids]
620
+ if missing:
621
+ warnings.append(
622
+ "요청한 시리즈 {}가 응답에 없습니다. 해당 시리즈는 본문에 쓰지 마세요.".format(
623
+ ", ".join(missing)
624
+ )
625
+ )
626
+ if len(rows) > 200:
627
+ warnings.append(
628
+ "FRED CSV endpoint는 기간 파라미터를 무시하고 전체 이력을 반환합니다. helper가 {}개 행에서 클라이언트 절단을 수행했습니다.".format(
629
+ len(rows)
630
+ )
631
+ )
632
+
633
+ series = [
634
+ summarize_series(rows, series_id, args.session) for series_id in parsed_ids
635
+ ]
636
+ for item in series:
637
+ if item["stale"]:
638
+ warnings.append(
639
+ "{} 최신 관측일 {}이(가) 목표 세션 {}보다 {}일 이전입니다. 이 값을 해당 세션 값으로 쓰지 마세요.".format(
640
+ item["id"], item["latest_date"], args.session, item["lag_days"]
641
+ )
642
+ )
643
+
644
+ trimmed = trim_rows(rows, start=args.start, end=args.end, last=args.last)
645
+ if not trimmed:
646
+ raise DataError(
647
+ "EMPTY", "지정한 기간에 해당하는 FRED 관측치가 없습니다", "fred"
648
+ )
649
+ return {
650
+ "ok": True,
651
+ "command": "series",
652
+ "session": args.session,
653
+ "source": {"name": "fred", "url": url, "fetched_at": _now_utc()},
654
+ "rows": trimmed,
655
+ "series": series,
656
+ "warnings": warnings,
657
+ }
658
+
659
+
660
+ def _dated_source_envelope(command, source, url, rows, session, last):
661
+ selected = trim_rows(rows, last=last)
662
+ latest = rows[-1]
663
+ stale, lag_days = assess_staleness(latest["date"], session)
664
+ warnings = []
665
+ if stale:
666
+ warnings.append(
667
+ "최신 관측일 {}이(가) 목표 세션 {}보다 {}일 이전입니다.".format(
668
+ latest["date"], session, lag_days
669
+ )
670
+ )
671
+ return {
672
+ "ok": True,
673
+ "command": command,
674
+ "session": session,
675
+ "source": {"name": source, "url": url, "fetched_at": _now_utc()},
676
+ "rows": selected,
677
+ "stale": stale,
678
+ "lag_days": lag_days,
679
+ "warnings": warnings,
680
+ }
681
+
682
+
683
+ def build_cboe_envelope(args):
684
+ raw = fetch_with_retry(CBOE_VIX_URL, "cboe-vix", timeout=args.timeout)
685
+ rows = parse_cboe_csv(raw.decode("utf-8", errors="replace"))
686
+ envelope = _dated_source_envelope(
687
+ "cboe-vix", "Cboe VIX", CBOE_VIX_URL, rows, args.session, args.last
688
+ )
689
+ for row in envelope["rows"]:
690
+ row.update(symbol="VIX", unit="pt", basis="close")
691
+ return envelope
692
+
693
+
694
+ def build_ecb_envelope(args):
695
+ end = args.end or date.today().isoformat()
696
+ start = args.start or (date.fromisoformat(end) - timedelta(days=14)).isoformat()
697
+ url = ECB_FX_URL.format(start=start, end=end)
698
+ raw = fetch_with_retry(url, "ecb-fx", timeout=args.timeout)
699
+ rows = parse_ecb_csv(raw.decode("utf-8", errors="replace"))
700
+ converted = [
701
+ {"date": row["date"], "values": ecb_crosses(row), "basis": "reference-rate"}
702
+ for row in rows
703
+ ]
704
+ return _dated_source_envelope(
705
+ "ecb-fx", "ECB reference rates", url, converted, args.session, args.last
706
+ )
707
+
708
+
709
+ def _failure(error, section):
710
+ return {
711
+ "source": error.source,
712
+ "error": error.code,
713
+ "message": error.message,
714
+ "section": section,
715
+ }
716
+
717
+
718
+ def build_snapshot_envelope(args):
719
+ """공식 원천을 독립 호출해 한 원천 실패가 다른 자산군을 중단하지 않게 한다."""
720
+ briefing = date.fromisoformat(args.briefing_date)
721
+ cutoff = briefing.isoformat()
722
+ start = (briefing - timedelta(days=21)).isoformat()
723
+ items, warnings, failures = [], [], []
724
+ sessions = {"us_overnight": None, "korea_previous": None}
725
+
726
+ try:
727
+ raw = fetch_with_retry(CBOE_VIX_URL, "cboe-vix", timeout=args.timeout)
728
+ row = latest_before(
729
+ parse_cboe_csv(raw.decode("utf-8", errors="replace")), cutoff
730
+ )
731
+ if not row:
732
+ raise DataError("EMPTY", "브리핑일 이전 Cboe 관측치가 없습니다", "cboe-vix")
733
+ sessions["us_overnight"] = row["date"]
734
+ items.append(
735
+ {
736
+ "symbol": "VIX",
737
+ "section": "Equity, Vol",
738
+ "value": row["value"],
739
+ "unit": "pt",
740
+ "observed": row["date"],
741
+ "session": row["date"],
742
+ "basis": "close",
743
+ "source_name": "Cboe",
744
+ "source_url": CBOE_VIX_URL,
745
+ }
746
+ )
747
+ except DataError as error:
748
+ failures.append(_failure(error, "Equity, Vol"))
749
+
750
+ ecos_url = None
751
+ try:
752
+ rows, row = [], None
753
+ candidate = briefing - timedelta(days=1)
754
+ for _ in range(14):
755
+ if candidate.weekday() < 5:
756
+ compact = candidate.strftime("%Y%m%d")
757
+ ecos_url = ECOS_URL.format(start=compact, end=compact)
758
+ try:
759
+ raw = fetch_with_retry(
760
+ ecos_url, "ecos", timeout=args.timeout, attempts=1
761
+ )
762
+ rows = parse_ecos_json(raw.decode("utf-8", errors="replace"))
763
+ kospi_rows = [
764
+ market_row
765
+ for market_row in rows
766
+ if market_row["symbol"] == "KOSPI"
767
+ ]
768
+ row = kospi_rows[-1] if kospi_rows else None
769
+ except DataError as candidate_error:
770
+ if candidate_error.code not in {"EMPTY", "INFO-200"}:
771
+ raise
772
+ if row:
773
+ break
774
+ candidate -= timedelta(days=1)
775
+ if not row:
776
+ raise DataError("EMPTY", "브리핑일 이전 KOSPI 관측치가 없습니다", "ecos")
777
+ sessions["korea_previous"] = row["date"]
778
+ for market_row in rows:
779
+ if market_row["date"] == row["date"]:
780
+ items.append(
781
+ {
782
+ "symbol": market_row["symbol"],
783
+ "section": "한국 증시",
784
+ "value": market_row["value"],
785
+ "unit": "pt",
786
+ "observed": market_row["date"],
787
+ "session": row["date"],
788
+ "basis": "close",
789
+ "source_name": "한국은행 ECOS",
790
+ "source_url": ecos_url,
791
+ }
792
+ )
793
+ except DataError as error:
794
+ failures.append(_failure(error, "한국 증시"))
795
+
796
+ us_session = sessions["us_overnight"]
797
+ if us_session:
798
+ try:
799
+ url = TREASURY_URL.format(year=briefing.year)
800
+ raw = fetch_with_retry(url, "us-treasury", timeout=args.timeout)
801
+ records = [
802
+ row
803
+ for row in parse_treasury_csv(raw.decode("utf-8", errors="replace"))
804
+ if row["date"] <= us_session
805
+ ]
806
+ matched = (
807
+ records[-1] if records and records[-1]["date"] == us_session else None
808
+ )
809
+ if not matched:
810
+ warnings.append(
811
+ "Rates: 미국 세션과 일치하는 Treasury 관측치가 없어 제외했습니다."
812
+ )
813
+ else:
814
+ computed = compute_yield_rows(records)
815
+ latest = computed[-1]
816
+ for symbol, field, unit in (
817
+ ("2Y", "y2", "%"),
818
+ ("10Y", "y10", "%"),
819
+ ("30Y", "y30", "%"),
820
+ ("2s10s", "spread_2s10s_bp", "bp"),
821
+ ):
822
+ if latest[field] is not None:
823
+ items.append(
824
+ {
825
+ "symbol": symbol,
826
+ "section": "Rates",
827
+ "value": latest[field],
828
+ "unit": unit,
829
+ "observed": latest["date"],
830
+ "session": us_session,
831
+ "basis": "close",
832
+ "source_name": "U.S. Treasury",
833
+ "source_url": url,
834
+ }
835
+ )
836
+ except DataError as error:
837
+ failures.append(_failure(error, "Rates"))
838
+
839
+ try:
840
+ fx_url = ECB_FX_URL.format(start=start, end=cutoff)
841
+ raw = fetch_with_retry(fx_url, "ecb-fx", timeout=args.timeout)
842
+ fx_rows = parse_ecb_csv(raw.decode("utf-8", errors="replace"))
843
+ fx_row = next(
844
+ (row for row in reversed(fx_rows) if row["date"] <= us_session), None
845
+ )
846
+ if not fx_row or fx_row["date"] != us_session:
847
+ warnings.append(
848
+ "FX: 미국 세션과 일치하는 ECB reference rate가 없어 제외했습니다."
849
+ )
850
+ else:
851
+ for symbol, quote in ecb_crosses(fx_row).items():
852
+ items.append(
853
+ {
854
+ "symbol": symbol,
855
+ "section": "FX",
856
+ "value": quote["value"],
857
+ "unit": quote["unit"],
858
+ "observed": fx_row["date"],
859
+ "session": us_session,
860
+ "basis": "reference-rate",
861
+ "source_name": "ECB",
862
+ "source_url": fx_url,
863
+ }
864
+ )
865
+ except DataError as error:
866
+ failures.append(_failure(error, "FX"))
867
+
868
+ return {
869
+ "version": 1,
870
+ "command": "snapshot",
871
+ "generated_at": _now_utc(),
872
+ "briefing_date": cutoff,
873
+ "sessions": sessions,
874
+ "items": items,
875
+ "warnings": warnings,
876
+ "failures": failures,
877
+ }
878
+
879
+
880
+ # --------------------------------------------------------------------------
881
+ # CLI
882
+ # --------------------------------------------------------------------------
883
+
884
+
885
+ def build_parser():
886
+ parser = argparse.ArgumentParser(
887
+ prog="market_data.py",
888
+ description="멀티에셋 모닝 브리핑용 공개 시장 데이터 helper",
889
+ )
890
+ subparsers = parser.add_subparsers(dest="command")
891
+
892
+ yields_parser = subparsers.add_parser(
893
+ "yields", help="미국 재무부 일별 par yield curve"
894
+ )
895
+ yields_parser.add_argument(
896
+ "--year", type=int, default=None, help="조회 연도 (기본: 올해)"
897
+ )
898
+ _add_common_arguments(yields_parser)
899
+
900
+ series_parser = subparsers.add_parser("series", help="FRED 공개 시계열")
901
+ series_parser.add_argument(
902
+ "--ids", required=True, help="쉼표로 구분한 FRED 시리즈 ID"
903
+ )
904
+ _add_common_arguments(series_parser)
905
+
906
+ cboe_parser = subparsers.add_parser("cboe-vix", help="Cboe 공식 VIX 일별 종가")
907
+ _add_common_arguments(cboe_parser)
908
+
909
+ ecb_parser = subparsers.add_parser("ecb-fx", help="ECB 공식 일별 reference rate")
910
+ _add_common_arguments(ecb_parser)
911
+
912
+ snapshot_parser = subparsers.add_parser(
913
+ "snapshot", help="독립 세션과 공식 기초 데이터"
914
+ )
915
+ snapshot_parser.add_argument(
916
+ "--briefing-date", required=True, help="서울 기준일 YYYY-MM-DD"
917
+ )
918
+ snapshot_parser.add_argument(
919
+ "--timeout", type=float, default=DEFAULT_TIMEOUT, help="HTTP 타임아웃(초)"
920
+ )
921
+
922
+ return parser
923
+
924
+
925
+ def _add_common_arguments(parser):
926
+ parser.add_argument("--last", type=int, default=None, help="마지막 N개 관측치만")
927
+ parser.add_argument("--start", default=None, help="시작일 YYYY-MM-DD")
928
+ parser.add_argument("--end", default=None, help="종료일 YYYY-MM-DD")
929
+ parser.add_argument("--session", default=None, help="목표 세션 날짜 YYYY-MM-DD")
930
+ parser.add_argument(
931
+ "--timeout", type=float, default=DEFAULT_TIMEOUT, help="HTTP 타임아웃(초)"
932
+ )
933
+ parser.add_argument("--text", action="store_true", help="JSON 대신 사람이 읽는 표")
934
+
935
+
936
+ def main(argv=None):
937
+ parser = build_parser()
938
+ args = parser.parse_args(argv)
939
+ if not args.command:
940
+ parser.print_help()
941
+ return 2
942
+
943
+ try:
944
+ if args.command == "yields":
945
+ envelope = build_yields_envelope(args)
946
+ emit(envelope, args.text, render_yields_text)
947
+ elif args.command == "series":
948
+ envelope = build_series_envelope(args)
949
+ emit(envelope, args.text, render_series_text)
950
+ elif args.command == "cboe-vix":
951
+ print(json.dumps(build_cboe_envelope(args), ensure_ascii=False, indent=2))
952
+ elif args.command == "ecb-fx":
953
+ print(json.dumps(build_ecb_envelope(args), ensure_ascii=False, indent=2))
954
+ else:
955
+ print(
956
+ json.dumps(build_snapshot_envelope(args), ensure_ascii=False, indent=2)
957
+ )
958
+ except ValueError as error:
959
+ data_error = DataError("USAGE", str(error), "cli")
960
+ print(
961
+ json.dumps(
962
+ {
963
+ "ok": False,
964
+ "error": {
965
+ "code": data_error.code,
966
+ "message": data_error.message,
967
+ "source": data_error.source,
968
+ },
969
+ },
970
+ ensure_ascii=False,
971
+ indent=2,
972
+ ),
973
+ file=sys.stderr,
974
+ )
975
+ return 1
976
+ except DataError as error:
977
+ if getattr(args, "text", False):
978
+ print(
979
+ "오류[{}] {}: {}".format(error.source, error.code, error.message),
980
+ file=sys.stderr,
981
+ )
982
+ else:
983
+ print(
984
+ json.dumps(
985
+ {
986
+ "ok": False,
987
+ "error": {
988
+ "code": error.code,
989
+ "message": error.message,
990
+ "source": error.source,
991
+ },
992
+ },
993
+ ensure_ascii=False,
994
+ indent=2,
995
+ ),
996
+ file=sys.stderr,
997
+ )
998
+ return 1
999
+ return 0
1000
+
1001
+
1002
+ if __name__ == "__main__":
1003
+ sys.exit(main())