feat(FEAT-MO-001): 接入TDX市场宽度板块与成交额趋势

This commit is contained in:
2026-08-18 17:37:36 +08:00
parent 82619489a9
commit ff8ada8316
21 changed files with 616 additions and 69 deletions
@@ -5,8 +5,11 @@
## 数据可用性
- `summary` 和 `indices`:使用 TDX K 线真实响应,展示来源、快照时间和指数涨跌。
- `breadth`、`sectors`、`funds`:桥接层暂未注册对应方法,使用 `unavailable` 响应和明确原因。
- 页面不把不可用指标降级为模拟值;后续补齐桥接方法时只替换后端映射,不改变前端契约。
- `breadth`:使用 TDX 指数快照的 `UpHome`、`DownHome` 汇总上证、深证和北证市场宽度;未返回的 `unchanged` 明确为 `null`,不得用总数推算。
- `sectors`:使用 TDX 系统分类中的一级行业(`get_stock_list(market=16)`)和批量日行情,按涨跌幅排序后展示重点强弱板块。
- `funds`:模块名称和口径为“成交额趋势”,使用核心交易所指数日行情的 `Amount` 汇总当前与前一交易日成交额;这不是主力净流入。
- 主力净流入和盘中新闻不在本次 TDX 首屏主链路中,后续通过独立补充数据源适配器接入,并在响应中标记来源与数据质量。
- 页面不把缺失或权限不足的指标降级为模拟值;单个来源失败只影响对应模块。
## 运行边界
@@ -8,7 +8,7 @@
- 数据源从早期“确定性模拟快照”调整为 TDX 只读直连,Nest API 访问本机 `127.0.0.1:17710`,Vue 不直接访问 TDX。
- API 按摘要、指数、市场宽度、板块和资金拆分,避免把整个首屏塞入单个接口。
- 当前 TDX 桥接层仅确认 `get_market_data` 可用;宽度、板块和资金方法未注册,因此对应模块明确返回 `unavailable`。
- 当前 TDX 桥接已确认 `get_market_snapshot`、`get_stock_list` 和批量 `get_market_data` 可用;市场宽度、一级行业板块和成交额趋势改为真实 TDX 映射。主力净流入字段虽存在但当前实测为零值,盘中新闻无官方行情接口,因此两者延期到补充源功能。
- MySQL、Redis、RocketMQ 和 AI 仍不进入本切片运行链路;它们保留为后续基础设施边界。
## 实际完成需求
@@ -18,6 +18,7 @@
- 完成结论、驱动与风险、核心指数、市场宽度、板块、资金趋势模块,以及加载/错误/不可用状态。
- 完成分析视角和分析引擎切换,本地保存用户偏好。
- 真实 TDX 指数和摘要已通过本机隧道冒烟;未用指数或离线样本伪造缺失指标。
- 真实 TDX 市场宽度、一级行业和成交额趋势已通过本机桥接冒烟;资金模块明确使用成交额口径,不宣称主力净流入。
## 修复缺陷
@@ -29,7 +30,7 @@
## 未完成与延期项
- TDX 桥接层补齐市场宽度、板块排行、资金趋势方法后再实现真实图表和口径。
- 主力净流入的权限/数据质量验证、盘中新闻补充源、真实图表和口径仍延期。
- “查看依据”抽屉、指数/板块详情页、AI 请求和用户体系另立功能。
- 本地验证不代表生产 7×24 采集稳定性、商业授权或公网部署安全性。
@@ -41,7 +42,7 @@
## 验证证据
- GitNexus 已完成最终重新索引;提交前 CLI `detect-changes --scope staged` 映射 11 条概览/E2E 执行流并标记 high。高风险来自本功能修改核心概览服务和测试启动入口,已由定向单测、HTTP/OpenAPI、构建和 E2E 覆盖;未发现额外业务模块调用方。MCP context 在当前 worktree 未初始化,因此以 CLI 索引和静态调用证据补充记录。
- API 冒烟:`/summary`、`/indices` 返回 `source=tdx,status=fresh`。
- API 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;完整结果和耗时见 `tdx-smoke-report.md`。
- 前端单测、typecheck、build、后端 TDX/映射/服务/HTTP/OpenAPI 测试通过。
- `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。
- 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。
@@ -6,4 +6,6 @@
4. ~~实现指数、宽度、板块和资金模块;不可用指标使用结构化状态。~~ 已完成。
5. ~~实现分析偏好菜单和本地持久化。~~ 已完成;依据抽屉和详情页入口延期至独立功能。
6. ~~完成单元测试、类型检查、构建、接口冒烟、浏览器桌面/移动端视觉验证。~~ 已完成。
7. 补齐 TDX 桥接层的宽度、板块和资金方法后,再单独更新对应数据口径和图表需求。
7. ~~补齐 TDX 市场宽度、一级行业板块和成交额趋势映射。~~ 已确认官方接口和本机桥接可用;主力净流入与盘中新闻保留为补充源需求。
8. 更新 DTO、OpenAPI 类型和前端组件,使真实数据状态、单位和 `unchanged=null` 口径可见。
9. 完成 TDX 真实响应夹具、错误隔离、类型、构建和浏览器视觉验证;Review 后再收口功能档案。
@@ -93,3 +93,4 @@ A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百
- 0.1:建立首版 PRD,补充结论优先、分析偏好和异常状态。
- 0.2:根据 TDX 实连验证修正数据源、可用性边界和未接入基础设施范围。
- 0.3:核对官方行情接口后确认市场宽度、一级行业板块和成交额趋势可由 TDX 直接提供;主力净流入与盘中新闻保留补充源边界。
@@ -2,11 +2,12 @@
首屏为“市场概览”模块中的综合概览,采用结论优先。市场概览内部通过轻量分析状态入口切换综合、短线和中长线视角,并支持规则、AI、混合与对照分析。
当前切片开发 Vue 3 可交互首屏,Nest API 通过 TDX 只读桥接层提供真实指数和摘要;市场宽度、板块和资金接口保留结构化不可用状态,不使用离线样本。不接用户体系,也不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
当前切片开发 Vue 3 可交互首屏,Nest API 通过 TDX 只读桥接层提供真实指数、市场宽度、板块和成交额趋势;主力净流入与盘中新闻不作为本次 TDX 主链路指标,不使用离线样本。不接用户体系,也不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
## 本次实际范围
- 已完成综合、短线、中长线视角切换,以及规则、AI、混合、对照偏好选择;偏好只保存于浏览器本地。
- 已完成按摘要、指数、宽度、板块、资金拆分的五个 HTTP 接口和前端 API 封装。
- 已完成 TDX 请求超时、响应大小限制、错误脱敏和模块级失败隔离。
- 已确认并接入 TDX 市场宽度、一级行业板块和成交额趋势;资金模块明确不等同于主力净流入,`unchanged` 无可靠总数时返回 `null`。
- 详情页和“查看依据”抽屉只保留入口,另立需求实现,不在本次发布范围内。
@@ -1,22 +1,24 @@
# FEAT-MO-001 TDX 只读冒烟报告
- 验证时间:2026-08-18 13:04 CST
- 网络场景:外出 Tailscale + SSH 隧道
- 验证时间:2026-08-18 17:21 CST
- 网络场景:本机回环桥接(TDX 服务可达)
- 本机业务入口:`127.0.0.1:17710`
- 请求方式:HTTP POST JSON
- 请求类型:单只公开行情 K 线只读请求
- API 入口:`127.0.0.1:3000/api/v1/market-overview`
- 请求方式:HTTP GET(业务 API)及桥接层 HTTP POST JSON
- 请求类型:市场宽度、一级行业板块、成交额趋势只读请求
- 原始载荷:未保存
## 结果
- HTTP 请求成功。
- TDX 业务 `ErrorId` 为 `0`。
- 返回字段完整性满足当前协议基线。
- 响应包含:股票代码键、K 线日期、开盘价、最高价、最低价、收盘价、成交额、成交量、复权因子和分页元数据。
- 未调用交易、账户、自选、写入或批量接口。
- 三条业务接口均返回 HTTP `200`,响应状态均为 `fresh`,`source` 均为 `tdx`。
- `/breadth`:成功汇总上证、深证和北证指数快照中的 `UpHome`、`DownHome`;`unchanged` 返回 `null`,未进行总数推算。
- `/sectors`:成功读取一级行业目录并批量获取日行情,按涨跌幅排序返回前 8 项。
- `/funds`:成功汇总三个交易所指数日行情 `Amount`,按万元转换为亿元并计算较前一交易日变化;该指标展示为“成交额趋势”,不宣称主力净流入。
- 端到端耗时:市场宽度约 `0.11s`,一级行业约 `0.27s`,成交额趋势约 `0.12s`(单次本机冒烟,不代表生产 SLA)。
- 未调用交易、账户、自选、写入接口;未保存真实行情原始载荷。
## 脱敏与限制
- 报告不保存真实价格、成交量、家庭网络地址、Tailscale 地址、SSH 用户、密钥或完整响应。
- 本报告只证明当前开发机到 TDX 的只读协议可达,不证明 7×24 生产采集、商业再分发授权或所有板块/资金方法已经可用。
- 后续接口实现必须继续以小批量只读请求验证板块、宽度和资金字段,并在映射测试中固定字段口径。
- 本报告只证明当前开发机到 TDX 的只读接口和本次映射可达,不证明 7×24 生产采集、商业再分发授权、主力净流入口径或盘中新闻接口已经可用。
- 主力净流入和盘中新闻继续保留为补充数据源需求;后续接入必须单独记录来源、权限、口径和数据质量。
@@ -4,7 +4,7 @@
- 分析状态可以临时切换并保存默认值。
- 依据抽屉能够打开和关闭。
- 五个模块并行请求,一个模块失败不阻断其他模块。
- 真实 TDX 指数和摘要响应可映射,未提供的方法返回 `unavailable`,不伪造行情。
- 真实 TDX 指数、市场宽度、一级行业板块和成交额趋势响应可映射;主力净流入与盘中新闻不以占位值伪造。
- 加载、部分数据、过期和错误状态具有明确文案。
- 桌面和窄窗口不出现阻断核心内容的溢出。
@@ -12,14 +12,14 @@
- 前端 Vitest、typecheck、build:通过。
- 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。
- `curl` 冒烟:`/summary`、`/indices` 返回 `source=tdx,status=fresh`;`/breadth`、`/sectors`、`/funds` 返回结构化不可用状态。
- `curl` 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;分别验证 `UpHome/DownHome` 汇总、一级行业排序和成交额单位转换。
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
## 安全、性能与残余风险
- 安全:TDX 地址固定为本机回环桥接端口;请求超时、响应 1 MiB 上限、错误脱敏;只读行情,不接受用户输入拼接请求。
- 性能:五个模块并行请求,单次 TDX 请求受超时保护;本切片不引入数据库、缓存或消息负载,未进行持续压测。
- 残余风险:桥接层尚未提供宽度、板块和资金方法;详情页、依据抽屉和 AI 运行时需另立功能;市场数据授权和生产 7×24 稳定性未由本地冒烟证明。
- 残余风险:主力净流入字段当前桥接实测为零值,需权限和口径专项验证;盘中新闻仍需补充源;详情页、依据抽屉和 AI 运行时需另立功能;市场数据授权和生产 7×24 稳定性未由本地冒烟证明。
## 安全验证
@@ -72,18 +72,44 @@ export class BreadthDataDto {
@ApiProperty({ type: Number })
declining!: number;
@ApiProperty({ type: Number })
unchanged!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
unchanged!: number | null;
}
export class SectorQuoteDto {
@ApiProperty({ type: String })
code!: string;
@ApiProperty({ type: String })
name!: string;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent!: number | null;
@ApiProperty({ enum: ['up', 'down', 'flat'] })
direction!: QuoteDirection;
@ApiProperty({ type: String })
tradeDate!: string;
}
export class SectorDataDto {
@ApiProperty({ type: [String] })
items!: string[];
@ApiProperty({ type: [SectorQuoteDto] })
items!: SectorQuoteDto[];
}
export class FundDataDto {
@ApiProperty({ type: [String] })
items!: string[];
@ApiProperty({ type: Number })
currentAmount!: number;
@ApiProperty({ type: Number })
previousAmount!: number;
@ApiProperty({ type: Number })
changePercent!: number;
@ApiProperty({ type: String, example: '亿元' })
unit!: '亿元';
}
export class SummaryResponseDto extends MarketOverviewMetaDto {
@@ -19,6 +19,63 @@ const indexResult = {
),
};
const breadthSnapshot = (up: string, down: string) => ({
ErrorId: '0',
UpHome: up,
DownHome: down,
ItemNum: '0',
});
const sectorResult = {
ErrorId: '0',
Value: [
{ Code: '881319.SH', Name: '半导体' },
{ Code: '881355.SH', Name: '软件服务' },
],
};
const sectorKlineResult = {
ErrorId: '0',
Value: {
'881319.SH': {
Date: ['20260817', '20260818'],
Open: ['100', '105'],
High: ['106', '110'],
Low: ['99', '104'],
Close: ['100', '108'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
},
'881355.SH': {
Date: ['20260817', '20260818'],
Open: ['100', '98'],
High: ['101', '99'],
Low: ['97', '95'],
Close: ['100', '96'],
Amount: ['1000', '900'],
Volume: ['100', '90'],
},
},
};
const turnoverResult = {
ErrorId: '0',
Value: Object.fromEntries(
['000001.SH', '399001.SZ', '899050.BJ'].map((code) => [
code,
{
Date: ['20260817', '20260818'],
Open: ['100', '100'],
High: ['101', '101'],
Low: ['99', '99'],
Close: ['100', '100'],
Amount: ['100000', '120000'],
Volume: ['1000', '1100'],
},
]),
),
};
describe('市场概览服务', () => {
it('读取指数并返回统一快照元数据', async () => {
const client = { request: vi.fn().mockResolvedValue(indexResult) };
@@ -38,14 +95,69 @@ describe('市场概览服务', () => {
);
});
it('对未验证的市场宽度能力返回不可用而不是伪造数据', async () => {
const client = { request: vi.fn() };
it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => {
const client = {
request: vi
.fn()
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
.mockResolvedValueOnce(breadthSnapshot('3', '2')),
};
const service = new MarketOverviewService(client);
const response = await service.getBreadth();
expect(response).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
expect(response.warnings[0]).toContain('市场宽度');
expect(client.request).not.toHaveBeenCalled();
expect(response).toMatchObject({
source: 'tdx',
status: 'fresh',
data: { advancing: 33, declining: 22, unchanged: null },
});
expect(client.request).toHaveBeenCalledWith(
'get_market_snapshot',
expect.objectContaining({ stock_code: '000001.SH' }),
);
});
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
const client = {
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
};
const service = new MarketOverviewService(client);
const response = await service.getSectors();
expect(response).toMatchObject({
source: 'tdx',
status: 'fresh',
data: {
items: [
expect.objectContaining({ code: '881319.SH', name: '半导体', changePercent: 8 }),
expect.objectContaining({ code: '881355.SH', name: '软件服务', changePercent: -4 }),
],
},
});
expect(client.request).toHaveBeenNthCalledWith(1, 'get_stock_list', {
market: '16',
list_type: 1,
});
});
it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => {
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
const service = new MarketOverviewService(client);
const response = await service.getFunds();
expect(response).toMatchObject({
source: 'tdx',
status: 'fresh',
data: {
currentAmount: 36,
previousAmount: 30,
changePercent: 20,
unit: '亿元',
},
});
expect(response.warnings).toEqual([]);
});
});
@@ -8,9 +8,19 @@ import type {
SummaryDataDto,
} from './market-overview.dto';
import { CORE_INDEXES } from './tdx/tdx.types';
import { mapIndexQuotes } from './tdx/tdx.mapper';
import {
mapBreadthSnapshots,
mapIndexQuotes,
mapSectorQuotes,
mapTurnoverTrend,
} from './tdx/tdx.mapper';
import { TdxClientError, type TdxClient } from './tdx/tdx.client';
import type { TdxMarketDataResult } from './tdx/tdx.types';
import type {
TdxMarketDataResult,
TdxMarketSnapshotResult,
TdxStockListResult,
} from './tdx/tdx.types';
import { BREADTH_INDEXES, TURNOVER_INDEXES } from './tdx/tdx.types';
export const TDX_CLIENT_PORT = Symbol('TDX_CLIENT_PORT');
@@ -32,10 +42,6 @@ function createSnapshotMeta(): Pick<
return { snapshotId, asOf, source: 'tdx' };
}
function unavailable<TData>(warning: string): MarketOverviewResponse<TData> {
return { ...createSnapshotMeta(), status: 'unavailable', data: null, warnings: [warning] };
}
@Injectable()
export class MarketOverviewService {
constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {}
@@ -89,24 +95,110 @@ export class MarketOverviewService {
? '市场维持偏强震荡'
: '市场维持偏弱震荡',
marketStatus,
volumeStatus: '成交量口径待接入',
sentimentStatus: '市场情绪口径待接入',
volumeStatus: '成交额趋势见下方',
sentimentStatus: '涨跌家数见下方',
drivers: changes.length ? [`核心指数平均涨跌幅 ${averageChange}%`] : ['核心指数数据不足'],
risks: ['市场宽度、板块和资金指标尚未由当前 TDX 桥接层提供'],
risks: ['主力净流入和盘中新闻仍待补充数据源验证'],
};
return { ...indices, data: summary };
}
async getBreadth(): Promise<MarketOverviewResponse<BreadthDataDto>> {
return unavailable<BreadthDataDto>('当前 TDX 桥接层未提供市场宽度方法');
const meta = createSnapshotMeta();
const results = await Promise.allSettled(
BREADTH_INDEXES.map((item) =>
this.tdxClient.request<TdxMarketSnapshotResult>('get_market_snapshot', {
stock_code: item.code,
}),
),
);
const snapshots = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : [],
);
if (!snapshots.length) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [
this.toWarning(
results[0]?.status === 'rejected' ? results[0].reason : undefined,
'市场宽度',
),
],
};
}
const status = snapshots.length === BREADTH_INDEXES.length ? 'fresh' : 'partial';
return {
...meta,
status,
data: mapBreadthSnapshots(snapshots),
warnings: status === 'partial' ? ['部分市场指数未返回涨跌家数'] : [],
};
}
async getSectors(): Promise<MarketOverviewResponse<SectorDataDto>> {
return unavailable<SectorDataDto>('当前 TDX 桥接层未提供板块排行方法');
const meta = createSnapshotMeta();
try {
const catalog = await this.tdxClient.request<TdxStockListResult>('get_stock_list', {
market: '16',
list_type: 1,
});
const definitions = catalog.Value.map(({ Code: code, Name: name }) => ({ code, name }));
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
stock_list: definitions.map((item) => item.code),
count: 2,
dividend_type: 'none',
period: '1d',
});
const mappedItems = mapSectorQuotes(result, definitions);
const items = mappedItems.slice(0, 8);
const status =
mappedItems.length === definitions.length
? 'fresh'
: mappedItems.length
? 'partial'
: 'unavailable';
return {
...meta,
status,
data: items.length ? { items } : null,
warnings:
status === 'fresh'
? []
: [status === 'partial' ? '部分一级行业未返回有效行情' : '一级行业行情不可用'],
};
} catch (error) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [this.toWarning(error, '板块行情')],
};
}
}
async getFunds(): Promise<MarketOverviewResponse<FundDataDto>> {
return unavailable<FundDataDto>('当前 TDX 桥接层未提供资金趋势方法');
const meta = createSnapshotMeta();
try {
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
stock_list: TURNOVER_INDEXES.map((item) => item.code),
count: 2,
dividend_type: 'none',
period: '1d',
});
const data = mapTurnoverTrend(result, TURNOVER_INDEXES);
if (!data)
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] };
return { ...meta, status: 'fresh', data, warnings: [] };
} catch (error) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [this.toWarning(error, '成交额趋势')],
};
}
}
private toWarning(error: unknown, subject: string): string {
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { mapIndexQuotes } from './tdx.mapper';
import { mapBreadthSnapshots, mapIndexQuotes, mapTurnoverTrend } from './tdx.mapper';
describe('TDX 行情映射', () => {
it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => {
@@ -55,4 +55,35 @@ describe('TDX 行情映射', () => {
expect(result[0]).toMatchObject({ price: 2105, changePercent: null, direction: 'flat' });
});
it('市场宽度映射只汇总可用的上涨和下跌家数', () => {
expect(
mapBreadthSnapshots([
{ UpHome: '10', DownHome: '5' },
{ UpHome: '2', DownHome: '3' },
]),
).toEqual({ advancing: 12, declining: 8, unchanged: null });
});
it('成交额映射将 TDX 万元转换为亿元并计算环比', () => {
expect(
mapTurnoverTrend(
{
ErrorId: '0',
Value: {
'000001.SH': {
Date: ['20260817', '20260818'],
Open: ['1', '1'],
High: ['1', '1'],
Low: ['1', '1'],
Close: ['1', '1'],
Amount: ['100000', '120000'],
Volume: ['1', '1'],
},
},
},
[{ code: '000001.SH', name: '上证市场' }],
),
).toEqual({ currentAmount: 12, previousAmount: 10, changePercent: 20, unit: '亿元' });
});
});
@@ -1,4 +1,9 @@
import type { IndexQuoteDto } from '../market-overview.dto';
import type {
BreadthDataDto,
FundDataDto,
IndexQuoteDto,
SectorQuoteDto,
} from '../market-overview.dto';
import type { IndexDefinition, TdxMarketDataResult, TdxKlineSeries } from './tdx.types';
function toNumber(value: string | undefined): number | null {
@@ -46,3 +51,51 @@ export function mapIndexQuotes(
return quote ? [quote] : [];
});
}
export function mapBreadthSnapshots(
snapshots: readonly { UpHome?: string; DownHome?: string }[],
): BreadthDataDto {
const advancing = snapshots.reduce((total, item) => total + (toNumber(item.UpHome) ?? 0), 0);
const declining = snapshots.reduce((total, item) => total + (toNumber(item.DownHome) ?? 0), 0);
return { advancing, declining, unchanged: null };
}
export function mapSectorQuotes(
result: TdxMarketDataResult,
definitions: readonly IndexDefinition[],
): SectorQuoteDto[] {
return mapIndexQuotes(result, definitions).sort((left, right) => {
return (
(right.changePercent ?? Number.NEGATIVE_INFINITY) -
(left.changePercent ?? Number.NEGATIVE_INFINITY)
);
});
}
export function mapTurnoverTrend(
result: TdxMarketDataResult,
definitions: readonly IndexDefinition[],
): FundDataDto | null {
let currentAmount = 0;
let previousAmount = 0;
let validSeries = 0;
for (const definition of definitions) {
const series = result.Value[definition.code];
if (!series) continue;
const current = toNumber(series.Amount.at(-1));
const previous = toNumber(series.Amount.at(-2));
if (current === null || previous === null) continue;
currentAmount += current;
previousAmount += previous;
validSeries += 1;
}
if (!validSeries || previousAmount === 0) return null;
return {
currentAmount: Math.round((currentAmount / 10000) * 100) / 100,
previousAmount: Math.round((previousAmount / 10000) * 100) / 100,
changePercent: Math.round(((currentAmount - previousAmount) / previousAmount) * 10000) / 100,
unit: '亿元',
};
}
@@ -14,6 +14,20 @@ export interface TdxMarketDataResult {
Value: Record<string, TdxKlineSeries>;
}
export interface TdxMarketSnapshotResult {
ErrorId: string | number;
UpHome?: string;
DownHome?: string;
Inside?: string;
Outside?: string;
Amount?: string;
}
export interface TdxStockListResult {
ErrorId: string | number;
Value: Array<{ Code: string; Name: string }>;
}
export interface IndexDefinition {
code: string;
name: string;
@@ -25,3 +39,17 @@ export const CORE_INDEXES: readonly IndexDefinition[] = [
{ code: '399001.SZ', name: '深证成指' },
{ code: '399006.SZ', name: '创业板指' },
];
/** TDX 快照对指数提供上涨/下跌家数,三个市场相加后作为首屏市场宽度口径。 */
export const BREADTH_INDEXES: readonly IndexDefinition[] = [
{ code: '000001.SH', name: '上证市场' },
{ code: '399001.SZ', name: '深证市场' },
{ code: '899050.BJ', name: '北证市场' },
];
/** 首屏资金模块使用交易所指数成交额,避免把成交额趋势误称为主力净流入。 */
export const TURNOVER_INDEXES: readonly IndexDefinition[] = [
{ code: '000001.SH', name: '上证市场' },
{ code: '399001.SZ', name: '深证市场' },
{ code: '899050.BJ', name: '北证市场' },
];
@@ -72,13 +72,32 @@ describe('市场概览 HTTP API', () => {
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
});
it('未提供的板块能力返回可读的不可用状态', async () => {
it('板块接口返回可点击的 TDX 一级行业对象', async () => {
const client = {
request: vi
.fn()
.mockResolvedValueOnce({
ErrorId: '0',
Value: [{ Code: '881319.SH', Name: '半导体' }],
})
.mockResolvedValueOnce({
ErrorId: '0',
Value: {
'881319.SH': {
Date: ['20260817', '20260818'],
Open: ['100', '101'],
High: ['102', '103'],
Low: ['99', '100'],
Close: ['100', '102'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
},
},
}),
};
const moduleRef = await Test.createTestingModule({
controllers: [MarketOverviewController],
providers: [
MarketOverviewService,
{ provide: TDX_CLIENT_PORT, useValue: { request: vi.fn() } },
],
providers: [MarketOverviewService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
@@ -88,7 +107,13 @@ describe('市场概览 HTTP API', () => {
.get('/api/v1/market-overview/sectors')
.expect(200);
expect(response.body).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
expect(response.body.warnings[0]).toContain('板块排行');
expect(response.body).toMatchObject({
source: 'tdx',
status: 'fresh',
data: {
items: [expect.objectContaining({ code: '881319.SH', name: '半导体', changePercent: 2 })],
},
});
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
});
});
+22 -5
View File
@@ -5,9 +5,9 @@
"properties": {
"advancing": { "type": "number" },
"declining": { "type": "number" },
"unchanged": { "type": "number" }
"unchanged": { "nullable": true, "type": "number" }
},
"required": ["advancing", "declining", "unchanged"],
"required": ["advancing", "declining"],
"type": "object"
},
"BreadthResponseDto": {
@@ -42,9 +42,12 @@
},
"FundDataDto": {
"properties": {
"items": { "items": { "type": "string" }, "type": "array" }
"changePercent": { "type": "number" },
"currentAmount": { "type": "number" },
"previousAmount": { "type": "number" },
"unit": { "example": "亿元", "type": "string" }
},
"required": ["items"],
"required": ["currentAmount", "previousAmount", "changePercent", "unit"],
"type": "object"
},
"FundsResponseDto": {
@@ -130,11 +133,25 @@
},
"SectorDataDto": {
"properties": {
"items": { "items": { "type": "string" }, "type": "array" }
"items": {
"items": { "$ref": "#/components/schemas/SectorQuoteDto" },
"type": "array"
}
},
"required": ["items"],
"type": "object"
},
"SectorQuoteDto": {
"properties": {
"changePercent": { "nullable": true, "type": "number" },
"code": { "type": "string" },
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
"name": { "type": "string" },
"tradeDate": { "type": "string" }
},
"required": ["code", "name", "direction", "tradeDate"],
"type": "object"
},
"SectorsResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
+15 -3
View File
@@ -107,7 +107,7 @@ export interface components {
BreadthDataDto: {
advancing: number;
declining: number;
unchanged: number;
unchanged?: number | null;
};
BreadthResponseDto: {
/** Format: date-time */
@@ -126,7 +126,11 @@ export interface components {
status: 'healthy' | 'unavailable' | 'disabled';
};
FundDataDto: {
items: string[];
changePercent: number;
currentAmount: number;
previousAmount: number;
/** @example 亿元 */
unit: string;
};
FundsResponseDto: {
/** Format: date-time */
@@ -177,7 +181,15 @@ export interface components {
warnings: string[];
};
SectorDataDto: {
items: string[];
items: components['schemas']['SectorQuoteDto'][];
};
SectorQuoteDto: {
changePercent?: number | null;
code: string;
/** @enum {string} */
direction: 'up' | 'down' | 'flat';
name: string;
tradeDate: string;
};
SectorsResponseDto: {
/** Format: date-time */
+42
View File
@@ -472,12 +472,54 @@ textarea {
}
.sector-list button {
display: inline-flex;
align-items: center;
justify-content: space-between;
min-width: 112px;
gap: 10px;
padding: 7px 9px;
color: #4c5966;
background: #f3f7f7;
font-size: 12px;
}
.sector-list button strong {
font-weight: 650;
}
.sector-list button[data-direction='up'] strong,
.turnover-trend [data-direction='up'] {
color: #c33f3f;
}
.sector-list button[data-direction='down'] strong,
.turnover-trend [data-direction='down'] {
color: #19846e;
}
.turnover-trend {
display: grid;
gap: 12px;
margin-top: 24px;
}
.turnover-trend div {
display: flex;
align-items: baseline;
gap: 7px;
}
.turnover-trend strong {
color: #1c2a35;
font-size: 25px;
}
.turnover-trend small,
.turnover-trend p {
color: #7d8893;
font-size: 12px;
}
@media (max-width: 900px) {
.overview-grid-top,
.overview-grid-bottom {
@@ -8,16 +8,28 @@ defineProps<{ response: FundsResponse | null; loading: boolean }>();
<section class="overview-panel" aria-labelledby="funds-title">
<div class="panel-heading">
<div>
<span class="section-kicker">资金观察</span>
<h2 id="funds-title">资金趋势</h2>
<span class="section-kicker">量能观察</span>
<h2 id="funds-title">成交额趋势</h2>
</div>
<span class="panel-meta">数据口径</span>
<span class="panel-meta">TDX · 亿元</span>
</div>
<div v-if="loading" class="module-placeholder">正在读取资金趋势…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<div v-else class="module-placeholder">暂无资金趋势数据</div>
<div v-else-if="response?.data" class="turnover-trend">
<div>
<span>三市成交额</span>
<strong>{{ response.data.currentAmount.toLocaleString('zh-CN') }}</strong>
<small>{{ response.data.unit }}</small>
</div>
<p :data-direction="response.data.changePercent >= 0 ? 'up' : 'down'">
较前一交易日
{{ response.data.changePercent > 0 ? '+' : ''
}}{{ response.data.changePercent.toFixed(2) }}%
</p>
</div>
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
</section>
</template>
@@ -26,7 +26,7 @@ defineProps<{ response: BreadthResponse | null; loading: boolean }>();
<span>下跌</span><strong class="negative">{{ response.data.declining }}</strong>
</div>
<div>
<span>平盘</span><strong>{{ response.data.unchanged }}</strong>
<span>平盘</span><strong>{{ response.data.unchanged ?? '—' }}</strong>
</div>
</div>
<div v-else class="module-placeholder">暂无宽度数据</div>
@@ -0,0 +1,78 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import FundTrend from './FundTrend.vue';
import MarketBreadth from './MarketBreadth.vue';
import SectorOverview from './SectorOverview.vue';
const meta = {
snapshotId: '20260818-150000',
asOf: '2026-08-18T15:00:00.000Z',
source: 'tdx' as const,
status: 'fresh' as const,
warnings: [],
};
describe('市场概览真实数据面板', () => {
it('市场宽度对未知平盘家数显示占位符而不是零', () => {
const wrapper = mount(MarketBreadth, {
props: {
loading: false,
response: { ...meta, data: { advancing: 2121, declining: 3292, unchanged: null } },
},
});
expect(wrapper.text()).toContain('2121');
expect(wrapper.text()).toContain('3292');
expect(wrapper.text()).toContain('平盘—');
});
it('板块展示名称和涨跌幅并以代码触发详情入口', async () => {
const wrapper = mount(SectorOverview, {
props: {
loading: false,
response: {
...meta,
data: {
items: [
{
code: '881319.SH',
name: '半导体',
changePercent: 2.56,
direction: 'up',
tradeDate: '20260818',
},
],
},
},
},
});
expect(wrapper.text()).toContain('半导体');
expect(wrapper.text()).toContain('+2.56%');
await wrapper.get('button').trigger('click');
expect(wrapper.emitted('select')).toEqual([['881319.SH']]);
});
it('资金模块明确展示成交额和与上一交易日的变化', () => {
const wrapper = mount(FundTrend, {
props: {
loading: false,
response: {
...meta,
data: {
currentAmount: 12345.67,
previousAmount: 11000,
changePercent: 12.23,
unit: '亿元' as const,
},
},
},
});
expect(wrapper.text()).toContain('成交额趋势');
expect(wrapper.text()).toContain('12,345.67');
expect(wrapper.text()).toContain('较前一交易日 +12.23%');
expect(wrapper.text()).not.toContain('主力净流入');
});
});
@@ -22,11 +22,20 @@ const emit = defineEmits<{ select: [sector: string] }>();
<div v-else-if="response?.data?.items.length" class="sector-list">
<button
v-for="sector in response.data.items"
:key="sector"
:key="sector.code"
type="button"
@click="emit('select', sector)"
:data-direction="sector.direction"
:title="`进入${sector.name}详细分析`"
@click="emit('select', sector.code)"
>
{{ sector }}
<span>{{ sector.name }}</span>
<strong>
{{
sector.changePercent == null
? '—'
: `${sector.changePercent > 0 ? '+' : ''}${sector.changePercent.toFixed(2)}%`
}}
</strong>
</button>
</div>
<div v-else class="module-placeholder">暂无板块数据</div>