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,251 @@
1
+ /**
2
+ * Refresh orchestration: cache, TTL, in-flight de-duplication, retry and
3
+ * graceful degradation (docs/02 §4.2, docs/07 T6.1).
4
+ *
5
+ * This is the only place that decides *when* a source is called. Adapters know
6
+ * how to fetch; this module knows whether it is worth fetching, what to do when
7
+ * it fails, and what to tell the UI about freshness.
8
+ *
9
+ * @module app/refresh
10
+ */
11
+ import { SourceError, toSourceErrorPayload } from '../core/types.js'
12
+ import { daysBetween } from '../core/time/range.js'
13
+
14
+ /** Per-source fetch budget. Keeps one blocked upstream from eating the panel's
15
+ * whole refresh budget (the browser half abandons a request at 8s). */
16
+ export const SOURCE_TIMEOUT_MS = 6000
17
+
18
+ /** Cache TTL in minutes, by indicator frequency (docs/02 §4.2). */
19
+ export const TTL_MINUTES = { daily: 15, weekly: 180, monthly: 360, quarterly: 1440, annual: 1440 }
20
+
21
+ /** Staleness threshold in days, by frequency (docs/05 §5). */
22
+ export const STALE_DAYS = { daily: 2, weekly: 10, monthly: 40, quarterly: 100, annual: 400 }
23
+
24
+ /**
25
+ * Canonicalize an adapter params object into a stable cache-key fragment.
26
+ *
27
+ * Every parameter is included, sorted by key: one series can serve several
28
+ * indicators that differ only by a parameter (the Treasury yield curve serves
29
+ * every tenor from one CSV name), and a key that ignored the parameter would
30
+ * hand the first tenor's values to all of them.
31
+ *
32
+ * @param {object|undefined} params - adapter params.
33
+ * @returns {string} canonical fragment.
34
+ */
35
+ export function paramsKey(params) {
36
+ if (params === null || params === undefined) return ''
37
+ if (typeof params !== 'object') return String(params)
38
+ const parts = []
39
+ for (const key of Object.keys(params).sort()) {
40
+ const value = params[key]
41
+ if (value === undefined) continue
42
+ parts.push(`${key}=${typeof value === 'object' ? paramsKey(value) : String(value)}`)
43
+ }
44
+ return parts.join(',')
45
+ }
46
+
47
+ /**
48
+ * Cache key for one series request.
49
+ *
50
+ * @param {{ adapterId: string, seriesRef: string, params?: object, range?: object }} request - request identity.
51
+ * @returns {string} cache key.
52
+ */
53
+ export function cacheKey({ adapterId, seriesRef, params, range }) {
54
+ return `${adapterId}|${seriesRef}|${paramsKey(params)}|${range?.preset ?? 'CUSTOM'}|${range?.from ?? ''}|${range?.to ?? ''}`
55
+ }
56
+
57
+ /**
58
+ * Whether a cached entry is still fresh.
59
+ *
60
+ * @param {{ storedAt: string, ttlMs: number }} entry - cache entry.
61
+ * @param {Date} now - current time.
62
+ * @returns {boolean} freshness.
63
+ */
64
+ export function isFresh(entry, now) {
65
+ const stored = new Date(entry.storedAt).getTime()
66
+ return now.getTime() - stored < entry.ttlMs
67
+ }
68
+
69
+ /**
70
+ * Create the refresh service.
71
+ *
72
+ * @param {object} deps - dependencies.
73
+ * @param {import('../ports/snapshot-repo.js').SnapshotRepository} deps.snapshots - snapshot cache.
74
+ * @param {import('../ports/clock.js').Clock} deps.clock - clock.
75
+ * @param {(adapterId: string, req: object, deps: object) => Promise<object>} deps.fetchViaAdapter - adapter dispatcher.
76
+ * @param {(msg: string, meta?: object) => void} [deps.log] - logger.
77
+ * @param {{ sleep?: (ms: number) => Promise<void>, retryDelayMs?: number, maxAttempts?: number, ttlMinutes?: object, staleDays?: object }} [deps.options] - tuning.
78
+ * @returns {object} refresh service.
79
+ */
80
+ export function createRefreshService({
81
+ snapshots,
82
+ clock,
83
+ fetchViaAdapter,
84
+ log = () => {},
85
+ options = {},
86
+ }) {
87
+ const ttlMinutes = options.ttlMinutes ?? TTL_MINUTES
88
+ const staleDays = options.staleDays ?? STALE_DAYS
89
+ const maxAttempts = options.maxAttempts ?? 2
90
+ const retryDelayMs = options.retryDelayMs ?? 500
91
+ // Sleeping is injected: `core/` and `app/` hold no timers (test/core/layering.test.js),
92
+ // and the host half supplies a real delay via its timer service.
93
+ const sleep = options.sleep ?? (async () => {})
94
+ const sourceTimeoutMs = options.sourceTimeoutMs ?? SOURCE_TIMEOUT_MS
95
+ // An adapter may declare a longer budget than the default (docs/04 §8).
96
+ /** Adapter-specific budget lookup: '(adapterId) => ms|undefined'. */
97
+ const timeoutFor = options.timeoutFor
98
+ /** @type {Map<string, Promise<any>>} */
99
+ const inFlight = new Map()
100
+
101
+ /**
102
+ * Attach freshness metadata to a series result.
103
+ *
104
+ * @param {object} series - raw series.
105
+ * @param {string} status - fresh | stale | error | missing.
106
+ * @param {object} [extra] - extra fields.
107
+ * @returns {object} annotated series.
108
+ */
109
+ function annotate(series, status, extra = {}) {
110
+ const latestAt = series?.points?.length ? series.points[series.points.length - 1].t : undefined
111
+ const ageDays = latestAt === undefined ? undefined : daysBetween(latestAt, clock.today())
112
+ return { ...series, status, latestAt, ageDays, ...extra }
113
+ }
114
+
115
+ /**
116
+ * Decide the freshness status for a successful fetch.
117
+ *
118
+ * @param {object} series - raw series.
119
+ * @param {string} freq - indicator frequency.
120
+ * @returns {'fresh'|'stale'|'missing'} status.
121
+ */
122
+ function statusFor(series, freq) {
123
+ if (!series?.points || series.points.length === 0) return 'missing'
124
+ const latestAt = series.points[series.points.length - 1].t
125
+ const threshold = staleDays[freq] ?? 40
126
+ return daysBetween(latestAt, clock.today()) > threshold ? 'stale' : 'fresh'
127
+ }
128
+
129
+ /**
130
+ * Fetch one indicator, using the cache when allowed.
131
+ *
132
+ * @param {object} req - request.
133
+ * @param {string} req.adapterId - adapter id.
134
+ * @param {string} req.seriesRef - series reference.
135
+ * @param {object} [req.params] - adapter params.
136
+ * @param {{ preset: string, from: string, to: string }} req.range - date range.
137
+ * @param {string} [req.freq] - indicator frequency (TTL/staleness).
138
+ * @param {AbortSignal} [req.signal] - cancellation.
139
+ * @param {{ force?: boolean, fetch?: Function, clock?: object }} req.runtime - the adapter runtime ('{ fetch, clock }'), plus an optional 'force' override. This object *is* the deps bag the adapters receive.
140
+ * @returns {Promise<object>} annotated series: '{ adapterId, seriesRef, points, stats?, sourceRef, status, cached, lastSuccessAt, error? }'.
141
+ */
142
+ async function refresh(req) {
143
+ const { adapterId, seriesRef, params, range, freq = 'monthly', runtime } = req
144
+ const key = cacheKey({ adapterId, seriesRef, params, range })
145
+ const ttlMs = (ttlMinutes[freq] ?? 360) * 60_000
146
+
147
+ if (runtime.force !== true) {
148
+ const cached = await snapshots.read(key)
149
+ if (cached !== undefined && isFresh(cached, clock.now())) {
150
+ return annotate(cached.payload, statusFor(cached.payload, freq), {
151
+ cached: true,
152
+ lastSuccessAt: cached.storedAt,
153
+ })
154
+ }
155
+ }
156
+
157
+ const existing = inFlight.get(key)
158
+ if (existing !== undefined) {
159
+ // Concurrent callers share one upstream call (docs/08 §2 defect 13).
160
+ const shared = await existing
161
+ return { ...shared, cached: true, deduped: true }
162
+ }
163
+
164
+ // Register the in-flight promise synchronously: a second caller arriving in
165
+ // the same tick must find it, not start its own request.
166
+ const operation = (async () => {
167
+ let lastError
168
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
169
+ try {
170
+ // `runtime` is the adapter deps bag itself (`{ fetch, clock }`); the
171
+ // optional `force` flag rides along and is ignored by adapters.
172
+ // A per-source timeout means one slow or blocked upstream cannot hold
173
+ // up the whole overview (docs/04 §8: 15s per request, 6s per source
174
+ // here so a failing source degrades while the others render).
175
+ const sourceDeps = runtime?.signal === undefined && typeof AbortSignal?.timeout === 'function'
176
+ ? { ...runtime, signal: AbortSignal.timeout(timeoutFor?.(adapterId) ?? sourceTimeoutMs) }
177
+ : runtime
178
+ const series = await fetchViaAdapter(adapterId, { seriesRef, params, range, preset: range?.preset }, sourceDeps)
179
+ await snapshots.write(key, series, { ttlMs, at: clock.now().toISOString() })
180
+ return annotate(series, statusFor(series, freq), { cached: false, lastSuccessAt: clock.now().toISOString() })
181
+ } catch (error) {
182
+ lastError = error
183
+ const payload = toSourceErrorPayload(error, adapterId, seriesRef)
184
+ const retryable = error instanceof SourceError ? error.retryable : false
185
+ if (!retryable || attempt === maxAttempts) break
186
+ log(`retrying ${adapterId}/${seriesRef} after ${payload.kind}`, payload)
187
+ await sleep(retryDelayMs * attempt)
188
+ }
189
+ }
190
+
191
+ const payload = toSourceErrorPayload(lastError, adapterId, seriesRef)
192
+ const previous = await snapshots.read(key)
193
+ if (previous !== undefined) {
194
+ // Degrade to the last good snapshot rather than dropping the card.
195
+ return annotate(previous.payload, 'stale', {
196
+ cached: true,
197
+ lastSuccessAt: previous.storedAt,
198
+ error: payload,
199
+ indicatorId: req.indicatorId,
200
+ })
201
+ }
202
+ return {
203
+ adapterId,
204
+ seriesRef,
205
+ indicatorId: req.indicatorId,
206
+ points: [],
207
+ meta: { name: seriesRef },
208
+ fetchedAt: clock.now().toISOString(),
209
+ sourceRef: undefined,
210
+ status: 'error',
211
+ cached: false,
212
+ lastSuccessAt: undefined,
213
+ error: payload,
214
+ }
215
+ })()
216
+ inFlight.set(key, operation)
217
+ operation.finally(() => {
218
+ if (inFlight.get(key) === operation) inFlight.delete(key)
219
+ }).catch(() => {})
220
+
221
+ return operation
222
+ }
223
+
224
+ /**
225
+ * Refresh many requests without letting one failure affect the others
226
+ * ('Promise.allSettled' semantics, docs/04 §8).
227
+ *
228
+ * @param {Array<object>} requests - refresh requests.
229
+ * @returns {Promise<object[]>} one result per request, in input order.
230
+ */
231
+ async function refreshAll(requests) {
232
+ const settled = await Promise.allSettled(requests.map((request) => refresh(request)))
233
+ return settled.map((outcome, index) => {
234
+ if (outcome.status === 'fulfilled') return outcome.value
235
+ const request = requests[index]
236
+ return {
237
+ adapterId: request.adapterId,
238
+ seriesRef: request.seriesRef,
239
+ indicatorId: request.indicatorId,
240
+ points: [],
241
+ meta: { name: request.seriesRef },
242
+ fetchedAt: clock.now().toISOString(),
243
+ status: 'error',
244
+ cached: false,
245
+ error: { kind: 'parse', adapterId: request.adapterId, seriesRef: request.seriesRef, detail: String(outcome.reason?.message ?? outcome.reason), retryable: false },
246
+ }
247
+ })
248
+ }
249
+
250
+ return { refresh, refreshAll, cacheKey, isFresh, statusFor, inFlightSize: () => inFlight.size }
251
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Single-indicator detail view (docs/07 T6.3).
3
+ *
4
+ * @module app/series-view
5
+ */
6
+ import { applyTransform, stats as computeStats } from '../core/stats/series.js'
7
+ import { applyDerive, innerJoin } from '../core/stats/derive.js'
8
+ import { filterRange, resolveRange } from '../core/time/range.js'
9
+
10
+ /** Error codes the detail view can return. */
11
+ export const VIEW_ERRORS = {
12
+ unknownIndicator: 'unknown-indicator',
13
+ insufficientOverlap: 'insufficient-overlap',
14
+ noData: 'no-data',
15
+ }
16
+
17
+ /**
18
+ * Create the series-view use case.
19
+ *
20
+ * @param {object} deps - dependencies.
21
+ * @param {Record<string, object>} deps.catalogById - id → definition.
22
+ * @param {object} deps.refresh - refresh service.
23
+ * @param {import('../ports/clock.js').Clock} deps.clock - clock.
24
+ * @param {object} deps.runtime - runtime deps for adapters.
25
+ * @returns {object} series-view use case.
26
+ */
27
+ export function createSeriesViewUseCase({ catalogById, refresh, clock, runtime }) {
28
+ /**
29
+ * Fetch one indicator and shape it into a 'SeriesView'.
30
+ *
31
+ * @param {object} request - request.
32
+ * @param {string} request.indicatorId - indicator id.
33
+ * @param {string} [request.range] - range preset.
34
+ * @param {object} [request.custom] - custom bounds.
35
+ * @param {string} [request.transform] - transform override.
36
+ * @param {string} [request.compareWith] - second indicator id.
37
+ * @param {{ fetch: Function, clock: object }} [request.runtime] - runtime override.
38
+ * @returns {Promise<object>} a 'SeriesView', or '{ error: { code, detail } }'.
39
+ */
40
+ async function seriesView(request = {}) {
41
+ const indicator = catalogById[request.indicatorId]
42
+ if (indicator === undefined) {
43
+ return { error: { code: VIEW_ERRORS.unknownIndicator, detail: `unknown indicator ${request.indicatorId}` } }
44
+ }
45
+ const range = resolveRange(request.range ?? '1Y', clock.today(), request.custom)
46
+ const runtimeDeps = request.runtime ?? runtime
47
+ const display = { ...indicator.display, ...(request.transform === undefined ? {} : { transform: request.transform }) }
48
+
49
+ /**
50
+ * Fetch one indicator's raw series through the refresh service.
51
+ *
52
+ * @param {object} def - indicator definition.
53
+ * @returns {Promise<object>} refresh result.
54
+ */
55
+ async function fetchOne(def) {
56
+ if (def.derive !== undefined) {
57
+ const operandResults = await Promise.all(
58
+ def.derive.operands.map((operand) => {
59
+ const operandDef = catalogById[operand]
60
+ return refresh.refresh({
61
+ adapterId: operandDef.source.adapter,
62
+ seriesRef: operandDef.source.seriesRef,
63
+ params: operandDef.source.params,
64
+ range,
65
+ freq: operandDef.freq,
66
+ runtime: runtimeDeps,
67
+ })
68
+ }),
69
+ )
70
+ const seriesById = {}
71
+ operandResults.forEach((result, index) => {
72
+ if (result.points?.length) seriesById[def.derive.operands[index]] = result.points
73
+ })
74
+ const derived = applyDerive(def.derive, seriesById)
75
+ // Same provenance rule as the overview: a derived series points at its
76
+ // first operand, labelled so nobody mistakes it for an upstream series.
77
+ const operandRef = operandResults.find((result) => result.sourceRef)?.sourceRef
78
+ const sourceRef = operandRef === undefined
79
+ ? undefined
80
+ : { ...operandRef, seriesRef: def.derive.operands.join('+'), label: `${operandRef.label}(现算)` }
81
+ return {
82
+ adapterId: 'derived',
83
+ seriesRef: def.derive.operands.join('+'),
84
+ points: derived.points,
85
+ meta: { name: def.label.zh, freq: def.freq },
86
+ fetchedAt: clock.now().toISOString(),
87
+ sourceRef,
88
+ status: derived.points.length > 0 ? 'fresh' : 'missing',
89
+ reason: derived.reason,
90
+ }
91
+ }
92
+ return refresh.refresh({
93
+ adapterId: def.source.adapter,
94
+ seriesRef: def.source.seriesRef,
95
+ // A caller may override source params (the chart's day/week/month bar
96
+ // size). The override is part of the cache key, so a weekly request never
97
+ // reuses the daily response.
98
+ params: request.params === undefined ? def.source.params : { ...def.source.params, ...request.params },
99
+ range,
100
+ freq: def.freq,
101
+ runtime: runtimeDeps,
102
+ })
103
+ }
104
+
105
+ const primary = await fetchOne(indicator)
106
+ if (primary.status === 'error') {
107
+ return {
108
+ indicatorId: indicator.id,
109
+ label: indicator.label,
110
+ unit: indicator.unit,
111
+ range,
112
+ points: [],
113
+ stats: undefined,
114
+ sourceRef: primary.sourceRef,
115
+ status: 'error',
116
+ errorKind: primary.error?.kind,
117
+ errorDetail: primary.error?.detail,
118
+ }
119
+ }
120
+
121
+ const transformedAll = applyTransform(primary.points ?? [], display)
122
+ const points = filterRange(transformedAll, range)
123
+ // Descriptive statistics describe the whole fetched series (a YoY reading
124
+ // needs twelve prior points even when the chart shows three months), but the
125
+ // reported *count* must agree with the points actually returned. They used to
126
+ // disagree — `/series` answered with 10 points while its own `stats.count`
127
+ // said 13 — and a model reading both found the contradiction before a human did.
128
+ const stats = computeStats(transformedAll, { decimals: display.decimals, freq: indicator.freq })
129
+ if (stats !== undefined) stats.count = points.length
130
+
131
+ const view = {
132
+ indicatorId: indicator.id,
133
+ label: indicator.label,
134
+ unit: indicator.unit,
135
+ unitLabel: unitLabel(indicator, display),
136
+ freq: indicator.freq,
137
+ seasonal: indicator.seasonal,
138
+ display,
139
+ range,
140
+ points,
141
+ stats,
142
+ sourceRef: primary.sourceRef,
143
+ // Whether a day/week/month switch would change anything for this source.
144
+ // The panel shows the control only when it is true.
145
+ barSizes: request.barSizes === true,
146
+ status: points.length === 0 ? 'missing' : primary.status,
147
+ lastSuccessAt: primary.lastSuccessAt,
148
+ }
149
+
150
+ if (request.compareWith !== undefined) {
151
+ const other = catalogById[request.compareWith]
152
+ if (other === undefined) {
153
+ return { error: { code: VIEW_ERRORS.unknownIndicator, detail: `unknown comparison indicator ${request.compareWith}` } }
154
+ }
155
+ const secondary = await fetchOne(other)
156
+ const otherPoints = filterRange(applyTransform(secondary.points ?? [], other.display), range)
157
+ const joined = innerJoin(points, otherPoints)
158
+ if (joined.length < 3) {
159
+ return {
160
+ error: {
161
+ code: VIEW_ERRORS.insufficientOverlap,
162
+ detail: `${indicator.id} and ${other.id} share only ${joined.length} dates in this range`,
163
+ },
164
+ view,
165
+ }
166
+ }
167
+ view.compare = {
168
+ indicatorId: other.id,
169
+ label: other.label,
170
+ unit: other.unit,
171
+ points: otherPoints,
172
+ join: joined,
173
+ stats: computeStats(applyTransform(secondary.points ?? [], other.display), { decimals: other.display?.decimals, freq: other.freq }),
174
+ }
175
+ }
176
+
177
+ return view
178
+ }
179
+
180
+ return { seriesView }
181
+ }
182
+
183
+ /**
184
+ * The unit label including any transform annotation (e.g. '% (年化)').
185
+ *
186
+ * @param {object} indicator - indicator definition.
187
+ * @param {object} display - effective display spec.
188
+ * @returns {string} unit label.
189
+ */
190
+ export function unitLabel(indicator, display) {
191
+ const parts = [indicator.unit]
192
+ if (display.transform === 'annualize') parts.push('年化')
193
+ if (display.movingAvg !== undefined) parts.push(`${display.movingAvg}期均值`)
194
+ return parts.join(' ')
195
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The watchlist / custom-indicator use case (docs/07 T6.4).
3
+ *
4
+ * The repository is injected, so the in-memory and file-backed implementations
5
+ * share one behaviour: 'add' is idempotent, 'remove' reports 'notFound' instead
6
+ * of throwing, and 'update' patches only the fields it was given.
7
+ *
8
+ * @module app/watchlist
9
+ */
10
+ import { validateIndicatorDef } from '../core/types.js'
11
+
12
+ /**
13
+ * Create the watchlist use case.
14
+ *
15
+ * @param {object} deps - dependencies.
16
+ * @param {import('../ports/snapshot-repo.js').WatchlistRepository} deps.repository - persistence.
17
+ * @param {import('../ports/clock.js').Clock} deps.clock - clock.
18
+ * @param {Record<string, object>} [deps.catalogById] - seed catalog, for id collision reporting.
19
+ * @returns {object} watchlist use case.
20
+ */
21
+ export function createWatchlistUseCase({ repository, clock, catalogById = {} }) {
22
+ /**
23
+ * List the stored items, ordered by their 'order' field then insertion.
24
+ *
25
+ * @returns {Promise<object[]>} items.
26
+ */
27
+ async function list() {
28
+ const items = await repository.list()
29
+ return items
30
+ .map((item, index) => ({ ...item, _index: index }))
31
+ .sort((a, b) => (a.order ?? a._index) - (b.order ?? b._index))
32
+ .map(({ _index, ...item }) => item)
33
+ }
34
+
35
+ /**
36
+ * Add an indicator (idempotent).
37
+ *
38
+ * @param {object} item - '{ indicatorId, ... }'.
39
+ * @param {{ createdBy?: 'user'|'ai'|'catalog' }} [options] - provenance.
40
+ * @returns {Promise<{ item: object, created: boolean, conflict?: boolean }>} result.
41
+ */
42
+ async function add(item, { createdBy = 'user' } = {}) {
43
+ const indicatorId = item?.indicatorId
44
+ if (typeof indicatorId !== 'string' || indicatorId === '') {
45
+ return { item: undefined, created: false, error: { code: 'invalid-item', detail: 'indicatorId is required' } }
46
+ }
47
+ if (item.definition !== undefined) {
48
+ const validation = validateIndicatorDef(item.definition)
49
+ if (!validation.ok) {
50
+ return { item: undefined, created: false, error: { code: 'invalid-definition', detail: validation.errors.join('; ') } }
51
+ }
52
+ }
53
+ const stored = { ...item, createdBy, addedAt: item.addedAt ?? clock.now().toISOString() }
54
+ const result = await repository.add(stored)
55
+ return {
56
+ ...result,
57
+ conflict: result.created === false && catalogById[indicatorId] !== undefined,
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Remove an indicator.
63
+ *
64
+ * @param {string} indicatorId - indicator id.
65
+ * @returns {Promise<{ status: 'removed'|'notFound' }>} result.
66
+ */
67
+ async function remove(indicatorId) {
68
+ const status = await repository.remove(indicatorId)
69
+ return { status }
70
+ }
71
+
72
+ /**
73
+ * Patch one indicator.
74
+ *
75
+ * @param {string} indicatorId - indicator id.
76
+ * @param {object} patch - fields to change.
77
+ * @returns {Promise<{ item?: object, status: 'updated'|'notFound' }>} result.
78
+ */
79
+ async function update(indicatorId, patch) {
80
+ const item = await repository.update(indicatorId, patch)
81
+ return item === undefined ? { status: 'notFound' } : { status: 'updated', item }
82
+ }
83
+
84
+ /**
85
+ * Reorder the list by explicit ids.
86
+ *
87
+ * @param {string[]} orderedIds - the desired order.
88
+ * @returns {Promise<{ items: object[], missing: string[] }>} result.
89
+ */
90
+ async function reorder(orderedIds) {
91
+ const existing = new Set((await repository.list()).map((item) => item.indicatorId))
92
+ const missing = orderedIds.filter((id) => !existing.has(id))
93
+ await Promise.all(
94
+ orderedIds
95
+ .filter((id) => existing.has(id))
96
+ .map((id, index) => repository.update(id, { order: index })),
97
+ )
98
+ return { items: await list(), missing }
99
+ }
100
+
101
+ return { list, add, remove, update, reorder }
102
+ }