echopai 2.4.0 → 2.5.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 +221 -131
  2. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -960,9 +960,9 @@ Phase 5.2 (server endpoint).
960
960
  views_since_days: {
961
961
  type: "integer",
962
962
  minimum: 1,
963
- maximum: 90,
963
+ maximum: 30,
964
964
  default: 7,
965
- description: "Views lookback in days (1-90)."
965
+ description: "Views lookback in days (1-30; views feed caps at 30d. Use the `views` verb's from/to for longer history)."
966
966
  },
967
967
  news_hours: {
968
968
  type: "integer",
@@ -1489,6 +1489,39 @@ Phase 5.2 (server endpoint).
1489
1489
  additionalProperties: false
1490
1490
  }
1491
1491
  },
1492
+ "limit-up.analysis": {
1493
+ cliKey: "limit-up.analysis",
1494
+ cliName: "limit-up analysis",
1495
+ method: "GET",
1496
+ path: "/v1/limit-up/analysis",
1497
+ 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',
1498
+ summary: "A-share limit-up LLM attribution (per-stock leading theme + reason)",
1499
+ positional: [],
1500
+ outputDefault: "json",
1501
+ pagination: "none",
1502
+ stream: false,
1503
+ billable: true,
1504
+ idempotencyRequired: false,
1505
+ scopesAny: [
1506
+ "market:read",
1507
+ "quote:l1",
1508
+ "quote:l2",
1509
+ "quote:delayed"
1510
+ ],
1511
+ sideEffect: "read",
1512
+ dryRunSupported: false,
1513
+ inputSchema: {
1514
+ type: "object",
1515
+ properties: {
1516
+ trade_date: {
1517
+ type: "string",
1518
+ format: "date",
1519
+ description: "ISO date YYYY-MM-DD;不传走库内**最新已归因**交易日(今日首段落库前会回退到上一交易日)。"
1520
+ }
1521
+ },
1522
+ additionalProperties: false
1523
+ }
1524
+ },
1492
1525
  "limit-up.history": {
1493
1526
  cliKey: "limit-up.history",
1494
1527
  cliName: "limit-up history",
@@ -1723,7 +1756,17 @@ Phase 5.2 (server endpoint).
1723
1756
  minimum: 1,
1724
1757
  maximum: 720,
1725
1758
  default: 24,
1726
- description: "Lookback window in hours (max 720 = 30 days)."
1759
+ description: "Rolling-feed lookback in hours (720 = 30d). For longer history use from/to."
1760
+ },
1761
+ from: {
1762
+ type: "string",
1763
+ format: "date",
1764
+ 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)."
1765
+ },
1766
+ to: {
1767
+ type: "string",
1768
+ format: "date",
1769
+ description: "History range end (inclusive, China date; defaults to today). Only used with from."
1727
1770
  },
1728
1771
  limit: {
1729
1772
  type: "integer",
@@ -1731,6 +1774,12 @@ Phase 5.2 (server endpoint).
1731
1774
  maximum: 100,
1732
1775
  default: 20,
1733
1776
  description: "Max items per page."
1777
+ },
1778
+ offset: {
1779
+ type: "integer",
1780
+ minimum: 0,
1781
+ default: 0,
1782
+ description: "Pagination offset (page through long history ranges)."
1734
1783
  }
1735
1784
  },
1736
1785
  additionalProperties: false,
@@ -1770,9 +1819,19 @@ Phase 5.2 (server endpoint).
1770
1819
  since_hours: {
1771
1820
  type: "integer",
1772
1821
  minimum: 1,
1773
- maximum: 168,
1822
+ maximum: 720,
1774
1823
  default: 24,
1775
- description: "Lookback window in hours."
1824
+ description: "Rolling-feed lookback in hours (≤720 = 30d). For longer history use from/to."
1825
+ },
1826
+ from: {
1827
+ type: "string",
1828
+ format: "date",
1829
+ 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)."
1830
+ },
1831
+ to: {
1832
+ type: "string",
1833
+ format: "date",
1834
+ description: "History range end (inclusive, China date; defaults to today). Only used with from."
1776
1835
  },
1777
1836
  limit: {
1778
1837
  type: "integer",
@@ -1780,6 +1839,12 @@ Phase 5.2 (server endpoint).
1780
1839
  maximum: 100,
1781
1840
  default: 20,
1782
1841
  description: "Max items per page."
1842
+ },
1843
+ offset: {
1844
+ type: "integer",
1845
+ minimum: 0,
1846
+ default: 0,
1847
+ description: "Pagination offset (page through long history ranges)."
1783
1848
  }
1784
1849
  },
1785
1850
  additionalProperties: false,
@@ -2330,70 +2395,6 @@ L1/L2/L3(X5001 食品饮料 / X500102 白酒 等)。
2330
2395
  additionalProperties: false
2331
2396
  }
2332
2397
  },
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
2398
  "stocks.hot": {
2398
2399
  cliKey: "stocks.hot",
2399
2400
  cliName: "stocks hot",
@@ -2586,9 +2587,19 @@ L1/L2/L3(X5001 食品饮料 / X500102 白酒 等)。
2586
2587
  since_days: {
2587
2588
  type: "integer",
2588
2589
  minimum: 1,
2589
- maximum: 90,
2590
+ maximum: 30,
2590
2591
  default: 7,
2591
- description: "Lookback window in days. (Translated to hours upstream.)"
2592
+ description: "Rolling-feed lookback in days (≤30, translated to hours upstream). For longer history use from/to instead."
2593
+ },
2594
+ from: {
2595
+ type: "string",
2596
+ format: "date",
2597
+ 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)."
2598
+ },
2599
+ to: {
2600
+ type: "string",
2601
+ format: "date",
2602
+ description: "History range end (inclusive, China date; defaults to today). Only used with from."
2592
2603
  },
2593
2604
  limit: {
2594
2605
  type: "integer",
@@ -2596,6 +2607,12 @@ L1/L2/L3(X5001 食品饮料 / X500102 白酒 等)。
2596
2607
  maximum: 100,
2597
2608
  default: 30,
2598
2609
  description: "Max items per page."
2610
+ },
2611
+ offset: {
2612
+ type: "integer",
2613
+ minimum: 0,
2614
+ default: 0,
2615
+ description: "Pagination offset (page through long history ranges)."
2599
2616
  }
2600
2617
  },
2601
2618
  additionalProperties: false
@@ -2821,6 +2838,10 @@ function buildCommandTree(program, dispatch) {
2821
2838
  {
2822
2839
  const noun = program.command("limit-up");
2823
2840
  noun.description("limit-up commands");
2841
+ {
2842
+ const cmd = noun.command("analysis");
2843
+ attachOperation(cmd, OPERATIONS["limit-up.analysis"], dispatch);
2844
+ }
2824
2845
  {
2825
2846
  const cmd = noun.command("history");
2826
2847
  attachOperation(cmd, OPERATIONS["limit-up.history"], dispatch);
@@ -2938,18 +2959,6 @@ function buildCommandTree(program, dispatch) {
2938
2959
  attachOperation(cmd, OPERATIONS["sentiment.turnover"], dispatch);
2939
2960
  }
2940
2961
  }
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
2962
  {
2954
2963
  const noun = program.command("stocks");
2955
2964
  noun.description("stocks commands");
@@ -3756,7 +3765,7 @@ import os2 from "node:os";
3756
3765
  import path2 from "node:path";
3757
3766
 
3758
3767
  // src/version.ts
3759
- var CLI_VERSION = "2.4.0";
3768
+ var CLI_VERSION = "2.5.0";
3760
3769
 
3761
3770
  // src/runtime/update_check.ts
3762
3771
  var UPDATE_CACHE_PATH = path2.join(os2.homedir(), ".config", "echopai", "update_cache.json");
@@ -5178,7 +5187,7 @@ var digestSpec = {
5178
5187
  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
5188
  inputSchema: {
5180
5189
  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)"),
5190
+ 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
5191
  news_hours: z5.number().int().min(1).max(168).default(24).describe("Lookback for news bucket (hours, default 24 — event horizon)"),
5183
5192
  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
5193
  },
@@ -5391,7 +5400,7 @@ function clamp2(raw, min, max, fallback) {
5391
5400
  function buildDigestCommand() {
5392
5401
  const cmd = new Command7(digestSpec.name).description(digestSpec.description);
5393
5402
  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"));
5403
+ cmd.addOption(new Option6("--views-since-days <n>", "Views lookback in days (1-30)").default("7"));
5395
5404
  cmd.addOption(new Option6("--news-hours <n>", "News lookback in hours (1-168)").default("24"));
5396
5405
  cmd.addOption(new Option6("--limit-per-bucket <n>", "Items per bucket (1-50)").default("10"));
5397
5406
  cmd.action(async (opts) => {
@@ -5400,7 +5409,7 @@ function buildDigestCommand() {
5400
5409
  }
5401
5410
  const args = {
5402
5411
  code: opts.code,
5403
- views_since_days: clamp2(opts.viewsSinceDays, 1, 90, 7),
5412
+ views_since_days: clamp2(opts.viewsSinceDays, 1, 30, 7),
5404
5413
  news_hours: clamp2(opts.newsHours, 1, 168, 24),
5405
5414
  limit_per_bucket: clamp2(opts.limitPerBucket, 1, 50, 10)
5406
5415
  };
@@ -5771,8 +5780,25 @@ var limitUpHistorySpec = {
5771
5780
  },
5772
5781
  backingOps: ["limit-up.history"]
5773
5782
  };
5783
+ var limitUpAnalysisSpec = {
5784
+ name: "limit_up_analysis",
5785
+ 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.",
5786
+ inputSchema: {
5787
+ 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)")
5788
+ },
5789
+ handler: async (args, ctx) => {
5790
+ const op = OPERATIONS["limit-up.analysis"];
5791
+ if (!op)
5792
+ throw new Error("limit-up.analysis op missing");
5793
+ const callArgs = {};
5794
+ if (args.trade_date)
5795
+ callArgs.trade_date = args.trade_date;
5796
+ return callOp(op, callArgs, ctx);
5797
+ },
5798
+ backingOps: ["limit-up.analysis"]
5799
+ };
5774
5800
  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).");
5801
+ 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
5802
  const pool = cmd.command("pool").description(limitUpPoolSpec.description);
5777
5803
  pool.addOption(new Option9("--trade-date <YYYY-MM-DD>", "Trade date; omit for latest"));
5778
5804
  pool.addOption(new Option9("--include <s>", "active = 排除 ST/退市; all = 全部").choices([...INCLUDE_VALUES]).default("active"));
@@ -5809,6 +5835,17 @@ function buildLimitUpCommand() {
5809
5835
  }
5810
5836
  await executeVerb(async (ctx) => limitUpHistorySpec.handler({ days: clampInt5(opts.days, 1, 250, 30) }, ctx));
5811
5837
  });
5838
+ const analysis = cmd.command("analysis").description(limitUpAnalysisSpec.description);
5839
+ analysis.addOption(new Option9("--trade-date <YYYY-MM-DD>", "Trade date; omit for latest"));
5840
+ analysis.action(async (opts) => {
5841
+ if (!OPERATIONS["limit-up.analysis"]) {
5842
+ emitVerbError("internal_error", "limit-up.analysis missing", undefined, 2);
5843
+ }
5844
+ const args = {};
5845
+ if (opts.tradeDate)
5846
+ args.trade_date = opts.tradeDate;
5847
+ await executeVerb(async (ctx) => limitUpAnalysisSpec.handler(args, ctx));
5848
+ });
5812
5849
  return cmd;
5813
5850
  }
5814
5851
  // src/verbs/lookup.ts
@@ -6019,29 +6056,46 @@ var newsSpec = {
6019
6056
  inputSchema: {
6020
6057
  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
6058
  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")
6059
+ 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."),
6060
+ 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."),
6061
+ to: z11.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("History range end (inclusive, China date; defaults to today). Use with from."),
6062
+ offset: z11.number().int().min(0).optional().describe("Pagination offset for paging through long history ranges."),
6063
+ limit: z11.number().int().min(1).max(100).default(20).describe("Max items per page")
6024
6064
  },
6025
6065
  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"];
6066
+ const { opKey, callArgs } = buildNewsCall(args);
6067
+ const op = OPERATIONS[opKey];
6039
6068
  if (!op)
6040
- throw new Error("news.feed op missing");
6041
- return callOp(op, {}, ctx);
6069
+ throw new Error(`${opKey} op missing`);
6070
+ return callOp(op, callArgs, ctx);
6042
6071
  },
6043
6072
  backingOps: ["news.list", "news.search", "news.feed"]
6044
6073
  };
6074
+ function buildNewsCall(args) {
6075
+ const hasRange = typeof args.from === "string" && args.from.length > 0;
6076
+ const withWindow = (a) => {
6077
+ if (typeof args.limit === "number")
6078
+ a.limit = args.limit;
6079
+ if (typeof args.offset === "number")
6080
+ a.offset = args.offset;
6081
+ if (hasRange) {
6082
+ a.from = args.from;
6083
+ if (typeof args.to === "string" && args.to.length > 0)
6084
+ a.to = args.to;
6085
+ } else {
6086
+ a.since_hours = args.hours;
6087
+ }
6088
+ return a;
6089
+ };
6090
+ if (args.code)
6091
+ return { opKey: "news.list", callArgs: withWindow({ security: args.code }) };
6092
+ if (args.query)
6093
+ return { opKey: "news.search", callArgs: withWindow({ query: args.query }) };
6094
+ if (hasRange) {
6095
+ throw new Error("range history (from/to) requires --code or --query; the bare feed has no narrowing filter");
6096
+ }
6097
+ return { opKey: "news.feed", callArgs: {} };
6098
+ }
6045
6099
  function clamp3(raw, min, max, fallback) {
6046
6100
  const n = Math.floor(Number(raw));
6047
6101
  if (!Number.isFinite(n))
@@ -6056,17 +6110,25 @@ function buildNewsCommand() {
6056
6110
  const cmd = new Command14(newsSpec.name).description(newsSpec.description);
6057
6111
  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
6112
  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"));
6113
+ cmd.addOption(new Option12("--hours <n>", "Rolling-feed lookback (1-168 hours)").default("24"));
6114
+ cmd.addOption(new Option12("--from <date>", "History range start YYYY-MM-DD (range mode; needs --code or --query)"));
6115
+ cmd.addOption(new Option12("--to <date>", "History range end YYYY-MM-DD (inclusive; default today)"));
6116
+ cmd.addOption(new Option12("--offset <n>", "Pagination offset for long history").default("0"));
6060
6117
  cmd.addOption(new Option12("--limit <n>", "Max items").default("20"));
6061
6118
  cmd.action(async (opts) => {
6062
6119
  const args = {
6063
6120
  hours: clamp3(opts.hours, 1, 168, 24),
6121
+ offset: clamp3(opts.offset, 0, 1e5, 0),
6064
6122
  limit: clamp3(opts.limit, 1, 100, 20)
6065
6123
  };
6066
6124
  if (opts.code)
6067
6125
  args.code = opts.code;
6068
6126
  if (opts.query)
6069
6127
  args.query = opts.query;
6128
+ if (opts.from)
6129
+ args.from = opts.from;
6130
+ if (opts.to)
6131
+ args.to = opts.to;
6070
6132
  await executeVerb(async (ctx) => newsSpec.handler(args, ctx));
6071
6133
  });
6072
6134
  return cmd;
@@ -6389,27 +6451,51 @@ var viewsSpec = {
6389
6451
  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
6452
  analyst: z16.string().optional().describe("Analyst Chinese name (exact / fuzzy)"),
6391
6453
  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")
6454
+ 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."),
6455
+ 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."),
6456
+ to: z16.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("History range end (inclusive, China date; defaults to today). Use with from."),
6457
+ offset: z16.number().int().min(0).optional().describe("Pagination offset for paging through long history ranges."),
6458
+ limit: z16.number().int().min(1).max(100).default(30).describe("Max items per page")
6394
6459
  },
6395
6460
  handler: async (args, ctx) => {
6396
6461
  const op = OPERATIONS["views.recent"];
6397
6462
  if (!op)
6398
6463
  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);
6464
+ return callOp(op, buildViewsCallArgs(args), ctx);
6410
6465
  },
6411
6466
  backingOps: ["views.recent"]
6412
6467
  };
6468
+ function buildViewsCallArgs(args) {
6469
+ const hasFilter = Boolean(args.code || args.analyst || args.institution);
6470
+ const sinceDays = typeof args.since_days === "number" ? args.since_days : 7;
6471
+ const callArgs = {};
6472
+ if (typeof args.limit === "number")
6473
+ callArgs.limit = args.limit;
6474
+ if (args.code)
6475
+ callArgs.security = args.code;
6476
+ if (args.analyst)
6477
+ callArgs.analyst = args.analyst;
6478
+ if (args.institution)
6479
+ callArgs.institution = args.institution;
6480
+ if (typeof args.offset === "number")
6481
+ callArgs.offset = args.offset;
6482
+ const explicitRange = typeof args.from === "string" && args.from.length > 0;
6483
+ if (explicitRange || sinceDays > 30) {
6484
+ if (!hasFilter) {
6485
+ throw new Error("range history (from/to or since_days>30) requires a narrowing filter: --code / --analyst / --institution");
6486
+ }
6487
+ callArgs.from = explicitRange ? args.from : chinaDateMinusDays(sinceDays);
6488
+ if (typeof args.to === "string" && args.to.length > 0)
6489
+ callArgs.to = args.to;
6490
+ } else {
6491
+ callArgs.since_days = sinceDays;
6492
+ }
6493
+ return callArgs;
6494
+ }
6495
+ function chinaDateMinusDays(days) {
6496
+ const chinaMs = Date.now() + 8 * 3600 * 1000 - days * 86400 * 1000;
6497
+ return new Date(chinaMs).toISOString().slice(0, 10);
6498
+ }
6413
6499
  var reportSpec = {
6414
6500
  name: "report",
6415
6501
  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 +6536,18 @@ function buildViewsCommand() {
6450
6536
  cmd.addOption(new Option17("--code <canonical_code>", "Filter by security canonical_code"));
6451
6537
  cmd.addOption(new Option17("--analyst <name>", "Analyst Chinese name (exact / fuzzy)"));
6452
6538
  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"));
6539
+ cmd.addOption(new Option17("--since-days <n>", "Lookback days (1-365). ≤30 = feed; >30 = range history (needs a filter, Pro-only)").default("7"));
6540
+ cmd.addOption(new Option17("--from <date>", "History range start YYYY-MM-DD (range mode; needs a filter)"));
6541
+ cmd.addOption(new Option17("--to <date>", "History range end YYYY-MM-DD (inclusive; default today)"));
6542
+ cmd.addOption(new Option17("--offset <n>", "Pagination offset for long history").default("0"));
6454
6543
  cmd.addOption(new Option17("--limit <n>", "Max items (1-100)").default("30"));
6455
6544
  cmd.action(async (opts) => {
6456
6545
  if (!OPERATIONS["views.recent"]) {
6457
6546
  emitVerbError("internal_error", "views.recent missing from codegen", undefined, 2);
6458
6547
  }
6459
6548
  const args = {
6460
- since_days: clamp5(opts.sinceDays, 1, 90, 7),
6549
+ since_days: clamp5(opts.sinceDays, 1, 365, 7),
6550
+ offset: clamp5(opts.offset, 0, 1e5, 0),
6461
6551
  limit: clamp5(opts.limit, 1, 100, 30)
6462
6552
  };
6463
6553
  if (opts.code)
@@ -6466,6 +6556,10 @@ function buildViewsCommand() {
6466
6556
  args.analyst = opts.analyst;
6467
6557
  if (opts.institution)
6468
6558
  args.institution = opts.institution;
6559
+ if (opts.from)
6560
+ args.from = opts.from;
6561
+ if (opts.to)
6562
+ args.to = opts.to;
6469
6563
  await executeVerb(async (ctx) => viewsSpec.handler(args, ctx));
6470
6564
  });
6471
6565
  return cmd;
@@ -6523,6 +6617,7 @@ var ALL_VERB_SPECS = [
6523
6617
  limitUpPoolSpec,
6524
6618
  limitUpSummarySpec,
6525
6619
  limitUpHistorySpec,
6620
+ limitUpAnalysisSpec,
6526
6621
  chartSpec,
6527
6622
  barsBatchSpec,
6528
6623
  scanSpec,
@@ -7521,6 +7616,11 @@ Phase 5.2 (server endpoint).
7521
7616
  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
7617
  example: "echopai industries snapshot"
7523
7618
  },
7619
+ "limit-up.analysis": {
7620
+ summary: "A-share limit-up LLM attribution (per-stock leading theme + reason)",
7621
+ 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',
7622
+ example: "echopai limit-up analysis"
7623
+ },
7524
7624
  "limit-up.history": {
7525
7625
  summary: "A-share limit-up daily trend (last N days)",
7526
7626
  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 +7749,6 @@ L1/L2/L3(X5001 食品饮料 / X500102 白酒 等)。
7649
7749
  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
7750
  example: "echopai sentiment turnover"
7651
7751
  },
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
7752
  "stocks.hot": {
7663
7753
  summary: "Today's hot-stock leaderboard",
7664
7754
  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.5.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",