@nomadamas/k-skill 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/k-skill.js +64 -43
- package/package.json +2 -2
- package/skills/biz-health-check/instruction.md +2 -0
- package/skills/company-analysis/instruction.md +68 -0
- package/skills/company-analysis/references/source-contract.md +75 -0
- package/skills/company-analysis/scripts/dart-rows.js +52 -0
- package/skills/company-analysis/scripts/dart-rows.ts +48 -0
- package/skills/company-analysis/scripts/evidence.js +567 -0
- package/skills/company-analysis/scripts/evidence.ts +520 -0
- package/skills/company-analysis/scripts/fetch-public.js +85 -0
- package/skills/company-analysis/scripts/fetch-public.ts +75 -0
- package/skills/company-analysis/skill.json +6 -0
- package/skills/express-bus-booking/scripts/kobus_express_booking.py +46 -18
- package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +53 -3
- package/skills/g2b-order-plan-search/instruction.md +17 -0
- package/skills/g2b-sanctioned-supplier/instruction.md +10 -1
- package/skills/g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py +8 -4
- package/skills/g2b-sanctioned-supplier/scripts/test_g2b_sanctioned_supplier.py +19 -0
- package/skills/government-bond-analysis/instruction.md +216 -0
- package/skills/government-bond-analysis/references/methodology.md +63 -0
- package/skills/government-bond-analysis/scripts/cli.mjs +268 -0
- package/skills/government-bond-analysis/scripts/collect.mjs +171 -0
- package/skills/government-bond-analysis/scripts/core.mjs +224 -0
- package/skills/government-bond-analysis/scripts/dual.mjs +178 -0
- package/skills/government-bond-analysis/scripts/harness.mjs +406 -0
- package/skills/government-bond-analysis/scripts/modes.mjs +136 -0
- package/skills/government-bond-analysis/scripts/news.mjs +113 -0
- package/skills/government-bond-analysis/scripts/render.mjs +120 -0
- package/skills/government-bond-analysis/scripts/schedule.mjs +96 -0
- package/skills/government-bond-analysis/scripts/ust.mjs +204 -0
- package/skills/government-bond-analysis/skill.json +8 -0
- package/skills/korean-bond-search/instruction.md +122 -0
- package/skills/korean-bond-search/references/fields.md +48 -0
- package/skills/korean-bond-search/references/source-notes.md +11 -0
- package/skills/korean-bond-search/scripts/bond_search.py +906 -0
- package/skills/korean-bond-search/skill.json +9 -0
- package/skills/market-event-impact/instruction.md +150 -0
- package/skills/market-event-impact/references/event-taxonomy.md +74 -0
- package/skills/market-event-impact/references/methodology.md +98 -0
- package/skills/market-event-impact/references/research-contract.md +120 -0
- package/skills/market-event-impact/references/source-priority.md +25 -0
- package/skills/market-event-impact/scripts/adapters.py +32 -0
- package/skills/market-event-impact/scripts/asset_selector.py +120 -0
- package/skills/market-event-impact/scripts/calendars.py +179 -0
- package/skills/market-event-impact/scripts/event_resolver.py +260 -0
- package/skills/market-event-impact/scripts/event_study.py +171 -0
- package/skills/market-event-impact/scripts/market_data.py +204 -0
- package/skills/market-event-impact/scripts/market_event.py +1065 -0
- package/skills/market-event-impact/scripts/research.py +389 -0
- package/skills/market-event-impact/skill.json +9 -0
- package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
- package/src/analytics-policy.json +18 -0
- package/src/analytics.js +106 -0
- package/src/metrics.js +49 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { date, sha256 } from './core.mjs';
|
|
2
|
+
const sourceTypes = new Set(['market-close', 'market-update', 'official-release', 'headline']);
|
|
3
|
+
const directions = new Set(['higher', 'lower', 'mixed', 'flat', 'context']);
|
|
4
|
+
const relations = new Set(['explicit', 'contextual']);
|
|
5
|
+
const clean = (value, name, max, min = 1) => {
|
|
6
|
+
if (typeof value !== 'string' || value.trim().length < min || value.length > max || /[\u0000-\u001f\u007f]/.test(value))
|
|
7
|
+
throw Error(`Invalid news ${name}`);
|
|
8
|
+
return value;
|
|
9
|
+
};
|
|
10
|
+
const instant = (value, name) => {
|
|
11
|
+
const text = clean(value, name, 40), time = Date.parse(text);
|
|
12
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== text)
|
|
13
|
+
throw Error(`Invalid news ${name}`);
|
|
14
|
+
return text;
|
|
15
|
+
};
|
|
16
|
+
const kstDate = (instantValue) => {
|
|
17
|
+
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(instantValue));
|
|
18
|
+
const pick = (type) => parts.find(p => p.type === type)?.value ?? '';
|
|
19
|
+
return date(`${pick('year')}-${pick('month')}-${pick('day')}`);
|
|
20
|
+
};
|
|
21
|
+
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}`));
|
|
22
|
+
const fingerprint = (a) => sha256(JSON.stringify(a));
|
|
23
|
+
export function newsRead(raw) {
|
|
24
|
+
if (raw.length > 100_000)
|
|
25
|
+
throw Error('News evidence exceeds 100 KB character limit');
|
|
26
|
+
const doc = JSON.parse(raw);
|
|
27
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc) || doc.version !== 1)
|
|
28
|
+
throw Error('Unsupported news evidence');
|
|
29
|
+
if (Object.keys(doc).some(k => !['version', 'observationDate', 'retrievedAt', 'articles'].includes(k)))
|
|
30
|
+
throw Error('Unknown news evidence field');
|
|
31
|
+
const observationDate = date(clean(doc.observationDate, 'observationDate', 10)), retrievedAt = instant(doc.retrievedAt, 'retrievedAt');
|
|
32
|
+
if (!Array.isArray(doc.articles) || doc.articles.length < 1 || doc.articles.length > 5)
|
|
33
|
+
throw Error('News evidence requires 1..5 articles');
|
|
34
|
+
const articles = doc.articles.map((value, index) => {
|
|
35
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
36
|
+
throw Error(`Invalid news article ${index + 1}`);
|
|
37
|
+
const x = value;
|
|
38
|
+
if (Object.keys(x).some(k => !['publisher', 'title', 'url', 'publishedAt', 'sourceType', 'marketDirection', 'relation', 'factor', 'evidenceSummary', 'sourceFingerprint'].includes(k)))
|
|
39
|
+
throw Error(`Unknown news article field ${index + 1}`);
|
|
40
|
+
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);
|
|
41
|
+
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))
|
|
42
|
+
throw Error(`Invalid news classification ${index + 1}`);
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = new URL(clean(x.url, 'url', 2048));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw Error(`Invalid news URL ${index + 1}`);
|
|
49
|
+
}
|
|
50
|
+
const host = parsed.hostname.toLowerCase();
|
|
51
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port || !acceptedHost(host))
|
|
52
|
+
throw Error(`Unapproved news host ${index + 1}`);
|
|
53
|
+
if ((host === 'news.einfomax.co.kr' && publisher !== '연합인포맥스') || ((host === 'investing.com' || host.endsWith('.investing.com')) && publisher !== 'Investing.com'))
|
|
54
|
+
throw Error(`Publisher/host mismatch ${index + 1}`);
|
|
55
|
+
if (Date.parse(publishedAt) > Date.parse(retrievedAt) || kstDate(publishedAt) !== observationDate)
|
|
56
|
+
throw Error(`News date alignment mismatch ${index + 1}`);
|
|
57
|
+
const articleBase = { publisher, title, url: parsed.toString(), publishedAt, sourceType: x.sourceType, marketDirection: x.marketDirection, relation: x.relation, factor, evidenceSummary };
|
|
58
|
+
const sourceFingerprint = fingerprint(articleBase);
|
|
59
|
+
if (x.sourceFingerprint !== undefined && x.sourceFingerprint !== sourceFingerprint)
|
|
60
|
+
throw Error(`News fingerprint mismatch ${index + 1}`);
|
|
61
|
+
if (articleBase.sourceType === 'official-release' && (articleBase.marketDirection !== 'context' || articleBase.relation !== 'contextual'))
|
|
62
|
+
throw Error(`Official release must be contextual ${index + 1}`);
|
|
63
|
+
if (articleBase.relation === 'explicit' && articleBase.marketDirection === 'context')
|
|
64
|
+
throw Error(`Explicit article requires market direction ${index + 1}`);
|
|
65
|
+
return { ...articleBase, sourceFingerprint };
|
|
66
|
+
});
|
|
67
|
+
if (new Set(articles.map(x => x.url)).size !== articles.length || new Set(articles.map(x => x.sourceFingerprint)).size !== articles.length)
|
|
68
|
+
throw Error('Duplicate news article');
|
|
69
|
+
return { version: 1, observationDate, retrievedAt, articles };
|
|
70
|
+
}
|
|
71
|
+
function direction(a) {
|
|
72
|
+
if (!a.changes)
|
|
73
|
+
return null;
|
|
74
|
+
const short = a.changes[1], long = a.changes[3], eps = 1e-7;
|
|
75
|
+
if (Math.abs(short) < eps && Math.abs(long) < eps)
|
|
76
|
+
return 'flat';
|
|
77
|
+
if (short > eps && long > eps)
|
|
78
|
+
return 'higher';
|
|
79
|
+
if (short < -eps && long < -eps)
|
|
80
|
+
return 'lower';
|
|
81
|
+
return 'mixed';
|
|
82
|
+
}
|
|
83
|
+
export function analyzeNews(evidence, a) {
|
|
84
|
+
const curveDirection = direction(a);
|
|
85
|
+
if (!evidence)
|
|
86
|
+
return { status: 'DISABLED', confidence: null, curveDirection, commentary: [], eligible: [], context: [], excluded: [], notes: [] };
|
|
87
|
+
const normalized = newsRead(JSON.stringify(evidence));
|
|
88
|
+
if (!a.current.actual || normalized.observationDate !== a.current.actual)
|
|
89
|
+
throw Error('News observation date must equal the current common observation date');
|
|
90
|
+
const context = normalized.articles.filter(x => x.relation === 'contextual'), eligible = curveDirection ? normalized.articles.filter(x => x.relation === 'explicit' && x.marketDirection === curveDirection) : [];
|
|
91
|
+
const excluded = normalized.articles.filter(x => x.relation === 'explicit' && x.marketDirection !== curveDirection), notes = [];
|
|
92
|
+
if (excluded.length)
|
|
93
|
+
notes.push(`${excluded.length}개 기사는 관측된 3Y·10Y 방향과 불일치하여 원인 코멘트에서 제외`);
|
|
94
|
+
let status = 'UNATTRIBUTED', confidence = null;
|
|
95
|
+
if (eligible.length) {
|
|
96
|
+
status = 'SOURCED';
|
|
97
|
+
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';
|
|
98
|
+
}
|
|
99
|
+
else if (context.length)
|
|
100
|
+
status = 'CONTEXT_ONLY';
|
|
101
|
+
const commentary = eligible.slice(0, 3).map(x => `시장 배경: ${x.factor} (${x.publisher} 「${x.title}」).`);
|
|
102
|
+
if (status === 'CONTEXT_ONLY')
|
|
103
|
+
commentary.push('같은 관측일의 공식 발표는 확인했지만 시장 움직임의 직접 원인으로 연결하지 않았다.');
|
|
104
|
+
if (status === 'UNATTRIBUTED')
|
|
105
|
+
commentary.push('관측 방향과 날짜가 일치하는 근거 기사를 확인하지 못했다.');
|
|
106
|
+
return { status, confidence, curveDirection, commentary, eligible, context, excluded, notes };
|
|
107
|
+
}
|
|
108
|
+
export function newsTemplate(observationDate, retrievedAt) {
|
|
109
|
+
date(observationDate);
|
|
110
|
+
instant(retrievedAt, 'retrievedAt');
|
|
111
|
+
const publishedAt = new Date(`${observationDate}T00:00:00+09:00`).toISOString();
|
|
112
|
+
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';
|
|
113
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import { analyze, TENORS } from './core.mjs';
|
|
5
|
+
import { analyzeNews } from './news.mjs';
|
|
6
|
+
const h = (s) => createHash('sha256').update(s).digest('hex');
|
|
7
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
8
|
+
const md = (s) => String(s).replace(/[\\`*_[\]<>]/g, '\\$&');
|
|
9
|
+
const n = (v, d = 2) => v === null ? '—' : v.toFixed(d);
|
|
10
|
+
const signed = (v) => v === null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
|
11
|
+
const trend = (v) => v === null ? '판정 불가' : v > 0 ? '상승' : v < 0 ? '하락' : '보합';
|
|
12
|
+
const spreadTrend = (v) => v === null ? '판정 불가' : v > 0 ? '확대' : v < 0 ? '축소' : '보합';
|
|
13
|
+
export function marketCsv(p, a) {
|
|
14
|
+
const rows = ['구분,만기,현재 요청일,현재 관측일,현재 금리(%),비교 요청일,비교 관측일,비교 금리(%),변화(bp)'];
|
|
15
|
+
TENORS.forEach((t, i) => rows.push([
|
|
16
|
+
'금리', `${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] ?? ''
|
|
17
|
+
].join(',')));
|
|
18
|
+
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(',')));
|
|
19
|
+
return '\uFEFF' + rows.join('\r\n') + '\r\n';
|
|
20
|
+
}
|
|
21
|
+
export function brief(p, a, news = analyzeNews(p.newsEvidence, a)) {
|
|
22
|
+
const current = a.current.actual ?? '확인 불가', previous = a.previous.actual ?? '확인 불가';
|
|
23
|
+
const lines = [`# ${md(p.label)}`, ``, `- 상태: **${a.status}**`, `- 요청 기준일: ${p.asOf} / 사용 관측일: ${current}`,
|
|
24
|
+
`- 비교 기준일: ${p.base} / 사용 관측일: ${previous}`, ''];
|
|
25
|
+
if (a.changes && a.current.values && a.previous.values) {
|
|
26
|
+
const i3 = TENORS.indexOf(3), i5 = TENORS.indexOf(5), i10 = TENORS.indexOf(10), s103 = a.spreads.find(s => s.name === '10년−3년');
|
|
27
|
+
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}**.`, '');
|
|
28
|
+
}
|
|
29
|
+
else
|
|
30
|
+
lines.push('## 결과 제한', '', '- 비교 가능한 여섯 만기의 공통 관측값이 없어 변화 계산과 문장 생성을 차단했습니다.', '');
|
|
31
|
+
if (news.status !== 'DISABLED')
|
|
32
|
+
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 수치만으로 인과관계를 확정하지 않는다.', '');
|
|
33
|
+
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}` : ''}`), '', '## 해석 범위', '', '- 이 결과는 공식 통계의 기간 비교다. 실시간 호가, 원인 추정, 금리 전망 또는 투자 권유가 아니다.', '');
|
|
34
|
+
return lines.join('\n') + '\n';
|
|
35
|
+
}
|
|
36
|
+
function chart(a) {
|
|
37
|
+
if (!a.current.values || !a.previous.values)
|
|
38
|
+
return '<div class="empty">공통 관측값이 없어 차트를 생성하지 않았습니다.</div>';
|
|
39
|
+
const all = [...a.current.values, ...a.previous.values], min = Math.min(...all) - .03, max = Math.max(...all) + .03, range = Math.max(max - min, .1);
|
|
40
|
+
const x = (i) => 75 + i * (500 / (TENORS.length - 1)), y = (v) => 260 - (v - min) / range * 190;
|
|
41
|
+
const path = (values) => values.map((v, i) => `${i ? 'L' : 'M'} ${x(i)} ${y(v).toFixed(1)}`).join(' ');
|
|
42
|
+
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('');
|
|
43
|
+
const dots = (values, cls) => values.map((v, i) => `<circle class="${cls}" cx="${x(i)}" cy="${y(v)}" r="5"/>`).join('');
|
|
44
|
+
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('');
|
|
45
|
+
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>`;
|
|
46
|
+
}
|
|
47
|
+
export function html(p, a, news = analyzeNews(p.newsEvidence, a)) {
|
|
48
|
+
const statusClass = a.status.toLowerCase();
|
|
49
|
+
const allInput = p.receipts.every(r => r.status === 'input'), allEcos = p.receipts.every(r => r.status !== 'input');
|
|
50
|
+
const sourceFooter = allInput ? '출처: 사용자 제공 CSV · 연% · 일별 입력자료. 원자료 성격은 제공자가 확인해야 합니다.' : allEcos ? '출처: 한국은행 ECOS 817Y002 · 연% · 일별 공식 통계. 실시간 호가나 투자 권유가 아닙니다.' : '출처: 한국은행 ECOS 및 사용자 제공 CSV 혼합 자료. 각 만기의 출처를 확인해야 합니다.';
|
|
51
|
+
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('');
|
|
52
|
+
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('');
|
|
53
|
+
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('');
|
|
54
|
+
const newsRows = [...news.eligible.map(x => ({ label: '채택', article: x })), ...news.context.map(x => ({ label: '맥락', article: x })), ...news.excluded.map(x => ({ label: '제외', article: x }))];
|
|
55
|
+
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>`;
|
|
56
|
+
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>
|
|
57
|
+
: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}}
|
|
58
|
+
</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>`;
|
|
59
|
+
}
|
|
60
|
+
const artifactNames = ['market-table.csv', 'brief.md', 'report.html', 'evidence.mjson', 'status.mjson'];
|
|
61
|
+
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'; }
|
|
62
|
+
export async function writeArtifacts(out, p) {
|
|
63
|
+
const a = analyze(p), news = analyzeNews(p.newsEvidence, a), parent = dirname(out), stage = join(parent, `.${basename(out)}.stage-${process.pid}-${randomUUID()}`);
|
|
64
|
+
await mkdir(parent, { recursive: true });
|
|
65
|
+
try {
|
|
66
|
+
await stat(out);
|
|
67
|
+
throw Error(`Output path already exists: ${out}`);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
if (e.code !== 'ENOENT')
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await mkdir(stage, { recursive: false });
|
|
75
|
+
const evidence = JSON.stringify(p, null, 2) + '\n';
|
|
76
|
+
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) };
|
|
77
|
+
const manifest = { version: 5, engine: p.engine, status: a.status, newsStatus: news.status, evidenceSha256: h(evidence), files: {} };
|
|
78
|
+
for (const name of artifactNames) {
|
|
79
|
+
const content = files[name];
|
|
80
|
+
if (/NaN|undefined/.test(content))
|
|
81
|
+
throw Error(`${name}: invalid token`);
|
|
82
|
+
manifest.files[name] = h(content);
|
|
83
|
+
await writeFile(join(stage, name), content);
|
|
84
|
+
}
|
|
85
|
+
await writeFile(join(stage, 'manifest.mjson'), JSON.stringify(manifest, null, 2) + '\n');
|
|
86
|
+
await validateArtifacts(stage);
|
|
87
|
+
await rename(stage, out);
|
|
88
|
+
return manifest;
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
await rm(stage, { recursive: true, force: true });
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function validateArtifacts(dir) {
|
|
96
|
+
const manifest = JSON.parse(await readFile(join(dir, 'manifest.mjson'), 'utf8'));
|
|
97
|
+
if (manifest.version !== 5 || manifest.engine !== 'korean-interest-rate-analysis/5' || !manifest.files || typeof manifest.files !== 'object')
|
|
98
|
+
throw Error('Unsupported manifest');
|
|
99
|
+
const expected = [...artifactNames].sort(), declared = Object.keys(manifest.files).sort(), present = (await readdir(dir)).sort();
|
|
100
|
+
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))
|
|
101
|
+
throw Error('Manifest file contract mismatch');
|
|
102
|
+
const evidence = await readFile(join(dir, 'evidence.mjson'), 'utf8');
|
|
103
|
+
if (h(evidence) !== manifest.evidenceSha256)
|
|
104
|
+
throw Error('Evidence hash mismatch');
|
|
105
|
+
const p = JSON.parse(evidence), a = analyze(p), news = analyzeNews(p.newsEvidence, a);
|
|
106
|
+
if (a.status !== manifest.status || news.status !== manifest.newsStatus)
|
|
107
|
+
throw Error('Status mismatch');
|
|
108
|
+
for (const [name, digest] of Object.entries(manifest.files)) {
|
|
109
|
+
const content = await readFile(join(dir, name), 'utf8');
|
|
110
|
+
if (h(content) !== digest)
|
|
111
|
+
throw Error(`${name}: hash mismatch`);
|
|
112
|
+
}
|
|
113
|
+
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');
|
|
114
|
+
if (mdFile !== brief(p, a, news) || csv !== marketCsv(p, a) || page !== html(p, a, news) || status !== statusJson(p, a, news))
|
|
115
|
+
throw Error('Rendered artifact is not reproducible');
|
|
116
|
+
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))))
|
|
117
|
+
throw Error('READY output contract is incomplete');
|
|
118
|
+
if (a.status !== 'READY' && !mdFile.includes('## 결과 제한'))
|
|
119
|
+
throw Error('Non-ready output must state its limitation');
|
|
120
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { date, days, shift } from './core.mjs';
|
|
2
|
+
const hosts = {
|
|
3
|
+
KR: { auction: ['ktb.moef.go.kr'], policy: ['bok.or.kr', 'www.bok.or.kr'] },
|
|
4
|
+
US: { auction: ['home.treasury.gov', 'www.treasurydirect.gov', 'treasurydirect.gov'], policy: ['www.federalreserve.gov', 'federalreserve.gov'] }
|
|
5
|
+
};
|
|
6
|
+
const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
7
|
+
const iso = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
8
|
+
function quotedDate(quoted, eventDate, excerpt) {
|
|
9
|
+
if (!excerpt.includes(quoted))
|
|
10
|
+
return false;
|
|
11
|
+
const weekday = quoted.match(/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),/);
|
|
12
|
+
if (weekday && new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', weekday: 'long' }).format(new Date(`${eventDate}T00:00:00Z`)) !== weekday[1])
|
|
13
|
+
return false;
|
|
14
|
+
const isoOrDots = quoted.match(/^(\d{4})[-.](\d{2})[-.](\d{2})\.?$/);
|
|
15
|
+
if (isoOrDots)
|
|
16
|
+
return `${isoOrDots[1]}-${isoOrDots[2]}-${isoOrDots[3]}` === eventDate;
|
|
17
|
+
const ko = quoted.match(/^(\d{1,2})월\s*(\d{1,2})일$/);
|
|
18
|
+
if (ko)
|
|
19
|
+
return excerpt.includes(eventDate.slice(0, 4)) && Number(ko[1]) === Number(eventDate.slice(5, 7)) && Number(ko[2]) === Number(eventDate.slice(8));
|
|
20
|
+
const fed = quoted.match(/^([A-Z][a-z]+)\s+(\d{1,2})-(\d{1,2})$/);
|
|
21
|
+
if (fed) {
|
|
22
|
+
if (Number(fed[2]) >= Number(fed[3]))
|
|
23
|
+
return false;
|
|
24
|
+
const parsed = Date.parse(`${fed[1]} ${fed[3]}, ${eventDate.slice(0, 4)} GMT`);
|
|
25
|
+
return excerpt.includes(eventDate.slice(0, 4)) && Number.isFinite(parsed) && new Date(parsed).toISOString().slice(0, 10) === eventDate;
|
|
26
|
+
}
|
|
27
|
+
const english = Date.parse(`${quoted} GMT`);
|
|
28
|
+
return Number.isFinite(english) && new Date(english).toISOString().slice(0, 10) === eventDate;
|
|
29
|
+
}
|
|
30
|
+
function validateEvent(e, anchorDate) {
|
|
31
|
+
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))
|
|
32
|
+
throw Error('Schedule event date, market, or kind mismatch');
|
|
33
|
+
if (!text(e.sourceUrl, 500) || !text(e.sourceExcerpt, 500) || !text(e.sourceDateText, 80))
|
|
34
|
+
throw Error('Schedule source text missing or unsafe');
|
|
35
|
+
let host;
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(e.sourceUrl);
|
|
38
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.toString() !== e.sourceUrl || /[\s()<>{}]/.test(e.sourceUrl))
|
|
39
|
+
throw Error();
|
|
40
|
+
host = url.hostname.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw Error('Schedule source URL must be official HTTPS');
|
|
44
|
+
}
|
|
45
|
+
if (!hosts[e.market][e.kind].includes(host))
|
|
46
|
+
throw Error('Schedule source host does not match market and kind');
|
|
47
|
+
if (!quotedDate(e.sourceDateText, e.date, e.sourceExcerpt))
|
|
48
|
+
throw Error('Schedule quoted date does not match event date');
|
|
49
|
+
if (e.kind === 'auction') {
|
|
50
|
+
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}년물`)))
|
|
51
|
+
throw Error('Schedule auction tenor or source marker mismatch');
|
|
52
|
+
if (e.market === 'US') {
|
|
53
|
+
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]);
|
|
54
|
+
if (/tentative/i.test(e.sourceUrl) && dates.length < 3)
|
|
55
|
+
throw Error('Tentative U.S. auction row must include announcement, auction, and settlement dates');
|
|
56
|
+
if (dates.length >= 3 && e.sourceDateText !== dates[1])
|
|
57
|
+
throw Error('Schedule U.S. auction date must be the second date in the official row');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (e.tenor !== undefined || (e.market === 'KR' && !/통화정책방향|금융통화위원회/.test(e.sourceExcerpt)) || (e.market === 'US' && !/FOMC|Federal Open Market Committee/.test(e.sourceExcerpt)))
|
|
61
|
+
throw Error('Schedule policy source marker mismatch');
|
|
62
|
+
}
|
|
63
|
+
export function readSchedule(raw, anchorDate) {
|
|
64
|
+
if (raw.length > 100_000)
|
|
65
|
+
throw Error('Schedule evidence exceeds 100 KB');
|
|
66
|
+
const evidence = JSON.parse(raw);
|
|
67
|
+
validateSchedule(evidence, anchorDate);
|
|
68
|
+
return evidence;
|
|
69
|
+
}
|
|
70
|
+
export function validateSchedule(evidence, anchorDate) {
|
|
71
|
+
date(anchorDate);
|
|
72
|
+
if (!evidence || typeof evidence !== 'object' || evidence.version !== 1 || evidence.anchorDate !== anchorDate || !iso(evidence.checkedAt) || !Array.isArray(evidence.events) || evidence.events.length > 30)
|
|
73
|
+
throw Error('Schedule evidence identity or size mismatch');
|
|
74
|
+
const checkedDate = evidence.checkedAt.slice(0, 10);
|
|
75
|
+
if (days(checkedDate, anchorDate) < -1 || days(checkedDate, anchorDate) > 14)
|
|
76
|
+
throw Error('Schedule verification time is too far from report date');
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
for (const e of evidence.events) {
|
|
79
|
+
validateEvent(e, anchorDate);
|
|
80
|
+
const key = `${e.date}/${e.market}/${e.kind}/${e.tenor ?? ''}`;
|
|
81
|
+
if (seen.has(key))
|
|
82
|
+
throw Error('Duplicate schedule event');
|
|
83
|
+
seen.add(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function analyzeSchedule(evidence, anchorDate) {
|
|
87
|
+
date(anchorDate);
|
|
88
|
+
if (!evidence)
|
|
89
|
+
return { status: 'UNVERIFIED', anchorDate, checkedAt: null, events: [], note: '공식 일정 자료를 확인하지 못해 날짜를 표시하지 않았습니다.' };
|
|
90
|
+
validateSchedule(evidence, anchorDate);
|
|
91
|
+
const label = (e) => e.kind === 'policy' ? e.market === 'KR' ? '한국은행 통화정책방향 결정회의' : 'FOMC 금리 결정' : e.market === 'KR' ? `국고채 ${e.tenor}년물 입찰` : `미국 국채 ${e.tenor}년물 입찰${/tentative/i.test(e.sourceUrl) ? ' (잠정)' : ''}`;
|
|
92
|
+
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));
|
|
93
|
+
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) }));
|
|
94
|
+
const markets = new Set(events.map(e => e.market));
|
|
95
|
+
return { status: markets.size === 2 ? 'VERIFIED' : 'PARTIAL', anchorDate, checkedAt: evidence.checkedAt, events: limited, note: markets.size === 2 ? '각 날짜는 해당 국가 현지일입니다. 예정 일정은 변경될 수 있으니 원문을 다시 확인하세요.' : markets.size === 1 ? `${markets.has('KR') ? '미국' : '한국'} 공식 일정은 확인되지 않았습니다. 표시 날짜는 해당 국가 현지일입니다.` : '조회 기간에 확인된 공식 일정이 없습니다.' };
|
|
96
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { bp, classify, date, days, shift, TENORS } from './core.mjs';
|
|
5
|
+
const FIELDS = {
|
|
6
|
+
2: 'BC_2YEAR', 3: 'BC_3YEAR', 5: 'BC_5YEAR',
|
|
7
|
+
10: 'BC_10YEAR', 20: 'BC_20YEAR', 30: 'BC_30YEAR'
|
|
8
|
+
};
|
|
9
|
+
export const UST_SOURCE = 'U.S. Treasury Daily Treasury Par Yield Curve Rates';
|
|
10
|
+
export const UST_SOURCE_PAGE = 'https://home.treasury.gov/resource-center/data-chart-center/interest-rates/TextView?type=daily_treasury_yield_curve';
|
|
11
|
+
export const UST_ENGINE = 'ktb-ust-rate-analysis/1';
|
|
12
|
+
const digest = (raw) => createHash('sha256').update(raw).digest('hex');
|
|
13
|
+
const cleanError = (error) => (error instanceof Error ? error.message : String(error)).replace(/[\r\n\t]+/g, ' ').slice(0, 240) || 'unknown error';
|
|
14
|
+
export function ustMonthUrl(month) {
|
|
15
|
+
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))
|
|
16
|
+
throw Error('Invalid UST month');
|
|
17
|
+
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('-', '')}`;
|
|
18
|
+
}
|
|
19
|
+
function oneTag(xml, name) {
|
|
20
|
+
const matches = [...xml.matchAll(new RegExp(`<d:${name}(?:\\s[^>]*)?>([^<]*)<\\/d:${name}>`, 'g'))];
|
|
21
|
+
const nulls = [...xml.matchAll(new RegExp(`<d:${name}\\s+[^>]*m:null="true"[^>]*/>`, 'g'))];
|
|
22
|
+
if (matches.length + nulls.length !== 1)
|
|
23
|
+
throw Error(`UST XML field ${name} missing or duplicated`);
|
|
24
|
+
if (nulls.length)
|
|
25
|
+
return null;
|
|
26
|
+
return matches[0][1].trim();
|
|
27
|
+
}
|
|
28
|
+
export function parseUstXml(raw, month) {
|
|
29
|
+
if (raw.length > 5_000_000 || !raw.includes('<feed ') || !raw.includes('<title type="text">DailyTreasuryYieldCurveRateData</title>'))
|
|
30
|
+
throw Error('Invalid UST XML feed');
|
|
31
|
+
const entries = [...raw.matchAll(/<entry>([\s\S]*?)<\/entry>/g)];
|
|
32
|
+
if (!entries.length)
|
|
33
|
+
throw Error(`UST feed has no entries for ${month}`);
|
|
34
|
+
const rows = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const [, entry] of entries) {
|
|
37
|
+
const props = [...entry.matchAll(/<m:properties>([\s\S]*?)<\/m:properties>/g)];
|
|
38
|
+
if (props.length !== 1)
|
|
39
|
+
throw Error('UST XML properties missing or duplicated');
|
|
40
|
+
const timestamp = oneTag(props[0][1], 'NEW_DATE');
|
|
41
|
+
if (!timestamp || !/^\d{4}-\d{2}-\d{2}T00:00:00$/.test(timestamp))
|
|
42
|
+
throw Error('Invalid UST observation date');
|
|
43
|
+
const observationDate = date(timestamp.slice(0, 10));
|
|
44
|
+
if (!observationDate.startsWith(`${month}-`) || seen.has(observationDate))
|
|
45
|
+
throw Error('UST month/date mismatch or duplicate observation');
|
|
46
|
+
seen.add(observationDate);
|
|
47
|
+
const yields = {};
|
|
48
|
+
let complete = true;
|
|
49
|
+
for (const tenor of TENORS) {
|
|
50
|
+
const value = oneTag(props[0][1], FIELDS[tenor]);
|
|
51
|
+
if (value === null || value === '') {
|
|
52
|
+
complete = false;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!/^-?\d+(?:\.\d+)?$/.test(value))
|
|
56
|
+
throw Error(`Invalid UST ${tenor}Y yield`);
|
|
57
|
+
const numeric = Number(value);
|
|
58
|
+
if (!Number.isFinite(numeric) || numeric < 0 || numeric > 50)
|
|
59
|
+
throw Error(`UST ${tenor}Y yield outside annual-percent bounds`);
|
|
60
|
+
yields[tenor] = numeric;
|
|
61
|
+
}
|
|
62
|
+
if (complete)
|
|
63
|
+
rows.push({ date: observationDate, yields });
|
|
64
|
+
}
|
|
65
|
+
return rows.sort((a, b) => a.date.localeCompare(b.date));
|
|
66
|
+
}
|
|
67
|
+
export function ustMonths(base, asOf, maxLag) {
|
|
68
|
+
date(base);
|
|
69
|
+
date(asOf);
|
|
70
|
+
if (base >= asOf || days(asOf, base) > 366)
|
|
71
|
+
throw Error('UST comparison dates must be ordered within 366 days');
|
|
72
|
+
if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
|
|
73
|
+
throw Error('UST maxLag must be 0..14');
|
|
74
|
+
const months = new Set();
|
|
75
|
+
for (const anchor of [base, asOf]) {
|
|
76
|
+
for (let d = shift(anchor, -maxLag); d <= anchor; d = shift(d, 1))
|
|
77
|
+
months.add(d.slice(0, 7));
|
|
78
|
+
}
|
|
79
|
+
return [...months].sort();
|
|
80
|
+
}
|
|
81
|
+
async function fetchUst(url, month, fetcher) {
|
|
82
|
+
let error = Error('UST request failed');
|
|
83
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(15_000), headers: { Accept: 'application/xml,text/xml' } });
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 429)
|
|
88
|
+
throw Error(`UST HTTP ${response.status} (fatal)`);
|
|
89
|
+
throw Error(`UST HTTP ${response.status}`);
|
|
90
|
+
}
|
|
91
|
+
const raw = await response.text();
|
|
92
|
+
if (raw.length > 5_000_000)
|
|
93
|
+
throw Error('UST response exceeds 5 MB');
|
|
94
|
+
parseUstXml(raw, month);
|
|
95
|
+
return raw;
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
error = e instanceof Error ? e : Error(String(e));
|
|
99
|
+
if (error.message.endsWith('(fatal)'))
|
|
100
|
+
break;
|
|
101
|
+
if (attempt < 3)
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, 200 * attempt));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
async function writeUstCache(file, source) {
|
|
108
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
109
|
+
try {
|
|
110
|
+
await writeFile(temporary, JSON.stringify(source));
|
|
111
|
+
await rename(temporary, file);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
await rm(temporary, { force: true });
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function collectUst(base, asOf, maxLag, cacheDir, useCache = true, fetcher = fetch, now = new Date()) {
|
|
119
|
+
if (!Number.isFinite(now.getTime()))
|
|
120
|
+
throw Error('Invalid UST collection clock');
|
|
121
|
+
const sources = [];
|
|
122
|
+
for (const month of ustMonths(base, asOf, maxLag)) {
|
|
123
|
+
const url = ustMonthUrl(month), file = join(cacheDir, `ust-${month}.mjson`);
|
|
124
|
+
let cached = null;
|
|
125
|
+
if (useCache) {
|
|
126
|
+
try {
|
|
127
|
+
const candidate = JSON.parse(await readFile(file, 'utf8'));
|
|
128
|
+
const age = (now.getTime() - Date.parse(candidate.retrievedAt)) / 3_600_000;
|
|
129
|
+
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) {
|
|
130
|
+
parseUstXml(candidate.raw, month);
|
|
131
|
+
cached = { ...candidate, status: 'cache', cacheAgeHours: Math.round(age * 100) / 100 };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch { /* invalid cache is ignored */ }
|
|
135
|
+
}
|
|
136
|
+
const usParts = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit' }).formatToParts(now);
|
|
137
|
+
const currentUsMonth = `${usParts.find(p => p.type === 'year')?.value}-${usParts.find(p => p.type === 'month')?.value}`;
|
|
138
|
+
if (cached && cached.cacheAgeHours <= (month === currentUsMonth ? 6 : 24)) {
|
|
139
|
+
sources.push(cached);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const raw = await fetchUst(url, month, fetcher);
|
|
144
|
+
const source = { month, url, retrievedAt: now.toISOString(), status: 'live', raw, sha256: digest(raw) };
|
|
145
|
+
sources.push(source);
|
|
146
|
+
if (useCache) {
|
|
147
|
+
try {
|
|
148
|
+
await mkdir(cacheDir, { recursive: true });
|
|
149
|
+
await writeUstCache(file, source);
|
|
150
|
+
}
|
|
151
|
+
catch { /* live data remains valid */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
if (cached)
|
|
156
|
+
sources.push(cached);
|
|
157
|
+
else
|
|
158
|
+
sources.push({ month, url, retrievedAt: now.toISOString(), status: 'failed', raw: '', sha256: '', error: cleanError(e) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { version: 1, engine: UST_ENGINE, base, asOf, maxLag, sources };
|
|
162
|
+
}
|
|
163
|
+
export function analyzeUst(packet) {
|
|
164
|
+
if (packet.version !== 1 || packet.engine !== UST_ENGINE || !Array.isArray(packet.sources))
|
|
165
|
+
throw Error('Unsupported UST evidence packet');
|
|
166
|
+
const expected = ustMonths(packet.base, packet.asOf, packet.maxLag);
|
|
167
|
+
if (packet.sources.length !== expected.length)
|
|
168
|
+
throw Error('UST source month count mismatch');
|
|
169
|
+
const rows = [];
|
|
170
|
+
let omittedIncompleteDates = 0;
|
|
171
|
+
for (let i = 0; i < expected.length; i++) {
|
|
172
|
+
const source = packet.sources[i];
|
|
173
|
+
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)
|
|
174
|
+
throw Error('UST source identity or checksum mismatch');
|
|
175
|
+
if (source.status === 'failed') {
|
|
176
|
+
if (source.raw !== '' || source.sha256 !== '' || typeof source.error !== 'string' || !source.error || source.error.length > 240 || /[\r\n\t]/.test(source.error) || source.cacheAgeHours !== undefined)
|
|
177
|
+
throw Error('Invalid failed UST source receipt');
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (source.error !== undefined)
|
|
181
|
+
throw Error('Successful UST source cannot contain an error');
|
|
182
|
+
if (source.sha256 !== digest(source.raw))
|
|
183
|
+
throw Error('UST source checksum mismatch');
|
|
184
|
+
if (source.status === 'cache' && (!Number.isFinite(source.cacheAgeHours) || source.cacheAgeHours < 0 || source.cacheAgeHours > 168))
|
|
185
|
+
throw Error('Invalid UST cache age');
|
|
186
|
+
if (source.status === 'live' && source.cacheAgeHours !== undefined)
|
|
187
|
+
throw Error('Live UST source cannot claim cache age');
|
|
188
|
+
const parsed = parseUstXml(source.raw, source.month);
|
|
189
|
+
omittedIncompleteDates += [...source.raw.matchAll(/<entry>/g)].length - parsed.length;
|
|
190
|
+
rows.push(...parsed);
|
|
191
|
+
}
|
|
192
|
+
const point = (requested) => {
|
|
193
|
+
const row = rows.filter(r => r.date <= requested).at(-1) ?? null;
|
|
194
|
+
const lag = row ? days(requested, row.date) : null;
|
|
195
|
+
const reason = !row ? 'UST 공통 관측일 없음' : lag > packet.maxLag ? `UST 관측일 ${lag}일 지연: 허용 ${packet.maxLag}일 초과` : null;
|
|
196
|
+
return { requested, actual: row?.date ?? null, lag, yields: reason ? null : row.yields, reason };
|
|
197
|
+
};
|
|
198
|
+
const current = point(packet.asOf), previous = point(packet.base);
|
|
199
|
+
const comparable = current.yields && previous.yields && current.actual !== previous.actual;
|
|
200
|
+
const changes = comparable ? Object.fromEntries(TENORS.map(t => [t, bp(current.yields[t], previous.yields[t])])) : null;
|
|
201
|
+
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]));
|
|
202
|
+
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);
|
|
203
|
+
return { status: changes ? 'READY' : current.yields || previous.yields ? 'INCOMPLETE' : 'BLOCKED', current, previous, changes, spreads, movement: changes ? classify(changes[3], changes[10]) : '판정 불가', notes, rows };
|
|
204
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "government-bond-analysis",
|
|
3
|
+
"description": "한국·미국 국채시장을 공식 금리 데이터로 분석하고 커브 변화, 한·미 금리차, 확인된 국채 입찰·통화정책 일정을 같은 형식으로 정리한다. 국고채·미국 국채 금리 비교, 최근 변화, 주요 일정 또는 출처 있는 보고서를 요청할 때 사용한다. 실시간 호가, 개별 채권 가격평가, 매매 추천은 다루지 않는다.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"lookup"
|
|
6
|
+
],
|
|
7
|
+
"frontmatter": "name: government-bond-analysis\ndescription: 한국·미국 국채시장을 공식 금리 데이터로 분석하고 커브 변화, 한·미 금리차, 확인된 국채 입찰·통화정책 일정을 같은 형식으로 정리한다. 국고채·미국 국채 금리 비교, 최근 변화, 주요 일정 또는 출처 있는 보고서를 요청할 때 사용한다. 실시간 호가, 개별 채권 가격평가, 매매 추천은 다루지 않는다.\nlicense: MIT\nmetadata:\n category: finance\n locale: ko-KR\n phase: v1"
|
|
8
|
+
}
|