feat(FEAT-MO-005): 增加行业题材板块排行接口
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { MarketOverviewStatus } from '../market-overview.dto';
|
||||
import { mapSectorRanking } from './market-structure.mapper';
|
||||
import type {
|
||||
SectorRankingRecord,
|
||||
StructureCategory,
|
||||
StructureSort,
|
||||
MarketStructureSourceClient,
|
||||
} from './market-structure.types';
|
||||
import type { SectorRankingsResponseDto } from './market-structure.dto';
|
||||
|
||||
export const MARKET_STRUCTURE_SOURCE_PORT = Symbol('MARKET_STRUCTURE_SOURCE_PORT');
|
||||
|
||||
export interface SectorRankingQuery {
|
||||
category: StructureCategory;
|
||||
sort: Extract<StructureSort, 'rise' | 'fall' | 'turnover'>;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
const CATEGORIES = new Set<StructureCategory>(['industry', 'concept', 'board']);
|
||||
const SORTS = new Set<SectorRankingQuery['sort']>(['rise', 'fall', 'turnover']);
|
||||
|
||||
export function parseSectorRankingQuery(
|
||||
category = 'industry',
|
||||
sort = 'rise',
|
||||
limit = '10',
|
||||
): SectorRankingQuery {
|
||||
if (!CATEGORIES.has(category as StructureCategory)) throw new Error('分类参数无效');
|
||||
if (!SORTS.has(sort as SectorRankingQuery['sort'])) throw new Error('排序参数无效');
|
||||
if (!/^\d+$/.test(limit)) throw new Error('数量参数无效');
|
||||
const parsedLimit = Number(limit);
|
||||
if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
|
||||
throw new Error('数量参数无效');
|
||||
}
|
||||
return {
|
||||
category: category as StructureCategory,
|
||||
sort: sort as SectorRankingQuery['sort'],
|
||||
limit: parsedLimit,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotMeta(
|
||||
source: 'aktools',
|
||||
): Pick<SectorRankingsResponseDto, 'snapshotId' | 'asOf' | 'source'> {
|
||||
const asOf = new Date().toISOString();
|
||||
return {
|
||||
snapshotId: asOf.replace(/[-:TZ.]/g, '').slice(0, 15),
|
||||
asOf,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function sortItems(
|
||||
items: readonly SectorRankingRecord[],
|
||||
sort: SectorRankingQuery['sort'],
|
||||
): SectorRankingRecord[] {
|
||||
const valueFor = (item: SectorRankingRecord): number => {
|
||||
if (sort === 'turnover') return item.turnoverAmount ?? Number.NEGATIVE_INFINITY;
|
||||
return item.changePercent ?? Number.NEGATIVE_INFINITY;
|
||||
};
|
||||
const direction = sort === 'fall' ? 1 : -1;
|
||||
return [...items].sort((left, right) => {
|
||||
const difference = (valueFor(left) - valueFor(right)) * direction;
|
||||
return difference || left.name.localeCompare(right.name, 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
function statusFor(items: readonly SectorRankingRecord[], rawCount: number): MarketOverviewStatus {
|
||||
if (!items.length) return 'unavailable';
|
||||
if (items.length < rawCount || items.some((item) => item.tradeDate === null)) return 'partial';
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MarketStructureService {
|
||||
constructor(
|
||||
@Inject(MARKET_STRUCTURE_SOURCE_PORT)
|
||||
private readonly source: MarketStructureSourceClient,
|
||||
) {}
|
||||
|
||||
async getSectorRankings(query: SectorRankingQuery): Promise<SectorRankingsResponseDto> {
|
||||
const meta = snapshotMeta('aktools');
|
||||
try {
|
||||
const rows = await this.source.getSectorRows(query.category);
|
||||
const items = rows.flatMap((row) => {
|
||||
const mapped = mapSectorRanking(row, query.category, this.readTradeDate(row));
|
||||
return mapped ? [mapped] : [];
|
||||
});
|
||||
const sortedAll = sortItems(items, query.sort);
|
||||
const sorted = sortedAll.slice(0, query.limit);
|
||||
const status = statusFor(sortedAll, rows.length);
|
||||
return {
|
||||
...meta,
|
||||
status,
|
||||
data:
|
||||
status === 'unavailable'
|
||||
? null
|
||||
: { items: sorted, sort: query.sort, category: query.category },
|
||||
warnings:
|
||||
status === 'fresh'
|
||||
? []
|
||||
: status === 'partial'
|
||||
? ['部分行业或题材排行字段不可用']
|
||||
: ['行业排行数据不可用'],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...meta,
|
||||
status: 'unavailable',
|
||||
data: null,
|
||||
warnings: ['行业排行数据不可用'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private readTradeDate(row: Readonly<Record<string, unknown>>): string | null {
|
||||
const value = row['日期'] ?? row['交易日期'] ?? row['tradeDate'];
|
||||
return typeof value === 'string' && /^\d{8}$/.test(value) ? value : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user