dsh-plugin-show-me-data 0.1.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 (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. package/src/sources/worldbank.js +170 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * The eight "worth paying attention to" rules (docs/01 §4, docs/07 T5.1).
3
+ *
4
+ * Every rule is a pure function over already-computed metrics: no IO, no clock,
5
+ * no catalog mutation. A rule returns 'Hit[]', each carrying structured
6
+ * 'evidence' so the UI and the AI layer can quote *why* something was flagged
7
+ * instead of restating the number.
8
+ *
9
+ * Thresholds live in {@link RULE_CONFIG} at the top of the file so tuning them is
10
+ * a reviewable data change, and 'test/core/insight-rules.test.js' pins each rule
11
+ * with a hit, a near-miss and an insufficient-data case.
12
+ *
13
+ * @module core/insight/rules
14
+ */
15
+ import { daysBetween } from '../time/range.js'
16
+
17
+ /** Days within which a release still counts as "fresh", per frequency. */
18
+ export const FRESH_WINDOW_DAYS = { daily: 3, weekly: 10, monthly: 10, quarterly: 45, annual: 400 }
19
+
20
+ /** Days after which an expected update is considered overdue, per frequency. */
21
+ export const STALE_WINDOW_DAYS = { daily: 5, weekly: 21, monthly: 40, quarterly: 100, annual: 400 }
22
+
23
+ /** All tunable thresholds and their weights, in one reviewable place. */
24
+ export const RULE_CONFIG = {
25
+ 'fresh-release': { weight: 1.0, score: 30 },
26
+ 'surprise-sigma': { weight: 1.6, score: 55, minZ: 1.5, minSamples: 6 },
27
+ extreme: { weight: 1.4, score: 45, percentileHigh: 0.95, percentileLow: 0.05, lookbackDays: 1095, minSamples: 12 },
28
+ 'trend-break': { weight: 1.3, score: 40, minRelativeSlope: 0.002 },
29
+ 'threshold-cross': { weight: 1.5, score: 50 },
30
+ 'spread-signal': { weight: 1.5, score: 50, fastConvergenceSigma: 2 },
31
+ divergence: { weight: 1.2, score: 35, flatTolerance: 0.1 },
32
+ 'stale-gap': { weight: 1.1, score: 25 },
33
+ }
34
+
35
+ /** Policy-rate and level thresholds that trigger 'threshold-cross'. */
36
+ export const THRESHOLDS = [
37
+ { indicatorId: 'us.cpi.yoy', value: 3, direction: 'above', label: { zh: 'CPI 同比上穿 3%', en: 'CPI YoY crosses above 3%' } },
38
+ { indicatorId: 'us.core.cpi.yoy', value: 3, direction: 'above', label: { zh: '核心 CPI 同比上穿 3%', en: 'Core CPI YoY crosses above 3%' } },
39
+ { indicatorId: 'us.unrate', value: 4.5, direction: 'above', label: { zh: '失业率上穿 4.5%', en: 'Unemployment crosses above 4.5%' } },
40
+ { indicatorId: 'us.fedfunds.daily', value: 5, direction: 'any', label: { zh: '政策利率跨越 5%', en: 'Policy rate crosses 5%' } },
41
+ { indicatorId: 'us.fedfunds.daily', value: 4, direction: 'any', label: { zh: '政策利率跨越 4%', en: 'Policy rate crosses 4%' } },
42
+ { indicatorId: 'us.fedfunds.daily', value: 3, direction: 'any', label: { zh: '政策利率跨越 3%', en: 'Policy rate crosses 3%' } },
43
+ { indicatorId: 'us.fedfunds.daily', value: 2, direction: 'any', label: { zh: '政策利率跨越 2%', en: 'Policy rate crosses 2%' } },
44
+ { indicatorId: 'cn.pmi.mfg', value: 50, direction: 'any', label: { zh: '制造业 PMI 跨越荣枯线 50', en: 'Manufacturing PMI crosses 50' } },
45
+ ]
46
+
47
+ /** Indicator pairs whose contradiction is itself informative. */
48
+ export const DIVERGENCE_PAIRS = [
49
+ {
50
+ left: 'us.payrolls.change',
51
+ right: 'us.unrate',
52
+ // Payroll growth rising while unemployment does not move is the classic
53
+ // "two labour reports disagree" signal.
54
+ expect: 'same-direction',
55
+ label: { zh: '非农与失业率背离', en: 'Payrolls and unemployment diverge' },
56
+ note: { zh: '就业增长与失业率未同向变动,两个劳动力调查口径出现分歧。' },
57
+ },
58
+ ]
59
+
60
+ /**
61
+ * @typedef {Object} RuleContext
62
+ * @property {string} today - reference date ('Clock.today()').
63
+ * @property {Record<string, { points: Array<{ t: string, v: number }>, stats?: object }>} seriesById - resolved series,
64
+ * including derived ones (spreads/ratios) computed by the caller.
65
+ * @property {Record<string, object>} catalogById - indicator definitions.
66
+ */
67
+
68
+ /**
69
+ * Build one hit.
70
+ *
71
+ * @param {string} ruleId - rule id.
72
+ * @param {string} indicatorId - indicator being flagged.
73
+ * @param {{ zh: string, en?: string }} reason - human reason.
74
+ * @param {object} evidence - structured evidence.
75
+ * @returns {object} hit.
76
+ */
77
+ function hit(ruleId, indicatorId, reason, evidence) {
78
+ return {
79
+ ruleId,
80
+ indicatorId,
81
+ score: RULE_CONFIG[ruleId].score,
82
+ reason,
83
+ evidence,
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Latest observation and its previous one.
89
+ *
90
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
91
+ * @returns {{ latest: object, prev: object|undefined }} last two points.
92
+ */
93
+ function lastTwo(points) {
94
+ return { latest: points[points.length - 1], prev: points[points.length - 2] }
95
+ }
96
+
97
+ /**
98
+ * Rule 1 — a release landed inside the freshness window for its frequency.
99
+ *
100
+ * @param {RuleContext} context - rule context.
101
+ * @returns {object[]} hits.
102
+ */
103
+ export function freshRelease({ seriesById, catalogById, today }) {
104
+ const hits = []
105
+ for (const [id, series] of Object.entries(seriesById)) {
106
+ const def = catalogById[id]
107
+ if (def === undefined || !series.points?.length) continue
108
+ const window = FRESH_WINDOW_DAYS[def.freq]
109
+ if (window === undefined) continue
110
+ const age = daysBetween(series.points[series.points.length - 1].t, today)
111
+ if (age > window || age < 0) continue
112
+ hits.push(
113
+ hit('fresh-release', id, { zh: `新发布:${def.label.zh}(观测日 ${series.points[series.points.length - 1].t})` }, {
114
+ latestAt: series.points[series.points.length - 1].t,
115
+ ageDays: age,
116
+ windowDays: window,
117
+ freq: def.freq,
118
+ value: series.points[series.points.length - 1].v,
119
+ }),
120
+ )
121
+ }
122
+ return hits
123
+ }
124
+
125
+ /**
126
+ * Rule 2 — the latest change is a statistical surprise.
127
+ *
128
+ * @param {RuleContext} context - rule context.
129
+ * @returns {object[]} hits.
130
+ */
131
+ export function surpriseSigma({ seriesById, catalogById }) {
132
+ const { minZ, minSamples } = RULE_CONFIG['surprise-sigma']
133
+ const hits = []
134
+ for (const [id, series] of Object.entries(seriesById)) {
135
+ const def = catalogById[id]
136
+ if (def === undefined || !series.points?.length) continue
137
+ const stats = series.stats ?? {}
138
+ const z = stats.zScoreLatestChange
139
+ if (typeof z !== 'number' || !Number.isFinite(z)) continue // insufficient sample → no fabricated σ
140
+ if (series.points.length < minSamples) continue
141
+ if (Math.abs(z) < minZ) continue
142
+ hits.push(
143
+ hit('surprise-sigma', id, { zh: `${def.label.zh} 本期变化为 ${z.toFixed(1)}σ,超出常态区间` }, {
144
+ z,
145
+ threshold: minZ,
146
+ latest: stats.latest,
147
+ changeAbs: stats.changeAbs,
148
+ samples: series.points.length,
149
+ }),
150
+ )
151
+ }
152
+ return hits
153
+ }
154
+
155
+ /**
156
+ * Rule 3 — a multi-year high/low or an extreme percentile.
157
+ *
158
+ * @param {RuleContext} context - rule context.
159
+ * @returns {object[]} hits.
160
+ */
161
+ export function extreme({ seriesById, catalogById, today }) {
162
+ const { percentileHigh, percentileLow, lookbackDays, minSamples } = RULE_CONFIG.extreme
163
+ const hits = []
164
+ for (const [id, series] of Object.entries(seriesById)) {
165
+ const def = catalogById[id]
166
+ if (def === undefined || !series.points || series.points.length < minSamples) continue
167
+ const points = series.points.filter((p) => daysBetween(p.t, today) <= lookbackDays)
168
+ if (points.length < minSamples) continue
169
+ const values = points.map((p) => p.v)
170
+ const latest = points[points.length - 1]
171
+ const isHigh = latest.v >= Math.max(...values)
172
+ const isLow = latest.v <= Math.min(...values)
173
+ const percentile = series.stats?.percentile
174
+ const extremePercentile = typeof percentile === 'number' && (percentile >= percentileHigh || percentile <= percentileLow)
175
+ if (!isHigh && !isLow && !extremePercentile) continue
176
+ hits.push(
177
+ hit('extreme', id, {
178
+ zh: `${def.label.zh} 处于近 3 年${isHigh ? '新高' : isLow ? '新低' : '极值分位'}(分位 ${typeof percentile === 'number' ? percentile.toFixed(2) : 'n/a'})`,
179
+ }, {
180
+ latest: latest.v,
181
+ min: Math.min(...values),
182
+ max: Math.max(...values),
183
+ percentile: percentile ?? null,
184
+ window: `${points[0].t}..${latest.t}`,
185
+ dates: [latest.t],
186
+ }),
187
+ )
188
+ }
189
+ return hits
190
+ }
191
+
192
+ /**
193
+ * Rule 4 — the trend's slope changed sign by a meaningful amount.
194
+ *
195
+ * @param {RuleContext} context - rule context.
196
+ * @returns {object[]} hits.
197
+ */
198
+ export function trendBreak({ seriesById, catalogById }) {
199
+ const { minRelativeSlope } = RULE_CONFIG['trend-break']
200
+ const hits = []
201
+ for (const [id, series] of Object.entries(seriesById)) {
202
+ const def = catalogById[id]
203
+ if (def === undefined || !series.points || series.points.length < 8) continue
204
+ const points = series.points
205
+ const half = Math.floor(points.length / 2)
206
+ const slopeOf = (slice) => {
207
+ const n = slice.length
208
+ if (n < 3) return undefined
209
+ const meanX = (n - 1) / 2
210
+ const meanY = slice.reduce((acc, p) => acc + p.v, 0) / n
211
+ let num = 0
212
+ let den = 0
213
+ for (let i = 0; i < n; i += 1) {
214
+ num += (i - meanX) * (slice[i].v - meanY)
215
+ den += (i - meanX) ** 2
216
+ }
217
+ return den === 0 ? undefined : num / den
218
+ }
219
+ const early = slopeOf(points.slice(0, half))
220
+ const late = slopeOf(points.slice(half))
221
+ if (early === undefined || late === undefined) continue
222
+ if (Math.sign(early) === Math.sign(late) || early === 0 || late === 0) continue
223
+ const mean = points.reduce((acc, p) => acc + p.v, 0) / points.length
224
+ if (mean === 0) continue
225
+ const relative = Math.abs(late - early) / Math.abs(mean)
226
+ if (relative <= minRelativeSlope) continue
227
+ hits.push(
228
+ hit('trend-break', id, {
229
+ zh: `${def.label.zh} 斜率由${early > 0 ? '上行' : '下行'}转为${late > 0 ? '上行' : '下行'},趋势出现拐点`,
230
+ }, {
231
+ earlySlope: early,
232
+ lateSlope: late,
233
+ relativeChange: relative,
234
+ threshold: minRelativeSlope,
235
+ }),
236
+ )
237
+ }
238
+ return hits
239
+ }
240
+
241
+ /**
242
+ * Rule 5 — a configured level was crossed between the last two observations.
243
+ *
244
+ * @param {RuleContext} context - rule context.
245
+ * @returns {object[]} hits.
246
+ */
247
+ export function thresholdCross({ seriesById, catalogById }) {
248
+ const hits = []
249
+ for (const threshold of THRESHOLDS) {
250
+ const series = seriesById[threshold.indicatorId]
251
+ const def = catalogById[threshold.indicatorId]
252
+ if (series === undefined || def === undefined || !series.points || series.points.length < 2) continue
253
+ const { latest, prev } = lastTwo(series.points)
254
+ const crossed =
255
+ threshold.direction === 'above'
256
+ ? prev.v <= threshold.value && latest.v > threshold.value
257
+ : (prev.v - threshold.value) * (latest.v - threshold.value) < 0
258
+ if (!crossed) continue
259
+ hits.push(
260
+ hit('threshold-cross', threshold.indicatorId, { zh: `${threshold.label.zh}(${prev.v} → ${latest.v})` }, {
261
+ value: latest.v,
262
+ previous: prev.v,
263
+ threshold: threshold.value,
264
+ direction: threshold.direction,
265
+ dates: [prev.t, latest.t],
266
+ }),
267
+ )
268
+ }
269
+ return hits
270
+ }
271
+
272
+ /**
273
+ * Rule 6 — a spread flipped sign (an inversion resolving) or converged fast.
274
+ *
275
+ * @param {RuleContext} context - rule context.
276
+ * @returns {object[]} hits.
277
+ */
278
+ export function spreadSignal({ seriesById, catalogById }) {
279
+ const { fastConvergenceSigma } = RULE_CONFIG['spread-signal']
280
+ const hits = []
281
+ for (const [id, series] of Object.entries(seriesById)) {
282
+ const def = catalogById[id]
283
+ if (def === undefined || !series.points || series.points.length < 2) continue
284
+ const isSpread = def.unit === 'pp' || def.derive?.op === 'spread' || id.includes('spread')
285
+ if (!isSpread) continue
286
+ const { latest, prev } = lastTwo(series.points)
287
+ if (prev.v < 0 && latest.v >= 0) {
288
+ hits.push(
289
+ hit('spread-signal', id, { zh: `${def.label.zh} 由负转正,倒挂解除(${prev.v} → ${latest.v})` }, {
290
+ value: latest.v,
291
+ previous: prev.v,
292
+ dates: [prev.t, latest.t],
293
+ kind: 'inversion-cleared',
294
+ }),
295
+ )
296
+ continue
297
+ }
298
+ const stats = series.stats ?? {}
299
+ const sigma = stats.stdDev
300
+ if (typeof sigma === 'number' && sigma > 0 && Math.abs(latest.v - prev.v) > fastConvergenceSigma * sigma) {
301
+ hits.push(
302
+ hit('spread-signal', id, { zh: `${def.label.zh} 快速收敛:单期变化超过 ${fastConvergenceSigma}σ` }, {
303
+ value: latest.v,
304
+ previous: prev.v,
305
+ changeAbs: latest.v - prev.v,
306
+ sigma,
307
+ dates: [prev.t, latest.t],
308
+ kind: 'fast-convergence',
309
+ }),
310
+ )
311
+ }
312
+ }
313
+ return hits
314
+ }
315
+
316
+ /**
317
+ * Rule 7 — two configured indicators contradict each other.
318
+ *
319
+ * @param {RuleContext} context - rule context.
320
+ * @returns {object[]} hits.
321
+ */
322
+ export function divergence({ seriesById, catalogById }) {
323
+ const { flatTolerance } = RULE_CONFIG.divergence
324
+ const hits = []
325
+ for (const pair of DIVERGENCE_PAIRS) {
326
+ const left = seriesById[pair.left]
327
+ const right = seriesById[pair.right]
328
+ const leftDef = catalogById[pair.left]
329
+ const rightDef = catalogById[pair.right]
330
+ if (!left?.points?.length || !right?.points?.length || !leftDef || !rightDef) continue
331
+ const leftStats = left.stats ?? {}
332
+ const rightStats = right.stats ?? {}
333
+ if (leftStats.changeAbs === undefined || rightStats.changeAbs === undefined) continue
334
+ const leftTolerance = flatTolerance * (leftDef.unit === '万人' ? 10 : 1)
335
+ const leftMoving = Math.abs(leftStats.changeAbs) > leftTolerance
336
+ const rightFlat = Math.abs(rightStats.changeAbs) <= flatTolerance
337
+ const contradicts =
338
+ pair.expect === 'same-direction' ? leftMoving && rightFlat : leftMoving === rightFlat
339
+ if (!contradicts) continue
340
+ hits.push(
341
+ hit('divergence', pair.left, { zh: pair.label.zh, en: pair.label.en }, {
342
+ pair: [pair.left, pair.right],
343
+ leftChange: leftStats.changeAbs,
344
+ rightChange: rightStats.changeAbs,
345
+ tolerance: flatTolerance,
346
+ note: pair.note.zh,
347
+ dates: [leftStats.latestAt, rightStats.latestAt],
348
+ }),
349
+ )
350
+ }
351
+ return hits
352
+ }
353
+
354
+ /**
355
+ * Rule 8 — an indicator that should have updated has not.
356
+ *
357
+ * @param {RuleContext} context - rule context.
358
+ * @returns {object[]} hits.
359
+ */
360
+ export function staleGap({ seriesById, catalogById, today }) {
361
+ const hits = []
362
+ for (const [id, series] of Object.entries(seriesById)) {
363
+ const def = catalogById[id]
364
+ if (def === undefined || !series.points?.length) continue
365
+ const window = STALE_WINDOW_DAYS[def.freq]
366
+ if (window === undefined) continue
367
+ const latestAt = series.points[series.points.length - 1].t
368
+ const age = daysBetween(latestAt, today)
369
+ if (age <= window) continue
370
+ hits.push(
371
+ hit('stale-gap', id, { zh: `${def.label.zh} 应更新而未更新(最后观测 ${latestAt},已 ${age} 天)` }, {
372
+ latestAt,
373
+ ageDays: age,
374
+ windowDays: window,
375
+ freq: def.freq,
376
+ expectedEvery: def.freq,
377
+ }),
378
+ )
379
+ }
380
+ return hits
381
+ }
382
+
383
+ /** Every rule, in evaluation order. */
384
+ export const RULES = [
385
+ { id: 'fresh-release', weight: RULE_CONFIG['fresh-release'].weight, run: freshRelease },
386
+ { id: 'surprise-sigma', weight: RULE_CONFIG['surprise-sigma'].weight, run: surpriseSigma },
387
+ { id: 'extreme', weight: RULE_CONFIG.extreme.weight, run: extreme },
388
+ { id: 'trend-break', weight: RULE_CONFIG['trend-break'].weight, run: trendBreak },
389
+ { id: 'threshold-cross', weight: RULE_CONFIG['threshold-cross'].weight, run: thresholdCross },
390
+ { id: 'spread-signal', weight: RULE_CONFIG['spread-signal'].weight, run: spreadSignal },
391
+ { id: 'divergence', weight: RULE_CONFIG.divergence.weight, run: divergence },
392
+ { id: 'stale-gap', weight: RULE_CONFIG['stale-gap'].weight, run: staleGap },
393
+ ]
394
+
395
+ /**
396
+ * Run every rule.
397
+ *
398
+ * A rule that throws is contained (and reported) rather than taking the panel
399
+ * down with it: a data panel must never render nothing because one heuristic
400
+ * met an unexpected shape.
401
+ *
402
+ * @param {RuleContext} context - rule context.
403
+ * @returns {{ hits: object[], errors: Array<{ ruleId: string, message: string }> }} hits and contained failures.
404
+ */
405
+ export function runRules(context) {
406
+ const hits = []
407
+ const errors = []
408
+ for (const rule of RULES) {
409
+ try {
410
+ const produced = rule.run(context) ?? []
411
+ for (const entry of produced) hits.push({ ...entry, weight: rule.weight })
412
+ } catch (error) {
413
+ errors.push({ ruleId: rule.id, message: error?.message ?? String(error) })
414
+ }
415
+ }
416
+ return { hits, errors }
417
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Derived series — spreads, ratios and averages built from catalog indicators
3
+ * (docs/03 §3.5). All operations join on the **date**, never on position:
4
+ * two series with different publication calendars must not be paired by index.
5
+ *
6
+ * @module core/stats/derive
7
+ */
8
+ import { sortDedupe } from './series.js'
9
+
10
+ /** Minimum number of overlapping observations before a derived series is usable. */
11
+ export const MIN_OVERLAP = 3
12
+
13
+ /**
14
+ * @typedef {Object} DeriveResult
15
+ * @property {Array<{ t: string, v: number }>} points - derived points.
16
+ * @property {number} overlap - number of joined observations.
17
+ * @property {string} [reason] - why the result is empty, when it is.
18
+ */
19
+
20
+ /**
21
+ * Join two series on their exact dates.
22
+ *
23
+ * @param {Array<{ t: string, v: number }>} a - left series.
24
+ * @param {Array<{ t: string, v: number }>} b - right series.
25
+ * @returns {Array<{ t: string, a: number, b: number }>} joined rows, ascending.
26
+ */
27
+ export function innerJoin(a, b) {
28
+ const left = new Map(sortDedupe(a).map((p) => [p.t, p.v]))
29
+ const right = new Map(sortDedupe(b).map((p) => [p.t, p.v]))
30
+ const dates = [...left.keys()].filter((t) => right.has(t)).sort()
31
+ return dates.map((t) => ({ t, a: left.get(t), b: right.get(t) }))
32
+ }
33
+
34
+ /**
35
+ * Pointwise transformation over an exact-date join.
36
+ *
37
+ * @param {Array<{ t: string, v: number }>} a - left series.
38
+ * @param {Array<{ t: string, v: number }>} b - right series.
39
+ * @param {(a: number, b: number) => number|undefined} combine - pointwise op; 'undefined' drops the row.
40
+ * @returns {DeriveResult} derived points with the overlap count.
41
+ */
42
+ function pointwise(a, b, combine) {
43
+ const rows = innerJoin(a, b)
44
+ const points = []
45
+ for (const row of rows) {
46
+ const value = combine(row.a, row.b)
47
+ if (typeof value === 'number' && Number.isFinite(value)) points.push({ t: row.t, v: value })
48
+ }
49
+ if (rows.length < MIN_OVERLAP) {
50
+ return { points: [], overlap: rows.length, reason: 'insufficient-overlap' }
51
+ }
52
+ return { points, overlap: rows.length }
53
+ }
54
+
55
+ /**
56
+ * 'a − b' on shared dates — the spread of two rates or the real rate from a
57
+ * nominal rate and an inflation expectation.
58
+ *
59
+ * @param {Array<{ t: string, v: number }>} a - minuend series.
60
+ * @param {Array<{ t: string, v: number }>} b - subtrahend series.
61
+ * @returns {DeriveResult} spread series.
62
+ */
63
+ export function spread(a, b) {
64
+ return pointwise(a, b, (x, y) => x - y)
65
+ }
66
+
67
+ /**
68
+ * 'a / b' on shared dates. Rows whose denominator is zero are skipped rather
69
+ * than producing 'Infinity'.
70
+ *
71
+ * @param {Array<{ t: string, v: number }>} a - numerator series.
72
+ * @param {Array<{ t: string, v: number }>} b - denominator series.
73
+ * @returns {DeriveResult} ratio series.
74
+ */
75
+ export function ratio(a, b) {
76
+ return pointwise(a, b, (x, y) => (y === 0 ? undefined : x / y))
77
+ }
78
+
79
+ /**
80
+ * Mean of two or more series on dates every operand shares.
81
+ *
82
+ * @param {Array<Array<{ t: string, v: number }>>} series - operand series.
83
+ * @returns {DeriveResult} averaged series.
84
+ */
85
+ export function avgOf(series) {
86
+ if (!Array.isArray(series) || series.length === 0) {
87
+ return { points: [], overlap: 0, reason: 'insufficient-overlap' }
88
+ }
89
+ const maps = series.map((s) => new Map(sortDedupe(s).map((p) => [p.t, p.v])))
90
+ const dates = [...maps[0].keys()]
91
+ .filter((t) => maps.every((m) => m.has(t)))
92
+ .sort()
93
+ // Rule (docs/07 T1.3): a derived series is *empty* when the overlap is below
94
+ // the floor. That makes "the spread exists" a guarantee the UI and the AI
95
+ // layer can rely on, instead of a two-point spread masquerading as history.
96
+ if (dates.length < MIN_OVERLAP) {
97
+ return { points: [], overlap: dates.length, reason: 'insufficient-overlap' }
98
+ }
99
+ return {
100
+ points: dates.map((t) => ({
101
+ t,
102
+ v: maps.reduce((acc, m) => acc + m.get(t), 0) / maps.length,
103
+ })),
104
+ overlap: dates.length,
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Apply a catalog 'derive' directive (docs/03 §3.5).
110
+ *
111
+ * @param {{ op: 'spread'|'ratio'|'avg', operands: string[] }} directive - catalog directive.
112
+ * @param {Record<string, Array<{ t: string, v: number }>>} seriesById - resolved operand series.
113
+ * @returns {DeriveResult & { missing?: string[] }} derived series, plus operands that were unavailable.
114
+ */
115
+ export function applyDerive(directive, seriesById) {
116
+ const missing = directive.operands.filter((id) => !Array.isArray(seriesById[id]))
117
+ if (missing.length > 0) return { points: [], overlap: 0, reason: 'insufficient-overlap', missing }
118
+ const operands = directive.operands.map((id) => seriesById[id])
119
+ if (directive.op === 'spread') return spread(operands[0], operands[1])
120
+ if (directive.op === 'ratio') return ratio(operands[0], operands[1])
121
+ if (directive.op === 'avg') return avgOf(operands)
122
+ throw new RangeError(`unknown derive op: ${JSON.stringify(directive.op)}`)
123
+ }