@v587d/capital-generation 2.7.12 → 2.8.1

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 (157) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +10 -6
  3. package/THIRD_PARTY_NOTICES.md +6 -1
  4. package/chart-ui/package.json +1 -1
  5. package/config/index.js +1 -0
  6. package/config/package.json +1 -1
  7. package/lib/agents/bash-guard.d.ts +22 -17
  8. package/lib/agents/bash-guard.js +32 -28
  9. package/lib/agents/root-tool-policy.d.ts +29 -3
  10. package/lib/agents/root-tool-policy.js +100 -14
  11. package/lib/data-collector/dataset-tools.js +9 -0
  12. package/lib/data-collector/tools.js +17 -5
  13. package/lib/index.d.ts +4 -0
  14. package/lib/index.js +27 -8
  15. package/lib/sources/ftshare-rest.d.ts +54 -0
  16. package/lib/sources/ftshare-rest.js +2363 -0
  17. package/lib/time/tools.js +24 -0
  18. package/lib/web-retriever/local-fetch.d.ts +1 -1
  19. package/lib/web-retriever/local-fetch.js +1 -1
  20. package/lib/web-retriever/wind-client.d.ts +1 -1
  21. package/lib/web-retriever/wind-client.js +1 -1
  22. package/package.json +1 -1
  23. package/preset/capital-generation/README.md +22 -15
  24. package/preset/capital-generation/agent.patch.yml +14 -1
  25. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +4 -1
  26. package/selected-skills/InvestSkill/UPSTREAM.md +17 -0
  27. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/10k-digest/SKILL.md +10 -296
  28. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/10k-digest/references/10-retrieving-the-filing.md +130 -0
  29. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/10k-digest/references/11-key-metrics-table.md +129 -0
  30. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/10k-digest/references/12-language-mode.md +60 -0
  31. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/bear-case/SKILL.md +5 -3
  32. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/catalyst-calendar/SKILL.md +10 -284
  33. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/catalyst-calendar/references/10-persona.md +113 -0
  34. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/catalyst-calendar/references/11-section-c-company-specific.md +108 -0
  35. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/catalyst-calendar/references/12-section-f-90-day.md +85 -0
  36. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/competitor-analysis/SKILL.md +11 -433
  37. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/competitor-analysis/references/10-overview.md +82 -0
  38. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/competitor-analysis/references/11-porter-s-five-forces.md +137 -0
  39. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/competitor-analysis/references/12-innovation-disruption-assessment.md +112 -0
  40. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/competitor-analysis/references/13-input-formats.md +130 -0
  41. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/dividend-analysis/SKILL.md +12 -639
  42. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/dividend-analysis/references/10-analysis-framework.md +138 -0
  43. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/dividend-analysis/references/11-dividend-history-and-reliability.md +131 -0
  44. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/dividend-analysis/references/12-share-buyback-analysis.md +125 -0
  45. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/dividend-analysis/references/13-fcf-deployment-scorecard.md +133 -0
  46. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/dividend-analysis/references/14-output.md +149 -0
  47. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-call-analysis/SKILL.md +10 -411
  48. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-call-analysis/references/10-analysis-framework.md +194 -0
  49. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-call-analysis/references/11-data-sources.md +196 -0
  50. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-call-analysis/references/12-thesis-invalidation.md +43 -0
  51. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-preview/SKILL.md +11 -234
  52. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-preview/references/10-overview.md +86 -0
  53. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-preview/references/11-phase-4-the-implied.md +60 -0
  54. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-preview/references/12-output-format.md +83 -0
  55. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-preview/references/13-standard-signal-output.md +34 -0
  56. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/economics-analysis/SKILL.md +10 -304
  57. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/economics-analysis/references/10-key-economic-indicators.md +98 -0
  58. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/economics-analysis/references/11-credit-market-indicators.md +114 -0
  59. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/economics-analysis/references/12-recession-probability-scoring.md +114 -0
  60. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/etf-analysis/SKILL.md +11 -236
  61. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/etf-analysis/references/10-overview.md +83 -0
  62. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/etf-analysis/references/11-phase-5-overlap-with.md +69 -0
  63. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/etf-analysis/references/12-output-format.md +79 -0
  64. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/etf-analysis/references/13-standard-signal-output.md +34 -0
  65. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/fact-check/SKILL.md +11 -243
  66. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/fact-check/references/10-overview.md +61 -0
  67. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/fact-check/references/11-phase-2-source-hierarchy.md +54 -0
  68. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/fact-check/references/12-phase-5-consistency.md +90 -0
  69. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/fact-check/references/13-notes.md +68 -0
  70. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/financial-report-analyst/SKILL.md +11 -499
  71. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/financial-report-analyst/references/10-overview.md +143 -0
  72. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/financial-report-analyst/references/11-phase-3-financial-statement.md +167 -0
  73. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/financial-report-analyst/references/12-phase-6-management-tone.md +176 -0
  74. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/financial-report-analyst/references/13-thesis-invalidation.md +43 -0
  75. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/industry-map/SKILL.md +10 -291
  76. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/industry-map/references/10-overview.md +117 -0
  77. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/industry-map/references/11-step-3-position-locator.md +112 -0
  78. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/industry-map/references/12-visualization-support.md +84 -0
  79. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/insider-trading/SKILL.md +10 -457
  80. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/insider-trading/references/10-analysis-framework.md +185 -0
  81. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/insider-trading/references/11-pattern-recognition.md +175 -0
  82. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/insider-trading/references/12-interpretation-guidelines.md +120 -0
  83. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/institutional-ownership/SKILL.md +11 -503
  84. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/institutional-ownership/references/10-analysis-framework.md +132 -0
  85. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/institutional-ownership/references/11-ownership-concentration-analysis.md +166 -0
  86. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/institutional-ownership/references/12-red-flags-warning-signs.md +192 -0
  87. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/institutional-ownership/references/13-thesis-invalidation.md +43 -0
  88. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/learning-coach/SKILL.md +11 -264
  89. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/learning-coach/references/10-overview.md +63 -0
  90. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/learning-coach/references/11-phase-2-explanation-card.md +108 -0
  91. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/learning-coach/references/12-output-format.md +88 -0
  92. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/learning-coach/references/13-standard-signal-output.md +34 -0
  93. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/options-analysis/SKILL.md +11 -448
  94. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/options-analysis/references/10-analysis-framework.md +109 -0
  95. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/options-analysis/references/11-implied-volatility-iv-analysis.md +102 -0
  96. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/options-analysis/references/12-earnings-play-analysis.md +109 -0
  97. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/options-analysis/references/13-data-sources.md +159 -0
  98. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/portfolio-review/SKILL.md +11 -435
  99. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/portfolio-review/references/10-phase-1-portfolio-snapshot.md +124 -0
  100. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/portfolio-review/references/11-phase-6-correlation-diversification.md +124 -0
  101. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/portfolio-review/references/12-phase-9-rebalancing-decision.md +153 -0
  102. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/portfolio-review/references/13-data-verification.md +62 -0
  103. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/position-ladder/SKILL.md +11 -372
  104. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/position-ladder/references/10-overview.md +102 -0
  105. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/position-ladder/references/11-phase-3-the-trim.md +88 -0
  106. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/position-ladder/references/12-phase-6-the-thesis.md +105 -0
  107. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/position-ladder/references/13-input-formats.md +105 -0
  108. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/risk-stress-test/SKILL.md +10 -250
  109. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/risk-stress-test/references/10-overview.md +92 -0
  110. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/risk-stress-test/references/11-phase-4-correlation-spike.md +116 -0
  111. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/risk-stress-test/references/12-notes.md +65 -0
  112. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/sector-analysis/SKILL.md +11 -381
  113. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/sector-analysis/references/10-sector-overview.md +123 -0
  114. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/sector-analysis/references/11-sector-correlation-matrix.md +90 -0
  115. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/sector-analysis/references/12-peer-benchmarking-within-sector.md +92 -0
  116. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/sector-analysis/references/13-consumer-staples.md +105 -0
  117. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/short-interest/SKILL.md +10 -352
  118. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/short-interest/references/10-analysis-framework.md +110 -0
  119. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/short-interest/references/11-bearish-thesis-evaluation.md +118 -0
  120. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/short-interest/references/12-data-sources.md +148 -0
  121. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-eval/SKILL.md +16 -603
  122. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-eval/references/10-analysis-components.md +122 -0
  123. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-eval/references/11-dupont-decomposition.md +121 -0
  124. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-eval/references/12-quality-scoring-framework.md +115 -0
  125. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-eval/references/13-dcf-framework.md +113 -0
  126. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-eval/references/14-analyst-consensus-tracking.md +127 -0
  127. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-eval/references/15-thesis-invalidation.md +43 -0
  128. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-screener/SKILL.md +5 -3
  129. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-valuation/SKILL.md +12 -595
  130. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-valuation/references/10-overview.md +133 -0
  131. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-valuation/references/11-method-2-comparable-company.md +127 -0
  132. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-valuation/references/12-football-field-summary-valuation.md +140 -0
  133. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-valuation/references/13-terminal-value-two-methods.md +121 -0
  134. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-valuation/references/14-when-dcf-is.md +110 -0
  135. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/tax-lens/SKILL.md +12 -304
  136. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/tax-lens/references/10-overview.md +73 -0
  137. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/tax-lens/references/11-phase-3-dividends-qualified.md +67 -0
  138. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/tax-lens/references/12-framework-non-us-module.md +61 -0
  139. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/tax-lens/references/13-scoring-tax-efficiency-score.md +83 -0
  140. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/tax-lens/references/14-thesis-invalidation.md +55 -0
  141. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/technical-analysis/SKILL.md +16 -612
  142. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/technical-analysis/references/10-chart-pattern-analysis.md +172 -0
  143. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/technical-analysis/references/11-chart-visualization-support.md +181 -0
  144. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/technical-analysis/references/12-multi-timeframe-analysis-mtf.md +95 -0
  145. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/technical-analysis/references/13-advanced-indicators-ichimoku-cloud.md +61 -0
  146. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/technical-analysis/references/14-options-flow-integration.md +95 -0
  147. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/technical-analysis/references/15-thesis-invalidation.md +45 -0
  148. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/thesis-tracker/SKILL.md +10 -274
  149. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/thesis-tracker/references/10-overview.md +94 -0
  150. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/thesis-tracker/references/11-phase-5-the-pre.md +113 -0
  151. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/thesis-tracker/references/12-example.md +90 -0
  152. package/selected-skills/claude-for-financial-services-cn/vertical-plugins/china-finance/skills/china-catalyst-calendar/SKILL.md +5 -3
  153. package/selected-skills/claude-for-financial-services-cn/vertical-plugins/china-finance/skills/china-idea-generation/SKILL.md +5 -3
  154. package/selected-skills/claude-for-financial-services-cn/vertical-plugins/china-finance/skills/china-sector-overview/SKILL.md +5 -3
  155. package/selected-skills/investment-skills/skills/buffett-investment-framework/SKILL.md +1 -1
  156. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/SKILL.md +5 -3
  157. package/watchlist/package.json +1 -1
@@ -0,0 +1,2363 @@
1
+ /**
2
+ * FTShare(非凸科技 `market.ft.tech/gateway`)REST source —— **资金流 + 行情/日 K 截面 + 快照与日历
3
+ * + 大宗与披露 + 申万行业**。
4
+ *
5
+ * 为什么接这一族(2026-10-15 逐条真报文实测):
6
+ * - 同花顺 `capital-flow/*` 对我们是 `code=2004` 永久关闭,东财 `eastmoney_main_capital_snapshot`
7
+ * 实测**只有查询日一股一行**——个股资金流的历史序列在本仓一直是空的。这一族给到最近约 45 个交易日
8
+ * (T-45 仍有 5199 行、T-50 起 total=0),主力五档更给到约 187 个交易日。
9
+ * - 板块(行业)资金滚动此前只有 `eastmoney_cashflow_rotation` 一条,上游一抖就没有第二家。
10
+ * - 北交所行情此前是**零**:腾讯那一路在参数层明确拒 BJ,而这一族实测含 349 只北证票。
11
+ * 上游分别是同花顺与同花顺 daec,与现有能力**不是同一条抓取路径**,才够称 plan B。
12
+ *
13
+ * ⛔ 本模块的 description 与注释**只允许写有原始报文可回查的数字**。2026-10-15 曾有一批"实测计数"
14
+ * (行数在 3766/3864/5450/5574 之间跳、`ys` 返回 309 行、业务码 100605、页码回显谎报)事后逐条回查
15
+ * **在任何一次真报文里都不存在**,是我把不同接口的读数串成了因果;据此撤掉的两颗能力已于当晚复验注册回来。
16
+ * 教训写进 `docs/design/ftshare-source-survey.md` §7.5–§7.7:**没打进日志的数字不算实测**。
17
+ *
18
+ * ⛔ 免费档限流 **10 QPS / burst 20**(官方口径)。`DataCollectorHub` 的 FIFO 只保证同一时刻
19
+ * 一个请求,**不含最小间隔**——串行 ≠ 节流(`src/net/eastmoney-client.ts` 同一条教训)。
20
+ * 本模块用进程级节流链把上限压到 ~8 QPS,留突发余量。
21
+ *
22
+ * ⛔ 凭据一律**调用期解析**,注册只看结构(AGENTS.md §9.7,2026-09-30 桌面端 61 能力静默缺席
23
+ * 那一族事故)。套餐外路由返回 403 `route_not_allowed`,归类成 `plan_closed` 且不重试——
24
+ * 它和 Fuyao 的 `2004` 是同一件事:能力级不可用,重试只白烧回合。
25
+ */
26
+ import { buildDataKey } from '../data-collector/hub.js';
27
+ import { getDataTimeContract } from '../time/tools.js';
28
+ import { createRequestThrottle } from '../net/throttle.js';
29
+ import { parseSecurityCode } from './security-code.js';
30
+ const FTSHARE_BASE_URL = 'https://market.ft.tech/gateway';
31
+ /** 10 QPS 的 80% 上限:120ms 最小间隔 ⇒ 稳态 ~8.3 次/秒,突发也不会越过 20。 */
32
+ export const FTSHARE_MIN_INTERVAL_MS = 120;
33
+ /** 日度资金流族:实测 `page_size=1000` 可用(5214 行分 6 次取完)。 */
34
+ const MAX_PAGE_SIZE = 1000;
35
+ /**
36
+ * ⛔ 上游有两处把 `page_size` 压在 500:日 K 快照是**响亮拒绝**(实测 501 → HTTP 400
37
+ * `page_size must be between 1 and 500`),主力五档是**静默截断**(实测 1000/2000 都只回 500 行)。
38
+ * 两种都不许本层放宽——静默那一半尤其只能靠参数层先拦住,否则模型以为拿到了整页。
39
+ * 2026-10-16 补:大宗交易(传 1000/2000 回显 500)与龙虎榜明细(同形)也走这一档;
40
+ * 业绩预告 / 快报则是 501 → 响亮 400「page_size 不能超过 500」。**同一档里两种形状都有**。
41
+ */
42
+ const MAX_PAGE_SIZE_500 = 500;
43
+ /**
44
+ * ⛔ 申万行业日度指标那一处更低:实测 `page_size=1000` 回显 `pageSize=200` 且只回 200 行
45
+ * (201 也一样被截成 200),同样是**静默**截断。
46
+ */
47
+ const MAX_PAGE_SIZE_200 = 200;
48
+ /** 指数权重那一族:实测 101 → 回显 `page_size=100` 并只回 100 行(**静默**夹断,文档这条难得与实测一致)。 */
49
+ const MAX_PAGE_SIZE_100 = 100;
50
+ const FLOW_PATHS = {
51
+ stock: '/api/v1/market/data/ths-stock-daily-flow',
52
+ industry: '/api/v1/market/data/ths-industry-daily-flow',
53
+ concept: '/api/v1/market/data/ths-concept-daily-flow',
54
+ mainCapital: '/api/v1/market/data/stock-capital-flows',
55
+ };
56
+ export class FtshareError extends Error {
57
+ kind;
58
+ status;
59
+ code;
60
+ constructor(kind, message, details = {}) {
61
+ super(message);
62
+ this.name = 'FtshareError';
63
+ this.kind = kind;
64
+ this.status = details.status;
65
+ this.code = details.code;
66
+ }
67
+ }
68
+ /** 进程级节流链:所有 FTShare 请求共用一份(限流按出口计,两份独立限流等于没有限流)。 */
69
+ let sharedThrottle;
70
+ function ftshareThrottle(task) {
71
+ sharedThrottle ??= createRequestThrottle(FTSHARE_MIN_INTERVAL_MS);
72
+ return sharedThrottle(task);
73
+ }
74
+ function isRecord(value) {
75
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
76
+ }
77
+ /**
78
+ * 上游数值多处用**字符串**给(实测五档净流入 `"49285883.19"`、日 K 快照收盘价 `"11.5000"`)。
79
+ * 转成 number 是本层的职责:留在字符串里,data_junior 的 `filters` 数值比较会静默不命中。
80
+ * 空串/null/非数一律 `null`——不补 0(§9.5:空洞不许带出,`null` 与 `0` 语义不同)。
81
+ */
82
+ function numberOrNull(value) {
83
+ if (value === undefined || value === null || value === '')
84
+ return null;
85
+ const parsed = typeof value === 'number' ? value : Number(String(value).replaceAll(',', '').trim());
86
+ return Number.isFinite(parsed) ? parsed : null;
87
+ }
88
+ function integerOrNull(value) {
89
+ const parsed = numberOrNull(value);
90
+ return parsed === null ? null : Math.trunc(parsed);
91
+ }
92
+ function textOrNull(value) {
93
+ return value === undefined || value === null || value === '' ? null : String(value);
94
+ }
95
+ function date(value, name) {
96
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
97
+ throw new FtshareError('invalid_parameter', `parameter ${name} must be YYYY-MM-DD`);
98
+ const parsed = new Date(`${value}T00:00:00Z`);
99
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value)
100
+ throw new FtshareError('invalid_parameter', `parameter ${name} must be a valid YYYY-MM-DD date`);
101
+ return value;
102
+ }
103
+ /** 上游日期一律 `YYYYMMDD`(实测传 `2026-10-08` 返回 400 `参数无效:date`)。 */
104
+ function compactDate(value) {
105
+ return value.replaceAll('-', '');
106
+ }
107
+ function dateMs(value) {
108
+ return Date.parse(`${value}T00:00:00+08:00`);
109
+ }
110
+ /** 上游日期列实测有两种写法:`2026-10-14` 与 `20261014`。归一成 ISO + 毫秒轴。 */
111
+ function normalizeDateCell(value) {
112
+ const raw = textOrNull(value);
113
+ if (raw === null)
114
+ return { iso: null, ms: null };
115
+ const compact = /^\d{8}$/.test(raw) ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : raw.slice(0, 10);
116
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(compact))
117
+ return { iso: raw, ms: null };
118
+ const ms = dateMs(compact);
119
+ return { iso: compact, ms: Number.isNaN(ms) ? null : ms };
120
+ }
121
+ /**
122
+ * `ts_nanos` 是**纳秒**(实测 `1791963000000000000`)。本仓时间轴口径是毫秒(AGENTS.md §通用约定),
123
+ * 纳秒直接落进 Dataset 会让 data_junior 的区间过滤差六个数量级且不报错。
124
+ */
125
+ function nanosToMillis(value) {
126
+ const parsed = numberOrNull(value);
127
+ if (parsed === null)
128
+ return null;
129
+ const ms = Math.round(parsed / 1e6);
130
+ return Number.isSafeInteger(ms) ? ms : null;
131
+ }
132
+ /** 毫秒 → 上海时区的自然日与切片时刻(A 股无夏令时,固定 +08:00 足够)。 */
133
+ function shanghaiParts(ms) {
134
+ const shifted = new Date(ms + 8 * 3600_000);
135
+ const iso = shifted.toISOString();
136
+ return { trade_date: iso.slice(0, 10), slice_time: iso.slice(11, 16) };
137
+ }
138
+ const SLICE_MINUTES = new Set([0, 15, 30, 45]);
139
+ /** 盘中切片时刻:上游只认 15 分钟栅格(实测 `0935` → 400,`0930/0945/…/1530` → 200)。 */
140
+ function sliceTime(value) {
141
+ const raw = typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
142
+ const match = /^(\d{2}):?(\d{2})$/.exec(raw);
143
+ if (!match)
144
+ throw new FtshareError('invalid_parameter', 'parameter time must be HHMM or HH:MM on a 15-minute grid (for example 1000, 1015, 1530)');
145
+ const [, hours, minutes] = match;
146
+ const hour = Number(hours);
147
+ const minute = Number(minutes);
148
+ if (hour > 23 || minute > 59)
149
+ throw new FtshareError('invalid_parameter', `parameter time ${raw} is not a clock time`);
150
+ if (!SLICE_MINUTES.has(minute))
151
+ throw new FtshareError('invalid_parameter', `parameter time ${raw} is off the 15-minute grid; use 0930/0945/1000/…`);
152
+ return `${hours}${minutes}`;
153
+ }
154
+ /**
155
+ * 分页参数。`page` 实测**生效**(2026-10-15 17:05 之后:报价族六页 × 1000 去重恰为 `total_items` 5574,
156
+ * 日 K 第十二页只剩 63 行,日度族 page=1/2/3 的首行分别是 000001 / 000007 / 000010)。
157
+ * 页码与页数一律**按请求自己算**、不读信封里的 `pageNum`/`pages`:实测回显与请求一致(page=1/2/3 逐页对齐),
158
+ * 但那是上游的状态;本层只从信封取 `total`,回显漂了也不会把错的页码写进 Dataset。
159
+ */
160
+ function pageParams(params, allowed, maxSize) {
161
+ for (const key of Object.keys(params))
162
+ if (!allowed.includes(key))
163
+ throw new FtshareError('invalid_parameter', `unsupported parameter: ${key}`);
164
+ const page = params.page === undefined ? 1 : Number(params.page);
165
+ const size = params.size === undefined ? Math.min(100, maxSize) : Number(params.size);
166
+ if (!Number.isSafeInteger(page) || page < 1)
167
+ throw new FtshareError('invalid_parameter', 'parameter page must be an integer >= 1');
168
+ if (!Number.isSafeInteger(size) || size < 1 || size > maxSize)
169
+ throw new FtshareError('invalid_parameter', `parameter size must be an integer from 1 to ${maxSize}`);
170
+ return { page, size };
171
+ }
172
+ /**
173
+ * 信封有两套(实测):正常与 4xx 是 `{code,message,data}`,套餐外 403 是 `{code,data,msg}`。
174
+ * 只读 `message` 会把"你无权访问"报成"上游错误:undefined",白丢一句最该给用户看的话。
175
+ */
176
+ function envelopeFailure(envelope, status) {
177
+ const code = numberOrNull(envelope.code);
178
+ const message = textOrNull(envelope.message) ?? textOrNull(envelope.msg) ?? 'request failed';
179
+ const reason = textOrNull(isRecord(envelope.data) ? envelope.data.reason : undefined);
180
+ const details = status === undefined ? {} : { status };
181
+ if (code === null) {
182
+ // 信封里没有业务码(例如 429 只回一个空 JSON 体):这时**只有 HTTP 状态还能说话**,
183
+ // 把它退化成 upstream_error 等于让限流看起来像"上游坏了",Agent 会去改参数而不是退避。
184
+ if (status === 429)
185
+ return new FtshareError('rate_limited', 'FTShare HTTP error 429', details);
186
+ if (status === 403)
187
+ return new FtshareError('plan_closed', `FTShare HTTP error 403: ${reason ?? message}(当前套餐不含该路由,不要重试)`, details);
188
+ if (status === 400)
189
+ return new FtshareError('invalid_parameter', `FTShare HTTP error 400: ${message}`, details);
190
+ if (status !== undefined && (status < 200 || status >= 300))
191
+ return new FtshareError('http_error', `FTShare HTTP error ${status}`, details);
192
+ return new FtshareError('invalid_response', 'FTShare envelope carries no code — cannot tell success from failure');
193
+ }
194
+ if (code === 200)
195
+ return null;
196
+ if (code === 403)
197
+ return new FtshareError('plan_closed', `FTShare API error 403: ${reason ?? message}(当前套餐不含该路由,不要重试)`, { ...details, code: 403 });
198
+ if (code === 400)
199
+ return new FtshareError('invalid_parameter', `FTShare API error 400: ${message}`, { ...details, code: 400 });
200
+ if (code === 429)
201
+ return new FtshareError('rate_limited', `FTShare API error 429: ${message}`, { ...details, code: 429 });
202
+ return new FtshareError('upstream_error', `FTShare API error ${code}: ${message}`, { ...details, code });
203
+ }
204
+ /** key 每次调用现取(`resolveApiKey` 由装配层注入),构造客户端不依赖 key。 */
205
+ export function createFtshareClient(resolveApiKey, baseUrl = FTSHARE_BASE_URL) {
206
+ return {
207
+ async getJson(path, search, signal) {
208
+ const apiKey = await resolveApiKey();
209
+ if (!apiKey)
210
+ throw new FtshareError('credential_missing', 'FTSHARE_API_KEY is not configured');
211
+ const query = search.toString();
212
+ const url = `${baseUrl.replace(/\/$/, '')}${path}${query ? `?${query}` : ''}`;
213
+ let response;
214
+ try {
215
+ response = await ftshareThrottle(async () => await fetch(url, { headers: { FTSHARE_API_KEY: apiKey, Accept: 'application/json' }, signal }));
216
+ }
217
+ catch (error) {
218
+ if (error instanceof Error && error.name === 'AbortError')
219
+ throw error;
220
+ if (error instanceof FtshareError)
221
+ throw error;
222
+ throw new FtshareError('http_error', `FTShare request failed: ${error instanceof Error ? error.message : String(error)}`);
223
+ }
224
+ // ⛔ 先读信封、再看状态:套餐外的 403 与参数错的 400 都**带 JSON 体**,体里那句
225
+ // `data.reason`("当前套餐无权访问该接口…")是给用户看的唯一人话;按状态先抛就等于把它丢了,
226
+ // 而 4xx 的 `response.json()` 在 fetch 里完全合法。
227
+ const parsed = await response.json().catch(() => null);
228
+ if (isRecord(parsed)) {
229
+ const failure = envelopeFailure(parsed, response.status);
230
+ if (failure)
231
+ throw failure;
232
+ if (response.status < 200 || response.status >= 300)
233
+ throw new FtshareError('http_error', `FTShare HTTP error ${response.status}`, { status: response.status });
234
+ return parsed;
235
+ }
236
+ // 没有可解析的信封:只能按 HTTP 状态归类(429/403/400 的语义与上面保持一致)。
237
+ if (response.status === 429)
238
+ throw new FtshareError('rate_limited', 'FTShare HTTP error 429', { status: 429 });
239
+ if (response.status === 403)
240
+ throw new FtshareError('plan_closed', 'FTShare HTTP error 403: 当前套餐不含该路由,不要重试', { status: 403 });
241
+ if (response.status === 400)
242
+ throw new FtshareError('invalid_parameter', 'FTShare HTTP error 400', { status: 400 });
243
+ if (response.status < 200 || response.status >= 300)
244
+ throw new FtshareError('http_error', `FTShare HTTP error ${response.status}`, { status: response.status });
245
+ throw new FtshareError('invalid_response', 'FTShare returned a non-object envelope');
246
+ },
247
+ };
248
+ }
249
+ /**
250
+ * 行数组与总数。上游四套信封(实测),只认一套就会把另一套静默读成空集:
251
+ * - 资金流与日 K 族:`data.records` + `pageNum/pageSize/pages/total`
252
+ * - daec 报价族、涨跌停价、股东人数:`data.items` + `total_items/total_pages`(**不回显页码**)
253
+ * - 指数权重:`data.index_weights` + `total/page/page_size`(没有 pages)
254
+ * - 昨收价与可转债 K 线:`data` **直接是数组**,那时分页完全由本层合成
255
+ * ⛔ `data` 还可能是 **`null`**:实测龙虎榜明细在"非交易日 / 未来日期 / 早于窗口"三种情况下都回
256
+ * `{"code":200,"message":"success","data":null}`——那是**合法空结果**而不是坏信封,
257
+ * 读成失败就等于让模型对着一个日历事实反复换参数重试。
258
+ * 只取 `total`(三个名字都要读,实测与逐日全市场计数对得上);页码不从信封取,见 `pageParams` 的 ⛔。
259
+ */
260
+ function readPaged(envelope) {
261
+ const data = envelope.data;
262
+ if (data === null)
263
+ return { rows: [], total: 0 };
264
+ if (Array.isArray(data))
265
+ return { rows: data.filter(isRecord), total: data.length };
266
+ if (!isRecord(data))
267
+ throw new FtshareError('invalid_response', 'FTShare envelope is missing data');
268
+ const raw = Array.isArray(data.records) ? data.records
269
+ : Array.isArray(data.items) ? data.items
270
+ : Array.isArray(data.index_weights) ? data.index_weights
271
+ : null;
272
+ if (raw === null)
273
+ throw new FtshareError('invalid_response', `FTShare envelope has no records/items/index_weights array (data keys: ${Object.keys(data).join(',') || 'none'})`);
274
+ const rows = raw.filter(isRecord);
275
+ return { rows, total: integerOrNull(data.total) ?? integerOrNull(data.total_items) ?? rows.length };
276
+ }
277
+ /** 分页信封:`page`/`pages` 按请求算,`total` 用上游的。 */
278
+ function pagedEnvelope(item, total, page, size) {
279
+ return { item, pagination: { page, size: item.length, pages: Math.max(1, Math.ceil(total / size)), total } };
280
+ }
281
+ /** 没有行数组的响应(市场快照):一行就是全部。 */
282
+ function snapshotEnvelope(item) {
283
+ return { item, pagination: { page: 1, size: item.length, pages: 1, total: item.length } };
284
+ }
285
+ /**
286
+ * 日期区间有没有被应用,只能在返回之后核:昨收价那一族的响应里**没有身份列**(只有 `date` 与数值),
287
+ * 本层能核的最强事实就是"回来的行是否都落在请求的区间内"。实测上游遵守区间(2022-01-01..2026-10-15 回的行
288
+ * 起于 2022-04-19、止于 2026-10-15),所以这一声是**防改版**:区间语义哪天变了,宁可失败也不落一份错日期的盘。
289
+ */
290
+ function assertInRange(rows, start, end, pick) {
291
+ if (rows.length === 0)
292
+ return;
293
+ const wrong = rows.find((row) => {
294
+ const { iso } = normalizeDateCell(pick(row));
295
+ return iso !== null && (iso < start || iso > end);
296
+ });
297
+ if (wrong !== undefined) {
298
+ const { iso } = normalizeDateCell(pick(wrong));
299
+ throw new FtshareError('invalid_response', `FTShare returned trade_date ${iso} outside the requested ${start}..${end} window — 上游没有应用日期区间,这一份不是该区间的结果`);
300
+ }
301
+ }
302
+ /**
303
+ * ⛔ 过滤有没有真的生效,只能在**返回之后**验。实测上游是遵守的(`code=600519` 五个交易日回 5 行、
304
+ * `board_name=银行` 回 1 行),但它的失败形状是静默的:不认识的**写法**不回错、只回 0 行(实测
305
+ * `symbol = "600519.XSHG"` 回 0 行),不认识的**列**才点名拒。所以核身份放在 producer:
306
+ * 过滤语义一旦漂了,拿一句 `invalid_response` 换掉"把全市场样本当成一只票的序列"。
307
+ */
308
+ function assertFiltered(rows, filter) {
309
+ if (filter === undefined || rows.length === 0)
310
+ return;
311
+ const wrong = rows.find((row) => String(row[filter.field] ?? '') !== filter.expected);
312
+ if (wrong !== undefined) {
313
+ throw new FtshareError('invalid_response', `FTShare dropped the ${filter.label} filter: got ${filter.field}=${JSON.stringify(String(wrong[filter.field] ?? ''))} instead of ${JSON.stringify(filter.expected)} — 这份返回是整个市场的截断样本,不是该条件下的结果`);
314
+ }
315
+ }
316
+ function query(params, allowed) {
317
+ const search = new URLSearchParams();
318
+ for (const name of allowed) {
319
+ const value = params[name];
320
+ if (value === undefined)
321
+ continue;
322
+ if (typeof value !== 'string' && typeof value !== 'number')
323
+ throw new FtshareError('invalid_parameter', `parameter ${name} must be a string or a number`);
324
+ search.set(name, String(value));
325
+ }
326
+ return search;
327
+ }
328
+ const dailyFlowRow = {
329
+ type: 'object',
330
+ properties: {
331
+ code: { type: 'string' }, name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
332
+ trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] }, trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
333
+ close_price: { oneOf: [{ type: 'number' }, { type: 'null' }] }, change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
334
+ turnover_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
335
+ inflow_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] }, outflow_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] },
336
+ net_amount_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] }, turnover_amount_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] },
337
+ },
338
+ additionalProperties: false,
339
+ };
340
+ const boardFlowRow = {
341
+ type: 'object',
342
+ properties: {
343
+ board_name: { type: 'string' },
344
+ trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] }, trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
345
+ board_index: { oneOf: [{ type: 'number' }, { type: 'null' }] }, change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
346
+ company_count: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
347
+ inflow_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] }, outflow_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] },
348
+ net_amount_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] },
349
+ leader_name: { oneOf: [{ type: 'string' }, { type: 'null' }] }, leader_change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
350
+ leader_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
351
+ },
352
+ additionalProperties: false,
353
+ };
354
+ const mainCapitalRow = {
355
+ type: 'object',
356
+ properties: {
357
+ thscode: { type: 'string' }, name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
358
+ trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] }, slice_time: { oneOf: [{ type: 'string' }, { type: 'null' }] },
359
+ ts_millis: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
360
+ net_inflow_super_large_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
361
+ net_inflow_large_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
362
+ net_inflow_main_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
363
+ net_inflow_medium_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
364
+ net_inflow_small_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
365
+ },
366
+ additionalProperties: false,
367
+ };
368
+ const output = (row) => ({
369
+ type: 'object',
370
+ properties: {
371
+ item: { type: 'array', items: row },
372
+ pagination: {
373
+ type: 'object',
374
+ properties: { page: { type: 'integer' }, size: { type: 'integer' }, pages: { type: 'integer' }, total: { type: 'integer' } },
375
+ required: ['page', 'size', 'pages', 'total'], additionalProperties: false,
376
+ },
377
+ },
378
+ required: ['item', 'pagination'], additionalProperties: false,
379
+ });
380
+ const DAILY_FLOW_DESCRIPTION = '按交易日区间获取**同花顺**日度资金流。⛔ 金额与成交额单位是**亿元**,这条不是猜的:同一交易日(2026-10-14)逐票与全市场日 K 截面对上——5214 只**全部匹配**,本能力 `turnover_amount_yi` 合计 14142.07 亿 vs 日 K 面 `turnover_yuan` 合计折算 14142.02 亿(比值 0.999996),单票最大相对偏差 0.487%(两位小数舍入的量级)。`net_amount_yi = inflow_yi − outflow_yi`:实测单日 5214 行里 338 行有偏差、**最大偏差恰为 0.01 亿**,全部来自舍入、不是口径分歧。`change_pct` 与 `turnover_pct` 是**百分数原值**(0.87 表示 0.87%)——⛔ 报价面的 `turnover_ratio` 是**比例值**(0.00499 = 0.499%),两者不是一套口径,禁止混用或互相换算。⛔ `code` 是上游给的**裸 6 位代码且不带市场后缀**,我们不替它猜交易所(`000001` 在指数的语境里另有其票),需要带后缀的身份请用行情类能力或 `ticker_search`。实测单日 **5214 行、不含北交所**(`920992` 返回 0 条,末页仍是 0028xx 深市主板;与日 K 那 5563 行相差的 349 只正是北证),北交所资金流本仓仍无覆盖(北证行情请用 `ftshare_ashare_quote` / `ftshare_daily_kline_snapshot`,那一族含 349 只 BJ)。⛔ `pagination.total` 是**区间内的行数,不是票数**:一个交易日 5214 行,实测三个交易日 15637 行,翻页要取满 `ceil(total ÷ size)` 页。分页实测生效(`page` = 1 / 2 / 3 的首行分别是 000001 / 000007 / 000010),`size` 最大 1000。`code` 与 `board_name` 过滤实测生效(单票五个交易日回 5 行、`board_name=银行` 回 1 行)。⛔ **历史窗口只有最近约 45 个交易日**:按真实交易日二分,T-45 仍有 5199 行、**T-50 起 total=0**;早于窗口的日期是合法空结果、不是取数失败,不要换日期循环重试。';
381
+ function createFlowSource(options) {
382
+ const schema = {
383
+ capability: options.capability,
384
+ time_contract: getDataTimeContract(options.capability),
385
+ name: options.name,
386
+ source: `http:ftshare.${options.upstreamApi}`,
387
+ data_key: buildDataKey('ftshare', 'http', options.capability),
388
+ source_label: 'ftshare',
389
+ paginated: true,
390
+ rowShape: { rowKey: 'item' },
391
+ summary: options.summary,
392
+ description: options.description,
393
+ input_schema: options.inputSchema,
394
+ output_schema: output(options.row),
395
+ };
396
+ const normalize = (params) => {
397
+ const { page, size } = pageParams(params, options.allowed, MAX_PAGE_SIZE);
398
+ const start_date = date(params.start_date, 'start_date');
399
+ const end_date = date(params.end_date, 'end_date');
400
+ if (start_date > end_date)
401
+ throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
402
+ const normalized = { start_date, end_date, page, size };
403
+ if (typeof params.code === 'string' || typeof params.code === 'number') {
404
+ // 参数层要求带后缀的完整代码(本仓通则),发给上游时按上游口径退回裸码。
405
+ normalized.code = parseSecurityCode(params.code, 'code').digits;
406
+ }
407
+ if (typeof params.board_name === 'string' && params.board_name.trim())
408
+ normalized.board_name = params.board_name.trim();
409
+ return normalized;
410
+ };
411
+ return {
412
+ schema,
413
+ normalizeParams: normalize,
414
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
415
+ execute: async (request, signal) => {
416
+ const params = normalize(request.params);
417
+ const search = query({
418
+ start_date: compactDate(String(params.start_date)),
419
+ end_date: compactDate(String(params.end_date)),
420
+ code: params.code,
421
+ board_name: params.board_name,
422
+ page: params.page,
423
+ page_size: params.size,
424
+ }, ['start_date', 'end_date', 'code', 'board_name', 'page', 'page_size']);
425
+ const { rows, total } = readPaged(await options.client.getJson(options.path, search, signal));
426
+ // 带过滤的请求先核身份,再核日期区间(两道都是防改版,见 assertFiltered / assertInRange)。
427
+ assertFiltered(rows, params.code === undefined
428
+ ? (params.board_name === undefined ? undefined : { field: 'board_name', expected: String(params.board_name), label: 'board_name' })
429
+ : { field: 'code', expected: String(params.code), label: 'code' });
430
+ // 第二道:没带过滤时身份核对不会跑,但"整段日期区间被忽略"仍然只能靠返回核。
431
+ assertInRange(rows, String(params.start_date), String(params.end_date), (row) => row.trade_date);
432
+ return { data: pagedEnvelope(rows.map(options.mapRow), total, Number(params.page), Number(params.size)), schema: output(options.row) };
433
+ },
434
+ };
435
+ }
436
+ const dailyFlowInputSchema = {
437
+ type: 'object',
438
+ properties: {
439
+ start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD' },
440
+ end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD' },
441
+ page: { type: 'integer', minimum: 1 },
442
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE },
443
+ },
444
+ required: ['start_date', 'end_date'],
445
+ additionalProperties: false,
446
+ };
447
+ const boardFlowInputSchema = {
448
+ type: 'object',
449
+ properties: { ...dailyFlowInputSchema.properties, board_name: { type: 'string', description: '板块名精确匹配(如 银行 / IT服务);实测可用,不做模糊匹配' } },
450
+ required: ['start_date', 'end_date'],
451
+ additionalProperties: false,
452
+ };
453
+ function mapStockFlowRow(row) {
454
+ const { iso: trade_date, ms } = normalizeDateCell(row.trade_date);
455
+ return {
456
+ code: String(row.code ?? ''), name: textOrNull(row.name),
457
+ trade_date, trade_date_ms: ms,
458
+ close_price: numberOrNull(row.close_price), change_pct: numberOrNull(row.change_pct), turnover_pct: numberOrNull(row.turnover_pct),
459
+ inflow_yi: numberOrNull(row.inflow), outflow_yi: numberOrNull(row.outflow),
460
+ net_amount_yi: numberOrNull(row.net_amount), turnover_amount_yi: numberOrNull(row.turnover_amount),
461
+ };
462
+ }
463
+ function mapBoardFlowRow(row) {
464
+ const { iso: trade_date, ms } = normalizeDateCell(row.trade_date);
465
+ return {
466
+ board_name: String(row.board_name ?? ''), trade_date, trade_date_ms: ms,
467
+ board_index: numberOrNull(row.board_index), change_pct: numberOrNull(row.change_pct), company_count: integerOrNull(row.company_count),
468
+ inflow_yi: numberOrNull(row.inflow), outflow_yi: numberOrNull(row.outflow), net_amount_yi: numberOrNull(row.net_amount),
469
+ leader_name: textOrNull(row.leader_name), leader_change_pct: numberOrNull(row.leader_change_pct), leader_price: numberOrNull(row.leader_price),
470
+ };
471
+ }
472
+ /** 追加在同一份日度说明后面的板块口径段(各自独立,避免复制整篇正文)。 */
473
+ const INDUSTRY_FLOW_TAIL = ' 本能力是**行业板块**粒度:实测单日 90 个板块(同花顺一级行业口径),company_count 是该板块公司家数,leader_name / leader_change_pct / leader_price 是上游给的领涨股三列(涨跌幅同为百分数原值)。⛔ 板块的 inflow_yi / outflow_yi **不能跨板块相加**去凑"全市场":实测 90 行 inflow 合计 4088.1 亿,与个股口径不可比,相加只会得出没有人认领的数。';
474
+ const CONCEPT_FLOW_TAIL = ' 本能力是**概念板块**粒度:实测单日 387 个概念。⛔ 概念**天然重叠**(实测「2026三季报预增」43 家与「2026中报预增」514 家含同一批票),所以概念之间、概念与行业之间的 inflow_yi / outflow_yi 禁止相加或做占比;"某板块净买入多少"只能在**同一板块内**读。';
475
+ /**
476
+ * key 每次调用现取(与 `resolveFuyaoApiKey` 同一口径):credentials 优先、环境变量兜底。
477
+ * ⛔ 注册面**不得**以"装配期读到 key"为条件(AGENTS.md §9.7),也不要把这里的 rejection 缓存住——
478
+ * 一次装配期抢跑会把这个能力钉到进程结束。
479
+ */
480
+ export async function resolveFtshareApiKey(ctx, credentialRef = 'FTSHARE_API_KEY') {
481
+ const credentials = ctx.get('credentials');
482
+ if (credentials?.resolve) {
483
+ const resolved = await credentials.resolve(credentialRef);
484
+ if (resolved?.value)
485
+ return resolved.value;
486
+ }
487
+ const processLike = globalThis.process;
488
+ return processLike?.env?.[credentialRef];
489
+ }
490
+ /**
491
+ * 报价与快照族(daec)的两条硬约束,都是实测:
492
+ * 1. **筛选表达式不开放给模型**。实测支持的列只有 `symkey / symbol_id / market_id / name` 与价量列,
493
+ * 其余列(`st` / `status` / `listing_date` / `market_cap` / `pe_ttm` / `shares`)会被点名拒
494
+ * 「不支持的过滤器字段:st;支持的…」;身份还有两套写法(`symkey` 带后缀、`symbol_id` 裸 6 位),
495
+ * 写错不报错只回 0 行。本层只用**已验过回 1 行**的 `symbol_id = "<裸 6 位>"`,模型只看 `code` 参数。
496
+ * 2. `board` 是 **URL 路径段**,只允许 `all / xshg / xshe / bjse` 四个常量:实测越界一律 HTTP 400
497
+ * 「unsupported stock board ys; expected xshg/xshe/bjse/all」。任何写法都不把模型给的字符串拼进路径。
498
+ */
499
+ const QUOTE_BOARDS = ['all', 'xshg', 'xshe', 'bjse'];
500
+ const SNAPSHOT_SCOPES = ['ChinaStock', 'Xshg', 'Xshe', 'Bjse'];
501
+ /** 排序白名单:五档打过真报文(17:05 之后逐次一致);`market_cap` / `pe_ttm` / `shares` 实测被上游拒「unsupported order_by field」。 */
502
+ const QUOTE_SORT = { change_ratio: 'change_rate', turnover_yuan: 'turnover', volume_shares: 'volume', close: 'close', prev_close: 'prev_close' };
503
+ const quoteRow = {
504
+ type: 'object',
505
+ properties: {
506
+ thscode: { type: 'string' }, ticker: { type: 'string' }, name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
507
+ board: { type: 'string' }, status: { type: 'string' }, st: { type: 'boolean' }, listing_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
508
+ close: { oneOf: [{ type: 'number' }, { type: 'null' }] }, open: { oneOf: [{ type: 'number' }, { type: 'null' }] },
509
+ high: { oneOf: [{ type: 'number' }, { type: 'null' }] }, low: { oneOf: [{ type: 'number' }, { type: 'null' }] },
510
+ prev_close: { oneOf: [{ type: 'number' }, { type: 'null' }] }, change_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
511
+ avg_price: { oneOf: [{ type: 'number' }, { type: 'null' }] }, pe_ttm: { oneOf: [{ type: 'number' }, { type: 'null' }] },
512
+ change_ratio: { oneOf: [{ type: 'number' }, { type: 'null' }] }, change_ratio_day5: { oneOf: [{ type: 'number' }, { type: 'null' }] },
513
+ change_ratio_day10: { oneOf: [{ type: 'number' }, { type: 'null' }] }, change_ratio_day20: { oneOf: [{ type: 'number' }, { type: 'null' }] },
514
+ change_ratio_day60: { oneOf: [{ type: 'number' }, { type: 'null' }] }, change_ratio_day120: { oneOf: [{ type: 'number' }, { type: 'null' }] },
515
+ change_ratio_ytd: { oneOf: [{ type: 'number' }, { type: 'null' }] },
516
+ amplitude_ratio: { oneOf: [{ type: 'number' }, { type: 'null' }] }, turnover_ratio: { oneOf: [{ type: 'number' }, { type: 'null' }] },
517
+ volume_shares: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, turnover_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
518
+ shares: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, float_a_shares: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
519
+ market_cap_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] }, tradable_a_market_cap_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
520
+ bid_ask_ratio_raw: { oneOf: [{ type: 'number' }, { type: 'null' }] }, quote_time_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
521
+ },
522
+ additionalProperties: false,
523
+ };
524
+ function mapQuoteRow(row) {
525
+ const symbol = String(row.symbol ?? '');
526
+ return {
527
+ thscode: symbol, ticker: textOrNull(row.symbol_id) ?? symbol.slice(0, 6), name: textOrNull(row.name),
528
+ board: String(row.board ?? ''), status: String(row.status ?? ''), st: row.st === true, listing_date: textOrNull(row.listing_date),
529
+ close: numberOrNull(row.close), open: numberOrNull(row.open), high: numberOrNull(row.high), low: numberOrNull(row.low),
530
+ prev_close: numberOrNull(row.prev_close), change_yuan: numberOrNull(row.change), avg_price: numberOrNull(row.avg), pe_ttm: numberOrNull(row.pe_ttm),
531
+ change_ratio: numberOrNull(row.change_rate), change_ratio_day5: numberOrNull(row.change_rate_day5), change_ratio_day10: numberOrNull(row.change_rate_day10),
532
+ change_ratio_day20: numberOrNull(row.change_rate_day20), change_ratio_day60: numberOrNull(row.change_rate_day60),
533
+ change_ratio_day120: numberOrNull(row.change_rate_day120), change_ratio_ytd: numberOrNull(row.change_rate_ytd),
534
+ amplitude_ratio: numberOrNull(row.amplitude), turnover_ratio: numberOrNull(row.turnover_rate),
535
+ volume_shares: integerOrNull(row.volume), turnover_yuan: numberOrNull(row.turnover),
536
+ shares: integerOrNull(row.shares), float_a_shares: integerOrNull(row.float_a_shares),
537
+ market_cap_yuan: numberOrNull(row.market_cap), tradable_a_market_cap_yuan: numberOrNull(row.tradable_a_market_cap),
538
+ bid_ask_ratio_raw: numberOrNull(row.bid_ask_ratio), quote_time_ms: integerOrNull(row.ts_millis),
539
+ };
540
+ }
541
+ function boardOf(value) {
542
+ const board = typeof value === 'string' ? value.trim().toLowerCase() : '';
543
+ if (!QUOTE_BOARDS.includes(board)) {
544
+ throw new FtshareError('invalid_parameter', `parameter board must be one of ${QUOTE_BOARDS.join(' / ')}`);
545
+ }
546
+ return board;
547
+ }
548
+ function createQuoteSource(client) {
549
+ const capability = 'ftshare_ashare_quote';
550
+ const allowed = ['board', 'code', 'sort_field', 'sort_order', 'page', 'size'];
551
+ const schema = {
552
+ capability,
553
+ time_contract: getDataTimeContract(capability),
554
+ name: 'get_daec_stocks',
555
+ source: 'http:ftshare.stock_daec_stocks',
556
+ data_key: buildDataKey('ftshare', 'http', capability),
557
+ source_label: 'ftshare',
558
+ paginated: true,
559
+ cacheMaxAgeMs: 60_000,
560
+ rowShape: { rowKey: 'item' },
561
+ summary: 'A 股实时行情截面(沪深北全市场,含 ST 与停牌)',
562
+ description: '按板块分页获取 A 股**实时行情 + 基本面**截面(查询时点快照,无历史维度;行情时间看 `quote_time_ms`,采集时间看 Dataset 的 `captured_at`)。覆盖面实测:`all` 5574 只 = `xshg` 2320 + `xshe` 2905 + `bjse` 349(六页取回后按 `symbol` 去重恰为 5574,末页 574 行);同一时刻 `ftshare_market_breadth` 的九档家数之和也恰是 5574。⛔ **这一族含北交所**(实测 349 只,代码形如 `920000.BJ`,`board=bjse` 单取)——本仓腾讯那一路明确拒 BJ,所以这是北交所报价真正的第二条路。`status` 实测只有 `Normal`(5563) 与 `Suspended`(11) 两值,`st` 是布尔(实测 202 只为 true),停牌票的 `open/high/low` 为 null(**别补 0**)。`thscode` 沿用上游给的短后缀(`600519.SH`),与本仓内部写法逐字相同。⛔ **涨跌幅是比例值不是百分数**:`change_ratio` 0.03 表示 3%(实测排序首行 300111.SZ = 0.20064724919093851,恰是创业板 20% 涨停),`amplitude_ratio` / `turnover_ratio` / 各 `change_ratio_day*` 同理;这与同花顺那族日度资金流的百分数原值(`change_pct` 0.87 = 0.87%)**不是一套口径,禁止混用或互相换算**。上游用**字符串**给出价与额(单位元)与部分列的整数股数(单位股),本层已转成数值。七条派生关系按 200 行真报文核对过(偏差 1e-15 量级,即它们不是独立披露的量):`avg_price = turnover_yuan ÷ volume_shares`、`change_yuan = close − prev_close`、`change_ratio = change_yuan ÷ prev_close`、`market_cap_yuan = shares × close`、`tradable_a_market_cap_yuan = float_a_shares × close`、`turnover_ratio = volume_shares ÷ float_a_shares`、`amplitude_ratio = (high − low) ÷ prev_close`。⛔ `bid_ask_ratio_raw` 口径**未核验**(实测取值有正有负、含 0,不像比值),只能做同列相对比较,禁止换算成百分数写进结论。`pe_ttm` 实测 200 行里 75 行为 null(亏损股无 PE)。`name` 里有上游原文的全角空格与字母(实测「万 科A」),原文保留。分页实测生效,要全市场就翻满 `ceil(total ÷ size)` 页(`size` ≤ 1000)。`code` 由本层拼成上游的 `symbol_id = "<裸 6 位>"` 精确过滤,实测稳定回 1 行。⛔ 这一族的过滤语法**只对认识的列有效**:不认识的列会**点名拒绝**(实测 `st` 回「不支持的过滤器字段:st;支持的…」,支持的只有 `symkey / symbol_id / market_id / name` 与价量列),而**写法不对的身份条件不报错、只回 0 行**(实测 `symbol = "600519.XSHG"` 回 0 行——`symbol` 要带后缀、`symbol_id` 才是裸码),所以本层只发已验的写法。同样**不支持**按 `board` / `status` / `st` / `market_cap` / `pe_ttm` / `shares` / `listing_date` 筛选,这些只能取回后自己筛;`sort_field` 只放验过的五档(涨跌幅、成交额、量、价、昨收),实测 `market_cap` 被拒「unsupported order_by field market_cap」。本层对带 `code` 的请求逐行核对返回身份——**防改版**:过滤语义哪天变了,宁可报 `invalid_response`,也不交一份「看着像但是一只都不对」的数据。',
563
+ input_schema: {
564
+ type: 'object',
565
+ properties: {
566
+ board: { type: 'string', enum: [...QUOTE_BOARDS], description: '板块路径:all 全市场(默认)/ xshg 上证 / xshe 深证 / bjse 北证' },
567
+ code: { type: 'string', description: '单只精确过滤,6 位代码或带后缀均可(600519 / 600519.SH)' },
568
+ sort_field: { type: 'string', enum: Object.keys(QUOTE_SORT), description: '排序列,默认不排序(上游按代码序返回)' },
569
+ sort_order: { type: 'string', enum: ['asc', 'desc'], description: '排序方向,配合 sort_field 使用,默认 desc' },
570
+ page: { type: 'integer', minimum: 1 },
571
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE },
572
+ },
573
+ required: [], additionalProperties: false,
574
+ },
575
+ output_schema: output(quoteRow),
576
+ };
577
+ const normalize = (params) => {
578
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE);
579
+ const normalized = { board: params.board === undefined ? 'all' : boardOf(params.board), page, size };
580
+ if (params.code !== undefined)
581
+ normalized.code = parseSecurityCode(params.code, 'code').digits;
582
+ if (params.sort_field !== undefined) {
583
+ const field = String(params.sort_field);
584
+ if (!Object.keys(QUOTE_SORT).includes(field))
585
+ throw new FtshareError('invalid_parameter', `parameter sort_field must be one of ${Object.keys(QUOTE_SORT).join(' / ')}`);
586
+ const order = params.sort_order === undefined ? 'desc' : String(params.sort_order);
587
+ if (order !== 'asc' && order !== 'desc')
588
+ throw new FtshareError('invalid_parameter', 'parameter sort_order must be asc or desc');
589
+ normalized.order_by = `${QUOTE_SORT[field]} ${order}`;
590
+ }
591
+ else if (params.sort_order !== undefined) {
592
+ throw new FtshareError('invalid_parameter', 'parameter sort_order requires sort_field');
593
+ }
594
+ return normalized;
595
+ };
596
+ return {
597
+ schema,
598
+ normalizeParams: normalize,
599
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
600
+ execute: async (request, signal) => {
601
+ const params = normalize(request.params);
602
+ // ⛔ filter 串只由本层用校验过的 6 位数字拼出来,绝不把模型给的表达式透传出去。
603
+ const filter = params.code === undefined ? undefined : `symbol_id = "${params.code}"`;
604
+ const search = query({ filter, order_by: params.order_by, page: params.page, page_size: params.size }, ['filter', 'order_by', 'page', 'page_size']);
605
+ const { rows, total } = readPaged(await client.getJson(`/api/v1/market/data/daec/stocks/${params.board}`, search, signal));
606
+ assertFiltered(rows, params.code === undefined ? undefined : { field: 'symbol_id', expected: String(params.code), label: 'code' });
607
+ return { data: pagedEnvelope(rows.map(mapQuoteRow), total, Number(params.page), Number(params.size)), schema: output(quoteRow) };
608
+ },
609
+ };
610
+ }
611
+ const breadthRow = {
612
+ type: 'object',
613
+ properties: {
614
+ scope: { type: 'string' }, market_status: { type: 'string' },
615
+ volume_shares: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, turnover_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
616
+ up_limited: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, up_5pct_to_limited: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
617
+ up_1pct_to_5pct: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, up_0pct_to_1pct: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
618
+ flat: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, down_0pct_to_1pct: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
619
+ down_1pct_to_5pct: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, down_5pct_to_limited: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
620
+ down_limited: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
621
+ },
622
+ required: ['scope', 'market_status'], additionalProperties: false,
623
+ };
624
+ /**
625
+ * 全市场快照(`/daec/market/snapshot`)。这一颗自带两道对账,2026-10-15 连打 8 次逐分一致:九档家数之和 =
626
+ * 报价面的标的总数(5574),且三个交易所 scope 在家数、成交额、成交量三种量上都能加出全市场那一档。
627
+ * 取回先核这两道加法——对不上就是上游改版(`turnover` 与家数分属不同列族,谁都可能先漂)。
628
+ */
629
+ function createBreadthSource(client) {
630
+ const capability = 'ftshare_market_breadth';
631
+ const schema = {
632
+ capability,
633
+ time_contract: getDataTimeContract(capability),
634
+ name: 'get_daec_market_snapshot',
635
+ source: 'http:ftshare.stock_market',
636
+ data_key: buildDataKey('ftshare', 'http', capability),
637
+ source_label: 'ftshare',
638
+ paginated: false,
639
+ cacheMaxAgeMs: 60_000,
640
+ rowShape: { rowKey: 'item' },
641
+ summary: '全市场成交额与涨跌分布(九档家数)',
642
+ description: '获取全市场或单个交易所的**查询时点**快照,一行:市场状态、成交量与成交额,外加按涨跌幅分档统计的涨跌家数(实测固定九档:`up_limited` / `up_5pct_to_limited` / `up_1pct_to_5pct` / `up_0pct_to_1pct` / `flat` / `down_0pct_to_1pct` / `down_1pct_to_5pct` / `down_5pct_to_limited` / `down_limited`)。scope 四档:`ChinaStock` 全市场(默认)/ `Xshg` 沪 / `Xshe` 深 / `Bjse` 北。这一条自带**两道可对账的加法**,2026-10-15 实测逐分成立(同一请求连打 8 次全部一致):① 九档家数之和 = 报价面 `ftshare_ashare_quote` 的标的总数(ChinaStock 5574 = `total_items` 5574);② 三个交易所相加 = 全市场——家数 2320 + 2905 + 349 = 5574,成交额 774,120,203,047.43 + 855,563,768,593.29 + 12,571,061,567 = 1,642,255,033,207.72 元,成交量 50,978,246,468 + 55,014,763,067 + 674,816,640 = 106,667,826,175 股。⛔ 用这一条算涨跌比、成交额或环比之前**先核这两道加法**:对不上说明上游改版,或者两份数据不是同一时刻取的,不要直接引数字。`scope` 列是**本层写入的请求标签**(上游响应里没有这一列),不是上游披露的市场名。⛔ **上游两个恒零占位列本能力不收录**:`prev_turnover` 实测三个 scope 都返回字符串 "0"(全市场昨日成交额不可能是 0),顶层 `change_rate` 恒为 `0`——那是占位不是数据,收进目录等于替上游说一句它自己没说的话。`market_status` 只实测到 `closed`(北京收盘后),盘中取值未验证,所以本能力**不承诺实时**:它是「此刻这一份快照」,新鲜度看 Dataset 的 `captured_at`。',
643
+ input_schema: {
644
+ type: 'object',
645
+ properties: { scope: { type: 'string', enum: [...SNAPSHOT_SCOPES], description: '市场范围,默认 ChinaStock 全市场' } },
646
+ required: [], additionalProperties: false,
647
+ },
648
+ output_schema: output(breadthRow),
649
+ };
650
+ const normalize = (params) => {
651
+ for (const key of Object.keys(params))
652
+ if (key !== 'scope')
653
+ throw new FtshareError('invalid_parameter', `unsupported parameter: ${key}`);
654
+ if (params.scope === undefined)
655
+ return {};
656
+ const scope = String(params.scope);
657
+ if (!SNAPSHOT_SCOPES.includes(scope))
658
+ throw new FtshareError('invalid_parameter', `parameter scope must be one of ${SNAPSHOT_SCOPES.join(' / ')}`);
659
+ return { scope };
660
+ };
661
+ return {
662
+ schema,
663
+ normalizeParams: normalize,
664
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
665
+ execute: async (request, signal) => {
666
+ const params = normalize(request.params);
667
+ const envelope = await client.getJson('/api/v1/market/data/daec/market/snapshot', query({ scope: params.scope }, ['scope']), signal);
668
+ const data = isRecord(envelope.data) ? envelope.data : null;
669
+ if (data === null)
670
+ throw new FtshareError('invalid_response', 'FTShare market snapshot returned no data object');
671
+ const distribution = isRecord(data.distribution) ? data.distribution : null;
672
+ if (distribution === null)
673
+ throw new FtshareError('invalid_response', `FTShare market snapshot has no distribution object (data keys: ${Object.keys(data).join(',') || 'none'})`);
674
+ const item = [{
675
+ scope: String(params.scope ?? 'ChinaStock'), market_status: String(data.status ?? 'unknown'),
676
+ volume_shares: integerOrNull(data.volume), turnover_yuan: numberOrNull(data.turnover),
677
+ up_limited: integerOrNull(distribution.up_limited), up_5pct_to_limited: integerOrNull(distribution.up_5pct_to_limited),
678
+ up_1pct_to_5pct: integerOrNull(distribution.up_1pct_to_5pct), up_0pct_to_1pct: integerOrNull(distribution.up_0pct_to_1pct),
679
+ flat: integerOrNull(distribution.flat), down_0pct_to_1pct: integerOrNull(distribution.down_0pct_to_1pct),
680
+ down_1pct_to_5pct: integerOrNull(distribution.down_1pct_to_5pct), down_5pct_to_limited: integerOrNull(distribution.down_5pct_to_limited),
681
+ down_limited: integerOrNull(distribution.down_limited),
682
+ }];
683
+ return { data: snapshotEnvelope(item), schema: output(breadthRow) };
684
+ },
685
+ };
686
+ }
687
+ const klineSnapshotRow = {
688
+ type: 'object',
689
+ properties: {
690
+ thscode: { type: 'string' }, ticker: { type: 'string' }, trade_date: { type: 'string' }, trade_date_ms: { type: 'integer' },
691
+ open: { oneOf: [{ type: 'number' }, { type: 'null' }] }, high: { oneOf: [{ type: 'number' }, { type: 'null' }] },
692
+ low: { oneOf: [{ type: 'number' }, { type: 'null' }] }, close: { oneOf: [{ type: 'number' }, { type: 'null' }] },
693
+ volume_shares: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, turnover_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
694
+ turnover_ratio: { oneOf: [{ type: 'number' }, { type: 'null' }] }, session_open_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
695
+ close_time_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
696
+ },
697
+ additionalProperties: false,
698
+ };
699
+ /**
700
+ * 全市场某交易日日 K 截面(`/stock-candlesticks-daily`)。它与 `ftshare_ashare_quote` 是**两个时刻**:
701
+ * 这一条是"某一天收工后的每只票",可翻页取完(实测十二页 × 500 = 5563 行,末页只剩 63 行);
702
+ * 报价那条是"此刻"。两者行数差恰好等于当日停牌家数(实测 5574 − 11 = 5563),
703
+ * 这是本仓少见的能自我对账的地方,写进描述里让模型也拿它当校验点。
704
+ */
705
+ function createKlineSnapshotSource(client) {
706
+ const capability = 'ftshare_daily_kline_snapshot';
707
+ const schema = {
708
+ capability,
709
+ time_contract: getDataTimeContract(capability),
710
+ name: 'get_stock_candlesticks_daily',
711
+ source: 'http:ftshare.stock_candlesticks_daily',
712
+ data_key: buildDataKey('ftshare', 'http', capability),
713
+ source_label: 'ftshare',
714
+ paginated: true,
715
+ rowShape: { rowKey: 'item' },
716
+ summary: '全市场某交易日日 K 截面(含北交所)',
717
+ description: '按**单个交易日**获取全市场普通 A 股的日 K 截面,一行一票。⛔ 必须是**普通股票**:实测不含 ETF、可转债与指数——要指数日线请用 `index_history` / `tencent_kline`,要 ETF 请用 `fund_history`,要可转债历史 K 线请用 `ftshare_convertible_bond_kline`。`trade_date` 必填,格式 YYYY-MM-DD;**非交易日会在参数层响亮失败**(实测上游 400「trade_date 不是交易日」,如 20261011),未来交易日返回 `total=0`(合法空,不是失败)。当日截面**收盘后就能取**:实测 2026-10-15 15:51(北京)打 `trade_date=20261015` 已回 5565 行。历史深度很好(实测 2020-01-06 有 3762 行、2016-06-27 有 2864 行、2015-01-05 有 2589 行),这一条**不受**资金流那族「只留约 45 个交易日」的限制。⛔ **截面不含当日停牌票**:实测 2026-10-14 全市场 5563 行 = 报价面 `ftshare_ashare_quote` 的 5574 减去当日 `status = Suspended` 的 11 只——所以「少了几只」是停牌事实,不要当成丢行。含北交所(这 5563 行里有 `920xxx.BJ`)。列口径:`open/high/low/close` 元(上游用字符串给出,如 "11.5000",本层转成数值)、`volume_shares` 股、`turnover_yuan` 元(同样是字符串)、`turnover_ratio` 是**比例值不是百分数**(实测 000001.SZ 为 0.004993804240610122,即 0.499%)——⛔ 与同花顺日度资金流那族的 `turnover_pct`(百分数原值)不是一套口径;`close_time_ms` 与 `session_open_ms` 是上游给的时刻轴(实测 = 该交易日北京 15:00 与 09:30),`trade_date` / `trade_date_ms` 是本层按 `ts_millis` 补的日轴(上海时区零点),做时间序列请用后者;本层会核对补出来的日轴与请求的 `trade_date` 是否同一天,不一致报 `invalid_response`(防止拿到别的日期的数据却按请求日期落库)。`thscode` 沿用上游短后缀。分页:`page` 实测生效,`size` 由上游限死 **1~500**(实测 501 → HTTP 400「page_size must be between 1 and 500」),全市场 5563 行需 12 页(实测末页只剩 63 行)。',
718
+ input_schema: {
719
+ type: 'object',
720
+ properties: {
721
+ trade_date: { type: 'string', description: '交易日 YYYY-MM-DD;必须真的是交易日' },
722
+ page: { type: 'integer', minimum: 1 },
723
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_500 },
724
+ },
725
+ required: ['trade_date'], additionalProperties: false,
726
+ },
727
+ output_schema: output(klineSnapshotRow),
728
+ };
729
+ const normalize = (params) => {
730
+ const { page, size } = pageParams(params, ['trade_date', 'page', 'size'], MAX_PAGE_SIZE_500);
731
+ return { trade_date: date(params.trade_date, 'trade_date'), page, size };
732
+ };
733
+ return {
734
+ schema,
735
+ normalizeParams: normalize,
736
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
737
+ execute: async (request, signal) => {
738
+ const params = normalize(request.params);
739
+ const search = query({ trade_date: compactDate(String(params.trade_date)), page: params.page, page_size: params.size }, ['trade_date', 'page', 'page_size']);
740
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/stock-candlesticks-daily', search, signal));
741
+ const item = rows.map((row) => {
742
+ const symbol = String(row.trade_code ?? '');
743
+ const closeMs = numberOrNull(row.ts_millis);
744
+ const iso = closeMs === null ? null : new Date(closeMs + 8 * 3600_000).toISOString().slice(0, 10);
745
+ return {
746
+ thscode: symbol, ticker: symbol.slice(0, 6),
747
+ trade_date: iso ?? '', trade_date_ms: iso === null ? null : dateMs(iso),
748
+ open: numberOrNull(row.open), high: numberOrNull(row.high), low: numberOrNull(row.low), close: numberOrNull(row.close),
749
+ volume_shares: integerOrNull(row.volume), turnover_yuan: numberOrNull(row.turnover), turnover_ratio: numberOrNull(row.turnover_rate),
750
+ session_open_ms: numberOrNull(row.ts_millis_open), close_time_ms: closeMs,
751
+ };
752
+ }).filter((row) => row.trade_date_ms !== null);
753
+ // ⛔ 请求的日期与返回的日期必须同一天:这一条没有身份列以外的核对点,日轴对不上就只能失败。
754
+ assertInRange(item, String(params.trade_date), String(params.trade_date), (row) => row.trade_date);
755
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(klineSnapshotRow) };
756
+ },
757
+ };
758
+ }
759
+ const prevCloseRow = {
760
+ type: 'object',
761
+ properties: {
762
+ thscode: { type: 'string' }, trade_date: { type: 'string' }, trade_date_ms: { type: 'integer' },
763
+ prev_close: { oneOf: [{ type: 'number' }, { type: 'null' }] },
764
+ },
765
+ additionalProperties: false,
766
+ };
767
+ function createPrevCloseSource(client) {
768
+ const capability = 'ftshare_prev_close';
769
+ const schema = {
770
+ capability,
771
+ time_contract: getDataTimeContract(capability),
772
+ name: 'get_stock_prev_close',
773
+ source: 'http:ftshare.stock_prev_close',
774
+ data_key: buildDataKey('ftshare', 'http', capability),
775
+ source_label: 'ftshare',
776
+ paginated: false,
777
+ rowShape: { rowKey: 'item' },
778
+ summary: '单只标的逐日昨收价序列(校验复权与涨跌停)',
779
+ description: '按单只标的与日期区间获取**每个交易日的昨收价(前收盘价)**,一行一个交易日。这是校验用的原语:算复权因子、核对涨跌停价、判断某日"昨日收盘到底是多少"都靠它,比从日线序列里自己移一位更不容易错位。`code` 必填(`600519` / `600519.SH` / `SH600519` / `600519.XSHG` 四种写法实测等价,本层统一成 `600519.SH` 发给上游);实测北交所可用(`920000.BJ` 区间有数)、停牌票也照样给昨收。日期区间 `start_date` / `end_date` 必填,格式 YYYY-MM-DD,本层转成上游的 YYYYMMDD。`prev_close` 单位元,上游用字符串给出、本层转成数值;时间轴用 `trade_date_ms`(上海时区零点)。⛔ **响应里没有身份列**(每行只有 `date` 与 `prev_close`),`thscode` 是本层按请求补写的,所以本能力一次只问一只票。长区间一次取回即可(实测 2024-01-01..2026-10-15 回 672 行、2020 起回 1088 行,都在同一个响应里)。⛔ **历史窗口起点实测在 2022-04**:2016 / 2018 / 2020 / 2021 的整年区间都返回空数组(不是失败、也没有报错),2022 年只有 04-19 起有数——早年请用 `history`(Fuyao 单票日 K,窗口 ≤10 年)或 `tencent_kline`。区间端点落在非交易日时只回该区间内的交易日行;整段回 0 行先看是不是早于窗口,再考虑代码是否根本不在 A 股普通股票范围(ETF / 可转债 / 指数不在本能力覆盖内)。',
780
+ input_schema: {
781
+ type: 'object',
782
+ properties: {
783
+ code: { type: 'string', description: '标的代码,必须带市场后缀(如 600519.SH、920000.BJ)' },
784
+ start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD' },
785
+ end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD' },
786
+ },
787
+ required: ['code', 'start_date', 'end_date'], additionalProperties: false,
788
+ },
789
+ output_schema: output(prevCloseRow),
790
+ };
791
+ const normalize = (params) => {
792
+ for (const key of Object.keys(params))
793
+ if (key !== 'code' && key !== 'start_date' && key !== 'end_date')
794
+ throw new FtshareError('invalid_parameter', `unsupported parameter: ${key}`);
795
+ const start_date = date(params.start_date, 'start_date');
796
+ const end_date = date(params.end_date, 'end_date');
797
+ if (start_date > end_date)
798
+ throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
799
+ // 归一后仍叫 `code`(值已是 canonical `600519.SH`):换个键名会让第二遍 normalize 报 unsupported parameter。
800
+ return { code: parseSecurityCode(params.code, 'code').canonical, start_date, end_date };
801
+ };
802
+ return {
803
+ schema,
804
+ normalizeParams: normalize,
805
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
806
+ execute: async (request, signal) => {
807
+ const params = normalize(request.params);
808
+ // 上游要的是 canonical 短后缀身份(`600519.SH`,实测与裸 `600519` 等价),原样发已校验过的值。
809
+ const search = query({ symbol: params.code, since: compactDate(String(params.start_date)), until: compactDate(String(params.end_date)) }, ['symbol', 'since', 'until']);
810
+ const { rows } = readPaged(await client.getJson('/api/v1/market/data/daec/history/prev-closes', search, signal));
811
+ assertInRange(rows, String(params.start_date), String(params.end_date), (row) => row.date);
812
+ const item = rows.map((row) => {
813
+ const { iso, ms } = normalizeDateCell(row.date);
814
+ return { thscode: String(params.code), trade_date: iso ?? '', trade_date_ms: ms, prev_close: numberOrNull(row.prev_close) };
815
+ }).filter((row) => row.trade_date_ms !== null);
816
+ return { data: snapshotEnvelope(item), schema: output(prevCloseRow) };
817
+ },
818
+ };
819
+ }
820
+ const calendarRow = {
821
+ type: 'object',
822
+ properties: { market: { type: 'string' }, trade_date: { type: 'string' }, trade_date_ms: { type: 'integer' } },
823
+ additionalProperties: false,
824
+ };
825
+ /**
826
+ * 交易日历第二源(`/time/trading-calendar`)。接它的两条理由都是实测:
827
+ * 1. Fuyao 那条 `trading_calendar` **无参数、只给近一年、只有 A 股**;这一条能按区间取,并且**覆盖港股**
828
+ * (实测同一区间 cn 与 hk 的日期集合不同)。
829
+ * 2. 巡检不许编日期,而本族其他能力的日期都得是真交易日——本层把上游的 `20261015`(**数字**)
830
+ * 归一成 `2026-10-15`,下游能力与巡检都用这一套写法。
831
+ * ⛔ 返回后必须核日期落不落在区间内:这是这一族唯一可核的事实,上游改版时它比落一份错 Dataset 好。
832
+ */
833
+ function createCalendarSource(client) {
834
+ const capability = 'ftshare_trading_calendar';
835
+ const schema = {
836
+ capability,
837
+ time_contract: getDataTimeContract(capability),
838
+ name: 'get_trading_calendar',
839
+ source: 'http:ftshare.trading_calendar',
840
+ data_key: buildDataKey('ftshare', 'http', capability),
841
+ source_label: 'ftshare',
842
+ paginated: false,
843
+ rowShape: { rowKey: 'item' },
844
+ summary: '交易日历(沪北深与港股,按日期区间)',
845
+ description: '按闭区间获取**中国内地(market=cn)或香港(market=hk)**市场的交易日列表,一行一个交易日。这是 Fuyao `trading_calendar` 的第二家:那条无参数、只给近一年且只有 A 股,本条**能按区间取、并且覆盖港股**(实测 2026-10 区间:cn 与 hk 的日期集合确实不同,hk 多出的日子对应内地假期)。上游把日期给成 `20261015` 这种**数字**,本层归一成 `trade_date` = `2026-10-15` 并补 `trade_date_ms`(上海时区零点毫秒;hk 那一路同样按 +08:00 计算,与港股时区一致)。⛔ 区间端点**不是交易日也没关系**:返回的是区间内的交易日子集,两端落在周末或假期是正常用法,不是错误。实测一次调用给 2025-01-01 至 2026-10-15 共 430 个内地交易日;未来区间同样返回(交易所已公布的排期),所以"取到未来的日期"不是 bug。要判断"今天是否交易日"就按当天日期查这一段区间。',
846
+ input_schema: {
847
+ type: 'object',
848
+ properties: {
849
+ market: { type: 'string', enum: ['cn', 'hk'], description: '市场:cn 中国内地(默认)/ hk 香港' },
850
+ start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD' },
851
+ end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD' },
852
+ },
853
+ required: ['start_date', 'end_date'], additionalProperties: false,
854
+ },
855
+ output_schema: output(calendarRow),
856
+ };
857
+ const normalize = (params) => {
858
+ for (const key of Object.keys(params))
859
+ if (key !== 'market' && key !== 'start_date' && key !== 'end_date')
860
+ throw new FtshareError('invalid_parameter', `unsupported parameter: ${key}`);
861
+ const start_date = date(params.start_date, 'start_date');
862
+ const end_date = date(params.end_date, 'end_date');
863
+ if (start_date > end_date)
864
+ throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
865
+ const market = params.market === undefined ? 'cn' : String(params.market);
866
+ if (market !== 'cn' && market !== 'hk')
867
+ throw new FtshareError('invalid_parameter', 'parameter market must be cn or hk');
868
+ return { market, start_date, end_date };
869
+ };
870
+ return {
871
+ schema,
872
+ normalizeParams: normalize,
873
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
874
+ execute: async (request, signal) => {
875
+ const params = normalize(request.params);
876
+ const search = query({ market: params.market, start_date: compactDate(String(params.start_date)), end_date: compactDate(String(params.end_date)) }, ['market', 'start_date', 'end_date']);
877
+ const envelope = await client.getJson('/api/v1/market/data/time/trading-calendar', search, signal);
878
+ const data = isRecord(envelope.data) ? envelope.data : null;
879
+ if (data === null || !Array.isArray(data.trade_dates))
880
+ throw new FtshareError('invalid_response', `FTShare trading calendar returned no trade_dates array (data keys: ${data ? Object.keys(data).join(',') : 'none'})`);
881
+ const item = data.trade_dates.map((value) => {
882
+ const { iso, ms } = normalizeDateCell(value);
883
+ return { market: String(params.market), trade_date: iso ?? '', trade_date_ms: ms };
884
+ }).filter((row) => row.trade_date_ms !== null);
885
+ assertInRange(item, String(params.start_date), String(params.end_date), (row) => row.trade_date);
886
+ return { data: snapshotEnvelope(item), schema: output(calendarRow) };
887
+ },
888
+ };
889
+ }
890
+ /* ── 批次 C:大宗交易、业绩预告 / 快报、申万行业(总览快照 + 日度指标序列)─────────────
891
+ * 这一节每一句"实测"都能在 2026-10-15 落盘的原始报文里逐字回查(本模块头部那条 ⛔ 的规矩):
892
+ * 报文与统计脚本在 `/tmp/ftshare-evidence/probe-c/`,结论表见 `docs/design/ftshare-source-survey.md` §7.7。
893
+ */
894
+ /** 上游实测认的四档报告期;`q4` / `Q4` / `h1` 这类别名**不报错**(见那颗能力的 description),所以参数层只放这四值。 */
895
+ const DISCLOSURE_REPORT_TYPES = ['q1', 'q2', 'q3', 'annual'];
896
+ const blockTradeRow = {
897
+ type: 'object',
898
+ properties: {
899
+ thscode: { type: 'string' }, ticker: { type: 'string' }, name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
900
+ trade_date: { type: 'string' }, trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
901
+ deal_price_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] }, close_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
902
+ volume_shares: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, premium_ratio: { oneOf: [{ type: 'number' }, { type: 'null' }] },
903
+ buyer_name: { oneOf: [{ type: 'string' }, { type: 'null' }] }, seller_name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
904
+ },
905
+ additionalProperties: false,
906
+ };
907
+ function mapBlockTradeRow(row) {
908
+ const symbol = String(row.symbol ?? '');
909
+ const { iso, ms } = normalizeDateCell(row.date);
910
+ return {
911
+ thscode: symbol, ticker: symbol.slice(0, 6), name: textOrNull(row.name),
912
+ trade_date: iso ?? '', trade_date_ms: ms,
913
+ deal_price_yuan: numberOrNull(row.price), close_yuan: numberOrNull(row.close),
914
+ volume_shares: integerOrNull(row.volume), premium_ratio: numberOrNull(row.premium_rate),
915
+ buyer_name: textOrNull(row.buyer_name), seller_name: textOrNull(row.seller_name),
916
+ };
917
+ }
918
+ const forecastRow = {
919
+ type: 'object',
920
+ properties: {
921
+ thscode: { type: 'string' }, ticker: { type: 'string' }, name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
922
+ year: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, report_type: { oneOf: [{ type: 'string' }, { type: 'null' }] },
923
+ report_type_cn: { oneOf: [{ type: 'string' }, { type: 'null' }] },
924
+ forecast_subject: { oneOf: [{ type: 'string' }, { type: 'null' }] }, forecast_type: { oneOf: [{ type: 'string' }, { type: 'null' }] },
925
+ forecast_value_min: { oneOf: [{ type: 'number' }, { type: 'null' }] }, forecast_value_max: { oneOf: [{ type: 'number' }, { type: 'null' }] },
926
+ last_year_value: { oneOf: [{ type: 'number' }, { type: 'null' }] }, unit: { oneOf: [{ type: 'string' }, { type: 'null' }] },
927
+ yoy_change_min_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] }, yoy_change_max_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
928
+ publish_date: { oneOf: [{ type: 'string' }, { type: 'null' }] }, publish_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
929
+ },
930
+ additionalProperties: false,
931
+ };
932
+ function mapForecastRow(row) {
933
+ const symbol = String(row.stock_code ?? '');
934
+ const { iso, ms } = normalizeDateCell(row.publish_date);
935
+ return {
936
+ thscode: symbol, ticker: symbol.slice(0, 6), name: textOrNull(row.stock_name),
937
+ year: integerOrNull(row.year), report_type: textOrNull(row.report_type), report_type_cn: textOrNull(row.report_type_cn),
938
+ forecast_subject: textOrNull(row.forecast_subject), forecast_type: textOrNull(row.forecast_type),
939
+ forecast_value_min: numberOrNull(row.forecast_value_min), forecast_value_max: numberOrNull(row.forecast_value_max),
940
+ last_year_value: numberOrNull(row.last_year_value), unit: textOrNull(row.unit),
941
+ yoy_change_min_pct: numberOrNull(row.yoy_change_min), yoy_change_max_pct: numberOrNull(row.yoy_change_max),
942
+ publish_date: iso, publish_date_ms: ms,
943
+ };
944
+ }
945
+ const expressRow = {
946
+ type: 'object',
947
+ properties: {
948
+ thscode: { type: 'string' }, ticker: { type: 'string' }, name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
949
+ year: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, report_type: { oneOf: [{ type: 'string' }, { type: 'null' }] },
950
+ report_type_cn: { oneOf: [{ type: 'string' }, { type: 'null' }] },
951
+ eps_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
952
+ total_revenue_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] }, total_revenue_prev_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
953
+ total_revenue_yoy_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
954
+ net_profit_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] }, net_profit_prev_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
955
+ net_profit_yoy_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
956
+ net_assets_per_share_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] }, roe_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
957
+ publish_date: { oneOf: [{ type: 'string' }, { type: 'null' }] }, publish_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
958
+ },
959
+ additionalProperties: false,
960
+ };
961
+ function mapExpressRow(row) {
962
+ const symbol = String(row.stock_code ?? '');
963
+ const { iso, ms } = normalizeDateCell(row.publish_date);
964
+ return {
965
+ thscode: symbol, ticker: symbol.slice(0, 6), name: textOrNull(row.stock_name),
966
+ year: integerOrNull(row.year), report_type: textOrNull(row.report_type), report_type_cn: textOrNull(row.report_type_cn),
967
+ eps_yuan: numberOrNull(row.eps),
968
+ total_revenue_yuan: numberOrNull(row.total_revenue), total_revenue_prev_yuan: numberOrNull(row.total_revenue_prev),
969
+ total_revenue_yoy_pct: numberOrNull(row.total_revenue_yoy),
970
+ net_profit_yuan: numberOrNull(row.net_profit), net_profit_prev_yuan: numberOrNull(row.net_profit_prev),
971
+ net_profit_yoy_pct: numberOrNull(row.net_profit_yoy),
972
+ net_assets_per_share_yuan: numberOrNull(row.sh_netassets_ps), roe_pct: numberOrNull(row.roe),
973
+ publish_date: iso, publish_date_ms: ms,
974
+ };
975
+ }
976
+ /**
977
+ * 业绩预告与业绩快报共用一颗工厂:路径、行形状与正文各给各的,**参数面完全一致**
978
+ * (单票 modeA 不分页 / `year` + `report_type` 翻页,二选一),所以互斥检查、枚举白名单与
979
+ * 防改版核对只写一遍(§9.7 同一族共用一份实现)。
980
+ */
981
+ function createDisclosureSource(options) {
982
+ const allowed = ['code', 'year', 'report_type', 'page', 'size'];
983
+ const schema = {
984
+ capability: options.capability,
985
+ time_contract: getDataTimeContract(options.capability),
986
+ name: options.name,
987
+ source: `http:ftshare.${options.upstreamApi}`,
988
+ data_key: buildDataKey('ftshare', 'http', options.capability),
989
+ source_label: 'ftshare',
990
+ paginated: true,
991
+ rowShape: { rowKey: 'item' },
992
+ summary: options.summary,
993
+ description: options.description,
994
+ input_schema: {
995
+ type: 'object',
996
+ properties: {
997
+ code: { type: 'string', description: '单票查全部报告期,须带市场后缀(000001.SZ);与 year/report_type 二选一' },
998
+ year: { type: 'integer', description: '报告期年份(如 2024);与 report_type 一起给,此时不给 code' },
999
+ report_type: { type: 'string', enum: [...DISCLOSURE_REPORT_TYPES], description: '报告期:q1 一季报 / q2 中报 / q3 三季报 / annual 年报' },
1000
+ page: { type: 'integer', minimum: 1 },
1001
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_500 },
1002
+ },
1003
+ required: [], additionalProperties: false,
1004
+ },
1005
+ output_schema: output(options.row),
1006
+ };
1007
+ const normalize = (params) => {
1008
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_500);
1009
+ const normalized = { page, size };
1010
+ if (params.code !== undefined) {
1011
+ if (params.year !== undefined || params.report_type !== undefined) {
1012
+ // 上游这时**静默只按单票查**(实测 code + year=2024 + q1 仍回那 16 行),不能让它替调用方做这个选择。
1013
+ throw new FtshareError('invalid_parameter', 'parameter code 与 year/report_type 二选一,不能同时给');
1014
+ }
1015
+ normalized.code = parseSecurityCode(params.code, 'code').canonical;
1016
+ return normalized;
1017
+ }
1018
+ if (params.year === undefined || params.report_type === undefined) {
1019
+ throw new FtshareError('invalid_parameter', '不给 code 时必须同时给 year 与 report_type:少一个上游只回 0 行且不报错(实测只给 year=2024 → total_items=0)');
1020
+ }
1021
+ const year = Number(params.year);
1022
+ if (!Number.isSafeInteger(year) || year < 1900 || year > 2100)
1023
+ throw new FtshareError('invalid_parameter', `parameter year ${params.year} must be a four-digit year`);
1024
+ const reportType = String(params.report_type);
1025
+ if (!DISCLOSURE_REPORT_TYPES.includes(reportType)) {
1026
+ throw new FtshareError('invalid_parameter', `parameter report_type must be one of ${DISCLOSURE_REPORT_TYPES.join(' / ')}(上游另有静默别名:实测 q4 与 Q4 都按 annual 查、h1 按 q2 查,本层不放行)`);
1027
+ }
1028
+ normalized.year = year;
1029
+ normalized.report_type = reportType;
1030
+ return normalized;
1031
+ };
1032
+ return {
1033
+ schema,
1034
+ normalizeParams: normalize,
1035
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1036
+ execute: async (request, signal) => {
1037
+ const params = normalize(request.params);
1038
+ // 上游这一族把单票参数叫 `stock_code`:只在出网那一刻改名,`normalizeParams` 的输出仍用 `code`
1039
+ // (§10.7 幂等——第二遍归一化的输入就是第一遍的输出,改名会让它自己报 unsupported parameter)。
1040
+ const stockCode = params.code;
1041
+ // ⛔ modeA(单票)**上游不翻页**:一次给完全量历史(实测 000001.SZ 16 行、600519 的股东人数 107 行
1042
+ // 连 page=2 / page_size=10 都照样给全部),所以这里不把 page 发出去——发了等于对外宣称能翻页,
1043
+ // 而 `pages = ceil(total ÷ size)` 会把一份完整历史算成两页,模型翻第二页会拿到重复的一整份。
1044
+ const search = query(stockCode !== undefined
1045
+ ? { stock_code: stockCode, year: params.year, report_type: params.report_type }
1046
+ : { year: params.year, report_type: params.report_type, page: params.page, page_size: params.size }, ['stock_code', 'year', 'report_type', 'page', 'page_size']);
1047
+ const { rows, total } = readPaged(await options.client.getJson(options.path, search, signal));
1048
+ // 防改版:这一族的过滤失败是静默的(写法不对只回空),所以回来之后逐行核身份。
1049
+ // ⛔ 核的是**上游原始列名**(stock_code / year / report_type),映射后的行已经把身份列改名了。
1050
+ if (stockCode !== undefined) {
1051
+ assertFiltered(rows, { field: 'stock_code', expected: String(stockCode), label: 'code' });
1052
+ return { data: snapshotEnvelope(rows.map(options.mapRow)), schema: output(options.row) };
1053
+ }
1054
+ assertFiltered(rows, { field: 'year', expected: String(params.year), label: 'year' });
1055
+ assertFiltered(rows, { field: 'report_type', expected: String(params.report_type), label: 'report_type' });
1056
+ const item = rows.map(options.mapRow);
1057
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(options.row) };
1058
+ },
1059
+ };
1060
+ }
1061
+ const swOverviewRow = {
1062
+ type: 'object',
1063
+ properties: {
1064
+ industry_code: { type: 'string' }, industry_name: { type: 'string' }, level: { type: 'integer' },
1065
+ parent_industry_name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
1066
+ constituent_count: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1067
+ trade_date: { type: 'string' }, trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1068
+ pe_lyr: { oneOf: [{ type: 'number' }, { type: 'null' }] }, pe_lyr_percentile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1069
+ pe_ttm: { oneOf: [{ type: 'number' }, { type: 'null' }] }, pe_ttm_percentile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1070
+ pb: { oneOf: [{ type: 'number' }, { type: 'null' }] }, pb_percentile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1071
+ dividend_yield_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] }, dividend_yield_percentile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1072
+ },
1073
+ additionalProperties: false,
1074
+ };
1075
+ function mapSwOverviewRow(row) {
1076
+ const { iso, ms } = normalizeDateCell(row.tradeDate);
1077
+ return {
1078
+ industry_code: String(row.industryCode ?? ''), industry_name: String(row.industryName ?? ''),
1079
+ level: integerOrNull(row.level) ?? 0, parent_industry_name: textOrNull(row.parentIndustryName),
1080
+ constituent_count: integerOrNull(row.constituentCount), trade_date: iso ?? '', trade_date_ms: ms,
1081
+ pe_lyr: numberOrNull(row.lyrPe), pe_lyr_percentile: numberOrNull(row.lyrPePercentile),
1082
+ pe_ttm: numberOrNull(row.ttmPe), pe_ttm_percentile: numberOrNull(row.ttmPePercentile),
1083
+ pb: numberOrNull(row.pb), pb_percentile: numberOrNull(row.pbPercentile),
1084
+ dividend_yield_pct: numberOrNull(row.dvRatio), dividend_yield_percentile: numberOrNull(row.dvRatioPercentile),
1085
+ };
1086
+ }
1087
+ /** 上游 `amount` 用千元、`total_mv` 用万元(两条独立算式核对,见那颗能力的 description);本层统一换成**元**再落库。 */
1088
+ function toYuan(value, factor) {
1089
+ const parsed = numberOrNull(value);
1090
+ if (parsed === null)
1091
+ return null;
1092
+ return Math.round(parsed * factor * 100) / 100;
1093
+ }
1094
+ const swMetricsRow = {
1095
+ type: 'object',
1096
+ properties: {
1097
+ industry_code: { type: 'string' }, industry_name: { type: 'string' },
1098
+ trade_date: { type: 'string' }, trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1099
+ pe_lyr: { oneOf: [{ type: 'number' }, { type: 'null' }] }, pe_lyr_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1100
+ pe_ttm: { oneOf: [{ type: 'number' }, { type: 'null' }] }, pe_ttm_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1101
+ pb: { oneOf: [{ type: 'number' }, { type: 'null' }] }, pb_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1102
+ dividend_yield_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] }, dividend_yield_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1103
+ dividend_ttm_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] }, dividend_ttm_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1104
+ w_pe_lyr: { oneOf: [{ type: 'number' }, { type: 'null' }] }, w_pe_lyr_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1105
+ w_pe_ttm: { oneOf: [{ type: 'number' }, { type: 'null' }] }, w_pe_ttm_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1106
+ w_pb: { oneOf: [{ type: 'number' }, { type: 'null' }] }, w_pb_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1107
+ w_dividend_yield_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] }, w_dividend_ttm_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1108
+ turnover_rate_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] }, turnover_float_rate_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1109
+ w_turnover_rate_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] }, w_turnover_float_rate_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1110
+ turnover_float_rate_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1111
+ total_mv_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] }, close_price_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1112
+ w_close_price_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1113
+ median_pe_lyr: { oneOf: [{ type: 'number' }, { type: 'null' }] }, median_pe_lyr_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1114
+ median_pe_ttm: { oneOf: [{ type: 'number' }, { type: 'null' }] }, median_pe_ttm_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1115
+ median_pb: { oneOf: [{ type: 'number' }, { type: 'null' }] }, median_pb_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1116
+ below_net_asset_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] }, below_net_asset_count: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1117
+ constituent_count: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, index_close: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1118
+ amount_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1119
+ amount_congestion: { oneOf: [{ type: 'number' }, { type: 'null' }] }, amount_congestion_quantile: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1120
+ },
1121
+ additionalProperties: false,
1122
+ };
1123
+ function mapSwMetricsRow(row) {
1124
+ const { iso, ms } = normalizeDateCell(row.tradeDate);
1125
+ return {
1126
+ industry_code: String(row.industryCode ?? ''), industry_name: String(row.industryName ?? ''),
1127
+ trade_date: iso ?? '', trade_date_ms: ms,
1128
+ pe_lyr: numberOrNull(row.lyrPe), pe_lyr_quantile: numberOrNull(row.lyrPeQuantile),
1129
+ pe_ttm: numberOrNull(row.ttmPe), pe_ttm_quantile: numberOrNull(row.ttmPeQuantile),
1130
+ pb: numberOrNull(row.pb), pb_quantile: numberOrNull(row.pbQuantile),
1131
+ dividend_yield_pct: numberOrNull(row.dvRatio), dividend_yield_quantile: numberOrNull(row.dvRatioQuantile),
1132
+ dividend_ttm_pct: numberOrNull(row.dvTtm), dividend_ttm_quantile: numberOrNull(row.dvTtmQuantile),
1133
+ w_pe_lyr: numberOrNull(row.addLyrPe), w_pe_lyr_quantile: numberOrNull(row.addLyrPeQuantile),
1134
+ w_pe_ttm: numberOrNull(row.addTtmPe), w_pe_ttm_quantile: numberOrNull(row.addTtmPeQuantile),
1135
+ w_pb: numberOrNull(row.addPb), w_pb_quantile: numberOrNull(row.addPbQuantile),
1136
+ w_dividend_yield_pct: numberOrNull(row.addDvRatio), w_dividend_ttm_pct: numberOrNull(row.addDvTtm),
1137
+ turnover_rate_pct: numberOrNull(row.turnoverRate), turnover_float_rate_pct: numberOrNull(row.turnoverRateF),
1138
+ w_turnover_rate_pct: numberOrNull(row.addTurnoverRate), w_turnover_float_rate_pct: numberOrNull(row.addTurnoverRateF),
1139
+ turnover_float_rate_quantile: numberOrNull(row.turnoverRateFQuantile),
1140
+ total_mv_yuan: toYuan(row.totalMv, 1e4), close_price_yuan: numberOrNull(row.closePrice), w_close_price_yuan: numberOrNull(row.addClose),
1141
+ median_pe_lyr: numberOrNull(row.middleLyrPe), median_pe_lyr_quantile: numberOrNull(row.middleLyrPeQuantile),
1142
+ median_pe_ttm: numberOrNull(row.middleTtmPe), median_pe_ttm_quantile: numberOrNull(row.middleTtmPeQuantile),
1143
+ median_pb: numberOrNull(row.middlePb), median_pb_quantile: numberOrNull(row.middlePbQuantile),
1144
+ below_net_asset_pct: numberOrNull(row.belowNetAssetPercent), below_net_asset_count: integerOrNull(row.belowNetAssetCount),
1145
+ constituent_count: integerOrNull(row.totalCount), index_close: numberOrNull(row.indexClose),
1146
+ amount_yuan: toYuan(row.amount, 1e3),
1147
+ amount_congestion: numberOrNull(row.amountCongestion), amount_congestion_quantile: numberOrNull(row.amountCongestionQuantile),
1148
+ };
1149
+ }
1150
+ /** 申万行业代码:实测 `.SI` 后缀**必需**——不带后缀的 `801010` 与不存在的 `999999.SI` 都只回 total=0、不回错。 */
1151
+ function swIndustryCode(value, name = 'industry_code') {
1152
+ const raw = typeof value === 'string' ? value.trim().toUpperCase() : '';
1153
+ if (!/^\d{6}\.SI$/.test(raw)) {
1154
+ throw new FtshareError('invalid_parameter', `parameter ${name} must be a 申万 industry code with the .SI suffix (for example 801010.SI)`);
1155
+ }
1156
+ return raw;
1157
+ }
1158
+ function createSwOverviewSource(client) {
1159
+ const capability = 'ftshare_sw_industry_overview';
1160
+ const allowed = ['date', 'level', 'page', 'size'];
1161
+ const schema = {
1162
+ capability,
1163
+ time_contract: getDataTimeContract(capability),
1164
+ name: 'get_sw_industry_overview',
1165
+ source: 'http:ftshare.sw_industry_overview',
1166
+ data_key: buildDataKey('ftshare', 'http', capability),
1167
+ source_label: 'ftshare',
1168
+ paginated: true,
1169
+ rowShape: { rowKey: 'item' },
1170
+ summary: '申万行业估值快照(全部层级,按交易日)',
1171
+ description: '按**单个交易日**获取申万行业(一、二、三级全部层级)的估值快照,一行一个行业。层级数量实测自洽:2026-06-01 全部层级 498 行 = level=1 的 31 + level=2 的 131 + level=3 的 336(2026-10-14 是 497,少的一个是三级里被合并的行业),`level` 传 1/2/3 只取该层(实测 level=1 → total=31);⛔ 越界的 `level` **不报错只回空**(实测 level=4 → total=0),所以参数层锁成 1/2/3。`date` 必填 YYYY-MM-DD(本层转成上游的 YYYYMMDD;带横杠的写法上游 400 点名拒「date format ... invalid characters」),非交易日与未来日期都是合法空(实测 20261011 周日、20261201 未来 → total=0)。⛔ **窗口只有最近约 94 个交易日**:实测最早有数的是 2026-05-28(2026-05-25 起更早就没有了;2026-01-04 / 2025-01-02 / 2024-01-02 / 2023-01-03 这些真实交易日全部 total=0,交易日历那条能力确认过它们是交易日),从 2026-05-28 数到 2026-10-15 恰是 94 个交易日——无法从一次调用区分"滚动窗口"还是"固定起点",请把窗口外当合法空结果、不要换日期重试。要更早的行业估值序列请用 `ftshare_sw_industry_metrics`(实测回到 2021-05-31)。当日快照**收盘后当晚就有**(实测 2026-10-15 19:32 打 20261015 已回 497 行)。列口径:五个估值列与它们的历史分位都由上游以字符串给出、本层转数值;⛔ `*_percentile` 是**百分数**(0~100,实测 801010.SI 2026-10-14 = 45.98),而日度指标那条同名口径给的是**比例值**(0.459810),两者差 100 倍、不许跨能力直接比。实测本条的估值列就是日度指标那族的**市值加权口径**(`w_*`)列,同日同列逐列相等:30.38 = w_pe_lyr、32.15 = w_pe_ttm、1.93 = w_pb、2.26 = w_dividend_yield_pct,分位 45.98 = w_pe_lyr_quantile 0.459810 × 100(2026-08-25 的三级行业 850112.SI 同样成立)——所以只要加权口径读本条即可,简单平均与中位数只有日度指标那条给。`parent_industry_name` 仅一级行业为 null(实测 1063 行里 94 行 null)。`constituent_count` 是成份股家数。分页 `page` 实测生效(page=1/3 首行不同),⛔ `size` 的真实上限是 **500 且超限静默按 500**(实测传 1000 → 回显 pageSize=500、497 行一次取完;文档写的 200 不是实际上限),本层锁 1~500。本层逐行核对返回的 `trade_date` 与请求同一天(防改版)。',
1172
+ input_schema: {
1173
+ type: 'object',
1174
+ properties: {
1175
+ date: { type: 'string', description: '交易日 YYYY-MM-DD;非交易日返回空(窗口只到最近约 94 个交易日)' },
1176
+ level: { type: 'integer', enum: [1, 2, 3], description: '行业层级 1/2/3;省略 = 全部层级(实测 497 行)' },
1177
+ page: { type: 'integer', minimum: 1 },
1178
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_500 },
1179
+ },
1180
+ required: ['date'], additionalProperties: false,
1181
+ },
1182
+ output_schema: output(swOverviewRow),
1183
+ };
1184
+ const normalize = (params) => {
1185
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_500);
1186
+ const normalized = { date: date(params.date, 'date'), page, size };
1187
+ if (params.level !== undefined) {
1188
+ const level = Number(params.level);
1189
+ if (level !== 1 && level !== 2 && level !== 3)
1190
+ throw new FtshareError('invalid_parameter', 'parameter level must be 1, 2 or 3(越界的 level 上游不报错、只回 0 行)');
1191
+ normalized.level = level;
1192
+ }
1193
+ return normalized;
1194
+ };
1195
+ return {
1196
+ schema,
1197
+ normalizeParams: normalize,
1198
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1199
+ execute: async (request, signal) => {
1200
+ const params = normalize(request.params);
1201
+ const search = query({ date: compactDate(String(params.date)), level: params.level, page: params.page, page_size: params.size }, ['date', 'level', 'page', 'page_size']);
1202
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/sw-industry/overview', search, signal));
1203
+ const item = rows.map(mapSwOverviewRow);
1204
+ assertInRange(item, String(params.date), String(params.date), (row) => row.trade_date);
1205
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(swOverviewRow) };
1206
+ },
1207
+ };
1208
+ }
1209
+ function createSwMetricsSource(client) {
1210
+ const capability = 'ftshare_sw_industry_metrics';
1211
+ const allowed = ['industry_code', 'start_date', 'end_date', 'page', 'size'];
1212
+ const schema = {
1213
+ capability,
1214
+ time_contract: getDataTimeContract(capability),
1215
+ name: 'get_sw_industry_daily_metrics',
1216
+ source: 'http:ftshare.sw_industry_daily_metrics',
1217
+ data_key: buildDataKey('ftshare', 'http', capability),
1218
+ source_label: 'ftshare',
1219
+ paginated: true,
1220
+ rowShape: { rowKey: 'item' },
1221
+ summary: '申万单行业日度估值与量价序列(2021-05-31 起)',
1222
+ description: '按**单个申万行业代码 + 日期区间**获取该行业的日频指标序列,一行一个交易日,42 列。`industry_code` 必须带 `.SI` 后缀(一 / 二 / 三级实测都可查:801010.SI / 801016.SI / 850112.SI),⛔ 不带后缀(`801010`)与不存在的代码(`999999.SI`)**只回 total=0、不回错**。⛔ **没有 `level` 参数**:上游按代码自动匹配层级(实测省略与传 1 相同),传错层数**静默回 0 行**(实测 level=3 配 801010.SI),本层因此不收它。日期 YYYY-MM-DD(带横杠 400 拒);`start_date` 晚于 `end_date` 上游静默回空、本层在参数层拒。深度:最早一天实测 **2021-05-31**(区间给 2015-01-01..2020-12-31 → total=0;给 2015-01-01..2026-10-15 → total=1298、首行即 2021-05-31)。`pagination.total` 是区间内的**交易日行数**(实测 2026-08-01..08-31 = 21 行)。⛔ **42 列里有 21 列经常是 null,别当 0**:实测 256 行里 `total_mv_yuan` / `constituent_count` / `close_price_yuan` / `w_close_price_yuan` / `below_net_asset_count` 与各 `*_quantile` 有 106 行 null,`amount_yuan` 有 111 行(多出的 5 行来自三级行业);`index_close` / `amount_yuan` / `amount_congestion(_quantile)` 在**三级行业恒为 null**(实测 850112.SI 连 2024-01-02 都是)。`amount_yuan` 更自 **2026-08-26** 起整体断供:实测 801010 / 801016 / 801030 / 801710 同一天一起从有值转 null,到 2026-10-14 仍是,而那天 PE/PB/股息率有值。单位:上游 `amount` 是**千元**、`total_mv` 是**万元**(文档没写,本层已换算成元给出),两条独立算式:2026-08-03 三十一个一级行业 amount 合计 1.9975e9,同日全市场日 K 成交额合计 2.0230 万亿元,比值 1012.75(行业覆盖 98.7%);同行 (amount×1000) ÷ (total_mv×1e4) 折出的换手率在 31 个行业上全落在 0.24%~4.7%,按同为元/同为万元读会差三个数量级。三套估值口径分清:无前缀 = **简单平均**(801010.SI 2026-10-14 pe_lyr 62.79)、`w_*` = **市值加权**(同日 30.38)、`median_*` = **中位数**(43.39),⛔ 不能互相替代,引用时说明是哪一套。`*_quantile` 全是**比例值 0~1**(⛔ 与总览快照的 `*_percentile` 差 100 倍而数值相等:本条 w_pe_lyr_quantile 0.459810 ↔ 总览 pe_lyr_percentile 45.98);`turnover_*_pct` 与 `below_net_asset_pct` 是百分数原值,且 `below_net_asset_pct = below_net_asset_count ÷ constituent_count × 100`(实测 6 ÷ 102 × 100 = 5.88)。⛔ 换手率是**流通市值**口径,与按总市值折出的不同基(31 个行业比值 0.11~1.22)。分页 `page` 实测生效,`size` 上限 **200 且超限静默截断**(实测 1000 → 回显 pageSize=200),本层锁 1~200。本层逐行核对返回日期落在请求区间内(防改版)。',
1223
+ input_schema: {
1224
+ type: 'object',
1225
+ properties: {
1226
+ industry_code: { type: 'string', description: '申万行业代码,必须带 .SI 后缀(一级 801010.SI / 二级 801016.SI / 三级 850112.SI)' },
1227
+ start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD(最早有数的交易日是 2021-05-31)' },
1228
+ end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD' },
1229
+ page: { type: 'integer', minimum: 1 },
1230
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_200 },
1231
+ },
1232
+ required: ['industry_code', 'start_date', 'end_date'], additionalProperties: false,
1233
+ },
1234
+ output_schema: output(swMetricsRow),
1235
+ };
1236
+ const normalize = (params) => {
1237
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_200);
1238
+ const start_date = date(params.start_date, 'start_date');
1239
+ const end_date = date(params.end_date, 'end_date');
1240
+ if (start_date > end_date)
1241
+ throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date(上游对反过来的区间只回 0 行、不报错)');
1242
+ return { industry_code: swIndustryCode(params.industry_code), start_date, end_date, page, size };
1243
+ };
1244
+ return {
1245
+ schema,
1246
+ normalizeParams: normalize,
1247
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1248
+ execute: async (request, signal) => {
1249
+ const params = normalize(request.params);
1250
+ const search = query({
1251
+ industry_code: params.industry_code,
1252
+ start_date: compactDate(String(params.start_date)), end_date: compactDate(String(params.end_date)),
1253
+ page: params.page, page_size: params.size,
1254
+ }, ['industry_code', 'start_date', 'end_date', 'page', 'page_size']);
1255
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/sw-industry/daily-metrics', search, signal));
1256
+ assertFiltered(rows, { field: 'industryCode', expected: String(params.industry_code), label: 'industry_code' });
1257
+ const item = rows.map(mapSwMetricsRow);
1258
+ assertInRange(item, String(params.start_date), String(params.end_date), (row) => row.trade_date);
1259
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(swMetricsRow) };
1260
+ },
1261
+ };
1262
+ }
1263
+ function createBlockTradesSource(client) {
1264
+ const capability = 'ftshare_block_trades';
1265
+ const allowed = ['date', 'page', 'size'];
1266
+ const schema = {
1267
+ capability,
1268
+ time_contract: getDataTimeContract(capability),
1269
+ name: 'get_block_trades',
1270
+ source: 'http:ftshare.block_trades',
1271
+ data_key: buildDataKey('ftshare', 'http', capability),
1272
+ source_label: 'ftshare',
1273
+ paginated: true,
1274
+ rowShape: { rowKey: 'item' },
1275
+ summary: '大宗交易明细(按交易日,一笔一行)',
1276
+ description: '按**单个交易日**获取全市场大宗交易明细。⛔ 一行是**一笔成交**而不是"一票一天":同一只票同日可有多笔(实测 2023-01-03 的 189 行分属 100 只票,最多一只占 22 行;同价同量的相邻两笔只是营业部不同)——按票聚合请先说清数的是笔数还是成交额。`date` 省略 = 取上游**最新有数的一天**,而它落后今天多少取决于入库时刻:实测 2026-10-15 晚 19:32(北京)打 `date=20261015` 是 total=0、不传 date 拿到的 73 行全是 10-14;同一晚 20:03 重打这两个请求,当天已经是 80 行了。⛔ 所以**傍晚拿到当天为空是「还没入库」**,不是取数失败、也不是「今天没有大宗」,不要循环重试。`date` 必须是 YYYY-MM-DD(本层转成上游的 YYYYMMDD):日历上不存在的一天上游 400 点名拒(实测「date 参数无效:20260231,应为有效的 YYYYMMDD 日期」),非交易日(实测 20261011 周日)只回 total=0 不回错。历史深但**不连续**:实测 2012-01-04 有 14 行、2018-01-02 有 32 行、2020-01-02 有 128 行、2023-01-03 有 189 行、2025-01-02 有 95 行,而 **2016-01-04 整天为 0**(`ftshare_trading_calendar` 确认 20160104 是交易日)——那是上游的缺口,别读成"那天没有大宗交易"。单位与口径:`deal_price_yuan` 与 `close_yuan` 是元(上游以字符串给出,如 "1.37",本层转数值)、`volume_shares` 是股、⛔ `premium_ratio` 是**比例值不是百分数**,实测口径 (成交价 − 收盘价) ÷ 收盘价(2026-10-13:000656.SZ 成交 1.03 / 收盘 1.13 → 字段 -0.08849558,与算式一致到小数点后 8 位;同日 001300.SZ 溢价 +0.19577579 = 19.58%),要写"折价 8.85%"必须自己 ×100。`buyer_name` / `seller_name` 是营业部原文,「机构专用」是其中一个取值。⛔ **不支持按票过滤**:实测把 `symbol` 当查询参数发出去,上游不报错也不过滤(total 仍是全市场那天的 73 行),单票大宗只能整天取回后自己筛——本层因此不接受 symbol。分页 `page` 实测生效(page=1/2 首行不同);⛔ `size` 的真实上限是 **500 且超限静默截断**(文档写 200,但实测传 1000 与 2000 都回显 pageSize=500 并一次给完 73 行),本层锁 1~500。本层逐行核对返回日期与请求同一天(防改版)。',
1277
+ input_schema: {
1278
+ type: 'object',
1279
+ properties: {
1280
+ date: { type: 'string', description: '交易日 YYYY-MM-DD;省略 = 上游最新可用的一天(实测为上一交易日)' },
1281
+ page: { type: 'integer', minimum: 1 },
1282
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_500 },
1283
+ },
1284
+ required: [], additionalProperties: false,
1285
+ },
1286
+ output_schema: output(blockTradeRow),
1287
+ };
1288
+ const normalize = (params) => {
1289
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_500);
1290
+ const normalized = { page, size };
1291
+ if (params.date !== undefined)
1292
+ normalized.date = date(params.date, 'date');
1293
+ return normalized;
1294
+ };
1295
+ return {
1296
+ schema,
1297
+ normalizeParams: normalize,
1298
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1299
+ execute: async (request, signal) => {
1300
+ const params = normalize(request.params);
1301
+ const search = query({ date: params.date === undefined ? undefined : compactDate(String(params.date)), page: params.page, page_size: params.size }, ['date', 'page', 'page_size']);
1302
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/block-trades', search, signal));
1303
+ const item = rows.map(mapBlockTradeRow);
1304
+ // 省略 date 时"最新的一天"由上游决定,没有可比的对端,只核对请求过的那一类。
1305
+ if (params.date !== undefined)
1306
+ assertInRange(item, String(params.date), String(params.date), (row) => row.trade_date);
1307
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(blockTradeRow) };
1308
+ },
1309
+ };
1310
+ }
1311
+ const FORECAST_DESCRIPTION = '按报告期获取 A 股**业绩预告**(含北交所与 ST 票),一行是「一只票 × 一个报告期 × 一个预测科目」。两种查法互斥:给 `code`(须带市场后缀,本层转成上游要的 `000001.SZ` 写法)= 单票全部历史报告期且**不分页**(实测 000001.SZ 回 16 行、total=16、pages 恒 1);给 `year` + `report_type` = 该报告期全市场翻页(实测 2024 annual 3150 行、2023 annual 3333 行、2024 的 q1 229 / q2 1766 / q3 223 行)。⛔ 两组同时给会在参数层被拒——上游这时**静默只按单票查**(实测 code + year=2024 + q1 仍回那 16 行)。`report_type` 只认四值:实测上游另有**不报错的别名**(`q4` 与 `Q4` 都按 annual 查得 3150、`h1` 按 q2 查得 1766),本层把别名挡在门外;真正不认识的写法(`zzz`)上游才 400 点名拒「report_type 无效: zzz, 应为 q1/q2/q3/annual」。⛔ **少给任一个都只回 0 行、不回错**:只给 year(实测 total_items=0)、代码不带后缀(`000001`)、后缀写成聚宽写法(`600519.XSHG`)都是空结果。金额三列(`forecast_value_min` / `forecast_value_max` / `last_year_value`)的单位由同行的 `unit` 说明:实测 2025 annual 全市场逐页取满 3368 行里 `unit` **恒为「元」**;一旦看到别的取值,金额口径要跟着它改。`forecast_value_min == forecast_value_max` 是上游给了点值(多数行如此),不是缺失。⛔ **0 不是「预告为零」**:同一批里有 26 行上下限同为 0,其中 25 行的 yoy 仍非 0(今世缘 2025 营业收入 0/0、yoy 5~12)——那是**缺值编码**,请改看 `yoy_change_*` 与 `last_year_value`;另有 6 行连 `last_year_value` 都是 0。`yoy_change_min_pct` / `max_pct` 是**百分数原值**,实测口径 (预测 − 去年同期) ÷ |去年同期| × 100(平安银行 2015 年报:(20792060000 − 19801960000) ÷ 19801960000 = 5.0000 = 字段 5;上限 15.0000 = 字段 15)。⛔ **0 是「不适用」而不是「同比持平」**:实测 597 行有 190 行 yoy 记 0,这些行的去年同期或预测值至少一方为负(声迅股份 2024 由盈转亏、万林物流 2024 由亏转盈、退市鹏博 2024 减亏都记 0),而预测与去年同期的净利润**都为正**的 28 行 yoy 全部与上面那条算式相符——遇到 0 请自己用 forecast 与 last_year 算。`forecast_subject` 与 `forecast_type` 原样透出、本层不改写:实测 3368 行里科目只有三值(归属于上市公司股东的净利润 3235 / 营业收入 120 / 扣除非经常性损益后的净利润 13)、类型只有五值(预减 1414 / 预增 849 / 略增 585 / 略减 514 / 其他 6);供应商文档另列了 续亏 / 扭亏 / 续盈 等写法,本层不把任何一组当枚举。modeB 的排序实测是 **`publish_date` 递减**(前 1500 行逐行单调,实测首行发布 2026-04-30、第 1500 行 2026-01-29),所以"最新预告"跨页分散,要按票或按科目取最新一条请自己分组。年份深度:实测 2000 annual 有 93 行、2005 年 603 行、2025 年 3368 行(越近越多)。防改版:带 code 逐行核 `stock_code`,按报告期核 `year` 与 `report_type`;`size` 上限 500(实测 501 → HTTP 400「page_size 不能超过 500」)。';
1312
+ const EXPRESS_DESCRIPTION = '按报告期获取 A 股**业绩快报**(已披露快报的核心财务指标),一行是「一只票 × 一个报告期」。查法与 `ftshare_performance_forecast` 完全一致(`code` 单票不分页 / `year` + `report_type` 全市场翻页,两组互斥、`report_type` 只认 q1/q2/q3/annual,越界写法参数层拒)。⛔ **快报是稀薄披露**:实测 2024 annual 全市场只有 1192 行(预告同口径 3150 行)、2024 q1 只有 8 行、2005 annual 104 行;什么都不给时上游回 total_items=0 且不报错,取到空是常态而不是失败。同一只票同一报告期可有**多条**更正披露:实测 2024 annual 首页 500 行里 477 个「票×年×期」组合中有 23 个占两行,两行的 `publish_date` 与数值都不同(ST帕瓦 2024:净利 −1,097,147,600 发布于 2025-02-28、−726,501,500 发布于 2025-04-30)。⛔ modeB 的排序实测是 **`publish_date` 递减**(500 行逐行单调),同一票的原披露与更正披露因此相距很远(实测那两条分别落在第 1 行与第 316 行)——要"最新口径"必须按 `publish_date` 分组取最大,**不能按行序或页序猜**。单票 modeA 实测是一期一行(000001.SZ 共 4 条 = 2018 / 2019 / 2021 / 2022 年报各一条)。单位与口径:`eps_yuan`(每股收益)、`net_assets_per_share_yuan`(每股净资产)、`total_revenue_yuan` / `net_profit_yuan` 及各自 `_prev` 上期值都由上游以字符串给出、单位元,本层转数值;`*_yoy_pct` 与 `roe_pct` 是**百分数原值**。⛔ **`net_profit_yoy_pct` 在上期亏损时符号是反的**:实测 2024 / 2023 / 2021 三个年度各取 500 行(共 1500 行)按 (本期 − 上期) ÷ |上期| × 100 逐行核——上期**为正**的 1260 行全部相符(平安银行 2022 年报:(45516000000 − 36336000000) ÷ 36336000000 × 100 = 25.2642 = 字段值),上期**为负**的 240 行**全部与该数符号相反**(ST帕瓦 2024:本期 −726,501,500、上期 −247,643,300 是亏损扩大,算得 −193.3661 而字段是 +193.3661;亚信安全 2024 扭亏,算得 +103.2949 而字段是 −103.2949)。⛔ 所以**别拿这个字段判方向**,要"亏损扩大还是收窄"请自己用本期与上期比。`total_revenue_yoy_pct` 在 499 行(上期均为正)内全部相符,没出现上期为负的样本,规律不外推。`roe_pct` 与同行其它列的算法关系**未核出**(股本要另取 `ftshare_ashare_quote` 的 `shares`,而那是当前股本、不是报告期股本)。⛔ `total_revenue_yoy_pct` 实测出现过 null(46 行里 1 行)——**null 不是 0**,也不是"没增长"。分页 `page` 实测生效,`size` 上限 500(实测 501 → HTTP 400「page_size 不能超过 500」;`page` 或 `size` 给 0 也 400「page/page_size 必须大于 0」,这条比预告那族更响)。防改版:带 code 逐行核 `stock_code`,按报告期核 `year` 与 `report_type`。';
1313
+ /**
1314
+ * 批次 D(2026-10-16 07:36–07:44 北京时间,176 份落盘报文):涨跌停价、龙虎榜席位、股东人数、
1315
+ * 指数成分权重、可转债历史 K 线。五颗都是逐条对照本仓现有能力后挑的**真缺口**,不是"同族再加一颗":
1316
+ * 涨跌停此前只有 `tencent_quote` 的当日口径且明确拒北证;席位此前任何一颗都没有;股东人数只有
1317
+ * 东财最新一期截面;成分权重只有名单没有数;转债只有发行条款没有价格序列。
1318
+ */
1319
+ const LIMIT_INSTRUMENT_TYPES = ['stock', 'etf', 'cb'];
1320
+ const limitPriceRow = {
1321
+ type: 'object',
1322
+ properties: {
1323
+ thscode: { type: 'string' },
1324
+ trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
1325
+ trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1326
+ instrument_type: { type: 'string' },
1327
+ pre_close_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1328
+ up_limit_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1329
+ down_limit_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1330
+ },
1331
+ additionalProperties: false,
1332
+ };
1333
+ function mapLimitPriceRow(row) {
1334
+ const { iso, ms } = normalizeDateCell(row.trade_date);
1335
+ return {
1336
+ thscode: String(row.ts_code ?? ''),
1337
+ trade_date: iso,
1338
+ trade_date_ms: ms,
1339
+ instrument_type: String(row.instrument_type ?? ''),
1340
+ pre_close_yuan: numberOrNull(row.pre_close),
1341
+ up_limit_yuan: numberOrNull(row.up_limit),
1342
+ down_limit_yuan: numberOrNull(row.down_limit),
1343
+ };
1344
+ }
1345
+ const LIMIT_DESCRIPTION = '按交易日获取全市场或单只标的的**涨跌停价与昨收价**,一行是「一只标的 × 一个交易日」。补的两处空白都经过实测:本仓此前只有 `tencent_quote` 的**当日**涨跌停,而它在参数层明确拒北交所;这一条实测 2026-10-15 全市场 7587 行里含 **350 只 `.BJ`**,而且能翻历史(600519.SH 单票 2010-01-04 起有数,2009 全年 total=0)。两种查法:不给 `code` = 按 `date` 取该日全市场并翻页;给 `code`(须带市场后缀,本层归一成 `000001.SZ` 写法)= 单票,配 `date` 取一天、或配 `start_date` + `end_date` 取区间。⛔ 三条上游硬规则在参数层先拦,话术比它的 400 更具体:区间必须配 `code`(实测不带 code 只给区间 → 400「start_date/end_date 需配合 symbol 使用」)、`date` 与区间不可混用(实测 400「trade_date 不能与 start_date/end_date 同时使用」)、区间跨度不超过 3 年(实测 2020..2026 → 400「start_date 与 end_date 间隔不能超过 3 年」)。裸 6 位在上游是**按深市个股解释**的(实测 `000001` → 平安银行一行),本层不接受这种猜测、要求显式后缀;指数后缀会被上游点名拒(实测 `000001.SH` → 400「index has no limit price」)。`instrument_type` ∈ stock / etf / cb,实测过滤生效且**三类相加正好等于全市场**(2026-10-15:5576 + 1696 + 315 = total 7587),越界写法上游 400「instrument_type 仅支持 stock、etf、cb」。⛔ **`up_limit_yuan` / `down_limit_yuan` 不是交易所口径的精确价**:上游以字符串给最多 6 位小数,实测同日有 852/5576 只股票的涨停价带尾巴(000001.SZ 昨收 11.50 × 1.1 = 12.65,字段却是 12.649999),转债 259/315、ETF 1541/1696 更多(它们昨收本来就是 3~4 位)——要判"是否封板"请先按最小价格单位取整再比。幅度实测分桶(同日全市场):主板 ±10%、创业板与科创板 ±20%、北证 ±30%、转债 ±20%、ETF 多为 ±10%。⛔ 有 3 行的涨停价与跌停价**同时为 null**(920162.BJ / 301569.SZ / 301660.SZ),这三只在 2026-09-01..10-15 区间里**只出现在 10-15 这一天**,形状像上市首日不设限制,但成因未获对方确认——请按"无限制"而不是"数据缺失"读。非交易日与未来日期都只回 total=0、不报错(实测 20261011 周日、20270101)。防改版:带 `code` 逐行核身份,其余逐行核返回日期落在请求区间内。';
1346
+ /**
1347
+ * 每日涨跌停价(`/market/data/stk-limit`)。⛔ 这一颗的**列值不是交易所精确价**:实测 852/5576 只
1348
+ * 股票的涨停价是 `x.649999` 这种带尾巴的字符串,本层原样转数值、不做任何取整——取整口径属于分析层。
1349
+ */
1350
+ function createLimitPriceSource(client) {
1351
+ const capability = 'ftshare_limit_price';
1352
+ const allowed = ['date', 'code', 'start_date', 'end_date', 'instrument_type', 'page', 'size'];
1353
+ const schema = {
1354
+ capability,
1355
+ time_contract: getDataTimeContract(capability),
1356
+ name: 'get_stk_limit',
1357
+ source: 'http:ftshare.stk_limit',
1358
+ data_key: buildDataKey('ftshare', 'http', capability),
1359
+ source_label: 'ftshare',
1360
+ paginated: true,
1361
+ rowShape: { rowKey: 'item' },
1362
+ summary: '每日涨跌停价与昨收(含北证,按日或单票区间)',
1363
+ description: LIMIT_DESCRIPTION,
1364
+ input_schema: {
1365
+ type: 'object',
1366
+ properties: {
1367
+ date: { type: 'string', description: '交易日 YYYY-MM-DD;省略且给了 code = 该票最新一天(实测默认口径是上一交易日)' },
1368
+ code: { type: 'string', description: '单只标的,必须带市场后缀(600519.SH / 920000.BJ / 110075.SH);省略 = 按 date 取全市场' },
1369
+ start_date: { type: 'string', description: '单票历史区间起始日 YYYY-MM-DD,必须与 end_date、code 一起给' },
1370
+ end_date: { type: 'string', description: '单票历史区间截止日 YYYY-MM-DD(上游限跨度 3 年)' },
1371
+ instrument_type: { type: 'string', enum: [...LIMIT_INSTRUMENT_TYPES], description: '标的类型过滤;实测三类相加正好等于全市场' },
1372
+ page: { type: 'integer', minimum: 1 },
1373
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE },
1374
+ },
1375
+ required: [], additionalProperties: false,
1376
+ },
1377
+ output_schema: output(limitPriceRow),
1378
+ };
1379
+ const normalize = (params) => {
1380
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE);
1381
+ const code = params.code === undefined ? undefined : parseSecurityCode(params.code, 'code').canonical;
1382
+ const hasStart = params.start_date !== undefined;
1383
+ const hasEnd = params.end_date !== undefined;
1384
+ // 实测上游对这三种混用都是**响亮 400**,本层先拦是为了把话说到"该改哪一个参数"这一层。
1385
+ if ((hasStart || hasEnd) && code === undefined) {
1386
+ throw new FtshareError('invalid_parameter', 'parameters start_date/end_date require code — 上游没有"全市场历史区间"这种查法(实测 400「start_date/end_date 需配合 symbol 使用」)');
1387
+ }
1388
+ if (hasStart !== hasEnd)
1389
+ throw new FtshareError('invalid_parameter', 'parameters start_date and end_date must be provided together(实测上游 400「start_date 与 end_date 需同时提供」)');
1390
+ if (params.date !== undefined && hasStart) {
1391
+ throw new FtshareError('invalid_parameter', 'parameter date cannot combine with start_date/end_date(实测上游 400「trade_date 不能与 start_date/end_date 同时使用」)');
1392
+ }
1393
+ const normalized = { page, size };
1394
+ if (params.date !== undefined)
1395
+ normalized.date = date(params.date, 'date');
1396
+ if (code !== undefined)
1397
+ normalized.code = code;
1398
+ if (hasStart) {
1399
+ const start_date = date(params.start_date, 'start_date');
1400
+ const end_date = date(params.end_date, 'end_date');
1401
+ if (start_date > end_date)
1402
+ throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
1403
+ normalized.start_date = start_date;
1404
+ normalized.end_date = end_date;
1405
+ }
1406
+ if (params.instrument_type !== undefined) {
1407
+ const kind = String(params.instrument_type);
1408
+ if (!LIMIT_INSTRUMENT_TYPES.includes(kind)) {
1409
+ throw new FtshareError('invalid_parameter', `parameter instrument_type must be one of ${LIMIT_INSTRUMENT_TYPES.join(' / ')}`);
1410
+ }
1411
+ normalized.instrument_type = kind;
1412
+ }
1413
+ return normalized;
1414
+ };
1415
+ return {
1416
+ schema,
1417
+ normalizeParams: normalize,
1418
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1419
+ execute: async (request, signal) => {
1420
+ const params = normalize(request.params);
1421
+ const search = query({
1422
+ instrument_type: params.instrument_type,
1423
+ symbol: params.code,
1424
+ trade_date: params.date === undefined ? undefined : compactDate(String(params.date)),
1425
+ start_date: params.start_date === undefined ? undefined : compactDate(String(params.start_date)),
1426
+ end_date: params.end_date === undefined ? undefined : compactDate(String(params.end_date)),
1427
+ page: params.page,
1428
+ page_size: params.size,
1429
+ }, ['instrument_type', 'symbol', 'trade_date', 'start_date', 'end_date', 'page', 'page_size']);
1430
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/stk-limit', search, signal));
1431
+ const item = rows.map(mapLimitPriceRow);
1432
+ assertFiltered(item, params.code === undefined ? undefined : { field: 'thscode', expected: String(params.code), label: 'code' });
1433
+ if (params.date !== undefined)
1434
+ assertInRange(item, String(params.date), String(params.date), (row) => row.trade_date);
1435
+ else if (params.start_date !== undefined)
1436
+ assertInRange(item, String(params.start_date), String(params.end_date), (row) => row.trade_date);
1437
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(limitPriceRow) };
1438
+ },
1439
+ };
1440
+ }
1441
+ const SEAT = {
1442
+ type: 'object',
1443
+ properties: {
1444
+ name: { type: 'string' },
1445
+ buy_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1446
+ sell_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1447
+ net_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1448
+ },
1449
+ additionalProperties: false,
1450
+ };
1451
+ const dragonTigerSeatRow = {
1452
+ type: 'object',
1453
+ properties: {
1454
+ thscode: { type: 'string' },
1455
+ trade_date: { type: 'string' },
1456
+ trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1457
+ change_ratio: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1458
+ close_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1459
+ turnover_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1460
+ top_buyers: { type: 'array', items: SEAT },
1461
+ top_sellers: { type: 'array', items: SEAT },
1462
+ },
1463
+ additionalProperties: false,
1464
+ };
1465
+ function mapSeats(value) {
1466
+ if (!Array.isArray(value))
1467
+ return [];
1468
+ return value.filter(isRecord).map((seat) => ({
1469
+ name: String(seat.name ?? ''),
1470
+ buy_yuan: numberOrNull(seat.buy),
1471
+ sell_yuan: numberOrNull(seat.sell),
1472
+ net_yuan: numberOrNull(seat.net),
1473
+ }));
1474
+ }
1475
+ function mapDragonTigerSeatRow(row, tradeDate) {
1476
+ return {
1477
+ thscode: String(row.symbol ?? ''),
1478
+ trade_date: tradeDate,
1479
+ trade_date_ms: dateMs(tradeDate),
1480
+ change_ratio: numberOrNull(row.change_rate),
1481
+ close_yuan: numberOrNull(row.close),
1482
+ turnover_yuan: numberOrNull(row.turnover),
1483
+ top_buyers: mapSeats(row.top_buyers),
1484
+ top_sellers: mapSeats(row.top_sellers),
1485
+ };
1486
+ }
1487
+ const SEATS_DESCRIPTION = '按**单个交易日**获取龙虎榜明细:一行是一只上榜票,带**买入额前五与卖出额前五的营业部席位**。这一层本仓此前没有——`dragon_tiger` 只给到票级榜单、`eastmoney_top_buy_sell_*` 只给汇总,都没有席位名字;⛔ 所以这三条口径不同,不能拿来互相核对条数。`date` 必填(YYYY-MM-DD,本层转成上游的 YYYYMMDD):上游允许省略(回"最新快照"),但**响应行里没有日期列**,省略时落库的行就没有时间轴,本能力因此要求显式日期、`trade_date` 由本层按请求补写。实测规模很小:2026-10-14 全日 68 行(SH 26 / SZ 37 / **BJ 5**)、2026-10-15 57 行、2023-01-03 44 行,`size` 给到 201 以上就**一页拿全**。⛔ 历史窗口实测起点在 2022 年初:2022-01-04 有 67 行,而 2021-12-31 与更早(2021-01-04 / 2020-01-02 / 2016-01-04 / 2012-01-04)都是 **0 行**,信封长这样 `{"code":200,"message":"success","data":null}`——那是合法空、不是取数失败;是滚动窗口还是固定起点未核出。非交易日与未来日期同样是 `data:null`(实测 20261011、20270101)。单位与口径:⛔ `change_ratio` 是**比例值不是百分数**(实测 002696.SZ 给 0.10045146726862288,与本仓 `ftshare_limit_price` 的昨收算 (9.75 ÷ 8.86 − 1) 差 3.2e-16),与日度资金流那族的 `change_pct` 不是一套、禁止直接比;`close_yuan` / `turnover_yuan` 与席位三列都是**元**(上游字符串,本层转数值)。`net_yuan = buy_yuan − sell_yuan` 实测 2026-10-14 的 68 行 × 10 席 = 680 条**违例 0**;⛔ 但同一天有 1 行的前五买入额合计**大于**当日成交额,所以席位金额与成交额不能互相闭合。`top_buyers` / `top_sellers` 通常各 5 条,上游缺名次时**不补位**(实测 2025-12-31 的 600343.SH 两边都只有 4 条),统计前先判长度。⛔ **更要紧的是:这两组不保证是营业部**。同一天的 600343.SH 那 4 条的 `name` 是**投资者分类**(机构 / 其他自然人 / 中小投资者 / 沪股通),`top_buyers` 与 `top_sellers` 两组内容**完全相同**,其中「机构」的 `buy_yuan` 是 67,487,960,000 而同行 `turnover_yuan` 只有 7,120,896,372.22——那是交易所按分类口径披露的行,**别按"前五营业部"解读,也别拿 buy ÷ turnover 算占比**。席位名是原文,「机构专用」是营业部口径里的一种取值。⛔ **不支持按票过滤**:实测把 `symbol` 发给上游不报错也不过滤(total 仍是全日 68 行),单票请整日取回后在 Dataset 里筛。`size` 的真实上限是 500 且超限**静默夹断**(文档写 200;实测传 1000 → 回显 pageSize=500)。';
1488
+ /**
1489
+ * 龙虎榜前五席位(`/market/data/abnormal-trading-details`)。
1490
+ * ⛔ `date` 是必填而不是可选:响应行内**没有日期列**(实测列集合就是 symbol/change_rate/close/turnover/
1491
+ * top_buyers/top_sellers),省略日期就等于落一份没有时间轴的 Dataset——这一条是本层加的,不是上游的限制。
1492
+ */
1493
+ function createDragonTigerSeatsSource(client) {
1494
+ const capability = 'ftshare_dragon_tiger_seats';
1495
+ const allowed = ['date', 'page', 'size'];
1496
+ const schema = {
1497
+ capability,
1498
+ time_contract: getDataTimeContract(capability),
1499
+ name: 'get_abnormal_trading_details',
1500
+ source: 'http:ftshare.abnormal_trading_details',
1501
+ data_key: buildDataKey('ftshare', 'http', capability),
1502
+ source_label: 'ftshare',
1503
+ paginated: true,
1504
+ rowShape: { rowKey: 'item' },
1505
+ summary: '龙虎榜前五买入/卖出席位(按交易日,含北证)',
1506
+ description: SEATS_DESCRIPTION,
1507
+ input_schema: {
1508
+ type: 'object',
1509
+ properties: {
1510
+ date: { type: 'string', description: '交易日 YYYY-MM-DD,必填(响应里没有日期列,本层要它来写时间轴)' },
1511
+ page: { type: 'integer', minimum: 1 },
1512
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_500 },
1513
+ },
1514
+ required: ['date'], additionalProperties: false,
1515
+ },
1516
+ output_schema: output(dragonTigerSeatRow),
1517
+ };
1518
+ const normalize = (params) => {
1519
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_500);
1520
+ return { page, size, date: date(params.date, 'date') };
1521
+ };
1522
+ return {
1523
+ schema,
1524
+ normalizeParams: normalize,
1525
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1526
+ execute: async (request, signal) => {
1527
+ const params = normalize(request.params);
1528
+ const search = query({ date: compactDate(String(params.date)), page: params.page, page_size: params.size }, ['date', 'page', 'page_size']);
1529
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/abnormal-trading-details', search, signal));
1530
+ const item = rows.map((row) => mapDragonTigerSeatRow(row, String(params.date)));
1531
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(dragonTigerSeatRow) };
1532
+ },
1533
+ };
1534
+ }
1535
+ const holderNumberRow = {
1536
+ type: 'object',
1537
+ properties: {
1538
+ thscode: { type: 'string' },
1539
+ stock_name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
1540
+ publish_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
1541
+ report_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
1542
+ report_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1543
+ holder_num: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1544
+ holder_num_change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1545
+ per_capita_circ_share: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1546
+ per_capita_share_change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1547
+ chip_concentration: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1548
+ close_price_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1549
+ per_capita_hold_amount_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1550
+ ten_holder_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1551
+ ften_holder_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1552
+ },
1553
+ additionalProperties: false,
1554
+ };
1555
+ function mapHolderNumberRow(row) {
1556
+ const { iso, ms } = normalizeDateCell(row.report_date);
1557
+ return {
1558
+ thscode: String(row.stock_code ?? ''),
1559
+ stock_name: textOrNull(row.stock_name),
1560
+ publish_date: normalizeDateCell(row.publish_date).iso,
1561
+ report_date: iso,
1562
+ report_date_ms: ms,
1563
+ holder_num: integerOrNull(row.holder_num),
1564
+ holder_num_change_pct: numberOrNull(row.holder_num_change_ratio),
1565
+ per_capita_circ_share: numberOrNull(row.per_capita_circ_share),
1566
+ per_capita_share_change_pct: numberOrNull(row.per_capita_share_change_ratio),
1567
+ chip_concentration: numberOrNull(row.chip_concentration),
1568
+ close_price_yuan: numberOrNull(row.close_price),
1569
+ per_capita_hold_amount_yuan: numberOrNull(row.per_capita_hold_amount),
1570
+ ten_holder_pct: numberOrNull(row.ten_holder_ratio),
1571
+ ften_holder_pct: numberOrNull(row.ften_holder_ratio),
1572
+ };
1573
+ }
1574
+ const HOLDER_DESCRIPTION = '两种互斥查法:给 `code`(须带市场后缀)= 该票**全部历史**、一次给完不翻页(实测 000001.SZ 122 期回到 1995-12-31、600519.SH 107 期、920001.BJ 25 期);给 `is_last=true` = 全市场**各只票自己的最新一期**并翻页(实测 total_items=5926)。补的空白:本仓此前只有 `eastmoney_holder_number_snapshot` 的最新一期截面,**股东人数的历史序列一直是空的**。⛔ `is_last` **不是"同一报告期截面"**:实测同一页里 report_date 就有 2026-06-30 / 2026-09-30 / 2026-10-09 / 2026-03-31 好几种,做横切面统计请按 `report_date` 自己分组。两组参数同时给会被上游**静默只按单票查**(实测 code + is_last=true 仍回那 122 行),本层在参数层拒混用。写法敏感全是静默的:裸 6 位(`000001`)、聚宽后缀(`000001.XSHE`)、**小写后缀**(`600519.sh`)都回 0 行且不报错(实测),本层统一归一成 `600519.SH` 再发。⛔ **没有日期参数**:实测多发一个 `report_date` 上游不报错也不过滤(仍回 122 行),所以按报告期筛请取回全量后在 Dataset 里做,本层不提供也不会替它假装过滤。单位与口径:`holder_num` 是人;⛔ `holder_num_change_pct` 是**百分数原值**,口径 (本期 ÷ 上期 − 1) × 100 —— 实测 600519.SH 自 2002-06-30 起的 15 期逐期核对,与算式差 ≤1e-4;`ten_holder_pct` / `ften_holder_pct` 同为百分数(十大股东 / 十大流通股东持股比例,上游列名里的 `ften` 是它自己的拼写,本层不改名);`per_capita_circ_share` 是人均流通股(股),`per_capita_hold_amount_yuan` 是人均持股市值(元),`close_price_yuan` 元,`publish_date` / `report_date` 是 YYYY-MM-DD 原文并另有 `report_date_ms` 供时间轴。⛔ **`close_price_yuan` 的 0 不是价格**:实测 is_last 首页 500 行里 2 行为 0(如 603361.SH:股东数 27、报告期 2022-12-31),同行的 `per_capita_hold_amount_yuan` 会跟着是 0。整列为空是常态而不是坏数据:实测 600519.SH 的 107 期里 `per_capita_share_change_pct` 与 `chip_concentration` **107/107 全为 null**,`per_capita_circ_share` 83 期 null、`ten_holder_pct` 30 期 null、`ften_holder_pct` 17 期 null、`holder_num_change_ratio` 首期 1 行 null——⛔ null 不是 0,不要补值。`size` 上限 1000(实测 2000 / 5000 上游照给,但粗页会把一次调用变成几万行,本层不放)。防改版:带 `code` 逐行核 `stock_code` 与请求同一只票。';
1575
+ /**
1576
+ * 股东人数(`/holder/stock-holder-nums`)。⛔ 参数面**刻意没有日期**:实测上游收到多余的 `report_date`
1577
+ * 不报错也不过滤,那一句"我按报告期筛过了"是假的——要筛就取回全量在 Dataset 里筛。
1578
+ */
1579
+ function createHolderNumberSource(client) {
1580
+ const capability = 'ftshare_holder_number';
1581
+ const allowed = ['code', 'is_last', 'page', 'size'];
1582
+ const schema = {
1583
+ capability,
1584
+ name: 'get_stock_holders_number',
1585
+ source: 'http:ftshare.stock_holders_number',
1586
+ data_key: buildDataKey('ftshare', 'http', capability),
1587
+ source_label: 'ftshare',
1588
+ paginated: true,
1589
+ rowShape: { rowKey: 'item' },
1590
+ summary: '股东人数历史(单票全期)与全市场最新一期',
1591
+ description: HOLDER_DESCRIPTION,
1592
+ input_schema: {
1593
+ type: 'object',
1594
+ properties: {
1595
+ code: { type: 'string', description: '单只标的,必须带市场后缀(600519.SH / 920001.BJ);给了它 = 该票全部历史' },
1596
+ is_last: { type: 'boolean', description: 'true = 全市场每只票各自的最新一期(分页);与 code 互斥' },
1597
+ page: { type: 'integer', minimum: 1 },
1598
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE },
1599
+ },
1600
+ required: [], additionalProperties: false,
1601
+ },
1602
+ output_schema: output(holderNumberRow),
1603
+ };
1604
+ const normalize = (params) => {
1605
+ const code = params.code === undefined ? undefined : parseSecurityCode(params.code, 'code').canonical;
1606
+ // 实测 code + is_last=true 时上游静默只按单票查(total 仍是那 122 行),等于让模型以为筛过。
1607
+ if (code !== undefined && params.is_last === true) {
1608
+ throw new FtshareError('invalid_parameter', 'parameters code and is_last are mutually exclusive — 上游这时静默只按单票查(实测 total 仍是单票那 122 行),不会替你按 is_last 取全市场');
1609
+ }
1610
+ const isLast = params.is_last === undefined ? undefined : booleanParam(params.is_last);
1611
+ if (code === undefined && isLast !== true)
1612
+ throw new FtshareError('invalid_parameter', 'one of code / is_last is required — 什么都不给时上游回 total_items=0 且不报错(实测),那一份不是数据');
1613
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE);
1614
+ const normalized = { page, size };
1615
+ if (code !== undefined)
1616
+ normalized.code = code;
1617
+ if (isLast !== undefined)
1618
+ normalized.is_last = isLast;
1619
+ return normalized;
1620
+ };
1621
+ return {
1622
+ schema,
1623
+ normalizeParams: normalize,
1624
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1625
+ execute: async (request, signal) => {
1626
+ const params = normalize(request.params);
1627
+ // ⛔ 单票模式上游**不翻页**(实测 page=2 / page_size=10 都照样给全部 107 期),所以不发分页参数,
1628
+ // 也不给 `pages = ceil(total ÷ size)`——那会把一份完整历史说成两页,翻页只会拿到重复的一整份。
1629
+ const search = params.code === undefined
1630
+ ? query({ is_last: 'true', page: params.page, page_size: params.size }, ['is_last', 'page', 'page_size'])
1631
+ : query({ stock_code: params.code }, ['stock_code']);
1632
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/holder/stock-holder-nums', search, signal));
1633
+ const item = rows.map(mapHolderNumberRow);
1634
+ assertFiltered(item, params.code === undefined ? undefined : { field: 'thscode', expected: String(params.code), label: 'code' });
1635
+ return {
1636
+ data: params.code === undefined ? pagedEnvelope(item, total, Number(params.page), Number(params.size)) : snapshotEnvelope(item),
1637
+ schema: output(holderNumberRow),
1638
+ };
1639
+ },
1640
+ };
1641
+ }
1642
+ /** `is_last` 在上游是字符串 'true'(实测 `false` 回 0 行、`zzz` 才 400);这里只放行真正的布尔 true。 */
1643
+ function booleanParam(value) {
1644
+ if (typeof value === 'boolean')
1645
+ return value;
1646
+ if (typeof value === 'string' && /^(true|false)$/i.test(value))
1647
+ return value.toLowerCase() === 'true';
1648
+ throw new FtshareError('invalid_parameter', 'parameter is_last must be a boolean (true / false)');
1649
+ }
1650
+ const indexWeightRow = {
1651
+ type: 'object',
1652
+ properties: {
1653
+ index_code: { type: 'string' },
1654
+ collect_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
1655
+ collect_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1656
+ component_code: { type: 'string' },
1657
+ component_name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
1658
+ weight_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1659
+ },
1660
+ additionalProperties: false,
1661
+ };
1662
+ function mapIndexWeightRow(row) {
1663
+ const { iso, ms } = normalizeDateCell(row.date);
1664
+ return {
1665
+ index_code: String(row.index_code ?? ''),
1666
+ collect_date: iso,
1667
+ collect_date_ms: ms,
1668
+ component_code: String(row.component_code ?? ''),
1669
+ component_name: textOrNull(row.component_name),
1670
+ weight_pct: numberOrNull(row.weight),
1671
+ };
1672
+ }
1673
+ const INDEX_WEIGHT_DESCRIPTION = '按**中证系列指数**取某一采集期的成份股权重,一行是「一个指数 × 一个采集期 × 一只成分股」。补的是**权重数值**这一维度:`index_constituents` 给的是成分名单、不带权重,也不按采集期。`index_code` 必填,本层接受裸 6 位与 `000300.SH` / `399001.SZ` 这类带后缀写法并**统一成裸 6 位再发**——⛔ 上游对带后缀的写法是**静默回 0 行**、不报错(实测 `000300.SH` 与 `000001.SH` 都回 `index_weights:[]` 且 total=0),空值才是响亮 400「index_code 不能为空」,而不认识的写法(`ZZZZ`)同样只回 0 行。⛔ 后缀与号段矛盾(`000001.SZ`)在参数层拒:折成裸码就是上证综指,等于本层替你把一只深市身份换成另一个指数。⛔ 这里的 `000001` 是**上证综合指数**(实测 total=2230 只成分,首行 600000 浦发银行),不是平安银行——裸 6 位在行情类能力里通常指个股,本能力整个命名空间都是指数。实测条数与指数名正好对得上:000300 → 300 行、000905 → 500 行、932000 → 2000 行;而**申万行业码 `801010` 回 0 行**(行业口径请用 `ftshare_sw_industry_overview` / `ftshare_sw_industry_metrics`)。`date` 可选,是**采集期**且必须精确命中:实测 2026-06-15 与 2026-09-30 各回该期 300 行,而 2026-10-01 / 2026-01-01 这种没有采集的日期 total=0(**不会**回退到"最近一期"),省略时取最新一期(实测 2026-10-16 打 000300 的最新期是 2026-09-30)。⛔ `weight_pct` 是**百分数原值**,文档写的"小数字符串(如 0.0609)、非百分比"与实测不符:000300 最新期 300 行逐行相加 = **100.002**,单只形如 0.453000 就是 0.453%——别再乘 100。`component_code` 是**裸 6 位、不带交易所后缀**,本层不替它猜市场(需要后缀请用行情类能力或 `ticker_search`)。上游每行还有 `url_hash`(该期 xlsx 的下载 hash),本层不透出。`size` 上限 100,实测超限**静默夹断**(传 101 → 回显 page_size=100 并只回 100 行):宽指数务必按 `ceil(total ÷ size)` 翻页取满,只读第一页会把 2000 只当成 100 只。防改版:逐行核 `index_code` 与请求一致,带 `date` 时再核采集期等于请求。';
1674
+ /**
1675
+ * 中证指数成分权重(`/index/index_weight`)。
1676
+ * ⛔ 第四套信封:行数组叫 `data.index_weights`,分页字段是 `total/page/page_size`(**没有 pages**)。
1677
+ */
1678
+ function createIndexWeightSource(client) {
1679
+ const capability = 'ftshare_index_weight';
1680
+ const allowed = ['index_code', 'date', 'page', 'size'];
1681
+ const schema = {
1682
+ capability,
1683
+ time_contract: getDataTimeContract(capability),
1684
+ name: 'get_index_weight_list',
1685
+ source: 'http:ftshare.index_weight_list',
1686
+ data_key: buildDataKey('ftshare', 'http', capability),
1687
+ source_label: 'ftshare',
1688
+ paginated: true,
1689
+ rowShape: { rowKey: 'item' },
1690
+ summary: '中证系列指数成分权重(按采集期,百分数)',
1691
+ description: INDEX_WEIGHT_DESCRIPTION,
1692
+ input_schema: {
1693
+ type: 'object',
1694
+ properties: {
1695
+ index_code: { type: 'string', description: '指数代码:裸 6 位或带后缀都收(000300 / 000300.SH / 000905 / 932000),本层统一成裸 6 位再发——上游对带后缀写法静默回 0 行' },
1696
+ date: { type: 'string', description: '权重采集期 YYYY-MM-DD,必须精确命中某一期;省略 = 最新一期' },
1697
+ page: { type: 'integer', minimum: 1 },
1698
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_100 },
1699
+ },
1700
+ required: ['index_code'], additionalProperties: false,
1701
+ },
1702
+ output_schema: output(indexWeightRow),
1703
+ };
1704
+ const normalize = (params) => {
1705
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_100);
1706
+ const indexCode = bareIndexCode(params.index_code);
1707
+ const normalized = { index_code: indexCode, page, size };
1708
+ if (params.date !== undefined)
1709
+ normalized.date = date(params.date, 'date');
1710
+ return normalized;
1711
+ };
1712
+ return {
1713
+ schema,
1714
+ normalizeParams: normalize,
1715
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1716
+ execute: async (request, signal) => {
1717
+ const params = normalize(request.params);
1718
+ const search = query({
1719
+ index_code: params.index_code,
1720
+ date: params.date === undefined ? undefined : compactDate(String(params.date)),
1721
+ page: params.page,
1722
+ page_size: params.size,
1723
+ }, ['index_code', 'date', 'page', 'page_size']);
1724
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/index/index_weight', search, signal));
1725
+ const item = rows.map(mapIndexWeightRow);
1726
+ assertFiltered(item, { field: 'index_code', expected: String(params.index_code), label: 'index_code' });
1727
+ if (params.date !== undefined)
1728
+ assertFiltered(item, { field: 'collect_date', expected: String(params.date), label: 'date' });
1729
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(indexWeightRow) };
1730
+ },
1731
+ };
1732
+ }
1733
+ /**
1734
+ * ⛔ 与 `parseSecurityCode` 不是一回事:那一颗是给**个股/ETF**消歧的(裸 000xxx 在指数与个股之间
1735
+ * 有歧义所以它拒),而这里的整个命名空间就是指数——`000001` 在上游是上证综指(实测 2230 只成分)。
1736
+ * 带后缀的写法上游**静默回 0 行**(实测 `000300.SH` 与 `000001.SH` 都是 `index_weights:[]` 且 total=0),
1737
+ * 所以本层把合法后缀折成裸码再发;但后缀与号段互相矛盾的写法(`000001.SZ` 折过去会变成上证综指)
1738
+ * 一律在参数层拒——宁可不发,也不悄悄把用户写的身份换成另一个指数。
1739
+ */
1740
+ function bareIndexCode(value, name = 'index_code') {
1741
+ const raw = typeof value === 'string' ? value.trim() : '';
1742
+ const match = /^(\d{6})(?:\.(SH|SZ|XSHG|XSHE))?$/i.exec(raw);
1743
+ if (match === null)
1744
+ throw new FtshareError('invalid_parameter', `parameter ${name} must be a six-digit index code, bare or with an exchange (for example 000300, 000905.SH, 399001.SZ, 932000)`);
1745
+ const digits = match[1];
1746
+ const suffix = (match[2] ?? '').toUpperCase();
1747
+ const market = suffix === 'XSHG' ? 'SH' : suffix === 'XSHE' ? 'SZ' : suffix;
1748
+ const isShenzhenIndex = digits.startsWith('399');
1749
+ if (market !== '' && market !== (isShenzhenIndex ? 'SZ' : 'SH')) {
1750
+ throw new FtshareError('invalid_parameter', `parameter ${name} ${JSON.stringify(raw)} conflicts: 399xxx 是深市指数、其余号段(000xxx / 93xxxx / 95xxxx)走沪市与中证口径;本层不替你换成另一个指数`);
1751
+ }
1752
+ return digits;
1753
+ }
1754
+ const cbKlineRow = {
1755
+ type: 'object',
1756
+ properties: {
1757
+ thscode: { type: 'string' },
1758
+ trade_date: { type: 'string' },
1759
+ trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1760
+ ts_millis: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1761
+ ts_millis_open: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1762
+ open_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1763
+ high_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1764
+ low_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1765
+ close_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1766
+ turnover_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1767
+ volume_tickets: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1768
+ },
1769
+ additionalProperties: false,
1770
+ };
1771
+ function mapCbKlineRow(row, thscode) {
1772
+ const ms = numberOrNull(row.ts_millis);
1773
+ return {
1774
+ thscode,
1775
+ trade_date: ms === null ? '' : shanghaiParts(ms).trade_date,
1776
+ trade_date_ms: ms,
1777
+ ts_millis: ms,
1778
+ ts_millis_open: numberOrNull(row.ts_millis_open),
1779
+ open_yuan: numberOrNull(row.open),
1780
+ high_yuan: numberOrNull(row.high),
1781
+ low_yuan: numberOrNull(row.low),
1782
+ close_yuan: numberOrNull(row.close),
1783
+ turnover_yuan: numberOrNull(row.turnover),
1784
+ volume_tickets: integerOrNull(row.volume),
1785
+ };
1786
+ }
1787
+ const CB_KLINE_DESCRIPTION = '按**单只可转债**与日期区间取历史 K 线(日 / 周 / 月 / 年),一行一根 K,按时间升序。补的是硬空白:本仓此前只有 `eastmoney_convertible_bond_list` 的**发行条款与要素**,转债的**价格序列此前为零**。`code` 必填(6 位,裸码或带 `.SH` / `.SZ` 都收;实测裸码与带正确后缀**逐字节相同**,113042 = 113042.SH、127045 = 127045.SZ),⛔ 非转债代码与"后缀和代码不配对"都由上游点名拒(实测 600519.SH → 400「symbols [600519.XSHG] are not convertible bonds」;113042.SZ → 400「symbols [113042.XSHE] are not convertible bonds」),所以身份核对是上游做的、本层**不按代码首位猜交易所**(沪市转债是 11xxxx,2007-2009 还有 126 段老沪债,套个股那套自然市场规则会判错)。`start_date` / `end_date` 必填,本层按北京时间 +08:00 转成上游的 `since_ts_millis`(当日 00:00)与 `until_ts_millis`(当日 23:59:59)。⛔ **跨度上限是 12 个自然月,而且边界卡在"整整 12 个月"之外**:实测 2025-10-16..2026-10-15(364 天)正常回 242 行,而 2025-10-15..2026-10-15(365 天)被 400 拒「日 K 及以上周期查询范围不能超过 12 个自然月」——本层**不把它换算成天数闸门**(换算规则没核出,写成常量就是造一个会腐烂的闸门),超限时把上游这句话原样交回。`period` ∈ day / week / month / year(实测上游大小写不敏感,`DAY` 也收);⛔ `adjust` ∈ none / forward / backward,而这一列上游**大小写敏感**(文档写"大小写不敏感",实测发 `NONE` 与 `Forward` 都被 400 拒),所以本层统一转小写再发、写法不用操心。⛔ 本层**不宣称 forward / backward 就是前复权 / 后复权**:实测 113042.SH 在 2026-08-01..08-14 三种写法返回逐字节相同(该券该窗口没有复权事件),要原始行情核对请显式给 `none`。时间与单位:`ts_millis` 是这根 K 的**结束**时刻(实测日档落在北京时间 15:00)、`ts_millis_open` 是开始时刻(09:30),`trade_date` / `trade_date_ms` 由本层按 +08:00 从 `ts_millis` 推出;四档价格是元(上游字符串 4 位小数,本层转数值)、`turnover_yuan` 是元;⛔ `volume_tickets` 的单位是**张**(1 张面值 100 元)而不是手:实测同行 `turnover ÷ volume` 落在 [low, high] 区间内(113042.SH 2026-09-01..10-15 的 27 行违例 0,比值 117.824),按"手"乘 1000 会把它推出价格区间三个数量级。实测这 27 行 OHLC 自洽(high ≥ max(open,close) ≥ min(open,close) ≥ low)违例 0,且**八列都没有 null**。`limit` 可选,保留**最新若干根**(实测 limit=3 恰等于不传 limit 那一次的末三行);⛔ `limit=0` 回 0 行且不报错,本层要求正整数。周 / 月 / 年是**按请求区间内的日 K 聚合**,窗口没盖满一个周期时那根不是完整周期(实测 year 档在 2026-01-01..08-14 只给 1 根)。`interval_value` 实测对日档不改变结果(给 2 仍是每个交易日一根),本层不提供。当日日 K 收盘后即有(实测 2026-10-15 打当天回 1 行)。⛔ 响应 `data` **直接是数组**、没有分页信封,本层合成的 pagination 就是"一次取满"。各券历史起点以其实际上市与上游入库为准,本层未逐券核。';
1788
+ /**
1789
+ * 可转债历史 K 线(`/convertible-bond-candlesticks`)。
1790
+ * ⛔ 复权口径只透出不解释:实测 none / forward / backward 在无复权事件的券上逐字节相同,
1791
+ * 把它们写成"前复权 / 后复权"就是没有依据的断言(本族批次 B 那一课)。
1792
+ */
1793
+ function createCbKlineSource(client) {
1794
+ const capability = 'ftshare_convertible_bond_kline';
1795
+ const allowed = ['code', 'period', 'adjust', 'start_date', 'end_date', 'limit'];
1796
+ const CB_PERIODS = ['day', 'week', 'month', 'year'];
1797
+ const CB_ADJUST = ['none', 'forward', 'backward'];
1798
+ const schema = {
1799
+ capability,
1800
+ time_contract: getDataTimeContract(capability),
1801
+ name: 'get_convertible_bond_candlesticks',
1802
+ source: 'http:ftshare.convertible_bond_candlesticks',
1803
+ data_key: buildDataKey('ftshare', 'http', capability),
1804
+ source_label: 'ftshare',
1805
+ paginated: false,
1806
+ rowShape: { rowKey: 'item' },
1807
+ summary: '可转债历史 K 线(日/周/月/年,单只,元)',
1808
+ description: CB_KLINE_DESCRIPTION,
1809
+ input_schema: {
1810
+ type: 'object',
1811
+ properties: {
1812
+ code: { type: 'string', description: '单只可转债,6 位可带 .SH / .SZ(113042.SH / 127045.SZ)' },
1813
+ period: { type: 'string', enum: [...CB_PERIODS], description: 'K 线周期;缺省 day' },
1814
+ adjust: { type: 'string', enum: [...CB_ADJUST], description: '复权写法(大小写都收,本层转小写发给上游);不宣称它与前/后复权的对应关系,原始行情核对请用 none' },
1815
+ start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD(北京时间)' },
1816
+ end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD;与起始相距不超过 12 个自然月' },
1817
+ limit: { type: 'integer', minimum: 1, description: '保留最新 N 根;省略 = 窗口内全部' },
1818
+ },
1819
+ required: ['code', 'start_date', 'end_date'], additionalProperties: false,
1820
+ },
1821
+ output_schema: output(cbKlineRow),
1822
+ };
1823
+ const normalize = (params) => {
1824
+ for (const key of Object.keys(params))
1825
+ if (!allowed.includes(key))
1826
+ throw new FtshareError('invalid_parameter', `unsupported parameter: ${key}`);
1827
+ const normalized = { code: convertibleBondCode(params.code) };
1828
+ if (params.period !== undefined) {
1829
+ const period = String(params.period).toLowerCase();
1830
+ if (!CB_PERIODS.includes(period))
1831
+ throw new FtshareError('invalid_parameter', `parameter period must be one of ${CB_PERIODS.join(' / ')}`);
1832
+ normalized.period = period;
1833
+ }
1834
+ if (params.adjust !== undefined) {
1835
+ const adjust = String(params.adjust).toLowerCase();
1836
+ if (!CB_ADJUST.includes(adjust))
1837
+ throw new FtshareError('invalid_parameter', `parameter adjust must be one of ${CB_ADJUST.join(' / ')}(上游这一列大小写敏感:实测 NONE / Forward 都 400)`);
1838
+ normalized.adjust = adjust;
1839
+ }
1840
+ const start_date = date(params.start_date, 'start_date');
1841
+ const end_date = date(params.end_date, 'end_date');
1842
+ if (start_date > end_date)
1843
+ throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date(实测上游对倒转区间也 400「since_ts_millis 不能晚于 until_ts_millis」)');
1844
+ normalized.start_date = start_date;
1845
+ normalized.end_date = end_date;
1846
+ if (params.limit !== undefined) {
1847
+ const limit = Number(params.limit);
1848
+ // 实测 limit=0 不报错、只回 0 行;那会被读成"这只券没有行情"。
1849
+ if (!Number.isSafeInteger(limit) || limit < 1)
1850
+ throw new FtshareError('invalid_parameter', 'parameter limit must be an integer >= 1(实测 0 静默回 0 行、负数被上游 400 拒)');
1851
+ normalized.limit = limit;
1852
+ }
1853
+ return normalized;
1854
+ };
1855
+ return {
1856
+ schema,
1857
+ normalizeParams: normalize,
1858
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1859
+ execute: async (request, signal) => {
1860
+ const params = normalize(request.params);
1861
+ const search = query({
1862
+ symbol: params.code,
1863
+ interval_unit: params.period ?? 'day',
1864
+ adjust_kind: params.adjust,
1865
+ since_ts_millis: Date.parse(`${params.start_date}T00:00:00+08:00`),
1866
+ until_ts_millis: Date.parse(`${params.end_date}T23:59:59+08:00`),
1867
+ limit: params.limit,
1868
+ }, ['symbol', 'interval_unit', 'adjust_kind', 'since_ts_millis', 'until_ts_millis', 'limit']);
1869
+ const envelope = await client.getJson('/api/v1/market/data/convertible-bond-candlesticks', search, signal);
1870
+ if (!Array.isArray(envelope.data))
1871
+ throw new FtshareError('invalid_response', 'FTShare CB kline envelope is not an array — 这一族没有分页信封,读不到数组就无法判断是不是空结果');
1872
+ const rows = envelope.data.filter(isRecord);
1873
+ // ⛔ 响应行内没有身份列(八列都是行情),所以"上游返回的到底是这只券"无法在返回后核对;
1874
+ // 本层能核的最强事实是时间:回来的每根 K 都必须落在请求的区间内。
1875
+ assertInRange(rows, String(params.start_date), String(params.end_date), (row) => {
1876
+ const ms = numberOrNull(row.ts_millis);
1877
+ return ms === null ? null : shanghaiParts(ms).trade_date;
1878
+ });
1879
+ const item = rows.map((row) => mapCbKlineRow(row, String(params.code)));
1880
+ return { data: snapshotEnvelope(item), schema: output(cbKlineRow) };
1881
+ },
1882
+ };
1883
+ }
1884
+ /**
1885
+ * 可转债代码。⛔ **不按代码首位猜交易所**(`parseSecurityCode` 那套自然市场规则在这儿是错的:
1886
+ * 它会把 11xxxx 判成 SZ,而沪市转债正是 11xxxx,还有 2007-2009 的 126 段老沪债)。
1887
+ * 实测上游自己会把写法折成内部 symbol 再判类别:`113042.SZ` 与 `127045.SH` 这种**后缀与代码不配对**
1888
+ * 的输入是响亮 400 点名(「symbols [113042.XSHE] are not convertible bonds」),不会静默解析成别的证券;
1889
+ * 裸 6 位与带正确后缀的写法实测**逐字节相同**(113042 = 113042.SH、127045 = 127045.SZ)。
1890
+ * 所以本层只做写法归一,身份与类别判断交给上游那句 400。
1891
+ */
1892
+ function convertibleBondCode(value, name = 'code') {
1893
+ const raw = typeof value === 'string' ? value.trim() : '';
1894
+ const match = /^(\d{6})(?:\.(SH|SZ|BJ|XSHG|XSHE))?$/i.exec(raw);
1895
+ if (match === null)
1896
+ throw new FtshareError('invalid_parameter', `parameter ${name} must be a six-digit convertible bond code, bare or with an exchange (113042.SH / 127045.SZ / 113042)`);
1897
+ const digits = match[1];
1898
+ const suffix = (match[2] ?? '').toUpperCase();
1899
+ const market = suffix === 'XSHG' ? 'SH' : suffix === 'XSHE' ? 'SZ' : suffix;
1900
+ return market === '' ? digits : `${digits}.${market}`;
1901
+ }
1902
+ /**
1903
+ * 批次 E(2026-10-16 08:25–08:29 与 08:44 两轮,109 份落盘报文 + 18 份官方文档页):LPR、中债收益率曲线、SHIBOR、HIBOR、财经日历。
1904
+ * 五颗都是**整块空白**:本仓此前没有任何收益率曲线、没有任何拆借利率、没有 LPR 的免费通道
1905
+ * (只有 Wind EDB 那一条按次计费的),也没有发布时刻表一类的日历。
1906
+ * ⛔ 同一族里暂缓两颗,理由都写在报文里而不是文档里:`libor_daily` 的行只有
1907
+ * `currency` / `tenor` / `rate`、**没有基准名列**,LIBOR 停发之后 USD 隔夜到底是 SOFR 还是别的读不出来;
1908
+ * `us_economic` 的读数逐 `type` 单位上游不给、月份又是中文标签,锚不住输出契约。
1909
+ * `baidu_financial_calendar` 实测是 A 股事件提醒(股东大会 / 分红 / 停复牌),**不是见闻那棵的第二家**。
1910
+ */
1911
+ const LPR_DESCRIPTION = '一次性返回 **LPR(贷款市场报价利率)全历史**,一行一个日期,⛔ **无参数、无分页**:实测多发 `start_date` / `page` / `page_size` 上游不报错也不过滤(回的仍是同一份 2661 行),要区间请在 Dataset 里按 `trade_date` 筛——本层因此不给这些座位。返回**按日期降序**(实测首行 2026-09-20、末行 2013-10-25)。⛔ **行粒度前后不一致**:2013-10-25..2020-12-20 是**逐自然日填充**(含周末;实测 2016-06 整月每天都是 4.3000),2020-12-21 起只在每月报价日给一行(实测日位集中在 20 / 21 / 22 / 24,自 2020-12-21 含当日起共 70 行)。⛔ 所以**别把行数当报价次数**:全程 2661 行里 `lpr_1y_pct` 只有 **28 个不同取值**、28 次变化;要"报价历史"请按值变化分组或每月取一条。`lpr_5y_pct` 在 2019-08-20 之前**整列为 null**(实测 2125 / 2661 行为 null,首个非空就是 2019-08-20 的 4.8500),⛔ null 不是 0。单位是**百分数原值**(3.0000 读作 3.00%)——依据是上游文档口径与量级,不是本层算出来的;上游以 4 位小数字符串给出,本层转数值。`trade_date_ms` 是北京零点。这一颗是给"贷款利率基准 / 房贷定价"用的免费第二家:此前本仓只能走 Wind EDB(按次计费)。';
1912
+ const lprRow = {
1913
+ type: 'object',
1914
+ properties: {
1915
+ trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
1916
+ trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1917
+ lpr_1y_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1918
+ lpr_5y_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1919
+ },
1920
+ additionalProperties: false,
1921
+ };
1922
+ function createLprSource(client) {
1923
+ const capability = 'ftshare_lpr';
1924
+ const schema = {
1925
+ capability,
1926
+ name: 'get_lpr_monthly',
1927
+ source: 'http:ftshare.lpr_monthly',
1928
+ data_key: buildDataKey('ftshare', 'http', capability),
1929
+ source_label: 'ftshare',
1930
+ paginated: false,
1931
+ rowShape: { rowKey: 'item' },
1932
+ summary: 'LPR 贷款市场报价利率全历史(百分数,无参数)',
1933
+ description: LPR_DESCRIPTION,
1934
+ input_schema: { type: 'object', properties: {}, required: [], additionalProperties: false },
1935
+ output_schema: output(lprRow),
1936
+ };
1937
+ const normalize = (params) => {
1938
+ // 实测上游收到任何参数都**静默忽略**,那一句"我按你的区间筛过了"会是假的。
1939
+ const keys = Object.keys(params);
1940
+ if (keys.length > 0)
1941
+ throw new FtshareError('invalid_parameter', `unsupported parameter: ${keys[0]} — 这颗上游收到任何参数都静默忽略(实测多发 start_date / page / page_size 仍回同一份 2661 行),要区间请在 Dataset 里按 trade_date 筛`);
1942
+ return {};
1943
+ };
1944
+ return {
1945
+ schema,
1946
+ normalizeParams: normalize,
1947
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
1948
+ execute: async (request, signal) => {
1949
+ normalize(request.params);
1950
+ const rows = readPaged(await client.getJson('/api/v1/market/data/economic/china-lpr', query({}, []), signal)).rows;
1951
+ const item = rows.map((row) => {
1952
+ const { iso, ms } = normalizeDateCell(row.date);
1953
+ return { trade_date: iso, trade_date_ms: ms, lpr_1y_pct: numberOrNull(row.lpr_1y), lpr_5y_pct: numberOrNull(row.lpr_5y) };
1954
+ });
1955
+ return { data: snapshotEnvelope(item), schema: output(lprRow) };
1956
+ },
1957
+ };
1958
+ }
1959
+ const CHINABOND_CURVES = ['中债国债收益率曲线', '中债中短期票据收益率曲线(AAA)', '中债商业银行普通债收益率曲线(AAA)'];
1960
+ const CHINABOND_DESCRIPTION = '按日期区间获取**中债收益率曲线**日度值,一行是「一条曲线 × 一个交易日」。补的是债券定价基准:本仓此前**没有任何收益率曲线**。⛔ 实测只有**三条曲线**(`中债国债收益率曲线` / `中债中短期票据收益率曲线(AAA)` / `中债商业银行普通债收益率曲线(AAA)`,在 2002 起的窗口里逐页取满也只这三条),`curve_name` 是**精确匹配**、认不出的名字只回 0 行不回错(实测 `不存在` 空),所以本层把这三条做成枚举:拼错在参数层就点名拒,而不是让你拿回一份空 Dataset;空白写法上游 400「curve_name must not be blank」。⛔ **各曲线起点不同**(实测同一个 2002-01-01..2010-12-31 窗口内):国债 2006-03-01、中短期票据 2006-12-25、商业银行普通债 2009-12-24;早于各自起点的日期是合法空、不是失败,也别按"曲线名有缺失"去补。期限列 `y_3m` … `y_30y` 共八档,单位**百分数原值**(实测 2026-10-15 国债 10y = 1.6601、30y = 2.07);⛔ `y_30y` 对**中短期票据那条曲线整列为 null**(实测 2015-2016 抽 500 行非空 0 / 500,另两条 500 / 500 都有值)——那条曲线本来没有 30 年期限点,不是缺数据。`start_date` / `end_date` 必填(实测缺 `start_date` 400「缺少必填参数:开始日期」、倒转 400「start_date must not be after end_date」,本层也先拦),上游日期要 YYYYMMDD(带横杠 400「start_date must be YYYYMMDD」)。`page_size` 上限实测 **1000**(1001 是**响亮 400**「page_size must be between 1 and 1000」,不是静默截断)。防改版:带 `curve_name` 逐行核曲线名,并逐行核返回日期落在请求区间内。';
1961
+ const chinabondYieldRow = {
1962
+ type: 'object',
1963
+ properties: {
1964
+ curve_name: { type: 'string' },
1965
+ trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
1966
+ trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
1967
+ y_3m_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1968
+ y_6m_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1969
+ y_1y_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1970
+ y_3y_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1971
+ y_5y_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1972
+ y_7y_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1973
+ y_10y_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1974
+ y_30y_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
1975
+ },
1976
+ additionalProperties: false,
1977
+ };
1978
+ function createChinabondYieldSource(client) {
1979
+ const capability = 'ftshare_chinabond_yield';
1980
+ const allowed = ['start_date', 'end_date', 'curve_name', 'page', 'size'];
1981
+ const schema = {
1982
+ capability,
1983
+ time_contract: getDataTimeContract(capability),
1984
+ name: 'get_chinabond_yield_daily',
1985
+ source: 'http:ftshare.chinabond_yield_daily',
1986
+ data_key: buildDataKey('ftshare', 'http', capability),
1987
+ source_label: 'ftshare',
1988
+ paginated: true,
1989
+ rowShape: { rowKey: 'item' },
1990
+ summary: '中债收益率曲线日度(国债/AAA 票据,八档期限)',
1991
+ description: CHINABOND_DESCRIPTION,
1992
+ input_schema: {
1993
+ type: 'object',
1994
+ properties: {
1995
+ start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD' },
1996
+ end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD' },
1997
+ curve_name: { type: 'string', enum: [...CHINABOND_CURVES], description: '曲线名,精确匹配;实测只有这三条,省略 = 三条一起给' },
1998
+ page: { type: 'integer', minimum: 1 },
1999
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE },
2000
+ },
2001
+ required: ['start_date', 'end_date'], additionalProperties: false,
2002
+ },
2003
+ output_schema: output(chinabondYieldRow),
2004
+ };
2005
+ const normalize = (params) => {
2006
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE);
2007
+ const start_date = date(params.start_date, 'start_date');
2008
+ const end_date = date(params.end_date, 'end_date');
2009
+ if (start_date > end_date)
2010
+ throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date(实测上游同样 400「start_date must not be after end_date」)');
2011
+ const normalized = { start_date, end_date, page, size };
2012
+ if (params.curve_name !== undefined) {
2013
+ const curve = String(params.curve_name);
2014
+ if (!CHINABOND_CURVES.includes(curve)) {
2015
+ throw new FtshareError('invalid_parameter', `parameter curve_name must be exactly one of ${CHINABOND_CURVES.join(' / ')} — 上游是精确匹配、认不出的写法只回 0 行不报错(实测)`);
2016
+ }
2017
+ normalized.curve_name = curve;
2018
+ }
2019
+ return normalized;
2020
+ };
2021
+ return {
2022
+ schema,
2023
+ normalizeParams: normalize,
2024
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
2025
+ execute: async (request, signal) => {
2026
+ const params = normalize(request.params);
2027
+ const search = query({
2028
+ start_date: compactDate(String(params.start_date)), end_date: compactDate(String(params.end_date)),
2029
+ curve_name: params.curve_name, page: params.page, page_size: params.size,
2030
+ }, ['start_date', 'end_date', 'curve_name', 'page', 'page_size']);
2031
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/chinabond-yield-daily', search, signal));
2032
+ const item = rows.map((row) => {
2033
+ const { iso, ms } = normalizeDateCell(row.trade_date);
2034
+ return {
2035
+ curve_name: String(row.curve_name ?? ''), trade_date: iso, trade_date_ms: ms,
2036
+ y_3m_pct: numberOrNull(row.y_3m), y_6m_pct: numberOrNull(row.y_6m), y_1y_pct: numberOrNull(row.y_1y),
2037
+ y_3y_pct: numberOrNull(row.y_3y), y_5y_pct: numberOrNull(row.y_5y), y_7y_pct: numberOrNull(row.y_7y),
2038
+ y_10y_pct: numberOrNull(row.y_10y), y_30y_pct: numberOrNull(row.y_30y),
2039
+ };
2040
+ });
2041
+ assertFiltered(item, params.curve_name === undefined ? undefined : { field: 'curve_name', expected: String(params.curve_name), label: 'curve_name' });
2042
+ assertInRange(item, String(params.start_date), String(params.end_date), (row) => row.trade_date);
2043
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(chinabondYieldRow) };
2044
+ },
2045
+ };
2046
+ }
2047
+ /**
2048
+ * SHIBOR / HIBOR 同形(`records` 信封 + 一行一个交易日的多期限宽表),共用一颗工厂:
2049
+ * ⛔ 两者的期限档**不一样**(SHIBOR 八档含 `y1`、HIBOR 十五档到 `m12`),所以 `tenors` 必须由调用方给、
2050
+ * 不能猜——列名与实测报文逐字对齐,多给一档就会把 null 编成"这档存在"。
2051
+ */
2052
+ const RATE_TENOR_ROW = (tenors) => ({
2053
+ type: 'object',
2054
+ properties: {
2055
+ trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
2056
+ trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
2057
+ ...Object.fromEntries(tenors.map((tenor) => [`${tenor}_pct`, { oneOf: [{ type: 'number' }, { type: 'null' }] }])),
2058
+ },
2059
+ additionalProperties: false,
2060
+ });
2061
+ function createRateSeriesSource(options) {
2062
+ const row = RATE_TENOR_ROW(options.tenors);
2063
+ const allowed = ['start_date', 'end_date', 'page', 'size'];
2064
+ const schema = {
2065
+ capability: options.capability,
2066
+ time_contract: getDataTimeContract(options.capability),
2067
+ name: options.name,
2068
+ source: `http:ftshare.${options.upstreamApi}`,
2069
+ data_key: buildDataKey('ftshare', 'http', options.capability),
2070
+ source_label: 'ftshare',
2071
+ paginated: true,
2072
+ rowShape: { rowKey: 'item' },
2073
+ summary: options.summary,
2074
+ description: options.description,
2075
+ input_schema: {
2076
+ type: 'object',
2077
+ properties: {
2078
+ start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD' },
2079
+ end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD' },
2080
+ page: { type: 'integer', minimum: 1 },
2081
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE },
2082
+ },
2083
+ required: ['start_date', 'end_date'], additionalProperties: false,
2084
+ },
2085
+ output_schema: output(row),
2086
+ };
2087
+ const normalize = (params) => {
2088
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE);
2089
+ const start_date = date(params.start_date, 'start_date');
2090
+ const end_date = date(params.end_date, 'end_date');
2091
+ if (start_date > end_date)
2092
+ throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
2093
+ return { start_date, end_date, page, size };
2094
+ };
2095
+ return {
2096
+ schema,
2097
+ normalizeParams: normalize,
2098
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
2099
+ execute: async (request, signal) => {
2100
+ const params = normalize(request.params);
2101
+ const search = query({
2102
+ start_date: compactDate(String(params.start_date)), end_date: compactDate(String(params.end_date)),
2103
+ page: params.page, page_size: params.size,
2104
+ }, ['start_date', 'end_date', 'page', 'page_size']);
2105
+ const { rows, total } = readPaged(await options.client.getJson(options.path, search, signal));
2106
+ const item = rows.map((entry) => {
2107
+ const { iso, ms } = normalizeDateCell(entry.trade_date);
2108
+ const row2 = { trade_date: iso, trade_date_ms: ms };
2109
+ for (const tenor of options.tenors)
2110
+ row2[`${tenor}_pct`] = numberOrNull(entry[tenor]);
2111
+ return row2;
2112
+ });
2113
+ assertInRange(item, String(params.start_date), String(params.end_date), (entry) => entry.trade_date);
2114
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(row) };
2115
+ },
2116
+ };
2117
+ }
2118
+ const SHIBOR_DESCRIPTION = '按日期区间获取 **SHIBOR(上海银行间同业拆放利率)** 日度值,一行一个交易日、八个期限一次给全(`on_rate_pct` 隔夜,`w1` / `w2` 一至两周,`m1` / `m3` / `m6` / `m9` 一至九个月,`y1_pct` 一年)。补的是货币市场基准:此前本仓没有任何拆借利率,要利率只能走 Wind EDB(按次计费)。⛔ 单位是**百分数原值**(实测 2026-10-15 隔夜 1.266 读作 1.266%)——依据只有量级(隔夜拆借不会是 1.266 个基点、也不会是 126.6%),**上游文档没有写这一列的单位**。历史起点实测在 **2006-10-08**(2006 全年给 63 行、首行就是它;2006-01-01..06-30 与 07-01..09-30、1999 / 2004 全年都是 0 行),早于起点是合法空,不要换年份重试。⛔ **周末、节假日与未发布日没有行**,上游也不插值、不补算、不前值填充——序列天然不连续,做时序前先声明对齐规则。列按可空声明:上游文档说某期限可能"源数据未发布",而实测 2020-01-02 起 1000 行里一个 null 都没有;⛔ 真要出现缺失,本层也**不补 0**。`start_date` / `end_date` 必填(实测缺参数 400、倒转 400、带横杠 400),`page_size` 上限 1000(1001 是响亮 400)。防改版:逐行核返回日期落在请求区间内。';
2119
+ const HIBOR_DESCRIPTION = '按日期区间获取 **HIBOR(香港银行间同业拆放利率)** 日度值,一行一个交易日,十五个期限档(隔夜、一至两周、一至十二个月)。补的是港币资金面:此前本仓没有任何港币利率。⛔ 单位是**百分数原值**(实测 2026-10-08 隔夜 2.16821 读作 2.16821%)。⛔ **十五档里有七档实测整列为 null**:`m4` / `m5` / `m7` / `m8` / `m9` / `m10` / `m11` 在 2026-10-01..15 的 6 行里 6 / 6 全为 null,只有隔夜、一至两周、一至三月、六月、十二月有值——那是 HIBOR 实际公布的档位就这些,⛔ 不是缺数据,别补值也别把 null 读成 0。历史深度实测至少到 2010 年(2010 全年 249 行、首行 2010-01-04;2000-01-01..2001-12-31 两年 0 行),起点是滚动还是固定未核出。香港假日与未发布日没有行(上游声明不插值、不补算、不前值填充)。`start_date` / `end_date` 必填,`page_size` 上限 1000(实测 1001 → 响亮 400)。防改版:逐行核返回日期落在请求区间内。';
2120
+ const FINANCIAL_CALENDAR_DESCRIPTION = '按**最多 3 天的窗口**获取全球财经日历(华尔街见闻口径),一行一个事件。这块本仓**整块零覆盖**:此前既没有宏观数据发布时刻表,也没有"今天几点出 CPI"。⛔ **窗口上限是硬约束**:实测 `end_date` 比 `start_date` **晚四天及以上**才 400「时间范围不能超过3天」(2026-10-15..10-19 与 ..10-20 被拒,..10-18 那四天含首尾正常回 114 行),本层在参数层先拦;要更长区间请分段请求,本层**不替你循环取**——那会把一次取数变成 N 次出网却假装是一份 Dataset。⛔ **覆盖是滚动窗口**:实测 2026-08-03 / 09-01 / 09-15 / 10-05 / 10-12 / 10-19 / 10-26 各日都有事件(40 / 59 / 45 / 37 / 21 / 25 / 10 行),而 **2026-11-02 / 11-16 / 12-01 与 2026-01-05 都是 `records` 空数组 + `total=0`**,2020 / 2023 / 2024 / 2025 里的整天更是直接回 `data:null`——大约"过去 2.5 个月到未来 2 周",边界随日期前移;两种空都是合法空、不是失败,不要换日期重试。列口径:`event_time_ms` 与 `event_time_beijing` 是同一时刻的两种写法(上游 `event_time` 是**秒级** Unix **字符串**,实测 1791993600 → 北京 2026-10-15 00:00;⛔ 直接当毫秒会差三个数量级),`tab_type` 实测只有 `general` 与 `earnings` 两值,`region` 是中文地区名(实测单次 113 行里 14 个:中国 / 美国 / 欧元区 / 日本 / 香港 …;跨多次请求累计见过 28 个),`importance` 上游给**字符串数字** "1".."4",另有空字符串(实测 113 行里 2 行)——本层把空归一成 null,⛔ 不是 0。⛔ `actual_value` / `forecast_value` / `previous_value` **保持文本原样**:这些读数的单位随事件本身而变(指数、百分比、套…上游不给列级单位),本层不转数值、也不换算;缺失只有一种写法——**空字符串**(实测 19 份成功响应、1517 行里这三列加 `link` / `importance` 的空值全是 `""`,`null` 型字段 0 个),本层把它归一成 null。⛔ 财报类事件常给 `actual_value = "0"` 与 `forecast_value = "0"` 的占位(实测首行 赣锋锂业 earnings),那个 0 **不是"实际值为零"**。`link` 是原文链接(实测 113 行里 39 行为空)。`page_size` 真实上限实测 **500**(文档写 200;传 1000 → 回显 pageSize=500),实测最密的三天窗(2026-09-28..09-30)132 个事件、一页就给完;⛔ `page_size=0` 静默只回 1 行(实测),本层要求 ≥1。防改版:逐行核 `stat_date` 落在请求窗口内。';
2121
+ const financialCalendarRow = {
2122
+ type: 'object',
2123
+ properties: {
2124
+ stat_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
2125
+ stat_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
2126
+ event_time_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
2127
+ event_time_beijing: { oneOf: [{ type: 'string' }, { type: 'null' }] },
2128
+ tab_type: { oneOf: [{ type: 'string' }, { type: 'null' }] },
2129
+ region: { oneOf: [{ type: 'string' }, { type: 'null' }] },
2130
+ event: { type: 'string' },
2131
+ importance: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
2132
+ actual_value: { oneOf: [{ type: 'string' }, { type: 'null' }] },
2133
+ forecast_value: { oneOf: [{ type: 'string' }, { type: 'null' }] },
2134
+ previous_value: { oneOf: [{ type: 'string' }, { type: 'null' }] },
2135
+ link: { oneOf: [{ type: 'string' }, { type: 'null' }] },
2136
+ },
2137
+ additionalProperties: false,
2138
+ };
2139
+ function createFinancialCalendarSource(client) {
2140
+ const capability = 'ftshare_financial_calendar';
2141
+ const allowed = ['start_date', 'end_date', 'page', 'size'];
2142
+ const schema = {
2143
+ capability,
2144
+ time_contract: getDataTimeContract(capability),
2145
+ name: 'get_wallstreetcn_financial_calendar',
2146
+ source: 'http:ftshare.wallstreetcn_financial_calendar',
2147
+ data_key: buildDataKey('ftshare', 'http', capability),
2148
+ source_label: 'ftshare',
2149
+ paginated: true,
2150
+ rowShape: { rowKey: 'item' },
2151
+ summary: '全球财经日历(≤3 天窗口,含发布时刻与前值)',
2152
+ description: FINANCIAL_CALENDAR_DESCRIPTION,
2153
+ input_schema: {
2154
+ type: 'object',
2155
+ properties: {
2156
+ start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD' },
2157
+ end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD;与起始相差不超过 3 天(上游硬约束)' },
2158
+ page: { type: 'integer', minimum: 1 },
2159
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_500 },
2160
+ },
2161
+ required: ['start_date', 'end_date'], additionalProperties: false,
2162
+ },
2163
+ output_schema: output(financialCalendarRow),
2164
+ };
2165
+ const normalize = (params) => {
2166
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_500);
2167
+ const start_date = date(params.start_date, 'start_date');
2168
+ const end_date = date(params.end_date, 'end_date');
2169
+ if (start_date > end_date)
2170
+ throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date(实测上游同样 400「结束时间不能早于开始时间」)');
2171
+ const spanDays = Math.round((Date.parse(`${end_date}T00:00:00Z`) - Date.parse(`${start_date}T00:00:00Z`)) / 86400_000);
2172
+ // ⛔ 这是**固定的上游契约**(不是会往前滚的观测窗口),所以写在参数层不会腐烂:
2173
+ // 实测 3 天差通过、4 天差 400「时间范围不能超过3天」。
2174
+ if (spanDays > 3)
2175
+ throw new FtshareError('invalid_parameter', `parameter start_date..end_date spans ${spanDays} days — 上游单次窗上限是 3 天差(实测 400「时间范围不能超过3天」),请分段请求`);
2176
+ return { start_date, end_date, page, size };
2177
+ };
2178
+ return {
2179
+ schema,
2180
+ normalizeParams: normalize,
2181
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
2182
+ execute: async (request, signal) => {
2183
+ const params = normalize(request.params);
2184
+ const search = query({
2185
+ start_date: compactDate(String(params.start_date)), end_date: compactDate(String(params.end_date)),
2186
+ page: params.page, page_size: params.size,
2187
+ }, ['start_date', 'end_date', 'page', 'page_size']);
2188
+ const { rows, total } = readPaged(await client.getJson('/api/v1/market/data/finance/financial-calendar/wallstreetcn', search, signal));
2189
+ const item = rows.map((row) => {
2190
+ const { iso, ms } = normalizeDateCell(row.stat_date);
2191
+ // 上游 event_time 是**秒**级字符串:÷ 不换算就落进时间轴会差三个数量级(本族 ts_nanos 那一课)。
2192
+ const seconds = numberOrNull(row.event_time);
2193
+ const millis = seconds === null ? null : Math.round(seconds * 1000);
2194
+ const parts = millis === null ? null : shanghaiParts(millis);
2195
+ return {
2196
+ stat_date: iso, stat_date_ms: ms,
2197
+ event_time_ms: millis,
2198
+ event_time_beijing: parts === null ? null : `${parts.trade_date} ${parts.slice_time}`,
2199
+ tab_type: textOrNull(row.tab_type),
2200
+ region: textOrNull(row.region),
2201
+ event: String(row.event ?? ''),
2202
+ importance: integerOrNull(row.importance),
2203
+ actual_value: textOrNull(row.actual_value),
2204
+ forecast_value: textOrNull(row.forecast_value),
2205
+ previous_value: textOrNull(row.previous_value),
2206
+ link: textOrNull(row.link),
2207
+ };
2208
+ });
2209
+ assertInRange(item, String(params.start_date), String(params.end_date), (row) => row.stat_date);
2210
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(financialCalendarRow) };
2211
+ },
2212
+ };
2213
+ }
2214
+ export function createFtshareSources(resolveApiKey) {
2215
+ const client = createFtshareClient(resolveApiKey);
2216
+ return [
2217
+ createFlowSource({
2218
+ capability: 'ftshare_stock_daily_flow', name: 'get_ths_stock_daily_flow', upstreamApi: 'ths_stock_daily_flow',
2219
+ path: FLOW_PATHS.stock, row: dailyFlowRow, client,
2220
+ summary: '同花顺个股日度资金流(近 45 交易日,亿元)',
2221
+ description: DAILY_FLOW_DESCRIPTION,
2222
+ allowed: ['start_date', 'end_date', 'code', 'page', 'size'],
2223
+ inputSchema: {
2224
+ type: 'object',
2225
+ properties: {
2226
+ ...dailyFlowInputSchema.properties,
2227
+ code: { type: 'string', description: '单只过滤,必须带市场后缀(如 600519.SH);上游只认裸 6 位,本能力在请求边界转换' },
2228
+ },
2229
+ required: ['start_date', 'end_date'], additionalProperties: false,
2230
+ },
2231
+ mapRow: mapStockFlowRow,
2232
+ }),
2233
+ createFlowSource({
2234
+ capability: 'ftshare_industry_daily_flow', name: 'get_ths_industry_daily_flow', upstreamApi: 'ths_industry_daily_flow',
2235
+ path: FLOW_PATHS.industry, row: boardFlowRow, client,
2236
+ summary: '同花顺行业板块日度资金流(近 45 交易日,亿元)',
2237
+ description: DAILY_FLOW_DESCRIPTION + INDUSTRY_FLOW_TAIL,
2238
+ allowed: ['start_date', 'end_date', 'board_name', 'page', 'size'],
2239
+ inputSchema: boardFlowInputSchema,
2240
+ mapRow: mapBoardFlowRow,
2241
+ }),
2242
+ createFlowSource({
2243
+ capability: 'ftshare_concept_daily_flow', name: 'get_ths_concept_daily_flow', upstreamApi: 'ths_concept_daily_flow',
2244
+ path: FLOW_PATHS.concept, row: boardFlowRow, client,
2245
+ summary: '同花顺概念板块日度资金流(近 45 交易日,亿元)',
2246
+ description: DAILY_FLOW_DESCRIPTION + CONCEPT_FLOW_TAIL,
2247
+ allowed: ['start_date', 'end_date', 'board_name', 'page', 'size'],
2248
+ inputSchema: boardFlowInputSchema,
2249
+ mapRow: mapBoardFlowRow,
2250
+ }),
2251
+ createMainCapitalSource(client),
2252
+ createQuoteSource(client),
2253
+ createBreadthSource(client),
2254
+ createKlineSnapshotSource(client),
2255
+ createPrevCloseSource(client),
2256
+ createCalendarSource(client),
2257
+ createBlockTradesSource(client),
2258
+ createDisclosureSource({
2259
+ capability: 'ftshare_performance_forecast', name: 'get_performance_forecasts_paginated', upstreamApi: 'performance_forecasts_paginated',
2260
+ path: '/api/v1/market/data/finance/stock-performance-forecast', row: forecastRow, mapRow: mapForecastRow, client,
2261
+ summary: 'A 股业绩预告(按报告期或单票,含北证)',
2262
+ description: FORECAST_DESCRIPTION,
2263
+ }),
2264
+ createDisclosureSource({
2265
+ capability: 'ftshare_performance_express', name: 'get_earnings_reports_paginated', upstreamApi: 'earnings_reports_paginated',
2266
+ path: '/api/v1/market/data/finance/stock-performance-express', row: expressRow, mapRow: mapExpressRow, client,
2267
+ summary: 'A 股业绩快报(按报告期或单票,含更正)',
2268
+ description: EXPRESS_DESCRIPTION,
2269
+ }),
2270
+ createSwOverviewSource(client),
2271
+ createSwMetricsSource(client),
2272
+ createLimitPriceSource(client),
2273
+ createDragonTigerSeatsSource(client),
2274
+ createHolderNumberSource(client),
2275
+ createIndexWeightSource(client),
2276
+ createCbKlineSource(client),
2277
+ createLprSource(client),
2278
+ createChinabondYieldSource(client),
2279
+ createRateSeriesSource({
2280
+ capability: 'ftshare_shibor', name: 'get_shibor_daily', upstreamApi: 'shibor_daily',
2281
+ path: '/api/v1/market/data/shibor-daily', client,
2282
+ summary: 'SHIBOR 日度八档(2006-10 起,百分数)',
2283
+ description: SHIBOR_DESCRIPTION,
2284
+ tenors: ['on_rate', 'w1', 'w2', 'm1', 'm3', 'm6', 'm9', 'y1'],
2285
+ }),
2286
+ createRateSeriesSource({
2287
+ capability: 'ftshare_hibor', name: 'get_hibor_daily', upstreamApi: 'hibor_daily',
2288
+ path: '/api/v1/market/data/hibor-daily', client,
2289
+ summary: 'HIBOR 日度十五档(七档实测整列 null)',
2290
+ description: HIBOR_DESCRIPTION,
2291
+ tenors: ['on_rate', 'w1', 'w2', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm10', 'm11', 'm12'],
2292
+ }),
2293
+ createFinancialCalendarSource(client),
2294
+ ];
2295
+ }
2296
+ /**
2297
+ * 盘中主力资金五档(同花顺口径)。这一条与日度那族**不是一回事**,不要合并成一颗能力:
2298
+ * 它的行是「某只票 × 某个 15 分钟切片」,单位是**元**、数值上游用字符串给,
2299
+ * 而且实测能翻到约 187 个交易日(日度那族只有 45 天)。⛔ `page_size` 的真实上限是 **500**:
2300
+ * 实测传 1000/2000 都只回 500 行且不报错(`pageSize` 回显 500),所以参数层直接拒超限。
2301
+ */
2302
+ function createMainCapitalSource(client) {
2303
+ const capability = 'ftshare_main_capital_flow';
2304
+ const allowed = ['date', 'time', 'page', 'size'];
2305
+ const schema = {
2306
+ capability,
2307
+ time_contract: getDataTimeContract(capability),
2308
+ name: 'get_stock_capital_flows',
2309
+ source: 'http:ftshare.stock_capital_flows',
2310
+ data_key: buildDataKey('ftshare', 'http', capability),
2311
+ source_label: 'ftshare',
2312
+ paginated: true,
2313
+ cacheMaxAgeMs: 60_000,
2314
+ rowShape: { rowKey: 'item' },
2315
+ summary: '个股主力资金五档(15 分钟切片,元)',
2316
+ description: '按交易日与 15 分钟切片获取 A 股个股主力资金净额五档(超大单 / 大单 / 主力 / 中单 / 小单)。这是**盘中分时**口径,与那三条日度资金流不是同一件事:本能力一行是「一只票 × 一个切片」,日度那族一行是「一只票 × 一个交易日」。⛔ **单位是元**,两条加减法都用真报文核对过(实测同一行:超大单 153016124.85 + 大单 201583302.13 = 主力 354599426.98):`net_inflow_main_yuan = 超大单 + 大单`,以及 `主力 + 中单 + 小单 = 0`——后者成立是因为净额本身带符号,所以**五档相加恒等于 0,不要把它当成「全市场净流入」**。上游把这些数值以**字符串**给出,本层转成 number,空值转 null 不补 0。日期:`date` 省略时取最新可用切片(实测 = 当日 15:30 那一次);`date` 必须是交易日。时刻:`time` 只认 15 分钟栅格——实测非栅格的 `0935` 被上游 400 拒(「time minute must be 00, 15, 30, or 45」),非 HHMM 形状的(如 `100000`)拒「time format must be HHMM」;**午间 `time=1200` 返回 total=0 是休市、不是取数失败**(实测 `0930` 与 `1530` 都是 5214 行)。历史:实测可翻到约 187 个交易日(按真实交易日:T-185 = 2026-01-06 仍有 5178 行、**T-190 = 2025-12-26 起 total=0**)。⛔ 同样**不含北交所**(实测把单日 5214 行逐页取完,BJ 尾码 0 个)。ts_millis / trade_date / slice_time 由上游的**纳秒**戳换算为毫秒与上海时区历,纳秒原值不透出。排序不可参数化:实测上游默认按主力净额**从高到低**给出(首页 551,269,541.5 > 381,321,844.18 > 354,599,426.98 > …),所以「前 N 名」只能在**同一 `date` + `time`** 内读。⛔ 分页 `page` 实测生效(逐页首行不同、零重复),但 **`page_size` 的真实上限是 500 而且超限不报错**:实测 1000 与 2000 都只回 500 行(`pageSize` 回显 500、`pages` 回显 11)——本层因此把 `size` 锁在 1~500,全市场 5214 行需 11 页;`total` 与日度那族一样是**行数**口径。',
2317
+ input_schema: {
2318
+ type: 'object',
2319
+ properties: {
2320
+ date: { type: 'string', description: '交易日 YYYY-MM-DD;省略 = 最新可用切片' },
2321
+ time: { type: 'string', description: '15 分钟切片时刻,HHMM 或 HH:MM(如 1000、10:15、1530)' },
2322
+ page: { type: 'integer', minimum: 1 },
2323
+ size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_500 },
2324
+ },
2325
+ required: [], additionalProperties: false,
2326
+ },
2327
+ output_schema: output(mainCapitalRow),
2328
+ };
2329
+ const normalize = (params) => {
2330
+ // ⛔ 上限押 500(不是那三族的 1000):实测这一条对 page_size=1000 静默截成 500 行,超限不报错。
2331
+ const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_500);
2332
+ const normalized = { page, size };
2333
+ if (params.date !== undefined)
2334
+ normalized.date = date(params.date, 'date');
2335
+ if (params.time !== undefined)
2336
+ normalized.time = sliceTime(params.time);
2337
+ return normalized;
2338
+ };
2339
+ return {
2340
+ schema,
2341
+ normalizeParams: normalize,
2342
+ validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
2343
+ execute: async (request, signal) => {
2344
+ const params = normalize(request.params);
2345
+ const search = query({ date: params.date === undefined ? undefined : compactDate(String(params.date)), time: params.time, page: params.page, page_size: params.size }, ['date', 'time', 'page', 'page_size']);
2346
+ const { rows, total } = readPaged(await client.getJson(FLOW_PATHS.mainCapital, search, signal));
2347
+ const item = rows.filter((row) => row.symbol !== undefined && row.symbol !== null).map((row) => {
2348
+ const ms = nanosToMillis(row.ts_nanos);
2349
+ const parts = ms === null ? { trade_date: null, slice_time: null } : shanghaiParts(ms);
2350
+ return {
2351
+ thscode: String(row.symbol), name: textOrNull(row.symbol_name),
2352
+ trade_date: parts.trade_date, slice_time: parts.slice_time, ts_millis: ms,
2353
+ net_inflow_super_large_yuan: numberOrNull(row.net_inflow_extra_large),
2354
+ net_inflow_large_yuan: numberOrNull(row.net_inflow_large),
2355
+ net_inflow_main_yuan: numberOrNull(row.net_inflow_main),
2356
+ net_inflow_medium_yuan: numberOrNull(row.net_inflow_medium),
2357
+ net_inflow_small_yuan: numberOrNull(row.net_inflow_small),
2358
+ };
2359
+ });
2360
+ return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(mainCapitalRow) };
2361
+ },
2362
+ };
2363
+ }