feat(FEAT-MO-005): 建立市场结构数据映射边界
This commit is contained in:
@@ -1,12 +1,36 @@
|
||||
# GitNexus 开发前影响计划
|
||||
|
||||
- 功能编号:FEAT-MO-005
|
||||
- 总体风险:低。新增独立 Node 探针和功能档案,不修改既有函数、类、方法、HTTP 路由或运行时配置。
|
||||
- 总体风险:中。新增结构边界本身独立,但共享响应 DTO 有 24 个上游影响项,必须保持旧接口字段和 `source=tdx` 行为兼容。
|
||||
|
||||
## 直接与间接影响
|
||||
|
||||
执行 `gitnexus_impact({ target: "market-structure-capability", direction: "upstream", minConfidence: 0.8 })`:目标尚未在索引中,返回零消费者与 `UNKNOWN`。这是新增符号的预编辑预期,等价于当前无直接调用者;本任务不触碰既有符号,因此无既有执行流受影响。
|
||||
### `MarketOverviewMetaDto`(MEDIUM)
|
||||
|
||||
- 直接影响 12 项:现有 summary、indices、breadth、sectors、funds 响应 DTO,TDX mapper、详情 DTO/mapper、controller 和 service。
|
||||
- 间接影响 12 项:概览和详情 e2e、OpenAPI 导出、模块注册及相关测试。
|
||||
- 决策:新增数据源联合类型时只扩展 `source` 的允许值,不改变既有 TDX 响应结构;所有旧测试必须继续通过。
|
||||
|
||||
### `MarketOverviewService`(MEDIUM)
|
||||
|
||||
- 直接影响 7 项:概览 e2e、服务测试、OpenAPI 导出、模块/controller 和详情服务引用。
|
||||
- 间接影响 6 项:API 入口和进程冒烟测试。
|
||||
- 决策:新结构服务独立注入,不重写现有 `MarketOverviewService` 的业务流程;仅在模块注册层增加 provider/controller。
|
||||
|
||||
### `createAktoolsClient`(LOW)
|
||||
|
||||
- 直接影响 1 项:现有 AKTools client 测试。
|
||||
- 决策:不扩展短线 client 的固定端点,新增 `AktoolsStructureClient`,避免行业/资金接口与短线情绪接口耦合。
|
||||
|
||||
GitNexus `query("市场概览 板块排行 资金流入流出 成交额趋势 TDX AKTools")` 定位到现有概览流程、TDX mapper 和 AKTools 模块;本任务后续只新增 structure 领域文件,不改变已验证的短线流程。
|
||||
|
||||
## 跨模块同步修改
|
||||
|
||||
HTTP/OpenAPI、Web API、消息契约、数据库、Redis、API、Scheduler、Worker、配置部署均不修改。新增 Node 测试、探针与功能档案;后续若将任何源接入 Nest API,必须重新进行该目标的符号级影响分析。
|
||||
| 范围 | 判断 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| HTTP/OpenAPI | 需要 | Tasks 3–5 新增三个独立只读路由,Task 6 重新生成契约 |
|
||||
| Web API/前端 | 需要 | Task 6 添加 Axios API 和 composable 状态,Task 7 添加面板 |
|
||||
| 消息契约、数据库、Redis、Scheduler、Worker | 不需要 | 本切片只读请求,无持久化、缓存和异步任务 |
|
||||
| API 模块注册 | 需要 | `market-overview.module.ts` 增加结构 controller/provider |
|
||||
| 配置部署 | 不需要 | 复用已存在的本机 TDX/AKTools 配置,不新增凭据或地址 |
|
||||
| 测试与文档 | 需要 | 新增 mapper/service/API/UI/E2E 测试和完整功能档案 |
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export type MarketOverviewStatus = 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||
export type QuoteDirection = 'up' | 'down' | 'flat';
|
||||
export type MarketOverviewSource = 'tdx' | 'aktools' | 'mixed';
|
||||
|
||||
export class MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: String })
|
||||
@@ -10,8 +11,8 @@ export class MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: String, format: 'date-time' })
|
||||
asOf!: string;
|
||||
|
||||
@ApiProperty({ type: String, enum: ['tdx'] })
|
||||
source!: 'tdx';
|
||||
@ApiProperty({ type: String, enum: ['tdx', 'aktools', 'mixed'] })
|
||||
source!: MarketOverviewSource;
|
||||
|
||||
@ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] })
|
||||
status!: MarketOverviewStatus;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAktoolsStructureClient } from './aktools-structure.client';
|
||||
|
||||
describe('AKTools 市场结构客户端', () => {
|
||||
it('只调用固定的行业接口并解析数组', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response(JSON.stringify([{ 名称: '半导体' }]), { status: 200 }));
|
||||
const client = createAktoolsStructureClient({
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
timeoutMs: 1000,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await expect(client.getSectorRows('industry')).resolves.toEqual([{ 名称: '半导体' }]);
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:8080/api/public/stock_board_industry_spot_em',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('不为未验证的 board 分类拼接任意接口', async () => {
|
||||
const client = createAktoolsStructureClient({
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
timeoutMs: 1000,
|
||||
fetchImpl: vi.fn(),
|
||||
});
|
||||
await expect(client.getSectorRows('board')).rejects.toMatchObject({ kind: 'unavailable' });
|
||||
});
|
||||
|
||||
it('服务关闭时不发起请求', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>();
|
||||
const client = createAktoolsStructureClient({
|
||||
enabled: false,
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
timeoutMs: 1000,
|
||||
fetchImpl,
|
||||
});
|
||||
await expect(client.getCapitalFlowRows('industry')).rejects.toMatchObject({ kind: 'disabled' });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { AktoolsRuntimeConfig } from '../../../../../libs/config/src/runtime-config';
|
||||
|
||||
import { AktoolsClientError } from '../aktools/aktools.client';
|
||||
import type {
|
||||
MarketStructureSourceClient,
|
||||
StructureCategory,
|
||||
StructureRawRow,
|
||||
} from './market-structure.types';
|
||||
|
||||
export interface AktoolsStructureClientOptions extends AktoolsRuntimeConfig {
|
||||
fetchImpl?: typeof fetch;
|
||||
maxResponseBytes?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
const ENDPOINTS = Object.freeze({
|
||||
industry: 'stock_board_industry_spot_em',
|
||||
concept: 'stock_board_concept_spot_em',
|
||||
capitalFlow: 'stock_sector_fund_flow_rank',
|
||||
marketFundFlow: 'stock_market_fund_flow',
|
||||
});
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
|
||||
function categoryEndpoint(category: StructureCategory): string {
|
||||
if (category === 'industry') return ENDPOINTS.industry;
|
||||
if (category === 'concept') return ENDPOINTS.concept;
|
||||
throw new AktoolsClientError('unavailable', '板块分类数据源未验证');
|
||||
}
|
||||
|
||||
function categoryFlowType(category: StructureCategory): string {
|
||||
if (category === 'industry') return '行业资金流';
|
||||
if (category === 'concept') return '概念资金流';
|
||||
throw new AktoolsClientError('unavailable', '板块资金流数据源未验证');
|
||||
}
|
||||
|
||||
export function createAktoolsStructureClient({
|
||||
enabled,
|
||||
baseUrl,
|
||||
timeoutMs,
|
||||
fetchImpl = fetch,
|
||||
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
||||
}: AktoolsStructureClientOptions): MarketStructureSourceClient {
|
||||
const endpoint = `${baseUrl.replace(/\/$/, '')}/api/public/`;
|
||||
|
||||
async function request(
|
||||
path: string,
|
||||
params: Readonly<Record<string, string>> = {},
|
||||
): Promise<StructureRawRow[]> {
|
||||
if (!enabled) throw new AktoolsClientError('disabled', 'AKTools 未启用');
|
||||
const search = new URLSearchParams(params);
|
||||
const url = `${endpoint}${path}${search.toString() ? `?${search.toString()}` : ''}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength && Number(contentLength) > maxResponseBytes) {
|
||||
throw new AktoolsClientError('protocol', 'AKTools 响应体超过安全上限');
|
||||
}
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text, 'utf8') > maxResponseBytes) {
|
||||
throw new AktoolsClientError('protocol', 'AKTools 响应体超过安全上限');
|
||||
}
|
||||
if (!response.ok) throw new AktoolsClientError('unavailable', 'AKTools 服务不可用');
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw new AktoolsClientError('protocol', 'AKTools 返回格式错误');
|
||||
}
|
||||
if (!Array.isArray(value) || value.some((item) => !item || typeof item !== 'object')) {
|
||||
throw new AktoolsClientError('protocol', 'AKTools 返回数据结构错误');
|
||||
}
|
||||
return value as StructureRawRow[];
|
||||
} catch (error) {
|
||||
if (error instanceof AktoolsClientError) throw error;
|
||||
if (isAbortError(error)) throw new AktoolsClientError('timeout', 'AKTools 请求超时');
|
||||
throw new AktoolsClientError('network', 'AKTools 网络请求失败');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getSectorRows: async (category) => request(categoryEndpoint(category)),
|
||||
getCapitalFlowRows: async (category) =>
|
||||
request(ENDPOINTS.capitalFlow, {
|
||||
indicator: '今日',
|
||||
sector_type: categoryFlowType(category),
|
||||
}),
|
||||
getMarketFundFlowRows: () => request(ENDPOINTS.marketFundFlow),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mapCapitalFlow, mapSectorRanking, mapTurnoverPoints } from './market-structure.mapper';
|
||||
|
||||
describe('市场结构数据映射', () => {
|
||||
it('映射行业排行并保留涨跌幅与成交额', () => {
|
||||
expect(
|
||||
mapSectorRanking(
|
||||
{
|
||||
代码: 'BK0001',
|
||||
名称: '半导体',
|
||||
涨跌幅: '2.5',
|
||||
成交额: '123456789',
|
||||
上涨家数: '20',
|
||||
下跌家数: '4',
|
||||
领涨股: '测试股份',
|
||||
},
|
||||
'industry',
|
||||
'20260825',
|
||||
),
|
||||
).toEqual({
|
||||
category: 'industry',
|
||||
code: 'BK0001',
|
||||
name: '半导体',
|
||||
changePercent: 2.5,
|
||||
turnoverAmount: 1.23,
|
||||
advancingCount: 20,
|
||||
decliningCount: 4,
|
||||
leader: '测试股份',
|
||||
tradeDate: '20260825',
|
||||
});
|
||||
});
|
||||
|
||||
it('拒绝未知分类和缺少代码名称的排行记录', () => {
|
||||
expect(mapSectorRanking({ code: 'BK0001', name: '测试' }, 'board', '20260825')).toMatchObject({
|
||||
category: 'board',
|
||||
code: 'BK0001',
|
||||
});
|
||||
expect(mapSectorRanking({ 名称: '无代码' }, 'industry', '20260825')).toBeNull();
|
||||
});
|
||||
|
||||
it('只使用上游明确的净流入字段,不用成交额推导资金', () => {
|
||||
expect(
|
||||
mapCapitalFlow(
|
||||
{
|
||||
名称: '半导体',
|
||||
'今日主力净流入-净额': '100000000',
|
||||
成交额: '500000000',
|
||||
},
|
||||
'industry',
|
||||
'20260825',
|
||||
),
|
||||
).toMatchObject({
|
||||
name: '半导体',
|
||||
netInflow: 1,
|
||||
netOutflow: null,
|
||||
turnoverAmount: 5,
|
||||
flowDefinition: '今日主力净流入-净额',
|
||||
});
|
||||
expect(mapCapitalFlow({ 名称: '未知', 成交额: '100' }, 'industry', '20260825')).toMatchObject({
|
||||
netInflow: null,
|
||||
netOutflow: null,
|
||||
flowDefinition: 'unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('拒绝倒序、重复和负数成交额点并计算累计值', () => {
|
||||
expect(
|
||||
mapTurnoverPoints([
|
||||
{ 时间: '2026-08-25 09:30:00', 成交额: '100000000' },
|
||||
{ 时间: '2026-08-25 09:35:00', 成交额: '200000000' },
|
||||
{ 时间: '2026-08-25 09:35:00', 成交额: '300000000' },
|
||||
{ 时间: '2026-08-25 09:34:00', 成交额: '400000000' },
|
||||
{ 时间: '2026-08-25 09:40:00', 成交额: '-100000000' },
|
||||
]),
|
||||
).toEqual({
|
||||
points: [
|
||||
{ time: '2026-08-25 09:30:00', amount: 1, cumulativeAmount: 1 },
|
||||
{ time: '2026-08-25 09:35:00', amount: 2, cumulativeAmount: 3 },
|
||||
],
|
||||
invalidCount: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
CapitalFlowRecord,
|
||||
SectorRankingRecord,
|
||||
StructureCategory,
|
||||
StructureRawRow,
|
||||
TurnoverMappingResult,
|
||||
TurnoverPoint,
|
||||
} from './market-structure.types';
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const parsed = Number(value.replaceAll(',', ''));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toNonNegativeNumber(value: unknown): number | null {
|
||||
const parsed = toNumber(value);
|
||||
return parsed !== null && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function toText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const text = value.trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
function firstValue(row: StructureRawRow, keys: readonly string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (row[key] !== undefined) return row[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function amountInYi(value: unknown): number | null {
|
||||
const parsed = toNonNegativeNumber(value);
|
||||
return parsed === null ? null : Math.round((parsed / 100_000_000) * 100) / 100;
|
||||
}
|
||||
|
||||
function dateValue(value: unknown): string | null {
|
||||
const text = toText(value);
|
||||
return text && /^\d{8}$/.test(text) ? text : text;
|
||||
}
|
||||
|
||||
export function mapSectorRanking(
|
||||
row: StructureRawRow,
|
||||
category: StructureCategory,
|
||||
tradeDate: string | null,
|
||||
): SectorRankingRecord | null {
|
||||
const code = toText(firstValue(row, ['代码', '板块代码', 'code', 'f12']));
|
||||
const name = toText(firstValue(row, ['名称', '板块名称', 'name', 'f14']));
|
||||
if (!code || !name) return null;
|
||||
|
||||
return {
|
||||
category,
|
||||
code,
|
||||
name,
|
||||
changePercent: toNumber(firstValue(row, ['涨跌幅', '今日涨跌幅', 'changePercent', 'f3'])),
|
||||
turnoverAmount: amountInYi(firstValue(row, ['成交额', 'turnoverAmount', 'f48'])),
|
||||
advancingCount: toNonNegativeNumber(firstValue(row, ['上涨家数', 'advancingCount'])),
|
||||
decliningCount: toNonNegativeNumber(firstValue(row, ['下跌家数', 'decliningCount'])),
|
||||
leader: toText(firstValue(row, ['领涨股', '主力净流入最大股', 'leader'])),
|
||||
tradeDate: dateValue(tradeDate),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapCapitalFlow(
|
||||
row: StructureRawRow,
|
||||
category: StructureCategory,
|
||||
tradeDate: string | null,
|
||||
): CapitalFlowRecord {
|
||||
const definitionKeys = ['今日主力净流入-净额', '主力净流入-净额', 'netInflow'] as const;
|
||||
const netInflowRaw = firstValue(row, definitionKeys);
|
||||
const netInflow = toNumber(netInflowRaw);
|
||||
const netOutflow = toNumber(firstValue(row, ['netOutflow', '净流出', '净流出额']));
|
||||
const flowDefinition =
|
||||
netInflowRaw !== undefined
|
||||
? (definitionKeys.find((key) => row[key] !== undefined) ?? 'unknown')
|
||||
: 'unavailable';
|
||||
|
||||
return {
|
||||
category,
|
||||
code: toText(firstValue(row, ['代码', '板块代码', 'code', 'f12'])),
|
||||
name: toText(firstValue(row, ['名称', '板块名称', 'name', 'f14'])) ?? '未知板块',
|
||||
netInflow: netInflow === null ? null : Math.round((netInflow / 100_000_000) * 100) / 100,
|
||||
netOutflow: netOutflow === null ? null : Math.round((netOutflow / 100_000_000) * 100) / 100,
|
||||
turnoverAmount: amountInYi(firstValue(row, ['成交额', 'turnoverAmount', 'f48'])),
|
||||
tradeDate: dateValue(tradeDate),
|
||||
flowDefinition,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTime(row: StructureRawRow): string | null {
|
||||
return toText(firstValue(row, ['时间', '日期时间', '时间戳', 'time', 'datetime', 'Date']));
|
||||
}
|
||||
|
||||
export function mapTurnoverPoints(rows: readonly StructureRawRow[]): TurnoverMappingResult {
|
||||
const points: TurnoverPoint[] = [];
|
||||
const seen = new Set<string>();
|
||||
let invalidCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const time = parseTime(row);
|
||||
const amount = amountInYi(firstValue(row, ['成交额', 'amount', 'Amount']));
|
||||
if (
|
||||
!time ||
|
||||
amount === null ||
|
||||
seen.has(time) ||
|
||||
(points.length > 0 && time <= points.at(-1)!.time)
|
||||
) {
|
||||
invalidCount += 1;
|
||||
continue;
|
||||
}
|
||||
seen.add(time);
|
||||
const previous = points.at(-1)?.cumulativeAmount ?? 0;
|
||||
points.push({ time, amount, cumulativeAmount: Math.round((previous + amount) * 100) / 100 });
|
||||
}
|
||||
|
||||
return { points, invalidCount };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type StructureCategory = 'industry' | 'concept' | 'board';
|
||||
|
||||
export type StructureSort = 'rise' | 'fall' | 'turnover' | 'inflow' | 'outflow';
|
||||
|
||||
export type StructureRawRow = Readonly<Record<string, unknown>>;
|
||||
|
||||
export interface SectorRankingRecord {
|
||||
category: StructureCategory;
|
||||
code: string;
|
||||
name: string;
|
||||
changePercent: number | null;
|
||||
turnoverAmount: number | null;
|
||||
advancingCount: number | null;
|
||||
decliningCount: number | null;
|
||||
leader: string | null;
|
||||
tradeDate: string | null;
|
||||
}
|
||||
|
||||
export interface CapitalFlowRecord {
|
||||
category: StructureCategory;
|
||||
code: string | null;
|
||||
name: string;
|
||||
netInflow: number | null;
|
||||
netOutflow: number | null;
|
||||
turnoverAmount: number | null;
|
||||
tradeDate: string | null;
|
||||
flowDefinition: string;
|
||||
}
|
||||
|
||||
export interface TurnoverPoint {
|
||||
time: string;
|
||||
amount: number;
|
||||
cumulativeAmount: number;
|
||||
}
|
||||
|
||||
export interface TurnoverMappingResult {
|
||||
points: TurnoverPoint[];
|
||||
invalidCount: number;
|
||||
}
|
||||
|
||||
export interface MarketStructureSourceClient {
|
||||
getSectorRows(category: StructureCategory): Promise<StructureRawRow[]>;
|
||||
getCapitalFlowRows(category: StructureCategory): Promise<StructureRawRow[]>;
|
||||
getMarketFundFlowRows(): Promise<StructureRawRow[]>;
|
||||
}
|
||||
Reference in New Issue
Block a user