Files
a-share-analysis/tools/probes/market-structure-capability.mjs
T

284 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 };