docs(FEAT-MO-005): 建立市场结构数据源探针
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
# FEAT-MO-005 数据源能力探针
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
收盘后于 2026-08-25(Asia/Shanghai)执行两次本机只读探测。TDX 三市场日线可用,`Date` 与 `Amount` 均等长;1/5/15 分钟请求业务成功但序列为空。AKTools 五个候选中,市场资金流曾可用一次、随后 HTTP 500,归类为 `partial`;其余候选为 HTTP 500 或超时,归类为 `unavailable`。
|
||||||
|
|
||||||
|
两次均在非交易时段,最后一条记录仅为收盘后最后记录,不能推断盘中新鲜度。交易时段观测尚未执行,不能将任一来源标为实时。
|
||||||
|
|
||||||
|
## 脱敏能力表
|
||||||
|
|
||||||
|
| 来源/接口 | 分类 | 两次收盘后摘要 | 字段与时间结论 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| TDX `get_market_data`,三市场 `1d` | `usable` | 30 行;63-148ms | `Date`/`Amount` 对齐;首末日期为 20260714/20260824;非分时。 |
|
||||||
|
| TDX `get_market_data`,三市场 `1m`/`5m`/`15m` | `partial` | 0 行;63-193ms | 无时间序列,不能形成盘中成交额趋势。 |
|
||||||
|
| AKTools `stock_board_industry_spot_em` | `unavailable` | HTTP 500;约 5.2-5.5 秒 | 未取得字段或时间。 |
|
||||||
|
| AKTools `stock_board_concept_spot_em` | `unavailable` | 10 秒 timeout | 未取得字段或时间。 |
|
||||||
|
| AKTools `stock_sector_fund_flow_rank` | `unavailable` | HTTP 500;117-144ms | 未取得字段或时间。 |
|
||||||
|
| AKTools `stock_market_fund_flow` | `partial` | 第一次 120 行/172ms,第二次 HTTP 500/130ms | 可用时含日期和各档净流入净额/净占比;不可作为稳定排行源。 |
|
||||||
|
| AKTools `stock_zh_a_spot_em` | `unavailable` | HTTP 500;约 5.6-5.9 秒 | 未取得字段或时间。 |
|
||||||
|
|
||||||
|
未保存原始行、请求体、凭据、上游错误内容或非本机地址。HTTP 500 与 timeout 作为不同短错误类别记录。
|
||||||
|
|
||||||
|
## 后续准入
|
||||||
|
|
||||||
|
Tasks 2-4 只能按 `design.md` 中的映射实现明确的不可用/部分可用降级;不得接入行业、题材、板块排行或盘中成交额趋势。交易时段需要至少一次复测,以验证最后时间与实际时段的新鲜度。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# FEAT-MO-005 设计说明
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
探针固定请求五个 AKTools 候选和 TDX `get_market_data` 的三只指数、四种周期。输出仅允许状态、耗时、行数、字段名、首末时间与短错误类别;原始行、请求体、凭据和非本机 URL 不记录。
|
||||||
|
|
||||||
|
后续结构模块以 TDX 日线的 `Date`、`Amount` 作为全市场日度成交额快照依据。分钟周期为空时,成交额趋势不得伪装为盘中分时;行业、题材与排行资金流接口未通过时,API 只返回不可用状态。
|
||||||
|
|
||||||
|
## 字段定义与来源映射
|
||||||
|
|
||||||
|
| 指标 | 字段口径 | 映射结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 全市场日度成交额 | 三市场同交易日 `Amount` 相加,TDX 单位万元转亿元 | 已验证快照派生(TDX 主来源) |
|
||||||
|
| 全市场盘中成交额趋势 | `Date` 与 `Amount` 对齐的 1/5/15 分钟序列 | 延期为不可用,当前分钟序列为空 |
|
||||||
|
| 行业、题材、板块排行 | 上游可证明的名称、涨跌幅、成交额字段 | 延期为不可用,候选接口失败 |
|
||||||
|
| 行业、题材、板块资金流排行 | 上游明确的净流入字段和定义 | 延期为不可用,排行接口失败 |
|
||||||
|
| 全市场资金流历史 | AKTools `主力/超大/大/中/小单净流入-净额` 等明示字段 | AKTools 补充源,当前为 `partial`,不能用于排行 |
|
||||||
|
|
||||||
|
## 安全策略与评估
|
||||||
|
|
||||||
|
资产为本机桥接可返回的行情、配置中的 TDX 地址和任何凭据。信任边界位于 Node 探针与本机回环服务之间:参数被固定白名单限制,URL 仅接受 `127.0.0.1` 固定端口,10 秒超时和 2 MiB 响应上限限制上游异常。输出仅为脱敏摘要,错误不包含上游响应。认证、授权、SQL、XSS、CSRF 与 CORS 不适用于该无 HTTP 入站、无持久化、无浏览器页面的本地 CLI;其边界是后续 Nest API 必须重新验证。
|
||||||
|
|
||||||
|
残余风险是本机桥接和免费上游不稳定,且未验证盘中延迟、授权和再分发要求;任何生产接入前必须单独审查。
|
||||||
|
|
||||||
|
## 性能目标与评估
|
||||||
|
|
||||||
|
单次探针固定为 17 个请求,单请求上限 10 秒、响应上限 2 MiB,不保存载荷。收盘后两次观测中 TDX 单请求为 63-193ms,AKTools 出现 117ms 至 10,004ms 的失败/超时,故不把本地观测视为容量结论;本低频开发工具不做负载测试。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# GitNexus 开发前基线
|
||||||
|
|
||||||
|
- 功能编号:FEAT-MO-005
|
||||||
|
- 记录时间:2026年8月25日 GMT+8 01:15:39
|
||||||
|
- 分支:codex/feat-mo-005-market-structure-turnover
|
||||||
|
- 基线提交:4f5a56df3a71
|
||||||
|
|
||||||
|
## 工作区
|
||||||
|
|
||||||
|
```text
|
||||||
|
?? "SRMS3.6.0\345\270\270\350\247\201\351\227\256\351\242\230\346\265\213\350\257\204\351\242\230.2csv"
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitNexus 状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
|
||||||
|
Indexed: 8/25/2026, 1:15:38 AM
|
||||||
|
Indexed commit: 4f5a56d
|
||||||
|
Current commit: 4f5a56d
|
||||||
|
Status: ✅ up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后续人工分析
|
||||||
|
|
||||||
|
在修改代码前,将 GitNexus query、context、impact 的结论写入 `gitnexus/impact-plan.md`。高风险结果必须先获得用户确认。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# GitNexus 开发前影响计划
|
||||||
|
|
||||||
|
- 功能编号:FEAT-MO-005
|
||||||
|
- 总体风险:低。新增独立 Node 探针和功能档案,不修改既有函数、类、方法、HTTP 路由或运行时配置。
|
||||||
|
|
||||||
|
## 直接与间接影响
|
||||||
|
|
||||||
|
执行 `gitnexus_impact({ target: "market-structure-capability", direction: "upstream", minConfidence: 0.8 })`:目标尚未在索引中,返回零消费者与 `UNKNOWN`。这是新增符号的预编辑预期,等价于当前无直接调用者;本任务不触碰既有符号,因此无既有执行流受影响。
|
||||||
|
|
||||||
|
## 跨模块同步修改
|
||||||
|
|
||||||
|
HTTP/OpenAPI、Web API、消息契约、数据库、Redis、API、Scheduler、Worker、配置部署均不修改。新增 Node 测试、探针与功能档案;后续若将任何源接入 Nest API,必须重新进行该目标的符号级影响分析。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
featureId: FEAT-MO-005
|
||||||
|
slug: market-structure-turnover
|
||||||
|
name: 市场结构与成交额趋势
|
||||||
|
status: in_development
|
||||||
|
release: unreleased
|
||||||
|
scope: local_development_probe
|
||||||
|
createdAt: 2026-08-25
|
||||||
|
sources:
|
||||||
|
tdx: local_loopback_primary
|
||||||
|
aktools: local_loopback_supplement
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# FEAT-MO-005 产品需求
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为市场概览补充行业、题材、板块强弱排行,明确口径的资金流向,以及全市场成交额趋势;每个模块独立显示来源、交易日期与可用状态。
|
||||||
|
|
||||||
|
## 用户价值与验收
|
||||||
|
|
||||||
|
- 用户可在一个页面比较行业、题材、板块的涨跌幅和成交额,缺失字段不以零填充。
|
||||||
|
- 资金流向只展示上游明确提供的净流入字段及其定义,禁止由涨跌或成交额推导。
|
||||||
|
- 成交额统一为亿元,盘中新鲜度和收盘后最后记录分别表达。
|
||||||
|
- 上游失败时保留已验证数据或显示 `partial`/`unavailable`,不提供离线样本。
|
||||||
|
|
||||||
|
## 范围边界
|
||||||
|
|
||||||
|
本功能只读取本机 TDX 与 AKTools 桥接服务;不连接生产环境、不写数据库、不调用交易、账户或自选接口。上游授权、稳定性和再分发权未验证前,不作为生产数据源承诺。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# FEAT-MO-005 工程需求
|
||||||
|
|
||||||
|
## 数据与状态
|
||||||
|
|
||||||
|
- 服务端固定白名单访问上游,浏览器只访问 Nest API。
|
||||||
|
- 状态只能为 `fresh`、`delayed`、`partial`、`unavailable`;收盘后最后记录不等于盘中新鲜。
|
||||||
|
- `turnoverAmount`、`cumulativeAmount` 统一为亿元;`netInflow` 仅使用上游明示的净额字段。
|
||||||
|
- 每条输出保留 `source`、`tradeDate`、`asOf` 与适用的字段定义;未知字段返回 `null` 或省略。
|
||||||
|
|
||||||
|
## 同步影响判定
|
||||||
|
|
||||||
|
| 项目 | 本任务结论 | 边界 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| HTTP/OpenAPI、Web API | 不修改 | 本任务仅能力探针与档案。 |
|
||||||
|
| 消息、数据库、Redis、Scheduler、Worker、部署 | 不修改 | 只读本机调用,无持久化和后台任务。 |
|
||||||
|
| API 数据源适配器 | 后续任务需要 | 仅接入本报告标为可用或部分可用且有明确降级的接口。 |
|
||||||
|
| 测试与文档 | 已修改 | Node 探针测试和功能档案共同记录证据。 |
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# FEAT-MO-005 回退说明
|
||||||
|
|
||||||
|
本任务仅新增本地只读探针与文档,无运行时服务、数据库迁移、消息、缓存或部署变更。若探针产生误导性结果,删除后续对该映射的引用并恢复到本提交前的功能档案;不得通过重试把失败源标记为可用。
|
||||||
|
|
||||||
|
后续 API 一旦错误标记盘中新鲜、泄漏上游信息或使用未验证净流入口径,应立即关闭对应固定白名单端点,返回 `unavailable`,并保留最后已验证快照的实际日期。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# FEAT-MO-005 测试计划
|
||||||
|
|
||||||
|
## 自动化测试
|
||||||
|
|
||||||
|
- `node --test tools/probes/market-structure-capability.test.mjs`:验证 CLI 明示固定本机和只读边界;验证 TDX 列式 `Date` 与 `Amount` 等长、分钟时间戳的对齐判定。
|
||||||
|
- 手工只读探测:收盘后连续执行两次,记录状态、耗时、行数、字段、首末时间与错误类别;交易时段另执行一次。
|
||||||
|
|
||||||
|
## 安全验证
|
||||||
|
|
||||||
|
验证 `--base-url` 拒绝非回环地址和错误端口;响应体超过 2 MiB、无效 JSON、HTTP 500 与超时仅归类,不输出载荷、请求体或凭据。当前测试覆盖 CLI 边界和列式字段;上游异常路径由本机实测覆盖,后续适配器需补全固定样本契约测试。
|
||||||
|
|
||||||
|
## 性能验证
|
||||||
|
|
||||||
|
记录每个固定调用耗时,10 秒超时可重复验证。该探针不产生数据库、缓存、消息或前端渲染负载;交易时段连续观测尚未完成,不能证明新鲜度或 P95 容量。
|
||||||
|
|
||||||
|
## 通过条件与残余风险
|
||||||
|
|
||||||
|
只有映射表已记录、探针不泄漏原始内容、自动化测试通过且三次时段观测齐备,才可将具体源升级为生产候选。当前缺少交易时段观测,源映射维持保守状态。
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
import { analyzeTdxRows } from './market-structure-capability.mjs';
|
||||||
|
|
||||||
|
test('帮助信息声明固定的本机数据源边界', () => {
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
['tools/probes/market-structure-capability.mjs', '--help'],
|
||||||
|
{
|
||||||
|
cwd: new URL('../..', import.meta.url),
|
||||||
|
encoding: 'utf8',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.match(result.stdout, /127\.0\.0\.1:8080/);
|
||||||
|
assert.match(result.stdout, /只读/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TDX 列式序列在 Date 与 Amount 等长且分钟时间戳完整时标记为对齐', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
analyzeTdxRows({
|
||||||
|
Date: ['2026-08-25 09:30:00', '2026-08-25 09:35:00'],
|
||||||
|
Amount: ['10', '12'],
|
||||||
|
}),
|
||||||
|
{ dateAmountAligned: true, timestampsIntraday: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user