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
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();
|