@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.
Files changed (54) 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/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
  52. package/src/analytics-policy.json +18 -0
  53. package/src/analytics.js +106 -0
  54. package/src/metrics.js +49 -0
@@ -0,0 +1,406 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFile } from 'node:child_process';
3
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { promisify } from 'node:util';
8
+ import { collect, collectWindows, parse, pointWindows, rangeWindows, request } from './collect.mjs';
9
+ import { ENGINE, PACKET_VERSION, SOURCE_URL, TENOR_META, TENORS, analyze, classify, csvRead, date, days, kstToday, sha256, shift } from './core.mjs';
10
+ import { collectPolicyRate, commonSnapshots, dashboard, eventQueryWindows, monthComparisonDates, onOrAfter, onOrBefore, previousMonthEnd, previousQuarterEnd, previousYearEnd, trend } from './modes.mjs';
11
+ import { analyzeNews, newsRead } from './news.mjs';
12
+ import { validateArtifacts, writeArtifacts } from './render.mjs';
13
+ import { analyzeUst, collectUst, parseUstXml, UST_ENGINE, ustMonthUrl, ustMonths } from './ust.mjs';
14
+ import { analyzeDual, DUAL_ENGINE, dualChat, validateDualArtifacts, writeDualArtifacts } from './dual.mjs';
15
+ import { analyzeSchedule, readSchedule } from './schedule.mjs';
16
+ const cliPath = fileURLToPath(new URL('./cli.mjs', import.meta.url));
17
+ let checks = 0;
18
+ const check = (fn) => { fn(); checks++; }, run = promisify(execFile);
19
+ const baseValues = { 2: 3, 3: 3.1, 5: 3.2, 10: 3.3, 20: 3.4, 30: 3.5 };
20
+ const currentValues = { 2: 2.95, 3: 3, 5: 3.15, 10: 3.25, 20: 3.35, 30: 3.45 };
21
+ const rowsAt = (day, values) => TENORS.map(tenor => ({ date: day, tenor, value: values[tenor] }));
22
+ const obs = [...rowsAt('2026-09-11', baseValues), ...rowsAt('2026-09-18', currentValues)];
23
+ const csvFor = (rows) => ['date,tenor,yield_pct', ...rows.map(r => `${r.date},${r.tenor},${r.value}`)].join('\n');
24
+ const receiptsFor = (rows) => { const raw = csvFor(rows); return TENORS.map(tenor => { const own = rows.filter(r => r.tenor === tenor), meta = TENOR_META[tenor]; return { tenor, sourceId: '사용자 제공 CSV: fixture.csv', sourceUrl: '', statCode: 'USER_CSV', itemCode: meta.itemCode, itemName: meta.itemName, unit: '연%', cycle: 'D', retrievedAt: '2026-09-18T09:00:00.000Z', status: 'input', raw: [raw], rawSha256: [sha256(raw)], count: own.length, first: own[0]?.date ?? null, last: own.at(-1)?.date ?? null, windows: [] }; }); };
25
+ const packetFor = (rows = obs) => ({ version: PACKET_VERSION, engine: ENGINE, asOf: '2026-09-18', base: '2026-09-11', maxLag: 7, observations: rows, receipts: receiptsFor(rows), label: '테스트' });
26
+ const packet = packetFor(), analysis = analyze(packet);
27
+ const newsInput = { version: 1, observationDate: '2026-09-18', retrievedAt: '2026-09-18T12:00:00.000Z', articles: [{ publisher: '연합인포맥스', title: '[채권-마감] 금리 하락…외국인 선물 매수', url: 'https://news.einfomax.co.kr/news/articleView.html?idxno=4359540', publishedAt: '2026-09-18T09:00:00.000Z', sourceType: 'market-close', marketDirection: 'lower', relation: 'explicit', factor: '외국인의 국채선물 순매수', evidenceSummary: '연합인포맥스는 외국인 국채선물 순매수를 금리 하락의 시장 배경으로 전했다.' }] }, newsEvidence = newsRead(JSON.stringify(newsInput)), newsAnalysis = analyzeNews(newsEvidence, analysis);
28
+ check(() => { assert.equal(analysis.status, 'READY'); assert.deepEqual(analysis.changes, [-5, -10, -5, -5, -5, -5]); assert.equal(analysis.spreads.find(s => s.name === '10년−3년')?.change, 5); assert.equal(analysis.movement, 'Bull 스티프닝'); });
29
+ check(() => { assert.equal(newsAnalysis.status, 'SOURCED'); assert.equal(newsAnalysis.confidence, 'HIGH'); assert.equal(newsAnalysis.curveDirection, 'lower'); assert.equal(newsAnalysis.eligible.length, 1); assert.match(newsAnalysis.commentary[0], /외국인의 국채선물 순매수/); });
30
+ check(() => { const mismatch = structuredClone(newsInput); mismatch.articles[0].marketDirection = 'higher'; const result = analyzeNews(newsRead(JSON.stringify(mismatch)), analysis); assert.equal(result.status, 'UNATTRIBUTED'); assert.equal(result.excluded.length, 1); assert.match(result.notes[0], /방향과 불일치/); });
31
+ check(() => { const bad = structuredClone(newsInput); bad.articles[0].url = 'https://example.com/article'; assert.throws(() => newsRead(JSON.stringify(bad)), /Unapproved/); });
32
+ check(() => { const bad = { ...structuredClone(newsInput), unexpected: true }; assert.throws(() => newsRead(JSON.stringify(bad)), /Unknown news evidence field/); });
33
+ check(() => { const bad = structuredClone(newsInput); bad.articles[0].unexpected = true; assert.throws(() => newsRead(JSON.stringify(bad)), /Unknown news article field/); });
34
+ check(() => { const bad = structuredClone(newsInput); bad.articles[0].publisher = '임의매체'; assert.throws(() => newsRead(JSON.stringify(bad)), /Publisher\/host mismatch/); });
35
+ check(() => { const bad = structuredClone(newsInput); bad.retrievedAt = '2026-09-18T08:59:59.000Z'; assert.throws(() => newsRead(JSON.stringify(bad)), /date alignment mismatch/); });
36
+ check(() => { const bad = structuredClone(newsInput); bad.articles.push(structuredClone(bad.articles[0])); assert.throws(() => newsRead(JSON.stringify(bad)), /Duplicate/); });
37
+ check(() => { const bad = structuredClone(newsEvidence); bad.articles[0].sourceFingerprint = '0'.repeat(64); assert.throws(() => newsRead(JSON.stringify(bad)), /fingerprint/); });
38
+ check(() => { const contextual = structuredClone(newsInput); contextual.articles[0].publisher = '한국은행'; contextual.articles[0].url = 'https://www.bok.or.kr/portal/bbs/B0000217/view.do?nttId=10000000'; contextual.articles[0].sourceType = 'official-release'; contextual.articles[0].marketDirection = 'context'; contextual.articles[0].relation = 'contextual'; const result = analyzeNews(newsRead(JSON.stringify(contextual)), analysis); assert.equal(result.status, 'CONTEXT_ONLY'); assert.equal(result.context.length, 1); assert.equal(result.eligible.length, 0); });
39
+ check(() => { const wrongDate = structuredClone(newsInput); wrongDate.observationDate = '2026-09-17'; wrongDate.articles[0].publishedAt = '2026-09-17T09:00:00.000Z'; assert.throws(() => analyzeNews(newsRead(JSON.stringify(wrongDate)), analysis), /current common observation/); });
40
+ check(() => { assert.equal(analysis.current.values?.length, 6); assert.deepEqual(TENORS, [2, 3, 5, 10, 20, 30]); });
41
+ check(() => { assert.equal(classify(5, 5), '평행 상승'); assert.equal(classify(-5, -5), '평행 하락'); assert.equal(classify(-2, 3), '혼합 움직임 / 스티프닝'); });
42
+ check(() => assert.throws(() => date('2025-02-29'), /Invalid date/));
43
+ check(() => assert.equal(date('2024-02-29'), '2024-02-29'));
44
+ check(() => assert.equal(kstToday(new Date('2026-09-20T15:30:00Z')), '2026-09-21'));
45
+ check(() => assert.throws(() => csvRead('date,tenor,yield_pct\n2026-01-01,3,3.0\n2026-01-01,3,3.1'), /Conflicting/));
46
+ check(() => assert.throws(() => csvRead('date,tenor,yield_pct\n2026-01-01,3,NaN'), /Invalid CSV/));
47
+ check(() => { assert.throws(() => csvRead('date,tenor,yield_pct\n2026-01-01,3,3.0 '), /Invalid CSV/); assert.throws(() => csvRead(' date,tenor,yield_pct\n2026-01-01,3,3.0'), /header/); });
48
+ check(() => assert.throws(() => analyze({ ...packet, base: '2025-01-01' }), /366/));
49
+ check(() => assert.throws(() => analyze({ ...packet, maxLag: 15 }), /0..14/));
50
+ check(() => assert.throws(() => analyze({ ...packet, label: 'bad\nheading' }), /invalid evidence/));
51
+ check(() => { const x = analyze({ ...packet, asOf: '2026-09-26' }); assert.equal(x.status, 'INCOMPLETE'); assert.match(x.notes[0], /허용 7일 초과/); });
52
+ check(() => { const x = analyze({ ...packet, asOf: '2026-09-25' }); assert.equal(x.status, 'READY'); });
53
+ check(() => { const rows = obs.filter(r => r.date <= '2026-09-12'), x = analyze({ ...packet, asOf: '2026-09-12', observations: rows, receipts: receiptsFor(rows) }); assert.equal(x.status, 'INCOMPLETE'); assert.match(x.notes.join(' '), /같은 관측일/); });
54
+ check(() => { const bad = structuredClone(packet); bad.receipts[0].unit = 'bp'; assert.throws(() => analyze(bad), /metadata mismatch/); });
55
+ check(() => { const bad = structuredClone(packet); bad.receipts[0].rawSha256[0] = '0'.repeat(64); assert.throws(() => analyze(bad), /raw source hash/); });
56
+ check(() => { const bad = structuredClone(packet); bad.receipts[0].count++; assert.throws(() => analyze(bad), /count\/range/); });
57
+ check(() => { const bad = structuredClone(packet); bad.observations[0].value += .01; assert.throws(() => analyze(bad), /do not match source raw data/); });
58
+ check(() => { const bad = structuredClone(packet); const raw = bad.receipts[0].raw[0].replace('2026-09-11,2,3', '2026-09-11,2,3.01'); bad.receipts[0].raw = [raw]; bad.receipts[0].rawSha256 = [sha256(raw)]; assert.throws(() => analyze(bad), /do not match source raw data/); });
59
+ check(() => { const bad = structuredClone(packet); bad.receipts[0].retrievedAt = '2026-09-18'; assert.throws(() => analyze(bad), /Invalid source receipt/); });
60
+ check(() => { const w = rangeWindows('2026-01-01', '2026-01-25'); assert.equal(w.length, 3); assert.deepEqual(w.at(-1), { from: '2026-01-21', to: '2026-01-25' }); });
61
+ check(() => assert.equal(pointWindows(['2026-09-18', '2026-09-18'], 7).length, 1));
62
+ check(() => { const w = pointWindows(['2026-09-18'], 14); assert.equal(w.length, 2); assert.ok(w.every(x => (Date.parse(x.to) - Date.parse(x.from)) / 86400000 + 1 <= 10)); });
63
+ check(() => { for (let length = 1; length <= 100; length++) {
64
+ const end = shift('2023-12-15', length - 1), w = rangeWindows('2023-12-15', end);
65
+ assert.equal(w[0].from, '2023-12-15');
66
+ assert.equal(w.at(-1)?.to, end);
67
+ assert.equal(w.reduce((sum, x) => sum + days(x.to, x.from) + 1, 0), length);
68
+ assert.ok(w.every(x => days(x.to, x.from) + 1 <= 10));
69
+ } });
70
+ check(() => { for (let lag = 0; lag <= 14; lag++) {
71
+ const w = pointWindows(['2024-03-01'], lag);
72
+ assert.equal(w[0].from, shift('2024-03-01', -lag));
73
+ assert.equal(w.at(-1)?.to, '2024-03-01');
74
+ } });
75
+ check(() => { assert.equal(previousMonthEnd('2026-01-15'), '2025-12-31'); assert.equal(previousQuarterEnd('2026-04-01'), '2026-03-31'); assert.equal(previousYearEnd('2026-01-01'), '2025-12-31'); });
76
+ check(() => { assert.deepEqual(monthComparisonDates('2026-08', '2026-09', '2026-09-22'), { base: '2026-08-31', asOf: '2026-09-22' }); assert.deepEqual(monthComparisonDates('2026-07', '2026-08', '2026-09-22'), { base: '2026-07-31', asOf: '2026-08-31' }); assert.throws(() => monthComparisonDates('2026-09', '2026-10', '2026-09-22'), /future/); assert.throws(() => monthComparisonDates('2026-09', '2026-09', '2026-09-22'), /earlier/); });
77
+ check(() => { const w = eventQueryWindows('2026-09-01', '2026-09-30', 14); assert.equal(w[0].from, '2026-08-17'); assert.equal(w.at(-1)?.to, '2026-09-15'); assert.ok(w.every(x => days(x.to, x.from) + 1 <= 10)); });
78
+ const modeObs = [...rowsAt('2026-08-31', Object.fromEntries(TENORS.map(t => [t, baseValues[t] - .1]))), ...rowsAt('2026-09-10', Object.fromEntries(TENORS.map(t => [t, baseValues[t] + .02]))), ...obs];
79
+ check(() => assert.equal(commonSnapshots(modeObs).length, 4));
80
+ check(() => assert.equal(onOrBefore(modeObs, '2026-09-17', 7).actual, '2026-09-11'));
81
+ check(() => assert.equal(onOrAfter(modeObs, '2026-09-12', 7).actual, '2026-09-18'));
82
+ check(() => assert.throws(() => onOrBefore(modeObs, '2026-09-18', -1), /0..14/));
83
+ const policy = { date: '2026-09-18', value: 3, unit: '연%', status: 'live', retrievedAt: '2026-09-18T09:00:00Z', sourceUrl: SOURCE_URL, statCode: '722Y001', itemCode: '0101000', rawSha256: 'a'.repeat(64) };
84
+ const dash = dashboard('2026-09-18', modeObs, 7, policy);
85
+ check(() => { assert.equal(dash.result, 'READY'); assert.equal(dash.references.previousSession.actual, '2026-09-11'); assert.equal(dash.references.previousMonthEnd.actual, '2026-08-31'); assert.equal(dash.policyGap3YBp, 0); assert.equal(dash.spreads['30Y-10Y'], 20); });
86
+ const trendRows = ['2026-09-14', '2026-09-15', '2026-09-16', '2026-09-17', '2026-09-18'].flatMap((d, i) => rowsAt(d, Object.fromEntries(TENORS.map(t => [t, baseValues[t] + i * .01])))), trendResult = trend(trendRows, 5);
87
+ check(() => { assert.equal(trendResult.result, 'READY'); assert.equal(trendResult.actualSessions, 5); assert.equal(trendResult.stats[0].netChangeBp, 4); });
88
+ check(() => assert.equal(trend(trendRows.slice(0, -TENORS.length), 5).result, 'INCOMPLETE'));
89
+ check(() => { assert.equal(trend(rowsAt('2026-09-18', baseValues), 5).result, 'INCOMPLETE'); assert.equal(trend([], 5).result, 'BLOCKED'); });
90
+ const ecos = (tenor, time = '20260918', value = '3.15') => JSON.stringify({ StatisticSearch: { list_total_count: 1, row: [{ STAT_CODE: '817Y002', STAT_NAME: '1.3.2.1. 시장금리(일별)', ITEM_CODE1: TENOR_META[tenor].itemCode, ITEM_NAME1: TENOR_META[tenor].itemName, UNIT_NAME: '연%', TIME: time, DATA_VALUE: value }] } });
91
+ check(() => assert.equal(parse(ecos(30), 30, '2026-09-18', '2026-09-18')[0].value, 3.15));
92
+ check(() => { const doc = JSON.parse(ecos(20)); doc.StatisticSearch.row[0].UNIT_NAME = 'bp'; assert.throws(() => parse(JSON.stringify(doc), 20, '2026-09-18', '2026-09-18'), /metadata/); });
93
+ check(() => { const doc = JSON.parse(ecos(2)); doc.StatisticSearch.list_total_count = 2; assert.throws(() => parse(JSON.stringify(doc), 2, '2026-09-18', '2026-09-18'), /Incomplete/); });
94
+ check(() => { const doc = JSON.parse(ecos(3)); doc.StatisticSearch.row[0].DATA_VALUE = '0x10'; assert.throws(() => parse(JSON.stringify(doc), 3, '2026-09-18', '2026-09-18'), /metadata/); });
95
+ check(() => { const doc = JSON.parse(ecos(5)); doc.StatisticSearch.list_total_count = 2; doc.StatisticSearch.row.push({ ...doc.StatisticSearch.row[0] }); assert.throws(() => parse(JSON.stringify(doc), 5, '2026-09-18', '2026-09-18'), /duplicate/); });
96
+ let tries = 0;
97
+ await request('x', (async () => { tries++; return new Response(tries < 3 ? '' : '{}', { status: tries < 3 ? 503 : 200 }); }), async () => { });
98
+ check(() => assert.equal(tries, 3));
99
+ let fatalTries = 0;
100
+ await assert.rejects(() => request('x', (async () => { fatalTries++; return new Response('', { status: 404 }); }), async () => { }), /HTTP 404/);
101
+ check(() => assert.equal(fatalTries, 1));
102
+ let semanticTries = 0;
103
+ const semanticRequest = await request('x', (async () => { semanticTries++; const raw = semanticTries < 3 ? ecos(3).replace('"UNIT_NAME":"연%"', '"UNIT_NAME":"bp"') : ecos(3); return new Response(raw, { status: 200 }); }), async () => { }, raw => { parse(raw, 3, '2026-09-18', '2026-09-18'); });
104
+ check(() => { assert.equal(semanticTries, 3); assert.equal(semanticRequest.attempts, 3); });
105
+ const root = await mkdtemp(join(tmpdir(), 'curve-v3-'));
106
+ try {
107
+ const first = join(root, 'first'), second = join(root, 'second');
108
+ await writeArtifacts(first, packet);
109
+ await validateArtifacts(first);
110
+ checks++;
111
+ const replay = JSON.parse(await readFile(join(first, 'evidence.mjson'), 'utf8'));
112
+ await writeArtifacts(second, replay);
113
+ await validateArtifacts(second);
114
+ for (const f of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
115
+ assert.equal(await readFile(join(first, f), 'utf8'), await readFile(join(second, f), 'utf8'));
116
+ checks++;
117
+ const report = await readFile(join(first, 'report.html'), 'utf8'), csv = await readFile(join(first, 'market-table.csv'), 'utf8');
118
+ check(() => { assert.match(report, /30년/); assert.match(report, /10년−5년/); assert.equal(csv.trim().split(/\r?\n/).length, 11); assert.doesNotMatch(report, /NaN|undefined/); });
119
+ const newsFirst = join(root, 'news-first'), newsSecond = join(root, 'news-second'), newsPacket = { ...packet, newsEvidence };
120
+ await writeArtifacts(newsFirst, newsPacket);
121
+ await validateArtifacts(newsFirst);
122
+ const embedded = JSON.parse(await readFile(join(newsFirst, 'evidence.mjson'), 'utf8'));
123
+ await writeArtifacts(newsSecond, embedded);
124
+ await validateArtifacts(newsSecond);
125
+ for (const f of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
126
+ assert.equal(await readFile(join(newsFirst, f), 'utf8'), await readFile(join(newsSecond, f), 'utf8'));
127
+ const newsPage = await readFile(join(newsFirst, 'report.html'), 'utf8'), newsBrief = await readFile(join(newsFirst, 'brief.md'), 'utf8');
128
+ check(() => { assert.match(newsPage, /시장 배경/); assert.ok(newsPage.includes(newsEvidence.articles[0].url)); assert.match(newsPage, /class="value-label/); assert.doesNotMatch(newsPage, /원문 SHA-256|근거 SHA-256/); assert.ok(!newsPage.includes(newsEvidence.articles[0].sourceFingerprint)); assert.match(newsBrief, /SOURCED/); });
129
+ checks++;
130
+ const yields = Object.fromEntries(TENORS.map(t => [TENOR_META[t].itemCode, String(currentValues[t])])), fakeEcos = async (url) => { const value = String(url), item = Object.keys(yields).find(code => value.endsWith(`/${code}`)), current = value.includes('/20260911/20260918/'), TIME = current ? '20260918' : '20260911', tenor = TENORS.find(t => TENOR_META[t].itemCode === item); return new Response(ecos(tenor, TIME, yields[item]), { status: 200 }); };
131
+ const cache = join(root, 'cache'), clock = new Date('2026-09-18T12:00:00Z'), live = await collect('2026-09-11', '2026-09-18', 7, cache, true, fakeEcos, clock);
132
+ check(() => { assert.equal(live.observations.length, 12); assert.ok(live.receipts.every(r => r.status === 'live')); });
133
+ let transientBadRows = 0;
134
+ const semanticFetch = (async (url) => { const response = await fakeEcos(url); if (String(url).endsWith(`/${TENOR_META[3].itemCode}`) && String(url).includes('/20260911/20260918/') && ++transientBadRows < 3)
135
+ return new Response((await response.text()).replace('"UNIT_NAME":"연%"', '"UNIT_NAME":"bp"'), { status: 200 }); return response; });
136
+ const semanticCollected = await collect('2026-09-11', '2026-09-18', 7, join(root, 'semantic-retry'), false, semanticFetch, clock);
137
+ check(() => { assert.equal(transientBadRows, 3); assert.equal(semanticCollected.receipts.find(r => r.tenor === 3)?.windows.find(w => w.to === '2026-09-18')?.attempts, 3); assert.equal(semanticCollected.observations.length, 12); });
138
+ check(() => { const bad = structuredClone(packetFor(live.observations)); bad.receipts = structuredClone(live.receipts); bad.receipts[0].windows[0].attempts = 0; assert.throws(() => analyze(bad), /invalid source window/); });
139
+ check(() => { const bad = structuredClone(packetFor(live.observations)); bad.receipts = structuredClone(live.receipts); bad.receipts[0].raw.pop(); bad.receipts[0].rawSha256.pop(); assert.throws(() => analyze(bad), /window\/raw mismatch/); });
140
+ const unavailable = (async () => new Response('', { status: 404 })), recovered = await collect('2026-09-11', '2026-09-18', 7, cache, true, unavailable, clock);
141
+ check(() => { assert.equal(recovered.observations.length, 12); assert.ok(recovered.receipts.every(r => r.status === 'cache')); });
142
+ const noCacheDir = join(root, 'no-cache');
143
+ await collect('2026-09-11', '2026-09-18', 7, noCacheDir, false, fakeEcos, clock);
144
+ await assert.rejects(() => readdir(noCacheDir), /ENOENT/);
145
+ checks++;
146
+ let reversedCalls = 0;
147
+ await assert.rejects(() => collect('2026-09-18', '2026-09-11', 7, join(root, 'reversed'), false, (async () => { reversedCalls++; return new Response('{}', { status: 200 }); }), clock), /Base must precede/);
148
+ check(() => assert.equal(reversedCalls, 0));
149
+ const files = await readdir(cache), cacheFile = join(cache, files.find(x => !x.startsWith('policy-')));
150
+ const cached = JSON.parse(await readFile(cacheFile, 'utf8'));
151
+ cached.digest = '0'.repeat(64);
152
+ await writeFile(cacheFile, JSON.stringify(cached));
153
+ const rejected = await collect('2026-09-11', '2026-09-18', 7, cache, true, unavailable, clock);
154
+ check(() => assert.ok(rejected.observations.length < 12));
155
+ const policyRaw = JSON.stringify({ StatisticSearch: { list_total_count: 1, row: [{ STAT_CODE: '722Y001', STAT_NAME: '1.3.1. 한국은행 기준금리 및 여수신금리', ITEM_CODE1: '0101000', ITEM_NAME1: '한국은행 기준금리', UNIT_NAME: '연%', TIME: '20260918', DATA_VALUE: '3' }] } }), policyFetch = (async () => new Response(policyRaw, { status: 200 }));
156
+ const policyCache = join(root, 'policy-cache'), pLive = await collectPolicyRate('2026-09-18', policyCache, true, policyFetch, clock);
157
+ check(() => { assert.equal(pLive?.value, 3); assert.equal(pLive?.status, 'live'); });
158
+ const pCache = await collectPolicyRate('2026-09-18', policyCache, true, unavailable, clock);
159
+ check(() => assert.equal(pCache?.status, 'cache'));
160
+ const noPolicyCacheDir = join(root, 'no-policy-cache');
161
+ await collectPolicyRate('2026-09-18', noPolicyCacheDir, false, policyFetch, clock);
162
+ await assert.rejects(() => readdir(noPolicyCacheDir), /ENOENT/);
163
+ checks++;
164
+ const policyCollision = join(root, 'policy-write-collision');
165
+ await mkdir(policyCollision);
166
+ await mkdir(join(policyCollision, 'policy-2026-09-11-2026-09-18.mjson'));
167
+ const policyDespiteCacheFailure = await collectPolicyRate('2026-09-18', policyCollision, true, policyFetch, clock);
168
+ check(() => { assert.equal(policyDespiteCacheFailure?.status, 'live'); assert.ok(policyDespiteCacheFailure?.cacheWriteError); });
169
+ const policyCollisionFiles = await readdir(policyCollision);
170
+ check(() => assert.ok(policyCollisionFiles.every(x => !x.endsWith('.tmp'))));
171
+ const invalidPolicy = policyRaw.replace('"DATA_VALUE":"3"', '"DATA_VALUE":"0x10"'), badPolicy = await collectPolicyRate('2026-09-18', join(root, 'bad-policy'), false, (async () => new Response(invalidPolicy, { status: 200 })), clock);
172
+ check(() => assert.equal(badPolicy, null));
173
+ const ranged = await collectWindows([{ from: '2026-09-11', to: '2026-09-18' }], join(root, 'range-cache'), false, fakeEcos, clock);
174
+ check(() => assert.equal(ranged.receipts.length, 6));
175
+ const unwritable = join(root, 'cache-write-collision');
176
+ await mkdir(unwritable);
177
+ for (const t of TENORS)
178
+ await mkdir(join(unwritable, `${t}-2026-09-18-2026-09-18.mjson`));
179
+ const singleEcos = async (url) => { const value = String(url), item = Object.keys(yields).find(code => value.endsWith(`/${code}`)), tenor = TENORS.find(t => TENOR_META[t].itemCode === item); return new Response(ecos(tenor, '20260918', yields[item]), { status: 200 }); }, cacheWriteFailure = await collectWindows([{ from: '2026-09-18', to: '2026-09-18' }], unwritable, true, singleEcos, clock);
180
+ check(() => { assert.equal(cacheWriteFailure.observations.length, 6); assert.ok(cacheWriteFailure.receipts.every(x => x.status === 'live' && x.windows[0].error?.includes('cache write failed'))); });
181
+ const cacheWriteAnalysis = analyze({ version: PACKET_VERSION, engine: ENGINE, base: '2026-09-17', asOf: '2026-09-18', maxLag: 1, observations: cacheWriteFailure.observations, receipts: cacheWriteFailure.receipts, label: '캐시 저장 실패 테스트' });
182
+ check(() => assert.match(cacheWriteAnalysis.notes.join(' '), /로컬 캐시 저장 실패/));
183
+ const collisionFiles = await readdir(unwritable);
184
+ check(() => assert.ok(collisionFiles.every(x => !x.endsWith('.tmp'))));
185
+ await assert.rejects(() => collectWindows([{ from: '2026-09-11', to: '2026-09-18' }], join(root, 'bad-clock'), false, fakeEcos, new Date('invalid')), /Invalid collection clock/);
186
+ checks++;
187
+ const inputFile = join(root, 'input.csv'), csvRows = ['date,tenor,yield_pct', ...obs.map(r => `${r.date},${r.tenor},${r.value}`)].join('\n');
188
+ await writeFile(inputFile, csvRows);
189
+ const cli = await run(process.execPath, [cliPath, '--input', inputFile, '--base', '2026-09-11', '--as-of', '2026-09-18'], { cwd: root });
190
+ check(() => { const parsed = JSON.parse(cli.stdout); assert.equal(parsed.result, 'READY'); assert.equal(parsed.rates.length, 6); assert.doesNotMatch(cli.stdout, /report\.html/); });
191
+ const newsFile = join(root, 'news.mjson');
192
+ await writeFile(newsFile, JSON.stringify(newsInput));
193
+ const cliNews = await run(process.execPath, [cliPath, '--input', inputFile, '--base', '2026-09-11', '--as-of', '2026-09-18', '--news-evidence', newsFile], { cwd: root });
194
+ check(() => { const parsed = JSON.parse(cliNews.stdout); assert.equal(parsed.news.status, 'SOURCED'); assert.equal(parsed.news.confidence, 'HIGH'); });
195
+ const template = await run(process.execPath, [cliPath, '--news-template', '2026-09-18'], { cwd: root });
196
+ check(() => assert.equal(newsRead(template.stdout).observationDate, '2026-09-18'));
197
+ try {
198
+ await run(process.execPath, [cliPath, '--base', '2026-09-11', '--base', '2026-09-12', '--as-of', '2026-09-18'], { cwd: root });
199
+ assert.fail('duplicate option accepted');
200
+ }
201
+ catch (e) {
202
+ assert.match(String(e.stderr), /Duplicate option/);
203
+ checks++;
204
+ }
205
+ try {
206
+ await run(process.execPath, [cliPath, '--input', inputFile], { cwd: root });
207
+ assert.fail('implicit input dates accepted');
208
+ }
209
+ catch (e) {
210
+ assert.match(String(e.stderr), /requires explicit/);
211
+ checks++;
212
+ }
213
+ const cliReject = async (argv, pattern) => { try {
214
+ await run(process.execPath, [cliPath, ...argv], { cwd: root });
215
+ assert.fail(`CLI accepted ${argv.join(' ')}`);
216
+ }
217
+ catch (e) {
218
+ assert.match(String(e.stderr), pattern);
219
+ checks++;
220
+ } };
221
+ await cliReject(['--base', '2026-09-18', '--as-of', '2026-09-18'], /precede/);
222
+ await cliReject(['--base', '2020-01-01', '--as-of', '2022-01-01'], /366/);
223
+ await cliReject(['--preset', 'dashboard', '--lookback-sessions', '5'], /mutually exclusive/);
224
+ await cliReject(['--replay', 'missing.mjson', '--base', '2026-09-11'], /accepts only/);
225
+ await cliReject(['--from-month', '2026-09', '--to-month', '2026-09'], /earlier/);
226
+ await cliReject(['--market', 'kr', '--json'], /only needed/);
227
+ const third = join(root, 'third');
228
+ await writeArtifacts(third, packet);
229
+ const statusPath = join(third, 'status.mjson'), manifestPath = join(third, 'manifest.mjson');
230
+ await writeFile(statusPath, (await readFile(statusPath, 'utf8')).replace('"usable": true', '"usable": false'));
231
+ const alteredManifest = JSON.parse(await readFile(manifestPath, 'utf8'));
232
+ alteredManifest.files['status.mjson'] = sha256(await readFile(statusPath, 'utf8'));
233
+ await writeFile(manifestPath, JSON.stringify(alteredManifest, null, 2) + '\n');
234
+ await assert.rejects(() => validateArtifacts(third), /not reproducible/);
235
+ checks++;
236
+ const fourth = join(root, 'fourth');
237
+ await writeArtifacts(fourth, packet);
238
+ const incompleteManifest = JSON.parse(await readFile(join(fourth, 'manifest.mjson'), 'utf8'));
239
+ delete incompleteManifest.files['status.mjson'];
240
+ await writeFile(join(fourth, 'manifest.mjson'), JSON.stringify(incompleteManifest, null, 2) + '\n');
241
+ await assert.rejects(() => validateArtifacts(fourth), /file contract/);
242
+ checks++;
243
+ await writeFile(join(first, 'brief.md'), (await readFile(join(first, 'brief.md'), 'utf8')) + 'tampered');
244
+ await assert.rejects(() => validateArtifacts(first), /hash mismatch/);
245
+ checks++;
246
+ }
247
+ finally {
248
+ await rm(root, { recursive: true, force: true });
249
+ }
250
+ const ustFixture = (dates) => `<?xml version="1.0"?><feed xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"><title type="text">DailyTreasuryYieldCurveRateData</title>${dates.map((d, i) => `<entry><content><m:properties><d:NEW_DATE m:type="Edm.DateTime">${d}T00:00:00</d:NEW_DATE>${TENORS.map(t => `<d:BC_${t}YEAR m:type="Edm.Double">${(4 + t / 100 + i / 100).toFixed(2)}</d:BC_${t}YEAR>`).join('')}</m:properties></content></entry>`).join('')}</feed>`;
251
+ const ustRaw = ustFixture(['2026-09-11', '2026-09-18']);
252
+ check(() => { const rows = parseUstXml(ustRaw, '2026-09'); assert.equal(rows.length, 2); assert.equal(rows[0].yields[10], 4.1); assert.equal(rows[1].yields[30], 4.31); });
253
+ check(() => assert.throws(() => parseUstXml(ustRaw, '2026-08'), /month\/date mismatch/));
254
+ check(() => assert.throws(() => parseUstXml(ustFixture(['2026-09-11', '2026-09-11']), '2026-09'), /duplicate observation/));
255
+ check(() => assert.throws(() => parseUstXml(ustRaw.replace('<d:BC_10YEAR m:type="Edm.Double">4.10</d:BC_10YEAR>', '<d:BC_10YEAR m:type="Edm.Double">400bp</d:BC_10YEAR>'), '2026-09'), /Invalid UST 10Y yield/));
256
+ const ustMissingTenor = ustRaw.replace('<d:BC_20YEAR m:type="Edm.Double">4.20</d:BC_20YEAR>', '<d:BC_20YEAR m:null="true" />');
257
+ check(() => { const rows = parseUstXml(ustMissingTenor, '2026-09'); assert.equal(rows.length, 1); assert.equal(rows[0].date, '2026-09-18'); });
258
+ check(() => assert.equal(ustMonthUrl('2026-09').endsWith('202609'), true));
259
+ check(() => assert.deepEqual(ustMonths('2026-08-31', '2026-09-01', 7), ['2026-08', '2026-09']));
260
+ const dualRoot = await mkdtemp(join(tmpdir(), 'ktb-ust-'));
261
+ try {
262
+ let requests = 0;
263
+ const fetcher = (async () => { requests++; return new Response(ustRaw, { status: 200, headers: { 'Content-Type': 'application/xml' } }); });
264
+ const us = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'cache'), true, fetcher, new Date('2026-09-18T23:00:00.000Z'));
265
+ check(() => { assert.equal(requests, 1); assert.equal(us.engine, UST_ENGINE); assert.equal(analyzeUst(us).status, 'READY'); assert.equal(analyzeUst(us).changes?.[10], 1); });
266
+ const refreshed = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'cache'), true, fetcher, new Date('2026-09-19T07:00:00.000Z'));
267
+ check(() => { assert.equal(requests, 2); assert.equal(refreshed.sources[0].status, 'live'); });
268
+ const offline = (async () => new Response('', { status: 503 }));
269
+ const historicalCache = join(dualRoot, 'historical-cache'), historicalRaw = ustFixture(['2026-08-11', '2026-08-18']), historicalFetch = (async () => new Response(historicalRaw, { status: 200 }));
270
+ const historicalLive = await collectUst('2026-08-11', '2026-08-18', 7, historicalCache, true, historicalFetch, new Date('2026-09-23T00:00:00.000Z'));
271
+ check(() => assert.equal(historicalLive.sources[0].status, 'live', JSON.stringify(historicalLive.sources[0])));
272
+ const historical = await collectUst('2026-08-11', '2026-08-18', 7, historicalCache, true, offline, new Date('2026-09-23T12:00:00.000Z'));
273
+ check(() => assert.equal(historical.sources[0].status, 'cache'));
274
+ const noUstCache = join(dualRoot, 'no-ust-cache');
275
+ await collectUst('2026-09-11', '2026-09-18', 7, noUstCache, false, fetcher, new Date('2026-09-18T23:00:00.000Z'));
276
+ await assert.rejects(() => readdir(noUstCache), /ENOENT/);
277
+ checks++;
278
+ const ustCollision = join(dualRoot, 'ust-cache-collision');
279
+ await mkdir(ustCollision);
280
+ await mkdir(join(ustCollision, 'ust-2026-09.mjson'));
281
+ const collisionResult = await collectUst('2026-09-11', '2026-09-18', 7, ustCollision, true, fetcher, new Date('2026-09-18T23:00:00.000Z'));
282
+ check(() => { assert.equal(collisionResult.sources[0].status, 'live'); assert.equal(analyzeUst(collisionResult).status, 'READY'); });
283
+ const ustCollisionFiles = await readdir(ustCollision);
284
+ check(() => assert.ok(!ustCollisionFiles.some(x => x.endsWith('.tmp'))));
285
+ let malformedCalls = 0;
286
+ const malformedThenValid = (async () => { malformedCalls++; return new Response(malformedCalls < 3 ? '<html>temporary response</html>' : ustRaw, { status: 200 }); });
287
+ const retried = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'retry-cache'), false, malformedThenValid, new Date('2026-09-18T23:00:00.000Z'));
288
+ check(() => { assert.equal(malformedCalls, 3); assert.equal(retried.sources[0].status, 'live'); assert.equal(analyzeUst(retried).status, 'READY'); });
289
+ const cached = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'cache'), true, offline, new Date('2026-09-19T09:00:00.000Z'));
290
+ check(() => assert.equal(cached.sources[0].status, 'cache'));
291
+ const blocked = await collectUst('2026-09-11', '2026-09-18', 7, join(dualRoot, 'empty-cache'), false, offline, new Date('2026-09-19T01:00:00.000Z'));
292
+ check(() => { assert.equal(blocked.sources[0].status, 'failed'); assert.equal(analyzeUst(blocked).status, 'BLOCKED'); assert.match(analyzeUst(blocked).notes.join(' '), /수집 실패/); });
293
+ check(() => { const bad = structuredClone(blocked); bad.sources[0].error = 'failed\n## injected section'; assert.throws(() => analyzeUst(bad), /Invalid failed UST source receipt/); });
294
+ const incompleteUs = structuredClone(us);
295
+ incompleteUs.sources[0].raw = ustMissingTenor;
296
+ incompleteUs.sources[0].sha256 = sha256(ustMissingTenor);
297
+ check(() => { const a = analyzeUst(incompleteUs); assert.equal(a.status, 'INCOMPLETE'); assert.match(a.notes.join(' '), /만기 결측이 있는 1개 관측일/); });
298
+ const partial = analyzeDual({ version: 1, engine: DUAL_ENGINE, ktb: packet, ust: blocked });
299
+ check(() => assert.equal(partial.status, 'INCOMPLETE'));
300
+ const partialChat = dualChat({ version: 1, engine: DUAL_ENGINE, ktb: packet, ust: blocked }, partial);
301
+ check(() => { assert.match(partialChat, /\*\*상태:\*\* 일부 자료 부족, 확인된 값만 표시 \(INCOMPLETE\)/); assert.match(partialChat, /UST는 비교 가능한 두 관측일이 부족합니다/); assert.match(partialChat, /공통 관측일이 없어 계산 불가/); assert.doesNotMatch(partialChat, /NaN|undefined|계산 불가bp/); });
302
+ const emptyKtb = packetFor([]), fullyBlocked = { version: 1, engine: DUAL_ENGINE, ktb: emptyKtb, ust: blocked };
303
+ const blockedChat = dualChat(fullyBlocked);
304
+ check(() => { assert.match(blockedChat, /자료 확인 불가 \(BLOCKED\)/); assert.equal((blockedChat.match(/^\| \d+년 \|/gm) ?? []).length, 6); assert.doesNotMatch(blockedChat, /NaN|undefined/); });
305
+ const partialOut = join(dualRoot, 'partial');
306
+ await writeDualArtifacts(partialOut, { version: 1, engine: DUAL_ENGINE, ktb: packet, ust: blocked });
307
+ await validateDualArtifacts(partialOut);
308
+ const partialBrief = await readFile(join(partialOut, 'brief.md'), 'utf8');
309
+ check(() => { assert.match(partialBrief, /INCOMPLETE/); assert.match(partialBrief, /UST 원천 수집 실패/); assert.match(partialBrief, /계산 불가 \(/); assert.doesNotMatch(partialBrief, /계산 불가bp/); });
310
+ for (const [label, evidence] of [['partial', { version: 1, engine: DUAL_ENGINE, ktb: packet, ust: blocked }], ['blocked', fullyBlocked]]) {
311
+ const original = label === 'partial' ? partialOut : join(dualRoot, 'blocked');
312
+ if (label === 'blocked')
313
+ await writeDualArtifacts(original, evidence);
314
+ const replayed = join(dualRoot, `${label}-replay`), saved = JSON.parse(await readFile(join(original, 'evidence.mjson'), 'utf8'));
315
+ await writeDualArtifacts(replayed, saved);
316
+ await validateDualArtifacts(replayed);
317
+ for (const name of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
318
+ assert.equal(await readFile(join(original, name), 'utf8'), await readFile(join(replayed, name), 'utf8'));
319
+ checks++;
320
+ }
321
+ check(() => { const tampered = structuredClone(us); tampered.sources[0].sha256 = '0'.repeat(64); assert.throws(() => analyzeUst(tampered), /checksum/); });
322
+ check(() => { const bad = structuredClone(us); bad.sources[0].url = 'https://example.com/'; assert.throws(() => analyzeUst(bad), /identity/); });
323
+ const dual = { version: 1, engine: DUAL_ENGINE, ktb: packet, ust: us }, result = analyzeDual(dual);
324
+ check(() => { assert.equal(result.status, 'READY'); assert.equal(result.cross.current.actual, '2026-09-18'); assert.equal(result.cross.current.gapBp, 86); });
325
+ const lagged = structuredClone(dual);
326
+ lagged.ktb.asOf = '2026-09-19';
327
+ lagged.ust.asOf = '2026-09-19';
328
+ const laggedChat = dualChat(lagged);
329
+ check(() => { assert.match(laggedChat, /요청일 2026-09-19보다 1일 전/); assert.match(laggedChat, /분석 완료 \(READY\)/); });
330
+ const chat = dualChat(dual, result), sections = ['**상태:**', '**핵심 변화**', '| 만기 |', '**커브·한미 차이**', '**주요 일정**', '**확인 사항**', '**출처:**'];
331
+ check(() => { for (const variant of [chat, partialChat, blockedChat]) {
332
+ const positions = sections.map(s => variant.indexOf(s));
333
+ assert.ok(positions.every(i => i >= 0));
334
+ assert.deepEqual(positions, [...positions].sort((a, b) => a - b));
335
+ assert.equal((variant.match(/^\| \d+년 \|/gm) ?? []).length, 6);
336
+ assert.doesNotMatch(variant, /NaN|undefined/);
337
+ } assert.match(chat, /KTB 3년 -10\.00bp, 10년 -5\.00bp/); assert.match(chat, /UST 3년 \+1\.00bp, 10년 \+1\.00bp/); assert.match(chat, /공통 관측일 2026-09-18/); assert.match(chat, /https:\/\/ecos\.bok\.or\.kr\//); assert.match(chat, /home\.treasury\.gov/); });
338
+ const first = join(dualRoot, 'first'), second = join(dualRoot, 'second');
339
+ await writeDualArtifacts(first, dual);
340
+ await validateDualArtifacts(first);
341
+ const replay = JSON.parse(await readFile(join(first, 'evidence.mjson'), 'utf8'));
342
+ await writeDualArtifacts(second, replay);
343
+ await validateDualArtifacts(second);
344
+ for (const name of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
345
+ assert.equal(await readFile(join(first, name), 'utf8'), await readFile(join(second, name), 'utf8'));
346
+ checks++;
347
+ const html = await readFile(join(first, 'report.html'), 'utf8');
348
+ check(() => { assert.match(html, /한국 국고채/); assert.match(html, /미국 국채 par/); assert.match(html, /공통 관측일/); assert.ok(html.includes(us.sources[0].url.replaceAll('&', '&amp;'))); assert.doesNotMatch(html, /NaN|undefined/); });
349
+ const schedule = { version: 1, anchorDate: '2026-09-18', checkedAt: '2026-09-18T23:00:00.000Z', events: [
350
+ { date: '2026-09-21', market: 'KR', kind: 'auction', tenor: 5, sourceUrl: 'https://ktb.moef.go.kr/mnbyIsuCldr.do', sourceExcerpt: '2026.09.21. | 5년물', sourceDateText: '2026.09.21.' },
351
+ { date: '2026-09-23', market: 'US', kind: 'auction', tenor: 5, sourceUrl: 'https://home.treasury.gov/system/files/221/TentativeAuctionScheduleQ22026.pdf', sourceExcerpt: '5-Year NOTE Thursday, September 17, 2026 Wednesday, September 23, 2026 Wednesday, September 30, 2026', sourceDateText: 'Wednesday, September 23, 2026' },
352
+ { date: '2026-10-22', market: 'KR', kind: 'policy', sourceUrl: 'https://www.bok.or.kr/portal/singl/crncyPolicyDrcMtg/listYear.do?menuNo=200755&mtgSe=A', sourceExcerpt: '2026 통화정책방향 결정회의 일정 및 자료 10월 22일(목)', sourceDateText: '10월 22일' },
353
+ { date: '2026-10-28', market: 'US', kind: 'policy', sourceUrl: 'https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm', sourceExcerpt: '2026 FOMC Meetings October 27-28', sourceDateText: 'October 27-28' }
354
+ ] };
355
+ const scheduled = readSchedule(JSON.stringify(schedule), '2026-09-18');
356
+ check(() => { const a = analyzeSchedule(scheduled, '2026-09-18'); assert.equal(a.status, 'VERIFIED'); assert.deepEqual(a.events.map(e => e.date), ['2026-09-21', '2026-09-23', '2026-10-22', '2026-10-28']); assert.equal(analyzeSchedule(undefined, '2026-09-18').status, 'UNVERIFIED'); });
357
+ const scheduledDual = { ...dual, schedule: scheduled }, scheduledChat = dualChat(scheduledDual);
358
+ check(() => { assert.match(scheduledChat, /2026-09-21 \(KR\) 국고채 5년물 입찰/); assert.match(scheduledChat, /2026-09-23 \(US\) 미국 국채 5년물 입찰/); assert.match(scheduledChat, /2026-10-22 \(KR\) 한국은행 통화정책방향 결정회의/); assert.match(scheduledChat, /2026-10-28 \(US\) FOMC 금리 결정/); assert.match(scheduledChat, /분석 완료 \(READY\)/); });
359
+ check(() => { const bad = structuredClone(schedule); bad.events[0].date = '2026-09-22'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /quoted date/); });
360
+ check(() => { const bad = structuredClone(schedule); bad.events[1].sourceUrl = 'https://home.treasury.gov.evil.example/schedule'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /host/); });
361
+ check(() => { const bad = structuredClone(schedule); bad.events[1].date = '2026-09-17'; bad.events[1].sourceDateText = 'Thursday, September 17, 2026'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /event date/); });
362
+ check(() => { const bad = structuredClone(schedule); bad.events[1].date = '2026-09-30'; bad.events[1].sourceDateText = 'Wednesday, September 30, 2026'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /second date/); });
363
+ check(() => { const bad = structuredClone(schedule); bad.events[1].sourceExcerpt = bad.events[1].sourceExcerpt.replaceAll('Wednesday, September 23', 'Tuesday, September 23'); bad.events[1].sourceDateText = 'Tuesday, September 23, 2026'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /quoted date/); });
364
+ check(() => { const bad = structuredClone(schedule); bad.events[1].sourceExcerpt = '5-Year NOTE Wednesday, September 23, 2026'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /announcement, auction, and settlement/); });
365
+ check(() => { const bad = structuredClone(schedule); bad.events.push({ ...bad.events[0] }); assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /Duplicate/); });
366
+ check(() => { const bad = structuredClone(schedule); bad.checkedAt = '2026-10-18T00:00:00.000Z'; assert.throws(() => analyzeSchedule(bad, '2026-09-18'), /too far/); });
367
+ const scheduleOut = join(dualRoot, 'scheduled'), scheduleReplay = join(dualRoot, 'scheduled-replay');
368
+ await writeDualArtifacts(scheduleOut, scheduledDual);
369
+ await validateDualArtifacts(scheduleOut);
370
+ const scheduleSaved = JSON.parse(await readFile(join(scheduleOut, 'evidence.mjson'), 'utf8'));
371
+ await writeDualArtifacts(scheduleReplay, scheduleSaved);
372
+ await validateDualArtifacts(scheduleReplay);
373
+ for (const name of ['brief.md', 'market-table.csv', 'report.html', 'evidence.mjson', 'status.mjson', 'manifest.mjson'])
374
+ assert.equal(await readFile(join(scheduleOut, name), 'utf8'), await readFile(join(scheduleReplay, name), 'utf8'));
375
+ const scheduleHtml = await readFile(join(scheduleOut, 'report.html'), 'utf8');
376
+ check(() => { assert.match(scheduleHtml, /주요 일정/); assert.match(scheduleHtml, /국고채 5년물 입찰/); assert.match(scheduleHtml, /FOMC 금리 결정/); });
377
+ const scheduledCli = await run(process.execPath, [cliPath, '--market', 'both', '--replay', join(scheduleOut, 'evidence.mjson')], { cwd: dualRoot });
378
+ check(() => { assert.match(scheduledCli.stdout, /\*\*주요 일정\*\*/); assert.match(scheduledCli.stdout, /2026-10-28 \(US\) FOMC 금리 결정/); });
379
+ const jsonCli = await run(process.execPath, [cliPath, '--market', 'both', '--replay', join(scheduleOut, 'evidence.mjson'), '--json'], { cwd: dualRoot });
380
+ check(() => { const parsed = JSON.parse(jsonCli.stdout); assert.ok(Array.isArray(parsed.markets)); });
381
+ const altered = structuredClone(dual);
382
+ altered.ust.sources[0].raw = altered.ust.sources[0].raw.replace('4.10', '4.11');
383
+ check(() => assert.throws(() => analyzeDual(altered), /checksum/));
384
+ const noCommon = structuredClone(dual);
385
+ noCommon.ust.sources[0].raw = ustFixture(['2026-09-12', '2026-09-19']);
386
+ noCommon.ust.sources[0].sha256 = sha256(noCommon.ust.sources[0].raw);
387
+ noCommon.ust.asOf = '2026-09-19';
388
+ noCommon.ktb.asOf = '2026-09-19';
389
+ const gap = analyzeDual(noCommon);
390
+ check(() => assert.equal(gap.cross.current.gapBp, null));
391
+ const partialCurveRows = [...obs, { date: '2026-09-19', tenor: 10, value: 3.21 }], partialCurve = packetFor(partialCurveRows);
392
+ partialCurve.asOf = '2026-09-19';
393
+ const ustNewDate = structuredClone(us);
394
+ ustNewDate.asOf = '2026-09-19';
395
+ ustNewDate.sources[0].raw = ustFixture(['2026-09-11', '2026-09-18', '2026-09-19']);
396
+ ustNewDate.sources[0].sha256 = sha256(ustNewDate.sources[0].raw);
397
+ const aligned = analyzeDual({ version: 1, engine: DUAL_ENGINE, ktb: partialCurve, ust: ustNewDate });
398
+ check(() => { assert.equal(aligned.ktb.current.actual, '2026-09-18'); assert.equal(aligned.cross.current.actual, '2026-09-18'); assert.equal(aligned.cross.current.gapBp, 86); });
399
+ await writeFile(join(first, 'brief.md'), (await readFile(join(first, 'brief.md'), 'utf8') + 'tampered'));
400
+ await assert.rejects(() => validateDualArtifacts(first), /mismatch/);
401
+ checks++;
402
+ }
403
+ finally {
404
+ await rm(dualRoot, { recursive: true, force: true });
405
+ }
406
+ console.log(`PASS: ${checks} deterministic pipeline checks`);
@@ -0,0 +1,136 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { rangeWindows, request } from './collect.mjs';
5
+ import { TENORS, bp, date, days, normalize, sha256, shift } from './core.mjs';
6
+ function validMaxLag(maxLag) { if (!Number.isInteger(maxLag) || maxLag < 0 || maxLag > 14)
7
+ throw Error('maxLag must be 0..14 calendar days'); }
8
+ function endOfMonth(value) {
9
+ if (!/^\d{4}-\d{2}$/.test(value))
10
+ throw Error(`Invalid month: ${value}`);
11
+ const first = date(`${value}-01`), [year, month] = first.split('-').map(Number);
12
+ return shift(`${month === 12 ? year + 1 : year}-${String(month === 12 ? 1 : month + 1).padStart(2, '0')}-01`, -1);
13
+ }
14
+ export function monthComparisonDates(fromMonth, toMonth, today) {
15
+ date(today);
16
+ endOfMonth(fromMonth);
17
+ const toEnd = endOfMonth(toMonth);
18
+ if (fromMonth >= toMonth)
19
+ throw Error('--from-month must be earlier than --to-month');
20
+ const currentMonth = today.slice(0, 7);
21
+ if (toMonth > currentMonth)
22
+ throw Error('--to-month cannot be in the future');
23
+ return { base: endOfMonth(fromMonth), asOf: toMonth === currentMonth ? today : toEnd };
24
+ }
25
+ export function eventQueryWindows(eventDate, today, maxLag) {
26
+ date(eventDate);
27
+ date(today);
28
+ validMaxLag(maxLag);
29
+ if (eventDate > today)
30
+ throw Error('Event date cannot be in the future');
31
+ const beforeTo = shift(eventDate, -1), afterTo = shift(eventDate, maxLag) < today ? shift(eventDate, maxLag) : today;
32
+ return [...rangeWindows(shift(beforeTo, -maxLag), beforeTo), ...rangeWindows(eventDate, afterTo)];
33
+ }
34
+ export function previousMonthEnd(asOf) { const [y, m] = date(asOf).split('-').map(Number); return shift(`${y}-${String(m).padStart(2, '0')}-01`, -1); }
35
+ export function previousQuarterEnd(asOf) { const [y, m] = date(asOf).split('-').map(Number), quarterStart = Math.floor((m - 1) / 3) * 3 + 1; return shift(`${y}-${String(quarterStart).padStart(2, '0')}-01`, -1); }
36
+ export function previousYearEnd(asOf) { const y = Number(date(asOf).slice(0, 4)); return `${y - 1}-12-31`; }
37
+ export function commonSnapshots(input) {
38
+ const rows = normalize(input), dates = [...new Set(rows.map(r => r.date))].sort();
39
+ return dates.filter(d => TENORS.every(t => rows.some(r => r.date === d && r.tenor === t))).map(actual => ({ requested: actual, actual, lag: 0, values: Object.fromEntries(TENORS.map(t => [`${t}Y`, rows.find(r => r.date === actual && r.tenor === t).value])), reason: null }));
40
+ }
41
+ export function onOrBefore(input, requested, maxLag) {
42
+ date(requested);
43
+ validMaxLag(maxLag);
44
+ const found = commonSnapshots(input).filter(s => s.actual <= requested).at(-1) ?? null, lag = found?.actual ? days(requested, found.actual) : null;
45
+ const reason = !found ? '여섯 만기의 공통 관측일 없음' : lag > maxLag ? `공통 관측일이 ${lag}일 전: 허용 ${maxLag}일 초과` : null;
46
+ return { requested, actual: found?.actual ?? null, lag, values: reason ? null : found.values, reason };
47
+ }
48
+ export function onOrAfter(input, requested, maxLag) {
49
+ date(requested);
50
+ validMaxLag(maxLag);
51
+ const found = commonSnapshots(input).find(s => s.actual >= requested) ?? null, lag = found?.actual ? days(found.actual, requested) : null;
52
+ const reason = !found ? '여섯 만기의 공통 관측일 없음' : lag > maxLag ? `공통 관측일이 ${lag}일 후: 허용 ${maxLag}일 초과` : null;
53
+ return { requested, actual: found?.actual ?? null, lag, values: reason ? null : found.values, reason };
54
+ }
55
+ function changes(current, base) { return TENORS.map(t => ({ tenor: `${t}Y`, base: base.values?.[`${t}Y`] ?? null, current: current.values?.[`${t}Y`] ?? null, changeBp: base.values && current.values ? bp(current.values[`${t}Y`], base.values[`${t}Y`]) : null })); }
56
+ function spread(snapshot, long, short) { return snapshot.values ? bp(snapshot.values[`${long}Y`], snapshot.values[`${short}Y`]) : null; }
57
+ export function dashboard(asOf, input, maxLag, policyRate) {
58
+ const snapshots = commonSnapshots(input), current = onOrBefore(input, asOf, maxLag);
59
+ const previousActual = current.actual ? snapshots.filter(s => s.actual < current.actual).at(-1) ?? null : null;
60
+ const previousSession = previousActual ? { ...previousActual, requested: `${current.actual} 직전 관측일` } : { requested: '직전 관측일', actual: null, lag: null, values: null, reason: '직전 공통 관측일 없음' };
61
+ const weekAgo = onOrBefore(input, shift(asOf, -7), maxLag), monthEnd = onOrBefore(input, previousMonthEnd(asOf), maxLag), notes = [current.reason, previousSession.reason, weekAgo.reason, monthEnd.reason].filter((x) => !!x);
62
+ const current3 = current.values?.['3Y'] ?? null, policyGap3YBp = current3 !== null && policyRate ? bp(current3, policyRate.value) : null;
63
+ if (!policyRate)
64
+ notes.push('기준금리 확보 실패: 정책금리 갭 제외');
65
+ else if (policyRate.cacheWriteError)
66
+ notes.push(`기준금리 원자료는 검증했으나 로컬 캐시 저장 실패 (${policyRate.cacheWriteError})`);
67
+ const ready = !!current.values && !!previousSession.values && !!weekAgo.values && !!monthEnd.values;
68
+ return { result: ready ? 'READY' : current.values ? 'INCOMPLETE' : 'BLOCKED', mode: 'dashboard', asOf, current, references: { previousSession, weekAgo, previousMonthEnd: monthEnd }, changes: { previousSession: changes(current, previousSession), weekAgo: changes(current, weekAgo), previousMonthEnd: changes(current, monthEnd) }, spreads: { '5Y-3Y': spread(current, 5, 3), '10Y-5Y': spread(current, 10, 5), '10Y-3Y': spread(current, 10, 3), '30Y-10Y': spread(current, 30, 10) }, policyRate, policyGap3YBp, notes };
69
+ }
70
+ export function trend(input, sessions) {
71
+ if (![5, 20].includes(sessions))
72
+ throw Error('lookback sessions must be 5 or 20');
73
+ const all = commonSnapshots(input), selected = all.slice(-sessions), notes = [];
74
+ if (selected.length < sessions)
75
+ notes.push(`요청 ${sessions}거래일 중 ${selected.length}개 공통 관측일만 확보`);
76
+ const stats = selected.length < 2 ? [] : TENORS.map(t => { const key = `${t}Y`, values = selected.map(s => s.values[key]), moves = selected.slice(1).map((s, i) => ({ date: s.actual, move: bp(s.values[key], selected[i].values[key]) })), largest = moves.reduce((a, b) => Math.abs(b.move) > Math.abs(a.move) ? b : a); return { tenor: key, start: values[0], end: values.at(-1), netChangeBp: bp(values.at(-1), values[0]), high: Math.max(...values), low: Math.min(...values), largestDailyMoveBp: largest.move, largestDailyMoveDate: largest.date }; });
77
+ return { result: selected.length >= sessions ? 'READY' : selected.length ? 'INCOMPLETE' : 'BLOCKED', mode: 'trend', requestedSessions: sessions, actualSessions: selected.length, from: selected[0]?.actual ?? null, to: selected.at(-1)?.actual ?? null, sessions: selected, stats, notes };
78
+ }
79
+ function parsePolicy(raw, from, to) {
80
+ const doc = JSON.parse(raw);
81
+ if (doc.RESULT)
82
+ throw Error(`ECOS policy ${doc.RESULT.CODE ?? 'unknown'}`);
83
+ const block = doc.StatisticSearch, rows = block?.row;
84
+ if (!block || !Array.isArray(rows) || block.list_total_count !== rows.length || rows.length > 10)
85
+ throw Error('Incomplete policy-rate response');
86
+ const seen = new Set(), valid = rows.map(r => { if (r.STAT_CODE !== '722Y001' || r.STAT_NAME !== '1.3.1. 한국은행 기준금리 및 여수신금리' || r.ITEM_CODE1 !== '0101000' || r.ITEM_NAME1 !== '한국은행 기준금리' || r.UNIT_NAME !== '연%' || !/^\d{8}$/.test(r.TIME ?? '') || !/^\d+(?:\.\d+)?$/.test(r.DATA_VALUE ?? ''))
87
+ throw Error('Policy-rate metadata mismatch'); const d = `${r.TIME.slice(0, 4)}-${r.TIME.slice(4, 6)}-${r.TIME.slice(6, 8)}`, value = Number(r.DATA_VALUE); date(d); if (seen.has(d))
88
+ throw Error('Duplicate policy-rate date'); seen.add(d); if (d < from || d > to || !Number.isFinite(value) || value < 0 || value > 30)
89
+ throw Error('Invalid policy-rate value/range'); return { date: d, value }; }).sort((a, b) => a.date.localeCompare(b.date));
90
+ if (!valid.length)
91
+ throw Error('No policy-rate observation');
92
+ return valid.at(-1);
93
+ }
94
+ export async function collectPolicyRate(asOf, cacheDir, useCache = true, get = fetch, now = new Date(), maxAgeHours = 168) {
95
+ date(asOf);
96
+ if (typeof cacheDir !== 'string' || !cacheDir.trim())
97
+ throw Error('cacheDir must be a non-empty path');
98
+ if (!Number.isFinite(now.getTime()))
99
+ throw Error('Invalid policy-rate clock');
100
+ if (!Number.isFinite(maxAgeHours) || maxAgeHours < 0)
101
+ throw Error('maxAgeHours must be non-negative');
102
+ const from = shift(asOf, -7), to = asOf, retrievedAt = now.toISOString(), file = join(cacheDir, `policy-${from}-${to}.mjson`);
103
+ if (useCache)
104
+ await mkdir(cacheDir, { recursive: true });
105
+ let liveError = '';
106
+ try {
107
+ const key = process.env.KSKILL_BOK_ECOS_API_KEY || 'sample', url = `https://ecos.bok.or.kr/api/StatisticSearch/${encodeURIComponent(key)}/json/kr/1/10/722Y001/D/${from.replaceAll('-', '')}/${to.replaceAll('-', '')}/0101000`, result = await request(url, get), point = parsePolicy(result.text, from, to), digest = sha256(result.text), data = { version: 1, storedAt: retrievedAt, from, to, raw: result.text, rawSha256: digest }, temp = `${file}.${randomUUID()}.tmp`;
108
+ let cacheWriteError;
109
+ if (useCache)
110
+ try {
111
+ await writeFile(temp, JSON.stringify({ data, digest: sha256(JSON.stringify(data)) }));
112
+ await rename(temp, file);
113
+ }
114
+ catch (e) {
115
+ cacheWriteError = e instanceof Error ? e.message : String(e);
116
+ await rm(temp, { force: true });
117
+ }
118
+ return { ...point, unit: '연%', status: 'live', retrievedAt, sourceUrl: 'https://ecos.bok.or.kr/', statCode: '722Y001', itemCode: '0101000', rawSha256: digest, ...(cacheWriteError ? { cacheWriteError } : {}) };
119
+ }
120
+ catch (e) {
121
+ liveError = e instanceof Error ? e.message : String(e);
122
+ }
123
+ if (useCache)
124
+ try {
125
+ const envelope = JSON.parse(await readFile(file, 'utf8')), data = envelope.data, age = (now.getTime() - Date.parse(data.storedAt)) / 3600000;
126
+ if (envelope.digest !== sha256(JSON.stringify(data)) || data.version !== 1 || data.from !== from || data.to !== to || data.rawSha256 !== sha256(data.raw) || !Number.isFinite(age) || age < 0 || age > maxAgeHours)
127
+ throw Error('Invalid policy cache');
128
+ const point = parsePolicy(data.raw, from, to);
129
+ return { ...point, unit: '연%', status: 'cache', retrievedAt: data.storedAt, sourceUrl: 'https://ecos.bok.or.kr/', statCode: '722Y001', itemCode: '0101000', rawSha256: data.rawSha256, cacheAgeHours: Math.round(age * 100) / 100 };
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ void liveError;
135
+ return null;
136
+ }