@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,224 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const TENORS = [2, 3, 5, 10, 20, 30];
|
|
3
|
+
export const TENOR_META = {
|
|
4
|
+
2: { itemCode: '010195000', itemName: '국고채(2년)' },
|
|
5
|
+
3: { itemCode: '010200000', itemName: '국고채(3년)' },
|
|
6
|
+
5: { itemCode: '010200001', itemName: '국고채(5년)' },
|
|
7
|
+
10: { itemCode: '010210000', itemName: '국고채(10년)' },
|
|
8
|
+
20: { itemCode: '010220000', itemName: '국고채(20년)' },
|
|
9
|
+
30: { itemCode: '010230000', itemName: '국고채(30년)' }
|
|
10
|
+
};
|
|
11
|
+
export const ENGINE = 'korean-interest-rate-analysis/5';
|
|
12
|
+
export const PACKET_VERSION = 5;
|
|
13
|
+
export const SOURCE_ID = '한국은행 ECOS 817Y002';
|
|
14
|
+
export const SOURCE_URL = 'https://ecos.bok.or.kr/';
|
|
15
|
+
export const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
16
|
+
export function date(value) {
|
|
17
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
18
|
+
throw Error(`Invalid date: ${value}`);
|
|
19
|
+
const [y, m, d] = value.split('-').map(Number), dt = new Date(Date.UTC(y, m - 1, d));
|
|
20
|
+
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d)
|
|
21
|
+
throw Error(`Invalid date: ${value}`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export const shift = (d, n) => {
|
|
25
|
+
if (!Number.isInteger(n))
|
|
26
|
+
throw Error('Date shift must be an integer');
|
|
27
|
+
const [y, m, day] = date(d).split('-').map(Number);
|
|
28
|
+
return new Date(Date.UTC(y, m - 1, day + n)).toISOString().slice(0, 10);
|
|
29
|
+
};
|
|
30
|
+
export const days = (a, b) => {
|
|
31
|
+
const [ay, am, ad] = date(a).split('-').map(Number), [by, bm, bd] = date(b).split('-').map(Number);
|
|
32
|
+
return (Date.UTC(ay, am - 1, ad) - Date.UTC(by, bm - 1, bd)) / 86400000;
|
|
33
|
+
};
|
|
34
|
+
export function kstToday(now = new Date()) {
|
|
35
|
+
if (!Number.isFinite(now.getTime()))
|
|
36
|
+
throw Error('Invalid clock');
|
|
37
|
+
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now);
|
|
38
|
+
const pick = (type) => parts.find(p => p.type === type)?.value ?? '';
|
|
39
|
+
return date(`${pick('year')}-${pick('month')}-${pick('day')}`);
|
|
40
|
+
}
|
|
41
|
+
export const bp = (a, b) => Math.round((a - b) * 100 * 1e8) / 1e8;
|
|
42
|
+
export function normalize(input) {
|
|
43
|
+
if (!Array.isArray(input))
|
|
44
|
+
throw Error('Observations must be an array');
|
|
45
|
+
const unique = new Map();
|
|
46
|
+
for (const item of input) {
|
|
47
|
+
if (!item || typeof item !== 'object')
|
|
48
|
+
throw Error('Invalid observation');
|
|
49
|
+
const r = item;
|
|
50
|
+
if (typeof r.date !== 'string' || !TENORS.includes(r.tenor) || typeof r.value !== 'number' || !Number.isFinite(r.value) || r.value < -10 || r.value > 50)
|
|
51
|
+
throw Error('Invalid tenor/value (expected yield in annual percent, -10..50)');
|
|
52
|
+
const row = { date: date(r.date), tenor: r.tenor, value: r.value };
|
|
53
|
+
const key = `${row.date}/${row.tenor}`, prior = unique.get(key);
|
|
54
|
+
if (prior && prior.value !== row.value)
|
|
55
|
+
throw Error(`Conflicting duplicate ${key}`);
|
|
56
|
+
unique.set(key, row);
|
|
57
|
+
}
|
|
58
|
+
return [...unique.values()].sort((a, b) => a.date.localeCompare(b.date) || a.tenor - b.tenor);
|
|
59
|
+
}
|
|
60
|
+
export function parseEcos(raw, tenor, start, end) {
|
|
61
|
+
date(start);
|
|
62
|
+
date(end);
|
|
63
|
+
if (start > end)
|
|
64
|
+
throw Error('Invalid ECOS window');
|
|
65
|
+
const doc = JSON.parse(raw);
|
|
66
|
+
if (doc.RESULT?.CODE === 'INFO-200')
|
|
67
|
+
return [];
|
|
68
|
+
if (doc.RESULT)
|
|
69
|
+
throw Error(`ECOS ${doc.RESULT.CODE ?? 'unknown'}${doc.RESULT.MESSAGE ? `: ${doc.RESULT.MESSAGE}` : ''}`);
|
|
70
|
+
const block = doc.StatisticSearch, rows = block?.row;
|
|
71
|
+
if (!block || !Array.isArray(rows) || !Number.isInteger(block.list_total_count) || block.list_total_count !== rows.length || rows.length > 10)
|
|
72
|
+
throw Error('Incomplete or invalid ECOS response');
|
|
73
|
+
const meta = TENOR_META[tenor];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
return normalize(rows.map(r => {
|
|
76
|
+
if (!/^\d{8}$/.test(r.TIME ?? ''))
|
|
77
|
+
throw Error('ECOS invalid observation date');
|
|
78
|
+
const d = `${r.TIME.slice(0, 4)}-${r.TIME.slice(4, 6)}-${r.TIME.slice(6, 8)}`;
|
|
79
|
+
date(d);
|
|
80
|
+
if (seen.has(d))
|
|
81
|
+
throw Error('ECOS duplicate observation date');
|
|
82
|
+
seen.add(d);
|
|
83
|
+
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 ?? ''))
|
|
84
|
+
throw Error('ECOS metadata/unit/range mismatch');
|
|
85
|
+
const value = Number(r.DATA_VALUE);
|
|
86
|
+
if (!Number.isFinite(value))
|
|
87
|
+
throw Error('ECOS non-numeric yield');
|
|
88
|
+
return { date: d, tenor, value };
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
function validateReceipt(r, rows) {
|
|
92
|
+
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)
|
|
93
|
+
throw Error('Invalid source receipt');
|
|
94
|
+
const meta = TENOR_META[r.tenor];
|
|
95
|
+
if (r.itemCode !== meta.itemCode || r.itemName !== meta.itemName || r.unit !== '연%' || r.cycle !== 'D')
|
|
96
|
+
throw Error(`${r.tenor}Y receipt metadata mismatch`);
|
|
97
|
+
if (r.status === 'input') {
|
|
98
|
+
if (r.statCode !== 'USER_CSV' || !r.sourceId.startsWith('사용자 제공 CSV: ') || r.sourceUrl !== '')
|
|
99
|
+
throw Error(`${r.tenor}Y input source identity mismatch`);
|
|
100
|
+
}
|
|
101
|
+
else if (r.statCode !== '817Y002' || r.sourceId !== SOURCE_ID || r.sourceUrl !== SOURCE_URL)
|
|
102
|
+
throw Error(`${r.tenor}Y ECOS source identity mismatch`);
|
|
103
|
+
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]))
|
|
104
|
+
throw Error(`${r.tenor}Y raw source hash mismatch`);
|
|
105
|
+
if (!Array.isArray(r.windows) || r.windows.some(w => { try {
|
|
106
|
+
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);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return true;
|
|
110
|
+
} }) || new Set(r.windows.map(w => `${w.from}/${w.to}`)).size !== r.windows.length)
|
|
111
|
+
throw Error(`${r.tenor}Y invalid source window`);
|
|
112
|
+
const own = rows.filter(x => x.tenor === r.tenor), first = own[0]?.date ?? null, last = own.at(-1)?.date ?? null;
|
|
113
|
+
if (r.count !== own.length || r.first !== first || r.last !== last)
|
|
114
|
+
throw Error(`${r.tenor}Y receipt count/range mismatch`);
|
|
115
|
+
if (r.status === 'failed' && r.count !== 0)
|
|
116
|
+
throw Error(`${r.tenor}Y failed receipt contains observations`);
|
|
117
|
+
if (r.status === 'input') {
|
|
118
|
+
if (r.windows.length !== 0 || r.raw.length !== 1)
|
|
119
|
+
throw Error(`${r.tenor}Y input receipt must contain one CSV and no network windows`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
if (!r.windows.length || r.raw.length !== r.windows.filter(w => w.status !== 'failed').length)
|
|
123
|
+
throw Error(`${r.tenor}Y source window/raw mismatch`);
|
|
124
|
+
const modes = new Set(r.windows.map(w => w.status));
|
|
125
|
+
const expected = r.count === 0 && modes.has('failed') ? 'failed' : modes.size === 1 ? (modes.has('live') ? 'live' : modes.has('cache') ? 'cache' : 'failed') : 'mixed';
|
|
126
|
+
if (r.status !== expected)
|
|
127
|
+
throw Error(`${r.tenor}Y receipt status mismatch`);
|
|
128
|
+
}
|
|
129
|
+
const fromRaw = r.status === 'input' ? csvRead(r.raw[0]).filter(x => x.tenor === r.tenor) : r.windows.flatMap((w, i) => {
|
|
130
|
+
if (w.status === 'failed')
|
|
131
|
+
return [];
|
|
132
|
+
const rawIndex = r.windows.slice(0, i).filter(x => x.status !== 'failed').length;
|
|
133
|
+
return parseEcos(r.raw[rawIndex], r.tenor, w.from, w.to);
|
|
134
|
+
});
|
|
135
|
+
if (JSON.stringify(normalize(fromRaw)) !== JSON.stringify(own))
|
|
136
|
+
throw Error(`${r.tenor}Y observations do not match source raw data`);
|
|
137
|
+
}
|
|
138
|
+
export function classify(short, long) {
|
|
139
|
+
const delta = long - short, eps = 1e-7;
|
|
140
|
+
if (Math.abs(delta) < eps)
|
|
141
|
+
return Math.abs(short) < eps ? '보합' : short > 0 ? '평행 상승' : '평행 하락';
|
|
142
|
+
const slope = delta > 0 ? '스티프닝' : '플래트닝';
|
|
143
|
+
if (short < 0 && long < 0)
|
|
144
|
+
return `Bull ${slope}`;
|
|
145
|
+
if (short > 0 && long > 0)
|
|
146
|
+
return `Bear ${slope}`;
|
|
147
|
+
return `혼합 움직임 / ${slope}`;
|
|
148
|
+
}
|
|
149
|
+
export function analyze(p) {
|
|
150
|
+
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))
|
|
151
|
+
throw Error('Unsupported or invalid evidence packet');
|
|
152
|
+
date(p.asOf);
|
|
153
|
+
date(p.base);
|
|
154
|
+
if (p.base >= p.asOf)
|
|
155
|
+
throw Error('Base must precede as-of');
|
|
156
|
+
if (days(p.asOf, p.base) > 366)
|
|
157
|
+
throw Error('Comparison window must be 366 days or less');
|
|
158
|
+
if (!Number.isInteger(p.maxLag) || p.maxLag < 0 || p.maxLag > 14)
|
|
159
|
+
throw Error('maxLag must be 0..14 calendar days');
|
|
160
|
+
const rows = normalize(p.observations);
|
|
161
|
+
if (rows.some(r => r.date > p.asOf))
|
|
162
|
+
throw Error('Future observation after as-of');
|
|
163
|
+
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)
|
|
164
|
+
throw Error('Exactly one source receipt is required per tenor');
|
|
165
|
+
for (const r of p.receipts)
|
|
166
|
+
validateReceipt(r, rows);
|
|
167
|
+
const at = (requested) => {
|
|
168
|
+
const dates = [...new Set(rows.filter(r => r.date <= requested).map(r => r.date))].sort().reverse();
|
|
169
|
+
const actual = dates.find(d => TENORS.every(t => rows.some(r => r.date === d && r.tenor === t))) ?? null;
|
|
170
|
+
const lag = actual ? days(requested, actual) : null;
|
|
171
|
+
const reason = !actual ? '여섯 만기의 공통 관측일 없음' : lag > p.maxLag ? `공통 관측일이 ${lag}일 전: 허용 ${p.maxLag}일 초과` : null;
|
|
172
|
+
return { requested, actual, lag, values: reason ? null : TENORS.map(t => rows.find(r => r.date === actual && r.tenor === t).value), reason };
|
|
173
|
+
};
|
|
174
|
+
const current = at(p.asOf), previous = at(p.base);
|
|
175
|
+
const comparable = !!current.values && !!previous.values && current.actual !== previous.actual;
|
|
176
|
+
const changes = comparable ? current.values.map((v, i) => bp(v, previous.values[i])) : null;
|
|
177
|
+
const spreads = [['5년−3년', 5, 3], ['10년−5년', 10, 5], ['10년−3년', 10, 3], ['30년−10년', 30, 10]].map(([name, longTenor, shortTenor]) => {
|
|
178
|
+
const l = TENORS.indexOf(longTenor), s = TENORS.indexOf(shortTenor);
|
|
179
|
+
return {
|
|
180
|
+
name: name, current: current.values ? bp(current.values[l], current.values[s]) : null,
|
|
181
|
+
previous: previous.values ? bp(previous.values[l], previous.values[s]) : null,
|
|
182
|
+
change: changes ? Math.round((changes[l] - changes[s]) * 1e8) / 1e8 : null
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
const notes = [current.reason, previous.reason].filter((v) => !!v);
|
|
186
|
+
for (const r of p.receipts) {
|
|
187
|
+
if (r.status === 'cache')
|
|
188
|
+
notes.push(`${r.tenor}년: 검증된 로컬 캐시 사용`);
|
|
189
|
+
if (r.status === 'mixed')
|
|
190
|
+
notes.push(`${r.tenor}년: 여러 조회 구간의 실시간·캐시·실패 상태가 혼재`);
|
|
191
|
+
if (r.status === 'failed')
|
|
192
|
+
notes.push(`${r.tenor}년: 수집 실패${r.error ? ` (${r.error})` : ''}`);
|
|
193
|
+
const failed = r.windows.filter(w => w.status === 'failed');
|
|
194
|
+
if (failed.length && r.status !== 'failed')
|
|
195
|
+
notes.push(`${r.tenor}년: ${failed.length}개 조회 구간 실패, 확보된 공통 관측값만 사용`);
|
|
196
|
+
const cacheWriteFailed = r.windows.filter(w => w.status === 'live' && w.error?.startsWith('cache write failed'));
|
|
197
|
+
if (cacheWriteFailed.length)
|
|
198
|
+
notes.push(`${r.tenor}년: 실시간 원자료는 검증했으나 ${cacheWriteFailed.length}개 로컬 캐시 저장 실패`);
|
|
199
|
+
}
|
|
200
|
+
if (current.actual && current.actual === previous.actual)
|
|
201
|
+
notes.push('두 기준일이 같은 관측일로 정렬되어 기간 변화 계산을 차단했습니다.');
|
|
202
|
+
if (current.values && current.lag)
|
|
203
|
+
notes.push(`현재 기준일은 ${current.actual} 공통 관측값으로 정렬했습니다.`);
|
|
204
|
+
if (previous.values && previous.lag)
|
|
205
|
+
notes.push(`비교 기준일은 ${previous.actual} 공통 관측값으로 정렬했습니다.`);
|
|
206
|
+
const shortIndex = TENORS.indexOf(3), longIndex = TENORS.indexOf(10);
|
|
207
|
+
return { status: changes ? 'READY' : current.values || previous.values ? 'INCOMPLETE' : 'BLOCKED', current, previous, changes, spreads, movement: changes ? classify(changes[shortIndex], changes[longIndex]) : '판정 불가', notes };
|
|
208
|
+
}
|
|
209
|
+
export function csvRead(text) {
|
|
210
|
+
if (text.length > 10_000_000)
|
|
211
|
+
throw Error('CSV exceeds 10 MB character limit');
|
|
212
|
+
const withoutBom = text.replace(/^\uFEFF/, ''), clean = withoutBom.replace(/(?:\r?\n)+$/, '');
|
|
213
|
+
if (!clean.trim())
|
|
214
|
+
throw Error('CSV is empty');
|
|
215
|
+
const lines = clean.split(/\r?\n/);
|
|
216
|
+
if (lines.shift() !== 'date,tenor,yield_pct')
|
|
217
|
+
throw Error('CSV header must be date,tenor,yield_pct');
|
|
218
|
+
return normalize(lines.map((line, i) => {
|
|
219
|
+
const f = line.split(',');
|
|
220
|
+
if (f.length !== 3 || f.some(v => v.trim() !== v || v === '') || !/^\d+$/.test(f[1]) || !/^-?\d+(\.\d+)?$/.test(f[2]))
|
|
221
|
+
throw Error(`Invalid CSV line ${i + 2}`);
|
|
222
|
+
return { date: f[0], tenor: Number(f[1]), value: Number(f[2]) };
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { randomUUID, createHash } 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, bp, TENORS } from './core.mjs';
|
|
5
|
+
import { analyzeUst, UST_SOURCE, UST_SOURCE_PAGE } from './ust.mjs';
|
|
6
|
+
import { analyzeSchedule } from './schedule.mjs';
|
|
7
|
+
export const DUAL_ENGINE = 'ktb-ust-rate-analysis/1';
|
|
8
|
+
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
9
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
10
|
+
const mdLine = (s) => s.replace(/[\r\n\t]+/g, ' ').replace(/[\\`*_\[\]<>|]/g, '\\$&');
|
|
11
|
+
const crossLabel = (a) => a.cross.current.gapBp === null ? '계산 불가' : `${a.cross.current.gapBp.toFixed(2)}bp`;
|
|
12
|
+
export function analyzeDual(packet) {
|
|
13
|
+
if (packet.version !== 1 || packet.engine !== DUAL_ENGINE || !packet.ktb || !packet.ust)
|
|
14
|
+
throw Error('Unsupported dual-market evidence packet');
|
|
15
|
+
if (packet.ktb.asOf !== packet.ust.asOf || packet.ktb.base !== packet.ust.base || packet.ktb.maxLag !== packet.ust.maxLag)
|
|
16
|
+
throw Error('KTB/UST request date or lag mismatch');
|
|
17
|
+
const ktb = analyze(packet.ktb), ust = analyzeUst(packet.ust), schedule = analyzeSchedule(packet.schedule, packet.ktb.asOf);
|
|
18
|
+
const common = (requested) => {
|
|
19
|
+
const krDates = new Map();
|
|
20
|
+
for (const row of packet.ktb.observations) {
|
|
21
|
+
if (!krDates.has(row.date))
|
|
22
|
+
krDates.set(row.date, new Set());
|
|
23
|
+
krDates.get(row.date).add(row.tenor);
|
|
24
|
+
}
|
|
25
|
+
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]));
|
|
26
|
+
const us10 = new Map(ust.rows.map((r) => [r.date, r.yields[10]]));
|
|
27
|
+
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;
|
|
28
|
+
const ust10Y = actual ? us10.get(actual) : null, ktb10Y = actual ? kr10.get(actual) : null;
|
|
29
|
+
return { requested, actual, ust10Y, ktb10Y, gapBp: ust10Y !== null && ktb10Y !== null ? bp(ust10Y, ktb10Y) : null };
|
|
30
|
+
};
|
|
31
|
+
const current = common(packet.ktb.asOf), previous = common(packet.ktb.base);
|
|
32
|
+
const changeBp = current.gapBp !== null && previous.gapBp !== null && current.actual !== previous.actual ? Math.round((current.gapBp - previous.gapBp) * 1e8) / 1e8 : null;
|
|
33
|
+
const status = ktb.status === 'READY' && ust.status === 'READY' ? 'READY' : ktb.status === 'BLOCKED' && ust.status === 'BLOCKED' ? 'BLOCKED' : 'INCOMPLETE';
|
|
34
|
+
const notes = [
|
|
35
|
+
'UST는 미국 재무부의 명목 par/constant-maturity 수익률이고 KTB는 한국은행 ECOS 국고채 시장금리입니다. 같은 날짜라도 종가 시점이 달라 단순 금리차는 동시 가격, 환헤지 비용 또는 상대가치를 뜻하지 않습니다.',
|
|
36
|
+
current.actual ? `한·미 10년 금리차의 현재 공통 관측일: ${current.actual}` : '현재 기준일의 한·미 공통 관측일이 없어 금리차를 표시하지 않았습니다.',
|
|
37
|
+
previous.actual ? `한·미 10년 금리차의 비교 공통 관측일: ${previous.actual}` : '비교 기준일의 한·미 공통 관측일이 없어 금리차 변화를 표시하지 않았습니다.',
|
|
38
|
+
...ktb.notes.map(s => `KTB: ${s}`), ...ust.notes.map(s => `UST: ${s}`)
|
|
39
|
+
];
|
|
40
|
+
return { status, ktb, ust, cross: { current, previous, changeBp }, schedule, notes };
|
|
41
|
+
}
|
|
42
|
+
function marketTable(name, base, current, changes) {
|
|
43
|
+
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('');
|
|
44
|
+
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>`;
|
|
45
|
+
}
|
|
46
|
+
export function dualSummary(packet, analysis = analyzeDual(packet)) {
|
|
47
|
+
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 });
|
|
48
|
+
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) }] };
|
|
49
|
+
}
|
|
50
|
+
const rateText = (value) => value === null || value === undefined ? '—' : `${value.toFixed(3)}%`;
|
|
51
|
+
const changeText = (value) => value === null || value === undefined ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}bp`;
|
|
52
|
+
const observedText = (requested, actual, lag) => actual ? `${actual}${lag ? ` (요청일 ${requested}보다 ${lag}일 전)` : ''}` : '확인 불가';
|
|
53
|
+
/** Stable, source-backed Markdown for direct conversational replies. */
|
|
54
|
+
export function dualChat(packet, a = analyzeDual(packet)) {
|
|
55
|
+
const ktb3 = a.ktb.changes?.[TENORS.indexOf(3)] ?? null;
|
|
56
|
+
const ktb10 = a.ktb.changes?.[TENORS.indexOf(10)] ?? null;
|
|
57
|
+
const ust3 = a.ust.changes?.[3] ?? null;
|
|
58
|
+
const ust10 = a.ust.changes?.[10] ?? null;
|
|
59
|
+
const headline = (name, short, longer, movement) => short === null || longer === null ? `${name}는 비교 가능한 두 관측일이 부족합니다.` :
|
|
60
|
+
`${name} 3년 ${changeText(short)}, 10년 ${changeText(longer)} (${movement}).`;
|
|
61
|
+
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])} |`);
|
|
62
|
+
const kSpread = a.ktb.spreads.find(s => s.name === '10년−3년');
|
|
63
|
+
const uSpread = a.ust.spreads['10Y-3Y'];
|
|
64
|
+
const gap = a.cross.current.gapBp === null ? '공통 관측일이 없어 계산 불가' :
|
|
65
|
+
`${changeText(a.cross.current.gapBp)} (공통 관측일 ${a.cross.current.actual})`;
|
|
66
|
+
const gapChange = a.cross.changeBp === null ? '계산 불가' : changeText(a.cross.changeBp);
|
|
67
|
+
const warnings = [...a.ktb.notes.map(n => `KTB: ${n}`), ...a.ust.notes.map(n => `UST: ${n}`)];
|
|
68
|
+
const statusNote = a.status === 'READY' ? '분석 완료' : a.status === 'INCOMPLETE' ? '일부 자료 부족, 확인된 값만 표시' : '자료 확인 불가';
|
|
69
|
+
return [
|
|
70
|
+
'## 한·미 국채 금리 분석',
|
|
71
|
+
`**상태:** ${statusNote} (${a.status})`,
|
|
72
|
+
`**요청 기간:** ${packet.ktb.base} → ${packet.ktb.asOf}`,
|
|
73
|
+
`**실제 관측:** 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)}`,
|
|
74
|
+
'',
|
|
75
|
+
'**핵심 변화**',
|
|
76
|
+
headline('KTB', ktb3, ktb10, a.ktb.movement),
|
|
77
|
+
headline('UST', ust3, ust10, a.ust.movement),
|
|
78
|
+
'',
|
|
79
|
+
'| 만기 | KTB 현재 | 변화 | UST 현재 | 변화 |',
|
|
80
|
+
'| --- | ---: | ---: | ---: | ---: |',
|
|
81
|
+
...table,
|
|
82
|
+
'',
|
|
83
|
+
'**커브·한미 차이**',
|
|
84
|
+
`- 10년−3년: KTB ${changeText(kSpread?.current)} (기간 변화 ${changeText(kSpread?.change)}), UST ${changeText(uSpread)}.`,
|
|
85
|
+
`- UST 10년−KTB 10년: ${gap} · 기간 변화 ${gapChange}.`,
|
|
86
|
+
'',
|
|
87
|
+
'**주요 일정**',
|
|
88
|
+
...(a.schedule.events.length ? a.schedule.events.map(e => `- ${e.date} (${e.market}) ${e.label} · [공식 일정](${e.sourceUrl})`) : [a.schedule.status === 'UNVERIFIED' ? '- 공식 일정 미확인.' : '- 조회 기간에 확인된 일정 없음.']),
|
|
89
|
+
`- 일정 확인: ${a.schedule.checkedAt ?? '미확인'} · ${a.schedule.status}. ${a.schedule.note}`,
|
|
90
|
+
'',
|
|
91
|
+
'**확인 사항**',
|
|
92
|
+
...(warnings.length ? warnings.map(w => `- ${mdLine(w)}`) : ['- 날짜·수집 관련 경고 없음.']),
|
|
93
|
+
'- UST는 미국 재무부의 명목 par/constant-maturity 수익률, KTB는 ECOS 국고채 시장금리입니다. 같은 날짜라도 종가 시점이 달라 단순 금리차는 동시 가격이나 환헤지 수익을 뜻하지 않습니다.',
|
|
94
|
+
'',
|
|
95
|
+
`**출처:** [한국은행 ECOS](https://ecos.bok.or.kr/) · [미국 재무부 일별 국채 수익률](${UST_SOURCE_PAGE})`
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
function dualCsv(a) {
|
|
99
|
+
const cells = ['market,date_role,observation_date,tenor,yield_pct,change_bp'];
|
|
100
|
+
for (const [market, detail] of [['KTB', a.ktb], ['UST', a.ust]]) {
|
|
101
|
+
for (const [role, point] of [['base', detail.previous], ['as_of', detail.current]]) {
|
|
102
|
+
for (const [i, tenor] of TENORS.entries()) {
|
|
103
|
+
const yieldValue = market === 'KTB' ? point.values?.[i] : point.yields?.[tenor];
|
|
104
|
+
const change = role === 'as_of' ? market === 'KTB' ? a.ktb.changes?.[i] : a.ust.changes?.[tenor] : null;
|
|
105
|
+
cells.push([market, role, point.actual ?? '', tenor, yieldValue ?? '', change ?? ''].join(','));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return cells.join('\n') + '\n';
|
|
110
|
+
}
|
|
111
|
+
function dualHtml(packet, a) {
|
|
112
|
+
const kBase = a.ktb.previous.values ? Object.fromEntries(TENORS.map((t, i) => [t, a.ktb.previous.values[i]])) : null;
|
|
113
|
+
const kCurrent = a.ktb.current.values ? Object.fromEntries(TENORS.map((t, i) => [t, a.ktb.current.values[i]])) : null;
|
|
114
|
+
const kChanges = a.ktb.changes ? Object.fromEntries(TENORS.map((t, i) => [t, a.ktb.changes[i]])) : null;
|
|
115
|
+
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('');
|
|
116
|
+
const cross = a.cross.current.gapBp === null ? '공통 관측일 없음' : `${a.cross.current.gapBp.toFixed(2)}bp (${a.cross.current.actual})`;
|
|
117
|
+
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>';
|
|
118
|
+
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>`;
|
|
119
|
+
}
|
|
120
|
+
const FILES = ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson'];
|
|
121
|
+
function scheduleBrief(a) {
|
|
122
|
+
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`;
|
|
123
|
+
}
|
|
124
|
+
export async function writeDualArtifacts(out, packet) {
|
|
125
|
+
const a = analyzeDual(packet), parent = dirname(out), stage = join(parent, `.${basename(out)}.stage-${process.pid}-${randomUUID()}`);
|
|
126
|
+
await mkdir(parent, { recursive: true });
|
|
127
|
+
try {
|
|
128
|
+
await stat(out);
|
|
129
|
+
throw Error(`Output path already exists: ${out}`);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
if (e.code !== 'ENOENT')
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await mkdir(stage);
|
|
137
|
+
const summary = dualSummary(packet, a);
|
|
138
|
+
const files = {
|
|
139
|
+
'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`,
|
|
140
|
+
'market-table.csv': dualCsv(a),
|
|
141
|
+
'report.html': dualHtml(packet, a),
|
|
142
|
+
'evidence.mjson': JSON.stringify(packet, null, 2) + '\n',
|
|
143
|
+
'status.mjson': JSON.stringify({ result: a.status, summary }, null, 2) + '\n'
|
|
144
|
+
};
|
|
145
|
+
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])])) };
|
|
146
|
+
for (const name of FILES)
|
|
147
|
+
await writeFile(join(stage, name), files[name]);
|
|
148
|
+
await writeFile(join(stage, 'manifest.mjson'), JSON.stringify(manifest, null, 2) + '\n');
|
|
149
|
+
await validateDualArtifacts(stage);
|
|
150
|
+
await rename(stage, out);
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
await rm(stage, { recursive: true, force: true });
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function validateDualArtifacts(dir) {
|
|
158
|
+
const manifest = JSON.parse(await readFile(join(dir, 'manifest.mjson'), 'utf8'));
|
|
159
|
+
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()))
|
|
160
|
+
throw Error('Dual report manifest contract mismatch');
|
|
161
|
+
const evidence = await readFile(join(dir, 'evidence.mjson'), 'utf8');
|
|
162
|
+
if (manifest.evidenceSha256 !== hash(evidence))
|
|
163
|
+
throw Error('Dual evidence checksum mismatch');
|
|
164
|
+
const packet = JSON.parse(evidence), a = analyzeDual(packet);
|
|
165
|
+
if (manifest.status !== a.status)
|
|
166
|
+
throw Error('Dual status mismatch');
|
|
167
|
+
const expected = {
|
|
168
|
+
'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`,
|
|
169
|
+
'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'
|
|
170
|
+
};
|
|
171
|
+
for (const name of FILES) {
|
|
172
|
+
const content = await readFile(join(dir, name), 'utf8');
|
|
173
|
+
if (hash(content) !== manifest.files[name] || content !== expected[name])
|
|
174
|
+
throw Error(`${name}: hash or deterministic output mismatch`);
|
|
175
|
+
}
|
|
176
|
+
if (a.status === 'READY' && (!expected['report.html'].includes('원천·수집 기록') || !expected['report.html'].includes(UST_SOURCE_PAGE)))
|
|
177
|
+
throw Error('Dual READY report lacks source provenance');
|
|
178
|
+
}
|