instruction.md
# 사건별 시장반응 리서치
## What this skill does
한 사건이 **무엇이었고, 무엇이 달라졌으며, 왜 시장이 움직였는지**를 공식 발표·당시 보도·검증된 시장가격으로 재구성하는 한국어 대화형 리서치 스킬이다. 정례 경제지표와 통화정책 결정뿐 아니라 금융사고·신용등급 변경·관세·지정학 충격처럼 형식이 일정하지 않은 사건도 다룬다. 사건과 자산의 관측시각을 맞추고 금리·외환·주식 등 교차자산 전달경로를 증거등급으로 평가한다. GS Quant의 `event_study`·`event_impact_analysis`·`CalendarAlignment` 구조를 참고한 D-1/D0/D+1/D+5 측정은 서술을 검증하는 **보조 증거**이며 분석의 본체가 아니다. 검증 가능한 과거 유사사례와 해석을 바꿀 다음 촉매를 덧붙인다. 실시간 호가·예측·매매 추천은 다루지 않는다.
## Architecture
`Generic Event Engine + Specialized Adapter` 구조다. FOMC·금통위·ECB·물가·고용·실적은 추가 확인 항목을 제공하는 선택적 adapter일 뿐이다. adapter가 없는 SVB 파산, 신용등급 강등, 관세 발표, 군사충돌, OPEC 감산, 공급차질도 Generic Engine으로 분석한다.
```text
Event Resolver
→ verified event name / type / date / time / source
→ transmission-based asset selection
→ calendar alignment and event window
→ cross-asset response and transmission
→ characteristic-based historical analogues
→ next catalysts
+ optional specialized adapter
```
## 1. Event Resolver
사용자의 자유 문장을 whitelist와 대조하지 않는다. 먼저 사건명, 충격 분야(`event_type`), 진행 형태(`event_structure`), 기준일, 지역, 현지시각, 직접 출처를 구조화한다. `event_type`은 `financial_stress` 같은 분야이며 `point_event|scheduled_event|developing_event`와 혼용하지 않는다. 시간 정밀도는 A(정확 시각+시간대), B(날짜만)로 표시하고, C는 정밀도가 아니라 다일 진행 구조(`developing_event`)로 분리한다. 유형 분류와 입력 계약은 다음을 읽는다.
```bash
npx -y @nomadamas/k-skill@0 read market-event-impact references/event-taxonomy.md
```
검증된 사건 파일이 있으면 자유 문장으로 식별할 수 있다.
```bash
npx -y @nomadamas/k-skill@0 exec market-event-impact scripts/market_event.py -- \
resolve --query "SVB 파산" --events-file /path/to/verified-events.json
```
사건이 파일에 없으면 helper가 유형 후보만 제시하고 `verification_required`로 멈춘다. 날짜나 출처를 추측하지 않는다. browser/web로 규제기관·정부·거래소·기업 IR 등 직접 원천을 확인해 사건 파일에 추가한 뒤 다시 실행한다. 여러 날짜가 후보면 사용자 표현이나 공식 타임라인으로 기준 사건을 좁힌다. 다일 사건은 출처가 연결된 `event_sequence`에 사전 신호·최초 충격·정책 대응·시장 재개를 날짜순으로 기록한다. `developing_event`에는 2~20개 타임라인 단계와 기준 사건 날짜가 필요하다.
## 2. Dynamic Asset Selection
사용자가 자산을 지정하면 그대로 우선한다. 지정하지 않으면 지역 기본 바스켓이 아니라 사건의 예상 전달경로와 시계열의 `roles` 메타데이터로 최대 8개를 선택한다. 후보가 많으면 사건 유형에 적합한 자산군(금리·외환·주식·변동성 등)을 먼저 한 개씩 채운 뒤 세부 역할(단기·장기금리, 성장주·시장지수 등)을 보강한다. 같은 역할에서는 확인 시각까지 사건 전 기준값과 이벤트일 관측값을 모두 확보한 시계열을 우선한다. 프로필에 적합한 자산군이 없어도 무관한 종목을 억지로 채우지 않는다.
- 통화정책: 단기금리·커브·FX·광범위 주식·변동성
- 인플레이션·고용: 단기/장기금리·FX·성장주·광범위 주식·변동성
- 금융스트레스: 단기금리·은행주·신용스프레드·VIX·안전자산
- 무역정책: 단기·장기금리·커브·FX·영향 주식·원유/원자재·변동성
- 지정학·공급충격: 원유·금·FX·금리·주식·변동성
- 기업실적·규제: 해당 증권·업종·시장지수·변동성
- 국가신용: 국채·통화·CDS·금·주식
역할과 시계열 계약은 다음을 읽는다.
```bash
npx -y @nomadamas/k-skill@0 read market-event-impact references/methodology.md
```
관련 자산이 입력 파일에 없으면 다른 자산으로 의미를 바꾸지 말고 미확보로 표시한다.
## 3. Research layer
공식 타임라인과 발표, 당일 시장기사, 확정 시장가격을 교차한다. 가격만 보고 원인을 역으로 만들지 않는다. 재현 가능한 연구 계약은 다음을 읽는다.
```bash
npx -y @nomadamas/k-skill@0 read market-event-impact references/research-contract.md
```
완성형 분석에는 사건 개요, 무엇이 바뀌었는지, 시장 해석, 전달경로, 후속 촉매가 필요하다. research layer가 없으면 Event Study만 제공한다고 명시한다.
## 4. Generic Event Study
```bash
npx -y @nomadamas/k-skill@0 exec market-event-impact scripts/market_event.py -- \
analyze --query "SVB 파산" \
--series-file /path/to/market-series.json \
--event-file /path/to/verified-events.json \
--research-file /path/to/verified-research.json \
--observed-at 2023-03-20T09:00:00+09:00
```
각 자산은 자기 관측 캘린더로 정렬한다. 사건 현지시각과 시계열 `observation_timezone + observation_time`을 UTC로 비교한다. 원천 시각이 없으면 확인된 `market_timezone` 마감시각, 이어서 지역 시장 마감 추정 또는 region 달력을 대리 기준으로 쓰고 결과에 명시한다. 서울·도쿄·상하이·프랑크푸르트·런던·뉴욕 시간대는 운영체제 tzdata가 없어도 일관되게 처리한다.
`--observed-at`은 분석의 정보 컷오프다. 해당 시각 뒤의 일별·장중 관측을 제외하고, 원천 관측시각이 없는 일별 값은 같은 현지 날짜에 이미 확정됐다고 추정하지 않는다. 컷오프 뒤에 발생한 사건은 분석하지 않는다. 과거 시점 분석용 research에는 `observed_at`을 넣어 확인 시각을 고정하고, 그 시점에 아직 발표되지 않은 결과를 서술하지 않는다. 가격이 더 필요할 때는 원천에서 해당 시점에 실제로 공개된 자료만 보강한다.
입력 시계열에 `observation_timezone`, `observation_time`, `market_timezone`, `price_type`을 원천 설명대로 기록한다. 예를 들어 FRED DEXKOUS는 뉴욕 정오 매입환율이지 서울 15:30 종가가 아니다. 한국 종가 반응을 분석할 때는 서울 현지 FX 종가를 우선하고, 해당 원천이 없으면 FRED 관측치를 별도 기준시각으로 표시한다.
일별 자료의 D0는 D-1 기준값에서 D0 일별 관측값까지의 움직임이다. 정확한 사건 시각이 있어도 사건 전 장중 구간이 섞일 수 있으므로 인과효과라고 쓰지 않는다. 장중 자료가 검증되면 사건 직전 마지막 바, +1시간 이후 첫 바, 첫 후속 거래 세션의 마지막 제공 바를 표시한다. 거래소 마감 시각과 확인되지 않은 바는 공식 종가라 부르지 않는다. 다일 진행 사건의 D+1/D+5에는 후속 사건과 대응이 섞일 수 있다고 설명한다.
원천 복구 규칙은 다음을 읽는다.
```bash
npx -y @nomadamas/k-skill@0 read market-event-impact references/source-priority.md
```
공식·거래소 시계열을 먼저 확인하고, 확정 종가가 비어 있을 때 Investing.com 역사적 데이터로 보강할 수 있다. 장중값을 종가로 쓰지 않는다. 아직 도래하지 않은 D+5는 결측이 아니라 관측 미도래로 표시한다.
## 5. Specialized Adapter
adapter가 있으면 Generic 결과 위에 사건별 세부 확인을 추가한다.
- FOMC: 성명서 redline, SEP·점도표, 기자회견, 사전 정책금리 pricing
- BOK MPC: 결정문, 소수의견, 총재 기자회견, 성장·물가 전망
- ECB: 결정문, 경제전망, 기자회견, 사전 pricing
- Inflation: Headline·Core·MoM·YoY·세부품목·컨센서스·수정치
- Employment: 고용증감·실업률·임금·참가율·수정치
- Earnings: 매출·이익·가이던스·사업부문·컨센서스·경영진 발언
adapter 자료가 없다는 이유로 Generic 분석을 중단하지 않는다. 반대로 adapter 문구를 다른 사건에 강제로 적용하지 않는다.
## 6. Transmission and analogues
전달 단계 status는 `CONFIRMED`, `CONSISTENT`, `MIXED`, `CONTRADICTED`, `UNVERIFIED`로 표준화한다. `CONFIRMED`는 공식자료/신뢰도 높은 보도와 가격이 해당 연결을 직접 뒷받침할 때만 쓴다. 동시 움직임만으로는 인과를 단정하지 않는다. 일부 자산만 일치하면 `MIXED`, 반대면 `CONTRADICTED`, 근거 부족이면 `UNVERIFIED`다. 같은 날의 다른 대형 사건은 교란요인으로 표시한다.
현재 이벤트와 과거 후보 양쪽에 사건유형, `shock_mechanisms`, `affected_channels`, 관측 `market_shock_pattern`을 구조화한다. 검증된 `historical_analogue_pool`을 이 속성과 실제 충격 방향으로 점수화해 상위 3~5개를 고른다. 충격 메커니즘 중복과 최소 한 개 시장 방향 일치가 없는 후보는 제외한다. 적격 후보가 3개 미만이면 비교를 억지로 채우지 말고 미생성 이유를 표시한다. 모든 후보에는 사건 전 날짜·출처·공통점·차이점이 필요하다.
## Output contract
helper 기본 출력은 사용자용 한국어 `chat`이다. JSON은 검증·자동화에만 사용한다.
```text
1. 무슨 일이 있었나 — 사건 식별 / 핵심 변화 / surprise / optional adapter
2. 시장은 왜 반응했나
3. 교차자산 반응 — 동적으로 선택한 자산과 선택 이유
4. 전달경로
5. 이벤트 스터디 — D-1 / D0 / D+1 / D+5와 실제 정렬일
6. 과거 유사 사례 — 특성과 관측 충격 방향으로 선정한 3~5개 또는 비교 미생성 사유
7. 앞으로 볼 것 — 현재 해석을 확인하거나 뒤집을 촉매
8. 출처 및 재현 기준
```
## Failure rules
| 상황 | 동작 |
| --- | --- |
| 자유 사건의 날짜·출처 미확보 | 유형 후보만 제시하고 `verification_required` |
| 동일 이름의 사건이 여러 개 | 후보를 제시하고 날짜·지역을 추가로 식별 |
| research layer 없음 | Event Study 전용 결과로 명시 |
| 관련 자산 시계열 없음 | 해당 전달 단계를 미검증 처리 |
| D0 휴장·시장 마감 후 발표 | 실제 다음 거래일로 정렬 |
| D+5가 아직 미래 | 관측 미도래 표시 |
| 전형적 경로와 가격이 반대 | 반대 반응 자체를 핵심 결과로 서술 |
| adapter 없음 | Generic Engine을 계속 실행 |
## Done when
- 사건을 직접 출처로 식별하고 기준일·시간의 의미를 설명했다.
- 고정 지역 바스켓이 아니라 전달경로로 자산을 선택했다.
- 자산별 캘린더와 이벤트 윈도를 재현 가능하게 계산했다.
- 실제 가격과 기사 근거를 분리해 전달경로를 판정했다.
- 유사 사건을 이름이 아닌 충격 특성으로 비교했다.
- 후속 촉매가 현재 해석을 어떻게 확인하거나 뒤집는지 설명했다.
references/event-taxonomy.md
# Event Resolver 계약
사건명은 whitelist가 아니다. 아래 `event_type`은 자산선정과 전달경로를 위한 넓은 분류이며, 개별 사건은 `event_id`와 직접 출처로 식별한다.
| event_type | 대표 사건 | 우선 확인할 전달경로 |
| --- | --- | --- |
| `monetary_policy` | 금리 결정·QE/QT·YCC | 금리·커브 → FX → 주식 |
| `inflation` | CPI·PPI·HICP | 정책금리 기대 → 금리·FX·성장주 |
| `employment` | NFP·실업률·임금 | 정책금리 기대 → 금리·FX·주식 |
| `growth` | GDP·PMI | 성장기대 → 장기금리·경기민감주·원자재 |
| `financial_stress` | 은행 파산·예금유출·자금시장 경색 | 은행주·신용·변동성 → 단기금리 |
| `sovereign_credit` | 국가신용등급·디폴트 | 국채·CDS·FX·금·주식 |
| `geopolitics` | 전쟁·군사충돌·제재 | 에너지·금·FX·금리·변동성 |
| `commodity_supply` | 감산·광산/항만 차질 | 원자재 → 기대인플레이션·관련주·FX |
| `corporate_earnings` | 실적·가이던스 쇼크 | 해당 종목 → 업종 → 시장지수 |
| `fiscal_policy` | 예산·감세·부양책 | 국채공급·성장기대 → 금리·FX·주식 |
| `trade_policy` | 관세·수출통제 | 영향 업종·FX·원자재·변동성 |
| `regulation` | 규제·소송·허가 | 해당 종목·업종·시장지수 |
| `market_shock` | 급락·쇼트스퀴즈·시장기능 장애 | 원인 자산·변동성·신용·안전자산 |
| `other` | 위 분류에 맞지 않는 사건 | 검증된 가설에 맞춰 사용자 지정 |
## Event object
```json
{
"event_id": "svb-failure-2023-03-10",
"event_name": "Silicon Valley Bank failure",
"event_type": "financial_stress",
"event_structure": "developing_event",
"aliases": ["SVB 파산", "SVB 사태"],
"region": "US",
"event_date": "2023-03-10",
"event_time_local": "11:15",
"event_timezone": "America/New_York",
"event_sequence": [
{
"date": "2023-03-09",
"stage": "prelude",
"event": "채권매각 손실 및 자본조달 이슈 공개",
"source": {"name": "당시 원문 보도", "url": "https://..."}
},
{
"date": "2023-03-10",
"time_local": "11:15",
"timezone": "America/New_York",
"stage": "trigger",
"event": "캘리포니아 규제당국의 은행 폐쇄",
"source": {"name": "FDIC", "url": "https://..."}
},
{
"date": "2023-03-12",
"stage": "policy_response",
"event": "예금자 보호 및 BTFP 발표",
"source": {"name": "Federal Reserve", "url": "https://..."}
}
],
"source": {"name": "직접 원천", "url": "https://..."}
}
```
- `event_id + event_date`는 파일 안에서 유일해야 한다.
- `event_type`은 금융스트레스·물가·정책 등 시장충격의 분야다. `event_structure`는 `point_event`, `scheduled_event`, `developing_event` 중 하나로 별도 기록한다. 두 분류를 혼용하지 않는다.
- `event_structure=developing_event`면 출처가 연결된 `event_sequence`를 2~20개 제공하고, 기준일 사건을 그 안에 포함한다. 사전 경고·최초 충격·후속 대응·시장 재개를 순서대로 기록할 수 있다.
- 날짜는 분석 기준이 된 최초 공식 발표·폐쇄·결정 시점 중 무엇인지 설명한다.
- 사건이 며칠에 걸치면 최초 충격과 후속 정책대응을 별도 event로 나눌 수 있다.
- `event_time_local`과 IANA `event_timezone`은 함께 쓰거나 함께 생략한다.
- timestamp 품질은 정확 시각과 시간대가 있으면 `A · 정확 시각`, 날짜만 확인되면 `B · 날짜만 확인`이다. `C · 다일 진행 사건`은 시간 품질이 아니라 `event_structure=developing_event`를 뜻한다.
- `event_date`와 정확 시각은 기준 충격(anchor)을 고르는 데 사용한다. 일별 데이터의 D0는 D-1 기준값부터 D0 일별 관측값까지의 변동이며 인과효과로 서술하지 않는다. 진행 사건의 D+1/D+5는 후속 충격·대응을 포함할 수 있다고 밝힌다.
- 이름만으로 날짜가 확정되지 않으면 `verification_required`로 멈춘다.
- 키워드가 여러 유형과 동시에 일치하면 임의로 하나를 고르지 않고 명시적 `event_type`을 요구한다.
## Optional adapter
`adapter`는 `FOMC`, `BOK_MPC`, `ECB`, `INFLATION`, `EMPLOYMENT`, `EARNINGS` 중 하나를 지정할 수 있다. 지정하지 않아도 Generic Engine은 동작한다.
references/methodology.md
# 이벤트 스터디 방법론
## Series input
JSON은 `series` 배열을 받는다.
```json
{
"series": [
{
"symbol": "UST2Y",
"region": "US",
"asset_class": "rates",
"unit": "pct",
"observation_timezone": "America/New_York",
"observation_time": "16:00",
"market_timezone": "America/New_York",
"price_type": "market_close",
"roles": ["short_rates", "government_bond", "safe_haven"],
"selection_priority": 10,
"source": {"name": "U.S. Treasury", "url": "https://..."},
"observations": [
{"date": "2026-09-15", "value": 4.67},
{"date": "2026-09-16", "value": 4.73}
]
}
]
}
```
일별 자료에서 관측 시점이 뉴욕 정오인 원/달러 원천의 예시는 다음과 같다. `region`은 자산의 시장/대상 지역이고, 관측 시각 필드는 실제 원천 기준시각이다.
```json
{
"symbol": "USDKRW",
"region": "KR",
"asset_class": "fx",
"unit": "krw_per_usd",
"observation_timezone": "America/New_York",
"observation_time": "12:00",
"market_timezone": "Asia/Seoul",
"price_type": "noon_buying_rate",
"source": {"name": "Federal Reserve H.10 via FRED", "url": "https://fred.stlouisfed.org/data/DEXKOUS"},
"observations": [{"date": "2023-03-10", "value": 1324.51}]
}
```
시각 메타데이터가 없으면 region으로 대리 정렬하고 결과에서 해당 한계를 표시한다. `observation_timezone`과 `observation_time`은 함께 제공해야 한다. `market_timezone`은 해당 가격이 대표하는 현지 시장 시계이며, `price_type`은 `market_close`, `noon_buying_rate`, `settlement`, `adjusted_close`처럼 원천의 실제 기준을 짧게 기록한다.
CSV는 `symbol,date,value,asset_class,unit,source_name,source_url`을 기본으로 받으며 `region,observation_timezone,observation_time,market_timezone,price_type`는 선택 열이다. 날짜는 `YYYY-MM-DD`, 값은 유한 숫자여야 한다. 같은 symbol/date가 두 번 나오면 입력 오류다. CSV에서 교차시장 정렬을 사용하려면 `region`을 넣는다. 모든 시리즈에는 `source.name`·`source.url`이 있어야 하며 symbol은 파일 전체에서 유일해야 한다.
장중 자료는 별도 `intraday_observations`로 넣고, 각 timestamp에 UTC offset을 명시한다. 동일 순간 중복은 거부되고 입력 순서와 무관하게 시간순 정규화된다.
```json
"intraday_observations": [
{"timestamp": "2023-03-10T11:00:00-05:00", "value": 100.0},
{"timestamp": "2023-03-10T12:30:00-05:00", "value": 99.2},
{"timestamp": "2023-03-10T16:00:00-05:00", "value": 98.5}
]
```
## Dynamic asset roles
JSON 시계열의 `roles`는 사건 전달경로와 자산을 연결한다. 한 시리즈에 여러 역할을 줄 수 있다. 대표 역할은 `short_rates`, `long_rates`, `curve`, `government_bond`, `bank_equity`, `credit`, `volatility`, `safe_haven`, `oil`, `gold`, `fx`, `broad_equity`, `sector_equity`, `event_security`, `affected_equity`다. 무역정책 프로필은 금리·커브·FX·영향/광범위 주식·원유/원자재·변동성을 선택한다. 임의의 새 역할도 허용하지만 selector profile과 일치해야 자동 선택된다.
`selection_priority`는 같은 역할 후보 사이의 정수 우선순위(-100~100)다. 사용자 `--symbols`가 있으면 역할과 우선순위보다 항상 우선한다. 자동 선택은 사건 유형에 적합한 자산군을 먼저 나눠 담고, 세부 역할을 채운다. 확인 시각까지 D-1과 D0를 모두 확보한 후보를 우선하며, 이 창을 만들 수 없는 시계열은 대체 후보가 없을 때만 사용한다. `roles`가 없거나 자산군명만 담은 입력은 `asset_class`를 보수적인 fallback으로 사용한다. `bank_equity`처럼 구체적이지만 사건 프로필과 맞지 않는 역할은 자산군이 주식이라는 이유만으로 넣지 않는다. 물가 사건의 주식 반응에는 `growth_equity`와 `broad_equity`를 구분하고 `volatility`도 별도 채널로 취급한다.
## Alignment
- `same`: 이벤트 날짜 관측이 없으면 해당 시리즈 실패
- `next`: 이벤트 날짜 이상 첫 관측일
- `next_strict`: 이벤트 날짜보다 뒤의 첫 관측일
- `previous`: 이벤트 날짜 이하 마지막 관측일
- cross-market에서는 우선 event timestamp와 시계열 `observation_timezone + observation_time`을 UTC로 비교한다. 제공되지 않으면 `market_timezone`의 마감시각, 이어서 `region` 시장 마감시각 추정 또는 거래일 달력을 대리 기준으로 사용하고 정렬 근거를 결과에 표시한다. 이벤트가 관측 시각 이전이면 `next`, 이후/동시이면 `next_strict`를 적용한다. IANA timezone과 DST를 반영하며 Windows에 tzdata가 없어도 KR·JP·CN·US·EU·UK 규칙을 자체 계산한다.
- `--observed-at` 이후 관측은 계산에서 제거한다. 검증된 관측 시각이 있는 경우 현지 날짜·시각을 UTC로 변환해 비교한다. 시각이 없는 일별 값은 같은 현지 날짜에 공개됐다고 추측하지 않고 다음 날짜부터 이용 가능한 것으로 보수적으로 처리한다. 원천에서 그보다 빠른 확정 시각을 확인하면 `observation_timezone`·`observation_time`을 채운다.
D-n/D+n은 달력일이 아니라 정렬된 시리즈의 유효 관측 순서다.
요청한 D+n이 컷오프까지 확보한 시리즈의 마지막 관측일 뒤에 있으면 `pending_D+n`이다. 아직 형성 전인지 원천 범위 부족인지는 이 코드만으로 알 수 없다. 원천 범위가 짧은 경우에는 `source-priority.md`의 missing-window recovery를 수행한 뒤 다시 계산한다.
## Measurement
- `asset_class=rates|policy|spread|credit`, `unit=pct`: `(value - anchor) × 100` bp
- `asset_class=rates|policy|spread|credit`, `unit=bp`: `value - anchor` bp
- 그 외 가격/지수: `(value / anchor - 1) × 100` %
- 기본 anchor는 D-1이다. 일별 데이터의 D0는 D-1→D0의 관측된 일별 변동이며 사건 이후 반응만 분리한 인과효과가 아니다. event timestamp가 있어도 같은 날짜 전체 수익률은 사건 전 구간을 포함할 수 있다.
- 값이 0인 가격 시리즈는 % 수익률을 계산하지 않는다.
- 정확한 event timestamp와 장중 바가 모두 있을 때는 사건 직전 마지막 바, 사건 1시간 후 이상 첫 바, 첫 후속 거래 세션의 마지막 제공 바를 계산한다. 마지막 바 시각이 시장 마감으로 확인되지 않으면 `공식 종가`가 아니라 제공 바라고 표시한다.
- `developing_event`의 D+1/D+5에는 사전 경고·후속 사건·정책 대응이 들어갈 수 있으므로 단일 촉발 사건에 귀속하지 않는다.
## Curve classification
단기와 장기 금리가 모두 상승하면 bear, 모두 하락하면 bull이다. 장기 변화가 단기 변화보다 크면 steepening, 작으면 flattening이다. 방향이 서로 다르거나 하나가 0이면 `Mixed`로 두고 스프레드 bp만 표시한다.
## Threshold
`above`는 `metric >= threshold`, `below`는 `metric <= threshold`다. 첫 관측은 직전값이 없어 판정하지 않는다. 중복 이벤트 날짜는 한 번만 반환한다.
`bp_change`는 rates·policy·spread 시리즈에만 허용한다.
references/research-contract.md
# Generic Event Research 계약
이벤트 스터디 숫자 위에 올라가는 재현 가능한 연구 계층이다. `event_id + event_date`는 파일 안에서 유일해야 한다.
```json
{
"research": [{
"event_id": "svb-failure-2023-03-10",
"event_name": "Silicon Valley Bank failure",
"event_type": "financial_stress",
"region": "US",
"event_date": "2023-03-10",
"observed_at": "2023-03-20T09:00:00+09:00",
"event_features": {
"shock_mechanisms": ["deposit_run", "duration_loss"],
"affected_channels": ["bank_equity", "credit", "short_rates", "volatility"],
"market_shock_pattern": {"bank_equity": "down", "credit": "wider", "short_rates": "down", "volatility": "up"}
},
"overview": {
"summary": "직접 확인된 사건과 배경",
"expected": false,
"surprise_assessment": "사전 기대와 달랐던 지점",
"classification": "충격 메커니즘 중심 분류",
"source": {"name": "직접 원천", "url": "https://..."}
},
"what_changed": [{
"channel": "사건별 변화 채널",
"finding": "확인된 변화",
"significance": "시장에 중요한 이유",
"source": {"name": "직접 원천", "url": "https://..."}
}],
"adapter_details": [],
"market_interpretation": [{
"channel": "금리·신용·성장·물가 등",
"finding": "당일 기사와 가격이 함께 지지한 해석",
"significance": "재가격과 연결되는 이유",
"source": {"name": "시장 반응 원천", "url": "https://..."}
}],
"transmission": [{
"from": "최초 충격",
"to": "첫 번째 시장 반응",
"status": "consistent",
"evidence": "실제 가격 근거",
"source": {"name": "가격 원천", "url": "https://..."}
}],
"catalysts": [{
"date": "2023-03-12",
"time_local": "18:00",
"timezone": "America/New_York",
"event": "후속 정책대응",
"why_it_matters": "현재 해석을 확인하거나 뒤집는 조건",
"source": {"name": "공식 일정", "url": "https://..."}
}],
"historical_context": {
"include": true,
"reason": "비교가 필요한 이유",
"comparisons": [{
"date": "2008-03-16",
"event": "과거 사건",
"matching_basis": "사건유형·전달경로·가격반응 중 선정 기준",
"similarity": "공통점",
"difference": "중요한 차이",
"source": {"name": "직접 원천", "url": "https://..."}
}]
},
"historical_analogue_pool": [{
"date": "2008-03-16",
"event": "Bear Stearns 구제",
"event_type": "financial_stress",
"shock_mechanisms": ["funding_stress", "liquidity_backstop"],
"affected_channels": ["bank_equity", "short_rates", "volatility"],
"market_shock_pattern": {"bank_equity": "down", "short_rates": "down", "volatility": "up"},
"similarity": "은행 유동성 충격과 정책 백스톱이 단기금리·변동성에 함께 반영됐다.",
"difference": "도매자금시장과 투자은행이 충격의 중심이었다.",
"source": {"name": "직접 원천", "url": "https://..."}
}]
}]
}
```
## Evidence rules
- 사건 정의와 기준일은 규제기관·정부·거래소·기업 IR 등 직접 원천을 우선한다.
- `what_changed`는 직전 상태 또는 사건 직전 기대와 비교해 확인된 변화만 쓴다.
- `market_interpretation`은 가격만 보고 역으로 만들지 않는다. 당일 시장기사와 가격 방향이 함께 지지해야 한다.
- 모든 claim, transmission, catalyst, historical comparison에는 직접 연결되는 출처가 필요하다.
- 과거 시점 분석에는 research `observed_at`을 적고 분석 CLI의 `--observed-at`보다 뒤에 확인된 연구를 넣지 않는다. helper가 research 시각이 분석 컷오프 이후이면 거부한다. 출처 URL만으로 보도·발표 시각을 증명할 수 없으므로 작성자가 개별 claim의 당시 공개 여부를 확인한다.
- 검색결과 snippet만으로 사실이나 인과를 확정하지 않는다.
- adapter가 있으면 `adapter_details`에 전용 확인 결과를 넣는다. adapter가 없으면 빈 배열이어도 된다.
## Transmission status
`status`는 다음 대문자 enum으로 표준화한다. 과거 입력의 소문자 `limited`는 `MIXED`로 정규화한다.
| status | 의미 |
| --- | --- |
| `CONFIRMED` | 공식자료 또는 신뢰도 높은 시장보도와 관측가격이 이 연결을 직접 뒷받침한다. 단지 동시에 움직였다는 뜻은 아니다. |
| `CONSISTENT` | 가격은 가설과 같은 방향이지만 인과는 확정하지 못했다. |
| `MIXED` | 일부 자산·단계만 일치하거나 반응이 제한적이다. |
| `CONTRADICTED` | 관측 결과가 제시한 전달방향과 반대다. |
| `UNVERIFIED` | 필요한 원천·가격·연결 근거가 부족하다. |
완성형 연구에는 최소 한 단계가 필요하다. 근거가 부족하면 생략하지 말고 `unverified`와 부족한 근거를 적는다.
## Catalysts and analogues
후속 촉매는 현재 해석을 확인하거나 뒤집을 일정이어야 하며 최소 2개, 최대 8개다. 같은 사건의 다음 날짜만 기계적으로 고르지 않는다.
일정의 발표시각이 확인되면 `time_local`과 `timezone`을 함께 넣어 동일 날짜의 경과·예정 여부를 판정한다. 날짜만 확인되면 당일에는 `시각 미확인`으로 표시한다.
과거 비교를 사용할 때는 3~5개를 쓴다. 이름이 같은 사건이 아니라 `matching_basis`에 사건유형, 충격 메커니즘, 핵심 가격반응 중 무엇이 일치하는지 명시한다. 현재 사건 이후 날짜나 중복 사례는 허용하지 않는다.
## Historical analogue ranking
범용 분석은 현재 사건과 과거 후보 양쪽에 `event_features`를 제공한다. 충격 메커니즘과 영향 채널은 짧고 표준화된 식별자 배열로, `market_shock_pattern`은 채널별 `up|down|mixed|flat|wider|tighter` 방향으로 기록한다. 후보마다 과거 날짜, event_type, similarity/difference 서술과 직접 출처가 필수다.
검증된 `historical_analogue_pool`은 이름 유사도로 고르지 않는다. helper는 사건유형 일치(+2), shock_mechanisms Jaccard(+3 가중), affected_channels Jaccard(+2), 관측 시장충격 방향 겹침(+4)을 점수화해 정렬한다. mechanism 중복과 최소 한 개 시장 방향 일치가 모두 있어야 후보 자격이 있다. 적격 후보 3개 미만이면 억지로 채우지 않고 비교 미생성 사유를 출력한다. 3~5개이면 높은 순으로 표시하며 매칭 점수 근거를 남긴다. 수동 `historical_context`는 호환을 위해 지원하되 같은 날짜·출처 검증을 적용한다.
일별 D0는 이벤트 이후 부분수익률이 아니라 일별 관측값의 변화다. exact event timestamp는 거래일 정렬을 개선하지만, 일별 종가만으로 인과효과를 분리하지 못한다. 다일 진행 사건의 후속 창에는 `event_sequence`에 기록된 후속 사건이 포함될 수 있으므로 하나의 촉발점에 귀속하지 않는다.
기존 `decision` 필드는 하위 호환을 위해 `overview`로 읽을 수 있지만, 새 입력은 `overview`를 사용한다.
references/source-priority.md
# Source priority
1. 사건을 직접 다루는 규제기관·정부·거래소·기업 IR과 공식 시계열
2. 공식 경제 캘린더와 공식 배포 일정
3. Reuters·Bloomberg·주요 통신사: consensus와 시장의 당일 해석 보강
4. Investing.com 역사적 데이터: 공식·거래소·재무부·중앙은행 시계열에 필요한 종가가 없을 때 시장가격 fallback
5. 기타 금융사이트: 위 원천 부재 시 제한적 교차 확인
검색결과 snippet만으로 수치·일정·인과를 확정하지 않는다. 각 수치에는 관측일, 단위, 원천 URL을 보존한다. 기사와 가격 반응이 충돌하면 가격 방향을 우선 사실로 기록하고 원인은 미확인으로 둔다.
시계열 provenance에는 원천이 실제로 측정한 `observation_timezone`, `observation_time`, 대상 시장의 `market_timezone`, `price_type`을 보존한다. 원천의 관측시각을 거래소 종가로 바꾸어 부르지 않는다. 특히 FRED `DEXKOUS`는 뉴욕 정오 매입환율이므로 서울 15:30 USD/KRW 종가를 대신할 수 없다. 한국 거래일 기준 FX 반응은 서울 현지 종가 원천을 우선하고, 해당 자료가 없을 때에만 FRED를 별도 시간축의 보조 관측치로 표시한다.
## Missing-window recovery
Event Study의 D0·D+1·D+5가 비어 있으면 즉시 `미확인`으로 출력하지 않는다.
1. 기존 원천의 날짜 범위가 필요한 거래일까지 포함되는지 확인한다.
2. 거래소·재무부·중앙은행·공식 지수 제공자의 일별 종가를 먼저 조회한다.
3. 공식 원천으로 보강할 수 없으면 Investing.com 역사적 데이터 화면에서 해당 날짜의 확정 종가를 확인한다.
- KOSPI: `https://www.investing.com/indices/kospi-historical-data`
- USD/KRW: `https://www.investing.com/currencies/usd-krw-historical-data`
4. 브라우저에 표시된 날짜·종가·통화·시장 상태를 normalized series에 추가하고 source URL을 보존한 뒤 Event Study를 다시 실행한다.
5. 장중값을 종가로 쓰지 않는다. 해당 시장의 다섯 번째 후속 거래일이 아직 끝나지 않았거나 역사적 데이터에 확정값이 없으면 `pending_D+n`으로 남긴다.
Investing.com은 시장가격 보강용이다. 정책결정, 규제조치, 기업발표, 군사·외교 발표, 공식 일정 등 사건 자체의 원천으로 사용하지 않는다.
scripts/adapters.py
#!/usr/bin/env python3
"""Optional specialized adapters layered on the generic event engine."""
from __future__ import annotations
from typing import Any
ADAPTERS = {
"FOMC": {"label": "FOMC", "channels": ("성명서", "SEP·점도표", "Powell 기자회견", "사전 정책금리 pricing")},
"BOK_MPC": {"label": "한국은행 금통위", "channels": ("통화정책방향 결정문", "소수의견", "총재 기자회견", "성장·물가 전망")},
"ECB": {"label": "ECB", "channels": ("결정문", "경제전망", "Lagarde 기자회견", "사전 정책금리 pricing")},
"INFLATION": {"label": "물가지표", "channels": ("Headline", "Core", "MoM·YoY", "세부 품목", "컨센서스·수정치")},
"EMPLOYMENT": {"label": "고용지표", "channels": ("고용증감", "실업률", "임금", "참가율", "수정치")},
"EARNINGS": {"label": "기업실적", "channels": ("매출·이익", "가이던스", "사업부문", "컨센서스", "경영진 발언")},
}
def adapter_for_event(event: dict[str, Any]) -> dict[str, Any] | None:
explicit = str(event.get("adapter") or "").strip().upper()
family = str(event.get("event_family") or "").strip().upper()
event_type = str(event.get("event_type") or "")
key = explicit or family
if key in ADAPTERS:
return {"key": key, **ADAPTERS[key]}
if event_type == "inflation":
return {"key": "INFLATION", **ADAPTERS["INFLATION"]}
if event_type == "employment":
return {"key": "EMPLOYMENT", **ADAPTERS["EMPLOYMENT"]}
if event_type == "corporate_earnings":
return {"key": "EARNINGS", **ADAPTERS["EARNINGS"]}
return None
scripts/asset_selector.py
#!/usr/bin/env python3
"""Select event-sensitive assets from available sourced series."""
from __future__ import annotations
from typing import Any
PROFILES = {
"monetary_policy": (("short_rates", "long_rates", "curve", "fx", "broad_equity", "volatility"), ("rates", "fx", "equity", "volatility")),
"inflation": (("short_rates", "long_rates", "inflation_breakeven", "fx", "growth_equity", "broad_equity", "volatility"), ("rates", "fx", "equity", "volatility")),
"employment": (("short_rates", "long_rates", "curve", "fx", "growth_equity", "broad_equity", "volatility"), ("rates", "fx", "equity", "volatility")),
"growth": (("long_rates", "cyclical_equity", "broad_equity", "fx", "commodity"), ("rates", "equity", "fx", "commodity")),
"financial_stress": (("short_rates", "bank_equity", "credit", "volatility", "safe_haven", "broad_equity", "fx"), ("rates", "credit", "equity", "volatility", "fx")),
"sovereign_credit": (("government_bond", "sovereign_cds", "fx", "gold", "broad_equity", "volatility"), ("rates", "credit", "fx", "commodity", "equity", "volatility")),
"geopolitics": (("oil", "gold", "safe_haven", "fx", "volatility", "broad_equity"), ("commodity", "fx", "rates", "volatility", "equity")),
"commodity_supply": (("oil", "commodity", "inflation_breakeven", "commodity_equity", "fx", "broad_equity", "volatility"), ("commodity", "rates", "equity", "fx", "volatility")),
"corporate_earnings": (("event_security", "sector_equity", "broad_equity", "volatility"), ("equity", "volatility")),
"fiscal_policy": (("government_bond", "curve", "fx", "broad_equity"), ("rates", "fx", "equity")),
"trade_policy": (("short_rates", "long_rates", "curve", "affected_equity", "broad_equity", "fx", "oil", "commodity", "volatility"), ("rates", "equity", "fx", "commodity", "volatility")),
"regulation": (("affected_equity", "sector_equity", "broad_equity", "volatility"), ("equity", "volatility")),
"market_shock": (("event_security", "broad_equity", "volatility", "credit", "safe_haven"), ("equity", "volatility", "credit", "rates")),
"other": (("event_security", "broad_equity", "fx", "short_rates", "long_rates", "government_bond", "volatility"), ("price", "equity", "fx", "rates", "volatility")),
}
EVENT_TYPE_LABELS = {
"monetary_policy": "통화정책", "inflation": "물가", "employment": "고용", "growth": "성장",
"financial_stress": "금융스트레스", "sovereign_credit": "국가신용", "geopolitics": "지정학",
"commodity_supply": "원자재 공급", "corporate_earnings": "기업실적", "fiscal_policy": "재정정책",
"trade_policy": "무역정책", "regulation": "규제", "market_shock": "시장충격", "other": "사건별",
}
ROLE_LABELS = {
"short_rates": "단기금리", "long_rates": "장기금리", "curve": "금리곡선", "government_bond": "국채",
"bank_equity": "은행주", "credit": "신용", "volatility": "변동성", "safe_haven": "안전자산",
"oil": "원유", "gold": "금", "fx": "외환", "broad_equity": "시장지수", "sector_equity": "업종주",
"event_security": "사건 직접 노출 종목", "affected_equity": "영향 주식", "commodity": "원자재",
"growth_equity": "성장주", "cyclical_equity": "경기민감주", "commodity_equity": "원자재 관련주",
"inflation_breakeven": "기대인플레이션", "sovereign_cds": "국가 CDS",
}
def select_assets(series: list[dict[str, Any]], event: dict[str, Any], requested: tuple[str, ...] | None = None, limit: int = 8) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 20:
raise ValueError("asset selection limit must be an integer from 1 to 20")
by_symbol = {item["symbol"].casefold(): item for item in series}
if requested:
selected, missing = [], []
for symbol in requested:
item = by_symbol.get(symbol.casefold())
if item is None:
missing.append(symbol)
elif item not in selected:
selected.append(item)
reasons = [{"symbol": item["symbol"], "reason": "사용자 지정", "matched_roles": []} for item in selected]
return selected, reasons, missing
event_type = str(event.get("event_type") or "other")
preferred_roles, preferred_classes = PROFILES.get(event_type, PROFILES["other"])
event_region = str(event.get("region") or "GLOBAL").upper()
scored: list[tuple[int, int, str, dict[str, Any], list[str]]] = []
for item in series:
roles = [str(role).casefold() for role in item.get("roles", [])]
matched = [role for role in preferred_roles if role in roles]
class_rank = preferred_classes.index(item["asset_class"]) if item["asset_class"] in preferred_classes else len(preferred_classes)
role_score = sum(len(preferred_roles) - preferred_roles.index(role) for role in matched)
region_score = 3 if item.get("region") == event_region else (1 if item.get("region") == "GLOBAL" else 0)
explicit_priority = int(item.get("selection_priority", 0))
availability_penalty = 100000 if item.get("selection_usable") is False else 0
total = role_score * 100 + region_score * 10 + explicit_priority - availability_penalty
# A generic role equal to its asset class conveys no narrower exposure.
# Specific nonmatching roles (e.g. bank_equity for a CPI event) remain excluded.
if roles and not matched and not all(role in {item["asset_class"], "price"} for role in roles):
continue
if not matched and item["asset_class"] not in preferred_classes:
continue
scored.append((total, -class_rank, item["symbol"], item, matched))
scored.sort(key=lambda row: (-row[0], -row[1], row[2]))
# Cover distinct asset classes before filling finer roles. Otherwise a
# profile with many rate/equity roles can exhaust a small limit before FX
# or volatility is considered. Unusable series never displace usable ones.
selected_rows: list[tuple[int, int, str, dict[str, Any], list[str]]] = []
used_symbols: set[str] = set()
for pool in ([row for row in scored if row[3].get("selection_usable") is not False],
[row for row in scored if row[3].get("selection_usable") is False]):
for asset_class in preferred_classes:
candidate = next((row for row in pool if row[3]["asset_class"] == asset_class and row[2] not in used_symbols), None)
if candidate is not None:
selected_rows.append(candidate)
used_symbols.add(candidate[2])
if len(selected_rows) >= limit:
break
if len(selected_rows) >= limit:
break
for role in preferred_roles:
candidate = next((row for row in pool if role in row[4] and row[2] not in used_symbols), None)
if candidate is not None:
selected_rows.append(candidate)
used_symbols.add(candidate[2])
if len(selected_rows) >= limit:
break
if len(selected_rows) >= limit:
break
for row in pool:
if row[2] not in used_symbols:
selected_rows.append(row)
used_symbols.add(row[2])
if len(selected_rows) >= limit:
break
if len(selected_rows) >= limit:
break
selected_rows.sort(key=lambda row: (preferred_classes.index(row[3]["asset_class"])
if row[3]["asset_class"] in preferred_classes else len(preferred_classes),
-row[0], row[2]))
selected = [row[3] for row in selected_rows]
reasons = [{
"symbol": row[3]["symbol"],
"reason": f"{EVENT_TYPE_LABELS.get(event_type, event_type)} 전달경로",
"matched_roles": [ROLE_LABELS.get(role, role) for role in (row[4] or [row[3]["asset_class"]])],
} for row in selected_rows]
return selected, reasons, []
scripts/calendars.py
#!/usr/bin/env python3
"""Trading-calendar alignment helpers based on actual observations."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone, tzinfo
from typing import Any, Iterable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
MARKET_CLOSES = {
"KR": ("Asia/Seoul", time(15, 30)),
"JP": ("Asia/Tokyo", time(15, 0)),
"CN": ("Asia/Shanghai", time(15, 0)),
"EU": ("Europe/Frankfurt", time(17, 30)),
"UK": ("Europe/London", time(16, 30)),
"US": ("America/New_York", time(16, 0)),
}
SESSION_ORDER = {"JP": 0, "KR": 0, "CN": 0, "EU": 1, "UK": 1, "US": 2}
FALLBACK_ZONES = {"Asia/Seoul", "Asia/Tokyo", "Asia/Shanghai", "Europe/Frankfurt", "Europe/London", "America/New_York"}
TIMEZONE_CLOSES = {zone: close for zone, close in MARKET_CLOSES.values()}
def _nth_weekday(year: int, month: int, weekday: int, occurrence: int) -> date:
current = date(year, month, 1)
shift = (weekday - current.weekday()) % 7
return date(year, month, 1 + shift + 7 * (occurrence - 1))
def _last_weekday(year: int, month: int, weekday: int) -> date:
next_month = date(year + (month == 12), 1 if month == 12 else month + 1, 1)
current = next_month - timedelta(days=1)
return current - timedelta(days=(current.weekday() - weekday) % 7)
def zone_for_date(name: str, day: date) -> tzinfo:
try:
return ZoneInfo(name)
except ZoneInfoNotFoundError:
if name in {"Asia/Seoul", "Asia/Tokyo"}:
return timezone(timedelta(hours=9), name)
if name == "Asia/Shanghai":
return timezone(timedelta(hours=8), name)
if name == "America/New_York":
dst = _nth_weekday(day.year, 3, 6, 2) <= day < _nth_weekday(day.year, 11, 6, 1)
return timezone(timedelta(hours=-4 if dst else -5), name)
if name == "Europe/Frankfurt":
dst = _last_weekday(day.year, 3, 6) <= day < _last_weekday(day.year, 10, 6)
return timezone(timedelta(hours=2 if dst else 1), name)
if name == "Europe/London":
dst = _last_weekday(day.year, 3, 6) <= day < _last_weekday(day.year, 10, 6)
return timezone(timedelta(hours=1 if dst else 0), name)
raise
def valid_timezone(name: str, day: date) -> bool:
try:
zone_for_date(name, day)
return True
except ZoneInfoNotFoundError:
return False
def iso_date(value: str) -> str:
return date.fromisoformat(value).isoformat()
def sorted_unique_dates(values: Iterable[str]) -> list[str]:
return sorted({iso_date(value) for value in values})
def align_date(event_date: str, observation_dates: Iterable[str], mode: str = "next") -> tuple[str | None, int | None]:
target = iso_date(event_date)
dates = sorted_unique_dates(observation_dates)
if mode not in {"same", "next", "next_strict", "previous"}:
raise ValueError("alignment must be same, next, next_strict, or previous")
if mode == "same":
return (target, dates.index(target)) if target in dates else (None, None)
if mode == "next":
for index, observed in enumerate(dates):
if observed >= target:
return observed, index
return None, None
if mode == "next_strict":
for index, observed in enumerate(dates):
if observed > target:
return observed, index
return None, None
for reverse_index, observed in enumerate(reversed(dates)):
if observed <= target:
index = len(dates) - reverse_index - 1
return observed, index
return None, None
def window_indices(anchor: int, size: int, offsets: Iterable[int]) -> dict[int, int | None]:
return {offset: anchor + offset if 0 <= anchor + offset < size else None for offset in offsets}
def market_alignment_for_event(event: dict[str, Any], target_region: str | None) -> str:
"""Choose same-date eligibility from event time versus target market close."""
event_region = str(event.get("region") or "").upper()
target = str(target_region or "").upper()
if target in {"", "GLOBAL"}:
return "next"
event_time = event.get("event_time_local")
event_timezone = event.get("event_timezone")
if event_time and event_timezone:
try:
parsed_time = time.fromisoformat(str(event_time))
if parsed_time.tzinfo is not None:
raise ValueError("event_time_local must not contain a timezone offset")
event_day = date.fromisoformat(str(event["event_date"]))
event_instant = datetime.combine(event_day, parsed_time, zone_for_date(str(event_timezone), event_day)).astimezone(timezone.utc)
target_zone, target_close = MARKET_CLOSES[target]
target_local_date = event_instant.astimezone(zone_for_date(target_zone, event_day)).date()
target_close_zone = zone_for_date(target_zone, target_local_date)
close_instant = datetime.combine(target_local_date, target_close, target_close_zone).astimezone(timezone.utc)
return "next" if event_instant <= close_instant else "next_strict"
except (KeyError, ValueError, ZoneInfoNotFoundError):
pass
if target == event_region:
return "next"
if event_region not in MARKET_CLOSES or target not in MARKET_CLOSES:
return "next_strict"
return "next" if SESSION_ORDER[event_region] < SESSION_ORDER[target] else "next_strict"
def market_alignment_for_series(event: dict[str, Any], series: dict[str, Any]) -> tuple[str, str, str]:
"""Return alignment mode, basis, and target-local date for a series."""
event_time = event.get("event_time_local")
event_timezone = event.get("event_timezone")
observation_time = series.get("observation_time")
observation_timezone = series.get("observation_timezone")
if event_time and event_timezone:
try:
event_day = date.fromisoformat(str(event["event_date"]))
parsed_event_time = time.fromisoformat(str(event_time))
event_instant = datetime.combine(
event_day, parsed_event_time, zone_for_date(str(event_timezone), event_day)
).astimezone(timezone.utc)
if observation_time and observation_timezone:
observation_zone = zone_for_date(str(observation_timezone), event_instant.date())
local_event = event_instant.astimezone(observation_zone)
local_date = local_event.date()
observation_instant = datetime.combine(
local_date, time.fromisoformat(str(observation_time)), observation_zone
).astimezone(timezone.utc)
mode = "next" if event_instant < observation_instant else "next_strict"
return mode, "source_observation_time", local_date.isoformat()
market_timezone = series.get("market_timezone")
if market_timezone:
market_zone = zone_for_date(str(market_timezone), event_instant.date())
local_event = event_instant.astimezone(market_zone)
local_date = local_event.date()
close = TIMEZONE_CLOSES.get(str(market_timezone))
if close:
close_instant = datetime.combine(local_date, close, market_zone).astimezone(timezone.utc)
mode = "next" if event_instant <= close_instant else "next_strict"
return mode, "market_close_proxy", local_date.isoformat()
except (KeyError, ValueError, ZoneInfoNotFoundError):
pass
target_region = str(series.get("region") or "").upper()
if target_region in MARKET_CLOSES:
try:
market_timezone, close = MARKET_CLOSES[target_region]
market_zone = zone_for_date(market_timezone, event_instant.date())
local_event = event_instant.astimezone(market_zone)
local_date = local_event.date()
close_instant = datetime.combine(local_date, close, market_zone).astimezone(timezone.utc)
mode = "next" if event_instant <= close_instant else "next_strict"
return mode, "region_market_close_proxy", local_date.isoformat()
except (ValueError, ZoneInfoNotFoundError):
pass
alignment = market_alignment_for_event(event, series.get("region"))
return alignment, "region_calendar_proxy", str(event["event_date"])
scripts/event_resolver.py
#!/usr/bin/env python3
"""Resolve free-form market events into a verified, generic event contract."""
from __future__ import annotations
import re
import hashlib
from datetime import date, time as dt_time
from typing import Any
from urllib.parse import urlparse
from calendars import valid_timezone
EVENT_TYPES = {
"monetary_policy",
"inflation",
"employment",
"growth",
"financial_stress",
"sovereign_credit",
"geopolitics",
"commodity_supply",
"corporate_earnings",
"fiscal_policy",
"trade_policy",
"regulation",
"market_shock",
"other",
}
EVENT_STRUCTURES = {"point_event", "scheduled_event", "developing_event"}
GENERIC_QUERY_TERMS = {"decision", "meeting", "event", "release", "announcement", "analysis", "update", "발표", "회의", "사건", "분석", "시장"}
KNOWN_ACRONYMS = {"svb", "fomc", "ecb", "boj", "bok", "cpi", "ppi", "hicp", "nfp", "gdp", "pmi", "opec", "s&p", "vix", "kre"}
TYPE_KEYWORDS = (
("financial_stress", ("svb", "bank run", "bank failure", "banking crisis", "은행위기", "은행 파산", "예금유출", "금융불안")),
("sovereign_credit", ("downgrade", "default", "credit rating", "신용등급", "국가부도", "디폴트")),
("geopolitics", ("war", "invasion", "missile", "conflict", "전쟁", "침공", "군사충돌", "지정학")),
("commodity_supply", ("opec", "production cut", "supply disruption", "감산", "공급차질")),
("trade_policy", ("tariff", "trade war", "관세", "무역분쟁")),
("corporate_earnings", ("earnings", "guidance", "실적", "가이던스")),
("monetary_policy", ("fomc", "ecb", "boj", "bok", "금통위", "기준금리", "금리 결정")),
("inflation", ("cpi", "ppi", "hicp", "inflation", "물가")),
("employment", ("nfp", "payroll", "unemployment", "고용", "실업률")),
("growth", ("gdp", "pmi", "growth", "성장률")),
("fiscal_policy", ("budget", "stimulus", "fiscal", "예산", "재정", "부양책")),
("regulation", ("regulation", "antitrust", "ban", "규제", "금지")),
("market_shock", ("flash crash", "selloff", "short squeeze", "폭락", "급등", "쇼트스퀴즈")),
)
REGION_ALIASES = {
"KOREA": "KR", "SOUTH KOREA": "KR", "한국": "KR",
"UNITED STATES": "US", "USA": "US", "미국": "US",
"EURO AREA": "EU", "EUROZONE": "EU", "유로존": "EU",
"JAPAN": "JP", "일본": "JP", "CHINA": "CN", "중국": "CN",
"UNITED KINGDOM": "UK", "영국": "UK", "GLOBAL": "GLOBAL", "글로벌": "GLOBAL",
}
class EventResolutionError(ValueError):
pass
def normalize_token(value: Any) -> str:
return " ".join(re.sub(r"[^\w]+", " ", str(value or "").casefold(), flags=re.UNICODE).split())
def stable_event_id(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", normalize_token(name)).strip("-")
if slug:
return slug[:80].rstrip("-")
digest = hashlib.sha256(normalize_token(name).encode("utf-8")).hexdigest()[:16]
return f"event-{digest}"
def infer_event_type(query: str) -> tuple[str, list[str]]:
normalized = normalize_token(query)
matches = [event_type for event_type, words in TYPE_KEYWORDS if any(normalize_token(word) in normalized for word in words)]
unique = list(dict.fromkeys(matches))
return (unique[0] if unique else "other", unique)
def _source(value: Any) -> dict[str, str]:
if not isinstance(value, dict):
raise EventResolutionError("event needs source.name and source.url")
name = str(value.get("name") or "").strip()
url = str(value.get("url") or "").strip()
parsed = urlparse(url)
if not name or parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise EventResolutionError("event needs a valid source.name and http(s) source.url")
return {"name": name, "url": url}
def normalize_region(value: Any) -> str:
raw = str(value or "").strip().upper()
resolved = REGION_ALIASES.get(raw, raw)
if not resolved or not re.fullmatch(r"[A-Z]{2,6}", resolved):
raise EventResolutionError("event region must be a 2-6 letter market code such as US, KR, EU, JP, or GLOBAL")
return resolved
def normalize_event(item: dict[str, Any]) -> dict[str, Any]:
if not isinstance(item, dict):
raise EventResolutionError("event must be an object")
name = str(item.get("event_name") or item.get("event_family") or "").strip()
if not name:
raise EventResolutionError("event_name is required")
event_id = str(item.get("event_id") or "").strip().casefold() or stable_event_id(name)
if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,95}", event_id):
raise EventResolutionError("event_id must use 1-96 lowercase letters, numbers, dots, underscores, or hyphens")
try:
event_date = date.fromisoformat(str(item.get("event_date"))).isoformat()
except ValueError as exc:
raise EventResolutionError("event_date must be YYYY-MM-DD") from exc
explicit_type = str(item.get("event_type") or "").strip().casefold()
inferred_type, matches = infer_event_type(" ".join([name, str(item.get("event_family") or ""), " ".join(map(str, item.get("aliases") or []))]))
event_type = explicit_type or inferred_type
if event_type not in EVENT_TYPES:
raise EventResolutionError(f"unsupported event_type: {event_type}")
if not explicit_type and len(matches) > 1:
raise EventResolutionError("event_type is ambiguous; provide an explicit event_type")
aliases = item.get("aliases") or []
if not isinstance(aliases, list) or not all(isinstance(alias, str) and alias.strip() for alias in aliases):
raise EventResolutionError("event aliases must be non-empty strings")
event_time = item.get("event_time_local")
event_timezone = item.get("event_timezone")
if bool(event_time) != bool(event_timezone):
raise EventResolutionError("event_time_local and event_timezone must be provided together")
if event_time:
try:
parsed_time = dt_time.fromisoformat(str(event_time))
if parsed_time.tzinfo is not None or not valid_timezone(str(event_timezone), date.fromisoformat(event_date)):
raise ValueError
except ValueError as exc:
raise EventResolutionError("event time must be local HH:MM with a valid IANA timezone") from exc
family = str(item.get("event_family") or "").strip().upper().replace(" ", "_") or None
raw_sequence = item.get("event_sequence") or []
if not isinstance(raw_sequence, list) or any(not isinstance(step, dict) for step in raw_sequence):
raise EventResolutionError("event_sequence must be an object array")
if len(raw_sequence) > 20:
raise EventResolutionError("event_sequence supports at most 20 timeline steps")
sequence = []
sequence_keys = set()
previous_date = None
for index, step in enumerate(raw_sequence):
try:
step_date = date.fromisoformat(str(step.get("date"))).isoformat()
except ValueError as exc:
raise EventResolutionError(f"event_sequence[{index}].date must be YYYY-MM-DD") from exc
step_time = step.get("time_local")
step_timezone = step.get("timezone")
if bool(step_time) != bool(step_timezone):
raise EventResolutionError(f"event_sequence[{index}].time_local and timezone must be provided together")
if step_time:
try:
parsed_step_time = dt_time.fromisoformat(str(step_time))
if parsed_step_time.tzinfo is not None or not valid_timezone(str(step_timezone), date.fromisoformat(step_date)):
raise ValueError
step_time = parsed_step_time.isoformat(timespec="minutes" if parsed_step_time.second == 0 else "seconds")
except ValueError as exc:
raise EventResolutionError(f"event_sequence[{index}] needs a local time and valid IANA timezone") from exc
step_name = str(step.get("event") or "").strip()
if not step_name:
raise EventResolutionError(f"event_sequence[{index}].event is required")
stage = str(step.get("stage") or "milestone").strip().casefold()
if not re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", stage):
raise EventResolutionError(f"event_sequence[{index}].stage must be a short identifier")
source = _source(step.get("source"))
if previous_date and step_date < previous_date:
raise EventResolutionError("event_sequence must be in chronological date order")
previous_date = step_date
key = (step_date, str(step_time or ""), step_name.casefold())
if key in sequence_keys:
raise EventResolutionError("event_sequence contains duplicate milestones")
sequence_keys.add(key)
sequence.append({
"date": step_date,
"time_local": step_time,
"timezone": str(step_timezone) if step_timezone else None,
"stage": stage,
"event": step_name,
"source": source,
})
raw_structure = str(item.get("event_structure") or "").strip().casefold()
if raw_structure:
if raw_structure not in EVENT_STRUCTURES:
raise EventResolutionError(f"event_structure must be one of {sorted(EVENT_STRUCTURES)}")
event_structure = raw_structure
structure_inferred = False
elif len(sequence) > 1:
event_structure = "developing_event"
structure_inferred = True
elif item.get("adapter") or family:
event_structure = "scheduled_event"
structure_inferred = True
else:
event_structure = "point_event"
structure_inferred = True
if event_structure == "developing_event":
if len(sequence) < 2:
raise EventResolutionError("developing_event needs at least two verified event_sequence milestones")
if not any(step["date"] == event_date for step in sequence):
raise EventResolutionError("developing_event sequence must include the primary event_date")
return {
**item,
"event_id": event_id,
"event_name": name,
"event_family": family,
"event_type": event_type,
"event_date": event_date,
"event_structure": event_structure,
"event_structure_inferred": structure_inferred,
"event_timestamp_quality": "exact_timestamp" if event_time else "date_only",
"event_sequence": sequence,
"region": normalize_region(item.get("region")),
"aliases": list(dict.fromkeys(alias.strip() for alias in aliases)),
"source": _source(item.get("source")),
}
def resolve_from_events(query: str, events: list[dict[str, Any]]) -> dict[str, Any]:
needle = normalize_token(query)
if not needle:
raise EventResolutionError("event query must be a non-empty string")
scored: list[tuple[int, dict[str, Any]]] = []
for raw in events:
item = normalize_event(raw)
candidates = [item["event_name"], item["event_id"], item.get("event_family") or "", *item["aliases"]]
normalized = [normalize_token(value) for value in candidates if value]
exact = any(needle == value for value in normalized)
contains = any(needle in value or value in needle for value in normalized)
needle_tokens = set(needle.split())
meaningful_tokens = needle_tokens - GENERIC_QUERY_TERMS
token_overlap = max((len(meaningful_tokens & (set(value.split()) - GENERIC_QUERY_TERMS)) for value in normalized), default=0)
acronym_match = bool(meaningful_tokens & KNOWN_ACRONYMS & set(" ".join(normalized).split()))
safe_contains = contains and not (len(needle_tokens) == 1 and needle in GENERIC_QUERY_TERMS)
score = 100 if exact else (50 if safe_contains else (10 if token_overlap >= 2 or acronym_match else 0))
if score:
scored.append((score, item))
if not scored:
inferred, matches = infer_event_type(query)
return {
"result": "verification_required",
"query": query,
"inferred_event_type": inferred,
"ambiguous_types": matches if len(matches) > 1 else [],
"message": "사건 날짜와 직접 출처를 확인한 뒤 events 파일에 추가해야 합니다.",
}
top_score = max(score for score, _ in scored)
top = [item for score, item in scored if score == top_score]
identities = {(item["event_id"], item["event_date"]) for item in top}
if len(identities) != 1:
return {
"result": "ambiguous",
"query": query,
"candidates": top,
"message": "동일하게 일치하는 사건이 여러 개입니다. 날짜나 지역을 추가해 주세요.",
}
return {"result": "ok", "query": query, "event": top[0], "resolution": "verified_event_file"}
scripts/event_study.py
#!/usr/bin/env python3
"""Deterministic event-window, curve, and threshold calculations."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from typing import Any, Iterable
from calendars import TIMEZONE_CLOSES, align_date, window_indices, zone_for_date
DEFAULT_OFFSETS = (-1, 0, 1, 5)
def _change(value: float | None, anchor: float | None, rates: bool, unit: str = "pct") -> float | None:
if value is None or anchor is None:
return None
if rates:
scale = 1.0 if unit.casefold() == "bp" else 100.0
return round((value - anchor) * scale, 10)
if anchor == 0:
return None
return round((value / anchor - 1.0) * 100.0, 10)
def analyze_series(series: dict[str, Any], event_date: str, alignment: str = "next",
offsets: Iterable[int] = DEFAULT_OFFSETS, alignment_date: str | None = None) -> dict[str, Any]:
observations = series["observations"]
dates = [row["date"] for row in observations]
target_date = alignment_date or event_date
aligned, anchor_index = align_date(target_date, dates, alignment)
result = {
"symbol": series["symbol"],
"region": series.get("region"),
"asset_class": series["asset_class"],
"unit": series["unit"],
"event_date": event_date,
"alignment_anchor_date": target_date,
"aligned_event_date": aligned,
"alignment": alignment,
"alignment_basis": series.get("alignment_basis", "region_calendar_proxy"),
"observation_provenance": {
"observation_timezone": series.get("observation_timezone"),
"observation_time": series.get("observation_time"),
"market_timezone": series.get("market_timezone"),
"price_type": series.get("price_type"),
},
"source": series["source"],
"latest_observation_date": dates[-1] if dates else None,
"window": {},
"warnings": [],
}
if anchor_index is None:
result["failure"] = "no_observation_for_alignment"
return result
positions = window_indices(anchor_index, len(observations), offsets)
rates = series["asset_class"].casefold() in {"rates", "policy", "spread", "credit"}
base_index = positions.get(-1)
base = observations[base_index]["value"] if base_index is not None else None
for offset, index in positions.items():
key = "d0" if offset == 0 else f"d{offset:+d}"
row = observations[index] if index is not None else None
result["window"][key] = {
"date": row["date"] if row else None,
"value": row["value"] if row else None,
"change_from_d_minus_1": _change(row["value"] if row else None, base, rates, series["unit"]),
"change_unit": "bp" if rates else "pct",
}
if row is None:
warning = f"pending_{key}" if offset > 0 and anchor_index + offset >= len(observations) else f"missing_{key}"
result["warnings"].append(warning)
if base is None:
result["warnings"].append("changes_unavailable_without_d_minus_1")
if not series.get("observation_timezone") or not series.get("observation_time"):
result["warnings"].append("observation_time_basis_missing")
return result
def analyze_intraday_reaction(series: dict[str, Any], event: dict[str, Any]) -> dict[str, Any] | None:
"""Measure source-stamped pre-event, +1h, and first-session end observations."""
rows = series.get("intraday_observations") or []
if not rows:
return None
if not event.get("event_time_local") or not event.get("event_timezone"):
return {"symbol": series["symbol"], "status": "unavailable", "reason": "exact_event_timestamp_required"}
event_day = date.fromisoformat(event["event_date"])
event_local_time = time.fromisoformat(event["event_time_local"])
event_instant = datetime.combine(
event_day, event_local_time, zone_for_date(event["event_timezone"], event_day)
).astimezone(timezone.utc)
normalized = []
for row in rows:
instant = datetime.fromisoformat(str(row["timestamp"]).replace("Z", "+00:00"))
normalized.append((instant.astimezone(timezone.utc), instant, float(row["value"])))
normalized.sort(key=lambda item: item[0])
before = [row for row in normalized if row[0] < event_instant]
after = [row for row in normalized if row[0] >= event_instant]
one_hour_after = [row for row in normalized if row[0] >= event_instant + timedelta(hours=1)]
market_timezone = series.get("market_timezone") or event["event_timezone"]
session_end = None
close_confirmed = False
if after:
first_market_date = after[0][1].astimezone(zone_for_date(market_timezone, after[0][1].date())).date()
session_rows = [row for row in after if row[1].astimezone(zone_for_date(market_timezone, row[1].date())).date() == first_market_date]
if session_rows:
session_end = session_rows[-1]
close_time = TIMEZONE_CLOSES.get(market_timezone)
close_confirmed = bool(close_time and session_end[1].astimezone(zone_for_date(market_timezone, first_market_date)).time().replace(tzinfo=None) == close_time)
rates = series["asset_class"].casefold() in {"rates", "policy", "spread", "credit"}
unit = series["unit"]
anchor = before[-1] if before else None
def pack(row: tuple[datetime, datetime, float] | None) -> dict[str, Any] | None:
if row is None:
return None
return {
"timestamp": row[1].isoformat(),
"value": row[2],
"change_from_pre_event": _change(row[2], anchor[2] if anchor else None, rates, unit),
"change_unit": "bp" if rates else "pct",
}
return {
"symbol": series["symbol"],
"status": "ok",
"pre_event": pack(anchor),
"plus_1h": pack(one_hour_after[0] if one_hour_after else None),
"session_end": pack(session_end),
"session_end_label": "market_close" if close_confirmed else "last_supplied_bar_not_confirmed_close",
"warnings": [name for name, missing in (
("intraday_missing_pre_event", anchor is None),
("intraday_missing_plus_1h", not one_hour_after),
("intraday_session_close_unconfirmed", session_end is not None and not close_confirmed),
) if missing],
"source": series["source"],
}
def classify_curve(short_change_bp: float | None, long_change_bp: float | None) -> dict[str, Any]:
if short_change_bp is None or long_change_bp is None:
return {"classification": None, "spread_change_bp": None}
spread = round(long_change_bp - short_change_bp, 10)
if short_change_bp > 0 and long_change_bp > 0:
direction = "Bear"
elif short_change_bp < 0 and long_change_bp < 0:
direction = "Bull"
else:
return {"classification": "Mixed", "spread_change_bp": spread}
shape = "Steepening" if spread > 0 else "Flattening" if spread < 0 else "Parallel"
return {"classification": f"{direction} {shape}", "spread_change_bp": spread}
def threshold_events(series: dict[str, Any], metric: str, direction: str, threshold: float) -> list[dict[str, Any]]:
if direction not in {"above", "below"}:
raise ValueError("direction must be above or below")
rates = metric == "bp_change"
if metric not in {"bp_change", "pct_change"}:
raise ValueError("metric must be bp_change or pct_change")
is_rates = series["asset_class"].casefold() in {"rates", "policy", "spread", "credit"}
if rates and not is_rates:
raise ValueError("bp_change requires a rates, policy, spread, or credit series")
rows = series["observations"]
matches = []
for previous, current in zip(rows, rows[1:]):
value = _change(current["value"], previous["value"], rates, series["unit"])
if value is None:
continue
matched = value >= threshold if direction == "above" else value <= threshold
if matched:
matches.append({"date": current["date"], "metric": metric, "value": value, "threshold": threshold})
return matches
scripts/market_data.py
#!/usr/bin/env python3
"""Strict JSON/CSV market-series loader with source metadata."""
from __future__ import annotations
import csv
import json
import math
import re
from datetime import date, datetime, time, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from calendars import valid_timezone, zone_for_date
class SeriesError(ValueError):
pass
def _finite(value: Any) -> float:
if isinstance(value, bool):
raise SeriesError(f"not a number: {value!r}")
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise SeriesError(f"not a number: {value!r}") from exc
if not math.isfinite(number):
raise SeriesError(f"not finite: {value!r}")
return number
def _normalize_series(item: dict[str, Any]) -> dict[str, Any]:
symbol = str(item.get("symbol") or "").strip()
if not symbol:
raise SeriesError("series symbol is required")
region = str(item.get("region") or "").strip().upper() or None
if region is not None and not re.fullmatch(r"[A-Z]{2,6}", region):
raise SeriesError(f"{symbol}: region must be a 2-6 letter market code")
asset_class = str(item.get("asset_class") or "").strip().casefold()
if asset_class not in {"rates", "policy", "spread", "credit", "fx", "equity", "commodity", "volatility", "price"}:
raise SeriesError(f"{symbol}: unsupported asset_class {asset_class!r}")
unit = str(item.get("unit") or "").strip()
if not unit:
raise SeriesError(f"{symbol}: unit is required")
unit = unit.casefold()
if asset_class in {"rates", "policy", "spread", "credit"} and unit not in {"pct", "bp"}:
raise SeriesError(f"{symbol}: rates, policy, spread, and credit unit must be pct or bp")
source = item.get("source") if isinstance(item.get("source"), dict) else {}
if not str(source.get("name") or "").strip() or not str(source.get("url") or "").strip():
raise SeriesError(f"{symbol}: source.name and source.url are required")
parsed_url = urlparse(str(source["url"]))
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
raise SeriesError(f"{symbol}: source.url must be an http(s) URL")
observations = item.get("observations")
if not isinstance(observations, list) or not observations:
raise SeriesError(f"{symbol}: observations must be a non-empty array")
seen: set[str] = set()
clean = []
for row in observations:
if not isinstance(row, dict) or "date" not in row or "value" not in row:
raise SeriesError(f"{symbol}: each observation needs date and value")
try:
observed = date.fromisoformat(str(row["date"])).isoformat()
except ValueError as exc:
raise SeriesError(f"{symbol}: invalid ISO date {row['date']!r}") from exc
if observed in seen:
raise SeriesError(f"{symbol}: duplicate date {observed}")
seen.add(observed)
clean.append({"date": observed, "value": _finite(row["value"])})
clean.sort(key=lambda row: row["date"])
observation_timezone = str(item.get("observation_timezone") or "").strip() or None
observation_time = str(item.get("observation_time") or "").strip() or None
market_timezone = str(item.get("market_timezone") or "").strip() or None
if bool(observation_timezone) != bool(observation_time):
raise SeriesError(f"{symbol}: observation_timezone and observation_time must be provided together")
for field, timezone_name in (("observation_timezone", observation_timezone), ("market_timezone", market_timezone)):
if timezone_name and not valid_timezone(timezone_name, date.fromisoformat(clean[0]["date"])):
raise SeriesError(f"{symbol}: {field} must be a valid IANA timezone")
if observation_time:
try:
parsed_observation_time = time.fromisoformat(observation_time)
if parsed_observation_time.tzinfo is not None:
raise ValueError
observation_time = parsed_observation_time.isoformat(timespec="minutes" if parsed_observation_time.second == 0 else "seconds")
except ValueError as exc:
raise SeriesError(f"{symbol}: observation_time must be local HH:MM[:SS] without an offset") from exc
price_type = str(item.get("price_type") or "").strip() or None
if price_type and len(price_type) > 64:
raise SeriesError(f"{symbol}: price_type must be 64 characters or fewer")
raw_intraday = item.get("intraday_observations") or []
if not isinstance(raw_intraday, list):
raise SeriesError(f"{symbol}: intraday_observations must be an array")
intraday = []
intraday_seen: set[str] = set()
for index, row in enumerate(raw_intraday):
if not isinstance(row, dict) or "timestamp" not in row or "value" not in row:
raise SeriesError(f"{symbol}: intraday_observations[{index}] needs timestamp and value")
try:
timestamp = datetime.fromisoformat(str(row["timestamp"]).replace("Z", "+00:00"))
except ValueError as exc:
raise SeriesError(f"{symbol}: invalid intraday timestamp {row.get('timestamp')!r}") from exc
if timestamp.tzinfo is None:
raise SeriesError(f"{symbol}: intraday timestamps must include a timezone offset")
utc_key = timestamp.astimezone(timezone.utc).isoformat()
if utc_key in intraday_seen:
raise SeriesError(f"{symbol}: duplicate intraday timestamp {timestamp.isoformat()}")
intraday_seen.add(utc_key)
intraday.append({"timestamp": timestamp.isoformat(), "value": _finite(row["value"]), "_utc": utc_key})
intraday.sort(key=lambda row: row["_utc"])
for row in intraday:
row.pop("_utc")
roles = item.get("roles") or []
if not isinstance(roles, list) or not all(isinstance(role, str) and role.strip() for role in roles):
raise SeriesError(f"{symbol}: roles must be non-empty strings")
priority = item.get("selection_priority", 0)
if isinstance(priority, bool) or not isinstance(priority, int) or not -100 <= priority <= 100:
raise SeriesError(f"{symbol}: selection_priority must be an integer from -100 to 100")
return {
"symbol": symbol,
"region": region,
"asset_class": asset_class,
"unit": unit,
"source": {"name": str(source.get("name")).strip(), "url": str(source.get("url")).strip()},
"observation_timezone": observation_timezone,
"observation_time": observation_time,
"market_timezone": market_timezone,
"price_type": price_type,
"intraday_observations": intraday,
"roles": list(dict.fromkeys(role.strip().casefold() for role in roles)),
"selection_priority": priority,
"observations": clean,
}
def _unique_symbols(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not items:
raise SeriesError("series input must contain at least one series")
symbols = [item["symbol"].casefold() for item in items]
duplicates = sorted({symbol for symbol in symbols if symbols.count(symbol) > 1})
if duplicates:
raise SeriesError(f"duplicate symbols: {', '.join(duplicates)}")
return items
def load_series(path_text: str) -> list[dict[str, Any]]:
path = Path(path_text).expanduser()
if not path.is_file():
raise SeriesError(f"series file not found: {path}")
if path.suffix.casefold() == ".json":
payload = json.loads(path.read_text(encoding="utf-8-sig"))
items = payload.get("series") if isinstance(payload, dict) else payload
if not isinstance(items, list):
raise SeriesError("JSON must be an array or contain a series array")
return _unique_symbols([_normalize_series(item) for item in items])
if path.suffix.casefold() == ".csv":
groups: dict[str, dict[str, Any]] = {}
with path.open(encoding="utf-8-sig", newline="") as handle:
for row in csv.DictReader(handle):
symbol = (row.get("symbol") or "").strip()
item = groups.setdefault(symbol, {
"symbol": symbol,
"region": row.get("region") or None,
"asset_class": row.get("asset_class") or "price",
"unit": row.get("unit") or "index",
"source": {"name": row.get("source_name"), "url": row.get("source_url")},
"observation_timezone": row.get("observation_timezone") or None,
"observation_time": row.get("observation_time") or None,
"market_timezone": row.get("market_timezone") or None,
"price_type": row.get("price_type") or None,
"observations": [],
})
item["observations"].append({"date": row.get("date"), "value": row.get("value")})
return _unique_symbols([_normalize_series(item) for item in groups.values()])
raise SeriesError("series input must be .json or .csv")
def series_as_of(item: dict[str, Any], observed_at: str) -> dict[str, Any]:
"""Exclude observations that were not yet observable at the analysis cutoff."""
cutoff = datetime.fromisoformat(observed_at.replace("Z", "+00:00")).astimezone(timezone.utc)
zone_name = item.get("observation_timezone") or item.get("market_timezone")
zone = zone_for_date(zone_name, cutoff.date()) if zone_name else timezone.utc
local_cutoff_date = cutoff.astimezone(zone).date()
observation_time = item.get("observation_time")
retained = []
for row in item["observations"]:
row_date = date.fromisoformat(row["date"])
if observation_time and item.get("observation_timezone"):
observed = datetime.combine(
row_date, time.fromisoformat(observation_time),
zone_for_date(item["observation_timezone"], row_date),
).astimezone(timezone.utc)
available = observed <= cutoff
else:
# A daily date without a verified clock cannot establish same-day availability.
available = row_date < local_cutoff_date
if available:
retained.append(row)
intraday = [row for row in item.get("intraday_observations", [])
if datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00")).astimezone(timezone.utc) <= cutoff]
return {**item, "observations": retained, "intraday_observations": intraday,
"asof_excluded_observations": len(item["observations"]) - len(retained),
"original_first_observation_date": item["observations"][0]["date"]}
scripts/market_event.py
#!/usr/bin/env python3
"""Generic, reproducible market event research and event-study CLI."""
from __future__ import annotations
import argparse
import html
import json
import math
import re
import sys
import urllib.error
import urllib.request
from datetime import date, datetime, time as dt_time, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlparse
from adapters import adapter_for_event
from asset_selector import select_assets
from calendars import MARKET_CLOSES, market_alignment_for_series, valid_timezone, zone_for_date
from event_resolver import EventResolutionError, infer_event_type, normalize_event, resolve_from_events
from event_study import analyze_intraday_reaction, analyze_series, classify_curve, threshold_events
from market_data import SeriesError, load_series, series_as_of
from research import ResearchError, load_research_file, select_research
SOURCES = {
("US", "FOMC"): ("Federal Reserve", "https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm", "America/New_York"),
("EU", "ECB"): ("European Central Bank", "https://www.ecb.europa.eu/press/calendars/mgcgc/html/index.en.html", "Europe/Frankfurt"),
("KR", "BOK_MPC"): ("Bank of Korea", "https://www.bok.or.kr/portal/singl/baseRate/list.do?dataSeCd=01&menuNo=200643", "Asia/Seoul"),
}
MONTHS = {name: index for index, name in enumerate(("", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December")) if name}
OUTPUT_FORMAT = "chat"
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
class JsonArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> None:
payload = {"schema_version": "1.0", "result": "input_failure", "message": message}
if "--format" in sys.argv and "json" in sys.argv:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print(f"분석 조건을 확인해 주세요.\n\n- {message}")
raise SystemExit(2)
def show(value: Any) -> str:
return "미확인" if value is None or value == "" else str(value)
def event_value(value: Any) -> str:
mapping = {"hold": "동결", "hike": "인상", "cut": "인하"}
return mapping.get(str(value).casefold(), show(value))
def curve_name(value: Any) -> str:
return {
"Bear Flattening": "베어 플래트닝",
"Bear Steepening": "베어 스티프닝",
"Bull Flattening": "불 플래트닝",
"Bull Steepening": "불 스티프닝",
"Bear Parallel": "베어 평행이동",
"Bull Parallel": "불 평행이동",
"Mixed": "혼조",
}.get(str(value), show(value))
def friendly_timestamp(value: Any) -> str:
if not value:
return "미확인"
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return parsed.strftime("%Y-%m-%d %H:%M:%S %z")
except ValueError:
return str(value)
def signed(value: Any, unit: str) -> str:
if value is None:
return "미확인"
return f"{float(value):+.2f}{unit}"
def window_change(response: dict[str, Any], key: str) -> str:
point = response.get("window", {}).get(key, {})
unit = point.get("change_unit")
suffix = "bp" if unit == "bp" else "%"
return signed(point.get("change_from_d_minus_1"), suffix)
def observation_value(response: dict[str, Any], key: str) -> str:
point = response.get("window", {}).get(key, {})
value = point.get("value")
if value is None:
return "미확인"
unit = str(response.get("unit") or "").casefold()
if unit == "pct":
return f"{float(value):,.2f}%"
if unit in {"usd", "us_dollar", "dollar"}:
precision = 4 if response.get("asset_class") == "fx" else 2
return f"${float(value):,.{precision}f}"
if unit == "krw_per_usd":
return f"{float(value):,.2f}원/USD"
if unit in {"index", "idx", "points", "point"}:
return f"{float(value):,.2f}pt"
return f"{float(value):,.4f} {unit}".strip()
def window_observation(response: dict[str, Any], key: str) -> str:
point = response.get("window", {}).get(key, {})
observed_date = point.get("date")
if not observed_date:
return "미확인"
if key == "d-1":
return f"{observed_date} · {observation_value(response, key)}"
return f"{observed_date} · {observation_value(response, key)} ({window_change(response, key)})"
def source_time_label(response: dict[str, Any]) -> str:
provenance = response.get("observation_provenance") or {}
timezone_name = provenance.get("observation_timezone")
observation_time = provenance.get("observation_time")
market_timezone = provenance.get("market_timezone")
price_type = provenance.get("price_type")
if not timezone_name or not observation_time:
basis = response.get("alignment_basis")
return "관측 시각 미제공 · 시장 마감시각 대리정렬" if basis in {"market_close_proxy", "region_market_close_proxy"} else "관측 시각 미제공 · 지역 거래일 기준 대리정렬"
label = f"{observation_time} {timezone_name}"
if market_timezone and market_timezone != timezone_name:
label += f" · 시장 {market_timezone}"
if price_type:
label += f" · {price_type}"
return label.replace("|", "\\|")
def alignment_label(response: dict[str, Any]) -> str:
mode = response.get("alignment")
basis = response.get("alignment_basis")
provenance = response.get("observation_provenance") or {}
if basis == "source_observation_time":
prefix = "다음 원천 관측일 · " if mode == "next_strict" else "원천 관측시각 기준 · "
return f"{prefix}{provenance.get('observation_time')} {provenance.get('observation_timezone')}"
if basis == "market_close_proxy":
prefix = "다음 거래일 · " if mode == "next_strict" else "사건 당일 시장 · "
return prefix + "시장 마감시각 추정"
if basis == "region_market_close_proxy":
return "다음 거래일 · 지역 시장 마감시각 추정" if mode == "next_strict" else "지역 시장 마감시각 추정"
if basis == "region_calendar_proxy":
if mode == "next_strict":
return "다음 거래일(교차시장) · 지역 달력 대리정렬"
return "지역 거래일 기준 대리정렬(원천 시각 미확인)"
return f"사용자 지정 · {show(mode)}"
def event_time_summary(event: dict[str, Any], responses: list[dict[str, Any]], intraday_responses: list[dict[str, Any]]) -> list[str]:
quality = event.get("event_timestamp_quality") or ("exact_timestamp" if event.get("event_time_local") else "date_only")
quality_label = "A · 정확 시각" if quality == "exact_timestamp" else "B · 날짜만 확인"
structure = event.get("event_structure", "point_event")
structure_label = {
"point_event": "단일 시점 사건",
"scheduled_event": "예정된 이벤트",
"developing_event": "C · 다일 진행 사건",
}.get(structure, structure)
timestamp = event.get("event_time_local")
zone = event.get("event_timezone")
when = f"{event.get('event_date')} {timestamp} {zone}" if timestamp and zone else f"{event.get('event_date')} (시각 미확인)"
lines = ["**사건 시점과 D0 해석**", f"- 기준 사건: {when} · 시간 정밀도 {quality_label} · 구조 {structure_label}"]
has_intraday = any(item.get("status") == "ok" for item in intraday_responses)
if has_intraday:
lines.append("- 장중 자료가 있는 시계열은 이벤트 직전·+1시간·첫 후속 세션의 마지막 제공 바를 별도로 표시합니다. 장중 바가 공식 종가인지 확인되지 않으면 종가로 부르지 않습니다.")
else:
lines.append("- 일별 자료만으로 계산한 D0는 기준 관측값부터 첫 적격 이벤트일 관측값까지의 일별 변동입니다. 사건 이후 구간만 분리하지 못하므로 인과효과로 해석하지 않습니다.")
if structure == "developing_event":
lines.append("- 다일 진행 사건의 D+1/D+5에는 타임라인의 후속 충격·정책대응이 포함될 수 있으며 최초 사건 하나의 효과로 귀속하지 않습니다.")
if event.get("event_structure_inferred"):
lines.append("- 진행 형태는 입력에서 명시되지 않아 기본값으로 추정했습니다. 비정형·다일 사건이면 사건 파일에서 확인해 수정해야 합니다.")
sequence = event.get("event_sequence") or []
if sequence:
lines.append("- 사건 타임라인:")
for step in sequence:
step_when = step["date"]
if step.get("time_local"):
step_when += f" {step['time_local']} {step['timezone']}"
source = step["source"]
lines.append(f" - {step_when} · {step['event']} ([{source['name']}]({source['url']}))")
missing_time = [item["symbol"] for item in responses if item.get("alignment_basis") in {"region_calendar_proxy", "region_market_close_proxy", "market_close_proxy"}]
if missing_time:
lines.append(f"- 원천의 관측 시각·시간대가 없어 지역 시장 마감시각 또는 거래일 달력으로 대리 정렬한 시계열: {', '.join(missing_time)}. 일별 시점이 다를 수 있습니다.")
return lines
def intraday_value_text(response: dict[str, Any], point: dict[str, Any] | None) -> str:
if not point:
return "미확인"
unit = str(response.get("unit") or "").casefold()
value = float(point["value"])
if unit == "pct":
return f"{value:,.2f}%"
if unit in {"usd", "us_dollar", "dollar"}:
precision = 4 if response.get("asset_class") == "fx" else 2
return f"${value:,.{precision}f}"
if unit == "krw_per_usd":
return f"{value:,.2f}원/USD"
if unit in {"index", "idx", "points", "point"}:
return f"{value:,.2f}pt"
return f"{value:,.4f} {unit}".strip()
def intraday_change_text(point: dict[str, Any] | None) -> str:
if not point or point.get("change_from_pre_event") is None:
return "변화 미확인"
suffix = "bp" if point.get("change_unit") == "bp" else "%"
return signed(point["change_from_pre_event"], suffix)
def render_events(payload: dict[str, Any]) -> str:
events = payload.get("events", [])
if not events:
return "조건에 맞는 공식 일정을 확인하지 못했습니다.\n\n조회 실패 항목은 원천별로 다시 확인해야 합니다."
lines = ["## 확인된 공식 일정", "", "| 날짜 | 지역 | 이벤트 | 현지 시각 |", "| --- | --- | --- | --- |"]
for item in events:
lines.append(f"| {item['event_date']} | {item['region']} | {show(item.get('event_name') or item.get('event_family'))} | {show(item.get('event_time_local'))} |")
lines.extend(["", f"확인 시각: {friendly_timestamp(payload.get('observed_at'))}"])
return "\n".join(lines)
def render_analysis(payload: dict[str, Any]) -> str:
event = payload.get("event", {})
responses = payload.get("market_response", [])
intraday_responses = payload.get("intraday_response", [])
curves = payload.get("curves", [])
research = payload.get("research")
adapter = payload.get("adapter")
selection = payload.get("asset_selection", [])
title_suffix = "" if research else " — 이벤트 스터디만 제공"
lines = [f"## {show(event.get('event_name') or event.get('event_family'))} 시장 영향 분석 — {show(event.get('event_date'))}{title_suffix}", ""]
lines.extend(["### 1. 무슨 일이 있었나", ""])
if research:
overview = research["overview"]
lines.append(overview["summary"])
lines.append("")
lines.append(overview["surprise_assessment"])
lines.append("")
lines.append(f"이번 이벤트의 성격은 **{overview['classification']}**입니다.")
lines.extend(["", "핵심 변화:"])
for claim in research["what_changed"]:
lines.append(
f"- **{claim['channel']}**: {claim['finding']} {claim['significance']} "
f"([{claim['source']['name']}]({claim['source']['url']}))"
)
if adapter:
lines.extend(["", f"**{adapter['label']} 어댑터 확인 항목**: {', '.join(adapter['channels'])}"])
for claim in research.get("adapter_details", []):
lines.append(
f"- **{claim['channel']}**: {claim['finding']} {claim['significance']} "
f"([{claim['source']['name']}]({claim['source']['url']}))"
)
else:
lines.append("사건을 설명할 직접 발표자료와 시장 맥락 자료가 없어 사건의 배경·변화·서프라이즈를 검증하지 못했습니다.")
lines.append("아래 가격 계산만으로는 완성된 이벤트 리서치가 아닙니다.")
lines.extend([""] + event_time_summary(event, responses, intraday_responses))
lines.extend(["", "### 2. 시장은 왜 반응했나", ""])
if research:
for claim in research["market_interpretation"]:
lines.append(
f"- **{claim['channel']}**: {claim['finding']} {claim['significance']} "
f"([{claim['source']['name']}]({claim['source']['url']}))"
)
else:
lines.append("공식발표와 당일 시장 해석을 연결할 근거가 없어 인과관계를 판단하지 않았습니다.")
lines.extend(["", "### 3. 교차자산 반응", ""])
if selection:
lines.append("분석 대상으로 선택한 자산:")
for item in selection:
roles = ", ".join(item.get("matched_roles", []))
lines.append(f"- {item['symbol']}: {item['reason']}" + (f" ({roles})" if roles else ""))
lines.append("")
groups = {
"금리": {"rates", "policy", "spread"}, "신용": {"credit"}, "외환": {"fx"}, "주식": {"equity"},
"원자재": {"commodity"}, "변동성": {"volatility"}, "기타": {"price"},
}
for group_name, classes in groups.items():
items = [item for item in responses if item.get("asset_class") in classes]
if not items:
continue
lines.append(f"**{group_name}**")
for item in items:
lines.append(f"- {item['symbol']}: D0 {window_change(item, 'd0')}, D+1 {window_change(item, 'd+1')} · {alignment_label(item)}")
lines.append("")
if curves:
curve = curves[0]
lines.append(
f"금리곡선은 **{curve_name(curve['classification'])}**입니다. "
f"{curve['short']}/{curve['long']} 스프레드는 {signed(curve.get('spread_change_bp'), 'bp')} 변했습니다."
)
lines.extend(["", "### 4. 전달경로", ""])
if research and research["transmission"]:
status_names = {
"CONFIRMED": "확인됨", "CONSISTENT": "가격과 일관", "MIXED": "반응 혼재/제한",
"CONTRADICTED": "전형적 경로와 반대", "UNVERIFIED": "미검증",
}
for step in research["transmission"]:
lines.append(
f"- **{step['from']} → {step['to']}** · `{step['status']}` ({status_names[step['status']]}): {step['evidence']} "
f"([{step['source']['name']}]({step['source']['url']}))"
)
else:
lines.append("전달경로를 판정할 검증 자료가 없습니다. 전형적인 경로를 실제 반응처럼 쓰지 않았습니다.")
lines.extend(["", "### 5. 이벤트 스터디 — Supporting Evidence", ""])
if intraday_responses:
lines.extend(["**장중 반응**", ""])
market_by_symbol = {item["symbol"]: item for item in responses}
for item in intraday_responses:
if item.get("status") != "ok":
lines.append(f"- {item['symbol']}: 장중 분석 불가 — 사건의 정확 시각이 확인되지 않았습니다.")
continue
market = market_by_symbol.get(item["symbol"], {})
before = item.get("pre_event")
plus_1h = item.get("plus_1h")
session_end = item.get("session_end")
close_label = "거래소 마감 시각 바" if item.get("session_end_label") == "market_close" else "해당 세션 마지막 제공 바(공식 종가 미확인)"
lines.append(
f"- **{item['symbol']}**: 직전 {show(before.get('timestamp') if before else None)} {intraday_value_text(market, before)} → "
f"+1h 이후 {show(plus_1h.get('timestamp') if plus_1h else None)} {intraday_value_text(market, plus_1h)} "
f"({intraday_change_text(plus_1h)}) → {close_label} {show(session_end.get('timestamp') if session_end else None)} "
f"{intraday_value_text(market, session_end)} ({intraday_change_text(session_end)})"
)
for warning in item.get("warnings", []):
if warning == "intraday_session_close_unconfirmed":
lines.append(f" - {item['symbol']}: 원천의 해당 세션 마지막 바는 거래소 공식 종가 시각과 일치하지 않습니다.")
lines.append("")
lines.extend([
"일별 자료의 변화는 표에 표시한 D-1 관측값을 기준으로 계산했습니다. 각 자산의 첫 적격 관측일까지의 움직임이며, 장중 반응이나 인과효과와 동일하지 않습니다.", "",
"| 자산 | D-1 기준값 | D0 관측값 (변화) | D+1 관측값 (변화) | D+5 관측값 (변화) | 정렬 | 관측 기준 |",
"| --- | --- | --- | --- | --- | --- | --- |",
])
for item in responses:
alignment = alignment_label(item)
lines.append(
f"| {item['symbol']} | {window_observation(item, 'd-1')} | {window_observation(item, 'd0')} | "
f"{window_observation(item, 'd+1')} | {window_observation(item, 'd+5')} | {alignment} | {source_time_label(item)} |"
)
history = research.get("historical_context") if research else None
lines.extend(["", "### 6. 과거 유사 사례", ""])
if history and history.get("include"):
lines.extend([history["reason"], ""])
for comparison in history["comparisons"]:
lines.append(
f"- **{comparison['date']} · {comparison['event']}**: 매칭 근거는 {comparison['matching_basis']}입니다. 공통점은 {comparison['similarity']} 차이점은 {comparison['difference']} "
f"([{comparison['source']['name']}]({comparison['source']['url']}))"
)
else:
reason = history.get("reason") if history else None
lines.append(reason or "검증된 후보 이벤트의 충격 특성과 시장 반응 자료가 없어 유사사례 비교를 생성하지 않았습니다. 이름만 비슷한 사건으로 비교하지 않습니다.")
catalysts = research.get("catalysts", []) if research else []
observed_date = None
observed_instant = None
try:
observed_instant = datetime.fromisoformat(str(payload.get("observed_at")).replace("Z", "+00:00"))
event_zone = event.get("event_timezone") or MARKET_CLOSES.get(event.get("region"), ("UTC",))[0]
observed_date = observed_instant.astimezone(zone_for_date(event_zone, observed_instant.date())).date()
except ValueError:
pass
catalyst_statuses = []
for item in catalysts:
item_date = date.fromisoformat(item["date"])
if observed_date is None or item_date > observed_date:
status = "예정"
elif item_date < observed_date:
status = "경과"
elif item.get("time_local") and item.get("timezone") and observed_instant:
item_instant = datetime.combine(item_date, dt_time.fromisoformat(item["time_local"]),
zone_for_date(item["timezone"], item_date))
status = "경과" if item_instant <= observed_instant else "예정"
else:
status = "당일·시각 미확인"
catalyst_statuses.append(status)
if catalysts and all(status == "경과" for status in catalyst_statuses):
catalyst_heading = "### 7. 사건 당시 후속 촉매 — 모두 경과"
elif any(status != "예정" for status in catalyst_statuses):
catalyst_heading = "### 7. 후속 촉매"
else:
catalyst_heading = "### 7. 앞으로 볼 것"
lines.extend(["", catalyst_heading, ""])
if catalysts and all(status == "경과" for status in catalyst_statuses):
lines.append(f"아래는 확인 시점({observed_date.isoformat()}) 기준으로 이미 지난 일정이며, 사건 당시 감시 대상의 시간순 기록입니다.")
lines.append("")
if catalysts:
for index, catalyst in enumerate(catalysts):
status = catalyst_statuses[index]
when = f"{catalyst['date']} {catalyst['time_local']} {catalyst['timezone']}" if catalyst.get("time_local") else catalyst["date"]
lines.append(
f"- **{status} · {when} · {catalyst['event']}**: {catalyst['why_it_matters']} "
f"([{catalyst['source']['name']}]({catalyst['source']['url']}))"
)
else:
next_events = payload.get("next_events", [])
if next_events:
for next_event in next_events:
lines.append(f"- {next_event['event_date']} · {next_event['event_family']} · {show(next_event.get('event_time_local'))} {show(next_event.get('event_timezone'))}")
else:
lines.append("현재 해석을 확인하거나 뒤집을 후속 공식 일정을 확보하지 못했습니다.")
warnings = [(item["symbol"], warning) for item in responses for warning in item.get("warnings", [])]
if warnings or payload.get("failures") or not research:
lines.extend(["", "### 데이터 확인 사항", ""])
if not research:
lines.append("- Research layer 미확보: `--research-file`로 검증된 공식발표·시장해석·촉매 자료를 제공해야 합니다.")
response_by_symbol = {item["symbol"]: item for item in responses}
for symbol, warning in warnings:
if warning == "changes_unavailable_without_d_minus_1":
continue
if warning == "observation_time_basis_missing":
basis = response_by_symbol.get(symbol, {}).get("alignment_basis")
proxy = "시장 마감시각" if basis in {"market_close_proxy", "region_market_close_proxy"} else "지역 거래일 달력"
lines.append(f"- {symbol}: 원천 관측 시각·시간대가 없어 {proxy}으로 대리 정렬했습니다. 자산 간 장중 기준시각 비교에는 한계가 있습니다.")
continue
if warning.startswith("pending_"):
latest = response_by_symbol.get(symbol, {}).get("latest_observation_date")
window_name = warning.removeprefix("pending_").upper()
lines.append(f"- {symbol}: 확인 시각까지 {window_name} 확정 관측값을 확보하지 못했습니다. 미도래인지 원천 범위 부족인지는 별도 확인이 필요합니다. 최신 관측일은 {show(latest)}입니다.")
elif warning == "missing_d-1":
lines.append(f"- {symbol}: D-1 기준 관측이 없어 이벤트 변화량을 계산하지 못했습니다.")
else:
window_name = warning.removeprefix("missing_").upper()
lines.append(f"- {symbol}: {window_name} 확정 관측값이 원천에 없습니다.")
for failure in payload.get("failures", []):
if failure.get("code") == "research_context_missing":
continue
failure_names = {
"curve_series_missing": "금리곡선 계산에 필요한 시계열 미확보",
"invalid_curve": "금리곡선 입력 오류",
"series_missing": "요청한 시계열 미확보",
"asset_selection_empty": "사건 전달경로와 일치하는 시계열 미확보",
"series_starts_after_event": "첫 관측일이 사건일 이후라 반응 계산에서 제외",
}
message = failure.get("message") or failure_names.get(failure.get("code"), "일부 분석 항목 미확보")
lines.append(f"- {failure.get('symbol', '분석 항목')}: {message}")
lines.extend(["", "### 출처 및 재현 기준", ""])
event_source = event.get("source", {})
lines.append(f"- 이벤트: [{show(event_source.get('name'))}]({show(event_source.get('url'))})")
seen: set[tuple[Any, Any]] = set()
for item in responses:
source = item.get("source", {})
key = (source.get("name"), source.get("url"))
if key not in seen:
seen.add(key)
lines.append(f"- {item['symbol']}: [{show(source.get('name'))}]({show(source.get('url'))})")
lines.append(f"- 분석 확인 시각: {friendly_timestamp(payload.get('observed_at'))}")
return "\n".join(lines)
def render_threshold(payload: dict[str, Any]) -> str:
events = payload.get("events", [])
if not events:
return f"## {show(payload.get('trigger'))} 임계치 이벤트\n\n조건을 충족한 관측일이 없습니다."
lines = [f"## {show(payload.get('trigger'))} 임계치 이벤트", "", f"조건을 충족한 관측일은 **{len(events)}개**입니다.", ""]
for item in events:
metric = item.get("metric")
unit = "bp" if metric == "bp_change" else "%"
lines.append(f"- **{show(item.get('date'))}**: {signed(item.get('value'), unit)} · 기준 {show(item.get('threshold'))}{unit}")
for response in item.get("responses", []):
if "changes_unavailable_without_d_minus_1" in response.get("warnings", []):
lines.append(f" - {response.get('symbol')}: D-1 기준 관측이 없어 변화율 계산 불가")
elif response.get("failure"):
lines.append(f" - {response.get('symbol')}: 이벤트일과 정렬할 관측값 없음")
else:
lines.append(f" - {response.get('symbol')}: D0 {window_change(response, 'd0')}, D+1 {window_change(response, 'd+1')}")
for failure in item.get("failures", []):
lines.append(f" - {failure.get('symbol')}: 요청한 반응 시계열 미확보")
source = payload.get("trigger_source", {})
lines.extend([
"",
f"출처: [{show(source.get('name'))}]({show(source.get('url'))})",
f"확인 시각: {friendly_timestamp(payload.get('observed_at'))}",
])
return "\n".join(lines)
def render_chat(payload: dict[str, Any]) -> str:
result = payload.get("result")
if result in {"input_failure", "source_failure", "parse_failure"}:
return f"분석을 완료하지 못했습니다.\n\n- 사유: {show(payload.get('message'))}"
if "market_response" in payload:
return render_analysis(payload)
if "resolution" in payload or payload.get("result") in {"verification_required", "ambiguous"}:
if payload.get("result") == "ok":
event = payload["event"]
return (
f"## 사건 식별 결과\n\n- 사건: {event['event_name']}\n- 유형: {event['event_type']}\n"
f"- 기준일: {event['event_date']}\n- 지역: {event['region']}\n- 확인 방식: {payload['resolution']}"
)
candidates = payload.get("candidates", [])
candidate_text = "\n".join(f"- {item['event_name']} · {item['event_date']}" for item in candidates)
return f"사건을 확정하지 못했습니다.\n\n- 사유: {show(payload.get('message'))}" + (f"\n\n후보:\n{candidate_text}" if candidate_text else "")
if "events" in payload and "trigger" in payload:
return render_threshold(payload)
if "events" in payload:
return render_events(payload)
return f"분석 결과: {show(result)}"
def emit(payload: dict[str, Any], code: int = 0) -> None:
normalized = {"schema_version": payload.pop("schema_version", "1.0"), **payload}
if OUTPUT_FORMAT == "json":
print(json.dumps(normalized, ensure_ascii=False, indent=2))
else:
print(render_chat(normalized))
raise SystemExit(code)
def fetch_text(url: str) -> str:
request = urllib.request.Request(url, headers={"User-Agent": "k-skill/1.0 (+https://github.com/NomaDamas/k-skill)"})
try:
with urllib.request.urlopen(request, timeout=30) as response:
data = response.read(2_000_000)
return data.decode(response.headers.get_content_charset() or "utf-8", errors="replace")
except (OSError, urllib.error.URLError) as exc:
raise RuntimeError(str(exc)) from exc
def plain_text(raw_html: str) -> str:
without_scripts = re.sub(r"<(script|style)\b[\s\S]*?</\1>", " ", raw_html, flags=re.I)
return " ".join(html.unescape(re.sub(r"<[^>]+>", " ", without_scripts)).split())
def parse_fomc(raw_html: str, year: int) -> list[dict[str, Any]]:
marker = f">{year} FOMC Meetings<"
start = raw_html.find(marker)
if start < 0:
text = plain_text(raw_html)
plain_marker = f"{year} FOMC Meetings"
if plain_marker not in text:
return []
section = text.split(plain_marker, 1)[1]
pattern = re.compile(r"(" + "|".join(MONTHS) + r")\s+(\d{1,2})(?:-(\d{1,2}))?\*?")
else:
next_heading = raw_html.find("FOMC Meetings<", start + len(marker))
section = raw_html[start:next_heading if next_heading >= 0 else len(raw_html)]
pattern = re.compile(
r'fomc-meeting__month[^>]*>\s*<strong>(' + "|".join(MONTHS) +
r')</strong>[\s\S]{0,500}?fomc-meeting__date[^>]*>\s*(\d{1,2})(?:-(\d{1,2}))?\*?\s*</div>',
re.I,
)
if not section:
return []
matches = list(pattern.finditer(section))
if not matches and "fomc-meeting__month" not in section:
pattern = re.compile(r"(" + "|".join(MONTHS) + r")\s+(\d{1,2})(?:-(\d{1,2}))?\*?", re.I)
matches = list(pattern.finditer(plain_text(section)))
found = []
seen: set[str] = set()
for match in matches:
month = MONTHS[match.group(1).title()]
day = int(match.group(3) or match.group(2))
try:
event_date = date(year, month, day).isoformat()
except ValueError:
continue
if event_date in seen:
continue
seen.add(event_date)
found.append(event_object("US", "FOMC", event_date))
return found[:8]
def parse_ecb(raw_html: str, year: int) -> list[dict[str, Any]]:
entry_pattern = re.compile(r"<dt>\s*(\d{2})/(\d{2})/(%d)\s*</dt>\s*<dd>([\s\S]*?)</dd>" % year, re.I)
events = []
for day, month, found_year, body in entry_pattern.findall(raw_html):
description = plain_text(body)
if "Governing Council of the ECB: monetary policy meeting" not in description or "(Day 2)" not in description:
continue
events.append(event_object("EU", "ECB", date(int(found_year), int(month), int(day)).isoformat()))
return events
def event_object(region: str, family: str, event_date: str, **extra: Any) -> dict[str, Any]:
source_name, source_url, event_timezone = SOURCES.get((region, family), (None, None, None))
item = {
"event_id": extra.pop("event_id", f"{family.casefold().replace('_', '-')}-{event_date}"),
"event_name": extra.pop("event_name", family.replace("_", " ")),
"event_type": extra.pop("event_type", "monetary_policy"),
"region": region,
"event_family": family,
"event_date": event_date,
"event_time_local": extra.pop("event_time_local", None),
"event_timezone": extra.pop("event_timezone", event_timezone),
"actual": extra.pop("actual", None),
"consensus": extra.pop("consensus", None),
"previous": extra.pop("previous", None),
"policy_before": extra.pop("policy_before", None),
"policy_after": extra.pop("policy_after", None),
"source": extra.pop("source", {"name": source_name, "url": source_url}),
}
item.update(extra)
return item
def load_events_file(path_text: str) -> list[dict[str, Any]]:
path = Path(path_text).expanduser()
if not path.is_file():
raise ValueError(f"events file not found: {path}")
payload = json.loads(path.read_text(encoding="utf-8-sig"))
items = payload.get("events") if isinstance(payload, dict) else payload
if not isinstance(items, list) or not all(isinstance(item, dict) for item in items):
raise ValueError("events file must be an array or contain an events array")
items = [normalize_event(item) for item in items]
keys = [(item["event_id"], item["event_date"]) for item in items]
if len(keys) != len(set(keys)):
raise ValueError("events file contains duplicate event keys")
return items
def discover_events(region: str, family: str, year: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
key = (region, family)
if key not in SOURCES:
return [], [{"region": region, "event_family": family, "code": "unsupported_live_schedule"}]
name, url, _ = SOURCES[key]
if key == ("KR", "BOK_MPC"):
return [], [{"region": region, "event_family": family, "code": "browser_required", "source": {"name": name, "url": url}}]
try:
raw = fetch_text(url)
items = parse_fomc(raw, year) if key == ("US", "FOMC") else parse_ecb(raw, year)
except RuntimeError as exc:
return [], [{"region": region, "event_family": family, "code": "source_failure", "message": str(exc), "source": {"name": name, "url": url}}]
if not items:
return [], [{"region": region, "event_family": family, "code": "parse_failure", "source": {"name": name, "url": url}}]
return items, []
def numeric_surprise(actual: Any, consensus: Any) -> float | None:
try:
result = float(actual) - float(consensus)
return round(result, 10) if math.isfinite(result) else None
except (TypeError, ValueError):
return None
def event_from_args(args: argparse.Namespace) -> dict[str, Any]:
family = str(args.event or "").upper().replace(" ", "_") or None
if args.event_file:
if args.event_time_local or args.event_timezone:
raise ValueError("event time overrides cannot be combined with --event-file")
events = load_events_file(args.event_file)
if args.query:
resolved = resolve_from_events(args.query, events)
if resolved["result"] != "ok":
raise ValueError(resolved["message"])
event = dict(resolved["event"])
else:
if not args.date:
raise ValueError("--date is required when --query is omitted")
candidates = [item for item in events if item["event_date"] == args.date]
if args.event_id:
candidates = [item for item in candidates if item["event_id"] == args.event_id]
if args.region:
candidates = [item for item in candidates if item["region"] == args.region.upper()]
if family:
candidates = [item for item in candidates if item.get("event_family") == family]
if len(candidates) != 1:
raise ValueError(f"event-file must contain exactly one matching event; found {len(candidates)}")
event = dict(candidates[0])
else:
if args.query:
raise ValueError("free-form --query requires --event-file with a verified event date and source")
if bool(args.event_time_local) != bool(args.event_timezone):
raise ValueError("event-time-local and event-timezone must be provided together")
if not args.date or not args.region or not (args.event_name or args.event) or not args.event_type:
raise ValueError("direct input requires --event-name (or --event), --event-type, --region, and --date")
source = None
if args.event_source_name or args.event_source_url:
if not args.event_source_name or not args.event_source_url:
raise ValueError("event-source-name and event-source-url must be provided together")
source = {"name": args.event_source_name, "url": args.event_source_url}
if source is None:
raise ValueError("event source is required; use --event-file or event-source-name/event-source-url")
event = normalize_event({
"event_id": args.event_id,
"event_name": args.event_name or args.event,
"event_family": family,
"event_type": args.event_type,
"region": args.region,
"event_date": args.date,
"actual": args.actual,
"consensus": args.consensus,
"previous": args.previous,
"event_time_local": args.event_time_local,
"event_timezone": args.event_timezone,
"source": source,
})
return event
def cmd_resolve(args: argparse.Namespace) -> None:
if not args.events_file:
event_type, _ = infer_event_type(args.query)
emit({
"result": "verification_required",
"query": args.query,
"inferred_event_type": event_type,
"message": "사건 날짜와 직접 출처를 확인한 events 파일이 필요합니다.",
"resolution": "unverified_query",
}, 1)
result = resolve_from_events(args.query, load_events_file(args.events_file))
result["resolution"] = result.get("resolution", "unresolved")
emit(result, 0 if result["result"] == "ok" else 1)
def cmd_events(args: argparse.Namespace) -> None:
if args.events_file:
items = load_events_file(args.events_file)
family = args.event.upper().replace(" ", "_")
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))]
emit({"result": "ok" if items else "empty", "events": items, "failures": [], "observed_at": args.observed_at})
family = args.event.upper().replace(" ", "_")
items, failures = discover_events(args.region, family, args.year)
emit({"result": "ok" if items else "partial_failure", "events": items, "failures": failures, "observed_at": args.observed_at}, 0 if items else 1)
def cmd_upcoming(args: argparse.Namespace) -> None:
start = date.fromisoformat(args.as_of)
end = start + timedelta(days=args.days)
regions = list(args.region)
items: list[dict[str, Any]] = []
failures: list[dict[str, Any]] = []
if args.events_file:
items = load_events_file(args.events_file)
else:
families = {"KR": "BOK_MPC", "US": "FOMC", "EU": "ECB"}
for region in regions:
for year in sorted({start.year, end.year}):
found, failed = discover_events(region, families.get(region, ""), year)
items.extend(found)
failures.extend(failed)
failures = list({(item.get("region"), item.get("event_family"), item.get("code")): item for item in failures}.values())
items = list({(item["event_id"], item["event_date"]): item for item in items}.values())
selected = [item for item in items if item["region"] in regions and start <= date.fromisoformat(item["event_date"]) <= end]
selected.sort(key=lambda item: item["event_date"])
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})
def cmd_analyze(args: argparse.Namespace) -> None:
try:
series = load_series(args.series_file)
except (OSError, ValueError, json.JSONDecodeError, SeriesError) as exc:
emit({"result": "input_failure", "message": str(exc)}, 1)
event = event_from_args(args)
cutoff = datetime.fromisoformat(args.observed_at).astimezone(timezone.utc)
event_zone_name = event.get("event_timezone") or MARKET_CLOSES.get(event["region"], ("UTC",))[0]
event_local_cutoff = cutoff.astimezone(zone_for_date(event_zone_name, cutoff.date())).date()
if date.fromisoformat(event["event_date"]) > event_local_cutoff:
raise ValueError("event date is after --observed-at")
if event.get("event_time_local") and event.get("event_timezone"):
event_day = date.fromisoformat(event["event_date"])
event_instant = datetime.combine(event_day, dt_time.fromisoformat(event["event_time_local"]),
zone_for_date(event["event_timezone"], event_day)).astimezone(timezone.utc)
if event_instant > cutoff:
raise ValueError("event timestamp is after --observed-at")
event["surprise"] = numeric_surprise(event.get("actual"), event.get("consensus"))
research = None
if args.research_file:
research = select_research(load_research_file(args.research_file), event)
research_cutoff = research.get("observed_at")
if research_cutoff and datetime.fromisoformat(research_cutoff).astimezone(timezone.utc) > cutoff:
raise ValueError("research observed_at is after analysis --observed-at")
next_events: list[dict[str, Any]] = []
if args.event_file:
recurrence_key = event.get("recurrence_key") or event.get("event_family")
next_events = [
item for item in load_events_file(args.event_file)
if recurrence_key
and (item.get("recurrence_key") or item.get("event_family")) == recurrence_key
and item["event_date"] > event["event_date"]
]
next_events.sort(key=lambda item: item["event_date"])
cutoff_series = [series_as_of(item, args.observed_at) for item in series]
for item in cutoff_series:
if not item["observations"]:
item["selection_usable"] = False
continue
selection_alignment = args.alignment
selection_date = event["event_date"]
if args.alignment == "next":
selection_alignment, _, selection_date = market_alignment_for_series(event, item)
candidate = analyze_series(item, event["event_date"], selection_alignment, (-1, 0), selection_date)
window = candidate.get("window", {})
item["selection_usable"] = (
candidate.get("failure") is None
and window.get("d-1", {}).get("value") is not None
and window.get("d0", {}).get("value") is not None
)
selected_series, asset_selection, missing_symbols = select_assets(cutoff_series, event, args.symbols, args.max_assets)
responses = []
intraday_responses = []
failures = []
if not selected_series:
failures.append({"code": "asset_selection_empty", "message": "no available series matched the event transmission profile"})
if research is None and not args.study_only:
failures.append({"code": "research_context_missing", "message": "full event research requires --research-file"})
failures.extend({"symbol": symbol, "code": "series_missing"} for symbol in missing_symbols)
for item in selected_series:
if item["original_first_observation_date"] > event["event_date"]:
failures.append({
"symbol": item["symbol"],
"code": "series_starts_after_event",
"message": f"first observation {item['original_first_observation_date']} is after event date {event['event_date']}",
})
continue
if not item["observations"]:
failures.append({"symbol": item["symbol"], "code": "series_no_observation_asof",
"message": "확인 시각 이전의 확정 관측값이 없습니다"})
continue
series_alignment = args.alignment
alignment_basis = "user_requested_alignment"
alignment_date = event["event_date"]
if args.alignment == "next":
series_alignment, alignment_basis, alignment_date = market_alignment_for_series(event, item)
analysis_item = {**item, "alignment_basis": alignment_basis}
response = analyze_series(
analysis_item, event["event_date"], series_alignment, (-1, 0, 1, args.window), alignment_date
)
if "failure" in response:
failures.append({"symbol": item["symbol"], "code": response.pop("failure")})
responses.append(response)
if item.get("intraday_observations"):
intraday_responses.append(analyze_intraday_reaction(item, event))
curves = []
by_symbol = {item["symbol"]: item for item in responses}
if args.curve:
short_symbol, long_symbol = args.curve
try:
short_response = by_symbol[short_symbol]
long_response = by_symbol[long_symbol]
if short_response["asset_class"] not in {"rates", "policy", "spread"} or long_response["asset_class"] not in {"rates", "policy", "spread"}:
raise ValueError("curve requires rates, policy, or spread series")
if short_response.get("region") != long_response.get("region"):
raise ValueError("curve series must belong to the same region")
if short_response["window"]["d0"]["date"] != long_response["window"]["d0"]["date"]:
raise ValueError("curve series must share the same D0 observation date")
short_clock = short_response.get("observation_provenance") or {}
long_clock = long_response.get("observation_provenance") or {}
if (short_clock.get("observation_timezone"), short_clock.get("observation_time")) != (
long_clock.get("observation_timezone"), long_clock.get("observation_time")
):
raise ValueError("curve series must share the same observation clock")
short = short_response.get("window", {}).get("d0", {}).get("change_from_d_minus_1")
long = long_response.get("window", {}).get("d0", {}).get("change_from_d_minus_1")
if short is None or long is None:
raise ValueError("curve D0 response or D-1 anchor is unavailable")
curves.append({"short": short_symbol, "long": long_symbol, **classify_curve(short, long)})
except KeyError:
failures.append({"code": "curve_series_missing", "symbols": [short_symbol, long_symbol]})
except ValueError as exc:
failures.append({"code": "invalid_curve", "symbols": [short_symbol, long_symbol], "message": str(exc)})
emit({
"result": "partial_failure" if failures else ("ok" if responses else "empty"),
"event": event,
"analysis_contract": {
"research_first": True,
"anchor": "D-1",
"offsets": [-1, 0, 1, args.window],
"event_timestamp_quality": event.get("event_timestamp_quality"),
"event_structure": event.get("event_structure"),
"daily_d0_is_causal_effect": False,
"cross_market_alignment": "source_observation_time_then_market_close_proxy_then_region_market_close_proxy_then_region_calendar_proxy",
},
"research": research,
"adapter": adapter_for_event(event),
"asset_selection": asset_selection,
"market_response": responses,
"intraday_response": intraday_responses,
"curves": curves,
"next_events": next_events[:3],
"failures": failures,
"observed_at": args.observed_at,
})
def cmd_threshold(args: argparse.Namespace) -> None:
try:
series = load_series(args.series_file)
except (OSError, ValueError, json.JSONDecodeError, SeriesError) as exc:
emit({"result": "input_failure", "message": str(exc)}, 1)
by_symbol = {item["symbol"]: item for item in series}
if args.trigger not in by_symbol:
emit({"result": "empty", "message": f"trigger series not found: {args.trigger}"}, 1)
try:
matches = threshold_events(by_symbol[args.trigger], args.metric, args.direction, args.threshold)
except ValueError as exc:
emit({"result": "input_failure", "message": str(exc)}, 1)
responses = [value.strip() for value in (args.response or "").split(",") if value.strip()]
for item in matches:
item["responses"] = [analyze_series(by_symbol[symbol], item["date"], args.alignment) for symbol in responses if symbol in by_symbol]
missing = [symbol for symbol in responses if symbol not in by_symbol]
if missing:
item["failures"] = [{"symbol": symbol, "code": "series_missing"} for symbol in missing]
emit({"result": "ok" if matches else "empty", "trigger": args.trigger, "trigger_source": by_symbol[args.trigger]["source"], "events": matches, "observed_at": args.observed_at})
def parse_iso_arg(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise argparse.ArgumentTypeError("date must be YYYY-MM-DD") from exc
def parse_year(value: str) -> int:
year = int(value)
if not 2000 <= year <= 2100:
raise argparse.ArgumentTypeError("year must be between 2000 and 2100")
return year
def parse_days(value: str) -> int:
days = int(value)
if not 0 <= days <= 366:
raise argparse.ArgumentTypeError("days must be between 0 and 366")
return days
def parse_window(value: str) -> int:
window = int(value)
if not 2 <= window <= 30:
raise argparse.ArgumentTypeError("window must be between 2 and 30")
return window
def parse_asset_limit(value: str) -> int:
limit = int(value)
if not 1 <= limit <= 20:
raise argparse.ArgumentTypeError("max-assets must be between 1 and 20")
return limit
def parse_curve(value: str) -> tuple[str, str]:
parts = [part.strip() for part in value.split(",") if part.strip()]
if len(parts) != 2 or parts[0] == parts[1]:
raise argparse.ArgumentTypeError("curve must contain two distinct symbols: short,long")
return parts[0], parts[1]
def parse_regions(value: str) -> tuple[str, ...]:
regions = tuple(dict.fromkeys(part.strip().upper() for part in value.split(",") if part.strip()))
invalid = [region for region in regions if not re.fullmatch(r"[A-Z]{2,6}", region)]
if not regions or invalid:
raise argparse.ArgumentTypeError("region must contain 2-6 letter market codes")
return regions
def parse_symbols(value: str) -> tuple[str, ...]:
symbols = tuple(dict.fromkeys(part.strip() for part in value.split(",") if part.strip()))
if not symbols:
raise argparse.ArgumentTypeError("symbols must contain at least one symbol")
return symbols
def parse_timestamp(value: str) -> str:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise argparse.ArgumentTypeError("observed-at must be ISO 8601") from exc
if parsed.tzinfo is None:
raise argparse.ArgumentTypeError("observed-at must include a timezone")
return parsed.isoformat()
def parse_local_time(value: str) -> str:
try:
parsed = dt_time.fromisoformat(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("event time must be HH:MM or HH:MM:SS") from exc
if parsed.tzinfo is not None:
raise argparse.ArgumentTypeError("event time must not include a timezone offset")
return parsed.isoformat(timespec="minutes" if parsed.second == 0 and parsed.microsecond == 0 else "seconds")
def build_parser() -> argparse.ArgumentParser:
parser = JsonArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
resolve = sub.add_parser("resolve")
resolve.add_argument("--query", required=True)
resolve.add_argument("--events-file")
resolve.add_argument("--format", choices=("chat", "json"), default="chat")
resolve.set_defaults(func=cmd_resolve)
events = sub.add_parser("events")
events.add_argument("--region", required=True, choices=("KR", "US", "EU"))
events.add_argument("--event", required=True)
events.add_argument("--year", required=True, type=parse_year)
events.add_argument("--events-file")
events.add_argument("--observed-at", type=parse_timestamp, default=datetime.now(timezone.utc).isoformat())
events.add_argument("--format", choices=("chat", "json"), default="chat")
events.set_defaults(func=cmd_events)
upcoming = sub.add_parser("upcoming")
upcoming.add_argument("--region", type=parse_regions, default=("KR", "US", "EU"))
upcoming.add_argument("--days", type=parse_days, default=30)
upcoming.add_argument("--as-of", type=parse_iso_arg, default=date.today().isoformat())
upcoming.add_argument("--events-file")
upcoming.add_argument("--observed-at", type=parse_timestamp, default=datetime.now(timezone.utc).isoformat())
upcoming.add_argument("--format", choices=("chat", "json"), default="chat")
upcoming.set_defaults(func=cmd_upcoming)
analyze = sub.add_parser("analyze")
analyze.add_argument("--query", help="free-form event name; requires a verified --event-file")
analyze.add_argument("--event-id")
analyze.add_argument("--event-name")
analyze.add_argument("--event-type")
analyze.add_argument("--region")
analyze.add_argument("--event", help="legacy event family or event-name alias")
analyze.add_argument("--date", type=parse_iso_arg)
analyze.add_argument("--window", type=parse_window, default=5)
analyze.add_argument("--alignment", choices=("same", "next", "previous"), default="next")
analyze.add_argument("--series-file", required=True)
analyze.add_argument("--symbols", type=parse_symbols)
analyze.add_argument("--max-assets", type=parse_asset_limit, default=8)
analyze.add_argument("--curve", type=parse_curve, help="short,long symbols")
analyze.add_argument("--event-file")
analyze.add_argument("--research-file", help="verified decision, communication, interpretation, transmission, and catalyst research JSON")
analyze.add_argument("--study-only", action="store_true", help="allow event-window output without a research layer")
analyze.add_argument("--actual")
analyze.add_argument("--consensus")
analyze.add_argument("--previous")
analyze.add_argument("--event-source-name")
analyze.add_argument("--event-source-url")
analyze.add_argument("--event-time-local", type=parse_local_time)
analyze.add_argument("--event-timezone")
analyze.add_argument("--observed-at", type=parse_timestamp, default=datetime.now(timezone.utc).isoformat())
analyze.add_argument("--format", choices=("chat", "json"), default="chat")
analyze.set_defaults(func=cmd_analyze)
threshold = sub.add_parser("threshold")
threshold.add_argument("--series-file", required=True)
threshold.add_argument("--trigger", required=True)
threshold.add_argument("--metric", choices=("bp_change", "pct_change"), required=True)
threshold.add_argument("--direction", choices=("above", "below"), required=True)
threshold.add_argument("--threshold", type=float, required=True)
threshold.add_argument("--response")
threshold.add_argument("--alignment", choices=("same", "next", "previous"), default="next")
threshold.add_argument("--observed-at", type=parse_timestamp, default=datetime.now(timezone.utc).isoformat())
threshold.add_argument("--format", choices=("chat", "json"), default="chat")
threshold.set_defaults(func=cmd_threshold)
return parser
def main(argv: Iterable[str] | None = None) -> None:
global OUTPUT_FORMAT
args = build_parser().parse_args(argv)
OUTPUT_FORMAT = args.format
try:
args.func(args)
except (OSError, ValueError, json.JSONDecodeError, SeriesError, ResearchError, EventResolutionError) as exc:
emit({"result": "input_failure", "message": str(exc)}, 1)
if __name__ == "__main__":
main()
scripts/research.py
#!/usr/bin/env python3
"""Validate reproducible narrative research inputs for arbitrary market events."""
from __future__ import annotations
import json
import re
from datetime import date, datetime, time as dt_time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from calendars import valid_timezone
from event_resolver import EVENT_TYPES, infer_event_type, stable_event_id
class ResearchError(ValueError):
pass
def _source(value: Any, context: str) -> dict[str, str]:
if not isinstance(value, dict) or not str(value.get("name") or "").strip() or not str(value.get("url") or "").strip():
raise ResearchError(f"{context} needs source.name and source.url")
url = str(value["url"]).strip()
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ResearchError(f"{context}.source.url must be an http(s) URL")
return {"name": str(value["name"]).strip(), "url": url}
def _text(value: Any, context: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ResearchError(f"{context} must be a non-empty string")
return value.strip()
def _claim_list(value: Any, field: str) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ResearchError(f"{field} must be an object array")
result = []
for index, item in enumerate(value):
context = f"{field}[{index}]"
result.append({
"channel": _text(item.get("channel"), f"{context}.channel"),
"finding": _text(item.get("finding"), f"{context}.finding"),
"significance": _text(item.get("significance"), f"{context}.significance"),
"source": _source(item.get("source"), context),
})
return result
def _catalysts(value: Any) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ResearchError("catalysts must be an object array")
result = []
for index, item in enumerate(value):
context = f"catalysts[{index}]"
try:
event_date = date.fromisoformat(str(item.get("date"))).isoformat()
except ValueError as exc:
raise ResearchError(f"{context}.date must be YYYY-MM-DD") from exc
time_local = item.get("time_local")
timezone_name = item.get("timezone")
if bool(time_local) != bool(timezone_name):
raise ResearchError(f"{context}.time_local and timezone must be provided together")
if time_local:
try:
parsed_time = dt_time.fromisoformat(str(time_local))
if parsed_time.tzinfo is not None or not valid_timezone(str(timezone_name), date.fromisoformat(event_date)):
raise ValueError
except ValueError as exc:
raise ResearchError(f"{context}.time_local requires local time and valid IANA timezone") from exc
time_local = parsed_time.isoformat(timespec="minutes" if parsed_time.second == 0 else "seconds")
result.append({
"date": event_date,
"time_local": time_local,
"timezone": timezone_name,
"event": _text(item.get("event"), f"{context}.event"),
"why_it_matters": _text(item.get("why_it_matters"), f"{context}.why_it_matters"),
"source": _source(item.get("source"), context),
})
result.sort(key=lambda item: (item["date"], item["time_local"] or ""))
return result
def _transmission(value: Any) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ResearchError("transmission must be an object array")
allowed = {"CONFIRMED", "CONSISTENT", "MIXED", "CONTRADICTED", "UNVERIFIED"}
result = []
for index, item in enumerate(value):
context = f"transmission[{index}]"
raw_status = str(item.get("status", "")).strip().casefold()
status = {"confirmed": "CONFIRMED", "consistent": "CONSISTENT", "limited": "MIXED", "mixed": "MIXED", "contradicted": "CONTRADICTED", "unverified": "UNVERIFIED"}.get(raw_status, "")
if status not in allowed:
raise ResearchError(f"{context}.status must be one of {sorted(allowed)}")
origin = _text(item.get("from"), f"{context}.from")
destination = _text(item.get("to"), f"{context}.to")
if origin.casefold() == destination.casefold():
raise ResearchError(f"{context}.from and .to must differ")
result.append({
"from": origin,
"to": destination,
"status": status,
"evidence": _text(item.get("evidence"), f"{context}.evidence"),
"source": _source(item.get("source"), context),
})
keys = [(item["from"].casefold(), item["to"].casefold()) for item in result]
if len(keys) != len(set(keys)):
raise ResearchError("transmission contains duplicate steps")
return result
def _history(value: Any) -> dict[str, Any]:
if value is None:
return {"include": False, "reason": None, "comparisons": []}
if not isinstance(value, dict):
raise ResearchError("historical_context must be an object")
include_raw = value.get("include", False)
if not isinstance(include_raw, bool):
raise ResearchError("historical_context.include must be true or false")
include = include_raw
comparisons = value.get("comparisons", [])
if not isinstance(comparisons, list) or not all(isinstance(item, dict) for item in comparisons):
raise ResearchError("historical_context.comparisons must be an object array")
normalized = []
for index, item in enumerate(comparisons):
context = f"historical_context.comparisons[{index}]"
try:
event_date = date.fromisoformat(str(item.get("date"))).isoformat()
except ValueError as exc:
raise ResearchError(f"{context}.date must be YYYY-MM-DD") from exc
normalized.append({
"date": event_date,
"event": _text(item.get("event"), f"{context}.event"),
"matching_basis": _text(item.get("matching_basis") or item.get("similarity"), f"{context}.matching_basis"),
"similarity": _text(item.get("similarity"), f"{context}.similarity"),
"difference": _text(item.get("difference"), f"{context}.difference"),
"source": _source(item.get("source"), context),
})
if include and not 3 <= len(normalized) <= 5:
raise ResearchError("historical_context.include=true needs 3 to 5 comparisons")
if not include and normalized:
raise ResearchError("historical_context comparisons require include=true")
return {
"include": include,
"reason": _text(value.get("reason"), "historical_context.reason") if include else None,
"comparisons": sorted(normalized, key=lambda item: item["date"]),
}
def _tokens(value: Any, context: str) -> list[str]:
if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
raise ResearchError(f"{context} must be a non-empty-string array")
return list(dict.fromkeys(item.strip().casefold() for item in value))
def _shock_pattern(value: Any, context: str) -> dict[str, str]:
allowed = {"up", "down", "mixed", "flat", "wider", "tighter"}
if not isinstance(value, dict) or not value:
raise ResearchError(f"{context} must be a non-empty object")
result = {}
for raw_key, raw_direction in value.items():
key = _text(raw_key, f"{context} key").casefold()
direction = _text(raw_direction, f"{context}.{key}").casefold()
if direction not in allowed:
raise ResearchError(f"{context}.{key} must be one of {sorted(allowed)}")
result[key] = direction
return result
def _event_features(value: Any, context: str = "event_features") -> dict[str, Any]:
if value is None:
return {"shock_mechanisms": [], "affected_channels": [], "market_shock_pattern": {}}
if not isinstance(value, dict):
raise ResearchError(f"{context} must be an object")
mechanisms = _tokens(value.get("shock_mechanisms", []), f"{context}.shock_mechanisms") if value.get("shock_mechanisms") else []
channels = _tokens(value.get("affected_channels", []), f"{context}.affected_channels") if value.get("affected_channels") else []
pattern = _shock_pattern(value["market_shock_pattern"], f"{context}.market_shock_pattern") if value.get("market_shock_pattern") else {}
return {"shock_mechanisms": mechanisms, "affected_channels": channels, "market_shock_pattern": pattern}
def _analogue_pool(value: Any, event_date: str) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ResearchError("historical_analogue_pool must be an object array")
if len(value) > 50:
raise ResearchError("historical_analogue_pool supports at most 50 candidates")
result = []
keys = set()
for index, item in enumerate(value):
context = f"historical_analogue_pool[{index}]"
try:
candidate_date = date.fromisoformat(str(item.get("date"))).isoformat()
except ValueError as exc:
raise ResearchError(f"{context}.date must be YYYY-MM-DD") from exc
if candidate_date >= event_date:
raise ResearchError(f"{context}.date must be before the research event_date")
event_type = _text(item.get("event_type"), f"{context}.event_type").casefold().replace(" ", "_")
if event_type not in EVENT_TYPES:
raise ResearchError(f"{context}.event_type is unsupported")
features = _event_features(item, context)
if not features["shock_mechanisms"] or not features["affected_channels"] or not features["market_shock_pattern"]:
raise ResearchError(f"{context} needs shock_mechanisms, affected_channels, and market_shock_pattern")
candidate = {
"date": candidate_date,
"event": _text(item.get("event"), f"{context}.event"),
"event_type": event_type,
**features,
"similarity": _text(item.get("similarity"), f"{context}.similarity"),
"difference": _text(item.get("difference"), f"{context}.difference"),
"source": _source(item.get("source"), context),
}
key = (candidate_date, candidate["event"].casefold())
if key in keys:
raise ResearchError("historical_analogue_pool contains duplicate events")
keys.add(key)
result.append(candidate)
return result
def _overlap(left: set[str], right: set[str]) -> float:
union = left | right
return len(left & right) / len(union) if union else 0.0
def _rank_analogues(current: dict[str, Any], candidates: list[dict[str, Any]], event_type: str) -> dict[str, Any]:
ranked = []
for candidate in candidates:
mechanism_overlap = _overlap(set(current["shock_mechanisms"]), set(candidate["shock_mechanisms"]))
channel_overlap = _overlap(set(current["affected_channels"]), set(candidate["affected_channels"]))
current_pattern = current["market_shock_pattern"]
candidate_pattern = candidate["market_shock_pattern"]
shared_keys = set(current_pattern) & set(candidate_pattern)
pattern_union = set(current_pattern) | set(candidate_pattern)
pattern_overlap = (
sum(current_pattern[key] == candidate_pattern[key] for key in shared_keys) / len(pattern_union)
if pattern_union else 0.0
)
if mechanism_overlap == 0 or pattern_overlap == 0:
continue
type_match = event_type == candidate["event_type"]
score = round(2.0 * type_match + 3.0 * mechanism_overlap + 2.0 * channel_overlap + 4.0 * pattern_overlap, 6)
matching_basis = (
f"사건유형 {'일치' if type_match else '불일치'}, 충격 메커니즘 중복률 {mechanism_overlap:.0%}, "
f"영향 채널 중복률 {channel_overlap:.0%}, 관측된 시장충격 방향 일치율 {pattern_overlap:.0%}"
)
ranked.append((score, candidate["date"], candidate, matching_basis))
ranked.sort(key=lambda item: (-item[0], item[1], item[2]["event"].casefold()))
if len(ranked) < 3:
return {
"include": False,
"reason": "특성과 시장충격 방향을 함께 대조할 검증된 과거 후보가 3개 미만입니다.",
"comparisons": [],
}
comparisons = []
for score, _, candidate, matching_basis in ranked[:5]:
comparisons.append({
"date": candidate["date"],
"event": candidate["event"],
"matching_basis": matching_basis,
"similarity": candidate["similarity"],
"difference": candidate["difference"],
"score": score,
"source": candidate["source"],
})
return {
"include": True,
"reason": "사건 특성과 실제 시장충격 방향을 함께 점수화해 과거 사례를 정렬했습니다.",
"comparisons": comparisons,
}
def normalize_research(item: dict[str, Any]) -> dict[str, Any]:
try:
event_date = date.fromisoformat(str(item.get("event_date"))).isoformat()
except ValueError as exc:
raise ResearchError("research event_date must be YYYY-MM-DD") from exc
region = str(item.get("region", "")).upper()
if not re.fullmatch(r"[A-Z]{2,6}", region):
raise ResearchError("research region must be a 2-6 letter market code")
overview = item.get("overview") or item.get("decision")
if not isinstance(overview, dict):
raise ResearchError("research overview must be an object")
if overview.get("expected") is not None and not isinstance(overview.get("expected"), bool):
raise ResearchError("overview.expected must be true, false, or null")
event_name = _text(item.get("event_name") or item.get("event_family"), "research.event_name")
event_id = str(item.get("event_id") or "").strip().casefold() or stable_event_id(event_name)
if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,95}", event_id):
raise ResearchError("research.event_id must use 1-96 lowercase letters, numbers, dots, underscores, or hyphens")
raw_event_type = item.get("event_type")
event_type = (_text(raw_event_type, "research.event_type").casefold().replace(" ", "_")
if raw_event_type is not None else infer_event_type(f"{event_name} {item.get('event_family') or ''}")[0])
if not event_id:
raise ResearchError("research.event_id is required when it cannot be derived from event_name")
if event_type not in EVENT_TYPES:
raise ResearchError(f"unsupported research event_type: {event_type}")
research_observed_at = item.get("observed_at")
if research_observed_at is not None:
try:
parsed_research_observed_at = datetime.fromisoformat(str(research_observed_at).replace("Z", "+00:00"))
except ValueError as exc:
raise ResearchError("research observed_at must be ISO 8601 with a timezone") from exc
if parsed_research_observed_at.tzinfo is None:
raise ResearchError("research observed_at must be ISO 8601 with a timezone")
research_observed_at = parsed_research_observed_at.isoformat()
normalized = {
"region": region,
"event_id": event_id,
"event_name": event_name,
"event_family": str(item.get("event_family") or "").strip().upper().replace(" ", "_") or None,
"event_type": event_type,
"event_date": event_date,
"observed_at": research_observed_at,
"event_features": _event_features(item.get("event_features")),
"historical_analogue_pool": _analogue_pool(item.get("historical_analogue_pool"), event_date),
"overview": {
"summary": _text(overview.get("summary"), "overview.summary"),
"expected": overview.get("expected") if isinstance(overview.get("expected"), bool) else None,
"surprise_assessment": _text(overview.get("surprise_assessment"), "overview.surprise_assessment"),
"classification": _text(overview.get("classification") or overview.get("event_type"), "overview.classification"),
"source": _source(overview.get("source"), "overview"),
},
"what_changed": _claim_list(item.get("what_changed"), "what_changed"),
"adapter_details": _claim_list(item.get("adapter_details"), "adapter_details"),
"market_interpretation": _claim_list(item.get("market_interpretation"), "market_interpretation"),
"transmission": _transmission(item.get("transmission")),
"catalysts": _catalysts(item.get("catalysts")),
"historical_context": _history(item.get("historical_context")),
}
if not normalized["historical_context"]["include"] and normalized["historical_analogue_pool"]:
normalized["historical_context"] = _rank_analogues(
normalized["event_features"], normalized["historical_analogue_pool"], event_type
)
if not normalized["what_changed"]:
raise ResearchError("research needs at least one what_changed claim")
if not normalized["market_interpretation"]:
raise ResearchError("research needs at least one market_interpretation claim")
if not normalized["transmission"]:
raise ResearchError("research needs at least one transmission step; use unverified when evidence is unavailable")
if len(normalized["catalysts"]) < 2:
raise ResearchError("research needs at least two relevant catalysts")
if len(normalized["catalysts"]) > 8:
raise ResearchError("research supports at most eight relevant catalysts")
catalyst_keys = [(item["date"], item["event"].casefold()) for item in normalized["catalysts"]]
if len(catalyst_keys) != len(set(catalyst_keys)):
raise ResearchError("research contains duplicate catalysts")
if any(item["date"] <= event_date for item in normalized["catalysts"]):
raise ResearchError("catalysts must be after the research event_date")
history = normalized["historical_context"]
if any(item["date"] >= event_date for item in history["comparisons"]):
raise ResearchError("historical comparisons must be before the research event_date")
return normalized
def load_research_file(path_text: str) -> list[dict[str, Any]]:
path = Path(path_text).expanduser()
if not path.is_file():
raise ResearchError(f"research file not found: {path}")
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
except json.JSONDecodeError as exc:
raise ResearchError(f"invalid research JSON: {exc}") from exc
items = payload.get("research") if isinstance(payload, dict) else payload
if not isinstance(items, list) or not all(isinstance(item, dict) for item in items):
raise ResearchError("research file must be an array or contain a research array")
normalized = [normalize_research(item) for item in items]
keys = [(item["event_id"], item["event_date"]) for item in normalized]
if len(keys) != len(set(keys)):
raise ResearchError("research file contains duplicate event keys")
return normalized
def select_research(items: list[dict[str, Any]], event: dict[str, Any]) -> dict[str, Any]:
matches = [item for item in items if item["event_id"] == event["event_id"] and item["event_date"] == event["event_date"]]
if not matches:
matches = [item for item in items if item["event_name"].casefold() == event["event_name"].casefold() and item["event_date"] == event["event_date"]]
if not matches and event.get("event_family"):
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"]]
if len(matches) != 1:
raise ResearchError(f"research file must contain exactly one matching event; found {len(matches)}")
return matches[0]
skill.json
{
"name": "market-event-impact",
"description": "GS Quant의 event_study·event_impact_analysis·CalendarAlignment 구조에서 영감을 받아, 정례 발표와 비정형 사건의 핵심 변화 및 시장 해석을 공식자료·당시 보도·검증 가격으로 정리한다. 교차자산 전달경로를 살피고 이벤트 스터디는 보조 증거로 제시하며, 유사사례와 다음 촉매를 한국어로 설명한다. Use for named market-moving events. Not for live quotes, forecasts, or investment advice.",
"profiles": [
"lookup",
"browser"
],
"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"
}
SKILL.md
---
name: market-event-impact
description: GS Quant의 event_study·event_impact_analysis·CalendarAlignment 구조에서 영감을 받아, 정례 발표와 비정형 사건의 핵심 변화 및 시장 해석을 공식자료·당시 보도·검증 가격으로 정리한다. 교차자산 전달경로를 살피고 이벤트 스터디는 보조 증거로 제시하며, 유사사례와 다음 촉매를 한국어로 설명한다. Use for named market-moving events. Not for live quotes, forecasts, or investment advice.
license: MIT
metadata:
category: finance
locale: ko-KR
phase: v1
---
# market-event-impact
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct market-event-impact
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files market-event-impact
```
Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with:
```bash
npx -y @nomadamas/k-skill@0 update
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/market-event-impact/instruction.md.
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.
tests/fixtures/events.json
{
"events": [
{
"event_id": "fomc-2026-09-16",
"event_name": "September 2026 FOMC decision",
"event_type": "monetary_policy",
"adapter": "FOMC",
"aliases": ["2026년 9월 FOMC", "September FOMC"],
"region": "US",
"event_family": "FOMC",
"event_date": "2026-09-16",
"event_time_local": "14:00",
"event_timezone": "America/New_York",
"actual": "hold",
"consensus": "hold",
"previous": "hold",
"source": {"name": "fixture", "url": "https://example.test/fomc"}
},
{
"event_id": "ecb-2026-10-29",
"event_name": "October 2026 ECB decision",
"event_type": "monetary_policy",
"adapter": "ECB",
"aliases": ["2026년 10월 ECB"],
"region": "EU",
"event_family": "ECB",
"event_date": "2026-10-29",
"event_time_local": "14:15",
"event_timezone": "Europe/Frankfurt",
"source": {"name": "fixture", "url": "https://example.test/ecb"}
},
{
"event_id": "fomc-2026-10-28",
"event_name": "October 2026 FOMC decision",
"event_type": "monetary_policy",
"adapter": "FOMC",
"aliases": ["2026년 10월 FOMC", "October FOMC"],
"region": "US",
"event_family": "FOMC",
"event_date": "2026-10-28",
"event_time_local": "14:00",
"event_timezone": "America/New_York",
"source": {"name": "fixture", "url": "https://example.test/fomc-next"}
},
{
"event_id": "svb-failure-2023-03-10",
"event_name": "실리콘밸리은행(SVB) 파산",
"event_type": "financial_stress",
"event_structure": "developing_event",
"aliases": ["SVB 파산", "SVB 사태", "Silicon Valley Bank collapse"],
"region": "US",
"event_date": "2023-03-10",
"event_time_local": "11:15",
"event_timezone": "America/New_York",
"event_sequence": [
{"date": "2023-03-09", "stage": "prelude", "event": "채권매각 손실 및 자본조달 이슈 공개", "source": {"name": "fixture news", "url": "https://example.test/svb-prelude"}},
{"date": "2023-03-10", "time_local": "11:15", "timezone": "America/New_York", "stage": "trigger", "event": "규제당국의 은행 폐쇄", "source": {"name": "fixture FDIC", "url": "https://example.test/svb-regulator"}},
{"date": "2023-03-12", "stage": "policy_response", "event": "예금자 보호 및 유동성 대책 발표", "source": {"name": "fixture Federal Reserve", "url": "https://example.test/svb-policy"}},
{"date": "2023-03-13", "stage": "market_reopen", "event": "주말 이후 첫 시장 재개", "source": {"name": "fixture market data", "url": "https://example.test/svb-reopen"}}
],
"source": {"name": "fixture regulator release", "url": "https://example.test/svb-regulator"}
}
]
}
tests/fixtures/fitch-event.json
{
"events": [
{
"event_id": "fitch-us-downgrade-2023-08-01",
"event_name": "2023년 피치 미국 국가신용등급 강등",
"aliases": ["피치 미국 신용등급 강등", "Fitch US sovereign downgrade"],
"event_type": "sovereign_credit",
"event_structure": "point_event",
"region": "US",
"event_date": "2023-08-01",
"event_time_local": "17:13",
"event_timezone": "America/New_York",
"source": {
"name": "Fitch Ratings — U.S. rating action, 1 Aug 2023",
"url": "https://www.fitchratings.com/research/sovereigns/fitch-downgrades-united-states-long-term-ratings-to-aa-from-aaa-outlook-stable-01-08-2023"
},
"event_sequence": [
{
"date": "2023-08-01",
"time_local": "17:13",
"timezone": "America/New_York",
"stage": "rating_action",
"event": "피치가 미국 장기 외화표시 IDR을 AAA에서 AA+로 강등하고 부정적 관찰대상을 해제, 전망을 안정적으로 부여",
"source": {
"name": "Fitch Ratings",
"url": "https://www.fitchratings.com/research/sovereigns/fitch-downgrades-united-states-long-term-ratings-to-aa-from-aaa-outlook-stable-01-08-2023"
}
},
{
"date": "2023-08-02",
"stage": "overlapping_catalyst",
"event": "미 재무부가 분기 국채 발행 규모 확대 계획을 발표",
"source": {
"name": "U.S. Treasury — Quarterly Refunding",
"url": "https://home.treasury.gov/news/press-releases/jy1671"
}
}
]
}
]
}
tests/fixtures/fitch-market-series.json
{
"series": [
{
"symbol": "DGS2", "region": "US", "asset_class": "rates", "unit": "pct",
"roles": ["government_bond", "short_rates"],
"observation_timezone": "America/New_York", "observation_time": "15:30",
"market_timezone": "America/New_York", "price_type": "H.15 Treasury yield, closing quote",
"source": {"name": "FRED DGS2", "url": "https://fred.stlouisfed.org/data/DGS2"},
"observations": [
{"date":"2023-08-01","value":4.92},{"date":"2023-08-02","value":4.88},
{"date":"2023-08-03","value":4.90},{"date":"2023-08-04","value":4.78},
{"date":"2023-08-07","value":4.76},{"date":"2023-08-08","value":4.74},
{"date":"2023-08-09","value":4.79},{"date":"2023-08-10","value":4.82}
]
},
{
"symbol": "DGS10", "region": "US", "asset_class": "rates", "unit": "pct",
"roles": ["government_bond", "long_rates"],
"observation_timezone": "America/New_York", "observation_time": "15:30",
"market_timezone": "America/New_York", "price_type": "H.15 Treasury yield, closing quote",
"source": {"name": "FRED DGS10", "url": "https://fred.stlouisfed.org/data/DGS10"},
"observations": [
{"date":"2023-08-01","value":4.05},{"date":"2023-08-02","value":4.08},
{"date":"2023-08-03","value":4.20},{"date":"2023-08-04","value":4.05},
{"date":"2023-08-07","value":4.09},{"date":"2023-08-08","value":4.02},
{"date":"2023-08-09","value":4.00},{"date":"2023-08-10","value":4.09}
]
},
{
"symbol": "SP500", "region": "US", "asset_class": "equity", "unit": "index",
"roles": ["broad_equity"],
"observation_timezone": "America/New_York", "observation_time": "16:00",
"market_timezone": "America/New_York", "price_type": "daily cash-market close",
"source": {"name": "FRED SP500", "url": "https://fred.stlouisfed.org/data/SP500"},
"observations": [
{"date":"2023-08-01","value":4576.73},{"date":"2023-08-02","value":4513.39},
{"date":"2023-08-03","value":4501.89},{"date":"2023-08-04","value":4478.03},
{"date":"2023-08-07","value":4518.44},{"date":"2023-08-08","value":4499.38},
{"date":"2023-08-09","value":4467.71},{"date":"2023-08-10","value":4468.83}
]
},
{
"symbol": "KOSPI", "region": "KR", "asset_class": "equity", "unit": "index",
"roles": ["broad_equity", "affected_equity"],
"observation_timezone": null, "observation_time": null,
"market_timezone": "Asia/Seoul", "price_type": "daily cash-market close",
"source": {"name": "Countryeconomy KOSPI August 2023", "url": "https://countryeconomy.com/stock-exchange/south-korea?dr=2023-08"},
"observations": [
{"date":"2023-08-01","value":2667.07},{"date":"2023-08-02","value":2616.47},
{"date":"2023-08-03","value":2605.39},{"date":"2023-08-04","value":2602.80},
{"date":"2023-08-07","value":2580.71},{"date":"2023-08-08","value":2573.98},
{"date":"2023-08-09","value":2605.12},{"date":"2023-08-10","value":2601.56}
]
},
{
"symbol": "DEXUSEU", "region": "US", "asset_class": "fx", "unit": "usd",
"roles": ["fx"],
"observation_timezone": "America/New_York", "observation_time": "12:00",
"market_timezone": "America/New_York", "price_type": "NYC noon buying rate; USD per EUR",
"source": {"name": "FRED DEXUSEU", "url": "https://fred.stlouisfed.org/data/DEXUSEU"},
"observations": [
{"date":"2023-08-01","value":1.0971},{"date":"2023-08-02","value":1.0939},
{"date":"2023-08-03","value":1.0947},{"date":"2023-08-04","value":1.1036},
{"date":"2023-08-07","value":1.1000},{"date":"2023-08-08","value":1.0951},
{"date":"2023-08-09","value":1.0975},{"date":"2023-08-10","value":1.1016}
]
},
{
"symbol": "VIXCLS", "region": "US", "asset_class": "volatility", "unit": "index",
"roles": ["volatility"], "price_type": "daily index close",
"source": {"name": "FRED VIXCLS", "url": "https://fred.stlouisfed.org/data/VIXCLS"},
"observations": [
{"date":"2023-08-01","value":13.93},{"date":"2023-08-02","value":16.09},
{"date":"2023-08-03","value":15.92},{"date":"2023-08-04","value":17.10},
{"date":"2023-08-07","value":15.77},{"date":"2023-08-08","value":15.99},
{"date":"2023-08-09","value":15.96},{"date":"2023-08-10","value":15.85}
]
}
]
}
tests/fixtures/fitch-research.json
{
"research": [
{
"event_id": "fitch-us-downgrade-2023-08-01",
"event_name": "2023년 피치 미국 국가신용등급 강등",
"event_type": "sovereign_credit",
"region": "US",
"event_date": "2023-08-01",
"observed_at": "2023-08-10T17:00:00-04:00",
"event_features": {
"shock_mechanisms": ["rating_downgrade", "fiscal_governance", "debt_supply_risk"],
"affected_channels": ["government_bond", "equity", "fx", "volatility"],
"market_shock_pattern": {"rates": "mixed", "equity": "down", "fx": "mixed", "volatility": "up"}
},
"overview": {
"summary": "피치는 8월 1일 오후 5시 13분(미 동부) 미국의 장기 외화표시 국가신용등급을 AAA에서 AA+로 한 단계 내렸다. 부정적 관찰대상은 해제하고 등급 전망은 안정적으로 바꿨다. 발표는 미국 주식 현물시장 마감 뒤였다.",
"expected": false,
"surprise_assessment": "부정적 관찰대상 지정으로 등급 조정 위험은 알려져 있었지만, 6월 부채한도 협상 타결 뒤의 강등 시점은 투자자에게 뜻밖이었다. 따라서 새 정보는 단순한 등급 한 단계보다 피치가 재정 악화와 거버넌스 문제를 여전히 지속적인 위험으로 본다는 판단이었다.",
"classification": "미국 국가신용 재평가와 국채 발행 확대 뉴스가 겹친 사건",
"source": {"name": "Fitch Ratings — U.S. rating action", "url": "https://www.fitchratings.com/research/sovereigns/fitch-downgrades-united-states-long-term-ratings-to-aa-from-aaa-outlook-stable-01-08-2023"}
},
"what_changed": [
{
"channel": "등급과 전망",
"finding": "장기 외화표시 발행자등급은 AAA에서 AA+로 하향됐고, Rating Watch Negative는 해제되며 전망은 Stable이 됐다. 단기등급과 국가상한은 그대로 유지됐다.",
"significance": "즉각적인 지급불능 선언이 아니라 중기 재정·제도 위험에 대한 신용평가 변경이다.",
"source": {"name": "Fitch Ratings", "url": "https://www.fitchratings.com/research/sovereigns/fitch-downgrades-united-states-long-term-ratings-to-aa-from-aaa-outlook-stable-01-08-2023"}
},
{
"channel": "강등 근거",
"finding": "피치는 향후 3년의 재정 악화 예상, 높은 정부부채, 반복된 부채한도 대치에 나타난 거버넌스 약화를 근거로 들었다.",
"significance": "시장의 확인 지점은 국채의 당일 신용손실보다 장기 발행 부담과 위험 프리미엄 경로였다.",
"source": {"name": "Fitch Ratings — Key Rating Drivers", "url": "https://www.fitchratings.com/research/sovereigns/fitch-downgrades-united-states-long-term-ratings-to-aa-from-aaa-outlook-stable-01-08-2023"}
},
{
"channel": "미 재무부의 반론",
"finding": "재무장관 옐런은 판단에 동의하지 않는다며 미국 국채의 안전성과 유동성을 강조했다.",
"significance": "등급 변경을 곧바로 국채 수요 붕괴나 달러 약세로 등치하지 말아야 한다.",
"source": {"name": "U.S. Treasury — Yellen statement", "url": "https://home.treasury.gov/news/press-releases/jy1665"}
}
],
"market_interpretation": [
{
"channel": "주식과 변동성",
"finding": "첫 미국 거래일인 8월 2일 S&P 500은 1.38% 하락하고 VIX는 13.93에서 16.09로 올랐다. Reuters는 강등 뒤 위험자산 약세와 기존 상승분의 차익실현을 함께 보도했다.",
"significance": "등급 뉴스가 위험회피의 계기였다는 해석과 일치하지만, 하루 전체 하락을 피치 결정 하나에 귀속할 수는 없다.",
"source": {"name": "Reuters — Wall Street after Fitch downgrade", "url": "https://www.marketscreener.com/news/latest/Wall-Street-ends-lower-as-investors-step-back-after-Fitch-downgrades-US-44498808/"}
},
{
"channel": "한국 주식의 다음 거래일",
"finding": "코스피는 뉴욕 장 마감 뒤 나온 강등 발표 다음 날인 8월 2일 2,667.07에서 2,616.47로 1.90% 하락했다.",
"significance": "미국 주식과 같은 위험회피 방향이지만 한국 현물시장 하락의 단일 원인을 피치 발표로 확정하지 않는다.",
"source": {"name": "Countryeconomy — KOSPI August 2023", "url": "https://countryeconomy.com/stock-exchange/south-korea?dr=2023-08"}
},
{
"channel": "국채와 발행 공급",
"finding": "8월 2일 미국 2년물 수익률은 전일보다 4bp 낮고 10년물은 3bp 높았다. 같은 날 재무부는 1,030억 달러 규모의 분기 차환과 국채 발행 확대를 발표했다.",
"significance": "장단기 금리의 방향도 달랐고 발행 공급 뉴스가 겹쳤다. 10년물 상승을 강등의 직접 비용으로 단정할 근거가 부족하다.",
"source": {"name": "U.S. Treasury — Quarterly Refunding", "url": "https://home.treasury.gov/news/press-releases/jy1671"}
},
{
"channel": "외환",
"finding": "FRED 뉴욕 정오 EUR/USD는 8월 1일 1.0971에서 8월 2일 1.0939로 내려갔다. 이 관측 구간에는 달러가 유로 대비 강세였다.",
"significance": "미국 등급 하향이면 달러가 약해질 것이라는 단선적 경로와 반대다. 뉴욕 정오 값이므로 발표 직후 장외 움직임은 따로 측정하지 못한다.",
"source": {"name": "FRED DEXUSEU", "url": "https://fred.stlouisfed.org/data/DEXUSEU"}
}
],
"transmission": [
{
"from": "국가신용등급 강등",
"to": "주식 위험선호 약화·변동성 상승",
"status": "CONSISTENT",
"evidence": "Reuters는 강등 뒤 주식 약세를 연결해 보도했다. S&P 500 -1.38%와 VIX 상승이 방향을 뒷받침하지만 차익실현·금리 상승·고용지표도 겹쳤다.",
"source": {"name": "Reuters — Wall Street after Fitch downgrade", "url": "https://www.marketscreener.com/news/latest/Wall-Street-ends-lower-as-investors-step-back-after-Fitch-downgrades-US-44498808/"}
},
{
"from": "미국 신용 뉴스와 글로벌 위험회피",
"to": "한국 주식시장 약세",
"status": "CONSISTENT",
"evidence": "코스피는 발표 다음 한국 거래일에 1.90% 하락했다. 시간순서와 방향은 일치하지만 동시 국내외 요인을 분리할 장중 근거는 없다.",
"source": {"name": "Countryeconomy — KOSPI August 2023", "url": "https://countryeconomy.com/stock-exchange/south-korea?dr=2023-08"}
},
{
"from": "등급 하향과 재정 우려",
"to": "미 국채 금리 상승",
"status": "MIXED",
"evidence": "8월 2일 10년물은 +3bp지만 2년물은 -4bp였다. 재무부의 차환·발행 확대 발표가 같은 날 있었으므로 국채금리 반응을 강등 단일 효과로 식별할 수 없다.",
"source": {"name": "U.S. Treasury — Quarterly Refunding", "url": "https://home.treasury.gov/news/press-releases/jy1671"}
},
{
"from": "미국 신용등급 하향",
"to": "달러 약세",
"status": "CONTRADICTED",
"evidence": "첫 뉴욕 정오 관측에서 EUR/USD가 1.0971에서 1.0939로 내려 달러는 유로 대비 강세였다. 단일 통화쌍 결과이며 전체 달러지수의 판정은 아니다.",
"source": {"name": "FRED DEXUSEU", "url": "https://fred.stlouisfed.org/data/DEXUSEU"}
}
],
"catalysts": [
{
"date": "2023-08-02", "time_local": "08:15", "timezone": "America/New_York",
"event": "ADP 미국 7월 민간고용",
"why_it_matters": "민간고용은 32만4천 명 증가했다. 첫 거래일 금리와 주식 가격에는 신용등급 이외의 노동시장 정보도 반영될 수 있다.",
"source": {"name": "ADP Research Institute", "url": "https://mediacenter.adp.com/2023-08-02-ADP-National-Employment-Report-Private-Sector-Employment-Increased-by-324%2C000-Jobs-in-July-Annual-Pay-was-Up-6-2"}
},
{
"date": "2023-08-02", "time_local": "08:30", "timezone": "America/New_York",
"event": "미 재무부 분기 차환 발표",
"why_it_matters": "국채 공급 증가와 등급 뉴스가 같은 첫 거래일 금리에 겹친 교란요인이다. 1,030억 달러의 차환 규모가 제시됐다.",
"source": {"name": "U.S. Treasury", "url": "https://home.treasury.gov/news/press-releases/jy1671"}
},
{
"date": "2023-08-04", "time_local": "08:30", "timezone": "America/New_York",
"event": "미국 7월 고용보고서",
"why_it_matters": "비농업고용 18만7천 명 증가와 실업률 3.5%는 D+2 이후 단기금리와 경기 해석을 바꿀 수 있는 독립 변수다.",
"source": {"name": "U.S. Bureau of Labor Statistics", "url": "https://www.bls.gov/news.release/archives/empsit_08042023.htm"}
},
{
"date": "2023-08-10", "time_local": "08:30", "timezone": "America/New_York",
"event": "미국 7월 CPI",
"why_it_matters": "헤드라인 CPI는 전월 대비 0.2%, 전년 대비 3.2%였다. D+5 이후 금리 변화가 신용 뉴스보다 물가 경로를 반영하는지 확인한다.",
"source": {"name": "U.S. Bureau of Labor Statistics", "url": "https://www.bls.gov/news.release/archives/cpi_08102023.htm"}
},
{
"date": "2023-08-16", "time_local": "14:00", "timezone": "America/New_York",
"event": "7월 FOMC 회의록 공개",
"why_it_matters": "단기금리와 장기금리가 다른 방향으로 움직인 해석에서 통화정책 경로가 얼마나 중요한지 다시 확인할 일정이다.",
"source": {"name": "Federal Reserve — 2023 FOMC calendar", "url": "https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm"}
}
],
"historical_analogue_pool": [
{
"date": "2011-08-05", "event": "S&P의 미국 국가신용등급 AAA에서 AA+ 강등",
"event_type": "sovereign_credit",
"shock_mechanisms": ["rating_downgrade", "fiscal_governance"],
"affected_channels": ["government_bond", "equity", "volatility"],
"market_shock_pattern": {"rates": "down", "equity": "down", "volatility": "up"},
"similarity": "미국 최고 국가신용등급이 재정·정치 문제를 이유로 하향됐고 위험자산이 약세였다.",
"difference": "2011년에는 국채가 안전자산으로 매수돼 금리가 하락했으며 유럽 재정위기 등 동시 충격도 컸다.",
"source": {"name": "U.S. Treasury — 2012 Financial Stability Annual Report", "url": "https://home.treasury.gov/system/files/261/2012-Annual-Report.pdf"}
}
]
}
]
}
tests/fixtures/market-series.json
{
"series": [
{
"symbol": "UST2Y",
"region": "US",
"asset_class": "rates",
"unit": "pct",
"observation_timezone": "America/New_York",
"observation_time": "16:00",
"market_timezone": "America/New_York",
"price_type": "market_close",
"roles": ["short_rates", "government_bond", "safe_haven"],
"selection_priority": 10,
"source": {"name": "fixture", "url": "https://example.test/ust2y"},
"observations": [
{"date": "2023-03-09", "value": 5.05},
{"date": "2023-03-10", "value": 4.59},
{"date": "2023-03-13", "value": 4.03},
{"date": "2023-03-14", "value": 4.20},
{"date": "2023-03-15", "value": 3.89},
{"date": "2023-03-16", "value": 4.14},
{"date": "2023-03-17", "value": 3.81},
{"date": "2026-09-14", "value": 4.60},
{"date": "2026-09-15", "value": 4.67},
{"date": "2026-09-16", "value": 4.73},
{"date": "2026-09-17", "value": 4.74},
{"date": "2026-09-18", "value": 4.72},
{"date": "2026-09-21", "value": 4.71},
{"date": "2026-09-22", "value": 4.70},
{"date": "2026-09-23", "value": 4.69}
]
},
{
"symbol": "UST10Y",
"region": "US",
"asset_class": "rates",
"unit": "pct",
"observation_timezone": "America/New_York",
"observation_time": "16:00",
"market_timezone": "America/New_York",
"price_type": "market_close",
"roles": ["long_rates", "government_bond", "curve"],
"source": {"name": "fixture", "url": "https://example.test/ust10y"},
"observations": [
{"date": "2026-09-14", "value": 4.20},
{"date": "2026-09-15", "value": 4.25},
{"date": "2026-09-16", "value": 4.26},
{"date": "2026-09-17", "value": 4.25},
{"date": "2026-09-18", "value": 4.24},
{"date": "2026-09-21", "value": 4.23},
{"date": "2026-09-22", "value": 4.22},
{"date": "2026-09-23", "value": 4.21}
]
},
{
"symbol": "KOSPI",
"region": "KR",
"asset_class": "equity",
"unit": "index",
"observation_timezone": "Asia/Seoul",
"observation_time": "15:30",
"market_timezone": "Asia/Seoul",
"price_type": "market_close",
"roles": ["broad_equity", "affected_equity"],
"source": {"name": "fixture", "url": "https://example.test/kospi"},
"observations": [
{"date": "2026-09-15", "value": 3000},
{"date": "2026-09-16", "value": 3010},
{"date": "2026-09-17", "value": 2980},
{"date": "2026-09-18", "value": 2990},
{"date": "2026-09-21", "value": 3020},
{"date": "2026-09-22", "value": 3030},
{"date": "2026-09-23", "value": 3040},
{"date": "2026-09-24", "value": 3035}
]
},
{
"symbol": "DXY",
"region": "US",
"asset_class": "fx",
"unit": "index",
"observation_timezone": "America/New_York",
"observation_time": "17:00",
"market_timezone": "America/New_York",
"price_type": "daily_close",
"roles": ["fx", "safe_haven"],
"source": {"name": "fixture", "url": "https://example.test/dxy"},
"observations": [
{"date": "2026-09-14", "value": 99.80},
{"date": "2026-09-15", "value": 100.00},
{"date": "2026-09-16", "value": 100.15},
{"date": "2026-09-17", "value": 100.10},
{"date": "2026-09-18", "value": 100.05},
{"date": "2026-09-21", "value": 100.20},
{"date": "2026-09-22", "value": 100.25},
{"date": "2026-09-23", "value": 100.30}
]
},
{
"symbol": "USDKRW",
"region": "KR",
"asset_class": "fx",
"unit": "krw_per_usd",
"observation_timezone": "America/New_York",
"observation_time": "12:00",
"market_timezone": "Asia/Seoul",
"price_type": "noon_buying_rate",
"roles": ["fx"],
"source": {"name": "fixture", "url": "https://example.test/usdkrw"},
"observations": [
{"date": "2026-09-15", "value": 1385},
{"date": "2026-09-16", "value": 1390},
{"date": "2026-09-17", "value": 1395},
{"date": "2026-09-18", "value": 1392},
{"date": "2026-09-21", "value": 1388},
{"date": "2026-09-22", "value": 1386},
{"date": "2026-09-23", "value": 1384},
{"date": "2026-09-24", "value": 1382}
]
},
{
"symbol": "KRE",
"region": "US",
"asset_class": "equity",
"unit": "index",
"observation_timezone": "America/New_York",
"observation_time": "16:00",
"market_timezone": "America/New_York",
"price_type": "adjusted_close",
"roles": ["bank_equity", "affected_equity"],
"selection_priority": 20,
"source": {"name": "fixture", "url": "https://example.test/kre"},
"observations": [
{"date": "2023-03-09", "value": 51.00},
{"date": "2023-03-10", "value": 48.70},
{"date": "2023-03-13", "value": 42.80},
{"date": "2023-03-14", "value": 44.10},
{"date": "2023-03-15", "value": 43.00},
{"date": "2023-03-16", "value": 44.20},
{"date": "2023-03-17", "value": 43.50}
]
},
{
"symbol": "CDX_IG",
"region": "US",
"asset_class": "credit",
"unit": "bp",
"observation_timezone": "America/New_York",
"observation_time": "16:00",
"market_timezone": "America/New_York",
"price_type": "market_close",
"roles": ["credit"],
"selection_priority": 20,
"source": {"name": "fixture", "url": "https://example.test/cdx"},
"observations": [
{"date": "2023-03-09", "value": 67},
{"date": "2023-03-10", "value": 73},
{"date": "2023-03-13", "value": 84},
{"date": "2023-03-14", "value": 80},
{"date": "2023-03-15", "value": 88},
{"date": "2023-03-16", "value": 82},
{"date": "2023-03-17", "value": 78}
]
},
{
"symbol": "VIX",
"region": "US",
"asset_class": "volatility",
"unit": "index",
"observation_timezone": "America/New_York",
"observation_time": "16:00",
"market_timezone": "America/New_York",
"price_type": "market_close",
"roles": ["volatility"],
"selection_priority": 20,
"source": {"name": "fixture", "url": "https://example.test/vix"},
"observations": [
{"date": "2023-03-09", "value": 22.61},
{"date": "2023-03-10", "value": 24.80},
{"date": "2023-03-13", "value": 26.52},
{"date": "2023-03-14", "value": 23.73},
{"date": "2023-03-15", "value": 26.14},
{"date": "2023-03-16", "value": 22.99},
{"date": "2023-03-17", "value": 25.51}
]
}
]
}
tests/fixtures/research.json
{
"research": [
{
"event_id": "fomc-2026-09-16",
"event_name": "September 2026 FOMC decision",
"event_type": "monetary_policy",
"region": "US",
"event_family": "FOMC",
"event_date": "2026-09-16",
"decision": {
"summary": "연준은 기준금리를 동결했다. 동결 자체는 사전 예상과 일치해 결정만 놓고 본 서프라이즈는 제한적이었다.",
"expected": true,
"surprise_assessment": "시장이 새롭게 반영한 핵심은 동결 여부가 아니라 향후 인하 속도와 최종금리 경로였다.",
"event_type": "동결보다 향후 정책경로 재가격이 중심인 이벤트",
"source": {"name": "fixture FOMC statement", "url": "https://example.test/fomc-statement"}
},
"what_changed": [
{
"channel": "성명서",
"finding": "인플레이션 진전에 대한 표현이 이전보다 신중해졌다.",
"significance": "조기 인하의 문턱이 높아졌다는 신호로 해석될 수 있다.",
"source": {"name": "fixture statement comparison", "url": "https://example.test/statement-redline"}
},
{
"channel": "SEP·점도표",
"finding": "위원들의 중간 정책금리 경로가 이전 전망보다 높아졌다.",
"significance": "동결 결정과 별개로 향후 완화 속도가 느려질 가능성을 보여줬다.",
"source": {"name": "fixture SEP", "url": "https://example.test/sep"}
},
{
"channel": "Powell 기자회견",
"finding": "인하 재개에는 물가 둔화에 대한 추가 확신이 필요하다는 조건이 강조됐다.",
"significance": "다음 회의보다 데이터 의존적인 경로가 중요해졌다.",
"source": {"name": "fixture press conference", "url": "https://example.test/presser"}
}
],
"market_interpretation": [
{
"channel": "정책금리 경로",
"finding": "단기물 금리가 장기물보다 크게 상승했다.",
"significance": "시장이 장기 성장률보다 가까운 시기의 정책금리 경로를 더 크게 재가격한 움직임과 일관된다.",
"source": {"name": "fixture market wrap", "url": "https://example.test/market-wrap"}
},
{
"channel": "인플레이션·고용",
"finding": "향후 인하 여부가 다음 CPI와 고용지표에 더 민감해졌다.",
"significance": "이번 회의 해석은 후속 데이터에 의해 빠르게 뒤집힐 수 있다.",
"source": {"name": "fixture policy analysis", "url": "https://example.test/policy-analysis"}
}
],
"transmission": [
{
"from": "더 높은 정책금리 경로",
"to": "미국 단기금리 상승",
"status": "consistent",
"evidence": "UST 2Y가 D-1 대비 6bp 올라 UST 10Y의 1bp 상승을 웃돌았다.",
"source": {"name": "fixture Treasury data", "url": "https://example.test/treasury"}
},
{
"from": "미국 단기금리 상승",
"to": "달러 강세",
"status": "limited",
"evidence": "DXY 상승폭은 0.15%에 그쳐 금리 반응보다 제한적이었다.",
"source": {"name": "fixture FX data", "url": "https://example.test/dxy"}
},
{
"from": "달러·미국 할인율 상승",
"to": "원화와 한국 주식 부담",
"status": "consistent",
"evidence": "다음 한국 거래일 USD/KRW는 0.36% 상승했고 KOSPI는 1.00% 하락했다.",
"source": {"name": "fixture Korea market data", "url": "https://example.test/korea-market"}
}
],
"catalysts": [
{
"date": "2026-10-02",
"event": "미국 고용보고서(NFP)",
"why_it_matters": "노동시장 둔화 여부가 인하 재개 조건을 바꿀 수 있다.",
"source": {"name": "fixture BLS calendar", "url": "https://example.test/nfp-calendar"}
},
{
"date": "2026-10-07",
"event": "FOMC 의사록",
"why_it_matters": "점도표 분산과 위원별 정책경로 판단의 배경을 확인할 수 있다.",
"source": {"name": "fixture Federal Reserve calendar", "url": "https://example.test/minutes-calendar"}
},
{
"date": "2026-10-13",
"event": "미국 CPI",
"why_it_matters": "물가 둔화에 대한 추가 확신이라는 연준의 조건을 직접 시험한다.",
"source": {"name": "fixture BLS calendar", "url": "https://example.test/cpi-calendar"}
},
{
"date": "2026-10-28",
"event": "다음 FOMC",
"why_it_matters": "고용·물가 데이터가 실제 정책경로 변경으로 이어졌는지 확인한다.",
"source": {"name": "fixture Federal Reserve calendar", "url": "https://example.test/fomc-calendar"}
}
],
"historical_context": {
"include": true,
"reason": "결정은 예상과 같았지만 향후 정책경로가 시장을 움직인 사례와 비교했습니다.",
"comparisons": [
{
"date": "2025-03-19",
"event": "FOMC 동결",
"similarity": "결정보다 점도표와 기자회견이 단기금리를 움직였다는 점이다.",
"difference": "당시에는 달러 반응이 더 강했다는 점이다.",
"source": {"name": "fixture historical study", "url": "https://example.test/history-1"}
},
{
"date": "2025-09-17",
"event": "FOMC 동결",
"similarity": "데이터 의존성과 인하 조건이 핵심이었다는 점이다.",
"difference": "당시 장기금리 반응은 이번보다 컸다는 점이다.",
"source": {"name": "fixture historical study", "url": "https://example.test/history-2"}
},
{
"date": "2026-06-17",
"event": "FOMC 동결",
"similarity": "동결이 예상됐고 정책경로 재가격이 중심이었다는 점이다.",
"difference": "이번에는 한국 주식의 다음 거래일 반응이 더 뚜렷했다는 점이다.",
"source": {"name": "fixture historical study", "url": "https://example.test/history-3"}
}
]
}
},
{
"event_id": "svb-failure-2023-03-10",
"event_name": "실리콘밸리은행(SVB) 파산",
"event_type": "financial_stress",
"region": "US",
"event_date": "2023-03-10",
"overview": {
"summary": "미국 규제당국은 예금 인출과 유동성 악화가 이어진 Silicon Valley Bank를 폐쇄했다. 이는 예정된 거시지표가 아니라 은행 시스템의 자금조달 안정성을 재평가하게 만든 신용·유동성 사건이었다.",
"expected": false,
"surprise_assessment": "폐쇄 자체와 손실 인식 속도가 사전 예상보다 빨랐고, 시장의 핵심 서프라이즈는 개별 은행 문제가 지역은행 전반의 예금·유동성 위험으로 번질 가능성이었다.",
"classification": "지역은행에서 시작된 금융스트레스와 정책경로 재가격 이벤트",
"source": {"name": "fixture regulator release", "url": "https://example.test/svb-regulator"}
},
"what_changed": [
{
"channel": "은행 상태",
"finding": "자본조달 계획 발표 이후 예금 인출이 가속됐고 규제당국이 은행을 폐쇄했다.",
"significance": "유동성 문제가 독립적인 자본조달 실패에서 시스템 전염 가능성으로 바뀌었다.",
"source": {"name": "fixture regulator release", "url": "https://example.test/svb-regulator"}
},
{
"channel": "정책 대응",
"finding": "당국은 예금자 보호와 은행권 유동성 공급 방안을 준비했다.",
"significance": "신용위험 확대와 정책 백스톱 기대가 동시에 가격에 반영될 조건이 형성됐다.",
"source": {"name": "fixture policy release", "url": "https://example.test/svb-policy"}
}
],
"market_interpretation": [
{
"channel": "금리경로",
"finding": "미국 2년물 금리가 급락했다.",
"significance": "시장은 금융안정 위험이 추가 긴축 여력을 제한할 가능성을 가격에 반영했다.",
"source": {"name": "fixture market wrap", "url": "https://example.test/svb-market"}
},
{
"channel": "은행·신용위험",
"finding": "지역은행 주가는 하락하고 신용스프레드와 변동성은 상승했다.",
"significance": "충격이 단일 은행 주가가 아니라 은행권과 신용시장으로 전달됐음을 보여준다.",
"source": {"name": "fixture market wrap", "url": "https://example.test/svb-market"}
}
],
"transmission": [
{
"from": "예금 유출과 은행 폐쇄",
"to": "지역은행 주가 하락",
"status": "confirmed",
"evidence": "KRE가 D-1 대비 D0에 하락하고 다음 거래일 낙폭이 확대됐다.",
"source": {"name": "fixture KRE", "url": "https://example.test/kre"}
},
{
"from": "금융안정 위험",
"to": "신용스프레드·VIX 상승",
"status": "consistent",
"evidence": "CDX IG와 VIX가 함께 상승해 위험회피가 주식 밖으로 확산됐다.",
"source": {"name": "fixture risk data", "url": "https://example.test/svb-risk"}
},
{
"from": "금융스트레스",
"to": "미국 단기금리 급락",
"status": "consistent",
"evidence": "UST 2Y가 D0에 46bp 하락해 향후 긴축경로가 빠르게 낮아졌다.",
"source": {"name": "fixture Treasury data", "url": "https://example.test/ust2y"}
}
],
"catalysts": [
{
"date": "2023-03-12",
"event": "미국 당국의 예금자 보호·유동성 대책",
"why_it_matters": "정책 백스톱의 범위가 은행권 전염 위험을 제한하는지 확인한다.",
"source": {"name": "fixture policy calendar", "url": "https://example.test/svb-policy"}
},
{
"date": "2023-03-22",
"event": "FOMC 결정",
"why_it_matters": "금융안정 위험이 실제 정책금리 경로에 반영되는지 확인한다.",
"source": {"name": "fixture Fed calendar", "url": "https://example.test/fomc-calendar-2023"}
}
],
"historical_context": {
"include": true,
"reason": "예금 유출과 유동성 충격이 은행주·신용·단기금리에 함께 전달된 사례와 비교했습니다.",
"comparisons": [
{
"date": "2008-03-16",
"event": "Bear Stearns 구제",
"matching_basis": "금융기관 유동성 충격과 정책 백스톱",
"similarity": "금융불안이 단기금리와 변동성에 즉시 반영됐다는 점이다.",
"difference": "당시에는 투자은행과 도매자금시장이 충격의 중심이었다.",
"source": {"name": "fixture historical source", "url": "https://example.test/bear-stearns"}
},
{
"date": "2008-09-15",
"event": "Lehman Brothers 파산",
"matching_basis": "은행 파산과 시스템 전염 위험",
"similarity": "은행주·신용스프레드·변동성이 동시에 악화됐다는 점이다.",
"difference": "Lehman 충격은 글로벌 결제·자금시장 전염 규모가 훨씬 컸다.",
"source": {"name": "fixture historical source", "url": "https://example.test/lehman"}
},
{
"date": "2019-09-17",
"event": "미국 레포시장 경색",
"matching_basis": "달러 유동성 경색과 중앙은행 대응",
"similarity": "유동성 부족이 정책 대응 기대와 단기금리에 영향을 줬다는 점이다.",
"difference": "당시에는 예금 손실 우려보다 단기자금시장 기능이 핵심이었다.",
"source": {"name": "fixture historical source", "url": "https://example.test/repo-stress"}
}
]
}
}
]
}
tests/fixtures/tariff-event.json
{
"events": [
{
"event_id": "us-reciprocal-tariff-2025-04-02",
"event_name": "2025년 미국 상호관세 발표와 무역긴장 전개",
"event_type": "trade_policy",
"event_structure": "developing_event",
"event_date": "2025-04-02",
"event_time_local": "16:06",
"event_timezone": "America/New_York",
"region": "US",
"aliases": ["미국 상호관세 발표", "2025 reciprocal tariffs", "Liberation Day tariffs"],
"source": {
"name": "The White House — Executive Order 14257",
"url": "https://www.whitehouse.gov/presidential-actions/2025/04/regulating-imports-with-a-reciprocal-tariff-to-rectify-trade-practices-that-contribute-to-large-and-persistent-annual-united-states-goods-trade-deficits/"
},
"event_sequence": [
{
"date": "2025-04-02",
"time_local": "16:06",
"timezone": "America/New_York",
"stage": "announcement",
"event": "백악관에서 상호관세 조치 발표",
"source": {
"name": "GovInfo — Presidential Daily Diary",
"url": "https://www.govinfo.gov/app/details/DCPD-202500423"
}
},
{
"date": "2025-04-04",
"stage": "retaliation",
"event": "중국, 미국산 수입품에 34% 추가 관세 발표(4월 10일 시행)",
"source": {
"name": "중국 재정부 관세세칙위원회",
"url": "https://gss.mof.gov.cn/gzdt/zhengcefabu/202504/t20250404_3961451.htm"
}
},
{
"date": "2025-04-05",
"stage": "implementation",
"event": "미국의 기본 10% 추가 관세 시행",
"source": {
"name": "The White House — Executive Order 14257",
"url": "https://www.whitehouse.gov/presidential-actions/2025/04/regulating-imports-with-a-reciprocal-tariff-to-rectify-trade-practices-that-contribute-to-large-and-persistent-annual-united-states-goods-trade-deficits/"
}
},
{
"date": "2025-04-08",
"stage": "escalation",
"event": "미국, 중국산 수입품의 추가 관세율을 34%에서 84%로 인상 발표",
"source": {
"name": "The White House — Executive Order 14266 background",
"url": "https://www.whitehouse.gov/presidential-actions/2025/04/modifying-reciprocal-tariff-rates-to-reflect-trading-partner-retaliation-and-alignment/"
}
},
{
"date": "2025-04-09",
"stage": "retaliation",
"event": "중국, 미국산 수입품 추가 관세율을 34%에서 84%로 인상 발표(4월 10일 시행)",
"source": {
"name": "중국 재정부 관세세칙위원회",
"url": "https://gss.mof.gov.cn/gzdt/zhengcejiedu/202504/t20250409_3961685.htm"
}
},
{
"date": "2025-04-09",
"stage": "policy_response",
"event": "미국, 중국 관세율을 125%로 추가 인상하는 한편 다른 국가별 상호관세를 90일 유예(4월 10일 시행)",
"source": {
"name": "The White House — Executive Order 14266",
"url": "https://www.whitehouse.gov/presidential-actions/2025/04/modifying-reciprocal-tariff-rates-to-reflect-trading-partner-retaliation-and-alignment/"
}
}
]
}
]
}
tests/fixtures/tariff-market-series.json
{
"series": [
{
"symbol": "DGS2",
"region": "US",
"asset_class": "rates",
"unit": "pct",
"roles": ["short_rates", "curve"],
"observation_timezone": "America/New_York",
"observation_time": "15:30",
"market_timezone": "America/New_York",
"price_type": "H.15 Treasury yield, closing quote",
"source": {"name": "FRED DGS2", "url": "https://fred.stlouisfed.org/data/DGS2"},
"observations": [
{"date":"2025-04-01","value":3.87},{"date":"2025-04-02","value":3.91},{"date":"2025-04-03","value":3.71},{"date":"2025-04-04","value":3.68},{"date":"2025-04-07","value":3.73},{"date":"2025-04-08","value":3.71},{"date":"2025-04-09","value":3.91},{"date":"2025-04-10","value":3.84},{"date":"2025-04-11","value":3.96}
]
},
{
"symbol": "DGS10",
"region": "US",
"asset_class": "rates",
"unit": "pct",
"roles": ["long_rates", "curve"],
"observation_timezone": "America/New_York",
"observation_time": "15:30",
"market_timezone": "America/New_York",
"price_type": "H.15 Treasury yield, closing quote",
"source": {"name": "FRED DGS10", "url": "https://fred.stlouisfed.org/data/DGS10"},
"observations": [
{"date":"2025-04-01","value":4.17},{"date":"2025-04-02","value":4.20},{"date":"2025-04-03","value":4.06},{"date":"2025-04-04","value":4.01},{"date":"2025-04-07","value":4.15},{"date":"2025-04-08","value":4.26},{"date":"2025-04-09","value":4.34},{"date":"2025-04-10","value":4.40},{"date":"2025-04-11","value":4.48}
]
},
{
"symbol": "SP500",
"region": "US",
"asset_class": "equity",
"unit": "index",
"roles": ["broad_equity"],
"observation_timezone": "America/New_York",
"observation_time": "16:00",
"market_timezone": "America/New_York",
"price_type": "daily cash-market close",
"source": {"name": "FRED SP500", "url": "https://fred.stlouisfed.org/data/SP500"},
"observations": [
{"date":"2025-04-01","value":5633.07},{"date":"2025-04-02","value":5670.97},{"date":"2025-04-03","value":5396.52},{"date":"2025-04-04","value":5074.08},{"date":"2025-04-07","value":5062.25},{"date":"2025-04-08","value":4982.77},{"date":"2025-04-09","value":5456.90},{"date":"2025-04-10","value":5268.05},{"date":"2025-04-11","value":5363.36}
]
},
{
"symbol": "KOSPI",
"region": "KR",
"asset_class": "equity",
"unit": "index",
"roles": ["affected_equity", "broad_equity"],
"observation_timezone": "Asia/Seoul",
"observation_time": "15:30",
"market_timezone": "Asia/Seoul",
"price_type": "일별 종가",
"source": {"name": "Countryeconomy KOSPI history", "url": "https://countryeconomy.com/stock-exchange/south-korea?dr=2025-04"},
"observations": [
{"date":"2025-04-01","value":2521.39},{"date":"2025-04-02","value":2505.86},{"date":"2025-04-03","value":2486.70},{"date":"2025-04-04","value":2465.42},{"date":"2025-04-07","value":2328.20},{"date":"2025-04-08","value":2334.23},{"date":"2025-04-09","value":2293.70},{"date":"2025-04-10","value":2445.06},{"date":"2025-04-11","value":2432.72}
]
},
{
"symbol": "DEXKOUS",
"region": "US",
"asset_class": "fx",
"unit": "krw_per_usd",
"roles": ["fx"],
"observation_timezone": "America/New_York",
"observation_time": "12:00",
"market_timezone": "America/New_York",
"price_type": "NYC noon buying rate",
"source": {"name": "FRED DEXKOUS", "url": "https://fred.stlouisfed.org/data/DEXKOUS"},
"observations": [
{"date":"2025-04-01","value":1471.00},{"date":"2025-04-02","value":1462.06},{"date":"2025-04-03","value":1450.55},{"date":"2025-04-04","value":1457.76},{"date":"2025-04-07","value":1470.22},{"date":"2025-04-08","value":1477.97},{"date":"2025-04-09","value":1472.75},{"date":"2025-04-10","value":1450.37},{"date":"2025-04-11","value":1418.79}
]
},
{
"symbol": "DEXUSEU",
"region": "US",
"asset_class": "fx",
"unit": "usd",
"roles": ["fx"],
"observation_timezone": "America/New_York",
"observation_time": "12:00",
"market_timezone": "America/New_York",
"price_type": "NYC noon buying rate",
"source": {"name": "FRED DEXUSEU", "url": "https://fred.stlouisfed.org/data/DEXUSEU"},
"observations": [
{"date":"2025-04-01","value":1.0800},{"date":"2025-04-02","value":1.0868},{"date":"2025-04-03","value":1.1052},{"date":"2025-04-04","value":1.1014},{"date":"2025-04-07","value":1.0912},{"date":"2025-04-08","value":1.0912},{"date":"2025-04-09","value":1.1040},{"date":"2025-04-10","value":1.1192},{"date":"2025-04-11","value":1.1325}
]
},
{
"symbol": "DCOILWTICO",
"region": "US",
"asset_class": "commodity",
"unit": "usd",
"roles": ["oil", "commodity"],
"price_type": "daily spot price",
"source": {"name": "FRED DCOILWTICO", "url": "https://fred.stlouisfed.org/data/DCOILWTICO"},
"observations": [
{"date":"2025-04-01","value":71.61},{"date":"2025-04-02","value":72.12},{"date":"2025-04-03","value":67.43},{"date":"2025-04-04","value":62.42},{"date":"2025-04-07","value":61.05},{"date":"2025-04-08","value":60.04},{"date":"2025-04-09","value":62.63},{"date":"2025-04-10","value":60.57},{"date":"2025-04-11","value":61.91}
]
},
{
"symbol": "VIXCLS",
"region": "US",
"asset_class": "volatility",
"unit": "index",
"roles": ["volatility"],
"price_type": "daily index close",
"source": {"name": "FRED VIXCLS", "url": "https://fred.stlouisfed.org/data/VIXCLS"},
"observations": [
{"date":"2025-04-01","value":21.77},{"date":"2025-04-02","value":21.51},{"date":"2025-04-03","value":30.02},{"date":"2025-04-04","value":45.31},{"date":"2025-04-07","value":46.98},{"date":"2025-04-08","value":52.33},{"date":"2025-04-09","value":33.62},{"date":"2025-04-10","value":40.72},{"date":"2025-04-11","value":37.56}
]
}
]
}
tests/fixtures/tariff-research.json
{
"research": [
{
"event_id": "us-reciprocal-tariff-2025-04-02",
"event_name": "2025년 미국 상호관세 발표와 무역긴장 전개",
"event_type": "trade_policy",
"region": "US",
"event_date": "2025-04-02",
"event_features": {
"shock_mechanisms": ["tariff_escalation", "retaliation_risk", "policy_uncertainty", "global_demand_shock"],
"affected_channels": ["equity", "rates", "fx", "commodity", "volatility"],
"market_shock_pattern": {"equity":"down", "rates":"down", "fx":"mixed", "commodity":"down", "volatility":"up"}
},
"overview": {
"summary": "미국은 2025년 4월 2일 광범위한 추가 관세와 국가별 상호관세를 발표했다. 대통령의 발표 시각은 16:06 ET로 미국 현물 주식시장 종가(16:00 ET) 이후다. 이후 중국의 보복 발표와 미국의 90일 유예가 이어져 단일 발표가 아닌 전개형 무역정책 충격으로 다룬다.",
"expected": false,
"surprise_assessment": "관세 발표가 예고돼 있었더라도 광범위한 10% 기본관세와 높은 국가별 세율의 범위·강도는 시장의 우려를 웃돌았다. 핵심 surprise는 관세 자체보다 적용 범위와 보복 가능성이었다.",
"classification": "보복과 정책 유예가 연속된 전개형 무역정책·정책불확실성 충격",
"source": {"name":"The White House — Executive Order 14257", "url":"https://www.whitehouse.gov/presidential-actions/2025/04/regulating-imports-with-a-reciprocal-tariff-to-rectify-trade-practices-that-contribute-to-large-and-persistent-annual-united-states-goods-trade-deficits/"}
},
"what_changed": [
{
"channel":"미국 관세 범위와 시행 일정",
"finding":"미국은 수입품에 10% 추가 관세를 발표하고, 별도 국가별 세율은 4월 9일부터 적용하도록 했다. 기본세율은 4월 5일 시행됐다.",
"significance":"발표 당일 가격은 시행 전이라도 향후 수입비용·기업 마진·교역량의 불확실성을 먼저 반영할 수 있다.",
"source":{"name":"The White House — Executive Order 14257", "url":"https://www.whitehouse.gov/presidential-actions/2025/04/regulating-imports-with-a-reciprocal-tariff-to-rectify-trade-practices-that-contribute-to-large-and-persistent-annual-united-states-goods-trade-deficits/"}
},
{
"channel":"보복과 다일 전개",
"finding":"중국은 4월 4일 미국산 수입품에 34% 추가 관세를 발표했고 4월 10일 시행한다고 밝혔다.",
"significance":"D+1 이후 가격에는 최초 발표뿐 아니라 보복·재보복 위험이 함께 들어가 단일 발표 효과로 분리할 수 없다.",
"source":{"name":"중국 재정부 관세세칙위원회", "url":"https://gss.mof.gov.cn/gzdt/zhengcefabu/202504/t20250404_3961451.htm"}
},
{
"channel":"미·중 관세 상호 인상",
"finding":"미국은 4월 8일 대중 추가 관세율을 34%에서 84%로 높였고, 중국도 4월 9일 미국산 수입품의 추가 관세를 84%로 높여 4월 10일부터 적용한다고 발표했다.",
"significance":"양국의 보복이 실제 세율 인상으로 가속돼, 위험회피 구간은 최초 미국 발표만으로 설명되지 않는다.",
"source":{"name":"중국 재정부 관세세칙위원회 — 4월 9일 조정", "url":"https://gss.mof.gov.cn/gzdt/zhengcejiedu/202504/t20250409_3961685.htm"}
},
{
"channel":"정책 경로의 급반전",
"finding":"미국은 4월 9일 중국산 수입품의 추가 관세를 125%로 높이는 동시에 중국 외 국가별 상호관세를 90일간 유예하고 10% 기본관세를 유지한다고 발표했다. 대부분 조치는 4월 10일부터 적용됐다.",
"significance":"중국과의 긴장은 악화됐지만 다른 교역국에는 유예를 발표해, 같은 날 시장에는 악화와 완화 신호가 동시에 전달됐다.",
"source":{"name":"The White House — Executive Order 14266", "url":"https://www.whitehouse.gov/presidential-actions/2025/04/modifying-reciprocal-tariff-rates-to-reflect-trading-partner-retaliation-and-alignment/"}
}
],
"market_interpretation": [
{
"channel":"성장·위험선호",
"finding":"4월 3일 미국 주식과 국제 주식이 급락하고 원유와 달러도 약세를 보였으며, 미 국채수익률은 하락하고 변동성은 뛰었다. Reuters는 이를 예상보다 강한 관세 발표와 성장 둔화 우려에 연결했다.",
"significance":"주식·원유 하락과 단기 국채금리 하락은 발표 직후 시장이 인플레이션 단독 충격보다 교역·성장 하방위험을 크게 가격에 반영한 흐름과 일치한다.",
"source":{"name":"Reuters — Dollar slides after U.S. tariffs", "url":"https://www.investing.com/news/economy-news/dollar-slides-as-traders-rush-into-safe-havens-after-us-tariffs-3964053"}
},
{
"channel":"달러와 통화시장",
"finding":"EUR/USD 뉴욕 정오 기준값은 4월 2일 1.0868에서 4월 3일 1.1052로 상승했다. USD/KRW도 FRED 뉴욕 정오 매입환율 기준 1,462.06원에서 1,450.55원으로 낮아졌다.",
"significance":"달러 약세는 전통적인 달러 안전자산 강세와 달랐다. 미국 정책 불확실성의 가격 반영과 일관되지만, 동시 움직임만으로 원인을 확정하지 않는다. USD/KRW 값은 서울 종가가 아니다.",
"source":{"name":"Reuters — Dollar slides after U.S. tariffs", "url":"https://www.investing.com/news/economy-news/dollar-slides-as-traders-rush-into-safe-havens-after-us-tariffs-3964053"}
},
{
"channel":"이벤트 창 내 상반된 정책 뉴스",
"finding":"S&P 500은 4월 3일 4.84% 하락했고, 4월 9일 중국 외 국가 유예 발표 뒤에는 9.5% 반등했다. 반면 중국 대상 추가 관세는 125%로 높아졌고 한국 코스피는 다음 날 6.6% 올랐다.",
"significance":"D+5는 최초 충격과 중국에 대한 추가 긴장, 다른 국가에 대한 유예에 따른 강한 되돌림을 함께 포함한다. 반등을 모든 관세의 철회나 최초 발표의 긍정 효과로 읽으면 안 된다.",
"source":{"name":"Associated Press — April 9 tariff pause rally", "url":"https://apnews.com/article/e8e54a68397e6829e1d27552a1d7bfb9"}
},
{
"channel":"장기금리의 후속 반전",
"finding":"미 10년물 수익률은 이벤트 기준일 대비 4월 3일 14bp 하락했지만 4월 10일에는 기준일보다 20bp 높았다. 같은 기간 2년물은 기준일보다 7bp 낮았다.",
"significance":"초기 안전자산 매수·성장 우려만으로 후속 금리경로를 설명할 수 없다. 4월 10일까지는 장기물 매도와 커브 스티프닝이 겹친 복합 국면이었고, 10년물은 그 주에 이례적으로 급등했다.",
"source":{"name":"Reuters — April 11 U.S. bond and dollar selloff", "url":"https://www.investing.com/news/economy-news/trading-day-a-crushing-week-for-us-bonds-dollar-3982264"}
}
],
"transmission": [
{
"from":"상호관세 발표·보복 위험",
"to":"주식·원유 하락 및 변동성 상승",
"status":"CONFIRMED",
"evidence":"Reuters는 관세 발표 뒤 주식·원유 하락과 변동성 급등을 직접 보도했다. 일별 자료에서 S&P 500은 4월 3일 -4.84%, WTI는 -6.50%, VIX는 21.51에서 30.02로 상승했다. 일별 변화는 장중 인과효과가 아니다.",
"source":{"name":"Reuters — Dollar slides after U.S. tariffs", "url":"https://www.investing.com/news/economy-news/dollar-slides-as-traders-rush-into-safe-havens-after-us-tariffs-3964053"}
},
{
"from":"성장 하방위험 재평가",
"to":"미 국채 단기금리 하락",
"status":"CONSISTENT",
"evidence":"미 2년물 수익률은 4월 2일 3.91%에서 4월 3일 3.71%로 20bp 하락했다. 이는 성장 우려와 완화 기대 경로에 부합하지만, 금리 변동의 단일 원인으로 단정하지 않는다.",
"source":{"name":"FRED DGS2", "url":"https://fred.stlouisfed.org/data/DGS2"}
},
{
"from":"미국의 90일 관세 유예 발표",
"to":"한국 주식시장 급반등",
"status":"CONFIRMED",
"evidence":"연합뉴스는 미국의 일시 유예 뒤 코스피가 4월 10일 6.6% 상승했다고 보도했다. 이는 관세 위험 프리미엄 축소와 일치하나, 한국 현물시장은 미국 발표 시점에 열려 있지 않았다.",
"source":{"name":"연합뉴스 — 관세 유예 뒤 서울 증시", "url":"https://m-en.yna.co.kr/view/AEN20250410007451320"}
}
],
"catalysts": [
{
"date":"2025-04-04",
"event":"미국 3월 고용보고서",
"why_it_matters":"당시 D+1 가격창의 별도 성장 촉매였다. 비농업고용은 22만8천 명 증가했고 실업률은 4.2%로 큰 변화가 없어, 관세 충격만으로 그날 시장변화를 설명할 수 없다.",
"source":{"name":"U.S. Bureau of Labor Statistics", "url":"https://www.bls.gov/news.release/archives/empsit_04042025.htm"}
},
{
"date":"2025-04-05",
"event":"미국 기본 10% 추가 관세 시행",
"why_it_matters":"발표된 정책이 실제 통관 비용에 적용되는 첫 시점이어서 적용 범위·면제·공급망 전가가 다음 확인 대상이었다.",
"source":{"name":"The White House — Executive Order 14257", "url":"https://www.whitehouse.gov/presidential-actions/2025/04/regulating-imports-with-a-reciprocal-tariff-to-rectify-trade-practices-that-contribute-to-large-and-persistent-annual-united-states-goods-trade-deficits/"}
},
{
"date":"2025-04-09",
"event":"3월 FOMC 회의록",
"why_it_matters":"관세 불확실성이 성장·물가와 향후 금리경로를 어떻게 바꾸는지 확인할 정책 자료였다. 같은 날 관세 유예 발표가 추가돼 금리·주식 반응은 복합 뉴스에 노출됐다.",
"source":{"name":"Federal Reserve — April 2025 calendar", "url":"https://www.federalreserve.gov/newsevents/2025-april.htm"}
},
{
"date":"2025-04-09",
"event":"미국, 국가별 관세 90일 유예 발표",
"why_it_matters":"중국은 유예에서 제외되고 대중 관세는 125%로 인상됐지만, 다른 교역국은 90일 유예 대상이었다. 실제 발표 뒤 미국 주식은 급반등했으므로 D+5 누적 반응은 순수한 최초 충격이 아니다.",
"source":{"name":"The White House — Executive Order 14266", "url":"https://www.whitehouse.gov/presidential-actions/2025/04/modifying-reciprocal-tariff-rates-to-reflect-trading-partner-retaliation-and-alignment/"}
},
{
"date":"2025-04-10",
"event":"미·중 상호 관세 인상 조치 적용",
"why_it_matters":"미국의 대중 125% 추가 관세와 중국의 대미 84% 추가 관세가 이날 발효됐다. D+5 관측은 관세 경로가 여전히 악화된 상태에서 발생한 CPI·시장 재가격을 포함한다.",
"source":{"name":"The White House — Executive Order 14266", "url":"https://www.whitehouse.gov/presidential-actions/2025/04/modifying-reciprocal-tariff-rates-to-reflect-trading-partner-retaliation-and-alignment/"}
},
{
"date":"2025-04-10",
"event":"미국 3월 CPI",
"why_it_matters":"헤드라인 CPI는 전월 대비 -0.1%, 전년 대비 2.4%였다. 조사기간이 관세 시행 전이므로 관세 전가효과의 증거가 아니라 물가의 기존 출발점이다.",
"source":{"name":"U.S. Bureau of Labor Statistics", "url":"https://www.bls.gov/news.release/archives/cpi_04102025.htm"}
}
],
"historical_analogue_pool": [
{
"date":"2018-03-22",
"event":"미국의 중국산 수입품 관세 조치 발표",
"event_type":"trade_policy",
"shock_mechanisms":["tariff_escalation","retaliation_risk","policy_uncertainty"],
"affected_channels":["equity","rates","fx","volatility"],
"market_shock_pattern":{"equity":"down","rates":"down","fx":"mixed","volatility":"up"},
"similarity":"관세 발표가 무역전쟁 우려를 키우며 주식 하락·국채 선호·변동성 상승으로 이어졌다.",
"difference":"중국 대상의 제한된 조치였고 같은 날 예정된 FOMC 금리인상 해석이 혼재했다.",
"source":{"name":"Reuters via Yahoo Finance", "url":"https://finance.yahoo.com/news/us-stocks-stocks-tumble-worst-204110408.html"}
},
{
"date":"2019-05-05",
"event":"미국의 대중 관세 인상 위협",
"event_type":"trade_policy",
"shock_mechanisms":["tariff_escalation","policy_uncertainty","negotiation_risk"],
"affected_channels":["equity","rates","fx","volatility"],
"market_shock_pattern":{"equity":"down","rates":"down","fx":"mixed","volatility":"up"},
"similarity":"주말 중 관세 위협이 커진 뒤 다음 거래일 중국·유럽 주식과 미국 주가지수 선물이 하락했다.",
"difference":"새로운 광범위 관세체계의 발표가 아니라 기존 미중 협상 중 관세율 인상 위협이었다.",
"source":{"name":"Reuters via Investing.com", "url":"https://www.investing.com/news/stock-market-news/global-shares-oil-drop-after-trump-tariff-threat-1856987"}
},
{
"date":"2019-08-23",
"event":"중국의 대미 보복관세 발표",
"event_type":"trade_policy",
"shock_mechanisms":["tariff_escalation","retaliation_risk","policy_uncertainty"],
"affected_channels":["equity","fx","commodity","volatility"],
"market_shock_pattern":{"equity":"down","fx":"mixed","commodity":"down","volatility":"up"},
"similarity":"중국의 보복이 교역갈등을 키워 원유 가격과 위험자산에 하방 압력을 줬다.",
"difference":"미국의 전면 관세 발표가 아니라 중국의 특정 미국산 원유 관세 등 보복 조치가 촉발점이었다.",
"source":{"name":"Reuters via Investing.com", "url":"https://www.investing.com/news/commodities-news/oil-spills-into-uschina-trade-war-prices-slump-1962658"}
}
]
}
]
}
tests/test_event_study.py
import importlib.util
import json
import sys
import tempfile
import unittest
from datetime import date, datetime
from pathlib import Path
from unittest.mock import patch
from zoneinfo import ZoneInfoNotFoundError
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from calendars import align_date, market_alignment_for_event, market_alignment_for_series, zone_for_date
from event_study import analyze_intraday_reaction, analyze_series, classify_curve, threshold_events
from market_data import SeriesError, load_series
class EventStudyTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.series = load_series(str(ROOT / "tests" / "fixtures" / "market-series.json"))
cls.by_symbol = {item["symbol"]: item for item in cls.series}
def test_bp_and_percent_change(self):
rates = analyze_series(self.by_symbol["UST2Y"], "2026-09-16")
self.assertAlmostEqual(rates["window"]["d0"]["change_from_d_minus_1"], 6.0)
equity = analyze_series(self.by_symbol["KOSPI"], "2026-09-16")
self.assertAlmostEqual(equity["window"]["d0"]["change_from_d_minus_1"], 1 / 3)
def test_rate_series_already_in_bp_is_not_multiplied_again(self):
spread = {
"symbol": "2s10s",
"region": "US",
"asset_class": "spread",
"unit": "bp",
"source": {"name": "fixture", "url": "https://example.test/spread"},
"observations": [{"date": "2026-09-15", "value": -42}, {"date": "2026-09-16", "value": -47}],
}
result = analyze_series(spread, "2026-09-16")
self.assertEqual(result["window"]["d0"]["change_from_d_minus_1"], -5)
triggered = threshold_events(spread, "bp_change", "below", -5)
self.assertEqual(triggered[0]["value"], -5)
def test_independent_next_alignment(self):
aligned, index = align_date("2026-09-19", [row["date"] for row in self.by_symbol["KOSPI"]["observations"]], "next")
self.assertEqual(aligned, "2026-09-21")
self.assertIsNotNone(index)
strict, _ = align_date("2026-09-16", [row["date"] for row in self.by_symbol["KOSPI"]["observations"]], "next_strict")
self.assertEqual(strict, "2026-09-17")
def test_cross_market_alignment_uses_event_time_and_dst(self):
fomc = {"region": "US", "event_date": "2026-09-16", "event_time_local": "14:00", "event_timezone": "America/New_York"}
self.assertEqual(market_alignment_for_event(fomc, "KR"), "next")
self.assertEqual(market_alignment_for_event(fomc, "EU"), "next_strict")
self.assertEqual(market_alignment_for_event(fomc, "US"), "next")
late_ny_event = {"region": "US", "event_date": "2026-09-16", "event_time_local": "18:00", "event_timezone": "America/New_York"}
self.assertEqual(market_alignment_for_event(late_ny_event, "KR"), "next")
self.assertEqual(market_alignment_for_event(late_ny_event, "US"), "next_strict")
nfp = {"region": "US", "event_date": "2026-09-04", "event_time_local": "08:30", "event_timezone": "America/New_York"}
self.assertEqual(market_alignment_for_event(nfp, "EU"), "next")
bok = {"region": "KR", "event_date": "2026-09-17", "event_time_local": "10:00", "event_timezone": "Asia/Seoul"}
self.assertEqual(market_alignment_for_event(bok, "US"), "next_strict")
def test_source_observation_clock_prevents_fx_session_misalignment(self):
event = {"region": "US", "event_date": "2023-03-10", "event_time_local": "11:15", "event_timezone": "America/New_York"}
fred_usdkrw = {
"region": "KR", "observation_timezone": "America/New_York", "observation_time": "12:00",
"market_timezone": "Asia/Seoul", "observations": [],
}
self.assertEqual(market_alignment_for_series(event, fred_usdkrw), ("next", "source_observation_time", "2023-03-10"))
fred_usdkrw["observation_time"] = "10:00"
self.assertEqual(market_alignment_for_series(event, fred_usdkrw), ("next_strict", "source_observation_time", "2023-03-10"))
seoul_close = {"region": "KR", "market_timezone": "Asia/Seoul", "observations": []}
self.assertEqual(market_alignment_for_series(event, seoul_close), ("next", "market_close_proxy", "2023-03-11"))
def test_intraday_reaction_uses_event_time_and_actual_bar_timestamps(self):
event = {"event_date": "2023-03-10", "event_time_local": "11:15", "event_timezone": "America/New_York"}
series = {
"symbol": "SPX", "asset_class": "equity", "unit": "index", "market_timezone": "America/New_York",
"source": {"name": "fixture", "url": "https://example.test/spx"},
"intraday_observations": [
{"timestamp": "2023-03-10T11:00:00-05:00", "value": 100},
{"timestamp": "2023-03-10T12:30:00-05:00", "value": 99},
{"timestamp": "2023-03-10T16:00:00-05:00", "value": 98},
],
}
result = analyze_intraday_reaction(series, event)
self.assertEqual(result["pre_event"]["timestamp"], "2023-03-10T11:00:00-05:00")
self.assertEqual(result["plus_1h"]["timestamp"], "2023-03-10T12:30:00-05:00")
self.assertEqual(result["plus_1h"]["change_from_pre_event"], -1.0)
self.assertEqual(result["session_end"]["change_from_pre_event"], -2.0)
self.assertEqual(result["session_end_label"], "market_close")
def test_dst_fallback_is_deterministic_without_system_tzdata(self):
with patch("calendars.ZoneInfo", side_effect=ZoneInfoNotFoundError("tzdata unavailable")):
ny_before = zone_for_date("America/New_York", date(2026, 3, 7))
ny_after = zone_for_date("America/New_York", date(2026, 3, 8))
london_after = zone_for_date("Europe/London", date(2026, 3, 29))
tokyo = zone_for_date("Asia/Tokyo", date(2026, 6, 1))
self.assertEqual(datetime(2026, 3, 7, 12, tzinfo=ny_before).utcoffset().total_seconds(), -5 * 3600)
self.assertEqual(datetime(2026, 3, 8, 12, tzinfo=ny_after).utcoffset().total_seconds(), -4 * 3600)
self.assertEqual(datetime(2026, 3, 29, 12, tzinfo=london_after).utcoffset().total_seconds(), 3600)
self.assertEqual(datetime(2026, 6, 1, 12, tzinfo=tokyo).utcoffset().total_seconds(), 9 * 3600)
def test_future_window_is_pending_not_false_missing(self):
truncated = dict(self.by_symbol["KOSPI"])
truncated["observations"] = truncated["observations"][:-1]
result = analyze_series(truncated, "2026-09-16", "next_strict", (-1, 0, 1, 5))
self.assertIn("pending_d+5", result["warnings"])
self.assertEqual(result["latest_observation_date"], "2026-09-23")
def test_curve_classification_and_mixed(self):
self.assertEqual(classify_curve(7, 1)["classification"], "Bear Flattening")
self.assertEqual(classify_curve(-5, -1)["classification"], "Bull Steepening")
self.assertEqual(classify_curve(2, -1)["classification"], "Mixed")
def test_threshold_is_inclusive(self):
events = threshold_events(self.by_symbol["UST2Y"], "bp_change", "above", 6)
self.assertIn("2026-09-15", {item["date"] for item in events})
self.assertTrue(any(abs(item["value"] - 6.0) < 1e-8 for item in events))
def test_bp_threshold_rejects_non_rate_series(self):
with self.assertRaisesRegex(ValueError, "bp_change requires"):
threshold_events(self.by_symbol["KOSPI"], "bp_change", "above", 10)
def test_duplicate_date_rejected(self):
bad = dict(self.by_symbol["UST2Y"])
bad["observations"] = bad["observations"] + [bad["observations"][0]]
from market_data import _normalize_series
with self.assertRaises(SeriesError):
_normalize_series(bad)
def test_invalid_date_and_missing_source_rejected(self):
from market_data import _normalize_series
base = {
"symbol": "BAD",
"region": "US",
"asset_class": "rates",
"unit": "pct",
"source": {"name": "source", "url": "https://example.test"},
"observations": [{"date": "2026-02-30", "value": 1}],
}
with self.assertRaisesRegex(SeriesError, "invalid ISO date"):
_normalize_series(base)
base["observations"] = [{"date": "2026-02-28", "value": 1}]
base["source"] = {}
with self.assertRaisesRegex(SeriesError, "source.name"):
_normalize_series(base)
def test_invalid_units_boolean_values_and_urls_rejected(self):
from market_data import _normalize_series
base = {
"symbol": "BAD",
"region": "US",
"asset_class": "rates",
"unit": "index",
"source": {"name": "source", "url": "https://example.test"},
"observations": [{"date": "2026-09-16", "value": 1}],
}
with self.assertRaisesRegex(SeriesError, "unit must be pct or bp"):
_normalize_series(base)
base["unit"] = "pct"
base["observations"][0]["value"] = True
with self.assertRaisesRegex(SeriesError, "not a number"):
_normalize_series(base)
base["observations"][0]["value"] = 1
base["source"]["url"] = "not-a-url"
with self.assertRaisesRegex(SeriesError, "http"):
_normalize_series(base)
def test_series_provenance_and_intraday_edge_cases_are_validated(self):
from market_data import _normalize_series
base = {
"symbol": "USDKRW", "region": "KR", "asset_class": "fx", "unit": "krw_per_usd",
"source": {"name": "FRED", "url": "https://example.test/fred"},
"observations": [{"date": "2023-03-10", "value": 1324.51}],
"observation_timezone": "America/New_York", "observation_time": "12:00",
"market_timezone": "Asia/Seoul", "price_type": "noon_buying_rate",
}
normalized = _normalize_series(base)
self.assertEqual(normalized["observation_timezone"], "America/New_York")
self.assertEqual(normalized["price_type"], "noon_buying_rate")
broken = dict(base, observation_time=None)
with self.assertRaisesRegex(SeriesError, "must be provided together"):
_normalize_series(broken)
broken = dict(base, observation_timezone="Mars/Olympus")
with self.assertRaisesRegex(SeriesError, "valid IANA timezone"):
_normalize_series(broken)
broken = dict(base, intraday_observations=[{"timestamp": "2023-03-10T11:00:00", "value": 1}])
with self.assertRaisesRegex(SeriesError, "include a timezone offset"):
_normalize_series(broken)
def test_duplicate_symbols_rejected(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "series.json"
item = self.by_symbol["UST2Y"]
path.write_text(json.dumps({"series": [item, item]}), encoding="utf-8")
with self.assertRaisesRegex(SeriesError, "duplicate symbols"):
load_series(str(path))
def test_empty_and_case_insensitive_duplicate_symbols_rejected(self):
with tempfile.TemporaryDirectory() as temp:
empty = Path(temp) / "empty.json"
empty.write_text('{"series": []}', encoding="utf-8")
with self.assertRaisesRegex(SeriesError, "at least one"):
load_series(str(empty))
path = Path(temp) / "series.json"
first = self.by_symbol["DXY"]
second = dict(first)
second["symbol"] = "dxy"
path.write_text(json.dumps({"series": [first, second]}), encoding="utf-8")
with self.assertRaisesRegex(SeriesError, "duplicate symbols"):
load_series(str(path))
if __name__ == "__main__":
unittest.main()
tests/test_generic_engine.py
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from asset_selector import PROFILES, select_assets
from event_resolver import EventResolutionError, normalize_event
def series(symbol, asset_class, roles):
return {
"symbol": symbol,
"region": "GLOBAL",
"asset_class": asset_class,
"unit": "index",
"roles": roles,
"selection_priority": 0,
"source": {"name": "fixture", "url": "https://example.test"},
"observations": [{"date": "2024-01-01", "value": 100}],
}
class GenericEngineTests(unittest.TestCase):
def setUp(self):
self.series = [
series("WTI", "commodity", ["oil"]),
series("GOLD", "commodity", ["gold", "safe_haven"]),
series("VIX", "volatility", ["volatility"]),
series("KRE", "equity", ["bank_equity"]),
series("NVDA", "equity", ["event_security"]),
series("SOX", "equity", ["sector_equity"]),
series("SPX", "equity", ["broad_equity"]),
]
def test_geopolitics_selects_transmission_assets_not_region_basket(self):
event = {"event_type": "geopolitics", "event_date": "2024-02-01", "region": "GLOBAL"}
selected, _, _ = select_assets(self.series, event)
symbols = {item["symbol"] for item in selected}
self.assertTrue({"WTI", "GOLD", "VIX"}.issubset(symbols))
self.assertNotIn("KRE", symbols)
def test_earnings_selects_security_sector_and_index(self):
event = {"event_type": "corporate_earnings", "event_date": "2024-02-01", "region": "GLOBAL"}
selected, _, _ = select_assets(self.series, event)
self.assertEqual({item["symbol"] for item in selected}, {"NVDA", "SOX", "SPX", "VIX"})
def test_financial_stress_includes_broad_equity_and_fx_when_available(self):
event = {"event_type": "financial_stress", "event_date": "2024-02-01", "region": "GLOBAL"}
available = self.series + [series("USDX", "fx", ["fx"])]
selected, _, _ = select_assets(available, event)
symbols = {item["symbol"] for item in selected}
self.assertTrue({"KRE", "VIX", "SPX", "USDX"}.issubset(symbols))
self.assertNotIn("WTI", symbols)
def test_trade_policy_includes_rates_in_cross_asset_transmission(self):
event = {"event_type": "trade_policy", "event_date": "2024-02-01", "region": "GLOBAL"}
available = self.series + [
series("DGS2", "rates", ["short_rates"]),
series("DGS10", "rates", ["long_rates", "curve"]),
series("USDX", "fx", ["fx"]),
]
selected, _, _ = select_assets(available, event, limit=20)
symbols = {item["symbol"] for item in selected}
self.assertTrue({"DGS2", "DGS10", "USDX", "SPX", "WTI", "VIX"}.issubset(symbols))
def test_sovereign_credit_keeps_cross_asset_coverage_with_many_bonds(self):
event = {"event_type": "sovereign_credit", "event_date": "2023-08-01", "region": "US"}
bonds = [series(f"UST{i}", "rates", ["government_bond"]) for i in range(20)]
available = bonds + [
series("EURUSD", "fx", ["fx"]),
series("SPX", "equity", ["broad_equity"]),
series("VIX", "volatility", ["volatility"]),
]
selected, _, _ = select_assets(available, event, limit=4)
self.assertEqual({item["symbol"] for item in selected} & {"EURUSD", "SPX", "VIX"},
{"EURUSD", "SPX", "VIX"})
self.assertEqual(len(selected), 4)
def test_inflation_selects_broad_equity_and_volatility_as_well_as_growth(self):
event = {"event_type": "inflation", "event_date": "2022-11-10", "region": "US"}
available = self.series + [
series("DGS2", "rates", ["short_rates"]),
series("DGS10", "rates", ["long_rates"]),
series("EURUSD", "fx", ["fx"]),
series("NASDAQ", "equity", ["growth_equity", "broad_equity"]),
]
selected, _, _ = select_assets(available, event)
symbols = {item["symbol"] for item in selected}
self.assertTrue({"DGS2", "DGS10", "EURUSD", "NASDAQ", "SPX", "VIX"}.issubset(symbols))
self.assertNotIn("KRE", symbols)
def test_small_limit_covers_distinct_asset_classes_before_variants(self):
event = {"event_type": "trade_policy", "event_date": "2025-04-02", "region": "US"}
available = self.series + [
series("DGS2", "rates", ["short_rates"]),
series("DGS10", "rates", ["long_rates", "curve"]),
series("EURUSD", "fx", ["fx"]),
]
selected, _, _ = select_assets(available, event, limit=5)
self.assertEqual({item["asset_class"] for item in selected},
{"rates", "equity", "fx", "commodity", "volatility"})
def test_broad_equity_role_is_not_lost_in_other_event_profiles(self):
for event_type in ("monetary_policy", "employment", "growth", "geopolitics", "commodity_supply", "fiscal_policy"):
with self.subTest(event_type=event_type):
selected, _, _ = select_assets(self.series, {"event_type": event_type, "region": "GLOBAL"})
self.assertIn("SPX", {item["symbol"] for item in selected})
def test_generic_class_role_falls_back_but_unrelated_specific_role_does_not(self):
event = {"event_type": "inflation", "region": "US"}
available = [series("GENERIC", "equity", ["equity"]), series("BANK", "equity", ["bank_equity"])]
selected, _, _ = select_assets(available, event)
self.assertEqual([item["symbol"] for item in selected], ["GENERIC"])
def test_unusable_class_does_not_displace_usable_other_channel(self):
event = {"event_type": "inflation", "region": "US"}
stale = series("STALE_RATE", "rates", ["short_rates"])
stale["selection_usable"] = False
available = [stale, series("FX", "fx", ["fx"]), series("SPX", "equity", ["broad_equity"])]
selected, _, _ = select_assets(available, event, limit=2)
self.assertEqual({item["symbol"] for item in selected}, {"FX", "SPX"})
def test_profile_matrix_can_cover_each_declared_asset_class(self):
candidates = [
series("DGS2", "rates", ["short_rates"]),
series("DGS10", "rates", ["long_rates", "curve", "government_bond", "safe_haven"]),
series("BEI", "rates", ["inflation_breakeven"]),
series("CDS", "credit", ["credit", "sovereign_cds"]),
series("FX", "fx", ["fx"]),
series("SPX", "equity", ["broad_equity"]),
series("NASDAQ", "equity", ["growth_equity"]),
series("KRE", "equity", ["bank_equity"]),
series("CYCLICAL", "equity", ["cyclical_equity"]),
series("RESOURCE", "equity", ["commodity_equity"]),
series("EVENT", "equity", ["event_security", "affected_equity", "sector_equity"]),
series("WTI", "commodity", ["oil", "commodity"]),
series("GOLD", "commodity", ["gold", "safe_haven"]),
series("VIX", "volatility", ["volatility"]),
series("DIRECT", "price", ["event_security"]),
]
for event_type, (_, expected_classes) in PROFILES.items():
with self.subTest(event_type=event_type):
selected, _, _ = select_assets(candidates, {"event_type": event_type, "region": "GLOBAL"}, limit=20)
self.assertTrue(set(expected_classes).issubset({item["asset_class"] for item in selected}))
def test_automatic_selection_prefers_usable_series_in_same_role(self):
event = {"event_type": "sovereign_credit", "event_date": "2023-08-01", "region": "US"}
stale = series("A_UNUSABLE", "rates", ["government_bond"])
stale["selection_usable"] = False
valid = series("Z_VALID", "rates", ["government_bond"])
valid["selection_usable"] = True
selected, _, _ = select_assets([stale, valid], event, limit=1)
self.assertEqual([item["symbol"] for item in selected], ["Z_VALID"])
def test_user_symbols_override_automatic_selection(self):
event = {"event_type": "geopolitics", "event_date": "2024-02-01", "region": "GLOBAL"}
selected, reasons, missing = select_assets(self.series, event, ("NVDA", "UNKNOWN"))
self.assertEqual([item["symbol"] for item in selected], ["NVDA"])
self.assertEqual(reasons[0]["reason"], "사용자 지정")
self.assertEqual(missing, ["UNKNOWN"])
def test_asset_limit_rejects_out_of_range_and_boolean_values(self):
event = {"event_type": "geopolitics", "event_date": "2024-02-01", "region": "GLOBAL"}
for invalid in (0, 21, True, 1.5):
with self.subTest(limit=invalid), self.assertRaisesRegex(ValueError, "integer from 1 to 20"):
select_assets(self.series, event, limit=invalid)
def test_ambiguous_type_requires_explicit_classification(self):
with self.assertRaisesRegex(EventResolutionError, "ambiguous"):
normalize_event({
"event_name": "OPEC 감산과 중동 전쟁",
"region": "GLOBAL",
"event_date": "2024-02-01",
"source": {"name": "fixture", "url": "https://example.test"},
})
if __name__ == "__main__":
unittest.main()
tests/test_market_event.py
import sys
import json
import tempfile
import unittest
from calendars import market_alignment_for_series
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from event_study import analyze_series, classify_curve
from event_resolver import EventResolutionError, infer_event_type, normalize_event, resolve_from_events
from market_event import curve_name, load_events_file, main, numeric_surprise, parse_curve, parse_days, parse_ecb, parse_fomc, parse_local_time, parse_regions, parse_symbols, parse_window, render_chat
from market_data import load_series, series_as_of
from research import ResearchError, load_research_file, normalize_research
class MarketEventTests(unittest.TestCase):
def test_event_fixture_and_surprise(self):
events = load_events_file(str(ROOT / "tests" / "fixtures" / "events.json"))
self.assertEqual(events[0]["event_family"], "FOMC")
svb = events[-1]
self.assertEqual(svb["event_time_local"], "11:15")
self.assertEqual(svb["event_structure"], "developing_event")
self.assertEqual(len(svb["event_sequence"]), 4)
self.assertEqual(numeric_surprise("3.2", "3.0"), 0.2)
self.assertIsNone(numeric_surprise("hold", "hold"))
self.assertIsNone(numeric_surprise("NaN", "0"))
self.assertEqual(curve_name("Bear Parallel"), "베어 평행이동")
def test_fomc_parser_uses_meeting_end_date(self):
html = "<h3>2026 FOMC Meetings</h3><div>January 27-28 Statement</div><div>March 17-18*</div>"
events = parse_fomc(html, 2026)
self.assertEqual([item["event_date"] for item in events], ["2026-01-28", "2026-03-18"])
lowercase = "<h3>2026 FOMC Meetings</h3><div>january 27-28 Statement</div>"
self.assertEqual(parse_fomc(lowercase, 2026)[0]["event_date"], "2026-01-28")
def test_ecb_parser_uses_day_two(self):
html = "<dt>28/10/2026</dt><dd>Governing Council of the ECB: monetary policy meeting (Day 1)</dd><dt>29/10/2026</dt><dd>Governing Council of the ECB: monetary policy meeting (Day 2), followed by press conference</dd>"
events = parse_ecb(html, 2026)
self.assertEqual([item["event_date"] for item in events], ["2026-10-29"])
def test_cli_range_validators(self):
self.assertEqual(parse_days("0"), 0)
self.assertEqual(parse_window("5"), 5)
self.assertEqual(parse_curve("UST2Y,UST10Y"), ("UST2Y", "UST10Y"))
self.assertEqual(parse_regions("kr,US,KR"), ("KR", "US"))
self.assertEqual(parse_symbols("UST2Y, UST10Y,UST2Y"), ("UST2Y", "UST10Y"))
self.assertEqual(parse_local_time("08:30"), "08:30")
with self.assertRaises(Exception):
parse_days("-1")
with self.assertRaises(Exception):
parse_window("1")
with self.assertRaises(Exception):
parse_curve("UST2Y")
self.assertEqual(parse_regions("US,JP"), ("US", "JP"))
with self.assertRaises(Exception):
parse_regions("US,1")
with self.assertRaises(Exception):
parse_symbols(" , ")
with self.assertRaises(Exception):
parse_local_time("08:30+09:00")
def test_event_file_requires_source_metadata(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "events.json"
path.write_text(json.dumps({"events": [{"region": "US", "event_family": "FOMC", "event_date": "2026-09-16"}]}), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "source.name"):
load_events_file(str(path))
def test_event_file_rejects_invalid_url_and_unpaired_time(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "events.json"
base = {"region": "US", "event_family": "FOMC", "event_date": "2026-09-16", "source": {"name": "Fed", "url": "bad"}}
path.write_text(json.dumps({"events": [base]}), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "http"):
load_events_file(str(path))
base["source"]["url"] = "https://example.test"
base["event_time_local"] = "14:00"
path.write_text(json.dumps({"events": [base]}), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "provided together"):
load_events_file(str(path))
base.pop("event_time_local")
base["event_family"] = " "
path.write_text(json.dumps({"events": [base]}), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "event_name"):
load_events_file(str(path))
def test_event_file_rejects_duplicate_keys(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "events.json"
item = {"region": "US", "event_family": "FOMC", "event_date": "2026-09-16", "source": {"name": "Fed", "url": "https://example.test"}}
path.write_text(json.dumps({"events": [item, dict(item)]}), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "duplicate event"):
load_events_file(str(path))
def test_chat_renderer_produces_research_brief_not_json(self):
event = load_events_file(str(ROOT / "tests" / "fixtures" / "events.json"))[0]
series = load_series(str(ROOT / "tests" / "fixtures" / "market-series.json"))
responses = []
for item in series:
alignment, basis, alignment_date = market_alignment_for_series(event, item)
responses.append(analyze_series({**item, "alignment_basis": basis}, event["event_date"], alignment, (-1, 0, 1, 5), alignment_date))
research = load_research_file(str(ROOT / "tests" / "fixtures" / "research.json"))[0]
payload = {
"result": "ok",
"event": event,
"market_response": responses,
"curves": [{"short": "UST2Y", "long": "UST10Y", **classify_curve(6.0, 1.0)}],
"research": research,
"failures": [],
"observed_at": "2026-09-24T09:00:00+09:00",
}
rendered = render_chat(payload)
self.assertIn("### 1. 무슨 일이 있었나", rendered)
self.assertIn("### 2. 시장은 왜 반응했나", rendered)
self.assertIn("### 3. 교차자산 반응", rendered)
self.assertIn("### 4. 전달경로", rendered)
self.assertIn("### 5. 이벤트 스터디 — Supporting Evidence", rendered)
self.assertIn("### 6. 과거 유사 사례", rendered)
self.assertIn("### 7. 앞으로 볼 것", rendered)
self.assertLess(rendered.index("### 1."), rendered.index("### 5."))
self.assertIn("향후 정책경로 재가격", rendered)
self.assertIn("FOMC 의사록", rendered)
self.assertIn("다음 원천 관측일", rendered)
self.assertIn("2026-09-17", rendered)
self.assertIn("D-1 기준값", rendered)
self.assertIn("2026-09-16 · 4.73%", rendered)
self.assertIn("A · 정확 시각", rendered)
self.assertIn("인과효과로 해석하지 않습니다", rendered)
self.assertIn("`CONSISTENT`", rendered)
self.assertIn("12:00 America/New_York", rendered)
self.assertNotIn('"market_response"', rendered)
def test_historical_catalysts_are_marked_elapsed(self):
event = load_events_file(str(ROOT / "tests" / "fixtures" / "tariff-event.json"))[0]
research = load_research_file(str(ROOT / "tests" / "fixtures" / "tariff-research.json"))[0]
rendered = render_chat({
"result": "ok",
"event": event,
"market_response": [],
"curves": [],
"research": research,
"failures": [],
"observed_at": "2025-04-11T17:00:00-04:00",
})
self.assertIn("### 7. 사건 당시 후속 촉매 — 모두 경과", rendered)
self.assertIn("**경과 · 2025-04-04 · 미국 3월 고용보고서**", rendered)
def test_asof_cutoff_removes_future_daily_and_intraday_observations(self):
source = {
"symbol": "SPX", "observation_timezone": "America/New_York", "observation_time": "16:00",
"observations": [{"date": "2023-08-01", "value": 100}, {"date": "2023-08-02", "value": 95}],
"intraday_observations": [
{"timestamp": "2023-08-02T12:00:00-04:00", "value": 98},
{"timestamp": "2023-08-02T16:00:00-04:00", "value": 95},
],
}
before_close = series_as_of(source, "2023-08-02T13:00:00-04:00")
self.assertEqual([row["date"] for row in before_close["observations"]], ["2023-08-01"])
self.assertEqual(len(before_close["intraday_observations"]), 1)
after_close = series_as_of(source, "2023-08-02T17:00:00-04:00")
self.assertEqual(len(after_close["observations"]), 2)
def test_date_only_same_day_price_waits_for_confirmed_clock(self):
source = {"symbol": "VIX", "market_timezone": "America/New_York", "observation_time": None,
"observations": [{"date": "2023-08-01", "value": 14}, {"date": "2023-08-02", "value": 16}]}
result = series_as_of(source, "2023-08-02T23:00:00-04:00")
self.assertEqual([row["date"] for row in result["observations"]], ["2023-08-01"])
def test_research_asof_is_validated(self):
raw = json.loads((ROOT / "tests" / "fixtures" / "tariff-research.json").read_text(encoding="utf-8"))["research"][0]
raw["observed_at"] = "2025-04-11T17:00:00-04:00"
self.assertEqual(normalize_research(raw)["observed_at"], raw["observed_at"])
raw["observed_at"] = "2025-04-11"
with self.assertRaisesRegex(ResearchError, "timezone"):
normalize_research(raw)
def test_cli_does_not_report_prices_after_analysis_cutoff(self):
output = StringIO()
args = [
"analyze", "--query", "미국 상호관세 발표",
"--event-file", str(ROOT / "tests" / "fixtures" / "tariff-event.json"),
"--series-file", str(ROOT / "tests" / "fixtures" / "tariff-market-series.json"),
"--study-only", "--observed-at", "2025-04-03T12:00:00-04:00", "--format", "json",
]
with self.assertRaises(SystemExit), redirect_stdout(output):
main(args)
payload = json.loads(output.getvalue())
by_symbol = {item["symbol"]: item for item in payload["market_response"]}
self.assertIsNone(by_symbol["SP500"]["window"].get("d0", {}).get("date"))
self.assertEqual(by_symbol["DEXKOUS"]["window"]["d0"]["date"], "2025-04-03")
self.assertIsNone(by_symbol["VIXCLS"]["window"].get("d0", {}).get("date"))
def test_fx_daily_output_keeps_four_decimal_precision(self):
from market_event import observation_value
response = {"asset_class": "fx", "unit": "usd", "window": {"d0": {"value": 1.0939}}}
self.assertEqual(observation_value(response, "d0"), "$1.0939")
def test_exact_event_cannot_precede_analysis_cutoff(self):
output = StringIO()
args = [
"analyze", "--query", "피치 미국 신용등급 강등",
"--event-file", str(ROOT / "tests" / "fixtures" / "fitch-event.json"),
"--series-file", str(ROOT / "tests" / "fixtures" / "fitch-market-series.json"),
"--study-only", "--observed-at", "2023-08-01T16:00:00-04:00", "--format", "json",
]
with self.assertRaises(SystemExit), redirect_stdout(output):
main(args)
payload = json.loads(output.getvalue())
self.assertEqual(payload["result"], "input_failure")
self.assertIn("timestamp is after", payload["message"])
def test_same_day_catalyst_uses_verified_release_time(self):
event = load_events_file(str(ROOT / "tests" / "fixtures" / "fitch-event.json"))[0]
research = load_research_file(str(ROOT / "tests" / "fixtures" / "fitch-research.json"))[0]
rendered = render_chat({
"result": "ok", "event": event, "market_response": [], "curves": [],
"research": research, "failures": [], "observed_at": "2023-08-10T17:00:00-04:00",
})
self.assertIn("경과 · 2023-08-10 08:30 America/New_York · 미국 7월 CPI", rendered)
self.assertIn("예정 · 2023-08-16 14:00 America/New_York · 7월 FOMC 회의록", rendered)
def test_missing_research_is_explicitly_downgraded(self):
event = load_events_file(str(ROOT / "tests" / "fixtures" / "events.json"))[0]
rendered = render_chat({"result": "partial_failure", "event": event, "market_response": [], "curves": [], "failures": [], "research": None})
self.assertIn("이벤트 스터디만 제공", rendered)
self.assertIn("완성된 이벤트 리서치가 아닙니다", rendered)
self.assertIn("### 6. 과거 유사 사례", rendered)
self.assertIn("### 7. 앞으로 볼 것", rendered)
self.assertNotIn("research_context_missing", rendered)
def test_generic_missing_research_copy_is_not_fomc_specific(self):
event = {
"event_name": "SVB 파산", "event_date": "2023-03-10", "event_type": "financial_stress",
"source": {"name": "fixture", "url": "https://example.test/svb"},
}
rendered = render_chat({"result": "partial_failure", "event": event, "market_response": [], "curves": [], "failures": [], "research": None})
self.assertIn("사건을 설명할 직접 발표자료", rendered)
self.assertNotIn("SEP", rendered)
self.assertNotIn("기자회견", rendered)
def test_research_claims_require_sources(self):
research = load_research_file(str(ROOT / "tests" / "fixtures" / "research.json"))[0]
self.assertEqual(research["overview"]["classification"], "동결보다 향후 정책경로 재가격이 중심인 이벤트")
broken = json.loads((ROOT / "tests" / "fixtures" / "research.json").read_text(encoding="utf-8"))["research"][0]
broken["what_changed"][0]["source"] = {}
with self.assertRaisesRegex(ResearchError, "source.name"):
normalize_research(broken)
def test_full_research_requires_transmission_and_multiple_catalysts(self):
raw = json.loads((ROOT / "tests" / "fixtures" / "research.json").read_text(encoding="utf-8"))["research"][0]
raw["transmission"] = []
with self.assertRaisesRegex(ResearchError, "transmission"):
normalize_research(raw)
raw = json.loads((ROOT / "tests" / "fixtures" / "research.json").read_text(encoding="utf-8"))["research"][0]
raw["catalysts"] = raw["catalysts"][:1]
with self.assertRaisesRegex(ResearchError, "at least two"):
normalize_research(raw)
def test_research_rejects_bad_chronology_duplicates_and_self_loops(self):
fixture = json.loads((ROOT / "tests" / "fixtures" / "research.json").read_text(encoding="utf-8"))["research"][0]
raw = json.loads(json.dumps(fixture))
raw["catalysts"][0]["date"] = raw["event_date"]
with self.assertRaisesRegex(ResearchError, "after"):
normalize_research(raw)
raw = json.loads(json.dumps(fixture))
raw["catalysts"][1] = dict(raw["catalysts"][0])
with self.assertRaisesRegex(ResearchError, "duplicate catalysts"):
normalize_research(raw)
raw = json.loads(json.dumps(fixture))
raw["transmission"][0]["to"] = raw["transmission"][0]["from"]
with self.assertRaisesRegex(ResearchError, "must differ"):
normalize_research(raw)
raw = json.loads(json.dumps(fixture))
raw["historical_context"]["comparisons"][0]["date"] = raw["event_date"]
with self.assertRaisesRegex(ResearchError, "before"):
normalize_research(raw)
raw = json.loads(json.dumps(fixture))
raw["decision"]["expected"] = "true"
with self.assertRaisesRegex(ResearchError, "true, false, or null"):
normalize_research(raw)
def test_historical_include_rejects_string_false(self):
raw = json.loads((ROOT / "tests" / "fixtures" / "research.json").read_text(encoding="utf-8"))["research"][0]
raw["historical_context"] = {"include": "false", "comparisons": []}
with self.assertRaisesRegex(ResearchError, "must be true or false"):
normalize_research(raw)
def test_historical_analogues_rank_attributes_and_observed_shock_pattern(self):
raw = json.loads((ROOT / "tests" / "fixtures" / "research.json").read_text(encoding="utf-8"))["research"][0]
raw["historical_context"] = {"include": False, "comparisons": []}
raw["event_features"] = {
"shock_mechanisms": ["policy_path_repricing", "inflation_surprise"],
"affected_channels": ["short_rates", "fx", "equity"],
"market_shock_pattern": {"short_rates": "up", "fx": "up", "equity": "down"},
}
candidates = []
for day, name in (("2023-06-14", "사례 A"), ("2024-03-20", "사례 B"), ("2025-01-29", "사례 C")):
candidates.append({
"date": day, "event": name, "event_type": "monetary_policy",
"shock_mechanisms": ["policy_path_repricing", "inflation_surprise"],
"affected_channels": ["short_rates", "fx"],
"market_shock_pattern": {"short_rates": "up", "fx": "up", "equity": "down"},
"similarity": "단기금리·달러 동반 상승", "difference": "충격의 배경은 달랐다.",
"source": {"name": "검증 자료", "url": f"https://example.test/{day}"},
})
candidates.append({
"date": "2025-02-01", "event": "방향이 다른 후보", "event_type": "monetary_policy",
"shock_mechanisms": ["policy_path_repricing"], "affected_channels": ["short_rates"],
"market_shock_pattern": {"short_rates": "down", "fx": "down"},
"similarity": "가격방향 불일치", "difference": "핵심 시장 충격 방향이 반대다.",
"source": {"name": "검증 자료", "url": "https://example.test/low-match"},
})
raw["historical_analogue_pool"] = candidates
result = normalize_research(raw)
history = result["historical_context"]
self.assertTrue(history["include"])
self.assertEqual(len(history["comparisons"]), 3)
self.assertNotIn("방향이 다른 후보", {item["event"] for item in history["comparisons"]})
self.assertIn("시장충격 방향 일치율", history["comparisons"][0]["matching_basis"])
def test_event_structure_and_sequence_require_verified_chronology(self):
base = {
"event_name": "SVB failure", "event_type": "financial_stress", "region": "US",
"event_date": "2023-03-10", "event_time_local": "11:15", "event_timezone": "America/New_York",
"source": {"name": "regulator", "url": "https://example.test/event"},
}
base["event_structure"] = "developing_event"
base["event_sequence"] = [
{"date": "2023-03-09", "event": "자금조달 이슈 공개", "stage": "prelude", "source": {"name": "News", "url": "https://example.test/prelude"}},
{"date": "2023-03-10", "time_local": "11:15", "timezone": "America/New_York", "event": "규제당국의 폐쇄", "stage": "trigger", "source": {"name": "FDIC", "url": "https://example.test/closure"}},
{"date": "2023-03-12", "event": "예금자 보호 조치", "stage": "response", "source": {"name": "Fed", "url": "https://example.test/response"}},
]
event = normalize_event(base)
self.assertEqual(event["event_structure"], "developing_event")
self.assertEqual(event["event_timestamp_quality"], "exact_timestamp")
self.assertEqual(len(event["event_sequence"]), 3)
broken = dict(base, event_sequence=list(reversed(base["event_sequence"])))
with self.assertRaisesRegex(EventResolutionError, "chronological"):
normalize_event(broken)
broken = dict(base, event_sequence=base["event_sequence"][:1])
with self.assertRaisesRegex(EventResolutionError, "at least two"):
normalize_event(broken)
def test_intraday_chat_discloses_last_bar_when_close_not_confirmed(self):
event = {
"event_name": "SVB failure", "event_date": "2023-03-10", "event_time_local": "11:15",
"event_timezone": "America/New_York", "event_timestamp_quality": "exact_timestamp",
"event_structure": "developing_event", "source": {"name": "FDIC", "url": "https://example.test/event"},
}
market = {"symbol": "SPX", "unit": "index", "alignment_basis": "source_observation_time", "observation_provenance": {"observation_timezone": "America/New_York", "observation_time": "16:00"}, "window": {}}
text = render_chat({
"result": "ok", "event": event, "market_response": [market], "intraday_response": [{
"symbol": "SPX", "status": "ok", "pre_event": {"timestamp": "2023-03-10T11:00:00-05:00", "value": 100},
"plus_1h": {"timestamp": "2023-03-10T12:30:00-05:00", "value": 99, "change_from_pre_event": -1, "change_unit": "pct"},
"session_end": {"timestamp": "2023-03-10T15:55:00-05:00", "value": 98, "change_from_pre_event": -2, "change_unit": "pct"},
"session_end_label": "last_supplied_bar_not_confirmed_close", "warnings": ["intraday_session_close_unconfirmed"],
}], "curves": [], "failures": [], "research": None,
})
self.assertIn("+1h 이후 2023-03-10T12:30:00-05:00", text)
self.assertIn("공식 종가 미확인", text)
self.assertIn("후속 충격·정책대응", text)
def test_free_form_resolver_handles_unregistered_event(self):
events = load_events_file(str(ROOT / "tests" / "fixtures" / "events.json"))
resolved = resolve_from_events("SVB 사태 분석", events)
self.assertEqual(resolved["result"], "ok")
self.assertEqual(resolved["event"]["event_type"], "financial_stress")
self.assertEqual(infer_event_type("중동 군사충돌과 유가")[0], "geopolitics")
def test_korean_event_name_gets_a_stable_identifier(self):
from event_resolver import normalize_event
raw = {
"event_name": "중동 군사충돌",
"region": "글로벌",
"event_date": "2024-01-01",
"source": {"name": "fixture", "url": "https://example.test/event"},
}
first = normalize_event(raw)["event_id"]
second = normalize_event(raw)["event_id"]
self.assertTrue(first.startswith("event-"))
self.assertEqual(first, second)
def test_vague_single_word_does_not_resolve(self):
events = load_events_file(str(ROOT / "tests" / "fixtures" / "events.json"))
result = resolve_from_events("decision", events)
self.assertIn(result["result"], {"verification_required", "ambiguous"})
def test_fomc_query_with_multiple_dates_is_ambiguous(self):
events = load_events_file(str(ROOT / "tests" / "fixtures" / "events.json"))
result = resolve_from_events("FOMC", events)
self.assertEqual(result["result"], "ambiguous")
def test_unknown_query_requires_verification_instead_of_inventing_date(self):
events = load_events_file(str(ROOT / "tests" / "fixtures" / "events.json"))
resolved = resolve_from_events("알 수 없는 신규 사건", events)
self.assertEqual(resolved["result"], "verification_required")
self.assertNotIn("event", resolved)
def test_svb_generic_engine_selects_transmission_assets_without_adapter(self):
output = StringIO()
args = [
"analyze", "--query", "SVB 파산",
"--series-file", str(ROOT / "tests" / "fixtures" / "market-series.json"),
"--event-file", str(ROOT / "tests" / "fixtures" / "events.json"),
"--research-file", str(ROOT / "tests" / "fixtures" / "research.json"),
"--observed-at", "2023-03-20T09:00:00+09:00", "--format", "json",
]
with self.assertRaises(SystemExit), redirect_stdout(output):
main(args)
payload = json.loads(output.getvalue())
self.assertIsNone(payload["adapter"])
symbols = {item["symbol"] for item in payload["market_response"]}
self.assertTrue({"UST2Y", "KRE", "CDX_IG", "VIX"}.issubset(symbols))
self.assertEqual(payload["event"]["event_type"], "financial_stress")
self.assertEqual(payload["event"]["event_structure"], "developing_event")
self.assertFalse(payload["analysis_contract"]["daily_d0_is_causal_effect"])
self.assertEqual(
payload["analysis_contract"]["cross_market_alignment"],
"source_observation_time_then_market_close_proxy_then_region_market_close_proxy_then_region_calendar_proxy",
)
fx_failure = next(item for item in payload["failures"] if item.get("symbol") == "USDKRW")
self.assertEqual(fx_failure["code"], "series_starts_after_event")
def test_user_selected_series_starting_after_event_is_not_mislabeled_d0(self):
output = StringIO()
args = [
"analyze", "--query", "SVB 파산",
"--series-file", str(ROOT / "tests" / "fixtures" / "market-series.json"),
"--event-file", str(ROOT / "tests" / "fixtures" / "events.json"),
"--symbols", "DXY", "--study-only", "--format", "json",
]
with self.assertRaises(SystemExit), redirect_stdout(output):
main(args)
payload = json.loads(output.getvalue())
self.assertEqual(payload["market_response"], [])
self.assertTrue(any(failure.get("code") == "series_starts_after_event" for failure in payload["failures"]))
def test_automatic_selection_prefers_complete_event_window_over_pre_event_only(self):
event = {
"event_id": "test-cpi-2022", "event_name": "Test CPI", "event_type": "inflation",
"region": "US", "event_date": "2022-11-10", "event_time_local": "08:30",
"event_timezone": "America/New_York",
"source": {"name": "BLS", "url": "https://example.test/event"},
}
base = {
"region": "US", "market_timezone": "America/New_York",
"selection_priority": 0, "source": {"name": "fixture", "url": "https://example.test/series"},
}
market = {"series": [
{**base, "symbol": "PRE_ONLY", "asset_class": "rates", "unit": "pct",
"roles": ["short_rates"], "observations": [{"date": "2022-11-09", "value": 4.0}]},
{**base, "symbol": "VIX", "asset_class": "volatility", "unit": "index",
"roles": ["volatility"], "observations": [
{"date": "2022-11-09", "value": 25.0}, {"date": "2022-11-10", "value": 22.0}
]},
]}
with tempfile.TemporaryDirectory() as temp:
event_path = Path(temp) / "event.json"
market_path = Path(temp) / "series.json"
event_path.write_text(json.dumps({"events": [event]}), encoding="utf-8")
market_path.write_text(json.dumps(market), encoding="utf-8")
output = StringIO()
with self.assertRaises(SystemExit), redirect_stdout(output):
main(["analyze", "--query", "Test CPI", "--event-file", str(event_path),
"--series-file", str(market_path), "--study-only", "--max-assets", "1",
"--observed-at", "2022-11-11T17:00:00-05:00", "--format", "json"])
payload = json.loads(output.getvalue())
self.assertEqual([item["symbol"] for item in payload["market_response"]], ["VIX"])
def test_fomc_remains_an_optional_adapter(self):
output = StringIO()
args = [
"analyze", "--query", "2026년 9월 FOMC",
"--series-file", str(ROOT / "tests" / "fixtures" / "market-series.json"),
"--event-file", str(ROOT / "tests" / "fixtures" / "events.json"),
"--research-file", str(ROOT / "tests" / "fixtures" / "research.json"),
"--format", "json",
]
with self.assertRaises(SystemExit), redirect_stdout(output):
main(args)
payload = json.loads(output.getvalue())
self.assertEqual(payload["adapter"]["key"], "FOMC")
symbols = {item["symbol"] for item in payload["market_response"]}
self.assertNotIn("KRE", symbols)
self.assertNotIn("CDX_IG", symbols)
def test_requested_missing_symbol_is_reported(self):
output = StringIO()
args = [
"analyze", "--region", "US", "--event", "FOMC", "--date", "2026-09-16",
"--series-file", str(ROOT / "tests" / "fixtures" / "market-series.json"),
"--event-file", str(ROOT / "tests" / "fixtures" / "events.json"),
"--symbols", "NOT_REAL", "--study-only", "--format", "json",
]
with self.assertRaises(SystemExit), redirect_stdout(output):
main(args)
payload = json.loads(output.getvalue())
self.assertEqual(payload["result"], "partial_failure")
self.assertIn({"symbol": "NOT_REAL", "code": "series_missing"}, payload["failures"])
def test_direct_event_time_requires_timezone(self):
output = StringIO()
args = [
"analyze", "--region", "US", "--event", "FOMC", "--date", "2026-09-16",
"--series-file", str(ROOT / "tests" / "fixtures" / "market-series.json"),
"--event-time-local", "14:00", "--study-only", "--format", "json",
]
with self.assertRaises(SystemExit), redirect_stdout(output):
main(args)
payload = json.loads(output.getvalue())
self.assertEqual(payload["result"], "input_failure")
self.assertIn("provided together", payload["message"])
def test_threshold_chat_uses_event_value_and_source(self):
rendered = render_chat({
"result": "ok",
"trigger": "UST2Y",
"trigger_source": {"name": "Treasury", "url": "https://example.test/ust2y"},
"events": [{"date": "2026-09-16", "metric": "bp_change", "value": 6.0, "threshold": 6.0, "responses": []}],
"observed_at": "2026-09-24T09:00:00+09:00",
})
self.assertIn("+6.00bp", rendered)
self.assertIn("[Treasury](https://example.test/ust2y)", rendered)
self.assertNotIn("미확인", rendered)
def test_threshold_chat_explains_missing_anchor(self):
response = {"symbol": "KOSPI", "warnings": ["missing_d-1", "changes_unavailable_without_d_minus_1"], "window": {}}
rendered = render_chat({
"result": "ok", "trigger": "UST2Y",
"trigger_source": {"name": "Treasury", "url": "https://example.test/ust2y"},
"events": [{"date": "2026-09-15", "metric": "bp_change", "value": 7.0, "threshold": 6.0, "responses": [response]}],
})
self.assertIn("D-1 기준 관측이 없어 변화율 계산 불가", rendered)
def test_d_minus_one_missing_warning_is_not_misreported_as_midseries_gap(self):
response = {
"symbol": "NEW_ASSET", "warnings": ["missing_d-1", "changes_unavailable_without_d_minus_1"],
"window": {}, "latest_observation_date": "2024-01-01",
}
event = {
"event_name": "테스트 사건", "event_date": "2024-01-02",
"source": {"name": "fixture", "url": "https://example.test/event"},
}
rendered = render_chat({"result": "partial_failure", "event": event, "market_response": [response], "research": None, "failures": []})
self.assertIn("D-1 기준 관측이 없어 이벤트 변화량을 계산하지 못했습니다", rendered)
self.assertNotIn("UNAVAILABLE_WITHOUT_D_MINUS_1", rendered)
if __name__ == "__main__":
unittest.main()