echopai 2.1.0 → 2.3.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.
@@ -0,0 +1,393 @@
1
+ /**
2
+ * `echopai concepts ...` + MCP tools for the concept-index family.
3
+ *
4
+ * Concept = THS / DC / 自定义 概念板块。所有指数采用 PLAN_CONCEPT_INDUSTRY_QUOTE
5
+ * §5.4 链式等权计算(base 1000)。9 个子端点:
6
+ *
7
+ * list —— 全量概念元数据 + 6 字段实时快照(默认 active)
8
+ * snapshot —— 全量 Redis snapshot hash(可 codes= 过滤)
9
+ * show —— 单个概念 meta + 当前成份股 + 个股最新行情
10
+ * alerts —— 当前激活的概念异动(big_move / limit_up_cluster)
11
+ * alerts-history —— 24h 异动历史(可按 rule / 时窗过滤)
12
+ * daily-bars —— 概念指数日 K(链式等权)
13
+ * minute-bars —— 概念指数分钟 K(max 7 天/请求)
14
+ * news —— 与概念名匹配的相关新闻
15
+ * views —— 关联券商观点(concept_views × broker_views JOIN)
16
+ *
17
+ * MCP 注册为 9 个 discrete tool;CLI 收纳到单个 `concepts` noun 下子命令。
18
+ */
19
+ import { Command, Option } from "commander";
20
+ import { z } from "zod";
21
+ import { OPERATIONS } from "../_generated/operations.js";
22
+ import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
23
+ import { callOp } from "../runtime/verb_runner.js";
24
+ const STATUS_VALUES = ["active", "pending_review", "deprecated"];
25
+ // Server-supported sort keys (stockpulse-rs concepts.rs sort_by chain).
26
+ const SERVER_SORT_VALUES = ["pct", "amount", "limit_up", "stock_count"];
27
+ // Client-side sort keys — fetched with server sort=pct (any), then re-sorted
28
+ // over the live snapshot fields returned per item. 涨速 (`speed_3min`) /
29
+ // 换手率 (no per-concept turnover; here it means 个股层面 turnover_rate
30
+ // aggregate is not present — `turnover` is alias of `amount`).
31
+ const CLIENT_SORT_VALUES = ["speed"];
32
+ const SORT_VALUES = [...SERVER_SORT_VALUES, ...CLIENT_SORT_VALUES];
33
+ const RULE_VALUES = ["big_move", "limit_up_cluster"];
34
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
35
+ function clampInt(raw, min, max, fallback) {
36
+ const n = Math.floor(Number(raw));
37
+ if (!Number.isFinite(n))
38
+ return fallback;
39
+ if (n < min)
40
+ return min;
41
+ if (n > max)
42
+ return max;
43
+ return n;
44
+ }
45
+ function parseConceptId(raw) {
46
+ const n = Math.floor(Number(raw));
47
+ if (!Number.isFinite(n) || n <= 0) {
48
+ throw new Error(`concept_id must be a positive integer (got ${raw})`);
49
+ }
50
+ return n;
51
+ }
52
+ export const conceptsListSpec = {
53
+ name: "concepts_list",
54
+ description: "List concepts with live 6-field snapshot (pct_change / index_value / up_count / down_count / limit_up_count / amount / speed_3min) joined from Redis. Default sort by pct (desc). Use as the entry point for browsing the concept universe — narrow down by sort and pick top movers, then drill in with `concepts_show` / `concepts_daily_bars`. Sort keys: `pct` / `amount` / `limit_up` / `stock_count` are pushed to the server; `speed` is applied client-side over `speed_3min` (3-minute pct delta).",
55
+ inputSchema: {
56
+ status: z.enum(STATUS_VALUES).default("active").describe("Concept status filter"),
57
+ sort: z
58
+ .enum(SORT_VALUES)
59
+ .default("pct")
60
+ .describe("Sort field: pct / amount / limit_up / stock_count / speed"),
61
+ limit: z
62
+ .number()
63
+ .int()
64
+ .min(1)
65
+ .max(1000)
66
+ .default(500)
67
+ .describe("Max items (1-1000, default 500)"),
68
+ },
69
+ handler: async (args, ctx) => {
70
+ const op = OPERATIONS["concepts.list"];
71
+ if (!op)
72
+ throw new Error("concepts.list op missing");
73
+ const sort = String(args.sort ?? "pct");
74
+ const isClientSort = CLIENT_SORT_VALUES.includes(sort);
75
+ // Client-side sort: fetch the full universe (server max 1000) so the
76
+ // re-rank sees every concept, then slice to user-requested `limit`.
77
+ // Server-side sort: pass-through (server enforces limit honestly).
78
+ const serverArgs = {
79
+ status: args.status,
80
+ sort: isClientSort ? "pct" : sort,
81
+ limit: isClientSort ? 1000 : args.limit,
82
+ };
83
+ const env = await callOp(op, serverArgs, ctx);
84
+ if (!isClientSort)
85
+ return env;
86
+ return resortListEnvelopeBySpeed(env, Number(args.limit ?? 500));
87
+ },
88
+ backingOps: ["concepts.list"],
89
+ };
90
+ function resortListEnvelopeBySpeed(env, limit) {
91
+ const body = env.data;
92
+ if (!body || typeof body !== "object" || !Array.isArray(body.items)) {
93
+ return env;
94
+ }
95
+ const items = body.items.slice();
96
+ items.sort((a, b) => {
97
+ const av = numericOrNeg(a.speed_3min);
98
+ const bv = numericOrNeg(b.speed_3min);
99
+ return bv - av;
100
+ });
101
+ const sliced = items.slice(0, Math.max(1, Math.floor(limit)));
102
+ const data = { ...body, items: sliced };
103
+ const meta = { ...(env.meta ?? {}), client_sort: "speed" };
104
+ return { data, meta };
105
+ }
106
+ function numericOrNeg(v) {
107
+ if (typeof v === "number" && Number.isFinite(v))
108
+ return v;
109
+ if (typeof v === "string") {
110
+ const n = Number(v);
111
+ if (Number.isFinite(n))
112
+ return n;
113
+ }
114
+ return Number.NEGATIVE_INFINITY;
115
+ }
116
+ export const conceptsSnapshotSpec = {
117
+ name: "concepts_snapshot",
118
+ description: "Bulk Redis snapshot of all concepts. Use `codes` (comma-separated concept_id list) to filter; omit for the full set. Cheaper than `concepts_list` when only the live numbers are needed without joining meta.",
119
+ inputSchema: {
120
+ codes: z
121
+ .string()
122
+ .optional()
123
+ .describe("Comma-separated concept_id list (e.g. `1,2,3`); omit for all."),
124
+ },
125
+ handler: async (args, ctx) => {
126
+ const op = OPERATIONS["concepts.snapshot"];
127
+ if (!op)
128
+ throw new Error("concepts.snapshot op missing");
129
+ const callArgs = {};
130
+ if (args.codes)
131
+ callArgs.codes = args.codes;
132
+ return callOp(op, callArgs, ctx);
133
+ },
134
+ backingOps: ["concepts.snapshot"],
135
+ };
136
+ export const conceptsShowSpec = {
137
+ name: "concepts_show",
138
+ description: "Detail for one concept: meta (full_name / source / status / approved_at / first_listed_at) plus current member securities with their latest snapshots. Use after `concepts_list` to inspect a specific theme.",
139
+ inputSchema: {
140
+ concept_id: z.number().int().positive().describe("Concept primary key (PG concepts.id)"),
141
+ },
142
+ handler: async (args, ctx) => {
143
+ const op = OPERATIONS["concepts.show"];
144
+ if (!op)
145
+ throw new Error("concepts.show op missing");
146
+ return callOp(op, { concept_id: args.concept_id }, ctx);
147
+ },
148
+ backingOps: ["concepts.show"],
149
+ };
150
+ export const conceptsAlertsSpec = {
151
+ name: "concepts_alerts",
152
+ description: "Currently active concept alerts. Two rules: `big_move` (|pct_change| > 3%) and `limit_up_cluster` (limit_up_count >= 3 AND stock_count >= 5). Cheap, non-billable. Use as the agent's signal for 'which themes are running hot right now'.",
153
+ inputSchema: {},
154
+ handler: async (_args, ctx) => {
155
+ const op = OPERATIONS["concepts.alerts"];
156
+ if (!op)
157
+ throw new Error("concepts.alerts op missing");
158
+ return callOp(op, {}, ctx);
159
+ },
160
+ backingOps: ["concepts.alerts"],
161
+ };
162
+ export const conceptsAlertsHistorySpec = {
163
+ name: "concepts_alerts_history",
164
+ description: "Concept-alert historical events (default last 24h). Filter by `rule` (big_move / limit_up_cluster) and Unix-ms `from`/`to` window. Used by AI agent / admin to review what fired and when.",
165
+ inputSchema: {
166
+ from: z
167
+ .number()
168
+ .int()
169
+ .optional()
170
+ .describe("Unix ms; default = now - 24h"),
171
+ to: z.number().int().optional().describe("Unix ms; default = now"),
172
+ rule: z.enum(RULE_VALUES).optional().describe("Filter by alert rule"),
173
+ limit: z
174
+ .number()
175
+ .int()
176
+ .min(1)
177
+ .max(2000)
178
+ .default(500)
179
+ .describe("Max items (1-2000, default 500)"),
180
+ },
181
+ handler: async (args, ctx) => {
182
+ const op = OPERATIONS["concepts.alerts-history"];
183
+ if (!op)
184
+ throw new Error("concepts.alerts-history op missing");
185
+ const callArgs = { limit: args.limit };
186
+ if (args.from !== undefined)
187
+ callArgs.from = args.from;
188
+ if (args.to !== undefined)
189
+ callArgs.to = args.to;
190
+ if (args.rule)
191
+ callArgs.rule = args.rule;
192
+ return callOp(op, callArgs, ctx);
193
+ },
194
+ backingOps: ["concepts.alerts-history"],
195
+ };
196
+ export const conceptsDailyBarsSpec = {
197
+ name: "concepts_daily_bars",
198
+ description: "Daily OHLC for a concept index (chain-linked equal-weight, base 1000). Returns breadth fields and `is_backfilled` flag. Requires `bars:read` scope.",
199
+ inputSchema: {
200
+ concept_id: z.number().int().positive().describe("Concept primary key"),
201
+ from: z.string().regex(DATE_RE).describe("Inclusive start date YYYY-MM-DD"),
202
+ to: z.string().regex(DATE_RE).describe("Inclusive end date YYYY-MM-DD"),
203
+ },
204
+ handler: async (args, ctx) => {
205
+ const op = OPERATIONS["concepts.daily-bars"];
206
+ if (!op)
207
+ throw new Error("concepts.daily-bars op missing");
208
+ return callOp(op, { concept_id: args.concept_id, from: args.from, to: args.to }, ctx);
209
+ },
210
+ backingOps: ["concepts.daily-bars"],
211
+ };
212
+ export const conceptsMinuteBarsSpec = {
213
+ name: "concepts_minute_bars",
214
+ description: "Concept-index minute-level OHLC (chain-linked equal-weight, base 1000). Max 7 days per request. Requires `bars:read` scope.",
215
+ inputSchema: {
216
+ concept_id: z.number().int().positive().describe("Concept primary key"),
217
+ from: z.string().regex(DATE_RE).describe("Inclusive start date YYYY-MM-DD"),
218
+ to: z.string().regex(DATE_RE).describe("Inclusive end date YYYY-MM-DD (max 7 days)"),
219
+ },
220
+ handler: async (args, ctx) => {
221
+ const op = OPERATIONS["concepts.minute-bars"];
222
+ if (!op)
223
+ throw new Error("concepts.minute-bars op missing");
224
+ return callOp(op, { concept_id: args.concept_id, from: args.from, to: args.to }, ctx);
225
+ },
226
+ backingOps: ["concepts.minute-bars"],
227
+ };
228
+ export const conceptsNewsSpec = {
229
+ name: "concepts_news",
230
+ description: "Recent news mentioning the concept name (ILIKE on news.title + content). Short-term implementation; long-term will switch to AI tagging. Requires `news:read` scope.",
231
+ inputSchema: {
232
+ concept_id: z.number().int().positive().describe("Concept primary key"),
233
+ days: z
234
+ .number()
235
+ .int()
236
+ .min(1)
237
+ .max(365)
238
+ .default(30)
239
+ .describe("Lookback window in days (1-365, default 30)"),
240
+ limit: z
241
+ .number()
242
+ .int()
243
+ .min(1)
244
+ .max(200)
245
+ .default(50)
246
+ .describe("Max items (1-200, default 50)"),
247
+ },
248
+ handler: async (args, ctx) => {
249
+ const op = OPERATIONS["concepts.news"];
250
+ if (!op)
251
+ throw new Error("concepts.news op missing");
252
+ return callOp(op, { concept_id: args.concept_id, days: args.days, limit: args.limit }, ctx);
253
+ },
254
+ backingOps: ["concepts.news"],
255
+ };
256
+ export const conceptsViewsSpec = {
257
+ name: "concepts_views",
258
+ description: "Broker views associated with a concept (concept_views × broker_views, ai_status=done), sorted by published_at desc. Primary research source for a theme. Requires `views:read` scope.",
259
+ inputSchema: {
260
+ concept_id: z.number().int().positive().describe("Concept primary key"),
261
+ limit: z
262
+ .number()
263
+ .int()
264
+ .min(1)
265
+ .max(200)
266
+ .default(50)
267
+ .describe("Max items (1-200, default 50)"),
268
+ },
269
+ handler: async (args, ctx) => {
270
+ const op = OPERATIONS["concepts.views"];
271
+ if (!op)
272
+ throw new Error("concepts.views op missing");
273
+ return callOp(op, { concept_id: args.concept_id, limit: args.limit }, ctx);
274
+ },
275
+ backingOps: ["concepts.views"],
276
+ };
277
+ export function buildConceptsCommand() {
278
+ const cmd = new Command("concepts").description("Concept indices — list / snapshot / show / alerts / alerts-history / daily-bars / minute-bars / news / views.");
279
+ const list = cmd.command("list").description(conceptsListSpec.description);
280
+ list.addOption(new Option("--status <s>", "Status filter").choices([...STATUS_VALUES]).default("active"));
281
+ list.addOption(new Option("--sort <field>", "Sort field").choices([...SORT_VALUES]).default("pct"));
282
+ list.addOption(new Option("--limit <n>", "Max items (1-1000)").default("500"));
283
+ list.action(async (opts) => {
284
+ if (!OPERATIONS["concepts.list"]) {
285
+ emitVerbError("internal_error", "concepts.list missing", undefined, 2);
286
+ }
287
+ await executeVerb(async (ctx) => conceptsListSpec.handler({
288
+ status: opts.status,
289
+ sort: opts.sort,
290
+ limit: clampInt(opts.limit, 1, 1000, 500),
291
+ }, ctx));
292
+ });
293
+ const snapshot = cmd.command("snapshot").description(conceptsSnapshotSpec.description);
294
+ snapshot.addOption(new Option("--codes <ids>", "Comma-separated concept_id list"));
295
+ snapshot.action(async (opts) => {
296
+ if (!OPERATIONS["concepts.snapshot"]) {
297
+ emitVerbError("internal_error", "concepts.snapshot missing", undefined, 2);
298
+ }
299
+ const args = {};
300
+ if (opts.codes)
301
+ args.codes = opts.codes;
302
+ await executeVerb(async (ctx) => conceptsSnapshotSpec.handler(args, ctx));
303
+ });
304
+ const show = cmd
305
+ .command("show <concept_id>")
306
+ .description(conceptsShowSpec.description);
307
+ show.action(async (conceptIdRaw) => {
308
+ if (!OPERATIONS["concepts.show"]) {
309
+ emitVerbError("internal_error", "concepts.show missing", undefined, 2);
310
+ }
311
+ await executeVerb(async (ctx) => conceptsShowSpec.handler({ concept_id: parseConceptId(conceptIdRaw) }, ctx));
312
+ });
313
+ const alerts = cmd.command("alerts").description(conceptsAlertsSpec.description);
314
+ alerts.action(async () => {
315
+ if (!OPERATIONS["concepts.alerts"]) {
316
+ emitVerbError("internal_error", "concepts.alerts missing", undefined, 2);
317
+ }
318
+ await executeVerb(async (ctx) => conceptsAlertsSpec.handler({}, ctx));
319
+ });
320
+ const alertsHist = cmd
321
+ .command("alerts-history")
322
+ .description(conceptsAlertsHistorySpec.description);
323
+ alertsHist.addOption(new Option("--from <unix_ms>", "Unix ms lower bound; default now-24h"));
324
+ alertsHist.addOption(new Option("--to <unix_ms>", "Unix ms upper bound; default now"));
325
+ alertsHist.addOption(new Option("--rule <r>", "Filter by rule").choices([...RULE_VALUES]));
326
+ alertsHist.addOption(new Option("--limit <n>", "Max items (1-2000)").default("500"));
327
+ alertsHist.action(async (opts) => {
328
+ if (!OPERATIONS["concepts.alerts-history"]) {
329
+ emitVerbError("internal_error", "concepts.alerts-history missing", undefined, 2);
330
+ }
331
+ const args = { limit: clampInt(opts.limit, 1, 2000, 500) };
332
+ if (opts.from !== undefined) {
333
+ const n = Math.floor(Number(opts.from));
334
+ if (Number.isFinite(n))
335
+ args.from = n;
336
+ }
337
+ if (opts.to !== undefined) {
338
+ const n = Math.floor(Number(opts.to));
339
+ if (Number.isFinite(n))
340
+ args.to = n;
341
+ }
342
+ if (opts.rule)
343
+ args.rule = opts.rule;
344
+ await executeVerb(async (ctx) => conceptsAlertsHistorySpec.handler(args, ctx));
345
+ });
346
+ const daily = cmd
347
+ .command("daily-bars <concept_id>")
348
+ .description(conceptsDailyBarsSpec.description);
349
+ daily.addOption(new Option("--from <YYYY-MM-DD>", "Inclusive start date").makeOptionMandatory(true));
350
+ daily.addOption(new Option("--to <YYYY-MM-DD>", "Inclusive end date").makeOptionMandatory(true));
351
+ daily.action(async (conceptIdRaw, opts) => {
352
+ if (!OPERATIONS["concepts.daily-bars"]) {
353
+ emitVerbError("internal_error", "concepts.daily-bars missing", undefined, 2);
354
+ }
355
+ await executeVerb(async (ctx) => conceptsDailyBarsSpec.handler({ concept_id: parseConceptId(conceptIdRaw), from: opts.from, to: opts.to }, ctx));
356
+ });
357
+ const minute = cmd
358
+ .command("minute-bars <concept_id>")
359
+ .description(conceptsMinuteBarsSpec.description);
360
+ minute.addOption(new Option("--from <YYYY-MM-DD>", "Inclusive start date").makeOptionMandatory(true));
361
+ minute.addOption(new Option("--to <YYYY-MM-DD>", "Inclusive end date (max 7 days)").makeOptionMandatory(true));
362
+ minute.action(async (conceptIdRaw, opts) => {
363
+ if (!OPERATIONS["concepts.minute-bars"]) {
364
+ emitVerbError("internal_error", "concepts.minute-bars missing", undefined, 2);
365
+ }
366
+ await executeVerb(async (ctx) => conceptsMinuteBarsSpec.handler({ concept_id: parseConceptId(conceptIdRaw), from: opts.from, to: opts.to }, ctx));
367
+ });
368
+ const news = cmd.command("news <concept_id>").description(conceptsNewsSpec.description);
369
+ news.addOption(new Option("--days <n>", "Lookback window (1-365)").default("30"));
370
+ news.addOption(new Option("--limit <n>", "Max items (1-200)").default("50"));
371
+ news.action(async (conceptIdRaw, opts) => {
372
+ if (!OPERATIONS["concepts.news"]) {
373
+ emitVerbError("internal_error", "concepts.news missing", undefined, 2);
374
+ }
375
+ await executeVerb(async (ctx) => conceptsNewsSpec.handler({
376
+ concept_id: parseConceptId(conceptIdRaw),
377
+ days: clampInt(opts.days, 1, 365, 30),
378
+ limit: clampInt(opts.limit, 1, 200, 50),
379
+ }, ctx));
380
+ });
381
+ const views = cmd.command("views <concept_id>").description(conceptsViewsSpec.description);
382
+ views.addOption(new Option("--limit <n>", "Max items (1-200)").default("50"));
383
+ views.action(async (conceptIdRaw, opts) => {
384
+ if (!OPERATIONS["concepts.views"]) {
385
+ emitVerbError("internal_error", "concepts.views missing", undefined, 2);
386
+ }
387
+ await executeVerb(async (ctx) => conceptsViewsSpec.handler({
388
+ concept_id: parseConceptId(conceptIdRaw),
389
+ limit: clampInt(opts.limit, 1, 200, 50),
390
+ }, ctx));
391
+ });
392
+ return cmd;
393
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Killer "one-shot research" verb:
5
5
  *
6
- * one canonical_code → views + research + quote + sentiment + news
6
+ * one canonical_code → views + quote + snapshot + sentiment + news
7
7
  *
8
8
  * Plan-doc §3.3 makes this the "agent's opening move": one call returns the
9
9
  * full picture of a security. Buckets stay separated (no ranking imposed) so
@@ -24,11 +24,13 @@
24
24
  *
25
25
  * Why these 5 ops in the fallback:
26
26
  * - views.recent : PRIMARY research signal (analyst opinions on this code)
27
- * - research.entity-performance-list : quality layer for view authors
28
27
  * - quote : current price / change %
28
+ * - financials.quote-snapshot : 14-field valuation (PE/PB/PS/换手/股息/量比) on this code
29
29
  * - sentiment.overview : market regime context (not per-code; intentional —
30
30
  * no per-code sentiment op exists)
31
- * - news.search : SUPPLEMENTARY breadth (event stream filter on code)
31
+ * - news.list : SUPPLEMENTARY breadth (canonical_code-exact news,
32
+ * via news_item_security mapping table — NOT keyword search,
33
+ * since article text never contains 'SSE:600519' literally)
32
34
  *
33
35
  * Windows differ on purpose: views=168h (7d), news=24h. Encodes the rule
34
36
  * that research time-horizon > news time-horizon.
@@ -40,11 +42,15 @@ import { CallApiError } from "../runtime/errors.js";
40
42
  import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
41
43
  import { callOp } from "../runtime/verb_runner.js";
42
44
  /** Buckets in the order the description establishes (views first, news last). */
43
- const BUCKETS = ["views", "research", "quote", "sentiment", "news"];
45
+ const BUCKETS = ["views", "quote", "snapshot", "sentiment", "news"];
46
+ // digest 故意保持 A 股 only:fan-out 里 quote / 14-field snapshot / sentiment 都是
47
+ // StockPulse Rust API 上的 A 股 endpoint,HK/US 没有 capability(PR #D capability gate
48
+ // 只把 HK/US 加进 search,没有 quote/chart)。混进 HK/US 会得到 partial_failures 一片红。
49
+ // 等 HK/US 行情接入再放开(独立立项)。
44
50
  const CODE_REGEX = /^(SSE|SZSE|BSE|SH|SZ|BJ):[0-9]{6}$/;
45
51
  export const digestSpec = {
46
52
  name: "digest",
47
- description: "One-shot research digest for a single security: fan-out across views (PRIMARY), research-entity performance, quote, market sentiment, and supplementary news. Returns separated buckets — does NOT rank views vs news for the agent. Partial failures surface in meta.partial_failures[] without poisoning successful buckets. Use as the agent's first call when asked 'what's going on with <stock>'.",
53
+ description: "One-shot research digest for a single security: fan-out across views (PRIMARY), quote, 14-field valuation snapshot (PE/PB/PS/turnover/dividend/volume-ratio), market sentiment, and supplementary news. Returns separated buckets — does NOT rank views vs news for the agent. Partial failures surface in meta.partial_failures[] without poisoning successful buckets. Use as the agent's first call when asked 'what's going on with <stock>'. AH dual-listing: for an A+H listed company always pass the A-share canonical (e.g. SSE:601398), not the HK side.",
48
54
  inputSchema: {
49
55
  code: z
50
56
  .string()
@@ -70,7 +76,7 @@ export const digestSpec = {
70
76
  .min(1)
71
77
  .max(50)
72
78
  .default(10)
73
- .describe("Per-bucket item cap (views/news). Quote/sentiment/research aren't paginated here."),
79
+ .describe("Per-bucket item cap (views/news). Quote/snapshot/sentiment aren't paginated here."),
74
80
  },
75
81
  handler: async (args, ctx) => runDigest(args, ctx),
76
82
  // Backing ops are listed in two groups: digest.get is the primary
@@ -84,10 +90,10 @@ export const digestSpec = {
84
90
  backingOps: [
85
91
  "digest.get",
86
92
  "views.recent",
87
- "research.entity-performance-list",
88
93
  "quote",
94
+ "financials.quote-snapshot",
89
95
  "sentiment.overview",
90
- "news.search",
96
+ "news.list",
91
97
  ],
92
98
  };
93
99
  /**
@@ -153,10 +159,10 @@ function shouldFallbackToFanout(e) {
153
159
  function buildBucketCalls(args, ctx) {
154
160
  const opsRequired = {
155
161
  views: "views.recent",
156
- research: "research.entity-performance-list",
157
162
  quote: "quote",
163
+ snapshot: "financials.quote-snapshot",
158
164
  sentiment: "sentiment.overview",
159
- news: "news.search",
165
+ news: "news.list",
160
166
  };
161
167
  const calls = [];
162
168
  for (const bucket of BUCKETS) {
@@ -184,18 +190,18 @@ function buildBucketCalls(args, ctx) {
184
190
  limit: args.limit_per_bucket,
185
191
  };
186
192
  break;
187
- case "research":
188
- opArgs = {};
189
- break;
190
193
  case "quote":
191
194
  opArgs = { codes: [args.code] };
192
195
  break;
196
+ case "snapshot":
197
+ opArgs = { code: args.code };
198
+ break;
193
199
  case "sentiment":
194
200
  opArgs = { scope: "all_a_ex_st" };
195
201
  break;
196
202
  case "news":
197
203
  opArgs = {
198
- query: args.code,
204
+ security: args.code,
199
205
  since_hours: args.news_hours,
200
206
  limit: args.limit_per_bucket,
201
207
  };
@@ -328,7 +334,10 @@ export function buildDigestCommand() {
328
334
  cmd.addOption(new Option("--limit-per-bucket <n>", "Items per bucket (1-50)").default("10"));
329
335
  cmd.action(async (opts) => {
330
336
  if (!CODE_REGEX.test(opts.code)) {
331
- emitVerbError("invalid_args", `code ${JSON.stringify(opts.code)} is not a canonical_code (expected SSE|SZSE|BSE:NNNNNN)`, "Use `echopai lookup --text <name>` to resolve to a canonical_code first.", 1);
337
+ const isHkUs = /^(HK|US):/.test(opts.code);
338
+ emitVerbError("invalid_args", `code ${JSON.stringify(opts.code)} is not a canonical_code (expected SSE|SZSE|BSE:NNNNNN)`, isHkUs
339
+ ? "digest fan-out depends on A-share quote/chart/sentiment endpoints — HK/US not yet supported. Use `search` or `views` for HK/US."
340
+ : "Use `echopai lookup --text <name>` to resolve to a canonical_code first.", 1);
332
341
  }
333
342
  const args = {
334
343
  code: opts.code,