docs(FEAT-MO-005): 建立市场结构数据源探针
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
#!/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 };
|
||||
Reference in New Issue
Block a user