instruction.md
# 한·미 국채시장 분석
한국 국고채(KTB)와 미국 국채(UST)의 금리 변화와 공식 주요 일정을 함께 분석한다.
```bash
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --market both --base 2026-09-18 --as-of 2026-09-22
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --market both --base 2026-09-18 --as-of 2026-09-22 --json
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --market both --base 2026-09-18 --as-of 2026-09-22 --out ./ktb-ust-report
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --market both --replay ./ktb-ust-report/evidence.mjson --out ./ktb-ust-replay
```
`--market both`의 기본 출력은 대화에 바로 붙일 수 있는 고정 Markdown이다. 순서는 **상태·관측일 → 핵심 변화 → 여섯 만기 표 → 커브·한미 차이 → 주요 일정 → 확인 사항·출처**다. 기계 처리에는 `--json`, 파일 보고서를 요청한 경우에만 `--out`을 사용한다. 에이전트는 검증된 수치를 재계산하거나 빠진 값을 추정하지 않고, `INCOMPLETE`/`BLOCKED`도 그대로 전달한다.
KTB는 한국은행 ECOS 국고채 일별 시장금리, UST는 미국 재무부의 일별 명목 par/constant-maturity 수익률이다. 각각 여섯 만기(2·3·5·10·20·30년)를 검증하고 시장별 관측일을 별도로 표시한다. 한·미 10년 금리차는 두 원천에 **동일한 관측일**이 있을 때만 계산한다. 같은 날짜라도 양국 종가 시점은 다르므로 이 차이를 동시 가격·환헤지 비용·상대가치로 해석하지 않는다. 두 공식 데이터 원천 모두 이 모드에서 별도 API 키를 요구하지 않는다. 원천 중 하나가 실패하면 확보된 시장의 수치는 보존하되 결과를 `INCOMPLETE`로 표시한다.
### 한·미 주요 일정
`--market both`는 `주요 일정` 섹션을 항상 출력한다. 최신 일정을 요청받았거나 이번 분석에 일정을 포함할 때는 먼저 **공식 페이지를 실제로 확인**하고 `schedule-evidence.json`을 만들어 `--schedule-evidence FILE`을 붙인다. 대상은 한국 국고채 입찰·한국은행 통화정책방향 결정회의, 미국 국채 입찰·FOMC 회의다. 일반적인 반복 패턴으로 날짜를 추정하거나 이미 지난 일정을 예정 일정으로 표시하지 않는다.
공식 확인처: [한국 국채시장 월별 발행 캘린더](https://ktb.moef.go.kr/mnbyIsuCldr.do), [한국은행 통화정책방향 결정회의](https://www.bok.or.kr/portal/singl/crncyPolicyDrcMtg/listYear.do?menuNo=200755&mtgSe=A), [미 재무부 입찰 일정](https://www.treasurydirect.gov/auctions/announcements-data-results/), [연준 FOMC 일정](https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm). 국고채 월별 계획이 아직 발표되지 않았다면 KR 입찰 날짜를 만들지 않는다. 미 재무부 잠정 일정은 `잠정`임을 답변에서 밝힌다. 미국 입찰표는 **Announcement Date / Auction Date / Settlement Date 중 Auction Date**를 사용한다. FOMC 이틀짜리 회의는 **결정일인 마지막 날**을 사용한다.
증거 파일은 `version: 1`, 보고서 `as-of`와 같은 `anchorDate`, UTC ISO `checkedAt`, `events` 배열을 가진 JSON이다. 각 이벤트에는 `date`(YYYY-MM-DD), `market`(`KR`/`US`), `kind`(`auction`/`policy`), 공식 `sourceUrl`, 원문에서 가져온 짧은 `sourceExcerpt`, 원문 안의 날짜 표현인 `sourceDateText`가 필요하다. 입찰에는 `tenor`(년)를 추가한다. 예를 들어 원문 `2026.09.21. | 5년물`을 확인했다면 `date: "2026-09-21"`, `sourceDateText: "2026.09.21."`, `tenor: 5`로 적는다. 모든 이벤트는 기준일 이후 45일 안이어야 하며 대화·보고서는 날짜순으로 최대 4건씩 보여준다. 링크·날짜 표현·만기·중복을 검증한 뒤 evidence와 함께 저장하므로 같은 evidence를 재생하면 일정도 그대로 나온다.
```bash
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --market both --base 2026-09-18 --as-of 2026-09-22 --schedule-evidence ./schedule-evidence.json
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --market both --base 2026-09-18 --as-of 2026-09-22 --schedule-evidence ./schedule-evidence.json --out ./ktb-ust-report
```
공식 일정 페이지를 확인하지 못했으면 `공식 일정 미확인`으로 남긴다. 일정 검증 상태(`VERIFIED`/`PARTIAL`/`UNVERIFIED`)는 금리 데이터 상태와 별도이며, 일정이 부족하다고 금리 수치를 바꾸지 않는다. 예정 일정은 추후 바뀔 수 있으므로 확인 시각과 원문 링크를 함께 보여준다.
## What this skill does
한국은행 ECOS와 미국 재무부의 국채 2·3·5·10·20·30년 금리를 각각 검증해 한·미 커브 변화와 공통 관측일의 10년 금리차를 분석한다. 공식 입찰·통화정책 일정도 별도 증거로 확인해 같은 답변 형식에 담는다. 국내 단독 모드에서는 ECOS 국고채를 공통 관측일로 정렬해 전일·1주·전월말 변화, 최근 5·20거래일 추이, 분기말·연초 대비, 구간별 스프레드와 기준금리 대비 3년물 갭을 계산한다. 사용자가 시장 배경까지 요청하면 같은 관측일의 공개 기사·공식 발표를 별도 evidence로 검증해 출처가 붙은 코멘트를 보고서에 추가한다.
범용 통계 조회는 `bok-ecos-stats`의 역할이다. 이 스킬은 국고채 커브 모니터링에 필요한 날짜 선택·단위 검증·bp 계산·기계적 분류를 수행한다. 공식 일별 통계 기반 조회·계산 전용이며 실시간 호가, 원인 추정, 전망, 매매 추천은 제공하지 않는다.
## When to use
- “오늘 국고채 커브 보여줘.”
- “한·미 국채 금리 변화와 앞으로의 주요 일정 정리해줘.”
- “전일·1주·전월 말 대비를 한 번에 정리해줘.”
- “최근 5일/20일 동안 어느 만기가 가장 많이 움직였어?”
- “분기말 또는 연초 대비 커브 변화 보여줘.”
- “국고채 3년이 기준금리보다 몇 bp 높아?”
- “금통위 전후 커브를 검산해줘.”
- “오늘 금리 움직임의 뉴스 배경과 출처까지 붙여 레포트로 줘.”
## When not to use
- 임의 ECOS 통계 검색 → `bok-ecos-stats`
- 채권 종목·발행정보·상환 일정 검색
- 실시간 호가·개별 지표채·민평가격
- 채권 가격·듀레이션·DV01 계산
- 금리 전망·매매 신호·투자 권유
## Prerequisites
- Node.js 18 이상, 런타임 외부 패키지 없음
- ECOS 공개 데모 키 `sample`로 무가입 동작한다. 조회창을 10일 이하로 분할해 호출당 10행 제한을 지킨다.
- 선택 환경변수 `KSKILL_BOK_ECOS_API_KEY`: ECOS에서 무료 발급한 개인 키
- 사용자 CSV 모드는 재현 가능한 기준을 위해 `--base`와 `--as-of`를 모두 명시한다.
- 뉴스 코멘트에는 공개 웹 검색·브라우저 접근이 필요하다. helper는 기사를 자동 크롤링하지 않으며 검증된 짧은 evidence만 입력받는다.
## Workflow
### 1. 기본 D/W/M 대시보드
“오늘 커브 정리”, “데일리 커브” 요청의 기본 모드다.
```bash
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --preset dashboard --text
```
현재 6개 만기의 레벨과 전일·1주·전월말 변화, 5Y−3Y·10Y−5Y·10Y−3Y·30Y−10Y 스프레드, 한국은행 기준금리와 3Y−기준금리 갭을 함께 표시한다. “오늘”은 KST 오늘 이전에 ECOS에서 확보되는 최신 공통 관측일이다.
### 2. 현재 스냅샷과 단일 비교 프리셋
```bash
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --preset today
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --preset previous-session
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --preset previous-week
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --preset previous-month-end
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --preset previous-quarter-end
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --preset year-start
```
`previous-session`은 달력상 어제가 아니라 최신 공통 관측일의 직전 공통 관측일이다. `year-start` 기준은 직전 연도 마지막 공통 관측일이다.
### 3. 최근 5·20거래일 추이
```bash
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --lookback-sessions 5 --text
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --lookback-sessions 20 --text
```
만기별 시작·종료 금리, 순변화, 고점·저점, 최대 일간 변동과 발생일을 표시한다. 공통 관측일이 요청 개수보다 적으면 `INCOMPLETE`이며 확보 건수를 밝힌다.
### 4. 월 범위와 정확한 두 날짜
월만 주어지면 월말 대 월말 스냅샷으로 비교한다. 종료 월이 현재 진행 중인 달이면 미래 월말을 요구하지 않고 KST 오늘 기준 최신 공통 관측일을 사용하며, 답변에 실제 관측일을 밝힌다.
```bash
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --from-month 2026-07 --to-month 2026-08
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --base 2026-09-11 --as-of 2026-09-18
```
### 5. 기준금리 갭
```bash
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --preset policy-gap
```
ECOS `722Y001/0101000` 한국은행 기준금리를 별도로 검증하고 같은 최신 시점의 국고채 3년과 차이를 bp로 계산한다. 기준금리 조회만 실패하면 커브 결과는 유지하고 갭만 제외한다.
### 6. 이벤트 전후
사용자가 지정한 금통위·물가 발표 등 이벤트 날짜의 직전 공통 관측일과 당일 또는 이후 최초 공통 관측일을 비교한다.
```bash
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --event-date 2026-08-27
```
“최근 금통위”는 한국은행 공식 일정에서 회의일을 확인하고 링크를 제시한 뒤 실행한다. 일정은 코드에 하드코딩하지 않는다.
### 7. 출처 있는 시장 배경 코멘트
사용자가 원인·배경·헤드라인 코멘트를 요청한 경우에만 실행한다. 먼저 커브를 계산해 실제 현재·직전 관측일을 확인한 뒤, 현재 관측일과 KST 날짜가 같은 공개 자료를 찾는다.
출처 우선순위는 연합인포맥스 `[채권-마감]`, 한국은행·기획재정부·Fed 등 공식 발표, 연합인포맥스 장중 기사, Investing.com 공개 헤드라인 순이다. Investing.com이나 연합인포맥스를 상시 크롤링하거나 기사 본문을 저장하지 않는다. 제목·링크·게시시각과 280자 이하의 짧은 의역만 evidence에 남긴다.
```bash
# 실제 관측일 확인
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --preset dashboard
# 관측일에 맞는 입력 틀 생성
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --news-template 2026-09-21 > news-evidence.json
# 기사 확인 후 evidence를 채워 보고서 생성
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --base 2026-09-18 --as-of 2026-09-21 \
--news-evidence news-evidence.json --out ./bond-report
```
기사 항목의 필수 필드는 다음과 같다.
| 필드 | 규칙 |
| --- | --- |
| `publisher`, `title`, `url` | 실제 공개 출처와 HTTPS 원문 링크 |
| `publishedAt` | UTC ISO 시각. KST 날짜가 실제 현재 관측일과 같아야 함 |
| `sourceType` | `market-close`, `market-update`, `official-release`, `headline` |
| `marketDirection` | 3Y·10Y 변화 기준 `higher`, `lower`, `mixed`, `flat`; 공식 발표는 `context` |
| `relation` | 기사가 요인을 직접 연결하면 `explicit`, 단순 동시 이벤트면 `contextual` |
| `factor` | 기사에서 명시한 시장 배경을 140자 이하로 의역 |
| `evidenceSummary` | 근거 내용을 20~280자로 의역. 기사 본문 복제 금지 |
`SOURCED/HIGH`는 같은 날 연합인포맥스 마감 기사가 관측 방향과 일치하고 요인을 직접 설명할 때만 부여한다. 방향이 다른 기사는 evidence에는 보존하되 코멘트에서 제외한다. 공식 발표만 있으면 `CONTEXT_ONLY`, 일치하는 근거가 없으면 `UNATTRIBUTED`다. 뉴스 확보 실패나 불일치는 검증된 커브의 `READY`를 변경하지 않는다.
### 8. 영구 보고서와 사용자 CSV
HTML·CSV·evidence 저장을 명시적으로 요청한 경우에만 정확한 두 날짜 비교에 `--out`을 붙인다.
```bash
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --base 2026-09-11 --as-of 2026-09-18 --out ./bond-report
npx -y @nomadamas/k-skill@0 exec government-bond-analysis scripts/cli.mjs -- --input input.csv --base 2026-09-11 --as-of 2026-09-18
```
CSV 헤더는 `date,tenor,yield_pct`이며 여섯 만기와 연% 단위를 사용한다. 사용자 자료는 `USER_CSV`로 표시한다.
## Output contract
- `READY`: 해당 모드에 필요한 모든 공통 관측값과 원천 검증 통과
- `INCOMPLETE`: 일부 기준일·세션만 확보. 확보되지 않은 비교는 해석하지 않음
- `BLOCKED`: 유효한 공통 관측값 없음. 기억값이나 추정값 사용 금지
- 금리 레벨은 연%, 변화와 스프레드는 bp
- 요청일과 실제 관측일을 항상 구분
- Bull/Bear 및 steepening/flattening은 관측 변화의 기계적 이름이며 전망이 아님
- 뉴스 상태는 `SOURCED`, `CONTEXT_ONLY`, `UNATTRIBUTED`로 별도 표시하며 커브 상태와 섞지 않음
- 공식 일정 상태는 `VERIFIED`, `PARTIAL`, `UNVERIFIED`로 별도 표시하며 금리 상태와 섞지 않음
- 뉴스 문장은 항상 매체에 귀속하고 인과관계를 자체 사실로 단정하지 않음
## Verification and data source
- 커브: https://ecos.bok.or.kr/ · `817Y002` · `연%` · `D`
- 2Y `010195000`, 3Y `010200000`, 5Y `010200001`, 10Y `010210000`, 20Y `010220000`, 30Y `010230000`
- 기준금리: `722Y001` · `0101000` · `한국은행 기준금리` · `연%`
- 통계표·통계명·항목코드·항목명·단위·날짜·응답 건수·원문 SHA-256을 검증
- 일시 오류만 최대 3회 재시도하고, 동일 요청의 168시간 이내 이중 해시 검증 캐시만 대체 사용
- 뉴스 evidence는 허용된 HTTPS 호스트, KST 관측일, 게시·수집 시각, 중복 URL, 방향 일치와 source fingerprint를 검증
```bash
npx -y @nomadamas/k-skill@0 read government-bond-analysis references/methodology.md
```
## Done when
- 선택 모드에 필요한 여섯 만기의 공통 관측일이 확인됨
- 실제 관측일, 연%와 bp, 모든 공식 항목코드가 검증됨
- `READY` 결과만 완전한 비교로 설명함
- 한국은행 ECOS 링크와 source receipt가 함께 제공됨
- 뉴스 코멘트 요청 시 채택·맥락·제외 기사의 링크와 fingerprint가 모두 남음
- 파일 요청 시 manifest 및 재생 검증을 통과함
## Failure modes
| 상황 | 동작 |
| --- | --- |
| 휴일·결측 | 7일 안의 가장 최근 여섯 만기 공통 관측일 사용 |
| 한 만기·한 구간 실패 | 다른 구간 유지, 검증된 캐시만 해당 구간에 사용 |
| 실데이터 성공·캐시 저장 실패 | 검증된 실데이터 결과는 유지하고 캐시 저장 경고 표시 |
| 기준금리만 실패 | 커브 유지, 정책금리 갭만 제외하고 경고 |
| 뉴스 검색 실패 | 커브 유지, `UNATTRIBUTED` 또는 뉴스 섹션 생략 |
| 기사 방향 불일치 | evidence에는 보존하고 코멘트에서는 제외 |
| 기사 날짜·URL·fingerprint 불일치 | 뉴스 evidence 거부 |
| 단위·항목·통계표 불일치 | 해당 원자료 거부 |
| 비교일이 같은 관측일로 귀결 | `INCOMPLETE`, 변화 해석 차단 |
| 공통 관측값 없음 | `BLOCKED`, 추정 금지 |
| 파일 미요청 | stdout만 출력하고 보고서 파일을 만들지 않음 |
LICENSE
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
references/methodology.md
# 계산·검증 방법론
## 데이터 정의
- 출처: 한국은행 ECOS 통계표 `817Y002` 시장금리(일별)
- 항목: 국고채 2년 `010195000`, 3년 `010200000`, 5년 `010200001`, 10년 `010210000`, 20년 `010220000`, 30년 `010230000`
- 정책금리 갭: 한국은행 기준금리 `722Y001/0101000`
- 단위: 연%
- 결과의 성격: 공식 일별 통계. 실시간 체결가 또는 개별 지표채 호가가 아니다.
## 날짜 정렬
각 요청일 이전에서 여섯 만기 값이 모두 존재하는 가장 최근 날짜를 공통 관측일로 사용한다. 기본 최대 지연은 주말과 한국 장기 연휴를 포괄하는 7일이며 설정 가능 범위는 0~14일이다. 전일 비교는 달력상 전일이 아니라 최신 공통 관측일 직전의 공통 관측일을 사용한다. 현재와 비교 기준이 같은 공통 관측일로 귀결될 때도 변화 계산을 차단한다.
대시보드는 최신 관측일, 직전 관측일, 7일 전 기준, 직전 월말 기준을 한 번에 계산한다. 분기말은 현재 분기의 직전 분기 말, 연초 대비는 전년도 마지막 공통 관측일을 기준으로 한다. 이벤트 비교는 이벤트 직전 마지막 공통 관측일과 이벤트 당일 또는 이후 첫 공통 관측일을 사용한다. 월 범위의 종료 월이 현재 진행 중이면 종료 기준일을 KST 오늘로 제한하고 최신 공통 관측일을 선택한다.
## 원천 검증
ECOS 응답마다 통계표 `817Y002`, 통계명 `1.3.2.1. 시장금리(일별)`, 항목코드·항목명, 단위 `연%`, 관측일 범위, 전체 건수와 실제 행 수를 확인한다. 원문 SHA-256은 evidence의 원문과 대조하고, 영수증의 건수·최초일·최종일은 정규화 관측치와 다시 대조한다. 캐시는 동일 만기·동일 조회 구간·168시간 이내인 경우에만 사용하며 캐시 봉투와 원문을 별도의 SHA-256으로 검증한다.
사용자 CSV는 `USER_CSV`와 파일명으로 별도 표시한다. ECOS에서 직접 수집하지 않은 입력에 한국은행 공식 원천 링크나 ECOS 통계표 식별자를 부여하지 않는다. 실시간 원자료 검증 후 로컬 캐시 저장만 실패한 경우 정상 관측값을 버리지 않고 결과에 캐시 저장 경고를 남긴다.
## 계산
- 만기별 변화(bp) = `(현재 연% − 비교 연%) × 100`
- 장단기 스프레드(bp) = `(장기 연% − 단기 연%) × 100`
- 스프레드 변화(bp) = `현재 스프레드 − 비교 스프레드`
- 정책금리 갭(bp) = `(국고채 3년 − 한국은행 기준금리) × 100`
기본 구간은 5년−3년, 10년−5년, 10년−3년, 30년−10년이다. 최근 5·20거래일 모드는 만기별 시작·종료, 고점·저점, 순변화와 절대값 기준 최대 일간 변동을 계산한다.
원자료로 계산한 뒤 출력 단계에서 표시 자릿수를 반올림한다.
## 곡선 분류
3년과 10년 변화 방향 및 10년−3년 스프레드 변화로 분류한다. 두 만기가 같은 폭으로 움직이면 평행 상승·하락으로 처리한다. 서로 반대 방향이면 혼합 움직임으로 표시하며 bull/bear 네 유형에 억지로 넣지 않는다.
## 재현성 경계
재현성이 뜻하는 것은 저장된 `evidence.mjson`을 같은 엔진 버전으로 처리했을 때 CSV·Markdown·HTML·상태 파일이 동일한 바이트로 생성된다는 것이다. 향후 ECOS 원자료가 수정되거나 신규 관측치가 추가되어도 새 조회 결과가 과거 실행과 같음을 보장하지 않는다.
주요 일정도 저장된 공식 일정 증거를 재생할 때만 동일하게 나온다. 일정 증거에는 확인한 원문 일부, 원문 날짜 표현, 공식 URL과 확인 UTC를 남긴다. 엔진은 날짜·만기·출처 호스트·입찰표의 Auction Date 위치를 검증하지만, 원문 페이지가 이후 수정되었는지는 재생 시 자동으로 확인하지 않는다. 최신 일정을 말할 때는 공식 원문을 다시 열어 확인한다. 일정 확인 실패는 금리 분석 상태를 변경하지 않으며 `UNVERIFIED`로 표시한다.
## 뉴스 evidence와 시장 배경
뉴스는 helper가 상시 크롤링하지 않는다. 에이전트가 공개 검색 결과와 원문 페이지를 확인한 뒤 제목, HTTPS 링크, 게시 UTC, 짧은 의역, 관측 방향과 관계 분류만 `news-evidence.json`에 기록한다. 기사 본문 전체를 저장하거나 재배포하지 않는다.
기사의 KST 게시일은 현재 공통 관측일과 같아야 한다. 3년과 10년 금리가 모두 상승하면 `higher`, 모두 하락하면 `lower`, 방향이 엇갈리면 `mixed`, 모두 보합이면 `flat`으로 정한다. 기사가 요인을 금리 움직임과 직접 연결한 경우에만 `explicit`으로 분류한다. 공식 발표나 같은 날의 관련 이벤트는 `contextual`이며 직접 원인 문장으로 사용하지 않는다.
같은 날 연합인포맥스 마감 기사에서 직접 설명하고 방향이 일치하면 `HIGH`, 그 밖의 일치 기사 하나는 `LOW`, 서로 다른 호스트의 복수 근거가 일치하면 `MEDIUM`으로 표시한다. 관측 방향과 다른 기사는 삭제하지 않고 `excluded`로 남긴다. 뉴스의 상태와 실패는 ECOS 커브의 `READY/INCOMPLETE/BLOCKED` 판정을 바꾸지 않는다.
각 기사 fingerprint는 정규화한 매체, 제목, URL, 게시시각, 유형, 방향, 관계, 요인과 근거 요약의 SHA-256이다. 이 fingerprint와 전체 `evidence.mjson` 및 manifest 해시를 함께 검증하므로 저장된 뉴스 코멘트도 replay에서 동일하게 생성된다.
## 산출물 게이트
스테이징 디렉터리에서 모든 파일을 만든 후 다음을 확인하고 최종 디렉터리로 이동한다.
- evidence SHA-256과 manifest 일치
- 각 산출물 SHA-256 일치
- evidence 재계산 상태와 manifest 상태 일치
- Markdown·CSV·HTML을 evidence에서 다시 렌더했을 때 동일
- 출력에 `NaN` 또는 `undefined` 없음
- 뉴스 사용 시 모든 채택·맥락·제외 출처와 fingerprint가 HTML·Markdown·상태 파일에 보존됨
scripts/cli.mjs
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { collect, collectWindows, pointWindows, rangeWindows } from './collect.mjs';
import { ENGINE, PACKET_VERSION, TENOR_META, TENORS, analyze, bp, classify, csvRead, date, days, kstToday, sha256, shift } from './core.mjs';
import { collectPolicyRate, dashboard, eventQueryWindows, monthComparisonDates, onOrAfter, onOrBefore, previousMonthEnd, previousQuarterEnd, previousYearEnd, trend } from './modes.mjs';
import { analyzeNews, newsRead, newsTemplate } from './news.mjs';
import { writeArtifacts } from './render.mjs';
import { analyzeDual, DUAL_ENGINE, dualChat, dualSummary, writeDualArtifacts } from './dual.mjs';
import { collectUst } from './ust.mjs';
import { readSchedule } from './schedule.mjs';
function args(argv) {
const out = {}, allowed = new Set(['as-of', 'base', 'from-month', 'to-month', 'preset', 'event-date', 'lookback-sessions', 'out', 'input', 'replay', 'news-evidence', 'news-template', 'schedule-evidence', 'cache-dir', 'max-lag', 'no-cache', 'text', 'json', 'help', 'market']);
for (let i = 0; i < argv.length; i++) {
const x = argv[i];
if (!x.startsWith('--'))
throw Error(`Unknown argument: ${x}`);
const k = x.slice(2);
if (!allowed.has(k))
throw Error(`Unknown option: --${k}`);
if (Object.hasOwn(out, k))
throw Error(`Duplicate option: --${k}`);
if (['no-cache', 'text', 'json', 'help'].includes(k))
out[k] = true;
else {
const v = argv[++i];
if (!v || v.startsWith('--'))
throw Error(`Missing value: --${k}`);
out[k] = v;
}
}
return out;
}
const usage = `Usage:
node scripts/cli.mjs [--as-of YYYY-MM-DD] [--base YYYY-MM-DD] [--max-lag 0..14] [--text]
node scripts/cli.mjs --from-month YYYY-MM --to-month YYYY-MM [--text]
node scripts/cli.mjs --preset today|dashboard|previous-session|previous-week|previous-month-end|previous-quarter-end|year-start|policy-gap [--as-of YYYY-MM-DD] [--text]
node scripts/cli.mjs --event-date YYYY-MM-DD [--text]
node scripts/cli.mjs --lookback-sessions 5|20 [--as-of YYYY-MM-DD] [--text]
node scripts/cli.mjs --input FILE.csv --as-of YYYY-MM-DD --base YYYY-MM-DD [--text]
node scripts/cli.mjs --base YYYY-MM-DD --as-of YYYY-MM-DD --news-evidence FILE.mjson [--out DIR]
node scripts/cli.mjs --news-template YYYY-MM-DD
node scripts/cli.mjs --replay evidence.mjson [--text]
node scripts/cli.mjs --market both [--as-of YYYY-MM-DD] [--base YYYY-MM-DD] [--schedule-evidence FILE.mjson] [--json|--out DIR]
node scripts/cli.mjs --market both --replay evidence.mjson [--out DIR]
Add --out DIR only when a persistent HTML/CSV/evidence report bundle is requested.
`;
function summary(p, a, news) {
return { result: a.status, requested: { base: p.base, asOf: p.asOf }, observed: { base: a.previous.actual, asOf: a.current.actual }, unit: 'annual percent; changes/spreads in bp', rates: TENORS.map((tenor, i) => ({ tenor: `${tenor}Y`, base: a.previous.values?.[i] ?? null, asOf: a.current.values?.[i] ?? null, changeBp: a.changes?.[i] ?? null })), spreads: a.spreads, movement: a.movement, notes: a.notes, news: news.status === 'DISABLED' ? null : { status: news.status, confidence: news.confidence, curveDirection: news.curveDirection, commentary: news.commentary, notes: news.notes, sources: [...news.eligible.map(x => ({ disposition: 'eligible', ...x })), ...news.context.map(x => ({ disposition: 'context', ...x })), ...news.excluded.map(x => ({ disposition: 'excluded', ...x }))].map(x => ({ disposition: x.disposition, publisher: x.publisher, title: x.title, url: x.url, publishedAt: x.publishedAt, sourceFingerprint: x.sourceFingerprint })) }, sources: p.receipts.map(r => ({ tenor: `${r.tenor}Y`, provider: r.sourceId, statCode: r.statCode, itemCode: r.itemCode, unit: r.unit, cycle: r.cycle, retrievedAt: r.retrievedAt, status: r.status, sourceUrl: r.sourceUrl, rawSha256: r.rawSha256 })) };
}
function textSummary(p, a, news) {
const lines = [`상태: ${a.status}`, `요청일: ${p.base} → ${p.asOf}`, `관측일: ${a.previous.actual ?? '없음'} → ${a.current.actual ?? '없음'}`];
if (a.changes && a.previous.values && a.current.values) {
TENORS.forEach((t, i) => lines.push(`${t}년: ${a.previous.values[i].toFixed(3)}% → ${a.current.values[i].toFixed(3)}% (${a.changes[i] >= 0 ? '+' : ''}${a.changes[i].toFixed(2)}bp)`));
for (const s of a.spreads)
lines.push(`${s.name}: ${s.previous?.toFixed(2)}bp → ${s.current?.toFixed(2)}bp (${(s.change ?? 0) >= 0 ? '+' : ''}${s.change?.toFixed(2)}bp)`);
lines.push(`곡선 판정: ${a.movement}`);
}
if (a.notes.length)
lines.push(...a.notes.map(n => `주의: ${n}`));
if (news.status !== 'DISABLED') {
lines.push(`뉴스 근거: ${news.status}${news.confidence ? ` / ${news.confidence}` : ''}`, ...news.commentary, ...news.notes.map(n => `주의: ${n}`));
for (const source of [...news.eligible, ...news.context, ...news.excluded])
lines.push(`기사: ${source.publisher} · ${source.title} · ${source.url}`);
lines.push('뉴스 코멘트는 출처가 전한 시장 해석이며 ECOS 수치만으로 인과를 확정하지 않습니다.');
}
lines.push(`출처: ${[...new Set(p.receipts.map(r => r.sourceUrl || r.sourceId))].join(', ')}`);
return lines.join('\n');
}
function snapshotLines(label, s) { const lines = [`${label}: ${s.actual ?? '없음'}`]; if (s.values)
lines.push(TENORS.map(t => `${t}Y ${s.values[`${t}Y`].toFixed(3)}%`).join(' · ')); return lines; }
function dashboardText(d) {
const lines = [`상태: ${d.result}`, ...snapshotLines('현재 관측일', d.current)];
for (const [label, key] of [['전일', 'previousSession'], ['1주', 'weekAgo'], ['전월말', 'previousMonthEnd']]) {
const ref = d.references[key];
lines.push(`${label} 기준: ${ref.actual ?? '없음'}`);
if (ref.values)
lines.push(d.changes[key].map(x => `${x.tenor} ${x.changeBp >= 0 ? '+' : ''}${x.changeBp?.toFixed(2)}bp`).join(' · '));
}
lines.push(`스프레드: ${Object.entries(d.spreads).map(([k, v]) => `${k} ${v?.toFixed(2) ?? '없음'}bp`).join(' · ')}`);
if (d.policyRate)
lines.push(`기준금리: ${d.policyRate.value.toFixed(2)}% (${d.policyRate.date}) · 3Y-기준금리 ${d.policyGap3YBp?.toFixed(2)}bp`);
if (d.notes.length)
lines.push(...d.notes.map(n => `주의: ${n}`));
lines.push('출처: https://ecos.bok.or.kr/');
return lines.join('\n');
}
function trendText(t) { const lines = [`상태: ${t.result}`, `기간: ${t.from ?? '없음'} → ${t.to ?? '없음'} (${t.actualSessions}/${t.requestedSessions}거래일)`]; for (const s of t.stats)
lines.push(`${s.tenor}: ${s.start.toFixed(3)}% → ${s.end.toFixed(3)}% (${s.netChangeBp >= 0 ? '+' : ''}${s.netChangeBp.toFixed(2)}bp), 고점 ${s.high.toFixed(3)}%, 저점 ${s.low.toFixed(3)}%, 최대 일변동 ${s.largestDailyMoveBp >= 0 ? '+' : ''}${s.largestDailyMoveBp.toFixed(2)}bp (${s.largestDailyMoveDate})`); if (t.notes.length)
lines.push(...t.notes.map(n => `주의: ${n}`)); lines.push('출처: https://ecos.bok.or.kr/'); return lines.join('\n'); }
function comparison(current, reference, mode) { const changes = TENORS.map(t => ({ tenor: `${t}Y`, base: reference.values?.[`${t}Y`] ?? null, current: current.values?.[`${t}Y`] ?? null, changeBp: current.values && reference.values ? Math.round((current.values[`${t}Y`] - reference.values[`${t}Y`]) * 100 * 1e8) / 1e8 : null })), c3 = changes.find(x => x.tenor === '3Y')?.changeBp, c10 = changes.find(x => x.tenor === '10Y')?.changeBp; return { result: current.values && reference.values && current.actual !== reference.actual ? 'READY' : current.values || reference.values ? 'INCOMPLETE' : 'BLOCKED', mode, current, reference, changes, movement: c3 !== null && c3 !== undefined && c10 !== null && c10 !== undefined ? classify(c3, c10) : '판정 불가', source: 'https://ecos.bok.or.kr/' }; }
function snapshotSpreads(s) { const spread = (long, short) => s.values ? bp(s.values[long], s.values[short]) : null; return { '5Y-3Y': spread('5Y', '3Y'), '10Y-5Y': spread('10Y', '5Y'), '10Y-3Y': spread('10Y', '3Y'), '30Y-10Y': spread('30Y', '10Y') }; }
async function main() {
const a = args(process.argv.slice(2));
if (a.help) {
console.log(usage);
return;
}
const market = typeof a.market === 'string' ? a.market : 'kr';
if (!['kr', 'both'].includes(market))
throw Error('--market must be kr or both');
if (a.text === true && a.json === true)
throw Error('--text and --json are mutually exclusive');
if (market === 'kr' && a.json === true)
throw Error('--json is only needed with --market both; Korean mode already defaults to JSON');
if (typeof a['news-template'] === 'string') {
if (Object.keys(a).some(k => k !== 'news-template'))
throw Error('--news-template cannot be combined with other options');
console.log(newsTemplate(date(a['news-template']), new Date().toISOString()));
return;
}
const hasMonth = typeof a['from-month'] === 'string' || typeof a['to-month'] === 'string';
const hasInput = typeof a.input === 'string', hasReplay = typeof a.replay === 'string';
if (hasInput && hasReplay)
throw Error('--input and --replay are mutually exclusive');
if (hasMonth && (typeof a['from-month'] !== 'string' || typeof a['to-month'] !== 'string'))
throw Error('--from-month and --to-month must be used together');
if (hasMonth && (typeof a.base === 'string' || typeof a['as-of'] === 'string'))
throw Error('Month range cannot be combined with --base/--as-of');
if (hasInput && (hasMonth || typeof a.base !== 'string' || typeof a['as-of'] !== 'string'))
throw Error('--input requires explicit --base and --as-of and cannot use month range');
if (hasReplay && Object.keys(a).some(k => !['replay', 'out', 'text', 'json', 'market'].includes(k)))
throw Error('--replay accepts only --market, --out, --text and --json');
const monthDates = hasMonth ? monthComparisonDates(a['from-month'], a['to-month'], kstToday()) : null;
const asOf = monthDates?.asOf ?? (typeof a['as-of'] === 'string' ? date(a['as-of']) : kstToday());
const base = monthDates?.base ?? (typeof a.base === 'string' ? date(a.base) : shift(asOf, -7));
const maxLag = typeof a['max-lag'] === 'string' ? Number(a['max-lag']) : 7;
if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
throw Error('--max-lag must be an integer from 0 to 14');
const cacheDir = resolve(typeof a['cache-dir'] === 'string' ? a['cache-dir'] : join(homedir(), '.cache', 'k-skill', 'korean-interest-rate-analysis')), useCache = a['no-cache'] !== true;
if (market === 'both') {
if (Object.keys(a).some(k => !['market', 'base', 'as-of', 'from-month', 'to-month', 'max-lag', 'out', 'replay', 'schedule-evidence', 'cache-dir', 'no-cache', 'text', 'json'].includes(k)))
throw Error('--market both supports dates, schedule evidence, cache, replay, --text, --json and --out only');
let dual;
if (hasReplay) {
const evidencePath = resolve(a.replay), raw = await readFile(evidencePath, 'utf8');
if (raw.length > 100_000_000)
throw Error('Dual replay evidence exceeds 100 MB');
try {
const manifest = JSON.parse(await readFile(join(dirname(evidencePath), 'manifest.mjson'), 'utf8'));
if (manifest.evidenceSha256 !== sha256(raw))
throw Error('Dual replay evidence hash differs from manifest');
}
catch (e) {
if (e.code !== 'ENOENT')
throw e;
}
dual = JSON.parse(raw);
}
else {
if (asOf > kstToday())
throw Error('Live collection cannot use a future KST as-of date');
if (base >= asOf || days(asOf, base) > 366)
throw Error(base >= asOf ? 'Base must precede as-of' : 'Comparison window must be 366 days or less');
const [kr, us] = await Promise.all([collect(base, asOf, maxLag, cacheDir, useCache), collectUst(base, asOf, maxLag, cacheDir, useCache)]);
const schedule = typeof a['schedule-evidence'] === 'string' ? readSchedule(await readFile(resolve(a['schedule-evidence']), 'utf8'), asOf) : undefined;
dual = { version: 1, engine: DUAL_ENGINE, ktb: { version: PACKET_VERSION, engine: ENGINE, asOf, base, maxLag, ...kr, label: '한·미 국채 금리 분석 · KTB' }, ust: us, ...(schedule ? { schedule } : {}) };
}
const result = analyzeDual(dual);
if (typeof a.out === 'string') {
const out = resolve(a.out);
await writeDualArtifacts(out, dual);
console.log(JSON.stringify({ result: result.status, out, files: ['report.html', 'brief.md', 'market-table.csv', 'evidence.mjson', 'status.mjson', 'manifest.mjson'] }, null, 2));
}
else if (a.json === true)
console.log(JSON.stringify(dualSummary(dual, result), null, 2));
else
console.log(dualChat(dual, result));
if (result.status !== 'READY')
process.exitCode = 2;
return;
}
const preset = typeof a.preset === 'string' ? a.preset : null, eventDate = typeof a['event-date'] === 'string' ? date(a['event-date']) : null, lookback = typeof a['lookback-sessions'] === 'string' ? Number(a['lookback-sessions']) : null;
if ((preset !== null || lookback !== null || eventDate !== null) && (typeof a.input === 'string' || typeof a.replay === 'string' || typeof a.out === 'string' || typeof a['news-evidence'] === 'string' || hasMonth || typeof a.base === 'string'))
throw Error('Preset/trend/event modes accept only date anchor, --max-lag, --cache-dir, --no-cache and --text');
if ([preset !== null, lookback !== null, eventDate !== null].filter(Boolean).length > 1)
throw Error('--preset, --lookback-sessions and --event-date are mutually exclusive');
if (!hasReplay && (base >= asOf || days(asOf, base) > 366))
throw Error(base >= asOf ? 'Base must precede as-of' : 'Comparison window must be 366 days or less');
if (preset !== null || lookback !== null || eventDate !== null) {
if (asOf > kstToday())
throw Error('Live collection cannot use a future KST as-of date');
let output;
if (eventDate !== null) {
const data = await collectWindows(eventQueryWindows(eventDate, kstToday(), maxLag), cacheDir, useCache), before = onOrBefore(data.observations, shift(eventDate, -1), maxLag), after = onOrAfter(data.observations, eventDate, maxLag);
output = { ...comparison(after, before, 'event'), eventDate };
console.log(JSON.stringify(output, null, 2));
}
else if (lookback !== null) {
if (![5, 20].includes(lookback))
throw Error('--lookback-sessions must be 5 or 20');
const from = shift(asOf, lookback === 20 ? -45 : -16), data = await collectWindows(rangeWindows(from, asOf), cacheDir, useCache);
output = trend(data.observations, lookback);
console.log(a.text === true ? trendText(output) : JSON.stringify(output, null, 2));
}
else if (preset === 'today') {
const data = await collectWindows(pointWindows([asOf], maxLag), cacheDir, useCache), current = onOrBefore(data.observations, asOf, maxLag);
output = { result: current.values ? 'READY' : 'BLOCKED', mode: 'today', current, spreads: snapshotSpreads(current), source: 'https://ecos.bok.or.kr/' };
console.log(JSON.stringify(output, null, 2));
}
else if (preset === 'policy-gap') {
const data = await collectWindows(pointWindows([asOf], maxLag), cacheDir, useCache), current = onOrBefore(data.observations, asOf, maxLag), policyRate = await collectPolicyRate(asOf, cacheDir, useCache), current3 = current.values?.['3Y'] ?? null, policyGap3YBp = current3 !== null && policyRate ? bp(current3, policyRate.value) : null, notes = [current.reason, !policyRate ? '기준금리 확보 실패: 정책금리 갭 제외' : null].filter((x) => !!x);
output = { result: current.values && policyRate ? 'READY' : current.values ? 'INCOMPLETE' : 'BLOCKED', mode: 'policy-gap', current, policyRate, policyGap3YBp, notes, source: 'https://ecos.bok.or.kr/' };
console.log(JSON.stringify(output, null, 2));
}
else if (preset === 'dashboard' || preset === 'previous-session') {
const windows = [...rangeWindows(shift(asOf, -(maxLag + 7)), asOf), ...pointWindows([shift(asOf, -7), previousMonthEnd(asOf)], maxLag)], data = await collectWindows(windows, cacheDir, useCache), policy = preset === 'dashboard' ? await collectPolicyRate(asOf, cacheDir, useCache) : null, d = dashboard(asOf, data.observations, maxLag, policy);
output = preset === 'dashboard' ? d : comparison(d.current, d.references.previousSession, 'previous-session');
console.log(a.text === true && preset === 'dashboard' ? dashboardText(d) : JSON.stringify(output, null, 2));
}
else {
const refs = { 'previous-week': shift(asOf, -7), 'previous-month-end': previousMonthEnd(asOf), 'previous-quarter-end': previousQuarterEnd(asOf), 'year-start': previousYearEnd(asOf) };
const referenceDate = refs[preset ?? ''];
if (!referenceDate)
throw Error(`Unknown preset: ${preset}`);
const data = await collectWindows(pointWindows([asOf, referenceDate], maxLag), cacheDir, useCache), current = onOrBefore(data.observations, asOf, maxLag), reference = onOrBefore(data.observations, referenceDate, maxLag);
output = comparison(current, reference, preset);
console.log(JSON.stringify(output, null, 2));
}
const result = output.result;
if (result !== 'READY')
process.exitCode = 2;
return;
}
let p;
if (typeof a.replay === 'string') {
const evidencePath = resolve(a.replay), raw = await readFile(evidencePath, 'utf8');
if (raw.length > 100_000_000)
throw Error('Replay evidence exceeds 100 MB character limit');
try {
const m = JSON.parse(await readFile(join(dirname(evidencePath), 'manifest.mjson'), 'utf8'));
if (m.evidenceSha256 && m.evidenceSha256 !== sha256(raw))
throw Error('Replay evidence hash differs from manifest');
}
catch (e) {
if (e.code !== 'ENOENT')
throw e;
}
p = JSON.parse(raw);
if (p.version !== PACKET_VERSION || p.engine !== ENGINE)
throw Error('Unsupported evidence packet');
}
else if (typeof a.input === 'string') {
const inputPath = resolve(a.input), raw = await readFile(inputPath, 'utf8'), observations = csvRead(raw), now = new Date().toISOString();
const receipts = TENORS.map(tenor => { const rows = observations.filter(r => r.tenor === tenor), meta = TENOR_META[tenor]; return { tenor, sourceId: `사용자 제공 CSV: ${basename(inputPath)}`, sourceUrl: '', statCode: 'USER_CSV', itemCode: meta.itemCode, itemName: meta.itemName, unit: '연%', cycle: 'D', retrievedAt: now, status: 'input', raw: [raw], rawSha256: [sha256(raw)], count: rows.length, first: rows[0]?.date ?? null, last: rows.at(-1)?.date ?? null, windows: [] }; });
p = { version: PACKET_VERSION, engine: ENGINE, asOf, base, maxLag, observations, receipts, label: '한국 금리 동향 분석' };
}
else {
if (asOf > kstToday())
throw Error('Live collection cannot use a future KST as-of date');
const result = await collect(base, asOf, maxLag, cacheDir, useCache);
p = { version: PACKET_VERSION, engine: ENGINE, asOf, base, maxLag, ...result, label: '한국 금리 동향 분석' };
}
if (typeof a['news-evidence'] === 'string') {
const raw = await readFile(resolve(a['news-evidence']), 'utf8');
p = { ...p, newsEvidence: newsRead(raw) };
}
const analysis = analyze(p), news = analyzeNews(p.newsEvidence, analysis);
if (typeof a.out === 'string') {
const out = resolve(a.out), manifest = await writeArtifacts(out, p);
console.log(JSON.stringify({ result: manifest.status, out, files: [...Object.keys(manifest.files), 'manifest.mjson'] }, null, 2));
}
else
console.log(a.text === true ? textSummary(p, analysis, news) : JSON.stringify(summary(p, analysis, news), null, 2));
if (analysis.status !== 'READY')
process.exitCode = 2;
}
main().catch(e => { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ result: 'error', message }, null, 2)); process.exitCode = 1; });
scripts/collect.mjs
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { SOURCE_ID, SOURCE_URL, TENORS, TENOR_META, date, days, normalize, parseEcos, sha256, shift } from './core.mjs';
class Fatal extends Error {
}
class RequestFailure extends Error {
attempts;
constructor(message, attempts) {
super(message);
this.attempts = attempts;
}
}
const transient = (status) => [408, 425, 429].includes(status) || status >= 500;
export async function request(url, get = fetch, pause = ms => new Promise(r => setTimeout(r, ms)), validate) {
let last = 'unknown error';
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await get(url, { signal: AbortSignal.timeout(10000), headers: { accept: 'application/json' } });
if (!res.ok) {
if (!transient(res.status))
throw new Fatal(`HTTP ${res.status}`);
throw Error(`HTTP ${res.status}`);
}
const text = await res.text();
if (text.length > 2_000_000)
throw new Fatal('Response too large');
JSON.parse(text);
validate?.(text);
return { text, attempts: attempt };
}
catch (e) {
last = e instanceof Error ? e.message : String(e);
if (e instanceof Fatal)
throw new RequestFailure(last, attempt);
if (attempt === 3)
throw new RequestFailure(`${last} after 3 attempts`, attempt);
await pause(300 * 2 ** (attempt - 1));
}
}
throw Error(last);
}
export const parse = parseEcos;
export function pointWindows(points, maxLag) {
const unique = new Map();
if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
throw Error('maxLag must be 0..14 calendar days');
for (const point of points) {
date(point);
for (const window of rangeWindows(shift(point, -maxLag), point))
unique.set(`${window.from}/${window.to}`, window);
}
return [...unique.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
}
export function rangeWindows(from, to) {
date(from);
date(to);
if (from > to)
throw Error('Invalid collection range');
const result = [];
for (let start = from; start <= to; start = shift(start, 10)) {
const end = shift(start, 9) < to ? shift(start, 9) : to;
result.push({ from: start, to: end });
}
return result;
}
async function atomic(path, content) { const temp = `${path}.${randomUUID()}.tmp`; try {
await writeFile(temp, content);
await rename(temp, path);
}
catch (e) {
await rm(temp, { force: true });
throw e;
} }
const cacheName = (tenor, from, to) => `${tenor}-${from}-${to}.mjson`;
export async function collect(base, asOf, maxLag, cacheDir, useCache = true, get = fetch, now = new Date(), maxCacheAgeHours = 168) {
date(base);
date(asOf);
if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
throw Error('maxLag must be 0..14 calendar days');
if (base >= asOf)
throw Error('Base must precede as-of');
if (days(asOf, base) > 366)
throw Error('Comparison window must be 366 days or less');
return collectWindows(pointWindows([base, asOf], maxLag), cacheDir, useCache, get, now, maxCacheAgeHours);
}
export async function collectWindows(inputSegments, cacheDir, useCache = true, get = fetch, now = new Date(), maxCacheAgeHours = 168) {
if (!Array.isArray(inputSegments) || !inputSegments.length)
throw Error('At least one query window is required');
if (typeof cacheDir !== 'string' || !cacheDir.trim())
throw Error('cacheDir must be a non-empty path');
if (!Number.isFinite(now.getTime()))
throw Error('Invalid collection clock');
if (!Number.isFinite(maxCacheAgeHours) || maxCacheAgeHours < 0)
throw Error('maxCacheAgeHours must be non-negative');
const unique = new Map();
for (const segment of inputSegments) {
if (!segment || typeof segment.from !== 'string' || typeof segment.to !== 'string')
throw Error('Invalid query window');
date(segment.from);
date(segment.to);
if (segment.from > segment.to || daysBetween(segment.from, segment.to) > 10)
throw Error('Query windows must contain 1..10 calendar days');
unique.set(`${segment.from}/${segment.to}`, segment);
}
const retrievedAt = now.toISOString(), segments = [...unique.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
if (useCache)
await mkdir(cacheDir, { recursive: true });
const parts = await Promise.all(TENORS.map(async (tenor) => {
const observations = [];
const raw = [];
const rawSha256 = [];
const windowReceipts = [];
const errors = [];
for (const segment of segments) {
const file = join(cacheDir, cacheName(tenor, segment.from, segment.to));
let liveError = '', liveAttempts = 0;
try {
const key = process.env.KSKILL_BOK_ECOS_API_KEY || 'sample', meta = TENOR_META[tenor];
const url = `https://ecos.bok.or.kr/api/StatisticSearch/${encodeURIComponent(key)}/json/kr/1/10/817Y002/D/${segment.from.replaceAll('-', '')}/${segment.to.replaceAll('-', '')}/${meta.itemCode}`;
const result = await request(url, get, undefined, text => { parse(text, tenor, segment.from, segment.to); });
liveAttempts = result.attempts;
const rows = parse(result.text, tenor, segment.from, segment.to), digest = sha256(result.text);
const data = { version: 1, storedAt: retrievedAt, tenor, ...segment, raw: result.text, rawSha256: digest };
let cacheError;
if (useCache)
try {
await atomic(file, JSON.stringify({ data, digest: sha256(JSON.stringify(data)) }));
}
catch (e) {
cacheError = `cache write failed (${e instanceof Error ? e.message : String(e)})`;
}
observations.push(...rows);
raw.push(result.text);
rawSha256.push(digest);
windowReceipts.push({ ...segment, status: 'live', attempts: result.attempts, ...(cacheError ? { error: cacheError } : {}) });
continue;
}
catch (e) {
liveError = e instanceof Error ? e.message : String(e);
liveAttempts = e instanceof RequestFailure ? e.attempts : Math.max(liveAttempts, 1);
}
if (useCache)
try {
const envelope = JSON.parse(await readFile(file, 'utf8')), data = envelope.data;
const age = (now.getTime() - Date.parse(data?.storedAt)) / 3600000;
if (!data || envelope.digest !== sha256(JSON.stringify(data)) || data.version !== 1 || data.tenor !== tenor || data.from !== segment.from || data.to !== segment.to || data.rawSha256 !== sha256(data.raw) || !Number.isFinite(age) || age < 0 || age > maxCacheAgeHours)
throw Error('cache identity, hash, or age check failed');
const rows = parse(data.raw, tenor, segment.from, segment.to);
observations.push(...rows);
raw.push(data.raw);
rawSha256.push(data.rawSha256);
windowReceipts.push({ ...segment, status: 'cache', attempts: liveAttempts, cacheAgeHours: Math.round(age * 100) / 100, error: liveError });
continue;
}
catch (e) {
errors.push(`${segment.from}..${segment.to}: ${liveError}; cache rejected (${e instanceof Error ? e.message : String(e)})`);
}
else
errors.push(`${segment.from}..${segment.to}: ${liveError}`);
windowReceipts.push({ ...segment, status: 'failed', attempts: liveAttempts, error: liveError });
}
const rows = normalize(observations), modes = new Set(windowReceipts.map(w => w.status));
const status = rows.length === 0 && modes.has('failed') ? 'failed' : modes.size === 1 ? (modes.has('live') ? 'live' : modes.has('cache') ? 'cache' : 'failed') : 'mixed';
const meta = TENOR_META[tenor];
const receipt = { tenor, sourceId: SOURCE_ID, sourceUrl: SOURCE_URL, statCode: '817Y002', itemCode: meta.itemCode, itemName: meta.itemName, unit: '연%', cycle: 'D', retrievedAt, status, raw, rawSha256, count: rows.length, first: rows[0]?.date ?? null, last: rows.at(-1)?.date ?? null, windows: windowReceipts, ...(errors.length ? { error: errors.join(' | ') } : {}) };
return { observations: rows, receipt };
}));
return { observations: normalize(parts.flatMap(p => p.observations)), receipts: parts.map(p => p.receipt) };
}
function daysBetween(from, to) { return Math.round((Date.parse(to) - Date.parse(from)) / 86400000) + 1; }
scripts/core.mjs
import { createHash } from 'node:crypto';
export const TENORS = [2, 3, 5, 10, 20, 30];
export const TENOR_META = {
2: { itemCode: '010195000', itemName: '국고채(2년)' },
3: { itemCode: '010200000', itemName: '국고채(3년)' },
5: { itemCode: '010200001', itemName: '국고채(5년)' },
10: { itemCode: '010210000', itemName: '국고채(10년)' },
20: { itemCode: '010220000', itemName: '국고채(20년)' },
30: { itemCode: '010230000', itemName: '국고채(30년)' }
};
export const ENGINE = 'korean-interest-rate-analysis/5';
export const PACKET_VERSION = 5;
export const SOURCE_ID = '한국은행 ECOS 817Y002';
export const SOURCE_URL = 'https://ecos.bok.or.kr/';
export const sha256 = (s) => createHash('sha256').update(s).digest('hex');
export function date(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
throw Error(`Invalid date: ${value}`);
const [y, m, d] = value.split('-').map(Number), dt = new Date(Date.UTC(y, m - 1, d));
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d)
throw Error(`Invalid date: ${value}`);
return value;
}
export const shift = (d, n) => {
if (!Number.isInteger(n))
throw Error('Date shift must be an integer');
const [y, m, day] = date(d).split('-').map(Number);
return new Date(Date.UTC(y, m - 1, day + n)).toISOString().slice(0, 10);
};
export const days = (a, b) => {
const [ay, am, ad] = date(a).split('-').map(Number), [by, bm, bd] = date(b).split('-').map(Number);
return (Date.UTC(ay, am - 1, ad) - Date.UTC(by, bm - 1, bd)) / 86400000;
};
export function kstToday(now = new Date()) {
if (!Number.isFinite(now.getTime()))
throw Error('Invalid clock');
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now);
const pick = (type) => parts.find(p => p.type === type)?.value ?? '';
return date(`${pick('year')}-${pick('month')}-${pick('day')}`);
}
export const bp = (a, b) => Math.round((a - b) * 100 * 1e8) / 1e8;
export function normalize(input) {
if (!Array.isArray(input))
throw Error('Observations must be an array');
const unique = new Map();
for (const item of input) {
if (!item || typeof item !== 'object')
throw Error('Invalid observation');
const r = item;
if (typeof r.date !== 'string' || !TENORS.includes(r.tenor) || typeof r.value !== 'number' || !Number.isFinite(r.value) || r.value < -10 || r.value > 50)
throw Error('Invalid tenor/value (expected yield in annual percent, -10..50)');
const row = { date: date(r.date), tenor: r.tenor, value: r.value };
const key = `${row.date}/${row.tenor}`, prior = unique.get(key);
if (prior && prior.value !== row.value)
throw Error(`Conflicting duplicate ${key}`);
unique.set(key, row);
}
return [...unique.values()].sort((a, b) => a.date.localeCompare(b.date) || a.tenor - b.tenor);
}
export function parseEcos(raw, tenor, start, end) {
date(start);
date(end);
if (start > end)
throw Error('Invalid ECOS window');
const doc = JSON.parse(raw);
if (doc.RESULT?.CODE === 'INFO-200')
return [];
if (doc.RESULT)
throw Error(`ECOS ${doc.RESULT.CODE ?? 'unknown'}${doc.RESULT.MESSAGE ? `: ${doc.RESULT.MESSAGE}` : ''}`);
const block = doc.StatisticSearch, rows = block?.row;
if (!block || !Array.isArray(rows) || !Number.isInteger(block.list_total_count) || block.list_total_count !== rows.length || rows.length > 10)
throw Error('Incomplete or invalid ECOS response');
const meta = TENOR_META[tenor];
const seen = new Set();
return normalize(rows.map(r => {
if (!/^\d{8}$/.test(r.TIME ?? ''))
throw Error('ECOS invalid observation date');
const d = `${r.TIME.slice(0, 4)}-${r.TIME.slice(4, 6)}-${r.TIME.slice(6, 8)}`;
date(d);
if (seen.has(d))
throw Error('ECOS duplicate observation date');
seen.add(d);
if (r.STAT_CODE !== '817Y002' || r.STAT_NAME !== '1.3.2.1. 시장금리(일별)' || r.ITEM_CODE1 !== meta.itemCode || r.ITEM_NAME1 !== meta.itemName || r.UNIT_NAME !== '연%' || d < start || d > end || !/^\-?\d+(?:\.\d+)?$/.test(r.DATA_VALUE ?? ''))
throw Error('ECOS metadata/unit/range mismatch');
const value = Number(r.DATA_VALUE);
if (!Number.isFinite(value))
throw Error('ECOS non-numeric yield');
return { date: d, tenor, value };
}));
}
function validateReceipt(r, rows) {
if (!r || typeof r !== 'object' || !TENORS.includes(r.tenor) || !['live', 'cache', 'mixed', 'input', 'failed'].includes(r.status) || typeof r.retrievedAt !== 'string' || !Number.isFinite(Date.parse(r.retrievedAt)) || new Date(r.retrievedAt).toISOString() !== r.retrievedAt)
throw Error('Invalid source receipt');
const meta = TENOR_META[r.tenor];
if (r.itemCode !== meta.itemCode || r.itemName !== meta.itemName || r.unit !== '연%' || r.cycle !== 'D')
throw Error(`${r.tenor}Y receipt metadata mismatch`);
if (r.status === 'input') {
if (r.statCode !== 'USER_CSV' || !r.sourceId.startsWith('사용자 제공 CSV: ') || r.sourceUrl !== '')
throw Error(`${r.tenor}Y input source identity mismatch`);
}
else if (r.statCode !== '817Y002' || r.sourceId !== SOURCE_ID || r.sourceUrl !== SOURCE_URL)
throw Error(`${r.tenor}Y ECOS source identity mismatch`);
if (!Array.isArray(r.raw) || !Array.isArray(r.rawSha256) || r.raw.length !== r.rawSha256.length || r.raw.some((raw, i) => typeof raw !== 'string' || typeof r.rawSha256[i] !== 'string' || !/^[a-f0-9]{64}$/.test(r.rawSha256[i]) || sha256(raw) !== r.rawSha256[i]))
throw Error(`${r.tenor}Y raw source hash mismatch`);
if (!Array.isArray(r.windows) || r.windows.some(w => { try {
return !w || typeof w !== 'object' || date(w.from) !== w.from || date(w.to) !== w.to || w.from > w.to || days(w.to, w.from) + 1 > 10 || !['live', 'cache', 'failed'].includes(w.status) || !Number.isInteger(w.attempts) || w.attempts < 1 || w.attempts > 3 || (w.status === 'cache' && (!Number.isFinite(w.cacheAgeHours) || w.cacheAgeHours < 0)) || (w.status !== 'cache' && w.cacheAgeHours !== undefined);
}
catch {
return true;
} }) || new Set(r.windows.map(w => `${w.from}/${w.to}`)).size !== r.windows.length)
throw Error(`${r.tenor}Y invalid source window`);
const own = rows.filter(x => x.tenor === r.tenor), first = own[0]?.date ?? null, last = own.at(-1)?.date ?? null;
if (r.count !== own.length || r.first !== first || r.last !== last)
throw Error(`${r.tenor}Y receipt count/range mismatch`);
if (r.status === 'failed' && r.count !== 0)
throw Error(`${r.tenor}Y failed receipt contains observations`);
if (r.status === 'input') {
if (r.windows.length !== 0 || r.raw.length !== 1)
throw Error(`${r.tenor}Y input receipt must contain one CSV and no network windows`);
}
else {
if (!r.windows.length || r.raw.length !== r.windows.filter(w => w.status !== 'failed').length)
throw Error(`${r.tenor}Y source window/raw mismatch`);
const modes = new Set(r.windows.map(w => w.status));
const expected = r.count === 0 && modes.has('failed') ? 'failed' : modes.size === 1 ? (modes.has('live') ? 'live' : modes.has('cache') ? 'cache' : 'failed') : 'mixed';
if (r.status !== expected)
throw Error(`${r.tenor}Y receipt status mismatch`);
}
const fromRaw = r.status === 'input' ? csvRead(r.raw[0]).filter(x => x.tenor === r.tenor) : r.windows.flatMap((w, i) => {
if (w.status === 'failed')
return [];
const rawIndex = r.windows.slice(0, i).filter(x => x.status !== 'failed').length;
return parseEcos(r.raw[rawIndex], r.tenor, w.from, w.to);
});
if (JSON.stringify(normalize(fromRaw)) !== JSON.stringify(own))
throw Error(`${r.tenor}Y observations do not match source raw data`);
}
export function classify(short, long) {
const delta = long - short, eps = 1e-7;
if (Math.abs(delta) < eps)
return Math.abs(short) < eps ? '보합' : short > 0 ? '평행 상승' : '평행 하락';
const slope = delta > 0 ? '스티프닝' : '플래트닝';
if (short < 0 && long < 0)
return `Bull ${slope}`;
if (short > 0 && long > 0)
return `Bear ${slope}`;
return `혼합 움직임 / ${slope}`;
}
export function analyze(p) {
if (p.version !== PACKET_VERSION || p.engine !== ENGINE || typeof p.label !== 'string' || !p.label.trim() || p.label.length > 120 || /[\u0000-\u001f\u007f]/.test(p.label))
throw Error('Unsupported or invalid evidence packet');
date(p.asOf);
date(p.base);
if (p.base >= p.asOf)
throw Error('Base must precede as-of');
if (days(p.asOf, p.base) > 366)
throw Error('Comparison window must be 366 days or less');
if (!Number.isInteger(p.maxLag) || p.maxLag < 0 || p.maxLag > 14)
throw Error('maxLag must be 0..14 calendar days');
const rows = normalize(p.observations);
if (rows.some(r => r.date > p.asOf))
throw Error('Future observation after as-of');
if (!Array.isArray(p.receipts) || p.receipts.length !== TENORS.length || p.receipts.some(r => !r || typeof r !== 'object') || new Set(p.receipts.map(r => r.tenor)).size !== TENORS.length)
throw Error('Exactly one source receipt is required per tenor');
for (const r of p.receipts)
validateReceipt(r, rows);
const at = (requested) => {
const dates = [...new Set(rows.filter(r => r.date <= requested).map(r => r.date))].sort().reverse();
const actual = dates.find(d => TENORS.every(t => rows.some(r => r.date === d && r.tenor === t))) ?? null;
const lag = actual ? days(requested, actual) : null;
const reason = !actual ? '여섯 만기의 공통 관측일 없음' : lag > p.maxLag ? `공통 관측일이 ${lag}일 전: 허용 ${p.maxLag}일 초과` : null;
return { requested, actual, lag, values: reason ? null : TENORS.map(t => rows.find(r => r.date === actual && r.tenor === t).value), reason };
};
const current = at(p.asOf), previous = at(p.base);
const comparable = !!current.values && !!previous.values && current.actual !== previous.actual;
const changes = comparable ? current.values.map((v, i) => bp(v, previous.values[i])) : null;
const spreads = [['5년−3년', 5, 3], ['10년−5년', 10, 5], ['10년−3년', 10, 3], ['30년−10년', 30, 10]].map(([name, longTenor, shortTenor]) => {
const l = TENORS.indexOf(longTenor), s = TENORS.indexOf(shortTenor);
return {
name: name, current: current.values ? bp(current.values[l], current.values[s]) : null,
previous: previous.values ? bp(previous.values[l], previous.values[s]) : null,
change: changes ? Math.round((changes[l] - changes[s]) * 1e8) / 1e8 : null
};
});
const notes = [current.reason, previous.reason].filter((v) => !!v);
for (const r of p.receipts) {
if (r.status === 'cache')
notes.push(`${r.tenor}년: 검증된 로컬 캐시 사용`);
if (r.status === 'mixed')
notes.push(`${r.tenor}년: 여러 조회 구간의 실시간·캐시·실패 상태가 혼재`);
if (r.status === 'failed')
notes.push(`${r.tenor}년: 수집 실패${r.error ? ` (${r.error})` : ''}`);
const failed = r.windows.filter(w => w.status === 'failed');
if (failed.length && r.status !== 'failed')
notes.push(`${r.tenor}년: ${failed.length}개 조회 구간 실패, 확보된 공통 관측값만 사용`);
const cacheWriteFailed = r.windows.filter(w => w.status === 'live' && w.error?.startsWith('cache write failed'));
if (cacheWriteFailed.length)
notes.push(`${r.tenor}년: 실시간 원자료는 검증했으나 ${cacheWriteFailed.length}개 로컬 캐시 저장 실패`);
}
if (current.actual && current.actual === previous.actual)
notes.push('두 기준일이 같은 관측일로 정렬되어 기간 변화 계산을 차단했습니다.');
if (current.values && current.lag)
notes.push(`현재 기준일은 ${current.actual} 공통 관측값으로 정렬했습니다.`);
if (previous.values && previous.lag)
notes.push(`비교 기준일은 ${previous.actual} 공통 관측값으로 정렬했습니다.`);
const shortIndex = TENORS.indexOf(3), longIndex = TENORS.indexOf(10);
return { status: changes ? 'READY' : current.values || previous.values ? 'INCOMPLETE' : 'BLOCKED', current, previous, changes, spreads, movement: changes ? classify(changes[shortIndex], changes[longIndex]) : '판정 불가', notes };
}
export function csvRead(text) {
if (text.length > 10_000_000)
throw Error('CSV exceeds 10 MB character limit');
const withoutBom = text.replace(/^\uFEFF/, ''), clean = withoutBom.replace(/(?:\r?\n)+$/, '');
if (!clean.trim())
throw Error('CSV is empty');
const lines = clean.split(/\r?\n/);
if (lines.shift() !== 'date,tenor,yield_pct')
throw Error('CSV header must be date,tenor,yield_pct');
return normalize(lines.map((line, i) => {
const f = line.split(',');
if (f.length !== 3 || f.some(v => v.trim() !== v || v === '') || !/^\d+$/.test(f[1]) || !/^-?\d+(\.\d+)?$/.test(f[2]))
throw Error(`Invalid CSV line ${i + 2}`);
return { date: f[0], tenor: Number(f[1]), value: Number(f[2]) };
}));
}
scripts/dual.mjs
import { randomUUID, createHash } from 'node:crypto';
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { analyze, bp, TENORS } from './core.mjs';
import { analyzeUst, UST_SOURCE, UST_SOURCE_PAGE } from './ust.mjs';
import { analyzeSchedule } from './schedule.mjs';
export const DUAL_ENGINE = 'ktb-ust-rate-analysis/1';
const hash = (text) => createHash('sha256').update(text).digest('hex');
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
const mdLine = (s) => s.replace(/[\r\n\t]+/g, ' ').replace(/[\\`*_\[\]<>|]/g, '\\$&');
const crossLabel = (a) => a.cross.current.gapBp === null ? '계산 불가' : `${a.cross.current.gapBp.toFixed(2)}bp`;
export function analyzeDual(packet) {
if (packet.version !== 1 || packet.engine !== DUAL_ENGINE || !packet.ktb || !packet.ust)
throw Error('Unsupported dual-market evidence packet');
if (packet.ktb.asOf !== packet.ust.asOf || packet.ktb.base !== packet.ust.base || packet.ktb.maxLag !== packet.ust.maxLag)
throw Error('KTB/UST request date or lag mismatch');
const ktb = analyze(packet.ktb), ust = analyzeUst(packet.ust), schedule = analyzeSchedule(packet.schedule, packet.ktb.asOf);
const common = (requested) => {
const krDates = new Map();
for (const row of packet.ktb.observations) {
if (!krDates.has(row.date))
krDates.set(row.date, new Set());
krDates.get(row.date).add(row.tenor);
}
const kr10 = new Map(packet.ktb.observations.filter(r => r.tenor === 10 && TENORS.every(t => krDates.get(r.date)?.has(t))).map(r => [r.date, r.value]));
const us10 = new Map(ust.rows.map((r) => [r.date, r.yields[10]]));
const actual = [...kr10.keys()].filter(d => d <= requested && us10.has(d) && (new Date(`${requested}T00:00:00Z`).getTime() - new Date(`${d}T00:00:00Z`).getTime()) / 86_400_000 <= packet.ktb.maxLag).sort().at(-1) ?? null;
const ust10Y = actual ? us10.get(actual) : null, ktb10Y = actual ? kr10.get(actual) : null;
return { requested, actual, ust10Y, ktb10Y, gapBp: ust10Y !== null && ktb10Y !== null ? bp(ust10Y, ktb10Y) : null };
};
const current = common(packet.ktb.asOf), previous = common(packet.ktb.base);
const changeBp = current.gapBp !== null && previous.gapBp !== null && current.actual !== previous.actual ? Math.round((current.gapBp - previous.gapBp) * 1e8) / 1e8 : null;
const status = ktb.status === 'READY' && ust.status === 'READY' ? 'READY' : ktb.status === 'BLOCKED' && ust.status === 'BLOCKED' ? 'BLOCKED' : 'INCOMPLETE';
const notes = [
'UST는 미국 재무부의 명목 par/constant-maturity 수익률이고 KTB는 한국은행 ECOS 국고채 시장금리입니다. 같은 날짜라도 종가 시점이 달라 단순 금리차는 동시 가격, 환헤지 비용 또는 상대가치를 뜻하지 않습니다.',
current.actual ? `한·미 10년 금리차의 현재 공통 관측일: ${current.actual}` : '현재 기준일의 한·미 공통 관측일이 없어 금리차를 표시하지 않았습니다.',
previous.actual ? `한·미 10년 금리차의 비교 공통 관측일: ${previous.actual}` : '비교 기준일의 한·미 공통 관측일이 없어 금리차 변화를 표시하지 않았습니다.',
...ktb.notes.map(s => `KTB: ${s}`), ...ust.notes.map(s => `UST: ${s}`)
];
return { status, ktb, ust, cross: { current, previous, changeBp }, schedule, notes };
}
function marketTable(name, base, current, changes) {
const rows = TENORS.map(t => `<tr><td>${t}Y</td><td>${base?.[t]?.toFixed(3) ?? '—'}</td><td>${current?.[t]?.toFixed(3) ?? '—'}</td><td>${changes?.[t] === undefined ? '—' : `${changes[t] >= 0 ? '+' : ''}${changes[t].toFixed(2)}`}</td></tr>`).join('');
return `<section class="panel"><h2>${esc(name)}</h2><table><thead><tr><th>만기</th><th>비교일 연%</th><th>현재일 연%</th><th>변화 bp</th></tr></thead><tbody>${rows}</tbody></table></section>`;
}
export function dualSummary(packet, analysis = analyzeDual(packet)) {
const market = (name, a) => ({ market: name, status: a.status, observed: { base: a.previous.actual, asOf: a.current.actual }, rates: TENORS.map((tenor, i) => ({ tenor: `${tenor}Y`, base: name === 'KTB' ? a.previous.values?.[i] ?? null : a.previous.yields?.[tenor] ?? null, asOf: name === 'KTB' ? a.current.values?.[i] ?? null : a.current.yields?.[tenor] ?? null, changeBp: name === 'KTB' ? a.changes?.[i] ?? null : a.changes?.[tenor] ?? null })), movement: a.movement, notes: a.notes });
return { result: analysis.status, requested: { base: packet.ktb.base, asOf: packet.ktb.asOf }, markets: [market('KTB', analysis.ktb), market('UST', analysis.ust)], cross: analysis.cross, schedule: analysis.schedule, notes: analysis.notes, sources: [{ market: 'KTB', url: 'https://ecos.bok.or.kr/' }, { market: 'UST', name: UST_SOURCE, url: UST_SOURCE_PAGE, feedUrls: packet.ust.sources.map(s => s.url) }] };
}
const rateText = (value) => value === null || value === undefined ? '—' : `${value.toFixed(3)}%`;
const changeText = (value) => value === null || value === undefined ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}bp`;
const observedText = (requested, actual, lag) => actual ? `${actual}${lag ? ` (요청일 ${requested}보다 ${lag}일 전)` : ''}` : '확인 불가';
/** Stable, source-backed Markdown for direct conversational replies. */
export function dualChat(packet, a = analyzeDual(packet)) {
const ktb3 = a.ktb.changes?.[TENORS.indexOf(3)] ?? null;
const ktb10 = a.ktb.changes?.[TENORS.indexOf(10)] ?? null;
const ust3 = a.ust.changes?.[3] ?? null;
const ust10 = a.ust.changes?.[10] ?? null;
const headline = (name, short, longer, movement) => short === null || longer === null ? `${name}는 비교 가능한 두 관측일이 부족합니다.` :
`${name} 3년 ${changeText(short)}, 10년 ${changeText(longer)} (${movement}).`;
const table = TENORS.map((tenor, i) => `| ${tenor}년 | ${rateText(a.ktb.current.values?.[i])} | ${changeText(a.ktb.changes?.[i])} | ${rateText(a.ust.current.yields?.[tenor])} | ${changeText(a.ust.changes?.[tenor])} |`);
const kSpread = a.ktb.spreads.find(s => s.name === '10년−3년');
const uSpread = a.ust.spreads['10Y-3Y'];
const gap = a.cross.current.gapBp === null ? '공통 관측일이 없어 계산 불가' :
`${changeText(a.cross.current.gapBp)} (공통 관측일 ${a.cross.current.actual})`;
const gapChange = a.cross.changeBp === null ? '계산 불가' : changeText(a.cross.changeBp);
const warnings = [...a.ktb.notes.map(n => `KTB: ${n}`), ...a.ust.notes.map(n => `UST: ${n}`)];
const statusNote = a.status === 'READY' ? '분석 완료' : a.status === 'INCOMPLETE' ? '일부 자료 부족, 확인된 값만 표시' : '자료 확인 불가';
return [
'## 한·미 국채 금리 분석',
`**상태:** ${statusNote} (${a.status})`,
`**요청 기간:** ${packet.ktb.base} → ${packet.ktb.asOf}`,
`**실제 관측:** KTB ${observedText(packet.ktb.base, a.ktb.previous.actual, a.ktb.previous.lag)} → ${observedText(packet.ktb.asOf, a.ktb.current.actual, a.ktb.current.lag)} · UST ${observedText(packet.ust.base, a.ust.previous.actual, a.ust.previous.lag)} → ${observedText(packet.ust.asOf, a.ust.current.actual, a.ust.current.lag)}`,
'',
'**핵심 변화**',
headline('KTB', ktb3, ktb10, a.ktb.movement),
headline('UST', ust3, ust10, a.ust.movement),
'',
'| 만기 | KTB 현재 | 변화 | UST 현재 | 변화 |',
'| --- | ---: | ---: | ---: | ---: |',
...table,
'',
'**커브·한미 차이**',
`- 10년−3년: KTB ${changeText(kSpread?.current)} (기간 변화 ${changeText(kSpread?.change)}), UST ${changeText(uSpread)}.`,
`- UST 10년−KTB 10년: ${gap} · 기간 변화 ${gapChange}.`,
'',
'**주요 일정**',
...(a.schedule.events.length ? a.schedule.events.map(e => `- ${e.date} (${e.market}) ${e.label} · [공식 일정](${e.sourceUrl})`) : [a.schedule.status === 'UNVERIFIED' ? '- 공식 일정 미확인.' : '- 조회 기간에 확인된 일정 없음.']),
`- 일정 확인: ${a.schedule.checkedAt ?? '미확인'} · ${a.schedule.status}. ${a.schedule.note}`,
'',
'**확인 사항**',
...(warnings.length ? warnings.map(w => `- ${mdLine(w)}`) : ['- 날짜·수집 관련 경고 없음.']),
'- UST는 미국 재무부의 명목 par/constant-maturity 수익률, KTB는 ECOS 국고채 시장금리입니다. 같은 날짜라도 종가 시점이 달라 단순 금리차는 동시 가격이나 환헤지 수익을 뜻하지 않습니다.',
'',
`**출처:** [한국은행 ECOS](https://ecos.bok.or.kr/) · [미국 재무부 일별 국채 수익률](${UST_SOURCE_PAGE})`
].join('\n');
}
function dualCsv(a) {
const cells = ['market,date_role,observation_date,tenor,yield_pct,change_bp'];
for (const [market, detail] of [['KTB', a.ktb], ['UST', a.ust]]) {
for (const [role, point] of [['base', detail.previous], ['as_of', detail.current]]) {
for (const [i, tenor] of TENORS.entries()) {
const yieldValue = market === 'KTB' ? point.values?.[i] : point.yields?.[tenor];
const change = role === 'as_of' ? market === 'KTB' ? a.ktb.changes?.[i] : a.ust.changes?.[tenor] : null;
cells.push([market, role, point.actual ?? '', tenor, yieldValue ?? '', change ?? ''].join(','));
}
}
}
return cells.join('\n') + '\n';
}
function dualHtml(packet, a) {
const kBase = a.ktb.previous.values ? Object.fromEntries(TENORS.map((t, i) => [t, a.ktb.previous.values[i]])) : null;
const kCurrent = a.ktb.current.values ? Object.fromEntries(TENORS.map((t, i) => [t, a.ktb.current.values[i]])) : null;
const kChanges = a.ktb.changes ? Object.fromEntries(TENORS.map((t, i) => [t, a.ktb.changes[i]])) : null;
const sourceRows = [...packet.ktb.receipts.map(r => `<tr><td>KTB ${r.tenor}Y</td><td>${esc(r.sourceId)} · ${esc(r.statCode)}/${esc(r.itemCode)}</td><td>${esc(r.retrievedAt)}</td><td><a href="${esc(r.sourceUrl || '#')}">원천</a></td></tr>`), ...packet.ust.sources.map(s => `<tr><td>UST ${esc(s.month)}</td><td><a href="${esc(UST_SOURCE_PAGE)}">${esc(UST_SOURCE)}</a> · ${esc(s.status)}</td><td>${esc(s.retrievedAt)}</td><td><a href="${esc(s.url)}">원천</a></td></tr>`)].join('');
const cross = a.cross.current.gapBp === null ? '공통 관측일 없음' : `${a.cross.current.gapBp.toFixed(2)}bp (${a.cross.current.actual})`;
const scheduleRows = a.schedule.events.length ? a.schedule.events.map(e => `<li><strong>${esc(e.date)} (${esc(e.market)})</strong> ${esc(e.label)} · <a href="${esc(e.sourceUrl)}">공식 일정</a></li>`).join('') : a.schedule.status === 'UNVERIFIED' ? '<li>공식 일정 미확인.</li>' : '<li>조회 기간에 확인된 일정 없음.</li>';
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>한·미 국채 금리 분석</title><style>body{font:15px/1.55 system-ui,sans-serif;background:#f4f6fa;color:#172033;margin:0}.wrap{max-width:1100px;margin:auto;padding:32px 16px}h1{font-size:30px;margin:0 0 8px}.meta{color:#586477}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.panel{background:#fff;border:1px solid #dce3ec;border-radius:12px;padding:18px;margin-top:18px;overflow:auto}table{border-collapse:collapse;width:100%;min-width:400px;font-variant-numeric:tabular-nums}th,td{padding:9px 8px;border-bottom:1px solid #e8edf3;text-align:right}th:first-child,td:first-child{text-align:left}th{font-size:12px;color:#536276}p{margin:8px 0}.notes li{margin:5px 0}a{color:#235cba}@media(max-width:760px){.grid{grid-template-columns:1fr}}</style></head><body><main class="wrap"><h1>한·미 국채 금리 분석</h1><div class="meta">요청 ${esc(packet.ktb.base)} → ${esc(packet.ktb.asOf)} · 상태 ${esc(a.status)}</div><p>KTB 관측 ${esc(a.ktb.previous.actual ?? '없음')} → ${esc(a.ktb.current.actual ?? '없음')} · UST 관측 ${esc(a.ust.previous.actual ?? '없음')} → ${esc(a.ust.current.actual ?? '없음')}</p><div class="grid">${marketTable('KTB · 한국 국고채', kBase, kCurrent, kChanges)}${marketTable('UST · 미국 국채 par 수익률', a.ust.previous.yields, a.ust.current.yields, a.ust.changes)}</div><section class="panel"><h2>커브와 한·미 차이</h2><p>KTB: ${esc(a.ktb.movement)} · UST: ${esc(a.ust.movement)}</p><p>UST 10Y − KTB 10Y: ${esc(cross)}${a.cross.changeBp === null ? '' : ` · 기간 변화 ${a.cross.changeBp >= 0 ? '+' : ''}${a.cross.changeBp.toFixed(2)}bp`}</p></section><section class="panel"><h2>주요 일정</h2><ul class="notes">${scheduleRows}</ul><p class="meta">일정 확인 ${esc(a.schedule.checkedAt ?? '미확인')} · ${esc(a.schedule.status)}. ${esc(a.schedule.note)}</p></section><section class="panel"><h2>검증 메모</h2><ul class="notes">${a.notes.map(n => `<li>${esc(n)}</li>`).join('')}</ul></section><section class="panel"><h2>원천·수집 기록</h2><table><thead><tr><th>구분</th><th>제공기관·항목</th><th>수집 UTC</th><th>링크</th></tr></thead><tbody>${sourceRows}</tbody></table></section></main></body></html>`;
}
const FILES = ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson'];
function scheduleBrief(a) {
return `## 주요 일정\n\n${a.schedule.events.length ? a.schedule.events.map(e => `- ${e.date} (${e.market}) ${e.label} · ${e.sourceUrl}`).join('\n') : a.schedule.status === 'UNVERIFIED' ? '- 공식 일정 미확인.' : '- 조회 기간에 확인된 일정 없음.'}\n\n일정 확인: ${a.schedule.checkedAt ?? '미확인'} · ${a.schedule.status}. ${a.schedule.note}\n`;
}
export async function writeDualArtifacts(out, packet) {
const a = analyzeDual(packet), parent = dirname(out), stage = join(parent, `.${basename(out)}.stage-${process.pid}-${randomUUID()}`);
await mkdir(parent, { recursive: true });
try {
await stat(out);
throw Error(`Output path already exists: ${out}`);
}
catch (e) {
if (e.code !== 'ENOENT')
throw e;
}
try {
await mkdir(stage);
const summary = dualSummary(packet, a);
const files = {
'brief.md': `# 한·미 국채 금리 분석\n\n요청 ${packet.ktb.base} → ${packet.ktb.asOf} · ${a.status}\n\nKTB 관측 ${a.ktb.previous.actual ?? '없음'} → ${a.ktb.current.actual ?? '없음'} · ${a.ktb.movement}\n\nUST 관측 ${a.ust.previous.actual ?? '없음'} → ${a.ust.current.actual ?? '없음'} · ${a.ust.movement}\n\nUST 10Y − KTB 10Y: ${crossLabel(a)} (공통 관측일 ${a.cross.current.actual ?? '없음'})\n\n${scheduleBrief(a)}\n${a.notes.map(n => `- ${mdLine(n)}`).join('\n')}\n\n원천: https://ecos.bok.or.kr/ · ${UST_SOURCE_PAGE}\n`,
'market-table.csv': dualCsv(a),
'report.html': dualHtml(packet, a),
'evidence.mjson': JSON.stringify(packet, null, 2) + '\n',
'status.mjson': JSON.stringify({ result: a.status, summary }, null, 2) + '\n'
};
const manifest = { version: 1, engine: DUAL_ENGINE, status: a.status, evidenceSha256: hash(files['evidence.mjson']), files: Object.fromEntries(FILES.map(name => [name, hash(files[name])])) };
for (const name of FILES)
await writeFile(join(stage, name), files[name]);
await writeFile(join(stage, 'manifest.mjson'), JSON.stringify(manifest, null, 2) + '\n');
await validateDualArtifacts(stage);
await rename(stage, out);
}
catch (e) {
await rm(stage, { recursive: true, force: true });
throw e;
}
}
export async function validateDualArtifacts(dir) {
const manifest = JSON.parse(await readFile(join(dir, 'manifest.mjson'), 'utf8'));
if (manifest.version !== 1 || manifest.engine !== DUAL_ENGINE || !manifest.files || JSON.stringify(Object.keys(manifest.files).sort()) !== JSON.stringify([...FILES].sort()) || JSON.stringify((await readdir(dir)).sort()) !== JSON.stringify([...FILES, 'manifest.mjson'].sort()))
throw Error('Dual report manifest contract mismatch');
const evidence = await readFile(join(dir, 'evidence.mjson'), 'utf8');
if (manifest.evidenceSha256 !== hash(evidence))
throw Error('Dual evidence checksum mismatch');
const packet = JSON.parse(evidence), a = analyzeDual(packet);
if (manifest.status !== a.status)
throw Error('Dual status mismatch');
const expected = {
'brief.md': `# 한·미 국채 금리 분석\n\n요청 ${packet.ktb.base} → ${packet.ktb.asOf} · ${a.status}\n\nKTB 관측 ${a.ktb.previous.actual ?? '없음'} → ${a.ktb.current.actual ?? '없음'} · ${a.ktb.movement}\n\nUST 관측 ${a.ust.previous.actual ?? '없음'} → ${a.ust.current.actual ?? '없음'} · ${a.ust.movement}\n\nUST 10Y − KTB 10Y: ${crossLabel(a)} (공통 관측일 ${a.cross.current.actual ?? '없음'})\n\n${scheduleBrief(a)}\n${a.notes.map(n => `- ${mdLine(n)}`).join('\n')}\n\n원천: https://ecos.bok.or.kr/ · ${UST_SOURCE_PAGE}\n`,
'market-table.csv': dualCsv(a), 'report.html': dualHtml(packet, a), 'evidence.mjson': evidence, 'status.mjson': JSON.stringify({ result: a.status, summary: dualSummary(packet, a) }, null, 2) + '\n'
};
for (const name of FILES) {
const content = await readFile(join(dir, name), 'utf8');
if (hash(content) !== manifest.files[name] || content !== expected[name])
throw Error(`${name}: hash or deterministic output mismatch`);
}
if (a.status === 'READY' && (!expected['report.html'].includes('원천·수집 기록') || !expected['report.html'].includes(UST_SOURCE_PAGE)))
throw Error('Dual READY report lacks source provenance');
}
scripts/harness.mjs
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { collect, collectWindows, parse, pointWindows, rangeWindows, request } from './collect.mjs';
import { ENGINE, PACKET_VERSION, SOURCE_URL, TENOR_META, TENORS, analyze, classify, csvRead, date, days, kstToday, sha256, shift } from './core.mjs';
import { collectPolicyRate, commonSnapshots, dashboard, eventQueryWindows, monthComparisonDates, onOrAfter, onOrBefore, previousMonthEnd, previousQuarterEnd, previousYearEnd, trend } from './modes.mjs';
import { analyzeNews, newsRead } from './news.mjs';
import { validateArtifacts, writeArtifacts } from './render.mjs';
import { analyzeUst, collectUst, parseUstXml, UST_ENGINE, ustMonthUrl, ustMonths } from './ust.mjs';
import { analyzeDual, DUAL_ENGINE, dualChat, validateDualArtifacts, writeDualArtifacts } from './dual.mjs';
import { analyzeSchedule, readSchedule } from './schedule.mjs';
const cliPath = fileURLToPath(new URL('./cli.mjs', import.meta.url));
let checks = 0;
const check = (fn) => { fn(); checks++; }, run = promisify(execFile);
const baseValues = { 2: 3, 3: 3.1, 5: 3.2, 10: 3.3, 20: 3.4, 30: 3.5 };
const currentValues = { 2: 2.95, 3: 3, 5: 3.15, 10: 3.25, 20: 3.35, 30: 3.45 };
const rowsAt = (day, values) => TENORS.map(tenor => ({ date: day, tenor, value: values[tenor] }));
const obs = [...rowsAt('2026-09-11', baseValues), ...rowsAt('2026-09-18', currentValues)];
const csvFor = (rows) => ['date,tenor,yield_pct', ...rows.map(r => `${r.date},${r.tenor},${r.value}`)].join('\n');
const receiptsFor = (rows) => { const raw = csvFor(rows); return TENORS.map(tenor => { const own = rows.filter(r => r.tenor === tenor), meta = TENOR_META[tenor]; return { tenor, sourceId: '사용자 제공 CSV: fixture.csv', sourceUrl: '', statCode: 'USER_CSV', itemCode: meta.itemCode, itemName: meta.itemName, unit: '연%', cycle: 'D', retrievedAt: '2026-09-18T09:00:00.000Z', status: 'input', raw: [raw], rawSha256: [sha256(raw)], count: own.length, first: own[0]?.date ?? null, last: own.at(-1)?.date ?? null, windows: [] }; }); };
const packetFor = (rows = obs) => ({ version: PACKET_VERSION, engine: ENGINE, asOf: '2026-09-18', base: '2026-09-11', maxLag: 7, observations: rows, receipts: receiptsFor(rows), label: '테스트' });
const packet = packetFor(), analysis = analyze(packet);
const newsInput = { version: 1, observationDate: '2026-09-18', retrievedAt: '2026-09-18T12:00:00.000Z', articles: [{ publisher: '연합인포맥스', title: '[채권-마감] 금리 하락…외국인 선물 매수', url: 'https://news.einfomax.co.kr/news/articleView.html?idxno=4359540', publishedAt: '2026-09-18T09:00:00.000Z', sourceType: 'market-close', marketDirection: 'lower', relation: 'explicit', factor: '외국인의 국채선물 순매수', evidenceSummary: '연합인포맥스는 외국인 국채선물 순매수를 금리 하락의 시장 배경으로 전했다.' }] }, newsEvidence = newsRead(JSON.stringify(newsInput)), newsAnalysis = analyzeNews(newsEvidence, analysis);
check(() => { assert.equal(analysis.status, 'READY'); assert.deepEqual(analysis.changes, [-5, -10, -5, -5, -5, -5]); assert.equal(analysis.spreads.find(s => s.name === '10년−3년')?.change, 5); assert.equal(analysis.movement, 'Bull 스티프닝'); });
check(() => { assert.equal(newsAnalysis.status, 'SOURCED'); assert.equal(newsAnalysis.confidence, 'HIGH'); assert.equal(newsAnalysis.curveDirection, 'lower'); assert.equal(newsAnalysis.eligible.length, 1); assert.match(newsAnalysis.commentary[0], /외국인의 국채선물 순매수/); });
check(() => { const mismatch = structuredClone(newsInput); mismatch.articles[0].marketDirection = 'higher'; const result = analyzeNews(newsRead(JSON.stringify(mismatch)), analysis); assert.equal(result.status, 'UNATTRIBUTED'); assert.equal(result.excluded.length, 1); assert.match(result.notes[0], /방향과 불일치/); });
check(() => { const bad = structuredClone(newsInput); bad.articles[0].url = 'https://example.com/article'; assert.throws(() => newsRead(JSON.stringify(bad)), /Unapproved/); });
check(() => { const bad = { ...structuredClone(newsInput), unexpected: true }; assert.throws(() => newsRead(JSON.stringify(bad)), /Unknown news evidence field/); });
check(() => { const bad = structuredClone(newsInput); bad.articles[0].unexpected = true; assert.throws(() => newsRead(JSON.stringify(bad)), /Unknown news article field/); });
check(() => { const bad = structuredClone(newsInput); bad.articles[0].publisher = '임의매체'; assert.throws(() => newsRead(JSON.stringify(bad)), /Publisher\/host mismatch/); });
check(() => { const bad = structuredClone(newsInput); bad.retrievedAt = '2026-09-18T08:59:59.000Z'; assert.throws(() => newsRead(JSON.stringify(bad)), /date alignment mismatch/); });
check(() => { const bad = structuredClone(newsInput); bad.articles.push(structuredClone(bad.articles[0])); assert.throws(() => newsRead(JSON.stringify(bad)), /Duplicate/); });
check(() => { const bad = structuredClone(newsEvidence); bad.articles[0].sourceFingerprint = '0'.repeat(64); assert.throws(() => newsRead(JSON.stringify(bad)), /fingerprint/); });
check(() => { const contextual = structuredClone(newsInput); contextual.articles[0].publisher = '한국은행'; contextual.articles[0].url = 'https://www.bok.or.kr/portal/bbs/B0000217/view.do?nttId=10000000'; contextual.articles[0].sourceType = 'official-release'; contextual.articles[0].marketDirection = 'context'; contextual.articles[0].relation = 'contextual'; const result = analyzeNews(newsRead(JSON.stringify(contextual)), analysis); assert.equal(result.status, 'CONTEXT_ONLY'); assert.equal(result.context.length, 1); assert.equal(result.eligible.length, 0); });
check(() => { const wrongDate = structuredClone(newsInput); wrongDate.observationDate = '2026-09-17'; wrongDate.articles[0].publishedAt = '2026-09-17T09:00:00.000Z'; assert.throws(() => analyzeNews(newsRead(JSON.stringify(wrongDate)), analysis), /current common observation/); });
check(() => { assert.equal(analysis.current.values?.length, 6); assert.deepEqual(TENORS, [2, 3, 5, 10, 20, 30]); });
check(() => { assert.equal(classify(5, 5), '평행 상승'); assert.equal(classify(-5, -5), '평행 하락'); assert.equal(classify(-2, 3), '혼합 움직임 / 스티프닝'); });
check(() => assert.throws(() => date('2025-02-29'), /Invalid date/));
check(() => assert.equal(date('2024-02-29'), '2024-02-29'));
check(() => assert.equal(kstToday(new Date('2026-09-20T15:30:00Z')), '2026-09-21'));
check(() => assert.throws(() => csvRead('date,tenor,yield_pct\n2026-01-01,3,3.0\n2026-01-01,3,3.1'), /Conflicting/));
check(() => assert.throws(() => csvRead('date,tenor,yield_pct\n2026-01-01,3,NaN'), /Invalid CSV/));
check(() => { assert.throws(() => csvRead('date,tenor,yield_pct\n2026-01-01,3,3.0 '), /Invalid CSV/); assert.throws(() => csvRead(' date,tenor,yield_pct\n2026-01-01,3,3.0'), /header/); });
check(() => assert.throws(() => analyze({ ...packet, base: '2025-01-01' }), /366/));
check(() => assert.throws(() => analyze({ ...packet, maxLag: 15 }), /0..14/));
check(() => assert.throws(() => analyze({ ...packet, label: 'bad\nheading' }), /invalid evidence/));
check(() => { const x = analyze({ ...packet, asOf: '2026-09-26' }); assert.equal(x.status, 'INCOMPLETE'); assert.match(x.notes[0], /허용 7일 초과/); });
check(() => { const x = analyze({ ...packet, asOf: '2026-09-25' }); assert.equal(x.status, 'READY'); });
check(() => { const rows = obs.filter(r => r.date <= '2026-09-12'), x = analyze({ ...packet, asOf: '2026-09-12', observations: rows, receipts: receiptsFor(rows) }); assert.equal(x.status, 'INCOMPLETE'); assert.match(x.notes.join(' '), /같은 관측일/); });
check(() => { const bad = structuredClone(packet); bad.receipts[0].unit = 'bp'; assert.throws(() => analyze(bad), /metadata mismatch/); });
check(() => { const bad = structuredClone(packet); bad.receipts[0].rawSha256[0] = '0'.repeat(64); assert.throws(() => analyze(bad), /raw source hash/); });
check(() => { const bad = structuredClone(packet); bad.receipts[0].count++; assert.throws(() => analyze(bad), /count\/range/); });
check(() => { const bad = structuredClone(packet); bad.observations[0].value += .01; assert.throws(() => analyze(bad), /do not match source raw data/); });
check(() => { const bad = structuredClone(packet); const raw = bad.receipts[0].raw[0].replace('2026-09-11,2,3', '2026-09-11,2,3.01'); bad.receipts[0].raw = [raw]; bad.receipts[0].rawSha256 = [sha256(raw)]; assert.throws(() => analyze(bad), /do not match source raw data/); });
check(() => { const bad = structuredClone(packet); bad.receipts[0].retrievedAt = '2026-09-18'; assert.throws(() => analyze(bad), /Invalid source receipt/); });
check(() => { const w = rangeWindows('2026-01-01', '2026-01-25'); assert.equal(w.length, 3); assert.deepEqual(w.at(-1), { from: '2026-01-21', to: '2026-01-25' }); });
check(() => assert.equal(pointWindows(['2026-09-18', '2026-09-18'], 7).length, 1));
check(() => { const w = pointWindows(['2026-09-18'], 14); assert.equal(w.length, 2); assert.ok(w.every(x => (Date.parse(x.to) - Date.parse(x.from)) / 86400000 + 1 <= 10)); });
check(() => { for (let length = 1; length <= 100; length++) {
const end = shift('2023-12-15', length - 1), w = rangeWindows('2023-12-15', end);
assert.equal(w[0].from, '2023-12-15');
assert.equal(w.at(-1)?.to, end);
assert.equal(w.reduce((sum, x) => sum + days(x.to, x.from) + 1, 0), length);
assert.ok(w.every(x => days(x.to, x.from) + 1 <= 10));
} });
check(() => { for (let lag = 0; lag <= 14; lag++) {
const w = pointWindows(['2024-03-01'], lag);
assert.equal(w[0].from, shift('2024-03-01', -lag));
assert.equal(w.at(-1)?.to, '2024-03-01');
} });
check(() => { assert.equal(previousMonthEnd('2026-01-15'), '2025-12-31'); assert.equal(previousQuarterEnd('2026-04-01'), '2026-03-31'); assert.equal(previousYearEnd('2026-01-01'), '2025-12-31'); });
check(() => { assert.deepEqual(monthComparisonDates('2026-08', '2026-09', '2026-09-22'), { base: '2026-08-31', asOf: '2026-09-22' }); assert.deepEqual(monthComparisonDates('2026-07', '2026-08', '2026-09-22'), { base: '2026-07-31', asOf: '2026-08-31' }); assert.throws(() => monthComparisonDates('2026-09', '2026-10', '2026-09-22'), /future/); assert.throws(() => monthComparisonDates('2026-09', '2026-09', '2026-09-22'), /earlier/); });
check(() => { const w = eventQueryWindows('2026-09-01', '2026-09-30', 14); assert.equal(w[0].from, '2026-08-17'); assert.equal(w.at(-1)?.to, '2026-09-15'); assert.ok(w.every(x => days(x.to, x.from) + 1 <= 10)); });
const modeObs = [...rowsAt('2026-08-31', Object.fromEntries(TENORS.map(t => [t, baseValues[t] - .1]))), ...rowsAt('2026-09-10', Object.fromEntries(TENORS.map(t => [t, baseValues[t] + .02]))), ...obs];
check(() => assert.equal(commonSnapshots(modeObs).length, 4));
check(() => assert.equal(onOrBefore(modeObs, '2026-09-17', 7).actual, '2026-09-11'));
check(() => assert.equal(onOrAfter(modeObs, '2026-09-12', 7).actual, '2026-09-18'));
check(() => assert.throws(() => onOrBefore(modeObs, '2026-09-18', -1), /0..14/));
const policy = { date: '2026-09-18', value: 3, unit: '연%', status: 'live', retrievedAt: '2026-09-18T09:00:00Z', sourceUrl: SOURCE_URL, statCode: '722Y001', itemCode: '0101000', rawSha256: 'a'.repeat(64) };
const dash = dashboard('2026-09-18', modeObs, 7, policy);
check(() => { assert.equal(dash.result, 'READY'); assert.equal(dash.references.previousSession.actual, '2026-09-11'); assert.equal(dash.references.previousMonthEnd.actual, '2026-08-31'); assert.equal(dash.policyGap3YBp, 0); assert.equal(dash.spreads['30Y-10Y'], 20); });
const trendRows = ['2026-09-14', '2026-09-15', '2026-09-16', '2026-09-17', '2026-09-18'].flatMap((d, i) => rowsAt(d, Object.fromEntries(TENORS.map(t => [t, baseValues[t] + i * .01])))), trendResult = trend(trendRows, 5);
check(() => { assert.equal(trendResult.result, 'READY'); assert.equal(trendResult.actualSessions, 5); assert.equal(trendResult.stats[0].netChangeBp, 4); });
check(() => assert.equal(trend(trendRows.slice(0, -TENORS.length), 5).result, 'INCOMPLETE'));
check(() => { assert.equal(trend(rowsAt('2026-09-18', baseValues), 5).result, 'INCOMPLETE'); assert.equal(trend([], 5).result, 'BLOCKED'); });
const ecos = (tenor, time = '20260918', value = '3.15') => JSON.stringify({ StatisticSearch: { list_total_count: 1, row: [{ STAT_CODE: '817Y002', STAT_NAME: '1.3.2.1. 시장금리(일별)', ITEM_CODE1: TENOR_META[tenor].itemCode, ITEM_NAME1: TENOR_META[tenor].itemName, UNIT_NAME: '연%', TIME: time, DATA_VALUE: value }] } });
check(() => assert.equal(parse(ecos(30), 30, '2026-09-18', '2026-09-18')[0].value, 3.15));
check(() => { const doc = JSON.parse(ecos(20)); doc.StatisticSearch.row[0].UNIT_NAME = 'bp'; assert.throws(() => parse(JSON.stringify(doc), 20, '2026-09-18', '2026-09-18'), /metadata/); });
check(() => { const doc = JSON.parse(ecos(2)); doc.StatisticSearch.list_total_count = 2; assert.throws(() => parse(JSON.stringify(doc), 2, '2026-09-18', '2026-09-18'), /Incomplete/); });
check(() => { const doc = JSON.parse(ecos(3)); doc.StatisticSearch.row[0].DATA_VALUE = '0x10'; assert.throws(() => parse(JSON.stringify(doc), 3, '2026-09-18', '2026-09-18'), /metadata/); });
check(() => { const doc = JSON.parse(ecos(5)); doc.StatisticSearch.list_total_count = 2; doc.StatisticSearch.row.push({ ...doc.StatisticSearch.row[0] }); assert.throws(() => parse(JSON.stringify(doc), 5, '2026-09-18', '2026-09-18'), /duplicate/); });
let tries = 0;
await request('x', (async () => { tries++; return new Response(tries < 3 ? '' : '{}', { status: tries < 3 ? 503 : 200 }); }), async () => { });
check(() => assert.equal(tries, 3));
let fatalTries = 0;
await assert.rejects(() => request('x', (async () => { fatalTries++; return new Response('', { status: 404 }); }), async () => { }), /HTTP 404/);
check(() => assert.equal(fatalTries, 1));
let semanticTries = 0;
const semanticRequest = await request('x', (async () => { semanticTries++; const raw = semanticTries < 3 ? ecos(3).replace('"UNIT_NAME":"연%"', '"UNIT_NAME":"bp"') : ecos(3); return new Response(raw, { status: 200 }); }), async () => { }, raw => { parse(raw, 3, '2026-09-18', '2026-09-18'); });
check(() => { assert.equal(semanticTries, 3); assert.equal(semanticRequest.attempts, 3); });
const root = await mkdtemp(join(tmpdir(), 'curve-v3-'));
try {
const first = join(root, 'first'), second = join(root, 'second');
await writeArtifacts(first, packet);
await validateArtifacts(first);
checks++;
const replay = JSON.parse(await readFile(join(first, 'evidence.mjson'), 'utf8'));
await writeArtifacts(second, replay);
await validateArtifacts(second);
for (const f of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
assert.equal(await readFile(join(first, f), 'utf8'), await readFile(join(second, f), 'utf8'));
checks++;
const report = await readFile(join(first, 'report.html'), 'utf8'), csv = await readFile(join(first, 'market-table.csv'), 'utf8');
check(() => { assert.match(report, /30년/); assert.match(report, /10년−5년/); assert.equal(csv.trim().split(/\r?\n/).length, 11); assert.doesNotMatch(report, /NaN|undefined/); });
const newsFirst = join(root, 'news-first'), newsSecond = join(root, 'news-second'), newsPacket = { ...packet, newsEvidence };
await writeArtifacts(newsFirst, newsPacket);
await validateArtifacts(newsFirst);
const embedded = JSON.parse(await readFile(join(newsFirst, 'evidence.mjson'), 'utf8'));
await writeArtifacts(newsSecond, embedded);
await validateArtifacts(newsSecond);
for (const f of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
assert.equal(await readFile(join(newsFirst, f), 'utf8'), await readFile(join(newsSecond, f), 'utf8'));
const newsPage = await readFile(join(newsFirst, 'report.html'), 'utf8'), newsBrief = await readFile(join(newsFirst, 'brief.md'), 'utf8');
check(() => { assert.match(newsPage, /시장 배경/); assert.ok(newsPage.includes(newsEvidence.articles[0].url)); assert.match(newsPage, /class="value-label/); assert.doesNotMatch(newsPage, /원문 SHA-256|근거 SHA-256/); assert.ok(!newsPage.includes(newsEvidence.articles[0].sourceFingerprint)); assert.match(newsBrief, /SOURCED/); });
checks++;
const yields = Object.fromEntries(TENORS.map(t => [TENOR_META[t].itemCode, String(currentValues[t])])), fakeEcos = async (url) => { const value = String(url), item = Object.keys(yields).find(code => value.endsWith(`/${code}`)), current = value.includes('/20260911/20260918/'), TIME = current ? '20260918' : '20260911', tenor = TENORS.find(t => TENOR_META[t].itemCode === item); return new Response(ecos(tenor, TIME, yields[item]), { status: 200 }); };
const cache = join(root, 'cache'), clock = new Date('2026-09-18T12:00:00Z'), live = await collect('2026-09-11', '2026-09-18', 7, cache, true, fakeEcos, clock);
check(() => { assert.equal(live.observations.length, 12); assert.ok(live.receipts.every(r => r.status === 'live')); });
let transientBadRows = 0;
const semanticFetch = (async (url) => { const response = await fakeEcos(url); if (String(url).endsWith(`/${TENOR_META[3].itemCode}`) && String(url).includes('/20260911/20260918/') && ++transientBadRows < 3)
return new Response((await response.text()).replace('"UNIT_NAME":"연%"', '"UNIT_NAME":"bp"'), { status: 200 }); return response; });
const semanticCollected = await collect('2026-09-11', '2026-09-18', 7, join(root, 'semantic-retry'), false, semanticFetch, clock);
check(() => { assert.equal(transientBadRows, 3); assert.equal(semanticCollected.receipts.find(r => r.tenor === 3)?.windows.find(w => w.to === '2026-09-18')?.attempts, 3); assert.equal(semanticCollected.observations.length, 12); });
check(() => { const bad = structuredClone(packetFor(live.observations)); bad.receipts = structuredClone(live.receipts); bad.receipts[0].windows[0].attempts = 0; assert.throws(() => analyze(bad), /invalid source window/); });
check(() => { const bad = structuredClone(packetFor(live.observations)); bad.receipts = structuredClone(live.receipts); bad.receipts[0].raw.pop(); bad.receipts[0].rawSha256.pop(); assert.throws(() => analyze(bad), /window\/raw mismatch/); });
const unavailable = (async () => new Response('', { status: 404 })), recovered = await collect('2026-09-11', '2026-09-18', 7, cache, true, unavailable, clock);
check(() => { assert.equal(recovered.observations.length, 12); assert.ok(recovered.receipts.every(r => r.status === 'cache')); });
const noCacheDir = join(root, 'no-cache');
await collect('2026-09-11', '2026-09-18', 7, noCacheDir, false, fakeEcos, clock);
await assert.rejects(() => readdir(noCacheDir), /ENOENT/);
checks++;
let reversedCalls = 0;
await assert.rejects(() => collect('2026-09-18', '2026-09-11', 7, join(root, 'reversed'), false, (async () => { reversedCalls++; return new Response('{}', { status: 200 }); }), clock), /Base must precede/);
check(() => assert.equal(reversedCalls, 0));
const files = await readdir(cache), cacheFile = join(cache, files.find(x => !x.startsWith('policy-')));
const cached = JSON.parse(await readFile(cacheFile, 'utf8'));
cached.digest = '0'.repeat(64);
await writeFile(cacheFile, JSON.stringify(cached));
const rejected = await collect('2026-09-11', '2026-09-18', 7, cache, true, unavailable, clock);
check(() => assert.ok(rejected.observations.length < 12));
const policyRaw = JSON.stringify({ StatisticSearch: { list_total_count: 1, row: [{ STAT_CODE: '722Y001', STAT_NAME: '1.3.1. 한국은행 기준금리 및 여수신금리', ITEM_CODE1: '0101000', ITEM_NAME1: '한국은행 기준금리', UNIT_NAME: '연%', TIME: '20260918', DATA_VALUE: '3' }] } }), policyFetch = (async () => new Response(policyRaw, { status: 200 }));
const policyCache = join(root, 'policy-cache'), pLive = await collectPolicyRate('2026-09-18', policyCache, true, policyFetch, clock);
check(() => { assert.equal(pLive?.value, 3); assert.equal(pLive?.status, 'live'); });
const pCache = await collectPolicyRate('2026-09-18', policyCache, true, unavailable, clock);
check(() => assert.equal(pCache?.status, 'cache'));
const noPolicyCacheDir = join(root, 'no-policy-cache');
await collectPolicyRate('2026-09-18', noPolicyCacheDir, false, policyFetch, clock);
await assert.rejects(() => readdir(noPolicyCacheDir), /ENOENT/);
checks++;
const policyCollision = join(root, 'policy-write-collision');
await mkdir(policyCollision);
await mkdir(join(policyCollision, 'policy-2026-09-11-2026-09-18.mjson'));
const policyDespiteCacheFailure = await collectPolicyRate('2026-09-18', policyCollision, true, policyFetch, clock);
check(() => { assert.equal(policyDespiteCacheFailure?.status, 'live'); assert.ok(policyDespiteCacheFailure?.cacheWriteError); });
const policyCollisionFiles = await readdir(policyCollision);
check(() => assert.ok(policyCollisionFiles.every(x => !x.endsWith('.tmp'))));
const invalidPolicy = policyRaw.replace('"DATA_VALUE":"3"', '"DATA_VALUE":"0x10"'), badPolicy = await collectPolicyRate('2026-09-18', join(root, 'bad-policy'), false, (async () => new Response(invalidPolicy, { status: 200 })), clock);
check(() => assert.equal(badPolicy, null));
const ranged = await collectWindows([{ from: '2026-09-11', to: '2026-09-18' }], join(root, 'range-cache'), false, fakeEcos, clock);
check(() => assert.equal(ranged.receipts.length, 6));
const unwritable = join(root, 'cache-write-collision');
await mkdir(unwritable);
for (const t of TENORS)
await mkdir(join(unwritable, `${t}-2026-09-18-2026-09-18.mjson`));
const singleEcos = async (url) => { const value = String(url), item = Object.keys(yields).find(code => value.endsWith(`/${code}`)), tenor = TENORS.find(t => TENOR_META[t].itemCode === item); return new Response(ecos(tenor, '20260918', yields[item]), { status: 200 }); }, cacheWriteFailure = await collectWindows([{ from: '2026-09-18', to: '2026-09-18' }], unwritable, true, singleEcos, clock);
check(() => { assert.equal(cacheWriteFailure.observations.length, 6); assert.ok(cacheWriteFailure.receipts.every(x => x.status === 'live' && x.windows[0].error?.includes('cache write failed'))); });
const cacheWriteAnalysis = analyze({ version: PACKET_VERSION, engine: ENGINE, base: '2026-09-17', asOf: '2026-09-18', maxLag: 1, observations: cacheWriteFailure.observations, receipts: cacheWriteFailure.receipts, label: '캐시 저장 실패 테스트' });
check(() => assert.match(cacheWriteAnalysis.notes.join(' '), /로컬 캐시 저장 실패/));
const collisionFiles = await readdir(unwritable);
check(() => assert.ok(collisionFiles.every(x => !x.endsWith('.tmp'))));
await assert.rejects(() => collectWindows([{ from: '2026-09-11', to: '2026-09-18' }], join(root, 'bad-clock'), false, fakeEcos, new Date('invalid')), /Invalid collection clock/);
checks++;
const inputFile = join(root, 'input.csv'), csvRows = ['date,tenor,yield_pct', ...obs.map(r => `${r.date},${r.tenor},${r.value}`)].join('\n');
await writeFile(inputFile, csvRows);
const cli = await run(process.execPath, [cliPath, '--input', inputFile, '--base', '2026-09-11', '--as-of', '2026-09-18'], { cwd: root });
check(() => { const parsed = JSON.parse(cli.stdout); assert.equal(parsed.result, 'READY'); assert.equal(parsed.rates.length, 6); assert.doesNotMatch(cli.stdout, /report\.html/); });
const newsFile = join(root, 'news.mjson');
await writeFile(newsFile, JSON.stringify(newsInput));
const cliNews = await run(process.execPath, [cliPath, '--input', inputFile, '--base', '2026-09-11', '--as-of', '2026-09-18', '--news-evidence', newsFile], { cwd: root });
check(() => { const parsed = JSON.parse(cliNews.stdout); assert.equal(parsed.news.status, 'SOURCED'); assert.equal(parsed.news.confidence, 'HIGH'); });
const template = await run(process.execPath, [cliPath, '--news-template', '2026-09-18'], { cwd: root });
check(() => assert.equal(newsRead(template.stdout).observationDate, '2026-09-18'));
try {
await run(process.execPath, [cliPath, '--base', '2026-09-11', '--base', '2026-09-12', '--as-of', '2026-09-18'], { cwd: root });
assert.fail('duplicate option accepted');
}
catch (e) {
assert.match(String(e.stderr), /Duplicate option/);
checks++;
}
try {
await run(process.execPath, [cliPath, '--input', inputFile], { cwd: root });
assert.fail('implicit input dates accepted');
}
catch (e) {
assert.match(String(e.stderr), /requires explicit/);
checks++;
}
const cliReject = async (argv, pattern) => { try {
await run(process.execPath, [cliPath, ...argv], { cwd: root });
assert.fail(`CLI accepted ${argv.join(' ')}`);
}
catch (e) {
assert.match(String(e.stderr), pattern);
checks++;
} };
await cliReject(['--base', '2026-09-18', '--as-of', '2026-09-18'], /precede/);
await cliReject(['--base', '2020-01-01', '--as-of', '2022-01-01'], /366/);
await cliReject(['--preset', 'dashboard', '--lookback-sessions', '5'], /mutually exclusive/);
await cliReject(['--replay', 'missing.mjson', '--base', '2026-09-11'], /accepts only/);
await cliReject(['--from-month', '2026-09', '--to-month', '2026-09'], /earlier/);
await cliReject(['--market', 'kr', '--json'], /only needed/);
const third = join(root, 'third');
await writeArtifacts(third, packet);
const statusPath = join(third, 'status.mjson'), manifestPath = join(third, 'manifest.mjson');
await writeFile(statusPath, (await readFile(statusPath, 'utf8')).replace('"usable": true', '"usable": false'));
const alteredManifest = JSON.parse(await readFile(manifestPath, 'utf8'));
alteredManifest.files['status.mjson'] = sha256(await readFile(statusPath, 'utf8'));
await writeFile(manifestPath, JSON.stringify(alteredManifest, null, 2) + '\n');
await assert.rejects(() => validateArtifacts(third), /not reproducible/);
checks++;
const fourth = join(root, 'fourth');
await writeArtifacts(fourth, packet);
const incompleteManifest = JSON.parse(await readFile(join(fourth, 'manifest.mjson'), 'utf8'));
delete incompleteManifest.files['status.mjson'];
await writeFile(join(fourth, 'manifest.mjson'), JSON.stringify(incompleteManifest, null, 2) + '\n');
await assert.rejects(() => validateArtifacts(fourth), /file contract/);
checks++;
await writeFile(join(first, 'brief.md'), (await readFile(join(first, 'brief.md'), 'utf8')) + 'tampered');
await assert.rejects(() => validateArtifacts(first), /hash mismatch/);
checks++;
}
finally {
await rm(root, { recursive: true, force: true });
}
const ustFixture = (dates) => `<?xml version="1.0"?><feed xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"><title type="text">DailyTreasuryYieldCurveRateData</title>${dates.map((d, i) => `<entry><content><m:properties><d:NEW_DATE m:type="Edm.DateTime">${d}T00:00:00</d:NEW_DATE>${TENORS.map(t => `<d:BC_${t}YEAR m:type="Edm.Double">${(4 + t / 100 + i / 100).toFixed(2)}</d:BC_${t}YEAR>`).join('')}</m:properties></content></entry>`).join('')}</feed>`;
const ustRaw = ustFixture(['2026-09-11', '2026-09-18']);
check(() => { const rows = parseUstXml(ustRaw, '2026-09'); assert.equal(rows.length, 2); assert.equal(rows[0].yields[10], 4.1); assert.equal(rows[1].yields[30], 4.31); });
check(() => assert.throws(() => parseUstXml(ustRaw, '2026-08'), /month\/date mismatch/));
check(() => assert.throws(() => parseUstXml(ustFixture(['2026-09-11', '2026-09-11']), '2026-09'), /duplicate observation/));
check(() => assert.throws(() => parseUstXml(ustRaw.replace('<d:BC_10YEAR m:type="Edm.Double">4.10</d:BC_10YEAR>', '<d:BC_10YEAR m:type="Edm.Double">400bp</d:BC_10YEAR>'), '2026-09'), /Invalid UST 10Y yield/));
const ustMissingTenor = ustRaw.replace('<d:BC_20YEAR m:type="Edm.Double">4.20</d:BC_20YEAR>', '<d:BC_20YEAR m:null="true" />');
check(() => { const rows = parseUstXml(ustMissingTenor, '2026-09'); assert.equal(rows.length, 1); assert.equal(rows[0].date, '2026-09-18'); });
check(() => assert.equal(ustMonthUrl('2026-09').endsWith('202609'), true));
check(() => assert.deepEqual(ustMonths('2026-08-31', '2026-09-01', 7), ['2026-08', '2026-09']));
const dualRoot = await mkdtemp(join(tmpdir(), 'ktb-ust-'));
try {
let requests = 0;
const fetcher = (async () => { requests++; return new Response(ustRaw, { status: 200, headers: { 'Content-Type': 'application/xml' } }); });
const us = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'cache'), true, fetcher, new Date('2026-09-18T23:00:00.000Z'));
check(() => { assert.equal(requests, 1); assert.equal(us.engine, UST_ENGINE); assert.equal(analyzeUst(us).status, 'READY'); assert.equal(analyzeUst(us).changes?.[10], 1); });
const refreshed = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'cache'), true, fetcher, new Date('2026-09-19T07:00:00.000Z'));
check(() => { assert.equal(requests, 2); assert.equal(refreshed.sources[0].status, 'live'); });
const offline = (async () => new Response('', { status: 503 }));
const historicalCache = join(dualRoot, 'historical-cache'), historicalRaw = ustFixture(['2026-08-11', '2026-08-18']), historicalFetch = (async () => new Response(historicalRaw, { status: 200 }));
const historicalLive = await collectUst('2026-08-11', '2026-08-18', 7, historicalCache, true, historicalFetch, new Date('2026-09-23T00:00:00.000Z'));
check(() => assert.equal(historicalLive.sources[0].status, 'live', JSON.stringify(historicalLive.sources[0])));
const historical = await collectUst('2026-08-11', '2026-08-18', 7, historicalCache, true, offline, new Date('2026-09-23T12:00:00.000Z'));
check(() => assert.equal(historical.sources[0].status, 'cache'));
const noUstCache = join(dualRoot, 'no-ust-cache');
await collectUst('2026-09-11', '2026-09-18', 7, noUstCache, false, fetcher, new Date('2026-09-18T23:00:00.000Z'));
await assert.rejects(() => readdir(noUstCache), /ENOENT/);
checks++;
const ustCollision = join(dualRoot, 'ust-cache-collision');
await mkdir(ustCollision);
await mkdir(join(ustCollision, 'ust-2026-09.mjson'));
const collisionResult = await collectUst('2026-09-11', '2026-09-18', 7, ustCollision, true, fetcher, new Date('2026-09-18T23:00:00.000Z'));
check(() => { assert.equal(collisionResult.sources[0].status, 'live'); assert.equal(analyzeUst(collisionResult).status, 'READY'); });
const ustCollisionFiles = await readdir(ustCollision);
check(() => assert.ok(!ustCollisionFiles.some(x => x.endsWith('.tmp'))));
let malformedCalls = 0;
const malformedThenValid = (async () => { malformedCalls++; return new Response(malformedCalls < 3 ? '<html>temporary response</html>' : ustRaw, { status: 200 }); });
const retried = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'retry-cache'), false, malformedThenValid, new Date('2026-09-18T23:00:00.000Z'));
check(() => { assert.equal(malformedCalls, 3); assert.equal(retried.sources[0].status, 'live'); assert.equal(analyzeUst(retried).status, 'READY'); });
const cached = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'cache'), true, offline, new Date('2026-09-19T09:00:00.000Z'));
check(() => assert.equal(cached.sources[0].status, 'cache'));
const blocked = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'empty-cache'), false, offline, new Date('2026-09-19T01:00:00.000Z'));
check(() => { assert.equal(blocked.sources[0].status, 'failed'); assert.equal(analyzeUst(blocked).status, 'BLOCKED'); assert.match(analyzeUst(blocked).notes.join(' '), /수집 실패/); });
check(() => { const bad = structuredClone(blocked); bad.sources[0].error = 'failed\n## injected section'; assert.throws(() => analyzeUst(bad), /Invalid failed UST source receipt/); });
const incompleteUs = structuredClone(us);
incompleteUs.sources[0].raw = ustMissingTenor;
incompleteUs.sources[0].sha256 = sha256(ustMissingTenor);
check(() => { const a = analyzeUst(incompleteUs); assert.equal(a.status, 'INCOMPLETE'); assert.match(a.notes.join(' '), /만기 결측이 있는 1개 관측일/); });
const partial = analyzeDual({ version: 1, engine: DUAL_ENGINE, ktb: packet, ust: blocked });
check(() => assert.equal(partial.status, 'INCOMPLETE'));
const partialChat = dualChat({ version: 1, engine: DUAL_ENGINE, ktb: packet, ust: blocked }, partial);
check(() => { assert.match(partialChat, /\*\*상태:\*\* 일부 자료 부족, 확인된 값만 표시 \(INCOMPLETE\)/); assert.match(partialChat, /UST는 비교 가능한 두 관측일이 부족합니다/); assert.match(partialChat, /공통 관측일이 없어 계산 불가/); assert.doesNotMatch(partialChat, /NaN|undefined|계산 불가bp/); });
const emptyKtb = packetFor([]), fullyBlocked = { version: 1, engine: DUAL_ENGINE, ktb: emptyKtb, ust: blocked };
const blockedChat = dualChat(fullyBlocked);
check(() => { assert.match(blockedChat, /자료 확인 불가 \(BLOCKED\)/); assert.equal((blockedChat.match(/^\| \d+년 \|/gm) ?? []).length, 6); assert.doesNotMatch(blockedChat, /NaN|undefined/); });
const partialOut = join(dualRoot, 'partial');
await writeDualArtifacts(partialOut, { version: 1, engine: DUAL_ENGINE, ktb: packet, ust: blocked });
await validateDualArtifacts(partialOut);
const partialBrief = await readFile(join(partialOut, 'brief.md'), 'utf8');
check(() => { assert.match(partialBrief, /INCOMPLETE/); assert.match(partialBrief, /UST 원천 수집 실패/); assert.match(partialBrief, /계산 불가 \(/); assert.doesNotMatch(partialBrief, /계산 불가bp/); });
for (const [label, evidence] of [['partial', { version: 1, engine: DUAL_ENGINE, ktb: packet, ust: blocked }], ['blocked', fullyBlocked]]) {
const original = label === 'partial' ? partialOut : join(dualRoot, 'blocked');
if (label === 'blocked')
await writeDualArtifacts(original, evidence);
const replayed = join(dualRoot, `${label}-replay`), saved = JSON.parse(await readFile(join(original, 'evidence.mjson'), 'utf8'));
await writeDualArtifacts(replayed, saved);
await validateDualArtifacts(replayed);
for (const name of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
assert.equal(await readFile(join(original, name), 'utf8'), await readFile(join(replayed, name), 'utf8'));
checks++;
}
check(() => { const tampered = structuredClone(us); tampered.sources[0].sha256 = '0'.repeat(64); assert.throws(() => analyzeUst(tampered), /checksum/); });
check(() => { const bad = structuredClone(us); bad.sources[0].url = 'https://example.com/'; assert.throws(() => analyzeUst(bad), /identity/); });
const dual = { version: 1, engine: DUAL_ENGINE, ktb: packet, ust: us }, result = analyzeDual(dual);
check(() => { assert.equal(result.status, 'READY'); assert.equal(result.cross.current.actual, '2026-09-18'); assert.equal(result.cross.current.gapBp, 86); });
const lagged = structuredClone(dual);
lagged.ktb.asOf = '2026-09-19';
lagged.ust.asOf = '2026-09-19';
const laggedChat = dualChat(lagged);
check(() => { assert.match(laggedChat, /요청일 2026-09-19보다 1일 전/); assert.match(laggedChat, /분석 완료 \(READY\)/); });
const chat = dualChat(dual, result), sections = ['**상태:**', '**핵심 변화**', '| 만기 |', '**커브·한미 차이**', '**주요 일정**', '**확인 사항**', '**출처:**'];
check(() => { for (const variant of [chat, partialChat, blockedChat]) {
const positions = sections.map(s => variant.indexOf(s));
assert.ok(positions.every(i => i >= 0));
assert.deepEqual(positions, [...positions].sort((a, b) => a - b));
assert.equal((variant.match(/^\| \d+년 \|/gm) ?? []).length, 6);
assert.doesNotMatch(variant, /NaN|undefined/);
} assert.match(chat, /KTB 3년 -10\.00bp, 10년 -5\.00bp/); assert.match(chat, /UST 3년 \+1\.00bp, 10년 \+1\.00bp/); assert.match(chat, /공통 관측일 2026-09-18/); assert.match(chat, /https:\/\/ecos\.bok\.or\.kr\//); assert.match(chat, /home\.treasury\.gov/); });
const first = join(dualRoot, 'first'), second = join(dualRoot, 'second');
await writeDualArtifacts(first, dual);
await validateDualArtifacts(first);
const replay = JSON.parse(await readFile(join(first, 'evidence.mjson'), 'utf8'));
await writeDualArtifacts(second, replay);
await validateDualArtifacts(second);
for (const name of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
assert.equal(await readFile(join(first, name), 'utf8'), await readFile(join(second, name), 'utf8'));
checks++;
const html = await readFile(join(first, 'report.html'), 'utf8');
check(() => { assert.match(html, /한국 국고채/); assert.match(html, /미국 국채 par/); assert.match(html, /공통 관측일/); assert.ok(html.includes(us.sources[0].url.replaceAll('&', '&'))); assert.doesNotMatch(html, /NaN|undefined/); });
const schedule = { version: 1, anchorDate: '2026-09-18', checkedAt: '2026-09-18T23:00:00.000Z', events: [
{ date: '2026-09-21', market: 'KR', kind: 'auction', tenor: 5, sourceUrl: 'https://ktb.moef.go.kr/mnbyIsuCldr.do', sourceExcerpt: '2026.09.21. | 5년물', sourceDateText: '2026.09.21.' },
{ date: '2026-09-23', market: 'US', kind: 'auction', tenor: 5, sourceUrl: 'https://home.treasury.gov/system/files/221/TentativeAuctionScheduleQ22026.pdf', sourceExcerpt: '5-Year NOTE Thursday, September 17, 2026 Wednesday, September 23, 2026 Wednesday, September 30, 2026', sourceDateText: 'Wednesday, September 23, 2026' },
{ date: '2026-10-22', market: 'KR', kind: 'policy', sourceUrl: 'https://www.bok.or.kr/portal/singl/crncyPolicyDrcMtg/listYear.do?menuNo=200755&mtgSe=A', sourceExcerpt: '2026 통화정책방향 결정회의 일정 및 자료 10월 22일(목)', sourceDateText: '10월 22일' },
{ date: '2026-10-28', market: 'US', kind: 'policy', sourceUrl: 'https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm', sourceExcerpt: '2026 FOMC Meetings October 27-28', sourceDateText: 'October 27-28' }
] };
const scheduled = readSchedule(JSON.stringify(schedule), '2026-09-18');
check(() => { const a = analyzeSchedule(scheduled, '2026-09-18'); assert.equal(a.status, 'VERIFIED'); assert.deepEqual(a.events.map(e => e.date), ['2026-09-21', '2026-09-23', '2026-10-22', '2026-10-28']); assert.equal(analyzeSchedule(undefined, '2026-09-18').status, 'UNVERIFIED'); });
const scheduledDual = { ...dual, schedule: scheduled }, scheduledChat = dualChat(scheduledDual);
check(() => { assert.match(scheduledChat, /2026-09-21 \(KR\) 국고채 5년물 입찰/); assert.match(scheduledChat, /2026-09-23 \(US\) 미국 국채 5년물 입찰/); assert.match(scheduledChat, /2026-10-22 \(KR\) 한국은행 통화정책방향 결정회의/); assert.match(scheduledChat, /2026-10-28 \(US\) FOMC 금리 결정/); assert.match(scheduledChat, /분석 완료 \(READY\)/); });
check(() => { const bad = structuredClone(schedule); bad.events[0].date = '2026-09-22'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /quoted date/); });
check(() => { const bad = structuredClone(schedule); bad.events[1].sourceUrl = 'https://home.treasury.gov.evil.example/schedule'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /host/); });
check(() => { const bad = structuredClone(schedule); bad.events[1].date = '2026-09-17'; bad.events[1].sourceDateText = 'Thursday, September 17, 2026'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /event date/); });
check(() => { const bad = structuredClone(schedule); bad.events[1].date = '2026-09-30'; bad.events[1].sourceDateText = 'Wednesday, September 30, 2026'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /second date/); });
check(() => { const bad = structuredClone(schedule); bad.events[1].sourceExcerpt = bad.events[1].sourceExcerpt.replaceAll('Wednesday, September 23', 'Tuesday, September 23'); bad.events[1].sourceDateText = 'Tuesday, September 23, 2026'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /quoted date/); });
check(() => { const bad = structuredClone(schedule); bad.events[1].sourceExcerpt = '5-Year NOTE Wednesday, September 23, 2026'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /announcement, auction, and settlement/); });
check(() => { const bad = structuredClone(schedule); bad.events.push({ ...bad.events[0] }); assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /Duplicate/); });
check(() => { const bad = structuredClone(schedule); bad.checkedAt = '2026-10-18T00:00:00.000Z'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /too far/); });
const scheduleOut = join(dualRoot, 'scheduled'), scheduleReplay = join(dualRoot, 'scheduled-replay');
await writeDualArtifacts(scheduleOut, scheduledDual);
await validateDualArtifacts(scheduleOut);
const scheduleSaved = JSON.parse(await readFile(join(scheduleOut, 'evidence.mjson'), 'utf8'));
await writeDualArtifacts(scheduleReplay, scheduleSaved);
await validateDualArtifacts(scheduleReplay);
for (const name of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
assert.equal(await readFile(join(scheduleOut, name), 'utf8'), await readFile(join(scheduleReplay, name), 'utf8'));
const scheduleHtml = await readFile(join(scheduleOut, 'report.html'), 'utf8');
check(() => { assert.match(scheduleHtml, /주요 일정/); assert.match(scheduleHtml, /국고채 5년물 입찰/); assert.match(scheduleHtml, /FOMC 금리 결정/); });
const scheduledCli = await run(process.execPath, [cliPath, '--market', 'both', '--replay', join(scheduleOut, 'evidence.mjson')], { cwd: dualRoot });
check(() => { assert.match(scheduledCli.stdout, /\*\*주요 일정\*\*/); assert.match(scheduledCli.stdout, /2026-10-28 \(US\) FOMC 금리 결정/); });
const jsonCli = await run(process.execPath, [cliPath, '--market', 'both', '--replay', join(scheduleOut, 'evidence.mjson'), '--json'], { cwd: dualRoot });
check(() => { const parsed = JSON.parse(jsonCli.stdout); assert.ok(Array.isArray(parsed.markets)); });
const altered = structuredClone(dual);
altered.ust.sources[0].raw = altered.ust.sources[0].raw.replace('4.10', '4.11');
check(() => assert.throws(() => analyzeDual(altered), /checksum/));
const noCommon = structuredClone(dual);
noCommon.ust.sources[0].raw = ustFixture(['2026-09-12', '2026-09-19']);
noCommon.ust.sources[0].sha256 = sha256(noCommon.ust.sources[0].raw);
noCommon.ust.asOf = '2026-09-19';
noCommon.ktb.asOf = '2026-09-19';
const gap = analyzeDual(noCommon);
check(() => assert.equal(gap.cross.current.gapBp, null));
const partialCurveRows = [...obs, { date: '2026-09-19', tenor: 10, value: 3.21 }], partialCurve = packetFor(partialCurveRows);
partialCurve.asOf = '2026-09-19';
const ustNewDate = structuredClone(us);
ustNewDate.asOf = '2026-09-19';
ustNewDate.sources[0].raw = ustFixture(['2026-09-11', '2026-09-18', '2026-09-19']);
ustNewDate.sources[0].sha256 = sha256(ustNewDate.sources[0].raw);
const aligned = analyzeDual({ version: 1, engine: DUAL_ENGINE, ktb: partialCurve, ust: ustNewDate });
check(() => { assert.equal(aligned.ktb.current.actual, '2026-09-18'); assert.equal(aligned.cross.current.actual, '2026-09-18'); assert.equal(aligned.cross.current.gapBp, 86); });
await writeFile(join(first, 'brief.md'), (await readFile(join(first, 'brief.md'), 'utf8') + 'tampered'));
await assert.rejects(() => validateDualArtifacts(first), /mismatch/);
checks++;
}
finally {
await rm(dualRoot, { recursive: true, force: true });
}
console.log(`PASS: ${checks} deterministic pipeline checks`);
scripts/modes.mjs
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { rangeWindows, request } from './collect.mjs';
import { TENORS, bp, date, days, normalize, sha256, shift } from './core.mjs';
function validMaxLag(maxLag) { if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
throw Error('maxLag must be 0..14 calendar days'); }
function endOfMonth(value) {
if (!/^\d{4}-\d{2}$/.test(value))
throw Error(`Invalid month: ${value}`);
const first = date(`${value}-01`), [year, month] = first.split('-').map(Number);
return shift(`${month === 12 ? year + 1 : year}-${String(month === 12 ? 1 : month + 1).padStart(2, '0')}-01`, -1);
}
export function monthComparisonDates(fromMonth, toMonth, today) {
date(today);
endOfMonth(fromMonth);
const toEnd = endOfMonth(toMonth);
if (fromMonth >= toMonth)
throw Error('--from-month must be earlier than --to-month');
const currentMonth = today.slice(0, 7);
if (toMonth > currentMonth)
throw Error('--to-month cannot be in the future');
return { base: endOfMonth(fromMonth), asOf: toMonth === currentMonth ? today : toEnd };
}
export function eventQueryWindows(eventDate, today, maxLag) {
date(eventDate);
date(today);
validMaxLag(maxLag);
if (eventDate > today)
throw Error('Event date cannot be in the future');
const beforeTo = shift(eventDate, -1), afterTo = shift(eventDate, maxLag) < today ? shift(eventDate, maxLag) : today;
return [...rangeWindows(shift(beforeTo, -maxLag), beforeTo), ...rangeWindows(eventDate, afterTo)];
}
export function previousMonthEnd(asOf) { const [y, m] = date(asOf).split('-').map(Number); return shift(`${y}-${String(m).padStart(2, '0')}-01`, -1); }
export function previousQuarterEnd(asOf) { const [y, m] = date(asOf).split('-').map(Number), quarterStart = Math.floor((m - 1) / 3) * 3 + 1; return shift(`${y}-${String(quarterStart).padStart(2, '0')}-01`, -1); }
export function previousYearEnd(asOf) { const y = Number(date(asOf).slice(0, 4)); return `${y - 1}-12-31`; }
export function commonSnapshots(input) {
const rows = normalize(input), dates = [...new Set(rows.map(r => r.date))].sort();
return dates.filter(d => TENORS.every(t => rows.some(r => r.date === d && r.tenor === t))).map(actual => ({ requested: actual, actual, lag: 0, values: Object.fromEntries(TENORS.map(t => [`${t}Y`, rows.find(r => r.date === actual && r.tenor === t).value])), reason: null }));
}
export function onOrBefore(input, requested, maxLag) {
date(requested);
validMaxLag(maxLag);
const found = commonSnapshots(input).filter(s => s.actual <= requested).at(-1) ?? null, lag = found?.actual ? days(requested, found.actual) : null;
const reason = !found ? '여섯 만기의 공통 관측일 없음' : lag > maxLag ? `공통 관측일이 ${lag}일 전: 허용 ${maxLag}일 초과` : null;
return { requested, actual: found?.actual ?? null, lag, values: reason ? null : found.values, reason };
}
export function onOrAfter(input, requested, maxLag) {
date(requested);
validMaxLag(maxLag);
const found = commonSnapshots(input).find(s => s.actual >= requested) ?? null, lag = found?.actual ? days(found.actual, requested) : null;
const reason = !found ? '여섯 만기의 공통 관측일 없음' : lag > maxLag ? `공통 관측일이 ${lag}일 후: 허용 ${maxLag}일 초과` : null;
return { requested, actual: found?.actual ?? null, lag, values: reason ? null : found.values, reason };
}
function changes(current, base) { return TENORS.map(t => ({ tenor: `${t}Y`, base: base.values?.[`${t}Y`] ?? null, current: current.values?.[`${t}Y`] ?? null, changeBp: base.values && current.values ? bp(current.values[`${t}Y`], base.values[`${t}Y`]) : null })); }
function spread(snapshot, long, short) { return snapshot.values ? bp(snapshot.values[`${long}Y`], snapshot.values[`${short}Y`]) : null; }
export function dashboard(asOf, input, maxLag, policyRate) {
const snapshots = commonSnapshots(input), current = onOrBefore(input, asOf, maxLag);
const previousActual = current.actual ? snapshots.filter(s => s.actual < current.actual).at(-1) ?? null : null;
const previousSession = previousActual ? { ...previousActual, requested: `${current.actual} 직전 관측일` } : { requested: '직전 관측일', actual: null, lag: null, values: null, reason: '직전 공통 관측일 없음' };
const weekAgo = onOrBefore(input, shift(asOf, -7), maxLag), monthEnd = onOrBefore(input, previousMonthEnd(asOf), maxLag), notes = [current.reason, previousSession.reason, weekAgo.reason, monthEnd.reason].filter((x) => !!x);
const current3 = current.values?.['3Y'] ?? null, policyGap3YBp = current3 !== null && policyRate ? bp(current3, policyRate.value) : null;
if (!policyRate)
notes.push('기준금리 확보 실패: 정책금리 갭 제외');
else if (policyRate.cacheWriteError)
notes.push(`기준금리 원자료는 검증했으나 로컬 캐시 저장 실패 (${policyRate.cacheWriteError})`);
const ready = !!current.values && !!previousSession.values && !!weekAgo.values && !!monthEnd.values;
return { result: ready ? 'READY' : current.values ? 'INCOMPLETE' : 'BLOCKED', mode: 'dashboard', asOf, current, references: { previousSession, weekAgo, previousMonthEnd: monthEnd }, changes: { previousSession: changes(current, previousSession), weekAgo: changes(current, weekAgo), previousMonthEnd: changes(current, monthEnd) }, spreads: { '5Y-3Y': spread(current, 5, 3), '10Y-5Y': spread(current, 10, 5), '10Y-3Y': spread(current, 10, 3), '30Y-10Y': spread(current, 30, 10) }, policyRate, policyGap3YBp, notes };
}
export function trend(input, sessions) {
if (![5, 20].includes(sessions))
throw Error('lookback sessions must be 5 or 20');
const all = commonSnapshots(input), selected = all.slice(-sessions), notes = [];
if (selected.length < sessions)
notes.push(`요청 ${sessions}거래일 중 ${selected.length}개 공통 관측일만 확보`);
const stats = selected.length < 2 ? [] : TENORS.map(t => { const key = `${t}Y`, values = selected.map(s => s.values[key]), moves = selected.slice(1).map((s, i) => ({ date: s.actual, move: bp(s.values[key], selected[i].values[key]) })), largest = moves.reduce((a, b) => Math.abs(b.move) > Math.abs(a.move) ? b : a); return { tenor: key, start: values[0], end: values.at(-1), netChangeBp: bp(values.at(-1), values[0]), high: Math.max(...values), low: Math.min(...values), largestDailyMoveBp: largest.move, largestDailyMoveDate: largest.date }; });
return { result: selected.length >= sessions ? 'READY' : selected.length ? 'INCOMPLETE' : 'BLOCKED', mode: 'trend', requestedSessions: sessions, actualSessions: selected.length, from: selected[0]?.actual ?? null, to: selected.at(-1)?.actual ?? null, sessions: selected, stats, notes };
}
function parsePolicy(raw, from, to) {
const doc = JSON.parse(raw);
if (doc.RESULT)
throw Error(`ECOS policy ${doc.RESULT.CODE ?? 'unknown'}`);
const block = doc.StatisticSearch, rows = block?.row;
if (!block || !Array.isArray(rows) || block.list_total_count !== rows.length || rows.length > 10)
throw Error('Incomplete policy-rate response');
const seen = new Set(), valid = rows.map(r => { if (r.STAT_CODE !== '722Y001' || r.STAT_NAME !== '1.3.1. 한국은행 기준금리 및 여수신금리' || r.ITEM_CODE1 !== '0101000' || r.ITEM_NAME1 !== '한국은행 기준금리' || r.UNIT_NAME !== '연%' || !/^\d{8}$/.test(r.TIME ?? '') || !/^\d+(?:\.\d+)?$/.test(r.DATA_VALUE ?? ''))
throw Error('Policy-rate metadata mismatch'); const d = `${r.TIME.slice(0, 4)}-${r.TIME.slice(4, 6)}-${r.TIME.slice(6, 8)}`, value = Number(r.DATA_VALUE); date(d); if (seen.has(d))
throw Error('Duplicate policy-rate date'); seen.add(d); if (d < from || d > to || !Number.isFinite(value) || value < 0 || value > 30)
throw Error('Invalid policy-rate value/range'); return { date: d, value }; }).sort((a, b) => a.date.localeCompare(b.date));
if (!valid.length)
throw Error('No policy-rate observation');
return valid.at(-1);
}
export async function collectPolicyRate(asOf, cacheDir, useCache = true, get = fetch, now = new Date(), maxAgeHours = 168) {
date(asOf);
if (typeof cacheDir !== 'string' || !cacheDir.trim())
throw Error('cacheDir must be a non-empty path');
if (!Number.isFinite(now.getTime()))
throw Error('Invalid policy-rate clock');
if (!Number.isFinite(maxAgeHours) || maxAgeHours < 0)
throw Error('maxAgeHours must be non-negative');
const from = shift(asOf, -7), to = asOf, retrievedAt = now.toISOString(), file = join(cacheDir, `policy-${from}-${to}.mjson`);
if (useCache)
await mkdir(cacheDir, { recursive: true });
let liveError = '';
try {
const key = process.env.KSKILL_BOK_ECOS_API_KEY || 'sample', url = `https://ecos.bok.or.kr/api/StatisticSearch/${encodeURIComponent(key)}/json/kr/1/10/722Y001/D/${from.replaceAll('-', '')}/${to.replaceAll('-', '')}/0101000`, result = await request(url, get), point = parsePolicy(result.text, from, to), digest = sha256(result.text), data = { version: 1, storedAt: retrievedAt, from, to, raw: result.text, rawSha256: digest }, temp = `${file}.${randomUUID()}.tmp`;
let cacheWriteError;
if (useCache)
try {
await writeFile(temp, JSON.stringify({ data, digest: sha256(JSON.stringify(data)) }));
await rename(temp, file);
}
catch (e) {
cacheWriteError = e instanceof Error ? e.message : String(e);
await rm(temp, { force: true });
}
return { ...point, unit: '연%', status: 'live', retrievedAt, sourceUrl: 'https://ecos.bok.or.kr/', statCode: '722Y001', itemCode: '0101000', rawSha256: digest, ...(cacheWriteError ? { cacheWriteError } : {}) };
}
catch (e) {
liveError = e instanceof Error ? e.message : String(e);
}
if (useCache)
try {
const envelope = JSON.parse(await readFile(file, 'utf8')), data = envelope.data, age = (now.getTime() - Date.parse(data.storedAt)) / 3600000;
if (envelope.digest !== sha256(JSON.stringify(data)) || data.version !== 1 || data.from !== from || data.to !== to || data.rawSha256 !== sha256(data.raw) || !Number.isFinite(age) || age < 0 || age > maxAgeHours)
throw Error('Invalid policy cache');
const point = parsePolicy(data.raw, from, to);
return { ...point, unit: '연%', status: 'cache', retrievedAt: data.storedAt, sourceUrl: 'https://ecos.bok.or.kr/', statCode: '722Y001', itemCode: '0101000', rawSha256: data.rawSha256, cacheAgeHours: Math.round(age * 100) / 100 };
}
catch {
return null;
}
void liveError;
return null;
}
scripts/news.mjs
import { date, sha256 } from './core.mjs';
const sourceTypes = new Set(['market-close', 'market-update', 'official-release', 'headline']);
const directions = new Set(['higher', 'lower', 'mixed', 'flat', 'context']);
const relations = new Set(['explicit', 'contextual']);
const clean = (value, name, max, min = 1) => {
if (typeof value !== 'string' || value.trim().length < min || value.length > max || /[\u0000-\u001f\u007f]/.test(value))
throw Error(`Invalid news ${name}`);
return value;
};
const instant = (value, name) => {
const text = clean(value, name, 40), time = Date.parse(text);
if (!Number.isFinite(time) || new Date(time).toISOString() !== text)
throw Error(`Invalid news ${name}`);
return text;
};
const kstDate = (instantValue) => {
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(instantValue));
const pick = (type) => parts.find(p => p.type === type)?.value ?? '';
return date(`${pick('year')}-${pick('month')}-${pick('day')}`);
};
const acceptedHost = (hostname) => ['news.einfomax.co.kr', 'kr.investing.com', 'www.investing.com', 'investing.com'].includes(hostname) || ['bok.or.kr', 'moef.go.kr', 'federalreserve.gov', 'treasury.gov', 'kostat.go.kr'].some(x => hostname === x || hostname.endsWith(`.${x}`));
const fingerprint = (a) => sha256(JSON.stringify(a));
export function newsRead(raw) {
if (raw.length > 100_000)
throw Error('News evidence exceeds 100 KB character limit');
const doc = JSON.parse(raw);
if (!doc || typeof doc !== 'object' || Array.isArray(doc) || doc.version !== 1)
throw Error('Unsupported news evidence');
if (Object.keys(doc).some(k => !['version', 'observationDate', 'retrievedAt', 'articles'].includes(k)))
throw Error('Unknown news evidence field');
const observationDate = date(clean(doc.observationDate, 'observationDate', 10)), retrievedAt = instant(doc.retrievedAt, 'retrievedAt');
if (!Array.isArray(doc.articles) || doc.articles.length < 1 || doc.articles.length > 5)
throw Error('News evidence requires 1..5 articles');
const articles = doc.articles.map((value, index) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw Error(`Invalid news article ${index + 1}`);
const x = value;
if (Object.keys(x).some(k => !['publisher', 'title', 'url', 'publishedAt', 'sourceType', 'marketDirection', 'relation', 'factor', 'evidenceSummary', 'sourceFingerprint'].includes(k)))
throw Error(`Unknown news article field ${index + 1}`);
const publisher = clean(x.publisher, 'publisher', 80, 2), title = clean(x.title, 'title', 220, 5), publishedAt = instant(x.publishedAt, 'publishedAt'), factor = clean(x.factor, 'factor', 140, 2), evidenceSummary = clean(x.evidenceSummary, 'evidenceSummary', 280, 20);
if (typeof x.sourceType !== 'string' || !sourceTypes.has(x.sourceType) || typeof x.marketDirection !== 'string' || !directions.has(x.marketDirection) || typeof x.relation !== 'string' || !relations.has(x.relation))
throw Error(`Invalid news classification ${index + 1}`);
let parsed;
try {
parsed = new URL(clean(x.url, 'url', 2048));
}
catch {
throw Error(`Invalid news URL ${index + 1}`);
}
const host = parsed.hostname.toLowerCase();
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port || !acceptedHost(host))
throw Error(`Unapproved news host ${index + 1}`);
if ((host === 'news.einfomax.co.kr' && publisher !== '연합인포맥스') || ((host === 'investing.com' || host.endsWith('.investing.com')) && publisher !== 'Investing.com'))
throw Error(`Publisher/host mismatch ${index + 1}`);
if (Date.parse(publishedAt) > Date.parse(retrievedAt) || kstDate(publishedAt) !== observationDate)
throw Error(`News date alignment mismatch ${index + 1}`);
const articleBase = { publisher, title, url: parsed.toString(), publishedAt, sourceType: x.sourceType, marketDirection: x.marketDirection, relation: x.relation, factor, evidenceSummary };
const sourceFingerprint = fingerprint(articleBase);
if (x.sourceFingerprint !== undefined && x.sourceFingerprint !== sourceFingerprint)
throw Error(`News fingerprint mismatch ${index + 1}`);
if (articleBase.sourceType === 'official-release' && (articleBase.marketDirection !== 'context' || articleBase.relation !== 'contextual'))
throw Error(`Official release must be contextual ${index + 1}`);
if (articleBase.relation === 'explicit' && articleBase.marketDirection === 'context')
throw Error(`Explicit article requires market direction ${index + 1}`);
return { ...articleBase, sourceFingerprint };
});
if (new Set(articles.map(x => x.url)).size !== articles.length || new Set(articles.map(x => x.sourceFingerprint)).size !== articles.length)
throw Error('Duplicate news article');
return { version: 1, observationDate, retrievedAt, articles };
}
function direction(a) {
if (!a.changes)
return null;
const short = a.changes[1], long = a.changes[3], eps = 1e-7;
if (Math.abs(short) < eps && Math.abs(long) < eps)
return 'flat';
if (short > eps && long > eps)
return 'higher';
if (short < -eps && long < -eps)
return 'lower';
return 'mixed';
}
export function analyzeNews(evidence, a) {
const curveDirection = direction(a);
if (!evidence)
return { status: 'DISABLED', confidence: null, curveDirection, commentary: [], eligible: [], context: [], excluded: [], notes: [] };
const normalized = newsRead(JSON.stringify(evidence));
if (!a.current.actual || normalized.observationDate !== a.current.actual)
throw Error('News observation date must equal the current common observation date');
const context = normalized.articles.filter(x => x.relation === 'contextual'), eligible = curveDirection ? normalized.articles.filter(x => x.relation === 'explicit' && x.marketDirection === curveDirection) : [];
const excluded = normalized.articles.filter(x => x.relation === 'explicit' && x.marketDirection !== curveDirection), notes = [];
if (excluded.length)
notes.push(`${excluded.length}개 기사는 관측된 3Y·10Y 방향과 불일치하여 원인 코멘트에서 제외`);
let status = 'UNATTRIBUTED', confidence = null;
if (eligible.length) {
status = 'SOURCED';
confidence = eligible.some(x => x.sourceType === 'market-close' && new URL(x.url).hostname === 'news.einfomax.co.kr') ? 'HIGH' : new Set(eligible.map(x => new URL(x.url).hostname)).size >= 2 ? 'MEDIUM' : 'LOW';
}
else if (context.length)
status = 'CONTEXT_ONLY';
const commentary = eligible.slice(0, 3).map(x => `시장 배경: ${x.factor} (${x.publisher} 「${x.title}」).`);
if (status === 'CONTEXT_ONLY')
commentary.push('같은 관측일의 공식 발표는 확인했지만 시장 움직임의 직접 원인으로 연결하지 않았다.');
if (status === 'UNATTRIBUTED')
commentary.push('관측 방향과 날짜가 일치하는 근거 기사를 확인하지 못했다.');
return { status, confidence, curveDirection, commentary, eligible, context, excluded, notes };
}
export function newsTemplate(observationDate, retrievedAt) {
date(observationDate);
instant(retrievedAt, 'retrievedAt');
const publishedAt = new Date(`${observationDate}T00:00:00+09:00`).toISOString();
return JSON.stringify({ version: 1, observationDate, retrievedAt, articles: [{ publisher: '연합인포맥스', title: '기사 제목을 입력', url: 'https://news.einfomax.co.kr/news/articleView.html?idxno=0000000', publishedAt, sourceType: 'market-close', marketDirection: 'mixed', relation: 'explicit', factor: '기사에서 명시한 시장 배경을 짧게 의역', evidenceSummary: '기사에서 시장 배경을 설명한 근거를 20~280자로 의역' }] }, null, 2) + '\n';
}
scripts/render.mjs
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { analyze, TENORS } from './core.mjs';
import { analyzeNews } from './news.mjs';
const h = (s) => createHash('sha256').update(s).digest('hex');
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const md = (s) => String(s).replace(/[\\`*_[\]<>]/g, '\\$&');
const n = (v, d = 2) => v === null ? '—' : v.toFixed(d);
const signed = (v) => v === null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
const trend = (v) => v === null ? '판정 불가' : v > 0 ? '상승' : v < 0 ? '하락' : '보합';
const spreadTrend = (v) => v === null ? '판정 불가' : v > 0 ? '확대' : v < 0 ? '축소' : '보합';
export function marketCsv(p, a) {
const rows = ['구분,만기,현재 요청일,현재 관측일,현재 금리(%),비교 요청일,비교 관측일,비교 금리(%),변화(bp)'];
TENORS.forEach((t, i) => rows.push([
'금리', `${t}Y`, a.current.requested, a.current.actual ?? '', a.current.values?.[i] ?? '', a.previous.requested, a.previous.actual ?? '', a.previous.values?.[i] ?? '', a.changes?.[i] ?? ''
].join(',')));
a.spreads.forEach(s => rows.push(['스프레드', s.name, a.current.requested, a.current.actual ?? '', s.current ?? '', a.previous.requested, a.previous.actual ?? '', s.previous ?? '', s.change ?? ''].join(',')));
return '\uFEFF' + rows.join('\r\n') + '\r\n';
}
export function brief(p, a, news = analyzeNews(p.newsEvidence, a)) {
const current = a.current.actual ?? '확인 불가', previous = a.previous.actual ?? '확인 불가';
const lines = [`# ${md(p.label)}`, ``, `- 상태: **${a.status}**`, `- 요청 기준일: ${p.asOf} / 사용 관측일: ${current}`,
`- 비교 기준일: ${p.base} / 사용 관측일: ${previous}`, ''];
if (a.changes && a.current.values && a.previous.values) {
const i3 = TENORS.indexOf(3), i5 = TENORS.indexOf(5), i10 = TENORS.indexOf(10), s103 = a.spreads.find(s => s.name === '10년−3년');
lines.push('## 핵심 변화', '', `- 국고채 3년은 ${signed(a.changes[i3])}bp ${trend(a.changes[i3])}, 5년은 ${signed(a.changes[i5])}bp ${trend(a.changes[i5])}, 10년은 ${signed(a.changes[i10])}bp ${trend(a.changes[i10])}.`, `- 10년−3년 스프레드는 ${n(s103.current)}bp이며 비교일보다 ${n(Math.abs(s103.change ?? 0))}bp ${spreadTrend(s103.change)}.`, `- 곡선 움직임: **${a.movement}**.`, '');
}
else
lines.push('## 결과 제한', '', '- 비교 가능한 여섯 만기의 공통 관측값이 없어 변화 계산과 문장 생성을 차단했습니다.', '');
if (news.status !== 'DISABLED')
lines.push('## 시장 배경', '', `- 뉴스 근거 상태: **${news.status}**${news.confidence ? ` / 신뢰도 **${news.confidence}**` : ''}`, ...news.commentary.map(x => `- ${md(x)}`), ...news.notes.map(x => `- 주의: ${md(x)}`), ...([...news.eligible.map(x => ({ label: '채택', article: x })), ...news.context.map(x => ({ label: '맥락', article: x })), ...news.excluded.map(x => ({ label: '제외', article: x }))].map(({ label, article: x }) => `- ${label} · ${md(x.publisher)} · ${md(x.title)} · ${x.publishedAt} · [원문](${x.url}) · 근거 SHA-256 ${x.sourceFingerprint}`)), '', '- 기사에서 전한 시장 해석을 출처와 함께 요약했으며, ECOS 수치만으로 인과관계를 확정하지 않는다.', '');
lines.push('## 검증 메모', '', ...(a.notes.length ? a.notes.map(x => `- ${md(x)}`) : ['- 결측·날짜 정렬 경고 없음.']), '', '## 출처', '', ...p.receipts.map(r => `- ${r.tenor}년: ${md(r.sourceId)} / ${md(r.statCode)}·${md(r.itemCode)} ${md(r.itemName)} / ${md(r.unit)}·${md(r.cycle)} / ${r.first ?? '없음'}~${r.last ?? '없음'} (${r.count}건) / ${r.status} / 원문 SHA-256 ${r.rawSha256.join(', ') || '없음'}${r.sourceUrl ? ` / ${r.sourceUrl}` : ''}`), '', '## 해석 범위', '', '- 이 결과는 공식 통계의 기간 비교다. 실시간 호가, 원인 추정, 금리 전망 또는 투자 권유가 아니다.', '');
return lines.join('\n') + '\n';
}
function chart(a) {
if (!a.current.values || !a.previous.values)
return '<div class="empty">공통 관측값이 없어 차트를 생성하지 않았습니다.</div>';
const all = [...a.current.values, ...a.previous.values], min = Math.min(...all) - .03, max = Math.max(...all) + .03, range = Math.max(max - min, .1);
const x = (i) => 75 + i * (500 / (TENORS.length - 1)), y = (v) => 260 - (v - min) / range * 190;
const path = (values) => values.map((v, i) => `${i ? 'L' : 'M'} ${x(i)} ${y(v).toFixed(1)}`).join(' ');
const grid = [0, .25, .5, .75, 1].map(q => { const yy = 260 - q * 190, v = min + q * range; return `<line x1="62" y1="${yy}" x2="560" y2="${yy}"/><text x="54" y="${yy + 4}" text-anchor="end">${v.toFixed(2)}</text>`; }).join('');
const dots = (values, cls) => values.map((v, i) => `<circle class="${cls}" cx="${x(i)}" cy="${y(v)}" r="5"/>`).join('');
const labels = (values, other, cls) => values.map((v, i) => { const above = y(v) <= y(other[i]), baseline = y(v) + (above ? -14 : 22), top = baseline - 13; return `<g class="value-label ${cls}"><rect x="${x(i) - 24}" y="${top}" width="48" height="18" rx="5"/><text x="${x(i)}" y="${baseline}" text-anchor="middle">${v.toFixed(3)}</text></g>`; }).join('');
return `<svg viewBox="0 0 620 320" role="img" aria-label="국고채 수익률곡선 비교"><g class="grid">${grid}</g><path class="line prior" d="${path(a.previous.values)}"/><path class="line now" d="${path(a.current.values)}"/>${dots(a.previous.values, 'prior')}${dots(a.current.values, 'now')}${labels(a.previous.values, a.current.values, 'prior')}${labels(a.current.values, a.previous.values, 'now')}<g class="axis">${TENORS.map((t, i) => `<text x="${x(i)}" y="288" text-anchor="middle">${t}년</text>`).join('')}</g><g class="legend"><circle cx="405" cy="26" r="5" class="now"/><text x="416" y="30">현재</text><circle cx="485" cy="26" r="5" class="prior"/><text x="496" y="30">비교</text></g></svg>`;
}
export function html(p, a, news = analyzeNews(p.newsEvidence, a)) {
const statusClass = a.status.toLowerCase();
const allInput = p.receipts.every(r => r.status === 'input'), allEcos = p.receipts.every(r => r.status !== 'input');
const sourceFooter = allInput ? '출처: 사용자 제공 CSV · 연% · 일별 입력자료. 원자료 성격은 제공자가 확인해야 합니다.' : allEcos ? '출처: 한국은행 ECOS 817Y002 · 연% · 일별 공식 통계. 실시간 호가나 투자 권유가 아닙니다.' : '출처: 한국은행 ECOS 및 사용자 제공 CSV 혼합 자료. 각 만기의 출처를 확인해야 합니다.';
const rows = TENORS.map((t, i) => `<tr><td>${t}년</td><td>${n(a.previous.values?.[i] ?? null, 3)}</td><td>${n(a.current.values?.[i] ?? null, 3)}</td><td class="${(a.changes?.[i] ?? 0) > 0 ? 'up' : (a.changes?.[i] ?? 0) < 0 ? 'down' : ''}">${signed(a.changes?.[i] ?? null)}</td></tr>`).join('');
const spreadRows = a.spreads.map(s => `<tr><td>${esc(s.name)}</td><td>${n(s.previous)}</td><td>${n(s.current)}</td><td>${signed(s.change)}</td></tr>`).join('');
const sourceRows = p.receipts.map(r => `<tr><td>${r.tenor}년</td><td>${esc(r.sourceId)}</td><td>${esc(r.statCode)} / ${esc(r.itemCode)}<br><span class="sub">${esc(r.itemName)}</span></td><td>${esc(r.unit)} / ${esc(r.cycle)}</td><td>${esc(r.first ?? '없음')}<br>${esc(r.last ?? '없음')} · ${r.count}건</td><td>${esc(r.retrievedAt)}<br><strong>${esc(r.status)}</strong></td><td>${r.sourceUrl ? `<a href="${esc(r.sourceUrl)}" rel="noreferrer">공식 원천</a>` : '로컬 입력'}</td></tr>`).join('');
const newsRows = [...news.eligible.map(x => ({ label: '채택', article: x })), ...news.context.map(x => ({ label: '맥락', article: x })), ...news.excluded.map(x => ({ label: '제외', article: x }))];
const newsSection = news.status === 'DISABLED' ? '' : `<section class="card" style="margin-top:18px"><h2>시장 배경</h2><div class="metric">${news.status}${news.confidence ? ` · ${news.confidence}` : ''}</div><ul class="notes">${[...news.commentary, ...news.notes.map(x => `주의: ${x}`)].map(x => `<li>${esc(x)}</li>`).join('')}</ul><div class="scroll"><table class="news-table"><colgroup><col class="news-result"><col class="news-publisher"><col class="news-title"><col class="news-time"><col class="news-summary"></colgroup><thead><tr><th>판정</th><th>매체</th><th>기사</th><th>게시 UTC</th><th>근거 요약</th></tr></thead><tbody>${newsRows.map(({ label, article: x }) => `<tr><td>${label}</td><td>${esc(x.publisher)}</td><td><a href="${esc(x.url)}" rel="noreferrer">${esc(x.title)}</a></td><td>${esc(x.publishedAt)}</td><td>${esc(x.evidenceSummary)}</td></tr>`).join('')}</tbody></table></div><p class="sub">기사에서 전한 시장 해석을 출처와 함께 요약했으며, ECOS 수치만으로 인과관계를 확정하지 않습니다.</p></section>`;
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(p.label)}</title><style>
:root{--ink:#172033;--muted:#687386;--paper:#f5f3ee;--card:#fff;--blue:#165dff;--orange:#e97522;--line:#dfe4eb;--good:#17745c;--warn:#a15c00;--bad:#a9363e}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}.wrap{max-width:1240px;margin:0 auto;padding:36px 22px 56px}header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;margin-bottom:20px}h1{font-size:30px;line-height:1.16;margin:4px 0 8px;letter-spacing:-.03em}.eyebrow{font-size:12px;font-weight:700;color:var(--blue);letter-spacing:.08em}.meta{color:var(--muted);font-size:13px}.badge{padding:7px 12px;border-radius:999px;font-size:13px;font-weight:800;background:#e8f5f0;color:var(--good)}.badge.incomplete{background:#fff0d9;color:var(--warn)}.badge.blocked{background:#fae8e9;color:var(--bad)}.grid2{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(340px,.7fr);gap:16px}.card{min-width:0;background:var(--card);border:1px solid var(--line);border-radius:15px;padding:19px;box-shadow:0 8px 24px #1a243008}.scroll{overflow-x:auto}h2{font-size:17px;margin:0 0 13px}table{width:100%;border-collapse:collapse;font-size:13px;font-variant-numeric:tabular-nums}th,td{text-align:right;padding:8px 7px;border-bottom:1px solid var(--line);vertical-align:middle}th:first-child,td:first-child{text-align:left}th{color:var(--muted);font-size:11px;font-weight:700;line-height:1.25;white-space:nowrap;text-transform:uppercase}.rates-table td,.spread-table td{white-space:nowrap}.news-table,.source-table{table-layout:fixed;min-width:920px}.news-table th,.news-table td,.source-table th,.source-table td{text-align:left;overflow-wrap:break-word}.news-table .news-result{width:7%}.news-table .news-publisher{width:12%}.news-table .news-title{width:24%}.news-table .news-time{width:18%}.news-table .news-summary{width:39%}.source-table .source-tenor{width:7%}.source-table .source-provider{width:18%}.source-table .source-code{width:21%}.source-table .source-unit{width:10%}.source-table .source-range{width:18%}.source-table .source-retrieved{width:18%}.source-table .source-link{width:8%}.metric{font-size:23px;line-height:1.25;font-weight:800}.sub{color:var(--muted);font-size:12px}.notes{margin:0;padding-left:19px}.notes li+li{margin-top:6px}svg{display:block;width:100%;height:auto}.grid line{stroke:#e5e9ef}.grid text,.axis text,.legend text{fill:#687386;font-size:11px}.line{fill:none;stroke-width:2.5}.line.now{stroke:var(--blue)}.line.prior{stroke:var(--orange);stroke-dasharray:7 5}circle.now{fill:var(--blue)}circle.prior{fill:var(--orange)}.value-label rect{fill:#fff;stroke-width:1}.value-label text{font-size:10.5px;font-weight:750;font-variant-numeric:tabular-nums}.value-label.now rect{stroke:#9db8ff}.value-label.now text{fill:#174fc5}.value-label.prior rect{stroke:#f0bc95}.value-label.prior text{fill:#a84e12}.empty{padding:80px 20px;text-align:center;color:var(--muted)}footer{margin-top:16px;color:var(--muted);font-size:11px}@media(max-width:820px){.grid2{grid-template-columns:1fr}header{display:block}.badge{display:inline-block;margin-top:12px}}@media(max-width:560px){.wrap{padding:24px 12px 40px}.card{padding:15px}h1{font-size:25px}}
</style></head><body><main class="wrap"><header><div><div class="eyebrow">KOREAN RATES · VERIFIED SNAPSHOT</div><h1>${esc(p.label)}</h1><div class="meta">요청 ${esc(p.base)} → ${esc(p.asOf)} · 관측 ${esc(a.previous.actual ?? '없음')} → ${esc(a.current.actual ?? '없음')}</div></div><div class="badge ${statusClass}">${a.status}</div></header><section class="grid2"><article class="card"><h2>수익률곡선</h2>${chart(a)}</article><article class="card"><h2>곡선 판정</h2><div class="metric">${esc(a.movement)}</div><p class="sub">3년과 10년 변화 및 10년−3년 스프레드를 기준으로 한 기계적 분류</p><table class="spread-table"><thead><tr><th>구간</th><th>비교</th><th>현재</th><th>변화(bp)</th></tr></thead><tbody>${spreadRows}</tbody></table></article></section><section class="card" style="margin-top:16px"><h2>만기별 금리</h2><table class="rates-table"><thead><tr><th>만기</th><th>비교일(%)</th><th>현재일(%)</th><th>변화(bp)</th></tr></thead><tbody>${rows}</tbody></table></section>${newsSection}<section class="card" style="margin-top:16px"><h2>검증 메모</h2><ul class="notes">${(a.notes.length ? a.notes : ['결측·날짜 정렬 경고 없음.']).map(x => `<li>${esc(x)}</li>`).join('')}</ul></section><section class="card" style="margin-top:16px"><h2>원천·검증 추적</h2><div class="scroll"><table class="source-table" data-provenance="true"><colgroup><col class="source-tenor"><col class="source-provider"><col class="source-code"><col class="source-unit"><col class="source-range"><col class="source-retrieved"><col class="source-link"></colgroup><thead><tr><th>만기</th><th>제공기관</th><th>통계/항목</th><th>단위/주기</th><th>관측 범위</th><th>수집 UTC/방식</th><th>링크</th></tr></thead><tbody>${sourceRows}</tbody></table></div></section><footer>${esc(sourceFooter)}</footer></main></body></html>`;
}
const artifactNames = ['market-table.csv', 'brief.md', 'report.html', 'evidence.mjson', 'status.mjson'];
function statusJson(p, a, news) { return JSON.stringify({ status: a.status, notes: a.notes, usable: a.status === 'READY', news: { status: news.status, confidence: news.confidence, curveDirection: news.curveDirection, notes: news.notes, sources: [...news.eligible.map(x => ({ disposition: 'eligible', ...x })), ...news.context.map(x => ({ disposition: 'context', ...x })), ...news.excluded.map(x => ({ disposition: 'excluded', ...x }))].map(({ disposition, publisher, title, url, publishedAt, sourceFingerprint }) => ({ disposition, publisher, title, url, publishedAt, sourceFingerprint })) }, sources: p.receipts.map(({ tenor, status, sourceId, statCode, itemCode, unit, cycle, count, first, last, retrievedAt, rawSha256 }) => ({ tenor, status, sourceId, statCode, itemCode, unit, cycle, count, first, last, retrievedAt, rawSha256 })) }, null, 2) + '\n'; }
export async function writeArtifacts(out, p) {
const a = analyze(p), news = analyzeNews(p.newsEvidence, a), parent = dirname(out), stage = join(parent, `.${basename(out)}.stage-${process.pid}-${randomUUID()}`);
await mkdir(parent, { recursive: true });
try {
await stat(out);
throw Error(`Output path already exists: ${out}`);
}
catch (e) {
if (e.code !== 'ENOENT')
throw e;
}
try {
await mkdir(stage, { recursive: false });
const evidence = JSON.stringify(p, null, 2) + '\n';
const files = { 'market-table.csv': marketCsv(p, a), 'brief.md': brief(p, a, news), 'report.html': html(p, a, news), 'evidence.mjson': evidence, 'status.mjson': statusJson(p, a, news) };
const manifest = { version: 5, engine: p.engine, status: a.status, newsStatus: news.status, evidenceSha256: h(evidence), files: {} };
for (const name of artifactNames) {
const content = files[name];
if (/NaN|undefined/.test(content))
throw Error(`${name}: invalid token`);
manifest.files[name] = h(content);
await writeFile(join(stage, name), content);
}
await writeFile(join(stage, 'manifest.mjson'), JSON.stringify(manifest, null, 2) + '\n');
await validateArtifacts(stage);
await rename(stage, out);
return manifest;
}
catch (e) {
await rm(stage, { recursive: true, force: true });
throw e;
}
}
export async function validateArtifacts(dir) {
const manifest = JSON.parse(await readFile(join(dir, 'manifest.mjson'), 'utf8'));
if (manifest.version !== 5 || manifest.engine !== 'korean-interest-rate-analysis/5' || !manifest.files || typeof manifest.files !== 'object')
throw Error('Unsupported manifest');
const expected = [...artifactNames].sort(), declared = Object.keys(manifest.files).sort(), present = (await readdir(dir)).sort();
if (JSON.stringify(declared) !== JSON.stringify(expected) || JSON.stringify(present) !== JSON.stringify([...expected, 'manifest.mjson'].sort()) || Object.values(manifest.files).some(x => typeof x !== 'string' || !/^[a-f0-9]{64}$/.test(x)) || !/^[a-f0-9]{64}$/.test(manifest.evidenceSha256))
throw Error('Manifest file contract mismatch');
const evidence = await readFile(join(dir, 'evidence.mjson'), 'utf8');
if (h(evidence) !== manifest.evidenceSha256)
throw Error('Evidence hash mismatch');
const p = JSON.parse(evidence), a = analyze(p), news = analyzeNews(p.newsEvidence, a);
if (a.status !== manifest.status || news.status !== manifest.newsStatus)
throw Error('Status mismatch');
for (const [name, digest] of Object.entries(manifest.files)) {
const content = await readFile(join(dir, name), 'utf8');
if (h(content) !== digest)
throw Error(`${name}: hash mismatch`);
}
const mdFile = await readFile(join(dir, 'brief.md'), 'utf8'), csv = await readFile(join(dir, 'market-table.csv'), 'utf8'), page = await readFile(join(dir, 'report.html'), 'utf8'), status = await readFile(join(dir, 'status.mjson'), 'utf8');
if (mdFile !== brief(p, a, news) || csv !== marketCsv(p, a) || page !== html(p, a, news) || status !== statusJson(p, a, news))
throw Error('Rendered artifact is not reproducible');
if (a.status === 'READY' && (!page.includes('<svg') || csv.trim().split(/\r?\n/).length !== 1 + TENORS.length + a.spreads.length || !mdFile.includes('## 핵심 변화') || !page.includes('data-provenance="true"') || !page.includes('class="value-label') || page.includes('원문 SHA-256') || p.receipts.some(r => r.sourceUrl && !page.includes(r.sourceUrl))))
throw Error('READY output contract is incomplete');
if (a.status !== 'READY' && !mdFile.includes('## 결과 제한'))
throw Error('Non-ready output must state its limitation');
}
scripts/schedule.mjs
import { date, days, shift } from './core.mjs';
const hosts = {
KR: { auction: ['ktb.moef.go.kr'], policy: ['bok.or.kr', 'www.bok.or.kr'] },
US: { auction: ['home.treasury.gov', 'www.treasurydirect.gov', 'treasurydirect.gov'], policy: ['www.federalreserve.gov', 'federalreserve.gov'] }
};
const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
const iso = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
function quotedDate(quoted, eventDate, excerpt) {
if (!excerpt.includes(quoted))
return false;
const weekday = quoted.match(/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),/);
if (weekday && new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', weekday: 'long' }).format(new Date(`${eventDate}T00:00:00Z`)) !== weekday[1])
return false;
const isoOrDots = quoted.match(/^(\d{4})[-.](\d{2})[-.](\d{2})\.?$/);
if (isoOrDots)
return `${isoOrDots[1]}-${isoOrDots[2]}-${isoOrDots[3]}` === eventDate;
const ko = quoted.match(/^(\d{1,2})월\s*(\d{1,2})일$/);
if (ko)
return excerpt.includes(eventDate.slice(0, 4)) && Number(ko[1]) === Number(eventDate.slice(5, 7)) && Number(ko[2]) === Number(eventDate.slice(8));
const fed = quoted.match(/^([A-Z][a-z]+)\s+(\d{1,2})-(\d{1,2})$/);
if (fed) {
if (Number(fed[2]) >= Number(fed[3]))
return false;
const parsed = Date.parse(`${fed[1]} ${fed[3]}, ${eventDate.slice(0, 4)} GMT`);
return excerpt.includes(eventDate.slice(0, 4)) && Number.isFinite(parsed) && new Date(parsed).toISOString().slice(0, 10) === eventDate;
}
const english = Date.parse(`${quoted} GMT`);
return Number.isFinite(english) && new Date(english).toISOString().slice(0, 10) === eventDate;
}
function validateEvent(e, anchorDate) {
if (!e || typeof e !== 'object' || !['KR', 'US'].includes(e.market) || !['auction', 'policy'].includes(e.kind) || date(e.date) !== e.date || e.date < anchorDate || e.date > shift(anchorDate, 45))
throw Error('Schedule event date, market, or kind mismatch');
if (!text(e.sourceUrl, 500) || !text(e.sourceExcerpt, 500) || !text(e.sourceDateText, 80))
throw Error('Schedule source text missing or unsafe');
let host;
try {
const url = new URL(e.sourceUrl);
if (url.protocol !== 'https:' || url.username || url.password || url.toString() !== e.sourceUrl || /[\s()<>{}]/.test(e.sourceUrl))
throw Error();
host = url.hostname.toLowerCase();
}
catch {
throw Error('Schedule source URL must be official HTTPS');
}
if (!hosts[e.market][e.kind].includes(host))
throw Error('Schedule source host does not match market and kind');
if (!quotedDate(e.sourceDateText, e.date, e.sourceExcerpt))
throw Error('Schedule quoted date does not match event date');
if (e.kind === 'auction') {
if (!Number.isInteger(e.tenor) || ![2, 3, 5, 7, 10, 20, 30, 50].includes(e.tenor) || (e.market === 'US' && !new RegExp(`\\b${e.tenor}-Year (?:NOTE|BOND)\\b`, 'i').test(e.sourceExcerpt)) || (e.market === 'KR' && !e.sourceExcerpt.includes(`${e.tenor}년물`)))
throw Error('Schedule auction tenor or source marker mismatch');
if (e.market === 'US') {
const dates = [...e.sourceExcerpt.matchAll(/(?:Monday|Tuesday|Wednesday|Thursday|Friday),\s+[A-Z][a-z]+\s+\d{1,2},\s+\d{4}/g)].map(match => match[0]);
if (/tentative/i.test(e.sourceUrl) && dates.length < 3)
throw Error('Tentative U.S. auction row must include announcement, auction, and settlement dates');
if (dates.length >= 3 && e.sourceDateText !== dates[1])
throw Error('Schedule U.S. auction date must be the second date in the official row');
}
}
else if (e.tenor !== undefined || (e.market === 'KR' && !/통화정책방향|금융통화위원회/.test(e.sourceExcerpt)) || (e.market === 'US' && !/FOMC|Federal Open Market Committee/.test(e.sourceExcerpt)))
throw Error('Schedule policy source marker mismatch');
}
export function readSchedule(raw, anchorDate) {
if (raw.length > 100_000)
throw Error('Schedule evidence exceeds 100 KB');
const evidence = JSON.parse(raw);
validateSchedule(evidence, anchorDate);
return evidence;
}
export function validateSchedule(evidence, anchorDate) {
date(anchorDate);
if (!evidence || typeof evidence !== 'object' || evidence.version !== 1 || evidence.anchorDate !== anchorDate || !iso(evidence.checkedAt) || !Array.isArray(evidence.events) || evidence.events.length > 30)
throw Error('Schedule evidence identity or size mismatch');
const checkedDate = evidence.checkedAt.slice(0, 10);
if (days(checkedDate, anchorDate) < -1 || days(checkedDate, anchorDate) > 14)
throw Error('Schedule verification time is too far from report date');
const seen = new Set();
for (const e of evidence.events) {
validateEvent(e, anchorDate);
const key = `${e.date}/${e.market}/${e.kind}/${e.tenor ?? ''}`;
if (seen.has(key))
throw Error('Duplicate schedule event');
seen.add(key);
}
}
export function analyzeSchedule(evidence, anchorDate) {
date(anchorDate);
if (!evidence)
return { status: 'UNVERIFIED', anchorDate, checkedAt: null, events: [], note: '공식 일정 자료를 확인하지 못해 날짜를 표시하지 않았습니다.' };
validateSchedule(evidence, anchorDate);
const label = (e) => e.kind === 'policy' ? e.market === 'KR' ? '한국은행 통화정책방향 결정회의' : 'FOMC 금리 결정' : e.market === 'KR' ? `국고채 ${e.tenor}년물 입찰` : `미국 국채 ${e.tenor}년물 입찰${/tentative/i.test(e.sourceUrl) ? ' (잠정)' : ''}`;
const events = [...evidence.events].sort((a, b) => a.date.localeCompare(b.date) || a.market.localeCompare(b.market) || a.kind.localeCompare(b.kind) || (a.tenor ?? 0) - (b.tenor ?? 0));
const limited = events.filter(e => events.filter(x => x.market === e.market && (x.date < e.date || (x.date === e.date && events.indexOf(x) <= events.indexOf(e)))).length <= 4).slice(0, 8).map(e => ({ ...e, label: label(e) }));
const markets = new Set(events.map(e => e.market));
return { status: markets.size === 2 ? 'VERIFIED' : 'PARTIAL', anchorDate, checkedAt: evidence.checkedAt, events: limited, note: markets.size === 2 ? '각 날짜는 해당 국가 현지일입니다. 예정 일정은 변경될 수 있으니 원문을 다시 확인하세요.' : markets.size === 1 ? `${markets.has('KR') ? '미국' : '한국'} 공식 일정은 확인되지 않았습니다. 표시 날짜는 해당 국가 현지일입니다.` : '조회 기간에 확인된 공식 일정이 없습니다.' };
}
scripts/ust.mjs
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { bp, classify, date, days, shift, TENORS } from './core.mjs';
const FIELDS = {
2: 'BC_2YEAR', 3: 'BC_3YEAR', 5: 'BC_5YEAR',
10: 'BC_10YEAR', 20: 'BC_20YEAR', 30: 'BC_30YEAR'
};
export const UST_SOURCE = 'U.S. Treasury Daily Treasury Par Yield Curve Rates';
export const UST_SOURCE_PAGE = 'https://home.treasury.gov/resource-center/data-chart-center/interest-rates/TextView?type=daily_treasury_yield_curve';
export const UST_ENGINE = 'ktb-ust-rate-analysis/1';
const digest = (raw) => createHash('sha256').update(raw).digest('hex');
const cleanError = (error) => (error instanceof Error ? error.message : String(error)).replace(/[\r\n\t]+/g, ' ').slice(0, 240) || 'unknown error';
export function ustMonthUrl(month) {
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))
throw Error('Invalid UST month');
return `https://home.treasury.gov/resource-center/data-chart-center/interest-rates/pages/xml?data=daily_treasury_yield_curve&field_tdr_date_value_month=${month.replace('-', '')}`;
}
function oneTag(xml, name) {
const matches = [...xml.matchAll(new RegExp(`<d:${name}(?:\\s[^>]*)?>([^<]*)<\\/d:${name}>`, 'g'))];
const nulls = [...xml.matchAll(new RegExp(`<d:${name}\\s+[^>]*m:null="true"[^>]*/>`, 'g'))];
if (matches.length + nulls.length !== 1)
throw Error(`UST XML field ${name} missing or duplicated`);
if (nulls.length)
return null;
return matches[0][1].trim();
}
export function parseUstXml(raw, month) {
if (raw.length > 5_000_000 || !raw.includes('<feed ') || !raw.includes('<title type="text">DailyTreasuryYieldCurveRateData</title>'))
throw Error('Invalid UST XML feed');
const entries = [...raw.matchAll(/<entry>([\s\S]*?)<\/entry>/g)];
if (!entries.length)
throw Error(`UST feed has no entries for ${month}`);
const rows = [];
const seen = new Set();
for (const [, entry] of entries) {
const props = [...entry.matchAll(/<m:properties>([\s\S]*?)<\/m:properties>/g)];
if (props.length !== 1)
throw Error('UST XML properties missing or duplicated');
const timestamp = oneTag(props[0][1], 'NEW_DATE');
if (!timestamp || !/^\d{4}-\d{2}-\d{2}T00:00:00$/.test(timestamp))
throw Error('Invalid UST observation date');
const observationDate = date(timestamp.slice(0, 10));
if (!observationDate.startsWith(`${month}-`) || seen.has(observationDate))
throw Error('UST month/date mismatch or duplicate observation');
seen.add(observationDate);
const yields = {};
let complete = true;
for (const tenor of TENORS) {
const value = oneTag(props[0][1], FIELDS[tenor]);
if (value === null || value === '') {
complete = false;
continue;
}
if (!/^-?\d+(?:\.\d+)?$/.test(value))
throw Error(`Invalid UST ${tenor}Y yield`);
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0 || numeric > 50)
throw Error(`UST ${tenor}Y yield outside annual-percent bounds`);
yields[tenor] = numeric;
}
if (complete)
rows.push({ date: observationDate, yields });
}
return rows.sort((a, b) => a.date.localeCompare(b.date));
}
export function ustMonths(base, asOf, maxLag) {
date(base);
date(asOf);
if (base >= asOf || days(asOf, base) > 366)
throw Error('UST comparison dates must be ordered within 366 days');
if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
throw Error('UST maxLag must be 0..14');
const months = new Set();
for (const anchor of [base, asOf]) {
for (let d = shift(anchor, -maxLag); d <= anchor; d = shift(d, 1))
months.add(d.slice(0, 7));
}
return [...months].sort();
}
async function fetchUst(url, month, fetcher) {
let error = Error('UST request failed');
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const response = await fetcher(url, { signal: AbortSignal.timeout(15_000), headers: { Accept: 'application/xml,text/xml' } });
if (!response.ok) {
if (response.status >= 400 && response.status < 500 && response.status !== 429)
throw Error(`UST HTTP ${response.status} (fatal)`);
throw Error(`UST HTTP ${response.status}`);
}
const raw = await response.text();
if (raw.length > 5_000_000)
throw Error('UST response exceeds 5 MB');
parseUstXml(raw, month);
return raw;
}
catch (e) {
error = e instanceof Error ? e : Error(String(e));
if (error.message.endsWith('(fatal)'))
break;
if (attempt < 3)
await new Promise(resolve => setTimeout(resolve, 200 * attempt));
}
}
throw error;
}
async function writeUstCache(file, source) {
const temporary = `${file}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, JSON.stringify(source));
await rename(temporary, file);
}
catch (error) {
await rm(temporary, { force: true });
throw error;
}
}
export async function collectUst(base, asOf, maxLag, cacheDir, useCache = true, fetcher = fetch, now = new Date()) {
if (!Number.isFinite(now.getTime()))
throw Error('Invalid UST collection clock');
const sources = [];
for (const month of ustMonths(base, asOf, maxLag)) {
const url = ustMonthUrl(month), file = join(cacheDir, `ust-${month}.mjson`);
let cached = null;
if (useCache) {
try {
const candidate = JSON.parse(await readFile(file, 'utf8'));
const age = (now.getTime() - Date.parse(candidate.retrievedAt)) / 3_600_000;
if (candidate.month === month && candidate.url === url && candidate.status === 'live' && candidate.error === undefined && candidate.cacheAgeHours === undefined && candidate.sha256 === digest(candidate.raw) && Number.isFinite(age) && age >= 0 && age <= 168) {
parseUstXml(candidate.raw, month);
cached = { ...candidate, status: 'cache', cacheAgeHours: Math.round(age * 100) / 100 };
}
}
catch { /* invalid cache is ignored */ }
}
const usParts = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit' }).formatToParts(now);
const currentUsMonth = `${usParts.find(p => p.type === 'year')?.value}-${usParts.find(p => p.type === 'month')?.value}`;
if (cached && cached.cacheAgeHours <= (month === currentUsMonth ? 6 : 24)) {
sources.push(cached);
continue;
}
try {
const raw = await fetchUst(url, month, fetcher);
const source = { month, url, retrievedAt: now.toISOString(), status: 'live', raw, sha256: digest(raw) };
sources.push(source);
if (useCache) {
try {
await mkdir(cacheDir, { recursive: true });
await writeUstCache(file, source);
}
catch { /* live data remains valid */ }
}
}
catch (e) {
if (cached)
sources.push(cached);
else
sources.push({ month, url, retrievedAt: now.toISOString(), status: 'failed', raw: '', sha256: '', error: cleanError(e) });
}
}
return { version: 1, engine: UST_ENGINE, base, asOf, maxLag, sources };
}
export function analyzeUst(packet) {
if (packet.version !== 1 || packet.engine !== UST_ENGINE || !Array.isArray(packet.sources))
throw Error('Unsupported UST evidence packet');
const expected = ustMonths(packet.base, packet.asOf, packet.maxLag);
if (packet.sources.length !== expected.length)
throw Error('UST source month count mismatch');
const rows = [];
let omittedIncompleteDates = 0;
for (let i = 0; i < expected.length; i++) {
const source = packet.sources[i];
if (!source || source.month !== expected[i] || source.url !== ustMonthUrl(source.month) || !['live', 'cache', 'failed'].includes(source.status) || !Number.isFinite(Date.parse(source.retrievedAt)) || new Date(source.retrievedAt).toISOString() !== source.retrievedAt)
throw Error('UST source identity or checksum mismatch');
if (source.status === 'failed') {
if (source.raw !== '' || source.sha256 !== '' || typeof source.error !== 'string' || !source.error || source.error.length > 240 || /[\r\n\t]/.test(source.error) || source.cacheAgeHours !== undefined)
throw Error('Invalid failed UST source receipt');
continue;
}
if (source.error !== undefined)
throw Error('Successful UST source cannot contain an error');
if (source.sha256 !== digest(source.raw))
throw Error('UST source checksum mismatch');
if (source.status === 'cache' && (!Number.isFinite(source.cacheAgeHours) || source.cacheAgeHours < 0 || source.cacheAgeHours > 168))
throw Error('Invalid UST cache age');
if (source.status === 'live' && source.cacheAgeHours !== undefined)
throw Error('Live UST source cannot claim cache age');
const parsed = parseUstXml(source.raw, source.month);
omittedIncompleteDates += [...source.raw.matchAll(/<entry>/g)].length - parsed.length;
rows.push(...parsed);
}
const point = (requested) => {
const row = rows.filter(r => r.date <= requested).at(-1) ?? null;
const lag = row ? days(requested, row.date) : null;
const reason = !row ? 'UST 공통 관측일 없음' : lag > packet.maxLag ? `UST 관측일 ${lag}일 지연: 허용 ${packet.maxLag}일 초과` : null;
return { requested, actual: row?.date ?? null, lag, yields: reason ? null : row.yields, reason };
};
const current = point(packet.asOf), previous = point(packet.base);
const comparable = current.yields && previous.yields && current.actual !== previous.actual;
const changes = comparable ? Object.fromEntries(TENORS.map(t => [t, bp(current.yields[t], previous.yields[t])])) : null;
const spreads = Object.fromEntries([['5Y-3Y', 5, 3], ['10Y-5Y', 10, 5], ['10Y-3Y', 10, 3], ['30Y-10Y', 30, 10]].map(([name, longer, shorter]) => [name, current.yields ? bp(current.yields[longer], current.yields[shorter]) : null]));
const notes = [current.reason, previous.reason, ...(current.actual === previous.actual && current.actual ? ['두 기준일이 같은 UST 관측일로 정렬되어 변화 계산을 차단했습니다.'] : []), ...(omittedIncompleteDates ? [`UST 만기 결측이 있는 ${omittedIncompleteDates}개 관측일을 제외했습니다.`] : []), ...packet.sources.filter(s => s.status === 'cache').map(s => `${s.month}: 검증된 UST 캐시 사용`), ...packet.sources.filter(s => s.status === 'failed').map(s => `${s.month}: UST 원천 수집 실패 (${s.error})`)].filter((x) => !!x);
return { status: changes ? 'READY' : current.yields || previous.yields ? 'INCOMPLETE' : 'BLOCKED', current, previous, changes, spreads, movement: changes ? classify(changes[3], changes[10]) : '판정 불가', notes, rows };
}
skill.json
{
"name": "government-bond-analysis",
"description": "한국·미국 국채시장을 공식 금리 데이터로 분석하고 커브 변화, 한·미 금리차, 확인된 국채 입찰·통화정책 일정을 같은 형식으로 정리한다. 국고채·미국 국채 금리 비교, 최근 변화, 주요 일정 또는 출처 있는 보고서를 요청할 때 사용한다. 실시간 호가, 개별 채권 가격평가, 매매 추천은 다루지 않는다.",
"profiles": [
"lookup"
],
"frontmatter": "name: government-bond-analysis\ndescription: 한국·미국 국채시장을 공식 금리 데이터로 분석하고 커브 변화, 한·미 금리차, 확인된 국채 입찰·통화정책 일정을 같은 형식으로 정리한다. 국고채·미국 국채 금리 비교, 최근 변화, 주요 일정 또는 출처 있는 보고서를 요청할 때 사용한다. 실시간 호가, 개별 채권 가격평가, 매매 추천은 다루지 않는다.\nlicense: MIT\nmetadata:\n category: finance\n locale: ko-KR\n phase: v1"
}
SKILL.md
---
name: government-bond-analysis
description: 한국·미국 국채시장을 공식 금리 데이터로 분석하고 커브 변화, 한·미 금리차, 확인된 국채 입찰·통화정책 일정을 같은 형식으로 정리한다. 국고채·미국 국채 금리 비교, 최근 변화, 주요 일정 또는 출처 있는 보고서를 요청할 때 사용한다. 실시간 호가, 개별 채권 가격평가, 매매 추천은 다루지 않는다.
license: MIT
metadata:
category: finance
locale: ko-KR
phase: v1
---
# government-bond-analysis
<!-- 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 government-bond-analysis
```
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 government-bond-analysis
```
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/government-bond-analysis/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.
src/cli.mts
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { collect, collectWindows, pointWindows, rangeWindows } from './collect.mjs';
import { ENGINE, PACKET_VERSION, TENOR_META, TENORS, analyze, bp, classify, csvRead, date, days, kstToday, sha256, shift, type Analysis, type Packet, type Receipt } from './core.mjs';
import { collectPolicyRate, dashboard, eventQueryWindows, monthComparisonDates, onOrAfter, onOrBefore, previousMonthEnd, previousQuarterEnd, previousYearEnd, trend, type CurveSnapshot, type DashboardResult, type TrendResult } from './modes.mjs';
import { analyzeNews, newsRead, newsTemplate, type NewsAnalysis } from './news.mjs';
import { writeArtifacts } from './render.mjs';
import { analyzeDual, DUAL_ENGINE, dualChat, dualSummary, writeDualArtifacts, type DualPacket } from './dual.mjs';
import { collectUst } from './ust.mjs';
import { readSchedule } from './schedule.mjs';
type Args=Record<string,string|boolean>;
function args(argv:string[]):Args{
const out:Args={},allowed=new Set(['as-of','base','from-month','to-month','preset','event-date','lookback-sessions','out','input','replay','news-evidence','news-template','schedule-evidence','cache-dir','max-lag','no-cache','text','json','help','market']);
for(let i=0;i<argv.length;i++){
const x=argv[i];if(!x.startsWith('--'))throw Error(`Unknown argument: ${x}`);const k=x.slice(2);if(!allowed.has(k))throw Error(`Unknown option: --${k}`);
if(Object.hasOwn(out,k))throw Error(`Duplicate option: --${k}`);
if(['no-cache','text','json','help'].includes(k))out[k]=true;else{const v=argv[++i];if(!v||v.startsWith('--'))throw Error(`Missing value: --${k}`);out[k]=v;}
}
return out;
}
const usage=`Usage:
node scripts/cli.mjs [--as-of YYYY-MM-DD] [--base YYYY-MM-DD] [--max-lag 0..14] [--text]
node scripts/cli.mjs --from-month YYYY-MM --to-month YYYY-MM [--text]
node scripts/cli.mjs --preset today|dashboard|previous-session|previous-week|previous-month-end|previous-quarter-end|year-start|policy-gap [--as-of YYYY-MM-DD] [--text]
node scripts/cli.mjs --event-date YYYY-MM-DD [--text]
node scripts/cli.mjs --lookback-sessions 5|20 [--as-of YYYY-MM-DD] [--text]
node scripts/cli.mjs --input FILE.csv --as-of YYYY-MM-DD --base YYYY-MM-DD [--text]
node scripts/cli.mjs --base YYYY-MM-DD --as-of YYYY-MM-DD --news-evidence FILE.mjson [--out DIR]
node scripts/cli.mjs --news-template YYYY-MM-DD
node scripts/cli.mjs --replay evidence.mjson [--text]
node scripts/cli.mjs --market both [--as-of YYYY-MM-DD] [--base YYYY-MM-DD] [--schedule-evidence FILE.mjson] [--json|--out DIR]
node scripts/cli.mjs --market both --replay evidence.mjson [--out DIR]
Add --out DIR only when a persistent HTML/CSV/evidence report bundle is requested.
`;
function summary(p:Packet,a:Analysis,news:NewsAnalysis):Record<string,unknown>{
return {result:a.status,requested:{base:p.base,asOf:p.asOf},observed:{base:a.previous.actual,asOf:a.current.actual},unit:'annual percent; changes/spreads in bp',rates:TENORS.map((tenor,i)=>({tenor:`${tenor}Y`,base:a.previous.values?.[i]??null,asOf:a.current.values?.[i]??null,changeBp:a.changes?.[i]??null})),spreads:a.spreads,movement:a.movement,notes:a.notes,news:news.status==='DISABLED'?null:{status:news.status,confidence:news.confidence,curveDirection:news.curveDirection,commentary:news.commentary,notes:news.notes,sources:[...news.eligible.map(x=>({disposition:'eligible',...x})),...news.context.map(x=>({disposition:'context',...x})),...news.excluded.map(x=>({disposition:'excluded',...x}))].map(x=>({disposition:x.disposition,publisher:x.publisher,title:x.title,url:x.url,publishedAt:x.publishedAt,sourceFingerprint:x.sourceFingerprint}))},sources:p.receipts.map(r=>({tenor:`${r.tenor}Y`,provider:r.sourceId,statCode:r.statCode,itemCode:r.itemCode,unit:r.unit,cycle:r.cycle,retrievedAt:r.retrievedAt,status:r.status,sourceUrl:r.sourceUrl,rawSha256:r.rawSha256}))};
}
function textSummary(p:Packet,a:Analysis,news:NewsAnalysis):string{
const lines=[`상태: ${a.status}`,`요청일: ${p.base} → ${p.asOf}`,`관측일: ${a.previous.actual??'없음'} → ${a.current.actual??'없음'}`];
if(a.changes&&a.previous.values&&a.current.values){
TENORS.forEach((t,i)=>lines.push(`${t}년: ${a.previous.values![i].toFixed(3)}% → ${a.current.values![i].toFixed(3)}% (${a.changes![i]>=0?'+':''}${a.changes![i].toFixed(2)}bp)`));
for(const s of a.spreads)lines.push(`${s.name}: ${s.previous?.toFixed(2)}bp → ${s.current?.toFixed(2)}bp (${(s.change??0)>=0?'+':''}${s.change?.toFixed(2)}bp)`);
lines.push(`곡선 판정: ${a.movement}`);
}
if(a.notes.length)lines.push(...a.notes.map(n=>`주의: ${n}`));
if(news.status!=='DISABLED'){lines.push(`뉴스 근거: ${news.status}${news.confidence?` / ${news.confidence}`:''}`,...news.commentary,...news.notes.map(n=>`주의: ${n}`));for(const source of [...news.eligible,...news.context,...news.excluded])lines.push(`기사: ${source.publisher} · ${source.title} · ${source.url}`);lines.push('뉴스 코멘트는 출처가 전한 시장 해석이며 ECOS 수치만으로 인과를 확정하지 않습니다.');}
lines.push(`출처: ${[...new Set(p.receipts.map(r=>r.sourceUrl||r.sourceId))].join(', ')}`);
return lines.join('\n');
}
function snapshotLines(label:string,s:CurveSnapshot):string[]{const lines=[`${label}: ${s.actual??'없음'}`];if(s.values)lines.push(TENORS.map(t=>`${t}Y ${s.values![`${t}Y`].toFixed(3)}%`).join(' · '));return lines;}
function dashboardText(d:DashboardResult):string{
const lines=[`상태: ${d.result}`,...snapshotLines('현재 관측일',d.current)];for(const [label,key] of [['전일','previousSession'],['1주','weekAgo'],['전월말','previousMonthEnd']] as const){const ref=d.references[key];lines.push(`${label} 기준: ${ref.actual??'없음'}`);if(ref.values)lines.push(d.changes[key].map(x=>`${x.tenor} ${x.changeBp!>=0?'+':''}${x.changeBp?.toFixed(2)}bp`).join(' · '));}
lines.push(`스프레드: ${Object.entries(d.spreads).map(([k,v])=>`${k} ${v?.toFixed(2)??'없음'}bp`).join(' · ')}`);if(d.policyRate)lines.push(`기준금리: ${d.policyRate.value.toFixed(2)}% (${d.policyRate.date}) · 3Y-기준금리 ${d.policyGap3YBp?.toFixed(2)}bp`);if(d.notes.length)lines.push(...d.notes.map(n=>`주의: ${n}`));lines.push('출처: https://ecos.bok.or.kr/');return lines.join('\n');
}
function trendText(t:TrendResult):string{const lines=[`상태: ${t.result}`,`기간: ${t.from??'없음'} → ${t.to??'없음'} (${t.actualSessions}/${t.requestedSessions}거래일)`];for(const s of t.stats)lines.push(`${s.tenor}: ${s.start.toFixed(3)}% → ${s.end.toFixed(3)}% (${s.netChangeBp>=0?'+':''}${s.netChangeBp.toFixed(2)}bp), 고점 ${s.high.toFixed(3)}%, 저점 ${s.low.toFixed(3)}%, 최대 일변동 ${s.largestDailyMoveBp>=0?'+':''}${s.largestDailyMoveBp.toFixed(2)}bp (${s.largestDailyMoveDate})`);if(t.notes.length)lines.push(...t.notes.map(n=>`주의: ${n}`));lines.push('출처: https://ecos.bok.or.kr/');return lines.join('\n');}
function comparison(current:CurveSnapshot,reference:CurveSnapshot,mode:string):Record<string,unknown>{const changes=TENORS.map(t=>({tenor:`${t}Y`,base:reference.values?.[`${t}Y`]??null,current:current.values?.[`${t}Y`]??null,changeBp:current.values&&reference.values?Math.round((current.values[`${t}Y`]-reference.values[`${t}Y`])*100*1e8)/1e8:null})),c3=changes.find(x=>x.tenor==='3Y')?.changeBp,c10=changes.find(x=>x.tenor==='10Y')?.changeBp;return {result:current.values&&reference.values&¤t.actual!==reference.actual?'READY':current.values||reference.values?'INCOMPLETE':'BLOCKED',mode,current,reference,changes,movement:c3!==null&&c3!==undefined&&c10!==null&&c10!==undefined?classify(c3,c10):'판정 불가',source:'https://ecos.bok.or.kr/'};}
function snapshotSpreads(s:CurveSnapshot):Record<string,number|null>{const spread=(long:string,short:string)=>s.values?bp(s.values[long],s.values[short]):null;return {'5Y-3Y':spread('5Y','3Y'),'10Y-5Y':spread('10Y','5Y'),'10Y-3Y':spread('10Y','3Y'),'30Y-10Y':spread('30Y','10Y')};}
async function main():Promise<void>{
const a=args(process.argv.slice(2));if(a.help){console.log(usage);return;}
const market=typeof a.market==='string'?a.market:'kr';if(!['kr','both'].includes(market))throw Error('--market must be kr or both');
if(a.text===true&&a.json===true)throw Error('--text and --json are mutually exclusive');
if(market==='kr'&&a.json===true)throw Error('--json is only needed with --market both; Korean mode already defaults to JSON');
if(typeof a['news-template']==='string'){if(Object.keys(a).some(k=>k!=='news-template'))throw Error('--news-template cannot be combined with other options');console.log(newsTemplate(date(a['news-template']),new Date().toISOString()));return;}
const hasMonth=typeof a['from-month']==='string'||typeof a['to-month']==='string';
const hasInput=typeof a.input==='string',hasReplay=typeof a.replay==='string';
if(hasInput&&hasReplay)throw Error('--input and --replay are mutually exclusive');
if(hasMonth&&(typeof a['from-month']!=='string'||typeof a['to-month']!=='string'))throw Error('--from-month and --to-month must be used together');
if(hasMonth&&(typeof a.base==='string'||typeof a['as-of']==='string'))throw Error('Month range cannot be combined with --base/--as-of');
if(hasInput&&(hasMonth||typeof a.base!=='string'||typeof a['as-of']!=='string'))throw Error('--input requires explicit --base and --as-of and cannot use month range');
if(hasReplay&&Object.keys(a).some(k=>!['replay','out','text','json','market'].includes(k)))throw Error('--replay accepts only --market, --out, --text and --json');
const monthDates=hasMonth?monthComparisonDates(a['from-month'] as string,a['to-month'] as string,kstToday()):null;
const asOf=monthDates?.asOf??(typeof a['as-of']==='string'?date(a['as-of']):kstToday());
const base=monthDates?.base??(typeof a.base==='string'?date(a.base):shift(asOf,-7));
const maxLag=typeof a['max-lag']==='string'?Number(a['max-lag']):7;if(!Number.isInteger(maxLag)||maxLag<0||maxLag>14)throw Error('--max-lag must be an integer from 0 to 14');
const cacheDir=resolve(typeof a['cache-dir']==='string'?a['cache-dir']:join(homedir(),'.cache','k-skill','korean-interest-rate-analysis')),useCache=a['no-cache']!==true;
if(market==='both'){
if(Object.keys(a).some(k=>!['market','base','as-of','from-month','to-month','max-lag','out','replay','schedule-evidence','cache-dir','no-cache','text','json'].includes(k)))throw Error('--market both supports dates, schedule evidence, cache, replay, --text, --json and --out only');
let dual: DualPacket;
if(hasReplay){
const evidencePath=resolve(a.replay as string),raw=await readFile(evidencePath,'utf8');
if(raw.length>100_000_000)throw Error('Dual replay evidence exceeds 100 MB');
try{const manifest=JSON.parse(await readFile(join(dirname(evidencePath),'manifest.mjson'),'utf8')) as {evidenceSha256?:string};if(manifest.evidenceSha256!==sha256(raw))throw Error('Dual replay evidence hash differs from manifest');}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}
dual=JSON.parse(raw) as DualPacket;
}else{
if(asOf>kstToday())throw Error('Live collection cannot use a future KST as-of date');
if(base>=asOf||days(asOf,base)>366)throw Error(base>=asOf?'Base must precede as-of':'Comparison window must be 366 days or less');
const [kr,us]=await Promise.all([collect(base,asOf,maxLag,cacheDir,useCache),collectUst(base,asOf,maxLag,cacheDir,useCache)]);
const schedule=typeof a['schedule-evidence']==='string'?readSchedule(await readFile(resolve(a['schedule-evidence']),'utf8'),asOf):undefined;
dual={version:1,engine:DUAL_ENGINE,ktb:{version:PACKET_VERSION,engine:ENGINE,asOf,base,maxLag,...kr,label:'한·미 국채 금리 분석 · KTB'},ust:us,...(schedule?{schedule}:{})};
}
const result=analyzeDual(dual);
if(typeof a.out==='string'){
const out=resolve(a.out);await writeDualArtifacts(out,dual);
console.log(JSON.stringify({result:result.status,out,files:['report.html','brief.md','market-table.csv','evidence.mjson','status.mjson','manifest.mjson']},null,2));
}else if(a.json===true)console.log(JSON.stringify(dualSummary(dual,result),null,2));
else console.log(dualChat(dual,result));
if(result.status!=='READY')process.exitCode=2;
return;
}
const preset=typeof a.preset==='string'?a.preset:null,eventDate=typeof a['event-date']==='string'?date(a['event-date']):null,lookback=typeof a['lookback-sessions']==='string'?Number(a['lookback-sessions']):null;
if((preset!==null||lookback!==null||eventDate!==null)&&(typeof a.input==='string'||typeof a.replay==='string'||typeof a.out==='string'||typeof a['news-evidence']==='string'||hasMonth||typeof a.base==='string'))throw Error('Preset/trend/event modes accept only date anchor, --max-lag, --cache-dir, --no-cache and --text');
if([preset!==null,lookback!==null,eventDate!==null].filter(Boolean).length>1)throw Error('--preset, --lookback-sessions and --event-date are mutually exclusive');
if(!hasReplay&&(base>=asOf||days(asOf,base)>366))throw Error(base>=asOf?'Base must precede as-of':'Comparison window must be 366 days or less');
if(preset!==null||lookback!==null||eventDate!==null){
if(asOf>kstToday())throw Error('Live collection cannot use a future KST as-of date');let output:unknown;
if(eventDate!==null){const data=await collectWindows(eventQueryWindows(eventDate,kstToday(),maxLag),cacheDir,useCache),before=onOrBefore(data.observations,shift(eventDate,-1),maxLag),after=onOrAfter(data.observations,eventDate,maxLag);output={...comparison(after,before,'event'),eventDate};console.log(JSON.stringify(output,null,2));}
else if(lookback!==null){if(![5,20].includes(lookback))throw Error('--lookback-sessions must be 5 or 20');const from=shift(asOf,lookback===20?-45:-16),data=await collectWindows(rangeWindows(from,asOf),cacheDir,useCache);output=trend(data.observations,lookback);console.log(a.text===true?trendText(output as TrendResult):JSON.stringify(output,null,2));}
else if(preset==='today'){
const data=await collectWindows(pointWindows([asOf],maxLag),cacheDir,useCache),current=onOrBefore(data.observations,asOf,maxLag);output={result:current.values?'READY':'BLOCKED',mode:'today',current,spreads:snapshotSpreads(current),source:'https://ecos.bok.or.kr/'};console.log(JSON.stringify(output,null,2));
}else if(preset==='policy-gap'){
const data=await collectWindows(pointWindows([asOf],maxLag),cacheDir,useCache),current=onOrBefore(data.observations,asOf,maxLag),policyRate=await collectPolicyRate(asOf,cacheDir,useCache),current3=current.values?.['3Y']??null,policyGap3YBp=current3!==null&&policyRate?bp(current3,policyRate.value):null,notes=[current.reason,!policyRate?'기준금리 확보 실패: 정책금리 갭 제외':null].filter((x):x is string=>!!x);output={result:current.values&&policyRate?'READY':current.values?'INCOMPLETE':'BLOCKED',mode:'policy-gap',current,policyRate,policyGap3YBp,notes,source:'https://ecos.bok.or.kr/'};console.log(JSON.stringify(output,null,2));
}else if(preset==='dashboard'||preset==='previous-session'){
const windows=[...rangeWindows(shift(asOf,-(maxLag+7)),asOf),...pointWindows([shift(asOf,-7),previousMonthEnd(asOf)],maxLag)],data=await collectWindows(windows,cacheDir,useCache),policy=preset==='dashboard'?await collectPolicyRate(asOf,cacheDir,useCache):null,d=dashboard(asOf,data.observations,maxLag,policy);
output=preset==='dashboard'?d:comparison(d.current,d.references.previousSession,'previous-session');console.log(a.text===true&&preset==='dashboard'?dashboardText(d):JSON.stringify(output,null,2));
}else{
const refs:Record<string,string>={'previous-week':shift(asOf,-7),'previous-month-end':previousMonthEnd(asOf),'previous-quarter-end':previousQuarterEnd(asOf),'year-start':previousYearEnd(asOf)};const referenceDate=refs[preset??''];if(!referenceDate)throw Error(`Unknown preset: ${preset}`);const data=await collectWindows(pointWindows([asOf,referenceDate],maxLag),cacheDir,useCache),current=onOrBefore(data.observations,asOf,maxLag),reference=onOrBefore(data.observations,referenceDate,maxLag);output=comparison(current,reference,preset!);console.log(JSON.stringify(output,null,2));
}
const result=(output as {result?:string}).result;if(result!=='READY')process.exitCode=2;return;
}
let p:Packet;
if(typeof a.replay==='string'){
const evidencePath=resolve(a.replay),raw=await readFile(evidencePath,'utf8');
if(raw.length>100_000_000)throw Error('Replay evidence exceeds 100 MB character limit');
try{const m=JSON.parse(await readFile(join(dirname(evidencePath),'manifest.mjson'),'utf8')) as {evidenceSha256?:string};if(m.evidenceSha256&&m.evidenceSha256!==sha256(raw))throw Error('Replay evidence hash differs from manifest');}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}
p=JSON.parse(raw) as Packet;if(p.version!==PACKET_VERSION||p.engine!==ENGINE)throw Error('Unsupported evidence packet');
}else if(typeof a.input==='string'){
const inputPath=resolve(a.input),raw=await readFile(inputPath,'utf8'),observations=csvRead(raw),now=new Date().toISOString();
const receipts=TENORS.map(tenor=>{const rows=observations.filter(r=>r.tenor===tenor),meta=TENOR_META[tenor];return {tenor,sourceId:`사용자 제공 CSV: ${basename(inputPath)}`,sourceUrl:'',statCode:'USER_CSV',itemCode:meta.itemCode,itemName:meta.itemName,unit:'연%',cycle:'D',retrievedAt:now,status:'input',raw:[raw],rawSha256:[sha256(raw)],count:rows.length,first:rows[0]?.date??null,last:rows.at(-1)?.date??null,windows:[]} satisfies Receipt;});
p={version:PACKET_VERSION,engine:ENGINE,asOf,base,maxLag,observations,receipts,label:'한국 금리 동향 분석'};
}else{
if(asOf>kstToday())throw Error('Live collection cannot use a future KST as-of date');
const result=await collect(base,asOf,maxLag,cacheDir,useCache);
p={version:PACKET_VERSION,engine:ENGINE,asOf,base,maxLag,...result,label:'한국 금리 동향 분석'};
}
if(typeof a['news-evidence']==='string'){const raw=await readFile(resolve(a['news-evidence']),'utf8');p={...p,newsEvidence:newsRead(raw)};}
const analysis=analyze(p),news=analyzeNews(p.newsEvidence,analysis);
if(typeof a.out==='string'){
const out=resolve(a.out),manifest=await writeArtifacts(out,p);
console.log(JSON.stringify({result:manifest.status,out,files:[...Object.keys(manifest.files),'manifest.mjson']},null,2));
}else console.log(a.text===true?textSummary(p,analysis,news):JSON.stringify(summary(p,analysis,news),null,2));
if(analysis.status!=='READY')process.exitCode=2;
}
main().catch(e=>{const message=e instanceof Error?e.message:String(e);console.error(JSON.stringify({result:'error',message},null,2));process.exitCode=1;});
src/collect.mts
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { SOURCE_ID, SOURCE_URL, TENORS, TENOR_META, date, days, normalize, parseEcos, sha256, shift, type Observation, type Receipt, type Tenor, type WindowReceipt } from './core.mjs';
interface CacheData {version:1;storedAt:string;tenor:Tenor;from:string;to:string;raw:string;rawSha256:string}
interface CacheEnvelope {data:CacheData;digest:string}
export interface RequestResult {text:string;attempts:number}
class Fatal extends Error{}
class RequestFailure extends Error {constructor(message:string,readonly attempts:number){super(message);}}
const transient=(status:number):boolean=>[408,425,429].includes(status)||status>=500;
export async function request(url:string,get:typeof fetch=fetch,pause:(ms:number)=>Promise<void>=ms=>new Promise(r=>setTimeout(r,ms)),validate?:(text:string)=>void):Promise<RequestResult>{
let last='unknown error';
for(let attempt=1;attempt<=3;attempt++){
try{
const res=await get(url,{signal:AbortSignal.timeout(10000),headers:{accept:'application/json'}});
if(!res.ok){if(!transient(res.status))throw new Fatal(`HTTP ${res.status}`);throw Error(`HTTP ${res.status}`);}
const text=await res.text();if(text.length>2_000_000)throw new Fatal('Response too large');JSON.parse(text);validate?.(text);
return {text,attempts:attempt};
}catch(e){
last=e instanceof Error?e.message:String(e);
if(e instanceof Fatal)throw new RequestFailure(last,attempt);
if(attempt===3)throw new RequestFailure(`${last} after 3 attempts`,attempt);
await pause(300*2**(attempt-1));
}
}
throw Error(last);
}
export const parse=parseEcos;
export interface QueryWindow {from:string;to:string}
export function pointWindows(points:string[],maxLag:number):QueryWindow[]{
const unique=new Map<string,QueryWindow>();
if(!Number.isInteger(maxLag)||maxLag<0||maxLag>14)throw Error('maxLag must be 0..14 calendar days');
for(const point of points){
date(point);
for(const window of rangeWindows(shift(point,-maxLag),point))unique.set(`${window.from}/${window.to}`,window);
}
return [...unique.values()].sort((a,b)=>a.from.localeCompare(b.from)||a.to.localeCompare(b.to));
}
export function rangeWindows(from:string,to:string):QueryWindow[]{
date(from);date(to);if(from>to)throw Error('Invalid collection range');const result:QueryWindow[]=[];
for(let start=from;start<=to;start=shift(start,10)){const end=shift(start,9)<to?shift(start,9):to;result.push({from:start,to:end});}
return result;
}
async function atomic(path:string,content:string):Promise<void>{const temp=`${path}.${randomUUID()}.tmp`;try{await writeFile(temp,content);await rename(temp,path);}catch(e){await rm(temp,{force:true});throw e;}}
const cacheName=(tenor:Tenor,from:string,to:string):string=>`${tenor}-${from}-${to}.mjson`;
export async function collect(base:string,asOf:string,maxLag:number,cacheDir:string,useCache=true,get:typeof fetch=fetch,now:Date=new Date(),maxCacheAgeHours=168):Promise<{observations:Observation[];receipts:Receipt[]}>{
date(base);date(asOf);if(!Number.isInteger(maxLag)||maxLag<0||maxLag>14)throw Error('maxLag must be 0..14 calendar days');
if(base>=asOf)throw Error('Base must precede as-of');if(days(asOf,base)>366)throw Error('Comparison window must be 366 days or less');
return collectWindows(pointWindows([base,asOf],maxLag),cacheDir,useCache,get,now,maxCacheAgeHours);
}
export async function collectWindows(inputSegments:QueryWindow[],cacheDir:string,useCache=true,get:typeof fetch=fetch,now:Date=new Date(),maxCacheAgeHours=168):Promise<{observations:Observation[];receipts:Receipt[]}>{
if(!Array.isArray(inputSegments)||!inputSegments.length)throw Error('At least one query window is required');
if(typeof cacheDir!=='string'||!cacheDir.trim())throw Error('cacheDir must be a non-empty path');
if(!Number.isFinite(now.getTime()))throw Error('Invalid collection clock');
if(!Number.isFinite(maxCacheAgeHours)||maxCacheAgeHours<0)throw Error('maxCacheAgeHours must be non-negative');
const unique=new Map<string,QueryWindow>();for(const segment of inputSegments){if(!segment||typeof segment.from!=='string'||typeof segment.to!=='string')throw Error('Invalid query window');date(segment.from);date(segment.to);if(segment.from>segment.to||daysBetween(segment.from,segment.to)>10)throw Error('Query windows must contain 1..10 calendar days');unique.set(`${segment.from}/${segment.to}`,segment);}
const retrievedAt=now.toISOString(),segments=[...unique.values()].sort((a,b)=>a.from.localeCompare(b.from)||a.to.localeCompare(b.to));if(useCache)await mkdir(cacheDir,{recursive:true});
const parts=await Promise.all(TENORS.map(async tenor=>{
const observations:Observation[]=[];const raw:string[]=[];const rawSha256:string[]=[];const windowReceipts:WindowReceipt[]=[];const errors:string[]=[];
for(const segment of segments){
const file=join(cacheDir,cacheName(tenor,segment.from,segment.to));let liveError='',liveAttempts=0;
try{
const key=process.env.KSKILL_BOK_ECOS_API_KEY||'sample',meta=TENOR_META[tenor];
const url=`https://ecos.bok.or.kr/api/StatisticSearch/${encodeURIComponent(key)}/json/kr/1/10/817Y002/D/${segment.from.replaceAll('-','')}/${segment.to.replaceAll('-','')}/${meta.itemCode}`;
const result=await request(url,get,undefined,text=>{parse(text,tenor,segment.from,segment.to);});liveAttempts=result.attempts;const rows=parse(result.text,tenor,segment.from,segment.to),digest=sha256(result.text);
const data:CacheData={version:1,storedAt:retrievedAt,tenor,...segment,raw:result.text,rawSha256:digest};
let cacheError:string|undefined;if(useCache)try{await atomic(file,JSON.stringify({data,digest:sha256(JSON.stringify(data))} satisfies CacheEnvelope));}catch(e){cacheError=`cache write failed (${e instanceof Error?e.message:String(e)})`;}
observations.push(...rows);raw.push(result.text);rawSha256.push(digest);windowReceipts.push({...segment,status:'live',attempts:result.attempts,...(cacheError?{error:cacheError}:{})});continue;
}catch(e){liveError=e instanceof Error?e.message:String(e);liveAttempts=e instanceof RequestFailure?e.attempts:Math.max(liveAttempts,1);}
if(useCache)try{
const envelope=JSON.parse(await readFile(file,'utf8')) as CacheEnvelope,data=envelope.data;
const age=(now.getTime()-Date.parse(data?.storedAt))/3600000;
if(!data||envelope.digest!==sha256(JSON.stringify(data))||data.version!==1||data.tenor!==tenor||data.from!==segment.from||data.to!==segment.to||data.rawSha256!==sha256(data.raw)||!Number.isFinite(age)||age<0||age>maxCacheAgeHours)throw Error('cache identity, hash, or age check failed');
const rows=parse(data.raw,tenor,segment.from,segment.to);observations.push(...rows);raw.push(data.raw);rawSha256.push(data.rawSha256);
windowReceipts.push({...segment,status:'cache',attempts:liveAttempts,cacheAgeHours:Math.round(age*100)/100,error:liveError});continue;
}catch(e){errors.push(`${segment.from}..${segment.to}: ${liveError}; cache rejected (${e instanceof Error?e.message:String(e)})`);}
else errors.push(`${segment.from}..${segment.to}: ${liveError}`);
windowReceipts.push({...segment,status:'failed',attempts:liveAttempts,error:liveError});
}
const rows=normalize(observations),modes=new Set(windowReceipts.map(w=>w.status));
const status:Receipt['status']=rows.length===0&&modes.has('failed')?'failed':modes.size===1?(modes.has('live')?'live':modes.has('cache')?'cache':'failed'):'mixed';
const meta=TENOR_META[tenor];
const receipt:Receipt={tenor,sourceId:SOURCE_ID,sourceUrl:SOURCE_URL,statCode:'817Y002',itemCode:meta.itemCode,itemName:meta.itemName,unit:'연%',cycle:'D',retrievedAt,status,raw,rawSha256,count:rows.length,first:rows[0]?.date??null,last:rows.at(-1)?.date??null,windows:windowReceipts,...(errors.length?{error:errors.join(' | ')}:{})};
return {observations:rows,receipt};
}));
return {observations:normalize(parts.flatMap(p=>p.observations)),receipts:parts.map(p=>p.receipt)};
}
function daysBetween(from:string,to:string):number{return Math.round((Date.parse(to)-Date.parse(from))/86400000)+1;}
src/core.mts
import { createHash } from 'node:crypto';
import type { NewsEvidence } from './news.mjs';
export const TENORS=[2,3,5,10,20,30] as const;
export type Tenor=typeof TENORS[number];
export const TENOR_META:Record<Tenor,{itemCode:string;itemName:string}>={
2:{itemCode:'010195000',itemName:'국고채(2년)'},
3:{itemCode:'010200000',itemName:'국고채(3년)'},
5:{itemCode:'010200001',itemName:'국고채(5년)'},
10:{itemCode:'010210000',itemName:'국고채(10년)'},
20:{itemCode:'010220000',itemName:'국고채(20년)'},
30:{itemCode:'010230000',itemName:'국고채(30년)'}
};
export const ENGINE='korean-interest-rate-analysis/5' as const;
export const PACKET_VERSION=5 as const;
export const SOURCE_ID='한국은행 ECOS 817Y002' as const;
export const SOURCE_URL='https://ecos.bok.or.kr/' as const;
export const sha256=(s:string):string=>createHash('sha256').update(s).digest('hex');
export interface Observation {date:string;tenor:Tenor;value:number}
export type SourceStatus='live'|'cache'|'mixed'|'input'|'failed';
export interface WindowReceipt {from:string;to:string;status:'live'|'cache'|'failed';attempts:number;cacheAgeHours?:number;error?:string}
export interface Receipt {
tenor:Tenor;sourceId:string;sourceUrl:string;statCode:string;itemCode:string;itemName:string;unit:string;cycle:string;
retrievedAt:string;status:SourceStatus;raw:string[];rawSha256:string[];count:number;first:string|null;last:string|null;
windows:WindowReceipt[];error?:string;
}
export interface Packet {version:5;engine:typeof ENGINE;asOf:string;base:string;maxLag:number;observations:Observation[];receipts:Receipt[];label:string;newsEvidence?:NewsEvidence}
export interface Point {requested:string;actual:string|null;lag:number|null;values:number[]|null;reason:string|null}
export interface Spread {name:string;current:number|null;previous:number|null;change:number|null}
export interface Analysis {status:'READY'|'INCOMPLETE'|'BLOCKED';current:Point;previous:Point;changes:number[]|null;spreads:Spread[];movement:string;notes:string[]}
export function date(value:string):string{
if(!/^\d{4}-\d{2}-\d{2}$/.test(value))throw Error(`Invalid date: ${value}`);
const [y,m,d]=value.split('-').map(Number),dt=new Date(Date.UTC(y,m-1,d));
if(dt.getUTCFullYear()!==y||dt.getUTCMonth()!==m-1||dt.getUTCDate()!==d)throw Error(`Invalid date: ${value}`);
return value;
}
export const shift=(d:string,n:number):string=>{
if(!Number.isInteger(n))throw Error('Date shift must be an integer');
const [y,m,day]=date(d).split('-').map(Number);
return new Date(Date.UTC(y,m-1,day+n)).toISOString().slice(0,10);
};
export const days=(a:string,b:string):number=>{
const [ay,am,ad]=date(a).split('-').map(Number),[by,bm,bd]=date(b).split('-').map(Number);
return (Date.UTC(ay,am-1,ad)-Date.UTC(by,bm-1,bd))/86400000;
};
export function kstToday(now:Date=new Date()):string{
if(!Number.isFinite(now.getTime()))throw Error('Invalid clock');
const parts=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Seoul',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(now);
const pick=(type:Intl.DateTimeFormatPartTypes):string=>parts.find(p=>p.type===type)?.value??'';
return date(`${pick('year')}-${pick('month')}-${pick('day')}`);
}
export const bp=(a:number,b:number):number=>Math.round((a-b)*100*1e8)/1e8;
export function normalize(input:unknown):Observation[]{
if(!Array.isArray(input))throw Error('Observations must be an array');
const unique=new Map<string,Observation>();
for(const item of input){
if(!item||typeof item!=='object')throw Error('Invalid observation');
const r=item as Partial<Observation>;
if(typeof r.date!=='string'||!TENORS.includes(r.tenor as Tenor)||typeof r.value!=='number'||!Number.isFinite(r.value)||r.value< -10||r.value>50)
throw Error('Invalid tenor/value (expected yield in annual percent, -10..50)');
const row:Observation={date:date(r.date),tenor:r.tenor as Tenor,value:r.value};
const key=`${row.date}/${row.tenor}`,prior=unique.get(key);
if(prior&&prior.value!==row.value)throw Error(`Conflicting duplicate ${key}`);
unique.set(key,row);
}
return [...unique.values()].sort((a,b)=>a.date.localeCompare(b.date)||a.tenor-b.tenor);
}
export function parseEcos(raw:string,tenor:Tenor,start:string,end:string):Observation[]{
date(start);date(end);if(start>end)throw Error('Invalid ECOS window');
const doc=JSON.parse(raw) as {RESULT?:{CODE?:string;MESSAGE?:string};StatisticSearch?:{list_total_count?:number;row?:Record<string,string>[]}};
if(doc.RESULT?.CODE==='INFO-200')return [];
if(doc.RESULT)throw Error(`ECOS ${doc.RESULT.CODE??'unknown'}${doc.RESULT.MESSAGE?`: ${doc.RESULT.MESSAGE}`:''}`);
const block=doc.StatisticSearch,rows=block?.row;
if(!block||!Array.isArray(rows)||!Number.isInteger(block.list_total_count)||block.list_total_count!==rows.length||rows.length>10)throw Error('Incomplete or invalid ECOS response');
const meta=TENOR_META[tenor];
const seen=new Set<string>();return normalize(rows.map(r=>{
if(!/^\d{8}$/.test(r.TIME??''))throw Error('ECOS invalid observation date');
const d=`${r.TIME.slice(0,4)}-${r.TIME.slice(4,6)}-${r.TIME.slice(6,8)}`;date(d);
if(seen.has(d))throw Error('ECOS duplicate observation date');seen.add(d);
if(r.STAT_CODE!=='817Y002'||r.STAT_NAME!=='1.3.2.1. 시장금리(일별)'||r.ITEM_CODE1!==meta.itemCode||r.ITEM_NAME1!==meta.itemName||r.UNIT_NAME!=='연%'||d<start||d>end||!/^\-?\d+(?:\.\d+)?$/.test(r.DATA_VALUE??''))throw Error('ECOS metadata/unit/range mismatch');
const value=Number(r.DATA_VALUE);if(!Number.isFinite(value))throw Error('ECOS non-numeric yield');
return {date:d,tenor,value};
}));
}
function validateReceipt(r:Receipt,rows:Observation[]):void{
if(!r||typeof r!=='object'||!TENORS.includes(r.tenor)||!['live','cache','mixed','input','failed'].includes(r.status)||typeof r.retrievedAt!=='string'||!Number.isFinite(Date.parse(r.retrievedAt))||new Date(r.retrievedAt).toISOString()!==r.retrievedAt)throw Error('Invalid source receipt');
const meta=TENOR_META[r.tenor];
if(r.itemCode!==meta.itemCode||r.itemName!==meta.itemName||r.unit!=='연%'||r.cycle!=='D')throw Error(`${r.tenor}Y receipt metadata mismatch`);
if(r.status==='input'){
if(r.statCode!=='USER_CSV'||!r.sourceId.startsWith('사용자 제공 CSV: ')||r.sourceUrl!=='')throw Error(`${r.tenor}Y input source identity mismatch`);
}else if(r.statCode!=='817Y002'||r.sourceId!==SOURCE_ID||r.sourceUrl!==SOURCE_URL)throw Error(`${r.tenor}Y ECOS source identity mismatch`);
if(!Array.isArray(r.raw)||!Array.isArray(r.rawSha256)||r.raw.length!==r.rawSha256.length||r.raw.some((raw,i)=>typeof raw!=='string'||typeof r.rawSha256[i]!=='string'||!/^[a-f0-9]{64}$/.test(r.rawSha256[i])||sha256(raw)!==r.rawSha256[i]))throw Error(`${r.tenor}Y raw source hash mismatch`);
if(!Array.isArray(r.windows)||r.windows.some(w=>{try{return !w||typeof w!=='object'||date(w.from)!==w.from||date(w.to)!==w.to||w.from>w.to||days(w.to,w.from)+1>10||!['live','cache','failed'].includes(w.status)||!Number.isInteger(w.attempts)||w.attempts<1||w.attempts>3||(w.status==='cache'&&(!Number.isFinite(w.cacheAgeHours)||w.cacheAgeHours!<0))||(w.status!=='cache'&&w.cacheAgeHours!==undefined);}catch{return true;}})||new Set(r.windows.map(w=>`${w.from}/${w.to}`)).size!==r.windows.length)throw Error(`${r.tenor}Y invalid source window`);
const own=rows.filter(x=>x.tenor===r.tenor),first=own[0]?.date??null,last=own.at(-1)?.date??null;
if(r.count!==own.length||r.first!==first||r.last!==last)throw Error(`${r.tenor}Y receipt count/range mismatch`);
if(r.status==='failed'&&r.count!==0)throw Error(`${r.tenor}Y failed receipt contains observations`);
if(r.status==='input'){
if(r.windows.length!==0||r.raw.length!==1)throw Error(`${r.tenor}Y input receipt must contain one CSV and no network windows`);
}else{
if(!r.windows.length||r.raw.length!==r.windows.filter(w=>w.status!=='failed').length)throw Error(`${r.tenor}Y source window/raw mismatch`);
const modes=new Set(r.windows.map(w=>w.status));
const expected:SourceStatus=r.count===0&&modes.has('failed')?'failed':modes.size===1?(modes.has('live')?'live':modes.has('cache')?'cache':'failed'):'mixed';
if(r.status!==expected)throw Error(`${r.tenor}Y receipt status mismatch`);
}
const fromRaw=r.status==='input'?csvRead(r.raw[0]).filter(x=>x.tenor===r.tenor):r.windows.flatMap((w,i)=>{
if(w.status==='failed')return [];
const rawIndex=r.windows.slice(0,i).filter(x=>x.status!=='failed').length;
return parseEcos(r.raw[rawIndex],r.tenor,w.from,w.to);
});
if(JSON.stringify(normalize(fromRaw))!==JSON.stringify(own))throw Error(`${r.tenor}Y observations do not match source raw data`);
}
export function classify(short:number,long:number):string{
const delta=long-short,eps=1e-7;
if(Math.abs(delta)<eps)return Math.abs(short)<eps?'보합':short>0?'평행 상승':'평행 하락';
const slope=delta>0?'스티프닝':'플래트닝';
if(short<0&&long<0)return `Bull ${slope}`;
if(short>0&&long>0)return `Bear ${slope}`;
return `혼합 움직임 / ${slope}`;
}
export function analyze(p:Packet):Analysis{
if(p.version!==PACKET_VERSION||p.engine!==ENGINE||typeof p.label!=='string'||!p.label.trim()||p.label.length>120||/[\u0000-\u001f\u007f]/.test(p.label))throw Error('Unsupported or invalid evidence packet');
date(p.asOf);date(p.base);
if(p.base>=p.asOf)throw Error('Base must precede as-of');
if(days(p.asOf,p.base)>366)throw Error('Comparison window must be 366 days or less');
if(!Number.isInteger(p.maxLag)||p.maxLag<0||p.maxLag>14)throw Error('maxLag must be 0..14 calendar days');
const rows=normalize(p.observations);
if(rows.some(r=>r.date>p.asOf))throw Error('Future observation after as-of');
if(!Array.isArray(p.receipts)||p.receipts.length!==TENORS.length||p.receipts.some(r=>!r||typeof r!=='object')||new Set(p.receipts.map(r=>r.tenor)).size!==TENORS.length)throw Error('Exactly one source receipt is required per tenor');
for(const r of p.receipts)validateReceipt(r,rows);
const at=(requested:string):Point=>{
const dates=[...new Set(rows.filter(r=>r.date<=requested).map(r=>r.date))].sort().reverse();
const actual=dates.find(d=>TENORS.every(t=>rows.some(r=>r.date===d&&r.tenor===t)))??null;
const lag=actual?days(requested,actual):null;
const reason=!actual?'여섯 만기의 공통 관측일 없음':lag!>p.maxLag?`공통 관측일이 ${lag}일 전: 허용 ${p.maxLag}일 초과`:null;
return {requested,actual,lag,values:reason?null:TENORS.map(t=>rows.find(r=>r.date===actual&&r.tenor===t)!.value),reason};
};
const current=at(p.asOf),previous=at(p.base);
const comparable=!!current.values&&!!previous.values&¤t.actual!==previous.actual;
const changes=comparable?current.values!.map((v,i)=>bp(v,previous.values![i])):null;
const spreads:Spread[]=[['5년−3년',5,3],['10년−5년',10,5],['10년−3년',10,3],['30년−10년',30,10]].map(([name,longTenor,shortTenor])=>{
const l=TENORS.indexOf(longTenor as Tenor),s=TENORS.indexOf(shortTenor as Tenor);return {
name:name as string,current:current.values?bp(current.values[l],current.values[s]):null,
previous:previous.values?bp(previous.values[l],previous.values[s]):null,
change:changes?Math.round((changes[l]-changes[s])*1e8)/1e8:null
}});
const notes:string[]=[current.reason,previous.reason].filter((v):v is string=>!!v);
for(const r of p.receipts){
if(r.status==='cache')notes.push(`${r.tenor}년: 검증된 로컬 캐시 사용`);
if(r.status==='mixed')notes.push(`${r.tenor}년: 여러 조회 구간의 실시간·캐시·실패 상태가 혼재`);
if(r.status==='failed')notes.push(`${r.tenor}년: 수집 실패${r.error?` (${r.error})`:''}`);
const failed=r.windows.filter(w=>w.status==='failed');if(failed.length&&r.status!=='failed')notes.push(`${r.tenor}년: ${failed.length}개 조회 구간 실패, 확보된 공통 관측값만 사용`);
const cacheWriteFailed=r.windows.filter(w=>w.status==='live'&&w.error?.startsWith('cache write failed'));if(cacheWriteFailed.length)notes.push(`${r.tenor}년: 실시간 원자료는 검증했으나 ${cacheWriteFailed.length}개 로컬 캐시 저장 실패`);
}
if(current.actual&¤t.actual===previous.actual)notes.push('두 기준일이 같은 관측일로 정렬되어 기간 변화 계산을 차단했습니다.');
if(current.values&¤t.lag)notes.push(`현재 기준일은 ${current.actual} 공통 관측값으로 정렬했습니다.`);
if(previous.values&&previous.lag)notes.push(`비교 기준일은 ${previous.actual} 공통 관측값으로 정렬했습니다.`);
const shortIndex=TENORS.indexOf(3),longIndex=TENORS.indexOf(10);
return {status:changes?'READY':current.values||previous.values?'INCOMPLETE':'BLOCKED',current,previous,changes,spreads,movement:changes?classify(changes[shortIndex],changes[longIndex]):'판정 불가',notes};
}
export function csvRead(text:string):Observation[]{
if(text.length>10_000_000)throw Error('CSV exceeds 10 MB character limit');
const withoutBom=text.replace(/^\uFEFF/,''),clean=withoutBom.replace(/(?:\r?\n)+$/,'');if(!clean.trim())throw Error('CSV is empty');
const lines=clean.split(/\r?\n/);if(lines.shift()!=='date,tenor,yield_pct')throw Error('CSV header must be date,tenor,yield_pct');
return normalize(lines.map((line,i)=>{
const f=line.split(',');if(f.length!==3||f.some(v=>v.trim()!==v||v==='')||!/^\d+$/.test(f[1])||!/^-?\d+(\.\d+)?$/.test(f[2]))throw Error(`Invalid CSV line ${i+2}`);
return {date:f[0],tenor:Number(f[1]),value:Number(f[2])};
}));
}
src/dual.mts
import { randomUUID, createHash } from 'node:crypto';
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { analyze, bp, TENORS, type Analysis, type Packet } from './core.mjs';
import { analyzeUst, UST_ENGINE, UST_SOURCE, UST_SOURCE_PAGE, type UstAnalysis, type UstPacket, type UstRow } from './ust.mjs';
import { analyzeSchedule, type ScheduleEvidence, type ScheduleAnalysis } from './schedule.mjs';
export const DUAL_ENGINE = 'ktb-ust-rate-analysis/1';
export interface DualPacket { version: 1; engine: typeof DUAL_ENGINE; ktb: Packet; ust: UstPacket; schedule?: ScheduleEvidence }
export interface CrossGap { requested: string; actual: string | null; ust10Y: number | null; ktb10Y: number | null; gapBp: number | null }
export interface DualAnalysis { status: 'READY' | 'INCOMPLETE' | 'BLOCKED'; ktb: Analysis; ust: UstAnalysis; cross: { current: CrossGap; previous: CrossGap; changeBp: number | null }; schedule: ScheduleAnalysis; notes: string[] }
const hash = (text: string): string => createHash('sha256').update(text).digest('hex');
const esc = (s: unknown): string => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
const mdLine = (s: string): string => s.replace(/[\r\n\t]+/g, ' ').replace(/[\\`*_\[\]<>|]/g, '\\$&');
const crossLabel = (a: DualAnalysis): string => a.cross.current.gapBp === null ? '계산 불가' : `${a.cross.current.gapBp.toFixed(2)}bp`;
export function analyzeDual(packet: DualPacket): DualAnalysis {
if (packet.version !== 1 || packet.engine !== DUAL_ENGINE || !packet.ktb || !packet.ust) throw Error('Unsupported dual-market evidence packet');
if (packet.ktb.asOf !== packet.ust.asOf || packet.ktb.base !== packet.ust.base || packet.ktb.maxLag !== packet.ust.maxLag) throw Error('KTB/UST request date or lag mismatch');
const ktb = analyze(packet.ktb), ust = analyzeUst(packet.ust), schedule = analyzeSchedule(packet.schedule, packet.ktb.asOf);
const common = (requested: string): CrossGap => {
const krDates = new Map<string, Set<number>>();
for (const row of packet.ktb.observations) {
if (!krDates.has(row.date)) krDates.set(row.date, new Set());
krDates.get(row.date)!.add(row.tenor);
}
const kr10 = new Map(packet.ktb.observations.filter(r => r.tenor === 10 && TENORS.every(t => krDates.get(r.date)?.has(t))).map(r => [r.date, r.value]));
const us10 = new Map<string, number>(ust.rows.map((r: UstRow) => [r.date, r.yields[10]]));
const actual = [...kr10.keys()].filter(d => d <= requested && us10.has(d) && (new Date(`${requested}T00:00:00Z`).getTime() - new Date(`${d}T00:00:00Z`).getTime()) / 86_400_000 <= packet.ktb.maxLag).sort().at(-1) ?? null;
const ust10Y = actual ? us10.get(actual)! : null, ktb10Y = actual ? kr10.get(actual)! : null;
return { requested, actual, ust10Y, ktb10Y, gapBp: ust10Y !== null && ktb10Y !== null ? bp(ust10Y, ktb10Y) : null };
};
const current = common(packet.ktb.asOf), previous = common(packet.ktb.base);
const changeBp = current.gapBp !== null && previous.gapBp !== null && current.actual !== previous.actual ? Math.round((current.gapBp - previous.gapBp) * 1e8) / 1e8 : null;
const status = ktb.status === 'READY' && ust.status === 'READY' ? 'READY' : ktb.status === 'BLOCKED' && ust.status === 'BLOCKED' ? 'BLOCKED' : 'INCOMPLETE';
const notes = [
'UST는 미국 재무부의 명목 par/constant-maturity 수익률이고 KTB는 한국은행 ECOS 국고채 시장금리입니다. 같은 날짜라도 종가 시점이 달라 단순 금리차는 동시 가격, 환헤지 비용 또는 상대가치를 뜻하지 않습니다.',
current.actual ? `한·미 10년 금리차의 현재 공통 관측일: ${current.actual}` : '현재 기준일의 한·미 공통 관측일이 없어 금리차를 표시하지 않았습니다.',
previous.actual ? `한·미 10년 금리차의 비교 공통 관측일: ${previous.actual}` : '비교 기준일의 한·미 공통 관측일이 없어 금리차 변화를 표시하지 않았습니다.',
...ktb.notes.map(s => `KTB: ${s}`), ...ust.notes.map(s => `UST: ${s}`)
];
return { status, ktb, ust, cross: { current, previous, changeBp }, schedule, notes };
}
function marketTable(name: string, base: Record<number, number> | null, current: Record<number, number> | null, changes: Record<number, number> | null): string {
const rows = TENORS.map(t => `<tr><td>${t}Y</td><td>${base?.[t]?.toFixed(3) ?? '—'}</td><td>${current?.[t]?.toFixed(3) ?? '—'}</td><td>${changes?.[t] === undefined ? '—' : `${changes[t] >= 0 ? '+' : ''}${changes[t].toFixed(2)}`}</td></tr>`).join('');
return `<section class="panel"><h2>${esc(name)}</h2><table><thead><tr><th>만기</th><th>비교일 연%</th><th>현재일 연%</th><th>변화 bp</th></tr></thead><tbody>${rows}</tbody></table></section>`;
}
export function dualSummary(packet: DualPacket, analysis = analyzeDual(packet)): Record<string, unknown> {
const market = (name: 'KTB' | 'UST', a: Analysis | UstAnalysis) => ({ market: name, status: a.status, observed: { base: a.previous.actual, asOf: a.current.actual }, rates: TENORS.map((tenor, i) => ({ tenor: `${tenor}Y`, base: name === 'KTB' ? (a as Analysis).previous.values?.[i] ?? null : (a as UstAnalysis).previous.yields?.[tenor] ?? null, asOf: name === 'KTB' ? (a as Analysis).current.values?.[i] ?? null : (a as UstAnalysis).current.yields?.[tenor] ?? null, changeBp: name === 'KTB' ? (a as Analysis).changes?.[i] ?? null : (a as UstAnalysis).changes?.[tenor] ?? null })), movement: a.movement, notes: a.notes });
return { result: analysis.status, requested: { base: packet.ktb.base, asOf: packet.ktb.asOf }, markets: [market('KTB', analysis.ktb), market('UST', analysis.ust)], cross: analysis.cross, schedule: analysis.schedule, notes: analysis.notes, sources: [{ market: 'KTB', url: 'https://ecos.bok.or.kr/' }, { market: 'UST', name: UST_SOURCE, url: UST_SOURCE_PAGE, feedUrls: packet.ust.sources.map(s => s.url) }] };
}
const rateText = (value: number | null | undefined): string => value === null || value === undefined ? '—' : `${value.toFixed(3)}%`;
const changeText = (value: number | null | undefined): string => value === null || value === undefined ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}bp`;
const observedText = (requested: string, actual: string | null, lag: number | null): string =>
actual ? `${actual}${lag ? ` (요청일 ${requested}보다 ${lag}일 전)` : ''}` : '확인 불가';
/** Stable, source-backed Markdown for direct conversational replies. */
export function dualChat(packet: DualPacket, a = analyzeDual(packet)): string {
const ktb3 = a.ktb.changes?.[TENORS.indexOf(3)] ?? null;
const ktb10 = a.ktb.changes?.[TENORS.indexOf(10)] ?? null;
const ust3 = a.ust.changes?.[3] ?? null;
const ust10 = a.ust.changes?.[10] ?? null;
const headline = (name: string, short: number | null, longer: number | null, movement: string): string =>
short === null || longer === null ? `${name}는 비교 가능한 두 관측일이 부족합니다.` :
`${name} 3년 ${changeText(short)}, 10년 ${changeText(longer)} (${movement}).`;
const table = TENORS.map((tenor, i) =>
`| ${tenor}년 | ${rateText(a.ktb.current.values?.[i])} | ${changeText(a.ktb.changes?.[i])} | ${rateText(a.ust.current.yields?.[tenor])} | ${changeText(a.ust.changes?.[tenor])} |`
);
const kSpread = a.ktb.spreads.find(s => s.name === '10년−3년');
const uSpread = a.ust.spreads['10Y-3Y'];
const gap = a.cross.current.gapBp === null ? '공통 관측일이 없어 계산 불가' :
`${changeText(a.cross.current.gapBp)} (공통 관측일 ${a.cross.current.actual})`;
const gapChange = a.cross.changeBp === null ? '계산 불가' : changeText(a.cross.changeBp);
const warnings = [...a.ktb.notes.map(n => `KTB: ${n}`), ...a.ust.notes.map(n => `UST: ${n}`)];
const statusNote = a.status === 'READY' ? '분석 완료' : a.status === 'INCOMPLETE' ? '일부 자료 부족, 확인된 값만 표시' : '자료 확인 불가';
return [
'## 한·미 국채 금리 분석',
`**상태:** ${statusNote} (${a.status})`,
`**요청 기간:** ${packet.ktb.base} → ${packet.ktb.asOf}`,
`**실제 관측:** KTB ${observedText(packet.ktb.base, a.ktb.previous.actual, a.ktb.previous.lag)} → ${observedText(packet.ktb.asOf, a.ktb.current.actual, a.ktb.current.lag)} · UST ${observedText(packet.ust.base, a.ust.previous.actual, a.ust.previous.lag)} → ${observedText(packet.ust.asOf, a.ust.current.actual, a.ust.current.lag)}`,
'',
'**핵심 변화**',
headline('KTB', ktb3, ktb10, a.ktb.movement),
headline('UST', ust3, ust10, a.ust.movement),
'',
'| 만기 | KTB 현재 | 변화 | UST 현재 | 변화 |',
'| --- | ---: | ---: | ---: | ---: |',
...table,
'',
'**커브·한미 차이**',
`- 10년−3년: KTB ${changeText(kSpread?.current)} (기간 변화 ${changeText(kSpread?.change)}), UST ${changeText(uSpread)}.`,
`- UST 10년−KTB 10년: ${gap} · 기간 변화 ${gapChange}.`,
'',
'**주요 일정**',
...(a.schedule.events.length ? a.schedule.events.map(e => `- ${e.date} (${e.market}) ${e.label} · [공식 일정](${e.sourceUrl})`) : [a.schedule.status === 'UNVERIFIED' ? '- 공식 일정 미확인.' : '- 조회 기간에 확인된 일정 없음.']),
`- 일정 확인: ${a.schedule.checkedAt ?? '미확인'} · ${a.schedule.status}. ${a.schedule.note}`,
'',
'**확인 사항**',
...(warnings.length ? warnings.map(w => `- ${mdLine(w)}`) : ['- 날짜·수집 관련 경고 없음.']),
'- UST는 미국 재무부의 명목 par/constant-maturity 수익률, KTB는 ECOS 국고채 시장금리입니다. 같은 날짜라도 종가 시점이 달라 단순 금리차는 동시 가격이나 환헤지 수익을 뜻하지 않습니다.',
'',
`**출처:** [한국은행 ECOS](https://ecos.bok.or.kr/) · [미국 재무부 일별 국채 수익률](${UST_SOURCE_PAGE})`
].join('\n');
}
function dualCsv(a: DualAnalysis): string {
const cells = ['market,date_role,observation_date,tenor,yield_pct,change_bp'];
for (const [market, detail] of [['KTB', a.ktb], ['UST', a.ust]] as const) {
for (const [role, point] of [['base', detail.previous], ['as_of', detail.current]] as const) {
for (const [i, tenor] of TENORS.entries()) {
const yieldValue = market === 'KTB' ? (point as Analysis['current']).values?.[i] : (point as UstAnalysis['current']).yields?.[tenor];
const change = role === 'as_of' ? market === 'KTB' ? a.ktb.changes?.[i] : a.ust.changes?.[tenor] : null;
cells.push([market, role, point.actual ?? '', tenor, yieldValue ?? '', change ?? ''].join(','));
}
}
}
return cells.join('\n') + '\n';
}
function dualHtml(packet: DualPacket, a: DualAnalysis): string {
const kBase = a.ktb.previous.values ? Object.fromEntries(TENORS.map((t, i) => [t, a.ktb.previous.values![i]])) : null;
const kCurrent = a.ktb.current.values ? Object.fromEntries(TENORS.map((t, i) => [t, a.ktb.current.values![i]])) : null;
const kChanges = a.ktb.changes ? Object.fromEntries(TENORS.map((t, i) => [t, a.ktb.changes![i]])) : null;
const sourceRows = [...packet.ktb.receipts.map(r => `<tr><td>KTB ${r.tenor}Y</td><td>${esc(r.sourceId)} · ${esc(r.statCode)}/${esc(r.itemCode)}</td><td>${esc(r.retrievedAt)}</td><td><a href="${esc(r.sourceUrl || '#')}">원천</a></td></tr>`), ...packet.ust.sources.map(s => `<tr><td>UST ${esc(s.month)}</td><td><a href="${esc(UST_SOURCE_PAGE)}">${esc(UST_SOURCE)}</a> · ${esc(s.status)}</td><td>${esc(s.retrievedAt)}</td><td><a href="${esc(s.url)}">원천</a></td></tr>`)].join('');
const cross = a.cross.current.gapBp === null ? '공통 관측일 없음' : `${a.cross.current.gapBp.toFixed(2)}bp (${a.cross.current.actual})`;
const scheduleRows = a.schedule.events.length ? a.schedule.events.map(e => `<li><strong>${esc(e.date)} (${esc(e.market)})</strong> ${esc(e.label)} · <a href="${esc(e.sourceUrl)}">공식 일정</a></li>`).join('') : a.schedule.status === 'UNVERIFIED' ? '<li>공식 일정 미확인.</li>' : '<li>조회 기간에 확인된 일정 없음.</li>';
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>한·미 국채 금리 분석</title><style>body{font:15px/1.55 system-ui,sans-serif;background:#f4f6fa;color:#172033;margin:0}.wrap{max-width:1100px;margin:auto;padding:32px 16px}h1{font-size:30px;margin:0 0 8px}.meta{color:#586477}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.panel{background:#fff;border:1px solid #dce3ec;border-radius:12px;padding:18px;margin-top:18px;overflow:auto}table{border-collapse:collapse;width:100%;min-width:400px;font-variant-numeric:tabular-nums}th,td{padding:9px 8px;border-bottom:1px solid #e8edf3;text-align:right}th:first-child,td:first-child{text-align:left}th{font-size:12px;color:#536276}p{margin:8px 0}.notes li{margin:5px 0}a{color:#235cba}@media(max-width:760px){.grid{grid-template-columns:1fr}}</style></head><body><main class="wrap"><h1>한·미 국채 금리 분석</h1><div class="meta">요청 ${esc(packet.ktb.base)} → ${esc(packet.ktb.asOf)} · 상태 ${esc(a.status)}</div><p>KTB 관측 ${esc(a.ktb.previous.actual ?? '없음')} → ${esc(a.ktb.current.actual ?? '없음')} · UST 관측 ${esc(a.ust.previous.actual ?? '없음')} → ${esc(a.ust.current.actual ?? '없음')}</p><div class="grid">${marketTable('KTB · 한국 국고채',kBase,kCurrent,kChanges)}${marketTable('UST · 미국 국채 par 수익률',a.ust.previous.yields,a.ust.current.yields,a.ust.changes)}</div><section class="panel"><h2>커브와 한·미 차이</h2><p>KTB: ${esc(a.ktb.movement)} · UST: ${esc(a.ust.movement)}</p><p>UST 10Y − KTB 10Y: ${esc(cross)}${a.cross.changeBp === null ? '' : ` · 기간 변화 ${a.cross.changeBp >= 0 ? '+' : ''}${a.cross.changeBp.toFixed(2)}bp`}</p></section><section class="panel"><h2>주요 일정</h2><ul class="notes">${scheduleRows}</ul><p class="meta">일정 확인 ${esc(a.schedule.checkedAt ?? '미확인')} · ${esc(a.schedule.status)}. ${esc(a.schedule.note)}</p></section><section class="panel"><h2>검증 메모</h2><ul class="notes">${a.notes.map(n => `<li>${esc(n)}</li>`).join('')}</ul></section><section class="panel"><h2>원천·수집 기록</h2><table><thead><tr><th>구분</th><th>제공기관·항목</th><th>수집 UTC</th><th>링크</th></tr></thead><tbody>${sourceRows}</tbody></table></section></main></body></html>`;
}
const FILES = ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson'] as const;
function scheduleBrief(a: DualAnalysis): string {
return `## 주요 일정\n\n${a.schedule.events.length ? a.schedule.events.map(e => `- ${e.date} (${e.market}) ${e.label} · ${e.sourceUrl}`).join('\n') : a.schedule.status === 'UNVERIFIED' ? '- 공식 일정 미확인.' : '- 조회 기간에 확인된 일정 없음.'}\n\n일정 확인: ${a.schedule.checkedAt ?? '미확인'} · ${a.schedule.status}. ${a.schedule.note}\n`;
}
export async function writeDualArtifacts(out: string, packet: DualPacket): Promise<void> {
const a = analyzeDual(packet), parent = dirname(out), stage = join(parent, `.${basename(out)}.stage-${process.pid}-${randomUUID()}`);
await mkdir(parent, { recursive: true });
try { await stat(out); throw Error(`Output path already exists: ${out}`); } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
try {
await mkdir(stage);
const summary = dualSummary(packet, a);
const files: Record<(typeof FILES)[number], string> = {
'brief.md': `# 한·미 국채 금리 분석\n\n요청 ${packet.ktb.base} → ${packet.ktb.asOf} · ${a.status}\n\nKTB 관측 ${a.ktb.previous.actual ?? '없음'} → ${a.ktb.current.actual ?? '없음'} · ${a.ktb.movement}\n\nUST 관측 ${a.ust.previous.actual ?? '없음'} → ${a.ust.current.actual ?? '없음'} · ${a.ust.movement}\n\nUST 10Y − KTB 10Y: ${crossLabel(a)} (공통 관측일 ${a.cross.current.actual ?? '없음'})\n\n${scheduleBrief(a)}\n${a.notes.map(n => `- ${mdLine(n)}`).join('\n')}\n\n원천: https://ecos.bok.or.kr/ · ${UST_SOURCE_PAGE}\n`,
'market-table.csv': dualCsv(a),
'report.html': dualHtml(packet, a),
'evidence.mjson': JSON.stringify(packet, null, 2) + '\n',
'status.mjson': JSON.stringify({ result: a.status, summary }, null, 2) + '\n'
};
const manifest = { version: 1, engine: DUAL_ENGINE, status: a.status, evidenceSha256: hash(files['evidence.mjson']), files: Object.fromEntries(FILES.map(name => [name, hash(files[name])])) };
for (const name of FILES) await writeFile(join(stage, name), files[name]);
await writeFile(join(stage, 'manifest.mjson'), JSON.stringify(manifest, null, 2) + '\n');
await validateDualArtifacts(stage);
await rename(stage, out);
} catch (e) { await rm(stage, { recursive: true, force: true }); throw e; }
}
export async function validateDualArtifacts(dir: string): Promise<void> {
const manifest = JSON.parse(await readFile(join(dir, 'manifest.mjson'), 'utf8')) as { version: number; engine: string; status: string; evidenceSha256: string; files: Record<string, string> };
if (manifest.version !== 1 || manifest.engine !== DUAL_ENGINE || !manifest.files || JSON.stringify(Object.keys(manifest.files).sort()) !== JSON.stringify([...FILES].sort()) || JSON.stringify((await readdir(dir)).sort()) !== JSON.stringify([...FILES, 'manifest.mjson'].sort())) throw Error('Dual report manifest contract mismatch');
const evidence = await readFile(join(dir, 'evidence.mjson'), 'utf8');
if (manifest.evidenceSha256 !== hash(evidence)) throw Error('Dual evidence checksum mismatch');
const packet = JSON.parse(evidence) as DualPacket, a = analyzeDual(packet);
if (manifest.status !== a.status) throw Error('Dual status mismatch');
const expected: Record<(typeof FILES)[number], string> = {
'brief.md': `# 한·미 국채 금리 분석\n\n요청 ${packet.ktb.base} → ${packet.ktb.asOf} · ${a.status}\n\nKTB 관측 ${a.ktb.previous.actual ?? '없음'} → ${a.ktb.current.actual ?? '없음'} · ${a.ktb.movement}\n\nUST 관측 ${a.ust.previous.actual ?? '없음'} → ${a.ust.current.actual ?? '없음'} · ${a.ust.movement}\n\nUST 10Y − KTB 10Y: ${crossLabel(a)} (공통 관측일 ${a.cross.current.actual ?? '없음'})\n\n${scheduleBrief(a)}\n${a.notes.map(n => `- ${mdLine(n)}`).join('\n')}\n\n원천: https://ecos.bok.or.kr/ · ${UST_SOURCE_PAGE}\n`,
'market-table.csv': dualCsv(a), 'report.html': dualHtml(packet, a), 'evidence.mjson': evidence, 'status.mjson': JSON.stringify({ result: a.status, summary: dualSummary(packet, a) }, null, 2) + '\n'
};
for (const name of FILES) {
const content = await readFile(join(dir, name), 'utf8');
if (hash(content) !== manifest.files[name] || content !== expected[name]) throw Error(`${name}: hash or deterministic output mismatch`);
}
if (a.status === 'READY' && (!expected['report.html'].includes('원천·수집 기록') || !expected['report.html'].includes(UST_SOURCE_PAGE))) throw Error('Dual READY report lacks source provenance');
}
src/harness.mts
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { collect, collectWindows, parse, pointWindows, rangeWindows, request } from './collect.mjs';
import { ENGINE, PACKET_VERSION, SOURCE_URL, TENOR_META, TENORS, analyze, classify, csvRead, date, days, kstToday, sha256, shift, type Observation, type Packet, type Receipt, type Tenor } from './core.mjs';
import { collectPolicyRate, commonSnapshots, dashboard, eventQueryWindows, monthComparisonDates, onOrAfter, onOrBefore, previousMonthEnd, previousQuarterEnd, previousYearEnd, trend, type PolicyRate } from './modes.mjs';
import { analyzeNews, newsRead } from './news.mjs';
import { validateArtifacts, writeArtifacts } from './render.mjs';
import { analyzeUst, collectUst, parseUstXml, UST_ENGINE, ustMonthUrl, ustMonths } from './ust.mjs';
import { analyzeDual, DUAL_ENGINE, dualChat, validateDualArtifacts, writeDualArtifacts, type DualPacket } from './dual.mjs';
import { analyzeSchedule, readSchedule, type ScheduleEvidence } from './schedule.mjs';
const cliPath=fileURLToPath(new URL('./cli.mjs', import.meta.url));
let checks=0;const check=(fn:()=>void):void=>{fn();checks++;},run=promisify(execFile);
const baseValues:Record<Tenor,number>={2:3,3:3.1,5:3.2,10:3.3,20:3.4,30:3.5};
const currentValues:Record<Tenor,number>={2:2.95,3:3,5:3.15,10:3.25,20:3.35,30:3.45};
const rowsAt=(day:string,values:Record<Tenor,number>):Observation[]=>TENORS.map(tenor=>({date:day,tenor,value:values[tenor]}));
const obs=[...rowsAt('2026-09-11',baseValues),...rowsAt('2026-09-18',currentValues)];
const csvFor=(rows:Observation[]):string=>['date,tenor,yield_pct',...rows.map(r=>`${r.date},${r.tenor},${r.value}`)].join('\n');
const receiptsFor=(rows:Observation[]):Receipt[]=>{const raw=csvFor(rows);return TENORS.map(tenor=>{const own=rows.filter(r=>r.tenor===tenor),meta=TENOR_META[tenor];return {tenor,sourceId:'사용자 제공 CSV: fixture.csv',sourceUrl:'',statCode:'USER_CSV',itemCode:meta.itemCode,itemName:meta.itemName,unit:'연%',cycle:'D',retrievedAt:'2026-09-18T09:00:00.000Z',status:'input',raw:[raw],rawSha256:[sha256(raw)],count:own.length,first:own[0]?.date??null,last:own.at(-1)?.date??null,windows:[]};});};
const packetFor=(rows:Observation[]=obs):Packet=>({version:PACKET_VERSION,engine:ENGINE,asOf:'2026-09-18',base:'2026-09-11',maxLag:7,observations:rows,receipts:receiptsFor(rows),label:'테스트'});
const packet=packetFor(),analysis=analyze(packet);
const newsInput={version:1,observationDate:'2026-09-18',retrievedAt:'2026-09-18T12:00:00.000Z',articles:[{publisher:'연합인포맥스',title:'[채권-마감] 금리 하락…외국인 선물 매수',url:'https://news.einfomax.co.kr/news/articleView.html?idxno=4359540',publishedAt:'2026-09-18T09:00:00.000Z',sourceType:'market-close',marketDirection:'lower',relation:'explicit',factor:'외국인의 국채선물 순매수',evidenceSummary:'연합인포맥스는 외국인 국채선물 순매수를 금리 하락의 시장 배경으로 전했다.'}]},newsEvidence=newsRead(JSON.stringify(newsInput)),newsAnalysis=analyzeNews(newsEvidence,analysis);
check(()=>{assert.equal(analysis.status,'READY');assert.deepEqual(analysis.changes,[-5,-10,-5,-5,-5,-5]);assert.equal(analysis.spreads.find(s=>s.name==='10년−3년')?.change,5);assert.equal(analysis.movement,'Bull 스티프닝');});
check(()=>{assert.equal(newsAnalysis.status,'SOURCED');assert.equal(newsAnalysis.confidence,'HIGH');assert.equal(newsAnalysis.curveDirection,'lower');assert.equal(newsAnalysis.eligible.length,1);assert.match(newsAnalysis.commentary[0],/외국인의 국채선물 순매수/);});
check(()=>{const mismatch=structuredClone(newsInput);mismatch.articles[0].marketDirection='higher';const result=analyzeNews(newsRead(JSON.stringify(mismatch)),analysis);assert.equal(result.status,'UNATTRIBUTED');assert.equal(result.excluded.length,1);assert.match(result.notes[0],/방향과 불일치/);});
check(()=>{const bad=structuredClone(newsInput);bad.articles[0].url='https://example.com/article';assert.throws(()=>newsRead(JSON.stringify(bad)),/Unapproved/);});
check(()=>{const bad={...structuredClone(newsInput),unexpected:true};assert.throws(()=>newsRead(JSON.stringify(bad)),/Unknown news evidence field/);});
check(()=>{const bad=structuredClone(newsInput) as typeof newsInput&{articles:Array<(typeof newsInput.articles)[number]&{unexpected?:boolean}>};bad.articles[0].unexpected=true;assert.throws(()=>newsRead(JSON.stringify(bad)),/Unknown news article field/);});
check(()=>{const bad=structuredClone(newsInput);bad.articles[0].publisher='임의매체';assert.throws(()=>newsRead(JSON.stringify(bad)),/Publisher\/host mismatch/);});
check(()=>{const bad=structuredClone(newsInput);bad.retrievedAt='2026-09-18T08:59:59.000Z';assert.throws(()=>newsRead(JSON.stringify(bad)),/date alignment mismatch/);});
check(()=>{const bad=structuredClone(newsInput);bad.articles.push(structuredClone(bad.articles[0]));assert.throws(()=>newsRead(JSON.stringify(bad)),/Duplicate/);});
check(()=>{const bad=structuredClone(newsEvidence);bad.articles[0].sourceFingerprint='0'.repeat(64);assert.throws(()=>newsRead(JSON.stringify(bad)),/fingerprint/);});
check(()=>{const contextual=structuredClone(newsInput);contextual.articles[0].publisher='한국은행';contextual.articles[0].url='https://www.bok.or.kr/portal/bbs/B0000217/view.do?nttId=10000000';contextual.articles[0].sourceType='official-release';contextual.articles[0].marketDirection='context';contextual.articles[0].relation='contextual';const result=analyzeNews(newsRead(JSON.stringify(contextual)),analysis);assert.equal(result.status,'CONTEXT_ONLY');assert.equal(result.context.length,1);assert.equal(result.eligible.length,0);});
check(()=>{const wrongDate=structuredClone(newsInput);wrongDate.observationDate='2026-09-17';wrongDate.articles[0].publishedAt='2026-09-17T09:00:00.000Z';assert.throws(()=>analyzeNews(newsRead(JSON.stringify(wrongDate)),analysis),/current common observation/);});
check(()=>{assert.equal(analysis.current.values?.length,6);assert.deepEqual(TENORS,[2,3,5,10,20,30]);});
check(()=>{assert.equal(classify(5,5),'평행 상승');assert.equal(classify(-5,-5),'평행 하락');assert.equal(classify(-2,3),'혼합 움직임 / 스티프닝');});
check(()=>assert.throws(()=>date('2025-02-29'),/Invalid date/));check(()=>assert.equal(date('2024-02-29'),'2024-02-29'));
check(()=>assert.equal(kstToday(new Date('2026-09-20T15:30:00Z')),'2026-09-21'));
check(()=>assert.throws(()=>csvRead('date,tenor,yield_pct\n2026-01-01,3,3.0\n2026-01-01,3,3.1'),/Conflicting/));
check(()=>assert.throws(()=>csvRead('date,tenor,yield_pct\n2026-01-01,3,NaN'),/Invalid CSV/));
check(()=>{assert.throws(()=>csvRead('date,tenor,yield_pct\n2026-01-01,3,3.0 '),/Invalid CSV/);assert.throws(()=>csvRead(' date,tenor,yield_pct\n2026-01-01,3,3.0'),/header/);});
check(()=>assert.throws(()=>analyze({...packet,base:'2025-01-01'}),/366/));check(()=>assert.throws(()=>analyze({...packet,maxLag:15}),/0..14/));
check(()=>assert.throws(()=>analyze({...packet,label:'bad\nheading'}),/invalid evidence/));
check(()=>{const x=analyze({...packet,asOf:'2026-09-26'});assert.equal(x.status,'INCOMPLETE');assert.match(x.notes[0],/허용 7일 초과/);});
check(()=>{const x=analyze({...packet,asOf:'2026-09-25'});assert.equal(x.status,'READY');});
check(()=>{const rows=obs.filter(r=>r.date<='2026-09-12'),x=analyze({...packet,asOf:'2026-09-12',observations:rows,receipts:receiptsFor(rows)});assert.equal(x.status,'INCOMPLETE');assert.match(x.notes.join(' '),/같은 관측일/);});
check(()=>{const bad=structuredClone(packet);bad.receipts[0].unit='bp';assert.throws(()=>analyze(bad),/metadata mismatch/);});
check(()=>{const bad=structuredClone(packet);bad.receipts[0].rawSha256[0]='0'.repeat(64);assert.throws(()=>analyze(bad),/raw source hash/);});
check(()=>{const bad=structuredClone(packet);bad.receipts[0].count++;assert.throws(()=>analyze(bad),/count\/range/);});
check(()=>{const bad=structuredClone(packet);bad.observations[0].value+=.01;assert.throws(()=>analyze(bad),/do not match source raw data/);});
check(()=>{const bad=structuredClone(packet);const raw=bad.receipts[0].raw[0].replace('2026-09-11,2,3','2026-09-11,2,3.01');bad.receipts[0].raw=[raw];bad.receipts[0].rawSha256=[sha256(raw)];assert.throws(()=>analyze(bad),/do not match source raw data/);});
check(()=>{const bad=structuredClone(packet);bad.receipts[0].retrievedAt='2026-09-18';assert.throws(()=>analyze(bad),/Invalid source receipt/);});
check(()=>{const w=rangeWindows('2026-01-01','2026-01-25');assert.equal(w.length,3);assert.deepEqual(w.at(-1),{from:'2026-01-21',to:'2026-01-25'});});
check(()=>assert.equal(pointWindows(['2026-09-18','2026-09-18'],7).length,1));
check(()=>{const w=pointWindows(['2026-09-18'],14);assert.equal(w.length,2);assert.ok(w.every(x=>(Date.parse(x.to)-Date.parse(x.from))/86400000+1<=10));});
check(()=>{for(let length=1;length<=100;length++){const end=shift('2023-12-15',length-1),w=rangeWindows('2023-12-15',end);assert.equal(w[0].from,'2023-12-15');assert.equal(w.at(-1)?.to,end);assert.equal(w.reduce((sum,x)=>sum+days(x.to,x.from)+1,0),length);assert.ok(w.every(x=>days(x.to,x.from)+1<=10));}});
check(()=>{for(let lag=0;lag<=14;lag++){const w=pointWindows(['2024-03-01'],lag);assert.equal(w[0].from,shift('2024-03-01',-lag));assert.equal(w.at(-1)?.to,'2024-03-01');}});
check(()=>{assert.equal(previousMonthEnd('2026-01-15'),'2025-12-31');assert.equal(previousQuarterEnd('2026-04-01'),'2026-03-31');assert.equal(previousYearEnd('2026-01-01'),'2025-12-31');});
check(()=>{assert.deepEqual(monthComparisonDates('2026-08','2026-09','2026-09-22'),{base:'2026-08-31',asOf:'2026-09-22'});assert.deepEqual(monthComparisonDates('2026-07','2026-08','2026-09-22'),{base:'2026-07-31',asOf:'2026-08-31'});assert.throws(()=>monthComparisonDates('2026-09','2026-10','2026-09-22'),/future/);assert.throws(()=>monthComparisonDates('2026-09','2026-09','2026-09-22'),/earlier/);});
check(()=>{const w=eventQueryWindows('2026-09-01','2026-09-30',14);assert.equal(w[0].from,'2026-08-17');assert.equal(w.at(-1)?.to,'2026-09-15');assert.ok(w.every(x=>days(x.to,x.from)+1<=10));});
const modeObs=[...rowsAt('2026-08-31',Object.fromEntries(TENORS.map(t=>[t,baseValues[t]-.1])) as Record<Tenor,number>),...rowsAt('2026-09-10',Object.fromEntries(TENORS.map(t=>[t,baseValues[t]+.02])) as Record<Tenor,number>),...obs];
check(()=>assert.equal(commonSnapshots(modeObs).length,4));check(()=>assert.equal(onOrBefore(modeObs,'2026-09-17',7).actual,'2026-09-11'));
check(()=>assert.equal(onOrAfter(modeObs,'2026-09-12',7).actual,'2026-09-18'));
check(()=>assert.throws(()=>onOrBefore(modeObs,'2026-09-18',-1),/0..14/));
const policy:PolicyRate={date:'2026-09-18',value:3,unit:'연%',status:'live',retrievedAt:'2026-09-18T09:00:00Z',sourceUrl:SOURCE_URL,statCode:'722Y001',itemCode:'0101000',rawSha256:'a'.repeat(64)};
const dash=dashboard('2026-09-18',modeObs,7,policy);
check(()=>{assert.equal(dash.result,'READY');assert.equal(dash.references.previousSession.actual,'2026-09-11');assert.equal(dash.references.previousMonthEnd.actual,'2026-08-31');assert.equal(dash.policyGap3YBp,0);assert.equal(dash.spreads['30Y-10Y'],20);});
const trendRows=['2026-09-14','2026-09-15','2026-09-16','2026-09-17','2026-09-18'].flatMap((d,i)=>rowsAt(d,Object.fromEntries(TENORS.map(t=>[t,baseValues[t]+i*.01])) as Record<Tenor,number>)),trendResult=trend(trendRows,5);
check(()=>{assert.equal(trendResult.result,'READY');assert.equal(trendResult.actualSessions,5);assert.equal(trendResult.stats[0].netChangeBp,4);});
check(()=>assert.equal(trend(trendRows.slice(0,-TENORS.length),5).result,'INCOMPLETE'));
check(()=>{assert.equal(trend(rowsAt('2026-09-18',baseValues),5).result,'INCOMPLETE');assert.equal(trend([],5).result,'BLOCKED');});
const ecos=(tenor:Tenor,time='20260918',value='3.15')=>JSON.stringify({StatisticSearch:{list_total_count:1,row:[{STAT_CODE:'817Y002',STAT_NAME:'1.3.2.1. 시장금리(일별)',ITEM_CODE1:TENOR_META[tenor].itemCode,ITEM_NAME1:TENOR_META[tenor].itemName,UNIT_NAME:'연%',TIME:time,DATA_VALUE:value}]}});
check(()=>assert.equal(parse(ecos(30),30,'2026-09-18','2026-09-18')[0].value,3.15));
check(()=>{const doc=JSON.parse(ecos(20));doc.StatisticSearch.row[0].UNIT_NAME='bp';assert.throws(()=>parse(JSON.stringify(doc),20,'2026-09-18','2026-09-18'),/metadata/);});
check(()=>{const doc=JSON.parse(ecos(2));doc.StatisticSearch.list_total_count=2;assert.throws(()=>parse(JSON.stringify(doc),2,'2026-09-18','2026-09-18'),/Incomplete/);});
check(()=>{const doc=JSON.parse(ecos(3));doc.StatisticSearch.row[0].DATA_VALUE='0x10';assert.throws(()=>parse(JSON.stringify(doc),3,'2026-09-18','2026-09-18'),/metadata/);});
check(()=>{const doc=JSON.parse(ecos(5));doc.StatisticSearch.list_total_count=2;doc.StatisticSearch.row.push({...doc.StatisticSearch.row[0]});assert.throws(()=>parse(JSON.stringify(doc),5,'2026-09-18','2026-09-18'),/duplicate/);});
let tries=0;await request('x',(async()=>{tries++;return new Response(tries<3?'':'{}',{status:tries<3?503:200});}) as typeof fetch,async()=>{});check(()=>assert.equal(tries,3));
let fatalTries=0;await assert.rejects(()=>request('x',(async()=>{fatalTries++;return new Response('',{status:404});}) as typeof fetch,async()=>{}),/HTTP 404/);check(()=>assert.equal(fatalTries,1));
let semanticTries=0;const semanticRequest=await request('x',(async()=>{semanticTries++;const raw=semanticTries<3?ecos(3).replace('"UNIT_NAME":"연%"','"UNIT_NAME":"bp"'):ecos(3);return new Response(raw,{status:200});}) as typeof fetch,async()=>{},raw=>{parse(raw,3,'2026-09-18','2026-09-18');});check(()=>{assert.equal(semanticTries,3);assert.equal(semanticRequest.attempts,3);});
const root=await mkdtemp(join(tmpdir(),'curve-v3-'));
try{
const first=join(root,'first'),second=join(root,'second');await writeArtifacts(first,packet);await validateArtifacts(first);checks++;
const replay=JSON.parse(await readFile(join(first,'evidence.mjson'),'utf8')) as Packet;await writeArtifacts(second,replay);await validateArtifacts(second);for(const f of ['brief.md','market-table.csv','report.html','evidence.mjson','status.mjson','manifest.mjson'])assert.equal(await readFile(join(first,f),'utf8'),await readFile(join(second,f),'utf8'));checks++;
const report=await readFile(join(first,'report.html'),'utf8'),csv=await readFile(join(first,'market-table.csv'),'utf8');check(()=>{assert.match(report,/30년/);assert.match(report,/10년−5년/);assert.equal(csv.trim().split(/\r?\n/).length,11);assert.doesNotMatch(report,/NaN|undefined/);});
const newsFirst=join(root,'news-first'),newsSecond=join(root,'news-second'),newsPacket:Packet={...packet,newsEvidence};await writeArtifacts(newsFirst,newsPacket);await validateArtifacts(newsFirst);const embedded=JSON.parse(await readFile(join(newsFirst,'evidence.mjson'),'utf8')) as Packet;await writeArtifacts(newsSecond,embedded);await validateArtifacts(newsSecond);for(const f of ['brief.md','market-table.csv','report.html','evidence.mjson','status.mjson','manifest.mjson'])assert.equal(await readFile(join(newsFirst,f),'utf8'),await readFile(join(newsSecond,f),'utf8'));const newsPage=await readFile(join(newsFirst,'report.html'),'utf8'),newsBrief=await readFile(join(newsFirst,'brief.md'),'utf8');check(()=>{assert.match(newsPage,/시장 배경/);assert.ok(newsPage.includes(newsEvidence.articles[0].url));assert.match(newsPage,/class="value-label/);assert.doesNotMatch(newsPage,/원문 SHA-256|근거 SHA-256/);assert.ok(!newsPage.includes(newsEvidence.articles[0].sourceFingerprint));assert.match(newsBrief,/SOURCED/);});checks++;
const yields:Record<string,string>=Object.fromEntries(TENORS.map(t=>[TENOR_META[t].itemCode,String(currentValues[t])])),fakeEcos=async(url:URL|string|Request)=>{const value=String(url),item=Object.keys(yields).find(code=>value.endsWith(`/${code}`))!,current=value.includes('/20260911/20260918/'),TIME=current?'20260918':'20260911',tenor=TENORS.find(t=>TENOR_META[t].itemCode===item)!;return new Response(ecos(tenor,TIME,yields[item]),{status:200});};
const cache=join(root,'cache'),clock=new Date('2026-09-18T12:00:00Z'),live=await collect('2026-09-11','2026-09-18',7,cache,true,fakeEcos as typeof fetch,clock);check(()=>{assert.equal(live.observations.length,12);assert.ok(live.receipts.every(r=>r.status==='live'));});
let transientBadRows=0;const semanticFetch=(async(url:URL|string|Request)=>{const response=await fakeEcos(url);if(String(url).endsWith(`/${TENOR_META[3].itemCode}`)&&String(url).includes('/20260911/20260918/')&&++transientBadRows<3)return new Response((await response.text()).replace('"UNIT_NAME":"연%"','"UNIT_NAME":"bp"'),{status:200});return response;}) as typeof fetch;const semanticCollected=await collect('2026-09-11','2026-09-18',7,join(root,'semantic-retry'),false,semanticFetch,clock);check(()=>{assert.equal(transientBadRows,3);assert.equal(semanticCollected.receipts.find(r=>r.tenor===3)?.windows.find(w=>w.to==='2026-09-18')?.attempts,3);assert.equal(semanticCollected.observations.length,12);});
check(()=>{const bad=structuredClone(packetFor(live.observations));bad.receipts=structuredClone(live.receipts);bad.receipts[0].windows[0].attempts=0;assert.throws(()=>analyze(bad),/invalid source window/);});
check(()=>{const bad=structuredClone(packetFor(live.observations));bad.receipts=structuredClone(live.receipts);bad.receipts[0].raw.pop();bad.receipts[0].rawSha256.pop();assert.throws(()=>analyze(bad),/window\/raw mismatch/);});
const unavailable=(async()=>new Response('',{status:404})) as typeof fetch,recovered=await collect('2026-09-11','2026-09-18',7,cache,true,unavailable,clock);check(()=>{assert.equal(recovered.observations.length,12);assert.ok(recovered.receipts.every(r=>r.status==='cache'));});
const noCacheDir=join(root,'no-cache');await collect('2026-09-11','2026-09-18',7,noCacheDir,false,fakeEcos as typeof fetch,clock);await assert.rejects(()=>readdir(noCacheDir),/ENOENT/);checks++;
let reversedCalls=0;await assert.rejects(()=>collect('2026-09-18','2026-09-11',7,join(root,'reversed'),false,(async()=>{reversedCalls++;return new Response('{}',{status:200});}) as typeof fetch,clock),/Base must precede/);check(()=>assert.equal(reversedCalls,0));
const files=await readdir(cache),cacheFile=join(cache,files.find(x=>!x.startsWith('policy-'))!);const cached=JSON.parse(await readFile(cacheFile,'utf8'));cached.digest='0'.repeat(64);await writeFile(cacheFile,JSON.stringify(cached));const rejected=await collect('2026-09-11','2026-09-18',7,cache,true,unavailable,clock);check(()=>assert.ok(rejected.observations.length<12));
const policyRaw=JSON.stringify({StatisticSearch:{list_total_count:1,row:[{STAT_CODE:'722Y001',STAT_NAME:'1.3.1. 한국은행 기준금리 및 여수신금리',ITEM_CODE1:'0101000',ITEM_NAME1:'한국은행 기준금리',UNIT_NAME:'연%',TIME:'20260918',DATA_VALUE:'3'}]}}),policyFetch=(async()=>new Response(policyRaw,{status:200})) as typeof fetch;
const policyCache=join(root,'policy-cache'),pLive=await collectPolicyRate('2026-09-18',policyCache,true,policyFetch,clock);check(()=>{assert.equal(pLive?.value,3);assert.equal(pLive?.status,'live');});const pCache=await collectPolicyRate('2026-09-18',policyCache,true,unavailable,clock);check(()=>assert.equal(pCache?.status,'cache'));
const noPolicyCacheDir=join(root,'no-policy-cache');await collectPolicyRate('2026-09-18',noPolicyCacheDir,false,policyFetch,clock);await assert.rejects(()=>readdir(noPolicyCacheDir),/ENOENT/);checks++;
const policyCollision=join(root,'policy-write-collision');await mkdir(policyCollision);await mkdir(join(policyCollision,'policy-2026-09-11-2026-09-18.mjson'));const policyDespiteCacheFailure=await collectPolicyRate('2026-09-18',policyCollision,true,policyFetch,clock);check(()=>{assert.equal(policyDespiteCacheFailure?.status,'live');assert.ok(policyDespiteCacheFailure?.cacheWriteError);});const policyCollisionFiles=await readdir(policyCollision);check(()=>assert.ok(policyCollisionFiles.every(x=>!x.endsWith('.tmp'))));
const invalidPolicy=policyRaw.replace('"DATA_VALUE":"3"','"DATA_VALUE":"0x10"'),badPolicy=await collectPolicyRate('2026-09-18',join(root,'bad-policy'),false,(async()=>new Response(invalidPolicy,{status:200})) as typeof fetch,clock);check(()=>assert.equal(badPolicy,null));
const ranged=await collectWindows([{from:'2026-09-11',to:'2026-09-18'}],join(root,'range-cache'),false,fakeEcos as typeof fetch,clock);check(()=>assert.equal(ranged.receipts.length,6));
const unwritable=join(root,'cache-write-collision');await mkdir(unwritable);for(const t of TENORS)await mkdir(join(unwritable,`${t}-2026-09-18-2026-09-18.mjson`));const singleEcos=async(url:URL|string|Request)=>{const value=String(url),item=Object.keys(yields).find(code=>value.endsWith(`/${code}`))!,tenor=TENORS.find(t=>TENOR_META[t].itemCode===item)!;return new Response(ecos(tenor,'20260918',yields[item]),{status:200});},cacheWriteFailure=await collectWindows([{from:'2026-09-18',to:'2026-09-18'}],unwritable,true,singleEcos as typeof fetch,clock);check(()=>{assert.equal(cacheWriteFailure.observations.length,6);assert.ok(cacheWriteFailure.receipts.every(x=>x.status==='live'&&x.windows[0].error?.includes('cache write failed')));});const cacheWriteAnalysis=analyze({version:PACKET_VERSION,engine:ENGINE,base:'2026-09-17',asOf:'2026-09-18',maxLag:1,observations:cacheWriteFailure.observations,receipts:cacheWriteFailure.receipts,label:'캐시 저장 실패 테스트'});check(()=>assert.match(cacheWriteAnalysis.notes.join(' '),/로컬 캐시 저장 실패/));const collisionFiles=await readdir(unwritable);check(()=>assert.ok(collisionFiles.every(x=>!x.endsWith('.tmp'))));
await assert.rejects(()=>collectWindows([{from:'2026-09-11',to:'2026-09-18'}],join(root,'bad-clock'),false,fakeEcos as typeof fetch,new Date('invalid')),/Invalid collection clock/);checks++;
const inputFile=join(root,'input.csv'),csvRows=['date,tenor,yield_pct',...obs.map(r=>`${r.date},${r.tenor},${r.value}`)].join('\n');await writeFile(inputFile,csvRows);const cli=await run(process.execPath,[cliPath,'--input',inputFile,'--base','2026-09-11','--as-of','2026-09-18'],{cwd:root});check(()=>{const parsed=JSON.parse(cli.stdout);assert.equal(parsed.result,'READY');assert.equal(parsed.rates.length,6);assert.doesNotMatch(cli.stdout,/report\.html/);});
const newsFile=join(root,'news.mjson');await writeFile(newsFile,JSON.stringify(newsInput));const cliNews=await run(process.execPath,[cliPath,'--input',inputFile,'--base','2026-09-11','--as-of','2026-09-18','--news-evidence',newsFile],{cwd:root});check(()=>{const parsed=JSON.parse(cliNews.stdout);assert.equal(parsed.news.status,'SOURCED');assert.equal(parsed.news.confidence,'HIGH');});const template=await run(process.execPath,[cliPath,'--news-template','2026-09-18'],{cwd:root});check(()=>assert.equal(newsRead(template.stdout).observationDate,'2026-09-18'));
try{await run(process.execPath,[cliPath,'--base','2026-09-11','--base','2026-09-12','--as-of','2026-09-18'],{cwd:root});assert.fail('duplicate option accepted');}catch(e){assert.match(String((e as {stderr?:string}).stderr),/Duplicate option/);checks++;}
try{await run(process.execPath,[cliPath,'--input',inputFile],{cwd:root});assert.fail('implicit input dates accepted');}catch(e){assert.match(String((e as {stderr?:string}).stderr),/requires explicit/);checks++;}
const cliReject=async(argv:string[],pattern:RegExp):Promise<void>=>{try{await run(process.execPath,[cliPath,...argv],{cwd:root});assert.fail(`CLI accepted ${argv.join(' ')}`);}catch(e){assert.match(String((e as {stderr?:string}).stderr),pattern);checks++;}};
await cliReject(['--base','2026-09-18','--as-of','2026-09-18'],/precede/);await cliReject(['--base','2020-01-01','--as-of','2022-01-01'],/366/);await cliReject(['--preset','dashboard','--lookback-sessions','5'],/mutually exclusive/);await cliReject(['--replay','missing.mjson','--base','2026-09-11'],/accepts only/);await cliReject(['--from-month','2026-09','--to-month','2026-09'],/earlier/);await cliReject(['--market','kr','--json'],/only needed/);
const third=join(root,'third');await writeArtifacts(third,packet);const statusPath=join(third,'status.mjson'),manifestPath=join(third,'manifest.mjson');await writeFile(statusPath,(await readFile(statusPath,'utf8')).replace('"usable": true','"usable": false'));const alteredManifest=JSON.parse(await readFile(manifestPath,'utf8'));alteredManifest.files['status.mjson']=sha256(await readFile(statusPath,'utf8'));await writeFile(manifestPath,JSON.stringify(alteredManifest,null,2)+'\n');await assert.rejects(()=>validateArtifacts(third),/not reproducible/);checks++;
const fourth=join(root,'fourth');await writeArtifacts(fourth,packet);const incompleteManifest=JSON.parse(await readFile(join(fourth,'manifest.mjson'),'utf8'));delete incompleteManifest.files['status.mjson'];await writeFile(join(fourth,'manifest.mjson'),JSON.stringify(incompleteManifest,null,2)+'\n');await assert.rejects(()=>validateArtifacts(fourth),/file contract/);checks++;
await writeFile(join(first,'brief.md'),(await readFile(join(first,'brief.md'),'utf8'))+'tampered');await assert.rejects(()=>validateArtifacts(first),/hash mismatch/);checks++;
}finally{await rm(root,{recursive:true,force:true});}
const ustFixture=(dates:string[]):string=>`<?xml version="1.0"?><feed xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"><title type="text">DailyTreasuryYieldCurveRateData</title>${dates.map((d,i)=>`<entry><content><m:properties><d:NEW_DATE m:type="Edm.DateTime">${d}T00:00:00</d:NEW_DATE>${TENORS.map(t=>`<d:BC_${t}YEAR m:type="Edm.Double">${(4+t/100+i/100).toFixed(2)}</d:BC_${t}YEAR>`).join('')}</m:properties></content></entry>`).join('')}</feed>`;
const ustRaw=ustFixture(['2026-09-11','2026-09-18']);
check(()=>{const rows=parseUstXml(ustRaw,'2026-09');assert.equal(rows.length,2);assert.equal(rows[0].yields[10],4.1);assert.equal(rows[1].yields[30],4.31);});
check(()=>assert.throws(()=>parseUstXml(ustRaw,'2026-08'),/month\/date mismatch/));
check(()=>assert.throws(()=>parseUstXml(ustFixture(['2026-09-11','2026-09-11']),'2026-09'),/duplicate observation/));
check(()=>assert.throws(()=>parseUstXml(ustRaw.replace('<d:BC_10YEAR m:type="Edm.Double">4.10</d:BC_10YEAR>','<d:BC_10YEAR m:type="Edm.Double">400bp</d:BC_10YEAR>'),'2026-09'),/Invalid UST 10Y yield/));
const ustMissingTenor=ustRaw.replace('<d:BC_20YEAR m:type="Edm.Double">4.20</d:BC_20YEAR>','<d:BC_20YEAR m:null="true" />');
check(()=>{const rows=parseUstXml(ustMissingTenor,'2026-09');assert.equal(rows.length,1);assert.equal(rows[0].date,'2026-09-18');});
check(()=>assert.equal(ustMonthUrl('2026-09').endsWith('202609'),true));
check(()=>assert.deepEqual(ustMonths('2026-08-31','2026-09-01',7),['2026-08','2026-09']));
const dualRoot=await mkdtemp(join(tmpdir(),'ktb-ust-'));
try{
let requests=0;
const fetcher=(async()=>{requests++;return new Response(ustRaw,{status:200,headers:{'Content-Type':'application/xml'}});}) as typeof fetch;
const us=await collectUst('2026-09-11','2026-09-18',7,join(dualRoot,'cache'),true,fetcher,new Date('2026-09-18T23:00:00.000Z'));
check(()=>{assert.equal(requests,1);assert.equal(us.engine,UST_ENGINE);assert.equal(analyzeUst(us).status,'READY');assert.equal(analyzeUst(us).changes?.[10],1);});
const refreshed=await collectUst('2026-09-11','2026-09-18',7,join(dualRoot,'cache'),true,fetcher,new Date('2026-09-19T07:00:00.000Z'));
check(()=>{assert.equal(requests,2);assert.equal(refreshed.sources[0].status,'live');});
const offline=(async()=>new Response('',{status:503})) as typeof fetch;
const historicalCache=join(dualRoot,'historical-cache'),historicalRaw=ustFixture(['2026-08-11','2026-08-18']),historicalFetch=(async()=>new Response(historicalRaw,{status:200})) as typeof fetch;
const historicalLive=await collectUst('2026-08-11','2026-08-18',7,historicalCache,true,historicalFetch,new Date('2026-09-23T00:00:00.000Z'));
check(()=>assert.equal(historicalLive.sources[0].status,'live',JSON.stringify(historicalLive.sources[0])));
const historical=await collectUst('2026-08-11','2026-08-18',7,historicalCache,true,offline,new Date('2026-09-23T12:00:00.000Z'));
check(()=>assert.equal(historical.sources[0].status,'cache'));
const noUstCache=join(dualRoot,'no-ust-cache');await collectUst('2026-09-11','2026-09-18',7,noUstCache,false,fetcher,new Date('2026-09-18T23:00:00.000Z'));await assert.rejects(()=>readdir(noUstCache),/ENOENT/);checks++;
const ustCollision=join(dualRoot,'ust-cache-collision');await mkdir(ustCollision);await mkdir(join(ustCollision,'ust-2026-09.mjson'));
const collisionResult=await collectUst('2026-09-11','2026-09-18',7,ustCollision,true,fetcher,new Date('2026-09-18T23:00:00.000Z'));
check(()=>{assert.equal(collisionResult.sources[0].status,'live');assert.equal(analyzeUst(collisionResult).status,'READY');});
const ustCollisionFiles=await readdir(ustCollision);check(()=>assert.ok(!ustCollisionFiles.some(x=>x.endsWith('.tmp'))));
let malformedCalls=0;const malformedThenValid=(async()=>{malformedCalls++;return new Response(malformedCalls<3?'<html>temporary response</html>':ustRaw,{status:200});}) as typeof fetch;
const retried=await collectUst('2026-09-11','2026-09-18',7,join(dualRoot,'retry-cache'),false,malformedThenValid,new Date('2026-09-18T23:00:00.000Z'));
check(()=>{assert.equal(malformedCalls,3);assert.equal(retried.sources[0].status,'live');assert.equal(analyzeUst(retried).status,'READY');});
const cached=await collectUst('2026-09-11','2026-09-18',7,join(dualRoot,'cache'),true,offline,new Date('2026-09-19T09:00:00.000Z'));
check(()=>assert.equal(cached.sources[0].status,'cache'));
const blocked=await collectUst('2026-09-11','2026-09-18',7,join(dualRoot,'empty-cache'),false,offline,new Date('2026-09-19T01:00:00.000Z'));
check(()=>{assert.equal(blocked.sources[0].status,'failed');assert.equal(analyzeUst(blocked).status,'BLOCKED');assert.match(analyzeUst(blocked).notes.join(' '),/수집 실패/);});
check(()=>{const bad=structuredClone(blocked);bad.sources[0].error='failed\n## injected section';assert.throws(()=>analyzeUst(bad),/Invalid failed UST source receipt/);});
const incompleteUs=structuredClone(us);incompleteUs.sources[0].raw=ustMissingTenor;incompleteUs.sources[0].sha256=sha256(ustMissingTenor);
check(()=>{const a=analyzeUst(incompleteUs);assert.equal(a.status,'INCOMPLETE');assert.match(a.notes.join(' '),/만기 결측이 있는 1개 관측일/);});
const partial=analyzeDual({version:1,engine:DUAL_ENGINE,ktb:packet,ust:blocked});
check(()=>assert.equal(partial.status,'INCOMPLETE'));
const partialChat=dualChat({version:1,engine:DUAL_ENGINE,ktb:packet,ust:blocked},partial);
check(()=>{assert.match(partialChat,/\*\*상태:\*\* 일부 자료 부족, 확인된 값만 표시 \(INCOMPLETE\)/);assert.match(partialChat,/UST는 비교 가능한 두 관측일이 부족합니다/);assert.match(partialChat,/공통 관측일이 없어 계산 불가/);assert.doesNotMatch(partialChat,/NaN|undefined|계산 불가bp/);});
const emptyKtb=packetFor([]),fullyBlocked: DualPacket={version:1,engine:DUAL_ENGINE,ktb:emptyKtb,ust:blocked};
const blockedChat=dualChat(fullyBlocked);
check(()=>{assert.match(blockedChat,/자료 확인 불가 \(BLOCKED\)/);assert.equal((blockedChat.match(/^\| \d+년 \|/gm)??[]).length,6);assert.doesNotMatch(blockedChat,/NaN|undefined/);});
const partialOut=join(dualRoot,'partial');await writeDualArtifacts(partialOut,{version:1,engine:DUAL_ENGINE,ktb:packet,ust:blocked});await validateDualArtifacts(partialOut);
const partialBrief=await readFile(join(partialOut,'brief.md'),'utf8');check(()=>{assert.match(partialBrief,/INCOMPLETE/);assert.match(partialBrief,/UST 원천 수집 실패/);assert.match(partialBrief,/계산 불가 \(/);assert.doesNotMatch(partialBrief,/계산 불가bp/);});
for(const [label,evidence] of [['partial', {version:1,engine:DUAL_ENGINE,ktb:packet,ust:blocked}],['blocked',fullyBlocked]] as const){
const original=label==='partial'?partialOut:join(dualRoot,'blocked');if(label==='blocked')await writeDualArtifacts(original,evidence);
const replayed=join(dualRoot,`${label}-replay`),saved=JSON.parse(await readFile(join(original,'evidence.mjson'),'utf8')) as DualPacket;await writeDualArtifacts(replayed,saved);await validateDualArtifacts(replayed);
for(const name of ['brief.md','market-table.csv','report.html','evidence.mjson','status.mjson','manifest.mjson'])assert.equal(await readFile(join(original,name),'utf8'),await readFile(join(replayed,name),'utf8'));checks++;
}
check(()=>{const tampered=structuredClone(us);tampered.sources[0].sha256='0'.repeat(64);assert.throws(()=>analyzeUst(tampered),/checksum/);});
check(()=>{const bad=structuredClone(us);bad.sources[0].url='https://example.com/';assert.throws(()=>analyzeUst(bad),/identity/);});
const dual: DualPacket={version:1,engine:DUAL_ENGINE,ktb:packet,ust:us}, result=analyzeDual(dual);
check(()=>{assert.equal(result.status,'READY');assert.equal(result.cross.current.actual,'2026-09-18');assert.equal(result.cross.current.gapBp,86);});
const lagged=structuredClone(dual);lagged.ktb.asOf='2026-09-19';lagged.ust.asOf='2026-09-19';
const laggedChat=dualChat(lagged);check(()=>{assert.match(laggedChat,/요청일 2026-09-19보다 1일 전/);assert.match(laggedChat,/분석 완료 \(READY\)/);});
const chat=dualChat(dual,result),sections=['**상태:**','**핵심 변화**','| 만기 |','**커브·한미 차이**','**주요 일정**','**확인 사항**','**출처:**'];
check(()=>{for(const variant of [chat,partialChat,blockedChat]){const positions=sections.map(s=>variant.indexOf(s));assert.ok(positions.every(i=>i>=0));assert.deepEqual(positions,[...positions].sort((a,b)=>a-b));assert.equal((variant.match(/^\| \d+년 \|/gm)??[]).length,6);assert.doesNotMatch(variant,/NaN|undefined/);}assert.match(chat,/KTB 3년 -10\.00bp, 10년 -5\.00bp/);assert.match(chat,/UST 3년 \+1\.00bp, 10년 \+1\.00bp/);assert.match(chat,/공통 관측일 2026-09-18/);assert.match(chat,/https:\/\/ecos\.bok\.or\.kr\//);assert.match(chat,/home\.treasury\.gov/);});
const first=join(dualRoot,'first'),second=join(dualRoot,'second');await writeDualArtifacts(first,dual);await validateDualArtifacts(first);
const replay=JSON.parse(await readFile(join(first,'evidence.mjson'),'utf8')) as DualPacket;await writeDualArtifacts(second,replay);await validateDualArtifacts(second);
for(const name of ['brief.md','market-table.csv','report.html','evidence.mjson','status.mjson','manifest.mjson'])assert.equal(await readFile(join(first,name),'utf8'),await readFile(join(second,name),'utf8'));checks++;
const html=await readFile(join(first,'report.html'),'utf8');check(()=>{assert.match(html,/한국 국고채/);assert.match(html,/미국 국채 par/);assert.match(html,/공통 관측일/);assert.ok(html.includes(us.sources[0].url.replaceAll('&','&')));assert.doesNotMatch(html,/NaN|undefined/);});
const schedule:ScheduleEvidence={version:1,anchorDate:'2026-09-18',checkedAt:'2026-09-18T23:00:00.000Z',events:[
{date:'2026-09-21',market:'KR',kind:'auction',tenor:5,sourceUrl:'https://ktb.moef.go.kr/mnbyIsuCldr.do',sourceExcerpt:'2026.09.21. | 5년물',sourceDateText:'2026.09.21.'},
{date:'2026-09-23',market:'US',kind:'auction',tenor:5,sourceUrl:'https://home.treasury.gov/system/files/221/TentativeAuctionScheduleQ22026.pdf',sourceExcerpt:'5-Year NOTE Thursday, September 17, 2026 Wednesday, September 23, 2026 Wednesday, September 30, 2026',sourceDateText:'Wednesday, September 23, 2026'},
{date:'2026-10-22',market:'KR',kind:'policy',sourceUrl:'https://www.bok.or.kr/portal/singl/crncyPolicyDrcMtg/listYear.do?menuNo=200755&mtgSe=A',sourceExcerpt:'2026 통화정책방향 결정회의 일정 및 자료 10월 22일(목)',sourceDateText:'10월 22일'},
{date:'2026-10-28',market:'US',kind:'policy',sourceUrl:'https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm',sourceExcerpt:'2026 FOMC Meetings October 27-28',sourceDateText:'October 27-28'}
]};
const scheduled=readSchedule(JSON.stringify(schedule),'2026-09-18');check(()=>{const a=analyzeSchedule(scheduled,'2026-09-18');assert.equal(a.status,'VERIFIED');assert.deepEqual(a.events.map(e=>e.date),['2026-09-21','2026-09-23','2026-10-22','2026-10-28']);assert.equal(analyzeSchedule(undefined,'2026-09-18').status,'UNVERIFIED');});
const scheduledDual: DualPacket={...dual,schedule:scheduled},scheduledChat=dualChat(scheduledDual);check(()=>{assert.match(scheduledChat,/2026-09-21 \(KR\) 국고채 5년물 입찰/);assert.match(scheduledChat,/2026-09-23 \(US\) 미국 국채 5년물 입찰/);assert.match(scheduledChat,/2026-10-22 \(KR\) 한국은행 통화정책방향 결정회의/);assert.match(scheduledChat,/2026-10-28 \(US\) FOMC 금리 결정/);assert.match(scheduledChat,/분석 완료 \(READY\)/);});
check(()=>{const bad=structuredClone(schedule);bad.events[0].date='2026-09-22';assert.throws(()=>analyzeSchedule(bad,'2026-09-18'),/quoted date/);});
check(()=>{const bad=structuredClone(schedule);bad.events[1].sourceUrl='https://home.treasury.gov.evil.example/schedule';assert.throws(()=>analyzeSchedule(bad,'2026-09-18'),/host/);});
check(()=>{const bad=structuredClone(schedule);bad.events[1].date='2026-09-17';bad.events[1].sourceDateText='Thursday, September 17, 2026';assert.throws(()=>analyzeSchedule(bad,'2026-09-18'),/event date/);});
check(()=>{const bad=structuredClone(schedule);bad.events[1].date='2026-09-30';bad.events[1].sourceDateText='Wednesday, September 30, 2026';assert.throws(()=>analyzeSchedule(bad,'2026-09-18'),/second date/);});
check(()=>{const bad=structuredClone(schedule);bad.events[1].sourceExcerpt=bad.events[1].sourceExcerpt.replaceAll('Wednesday, September 23','Tuesday, September 23');bad.events[1].sourceDateText='Tuesday, September 23, 2026';assert.throws(()=>analyzeSchedule(bad,'2026-09-18'),/quoted date/);});
check(()=>{const bad=structuredClone(schedule);bad.events[1].sourceExcerpt='5-Year NOTE Wednesday, September 23, 2026';assert.throws(()=>analyzeSchedule(bad,'2026-09-18'),/announcement, auction, and settlement/);});
check(()=>{const bad=structuredClone(schedule);bad.events.push({...bad.events[0]});assert.throws(()=>analyzeSchedule(bad,'2026-09-18'),/Duplicate/);});
check(()=>{const bad=structuredClone(schedule);bad.checkedAt='2026-10-18T00:00:00.000Z';assert.throws(()=>analyzeSchedule(bad,'2026-09-18'),/too far/);});
const scheduleOut=join(dualRoot,'scheduled'),scheduleReplay=join(dualRoot,'scheduled-replay');await writeDualArtifacts(scheduleOut,scheduledDual);await validateDualArtifacts(scheduleOut);const scheduleSaved=JSON.parse(await readFile(join(scheduleOut,'evidence.mjson'),'utf8')) as DualPacket;await writeDualArtifacts(scheduleReplay,scheduleSaved);await validateDualArtifacts(scheduleReplay);for(const name of ['brief.md','market-table.csv','report.html','evidence.mjson','status.mjson','manifest.mjson'])assert.equal(await readFile(join(scheduleOut,name),'utf8'),await readFile(join(scheduleReplay,name),'utf8'));const scheduleHtml=await readFile(join(scheduleOut,'report.html'),'utf8');check(()=>{assert.match(scheduleHtml,/주요 일정/);assert.match(scheduleHtml,/국고채 5년물 입찰/);assert.match(scheduleHtml,/FOMC 금리 결정/);});
const scheduledCli=await run(process.execPath,[cliPath,'--market','both','--replay',join(scheduleOut,'evidence.mjson')],{cwd:dualRoot});check(()=>{assert.match(scheduledCli.stdout,/\*\*주요 일정\*\*/);assert.match(scheduledCli.stdout,/2026-10-28 \(US\) FOMC 금리 결정/);});
const jsonCli=await run(process.execPath,[cliPath,'--market','both','--replay',join(scheduleOut,'evidence.mjson'),'--json'],{cwd:dualRoot});check(()=>{const parsed=JSON.parse(jsonCli.stdout);assert.ok(Array.isArray(parsed.markets));});
const altered=structuredClone(dual);altered.ust.sources[0].raw=altered.ust.sources[0].raw.replace('4.10','4.11');check(()=>assert.throws(()=>analyzeDual(altered),/checksum/));
const noCommon=structuredClone(dual);noCommon.ust.sources[0].raw=ustFixture(['2026-09-12','2026-09-19']);noCommon.ust.sources[0].sha256=sha256(noCommon.ust.sources[0].raw);noCommon.ust.asOf='2026-09-19';noCommon.ktb.asOf='2026-09-19';const gap=analyzeDual(noCommon);check(()=>assert.equal(gap.cross.current.gapBp,null));
const partialCurveRows=[...obs,{date:'2026-09-19',tenor:10 as Tenor,value:3.21}],partialCurve=packetFor(partialCurveRows);
partialCurve.asOf='2026-09-19';const ustNewDate=structuredClone(us);ustNewDate.asOf='2026-09-19';ustNewDate.sources[0].raw=ustFixture(['2026-09-11','2026-09-18','2026-09-19']);ustNewDate.sources[0].sha256=sha256(ustNewDate.sources[0].raw);
const aligned=analyzeDual({version:1,engine:DUAL_ENGINE,ktb:partialCurve,ust:ustNewDate});
check(()=>{assert.equal(aligned.ktb.current.actual,'2026-09-18');assert.equal(aligned.cross.current.actual,'2026-09-18');assert.equal(aligned.cross.current.gapBp,86);});
await writeFile(join(first,'brief.md'),(await readFile(join(first,'brief.md'),'utf8')+'tampered'));await assert.rejects(()=>validateDualArtifacts(first),/mismatch/);checks++;
}finally{await rm(dualRoot,{recursive:true,force:true});}
console.log(`PASS: ${checks} deterministic pipeline checks`);
src/modes.mts
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { rangeWindows, request, type QueryWindow } from './collect.mjs';
import { TENORS, bp, date, days, normalize, sha256, shift, type Observation, type Tenor } from './core.mjs';
export interface CurveSnapshot {requested:string;actual:string|null;lag:number|null;values:Record<string,number>|null;reason:string|null}
export interface CurveChange {tenor:string;base:number|null;current:number|null;changeBp:number|null}
export interface DashboardResult {result:'READY'|'INCOMPLETE'|'BLOCKED';mode:'dashboard';asOf:string;current:CurveSnapshot;references:{previousSession:CurveSnapshot;weekAgo:CurveSnapshot;previousMonthEnd:CurveSnapshot};changes:{previousSession:CurveChange[];weekAgo:CurveChange[];previousMonthEnd:CurveChange[]};spreads:Record<string,number|null>;policyRate:PolicyRate|null;policyGap3YBp:number|null;notes:string[]}
export interface TrendStat {tenor:string;start:number;end:number;netChangeBp:number;high:number;low:number;largestDailyMoveBp:number;largestDailyMoveDate:string}
export interface TrendResult {result:'READY'|'INCOMPLETE'|'BLOCKED';mode:'trend';requestedSessions:number;actualSessions:number;from:string|null;to:string|null;sessions:CurveSnapshot[];stats:TrendStat[];notes:string[]}
export interface PolicyRate {date:string;value:number;unit:'연%';status:'live'|'cache';retrievedAt:string;sourceUrl:string;statCode:'722Y001';itemCode:'0101000';rawSha256:string;cacheAgeHours?:number;cacheWriteError?:string}
function validMaxLag(maxLag:number):void{if(!Number.isInteger(maxLag)||maxLag<0||maxLag>14)throw Error('maxLag must be 0..14 calendar days');}
function endOfMonth(value:string):string{
if(!/^\d{4}-\d{2}$/.test(value))throw Error(`Invalid month: ${value}`);
const first=date(`${value}-01`),[year,month]=first.split('-').map(Number);
return shift(`${month===12?year+1:year}-${String(month===12?1:month+1).padStart(2,'0')}-01`,-1);
}
export function monthComparisonDates(fromMonth:string,toMonth:string,today:string):{base:string;asOf:string}{
date(today);endOfMonth(fromMonth);const toEnd=endOfMonth(toMonth);
if(fromMonth>=toMonth)throw Error('--from-month must be earlier than --to-month');
const currentMonth=today.slice(0,7);if(toMonth>currentMonth)throw Error('--to-month cannot be in the future');
return {base:endOfMonth(fromMonth),asOf:toMonth===currentMonth?today:toEnd};
}
export function eventQueryWindows(eventDate:string,today:string,maxLag:number):QueryWindow[]{
date(eventDate);date(today);validMaxLag(maxLag);if(eventDate>today)throw Error('Event date cannot be in the future');
const beforeTo=shift(eventDate,-1),afterTo=shift(eventDate,maxLag)<today?shift(eventDate,maxLag):today;
return [...rangeWindows(shift(beforeTo,-maxLag),beforeTo),...rangeWindows(eventDate,afterTo)];
}
export function previousMonthEnd(asOf:string):string{const [y,m]=date(asOf).split('-').map(Number);return shift(`${y}-${String(m).padStart(2,'0')}-01`,-1);}
export function previousQuarterEnd(asOf:string):string{const [y,m]=date(asOf).split('-').map(Number),quarterStart=Math.floor((m-1)/3)*3+1;return shift(`${y}-${String(quarterStart).padStart(2,'0')}-01`,-1);}
export function previousYearEnd(asOf:string):string{const y=Number(date(asOf).slice(0,4));return `${y-1}-12-31`;}
export function commonSnapshots(input:Observation[]):CurveSnapshot[]{
const rows=normalize(input),dates=[...new Set(rows.map(r=>r.date))].sort();
return dates.filter(d=>TENORS.every(t=>rows.some(r=>r.date===d&&r.tenor===t))).map(actual=>({requested:actual,actual,lag:0,values:Object.fromEntries(TENORS.map(t=>[`${t}Y`,rows.find(r=>r.date===actual&&r.tenor===t)!.value])),reason:null}));
}
export function onOrBefore(input:Observation[],requested:string,maxLag:number):CurveSnapshot{
date(requested);validMaxLag(maxLag);const found=commonSnapshots(input).filter(s=>s.actual!<=requested).at(-1)??null,lag=found?.actual?days(requested,found.actual):null;
const reason=!found?'여섯 만기의 공통 관측일 없음':lag!>maxLag?`공통 관측일이 ${lag}일 전: 허용 ${maxLag}일 초과`:null;
return {requested,actual:found?.actual??null,lag,values:reason?null:found!.values,reason};
}
export function onOrAfter(input:Observation[],requested:string,maxLag:number):CurveSnapshot{
date(requested);validMaxLag(maxLag);const found=commonSnapshots(input).find(s=>s.actual!>=requested)??null,lag=found?.actual?days(found.actual,requested):null;
const reason=!found?'여섯 만기의 공통 관측일 없음':lag!>maxLag?`공통 관측일이 ${lag}일 후: 허용 ${maxLag}일 초과`:null;
return {requested,actual:found?.actual??null,lag,values:reason?null:found!.values,reason};
}
function changes(current:CurveSnapshot,base:CurveSnapshot):CurveChange[]{return TENORS.map(t=>({tenor:`${t}Y`,base:base.values?.[`${t}Y`]??null,current:current.values?.[`${t}Y`]??null,changeBp:base.values&¤t.values?bp(current.values[`${t}Y`],base.values[`${t}Y`]):null}));}
function spread(snapshot:CurveSnapshot,long:Tenor,short:Tenor):number|null{return snapshot.values?bp(snapshot.values[`${long}Y`],snapshot.values[`${short}Y`]):null;}
export function dashboard(asOf:string,input:Observation[],maxLag:number,policyRate:PolicyRate|null):DashboardResult{
const snapshots=commonSnapshots(input),current=onOrBefore(input,asOf,maxLag);
const previousActual=current.actual?snapshots.filter(s=>s.actual!<current.actual!).at(-1)??null:null;
const previousSession:CurveSnapshot=previousActual?{...previousActual,requested:`${current.actual} 직전 관측일`}:{requested:'직전 관측일',actual:null,lag:null,values:null,reason:'직전 공통 관측일 없음'};
const weekAgo=onOrBefore(input,shift(asOf,-7),maxLag),monthEnd=onOrBefore(input,previousMonthEnd(asOf),maxLag),notes=[current.reason,previousSession.reason,weekAgo.reason,monthEnd.reason].filter((x):x is string=>!!x);
const current3=current.values?.['3Y']??null,policyGap3YBp=current3!==null&&policyRate?bp(current3,policyRate.value):null;if(!policyRate)notes.push('기준금리 확보 실패: 정책금리 갭 제외');else if(policyRate.cacheWriteError)notes.push(`기준금리 원자료는 검증했으나 로컬 캐시 저장 실패 (${policyRate.cacheWriteError})`);
const ready=!!current.values&&!!previousSession.values&&!!weekAgo.values&&!!monthEnd.values;
return {result:ready?'READY':current.values?'INCOMPLETE':'BLOCKED',mode:'dashboard',asOf,current,references:{previousSession,weekAgo,previousMonthEnd:monthEnd},changes:{previousSession:changes(current,previousSession),weekAgo:changes(current,weekAgo),previousMonthEnd:changes(current,monthEnd)},spreads:{'5Y-3Y':spread(current,5,3),'10Y-5Y':spread(current,10,5),'10Y-3Y':spread(current,10,3),'30Y-10Y':spread(current,30,10)},policyRate,policyGap3YBp,notes};
}
export function trend(input:Observation[],sessions:number):TrendResult{
if(![5,20].includes(sessions))throw Error('lookback sessions must be 5 or 20');const all=commonSnapshots(input),selected=all.slice(-sessions),notes:string[]=[];
if(selected.length<sessions)notes.push(`요청 ${sessions}거래일 중 ${selected.length}개 공통 관측일만 확보`);
const stats:TrendStat[]=selected.length<2?[]:TENORS.map(t=>{const key=`${t}Y`,values=selected.map(s=>s.values![key]),moves=selected.slice(1).map((s,i)=>({date:s.actual!,move:bp(s.values![key],selected[i].values![key])})),largest=moves.reduce((a,b)=>Math.abs(b.move)>Math.abs(a.move)?b:a);return {tenor:key,start:values[0],end:values.at(-1)!,netChangeBp:bp(values.at(-1)!,values[0]),high:Math.max(...values),low:Math.min(...values),largestDailyMoveBp:largest.move,largestDailyMoveDate:largest.date};});
return {result:selected.length>=sessions?'READY':selected.length?'INCOMPLETE':'BLOCKED',mode:'trend',requestedSessions:sessions,actualSessions:selected.length,from:selected[0]?.actual??null,to:selected.at(-1)?.actual??null,sessions:selected,stats,notes};
}
interface PolicyCache {version:1;storedAt:string;from:string;to:string;raw:string;rawSha256:string}
function parsePolicy(raw:string,from:string,to:string):{date:string;value:number}{
const doc=JSON.parse(raw) as {RESULT?:{CODE?:string};StatisticSearch?:{list_total_count?:number;row?:Record<string,string>[]}};if(doc.RESULT)throw Error(`ECOS policy ${doc.RESULT.CODE??'unknown'}`);
const block=doc.StatisticSearch,rows=block?.row;if(!block||!Array.isArray(rows)||block.list_total_count!==rows.length||rows.length>10)throw Error('Incomplete policy-rate response');
const seen=new Set<string>(),valid=rows.map(r=>{if(r.STAT_CODE!=='722Y001'||r.STAT_NAME!=='1.3.1. 한국은행 기준금리 및 여수신금리'||r.ITEM_CODE1!=='0101000'||r.ITEM_NAME1!=='한국은행 기준금리'||r.UNIT_NAME!=='연%'||!/^\d{8}$/.test(r.TIME??'')||!/^\d+(?:\.\d+)?$/.test(r.DATA_VALUE??''))throw Error('Policy-rate metadata mismatch');const d=`${r.TIME.slice(0,4)}-${r.TIME.slice(4,6)}-${r.TIME.slice(6,8)}`,value=Number(r.DATA_VALUE);date(d);if(seen.has(d))throw Error('Duplicate policy-rate date');seen.add(d);if(d<from||d>to||!Number.isFinite(value)||value<0||value>30)throw Error('Invalid policy-rate value/range');return {date:d,value};}).sort((a,b)=>a.date.localeCompare(b.date));
if(!valid.length)throw Error('No policy-rate observation');return valid.at(-1)!;
}
export async function collectPolicyRate(asOf:string,cacheDir:string,useCache=true,get:typeof fetch=fetch,now:Date=new Date(),maxAgeHours=168):Promise<PolicyRate|null>{
date(asOf);if(typeof cacheDir!=='string'||!cacheDir.trim())throw Error('cacheDir must be a non-empty path');if(!Number.isFinite(now.getTime()))throw Error('Invalid policy-rate clock');if(!Number.isFinite(maxAgeHours)||maxAgeHours<0)throw Error('maxAgeHours must be non-negative');
const from=shift(asOf,-7),to=asOf,retrievedAt=now.toISOString(),file=join(cacheDir,`policy-${from}-${to}.mjson`);if(useCache)await mkdir(cacheDir,{recursive:true});let liveError='';
try{const key=process.env.KSKILL_BOK_ECOS_API_KEY||'sample',url=`https://ecos.bok.or.kr/api/StatisticSearch/${encodeURIComponent(key)}/json/kr/1/10/722Y001/D/${from.replaceAll('-','')}/${to.replaceAll('-','')}/0101000`,result=await request(url,get),point=parsePolicy(result.text,from,to),digest=sha256(result.text),data:PolicyCache={version:1,storedAt:retrievedAt,from,to,raw:result.text,rawSha256:digest},temp=`${file}.${randomUUID()}.tmp`;let cacheWriteError:string|undefined;if(useCache)try{await writeFile(temp,JSON.stringify({data,digest:sha256(JSON.stringify(data))}));await rename(temp,file);}catch(e){cacheWriteError=e instanceof Error?e.message:String(e);await rm(temp,{force:true});}return {...point,unit:'연%',status:'live',retrievedAt,sourceUrl:'https://ecos.bok.or.kr/',statCode:'722Y001',itemCode:'0101000',rawSha256:digest,...(cacheWriteError?{cacheWriteError}:{})};}catch(e){liveError=e instanceof Error?e.message:String(e);}
if(useCache)try{const envelope=JSON.parse(await readFile(file,'utf8')) as {data:PolicyCache;digest:string},data=envelope.data,age=(now.getTime()-Date.parse(data.storedAt))/3600000;if(envelope.digest!==sha256(JSON.stringify(data))||data.version!==1||data.from!==from||data.to!==to||data.rawSha256!==sha256(data.raw)||!Number.isFinite(age)||age<0||age>maxAgeHours)throw Error('Invalid policy cache');const point=parsePolicy(data.raw,from,to);return {...point,unit:'연%',status:'cache',retrievedAt:data.storedAt,sourceUrl:'https://ecos.bok.or.kr/',statCode:'722Y001',itemCode:'0101000',rawSha256:data.rawSha256,cacheAgeHours:Math.round(age*100)/100};}catch{return null;}
void liveError;return null;
}
src/news.mts
import { date, sha256, type Analysis } from './core.mjs';
export type NewsSourceType='market-close'|'market-update'|'official-release'|'headline';
export type NewsDirection='higher'|'lower'|'mixed'|'flat'|'context';
export type NewsRelation='explicit'|'contextual';
export interface NewsArticle {
publisher:string;title:string;url:string;publishedAt:string;sourceType:NewsSourceType;
marketDirection:NewsDirection;relation:NewsRelation;factor:string;evidenceSummary:string;sourceFingerprint:string;
}
export interface NewsEvidence {version:1;observationDate:string;retrievedAt:string;articles:NewsArticle[]}
export interface NewsAnalysis {
status:'DISABLED'|'SOURCED'|'CONTEXT_ONLY'|'UNATTRIBUTED';confidence:'HIGH'|'MEDIUM'|'LOW'|null;
curveDirection:Exclude<NewsDirection,'context'>|null;commentary:string[];eligible:NewsArticle[];context:NewsArticle[];excluded:NewsArticle[];notes:string[];
}
const sourceTypes=new Set<NewsSourceType>(['market-close','market-update','official-release','headline']);
const directions=new Set<NewsDirection>(['higher','lower','mixed','flat','context']);
const relations=new Set<NewsRelation>(['explicit','contextual']);
const clean=(value:unknown,name:string,max:number,min=1):string=>{
if(typeof value!=='string'||value.trim().length<min||value.length>max||/[\u0000-\u001f\u007f]/.test(value))throw Error(`Invalid news ${name}`);
return value;
};
const instant=(value:unknown,name:string):string=>{
const text=clean(value,name,40),time=Date.parse(text);if(!Number.isFinite(time)||new Date(time).toISOString()!==text)throw Error(`Invalid news ${name}`);return text;
};
const kstDate=(instantValue:string):string=>{
const parts=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Seoul',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date(instantValue));
const pick=(type:Intl.DateTimeFormatPartTypes)=>parts.find(p=>p.type===type)?.value??'';return date(`${pick('year')}-${pick('month')}-${pick('day')}`);
};
const acceptedHost=(hostname:string):boolean=>['news.einfomax.co.kr','kr.investing.com','www.investing.com','investing.com'].includes(hostname)||['bok.or.kr','moef.go.kr','federalreserve.gov','treasury.gov','kostat.go.kr'].some(x=>hostname===x||hostname.endsWith(`.${x}`));
const fingerprint=(a:Omit<NewsArticle,'sourceFingerprint'>):string=>sha256(JSON.stringify(a));
export function newsRead(raw:string):NewsEvidence{
if(raw.length>100_000)throw Error('News evidence exceeds 100 KB character limit');
const doc=JSON.parse(raw) as Record<string,unknown>;if(!doc||typeof doc!=='object'||Array.isArray(doc)||doc.version!==1)throw Error('Unsupported news evidence');
if(Object.keys(doc).some(k=>!['version','observationDate','retrievedAt','articles'].includes(k)))throw Error('Unknown news evidence field');
const observationDate=date(clean(doc.observationDate,'observationDate',10)),retrievedAt=instant(doc.retrievedAt,'retrievedAt');
if(!Array.isArray(doc.articles)||doc.articles.length<1||doc.articles.length>5)throw Error('News evidence requires 1..5 articles');
const articles=doc.articles.map((value,index)=>{
if(!value||typeof value!=='object'||Array.isArray(value))throw Error(`Invalid news article ${index+1}`);const x=value as Record<string,unknown>;if(Object.keys(x).some(k=>!['publisher','title','url','publishedAt','sourceType','marketDirection','relation','factor','evidenceSummary','sourceFingerprint'].includes(k)))throw Error(`Unknown news article field ${index+1}`);
const publisher=clean(x.publisher,'publisher',80,2),title=clean(x.title,'title',220,5),publishedAt=instant(x.publishedAt,'publishedAt'),factor=clean(x.factor,'factor',140,2),evidenceSummary=clean(x.evidenceSummary,'evidenceSummary',280,20);
if(typeof x.sourceType!=='string'||!sourceTypes.has(x.sourceType as NewsSourceType)||typeof x.marketDirection!=='string'||!directions.has(x.marketDirection as NewsDirection)||typeof x.relation!=='string'||!relations.has(x.relation as NewsRelation))throw Error(`Invalid news classification ${index+1}`);
let parsed:URL;try{parsed=new URL(clean(x.url,'url',2048));}catch{throw Error(`Invalid news URL ${index+1}`);}const host=parsed.hostname.toLowerCase();if(parsed.protocol!=='https:'||parsed.username||parsed.password||parsed.port||!acceptedHost(host))throw Error(`Unapproved news host ${index+1}`);if((host==='news.einfomax.co.kr'&&publisher!=='연합인포맥스')||((host==='investing.com'||host.endsWith('.investing.com'))&&publisher!=='Investing.com'))throw Error(`Publisher/host mismatch ${index+1}`);
if(Date.parse(publishedAt)>Date.parse(retrievedAt)||kstDate(publishedAt)!==observationDate)throw Error(`News date alignment mismatch ${index+1}`);
const articleBase={publisher,title,url:parsed.toString(),publishedAt,sourceType:x.sourceType as NewsSourceType,marketDirection:x.marketDirection as NewsDirection,relation:x.relation as NewsRelation,factor,evidenceSummary};
const sourceFingerprint=fingerprint(articleBase);if(x.sourceFingerprint!==undefined&&x.sourceFingerprint!==sourceFingerprint)throw Error(`News fingerprint mismatch ${index+1}`);
if(articleBase.sourceType==='official-release'&&(articleBase.marketDirection!=='context'||articleBase.relation!=='contextual'))throw Error(`Official release must be contextual ${index+1}`);
if(articleBase.relation==='explicit'&&articleBase.marketDirection==='context')throw Error(`Explicit article requires market direction ${index+1}`);
return {...articleBase,sourceFingerprint};
});
if(new Set(articles.map(x=>x.url)).size!==articles.length||new Set(articles.map(x=>x.sourceFingerprint)).size!==articles.length)throw Error('Duplicate news article');
return {version:1,observationDate,retrievedAt,articles};
}
function direction(a:Analysis):Exclude<NewsDirection,'context'>|null{
if(!a.changes)return null;const short=a.changes[1],long=a.changes[3],eps=1e-7;
if(Math.abs(short)<eps&&Math.abs(long)<eps)return 'flat';if(short>eps&&long>eps)return 'higher';if(short< -eps&&long< -eps)return 'lower';return 'mixed';
}
export function analyzeNews(evidence:NewsEvidence|undefined,a:Analysis):NewsAnalysis{
const curveDirection=direction(a);if(!evidence)return {status:'DISABLED',confidence:null,curveDirection,commentary:[],eligible:[],context:[],excluded:[],notes:[]};
const normalized=newsRead(JSON.stringify(evidence));if(!a.current.actual||normalized.observationDate!==a.current.actual)throw Error('News observation date must equal the current common observation date');
const context=normalized.articles.filter(x=>x.relation==='contextual'),eligible=curveDirection?normalized.articles.filter(x=>x.relation==='explicit'&&x.marketDirection===curveDirection):[];
const excluded=normalized.articles.filter(x=>x.relation==='explicit'&&x.marketDirection!==curveDirection),notes:string[]=[];if(excluded.length)notes.push(`${excluded.length}개 기사는 관측된 3Y·10Y 방향과 불일치하여 원인 코멘트에서 제외`);
let status:NewsAnalysis['status']='UNATTRIBUTED',confidence:NewsAnalysis['confidence']=null;if(eligible.length){status='SOURCED';confidence=eligible.some(x=>x.sourceType==='market-close'&&new URL(x.url).hostname==='news.einfomax.co.kr')?'HIGH':new Set(eligible.map(x=>new URL(x.url).hostname)).size>=2?'MEDIUM':'LOW';}else if(context.length)status='CONTEXT_ONLY';
const commentary=eligible.slice(0,3).map(x=>`시장 배경: ${x.factor} (${x.publisher} 「${x.title}」).`);if(status==='CONTEXT_ONLY')commentary.push('같은 관측일의 공식 발표는 확인했지만 시장 움직임의 직접 원인으로 연결하지 않았다.');if(status==='UNATTRIBUTED')commentary.push('관측 방향과 날짜가 일치하는 근거 기사를 확인하지 못했다.');
return {status,confidence,curveDirection,commentary,eligible,context,excluded,notes};
}
export function newsTemplate(observationDate:string,retrievedAt:string):string{
date(observationDate);instant(retrievedAt,'retrievedAt');const publishedAt=new Date(`${observationDate}T00:00:00+09:00`).toISOString();return JSON.stringify({version:1,observationDate,retrievedAt,articles:[{publisher:'연합인포맥스',title:'기사 제목을 입력',url:'https://news.einfomax.co.kr/news/articleView.html?idxno=0000000',publishedAt,sourceType:'market-close',marketDirection:'mixed',relation:'explicit',factor:'기사에서 명시한 시장 배경을 짧게 의역',evidenceSummary:'기사에서 시장 배경을 설명한 근거를 20~280자로 의역'}]},null,2)+'\n';
}
src/render.mts
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { analyze, TENORS, type Analysis, type Packet } from './core.mjs';
import { analyzeNews, type NewsAnalysis } from './news.mjs';
const h=(s:string):string=>createHash('sha256').update(s).digest('hex');
const esc=(s:unknown):string=>String(s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]!));
const md=(s:unknown):string=>String(s).replace(/[\\`*_[\]<>]/g,'\\$&');
const n=(v:number|null,d=2):string=>v===null?'—':v.toFixed(d);
const signed=(v:number|null):string=>v===null?'—':`${v>0?'+':''}${v.toFixed(2)}`;
const trend=(v:number|null):string=>v===null?'판정 불가':v>0?'상승':v<0?'하락':'보합';
const spreadTrend=(v:number|null):string=>v===null?'판정 불가':v>0?'확대':v<0?'축소':'보합';
export function marketCsv(p:Packet,a:Analysis):string{
const rows=['구분,만기,현재 요청일,현재 관측일,현재 금리(%),비교 요청일,비교 관측일,비교 금리(%),변화(bp)'];
TENORS.forEach((t,i)=>rows.push([
'금리',`${t}Y`,a.current.requested,a.current.actual??'',a.current.values?.[i]??'',a.previous.requested,a.previous.actual??'',a.previous.values?.[i]??'',a.changes?.[i]??''
].join(',')));
a.spreads.forEach(s=>rows.push(['스프레드',s.name,a.current.requested,a.current.actual??'',s.current??'',a.previous.requested,a.previous.actual??'',s.previous??'',s.change??''].join(',')));
return '\uFEFF'+rows.join('\r\n')+'\r\n';
}
export function brief(p:Packet,a:Analysis,news:NewsAnalysis=analyzeNews(p.newsEvidence,a)):string{
const current=a.current.actual??'확인 불가', previous=a.previous.actual??'확인 불가';
const lines=[`# ${md(p.label)}`,``,`- 상태: **${a.status}**`,`- 요청 기준일: ${p.asOf} / 사용 관측일: ${current}`,
`- 비교 기준일: ${p.base} / 사용 관측일: ${previous}`,''];
if(a.changes&&a.current.values&&a.previous.values){
const i3=TENORS.indexOf(3),i5=TENORS.indexOf(5),i10=TENORS.indexOf(10),s103=a.spreads.find(s=>s.name==='10년−3년')!;
lines.push('## 핵심 변화','',
`- 국고채 3년은 ${signed(a.changes[i3])}bp ${trend(a.changes[i3])}, 5년은 ${signed(a.changes[i5])}bp ${trend(a.changes[i5])}, 10년은 ${signed(a.changes[i10])}bp ${trend(a.changes[i10])}.`,
`- 10년−3년 스프레드는 ${n(s103.current)}bp이며 비교일보다 ${n(Math.abs(s103.change??0))}bp ${spreadTrend(s103.change)}.`,
`- 곡선 움직임: **${a.movement}**.`,'');
} else lines.push('## 결과 제한','', '- 비교 가능한 여섯 만기의 공통 관측값이 없어 변화 계산과 문장 생성을 차단했습니다.','');
if(news.status!=='DISABLED')lines.push('## 시장 배경','',`- 뉴스 근거 상태: **${news.status}**${news.confidence?` / 신뢰도 **${news.confidence}**`:''}`,...news.commentary.map(x=>`- ${md(x)}`),...news.notes.map(x=>`- 주의: ${md(x)}`),...([...news.eligible.map(x=>({label:'채택',article:x})),...news.context.map(x=>({label:'맥락',article:x})),...news.excluded.map(x=>({label:'제외',article:x}))].map(({label,article:x})=>`- ${label} · ${md(x.publisher)} · ${md(x.title)} · ${x.publishedAt} · [원문](${x.url}) · 근거 SHA-256 ${x.sourceFingerprint}`)),'','- 기사에서 전한 시장 해석을 출처와 함께 요약했으며, ECOS 수치만으로 인과관계를 확정하지 않는다.','');
lines.push('## 검증 메모','',...(a.notes.length?a.notes.map(x=>`- ${md(x)}`):['- 결측·날짜 정렬 경고 없음.']),'',
'## 출처','',...p.receipts.map(r=>`- ${r.tenor}년: ${md(r.sourceId)} / ${md(r.statCode)}·${md(r.itemCode)} ${md(r.itemName)} / ${md(r.unit)}·${md(r.cycle)} / ${r.first??'없음'}~${r.last??'없음'} (${r.count}건) / ${r.status} / 원문 SHA-256 ${r.rawSha256.join(', ')||'없음'}${r.sourceUrl?` / ${r.sourceUrl}`:''}`),'',
'## 해석 범위','', '- 이 결과는 공식 통계의 기간 비교다. 실시간 호가, 원인 추정, 금리 전망 또는 투자 권유가 아니다.','');
return lines.join('\n')+'\n';
}
function chart(a:Analysis):string{
if(!a.current.values||!a.previous.values)return '<div class="empty">공통 관측값이 없어 차트를 생성하지 않았습니다.</div>';
const all=[...a.current.values,...a.previous.values],min=Math.min(...all)-.03,max=Math.max(...all)+.03,range=Math.max(max-min,.1);
const x=(i:number)=>75+i*(500/(TENORS.length-1)), y=(v:number)=>260-(v-min)/range*190;
const path=(values:number[])=>values.map((v,i)=>`${i?'L':'M'} ${x(i)} ${y(v).toFixed(1)}`).join(' ');
const grid=[0,.25,.5,.75,1].map(q=>{const yy=260-q*190,v=min+q*range;return `<line x1="62" y1="${yy}" x2="560" y2="${yy}"/><text x="54" y="${yy+4}" text-anchor="end">${v.toFixed(2)}</text>`}).join('');
const dots=(values:number[],cls:string)=>values.map((v,i)=>`<circle class="${cls}" cx="${x(i)}" cy="${y(v)}" r="5"/>`).join('');
const labels=(values:number[],other:number[],cls:string)=>values.map((v,i)=>{const above=y(v)<=y(other[i]),baseline=y(v)+(above?-14:22),top=baseline-13;return `<g class="value-label ${cls}"><rect x="${x(i)-24}" y="${top}" width="48" height="18" rx="5"/><text x="${x(i)}" y="${baseline}" text-anchor="middle">${v.toFixed(3)}</text></g>`;}).join('');
return `<svg viewBox="0 0 620 320" role="img" aria-label="국고채 수익률곡선 비교"><g class="grid">${grid}</g><path class="line prior" d="${path(a.previous.values)}"/><path class="line now" d="${path(a.current.values)}"/>${dots(a.previous.values,'prior')}${dots(a.current.values,'now')}${labels(a.previous.values,a.current.values,'prior')}${labels(a.current.values,a.previous.values,'now')}<g class="axis">${TENORS.map((t,i)=>`<text x="${x(i)}" y="288" text-anchor="middle">${t}년</text>`).join('')}</g><g class="legend"><circle cx="405" cy="26" r="5" class="now"/><text x="416" y="30">현재</text><circle cx="485" cy="26" r="5" class="prior"/><text x="496" y="30">비교</text></g></svg>`;
}
export function html(p:Packet,a:Analysis,news:NewsAnalysis=analyzeNews(p.newsEvidence,a)):string{
const statusClass=a.status.toLowerCase();
const allInput=p.receipts.every(r=>r.status==='input'),allEcos=p.receipts.every(r=>r.status!=='input');
const sourceFooter=allInput?'출처: 사용자 제공 CSV · 연% · 일별 입력자료. 원자료 성격은 제공자가 확인해야 합니다.':allEcos?'출처: 한국은행 ECOS 817Y002 · 연% · 일별 공식 통계. 실시간 호가나 투자 권유가 아닙니다.':'출처: 한국은행 ECOS 및 사용자 제공 CSV 혼합 자료. 각 만기의 출처를 확인해야 합니다.';
const rows=TENORS.map((t,i)=>`<tr><td>${t}년</td><td>${n(a.previous.values?.[i]??null,3)}</td><td>${n(a.current.values?.[i]??null,3)}</td><td class="${(a.changes?.[i]??0)>0?'up':(a.changes?.[i]??0)<0?'down':''}">${signed(a.changes?.[i]??null)}</td></tr>`).join('');
const spreadRows=a.spreads.map(s=>`<tr><td>${esc(s.name)}</td><td>${n(s.previous)}</td><td>${n(s.current)}</td><td>${signed(s.change)}</td></tr>`).join('');
const sourceRows=p.receipts.map(r=>`<tr><td>${r.tenor}년</td><td>${esc(r.sourceId)}</td><td>${esc(r.statCode)} / ${esc(r.itemCode)}<br><span class="sub">${esc(r.itemName)}</span></td><td>${esc(r.unit)} / ${esc(r.cycle)}</td><td>${esc(r.first??'없음')}<br>${esc(r.last??'없음')} · ${r.count}건</td><td>${esc(r.retrievedAt)}<br><strong>${esc(r.status)}</strong></td><td>${r.sourceUrl?`<a href="${esc(r.sourceUrl)}" rel="noreferrer">공식 원천</a>`:'로컬 입력'}</td></tr>`).join('');
const newsRows=[...news.eligible.map(x=>({label:'채택',article:x})),...news.context.map(x=>({label:'맥락',article:x})),...news.excluded.map(x=>({label:'제외',article:x}))];const newsSection=news.status==='DISABLED'?'':`<section class="card" style="margin-top:18px"><h2>시장 배경</h2><div class="metric">${news.status}${news.confidence?` · ${news.confidence}`:''}</div><ul class="notes">${[...news.commentary,...news.notes.map(x=>`주의: ${x}`)].map(x=>`<li>${esc(x)}</li>`).join('')}</ul><div class="scroll"><table class="news-table"><colgroup><col class="news-result"><col class="news-publisher"><col class="news-title"><col class="news-time"><col class="news-summary"></colgroup><thead><tr><th>판정</th><th>매체</th><th>기사</th><th>게시 UTC</th><th>근거 요약</th></tr></thead><tbody>${newsRows.map(({label,article:x})=>`<tr><td>${label}</td><td>${esc(x.publisher)}</td><td><a href="${esc(x.url)}" rel="noreferrer">${esc(x.title)}</a></td><td>${esc(x.publishedAt)}</td><td>${esc(x.evidenceSummary)}</td></tr>`).join('')}</tbody></table></div><p class="sub">기사에서 전한 시장 해석을 출처와 함께 요약했으며, ECOS 수치만으로 인과관계를 확정하지 않습니다.</p></section>`;
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(p.label)}</title><style>
:root{--ink:#172033;--muted:#687386;--paper:#f5f3ee;--card:#fff;--blue:#165dff;--orange:#e97522;--line:#dfe4eb;--good:#17745c;--warn:#a15c00;--bad:#a9363e}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}.wrap{max-width:1240px;margin:0 auto;padding:36px 22px 56px}header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;margin-bottom:20px}h1{font-size:30px;line-height:1.16;margin:4px 0 8px;letter-spacing:-.03em}.eyebrow{font-size:12px;font-weight:700;color:var(--blue);letter-spacing:.08em}.meta{color:var(--muted);font-size:13px}.badge{padding:7px 12px;border-radius:999px;font-size:13px;font-weight:800;background:#e8f5f0;color:var(--good)}.badge.incomplete{background:#fff0d9;color:var(--warn)}.badge.blocked{background:#fae8e9;color:var(--bad)}.grid2{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(340px,.7fr);gap:16px}.card{min-width:0;background:var(--card);border:1px solid var(--line);border-radius:15px;padding:19px;box-shadow:0 8px 24px #1a243008}.scroll{overflow-x:auto}h2{font-size:17px;margin:0 0 13px}table{width:100%;border-collapse:collapse;font-size:13px;font-variant-numeric:tabular-nums}th,td{text-align:right;padding:8px 7px;border-bottom:1px solid var(--line);vertical-align:middle}th:first-child,td:first-child{text-align:left}th{color:var(--muted);font-size:11px;font-weight:700;line-height:1.25;white-space:nowrap;text-transform:uppercase}.rates-table td,.spread-table td{white-space:nowrap}.news-table,.source-table{table-layout:fixed;min-width:920px}.news-table th,.news-table td,.source-table th,.source-table td{text-align:left;overflow-wrap:break-word}.news-table .news-result{width:7%}.news-table .news-publisher{width:12%}.news-table .news-title{width:24%}.news-table .news-time{width:18%}.news-table .news-summary{width:39%}.source-table .source-tenor{width:7%}.source-table .source-provider{width:18%}.source-table .source-code{width:21%}.source-table .source-unit{width:10%}.source-table .source-range{width:18%}.source-table .source-retrieved{width:18%}.source-table .source-link{width:8%}.metric{font-size:23px;line-height:1.25;font-weight:800}.sub{color:var(--muted);font-size:12px}.notes{margin:0;padding-left:19px}.notes li+li{margin-top:6px}svg{display:block;width:100%;height:auto}.grid line{stroke:#e5e9ef}.grid text,.axis text,.legend text{fill:#687386;font-size:11px}.line{fill:none;stroke-width:2.5}.line.now{stroke:var(--blue)}.line.prior{stroke:var(--orange);stroke-dasharray:7 5}circle.now{fill:var(--blue)}circle.prior{fill:var(--orange)}.value-label rect{fill:#fff;stroke-width:1}.value-label text{font-size:10.5px;font-weight:750;font-variant-numeric:tabular-nums}.value-label.now rect{stroke:#9db8ff}.value-label.now text{fill:#174fc5}.value-label.prior rect{stroke:#f0bc95}.value-label.prior text{fill:#a84e12}.empty{padding:80px 20px;text-align:center;color:var(--muted)}footer{margin-top:16px;color:var(--muted);font-size:11px}@media(max-width:820px){.grid2{grid-template-columns:1fr}header{display:block}.badge{display:inline-block;margin-top:12px}}@media(max-width:560px){.wrap{padding:24px 12px 40px}.card{padding:15px}h1{font-size:25px}}
</style></head><body><main class="wrap"><header><div><div class="eyebrow">KOREAN RATES · VERIFIED SNAPSHOT</div><h1>${esc(p.label)}</h1><div class="meta">요청 ${esc(p.base)} → ${esc(p.asOf)} · 관측 ${esc(a.previous.actual??'없음')} → ${esc(a.current.actual??'없음')}</div></div><div class="badge ${statusClass}">${a.status}</div></header><section class="grid2"><article class="card"><h2>수익률곡선</h2>${chart(a)}</article><article class="card"><h2>곡선 판정</h2><div class="metric">${esc(a.movement)}</div><p class="sub">3년과 10년 변화 및 10년−3년 스프레드를 기준으로 한 기계적 분류</p><table class="spread-table"><thead><tr><th>구간</th><th>비교</th><th>현재</th><th>변화(bp)</th></tr></thead><tbody>${spreadRows}</tbody></table></article></section><section class="card" style="margin-top:16px"><h2>만기별 금리</h2><table class="rates-table"><thead><tr><th>만기</th><th>비교일(%)</th><th>현재일(%)</th><th>변화(bp)</th></tr></thead><tbody>${rows}</tbody></table></section>${newsSection}<section class="card" style="margin-top:16px"><h2>검증 메모</h2><ul class="notes">${(a.notes.length?a.notes:['결측·날짜 정렬 경고 없음.']).map(x=>`<li>${esc(x)}</li>`).join('')}</ul></section><section class="card" style="margin-top:16px"><h2>원천·검증 추적</h2><div class="scroll"><table class="source-table" data-provenance="true"><colgroup><col class="source-tenor"><col class="source-provider"><col class="source-code"><col class="source-unit"><col class="source-range"><col class="source-retrieved"><col class="source-link"></colgroup><thead><tr><th>만기</th><th>제공기관</th><th>통계/항목</th><th>단위/주기</th><th>관측 범위</th><th>수집 UTC/방식</th><th>링크</th></tr></thead><tbody>${sourceRows}</tbody></table></div></section><footer>${esc(sourceFooter)}</footer></main></body></html>`;
}
const artifactNames=['market-table.csv','brief.md','report.html','evidence.mjson','status.mjson'] as const;
function statusJson(p:Packet,a:Analysis,news:NewsAnalysis):string{return JSON.stringify({status:a.status,notes:a.notes,usable:a.status==='READY',news:{status:news.status,confidence:news.confidence,curveDirection:news.curveDirection,notes:news.notes,sources:[...news.eligible.map(x=>({disposition:'eligible',...x})),...news.context.map(x=>({disposition:'context',...x})),...news.excluded.map(x=>({disposition:'excluded',...x}))].map(({disposition,publisher,title,url,publishedAt,sourceFingerprint})=>({disposition,publisher,title,url,publishedAt,sourceFingerprint}))},sources:p.receipts.map(({tenor,status,sourceId,statCode,itemCode,unit,cycle,count,first,last,retrievedAt,rawSha256})=>({tenor,status,sourceId,statCode,itemCode,unit,cycle,count,first,last,retrievedAt,rawSha256}))},null,2)+'\n';}
export interface Manifest { version:5; engine:string; status:Analysis['status']; newsStatus:NewsAnalysis['status']; evidenceSha256:string; files:Record<string,string> }
export async function writeArtifacts(out:string,p:Packet):Promise<Manifest>{
const a=analyze(p),news=analyzeNews(p.newsEvidence,a),parent=dirname(out),stage=join(parent,`.${basename(out)}.stage-${process.pid}-${randomUUID()}`);
await mkdir(parent,{recursive:true});
try{await stat(out);throw Error(`Output path already exists: ${out}`);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}
try{
await mkdir(stage,{recursive:false});
const evidence=JSON.stringify(p,null,2)+'\n';
const files:Record<string,string>={'market-table.csv':marketCsv(p,a),'brief.md':brief(p,a,news),'report.html':html(p,a,news),'evidence.mjson':evidence,'status.mjson':statusJson(p,a,news)};
const manifest:Manifest={version:5,engine:p.engine,status:a.status,newsStatus:news.status,evidenceSha256:h(evidence),files:{}};
for(const name of artifactNames){const content=files[name];if(/NaN|undefined/.test(content))throw Error(`${name}: invalid token`);manifest.files[name]=h(content);await writeFile(join(stage,name),content);}
await writeFile(join(stage,'manifest.mjson'),JSON.stringify(manifest,null,2)+'\n');await validateArtifacts(stage);await rename(stage,out);return manifest;
}catch(e){await rm(stage,{recursive:true,force:true});throw e;}
}
export async function validateArtifacts(dir:string):Promise<void>{
const manifest=JSON.parse(await readFile(join(dir,'manifest.mjson'),'utf8')) as Manifest;
if(manifest.version!==5||manifest.engine!=='korean-interest-rate-analysis/5'||!manifest.files||typeof manifest.files!=='object')throw Error('Unsupported manifest');
const expected=[...artifactNames].sort(),declared=Object.keys(manifest.files).sort(),present=(await readdir(dir)).sort();
if(JSON.stringify(declared)!==JSON.stringify(expected)||JSON.stringify(present)!==JSON.stringify([...expected,'manifest.mjson'].sort())||Object.values(manifest.files).some(x=>typeof x!=='string'||!/^[a-f0-9]{64}$/.test(x))||!/^[a-f0-9]{64}$/.test(manifest.evidenceSha256))throw Error('Manifest file contract mismatch');
const evidence=await readFile(join(dir,'evidence.mjson'),'utf8');
if(h(evidence)!==manifest.evidenceSha256)throw Error('Evidence hash mismatch');
const p=JSON.parse(evidence) as Packet,a=analyze(p),news=analyzeNews(p.newsEvidence,a);
if(a.status!==manifest.status||news.status!==manifest.newsStatus)throw Error('Status mismatch');
for(const [name,digest] of Object.entries(manifest.files)){const content=await readFile(join(dir,name),'utf8');if(h(content)!==digest)throw Error(`${name}: hash mismatch`);}
const mdFile=await readFile(join(dir,'brief.md'),'utf8'),csv=await readFile(join(dir,'market-table.csv'),'utf8'),page=await readFile(join(dir,'report.html'),'utf8'),status=await readFile(join(dir,'status.mjson'),'utf8');
if(mdFile!==brief(p,a,news)||csv!==marketCsv(p,a)||page!==html(p,a,news)||status!==statusJson(p,a,news))throw Error('Rendered artifact is not reproducible');
if(a.status==='READY'&&(!page.includes('<svg')||csv.trim().split(/\r?\n/).length!==1+TENORS.length+a.spreads.length||!mdFile.includes('## 핵심 변화')||!page.includes('data-provenance="true"')||!page.includes('class="value-label')||page.includes('원문 SHA-256')||p.receipts.some(r=>r.sourceUrl&&!page.includes(r.sourceUrl))))throw Error('READY output contract is incomplete');
if(a.status!=='READY'&&!mdFile.includes('## 결과 제한'))throw Error('Non-ready output must state its limitation');
}
src/schedule.mts
import { date, days, shift } from './core.mjs';
export type ScheduleMarket = 'KR' | 'US';
export type ScheduleKind = 'auction' | 'policy';
export interface ScheduleEvent {
date: string;
market: ScheduleMarket;
kind: ScheduleKind;
tenor?: number;
sourceUrl: string;
sourceExcerpt: string;
sourceDateText: string;
}
export interface ScheduleEvidence {
version: 1;
anchorDate: string;
checkedAt: string;
events: ScheduleEvent[];
}
export interface ScheduleAnalysis {
status: 'VERIFIED' | 'PARTIAL' | 'UNVERIFIED';
anchorDate: string;
checkedAt: string | null;
events: Array<ScheduleEvent & { label: string }>;
note: string;
}
const hosts: Record<ScheduleMarket, Record<ScheduleKind, readonly string[]>> = {
KR: { auction: ['ktb.moef.go.kr'], policy: ['bok.or.kr', 'www.bok.or.kr'] },
US: { auction: ['home.treasury.gov', 'www.treasurydirect.gov', 'treasurydirect.gov'], policy: ['www.federalreserve.gov', 'federalreserve.gov'] }
};
const text = (value: unknown, max: number): value is string => typeof value === 'string' && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
const iso = (value: unknown): value is string => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
function quotedDate(quoted: string, eventDate: string, excerpt: string): boolean {
if (!excerpt.includes(quoted)) return false;
const weekday = quoted.match(/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),/);
if (weekday && new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', weekday: 'long' }).format(new Date(`${eventDate}T00:00:00Z`)) !== weekday[1]) return false;
const isoOrDots = quoted.match(/^(\d{4})[-.](\d{2})[-.](\d{2})\.?$/);
if (isoOrDots) return `${isoOrDots[1]}-${isoOrDots[2]}-${isoOrDots[3]}` === eventDate;
const ko = quoted.match(/^(\d{1,2})월\s*(\d{1,2})일$/);
if (ko) return excerpt.includes(eventDate.slice(0, 4)) && Number(ko[1]) === Number(eventDate.slice(5, 7)) && Number(ko[2]) === Number(eventDate.slice(8));
const fed = quoted.match(/^([A-Z][a-z]+)\s+(\d{1,2})-(\d{1,2})$/);
if (fed) {
if (Number(fed[2]) >= Number(fed[3])) return false;
const parsed = Date.parse(`${fed[1]} ${fed[3]}, ${eventDate.slice(0, 4)} GMT`);
return excerpt.includes(eventDate.slice(0, 4)) && Number.isFinite(parsed) && new Date(parsed).toISOString().slice(0, 10) === eventDate;
}
const english = Date.parse(`${quoted} GMT`);
return Number.isFinite(english) && new Date(english).toISOString().slice(0, 10) === eventDate;
}
function validateEvent(e: ScheduleEvent, anchorDate: string): void {
if (!e || typeof e !== 'object' || !['KR', 'US'].includes(e.market) || !['auction', 'policy'].includes(e.kind) || date(e.date) !== e.date || e.date < anchorDate || e.date > shift(anchorDate, 45)) throw Error('Schedule event date, market, or kind mismatch');
if (!text(e.sourceUrl, 500) || !text(e.sourceExcerpt, 500) || !text(e.sourceDateText, 80)) throw Error('Schedule source text missing or unsafe');
let host: string;
try { const url = new URL(e.sourceUrl); if (url.protocol !== 'https:' || url.username || url.password || url.toString() !== e.sourceUrl || /[\s()<>{}]/.test(e.sourceUrl)) throw Error(); host = url.hostname.toLowerCase(); } catch { throw Error('Schedule source URL must be official HTTPS'); }
if (!hosts[e.market][e.kind].includes(host)) throw Error('Schedule source host does not match market and kind');
if (!quotedDate(e.sourceDateText, e.date, e.sourceExcerpt)) throw Error('Schedule quoted date does not match event date');
if (e.kind === 'auction') {
if (!Number.isInteger(e.tenor) || ![2, 3, 5, 7, 10, 20, 30, 50].includes(e.tenor!) || (e.market === 'US' && !new RegExp(`\\b${e.tenor}-Year (?:NOTE|BOND)\\b`, 'i').test(e.sourceExcerpt)) || (e.market === 'KR' && !e.sourceExcerpt.includes(`${e.tenor}년물`))) throw Error('Schedule auction tenor or source marker mismatch');
if (e.market === 'US') {
const dates = [...e.sourceExcerpt.matchAll(/(?:Monday|Tuesday|Wednesday|Thursday|Friday),\s+[A-Z][a-z]+\s+\d{1,2},\s+\d{4}/g)].map(match => match[0]);
if (/tentative/i.test(e.sourceUrl) && dates.length < 3) throw Error('Tentative U.S. auction row must include announcement, auction, and settlement dates');
if (dates.length >= 3 && e.sourceDateText !== dates[1]) throw Error('Schedule U.S. auction date must be the second date in the official row');
}
} else if (e.tenor !== undefined || (e.market === 'KR' && !/통화정책방향|금융통화위원회/.test(e.sourceExcerpt)) || (e.market === 'US' && !/FOMC|Federal Open Market Committee/.test(e.sourceExcerpt))) throw Error('Schedule policy source marker mismatch');
}
export function readSchedule(raw: string, anchorDate: string): ScheduleEvidence {
if (raw.length > 100_000) throw Error('Schedule evidence exceeds 100 KB');
const evidence = JSON.parse(raw) as ScheduleEvidence;
validateSchedule(evidence, anchorDate);
return evidence;
}
export function validateSchedule(evidence: ScheduleEvidence, anchorDate: string): void {
date(anchorDate);
if (!evidence || typeof evidence !== 'object' || evidence.version !== 1 || evidence.anchorDate !== anchorDate || !iso(evidence.checkedAt) || !Array.isArray(evidence.events) || evidence.events.length > 30) throw Error('Schedule evidence identity or size mismatch');
const checkedDate = evidence.checkedAt.slice(0, 10);
if (days(checkedDate, anchorDate) < -1 || days(checkedDate, anchorDate) > 14) throw Error('Schedule verification time is too far from report date');
const seen = new Set<string>();
for (const e of evidence.events) {
validateEvent(e, anchorDate);
const key = `${e.date}/${e.market}/${e.kind}/${e.tenor ?? ''}`;
if (seen.has(key)) throw Error('Duplicate schedule event');
seen.add(key);
}
}
export function analyzeSchedule(evidence: ScheduleEvidence | undefined, anchorDate: string): ScheduleAnalysis {
date(anchorDate);
if (!evidence) return { status: 'UNVERIFIED', anchorDate, checkedAt: null, events: [], note: '공식 일정 자료를 확인하지 못해 날짜를 표시하지 않았습니다.' };
validateSchedule(evidence, anchorDate);
const label = (e: ScheduleEvent): string => e.kind === 'policy' ? e.market === 'KR' ? '한국은행 통화정책방향 결정회의' : 'FOMC 금리 결정' : e.market === 'KR' ? `국고채 ${e.tenor}년물 입찰` : `미국 국채 ${e.tenor}년물 입찰${/tentative/i.test(e.sourceUrl) ? ' (잠정)' : ''}`;
const events = [...evidence.events].sort((a, b) => a.date.localeCompare(b.date) || a.market.localeCompare(b.market) || a.kind.localeCompare(b.kind) || (a.tenor ?? 0) - (b.tenor ?? 0));
const limited = events.filter(e => events.filter(x => x.market === e.market && (x.date < e.date || (x.date === e.date && events.indexOf(x) <= events.indexOf(e)))).length <= 4).slice(0, 8).map(e => ({ ...e, label: label(e) }));
const markets = new Set(events.map(e => e.market));
return { status: markets.size === 2 ? 'VERIFIED' : 'PARTIAL', anchorDate, checkedAt: evidence.checkedAt, events: limited, note: markets.size === 2 ? '각 날짜는 해당 국가 현지일입니다. 예정 일정은 변경될 수 있으니 원문을 다시 확인하세요.' : markets.size === 1 ? `${markets.has('KR') ? '미국' : '한국'} 공식 일정은 확인되지 않았습니다. 표시 날짜는 해당 국가 현지일입니다.` : '조회 기간에 확인된 공식 일정이 없습니다.' };
}
src/ust.mts
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { bp, classify, date, days, shift, TENORS, type Tenor } from './core.mjs';
const FIELDS: Record<Tenor, string> = {
2: 'BC_2YEAR', 3: 'BC_3YEAR', 5: 'BC_5YEAR',
10: 'BC_10YEAR', 20: 'BC_20YEAR', 30: 'BC_30YEAR'
};
export const UST_SOURCE = 'U.S. Treasury Daily Treasury Par Yield Curve Rates';
export const UST_SOURCE_PAGE = 'https://home.treasury.gov/resource-center/data-chart-center/interest-rates/TextView?type=daily_treasury_yield_curve';
export const UST_ENGINE = 'ktb-ust-rate-analysis/1';
export interface UstRow { date: string; yields: Record<Tenor, number> }
export interface UstSource { month: string; url: string; retrievedAt: string; status: 'live' | 'cache' | 'failed'; raw: string; sha256: string; cacheAgeHours?: number; error?: string }
export interface UstPacket { version: 1; engine: typeof UST_ENGINE; asOf: string; base: string; maxLag: number; sources: UstSource[] }
export interface UstPoint { requested: string; actual: string | null; lag: number | null; yields: Record<Tenor, number> | null; reason: string | null }
export interface UstAnalysis { status: 'READY' | 'INCOMPLETE' | 'BLOCKED'; current: UstPoint; previous: UstPoint; changes: Record<Tenor, number> | null; spreads: Record<string, number | null>; movement: string; notes: string[]; rows: UstRow[] }
const digest = (raw: string): string => createHash('sha256').update(raw).digest('hex');
const cleanError = (error: unknown): string => (error instanceof Error ? error.message : String(error)).replace(/[\r\n\t]+/g, ' ').slice(0, 240) || 'unknown error';
export function ustMonthUrl(month: string): string {
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw Error('Invalid UST month');
return `https://home.treasury.gov/resource-center/data-chart-center/interest-rates/pages/xml?data=daily_treasury_yield_curve&field_tdr_date_value_month=${month.replace('-', '')}`;
}
function oneTag(xml: string, name: string): string | null {
const matches = [...xml.matchAll(new RegExp(`<d:${name}(?:\\s[^>]*)?>([^<]*)<\\/d:${name}>`, 'g'))];
const nulls = [...xml.matchAll(new RegExp(`<d:${name}\\s+[^>]*m:null="true"[^>]*/>`, 'g'))];
if (matches.length + nulls.length !== 1) throw Error(`UST XML field ${name} missing or duplicated`);
if (nulls.length) return null;
return matches[0][1].trim();
}
export function parseUstXml(raw: string, month: string): UstRow[] {
if (raw.length > 5_000_000 || !raw.includes('<feed ') || !raw.includes('<title type="text">DailyTreasuryYieldCurveRateData</title>')) throw Error('Invalid UST XML feed');
const entries = [...raw.matchAll(/<entry>([\s\S]*?)<\/entry>/g)];
if (!entries.length) throw Error(`UST feed has no entries for ${month}`);
const rows: UstRow[] = [];
const seen = new Set<string>();
for (const [, entry] of entries) {
const props = [...entry.matchAll(/<m:properties>([\s\S]*?)<\/m:properties>/g)];
if (props.length !== 1) throw Error('UST XML properties missing or duplicated');
const timestamp = oneTag(props[0][1], 'NEW_DATE');
if (!timestamp || !/^\d{4}-\d{2}-\d{2}T00:00:00$/.test(timestamp)) throw Error('Invalid UST observation date');
const observationDate = date(timestamp.slice(0, 10));
if (!observationDate.startsWith(`${month}-`) || seen.has(observationDate)) throw Error('UST month/date mismatch or duplicate observation');
seen.add(observationDate);
const yields = {} as Record<Tenor, number>;
let complete = true;
for (const tenor of TENORS) {
const value = oneTag(props[0][1], FIELDS[tenor]);
if (value === null || value === '') { complete = false; continue; }
if (!/^-?\d+(?:\.\d+)?$/.test(value)) throw Error(`Invalid UST ${tenor}Y yield`);
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0 || numeric > 50) throw Error(`UST ${tenor}Y yield outside annual-percent bounds`);
yields[tenor] = numeric;
}
if (complete) rows.push({ date: observationDate, yields });
}
return rows.sort((a, b) => a.date.localeCompare(b.date));
}
export function ustMonths(base: string, asOf: string, maxLag: number): string[] {
date(base); date(asOf);
if (base >= asOf || days(asOf, base) > 366) throw Error('UST comparison dates must be ordered within 366 days');
if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14) throw Error('UST maxLag must be 0..14');
const months = new Set<string>();
for (const anchor of [base, asOf]) {
for (let d = shift(anchor, -maxLag); d <= anchor; d = shift(d, 1)) months.add(d.slice(0, 7));
}
return [...months].sort();
}
async function fetchUst(url: string, month: string, fetcher: typeof fetch): Promise<string> {
let error: Error = Error('UST request failed');
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const response = await fetcher(url, { signal: AbortSignal.timeout(15_000), headers: { Accept: 'application/xml,text/xml' } });
if (!response.ok) {
if (response.status >= 400 && response.status < 500 && response.status !== 429) throw Error(`UST HTTP ${response.status} (fatal)`);
throw Error(`UST HTTP ${response.status}`);
}
const raw = await response.text();
if (raw.length > 5_000_000) throw Error('UST response exceeds 5 MB');
parseUstXml(raw, month);
return raw;
} catch (e) {
error = e instanceof Error ? e : Error(String(e));
if (error.message.endsWith('(fatal)')) break;
if (attempt < 3) await new Promise(resolve => setTimeout(resolve, 200 * attempt));
}
}
throw error;
}
async function writeUstCache(file: string, source: UstSource): Promise<void> {
const temporary = `${file}.${randomUUID()}.tmp`;
try { await writeFile(temporary, JSON.stringify(source)); await rename(temporary, file); }
catch (error) { await rm(temporary, { force: true }); throw error; }
}
export async function collectUst(base: string, asOf: string, maxLag: number, cacheDir: string, useCache = true, fetcher: typeof fetch = fetch, now = new Date()): Promise<UstPacket> {
if (!Number.isFinite(now.getTime())) throw Error('Invalid UST collection clock');
const sources: UstSource[] = [];
for (const month of ustMonths(base, asOf, maxLag)) {
const url = ustMonthUrl(month), file = join(cacheDir, `ust-${month}.mjson`);
let cached: UstSource | null = null;
if (useCache) {
try {
const candidate = JSON.parse(await readFile(file, 'utf8')) as UstSource;
const age = (now.getTime() - Date.parse(candidate.retrievedAt)) / 3_600_000;
if (candidate.month === month && candidate.url === url && candidate.status === 'live' && candidate.error === undefined && candidate.cacheAgeHours === undefined && candidate.sha256 === digest(candidate.raw) && Number.isFinite(age) && age >= 0 && age <= 168) {
parseUstXml(candidate.raw, month);
cached = { ...candidate, status: 'cache', cacheAgeHours: Math.round(age * 100) / 100 };
}
} catch { /* invalid cache is ignored */ }
}
const usParts = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit' }).formatToParts(now);
const currentUsMonth = `${usParts.find(p => p.type === 'year')?.value}-${usParts.find(p => p.type === 'month')?.value}`;
if (cached && cached.cacheAgeHours! <= (month === currentUsMonth ? 6 : 24)) { sources.push(cached); continue; }
try {
const raw = await fetchUst(url, month, fetcher);
const source: UstSource = { month, url, retrievedAt: now.toISOString(), status: 'live', raw, sha256: digest(raw) };
sources.push(source);
if (useCache) { try { await mkdir(cacheDir, { recursive: true }); await writeUstCache(file, source); } catch { /* live data remains valid */ } }
} catch (e) {
if (cached) sources.push(cached);
else sources.push({ month, url, retrievedAt: now.toISOString(), status: 'failed', raw: '', sha256: '', error: cleanError(e) });
}
}
return { version: 1, engine: UST_ENGINE, base, asOf, maxLag, sources };
}
export function analyzeUst(packet: UstPacket): UstAnalysis {
if (packet.version !== 1 || packet.engine !== UST_ENGINE || !Array.isArray(packet.sources)) throw Error('Unsupported UST evidence packet');
const expected = ustMonths(packet.base, packet.asOf, packet.maxLag);
if (packet.sources.length !== expected.length) throw Error('UST source month count mismatch');
const rows: UstRow[] = [];
let omittedIncompleteDates = 0;
for (let i = 0; i < expected.length; i++) {
const source = packet.sources[i];
if (!source || source.month !== expected[i] || source.url !== ustMonthUrl(source.month) || !['live', 'cache', 'failed'].includes(source.status) || !Number.isFinite(Date.parse(source.retrievedAt)) || new Date(source.retrievedAt).toISOString() !== source.retrievedAt) throw Error('UST source identity or checksum mismatch');
if (source.status === 'failed') {
if (source.raw !== '' || source.sha256 !== '' || typeof source.error !== 'string' || !source.error || source.error.length > 240 || /[\r\n\t]/.test(source.error) || source.cacheAgeHours !== undefined) throw Error('Invalid failed UST source receipt');
continue;
}
if (source.error !== undefined) throw Error('Successful UST source cannot contain an error');
if (source.sha256 !== digest(source.raw)) throw Error('UST source checksum mismatch');
if (source.status === 'cache' && (!Number.isFinite(source.cacheAgeHours) || source.cacheAgeHours! < 0 || source.cacheAgeHours! > 168)) throw Error('Invalid UST cache age');
if (source.status === 'live' && source.cacheAgeHours !== undefined) throw Error('Live UST source cannot claim cache age');
const parsed = parseUstXml(source.raw, source.month);
omittedIncompleteDates += [...source.raw.matchAll(/<entry>/g)].length - parsed.length;
rows.push(...parsed);
}
const point = (requested: string): UstPoint => {
const row = rows.filter(r => r.date <= requested).at(-1) ?? null;
const lag = row ? days(requested, row.date) : null;
const reason = !row ? 'UST 공통 관측일 없음' : lag! > packet.maxLag ? `UST 관측일 ${lag}일 지연: 허용 ${packet.maxLag}일 초과` : null;
return { requested, actual: row?.date ?? null, lag, yields: reason ? null : row!.yields, reason };
};
const current = point(packet.asOf), previous = point(packet.base);
const comparable = current.yields && previous.yields && current.actual !== previous.actual;
const changes = comparable ? Object.fromEntries(TENORS.map(t => [t, bp(current.yields![t], previous.yields![t])])) as Record<Tenor, number> : null;
const spreads = Object.fromEntries(([['5Y-3Y',5,3],['10Y-5Y',10,5],['10Y-3Y',10,3],['30Y-10Y',30,10]] as const).map(([name, longer, shorter]) => [name, current.yields ? bp(current.yields[longer], current.yields[shorter]) : null]));
const notes = [current.reason, previous.reason, ...(current.actual === previous.actual && current.actual ? ['두 기준일이 같은 UST 관측일로 정렬되어 변화 계산을 차단했습니다.'] : []), ...(omittedIncompleteDates ? [`UST 만기 결측이 있는 ${omittedIncompleteDates}개 관측일을 제외했습니다.`] : []), ...packet.sources.filter(s => s.status === 'cache').map(s => `${s.month}: 검증된 UST 캐시 사용`), ...packet.sources.filter(s => s.status === 'failed').map(s => `${s.month}: UST 원천 수집 실패 (${s.error})`)].filter((x): x is string => !!x);
return { status: changes ? 'READY' : current.yields || previous.yields ? 'INCOMPLETE' : 'BLOCKED', current, previous, changes, spreads, movement: changes ? classify(changes[3], changes[10]) : '판정 불가', notes, rows };
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"rootDir": "src",
"outDir": "scripts",
"types": ["node"]
},
"include": ["src/*.mts"]
}