From 548f9c4b8b788ba3c8cd436c701d9ec18d3bffe2 Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Tue, 25 Aug 2026 01:42:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(FEAT-MO-005):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E7=BB=93=E6=9E=84=E6=95=B0=E6=8D=AE=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitnexus/impact-plan.md | 30 ++++- .../market-overview/market-overview.dto.ts | 5 +- .../aktools-structure.client.test.ts | 45 +++++++ .../structure/aktools-structure.client.ts | 100 +++++++++++++++ .../structure/market-structure.mapper.test.ts | 84 ++++++++++++ .../structure/market-structure.mapper.ts | 120 ++++++++++++++++++ .../structure/market-structure.types.ts | 45 +++++++ 7 files changed, 424 insertions(+), 5 deletions(-) create mode 100644 server/apps/api/src/market-overview/structure/aktools-structure.client.test.ts create mode 100644 server/apps/api/src/market-overview/structure/aktools-structure.client.ts create mode 100644 server/apps/api/src/market-overview/structure/market-structure.mapper.test.ts create mode 100644 server/apps/api/src/market-overview/structure/market-structure.mapper.ts create mode 100644 server/apps/api/src/market-overview/structure/market-structure.types.ts diff --git a/docs/features/FEAT-MO-005-market-structure-turnover/gitnexus/impact-plan.md b/docs/features/FEAT-MO-005-market-structure-turnover/gitnexus/impact-plan.md index e19d157..e2c7b93 100644 --- a/docs/features/FEAT-MO-005-market-structure-turnover/gitnexus/impact-plan.md +++ b/docs/features/FEAT-MO-005-market-structure-turnover/gitnexus/impact-plan.md @@ -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 测试和完整功能档案 | diff --git a/server/apps/api/src/market-overview/market-overview.dto.ts b/server/apps/api/src/market-overview/market-overview.dto.ts index af7aceb..c46af11 100644 --- a/server/apps/api/src/market-overview/market-overview.dto.ts +++ b/server/apps/api/src/market-overview/market-overview.dto.ts @@ -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; diff --git a/server/apps/api/src/market-overview/structure/aktools-structure.client.test.ts b/server/apps/api/src/market-overview/structure/aktools-structure.client.test.ts new file mode 100644 index 0000000..a1eb877 --- /dev/null +++ b/server/apps/api/src/market-overview/structure/aktools-structure.client.test.ts @@ -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() + .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(); + 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(); + }); +}); diff --git a/server/apps/api/src/market-overview/structure/aktools-structure.client.ts b/server/apps/api/src/market-overview/structure/aktools-structure.client.ts new file mode 100644 index 0000000..66b9961 --- /dev/null +++ b/server/apps/api/src/market-overview/structure/aktools-structure.client.ts @@ -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> = {}, + ): Promise { + 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), + }; +} diff --git a/server/apps/api/src/market-overview/structure/market-structure.mapper.test.ts b/server/apps/api/src/market-overview/structure/market-structure.mapper.test.ts new file mode 100644 index 0000000..55ce82e --- /dev/null +++ b/server/apps/api/src/market-overview/structure/market-structure.mapper.test.ts @@ -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, + }); + }); +}); diff --git a/server/apps/api/src/market-overview/structure/market-structure.mapper.ts b/server/apps/api/src/market-overview/structure/market-structure.mapper.ts new file mode 100644 index 0000000..f68b64a --- /dev/null +++ b/server/apps/api/src/market-overview/structure/market-structure.mapper.ts @@ -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(); + 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 }; +} diff --git a/server/apps/api/src/market-overview/structure/market-structure.types.ts b/server/apps/api/src/market-overview/structure/market-structure.types.ts new file mode 100644 index 0000000..139b58d --- /dev/null +++ b/server/apps/api/src/market-overview/structure/market-structure.types.ts @@ -0,0 +1,45 @@ +export type StructureCategory = 'industry' | 'concept' | 'board'; + +export type StructureSort = 'rise' | 'fall' | 'turnover' | 'inflow' | 'outflow'; + +export type StructureRawRow = Readonly>; + +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; + getCapitalFlowRows(category: StructureCategory): Promise; + getMarketFundFlowRows(): Promise; +}