#!/usr/bin/env node const candidates = Object.freeze([ 'stock_board_industry_spot_em', 'stock_board_concept_spot_em', 'stock_sector_fund_flow_rank', 'stock_market_fund_flow', 'stock_zh_a_spot_em', ]); const tdxSymbols = Object.freeze(['000001.SH', '399001.SZ', '899050.BJ']); const tdxPeriods = Object.freeze(['1m', '5m', '15m', '1d']); const DEFAULT_AKTOOLS_BASE_URL = 'http://127.0.0.1:8080'; const DEFAULT_TDX_BASE_URL = 'http://127.0.0.1:17710'; const TIMEOUT_MS = 10_000; const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; function usage() { return `市场结构能力探针(只读)\n\n用法:node tools/probes/market-structure-capability.mjs [--base-url http://127.0.0.1:8080]\n\n仅允许本机 AKTools(127.0.0.1:8080)及本机 TDX(127.0.0.1:17710)。输出不会包含原始行、凭据、请求体或异常详情。`; } function parseLocalBaseUrl(value, expectedPort, optionName) { let parsed; try { parsed = new URL(value); } catch { throw new Error(`${optionName} 必须是有效 URL`); } if ( parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || parsed.port !== expectedPort ) { throw new Error(`${optionName} 必须指向本机 127.0.0.1:${expectedPort}`); } if (parsed.pathname !== '' && parsed.pathname !== '/') { throw new Error(`${optionName} 不得包含路径`); } return parsed.toString().replace(/\/$/, ''); } function parseArguments(argv) { let baseUrl = DEFAULT_AKTOOLS_BASE_URL; for (let index = 0; index < argv.length; index += 1) { if (argv[index] === '--help' || argv[index] === '-h') return { help: true }; if (argv[index] === '--base-url' && argv[index + 1]) { baseUrl = argv[index + 1]; index += 1; continue; } throw new Error(`不支持的参数:${argv[index]}`); } return { aktoolsBaseUrl: parseLocalBaseUrl(baseUrl, '8080', '--base-url'), tdxBaseUrl: parseLocalBaseUrl( process.env.TDX_BASE_URL || DEFAULT_TDX_BASE_URL, '17710', 'TDX_BASE_URL', ), }; } function classifyError(error) { if (error?.name === 'AbortError') return 'timeout'; if (typeof error?.message === 'string' && error.message.startsWith('http_')) return error.message; if (error instanceof SyntaxError) return 'invalid_json'; return 'network'; } function getRows(value) { if (Array.isArray(value)) return value; if (value && typeof value === 'object') { for (const key of ['data', 'result', 'Value']) { const candidate = value[key]; if (Array.isArray(candidate)) return candidate; } } return []; } function getKeys(rows) { return [ ...new Set(rows.flatMap((row) => (row && typeof row === 'object' ? Object.keys(row) : []))), ].sort(); } function getTimestamp(row) { if (!row || typeof row !== 'object') return null; for (const key of ['Date', 'date', 'datetime', 'time', '时间', '日期']) { const value = row[key]; if (typeof value === 'string' || typeof value === 'number') return String(value); } return null; } function isIntradayTimestamp(timestamp) { return typeof timestamp === 'string' && /(?:T|\s)\d{2}:\d{2}/.test(timestamp); } function isShanghaiTradingSession(now = new Date()) { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).formatToParts(now); const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); if (values.weekday === 'Sat' || values.weekday === 'Sun') return false; const minutes = Number(values.hour) * 60 + Number(values.minute); return ( (minutes >= 9 * 60 + 30 && minutes <= 11 * 60 + 30) || (minutes >= 13 * 60 && minutes <= 15 * 60) ); } async function fetchJson(url, options) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); try { const response = await fetch(url, { ...options, signal: controller.signal }); const contentLength = response.headers.get('content-length'); if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES) throw new Error('http_response_too_large'); const text = await response.text(); if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) throw new Error('http_response_too_large'); if (!response.ok) throw new Error(`http_${response.status}`); return JSON.parse(text); } finally { clearTimeout(timeout); } } function summarizeRows(rows) { const firstTimestamp = getTimestamp(rows[0]); const lastTimestamp = getTimestamp(rows.at(-1)); return { rowCount: rows.length, keys: getKeys(rows), firstTimestamp, lastTimestamp, }; } function summarizeTdxSeries(series) { const dates = Array.isArray(series?.Date) ? series.Date : []; return { rowCount: dates.length, keys: series && typeof series === 'object' ? Object.keys(series).sort() : [], firstTimestamp: dates.length ? String(dates[0]) : null, lastTimestamp: dates.length ? String(dates.at(-1)) : null, }; } async function probeAktools(baseUrl, endpoint) { const startedAt = performance.now(); try { const value = await fetchJson(`${baseUrl}/api/public/${endpoint}`, { headers: { accept: 'application/json' }, }); const rows = getRows(value); return { source: 'aktools', endpoint, status: rows.length > 0 ? 'usable' : 'partial', elapsedMs: Math.round(performance.now() - startedAt), ...summarizeRows(rows), errorClass: null, }; } catch (error) { return { source: 'aktools', endpoint, status: 'unavailable', elapsedMs: Math.round(performance.now() - startedAt), rowCount: 0, keys: [], firstTimestamp: null, lastTimestamp: null, errorClass: classifyError(error), }; } } function analyzeTdxRows(series) { const dates = Array.isArray(series?.Date) ? series.Date : []; const amounts = Array.isArray(series?.Amount) ? series.Amount : []; return { dateAmountAligned: dates.length > 0 && dates.length === amounts.length, timestampsIntraday: dates.length > 0 && dates.every((date) => isIntradayTimestamp(String(date))), }; } async function probeTdx(baseUrl, symbol, period) { const startedAt = performance.now(); try { const envelope = await fetchJson(`${baseUrl}/`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 1, method: 'get_market_data', params: { stock_list: [symbol], count: 30, dividend_type: 'none', period }, }), }); const series = envelope?.result?.Value?.[symbol]; const businessError = envelope?.error !== undefined || !envelope?.result || (envelope.result.ErrorId !== undefined && envelope.result.ErrorId !== 0 && envelope.result.ErrorId !== '0'); const summary = summarizeTdxSeries(series); return { source: 'tdx', symbol, period, status: businessError ? 'unavailable' : summary.rowCount > 0 ? 'usable' : 'partial', elapsedMs: Math.round(performance.now() - startedAt), ...summary, ...analyzeTdxRows(series), errorClass: businessError ? 'protocol' : null, }; } catch (error) { return { source: 'tdx', symbol, period, status: 'unavailable', elapsedMs: Math.round(performance.now() - startedAt), rowCount: 0, keys: [], firstTimestamp: null, lastTimestamp: null, dateAmountAligned: false, timestampsIntraday: false, errorClass: classifyError(error), }; } } async function main() { const options = parseArguments(process.argv.slice(2)); if (options.help) { console.log(usage()); return; } const aktools = await Promise.all( candidates.map((endpoint) => probeAktools(options.aktoolsBaseUrl, endpoint)), ); const tdx = await Promise.all( tdxSymbols.flatMap((symbol) => tdxPeriods.map((period) => probeTdx(options.tdxBaseUrl, symbol, period)), ), ); console.log( JSON.stringify( { observedAt: new Date().toISOString(), aktoolsBaseUrl: options.aktoolsBaseUrl, tdxBaseUrl: options.tdxBaseUrl, tradingSession: isShanghaiTradingSession(), freshnessNote: isShanghaiTradingSession() ? '盘中观测:最后时间可用于后续新鲜度判断。' : '非盘中观测:最后一条记录仅表示收盘后最后记录,不能证明盘中新鲜度。', aktools, tdx, }, null, 2, ), ); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((error) => { console.error(JSON.stringify({ errorClass: 'configuration', message: error.message })); process.exitCode = 1; }); } export { analyzeTdxRows, isShanghaiTradingSession, parseArguments, summarizeRows };