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,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
|
+
}
|