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.
- package/LICENSE +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- 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
|
+
}
|