@v587d/capital-generation 2.8.2 → 2.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +3 -3
- package/chart-ui/package.json +1 -1
- package/config/package.json +1 -1
- package/lib/sources/ftshare-rest.d.ts +5 -2
- package/lib/sources/ftshare-rest.js +1182 -5
- package/lib/time/tools.js +11 -0
- package/lib/web-retriever/local-fetch.d.ts +1 -1
- package/lib/web-retriever/local-fetch.js +1 -1
- package/lib/web-retriever/wind-client.d.ts +1 -1
- package/lib/web-retriever/wind-client.js +1 -1
- package/package.json +1 -1
- package/watchlist/package.json +1 -1
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FTShare(非凸科技 `market.ft.tech/gateway`)REST source —— **资金流 + 行情/日 K 截面 + 快照与日历
|
|
3
|
-
* + 大宗与披露 +
|
|
3
|
+
* + 大宗与披露 + 申万行业 + 期货与贵金属**。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 为什么接这一族(批次 A–E 逐条真报文实测于 2026-10-15,批次 F 于 2026-10-16):
|
|
6
6
|
* - 同花顺 `capital-flow/*` 对我们是 `code=2004` 永久关闭,东财 `eastmoney_main_capital_snapshot`
|
|
7
7
|
* 实测**只有查询日一股一行**——个股资金流的历史序列在本仓一直是空的。这一族给到最近约 45 个交易日
|
|
8
8
|
* (T-45 仍有 5199 行、T-50 起 total=0),主力五档更给到约 187 个交易日。
|
|
9
9
|
* - 板块(行业)资金滚动此前只有 `eastmoney_cashflow_rotation` 一条,上游一抖就没有第二家。
|
|
10
10
|
* - 北交所行情此前是**零**:腾讯那一路在参数层明确拒 BJ,而这一族实测含 349 只北证票。
|
|
11
|
+
* - 期货与贵金属此前在本仓是**整块空白**(`ticker_search` 只允许消歧到期货交易所,没有一条数据端点),
|
|
12
|
+
* 所以批次 F 那十颗不是第二家,而是第一家:合约清单与要素、日/周/月/季 K 线、涨跌停价、每日结算参数、
|
|
13
|
+
* 仓单日报、品种周报、南华商品指数、东财会员持仓排名、贵金属价格。
|
|
11
14
|
* 上游分别是同花顺与同花顺 daec,与现有能力**不是同一条抓取路径**,才够称 plan B。
|
|
12
15
|
*
|
|
13
16
|
* ⛔ 本模块的 description 与注释**只允许写有原始报文可回查的数字**。2026-10-15 曾有一批"实测计数"
|
|
@@ -43,10 +46,41 @@ const MAX_PAGE_SIZE_500 = 500;
|
|
|
43
46
|
/**
|
|
44
47
|
* ⛔ 申万行业日度指标那一处更低:实测 `page_size=1000` 回显 `pageSize=200` 且只回 200 行
|
|
45
48
|
* (201 也一样被截成 200),同样是**静默**截断。
|
|
49
|
+
* 批次 F 复用这一档:南华指数日 K 实测 201 → **响亮** 400「page_size must be between 1 and 200」,
|
|
50
|
+
* 同一条上限两种失败形状都有。
|
|
46
51
|
*/
|
|
47
52
|
const MAX_PAGE_SIZE_200 = 200;
|
|
48
53
|
/** 指数权重那一族:实测 101 → 回显 `page_size=100` 并只回 100 行(**静默**夹断,文档这条难得与实测一致)。 */
|
|
49
54
|
const MAX_PAGE_SIZE_100 = 100;
|
|
55
|
+
/**
|
|
56
|
+
* ⛔ 期货那一族的 `page_size` 有四种命运,2026-10-16 逐颗真报文打出:
|
|
57
|
+
* 仓单 / 周报 / 结算参数传 5000 → **静默**夹到 4000(回显 `pageSize: 4000`);
|
|
58
|
+
* 东财持仓传 5000 → **响亮** 400「page_size must be between 1 and 500」;
|
|
59
|
+
* 贵金属传 1001 → 静默夹到 1000;涨跌停价压到 **50**——实测 SHFE 50 可过、60 起整页 400
|
|
60
|
+
* 「missing pre-settlement price」(缺前结算价的是上游自己的某些合约,不是我们的参数)。
|
|
61
|
+
*/
|
|
62
|
+
const MAX_PAGE_SIZE_4000 = 4000;
|
|
63
|
+
const MAX_PAGE_SIZE_50 = 50;
|
|
64
|
+
/**
|
|
65
|
+
* 期货合约代码的**多套后缀**(实测 2026-10-16):清单与基础数据给 `.SHF` / `.CFE` / `.CZC` / `.GFE`,
|
|
66
|
+
* 而涨跌停价与结算参数把同一家写成 `.CFX`(中金所 `IC2610.CFX`)与 `.ZCE`(郑商所 `AP610.ZCE`)——
|
|
67
|
+
* 同一批合约三种拼法;K 线只认 CTP **全称**后缀,短后缀 400 点名「unsupported suffix 'SHF'」。
|
|
68
|
+
* 所以映射表必须建在我们这一侧:模型从任何一颗拿到的代码都要能直接打到 K 线上。
|
|
69
|
+
* ⛔ `.IB`(银行间债券远期,清单里确有 64 只 `ADBC10_2612.IB`)换成长后缀也没有行情端——
|
|
70
|
+
* 实测 `.NIB` → 400,这一族上游不卖银行间行情,所以这里响亮拒绝而不是"试一下"。
|
|
71
|
+
*/
|
|
72
|
+
const FUTURES_SUFFIX = { SHF: 'SHFE', CFE: 'CFFEX', CFX: 'CFFEX', CZC: 'CZCE', ZCE: 'CZCE', GFE: 'GFEX' };
|
|
73
|
+
const FUTURES_LONG_SUFFIX = new Set(['SHFE', 'INE', 'DCE', 'CZCE', 'CFFEX', 'GFEX']);
|
|
74
|
+
/** 涨跌停价与结算参数这两颗的 `exchange` 是必填项(实测缺失 400「缺少必填参数:交易所」)。 */
|
|
75
|
+
const FUTURES_EXCHANGES = ['SHFE', 'INE', 'CZCE', 'CFFEX', 'DCE', 'GFEX'];
|
|
76
|
+
/** 仓单日报只覆盖商品所(实测 INE / DCE / CZCE / SHFE / GFEX 五家都有行,中金所不发仓单)。 */
|
|
77
|
+
const WARRANT_EXCHANGES = ['SHFE', 'INE', 'CZCE', 'DCE', 'GFEX'];
|
|
78
|
+
/**
|
|
79
|
+
* ⛔ 东财持仓那一族的 `exchange` 与全仓其他地方**不是同一套写法**:实测 `GFEX` / `gfex` 都只回
|
|
80
|
+
* 0 行、不报错,真名是 `gfe`;而且整族大小写敏感(`AG` 0 行、`ag` 有数)。
|
|
81
|
+
* 本层把六家常规写法换成上游那一套小写,模型不需要知道 `gfe` 这个坑。
|
|
82
|
+
*/
|
|
83
|
+
const EASTMONEY_FUTURES_EXCHANGE = { SHFE: 'shfe', DCE: 'dce', CZCE: 'czce', CFFEX: 'cffex', INE: 'ine', GFEX: 'gfe', GFE: 'gfe' };
|
|
50
84
|
const FLOW_PATHS = {
|
|
51
85
|
stock: '/api/v1/market/data/ths-stock-daily-flow',
|
|
52
86
|
industry: '/api/v1/market/data/ths-industry-daily-flow',
|
|
@@ -278,9 +312,13 @@ function readPaged(envelope) {
|
|
|
278
312
|
function pagedEnvelope(item, total, page, size) {
|
|
279
313
|
return { item, pagination: { page, size: item.length, pages: Math.max(1, Math.ceil(total / size)), total } };
|
|
280
314
|
}
|
|
281
|
-
/**
|
|
282
|
-
|
|
283
|
-
|
|
315
|
+
/**
|
|
316
|
+
* 没有分页信封的响应(市场快照 / 一次取满的那几颗):一行就是全部。
|
|
317
|
+
* ⛔ `total` 允许另给:期货 K 线那种"limit 只回最新若干根、但上游 total 报的是区间内根数"的响应,
|
|
318
|
+
* 把 total 写成 item.length 等于替上游抹掉截断(批次 E 的"截断要看得见"同一条)。
|
|
319
|
+
*/
|
|
320
|
+
function snapshotEnvelope(item, total = item.length) {
|
|
321
|
+
return { item, pagination: { page: 1, size: item.length, pages: 1, total } };
|
|
284
322
|
}
|
|
285
323
|
/**
|
|
286
324
|
* 日期区间有没有被应用,只能在返回之后核:昨收价那一族的响应里**没有身份列**(只有 `date` 与数值),
|
|
@@ -325,6 +363,56 @@ function query(params, allowed) {
|
|
|
325
363
|
}
|
|
326
364
|
return search;
|
|
327
365
|
}
|
|
366
|
+
/** 不分页的那几颗也要挡陌生键名(`pageParams` 顺手做了这件事,这里给它们同一句话)。 */
|
|
367
|
+
function assertKnown(params, allowed) {
|
|
368
|
+
for (const key of Object.keys(params))
|
|
369
|
+
if (!allowed.includes(key))
|
|
370
|
+
throw new FtshareError('invalid_parameter', `unsupported parameter: ${key}`);
|
|
371
|
+
}
|
|
372
|
+
/** 期货代码形状:`品种[数字]`,可带两到五个字母的交易所后缀。上游对不认识的写法是**静默空**,所以先在参数层核。 */
|
|
373
|
+
const FUTURES_CODE_PATTERN = /^[A-Z][A-Z0-9_]*\d{3,4}(?:\.[A-Z]{2,5})?$/;
|
|
374
|
+
/**
|
|
375
|
+
* 合约代码归一:转大写、把短后缀换成 K 线要的 CTP 全称后缀。
|
|
376
|
+
* ⛔ 裸码(`A2611`)原样放行——实测上游确实认(219 行日 K),但它**没有交易所身份**,
|
|
377
|
+
* 本层不替它猜(同一串裸码在别家上游可能另有其合约)。
|
|
378
|
+
*/
|
|
379
|
+
function futuresSymbol(value, name = 'symbol') {
|
|
380
|
+
const raw = typeof value === 'string' || typeof value === 'number' ? String(value).trim().toUpperCase() : '';
|
|
381
|
+
if (!FUTURES_CODE_PATTERN.test(raw))
|
|
382
|
+
throw new FtshareError('invalid_parameter', `parameter ${name} 要的是期货合约代码(如 AG2610.SHF / CU2612.SHFE / A2611),拿到 ${JSON.stringify(String(value ?? ''))}`);
|
|
383
|
+
const [base, suffix] = raw.split('.');
|
|
384
|
+
if (suffix === undefined)
|
|
385
|
+
return base;
|
|
386
|
+
if (suffix === 'IB')
|
|
387
|
+
throw new FtshareError('invalid_parameter', `parameter ${name} ${raw} 是银行间品种:那一族在清单里有行,但本上游没有银行间行情端(实测换成 .NIB 打 K 线 → 400 unsupported suffix 'NIB')`);
|
|
388
|
+
const long = FUTURES_SUFFIX[suffix] ?? (FUTURES_LONG_SUFFIX.has(suffix) ? suffix : undefined);
|
|
389
|
+
if (long === undefined)
|
|
390
|
+
throw new FtshareError('invalid_parameter', `parameter ${name} ${raw} 的后缀 ${suffix} 认不出:交易所后缀用 SHF/SHFE、CFE/CFX/CFFEX、CZC/ZCE/CZCE、GFE/GFEX、DCE、INE 这几家写法(清单与结算那几颗给的后缀长短不一,本层会换)`);
|
|
391
|
+
return `${base}.${long}`;
|
|
392
|
+
}
|
|
393
|
+
/** 涨跌停价与结算参数的 `ts_code`:实测短后缀与全称后缀**都能命中**(返回行一律写短后缀),所以只做形状与写法核。 */
|
|
394
|
+
function futuresTsCode(value, name = 'ts_code') {
|
|
395
|
+
const raw = typeof value === 'string' || typeof value === 'number' ? String(value).trim().toUpperCase() : '';
|
|
396
|
+
if (!FUTURES_CODE_PATTERN.test(raw) || !raw.includes('.'))
|
|
397
|
+
throw new FtshareError('invalid_parameter', `parameter ${name} 要的是带交易所后缀的期货合约代码(实测 AG2610.SHF 与 AG2610.SHFE 都命中,返回行写的是短后缀),拿到 ${JSON.stringify(String(value ?? ''))}`);
|
|
398
|
+
const suffix = raw.split('.')[1];
|
|
399
|
+
if (FUTURES_SUFFIX[suffix] === undefined && !FUTURES_LONG_SUFFIX.has(suffix))
|
|
400
|
+
throw new FtshareError('invalid_parameter', `parameter ${name} ${raw} 的交易所后缀认不出(SHF/SHFE、CFE/CFX/CFFEX、CZC/ZCE/CZCE、GFE/GFEX、DCE、INE 之一)`);
|
|
401
|
+
return raw;
|
|
402
|
+
}
|
|
403
|
+
function futuresExchange(value, allowed, name = 'exchange') {
|
|
404
|
+
const raw = typeof value === 'string' ? value.trim().toUpperCase() : '';
|
|
405
|
+
if (!allowed.includes(raw))
|
|
406
|
+
throw new FtshareError('invalid_parameter', `parameter ${name} must be one of ${allowed.join(' / ')}`);
|
|
407
|
+
return raw;
|
|
408
|
+
}
|
|
409
|
+
/** 周报的统计周期:上游只认 `YYYYWW`(实测 `2026-10-16` → 400「start_week must use YYYYWW format」)。 */
|
|
410
|
+
function weekCode(value, name) {
|
|
411
|
+
const raw = typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
|
|
412
|
+
if (!/^\d{6}$/.test(raw) || Number(raw.slice(4)) < 1 || Number(raw.slice(4)) > 53)
|
|
413
|
+
throw new FtshareError('invalid_parameter', `parameter ${name} 要的是 YYYYWW 六位周码(如 202639),拿到 ${JSON.stringify(String(value ?? ''))}(实测别的写法上游 400「${name} must use YYYYWW format」)`);
|
|
414
|
+
return raw;
|
|
415
|
+
}
|
|
328
416
|
const dailyFlowRow = {
|
|
329
417
|
type: 'object',
|
|
330
418
|
properties: {
|
|
@@ -2211,6 +2299,1083 @@ function createFinancialCalendarSource(client) {
|
|
|
2211
2299
|
},
|
|
2212
2300
|
};
|
|
2213
2301
|
}
|
|
2302
|
+
// ── 批次 F:期货与贵金属(2026-10-16 逐条真报文,免费档 11 颗里选 10 颗)───────────────
|
|
2303
|
+
// ⛔ 这一批全部按"先档结构、再打报文、最后落字"的顺序做,证据在 /tmp/ftshare-evidence/probe-f/
|
|
2304
|
+
// (raw* / summary* / stats10 / crosscheck10)与 docs/design/ftshare-source-survey.md §7.11。
|
|
2305
|
+
// 暂缓的那颗是 eastmoney_futures_strange(龙虎榜):它一次回 12 个指标、三种载荷形状,
|
|
2306
|
+
// 落成行集需要"一行变多行"的展平,而 hub 的 mapRow 一行只出一行——为它先改 hub 不属于这一批。
|
|
2307
|
+
const futuresContractRow = {
|
|
2308
|
+
type: 'object',
|
|
2309
|
+
properties: {
|
|
2310
|
+
symbol: { type: 'string' },
|
|
2311
|
+
exchange: { type: 'string' },
|
|
2312
|
+
product: { type: 'string' },
|
|
2313
|
+
contract_name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2314
|
+
future_type: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2315
|
+
trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2316
|
+
trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2317
|
+
},
|
|
2318
|
+
additionalProperties: false,
|
|
2319
|
+
};
|
|
2320
|
+
const FUTURES_CONTRACTS_DESCRIPTION = '按交易日获取**全部在册期货合约清单**(六家商品与金融期货交易所 + 银行间),一行一只合约。不传 `trade_date` = 上游按 CST 取前一交易日。⛔ **行数不是合约数**:实测 2026-10-15 回 1812 行,里面只有 **906 个不同 `symbol`**——每只合约出现两份,两份**除 `future_type` 外逐列相同**(906 只逐个键比过),一份写 `2`、一份写 `null`。要一份干净清单请按 `symbol` 去重;按行计数会把合约数翻倍。`future_type` 在这份快照里实测只出现 `2` 与 `null`(各 906 行),文档口径的 1 主力 / 3 连续**一个都没有** ⇒ 不许把 `null` 读成"主力"。交易所分布实测(同一交易日):CFFEX 56 / CZCE 486 / DCE 500 / GFEX 96 / INE 134 / NIB 64 / SHFE 476。⛔ `NIB` 是银行间债券远期(`ADBC10_2612.IB` 一类),**清单里有它、行情端没有它**:实测换成 `.NIB` 打 K 线 → 400「unsupported suffix \'NIB\'」,那 64 只在别处取不到数,不是本仓漏接。`symbol` 用的是**短后缀**(`.SHF` / `.CFE` / `.CZC` / `.GFE`),K 线那颗要全称后缀,本层按交易所映射,不用你手工换。⛔ 采集时点:实测 2026-10-16 上午打 `trade_date=20261016` → **400「当日期货数据将在上海时间 20:20 后开放查询」**,那是合法的入库时刻、不是取数故障,不要重试(同一族里另有两颗打当天是 200 + total=0,见 `ftshare_futures_limit_price`)。**这一颗没有分页**:实测多发 `page_size=10` 仍回全量 1812 行,分页座位是假的,本层在参数层拒。1812 行一次给完必然超一次工具返回预算(实测原始报文 177,957 字节),会回 `too_large` + `profile_ref`——那是快照的正常用法,请转 `query_dataset` 用 `filters`(`exchange` / `product`)与 `columns_of_interest` 收窄。';
|
|
2321
|
+
/** 中国期货列表(`futures-lists`):按交易日的合约清单快照,⛔ 无分页。 */
|
|
2322
|
+
function createFuturesContractsSource(client) {
|
|
2323
|
+
const capability = 'ftshare_futures_contracts';
|
|
2324
|
+
const allowed = ['trade_date'];
|
|
2325
|
+
const path = '/api/v1/market/data/futures/futures-lists';
|
|
2326
|
+
const schema = {
|
|
2327
|
+
capability,
|
|
2328
|
+
time_contract: getDataTimeContract(capability),
|
|
2329
|
+
name: 'get_china_futures_lists',
|
|
2330
|
+
source: 'http:ftshare.futures_lists',
|
|
2331
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
2332
|
+
source_label: 'ftshare',
|
|
2333
|
+
paginated: false,
|
|
2334
|
+
cacheMaxAgeMs: 60_000,
|
|
2335
|
+
rowShape: { rowKey: 'item' },
|
|
2336
|
+
summary: '期货合约清单(按交易日快照,含银行间)',
|
|
2337
|
+
description: FUTURES_CONTRACTS_DESCRIPTION,
|
|
2338
|
+
input_schema: {
|
|
2339
|
+
type: 'object',
|
|
2340
|
+
properties: { trade_date: { type: 'string', description: '交易日 YYYY-MM-DD;省略 = 上游按 CST 的前一交易日' } },
|
|
2341
|
+
required: [], additionalProperties: false,
|
|
2342
|
+
},
|
|
2343
|
+
output_schema: output(futuresContractRow),
|
|
2344
|
+
};
|
|
2345
|
+
const normalize = (params) => {
|
|
2346
|
+
assertKnown(params, allowed);
|
|
2347
|
+
const normalized = {};
|
|
2348
|
+
if (params.trade_date !== undefined)
|
|
2349
|
+
normalized.trade_date = date(params.trade_date, 'trade_date');
|
|
2350
|
+
return normalized;
|
|
2351
|
+
};
|
|
2352
|
+
return {
|
|
2353
|
+
schema,
|
|
2354
|
+
normalizeParams: normalize,
|
|
2355
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
2356
|
+
execute: async (request, signal) => {
|
|
2357
|
+
const params = normalize(request.params);
|
|
2358
|
+
const envelope = await client.getJson(path, query({ trade_date: params.trade_date === undefined ? undefined : compactDate(String(params.trade_date)) }, ['trade_date']), signal);
|
|
2359
|
+
const { rows } = readPaged(envelope);
|
|
2360
|
+
// 响应行内没有日期列,交易日只在 `data.trade_date` 回显一次:时间轴只能由本层补写。
|
|
2361
|
+
const { iso, ms } = normalizeDateCell(isRecord(envelope.data) ? envelope.data.trade_date : undefined);
|
|
2362
|
+
const item = rows.filter((row) => row.symbol !== undefined && row.symbol !== null).map((row) => ({
|
|
2363
|
+
symbol: String(row.symbol), exchange: textOrNull(row.exchange), product: textOrNull(row.product),
|
|
2364
|
+
contract_name: textOrNull(row.symbol_cn_name), future_type: integerOrNull(row.future_type),
|
|
2365
|
+
trade_date: iso ?? (params.trade_date === undefined ? null : String(params.trade_date)),
|
|
2366
|
+
trade_date_ms: ms ?? (params.trade_date === undefined ? null : dateMs(String(params.trade_date))),
|
|
2367
|
+
}));
|
|
2368
|
+
return { data: snapshotEnvelope(item), schema: output(futuresContractRow) };
|
|
2369
|
+
},
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
const futuresDetailRow = {
|
|
2373
|
+
type: 'object',
|
|
2374
|
+
properties: {
|
|
2375
|
+
symbol: { type: 'string' },
|
|
2376
|
+
exchange: { type: 'string' },
|
|
2377
|
+
product: { type: 'string' },
|
|
2378
|
+
contract_name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2379
|
+
future_type: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2380
|
+
trade_unit: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2381
|
+
quote_unit: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2382
|
+
multiplier: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2383
|
+
min_tick_info: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2384
|
+
margin_rule: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2385
|
+
price_fluctuation_rule: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2386
|
+
trade_months_rule: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2387
|
+
trade_hours_rule: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2388
|
+
delivery_method: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2389
|
+
delivery_site: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2390
|
+
last_trade_date_rule: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2391
|
+
delivery_date_rule: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2392
|
+
spot_symbol: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2393
|
+
list_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2394
|
+
list_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2395
|
+
delist_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2396
|
+
delist_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2397
|
+
trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2398
|
+
trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2399
|
+
},
|
|
2400
|
+
additionalProperties: false,
|
|
2401
|
+
};
|
|
2402
|
+
const FUTURES_DETAIL_DESCRIPTION = '按交易日获取**期货合约要素**(交易单位、合约乘数、最小变动价位、保证金、上市与摘牌日、交割方式),可按单只合约筛。与 `ftshare_futures_contracts` 同一套快照口径:⛔ 实测同一交易日 1812 行、只有 906 只合约,每只两份、两份除 `future_type` 外逐列相同。`symbol` 传标准合约全码(实测 `AG2610.SHF` 回 2 行、**大小写不敏感**:`ag2610.shf` 同样命中;⛔ 认不出的代码不回错、只回空 `items`,实测 `ZZZZ9999.SHF` → 200 + 0 行)。不带 `symbol` 时一次给全量要素,实测原始报文 **1,364,023 字节**,必然超一次工具返回预算并回 `too_large`——请带 `symbol`,或用 `query_dataset` 的 `filters` / `columns_of_interest` 收窄。⛔ 上游 28 列里本层只透出 24 列,**丢弃的列与理由**(按实测 1812 行的空值统计):`price_unit`(取值 1~1000、口径未披露)、`mean_cal_value`(只有 1 与 2)、`type_code` / `sub_type_code`(703xxx 内部码)、`value_info`(1756/1812 为空串)、`position_limit_info`(1806/1812 为空串)、`last_trade_date_hour`(1660/1812 为空串)。⛔ **两列装的是规则文字不是日期**:`delivery_date` 实测写「合约到期月份的第三个周五,遇法定节假日顺延(非完整周)」、`last_trade_date` 实测写「合约月份的15日(遇国家法定节假日顺延…)」,本层改名成 `delivery_date_rule` / `last_trade_date_rule`,让它们不会被当成日期列(§1.3:日期串落到数值时间列必须响亮失败,那前提是它得是个日期)。真日期是 `list_date`(实测 20231201~20261015)与 `de_list_tdate`(实测 20261015~20290831),都归一成 ISO + 毫秒轴;⛔ `de_list_tdate` 的最小值就是本次快照那天,说明这张表**带着当天摘牌的合约**,别拿它筛"在册"。单位:`trade_unit` 实测七种(张 / 吨 / 立方米 / 克 / 桶 / 手 / 千克),`quote_unit` 是报价单位文字(实测「人民币元/吨」「人民币元/千克」「指数点」),`multiplier` 是每手对应的交易单位数(实测 1~20000,72/1812 为 null)——⛔ 它与 `ftshare_futures_kline` 的 `vwap_per_lot_yuan`(每手成交额)是同一条算式的两侧:每手成交额 ≈ 价格 × multiplier。`spot_symbol` 只有 32/1812 行有值(股指期货那几只给现货指数代码,实测 IC → `000905.SH`)。';
|
|
2403
|
+
/** 中国期货基础数据(`futures-base-data`):合约要素快照,可按单只筛。⛔ 只取口径可承诺的列(§7.11)。 */
|
|
2404
|
+
function createFuturesContractDetailSource(client) {
|
|
2405
|
+
const capability = 'ftshare_futures_contract_detail';
|
|
2406
|
+
const allowed = ['trade_date', 'symbol'];
|
|
2407
|
+
const path = '/api/v1/market/data/futures/futures-base-data';
|
|
2408
|
+
const schema = {
|
|
2409
|
+
capability,
|
|
2410
|
+
time_contract: getDataTimeContract(capability),
|
|
2411
|
+
name: 'get_china_futures_base_data',
|
|
2412
|
+
source: 'http:ftshare.futures_base_data',
|
|
2413
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
2414
|
+
source_label: 'ftshare',
|
|
2415
|
+
paginated: false,
|
|
2416
|
+
cacheMaxAgeMs: 60_000,
|
|
2417
|
+
rowShape: { rowKey: 'item' },
|
|
2418
|
+
summary: '期货合约要素(乘数/单位/交割/上市摘牌日)',
|
|
2419
|
+
description: FUTURES_DETAIL_DESCRIPTION,
|
|
2420
|
+
input_schema: {
|
|
2421
|
+
type: 'object',
|
|
2422
|
+
properties: {
|
|
2423
|
+
trade_date: { type: 'string', description: '交易日 YYYY-MM-DD;省略 = 上游按 CST 的前一交易日' },
|
|
2424
|
+
symbol: { type: 'string', description: '标准合约全码(短后缀,如 AG2610.SHF),大小写都收;不传 = 该日全部' },
|
|
2425
|
+
},
|
|
2426
|
+
required: [], additionalProperties: false,
|
|
2427
|
+
},
|
|
2428
|
+
output_schema: output(futuresDetailRow),
|
|
2429
|
+
};
|
|
2430
|
+
const normalize = (params) => {
|
|
2431
|
+
assertKnown(params, allowed);
|
|
2432
|
+
const normalized = {};
|
|
2433
|
+
if (params.trade_date !== undefined)
|
|
2434
|
+
normalized.trade_date = date(params.trade_date, 'trade_date');
|
|
2435
|
+
if (params.symbol !== undefined)
|
|
2436
|
+
normalized.symbol = futuresTsCode(params.symbol, 'symbol');
|
|
2437
|
+
return normalized;
|
|
2438
|
+
};
|
|
2439
|
+
return {
|
|
2440
|
+
schema,
|
|
2441
|
+
normalizeParams: normalize,
|
|
2442
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
2443
|
+
execute: async (request, signal) => {
|
|
2444
|
+
const params = normalize(request.params);
|
|
2445
|
+
const envelope = await client.getJson(path, query({
|
|
2446
|
+
trade_date: params.trade_date === undefined ? undefined : compactDate(String(params.trade_date)),
|
|
2447
|
+
symbol: params.symbol,
|
|
2448
|
+
}, ['trade_date', 'symbol']), signal);
|
|
2449
|
+
const { rows } = readPaged(envelope);
|
|
2450
|
+
const { iso, ms } = normalizeDateCell(isRecord(envelope.data) ? envelope.data.trade_date : undefined);
|
|
2451
|
+
const snapshotDate = iso ?? (params.trade_date === undefined ? null : String(params.trade_date));
|
|
2452
|
+
const snapshotMs = ms ?? (params.trade_date === undefined ? null : dateMs(String(params.trade_date)));
|
|
2453
|
+
// 上游的 symbol 过滤大小写不敏感(实测两种写法都命中),所以核身份要比**大写后的**行值:
|
|
2454
|
+
// 否则用户写小写、行里是大写,会把一份正确返回判成"过滤没生效"。
|
|
2455
|
+
if (params.symbol !== undefined) {
|
|
2456
|
+
assertFiltered(rows.map((row) => ({ ...row, symbol: String(row.symbol ?? '').toUpperCase() })), {
|
|
2457
|
+
field: 'symbol', expected: String(params.symbol), label: 'symbol',
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
const item = rows.filter((row) => row.symbol !== undefined && row.symbol !== null).map((row) => {
|
|
2461
|
+
const listed = normalizeDateCell(row.list_date);
|
|
2462
|
+
const delisted = normalizeDateCell(row.de_list_tdate);
|
|
2463
|
+
return {
|
|
2464
|
+
symbol: String(row.symbol), exchange: textOrNull(row.exchange), product: textOrNull(row.product),
|
|
2465
|
+
contract_name: textOrNull(row.symbol_cn_name), future_type: integerOrNull(row.future_type),
|
|
2466
|
+
trade_unit: textOrNull(row.trade_unit), quote_unit: textOrNull(row.put_price),
|
|
2467
|
+
multiplier: numberOrNull(row.multiplier),
|
|
2468
|
+
min_tick_info: textOrNull(row.min_put_price_info), margin_rule: textOrNull(row.margin_info),
|
|
2469
|
+
price_fluctuation_rule: textOrNull(row.price_fluctuation_info),
|
|
2470
|
+
trade_months_rule: textOrNull(row.trade_months_info), trade_hours_rule: textOrNull(row.trade_hours_info),
|
|
2471
|
+
delivery_method: textOrNull(row.delivery_mean), delivery_site: textOrNull(row.delivery_site),
|
|
2472
|
+
last_trade_date_rule: textOrNull(row.last_trade_date), delivery_date_rule: textOrNull(row.delivery_date),
|
|
2473
|
+
spot_symbol: textOrNull(row.spot_symbol),
|
|
2474
|
+
list_date: listed.iso, list_date_ms: listed.ms, delist_date: delisted.iso, delist_date_ms: delisted.ms,
|
|
2475
|
+
trade_date: snapshotDate, trade_date_ms: snapshotMs,
|
|
2476
|
+
};
|
|
2477
|
+
});
|
|
2478
|
+
return { data: snapshotEnvelope(item), schema: output(futuresDetailRow) };
|
|
2479
|
+
},
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
const futuresKlineRow = {
|
|
2483
|
+
type: 'object',
|
|
2484
|
+
properties: {
|
|
2485
|
+
contract: { type: 'string' },
|
|
2486
|
+
symbol: { type: 'string' },
|
|
2487
|
+
trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2488
|
+
trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2489
|
+
bar_time_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2490
|
+
open_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2491
|
+
high_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2492
|
+
low_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2493
|
+
close_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2494
|
+
volume_lots: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2495
|
+
turnover_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2496
|
+
vwap_per_lot_yuan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2497
|
+
open_interest_lots: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2498
|
+
},
|
|
2499
|
+
additionalProperties: false,
|
|
2500
|
+
};
|
|
2501
|
+
const FUTURES_INTERVALS = ['daily', 'weekly', 'monthly', 'quarterly'];
|
|
2502
|
+
const FUTURES_KLINE_DESCRIPTION = '按**单个合约**与日期区间取历史 K 线(日 / 周 / 月 / 季),一行一根 K、按时间**升序**。⛔ 交易所后缀这一课:清单 / 要素 / 结算 / 涨跌停四颗给的都是短后缀(`AG2610.SHF`),而这一颗只认 CTP **全称**后缀,实测短后缀 400 点名「unsupported suffix \'SHF\', only CTP suffixes (.SHFE, .INE, .DCE, .CZCE, .CFFEX, .GFEX) are supported」——本层按 `.SHF→.SHFE`、`.CFE→.CFFEX`、`.CZC→.CZCE`、`.GFE→.GFEX` 自动换(DCE / INE 原样),六家各拿真实合约核过、每家回 20 行;大小写实测不敏感(`cu2612.SHFE` 与 `CU2612.SHFE` 返回同一份 198 行),本层统一转大写。裸码(实测 `A2611` 回 219 行)也收,但它**没有交易所身份**、本层不替它猜。⛔ `interval` 只有这四档,其余一律在参数层拒——上游这一族是**静默**的:实测 `1d` / `1w` / `week` 回 **200 + 0 行**,只有 `day` / `1M` / `1Q` / `60m` / `1h` / 空串才 400「unsupported futures period interval」;供应商文档写的"兼容 1d / 1w / 1mo / 1q"与实测相反,放行 `1d` 等于把"写法不认"伪装成"这段时间没行情"。⛔ 日期只收 `YYYY-MM-DD`:上游要的是**毫秒**时间戳,实测传 `20260906` 这种 YYYYMMDD 整数 → 200 + 0 行 + total=0,所以本层按北京时间 +08:00 换成当日零点与 23:59:59,不给裸整数留座位。⛔ 跨度上限是 12 个自然月,本层**不换算成天数闸门**(换算规则没核出):实测 300 天的窗正常(194 根)、365 天与 399 天都被 400「日 K 及以上周期查询范围不能超过 12 个自然月」拒——更长序列请分段取。⛔ 周 / 月 / 季是**由区间内日 K 现聚合**的,窗口没盖满一个周期时那一根不是完整周期——这条核到过逐列等式:末根周 K(trade_date 20261015)的 `volume` 与 `amount` **正好等于**窗口内 10-12/13/14/15 那四天的和(180,070 手 / 98,888,888,750 元,一分不差),`open` = 四天里第一天的开盘、`close` = 最后一天的收盘、`open_interest` = 最后一天的持仓。`start_date` 单独给可以(实测 198 根),`end_date` 单独给上游 400(原话「end cannot be provided without start」),都不给 = 最新 `limit` 根(实测 limit=20 回末 20 根,而 `pagination.total` 仍是区间内根数 198)。`limit` 的默认值文档写 500(本层没核省略时到底给几根),实测传 5000 照给、区间内 198 根全给不截断;⛔ `limit=0` 回 **1 行**且 total 仍报 198(不报错),负数 400「请求参数格式错误:limit: invalid digit found in string」,所以本层要求正整数。单位与口径:`bar_time_ms` 是这根 K 的**结束**时刻(实测日档落在北京时间 15:00),`trade_date` / `trade_date_ms` 用上游那一列(YYYYMMDD 整数)归一而来;四档价格是合约报价单位(元/吨、元/千克、指数点,见 `ftshare_futures_contract_detail` 的 `quote_unit`);`volume_lots` 与 `open_interest_lots` 是**手**;`turnover_yuan` 是元;⛔ `vwap_per_lot_yuan` **不是价格**,是每手成交额:实测 194 行**全部**满足 `vwap ≡ amount ÷ volume`,与铜的合约乘数交叉核对——末行 545,566 元/手 ÷ 5 吨/手 = 109,113 元/吨,同日收盘 108,290 元/吨(差 0.76%,是加权均价与收盘价的差)。上游另有 `dominant_contract` / `forward_factor` / `backward_factor` 三列,实测那 194 行**全为 null**(文档也明写当前接口返回 null),本层不收录,因此**这颗不提供前复权 / 后复权**,跨合约拼接请自己按 `ftshare_futures_contracts` 的口径决定。当日行情:实测 2026-10-16 上午打当天区间 → 200 + 0 行(不是清单那颗那句 400),未来区间同样空——两种都是合法空。信封是 `data.items` + `data.total`、**没有分页**(`limit` 是唯一的取满手段),`pagination.total` 用上游那个数,这样 `limit` 截断看得见。';
|
|
2503
|
+
const FUTURES_KLINE_PATH = '/api/v1/market/data/futures/kline';
|
|
2504
|
+
/**
|
|
2505
|
+
* 期货合约历史 K 线(`futures/kline`)。
|
|
2506
|
+
* ⛔ 除区间外还核一次身份:响应行里的 `symbol` 是**去后缀转小写**的写法(实测请求 `CU2612.SHFE`
|
|
2507
|
+
* 回行 `cu2612`),所以比的是剥掉交易所后缀再转小写的那一半,不是整串相等。
|
|
2508
|
+
*/
|
|
2509
|
+
function createFuturesKlineSource(client) {
|
|
2510
|
+
const capability = 'ftshare_futures_kline';
|
|
2511
|
+
const allowed = ['symbol', 'interval', 'start_date', 'end_date', 'limit'];
|
|
2512
|
+
const schema = {
|
|
2513
|
+
capability,
|
|
2514
|
+
time_contract: getDataTimeContract(capability),
|
|
2515
|
+
name: 'get_futures_kline',
|
|
2516
|
+
source: 'http:ftshare.futures_kline',
|
|
2517
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
2518
|
+
source_label: 'ftshare',
|
|
2519
|
+
paginated: false,
|
|
2520
|
+
rowShape: { rowKey: 'item' },
|
|
2521
|
+
summary: '期货合约历史 K 线(日/周/月/季,单合约)',
|
|
2522
|
+
description: FUTURES_KLINE_DESCRIPTION,
|
|
2523
|
+
input_schema: {
|
|
2524
|
+
type: 'object',
|
|
2525
|
+
properties: {
|
|
2526
|
+
symbol: { type: 'string', description: '单只合约,短后缀或全称后缀都收(AG2610.SHF / CU2612.SHFE),本层换成 K 线要的全称后缀' },
|
|
2527
|
+
interval: { type: 'string', enum: [...FUTURES_INTERVALS], description: 'K 线周期;缺省 daily。⛔ 1d / 1w / week 一类写法上游静默回空,本层直接拒' },
|
|
2528
|
+
start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD(北京时间)' },
|
|
2529
|
+
end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD;与起始相距不超过 12 个自然月' },
|
|
2530
|
+
limit: { type: 'integer', minimum: 1, description: '保留最新 N 根(实测 5000 照给、0 只回 1 行);省略 = 用上游默认(文档写 500,本层未核)' },
|
|
2531
|
+
},
|
|
2532
|
+
required: ['symbol'], additionalProperties: false,
|
|
2533
|
+
},
|
|
2534
|
+
output_schema: output(futuresKlineRow),
|
|
2535
|
+
};
|
|
2536
|
+
const normalize = (params) => {
|
|
2537
|
+
assertKnown(params, allowed);
|
|
2538
|
+
const normalized = { symbol: futuresSymbol(params.symbol) };
|
|
2539
|
+
if (params.interval !== undefined) {
|
|
2540
|
+
const interval = String(params.interval).toLowerCase();
|
|
2541
|
+
if (!FUTURES_INTERVALS.includes(interval))
|
|
2542
|
+
throw new FtshareError('invalid_parameter', `parameter interval must be one of ${FUTURES_INTERVALS.join(' / ')}(实测 1d / 1w / week 上游静默回 0 行,只有 day / 1M / 1Q / 60m 这类才 400)`);
|
|
2543
|
+
normalized.interval = interval;
|
|
2544
|
+
}
|
|
2545
|
+
if (params.end_date !== undefined && params.start_date === undefined)
|
|
2546
|
+
throw new FtshareError('invalid_parameter', 'parameter end_date 不能单独给(实测上游 400「end cannot be provided without start: please provide both start and end, or omit both」)');
|
|
2547
|
+
if (params.start_date !== undefined)
|
|
2548
|
+
normalized.start_date = date(params.start_date, 'start_date');
|
|
2549
|
+
if (params.end_date !== undefined)
|
|
2550
|
+
normalized.end_date = date(params.end_date, 'end_date');
|
|
2551
|
+
if (normalized.start_date !== undefined && normalized.end_date !== undefined && String(normalized.start_date) > String(normalized.end_date))
|
|
2552
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
|
|
2553
|
+
if (params.limit !== undefined) {
|
|
2554
|
+
const limit = Number(params.limit);
|
|
2555
|
+
// 实测 limit=0 不报错、只回 1 行,而 total 仍报区间内的根数——那会被读成"这根合约只走过一天"。
|
|
2556
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
2557
|
+
throw new FtshareError('invalid_parameter', 'parameter limit must be an integer >= 1(实测 0 静默回 1 行、负数被上游 400 拒)');
|
|
2558
|
+
normalized.limit = limit;
|
|
2559
|
+
}
|
|
2560
|
+
return normalized;
|
|
2561
|
+
};
|
|
2562
|
+
return {
|
|
2563
|
+
schema,
|
|
2564
|
+
normalizeParams: normalize,
|
|
2565
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
2566
|
+
execute: async (request, signal) => {
|
|
2567
|
+
const params = normalize(request.params);
|
|
2568
|
+
const search = query({
|
|
2569
|
+
symbol: params.symbol,
|
|
2570
|
+
interval: params.interval,
|
|
2571
|
+
start: params.start_date === undefined ? undefined : Date.parse(`${params.start_date}T00:00:00+08:00`),
|
|
2572
|
+
end: params.end_date === undefined ? undefined : Date.parse(`${params.end_date}T23:59:59+08:00`),
|
|
2573
|
+
limit: params.limit,
|
|
2574
|
+
}, ['symbol', 'interval', 'start', 'end', 'limit']);
|
|
2575
|
+
const { rows, total } = readPaged(await client.getJson(FUTURES_KLINE_PATH, search, signal));
|
|
2576
|
+
if (params.start_date !== undefined && params.end_date !== undefined)
|
|
2577
|
+
assertInRange(rows, String(params.start_date), String(params.end_date), (row) => row.trade_date);
|
|
2578
|
+
assertFiltered(rows, { field: 'symbol', expected: String(params.symbol).split('.')[0].toLowerCase(), label: 'symbol' });
|
|
2579
|
+
const item = rows.map((row) => {
|
|
2580
|
+
const day = normalizeDateCell(row.trade_date);
|
|
2581
|
+
return {
|
|
2582
|
+
contract: String(params.symbol), symbol: String(row.symbol ?? ''),
|
|
2583
|
+
trade_date: day.iso, trade_date_ms: day.ms, bar_time_ms: numberOrNull(row.datetime),
|
|
2584
|
+
open_price: numberOrNull(row.open), high_price: numberOrNull(row.high),
|
|
2585
|
+
low_price: numberOrNull(row.low), close_price: numberOrNull(row.close),
|
|
2586
|
+
volume_lots: integerOrNull(row.volume), turnover_yuan: numberOrNull(row.amount),
|
|
2587
|
+
vwap_per_lot_yuan: numberOrNull(row.vwap), open_interest_lots: numberOrNull(row.open_interest),
|
|
2588
|
+
};
|
|
2589
|
+
});
|
|
2590
|
+
// ⛔ total 用上游那个(区间内的根数),不是 item.length:limit 截断必须看得见。
|
|
2591
|
+
return { data: snapshotEnvelope(item, total), schema: output(futuresKlineRow) };
|
|
2592
|
+
},
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
const futuresLimitRow = {
|
|
2596
|
+
type: 'object',
|
|
2597
|
+
properties: {
|
|
2598
|
+
ts_code: { type: 'string' },
|
|
2599
|
+
exchange: { type: 'string' },
|
|
2600
|
+
product: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2601
|
+
contract_name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2602
|
+
trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2603
|
+
trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2604
|
+
up_limit_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2605
|
+
down_limit_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2606
|
+
m_ratio_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2607
|
+
},
|
|
2608
|
+
additionalProperties: false,
|
|
2609
|
+
};
|
|
2610
|
+
const FUTURES_LIMIT_DESCRIPTION = '按**一家交易所**获取期货合约的每日涨停价与跌停价(`exchange` 实测必填:不给就 400「缺少必填参数:交易所」;六家写法大小写不敏感,实测 `shfe` 与 `SHFE` 都回同一个 total=301)。日期两模式二选一:`trade_date` 单日,或 `start_date` + `end_date` 区间(实测区间有效:AG2610 在 2026-10-06..10-15 回 6 个交易日)。⛔ 行内 `ts_code` 的后缀拼法**随交易所而不随合约**:SHFE / DCE / GFEX / INE 分别是 `AG2610.SHF` / `A2611.DCE` / `LC2610.GFE` / `BC2610.INE`,而中金所写 `.CFX`(`IC2610.CFX`)、郑商所写 `.ZCE`(`AP610.ZCE`)——与清单那两颗(`.CFE` / `.CZC`)又不是一套;发给本层时长短拼法都能命中(实测 `AG2610.SHFE` 也命中,返回行仍写短后缀),要打到 K 线请用 `ftshare_futures_kline`,两种拼法本层都认。⛔ **分页上限压在 50,比文档写的 200 低得多**:实测 SHFE 2026-10-15 全天 301 行,`page_size` 20 与 50 可过,**60 起整页 400「missing pre-settlement price」**(100 / 500 / 1000 / 1500 / 2000 全是这一句),而 CZCE 传 51、100 都正常(total=243)、DCE 传 50 正常(total=232)——失败的不是页宽,是上游 SHFE 数据里某些合约**缺前结算价**;恢复手段是把页压回 50 以内逐页取(本层默认就锁在 50,模型不必自己试),不要换日期重试。`up_limit_price` / `down_limit_price` 是**价格**(合约报价单位,与 `ftshare_futures_settle` 同量纲):实测按 `ts_code` 与结算参数对上号之后,`up_limit ÷ settle` 在 248 行样本里落在 **1.0388~1.2706**(SHFE 1.056~1.271、DCE 1.048~1.114、CZCE 1.039~1.140、GFEX 1.080~1.214、INE 1.070~1.239)、`down_limit ÷ settle` 在 SHFE 那 50 行是 **0.8395~0.9750**——同量纲,所以这两个数可以直接与结算价对照着读。`m_ratio_pct` 是**百分数原值**(12 = 12%):文档写"最低交易保证金率,单位 %",实测它是 `ftshare_futures_settle` 里**套保两档的较低值 × 100**——五所合计 248 行**逐行完全吻合**(AD2612:投机 0.10、套保 0.09、m_ratio 9),而它**不等于投机档**(GFEX 48 行里只有 26 行等于多头投机、INE 50 行里只有 10 行)。⛔ 但 `m_ratio_pct` **不能用来复原涨跌停价**:`settle × (1 + m_ratio ÷ 100)` 与 `up_limit` 实测 248 行里只有 11 行吻合(SHFE 与 INE 各 0 行),因为涨跌停用的是**前结算价**与交易所当天的比例,不是这一档保证金率——不要拿它推算涨跌幅度。中金所那 28 行没法做这套交叉核对:实测 `ftshare_futures_settle` 对 CFFEX **回 0 行**(见那颗)。当日:实测 2026-10-16 打 `trade_date=20261016` → **200 + total=0**(与 `ftshare_futures_contracts` 那句 400 不同),收盘前拿到空页是合法的。行按 `trade_date` **降序**给(实测区间模式首行 20261015、第五行 20261009),"最近一天"在第一页。品种过滤 `cont` 实测生效(AG → total=12)。';
|
|
2611
|
+
/** 期货合约涨跌停价(`futures/ft-limit`):`exchange` 必填 + 单日或区间二选一。 */
|
|
2612
|
+
function createFuturesLimitPriceSource(client) {
|
|
2613
|
+
const capability = 'ftshare_futures_limit_price';
|
|
2614
|
+
const allowed = ['exchange', 'trade_date', 'start_date', 'end_date', 'ts_code', 'cont', 'page', 'size'];
|
|
2615
|
+
const path = '/api/v1/market/data/futures/ft-limit';
|
|
2616
|
+
const schema = {
|
|
2617
|
+
capability,
|
|
2618
|
+
time_contract: getDataTimeContract(capability),
|
|
2619
|
+
name: 'get_futures_ft_limit',
|
|
2620
|
+
source: 'http:ftshare.futures_ft_limit',
|
|
2621
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
2622
|
+
source_label: 'ftshare',
|
|
2623
|
+
paginated: true,
|
|
2624
|
+
rowShape: { rowKey: 'item' },
|
|
2625
|
+
summary: '期货合约每日涨跌停价(按交易所,含最低保证金原值)',
|
|
2626
|
+
description: FUTURES_LIMIT_DESCRIPTION,
|
|
2627
|
+
input_schema: {
|
|
2628
|
+
type: 'object',
|
|
2629
|
+
properties: {
|
|
2630
|
+
exchange: { type: 'string', enum: [...FUTURES_EXCHANGES], description: '交易所(必填,大小写都收)' },
|
|
2631
|
+
trade_date: { type: 'string', description: '单日交易日 YYYY-MM-DD;与区间二选一' },
|
|
2632
|
+
start_date: { type: 'string', description: '区间起始 YYYY-MM-DD,必须与 end_date 同时给' },
|
|
2633
|
+
end_date: { type: 'string', description: '区间截止 YYYY-MM-DD' },
|
|
2634
|
+
ts_code: { type: 'string', description: '单只合约过滤(短后缀或全称后缀都实测命中)' },
|
|
2635
|
+
cont: { type: 'string', description: '品种代码过滤(如 AG),实测生效' },
|
|
2636
|
+
page: { type: 'integer', minimum: 1 },
|
|
2637
|
+
// ⛔ 50 是实测上限(SHFE 60 起整页失败),文档写的 200 不可信,见 description。
|
|
2638
|
+
size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_50 },
|
|
2639
|
+
},
|
|
2640
|
+
required: ['exchange'], additionalProperties: false,
|
|
2641
|
+
},
|
|
2642
|
+
output_schema: output(futuresLimitRow),
|
|
2643
|
+
};
|
|
2644
|
+
const normalize = (params) => {
|
|
2645
|
+
const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_50);
|
|
2646
|
+
const normalized = { exchange: futuresExchange(params.exchange, FUTURES_EXCHANGES), page, size };
|
|
2647
|
+
const hasRange = params.start_date !== undefined || params.end_date !== undefined;
|
|
2648
|
+
if (params.trade_date !== undefined && hasRange)
|
|
2649
|
+
throw new FtshareError('invalid_parameter', 'parameter trade_date 与 start_date / end_date 二选一,不许混给(实测上游对缺一边的组合直接 400)');
|
|
2650
|
+
if (params.trade_date === undefined && !hasRange)
|
|
2651
|
+
throw new FtshareError('invalid_parameter', 'parameter trade_date 或 start_date + end_date 至少要给一组');
|
|
2652
|
+
if (params.trade_date !== undefined)
|
|
2653
|
+
normalized.trade_date = date(params.trade_date, 'trade_date');
|
|
2654
|
+
if (hasRange) {
|
|
2655
|
+
if (params.start_date === undefined || params.end_date === undefined)
|
|
2656
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date 与 end_date 必须同时给');
|
|
2657
|
+
normalized.start_date = date(params.start_date, 'start_date');
|
|
2658
|
+
normalized.end_date = date(params.end_date, 'end_date');
|
|
2659
|
+
if (String(normalized.start_date) > String(normalized.end_date))
|
|
2660
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
|
|
2661
|
+
}
|
|
2662
|
+
if (params.ts_code !== undefined)
|
|
2663
|
+
normalized.ts_code = futuresTsCode(params.ts_code);
|
|
2664
|
+
if (params.cont !== undefined)
|
|
2665
|
+
normalized.cont = String(params.cont).trim().toUpperCase();
|
|
2666
|
+
return normalized;
|
|
2667
|
+
};
|
|
2668
|
+
return {
|
|
2669
|
+
schema,
|
|
2670
|
+
normalizeParams: normalize,
|
|
2671
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
2672
|
+
execute: async (request, signal) => {
|
|
2673
|
+
const params = normalize(request.params);
|
|
2674
|
+
const search = query({
|
|
2675
|
+
exchange: params.exchange,
|
|
2676
|
+
ts_code: params.ts_code,
|
|
2677
|
+
cont: params.cont,
|
|
2678
|
+
trade_date: params.trade_date === undefined ? undefined : compactDate(String(params.trade_date)),
|
|
2679
|
+
start_date: params.start_date === undefined ? undefined : compactDate(String(params.start_date)),
|
|
2680
|
+
end_date: params.end_date === undefined ? undefined : compactDate(String(params.end_date)),
|
|
2681
|
+
page: params.page,
|
|
2682
|
+
page_size: params.size,
|
|
2683
|
+
}, ['exchange', 'ts_code', 'cont', 'trade_date', 'start_date', 'end_date', 'page', 'page_size']);
|
|
2684
|
+
const { rows, total } = readPaged(await client.getJson(path, search, signal));
|
|
2685
|
+
if (params.trade_date !== undefined) {
|
|
2686
|
+
assertFiltered(rows, { field: 'trade_date', expected: compactDate(String(params.trade_date)), label: 'trade_date' });
|
|
2687
|
+
}
|
|
2688
|
+
else {
|
|
2689
|
+
assertInRange(rows, String(params.start_date), String(params.end_date), (row) => row.trade_date);
|
|
2690
|
+
}
|
|
2691
|
+
assertFiltered(rows, { field: 'exchange', expected: String(params.exchange), label: 'exchange' });
|
|
2692
|
+
if (params.ts_code !== undefined) {
|
|
2693
|
+
// 上游收下长后缀、返回行写短后缀:核身份只能比**剥掉交易所后缀**的那一半。
|
|
2694
|
+
const bare = String(params.ts_code).split('.')[0];
|
|
2695
|
+
assertFiltered(rows.map((row) => ({ ...row, ts_code: String(row.ts_code ?? '').split('.')[0] })), { field: 'ts_code', expected: bare, label: 'ts_code' });
|
|
2696
|
+
}
|
|
2697
|
+
if (params.cont !== undefined)
|
|
2698
|
+
assertFiltered(rows, { field: 'cont', expected: String(params.cont), label: 'cont' });
|
|
2699
|
+
const item = rows.map((row) => {
|
|
2700
|
+
const day = normalizeDateCell(row.trade_date);
|
|
2701
|
+
return {
|
|
2702
|
+
ts_code: String(row.ts_code ?? ''), exchange: textOrNull(row.exchange), product: textOrNull(row.cont),
|
|
2703
|
+
contract_name: textOrNull(row.name), trade_date: day.iso, trade_date_ms: day.ms,
|
|
2704
|
+
up_limit_price: numberOrNull(row.up_limit), down_limit_price: numberOrNull(row.down_limit),
|
|
2705
|
+
m_ratio_pct: numberOrNull(row.m_ratio),
|
|
2706
|
+
};
|
|
2707
|
+
});
|
|
2708
|
+
return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(futuresLimitRow) };
|
|
2709
|
+
},
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
const futuresSettleRow = {
|
|
2713
|
+
type: 'object',
|
|
2714
|
+
properties: {
|
|
2715
|
+
ts_code: { type: 'string' },
|
|
2716
|
+
exchange: { type: 'string' },
|
|
2717
|
+
trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2718
|
+
trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2719
|
+
settle_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2720
|
+
long_margin_rate: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2721
|
+
short_margin_rate: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2722
|
+
b_hedging_margin_rate: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2723
|
+
s_hedging_margin_rate: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2724
|
+
trading_fee_rate: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2725
|
+
trading_fee: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2726
|
+
delivery_fee: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2727
|
+
offset_today_fee: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2728
|
+
},
|
|
2729
|
+
additionalProperties: false,
|
|
2730
|
+
};
|
|
2731
|
+
const FUTURES_SETTLE_DESCRIPTION = '按**一家交易所**获取期货合约的每日结算参数(结算价、四档保证金率、手续费)。`exchange` 必填(实测缺失 400「缺少必填参数:交易所」)。⛔ **中金所这一颗没有数据**:实测 2026-10-15 与 10-08..10-14 区间、`CFFEX` 与 `CFX` 两种写法都回 **200 + total=0**——中金所的保证金与手续费本层取不到,那不是参数错、重试也没用(中金所的涨跌停请用 `ftshare_futures_limit_price`,实测 28 行有数)。日期同样二选一:`trade_date` 单日或 `start_date` + `end_date` 区间,⛔ **区间跨度上限 31 天**(实测 2026-09-10..10-15 → 400「start_date and end_date must be at most 31 days apart」;这一条文档与实测一致,本层在参数层拦下,把上游原话带进错误信息)。`size` 上限 **4000**(实测传 5000 静默夹到 4000、回显 `pageSize: 4000`,超限不报错,所以只能在参数层拦)。`ts_code` 实测短后缀与全称后缀都能命中(`AG2610.SHF` 与 `AG2610.SHFE` 同样 total=6),返回行写的是各所自己的拼法(SHFE `.SHF`、DCE `.DCE`、GFEX `.GFE`、INE `.INE`、郑商所 `.ZCE`)。单位与口径:`settle_price` 是结算价(合约报价单位——实测 2026-10-15 SHFE 那 301 行 min 521.3 / max 377140,量纲随合约本身),它是与 `ftshare_futures_limit_price` 交叉核对的那一侧(实测同一天按 `ts_code` 对上 248 行、`up_limit ÷ settle` 全部落在 1.04~1.27)。四档保证金率(`long_margin_rate` / `short_margin_rate` / `b_hedging_margin_rate` / `s_hedging_margin_rate`)实测落在 **0.06~0.22**,按**小数比例**读(0.22 = 22%);⛔ 投机两档与套保两档**是两回事**(实测 AD2612 投机 0.10、套保 0.09),涨跌停那颗的 `m_ratio_pct` 实测等于**套保两档的较低值 × 100**(五所 248 行逐行吻合),别拿它当投机保证金。⛔ `trading_fee_rate` / `trading_fee` / `delivery_fee` / `offset_today_fee` 这四列**单位上游没披露、本层也没核出来**(实测同一交易所里 trading_fee_rate 取 0~0.6、offset_today_fee 取 0.005~10、trading_fee 取 0~20、delivery_fee 取 0~2):是百分数还是千分数无法判定,所以**原样透出、不加单位后缀、不参与换算**——要算交易成本请用别的能力核过之后再写结论。数值列在上游全是**字符串**,本层转成数值、空值转 null 不补 0。当日:实测 2026-10-16 打 `trade_date=20261016` → **200 + total=0**(不是清单那颗那句 400),20:20 之前拿到空页是合法的。';
|
|
2732
|
+
/** 期货每日结算参数(`futures/fut-settle`):区间跨度实测 31 天上限。 */
|
|
2733
|
+
function createFuturesSettleSource(client) {
|
|
2734
|
+
const capability = 'ftshare_futures_settle';
|
|
2735
|
+
const allowed = ['exchange', 'trade_date', 'start_date', 'end_date', 'ts_code', 'page', 'size'];
|
|
2736
|
+
const path = '/api/v1/market/data/futures/fut-settle';
|
|
2737
|
+
const schema = {
|
|
2738
|
+
capability,
|
|
2739
|
+
time_contract: getDataTimeContract(capability),
|
|
2740
|
+
name: 'get_futures_settle',
|
|
2741
|
+
source: 'http:ftshare.fut_settle',
|
|
2742
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
2743
|
+
source_label: 'ftshare',
|
|
2744
|
+
paginated: true,
|
|
2745
|
+
rowShape: { rowKey: 'item' },
|
|
2746
|
+
summary: '期货每日结算参数(结算价/保证金率/手续费)',
|
|
2747
|
+
description: FUTURES_SETTLE_DESCRIPTION,
|
|
2748
|
+
input_schema: {
|
|
2749
|
+
type: 'object',
|
|
2750
|
+
properties: {
|
|
2751
|
+
exchange: { type: 'string', enum: [...FUTURES_EXCHANGES], description: '交易所(必填)' },
|
|
2752
|
+
trade_date: { type: 'string', description: '单日交易日 YYYY-MM-DD;与区间二选一' },
|
|
2753
|
+
start_date: { type: 'string', description: '区间起始 YYYY-MM-DD,与 end_date 同时给,跨度不超过 31 天' },
|
|
2754
|
+
end_date: { type: 'string', description: '区间截止 YYYY-MM-DD' },
|
|
2755
|
+
ts_code: { type: 'string', description: '单只合约过滤(短后缀或全称后缀都实测命中)' },
|
|
2756
|
+
page: { type: 'integer', minimum: 1 },
|
|
2757
|
+
size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_4000 },
|
|
2758
|
+
},
|
|
2759
|
+
required: ['exchange'], additionalProperties: false,
|
|
2760
|
+
},
|
|
2761
|
+
output_schema: output(futuresSettleRow),
|
|
2762
|
+
};
|
|
2763
|
+
const normalize = (params) => {
|
|
2764
|
+
const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_4000);
|
|
2765
|
+
const normalized = { exchange: futuresExchange(params.exchange, FUTURES_EXCHANGES), page, size };
|
|
2766
|
+
const hasRange = params.start_date !== undefined || params.end_date !== undefined;
|
|
2767
|
+
if (params.trade_date !== undefined && hasRange)
|
|
2768
|
+
throw new FtshareError('invalid_parameter', 'parameter trade_date 与 start_date / end_date 二选一,不许混给');
|
|
2769
|
+
if (params.trade_date === undefined && !hasRange)
|
|
2770
|
+
throw new FtshareError('invalid_parameter', 'parameter trade_date 或 start_date + end_date 至少要给一组');
|
|
2771
|
+
if (params.trade_date !== undefined)
|
|
2772
|
+
normalized.trade_date = date(params.trade_date, 'trade_date');
|
|
2773
|
+
if (hasRange) {
|
|
2774
|
+
if (params.start_date === undefined || params.end_date === undefined)
|
|
2775
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date 与 end_date 必须同时给');
|
|
2776
|
+
normalized.start_date = date(params.start_date, 'start_date');
|
|
2777
|
+
normalized.end_date = date(params.end_date, 'end_date');
|
|
2778
|
+
const spanDays = (Date.parse(`${normalized.end_date}T00:00:00Z`) - Date.parse(`${normalized.start_date}T00:00:00Z`)) / 86_400_000;
|
|
2779
|
+
if (spanDays < 0)
|
|
2780
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
|
|
2781
|
+
if (spanDays > 31)
|
|
2782
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date / end_date 跨度不能超过 31 天(实测上游 400「start_date and end_date must be at most 31 days apart」)——要更长区间请按月分段取');
|
|
2783
|
+
}
|
|
2784
|
+
if (params.ts_code !== undefined)
|
|
2785
|
+
normalized.ts_code = futuresTsCode(params.ts_code);
|
|
2786
|
+
return normalized;
|
|
2787
|
+
};
|
|
2788
|
+
return {
|
|
2789
|
+
schema,
|
|
2790
|
+
normalizeParams: normalize,
|
|
2791
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
2792
|
+
execute: async (request, signal) => {
|
|
2793
|
+
const params = normalize(request.params);
|
|
2794
|
+
const search = query({
|
|
2795
|
+
exchange: params.exchange,
|
|
2796
|
+
ts_code: params.ts_code,
|
|
2797
|
+
trade_date: params.trade_date === undefined ? undefined : compactDate(String(params.trade_date)),
|
|
2798
|
+
start_date: params.start_date === undefined ? undefined : compactDate(String(params.start_date)),
|
|
2799
|
+
end_date: params.end_date === undefined ? undefined : compactDate(String(params.end_date)),
|
|
2800
|
+
page: params.page,
|
|
2801
|
+
page_size: params.size,
|
|
2802
|
+
}, ['exchange', 'ts_code', 'trade_date', 'start_date', 'end_date', 'page', 'page_size']);
|
|
2803
|
+
const { rows, total } = readPaged(await client.getJson(path, search, signal));
|
|
2804
|
+
if (params.trade_date !== undefined)
|
|
2805
|
+
assertFiltered(rows, { field: 'trade_date', expected: compactDate(String(params.trade_date)), label: 'trade_date' });
|
|
2806
|
+
else
|
|
2807
|
+
assertInRange(rows, String(params.start_date), String(params.end_date), (row) => row.trade_date);
|
|
2808
|
+
assertFiltered(rows, { field: 'exchange', expected: String(params.exchange), label: 'exchange' });
|
|
2809
|
+
if (params.ts_code !== undefined) {
|
|
2810
|
+
const bare = String(params.ts_code).split('.')[0];
|
|
2811
|
+
assertFiltered(rows.map((row) => ({ ...row, ts_code: String(row.ts_code ?? '').split('.')[0] })), { field: 'ts_code', expected: bare, label: 'ts_code' });
|
|
2812
|
+
}
|
|
2813
|
+
const item = rows.map((row) => {
|
|
2814
|
+
const day = normalizeDateCell(row.trade_date);
|
|
2815
|
+
return {
|
|
2816
|
+
ts_code: String(row.ts_code ?? ''), exchange: textOrNull(row.exchange), trade_date: day.iso, trade_date_ms: day.ms,
|
|
2817
|
+
settle_price: numberOrNull(row.settle),
|
|
2818
|
+
long_margin_rate: numberOrNull(row.long_margin_rate), short_margin_rate: numberOrNull(row.short_margin_rate),
|
|
2819
|
+
b_hedging_margin_rate: numberOrNull(row.b_hedging_margin_rate), s_hedging_margin_rate: numberOrNull(row.s_hedging_margin_rate),
|
|
2820
|
+
trading_fee_rate: numberOrNull(row.trading_fee_rate), trading_fee: numberOrNull(row.trading_fee),
|
|
2821
|
+
delivery_fee: numberOrNull(row.delivery_fee), offset_today_fee: numberOrNull(row.offset_today_fee),
|
|
2822
|
+
};
|
|
2823
|
+
});
|
|
2824
|
+
return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(futuresSettleRow) };
|
|
2825
|
+
},
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
const warrantRow = {
|
|
2829
|
+
type: 'object',
|
|
2830
|
+
properties: {
|
|
2831
|
+
product: { type: 'string' },
|
|
2832
|
+
futures_name: { type: 'string' },
|
|
2833
|
+
exchange: { type: 'string' },
|
|
2834
|
+
warehouse: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2835
|
+
warehouse_id: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2836
|
+
area: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2837
|
+
place: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2838
|
+
year_label: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2839
|
+
grade: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2840
|
+
brand: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2841
|
+
unit: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2842
|
+
trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2843
|
+
trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2844
|
+
prev_warrant_volume: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2845
|
+
warrant_volume: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2846
|
+
warrant_change: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2847
|
+
},
|
|
2848
|
+
additionalProperties: false,
|
|
2849
|
+
};
|
|
2850
|
+
const WARRANT_DESCRIPTION = '按交易日获取**商品交易所仓单日报**明细,一行是「一个品种 × 一家仓库」。日期三选一即可:`trade_date` 或 `start_date` + `end_date`(实测空参 400「证券代码、交易日期、开始日期或结束日期至少需要提供一个」——本层同样要求至少给一个条件,否则一次要拉全市场几年的行)。`exchange` 只五家(SHFE / INE / CZCE / DCE / GFEX,实测中金所不发仓单),`symbol` 是品种代码(实测 CU 配 SHFE 回 32 行、过滤生效)。`size` 上限 **4000**(实测 5000 静默夹到 4000 并回显;一天全市场实测 1469 行,一页就能取完)。⛔ **`unit` 随行而变,这是这张表最容易读错的一列**:实测 2026-10-15 一天里 CZCE 那 105 行是「张」、SHFE 那 32 行(铜)是「吨」、DCE 全部 193 行是「手」、GFEX 86 行是「手」,而 **INE 那 47 行同时有「吨」29 行与「桶」18 行**——同一家交易所内部就有两种单位。任何"仓单总量"都必须先按 `unit` 分组成员再相加;跨品种、跨所直接相加得到的是一个没有人认领的数。⛔ **DCE 有「小计」行的坑**:实测那 193 行里 49 行的 `futures_name` 带「小计」后缀(`豆一小计` 与 `豆一` 在同一品种 `A` 下并存),而两组的**仓库集合互不重叠**(实测 A / LH / V 三组重叠 0 家仓库:豆一 12 家 vs 豆一小计 9 家),所以「小计」不是把明细再加一遍、而是另一批仓库的名字;但按 `symbol` 分组求和会把这两种视图混在一起——要"某品种仓单"请**按 `futures_name` 分组**并看清它带不带「小计」。`warrant_change = warrant_volume − prev_warrant_volume` 实测 100 行**0 违例**,变动列可信。⛔ 列位随交易所而不随数据缺口:实测 DCE 193 行的 `warehouse_id` / `area` / `place` / `year_label` / `grade` / `brand` **整列为 null**;CZCE 那 105 行反过来只有 `area` 与 `brand` 全 null;SHFE 32 行只有 `area` 有值(实测「上海」);GFEX 86 行只有 `brand` 有 28 行——null 是"这一所不披露这一列"。上游另有 `pd` 与 `is_ct` 两列,实测五所合计 **463 行全为 null**、含义无法核出,本层不收录。当日:实测 2026-10-16 打 `trade_date=20261016` → 200 + total=0(合法的空,20:20 之前本就还没有当日仓单)。';
|
|
2851
|
+
/** 期货仓单日报(`fut_wsr`):⛔ 数量列必须先按 `unit` 分组,见 description。 */
|
|
2852
|
+
function createWarrantSource(client) {
|
|
2853
|
+
const capability = 'ftshare_futures_warrant';
|
|
2854
|
+
const allowed = ['trade_date', 'start_date', 'end_date', 'symbol', 'exchange', 'page', 'size'];
|
|
2855
|
+
const path = '/api/v1/market/data/futures/fut-wsr';
|
|
2856
|
+
const schema = {
|
|
2857
|
+
capability,
|
|
2858
|
+
time_contract: getDataTimeContract(capability),
|
|
2859
|
+
name: 'get_futures_warrant_daily',
|
|
2860
|
+
source: 'http:ftshare.fut_wsr',
|
|
2861
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
2862
|
+
source_label: 'ftshare',
|
|
2863
|
+
paginated: true,
|
|
2864
|
+
rowShape: { rowKey: 'item' },
|
|
2865
|
+
summary: '期货仓单日报(分交易所,数量单位随行而变)',
|
|
2866
|
+
description: WARRANT_DESCRIPTION,
|
|
2867
|
+
input_schema: {
|
|
2868
|
+
type: 'object',
|
|
2869
|
+
properties: {
|
|
2870
|
+
trade_date: { type: 'string', description: '单日交易日 YYYY-MM-DD;与区间二选一' },
|
|
2871
|
+
start_date: { type: 'string', description: '区间起始 YYYY-MM-DD,与 end_date 同时给' },
|
|
2872
|
+
end_date: { type: 'string', description: '区间截止 YYYY-MM-DD' },
|
|
2873
|
+
symbol: { type: 'string', description: '品种代码过滤(如 CU / A / SR),大小写都收' },
|
|
2874
|
+
exchange: { type: 'string', enum: [...WARRANT_EXCHANGES], description: '交易所(五家;中金所不发仓单)' },
|
|
2875
|
+
page: { type: 'integer', minimum: 1 },
|
|
2876
|
+
size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_4000 },
|
|
2877
|
+
},
|
|
2878
|
+
required: [], additionalProperties: false,
|
|
2879
|
+
},
|
|
2880
|
+
output_schema: output(warrantRow),
|
|
2881
|
+
};
|
|
2882
|
+
const normalize = (params) => {
|
|
2883
|
+
const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_4000);
|
|
2884
|
+
const normalized = { page, size };
|
|
2885
|
+
const hasRange = params.start_date !== undefined || params.end_date !== undefined;
|
|
2886
|
+
if (params.trade_date !== undefined && hasRange)
|
|
2887
|
+
throw new FtshareError('invalid_parameter', 'parameter trade_date 与 start_date / end_date 二选一,不许混给');
|
|
2888
|
+
if (params.trade_date === undefined && !hasRange)
|
|
2889
|
+
throw new FtshareError('invalid_parameter', 'parameter trade_date 或 start_date + end_date 至少要给一组(实测空参上游 400「证券代码、交易日期、开始日期或结束日期至少需要提供一个」)');
|
|
2890
|
+
if (params.trade_date !== undefined)
|
|
2891
|
+
normalized.trade_date = date(params.trade_date, 'trade_date');
|
|
2892
|
+
if (hasRange) {
|
|
2893
|
+
if (params.start_date === undefined || params.end_date === undefined)
|
|
2894
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date 与 end_date 必须同时给');
|
|
2895
|
+
normalized.start_date = date(params.start_date, 'start_date');
|
|
2896
|
+
normalized.end_date = date(params.end_date, 'end_date');
|
|
2897
|
+
if (String(normalized.start_date) > String(normalized.end_date))
|
|
2898
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
|
|
2899
|
+
}
|
|
2900
|
+
if (params.symbol !== undefined)
|
|
2901
|
+
normalized.symbol = String(params.symbol).trim().toUpperCase();
|
|
2902
|
+
if (params.exchange !== undefined)
|
|
2903
|
+
normalized.exchange = futuresExchange(params.exchange, WARRANT_EXCHANGES);
|
|
2904
|
+
return normalized;
|
|
2905
|
+
};
|
|
2906
|
+
return {
|
|
2907
|
+
schema,
|
|
2908
|
+
normalizeParams: normalize,
|
|
2909
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
2910
|
+
execute: async (request, signal) => {
|
|
2911
|
+
const params = normalize(request.params);
|
|
2912
|
+
const search = query({
|
|
2913
|
+
trade_date: params.trade_date === undefined ? undefined : compactDate(String(params.trade_date)),
|
|
2914
|
+
start_date: params.start_date === undefined ? undefined : compactDate(String(params.start_date)),
|
|
2915
|
+
end_date: params.end_date === undefined ? undefined : compactDate(String(params.end_date)),
|
|
2916
|
+
symbol: params.symbol,
|
|
2917
|
+
exchange: params.exchange,
|
|
2918
|
+
page: params.page,
|
|
2919
|
+
page_size: params.size,
|
|
2920
|
+
}, ['trade_date', 'start_date', 'end_date', 'symbol', 'exchange', 'page', 'page_size']);
|
|
2921
|
+
const { rows, total } = readPaged(await client.getJson(path, search, signal));
|
|
2922
|
+
if (params.trade_date !== undefined)
|
|
2923
|
+
assertFiltered(rows, { field: 'trade_date', expected: compactDate(String(params.trade_date)), label: 'trade_date' });
|
|
2924
|
+
else
|
|
2925
|
+
assertInRange(rows, String(params.start_date), String(params.end_date), (row) => row.trade_date);
|
|
2926
|
+
if (params.symbol !== undefined)
|
|
2927
|
+
assertFiltered(rows, { field: 'symbol', expected: String(params.symbol), label: 'symbol' });
|
|
2928
|
+
if (params.exchange !== undefined)
|
|
2929
|
+
assertFiltered(rows, { field: 'exchange', expected: String(params.exchange), label: 'exchange' });
|
|
2930
|
+
const item = rows.map((row) => {
|
|
2931
|
+
const day = normalizeDateCell(row.trade_date);
|
|
2932
|
+
return {
|
|
2933
|
+
product: String(row.symbol ?? ''), futures_name: textOrNull(row.fut_name), exchange: textOrNull(row.exchange),
|
|
2934
|
+
warehouse: textOrNull(row.warehouse), warehouse_id: textOrNull(row.wh_id),
|
|
2935
|
+
area: textOrNull(row.area), place: textOrNull(row.place),
|
|
2936
|
+
year_label: textOrNull(row.year), grade: textOrNull(row.grade), brand: textOrNull(row.brand),
|
|
2937
|
+
unit: textOrNull(row.unit),
|
|
2938
|
+
trade_date: day.iso, trade_date_ms: day.ms,
|
|
2939
|
+
prev_warrant_volume: numberOrNull(row.pre_vol), warrant_volume: numberOrNull(row.vol), warrant_change: numberOrNull(row.vol_chg),
|
|
2940
|
+
};
|
|
2941
|
+
});
|
|
2942
|
+
return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(warrantRow) };
|
|
2943
|
+
},
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
2946
|
+
const futuresWeeklyRow = {
|
|
2947
|
+
type: 'object',
|
|
2948
|
+
properties: {
|
|
2949
|
+
exchange: { type: 'string' },
|
|
2950
|
+
product: { type: 'string' },
|
|
2951
|
+
product_name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2952
|
+
week: { type: 'string' },
|
|
2953
|
+
week_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
2954
|
+
week_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2955
|
+
volume_lots: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2956
|
+
volume_yoy_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2957
|
+
amount_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2958
|
+
amount_yoy_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2959
|
+
cum_volume_lots: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2960
|
+
cum_volume_yoy_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2961
|
+
cum_amount_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2962
|
+
cum_amount_yoy_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2963
|
+
open_interest_lots: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
2964
|
+
open_interest_wow_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2965
|
+
week_close_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2966
|
+
close_wow_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
2967
|
+
},
|
|
2968
|
+
additionalProperties: false,
|
|
2969
|
+
};
|
|
2970
|
+
const FUTURES_WEEKLY_DESCRIPTION = '按统计周期获取**期货主要品种交易周报**,一行是「一周 × 一个品种」(实测一周整表 90 行、六家交易所都在)。条件至少给一个:`week` 单周、`prd` 品种(支持逗号分隔,实测 `CU,AL` 命中两个品种)、或 `start_week` + `end_week` 区间(实测空参 400「周、品种代码、开始周或结束周至少需要提供一个」;实测 `prd=CU` 单独给可用,一个品种 total=92 周、`page` 生效)。⛔ 周码一律 `YYYYWW`:实测 `2026-10-16` 这种写法 400「start_week must use YYYYWW format」,本层在参数层按同一个形状拦。`size` 上限 **4000**(实测 5000 静默夹到 4000)。⛔ **这张表有发布滞后,别按"本周"打**:实测 2026-10-16 打 202636..202642 只回 4 周 360 行(202636 / 202637 / 202638 / 202639),单独打 `week=202641` → **200 + total=0**;最新一期实测是两周前那一周。拿到空周先按"还没发布"理解,不是取数失败。⛔ `week_date` 是该周期的**截止交易日**,不是自然周边界:实测 202636→20260918(周五)、202637→20260924(周四)、202638→20260930(周三)、202639→20261009(周五,跨国庆),所以**不许按自然周对齐去补洞、也不许拿周码反推日期**,日期只认 `week_date` 这一列。单位:`volume_lots` / `cum_volume_lots` / `open_interest_lots` 是**手**;`amount_yi` / `cum_amount_yi` 是**亿元**——这条不是文档给的(文档只写"成交额"),是把 202639 那 90 行逐行拿 `amount × 1e8 ÷ vol` 与「`ftshare_futures_contract_detail` 里同品种的众数合约乘数 × `week_close_price`」相对照算出来的:82 行可比(另外 8 行要么当周无成交、要么 `prd` 是 `L-F` 这种清单里没有的写法),比值全部落在 **0.969~1.085**、中位 **0.999**(差额是周内加权均价与周末收盘价的差)。`*_yoy` / `open_interest_wow_pct` / `close_wow_pct` 是**百分数原值**(6.64 = 6.64%),不是比例值。⛔ 同比列的上游键名拼错:报文里写作 **`amout_yoy`**(少一个 n),而同一行的 `amount` 拼写正常,所以那是键名笔误不是另一种口径——本层改名成 `amount_yoy_pct` 透出(与东财 `SECUCODE → thscode` 同一做法)。无成交的那一行**不是 0 而是空洞**:实测 202639 里 JR / PM / RI / WH / ZC 五行 `volume_lots=0`、`cum_volume_lots=0`、`open_interest_lots=0`,而 `volume_yoy_pct` / `amount_yoy_pct` / `week_close_price` / `close_wow_pct` 全为 null(实测 90 行里 10 行 `volume_yoy_pct` 为 null、5 行 `week_close_price` 为 null)——null 保持 null,不补 0 也不沿用上期。';
|
|
2971
|
+
/** 期货主要品种交易周报(`fut-weekly-detail`):⛔ 上游把同比列键名写成 `amout_yoy`,本层改名透出。 */
|
|
2972
|
+
function createFuturesWeeklySource(client) {
|
|
2973
|
+
const capability = 'ftshare_futures_weekly';
|
|
2974
|
+
const allowed = ['week', 'start_week', 'end_week', 'prd', 'exchange', 'page', 'size'];
|
|
2975
|
+
const path = '/api/v1/market/data/futures/fut-weekly-detail';
|
|
2976
|
+
const schema = {
|
|
2977
|
+
capability,
|
|
2978
|
+
// ⛔ 这一颗刻意不挂时间契约:参数面没有任何日期列(只有周码),行内日期是 `week_date`。
|
|
2979
|
+
name: 'get_futures_weekly_detail',
|
|
2980
|
+
source: 'http:ftshare.fut_weekly_detail',
|
|
2981
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
2982
|
+
source_label: 'ftshare',
|
|
2983
|
+
paginated: true,
|
|
2984
|
+
rowShape: { rowKey: 'item' },
|
|
2985
|
+
summary: '期货主要品种交易周报(周码 YYYYWW,成交额亿元)',
|
|
2986
|
+
description: FUTURES_WEEKLY_DESCRIPTION,
|
|
2987
|
+
input_schema: {
|
|
2988
|
+
type: 'object',
|
|
2989
|
+
properties: {
|
|
2990
|
+
week: { type: 'string', description: '单个统计周期 YYYYWW(如 202639);与区间二选一' },
|
|
2991
|
+
start_week: { type: 'string', description: '起始周期 YYYYWW,与 end_week 同时给' },
|
|
2992
|
+
end_week: { type: 'string', description: '截止周期 YYYYWW' },
|
|
2993
|
+
prd: { type: 'string', description: '品种代码,支持逗号分隔(CU 或 CU,AL);实测单独给它就够' },
|
|
2994
|
+
exchange: { type: 'string', enum: [...FUTURES_EXCHANGES], description: '交易所(可选)' },
|
|
2995
|
+
page: { type: 'integer', minimum: 1 },
|
|
2996
|
+
size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_4000 },
|
|
2997
|
+
},
|
|
2998
|
+
required: [], additionalProperties: false,
|
|
2999
|
+
},
|
|
3000
|
+
output_schema: output(futuresWeeklyRow),
|
|
3001
|
+
};
|
|
3002
|
+
const normalize = (params) => {
|
|
3003
|
+
const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_4000);
|
|
3004
|
+
const normalized = { page, size };
|
|
3005
|
+
const hasRange = params.start_week !== undefined || params.end_week !== undefined;
|
|
3006
|
+
if (params.week !== undefined && hasRange)
|
|
3007
|
+
throw new FtshareError('invalid_parameter', 'parameter week 与 start_week / end_week 二选一,不许混给');
|
|
3008
|
+
if (params.week === undefined && !hasRange && params.prd === undefined)
|
|
3009
|
+
throw new FtshareError('invalid_parameter', 'parameter week / prd / start_week + end_week 至少要给一个(实测空参上游 400「周、品种代码、开始周或结束周至少需要提供一个」)');
|
|
3010
|
+
if (params.week !== undefined)
|
|
3011
|
+
normalized.week = weekCode(params.week, 'week');
|
|
3012
|
+
if (hasRange) {
|
|
3013
|
+
if (params.start_week === undefined || params.end_week === undefined)
|
|
3014
|
+
throw new FtshareError('invalid_parameter', 'parameter start_week 与 end_week 必须同时给');
|
|
3015
|
+
normalized.start_week = weekCode(params.start_week, 'start_week');
|
|
3016
|
+
normalized.end_week = weekCode(params.end_week, 'end_week');
|
|
3017
|
+
if (String(normalized.start_week) > String(normalized.end_week))
|
|
3018
|
+
throw new FtshareError('invalid_parameter', 'parameter start_week must not be later than end_week');
|
|
3019
|
+
}
|
|
3020
|
+
if (params.prd !== undefined)
|
|
3021
|
+
normalized.prd = String(params.prd).trim().toUpperCase();
|
|
3022
|
+
if (params.exchange !== undefined)
|
|
3023
|
+
normalized.exchange = futuresExchange(params.exchange, FUTURES_EXCHANGES);
|
|
3024
|
+
return normalized;
|
|
3025
|
+
};
|
|
3026
|
+
return {
|
|
3027
|
+
schema,
|
|
3028
|
+
normalizeParams: normalize,
|
|
3029
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
3030
|
+
execute: async (request, signal) => {
|
|
3031
|
+
const params = normalize(request.params);
|
|
3032
|
+
const search = query({
|
|
3033
|
+
week: params.week, start_week: params.start_week, end_week: params.end_week,
|
|
3034
|
+
prd: params.prd, exchange: params.exchange, page: params.page, page_size: params.size,
|
|
3035
|
+
}, ['week', 'start_week', 'end_week', 'prd', 'exchange', 'page', 'page_size']);
|
|
3036
|
+
const { rows, total } = readPaged(await client.getJson(path, search, signal));
|
|
3037
|
+
if (params.week !== undefined)
|
|
3038
|
+
assertFiltered(rows, { field: 'week', expected: String(params.week), label: 'week' });
|
|
3039
|
+
else if (params.start_week !== undefined && params.end_week !== undefined) {
|
|
3040
|
+
// 周码是 `YYYYWW` 定宽串,字典序就是时间序;拿它跟区间比才是真的核,
|
|
3041
|
+
// 换成"那一年的 1/1..12/31"这种宽框等于不核。
|
|
3042
|
+
const wrong = rows.find((row) => String(row.week ?? '') < String(params.start_week) || String(row.week ?? '') > String(params.end_week));
|
|
3043
|
+
if (wrong !== undefined)
|
|
3044
|
+
throw new FtshareError('invalid_response', `FTShare returned week ${JSON.stringify(String(wrong.week ?? ''))} outside the requested ${params.start_week}..${params.end_week} range — 上游没有应用周区间,这一份不是该区间的结果`);
|
|
3045
|
+
}
|
|
3046
|
+
if (params.exchange !== undefined)
|
|
3047
|
+
assertFiltered(rows, { field: 'exchange', expected: String(params.exchange), label: 'exchange' });
|
|
3048
|
+
if (params.prd !== undefined) {
|
|
3049
|
+
// 逗号分隔的多值过滤要整串比:拿 `{prd}` 单值比会把 `CU,AL` 那一次正确返回判成"过滤没生效"。
|
|
3050
|
+
const wanted = String(params.prd).split(',').map((item) => item.trim()).filter((item) => item !== '');
|
|
3051
|
+
const wrong = rows.find((row) => !wanted.includes(String(row.prd ?? '').toUpperCase()));
|
|
3052
|
+
if (wrong !== undefined)
|
|
3053
|
+
throw new FtshareError('invalid_response', `FTShare dropped the prd filter: got prd=${JSON.stringify(String(wrong.prd ?? ''))} instead of one of ${wanted.join(' / ')} — 这份返回不是该品种集合的结果`);
|
|
3054
|
+
}
|
|
3055
|
+
const item = rows.map((row) => {
|
|
3056
|
+
const end = normalizeDateCell(row.week_date);
|
|
3057
|
+
return {
|
|
3058
|
+
exchange: String(row.exchange ?? ''), product: String(row.prd ?? ''), product_name: textOrNull(row.name),
|
|
3059
|
+
week: String(row.week ?? ''), week_date: end.iso, week_date_ms: end.ms,
|
|
3060
|
+
volume_lots: integerOrNull(row.vol), volume_yoy_pct: numberOrNull(row.vol_yoy),
|
|
3061
|
+
amount_yi: numberOrNull(row.amount), amount_yoy_pct: numberOrNull(row.amout_yoy),
|
|
3062
|
+
cum_volume_lots: integerOrNull(row.cumvol), cum_volume_yoy_pct: numberOrNull(row.cumvol_yoy),
|
|
3063
|
+
cum_amount_yi: numberOrNull(row.cumamt), cum_amount_yoy_pct: numberOrNull(row.cumamt_yoy),
|
|
3064
|
+
open_interest_lots: integerOrNull(row.open_interest), open_interest_wow_pct: numberOrNull(row.interest_wow),
|
|
3065
|
+
week_close_price: numberOrNull(row.mc_close), close_wow_pct: numberOrNull(row.close_wow),
|
|
3066
|
+
};
|
|
3067
|
+
});
|
|
3068
|
+
return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(futuresWeeklyRow) };
|
|
3069
|
+
},
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
const nanhuaIndexRow = {
|
|
3073
|
+
type: 'object',
|
|
3074
|
+
properties: {
|
|
3075
|
+
code: { type: 'string' },
|
|
3076
|
+
trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
3077
|
+
trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3078
|
+
open: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3079
|
+
high: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3080
|
+
low: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3081
|
+
close: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3082
|
+
pre_close: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3083
|
+
change_value: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3084
|
+
change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3085
|
+
volume_lots: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3086
|
+
amount_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3087
|
+
},
|
|
3088
|
+
additionalProperties: false,
|
|
3089
|
+
};
|
|
3090
|
+
const NANHUA_DESCRIPTION = '按指数代码获取**南华期货指数日 K 线**(商品指数序列,代码形如 `AG_NH` / `CU_NH`)。两种查法:`trade_date` = 单日截面(实测 AG_NH + 20261015 回 1 行;⛔ 传了 `trade_date` 时区间参数被忽略,这是文档写的、实测单日只回 1 行与之相符),或 `start_date` + `end_date` = 区间序列(实测 AG_NH 20260817..20261015 回 total=38、按 `trade_date` **升序**)。`code` 与 `trade_date` 至少给一个(实测两者都缺 → 400「code and trade_date cannot both be empty」)。`size` 上限 **200**(实测 201 → **响亮** 400「page_size must be between 1 and 200」,这一颗与静默夹断那几颗不同);`page` 实测生效(page=2 / size=10 首行 20260831,与第一页零重叠)。⛔ **OHLC 不保证自洽**:指数是聚合出来的,实测 30 行里就有 **1 行 `high < open`**(AG_NH 20260928:open 1758.78 / high 1724.75 / low 1649.31 / close 1650.30)——不要拿单根 K 的高低自洽性判这份数据"坏了",更不要因此重试。可信的是涨跌那一组:实测 `change ≡ close − pre_close` 与 `pct_chg ≡ change ÷ pre_close × 100` 在 30 行里 **0 违例**。`change_pct` 是百分数原值(-4.3166 = 跌 4.32%)。⛔ `amount_yi` 的单位是**本层核出来的,文档那条是错的**:文档写「成交额(千元)」,而按千元读 CU_NH 20260930 那一行(`amount=802.72` / `vol=146,524` 手)得到每手只有 5.48 元、除以铜的 5 吨/手是 **1.1 元/吨**——比同日 `ftshare_futures_kline` 的 CU2612 收盘 108,290 元/吨低五个数量级;按**亿元**读才是每手 547,842 元、折合 **109,568 元/吨**,与 108,290 同量级(差 1.2%,指数覆盖多只合约)。所以这一列按亿元命名,但⛔ 它仍是**推断出的量级**:只做同 `code` 内的时序与相对比较,不要拿它和股票或单个合约的成交额对账。`volume_lots` 是手。认不出的代码不回错、只回空分页(实测 `ZZZZ_NH` → 200 + total=0)。文档口径覆盖自 20211115 起,本层未逐品种核起点。行内 `trade_date` 上游是**字符串** `YYYYMMDD`,本层归一成 ISO + 毫秒轴。';
|
|
3091
|
+
/** 南华期货指数日 K 线(`nanhua-index-kline`):⛔ 聚合指数不保证 high ≥ open,见 description。 */
|
|
3092
|
+
function createNanhuaIndexKlineSource(client) {
|
|
3093
|
+
const capability = 'ftshare_futures_index_kline';
|
|
3094
|
+
const allowed = ['code', 'trade_date', 'start_date', 'end_date', 'page', 'size'];
|
|
3095
|
+
const path = '/api/v1/market/data/futures/nanhua-index-kline';
|
|
3096
|
+
const schema = {
|
|
3097
|
+
capability,
|
|
3098
|
+
time_contract: getDataTimeContract(capability),
|
|
3099
|
+
name: 'get_nanhua_index_daily_kline',
|
|
3100
|
+
source: 'http:ftshare.futures_nanhua_index_kline',
|
|
3101
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
3102
|
+
source_label: 'ftshare',
|
|
3103
|
+
paginated: true,
|
|
3104
|
+
rowShape: { rowKey: 'item' },
|
|
3105
|
+
summary: '南华期货商品指数日 K 线(按指数代码)',
|
|
3106
|
+
description: NANHUA_DESCRIPTION,
|
|
3107
|
+
input_schema: {
|
|
3108
|
+
type: 'object',
|
|
3109
|
+
properties: {
|
|
3110
|
+
code: { type: 'string', description: '指数代码,形如 AG_NH / CU_NH;与 trade_date 至少给一个' },
|
|
3111
|
+
trade_date: { type: 'string', description: '单日截面 YYYY-MM-DD(给了它就忽略区间)' },
|
|
3112
|
+
start_date: { type: 'string', description: '区间起始 YYYY-MM-DD,与 end_date 同时给' },
|
|
3113
|
+
end_date: { type: 'string', description: '区间截止 YYYY-MM-DD' },
|
|
3114
|
+
page: { type: 'integer', minimum: 1 },
|
|
3115
|
+
size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_200 },
|
|
3116
|
+
},
|
|
3117
|
+
required: [], additionalProperties: false,
|
|
3118
|
+
},
|
|
3119
|
+
output_schema: output(nanhuaIndexRow),
|
|
3120
|
+
};
|
|
3121
|
+
const normalize = (params) => {
|
|
3122
|
+
const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_200);
|
|
3123
|
+
const normalized = { page, size };
|
|
3124
|
+
if (params.code === undefined && params.trade_date === undefined)
|
|
3125
|
+
throw new FtshareError('invalid_parameter', 'parameter code 与 trade_date 至少给一个(实测两者都缺上游 400「code and trade_date cannot both be empty」)');
|
|
3126
|
+
if (params.code !== undefined)
|
|
3127
|
+
normalized.code = String(params.code).trim().toUpperCase();
|
|
3128
|
+
if (params.trade_date !== undefined)
|
|
3129
|
+
normalized.trade_date = date(params.trade_date, 'trade_date');
|
|
3130
|
+
const hasRange = params.start_date !== undefined || params.end_date !== undefined;
|
|
3131
|
+
if (hasRange) {
|
|
3132
|
+
if (params.start_date === undefined || params.end_date === undefined)
|
|
3133
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date 与 end_date 必须同时给');
|
|
3134
|
+
normalized.start_date = date(params.start_date, 'start_date');
|
|
3135
|
+
normalized.end_date = date(params.end_date, 'end_date');
|
|
3136
|
+
if (String(normalized.start_date) > String(normalized.end_date))
|
|
3137
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
|
|
3138
|
+
}
|
|
3139
|
+
return normalized;
|
|
3140
|
+
};
|
|
3141
|
+
return {
|
|
3142
|
+
schema,
|
|
3143
|
+
normalizeParams: normalize,
|
|
3144
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
3145
|
+
execute: async (request, signal) => {
|
|
3146
|
+
const params = normalize(request.params);
|
|
3147
|
+
const compact = (value) => value === undefined ? undefined : compactDate(String(value));
|
|
3148
|
+
const search = query({
|
|
3149
|
+
code: params.code, trade_date: compact(params.trade_date),
|
|
3150
|
+
start_date: compact(params.start_date), end_date: compact(params.end_date),
|
|
3151
|
+
page: params.page, page_size: params.size,
|
|
3152
|
+
}, ['code', 'trade_date', 'start_date', 'end_date', 'page', 'page_size']);
|
|
3153
|
+
const { rows, total } = readPaged(await client.getJson(path, search, signal));
|
|
3154
|
+
if (params.code !== undefined)
|
|
3155
|
+
assertFiltered(rows, { field: 'code', expected: String(params.code), label: 'code' });
|
|
3156
|
+
if (params.trade_date !== undefined)
|
|
3157
|
+
assertFiltered(rows, { field: 'trade_date', expected: compact(params.trade_date), label: 'trade_date' });
|
|
3158
|
+
else if (params.start_date !== undefined && params.end_date !== undefined)
|
|
3159
|
+
assertInRange(rows, String(params.start_date), String(params.end_date), (row) => row.trade_date);
|
|
3160
|
+
const item = rows.map((row) => {
|
|
3161
|
+
const day = normalizeDateCell(row.trade_date);
|
|
3162
|
+
return {
|
|
3163
|
+
code: String(row.code ?? ''), trade_date: day.iso, trade_date_ms: day.ms,
|
|
3164
|
+
open: numberOrNull(row.open), high: numberOrNull(row.high), low: numberOrNull(row.low), close: numberOrNull(row.close),
|
|
3165
|
+
pre_close: numberOrNull(row.pre_close), change_value: numberOrNull(row.change), change_pct: numberOrNull(row.pct_chg),
|
|
3166
|
+
volume_lots: integerOrNull(row.vol), amount_yi: numberOrNull(row.amount),
|
|
3167
|
+
};
|
|
3168
|
+
});
|
|
3169
|
+
return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(nanhuaIndexRow) };
|
|
3170
|
+
},
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
const memberRankRow = {
|
|
3174
|
+
type: 'object',
|
|
3175
|
+
properties: {
|
|
3176
|
+
exchange: { type: 'string' },
|
|
3177
|
+
variety_code: { type: 'string' },
|
|
3178
|
+
contract_code: { type: 'string' },
|
|
3179
|
+
trade_date: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
3180
|
+
trade_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3181
|
+
org_code: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
3182
|
+
member_name: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
3183
|
+
volume_rank: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3184
|
+
volume_lots: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3185
|
+
volume_change: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3186
|
+
long_rank: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3187
|
+
long_position: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3188
|
+
long_change: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3189
|
+
short_rank: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3190
|
+
short_position: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3191
|
+
short_change: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3192
|
+
net_long_rank: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3193
|
+
net_long_position: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3194
|
+
net_long_change: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3195
|
+
net_short_rank: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3196
|
+
net_short_position: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3197
|
+
net_short_change: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3198
|
+
long_increase_rank: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3199
|
+
long_decrease_rank: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3200
|
+
short_increase_rank: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3201
|
+
short_decrease_rank: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3202
|
+
settle_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3203
|
+
long_avg_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3204
|
+
short_avg_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3205
|
+
},
|
|
3206
|
+
additionalProperties: false,
|
|
3207
|
+
};
|
|
3208
|
+
const MEMBER_RANK_DESCRIPTION = '按合约与会员维度获取**东方财富期货每日成交持仓排名**,一行是「一只合约 × 一家会员」。⛔ 这一族的写法坑本层已经替模型填平:实测上游 `exchange` **只认小写**、且广期所的真名是 `gfe`——`GFEX` 与 `gfex` 都回 **200 + 0 行、不报错**(实测同一个交易日 `gfe` 回 total=196、`gfex` 回 0),`variety_code` 同样大小写敏感(`AG` → 0 行、`ag` → total=100)。本层把六家常规写法换成上游那一套小写(GFEX → `gfe`)、品种转小写,`contract_code` 保持原样大写发(实测 `AG2610` 命中 20 行)。`trade_date` / `start_date` / `end_date` 都收(实测 `2026-10-15` 与 `20261015` 两种写法都回同一个 total=6911),行里的日期上游写作带横杠的 `2026-10-15`。⛔ **一定要带到合约级别的过滤**:实测不给任何日期 = 全表 1,435,879 行,给一个交易日 = 6,911 行,再加 `contract_code` = 20 行——只给日期的那次一次返回必然超闸(回 `too_large` + `profile_ref`,请转 `query_dataset` 收窄),什么都不给的那次翻页要两千多页。`size` 上限 **500**(实测 5000 → **响亮** 400「page_size must be between 1 and 500」)。⛔ **空串是这张表的常态而不是脏数据**:实测一个交易日 100 行里 `long_rank` / `long_position` 各 21 行为空串、空头那一组 25 行、净多与净空那一组 59~64 行;而且**排名列与它的数值列同时为空**(实测五对里 mismatch 0 对)——那读作"这一行没有这个口径",不是"排名第 0",本层一律转成 null、不补 0。身份:`org_code` 才是会员的稳定身份(实测广期所与中金所的中信期货同为 `10058975`),`member_name` 带后缀且不统一(中金所那行是「中信期货(代客)」、广期所那行是「中信期货」)⇒ 按名字跨所聚合会把同一家拆成两半。行里 `exchange` 与 `variety_code` 是**小写**,`contract_code` 的大小写随交易所(实测 cffex 给 `IC2610`、gfe 给 `lc2611`)⇒ ⛔ 别拿它直接 join `ftshare_futures_contracts`(那边是 `IC2610.CFE` 带后缀)。单位:`volume_lots` 是手(文档明写"成交量(手)"),持仓与变动那些列上游只对 volume 一列声明过单位,本层按同族惯例读作手但**不承诺换算**;`settle_price` / `long_avg_price` / `short_avg_price` 是合约报价单位。';
|
|
3209
|
+
/** 东方财富期货持仓排名(`eastmoney-futures-position`):⛔ 上游 exchange 只认小写且广期所写作 `gfe`。 */
|
|
3210
|
+
function createFuturesMemberRankSource(client) {
|
|
3211
|
+
const capability = 'ftshare_futures_member_rank';
|
|
3212
|
+
const allowed = ['exchange', 'variety_code', 'contract_code', 'member_name_abbr', 'trade_date', 'start_date', 'end_date', 'page', 'size'];
|
|
3213
|
+
const path = '/api/v1/market/data/eastmoney-futures-position';
|
|
3214
|
+
const schema = {
|
|
3215
|
+
capability,
|
|
3216
|
+
time_contract: getDataTimeContract(capability),
|
|
3217
|
+
name: 'get_eastmoney_futures_position',
|
|
3218
|
+
source: 'http:ftshare.eastmoney_futures_position',
|
|
3219
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
3220
|
+
source_label: 'ftshare',
|
|
3221
|
+
paginated: true,
|
|
3222
|
+
rowShape: { rowKey: 'item' },
|
|
3223
|
+
summary: '东财期货会员成交持仓排名(按合约×会员)',
|
|
3224
|
+
description: MEMBER_RANK_DESCRIPTION,
|
|
3225
|
+
input_schema: {
|
|
3226
|
+
type: 'object',
|
|
3227
|
+
properties: {
|
|
3228
|
+
exchange: { type: 'string', enum: [...FUTURES_EXCHANGES], description: '交易所(本层换成上游的小写写法,广期所 → gfe)' },
|
|
3229
|
+
variety_code: { type: 'string', description: '品种代码(大小写都收,本层转小写:实测大写会静默空)' },
|
|
3230
|
+
contract_code: { type: 'string', description: '合约代码(如 AG2610,不带交易所后缀),实测大写命中' },
|
|
3231
|
+
member_name_abbr: { type: 'string', description: '会员简称精确匹配(如 中信期货),实测生效' },
|
|
3232
|
+
trade_date: { type: 'string', description: '单日交易日 YYYY-MM-DD' },
|
|
3233
|
+
start_date: { type: 'string', description: '区间起始 YYYY-MM-DD' },
|
|
3234
|
+
end_date: { type: 'string', description: '区间截止 YYYY-MM-DD' },
|
|
3235
|
+
page: { type: 'integer', minimum: 1 },
|
|
3236
|
+
size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE_500 },
|
|
3237
|
+
},
|
|
3238
|
+
required: [], additionalProperties: false,
|
|
3239
|
+
},
|
|
3240
|
+
output_schema: output(memberRankRow),
|
|
3241
|
+
};
|
|
3242
|
+
const normalize = (params) => {
|
|
3243
|
+
const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE_500);
|
|
3244
|
+
const normalized = { page, size };
|
|
3245
|
+
if (params.exchange !== undefined) {
|
|
3246
|
+
const exchange = futuresExchange(params.exchange, FUTURES_EXCHANGES);
|
|
3247
|
+
normalized.exchange = EASTMONEY_FUTURES_EXCHANGE[exchange];
|
|
3248
|
+
}
|
|
3249
|
+
if (params.variety_code !== undefined)
|
|
3250
|
+
normalized.variety_code = String(params.variety_code).trim().toLowerCase();
|
|
3251
|
+
if (params.contract_code !== undefined)
|
|
3252
|
+
normalized.contract_code = String(params.contract_code).trim().toUpperCase();
|
|
3253
|
+
if (params.member_name_abbr !== undefined)
|
|
3254
|
+
normalized.member_name_abbr = String(params.member_name_abbr).trim();
|
|
3255
|
+
if (params.trade_date !== undefined)
|
|
3256
|
+
normalized.trade_date = date(params.trade_date, 'trade_date');
|
|
3257
|
+
if (params.start_date !== undefined)
|
|
3258
|
+
normalized.start_date = date(params.start_date, 'start_date');
|
|
3259
|
+
if (params.end_date !== undefined)
|
|
3260
|
+
normalized.end_date = date(params.end_date, 'end_date');
|
|
3261
|
+
if (normalized.start_date !== undefined && normalized.end_date !== undefined && String(normalized.start_date) > String(normalized.end_date))
|
|
3262
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
|
|
3263
|
+
return normalized;
|
|
3264
|
+
};
|
|
3265
|
+
return {
|
|
3266
|
+
schema,
|
|
3267
|
+
normalizeParams: normalize,
|
|
3268
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
3269
|
+
execute: async (request, signal) => {
|
|
3270
|
+
const params = normalize(request.params);
|
|
3271
|
+
const search = query({
|
|
3272
|
+
exchange: params.exchange, variety_code: params.variety_code, contract_code: params.contract_code,
|
|
3273
|
+
member_name_abbr: params.member_name_abbr, trade_date: params.trade_date, start_date: params.start_date, end_date: params.end_date,
|
|
3274
|
+
page: params.page, page_size: params.size,
|
|
3275
|
+
}, ['exchange', 'variety_code', 'contract_code', 'member_name_abbr', 'trade_date', 'start_date', 'end_date', 'page', 'page_size']);
|
|
3276
|
+
const { rows, total } = readPaged(await client.getJson(path, search, signal));
|
|
3277
|
+
if (params.trade_date !== undefined)
|
|
3278
|
+
assertFiltered(rows, { field: 'trade_date', expected: String(params.trade_date), label: 'trade_date' });
|
|
3279
|
+
if (params.start_date !== undefined && params.end_date !== undefined)
|
|
3280
|
+
assertInRange(rows, String(params.start_date), String(params.end_date), (row) => row.trade_date);
|
|
3281
|
+
// 行里的小写列要跟**发出去的那个小写值**比;拿用户的写法比会把正确返回判成过滤没生效。
|
|
3282
|
+
if (params.exchange !== undefined)
|
|
3283
|
+
assertFiltered(rows, { field: 'exchange', expected: String(params.exchange), label: 'exchange' });
|
|
3284
|
+
if (params.variety_code !== undefined)
|
|
3285
|
+
assertFiltered(rows, { field: 'variety_code', expected: String(params.variety_code), label: 'variety_code' });
|
|
3286
|
+
if (params.contract_code !== undefined)
|
|
3287
|
+
assertFiltered(rows, { field: 'contract_code', expected: String(params.contract_code), label: 'contract_code' });
|
|
3288
|
+
if (params.member_name_abbr !== undefined)
|
|
3289
|
+
assertFiltered(rows, { field: 'member_name_abbr', expected: String(params.member_name_abbr), label: 'member_name_abbr' });
|
|
3290
|
+
const item = rows.map((row) => {
|
|
3291
|
+
const day = normalizeDateCell(row.trade_date);
|
|
3292
|
+
return {
|
|
3293
|
+
exchange: String(row.exchange ?? ''), variety_code: String(row.variety_code ?? ''), contract_code: String(row.contract_code ?? ''),
|
|
3294
|
+
trade_date: day.iso, trade_date_ms: day.ms,
|
|
3295
|
+
org_code: textOrNull(row.org_code), member_name: textOrNull(row.member_name_abbr),
|
|
3296
|
+
volume_rank: integerOrNull(row.volume_rank), volume_lots: integerOrNull(row.volume), volume_change: integerOrNull(row.volume_change),
|
|
3297
|
+
long_rank: integerOrNull(row.lp_rank), long_position: integerOrNull(row.long_position), long_change: integerOrNull(row.lp_change),
|
|
3298
|
+
short_rank: integerOrNull(row.sp_rank), short_position: integerOrNull(row.short_position), short_change: integerOrNull(row.sp_change),
|
|
3299
|
+
net_long_rank: integerOrNull(row.nlp_rank), net_long_position: integerOrNull(row.net_long_position), net_long_change: integerOrNull(row.nlp_change),
|
|
3300
|
+
net_short_rank: integerOrNull(row.nsp_rank), net_short_position: integerOrNull(row.net_short_position), net_short_change: integerOrNull(row.nsp_change),
|
|
3301
|
+
long_increase_rank: integerOrNull(row.lp_up_rank), long_decrease_rank: integerOrNull(row.lp_down_rank),
|
|
3302
|
+
short_increase_rank: integerOrNull(row.sp_up_rank), short_decrease_rank: integerOrNull(row.sp_down_rank),
|
|
3303
|
+
settle_price: numberOrNull(row.settle_price), long_avg_price: numberOrNull(row.lp_avg_price), short_avg_price: numberOrNull(row.sp_avg_price),
|
|
3304
|
+
};
|
|
3305
|
+
});
|
|
3306
|
+
return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(memberRankRow) };
|
|
3307
|
+
},
|
|
3308
|
+
};
|
|
3309
|
+
}
|
|
3310
|
+
const bullionRow = {
|
|
3311
|
+
type: 'object',
|
|
3312
|
+
properties: {
|
|
3313
|
+
quote_date: { type: 'string' },
|
|
3314
|
+
quote_date_ms: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
3315
|
+
price_value: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
3316
|
+
},
|
|
3317
|
+
additionalProperties: false,
|
|
3318
|
+
};
|
|
3319
|
+
const BULLION_DESCRIPTION = '按日期区间获取**贵金属行情**(一行一个「日期 + 读数」)。`symbol` / `start_date` / `end_date` / `page` / `page_size` 五样都必须给(实测少 `page` → 400「缺少必填参数:page」,这一条文档与实测一致)。`size` 上限 **1000**(实测 1001 → **静默**夹到 1000 并回显 `pageSize: 1000`,超限不报错,所以只能在参数层拦)。⛔ **只有两个代码实测有数**:`AU9999` total=635、`XAUUSD` total=676(窗口 2024-01-01..2026-10-15);`AG9999` / `XAGUSD` / `XAU` 都是 **200 + total=0、不报错**(文档自己也写"不支持的 symbol 不报错")——拿到空分页是合法的"这一族没有这个标的",不要换写法重试。⛔ **序列停在 2026 年 8 月中旬**:实测 AU9999 大窗首行(= 窗内最新一天)是 **2026-08-14**、XAUUSD 是 2026-08-12,而 2026-10-06..10-15 那一窗 total=0——按"最近一周"打永远是空,请从有数的区间取。返回按日期**降序**(实测第一页首行 2026-08-14、末行 2026-08-03),与 K 线那一族相反。⛔ **单位上游不披露,本层不承诺**:AU9999 末行读数 940.72、XAUUSD 末行 4408.32,两者差一个数量级(一个是国内金、一个是国际金价,量纲显然不同),所以只做**同 `symbol` 内**的时序与相对比较,禁止跨 symbol 比价、禁止换算成元/克或美元/盎司。`price_value` 上游以字符串给高精度数(本层转数值,空值转 null);`quote_date` 上游已是 `YYYY-MM-DD`,本层补一条毫秒轴。';
|
|
3320
|
+
/** 贵金属价格(`bullion/price`):⛔ 单位未披露 + 序列停在 2026-08,两条都写进 description。 */
|
|
3321
|
+
function createBullionPriceSource(client) {
|
|
3322
|
+
const capability = 'ftshare_bullion_price';
|
|
3323
|
+
const allowed = ['symbol', 'start_date', 'end_date', 'page', 'size'];
|
|
3324
|
+
const path = '/api/v1/market/data/bullion/price';
|
|
3325
|
+
const schema = {
|
|
3326
|
+
capability,
|
|
3327
|
+
time_contract: getDataTimeContract(capability),
|
|
3328
|
+
name: 'get_bullion_price',
|
|
3329
|
+
source: 'http:ftshare.bullion_price',
|
|
3330
|
+
data_key: buildDataKey('ftshare', 'http', capability),
|
|
3331
|
+
source_label: 'ftshare',
|
|
3332
|
+
paginated: true,
|
|
3333
|
+
rowShape: { rowKey: 'item' },
|
|
3334
|
+
summary: '贵金属价格(AU9999 / XAUUSD,单位未披露)',
|
|
3335
|
+
description: BULLION_DESCRIPTION,
|
|
3336
|
+
input_schema: {
|
|
3337
|
+
type: 'object',
|
|
3338
|
+
properties: {
|
|
3339
|
+
symbol: { type: 'string', description: '标的代码:实测只有 AU9999 与 XAUUSD 有数,其余静默空分页' },
|
|
3340
|
+
start_date: { type: 'string', description: '起始自然日 YYYY-MM-DD(必填)' },
|
|
3341
|
+
end_date: { type: 'string', description: '截止自然日 YYYY-MM-DD(必填)' },
|
|
3342
|
+
page: { type: 'integer', minimum: 1 },
|
|
3343
|
+
size: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE },
|
|
3344
|
+
},
|
|
3345
|
+
required: ['symbol', 'start_date', 'end_date'], additionalProperties: false,
|
|
3346
|
+
},
|
|
3347
|
+
output_schema: output(bullionRow),
|
|
3348
|
+
};
|
|
3349
|
+
const normalize = (params) => {
|
|
3350
|
+
const { page, size } = pageParams(params, allowed, MAX_PAGE_SIZE);
|
|
3351
|
+
const normalized = { symbol: String(params.symbol ?? '').trim().toUpperCase(), start_date: date(params.start_date, 'start_date'), end_date: date(params.end_date, 'end_date'), page, size };
|
|
3352
|
+
if (normalized.symbol === '')
|
|
3353
|
+
throw new FtshareError('invalid_parameter', 'parameter symbol 不能为空(实测空值上游 400「缺少必填参数:证券代码」)');
|
|
3354
|
+
if (String(normalized.start_date) > String(normalized.end_date))
|
|
3355
|
+
throw new FtshareError('invalid_parameter', 'parameter start_date must not be later than end_date');
|
|
3356
|
+
return normalized;
|
|
3357
|
+
};
|
|
3358
|
+
return {
|
|
3359
|
+
schema,
|
|
3360
|
+
normalizeParams: normalize,
|
|
3361
|
+
validateOutput: (data) => isRecord(data) && Array.isArray(data.item) && isRecord(data.pagination),
|
|
3362
|
+
execute: async (request, signal) => {
|
|
3363
|
+
const params = normalize(request.params);
|
|
3364
|
+
const search = query({
|
|
3365
|
+
symbol: params.symbol,
|
|
3366
|
+
start_date: compactDate(String(params.start_date)), end_date: compactDate(String(params.end_date)),
|
|
3367
|
+
page: params.page, page_size: params.size,
|
|
3368
|
+
}, ['symbol', 'start_date', 'end_date', 'page', 'page_size']);
|
|
3369
|
+
const { rows, total } = readPaged(await client.getJson(path, search, signal));
|
|
3370
|
+
assertInRange(rows, String(params.start_date), String(params.end_date), (row) => row.date);
|
|
3371
|
+
const item = rows.map((row) => {
|
|
3372
|
+
const day = normalizeDateCell(row.date);
|
|
3373
|
+
return { quote_date: day.iso ?? String(row.date ?? ''), quote_date_ms: day.ms, price_value: numberOrNull(row.value) };
|
|
3374
|
+
});
|
|
3375
|
+
return { data: pagedEnvelope(item, total, Number(params.page), Number(params.size)), schema: output(bullionRow) };
|
|
3376
|
+
},
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
2214
3379
|
export function createFtshareSources(resolveApiKey) {
|
|
2215
3380
|
const client = createFtshareClient(resolveApiKey);
|
|
2216
3381
|
return [
|
|
@@ -2291,6 +3456,18 @@ export function createFtshareSources(resolveApiKey) {
|
|
|
2291
3456
|
tenors: ['on_rate', 'w1', 'w2', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm10', 'm11', 'm12'],
|
|
2292
3457
|
}),
|
|
2293
3458
|
createFinancialCalendarSource(client),
|
|
3459
|
+
// 批次 F 的十颗(期货与贵金属)。`eastmoney_futures_strange`(龙虎榜)刻意不在这里:
|
|
3460
|
+
// 它一次回 12 个指标、三种载荷形状,落成行集需要 hub 支持"一行变多行",不属于这一批。
|
|
3461
|
+
createFuturesContractsSource(client),
|
|
3462
|
+
createFuturesContractDetailSource(client),
|
|
3463
|
+
createFuturesKlineSource(client),
|
|
3464
|
+
createFuturesLimitPriceSource(client),
|
|
3465
|
+
createFuturesSettleSource(client),
|
|
3466
|
+
createWarrantSource(client),
|
|
3467
|
+
createFuturesWeeklySource(client),
|
|
3468
|
+
createNanhuaIndexKlineSource(client),
|
|
3469
|
+
createFuturesMemberRankSource(client),
|
|
3470
|
+
createBullionPriceSource(client),
|
|
2294
3471
|
];
|
|
2295
3472
|
}
|
|
2296
3473
|
/**
|