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.
@@ -10,12 +10,17 @@ import { executeVerb } from "../runtime/verb_cmd.js";
10
10
  import { callOp } from "../runtime/verb_runner.js";
11
11
  export const newsSpec = {
12
12
  name: "news",
13
- description: "SUPPLEMENTARY news / market briefs (short time-horizon event stream). Use ONLY to fill gaps not covered by `views`; prefer `views` as the primary research source. With `query` → full-text search; without `query` → time-window feed.",
13
+ description: "SUPPLEMENTARY news / market briefs (short time-horizon event stream). Use ONLY to fill gaps not covered by `views`; prefer `views` as the primary research source. Three modes: `code` (canonical) → news mentioning that security; `query` (free text) → full-text search; neitherrecent time-window feed. AH dual-listing: when filtering by `code` for an A+H listed company, pass the A-share canonical (e.g. SSE:601398) for consolidated coverage; HK side only if the user explicitly asks. Note: `query` is matched against news text, so passing a canonical_code as `query` will not work — use `code` for security filtering.",
14
14
  inputSchema: {
15
+ code: z
16
+ .string()
17
+ .regex(/^((SSE|SZSE|BSE):[0-9]{6}|HK:[0-9]{5}|US:[A-Z][A-Z0-9.\-]{0,5})$/)
18
+ .optional()
19
+ .describe("Filter by security canonical_code (e.g. SSE:600519, HK:00700). Takes precedence over `query`."),
15
20
  query: z
16
21
  .string()
17
22
  .optional()
18
- .describe("Free-text query (Chinese or English); omit for time-window feed"),
23
+ .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."),
19
24
  hours: z
20
25
  .number()
21
26
  .int()
@@ -26,6 +31,12 @@ export const newsSpec = {
26
31
  limit: z.number().int().min(1).max(100).default(20).describe("Max items"),
27
32
  },
28
33
  handler: async (args, ctx) => {
34
+ if (args.code) {
35
+ const op = OPERATIONS["news.list"];
36
+ if (!op)
37
+ throw new Error("news.list op missing");
38
+ return callOp(op, { security: args.code, since_hours: args.hours, limit: args.limit }, ctx);
39
+ }
29
40
  if (args.query) {
30
41
  const op = OPERATIONS["news.search"];
31
42
  if (!op)
@@ -37,7 +48,7 @@ export const newsSpec = {
37
48
  throw new Error("news.feed op missing");
38
49
  return callOp(op, {}, ctx);
39
50
  },
40
- backingOps: ["news.search", "news.feed"],
51
+ backingOps: ["news.list", "news.search", "news.feed"],
41
52
  };
42
53
  function clamp(raw, min, max, fallback) {
43
54
  const n = Math.floor(Number(raw));
@@ -51,6 +62,7 @@ function clamp(raw, min, max, fallback) {
51
62
  }
52
63
  export function buildNewsCommand() {
53
64
  const cmd = new Command(newsSpec.name).description(newsSpec.description);
65
+ cmd.addOption(new Option("--code <canonical_code>", "Filter by security canonical_code (e.g. SSE:600519); takes precedence over --query"));
54
66
  cmd.addOption(new Option("--query <text>", "Free-text query; omit for time-window feed"));
55
67
  cmd.addOption(new Option("--hours <n>", "Lookback window (1-168 hours)").default("24"));
56
68
  cmd.addOption(new Option("--limit <n>", "Max items").default("20"));
@@ -59,6 +71,8 @@ export function buildNewsCommand() {
59
71
  hours: clamp(opts.hours, 1, 168, 24),
60
72
  limit: clamp(opts.limit, 1, 100, 20),
61
73
  };
74
+ if (opts.code)
75
+ args.code = opts.code;
62
76
  if (opts.query)
63
77
  args.query = opts.query;
64
78
  await executeVerb(async (ctx) => newsSpec.handler(args, ctx));
@@ -9,7 +9,7 @@ import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
9
9
  import { callOp } from "../runtime/verb_runner.js";
10
10
  export const quoteSpec = {
11
11
  name: "quote",
12
- description: "Real-time quote for 1-200 A-share securities (last price, volume, change %, bid/ask). For >200 codes use `scan` instead.",
12
+ description: "Real-time quote for 1-200 A-share securities (last price, volume, change %, bid/ask). For >200 codes use `scan` instead. AH dual-listing: HK codes are not supported here; for an A+H listed company use the A-share canonical (e.g. SSE:601398 not HK:01398).",
13
13
  inputSchema: {
14
14
  codes: z
15
15
  .array(z.string().regex(/^(SSE|SZSE|BSE|SH|SZ|BJ):[0-9]{6}$/))
@@ -1,5 +1,14 @@
1
1
  /**
2
- * `echopai sentiment [--scope <universe>]` + MCP tool `sentiment`.
2
+ * `echopai sentiment ...` + MCP tools `sentiment` (overview alias, BC) /
3
+ * `sentiment_overview` / `sentiment_breadth` / `sentiment_turnover` /
4
+ * `sentiment_pct_distribution`.
5
+ *
6
+ * 四个子端点全部支持 `--scope` 切换股票池;`overview` / `breadth` /
7
+ * `turnover` 进一步支持 `--date YYYY-MM-DD` 取历史任意交易日的当日聚合
8
+ * (盘中实时为 Redis cache,历史走 ClickHouse `market_breadth_intraday` /
9
+ * `market_turnover_intraday`)。`turnover` 还支持 `--days 1-5` 拉同分钟同比。
10
+ *
11
+ * 顶层 `echopai sentiment` 无子命令时仍走 overview(保持 v2.2.0 兼容)。
3
12
  */
4
13
  import { Command, Option } from "commander";
5
14
  import { z } from "zod";
@@ -14,33 +23,209 @@ const SCOPE_VALUES = [
14
23
  "chinext",
15
24
  "bse",
16
25
  ];
26
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
27
+ function clampInt(raw, min, max, fallback) {
28
+ const n = Math.floor(Number(raw));
29
+ if (!Number.isFinite(n))
30
+ return fallback;
31
+ if (n < min)
32
+ return min;
33
+ if (n > max)
34
+ return max;
35
+ return n;
36
+ }
37
+ /**
38
+ * Legacy `sentiment` spec — kept as alias of overview for v2.2.0 backward
39
+ * compatibility (MCP clients that registered `sentiment` keep working).
40
+ */
17
41
  export const sentimentSpec = {
18
42
  name: "sentiment",
19
- description: "Aggregate market sentiment indicators (limit-up/down counts, breadth, divergence index, top movers). Defaults to all_a_ex_st (all A-shares ex-ST).",
43
+ description: "Aggregate market sentiment indicators (limit-up/down counts, breadth, divergence index, top movers). Defaults to all_a_ex_st (all A-shares ex-ST). Pass `at_date` (YYYY-MM-DD) for any historical trading day; omit for today's latest snapshot. Backward-compat alias of `sentiment_overview` — prefer the latter in new agent code.",
20
44
  inputSchema: {
21
45
  scope: z
22
46
  .enum(SCOPE_VALUES)
23
47
  .default("all_a_ex_st")
24
48
  .describe("Universe filter"),
49
+ at_date: z
50
+ .string()
51
+ .regex(DATE_RE)
52
+ .optional()
53
+ .describe("Trade date YYYY-MM-DD; omit for today's realtime snapshot"),
25
54
  },
26
55
  handler: async (args, ctx) => {
27
56
  const op = OPERATIONS["sentiment.overview"];
28
57
  if (!op)
29
58
  throw new Error("sentiment.overview op missing");
30
- return callOp(op, { scope: args.scope }, ctx);
59
+ const callArgs = { scope: args.scope };
60
+ if (args.at_date)
61
+ callArgs.trade_date = args.at_date;
62
+ return callOp(op, callArgs, ctx);
31
63
  },
32
64
  backingOps: ["sentiment.overview"],
33
65
  };
34
- export function buildSentimentCommand() {
35
- const cmd = new Command(sentimentSpec.name).description(sentimentSpec.description);
66
+ export const sentimentOverviewSpec = {
67
+ name: "sentiment_overview",
68
+ description: "Aggregate sentiment snapshot for one trading day's latest minute: up/down/flat counts, limit-up/down, gt3/lt3 breadth, divergence index + label, MA20/50/200 breadth, 52w new high/low. `at_date` omitted = today realtime (Redis); explicit YYYY-MM-DD = historical from ClickHouse `market_breadth_intraday` last minute.",
69
+ inputSchema: {
70
+ scope: z.enum(SCOPE_VALUES).default("all_a_ex_st").describe("Universe filter"),
71
+ at_date: z
72
+ .string()
73
+ .regex(DATE_RE)
74
+ .optional()
75
+ .describe("Trade date YYYY-MM-DD; omit for today realtime"),
76
+ },
77
+ handler: async (args, ctx) => {
78
+ const op = OPERATIONS["sentiment.overview"];
79
+ if (!op)
80
+ throw new Error("sentiment.overview op missing");
81
+ const callArgs = { scope: args.scope };
82
+ if (args.at_date)
83
+ callArgs.trade_date = args.at_date;
84
+ return callOp(op, callArgs, ctx);
85
+ },
86
+ backingOps: ["sentiment.overview"],
87
+ };
88
+ export const sentimentBreadthSpec = {
89
+ name: "sentiment_breadth",
90
+ description: "Intraday breadth time series for one trading day (up to 241 minute bars; 13:00 excluded due to sparse Sina ticks). `at_date` omitted = today (pre-open returns empty); explicit YYYY-MM-DD = historical replay. Returns per-minute rows of up/down/flat/limit_up/limit_down/breadth/divergence fields — same shape as `sentiment_overview` per row.",
91
+ inputSchema: {
92
+ scope: z.enum(SCOPE_VALUES).default("all_a_ex_st").describe("Universe filter"),
93
+ at_date: z
94
+ .string()
95
+ .regex(DATE_RE)
96
+ .optional()
97
+ .describe("Trade date YYYY-MM-DD; omit for today"),
98
+ },
99
+ handler: async (args, ctx) => {
100
+ const op = OPERATIONS["sentiment.breadth"];
101
+ if (!op)
102
+ throw new Error("sentiment.breadth op missing");
103
+ const callArgs = { scope: args.scope };
104
+ if (args.at_date)
105
+ callArgs.trade_date = args.at_date;
106
+ return callOp(op, callArgs, ctx);
107
+ },
108
+ backingOps: ["sentiment.breadth"],
109
+ };
110
+ export const sentimentTurnoverSpec = {
111
+ name: "sentiment_turnover",
112
+ description: "Intraday turnover time series with `current_turnover` / `predicted_total` / `delta_vs_yesterday` headline fields. `at_date` omitted = today (pre-open returns nulls); explicit YYYY-MM-DD = historical. `days` (1-5) pulls that day + N-1 prior trading days for same-minute YoY comparison.",
113
+ inputSchema: {
114
+ scope: z.enum(SCOPE_VALUES).default("all_a_ex_st").describe("Universe filter"),
115
+ at_date: z
116
+ .string()
117
+ .regex(DATE_RE)
118
+ .optional()
119
+ .describe("Trade date YYYY-MM-DD; omit for today"),
120
+ days: z
121
+ .number()
122
+ .int()
123
+ .min(1)
124
+ .max(5)
125
+ .default(1)
126
+ .describe("at_date 当日 + 之前 days-1 天(1-5,默认 1)"),
127
+ },
128
+ handler: async (args, ctx) => {
129
+ const op = OPERATIONS["sentiment.turnover"];
130
+ if (!op)
131
+ throw new Error("sentiment.turnover op missing");
132
+ const callArgs = { scope: args.scope, days: args.days };
133
+ if (args.at_date)
134
+ callArgs.trade_date = args.at_date;
135
+ return callOp(op, callArgs, ctx);
136
+ },
137
+ backingOps: ["sentiment.turnover"],
138
+ };
139
+ export const sentimentPctDistributionSpec = {
140
+ name: "sentiment_pct_distribution",
141
+ description: "Real-time A-share market pct-change distribution: bucket counts at ≤-10% / -9% / ... / +10% / 涨停, plus universe total. Snapshot only — historical replay not yet exposed.",
142
+ inputSchema: {},
143
+ handler: async (_args, ctx) => {
144
+ const op = OPERATIONS["sentiment.pct-distribution"];
145
+ if (!op)
146
+ throw new Error("sentiment.pct-distribution op missing");
147
+ return callOp(op, {}, ctx);
148
+ },
149
+ backingOps: ["sentiment.pct-distribution"],
150
+ };
151
+ function addScope(cmd) {
36
152
  cmd.addOption(new Option("--scope <universe>", "Universe filter")
37
153
  .choices([...SCOPE_VALUES])
38
154
  .default("all_a_ex_st"));
39
- cmd.action(async (opts) => {
155
+ }
156
+ function addDate(cmd) {
157
+ cmd.addOption(new Option("--date <YYYY-MM-DD>", "Trade date; omit for today's realtime"));
158
+ }
159
+ export function buildSentimentCommand() {
160
+ const cmd = new Command("sentiment").description("Market sentiment — overview / breadth / turnover / pct-distribution; supports any historical trade_date via --date.");
161
+ // No-subcommand path: `echopai sentiment [--scope X]` → overview (BC with
162
+ // v2.2.0 where sentiment was a single-shot verb). NOTE: `--date` is
163
+ // intentionally NOT declared at parent level — commander would consume
164
+ // it from `sentiment overview --date X` and never propagate to the
165
+ // subcommand. For historical date queries, always use the explicit
166
+ // subcommand form: `echopai sentiment overview --date 2026-05-20`.
167
+ addScope(cmd);
168
+ cmd.action(async (opts, command) => {
169
+ // commander invokes parent action even when a subcommand runs unless
170
+ // we guard. Skip if any argv token after the noun is a registered
171
+ // subcommand name.
172
+ const subNames = new Set(command.commands.map((c) => c.name()));
173
+ const argv = process.argv.slice(2);
174
+ if (argv.some((tok) => subNames.has(tok)))
175
+ return;
40
176
  if (!OPERATIONS["sentiment.overview"]) {
41
177
  emitVerbError("internal_error", "sentiment.overview missing", undefined, 2);
42
178
  }
43
179
  await executeVerb(async (ctx) => sentimentSpec.handler({ scope: opts.scope }, ctx));
44
180
  });
181
+ const overview = cmd.command("overview").description(sentimentOverviewSpec.description);
182
+ addScope(overview);
183
+ addDate(overview);
184
+ overview.action(async (opts) => {
185
+ if (!OPERATIONS["sentiment.overview"]) {
186
+ emitVerbError("internal_error", "sentiment.overview missing", undefined, 2);
187
+ }
188
+ const args = { scope: opts.scope };
189
+ if (opts.date)
190
+ args.at_date = opts.date;
191
+ await executeVerb(async (ctx) => sentimentOverviewSpec.handler(args, ctx));
192
+ });
193
+ const breadth = cmd.command("breadth").description(sentimentBreadthSpec.description);
194
+ addScope(breadth);
195
+ addDate(breadth);
196
+ breadth.action(async (opts) => {
197
+ if (!OPERATIONS["sentiment.breadth"]) {
198
+ emitVerbError("internal_error", "sentiment.breadth missing", undefined, 2);
199
+ }
200
+ const args = { scope: opts.scope };
201
+ if (opts.date)
202
+ args.at_date = opts.date;
203
+ await executeVerb(async (ctx) => sentimentBreadthSpec.handler(args, ctx));
204
+ });
205
+ const turnover = cmd.command("turnover").description(sentimentTurnoverSpec.description);
206
+ addScope(turnover);
207
+ addDate(turnover);
208
+ turnover.addOption(new Option("--days <n>", "at_date 当日 + 之前 days-1 天 (1-5)").default("1"));
209
+ turnover.action(async (opts) => {
210
+ if (!OPERATIONS["sentiment.turnover"]) {
211
+ emitVerbError("internal_error", "sentiment.turnover missing", undefined, 2);
212
+ }
213
+ const args = {
214
+ scope: opts.scope,
215
+ days: clampInt(opts.days, 1, 5, 1),
216
+ };
217
+ if (opts.date)
218
+ args.at_date = opts.date;
219
+ await executeVerb(async (ctx) => sentimentTurnoverSpec.handler(args, ctx));
220
+ });
221
+ const pctDist = cmd
222
+ .command("pct-distribution")
223
+ .description(sentimentPctDistributionSpec.description);
224
+ pctDist.action(async () => {
225
+ if (!OPERATIONS["sentiment.pct-distribution"]) {
226
+ emitVerbError("internal_error", "sentiment.pct-distribution missing", undefined, 2);
227
+ }
228
+ await executeVerb(async (ctx) => sentimentPctDistributionSpec.handler({}, ctx));
229
+ });
45
230
  return cmd;
46
231
  }
@@ -10,13 +10,15 @@ import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
10
10
  import { callOp } from "../runtime/verb_runner.js";
11
11
  export const viewsSpec = {
12
12
  name: "views",
13
- description: "PRIMARY research source for stock judgement: analyst views / sell-side reports / long-form opinion stream, with research_entity_id attribution (use `research` verb to query hit-rate). Prefer this over `news` when forming an investment opinion.",
13
+ description: "PRIMARY research source for stock judgement: analyst views / sell-side reports / long-form opinion stream, with research_entity_id attribution. Prefer this over `news` when forming an investment opinion. AH dual-listing: for an A+H listed company pass the A-share canonical to get the consolidated coverage (most domestic analyst views attribute to the A-share security); use HK code only if the user explicitly asks for the HK perspective.",
14
14
  inputSchema: {
15
15
  code: z
16
16
  .string()
17
- .regex(/^(SSE|SZSE|BSE|SH|SZ|BJ):[0-9]{6}$/)
17
+ // Any-market:与 openapi components.schemas.CanonicalCodeAny 一致
18
+ // 研究/观点类 endpoint 不限 A 股;HK:00700 / US:AAPL 也允许(PR #D 拉齐)
19
+ .regex(/^((SSE|SZSE|BSE):[0-9]{6}|HK:[0-9]{5}|US:[A-Z][A-Z0-9.\-]{0,5})$/)
18
20
  .optional()
19
- .describe("Filter by security canonical_code (e.g. SSE:600519)"),
21
+ .describe("Filter by security canonical_code (e.g. SSE:600519, HK:00700, US:AAPL)"),
20
22
  analyst: z.string().optional().describe("Analyst Chinese name (exact / fuzzy)"),
21
23
  institution: z.string().optional().describe("Broker / institution Chinese name"),
22
24
  since_days: z
package/dist/version.js CHANGED
@@ -2,4 +2,4 @@
2
2
  * Pinned at build time. Don't read package.json at runtime — adds startup latency
3
3
  * and breaks bundling.
4
4
  */
5
- export const CLI_VERSION = "2.1.0";
5
+ export const CLI_VERSION = "2.3.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echopai",
3
- "version": "2.1.0",
3
+ "version": "2.3.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",
@@ -1,44 +0,0 @@
1
- /**
2
- * `echopai research [--entity <id>]` + MCP tool `research`.
3
- *
4
- * Views quality signal layer.
5
- */
6
- import { Command, Option } from "commander";
7
- import { z } from "zod";
8
- import { OPERATIONS } from "../_generated/operations.js";
9
- import { executeVerb } from "../runtime/verb_cmd.js";
10
- import { callOp } from "../runtime/verb_runner.js";
11
- export const researchSpec = {
12
- name: "research",
13
- description: "Research-entity performance — hit rate / coverage / avg excess return. Pair with `views` (which returns research_entity_id per row) to assess analyst quality. Omit entity_id for aggregate top-performers list.",
14
- inputSchema: {
15
- entity_id: z
16
- .string()
17
- .optional()
18
- .describe("Specific research_entity_id; omit for aggregate top-performers list"),
19
- },
20
- handler: async (args, ctx) => {
21
- if (args.entity_id) {
22
- const op = OPERATIONS["research.entity-performance"];
23
- if (!op)
24
- throw new Error("research.entity-performance op missing");
25
- return callOp(op, { entity_id: args.entity_id }, ctx);
26
- }
27
- const op = OPERATIONS["research.entity-performance-list"];
28
- if (!op)
29
- throw new Error("research.entity-performance-list op missing");
30
- return callOp(op, {}, ctx);
31
- },
32
- backingOps: ["research.entity-performance-list", "research.entity-performance"],
33
- };
34
- export function buildResearchCommand() {
35
- const cmd = new Command(researchSpec.name).description(researchSpec.description);
36
- cmd.addOption(new Option("--entity <id>", "Specific research_entity_id; omit for aggregate list"));
37
- cmd.action(async (opts) => {
38
- const args = {};
39
- if (opts.entity)
40
- args.entity_id = opts.entity;
41
- await executeVerb(async (ctx) => researchSpec.handler(args, ctx));
42
- });
43
- return cmd;
44
- }