You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

321 lines
10 KiB

import prisma from '../config/database';
import { cache } from '../config/redis';
import config from '../config';
import { Sector, SectorMomentumHistory, MomentumStock } from '../types';
import { calculateSectorMomentum } from '../utils/maCalculator';
import logger from '../utils/logger';
export class SectorService {
// 获取版块列表(带动量排名)
async getSectorsWithMomentum(): Promise<Sector[]> {
const cacheKey = 'sectors:momentum';
const cached = await cache.get<Sector[]>(cacheKey);
if (cached) {
return cached;
}
try {
const sectors = await prisma.sector.findMany({
include: {
quotes: {
orderBy: { quoteTime: 'desc' },
take: 2,
},
},
});
// 计算动量分数和排名
const sectorsWithMomentum: Sector[] = sectors.map((sector) => {
const latestQuote = sector.quotes[0];
const previousQuote = sector.quotes[1];
const volume = latestQuote?.volume ? Number(latestQuote.volume) : 0;
const avgVolume = previousQuote?.volume ? Number(previousQuote.volume) : volume;
const momentumScore = calculateSectorMomentum(
latestQuote?.changePercent || 0,
volume,
avgVolume
);
return {
name: sector.name,
code: sector.code,
change: latestQuote?.change || 0,
changePercent: latestQuote?.changePercent || 0,
volume,
turnover: latestQuote?.turnover ? Number(latestQuote.turnover) : 0,
leadingStock: sector.name, // 可以从关联数据中获取
momentumScore,
rank: 0, // 稍后计算
previousRank: previousQuote?.rank || 0,
rankChange: 0,
};
});
// 按动量分数排序并分配排名
sectorsWithMomentum.sort((a, b) => (b.momentumScore || 0) - (a.momentumScore || 0));
sectorsWithMomentum.forEach((sector, index) => {
sector.rank = index + 1;
sector.rankChange = (sector.previousRank || 0) - sector.rank;
});
await cache.set(cacheKey, sectorsWithMomentum, config.cacheTtl.sectors);
return sectorsWithMomentum;
} catch (error) {
logger.error('Failed to get sectors with momentum:', error);
return this.getDefaultSectors();
}
}
// 获取默认版块数据
private getDefaultSectors(): Sector[] {
const sectors = [
'半导体', '新能源', '医药生物', '白酒', '银行', '证券', '保险',
'房地产', '汽车', '电子', '计算机', '通信', '传媒', '军工',
'有色金属', '钢铁', '煤炭', '化工', '建筑材料', '机械设备',
];
return sectors.map((name, index) => ({
name,
code: `880${String(index + 1).padStart(3, '0')}`,
change: Math.random() * 20 - 10,
changePercent: Math.random() * 5 - 2,
volume: Math.floor(Math.random() * 90000000) + 10000000,
turnover: Math.floor(Math.random() * 900000000) + 100000000,
leadingStock: `${name}龙头`,
momentumScore: Math.random() * 60 + 30,
rank: index + 1,
previousRank: Math.floor(Math.random() * 20) + 1,
rankChange: Math.floor(Math.random() * 10) - 5,
}));
}
// 获取版块详情
async getSectorDetail(sectorCode: string): Promise<Sector | null> {
const cacheKey = `sector:${sectorCode}:detail`;
const cached = await cache.get<Sector>(cacheKey);
if (cached) {
return cached;
}
try {
const sector = await prisma.sector.findUnique({
where: { code: sectorCode },
include: {
quotes: {
orderBy: { quoteTime: 'desc' },
take: 2,
},
},
});
if (!sector) {
return null;
}
const latestQuote = sector.quotes[0];
const previousQuote = sector.quotes[1];
const result: Sector = {
name: sector.name,
code: sector.code,
change: latestQuote?.change || 0,
changePercent: latestQuote?.changePercent || 0,
volume: latestQuote?.volume ? Number(latestQuote.volume) : 0,
turnover: latestQuote?.turnover ? Number(latestQuote.turnover) : 0,
momentumScore: latestQuote?.momentumScore || 50,
rank: latestQuote?.rank || 0,
previousRank: previousQuote?.rank || 0,
rankChange: (previousQuote?.rank || 0) - (latestQuote?.rank || 0),
};
await cache.set(cacheKey, result, config.cacheTtl.sectorDetail);
return result;
} catch (error) {
logger.error(`Failed to get sector detail ${sectorCode}:`, error);
return null;
}
}
// 获取版块历史排名
async getSectorRankHistory(sectorCode: string, days: number = 30): Promise<SectorMomentumHistory[]> {
const cacheKey = `sector:${sectorCode}:rank:history:${days}`;
const cached = await cache.get<SectorMomentumHistory[]>(cacheKey);
if (cached) {
return cached;
}
try {
const history = await prisma.sectorQuote.findMany({
where: {
sectorCode,
quoteTime: {
gte: new Date(Date.now() - days * 24 * 60 * 60 * 1000),
},
},
orderBy: { quoteTime: 'asc' },
select: {
quoteTime: true,
rank: true,
momentumScore: true,
},
});
const result: SectorMomentumHistory[] = history.map((h) => ({
date: h.quoteTime.toISOString().split('T')[0],
rank: h.rank,
momentumScore: h.momentumScore,
topStock: '', // 可以从其他表获取
}));
await cache.set(cacheKey, result, config.cacheTtl.sectorDetail);
return result;
} catch (error) {
logger.error(`Failed to get sector rank history ${sectorCode}:`, error);
return this.generateMockRankHistory(days);
}
}
// 生成模拟历史数据
private generateMockRankHistory(days: number): SectorMomentumHistory[] {
const history: SectorMomentumHistory[] = [];
const today = new Date();
let currentRank = Math.floor(Math.random() * 20) + 1;
let currentScore = Math.random() * 40 + 50;
for (let i = days; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const rankChange = Math.floor(Math.random() * 7) - 3;
currentRank = Math.max(1, Math.min(20, currentRank + rankChange));
const scoreChange = Math.random() * 10 - 5;
currentScore = Math.max(30, Math.min(100, currentScore + scoreChange));
history.push({
date: date.toISOString().split('T')[0],
rank: currentRank,
momentumScore: Number(currentScore.toFixed(2)),
topStock: `股票${Math.floor(Math.random() * 100)}`,
});
}
return history;
}
// 获取版块内股票
async getSectorStocks(sectorCode: string, limit: number = 20): Promise<any[]> {
try {
const stocks = await prisma.stock.findMany({
where: { sectorCode },
include: {
quotes: {
orderBy: { quoteTime: 'desc' },
take: 1,
},
},
take: limit,
});
return stocks.map((stock) => ({
code: stock.code,
name: stock.name,
price: stock.quotes[0]?.price || 0,
change: stock.quotes[0]?.change || 0,
changePercent: stock.quotes[0]?.changePercent || 0,
volume: stock.quotes[0]?.volume ? Number(stock.quotes[0].volume) : 0,
turnover: stock.quotes[0]?.turnover ? Number(stock.quotes[0].turnover) : 0,
marketCap: stock.marketCap ? Number(stock.marketCap) : 0,
pe: stock.pe,
pb: stock.pb,
industry: stock.sector?.name,
}));
} catch (error) {
logger.error(`Failed to get sector stocks ${sectorCode}:`, error);
return [];
}
}
// 获取版块内动量个股
async getSectorMomentumStocks(sectorCode: string): Promise<MomentumStock[]> {
try {
const stocks = await prisma.stock.findMany({
where: { sectorCode },
include: {
quotes: {
orderBy: { quoteTime: 'desc' },
take: 2,
},
momentumRecords: {
orderBy: { date: 'desc' },
take: 1,
},
},
});
const tags = ['强势突破', '量价齐升', '趋势反转', '资金流入', '技术金叉'];
return stocks
.map((stock) => {
const quote = stock.quotes[0];
const momentumRecord = stock.momentumRecords[0];
return {
code: stock.code,
name: stock.name,
price: quote?.price || 0,
change: quote?.change || 0,
changePercent: quote?.changePercent || 0,
volume: quote?.volume ? Number(quote.volume) : 0,
turnover: quote?.turnover ? Number(quote.turnover) : 0,
industry: stock.sector?.name || '',
momentumScore: momentumRecord?.momentumScore || Math.floor(Math.random() * 50) + 50,
tags: momentumRecord?.tags ? JSON.parse(momentumRecord.tags) : [tags[Math.floor(Math.random() * tags.length)]],
volumeRatio: momentumRecord?.volumeRatio || Math.random() * 6 + 1.5,
breakThrough: momentumRecord?.breakThrough || Math.random() > 0.6,
};
})
.sort((a, b) => b.momentumScore - a.momentumScore);
} catch (error) {
logger.error(`Failed to get sector momentum stocks ${sectorCode}:`, error);
return [];
}
}
// 更新版块排名
async updateSectorRankings(): Promise<void> {
try {
const sectors = await this.getSectorsWithMomentum();
// 批量更新排名
await Promise.all(
sectors.map((sector, index) =>
prisma.sectorQuote.updateMany({
where: {
sectorCode: sector.code,
},
data: {
rank: index + 1,
momentumScore: sector.momentumScore || 50,
},
})
)
);
// 清除缓存
await cache.delPattern('sectors:*');
logger.info('Sector rankings updated successfully');
} catch (error) {
logger.error('Failed to update sector rankings:', error);
}
}
}
export const sectorService = new SectorService();