echopai 2.4.0 → 2.6.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 (2) hide show
  1. package/dist/bin.js +340 -134
  2. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -229,6 +229,66 @@ var OPERATIONS = {
229
229
  additionalProperties: false
230
230
  }
231
231
  },
232
+ "announcements.search": {
233
+ cliKey: "announcements.search",
234
+ cliName: "announcements search",
235
+ method: "GET",
236
+ path: "/v1/announcements/search",
237
+ description: "公告混合搜索:名称 / 代码做结构化过滤 + 关键词 BM25 全文(ParadeDB jieba,覆盖 title / ai_summary / content_md 前 1500 字)。q 含 6 位代码或内嵌 A 股名 → 按代码精确过滤;残余或纯文本 → BM25 按相关度×时间衰减排序;纯代码/名称则按 published_at 倒序列出。需要 `announcements:read` scope。",
238
+ summary: "Hybrid search over A-share announcements",
239
+ positional: [],
240
+ outputDefault: "json",
241
+ pagination: "offset",
242
+ stream: false,
243
+ billable: true,
244
+ idempotencyRequired: false,
245
+ scopesAny: [
246
+ "announcements:read"
247
+ ],
248
+ sideEffect: "read",
249
+ dryRunSupported: false,
250
+ inputSchema: {
251
+ type: "object",
252
+ properties: {
253
+ q: {
254
+ type: "string",
255
+ minLength: 1,
256
+ maxLength: 100,
257
+ description: '名称 / 代码 / 关键词,可混合(如 "新华医疗 回购"、"600587"、"回购")。',
258
+ example: "新华医疗 回购"
259
+ },
260
+ type: {
261
+ type: "string",
262
+ maxLength: 64,
263
+ description: "分类 slug 过滤(与 /v1/announcements/feed 同集合)。"
264
+ },
265
+ since_days: {
266
+ type: "integer",
267
+ minimum: 1,
268
+ maximum: 1825,
269
+ default: 90,
270
+ description: "回看天数(最多 5 年)。"
271
+ },
272
+ limit: {
273
+ type: "integer",
274
+ minimum: 1,
275
+ maximum: 200,
276
+ default: 50,
277
+ description: "Max items per page."
278
+ },
279
+ offset: {
280
+ type: "integer",
281
+ minimum: 0,
282
+ default: 0,
283
+ description: "Pagination offset (items to skip)."
284
+ }
285
+ },
286
+ additionalProperties: false,
287
+ required: [
288
+ "q"
289
+ ]
290
+ }
291
+ },
232
292
  "announcements.stock": {
233
293
  cliKey: "announcements.stock",
234
294
  cliName: "announcements stock",
@@ -960,9 +1020,9 @@ Phase 5.2 (server endpoint).
960
1020
  views_since_days: {
961
1021
  type: "integer",
962
1022
  minimum: 1,
963
- maximum: 90,
1023
+ maximum: 30,
964
1024
  default: 7,
965
- description: "Views lookback in days (1-90)."
1025
+ description: "Views lookback in days (1-30; views feed caps at 30d. Use the `views` verb's from/to for longer history)."
966
1026
  },
967
1027
  news_hours: {
968
1028
  type: "integer",
@@ -1489,6 +1549,39 @@ Phase 5.2 (server endpoint).
1489
1549
  additionalProperties: false
1490
1550
  }
1491
1551
  },
1552
+ "limit-up.analysis": {
1553
+ cliKey: "limit-up.analysis",
1554
+ cliName: "limit-up analysis",
1555
+ method: "GET",
1556
+ path: "/v1/limit-up/analysis",
1557
+ description: '涨停股 LLM 归因:逐股「主导题材 leading_concept + 概念标签 concept_tags +\n涨停理由 reason」。题材由 LLM 依据最新研报 / 机构观点 / 快讯判定,**不依赖概念\n图谱**(可含图谱里还没有的全新题材),每交易日北京 9:45 / 11:00 / 12:30 / 14:00 /\n14:40 / 18:00 共 6 个时段自动刷新(后跑时段在前次基础上 refine)。\n\n与 `limit-up/pool` 互补:pool 给"涨停了哪些票"(行情数据,stockpulse 上游),\n本接口给"为什么涨停 / 属什么题材"(LLM 归因,echopai api 上游)。pool 里的\ngraph-based `leading_concept` 是另一条数据,本接口的 `leading_concept` 才是\nLLM 判定结果。\n\n⚠️ **trade_date 不传 = 库内最新「已归因」交易日**(非自然最新交易日):归因每个\n时段按当时池快照写库,今日**首个时段(北京约 09:47)落库前**,不传 trade_date\n会返回**上一交易日**的归因(不是今日空结果)——agent 拿当日数据请显式传今日\ntrade_date 并以 `trade_date` / `generated_at` 字段核对时效。显式传某交易日而该日\n尚未归因时,`analyses` 为空。\n\n平面归属:本接口在 **vu 平面**(前端 apiFetch 同源),与 `market/status` 同款\n`x-audience: vu`;行情类 `limit-up/pool|summary|history` 在 sp 平面(前端 spFetch)。\n需要 `market:read` 或任一 `quote:*` scope。\n',
1558
+ summary: "A-share limit-up LLM attribution (per-stock leading theme + reason)",
1559
+ positional: [],
1560
+ outputDefault: "json",
1561
+ pagination: "none",
1562
+ stream: false,
1563
+ billable: true,
1564
+ idempotencyRequired: false,
1565
+ scopesAny: [
1566
+ "market:read",
1567
+ "quote:l1",
1568
+ "quote:l2",
1569
+ "quote:delayed"
1570
+ ],
1571
+ sideEffect: "read",
1572
+ dryRunSupported: false,
1573
+ inputSchema: {
1574
+ type: "object",
1575
+ properties: {
1576
+ trade_date: {
1577
+ type: "string",
1578
+ format: "date",
1579
+ description: "ISO date YYYY-MM-DD;不传走库内**最新已归因**交易日(今日首段落库前会回退到上一交易日)。"
1580
+ }
1581
+ },
1582
+ additionalProperties: false
1583
+ }
1584
+ },
1492
1585
  "limit-up.history": {
1493
1586
  cliKey: "limit-up.history",
1494
1587
  cliName: "limit-up history",
@@ -1723,7 +1816,17 @@ Phase 5.2 (server endpoint).
1723
1816
  minimum: 1,
1724
1817
  maximum: 720,
1725
1818
  default: 24,
1726
- description: "Lookback window in hours (max 720 = 30 days)."
1819
+ description: "Rolling-feed lookback in hours (720 = 30d). For longer history use from/to."
1820
+ },
1821
+ from: {
1822
+ type: "string",
1823
+ format: "date",
1824
+ description: "History range start (China date YYYY-MM-DD). Range mode: with the security filter present, long history is capped at 365 days and Pro-only (non-Pro clamped to 30 days)."
1825
+ },
1826
+ to: {
1827
+ type: "string",
1828
+ format: "date",
1829
+ description: "History range end (inclusive, China date; defaults to today). Only used with from."
1727
1830
  },
1728
1831
  limit: {
1729
1832
  type: "integer",
@@ -1731,6 +1834,12 @@ Phase 5.2 (server endpoint).
1731
1834
  maximum: 100,
1732
1835
  default: 20,
1733
1836
  description: "Max items per page."
1837
+ },
1838
+ offset: {
1839
+ type: "integer",
1840
+ minimum: 0,
1841
+ default: 0,
1842
+ description: "Pagination offset (page through long history ranges)."
1734
1843
  }
1735
1844
  },
1736
1845
  additionalProperties: false,
@@ -1770,9 +1879,19 @@ Phase 5.2 (server endpoint).
1770
1879
  since_hours: {
1771
1880
  type: "integer",
1772
1881
  minimum: 1,
1773
- maximum: 168,
1882
+ maximum: 720,
1774
1883
  default: 24,
1775
- description: "Lookback window in hours."
1884
+ description: "Rolling-feed lookback in hours (≤720 = 30d). For longer history use from/to."
1885
+ },
1886
+ from: {
1887
+ type: "string",
1888
+ format: "date",
1889
+ description: "History range start (China date YYYY-MM-DD). Range mode: with the query filter present, long history is capped at 365 days and Pro-only (non-Pro clamped to 30 days)."
1890
+ },
1891
+ to: {
1892
+ type: "string",
1893
+ format: "date",
1894
+ description: "History range end (inclusive, China date; defaults to today). Only used with from."
1776
1895
  },
1777
1896
  limit: {
1778
1897
  type: "integer",
@@ -1780,6 +1899,12 @@ Phase 5.2 (server endpoint).
1780
1899
  maximum: 100,
1781
1900
  default: 20,
1782
1901
  description: "Max items per page."
1902
+ },
1903
+ offset: {
1904
+ type: "integer",
1905
+ minimum: 0,
1906
+ default: 0,
1907
+ description: "Pagination offset (page through long history ranges)."
1783
1908
  }
1784
1909
  },
1785
1910
  additionalProperties: false,
@@ -2330,70 +2455,6 @@ L1/L2/L3(X5001 食品饮料 / X500102 白酒 等)。
2330
2455
  additionalProperties: false
2331
2456
  }
2332
2457
  },
2333
- "squawk.audio": {
2334
- cliKey: "squawk.audio",
2335
- cliName: "squawk audio",
2336
- method: "GET",
2337
- path: "/v1/squawk/{item_id}/audio",
2338
- description: "返回 squawk 项的 TTS 音频(MP3)。直接 stream,可作为 `<audio>` src 用。",
2339
- summary: "Stream squawk TTS audio",
2340
- positional: [
2341
- "item_id"
2342
- ],
2343
- outputDefault: "json",
2344
- pagination: "none",
2345
- stream: false,
2346
- billable: false,
2347
- idempotencyRequired: false,
2348
- scopesAny: [],
2349
- sideEffect: "read",
2350
- dryRunSupported: false,
2351
- inputSchema: {
2352
- type: "object",
2353
- properties: {
2354
- item_id: {
2355
- type: "string",
2356
- description: "Squawk item id (returned by squawk.recent items[].id)."
2357
- }
2358
- },
2359
- additionalProperties: false,
2360
- required: [
2361
- "item_id"
2362
- ]
2363
- }
2364
- },
2365
- "squawk.waveform": {
2366
- cliKey: "squawk.waveform",
2367
- cliName: "squawk waveform",
2368
- method: "GET",
2369
- path: "/v1/squawk/{item_id}/waveform",
2370
- description: "返回 squawk 音频的预计算波形 peak 数据,用于客户端波形可视化。",
2371
- summary: "Waveform peaks for squawk audio",
2372
- positional: [
2373
- "item_id"
2374
- ],
2375
- outputDefault: "json",
2376
- pagination: "none",
2377
- stream: false,
2378
- billable: false,
2379
- idempotencyRequired: false,
2380
- scopesAny: [],
2381
- sideEffect: "read",
2382
- dryRunSupported: false,
2383
- inputSchema: {
2384
- type: "object",
2385
- properties: {
2386
- item_id: {
2387
- type: "string",
2388
- description: "Squawk item id (returned by squawk.recent items[].id)."
2389
- }
2390
- },
2391
- additionalProperties: false,
2392
- required: [
2393
- "item_id"
2394
- ]
2395
- }
2396
- },
2397
2458
  "stocks.hot": {
2398
2459
  cliKey: "stocks.hot",
2399
2460
  cliName: "stocks hot",
@@ -2586,9 +2647,19 @@ L1/L2/L3(X5001 食品饮料 / X500102 白酒 等)。
2586
2647
  since_days: {
2587
2648
  type: "integer",
2588
2649
  minimum: 1,
2589
- maximum: 90,
2650
+ maximum: 30,
2590
2651
  default: 7,
2591
- description: "Lookback window in days. (Translated to hours upstream.)"
2652
+ description: "Rolling-feed lookback in days (≤30, translated to hours upstream). For longer history use from/to instead."
2653
+ },
2654
+ from: {
2655
+ type: "string",
2656
+ format: "date",
2657
+ description: "History range start (China calendar date YYYY-MM-DD). When set, switches to range mode: requires a narrowing filter (security/institution/analyst); long history is capped at 365 days and is Pro-only (non-Pro silently clamped to 30 days)."
2658
+ },
2659
+ to: {
2660
+ type: "string",
2661
+ format: "date",
2662
+ description: "History range end (inclusive, China date; defaults to today). Only used with from."
2592
2663
  },
2593
2664
  limit: {
2594
2665
  type: "integer",
@@ -2596,6 +2667,12 @@ L1/L2/L3(X5001 食品饮料 / X500102 白酒 等)。
2596
2667
  maximum: 100,
2597
2668
  default: 30,
2598
2669
  description: "Max items per page."
2670
+ },
2671
+ offset: {
2672
+ type: "integer",
2673
+ minimum: 0,
2674
+ default: 0,
2675
+ description: "Pagination offset (page through long history ranges)."
2599
2676
  }
2600
2677
  },
2601
2678
  additionalProperties: false
@@ -2677,6 +2754,10 @@ function buildCommandTree(program, dispatch) {
2677
2754
  const cmd = noun.command("feed");
2678
2755
  attachOperation(cmd, OPERATIONS["announcements.feed"], dispatch);
2679
2756
  }
2757
+ {
2758
+ const cmd = noun.command("search");
2759
+ attachOperation(cmd, OPERATIONS["announcements.search"], dispatch);
2760
+ }
2680
2761
  {
2681
2762
  const cmd = noun.command("stock");
2682
2763
  attachOperation(cmd, OPERATIONS["announcements.stock"], dispatch);
@@ -2821,6 +2902,10 @@ function buildCommandTree(program, dispatch) {
2821
2902
  {
2822
2903
  const noun = program.command("limit-up");
2823
2904
  noun.description("limit-up commands");
2905
+ {
2906
+ const cmd = noun.command("analysis");
2907
+ attachOperation(cmd, OPERATIONS["limit-up.analysis"], dispatch);
2908
+ }
2824
2909
  {
2825
2910
  const cmd = noun.command("history");
2826
2911
  attachOperation(cmd, OPERATIONS["limit-up.history"], dispatch);
@@ -2938,18 +3023,6 @@ function buildCommandTree(program, dispatch) {
2938
3023
  attachOperation(cmd, OPERATIONS["sentiment.turnover"], dispatch);
2939
3024
  }
2940
3025
  }
2941
- {
2942
- const noun = program.command("squawk");
2943
- noun.description("squawk commands");
2944
- {
2945
- const cmd = noun.command("audio");
2946
- attachOperation(cmd, OPERATIONS["squawk.audio"], dispatch);
2947
- }
2948
- {
2949
- const cmd = noun.command("waveform");
2950
- attachOperation(cmd, OPERATIONS["squawk.waveform"], dispatch);
2951
- }
2952
- }
2953
3026
  {
2954
3027
  const noun = program.command("stocks");
2955
3028
  noun.description("stocks commands");
@@ -3756,7 +3829,7 @@ import os2 from "node:os";
3756
3829
  import path2 from "node:path";
3757
3830
 
3758
3831
  // src/version.ts
3759
- var CLI_VERSION = "2.4.0";
3832
+ var CLI_VERSION = "2.5.0";
3760
3833
 
3761
3834
  // src/runtime/update_check.ts
3762
3835
  var UPDATE_CACHE_PATH = path2.join(os2.homedir(), ".config", "echopai", "update_cache.json");
@@ -4583,6 +4656,32 @@ function buildQueryString3(params, method) {
4583
4656
  var CODE_RE = /^(SSE|SZSE|BSE|SH|SZ|BJ):[0-9]{6}$/;
4584
4657
  var ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
4585
4658
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4659
+ var announcementsSearchSpec = {
4660
+ name: "announcements_search",
4661
+ description: 'Hybrid search over A-share announcements (cninfo): mix Chinese company name / 6-digit code / free-text keyword in one query. Name or code → structured filter by stock; keyword → BM25 full-text (ParadeDB jieba over title + ai_summary + first 1500 chars of body), ranked by relevance × recency. Use when you DON\'T already have a canonical_code — e.g. "新华医疗 回购", "600587", or just "回购". For a known stock\'s full disclosure history prefer `announcements_stock`; for the market-wide recent feed prefer `announcements_feed`.',
4662
+ inputSchema: {
4663
+ q: z.string().min(1).max(100).describe('名称 / 代码 / 关键词,可混合(如 "新华医疗 回购"、"600587"、"回购")'),
4664
+ type: z.string().max(64).optional().describe("分类 slug 过滤(与 feed 同集合)"),
4665
+ since_days: z.number().int().min(1).max(1825).default(90).describe("回看天数(1-1825,默认 90)"),
4666
+ limit: z.number().int().min(1).max(200).default(50).describe("Max items (1-200, default 50)"),
4667
+ offset: z.number().int().min(0).default(0).describe("Pagination offset")
4668
+ },
4669
+ handler: async (args, ctx) => {
4670
+ const op = OPERATIONS["announcements.search"];
4671
+ if (!op)
4672
+ throw new Error("announcements.search op missing");
4673
+ const callArgs = {
4674
+ q: args.q,
4675
+ since_days: args.since_days,
4676
+ limit: args.limit,
4677
+ offset: args.offset
4678
+ };
4679
+ if (args.type)
4680
+ callArgs.type = args.type;
4681
+ return callOp(op, callArgs, ctx);
4682
+ },
4683
+ backingOps: ["announcements.search"]
4684
+ };
4586
4685
  var announcementsFeedSpec = {
4587
4686
  name: "announcements_feed",
4588
4687
  description: "Recent A-share announcement feed (cninfo source, sorted by published_at desc). Filter by `type` slug (e.g. annual_report / equity_change / restructuring) or `since` lower bound. Use when surveying market-wide disclosures over a recent window — for a specific stock prefer `announcements_stock`.",
@@ -4659,7 +4758,27 @@ function clampInt(raw, min, max, fallback) {
4659
4758
  return n;
4660
4759
  }
4661
4760
  function buildAnnouncementsCommand() {
4662
- const cmd = new Command3("announcements").description("A-share announcements (cninfo) — recent feed, per-stock history, single-item detail.");
4761
+ const cmd = new Command3("announcements").description("A-share announcements (cninfo) — hybrid search, recent feed, per-stock history, single-item detail.");
4762
+ const search = cmd.command("search").description(announcementsSearchSpec.description);
4763
+ search.addOption(new Option2("--q <text>", "名称 / 代码 / 关键词,可混合").makeOptionMandatory(true));
4764
+ search.addOption(new Option2("--type <slug>", "Filter by 分类 slug"));
4765
+ search.addOption(new Option2("--since-days <n>", "Lookback window in days (1-1825)").default("90"));
4766
+ search.addOption(new Option2("--limit <n>", "Max items (1-200)").default("50"));
4767
+ search.addOption(new Option2("--offset <n>", "Pagination offset").default("0"));
4768
+ search.action(async (opts) => {
4769
+ if (!OPERATIONS["announcements.search"]) {
4770
+ emitVerbError("internal_error", "announcements.search missing", undefined, 2);
4771
+ }
4772
+ const args = {
4773
+ q: opts.q,
4774
+ since_days: clampInt(opts.sinceDays, 1, 1825, 90),
4775
+ limit: clampInt(opts.limit, 1, 200, 50),
4776
+ offset: Math.max(0, Math.floor(Number(opts.offset)) || 0)
4777
+ };
4778
+ if (opts.type)
4779
+ args.type = opts.type;
4780
+ await executeVerb(async (ctx) => announcementsSearchSpec.handler(args, ctx));
4781
+ });
4663
4782
  const feed = cmd.command("feed").description(announcementsFeedSpec.description);
4664
4783
  feed.addOption(new Option2("--type <slug>", "Filter by 分类 slug (annual_report, equity_change, ...)"));
4665
4784
  feed.addOption(new Option2("--since <ISO-datetime>", "Lower bound published_at (RFC3339)"));
@@ -5178,7 +5297,7 @@ var digestSpec = {
5178
5297
  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, supplementary news, and ownership_events (last-30d insider holder trades + share repurchases, plus next-30d lockup expirations — structured corporate-action reinforcement). 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>'. Works across A-share | HK | US: A-share returns all buckets, while HK/US (e.g. HK:00700, US:BABA) return only the views + news buckets — quote / valuation / sentiment / ownership are A-share-only and are skipped (not reported as failures). AH dual-listing: for an A+H listed company pass the A-share canonical (e.g. SSE:601398) for the full picture; use the HK code only if the user explicitly wants the HK side.",
5179
5298
  inputSchema: {
5180
5299
  code: z5.string().regex(CODE_REGEX).describe("Canonical code: A-share SSE:600519 | HK HK:00700 | US US:BABA. HK/US return only views+news. Use `lookup` first if you only have a name."),
5181
- views_since_days: z5.number().int().min(1).max(90).default(7).describe("Lookback for views bucket (days, default 7 research horizon)"),
5300
+ views_since_days: z5.number().int().min(1).max(30).default(7).describe("Lookback for views bucket (days, 1-30; views feed caps at 30d). For longer research history use the `views` verb's from/to."),
5182
5301
  news_hours: z5.number().int().min(1).max(168).default(24).describe("Lookback for news bucket (hours, default 24 — event horizon)"),
5183
5302
  limit_per_bucket: z5.number().int().min(1).max(50).default(10).describe("Per-bucket item cap (views/news). Quote/snapshot/sentiment aren't paginated here.")
5184
5303
  },
@@ -5391,7 +5510,7 @@ function clamp2(raw, min, max, fallback) {
5391
5510
  function buildDigestCommand() {
5392
5511
  const cmd = new Command7(digestSpec.name).description(digestSpec.description);
5393
5512
  cmd.addOption(new Option6("--code <canonical_code>", "Canonical code: SSE:600519 | HK:00700 | US:BABA (HK/US return only views+news)").makeOptionMandatory(true));
5394
- cmd.addOption(new Option6("--views-since-days <n>", "Views lookback in days (1-90)").default("7"));
5513
+ cmd.addOption(new Option6("--views-since-days <n>", "Views lookback in days (1-30)").default("7"));
5395
5514
  cmd.addOption(new Option6("--news-hours <n>", "News lookback in hours (1-168)").default("24"));
5396
5515
  cmd.addOption(new Option6("--limit-per-bucket <n>", "Items per bucket (1-50)").default("10"));
5397
5516
  cmd.action(async (opts) => {
@@ -5400,7 +5519,7 @@ function buildDigestCommand() {
5400
5519
  }
5401
5520
  const args = {
5402
5521
  code: opts.code,
5403
- views_since_days: clamp2(opts.viewsSinceDays, 1, 90, 7),
5522
+ views_since_days: clamp2(opts.viewsSinceDays, 1, 30, 7),
5404
5523
  news_hours: clamp2(opts.newsHours, 1, 168, 24),
5405
5524
  limit_per_bucket: clamp2(opts.limitPerBucket, 1, 50, 10)
5406
5525
  };
@@ -5771,8 +5890,25 @@ var limitUpHistorySpec = {
5771
5890
  },
5772
5891
  backingOps: ["limit-up.history"]
5773
5892
  };
5893
+ var limitUpAnalysisSpec = {
5894
+ name: "limit_up_analysis",
5895
+ description: "Per-stock limit-up LLM attribution for a given trade date: leading_concept (主导题材) + concept_tags (相关标签) + reason (涨停理由). Themes are judged by an LLM from the latest research / views / news (NOT from the concept graph, so brand-new themes are covered) and refreshed across 6 Beijing slots daily. Complements limit-up pool (which says '哪些票涨停'); this says '为什么涨停/属什么题材'. STALE-DATA WARNING: omitting trade_date returns the latest *attributed* trading day, NOT necessarily today — before today's first slot lands (~09:47 Beijing) it falls back to the PREVIOUS day's attribution. For today's data pass trade_date explicitly and verify the returned trade_date / generated_at fields. An explicit trade_date with no attribution yet returns an empty analyses list.",
5896
+ inputSchema: {
5897
+ trade_date: z8.string().regex(DATE_RE5).optional().describe("ISO date YYYY-MM-DD; omit for the latest *attributed* day (falls back to the previous day until today's first slot lands ~09:47 Beijing — pass today explicitly for today's data)")
5898
+ },
5899
+ handler: async (args, ctx) => {
5900
+ const op = OPERATIONS["limit-up.analysis"];
5901
+ if (!op)
5902
+ throw new Error("limit-up.analysis op missing");
5903
+ const callArgs = {};
5904
+ if (args.trade_date)
5905
+ callArgs.trade_date = args.trade_date;
5906
+ return callOp(op, callArgs, ctx);
5907
+ },
5908
+ backingOps: ["limit-up.analysis"]
5909
+ };
5774
5910
  function buildLimitUpCommand() {
5775
- const cmd = new Command11("limit-up").description("A-share limit-up board — pool (per-stock detail) / summary (counts + ladder) / history (daily trend).");
5911
+ const cmd = new Command11("limit-up").description("A-share limit-up board — pool (per-stock detail) / summary (counts + ladder) / history (daily trend) / analysis (LLM 题材归因).");
5776
5912
  const pool = cmd.command("pool").description(limitUpPoolSpec.description);
5777
5913
  pool.addOption(new Option9("--trade-date <YYYY-MM-DD>", "Trade date; omit for latest"));
5778
5914
  pool.addOption(new Option9("--include <s>", "active = 排除 ST/退市; all = 全部").choices([...INCLUDE_VALUES]).default("active"));
@@ -5809,6 +5945,17 @@ function buildLimitUpCommand() {
5809
5945
  }
5810
5946
  await executeVerb(async (ctx) => limitUpHistorySpec.handler({ days: clampInt5(opts.days, 1, 250, 30) }, ctx));
5811
5947
  });
5948
+ const analysis = cmd.command("analysis").description(limitUpAnalysisSpec.description);
5949
+ analysis.addOption(new Option9("--trade-date <YYYY-MM-DD>", "Trade date; omit for latest"));
5950
+ analysis.action(async (opts) => {
5951
+ if (!OPERATIONS["limit-up.analysis"]) {
5952
+ emitVerbError("internal_error", "limit-up.analysis missing", undefined, 2);
5953
+ }
5954
+ const args = {};
5955
+ if (opts.tradeDate)
5956
+ args.trade_date = opts.tradeDate;
5957
+ await executeVerb(async (ctx) => limitUpAnalysisSpec.handler(args, ctx));
5958
+ });
5812
5959
  return cmd;
5813
5960
  }
5814
5961
  // src/verbs/lookup.ts
@@ -6019,29 +6166,46 @@ var newsSpec = {
6019
6166
  inputSchema: {
6020
6167
  code: z11.string().regex(/^((SSE|SZSE|BSE):[0-9]{6}|HK:[0-9]{5}|US:[A-Z][A-Z0-9.\-]{0,5})$/).optional().describe("Filter by security canonical_code (e.g. SSE:600519, HK:00700). Takes precedence over `query`."),
6021
6168
  query: z11.string().optional().describe("Free-text query (Chinese or English) matched against news content; omit for time-window feed. Do NOT pass canonical_code here — use `code` instead."),
6022
- hours: z11.number().int().min(1).max(168).default(24).describe("Lookback window in hours (default 24 — much narrower than views)"),
6023
- limit: z11.number().int().min(1).max(100).default(20).describe("Max items")
6169
+ hours: z11.number().int().min(1).max(168).default(24).describe("Rolling-feed lookback in hours (default 24 — much narrower than views). For longer history use from/to with code or query."),
6170
+ from: z11.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("History range start (China date YYYY-MM-DD). Range mode: needs code or query; Pro-only up to 365 days back."),
6171
+ to: z11.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("History range end (inclusive, China date; defaults to today). Use with from."),
6172
+ offset: z11.number().int().min(0).optional().describe("Pagination offset for paging through long history ranges."),
6173
+ limit: z11.number().int().min(1).max(100).default(20).describe("Max items per page")
6024
6174
  },
6025
6175
  handler: async (args, ctx) => {
6026
- if (args.code) {
6027
- const op2 = OPERATIONS["news.list"];
6028
- if (!op2)
6029
- throw new Error("news.list op missing");
6030
- return callOp(op2, { security: args.code, since_hours: args.hours, limit: args.limit }, ctx);
6031
- }
6032
- if (args.query) {
6033
- const op2 = OPERATIONS["news.search"];
6034
- if (!op2)
6035
- throw new Error("news.search op missing");
6036
- return callOp(op2, { query: args.query, since_hours: args.hours, limit: args.limit }, ctx);
6037
- }
6038
- const op = OPERATIONS["news.feed"];
6176
+ const { opKey, callArgs } = buildNewsCall(args);
6177
+ const op = OPERATIONS[opKey];
6039
6178
  if (!op)
6040
- throw new Error("news.feed op missing");
6041
- return callOp(op, {}, ctx);
6179
+ throw new Error(`${opKey} op missing`);
6180
+ return callOp(op, callArgs, ctx);
6042
6181
  },
6043
6182
  backingOps: ["news.list", "news.search", "news.feed"]
6044
6183
  };
6184
+ function buildNewsCall(args) {
6185
+ const hasRange = typeof args.from === "string" && args.from.length > 0;
6186
+ const withWindow = (a) => {
6187
+ if (typeof args.limit === "number")
6188
+ a.limit = args.limit;
6189
+ if (typeof args.offset === "number")
6190
+ a.offset = args.offset;
6191
+ if (hasRange) {
6192
+ a.from = args.from;
6193
+ if (typeof args.to === "string" && args.to.length > 0)
6194
+ a.to = args.to;
6195
+ } else {
6196
+ a.since_hours = args.hours;
6197
+ }
6198
+ return a;
6199
+ };
6200
+ if (args.code)
6201
+ return { opKey: "news.list", callArgs: withWindow({ security: args.code }) };
6202
+ if (args.query)
6203
+ return { opKey: "news.search", callArgs: withWindow({ query: args.query }) };
6204
+ if (hasRange) {
6205
+ throw new Error("range history (from/to) requires --code or --query; the bare feed has no narrowing filter");
6206
+ }
6207
+ return { opKey: "news.feed", callArgs: {} };
6208
+ }
6045
6209
  function clamp3(raw, min, max, fallback) {
6046
6210
  const n = Math.floor(Number(raw));
6047
6211
  if (!Number.isFinite(n))
@@ -6056,17 +6220,25 @@ function buildNewsCommand() {
6056
6220
  const cmd = new Command14(newsSpec.name).description(newsSpec.description);
6057
6221
  cmd.addOption(new Option12("--code <canonical_code>", "Filter by security canonical_code (A | HK | US, e.g. SSE:600519, HK:00700); takes precedence over --query"));
6058
6222
  cmd.addOption(new Option12("--query <text>", "Free-text query; omit for time-window feed"));
6059
- cmd.addOption(new Option12("--hours <n>", "Lookback window (1-168 hours)").default("24"));
6223
+ cmd.addOption(new Option12("--hours <n>", "Rolling-feed lookback (1-168 hours)").default("24"));
6224
+ cmd.addOption(new Option12("--from <date>", "History range start YYYY-MM-DD (range mode; needs --code or --query)"));
6225
+ cmd.addOption(new Option12("--to <date>", "History range end YYYY-MM-DD (inclusive; default today)"));
6226
+ cmd.addOption(new Option12("--offset <n>", "Pagination offset for long history").default("0"));
6060
6227
  cmd.addOption(new Option12("--limit <n>", "Max items").default("20"));
6061
6228
  cmd.action(async (opts) => {
6062
6229
  const args = {
6063
6230
  hours: clamp3(opts.hours, 1, 168, 24),
6231
+ offset: clamp3(opts.offset, 0, 1e5, 0),
6064
6232
  limit: clamp3(opts.limit, 1, 100, 20)
6065
6233
  };
6066
6234
  if (opts.code)
6067
6235
  args.code = opts.code;
6068
6236
  if (opts.query)
6069
6237
  args.query = opts.query;
6238
+ if (opts.from)
6239
+ args.from = opts.from;
6240
+ if (opts.to)
6241
+ args.to = opts.to;
6070
6242
  await executeVerb(async (ctx) => newsSpec.handler(args, ctx));
6071
6243
  });
6072
6244
  return cmd;
@@ -6147,11 +6319,11 @@ import { Command as Command17, Option as Option15 } from "commander";
6147
6319
  import { z as z14 } from "zod";
6148
6320
  var searchSpec = {
6149
6321
  name: "search",
6150
- description: "HYBRID semantic search across analyst views + news (views weighted higher per feedback_views_over_news). Combines entity lookup (company codes, analyst names), pgvector kNN, trigram fuzzy and exact ILIKE; RRF-fused then reranked. Best for theme/concept queries: '锂电池' brings out 正极材料/碳酸锂/宁德时代; '芯片' brings out 存储芯片/洁净室/晶圆代工. Use `news` or `views` instead if you need strict time-window listing.",
6322
+ description: "Structured-first hybrid search across analyst views + news (views weighted higher per feedback_views_over_news). Three lanes — entity (company codes / names, analyst names), concept-graph hub (theme alias → concept → constituents & research), and ParadeDB BM25 full-text (jieba) — fused with RRF. Deterministic & explainable: no vector kNN, no LLM rerank. Best for theme/concept queries: 'CPO' brings out 光模块/光通信/光芯片; '减肥药' brings out 创新药; '智驾' brings out 智能驾驶. Use `news` or `views` instead if you need strict time-window listing.",
6151
6323
  inputSchema: {
6152
6324
  q: z14.string().min(1).max(200).describe("Free-text query (Chinese or English)"),
6153
6325
  type: z14.enum(["news", "views", "all"]).default("all").describe("Search scope (views weighted higher in 'all')"),
6154
- mode: z14.enum(["hybrid", "exact"]).default("hybrid").describe("hybrid = semantic + concept expansion + rerank; exact = ILIKE only"),
6326
+ mode: z14.enum(["hybrid", "exact"]).default("hybrid").describe("hybrid = entity + concept-graph + BM25 fused; exact = BM25 keyword only"),
6155
6327
  hours: z14.number().int().min(1).max(720).optional().describe("Lookback window in hours; omit for no time limit"),
6156
6328
  limit: z14.number().int().min(1).max(50).default(20).describe("Max items returned")
6157
6329
  },
@@ -6389,27 +6561,51 @@ var viewsSpec = {
6389
6561
  code: z16.string().regex(/^((SSE|SZSE|BSE):[0-9]{6}|HK:[0-9]{5}|US:[A-Z][A-Z0-9.\-]{0,5})$/).optional().describe("Filter by security canonical_code (e.g. SSE:600519, HK:00700, US:AAPL)"),
6390
6562
  analyst: z16.string().optional().describe("Analyst Chinese name (exact / fuzzy)"),
6391
6563
  institution: z16.string().optional().describe("Broker / institution Chinese name"),
6392
- since_days: z16.number().int().min(1).max(90).default(7).describe("Lookback window in days (research time-horizon is longer than news)"),
6393
- limit: z16.number().int().min(1).max(100).default(30).describe("Max items")
6564
+ since_days: z16.number().int().min(1).max(365).default(7).describe("Lookback in days. ≤30 = rolling feed; >30 auto-switches to range history " + "(needs a code/analyst/institution filter; Pro-only up to 365). For an explicit window use from/to."),
6565
+ from: z16.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("History range start (China date YYYY-MM-DD). Range mode: needs a filter; Pro-only up to 365 days back."),
6566
+ to: z16.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("History range end (inclusive, China date; defaults to today). Use with from."),
6567
+ offset: z16.number().int().min(0).optional().describe("Pagination offset for paging through long history ranges."),
6568
+ limit: z16.number().int().min(1).max(100).default(30).describe("Max items per page")
6394
6569
  },
6395
6570
  handler: async (args, ctx) => {
6396
6571
  const op = OPERATIONS["views.recent"];
6397
6572
  if (!op)
6398
6573
  throw new Error("views.recent op missing from codegen");
6399
- const callArgs = {
6400
- since_days: args.since_days,
6401
- limit: args.limit
6402
- };
6403
- if (args.code)
6404
- callArgs.security = args.code;
6405
- if (args.analyst)
6406
- callArgs.analyst = args.analyst;
6407
- if (args.institution)
6408
- callArgs.institution = args.institution;
6409
- return callOp(op, callArgs, ctx);
6574
+ return callOp(op, buildViewsCallArgs(args), ctx);
6410
6575
  },
6411
6576
  backingOps: ["views.recent"]
6412
6577
  };
6578
+ function buildViewsCallArgs(args) {
6579
+ const hasFilter = Boolean(args.code || args.analyst || args.institution);
6580
+ const sinceDays = typeof args.since_days === "number" ? args.since_days : 7;
6581
+ const callArgs = {};
6582
+ if (typeof args.limit === "number")
6583
+ callArgs.limit = args.limit;
6584
+ if (args.code)
6585
+ callArgs.security = args.code;
6586
+ if (args.analyst)
6587
+ callArgs.analyst = args.analyst;
6588
+ if (args.institution)
6589
+ callArgs.institution = args.institution;
6590
+ if (typeof args.offset === "number")
6591
+ callArgs.offset = args.offset;
6592
+ const explicitRange = typeof args.from === "string" && args.from.length > 0;
6593
+ if (explicitRange || sinceDays > 30) {
6594
+ if (!hasFilter) {
6595
+ throw new Error("range history (from/to or since_days>30) requires a narrowing filter: --code / --analyst / --institution");
6596
+ }
6597
+ callArgs.from = explicitRange ? args.from : chinaDateMinusDays(sinceDays);
6598
+ if (typeof args.to === "string" && args.to.length > 0)
6599
+ callArgs.to = args.to;
6600
+ } else {
6601
+ callArgs.since_days = sinceDays;
6602
+ }
6603
+ return callArgs;
6604
+ }
6605
+ function chinaDateMinusDays(days) {
6606
+ const chinaMs = Date.now() + 8 * 3600 * 1000 - days * 86400 * 1000;
6607
+ return new Date(chinaMs).toISOString().slice(0, 10);
6608
+ }
6413
6609
  var reportSpec = {
6414
6610
  name: "report",
6415
6611
  description: "Fetch the FULL research-report text behind a view (mineru-parsed long-form), for when the `views` summary is not enough. Only works on views where `full_text_available=true` (gdrive sell-side reports). Two-step usage: default `format=outline` returns a few-KB heading map + total_chars + each heading's char_offset; then call `format=full` with `offset` (e.g. an outline char_offset) to read the body in pages — follow `next_offset` to page through large docs instead of pulling everything at once.",
@@ -6450,14 +6646,18 @@ function buildViewsCommand() {
6450
6646
  cmd.addOption(new Option17("--code <canonical_code>", "Filter by security canonical_code"));
6451
6647
  cmd.addOption(new Option17("--analyst <name>", "Analyst Chinese name (exact / fuzzy)"));
6452
6648
  cmd.addOption(new Option17("--institution <name>", "Broker / institution Chinese name"));
6453
- cmd.addOption(new Option17("--since-days <n>", "Lookback window (1-90 days)").default("7"));
6649
+ cmd.addOption(new Option17("--since-days <n>", "Lookback days (1-365). ≤30 = feed; >30 = range history (needs a filter, Pro-only)").default("7"));
6650
+ cmd.addOption(new Option17("--from <date>", "History range start YYYY-MM-DD (range mode; needs a filter)"));
6651
+ cmd.addOption(new Option17("--to <date>", "History range end YYYY-MM-DD (inclusive; default today)"));
6652
+ cmd.addOption(new Option17("--offset <n>", "Pagination offset for long history").default("0"));
6454
6653
  cmd.addOption(new Option17("--limit <n>", "Max items (1-100)").default("30"));
6455
6654
  cmd.action(async (opts) => {
6456
6655
  if (!OPERATIONS["views.recent"]) {
6457
6656
  emitVerbError("internal_error", "views.recent missing from codegen", undefined, 2);
6458
6657
  }
6459
6658
  const args = {
6460
- since_days: clamp5(opts.sinceDays, 1, 90, 7),
6659
+ since_days: clamp5(opts.sinceDays, 1, 365, 7),
6660
+ offset: clamp5(opts.offset, 0, 1e5, 0),
6461
6661
  limit: clamp5(opts.limit, 1, 100, 30)
6462
6662
  };
6463
6663
  if (opts.code)
@@ -6466,6 +6666,10 @@ function buildViewsCommand() {
6466
6666
  args.analyst = opts.analyst;
6467
6667
  if (opts.institution)
6468
6668
  args.institution = opts.institution;
6669
+ if (opts.from)
6670
+ args.from = opts.from;
6671
+ if (opts.to)
6672
+ args.to = opts.to;
6469
6673
  await executeVerb(async (ctx) => viewsSpec.handler(args, ctx));
6470
6674
  });
6471
6675
  return cmd;
@@ -6502,6 +6706,7 @@ var ALL_VERB_SPECS = [
6502
6706
  viewsSpec,
6503
6707
  reportSpec,
6504
6708
  newsSpec,
6709
+ announcementsSearchSpec,
6505
6710
  announcementsFeedSpec,
6506
6711
  announcementsStockSpec,
6507
6712
  announcementsDetailSpec,
@@ -6523,6 +6728,7 @@ var ALL_VERB_SPECS = [
6523
6728
  limitUpPoolSpec,
6524
6729
  limitUpSummarySpec,
6525
6730
  limitUpHistorySpec,
6731
+ limitUpAnalysisSpec,
6526
6732
  chartSpec,
6527
6733
  barsBatchSpec,
6528
6734
  scanSpec,
@@ -7347,6 +7553,11 @@ var HELP = {
7347
7553
  description: "A 股公告 feed,按 published_at 降序、同日按 cninfo source_id 降序。可按 type / since 过滤。需要 `announcements:read` scope。",
7348
7554
  example: "echopai announcements feed"
7349
7555
  },
7556
+ "announcements.search": {
7557
+ summary: "Hybrid search over A-share announcements",
7558
+ description: "公告混合搜索:名称 / 代码做结构化过滤 + 关键词 BM25 全文(ParadeDB jieba,覆盖 title / ai_summary / content_md 前 1500 字)。q 含 6 位代码或内嵌 A 股名 → 按代码精确过滤;残余或纯文本 → BM25 按相关度×时间衰减排序;纯代码/名称则按 published_at 倒序列出。需要 `announcements:read` scope。",
7559
+ example: "echopai announcements search --q 新华医疗 回购"
7560
+ },
7350
7561
  "announcements.stock": {
7351
7562
  summary: "List announcements for a specific A-share security",
7352
7563
  description: "指定股票代码的公告列表(含历史窗口)。代码接受 6 位纯数字或 SSE:600519 / SZSE:000001 / BSE:430000 形式。需要 `announcements:read` scope。",
@@ -7521,6 +7732,11 @@ Phase 5.2 (server endpoint).
7521
7732
  description: "Mirrors `/v1/concepts/snapshot` contract (PR #442 review 二轮):\n\n- **With `codes=`** (per-key completeness): hot → warm → CH fallback\n per industry; missing keys populated.\n- **Without `codes`** (best-effort dump): union of\n `industry_snapshots:latest` and `industry_snapshots:last_close`\n Redis hashes. Missing industries are **not** backfilled from CH; use\n GET `/v1/industries` for a fully populated list.\n\n9:00–9:14 reset window suppresses both Redis hashes and CH fallback.\nEach item carries `_source` ∈ {`latest`, `last_close`, `ch_daily`}.\n申万一级 (level=1) ≈ 127 industries, 申万二级 (level=2) ≈ 348.\n",
7522
7733
  example: "echopai industries snapshot"
7523
7734
  },
7735
+ "limit-up.analysis": {
7736
+ summary: "A-share limit-up LLM attribution (per-stock leading theme + reason)",
7737
+ description: '涨停股 LLM 归因:逐股「主导题材 leading_concept + 概念标签 concept_tags +\n涨停理由 reason」。题材由 LLM 依据最新研报 / 机构观点 / 快讯判定,**不依赖概念\n图谱**(可含图谱里还没有的全新题材),每交易日北京 9:45 / 11:00 / 12:30 / 14:00 /\n14:40 / 18:00 共 6 个时段自动刷新(后跑时段在前次基础上 refine)。\n\n与 `limit-up/pool` 互补:pool 给"涨停了哪些票"(行情数据,stockpulse 上游),\n本接口给"为什么涨停 / 属什么题材"(LLM 归因,echopai api 上游)。pool 里的\ngraph-based `leading_concept` 是另一条数据,本接口的 `leading_concept` 才是\nLLM 判定结果。\n\n⚠️ **trade_date 不传 = 库内最新「已归因」交易日**(非自然最新交易日):归因每个\n时段按当时池快照写库,今日**首个时段(北京约 09:47)落库前**,不传 trade_date\n会返回**上一交易日**的归因(不是今日空结果)——agent 拿当日数据请显式传今日\ntrade_date 并以 `trade_date` / `generated_at` 字段核对时效。显式传某交易日而该日\n尚未归因时,`analyses` 为空。\n\n平面归属:本接口在 **vu 平面**(前端 apiFetch 同源),与 `market/status` 同款\n`x-audience: vu`;行情类 `limit-up/pool|summary|history` 在 sp 平面(前端 spFetch)。\n需要 `market:read` 或任一 `quote:*` scope。\n',
7738
+ example: "echopai limit-up analysis"
7739
+ },
7524
7740
  "limit-up.history": {
7525
7741
  summary: "A-share limit-up daily trend (last N days)",
7526
7742
  description: "近 N 个交易日每日涨停数 + 最高连板 + 炸板数。涨停数 / 最高连板来源\n`market_limit_up_pool` (仅 status=limit_up);炸板数来源\n`market_breadth_intraday`(all_a_ex_st 当日最新分钟行)。\n\n响应按 trade_date asc 排序(旧日期在前)。需要 `quote:*` scope。\n",
@@ -7649,16 +7865,6 @@ L1/L2/L3(X5001 食品饮料 / X500102 白酒 等)。
7649
7865
  description: "Intraday turnover time series across one or more trading days.\n`trade_date` 缺省 → 今日(盘前 9:00 前返空 items);显式 YYYY-MM-DD 拉\n历史任意交易日的当日 + 之前 `days-1` 天。Response 含 `current_turnover`\n/ `predicted_total` / `delta_vs_yesterday` headline 字段。Requires\n`sentiment:read` scope.\n",
7650
7866
  example: "echopai sentiment turnover"
7651
7867
  },
7652
- "squawk.audio": {
7653
- summary: "Stream squawk TTS audio",
7654
- description: "返回 squawk 项的 TTS 音频(MP3)。直接 stream,可作为 `<audio>` src 用。",
7655
- example: "echopai squawk audio VALUE"
7656
- },
7657
- "squawk.waveform": {
7658
- summary: "Waveform peaks for squawk audio",
7659
- description: "返回 squawk 音频的预计算波形 peak 数据,用于客户端波形可视化。",
7660
- example: "echopai squawk waveform VALUE"
7661
- },
7662
7868
  "stocks.hot": {
7663
7869
  summary: "Today's hot-stock leaderboard",
7664
7870
  description: "今日最热股票榜(来源 East-Money),按搜索 / 关注 / 评论综合分排序。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echopai",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Command-line interface for the EchoPai Open Platform: stock-market data, news, analyst views, sentiment, signals, backtests.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://echopai.com",