@nomadamas/k-skill 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bin/k-skill.js +64 -43
  2. package/package.json +2 -2
  3. package/skills/biz-health-check/instruction.md +2 -0
  4. package/skills/company-analysis/instruction.md +68 -0
  5. package/skills/company-analysis/references/source-contract.md +75 -0
  6. package/skills/company-analysis/scripts/dart-rows.js +52 -0
  7. package/skills/company-analysis/scripts/dart-rows.ts +48 -0
  8. package/skills/company-analysis/scripts/evidence.js +567 -0
  9. package/skills/company-analysis/scripts/evidence.ts +520 -0
  10. package/skills/company-analysis/scripts/fetch-public.js +85 -0
  11. package/skills/company-analysis/scripts/fetch-public.ts +75 -0
  12. package/skills/company-analysis/skill.json +6 -0
  13. package/skills/express-bus-booking/scripts/kobus_express_booking.py +46 -18
  14. package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +53 -3
  15. package/skills/g2b-order-plan-search/instruction.md +17 -0
  16. package/skills/g2b-sanctioned-supplier/instruction.md +10 -1
  17. package/skills/g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py +8 -4
  18. package/skills/g2b-sanctioned-supplier/scripts/test_g2b_sanctioned_supplier.py +19 -0
  19. package/skills/government-bond-analysis/instruction.md +216 -0
  20. package/skills/government-bond-analysis/references/methodology.md +63 -0
  21. package/skills/government-bond-analysis/scripts/cli.mjs +268 -0
  22. package/skills/government-bond-analysis/scripts/collect.mjs +171 -0
  23. package/skills/government-bond-analysis/scripts/core.mjs +224 -0
  24. package/skills/government-bond-analysis/scripts/dual.mjs +178 -0
  25. package/skills/government-bond-analysis/scripts/harness.mjs +406 -0
  26. package/skills/government-bond-analysis/scripts/modes.mjs +136 -0
  27. package/skills/government-bond-analysis/scripts/news.mjs +113 -0
  28. package/skills/government-bond-analysis/scripts/render.mjs +120 -0
  29. package/skills/government-bond-analysis/scripts/schedule.mjs +96 -0
  30. package/skills/government-bond-analysis/scripts/ust.mjs +204 -0
  31. package/skills/government-bond-analysis/skill.json +8 -0
  32. package/skills/korean-bond-search/instruction.md +122 -0
  33. package/skills/korean-bond-search/references/fields.md +48 -0
  34. package/skills/korean-bond-search/references/source-notes.md +11 -0
  35. package/skills/korean-bond-search/scripts/bond_search.py +906 -0
  36. package/skills/korean-bond-search/skill.json +9 -0
  37. package/skills/market-event-impact/instruction.md +150 -0
  38. package/skills/market-event-impact/references/event-taxonomy.md +74 -0
  39. package/skills/market-event-impact/references/methodology.md +98 -0
  40. package/skills/market-event-impact/references/research-contract.md +120 -0
  41. package/skills/market-event-impact/references/source-priority.md +25 -0
  42. package/skills/market-event-impact/scripts/adapters.py +32 -0
  43. package/skills/market-event-impact/scripts/asset_selector.py +120 -0
  44. package/skills/market-event-impact/scripts/calendars.py +179 -0
  45. package/skills/market-event-impact/scripts/event_resolver.py +260 -0
  46. package/skills/market-event-impact/scripts/event_study.py +171 -0
  47. package/skills/market-event-impact/scripts/market_data.py +204 -0
  48. package/skills/market-event-impact/scripts/market_event.py +1065 -0
  49. package/skills/market-event-impact/scripts/research.py +389 -0
  50. package/skills/market-event-impact/skill.json +9 -0
  51. package/skills/multi-asset-morning-briefing/instruction.md +117 -0
  52. package/skills/multi-asset-morning-briefing/references/format-rules.md +236 -0
  53. package/skills/multi-asset-morning-briefing/scripts/market_data.py +1003 -0
  54. package/skills/multi-asset-morning-briefing/skill.json +9 -0
  55. package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
  56. package/src/analytics-policy.json +18 -0
  57. package/src/analytics.js +106 -0
  58. package/src/metrics.js +49 -0
@@ -0,0 +1,63 @@
1
+ # 계산·검증 방법론
2
+
3
+ ## 데이터 정의
4
+
5
+ - 출처: 한국은행 ECOS 통계표 `817Y002` 시장금리(일별)
6
+ - 항목: 국고채 2년 `010195000`, 3년 `010200000`, 5년 `010200001`, 10년 `010210000`, 20년 `010220000`, 30년 `010230000`
7
+ - 정책금리 갭: 한국은행 기준금리 `722Y001/0101000`
8
+ - 단위: 연%
9
+ - 결과의 성격: 공식 일별 통계. 실시간 체결가 또는 개별 지표채 호가가 아니다.
10
+
11
+ ## 날짜 정렬
12
+
13
+ 각 요청일 이전에서 여섯 만기 값이 모두 존재하는 가장 최근 날짜를 공통 관측일로 사용한다. 기본 최대 지연은 주말과 한국 장기 연휴를 포괄하는 7일이며 설정 가능 범위는 0~14일이다. 전일 비교는 달력상 전일이 아니라 최신 공통 관측일 직전의 공통 관측일을 사용한다. 현재와 비교 기준이 같은 공통 관측일로 귀결될 때도 변화 계산을 차단한다.
14
+
15
+ 대시보드는 최신 관측일, 직전 관측일, 7일 전 기준, 직전 월말 기준을 한 번에 계산한다. 분기말은 현재 분기의 직전 분기 말, 연초 대비는 전년도 마지막 공통 관측일을 기준으로 한다. 이벤트 비교는 이벤트 직전 마지막 공통 관측일과 이벤트 당일 또는 이후 첫 공통 관측일을 사용한다. 월 범위의 종료 월이 현재 진행 중이면 종료 기준일을 KST 오늘로 제한하고 최신 공통 관측일을 선택한다.
16
+
17
+ ## 원천 검증
18
+
19
+ ECOS 응답마다 통계표 `817Y002`, 통계명 `1.3.2.1. 시장금리(일별)`, 항목코드·항목명, 단위 `연%`, 관측일 범위, 전체 건수와 실제 행 수를 확인한다. 원문 SHA-256은 evidence의 원문과 대조하고, 영수증의 건수·최초일·최종일은 정규화 관측치와 다시 대조한다. 캐시는 동일 만기·동일 조회 구간·168시간 이내인 경우에만 사용하며 캐시 봉투와 원문을 별도의 SHA-256으로 검증한다.
20
+
21
+ 사용자 CSV는 `USER_CSV`와 파일명으로 별도 표시한다. ECOS에서 직접 수집하지 않은 입력에 한국은행 공식 원천 링크나 ECOS 통계표 식별자를 부여하지 않는다. 실시간 원자료 검증 후 로컬 캐시 저장만 실패한 경우 정상 관측값을 버리지 않고 결과에 캐시 저장 경고를 남긴다.
22
+
23
+ ## 계산
24
+
25
+ - 만기별 변화(bp) = `(현재 연% − 비교 연%) × 100`
26
+ - 장단기 스프레드(bp) = `(장기 연% − 단기 연%) × 100`
27
+ - 스프레드 변화(bp) = `현재 스프레드 − 비교 스프레드`
28
+ - 정책금리 갭(bp) = `(국고채 3년 − 한국은행 기준금리) × 100`
29
+
30
+ 기본 구간은 5년−3년, 10년−5년, 10년−3년, 30년−10년이다. 최근 5·20거래일 모드는 만기별 시작·종료, 고점·저점, 순변화와 절대값 기준 최대 일간 변동을 계산한다.
31
+
32
+ 원자료로 계산한 뒤 출력 단계에서 표시 자릿수를 반올림한다.
33
+
34
+ ## 곡선 분류
35
+
36
+ 3년과 10년 변화 방향 및 10년−3년 스프레드 변화로 분류한다. 두 만기가 같은 폭으로 움직이면 평행 상승·하락으로 처리한다. 서로 반대 방향이면 혼합 움직임으로 표시하며 bull/bear 네 유형에 억지로 넣지 않는다.
37
+
38
+ ## 재현성 경계
39
+
40
+ 재현성이 뜻하는 것은 저장된 `evidence.mjson`을 같은 엔진 버전으로 처리했을 때 CSV·Markdown·HTML·상태 파일이 동일한 바이트로 생성된다는 것이다. 향후 ECOS 원자료가 수정되거나 신규 관측치가 추가되어도 새 조회 결과가 과거 실행과 같음을 보장하지 않는다.
41
+
42
+ 주요 일정도 저장된 공식 일정 증거를 재생할 때만 동일하게 나온다. 일정 증거에는 확인한 원문 일부, 원문 날짜 표현, 공식 URL과 확인 UTC를 남긴다. 엔진은 날짜·만기·출처 호스트·입찰표의 Auction Date 위치를 검증하지만, 원문 페이지가 이후 수정되었는지는 재생 시 자동으로 확인하지 않는다. 최신 일정을 말할 때는 공식 원문을 다시 열어 확인한다. 일정 확인 실패는 금리 분석 상태를 변경하지 않으며 `UNVERIFIED`로 표시한다.
43
+
44
+ ## 뉴스 evidence와 시장 배경
45
+
46
+ 뉴스는 helper가 상시 크롤링하지 않는다. 에이전트가 공개 검색 결과와 원문 페이지를 확인한 뒤 제목, HTTPS 링크, 게시 UTC, 짧은 의역, 관측 방향과 관계 분류만 `news-evidence.json`에 기록한다. 기사 본문 전체를 저장하거나 재배포하지 않는다.
47
+
48
+ 기사의 KST 게시일은 현재 공통 관측일과 같아야 한다. 3년과 10년 금리가 모두 상승하면 `higher`, 모두 하락하면 `lower`, 방향이 엇갈리면 `mixed`, 모두 보합이면 `flat`으로 정한다. 기사가 요인을 금리 움직임과 직접 연결한 경우에만 `explicit`으로 분류한다. 공식 발표나 같은 날의 관련 이벤트는 `contextual`이며 직접 원인 문장으로 사용하지 않는다.
49
+
50
+ 같은 날 연합인포맥스 마감 기사에서 직접 설명하고 방향이 일치하면 `HIGH`, 그 밖의 일치 기사 하나는 `LOW`, 서로 다른 호스트의 복수 근거가 일치하면 `MEDIUM`으로 표시한다. 관측 방향과 다른 기사는 삭제하지 않고 `excluded`로 남긴다. 뉴스의 상태와 실패는 ECOS 커브의 `READY/INCOMPLETE/BLOCKED` 판정을 바꾸지 않는다.
51
+
52
+ 각 기사 fingerprint는 정규화한 매체, 제목, URL, 게시시각, 유형, 방향, 관계, 요인과 근거 요약의 SHA-256이다. 이 fingerprint와 전체 `evidence.mjson` 및 manifest 해시를 함께 검증하므로 저장된 뉴스 코멘트도 replay에서 동일하게 생성된다.
53
+
54
+ ## 산출물 게이트
55
+
56
+ 스테이징 디렉터리에서 모든 파일을 만든 후 다음을 확인하고 최종 디렉터리로 이동한다.
57
+
58
+ - evidence SHA-256과 manifest 일치
59
+ - 각 산출물 SHA-256 일치
60
+ - evidence 재계산 상태와 manifest 상태 일치
61
+ - Markdown·CSV·HTML을 evidence에서 다시 렌더했을 때 동일
62
+ - 출력에 `NaN` 또는 `undefined` 없음
63
+ - 뉴스 사용 시 모든 채택·맥락·제외 출처와 fingerprint가 HTML·Markdown·상태 파일에 보존됨
@@ -0,0 +1,268 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import { collect, collectWindows, pointWindows, rangeWindows } from './collect.mjs';
5
+ import { ENGINE, PACKET_VERSION, TENOR_META, TENORS, analyze, bp, classify, csvRead, date, days, kstToday, sha256, shift } from './core.mjs';
6
+ import { collectPolicyRate, dashboard, eventQueryWindows, monthComparisonDates, onOrAfter, onOrBefore, previousMonthEnd, previousQuarterEnd, previousYearEnd, trend } from './modes.mjs';
7
+ import { analyzeNews, newsRead, newsTemplate } from './news.mjs';
8
+ import { writeArtifacts } from './render.mjs';
9
+ import { analyzeDual, DUAL_ENGINE, dualChat, dualSummary, writeDualArtifacts } from './dual.mjs';
10
+ import { collectUst } from './ust.mjs';
11
+ import { readSchedule } from './schedule.mjs';
12
+ function args(argv) {
13
+ 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']);
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const x = argv[i];
16
+ if (!x.startsWith('--'))
17
+ throw Error(`Unknown argument: ${x}`);
18
+ const k = x.slice(2);
19
+ if (!allowed.has(k))
20
+ throw Error(`Unknown option: --${k}`);
21
+ if (Object.hasOwn(out, k))
22
+ throw Error(`Duplicate option: --${k}`);
23
+ if (['no-cache', 'text', 'json', 'help'].includes(k))
24
+ out[k] = true;
25
+ else {
26
+ const v = argv[++i];
27
+ if (!v || v.startsWith('--'))
28
+ throw Error(`Missing value: --${k}`);
29
+ out[k] = v;
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+ const usage = `Usage:
35
+ node scripts/cli.mjs [--as-of YYYY-MM-DD] [--base YYYY-MM-DD] [--max-lag 0..14] [--text]
36
+ node scripts/cli.mjs --from-month YYYY-MM --to-month YYYY-MM [--text]
37
+ 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]
38
+ node scripts/cli.mjs --event-date YYYY-MM-DD [--text]
39
+ node scripts/cli.mjs --lookback-sessions 5|20 [--as-of YYYY-MM-DD] [--text]
40
+ node scripts/cli.mjs --input FILE.csv --as-of YYYY-MM-DD --base YYYY-MM-DD [--text]
41
+ node scripts/cli.mjs --base YYYY-MM-DD --as-of YYYY-MM-DD --news-evidence FILE.mjson [--out DIR]
42
+ node scripts/cli.mjs --news-template YYYY-MM-DD
43
+ node scripts/cli.mjs --replay evidence.mjson [--text]
44
+ node scripts/cli.mjs --market both [--as-of YYYY-MM-DD] [--base YYYY-MM-DD] [--schedule-evidence FILE.mjson] [--json|--out DIR]
45
+ node scripts/cli.mjs --market both --replay evidence.mjson [--out DIR]
46
+ Add --out DIR only when a persistent HTML/CSV/evidence report bundle is requested.
47
+ `;
48
+ function summary(p, a, news) {
49
+ 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 })) };
50
+ }
51
+ function textSummary(p, a, news) {
52
+ const lines = [`상태: ${a.status}`, `요청일: ${p.base} → ${p.asOf}`, `관측일: ${a.previous.actual ?? '없음'} → ${a.current.actual ?? '없음'}`];
53
+ if (a.changes && a.previous.values && a.current.values) {
54
+ 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)`));
55
+ for (const s of a.spreads)
56
+ lines.push(`${s.name}: ${s.previous?.toFixed(2)}bp → ${s.current?.toFixed(2)}bp (${(s.change ?? 0) >= 0 ? '+' : ''}${s.change?.toFixed(2)}bp)`);
57
+ lines.push(`곡선 판정: ${a.movement}`);
58
+ }
59
+ if (a.notes.length)
60
+ lines.push(...a.notes.map(n => `주의: ${n}`));
61
+ if (news.status !== 'DISABLED') {
62
+ lines.push(`뉴스 근거: ${news.status}${news.confidence ? ` / ${news.confidence}` : ''}`, ...news.commentary, ...news.notes.map(n => `주의: ${n}`));
63
+ for (const source of [...news.eligible, ...news.context, ...news.excluded])
64
+ lines.push(`기사: ${source.publisher} · ${source.title} · ${source.url}`);
65
+ lines.push('뉴스 코멘트는 출처가 전한 시장 해석이며 ECOS 수치만으로 인과를 확정하지 않습니다.');
66
+ }
67
+ lines.push(`출처: ${[...new Set(p.receipts.map(r => r.sourceUrl || r.sourceId))].join(', ')}`);
68
+ return lines.join('\n');
69
+ }
70
+ function snapshotLines(label, s) { const lines = [`${label}: ${s.actual ?? '없음'}`]; if (s.values)
71
+ lines.push(TENORS.map(t => `${t}Y ${s.values[`${t}Y`].toFixed(3)}%`).join(' · ')); return lines; }
72
+ function dashboardText(d) {
73
+ const lines = [`상태: ${d.result}`, ...snapshotLines('현재 관측일', d.current)];
74
+ for (const [label, key] of [['전일', 'previousSession'], ['1주', 'weekAgo'], ['전월말', 'previousMonthEnd']]) {
75
+ const ref = d.references[key];
76
+ lines.push(`${label} 기준: ${ref.actual ?? '없음'}`);
77
+ if (ref.values)
78
+ lines.push(d.changes[key].map(x => `${x.tenor} ${x.changeBp >= 0 ? '+' : ''}${x.changeBp?.toFixed(2)}bp`).join(' · '));
79
+ }
80
+ lines.push(`스프레드: ${Object.entries(d.spreads).map(([k, v]) => `${k} ${v?.toFixed(2) ?? '없음'}bp`).join(' · ')}`);
81
+ if (d.policyRate)
82
+ lines.push(`기준금리: ${d.policyRate.value.toFixed(2)}% (${d.policyRate.date}) · 3Y-기준금리 ${d.policyGap3YBp?.toFixed(2)}bp`);
83
+ if (d.notes.length)
84
+ lines.push(...d.notes.map(n => `주의: ${n}`));
85
+ lines.push('출처: https://ecos.bok.or.kr/');
86
+ return lines.join('\n');
87
+ }
88
+ function trendText(t) { const lines = [`상태: ${t.result}`, `기간: ${t.from ?? '없음'} → ${t.to ?? '없음'} (${t.actualSessions}/${t.requestedSessions}거래일)`]; for (const s of t.stats)
89
+ 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)
90
+ lines.push(...t.notes.map(n => `주의: ${n}`)); lines.push('출처: https://ecos.bok.or.kr/'); return lines.join('\n'); }
91
+ 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/' }; }
92
+ 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') }; }
93
+ async function main() {
94
+ const a = args(process.argv.slice(2));
95
+ if (a.help) {
96
+ console.log(usage);
97
+ return;
98
+ }
99
+ const market = typeof a.market === 'string' ? a.market : 'kr';
100
+ if (!['kr', 'both'].includes(market))
101
+ throw Error('--market must be kr or both');
102
+ if (a.text === true && a.json === true)
103
+ throw Error('--text and --json are mutually exclusive');
104
+ if (market === 'kr' && a.json === true)
105
+ throw Error('--json is only needed with --market both; Korean mode already defaults to JSON');
106
+ if (typeof a['news-template'] === 'string') {
107
+ if (Object.keys(a).some(k => k !== 'news-template'))
108
+ throw Error('--news-template cannot be combined with other options');
109
+ console.log(newsTemplate(date(a['news-template']), new Date().toISOString()));
110
+ return;
111
+ }
112
+ const hasMonth = typeof a['from-month'] === 'string' || typeof a['to-month'] === 'string';
113
+ const hasInput = typeof a.input === 'string', hasReplay = typeof a.replay === 'string';
114
+ if (hasInput && hasReplay)
115
+ throw Error('--input and --replay are mutually exclusive');
116
+ if (hasMonth && (typeof a['from-month'] !== 'string' || typeof a['to-month'] !== 'string'))
117
+ throw Error('--from-month and --to-month must be used together');
118
+ if (hasMonth && (typeof a.base === 'string' || typeof a['as-of'] === 'string'))
119
+ throw Error('Month range cannot be combined with --base/--as-of');
120
+ if (hasInput && (hasMonth || typeof a.base !== 'string' || typeof a['as-of'] !== 'string'))
121
+ throw Error('--input requires explicit --base and --as-of and cannot use month range');
122
+ if (hasReplay && Object.keys(a).some(k => !['replay', 'out', 'text', 'json', 'market'].includes(k)))
123
+ throw Error('--replay accepts only --market, --out, --text and --json');
124
+ const monthDates = hasMonth ? monthComparisonDates(a['from-month'], a['to-month'], kstToday()) : null;
125
+ const asOf = monthDates?.asOf ?? (typeof a['as-of'] === 'string' ? date(a['as-of']) : kstToday());
126
+ const base = monthDates?.base ?? (typeof a.base === 'string' ? date(a.base) : shift(asOf, -7));
127
+ const maxLag = typeof a['max-lag'] === 'string' ? Number(a['max-lag']) : 7;
128
+ if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
129
+ throw Error('--max-lag must be an integer from 0 to 14');
130
+ 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;
131
+ if (market === 'both') {
132
+ 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)))
133
+ throw Error('--market both supports dates, schedule evidence, cache, replay, --text, --json and --out only');
134
+ let dual;
135
+ if (hasReplay) {
136
+ const evidencePath = resolve(a.replay), raw = await readFile(evidencePath, 'utf8');
137
+ if (raw.length > 100_000_000)
138
+ throw Error('Dual replay evidence exceeds 100 MB');
139
+ try {
140
+ const manifest = JSON.parse(await readFile(join(dirname(evidencePath), 'manifest.mjson'), 'utf8'));
141
+ if (manifest.evidenceSha256 !== sha256(raw))
142
+ throw Error('Dual replay evidence hash differs from manifest');
143
+ }
144
+ catch (e) {
145
+ if (e.code !== 'ENOENT')
146
+ throw e;
147
+ }
148
+ dual = JSON.parse(raw);
149
+ }
150
+ else {
151
+ if (asOf > kstToday())
152
+ throw Error('Live collection cannot use a future KST as-of date');
153
+ if (base >= asOf || days(asOf, base) > 366)
154
+ throw Error(base >= asOf ? 'Base must precede as-of' : 'Comparison window must be 366 days or less');
155
+ const [kr, us] = await Promise.all([collect(base, asOf, maxLag, cacheDir, useCache), collectUst(base, asOf, maxLag, cacheDir, useCache)]);
156
+ const schedule = typeof a['schedule-evidence'] === 'string' ? readSchedule(await readFile(resolve(a['schedule-evidence']), 'utf8'), asOf) : undefined;
157
+ dual = { version: 1, engine: DUAL_ENGINE, ktb: { version: PACKET_VERSION, engine: ENGINE, asOf, base, maxLag, ...kr, label: '한·미 국채 금리 분석 · KTB' }, ust: us, ...(schedule ? { schedule } : {}) };
158
+ }
159
+ const result = analyzeDual(dual);
160
+ if (typeof a.out === 'string') {
161
+ const out = resolve(a.out);
162
+ await writeDualArtifacts(out, dual);
163
+ console.log(JSON.stringify({ result: result.status, out, files: ['report.html', 'brief.md', 'market-table.csv', 'evidence.mjson', 'status.mjson', 'manifest.mjson'] }, null, 2));
164
+ }
165
+ else if (a.json === true)
166
+ console.log(JSON.stringify(dualSummary(dual, result), null, 2));
167
+ else
168
+ console.log(dualChat(dual, result));
169
+ if (result.status !== 'READY')
170
+ process.exitCode = 2;
171
+ return;
172
+ }
173
+ 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;
174
+ 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'))
175
+ throw Error('Preset/trend/event modes accept only date anchor, --max-lag, --cache-dir, --no-cache and --text');
176
+ if ([preset !== null, lookback !== null, eventDate !== null].filter(Boolean).length > 1)
177
+ throw Error('--preset, --lookback-sessions and --event-date are mutually exclusive');
178
+ if (!hasReplay && (base >= asOf || days(asOf, base) > 366))
179
+ throw Error(base >= asOf ? 'Base must precede as-of' : 'Comparison window must be 366 days or less');
180
+ if (preset !== null || lookback !== null || eventDate !== null) {
181
+ if (asOf > kstToday())
182
+ throw Error('Live collection cannot use a future KST as-of date');
183
+ let output;
184
+ if (eventDate !== null) {
185
+ const data = await collectWindows(eventQueryWindows(eventDate, kstToday(), maxLag), cacheDir, useCache), before = onOrBefore(data.observations, shift(eventDate, -1), maxLag), after = onOrAfter(data.observations, eventDate, maxLag);
186
+ output = { ...comparison(after, before, 'event'), eventDate };
187
+ console.log(JSON.stringify(output, null, 2));
188
+ }
189
+ else if (lookback !== null) {
190
+ if (![5, 20].includes(lookback))
191
+ throw Error('--lookback-sessions must be 5 or 20');
192
+ const from = shift(asOf, lookback === 20 ? -45 : -16), data = await collectWindows(rangeWindows(from, asOf), cacheDir, useCache);
193
+ output = trend(data.observations, lookback);
194
+ console.log(a.text === true ? trendText(output) : JSON.stringify(output, null, 2));
195
+ }
196
+ else if (preset === 'today') {
197
+ const data = await collectWindows(pointWindows([asOf], maxLag), cacheDir, useCache), current = onOrBefore(data.observations, asOf, maxLag);
198
+ output = { result: current.values ? 'READY' : 'BLOCKED', mode: 'today', current, spreads: snapshotSpreads(current), source: 'https://ecos.bok.or.kr/' };
199
+ console.log(JSON.stringify(output, null, 2));
200
+ }
201
+ else if (preset === 'policy-gap') {
202
+ 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);
203
+ output = { result: current.values && policyRate ? 'READY' : current.values ? 'INCOMPLETE' : 'BLOCKED', mode: 'policy-gap', current, policyRate, policyGap3YBp, notes, source: 'https://ecos.bok.or.kr/' };
204
+ console.log(JSON.stringify(output, null, 2));
205
+ }
206
+ else if (preset === 'dashboard' || preset === 'previous-session') {
207
+ 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);
208
+ output = preset === 'dashboard' ? d : comparison(d.current, d.references.previousSession, 'previous-session');
209
+ console.log(a.text === true && preset === 'dashboard' ? dashboardText(d) : JSON.stringify(output, null, 2));
210
+ }
211
+ else {
212
+ const refs = { 'previous-week': shift(asOf, -7), 'previous-month-end': previousMonthEnd(asOf), 'previous-quarter-end': previousQuarterEnd(asOf), 'year-start': previousYearEnd(asOf) };
213
+ const referenceDate = refs[preset ?? ''];
214
+ if (!referenceDate)
215
+ throw Error(`Unknown preset: ${preset}`);
216
+ const data = await collectWindows(pointWindows([asOf, referenceDate], maxLag), cacheDir, useCache), current = onOrBefore(data.observations, asOf, maxLag), reference = onOrBefore(data.observations, referenceDate, maxLag);
217
+ output = comparison(current, reference, preset);
218
+ console.log(JSON.stringify(output, null, 2));
219
+ }
220
+ const result = output.result;
221
+ if (result !== 'READY')
222
+ process.exitCode = 2;
223
+ return;
224
+ }
225
+ let p;
226
+ if (typeof a.replay === 'string') {
227
+ const evidencePath = resolve(a.replay), raw = await readFile(evidencePath, 'utf8');
228
+ if (raw.length > 100_000_000)
229
+ throw Error('Replay evidence exceeds 100 MB character limit');
230
+ try {
231
+ const m = JSON.parse(await readFile(join(dirname(evidencePath), 'manifest.mjson'), 'utf8'));
232
+ if (m.evidenceSha256 && m.evidenceSha256 !== sha256(raw))
233
+ throw Error('Replay evidence hash differs from manifest');
234
+ }
235
+ catch (e) {
236
+ if (e.code !== 'ENOENT')
237
+ throw e;
238
+ }
239
+ p = JSON.parse(raw);
240
+ if (p.version !== PACKET_VERSION || p.engine !== ENGINE)
241
+ throw Error('Unsupported evidence packet');
242
+ }
243
+ else if (typeof a.input === 'string') {
244
+ const inputPath = resolve(a.input), raw = await readFile(inputPath, 'utf8'), observations = csvRead(raw), now = new Date().toISOString();
245
+ 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: [] }; });
246
+ p = { version: PACKET_VERSION, engine: ENGINE, asOf, base, maxLag, observations, receipts, label: '한국 금리 동향 분석' };
247
+ }
248
+ else {
249
+ if (asOf > kstToday())
250
+ throw Error('Live collection cannot use a future KST as-of date');
251
+ const result = await collect(base, asOf, maxLag, cacheDir, useCache);
252
+ p = { version: PACKET_VERSION, engine: ENGINE, asOf, base, maxLag, ...result, label: '한국 금리 동향 분석' };
253
+ }
254
+ if (typeof a['news-evidence'] === 'string') {
255
+ const raw = await readFile(resolve(a['news-evidence']), 'utf8');
256
+ p = { ...p, newsEvidence: newsRead(raw) };
257
+ }
258
+ const analysis = analyze(p), news = analyzeNews(p.newsEvidence, analysis);
259
+ if (typeof a.out === 'string') {
260
+ const out = resolve(a.out), manifest = await writeArtifacts(out, p);
261
+ console.log(JSON.stringify({ result: manifest.status, out, files: [...Object.keys(manifest.files), 'manifest.mjson'] }, null, 2));
262
+ }
263
+ else
264
+ console.log(a.text === true ? textSummary(p, analysis, news) : JSON.stringify(summary(p, analysis, news), null, 2));
265
+ if (analysis.status !== 'READY')
266
+ process.exitCode = 2;
267
+ }
268
+ main().catch(e => { const message = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ result: 'error', message }, null, 2)); process.exitCode = 1; });
@@ -0,0 +1,171 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { SOURCE_ID, SOURCE_URL, TENORS, TENOR_META, date, days, normalize, parseEcos, sha256, shift } from './core.mjs';
5
+ class Fatal extends Error {
6
+ }
7
+ class RequestFailure extends Error {
8
+ attempts;
9
+ constructor(message, attempts) {
10
+ super(message);
11
+ this.attempts = attempts;
12
+ }
13
+ }
14
+ const transient = (status) => [408, 425, 429].includes(status) || status >= 500;
15
+ export async function request(url, get = fetch, pause = ms => new Promise(r => setTimeout(r, ms)), validate) {
16
+ let last = 'unknown error';
17
+ for (let attempt = 1; attempt <= 3; attempt++) {
18
+ try {
19
+ const res = await get(url, { signal: AbortSignal.timeout(10000), headers: { accept: 'application/json' } });
20
+ if (!res.ok) {
21
+ if (!transient(res.status))
22
+ throw new Fatal(`HTTP ${res.status}`);
23
+ throw Error(`HTTP ${res.status}`);
24
+ }
25
+ const text = await res.text();
26
+ if (text.length > 2_000_000)
27
+ throw new Fatal('Response too large');
28
+ JSON.parse(text);
29
+ validate?.(text);
30
+ return { text, attempts: attempt };
31
+ }
32
+ catch (e) {
33
+ last = e instanceof Error ? e.message : String(e);
34
+ if (e instanceof Fatal)
35
+ throw new RequestFailure(last, attempt);
36
+ if (attempt === 3)
37
+ throw new RequestFailure(`${last} after 3 attempts`, attempt);
38
+ await pause(300 * 2 ** (attempt - 1));
39
+ }
40
+ }
41
+ throw Error(last);
42
+ }
43
+ export const parse = parseEcos;
44
+ export function pointWindows(points, maxLag) {
45
+ const unique = new Map();
46
+ if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
47
+ throw Error('maxLag must be 0..14 calendar days');
48
+ for (const point of points) {
49
+ date(point);
50
+ for (const window of rangeWindows(shift(point, -maxLag), point))
51
+ unique.set(`${window.from}/${window.to}`, window);
52
+ }
53
+ return [...unique.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
54
+ }
55
+ export function rangeWindows(from, to) {
56
+ date(from);
57
+ date(to);
58
+ if (from > to)
59
+ throw Error('Invalid collection range');
60
+ const result = [];
61
+ for (let start = from; start <= to; start = shift(start, 10)) {
62
+ const end = shift(start, 9) < to ? shift(start, 9) : to;
63
+ result.push({ from: start, to: end });
64
+ }
65
+ return result;
66
+ }
67
+ async function atomic(path, content) { const temp = `${path}.${randomUUID()}.tmp`; try {
68
+ await writeFile(temp, content);
69
+ await rename(temp, path);
70
+ }
71
+ catch (e) {
72
+ await rm(temp, { force: true });
73
+ throw e;
74
+ } }
75
+ const cacheName = (tenor, from, to) => `${tenor}-${from}-${to}.mjson`;
76
+ export async function collect(base, asOf, maxLag, cacheDir, useCache = true, get = fetch, now = new Date(), maxCacheAgeHours = 168) {
77
+ date(base);
78
+ date(asOf);
79
+ if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
80
+ throw Error('maxLag must be 0..14 calendar days');
81
+ if (base >= asOf)
82
+ throw Error('Base must precede as-of');
83
+ if (days(asOf, base) > 366)
84
+ throw Error('Comparison window must be 366 days or less');
85
+ return collectWindows(pointWindows([base, asOf], maxLag), cacheDir, useCache, get, now, maxCacheAgeHours);
86
+ }
87
+ export async function collectWindows(inputSegments, cacheDir, useCache = true, get = fetch, now = new Date(), maxCacheAgeHours = 168) {
88
+ if (!Array.isArray(inputSegments) || !inputSegments.length)
89
+ throw Error('At least one query window is required');
90
+ if (typeof cacheDir !== 'string' || !cacheDir.trim())
91
+ throw Error('cacheDir must be a non-empty path');
92
+ if (!Number.isFinite(now.getTime()))
93
+ throw Error('Invalid collection clock');
94
+ if (!Number.isFinite(maxCacheAgeHours) || maxCacheAgeHours < 0)
95
+ throw Error('maxCacheAgeHours must be non-negative');
96
+ const unique = new Map();
97
+ for (const segment of inputSegments) {
98
+ if (!segment || typeof segment.from !== 'string' || typeof segment.to !== 'string')
99
+ throw Error('Invalid query window');
100
+ date(segment.from);
101
+ date(segment.to);
102
+ if (segment.from > segment.to || daysBetween(segment.from, segment.to) > 10)
103
+ throw Error('Query windows must contain 1..10 calendar days');
104
+ unique.set(`${segment.from}/${segment.to}`, segment);
105
+ }
106
+ const retrievedAt = now.toISOString(), segments = [...unique.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
107
+ if (useCache)
108
+ await mkdir(cacheDir, { recursive: true });
109
+ const parts = await Promise.all(TENORS.map(async (tenor) => {
110
+ const observations = [];
111
+ const raw = [];
112
+ const rawSha256 = [];
113
+ const windowReceipts = [];
114
+ const errors = [];
115
+ for (const segment of segments) {
116
+ const file = join(cacheDir, cacheName(tenor, segment.from, segment.to));
117
+ let liveError = '', liveAttempts = 0;
118
+ try {
119
+ const key = process.env.KSKILL_BOK_ECOS_API_KEY || 'sample', meta = TENOR_META[tenor];
120
+ 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}`;
121
+ const result = await request(url, get, undefined, text => { parse(text, tenor, segment.from, segment.to); });
122
+ liveAttempts = result.attempts;
123
+ const rows = parse(result.text, tenor, segment.from, segment.to), digest = sha256(result.text);
124
+ const data = { version: 1, storedAt: retrievedAt, tenor, ...segment, raw: result.text, rawSha256: digest };
125
+ let cacheError;
126
+ if (useCache)
127
+ try {
128
+ await atomic(file, JSON.stringify({ data, digest: sha256(JSON.stringify(data)) }));
129
+ }
130
+ catch (e) {
131
+ cacheError = `cache write failed (${e instanceof Error ? e.message : String(e)})`;
132
+ }
133
+ observations.push(...rows);
134
+ raw.push(result.text);
135
+ rawSha256.push(digest);
136
+ windowReceipts.push({ ...segment, status: 'live', attempts: result.attempts, ...(cacheError ? { error: cacheError } : {}) });
137
+ continue;
138
+ }
139
+ catch (e) {
140
+ liveError = e instanceof Error ? e.message : String(e);
141
+ liveAttempts = e instanceof RequestFailure ? e.attempts : Math.max(liveAttempts, 1);
142
+ }
143
+ if (useCache)
144
+ try {
145
+ const envelope = JSON.parse(await readFile(file, 'utf8')), data = envelope.data;
146
+ const age = (now.getTime() - Date.parse(data?.storedAt)) / 3600000;
147
+ 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)
148
+ throw Error('cache identity, hash, or age check failed');
149
+ const rows = parse(data.raw, tenor, segment.from, segment.to);
150
+ observations.push(...rows);
151
+ raw.push(data.raw);
152
+ rawSha256.push(data.rawSha256);
153
+ windowReceipts.push({ ...segment, status: 'cache', attempts: liveAttempts, cacheAgeHours: Math.round(age * 100) / 100, error: liveError });
154
+ continue;
155
+ }
156
+ catch (e) {
157
+ errors.push(`${segment.from}..${segment.to}: ${liveError}; cache rejected (${e instanceof Error ? e.message : String(e)})`);
158
+ }
159
+ else
160
+ errors.push(`${segment.from}..${segment.to}: ${liveError}`);
161
+ windowReceipts.push({ ...segment, status: 'failed', attempts: liveAttempts, error: liveError });
162
+ }
163
+ const rows = normalize(observations), modes = new Set(windowReceipts.map(w => w.status));
164
+ const status = rows.length === 0 && modes.has('failed') ? 'failed' : modes.size === 1 ? (modes.has('live') ? 'live' : modes.has('cache') ? 'cache' : 'failed') : 'mixed';
165
+ const meta = TENOR_META[tenor];
166
+ 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(' | ') } : {}) };
167
+ return { observations: rows, receipt };
168
+ }));
169
+ return { observations: normalize(parts.flatMap(p => p.observations)), receipts: parts.map(p => p.receipt) };
170
+ }
171
+ function daysBetween(from, to) { return Math.round((Date.parse(to) - Date.parse(from)) / 86400000) + 1; }