@pipeworx/mcp-china-exchange-data 0.1.2 → 0.1.3

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Live market data straight from the two Chinese mainland exchanges — SZSE (Shenzhen) and SSE (Shanghai), one pack because they are a single jurisdiction. Fills what `@pipeworx/china-stocks` doesn't cover: industry/sector turnover ranking, index daily/weekly bars, a full-market board snapshot for screens, and company bulletins (公告) by code.
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1683+ live data sources.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1764+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
6
6
 
7
7
  板块 主力资金 净流入 指数 日线 公告 — 深交所 上交所 A股行情数据接口
8
8
 
@@ -81,7 +81,7 @@ directly, instead of just this one's:
81
81
  }
82
82
  ```
83
83
 
84
- Both URLs reach the same gateway and the same 1683+ data sources. The
84
+ Both URLs reach the same gateway and the same 1764+ data sources. The
85
85
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
86
86
  reaches all of them from either one.
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-china-exchange-data",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "China Exchange Data MCP (SZSE + SSE). Keyless.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,7 +26,7 @@
26
26
  "@cloudflare/workers-types": "^4.20260405.1"
27
27
  },
28
28
  "pipeworx": {
29
- "sourceHash": "v1-36f467e70b9206734b3e86acabb567a845a345d21a56988898d668121b695459",
30
- "sourceCommit": "babdd280d475dceddecc9ebf8f2a661712781a81"
29
+ "sourceHash": "v1-8195c53163250126a605b218ef1356b0b73857bc1a28595cb321f7eff3f25320",
30
+ "sourceCommit": "0ce6905893fe8da0d285be2f05754a4200d67371"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pipeworx-io/china-exchange-data",
4
4
  "title": "China Exchange Data",
5
5
  "description": "China Exchange Data MCP (SZSE + SSE). Keyless.",
6
- "version": "0.1.2",
6
+ "version": "0.1.3",
7
7
  "websiteUrl": "https://pipeworx.io/packs/china-exchange-data",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-china-exchange-data",
package/src/index.ts CHANGED
@@ -687,14 +687,23 @@ function collapse(s: string): string {
687
687
  * CATALOGID=<id> — used here for CATALOGID=1804_gptj_merge_after (股票行业
688
688
  * 统计, industry turnover statistics; requires txtQueryDate).
689
689
  * - SZSE quote history: www.szse.cn/api/market/ssjjhq/getHistoryData?
690
- * cycleType=<32|33>&marketId=1&code=<code> — daily(32)/weekly(33) OHLC
691
- * bars for any SZSE-listed code OR SZSE index (399xxx). Row shape
690
+ * cycleType=<32|33|37>&marketId=1&code=<code> — daily(32)/weekly(33)/
691
+ * annual(37) OHLC bars for any SZSE-listed code OR SZSE index (399xxx).
692
+ * No begin/end window param — always returns full available history
693
+ * (confirmed back to 1991 for 399xxx indices), sliced client-side to the
694
+ * requested count. (cycleType 34/35/36 = monthly/quarterly/semi-annual,
695
+ * probed but not exposed as tool params — not asked for.) Row shape
692
696
  * (empirically confirmed, not documented by SZSE):
693
- * [date, open, close, low, high, change, change_pct, volume, amount].
697
+ * [date, open, close, low, high, change, change_pct, volume, amount] —
698
+ * change/change_pct are period-over-period (e.g. for cycleType=37, this
699
+ * year's close vs last year's close), computed by SZSE itself.
694
700
  * - SSE public quote system: yunhq.sse.com.cn:32041/v1/sh1/... (plain HTTP
695
- * only — the TLS port is not reachable from here). /dayk/<code> gives
696
- * daily OHLCV bars for any SSE code OR SSE index (000xxx/999xxx), row =
697
- * [date(YYYYMMDD), open, high, low, close, volume, amount].
701
+ * only — the TLS port is not reachable from here). /dayk/<code>?period=
702
+ * day|week|year gives OHLCV bars for any SSE code OR SSE index
703
+ * (000xxx/999xxx); period=year confirmed live back to the 1990s. Row =
704
+ * [date(YYYYMMDD), open, high, low, close, volume, amount] — no
705
+ * change/change_pct field, so ashares_index_bars computes it here from
706
+ * consecutive closes (fetches one extra bar as the prior-close anchor).
698
707
  * /list/exchange/equity gives the full SSE equity board as one paged call
699
708
  * (~2,358 rows): [code, name, last, change_pct, volume, amount] — a
700
709
  * real-time/latest-session snapshot, not per-symbol OHLC, and not
@@ -753,6 +762,24 @@ function round2(v: number | null): number | null {
753
762
  function todayISO(): string {
754
763
  return new Date().toISOString().slice(0, 10);
755
764
  }
765
+ /** True if `dateStr` (YYYY-MM-DD) is the exchange's in-progress CURRENT year,
766
+ * not yet a closed calendar year — both exchanges' annual-bar endpoints append
767
+ * a running bar for the current year dated "today" rather than year-end until
768
+ * the year actually closes (confirmed live 2026-10-10: the newest annual bar
769
+ * for 000001/399001 was dated 2026-10-09, not 2026-12-3x). Treating that as a
770
+ * completed "2026 annual close" would silently push a 6-year request to
771
+ * 2021-2026 instead of 2020-2025 — so `years` mode drops it. Once the
772
+ * calendar reaches the exchanges' actual year-end trading days (~Dec 29-31)
773
+ * the bar IS the real close and is kept. */
774
+ function isPartialCurrentYear(dateStr: string): boolean {
775
+ const today = todayISO();
776
+ if (dateStr.slice(0, 4) !== today.slice(0, 4)) return false;
777
+ return !(today.slice(5, 7) === '12' && Number(today.slice(8, 10)) >= 28);
778
+ }
779
+ function dropTrailingPartialYear<T extends { date: string }>(bars: T[]): T[] {
780
+ if (bars.length === 0) return bars;
781
+ return isPartialCurrentYear(bars[bars.length - 1].date) ? bars.slice(0, -1) : bars;
782
+ }
756
783
  function stripJsonp(text: string): unknown {
757
784
  const m = text.match(/^[^(]*\(([\s\S]*)\)\s*;?\s*$/);
758
785
  const body = m ? m[1] : text;
@@ -821,13 +848,14 @@ const tools: McpToolExport['tools'] = [
821
848
  {
822
849
  name: 'ashares_index_bars',
823
850
  description:
824
- '上证/深证指数日线周线 — Daily or weekly OHLC + volume + turnover bars for a major China A-share index: 上证指数 Shanghai Composite (000001), 上证50 (000016), 沪深300 CSI 300 (000300), 科创50 STAR50 (000688) from SSE; 深证成指 Shenzhen Component (399001), 创业板指 ChiNext (399006) from SZSE. Answers "上证指数000001最近10个交易日OHLC", "Shanghai Composite last 10 days", "深证成指周线", "ChiNext index history". Source: SSE yunhq public quote system (000xxx/999xxx indices) + SZSE ssjjhq history API (399xxx indices), both keyless.',
851
+ '上证/深证指数日线周线年线 — Daily, weekly, or annual OHLC + volume + turnover bars for a major China A-share index: 上证指数 Shanghai Composite (000001), 上证50 (000016), 沪深300 CSI 300 (000300), 科创50 STAR50 (000688) from SSE; 深证成指 Shenzhen Component (399001), 创业板指 ChiNext (399006) from SZSE. Weekly history goes back up to 6 years (312 bars); `years` returns one bar per calendar year (close + year-over-year pct change) for multi-year questions like "5 years of annual closes for CSI 300". Answers "上证指数000001最近10个交易日OHLC", "Shanghai Composite last 10 days", "深证成指周线", "ChiNext index history", "CSI 300 annual close 2020-2025". Source: SSE yunhq public quote system (000xxx/999xxx indices) + SZSE ssjjhq history API (399xxx indices), both keyless.',
825
852
  inputSchema: {
826
853
  type: 'object' as const,
827
854
  properties: {
828
855
  index: { type: 'string', description: 'Index code: 000001 (上证指数), 000016 (上证50), 000300 (沪深300), 000688 (科创50), 399001 (深证成指), 399006 (创业板指). Default 000001.' },
829
- days: { type: 'number', description: 'Number of daily bars to return, 1-250 (default 10). Ignored if weeks is set.' },
830
- weeks: { type: 'number', description: 'Number of weekly bars to return, 1-104. Overrides days when set.' },
856
+ days: { type: 'number', description: 'Number of daily bars to return, 1-250 (default 10). Ignored if weeks or years is set.' },
857
+ weeks: { type: 'number', description: 'Number of weekly bars to return, 1-312 (up to ~6 years). Overrides days when set; ignored if years is set.' },
858
+ years: { type: 'number', description: 'Number of annual bars to return, 1-40 — one bar per calendar year with close and year-over-year pct_change. Overrides weeks and days when set. E.g. years:6 for 2020-2025 inclusive.' },
831
859
  },
832
860
  },
833
861
  },
@@ -904,13 +932,27 @@ async function sectorFlows(args: Record<string, unknown>) {
904
932
  // ── ashares_index_bars ──────────────────────────────────────────────
905
933
  async function indexBars(args: Record<string, unknown>) {
906
934
  const code = typeof args.index === 'string' && args.index.trim() ? args.index.trim() : '000001';
907
- const weeks = args.weeks != null ? Math.min(Math.max(Number(args.weeks), 1), 104) : null;
908
- const days = weeks == null ? Math.min(Math.max(Number(args.days ?? 10), 1), 250) : null;
935
+ // years > weeks > days. Weekly ceiling raised 104 -> 312 (~6y); years is new
936
+ // (both exchanges expose a native annual cycle — confirmed live 2026-10-10:
937
+ // SZSE cycleType=37, SSE yunhq period=year — so this is a real upstream bar
938
+ // size, not a client-side aggregation).
939
+ const years = args.years != null ? Math.min(Math.max(Number(args.years), 1), 40) : null;
940
+ const weeks = years == null && args.weeks != null ? Math.min(Math.max(Number(args.weeks), 1), 312) : null;
941
+ const days = years == null && weeks == null ? Math.min(Math.max(Number(args.days ?? 10), 1), 250) : null;
942
+ const want = (years ?? weeks ?? days) as number;
943
+ const period = years != null ? 'annual' : weeks != null ? 'weekly' : 'daily';
909
944
  const exch = indexExchange(code);
910
945
  const name = exch === 'sse' ? SSE_INDICES[code] : SZSE_INDICES[code];
911
946
 
912
947
  if (exch === 'szse') {
913
- const cycleType = weeks != null ? 33 : 32;
948
+ // SZSE cycleType: 32 daily, 33 weekly, 37 annual (confirmed live; 34/35/36
949
+ // are monthly/quarterly/semi-annual, not used here). getHistoryData has no
950
+ // begin/end window param — it always returns full available history and
951
+ // we slice client-side, which is also how the weekly ceiling raise works
952
+ // with no upstream change. change/change_pct in the row are already
953
+ // period-over-period (confirmed: 2020 annual change = 2020 close - 2019
954
+ // close for 399001), so no recomputation needed here.
955
+ const cycleType = years != null ? 37 : weeks != null ? 33 : 32;
914
956
  const params = new URLSearchParams({ random: String(Math.random()), cycleType: String(cycleType), marketId: '1', code });
915
957
  const res = await pwFetch(`${SZSE_HISTORY}?${params}`, { headers: { Referer: SZSE_REFERER } }, 'SZSE');
916
958
  if (!res.ok) throw await httpError(res, 'SZSE');
@@ -918,36 +960,63 @@ async function indexBars(args: Record<string, unknown>) {
918
960
  if (body.code !== '0' || !body.data?.picupdata) {
919
961
  return { index: code, name: name ?? body.data?.name ?? null, found: false, message: `No SZSE history for index ${code}.` };
920
962
  }
921
- const want = (weeks ?? days) as number;
922
- const bars = body.data.picupdata.slice(-want).map((r) => szseRow(r));
963
+ // For annual, fetch a small margin so dropping the in-progress current
964
+ // year still leaves `want` closed years (full history is always returned
965
+ // here regardless, so this costs nothing extra upstream).
966
+ let bars = body.data.picupdata.slice(-(want + (years != null ? 2 : 0))).map((r) => szseRow(r));
967
+ if (years != null) bars = dropTrailingPartialYear(bars);
968
+ bars = bars.slice(-want);
923
969
  return {
924
970
  index: code,
925
971
  name: name ?? body.data.name ?? null,
926
- period: weeks != null ? 'weekly' : 'daily',
972
+ period,
927
973
  bar_count: bars.length,
928
974
  bars,
929
- source: 'SZSE ssjjhq getHistoryData (cycleType=32 daily / 33 weekly, marketId=1) — undocumented row order confirmed empirically as [date, open, close, low, high, change, change_pct, volume, amount]',
975
+ source: years != null
976
+ ? 'SZSE ssjjhq getHistoryData (cycleType=37 annual, marketId=1) — one bar per calendar year; change/change_pct are SZSE-computed year-over-year close deltas'
977
+ : 'SZSE ssjjhq getHistoryData (cycleType=32 daily / 33 weekly, marketId=1) — undocumented row order confirmed empirically as [date, open, close, low, high, change, change_pct, volume, amount]',
930
978
  };
931
979
  }
932
980
 
933
- // SSE via yunhq: /v1/sh1/dayk/<code>?begin=-N&end=-1&period=day
934
- const want = (weeks ?? days) as number;
935
- const period = weeks != null ? 'week' : 'day';
936
- const url = `${SSE_YUNHQ}/v1/sh1/dayk/${encodeURIComponent(code)}?begin=-${want}&end=-1&period=${period}`;
981
+ // SSE via yunhq: /v1/sh1/dayk/<code>?begin=-N&end=-1&period=day|week|year
982
+ // (period=year confirmed live 2026-10-10 — e.g. 000001 has 37 annual bars
983
+ // back to the 1990s). Two things to compensate for, both confirmed live:
984
+ // (a) yunhq's "begin=-N" is off by one — it returns N-1 bars, not N (this
985
+ // IS the original bug symptom: weeks:104 returned bar_count 103).
986
+ // (b) yunhq's row has no change/change_pct field, so we compute it here
987
+ // from consecutive closes, which needs one extra prior-close anchor.
988
+ // For annual we also need one more so dropping the in-progress current
989
+ // year still leaves `want` closed years.
990
+ const upstreamPeriod = years != null ? 'year' : weeks != null ? 'week' : 'day';
991
+ const offByOne = 1; // (a)
992
+ const anchorExtra = 1; // (b)
993
+ const partialYearExtra = years != null ? 1 : 0;
994
+ const requestN = want + offByOne + anchorExtra + partialYearExtra;
995
+ const url = `${SSE_YUNHQ}/v1/sh1/dayk/${encodeURIComponent(code)}?begin=-${requestN}&end=-1&period=${upstreamPeriod}`;
937
996
  const res = await pwFetch(url, {}, 'SSE');
938
997
  if (!res.ok) throw await httpError(res, 'SSE');
939
998
  const body = (await res.json()) as { code?: string; kline?: unknown[][] };
940
999
  if (!body.kline || body.kline.length === 0) {
941
1000
  return { index: code, name: name ?? null, found: false, message: `No SSE history for index ${code}.` };
942
1001
  }
943
- const bars = body.kline.map((r) => sseRow(r));
1002
+ const parsed = body.kline.map((r) => sseRow(r));
1003
+ let withChange = parsed.map((r, i) => {
1004
+ const prevClose = i > 0 ? parsed[i - 1].close : null;
1005
+ const change = prevClose != null && r.close != null ? round2(r.close - prevClose) : null;
1006
+ const change_pct = prevClose != null && r.close != null && prevClose !== 0 ? round2(((r.close - prevClose) / prevClose) * 100) : null;
1007
+ return { ...r, change, change_pct };
1008
+ });
1009
+ if (years != null) withChange = dropTrailingPartialYear(withChange);
1010
+ const bars = withChange.length > want ? withChange.slice(-want) : withChange;
944
1011
  return {
945
1012
  index: code,
946
1013
  name: name ?? null,
947
- period: weeks != null ? 'weekly' : 'daily',
1014
+ period,
948
1015
  bar_count: bars.length,
949
1016
  bars,
950
- source: 'SSE yunhq public quote system /v1/sh1/dayk (row = [date, open, high, low, close, volume, amount])',
1017
+ source: years != null
1018
+ ? 'SSE yunhq public quote system /v1/sh1/dayk period=year — one bar per calendar year; change/change_pct computed here (close vs prior year close) since SSE does not return them'
1019
+ : 'SSE yunhq public quote system /v1/sh1/dayk (row = [date, open, high, low, close, volume, amount]; change/change_pct computed here from consecutive closes)',
951
1020
  };
952
1021
  }
953
1022
 
package/src/server.ts CHANGED
@@ -9,7 +9,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
9
9
  import pack from './index.js';
10
10
 
11
11
  const server = new Server(
12
- { name: '@pipeworx/mcp-china-exchange-data', version: '0.1.2' },
12
+ { name: '@pipeworx/mcp-china-exchange-data', version: '0.1.3' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15