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,465 @@
1
+ /**
2
+ * Series statistics — pure functions over '{ t, v }[]' (docs/03 §2).
3
+ *
4
+ * Discipline (docs/07 M1): missing values are skipped, never interpolated and
5
+ * never treated as zero; small samples return 'undefined' rather than a
6
+ * fabricated σ or percentile; every returned number is finite or absent.
7
+ *
8
+ * @module core/stats/series
9
+ */
10
+ import { daysBetween, monthsBetween } from '../time/range.js'
11
+
12
+ /** Markers every upstream uses for "no observation". */
13
+ const MISSING_MARKERS = new Set(['.', '', '-', 'null', 'none', 'n/a', 'na', 'nan', 'undefined'])
14
+
15
+ /** Minimum sample size before a sample standard deviation is meaningful. */
16
+ export const MIN_STDDEV_N = 6
17
+
18
+ /** Minimum sample size before a percentile is meaningful. */
19
+ export const MIN_PERCENTILE_N = 12
20
+
21
+ /**
22
+ * Parse an upstream scalar into a number, honouring every documented missing
23
+ * marker (docs/03 §2 rule 2, docs/04 §1).
24
+ *
25
+ * @param {unknown} raw - upstream value.
26
+ * @returns {number|undefined} finite number or 'undefined' when missing.
27
+ */
28
+ export function parseNumber(raw) {
29
+ if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined
30
+ if (typeof raw !== 'string') return undefined
31
+ const trimmed = raw.trim()
32
+ if (MISSING_MARKERS.has(trimmed.toLowerCase())) return undefined
33
+ const cleaned = trimmed.replace(/,/g, '')
34
+ const value = Number(cleaned)
35
+ return Number.isFinite(value) ? value : undefined
36
+ }
37
+
38
+ /**
39
+ * Round to a fixed number of decimals, collapsing float noise
40
+ * ('115.004000000000005' → '115.004').
41
+ *
42
+ * @param {number|undefined} value - candidate value.
43
+ * @param {number} [decimals] - decimal places; omitted means unchanged.
44
+ * @returns {number|undefined} rounded value or 'undefined'.
45
+ */
46
+ export function round(value, decimals) {
47
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
48
+ if (decimals === undefined || decimals === null) return value
49
+ const factor = 10 ** decimals
50
+ const scaled = Math.round((value + Number.EPSILON * Math.sign(value)) * factor) / factor
51
+ return Object.is(scaled, -0) ? 0 : scaled
52
+ }
53
+
54
+ /**
55
+ * Sort ascending by date, drop non-finite values, and keep the **last** entry
56
+ * for a repeated date (upstream revisions win, docs/03 §2 rule 1).
57
+ *
58
+ * 'v' is always the series' primary value. A price series may additionally carry
59
+ * an OHLC bar (`o`/`h`/`l`, with 'v' as the close), which the chart layer draws as
60
+ * a candle; those extras travel through every transform untouched, so a candle
61
+ * view never re-fetches or re-derives what the source already returned.
62
+ *
63
+ * @param {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} points - arbitrary points.
64
+ * @returns {Array<{ t: string, v: number, o?: number, h?: number, l?: number }>} normalized points.
65
+ */
66
+ export function sortDedupe(points) {
67
+ const byDate = new Map()
68
+ for (const point of points) {
69
+ if (point === null || typeof point !== 'object') continue
70
+ if (typeof point.t !== 'string' || !Number.isFinite(point.v)) continue
71
+ const normalized = { t: point.t, v: point.v }
72
+ const bar = ohlcOf(point)
73
+ byDate.set(point.t, bar === undefined ? normalized : { ...normalized, ...bar })
74
+ }
75
+ return [...byDate.values()].sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
76
+ }
77
+
78
+ /**
79
+ * The OHLC extras of one point, when the bar is complete and coherent.
80
+ *
81
+ * A bar is only worth drawing if open, high and low are all finite and the
82
+ * high/low really do bound them: anything else would render an impossible
83
+ * candle, so the extras are dropped and the point stays a plain observation
84
+ * (the chart draws it as part of the line instead).
85
+ *
86
+ * @param {{ v?: number, o?: unknown, h?: unknown, l?: unknown }} [point] - candidate point.
87
+ * @returns {{ o: number, h: number, l: number }|undefined} the bar, or 'undefined'.
88
+ */
89
+ export function ohlcOf(point) {
90
+ const { o, h, l } = point ?? {}
91
+ if (!Number.isFinite(o) || !Number.isFinite(h) || !Number.isFinite(l)) return undefined
92
+ const v = point.v
93
+ if (!Number.isFinite(v)) return undefined
94
+ if (h < Math.max(o, v) || l > Math.min(o, v)) return undefined
95
+ return { o, h, l }
96
+ }
97
+
98
+ /**
99
+ * Whether a series carries enough usable bars for a candlestick view.
100
+ *
101
+ * @param {Array<{ o?: number }>} [points] - points.
102
+ * @param {number} [minimum] - bars required before candles are offered.
103
+ * @returns {boolean} whether candles can be drawn.
104
+ */
105
+ export function hasOhlc(points, minimum = 2) {
106
+ return (points ?? []).filter((point) => ohlcOf(point) !== undefined).length >= minimum
107
+ }
108
+
109
+ /**
110
+ * Period-over-period change against the previous existing observation
111
+ * (docs/03 §2 rule 4).
112
+ *
113
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
114
+ * @returns {{ abs: number, pct: number }|undefined} change, or 'undefined' when undefined/zero-based.
115
+ */
116
+ export function mom(points) {
117
+ if (points.length < 2) return undefined
118
+ const latest = points[points.length - 1]
119
+ const previous = points[points.length - 2]
120
+ if (previous.v === 0) return undefined
121
+ return { abs: latest.v - previous.v, pct: ((latest.v - previous.v) / Math.abs(previous.v)) * 100 }
122
+ }
123
+
124
+ /**
125
+ * Year-over-year change with same-calendar-period alignment (docs/03 §2 rule 3).
126
+ *
127
+ * The base is the observation one year (12 months) earlier for monthly and
128
+ * coarser series, or one quarter earlier for series dated on quarter ends.
129
+ * A ±1 month drift is tolerated and reported so downstream code can flag an
130
+ * approximate comparison; anything further is treated as "no base".
131
+ *
132
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
133
+ * @param {{ toleranceMonths?: number }} [options] - alignment tolerance.
134
+ * @returns {{ value: number, base: number, baseAt: string, drift: number }|undefined} yoy result.
135
+ */
136
+ export function yoy(points, { toleranceMonths = 1 } = {}) {
137
+ if (points.length < 2) return undefined
138
+ const latest = points[points.length - 1]
139
+ // Quarterly series are dated on quarter ends (03-31, 06-30, 09-30, 12-31).
140
+ // Their annual base is four quarters back — which is the same month one year
141
+ // earlier, so the target is a full 12 months for every frequency.
142
+ const targetMonths = 12
143
+
144
+ let best
145
+ for (const candidate of points) {
146
+ if (candidate.t >= latest.t) continue
147
+ const distance = monthsBetween(candidate.t, latest.t)
148
+ const drift = distance - targetMonths
149
+ if (Math.abs(drift) > toleranceMonths) continue
150
+ if (best === undefined || Math.abs(drift) < Math.abs(best.drift)) best = { candidate, drift }
151
+ }
152
+ if (best === undefined) return undefined
153
+ const base = best.candidate.v
154
+ if (base === 0) return undefined
155
+ return {
156
+ value: ((latest.v - base) / Math.abs(base)) * 100,
157
+ base,
158
+ baseAt: best.candidate.t,
159
+ drift: best.drift,
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Moving average over 'window' observations. The first 'window - 1' outputs are
165
+ * 'undefined' — a partial mean would misrepresent the window (docs/07 T1.2).
166
+ *
167
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
168
+ * @param {number} window - window size (>= 1).
169
+ * @returns {Array<{ t: string, v: number|undefined }>} moving average series.
170
+ */
171
+ export function movingAverage(points, window) {
172
+ if (!Number.isInteger(window) || window < 1) {
173
+ throw new RangeError(`movingAverage: window must be a positive integer (got ${window})`)
174
+ }
175
+ return points.map((point, index) => {
176
+ if (index < window - 1) return { t: point.t, v: undefined }
177
+ let sum = 0
178
+ for (let i = index - window + 1; i <= index; i += 1) sum += points[i].v
179
+ return { t: point.t, v: sum / window }
180
+ })
181
+ }
182
+
183
+ /**
184
+ * Difference against the value 'window' observations back: 'v[i] - v[i-window]'.
185
+ *
186
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
187
+ * @param {number} [window] - lookback in observations (default 1).
188
+ * @returns {Array<{ t: string, v: number|undefined }>} differenced series.
189
+ */
190
+ export function diff(points, window = 1) {
191
+ if (!Number.isInteger(window) || window < 1) {
192
+ throw new RangeError(`diff: window must be a positive integer (got ${window})`)
193
+ }
194
+ return points.map((point, index) =>
195
+ index < window ? { t: point.t, v: undefined } : { t: point.t, v: point.v - points[index - window].v },
196
+ )
197
+ }
198
+
199
+ /**
200
+ * Sample standard deviation (n−1). Below {@link MIN_STDDEV_N} observations this
201
+ * is 'undefined': the panel must not present "2σ" computed from three points.
202
+ *
203
+ * @param {number[]} values - observations.
204
+ * @returns {number|undefined} standard deviation.
205
+ */
206
+ export function stdDev(values) {
207
+ if (values.length < MIN_STDDEV_N) return undefined
208
+ const mean = values.reduce((a, b) => a + b, 0) / values.length
209
+ const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / (values.length - 1)
210
+ return Math.sqrt(variance)
211
+ }
212
+
213
+ /**
214
+ * Linear-interpolated percentile. Below {@link MIN_PERCENTILE_N} observations
215
+ * this is 'undefined' (docs/07 T1.2).
216
+ *
217
+ * @param {number[]} values - observations.
218
+ * @param {number} p - quantile in '[0, 1]'.
219
+ * @returns {number|undefined} percentile value.
220
+ */
221
+ export function percentile(values, p) {
222
+ if (values.length < MIN_PERCENTILE_N) return undefined
223
+ if (!(p >= 0 && p <= 1)) throw new RangeError(`percentile: p must be in [0,1] (got ${p})`)
224
+ const sorted = [...values].sort((a, b) => a - b)
225
+ const position = (sorted.length - 1) * p
226
+ const lower = Math.floor(position)
227
+ const upper = Math.ceil(position)
228
+ if (lower === upper) return sorted[lower]
229
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower)
230
+ }
231
+
232
+ /**
233
+ * Least-squares slope against the observation index, in units per period.
234
+ *
235
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
236
+ * @returns {number|undefined} slope per observation.
237
+ */
238
+ export function slope(points) {
239
+ if (points.length < 2) return undefined
240
+ const n = points.length
241
+ const meanX = (n - 1) / 2
242
+ const meanY = points.reduce((acc, p) => acc + p.v, 0) / n
243
+ let numerator = 0
244
+ let denominator = 0
245
+ for (let i = 0; i < n; i += 1) {
246
+ numerator += (i - meanX) * (points[i].v - meanY)
247
+ denominator += (i - meanX) ** 2
248
+ }
249
+ if (denominator === 0) return undefined
250
+ return numerator / denominator
251
+ }
252
+
253
+ /**
254
+ * z-score of the latest change against the history of changes
255
+ * (docs/03 §2 rule 8). Zero variance yields 'undefined' rather than Infinity.
256
+ *
257
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
258
+ * @returns {number|undefined} z-score.
259
+ */
260
+ export function zScoreLatestChange(points) {
261
+ const changes = []
262
+ for (let i = 1; i < points.length; i += 1) changes.push(points[i].v - points[i - 1].v)
263
+ if (changes.length < MIN_STDDEV_N - 1) return undefined
264
+ const latestChange = changes[changes.length - 1]
265
+ const history = changes.slice(0, -1)
266
+ const sigma = stdDev(history)
267
+ if (sigma === undefined || sigma === 0) return undefined
268
+ const mean = history.reduce((a, b) => a + b, 0) / history.length
269
+ return (latestChange - mean) / sigma
270
+ }
271
+
272
+ /**
273
+ * Count observations a fixed-cadence series should have had but does not
274
+ * (docs/07 T1.2).
275
+ *
276
+ * With an explicit 'cadenceDays' (the caller knows the indicator's frequency)
277
+ * the count is exact. Without one, cadence is inferred as the median gap of the
278
+ * observed series — which cannot see a series that published every other month
279
+ * consistently, and never invents a trading calendar (docs/03 §2 rule 12).
280
+ *
281
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
282
+ * @param {{ cadenceDays?: number }} [options] - expected cadence.
283
+ * @returns {number} number of missing slots.
284
+ */
285
+ export function missingCount(points, { cadenceDays } = {}) {
286
+ if (points.length < 2) return 0
287
+ const gaps = []
288
+ for (let i = 1; i < points.length; i += 1) gaps.push(daysBetween(points[i - 1].t, points[i].t))
289
+ const sorted = [...gaps].sort((a, b) => a - b)
290
+ const inferred = sorted[Math.floor(sorted.length / 2)]
291
+ const cadence = cadenceDays ?? inferred
292
+ if (!(cadence > 0)) return 0
293
+ let missing = 0
294
+ for (const gap of gaps) missing += Math.max(0, Math.round(gap / cadence) - 1)
295
+ return missing
296
+ }
297
+
298
+ /** Nominal days between observations, per frequency (documentation-only cadence). */
299
+ export const FREQ_CADENCE_DAYS = {
300
+ daily: 1,
301
+ weekly: 7,
302
+ monthly: 31,
303
+ quarterly: 92,
304
+ annual: 366,
305
+ }
306
+
307
+ /**
308
+ * Assemble the 'SeriesStats' contract (docs/03 §1.4).
309
+ *
310
+ * @param {Array<{ t: string, v: number }>} input - points, any order.
311
+ * @param {{ decimals?: number, freq?: string }} [options] - rounding and frequency (for gap counting).
312
+ * @returns {object|undefined} stats object, or 'undefined' for an empty series.
313
+ */
314
+ export function stats(input, { decimals, freq } = {}) {
315
+ const points = sortDedupe(input)
316
+ if (points.length === 0) return undefined
317
+
318
+ const latest = points[points.length - 1]
319
+ const prevPoint = points.length > 1 ? points[points.length - 2] : undefined
320
+ const values = points.map((p) => p.v)
321
+ const yoyResult = yoy(points)
322
+ const momResult = mom(points)
323
+ const sigma = stdDev(values)
324
+ // Both change fields are derived from the SAME difference and rounded to one
325
+ // decimal more than the reading itself, so they cannot contradict each other.
326
+ // Rounding `abs(latest - prev)` at display precision made a 0.05pp move show as
327
+ // `changeAbs: 0.0` beside `changePct: 1.5`, and an agent reading the panel
328
+ // reported that contradiction as a panel bug.
329
+ const changeDecimals = decimals === undefined ? 4 : decimals + 1
330
+ const change = prevPoint === undefined ? undefined : latest.v - prevPoint.v
331
+
332
+ const result = {
333
+ latest: round(latest.v, decimals),
334
+ latestAt: latest.t,
335
+ ...(prevPoint === undefined
336
+ ? {}
337
+ : {
338
+ prev: round(prevPoint.v, decimals),
339
+ changeAbs: round(change, changeDecimals),
340
+ ...(prevPoint.v === 0
341
+ ? {}
342
+ : { changePct: round((change / Math.abs(prevPoint.v)) * 100, decimals) }),
343
+ }),
344
+ ...(yoyResult === undefined ? {} : { yoy: round(yoyResult.value, decimals), yoyAlignedDrift: yoyResult.drift }),
345
+ ...(momResult === undefined ? {} : { mom: round(momResult.pct, decimals) }),
346
+ mean: round(values.reduce((a, b) => a + b, 0) / values.length, decimals),
347
+ min: round(Math.min(...values), decimals),
348
+ max: round(Math.max(...values), decimals),
349
+ stdDev: sigma === undefined ? 0 : round(sigma, decimals),
350
+ count: points.length,
351
+ missingCount: missingCount(points, { cadenceDays: FREQ_CADENCE_DAYS[freq] }),
352
+ }
353
+
354
+ const z = zScoreLatestChange(points)
355
+ if (z !== undefined) result.zScoreLatestChange = round(z, decimals)
356
+ const fitted = slope(points)
357
+ if (fitted !== undefined) {
358
+ result.slope = round(fitted, decimals)
359
+ const mean = values.reduce((a, b) => a + b, 0) / values.length
360
+ if (mean !== 0) result.slopeRel = round(fitted / mean, decimals)
361
+ }
362
+ const latestPercentile = percentileRank(values, latest.v)
363
+ if (latestPercentile !== undefined) result.percentile = latestPercentile
364
+
365
+ return result
366
+ }
367
+
368
+ /**
369
+ * Fraction of observations at or below 'value', linearly interpolated between
370
+ * the two bracketing ranks. 'undefined' below the percentile sample floor.
371
+ *
372
+ * @param {number[]} values - observations.
373
+ * @param {number} value - the value to rank.
374
+ * @returns {number|undefined} percentile in '[0, 1]'.
375
+ */
376
+ export function percentileRank(values, value) {
377
+ if (values.length < MIN_PERCENTILE_N) return undefined
378
+ const sorted = [...values].sort((a, b) => a - b)
379
+ if (value <= sorted[0]) return 0
380
+ if (value >= sorted[sorted.length - 1]) return 1
381
+ let below = 0
382
+ for (const v of sorted) if (v <= value) below += 1
383
+ const rank = (below - 1) / (sorted.length - 1)
384
+ return Math.min(1, Math.max(0, rank))
385
+ }
386
+
387
+ /**
388
+ * Apply a catalog display transform to a point series (docs/03 §2 rules 5–7).
389
+ *
390
+ * Transform chains are expressed as 'display.transform' plus optional
391
+ * 'display.window'/'display.movingAvg':
392
+ *
393
+ * - 'raw' — unchanged;
394
+ * - 'diff' — 'v[i] - v[i-window]' (levels to changes);
395
+ * - 'pctChange' / 'mom' — percentage change against the previous observation;
396
+ * - 'yoy' — same-calendar-period percentage change;
397
+ * - 'annualize' — monthly × 12, quarterly × 4 (levels only).
398
+ *
399
+ * A trailing moving average ('movingAvg') is applied **after** the transform and
400
+ * drops the first 'window - 1' points, so a 3-month average of monthly changes
401
+ * never shows a partial window.
402
+ *
403
+ * @param {Array<{ t: string, v: number }>} points - normalized points.
404
+ * @param {object} display - catalog display spec.
405
+ * @returns {Array<{ t: string, v: number }>} transformed points (may be empty).
406
+ */
407
+ export function applyTransform(points, display = {}) {
408
+ const base = sortDedupe(points)
409
+ const transform = display.transform ?? 'raw'
410
+ let out
411
+ switch (transform) {
412
+ case 'raw':
413
+ out = base
414
+ break
415
+ case 'diff':
416
+ out = diff(base, display.window ?? 1).filter((p) => p.v !== undefined)
417
+ break
418
+ case 'pctChange':
419
+ case 'mom':
420
+ out = base
421
+ .map((point, index) => {
422
+ if (index === 0) return undefined
423
+ const previous = base[index - 1]
424
+ if (previous.v === 0) return undefined
425
+ return { t: point.t, v: ((point.v - previous.v) / Math.abs(previous.v)) * 100 }
426
+ })
427
+ .filter(Boolean)
428
+ break
429
+ case 'yoy':
430
+ out = base
431
+ .map((point, index) => {
432
+ // Recompute yoy for every point, not only the last one.
433
+ const result = yoy(base.slice(0, index + 1))
434
+ return result === undefined ? undefined : { t: point.t, v: result.value }
435
+ })
436
+ .filter(Boolean)
437
+ break
438
+ case 'annualize':
439
+ out = base.map((point) => ({ t: point.t, v: point.v * (display.freq === 'quarterly' ? 4 : 12) }))
440
+ break
441
+ case 'ratio':
442
+ out = base
443
+ break
444
+ default:
445
+ throw new RangeError(`unknown display.transform: ${JSON.stringify(transform)}`)
446
+ }
447
+ if (display.movingAvg !== undefined) {
448
+ out = movingAverage(out, display.movingAvg).filter((p) => p.v !== undefined)
449
+ }
450
+ return out
451
+ }
452
+
453
+ /**
454
+ * Describe a transform's unit suffix, so the UI can label "(年化)" or a 3-month
455
+ * average without re-deriving it (docs/03 §2 rule 7).
456
+ *
457
+ * @param {object} display - catalog display spec.
458
+ * @returns {string} unit suffix ('' when none).
459
+ */
460
+ export function transformSuffix(display = {}) {
461
+ const parts = []
462
+ if (display.transform === 'annualize') parts.push('年化')
463
+ if (display.movingAvg !== undefined) parts.push(`${display.movingAvg}期均值`)
464
+ return parts.length === 0 ? '' : ` (${parts.join('·')})`
465
+ }