fix: 接口能获取,但是目前数据获取存在问题,接口响应时间慢

master
Lxy 3 months ago
parent 985d70fa2d
commit 1dc4176625

@ -7,6 +7,275 @@ datasource db {
url = env("DATABASE_URL")
}
// ============================================
// 基础数据表
// ============================================
// 交易日历表
model TradeDate {
id Int @id @default(autoincrement())
date DateTime @unique @db.Date
week Int @default(0)
isTrade Boolean @default(true) @map("is_trade")
createdAt DateTime @default(now()) @map("created_at")
@@index([date])
@@index([isTrade])
@@map("trade_dates")
}
// 股票基础信息表(新表)
model StockBasic {
id String @id @default(uuid())
code String @unique
name String
// 东财行业分类
blemind2 String? @map("blemind2") // 东财行业指数2级
blemind3 String? @map("blemind3") // 东财行业指数3级
// 上市信息
listDate DateTime? @map("list_date") // 首发上市日期
tradeDays Int @default(0) @map("trade_days") // 上市天数
// 机构持股
agenciesHold Float? @map("agencies_hold") // 机构持股比例(%)
// 市场信息
market String? // 所属市场(SH/SZ/BJ)
industry String? // 所属行业
// 关联数据
dailyQuotes StockDailyQuote[]
momentumRecords StockMomentum[]
limitRecords StockLimit[]
newRecords StockNewRecord[]
// 时间戳
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([code])
@@index([blemind2])
@@index([blemind3])
@@index([listDate])
@@map("stock_basics")
}
// ============================================
// 交易数据表
// ============================================
// 股票日行情数据表(基础数据,从数据服务获取)
model StockDailyQuote {
id Int @id @default(autoincrement())
stockCode String @map("stock_code")
stock StockBasic @relation(fields: [stockCode], references: [code], onDelete: Cascade)
tradeDay DateTime @map("trade_day") @db.Date
// 基础价格数据
open Float // 开盘价
close Float // 收盘价
high Float // 最高价
low Float // 最低价
// 成交量额
volume BigInt // 成交量
amount BigInt // 成交额
// 涨跌幅
differrange Float // 当日涨跌幅(%)
// 状态标记
isLimit Boolean @default(false) @map("is_limit") // 是否涨停
isDrop Boolean @default(false) @map("is_drop") // 是否跌停
// 关联的多周期涨跌幅
returns StockReturn[]
createdAt DateTime @default(now()) @map("created_at")
@@unique([stockCode, tradeDay])
@@index([stockCode])
@@index([tradeDay])
@@index([stockCode, tradeDay])
@@map("stock_daily_quotes")
}
// 股票多周期涨跌幅表(计算数据)
model StockReturn {
id Int @id @default(autoincrement())
stockCode String @map("stock_code")
quoteId Int @map("quote_id")
quote StockDailyQuote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
tradeDay DateTime @map("trade_day") @db.Date
// 多周期涨跌幅
period Int // 周期(1,3,5,10,15,20,30,60)
returnRate Float @map("return_rate") // 涨跌幅(%)
// 计算来源
basePrice Float @map("base_price") // 基准价格(N日前收盘价)
createdAt DateTime @default(now()) @map("created_at")
@@unique([stockCode, tradeDay, period])
@@index([stockCode])
@@index([tradeDay])
@@index([period])
@@index([stockCode, tradeDay])
@@map("stock_returns")
}
// 涨停跌停记录表
model StockLimit {
id Int @id @default(autoincrement())
stockCode String @map("stock_code")
stock StockBasic @relation(fields: [stockCode], references: [code], onDelete: Cascade)
tradeDay DateTime @map("trade_day") @db.Date
blemind2 String? @map("blemind2")
isLimit Boolean @default(false) @map("is_limit") // 是否涨停
isDrop Boolean @default(false) @map("is_drop") // 是否跌停
createdAt DateTime @default(now()) @map("created_at")
@@unique([stockCode, tradeDay])
@@index([stockCode])
@@index([tradeDay])
@@index([blemind2])
@@map("stock_limits")
}
// 新高新低记录表
model StockNewRecord {
id Int @id @default(autoincrement())
stockCode String @map("stock_code")
stock StockBasic @relation(fields: [stockCode], references: [code], onDelete: Cascade)
tradeDay DateTime @map("trade_day") @db.Date
blemind2 String? @map("blemind2")
isHigh Boolean @default(false) @map("is_high") // 是否300天新高
isLow Boolean @default(false) @map("is_low") // 是否300天新低
daysToHigh Int @default(0) @map("days_to_high") // 距离前高天数
daysToLow Int @default(0) @map("days_to_low") // 距离前低天数
createdAt DateTime @default(now()) @map("created_at")
@@unique([stockCode, tradeDay])
@@index([stockCode])
@@index([tradeDay])
@@index([blemind2])
@@map("stock_new_records")
}
// ============================================
// 动量计算结果表
// ============================================
// 动量个股表(计算结果)
model StockMomentum {
id Int @id @default(autoincrement())
stockCode String @map("stock_code")
stock StockBasic @relation(fields: [stockCode], references: [code], onDelete: Cascade)
tradeDay DateTime @map("trade_day") @db.Date
// 动量排名信息
sort Int // 排名
type Int // 动量数据类型(1,3,5,10,15,20,30,60日)
// 股票基本信息(冗余存储,便于查询)
name String?
blemind2 String? @map("blemind2")
blemind3 String? @map("blemind3")
// 价格数据
open Float? // 开盘价
close Float? // 收盘价
differrange Float? @map("differrange") // 当日涨跌幅
// 多周期涨跌幅
differrange10 Float? @map("differrange10")
differrange20 Float? @map("differrange20")
differrange60 Float? @map("differrange60")
// 最大回撤
backdifferrange10 Float? @map("backdifferrange10")
backdifferrange20 Float? @map("backdifferrange20")
backdifferrange60 Float? @map("backdifferrange60")
// 筛选条件(记录当时的数据)
tradeDays Int? @map("trade_days") // 上市天数
agenciesHold Float? @map("agencies_hold") // 机构持股比例
createdAt DateTime @default(now()) @map("created_at")
@@unique([stockCode, tradeDay, type])
@@index([stockCode])
@@index([tradeDay])
@@index([type])
@@index([tradeDay, type])
@@index([blemind2, tradeDay, type])
@@index([sort])
@@map("stock_momentum")
}
// 板块动量结果表
model SectorMomentum {
id Int @id @default(autoincrement())
tradeDay DateTime @map("trade_day") @db.Date
blemind2 String @map("blemind2") // 所属东财行业指数2级
// 动量计算结果
stocksCount Int @map("stocks_count") // 动量个股数量
totalStocks Int @map("total_stocks") // 板块总个股数
trendValue Float @map("trend_value") // 动量值 = (stocksCount^2) / totalStocks
trendValueChange Float? @map("trend_value_change") // 动量值变化
// 排名信息
sort Int // 板块排名
sortChange Int @map("sort_change") // 板块排名变化
// 动量类型
type Int // 动量数据类型(1,3,5,10,15,20,30,60日)
// 昨日数据快照(用于计算变化)
lastTrendValue Float? @map("last_trend_value")
lastSort Int? @map("last_sort")
createdAt DateTime @default(now()) @map("created_at")
@@unique([tradeDay, blemind2, type])
@@index([tradeDay])
@@index([blemind2])
@@index([type])
@@index([tradeDay, type])
@@index([sort])
@@map("sector_momentum")
}
// ============================================
// 系统配置和任务表
// ============================================
// 数据同步任务记录
model DataSyncTask {
id Int @id @default(autoincrement())
taskType String @map("task_type") // 任务类型(daily_quote/momentum/etc)
tradeDay DateTime? @map("trade_day") @db.Date
status String // pending/running/completed/failed
startTime DateTime? @map("start_time")
endTime DateTime? @map("end_time")
message String? // 执行信息/错误信息
recordCount Int? @map("record_count") // 处理记录数
createdAt DateTime @default(now()) @map("created_at")
@@index([taskType])
@@index([status])
@@index([tradeDay])
@@map("data_sync_tasks")
}
// ============================================
// 保留原有表(保持兼容性)
// ============================================
// 市场指数
model MarketIndex {
id Int @id @default(autoincrement())
@ -24,7 +293,7 @@ model MarketIndex {
@@map("market_indices")
}
// 版块信息
// 版块信息(系统板块)- 保留原表名
model Sector {
id String @id @default(uuid())
name String @unique
@ -39,7 +308,7 @@ model Sector {
@@map("sectors")
}
// 版块行情
// 版块行情(兼容原表)
model SectorQuote {
id Int @id @default(autoincrement())
sectorCode String @map("sector_code")
@ -59,7 +328,7 @@ model SectorQuote {
@@map("sector_quotes")
}
// 版块K线数据
// 版块K线数据(兼容原表)
model SectorKLine {
id Int @id @default(autoincrement())
sectorCode String @map("sector_code")
@ -78,7 +347,7 @@ model SectorKLine {
@@map("sector_klines")
}
// 股票信息
// 股票信息(兼容原表)
model Stock {
id String @id @default(uuid())
code String @unique
@ -99,7 +368,7 @@ model Stock {
@@map("stocks")
}
// 股票行情
// 股票行情(兼容原表)
model StockQuote {
id Int @id @default(autoincrement())
stockCode String @map("stock_code")
@ -121,7 +390,7 @@ model StockQuote {
@@map("stock_quotes")
}
// 股票K线数据
// 股票K线数据(兼容原表)
model StockKLine {
id Int @id @default(autoincrement())
stockCode String @map("stock_code")
@ -172,7 +441,7 @@ model UserFavorite {
@@map("user_favorites")
}
// 新高新低股票记录
// 新高新低股票记录(保留原表兼容性)
model HighLowStock {
id Int @id @default(autoincrement())
stockCode String @map("stock_code")
@ -188,7 +457,7 @@ model HighLowStock {
@@map("high_low_stocks")
}
// 动量股票推荐
// 动量股票推荐(保留原表兼容性)
model MomentumStock {
id Int @id @default(autoincrement())
stockCode String @map("stock_code")
@ -199,6 +468,7 @@ model MomentumStock {
date DateTime
createdAt DateTime @default(now()) @map("created_at")
@@unique([stockCode, date])
@@index([stockCode])
@@index([date])
@@map("momentum_stocks")

@ -11,6 +11,7 @@ import { requestLogger } from './middleware/logger';
import { generalLimiter } from './middleware/rateLimiter';
import StockSocket from './websocket/stockSocket';
import { marketDataSyncJob } from './jobs/syncMarketData';
import { momentumCalculationJob } from './jobs/momentumCalculationJob';
import { dataSyncService } from './services/dataSyncService';
import logger from './utils/logger';
@ -73,6 +74,7 @@ async function startServer(): Promise<void> {
// 启动定时任务
marketDataSyncJob.start();
momentumCalculationJob.start();
// 启动 HTTP 服务器
server.listen(config.port, () => {
@ -92,6 +94,7 @@ async function gracefulShutdown(): Promise<void> {
// 停止定时任务
marketDataSyncJob.stop();
momentumCalculationJob.stop();
// 关闭 WebSocket
if (stockSocket) {

@ -5,6 +5,7 @@ import logger from '../utils/logger';
import { DataSyncService } from '../services/dataSyncService';
import { externalDataSourceService } from '../services/externalDataSourceService';
import { getTradingDaysCount, getDailyStockCount } from '../utils/dateUtils';
import { log } from 'console';
const dataSyncService = new DataSyncService();
@ -138,6 +139,7 @@ export const getDataCheck = async (_req: Request, res: Response) => {
});
const localLatestDate = localLatestQuote?.quoteTime;
logger.info(`Local latest quote: ${JSON.stringify(localLatestQuote)}`);
// 获取外部数据源最新日期
let externalLatestDate: Date | null = null;
let externalStatus = 'disabled';
@ -146,6 +148,7 @@ export const getDataCheck = async (_req: Request, res: Response) => {
if (externalDataSourceService.isEnabled()) {
try {
const nearestTradingDate = await externalDataSourceService.getNearestTradingDate();
logger.info(`[adminController] External latest date: ${nearestTradingDate}`);
if (nearestTradingDate) {
externalLatestDate = new Date(nearestTradingDate);
externalStatus = 'connected';
@ -404,7 +407,7 @@ export const bufferMissingData = async (req: Request, res: Response) => {
const { startDate, endDate, types } = req.body;
const taskId = `buffer_${Date.now()}`;
logger.info(`Starting buffer task ${taskId}:`, { startDate, endDate, types });
logger.info(`Starting buffer task ${taskId}: startDate=${startDate}, endDate=${endDate}, types=${JSON.stringify(types)}`);
// 异步执行缓冲任务
setImmediate(async () => {
@ -444,19 +447,61 @@ export const bufferMissingData = async (req: Request, res: Response) => {
export const bufferStocks = async (_req: Request, res: Response) => {
try {
logger.info('[bufferStocks] 收到同步股票数据请求');
// 检查数据源是否可用
if (!externalDataSourceService.isEnabled()) {
logger.warn('[bufferStocks] 数据源未配置,拒绝请求');
res.status(503).json({
code: 503,
message: '数据源未配置,无法同步股票数据',
data: null,
});
return;
}
const taskId = `buffer_stocks_${Date.now()}`;
logger.info(`[bufferStocks] 创建任务: ${taskId}`);
// 创建任务记录
const task: Task = {
id: taskId,
type: 'buffer',
status: 'running',
progress: 0,
createdAt: new Date(),
};
tasks.set(taskId, task);
logger.info(`[bufferStocks] 任务已存入内存,当前任务数: ${tasks.size}`);
// 异步执行同步任务
logger.info(`[bufferStocks] 启动异步执行任务: ${taskId}`);
setImmediate(async () => {
try {
logger.info(`[${taskId}] 开始执行同步...`);
await dataSyncService.syncAllStocks();
// 更新任务状态为完成
task.status = 'completed';
task.progress = 100;
task.completedAt = new Date();
task.result = { message: '股票数据同步完成' };
logger.info(`[${taskId}] Buffer stocks completed successfully`);
} catch (error) {
logger.error('Buffer stocks failed:', error);
// 更新任务状态为失败
task.status = 'failed';
task.error = (error as Error).message;
task.completedAt = new Date();
logger.error(`[${taskId}] Buffer stocks failed:`, error);
}
});
logger.info(`[bufferStocks] 任务启动成功,返回 taskId: ${taskId}`);
res.json({
code: 200,
message: 'success',
message: '股票数据同步任务已启动',
data: { taskId },
});
} catch (error) {
@ -562,9 +607,12 @@ export const calculateMomentum = async (_req: Request, res: Response) => {
export const getSyncTask = async (req: Request, res: Response) => {
try {
const { taskId } = req.params;
logger.info(`[getSyncTask] 查询任务状态: ${taskId}`);
const task = tasks.get(taskId);
if (!task) {
logger.warn(`[getSyncTask] 任务不存在: ${taskId}`);
res.status(404).json({
code: 404,
message: '任务不存在或已过期',
@ -573,6 +621,8 @@ export const getSyncTask = async (req: Request, res: Response) => {
return;
}
logger.info(`[getSyncTask] 任务状态: ${taskId} - ${task.status}`);
res.json({
code: 200,
message: 'success',
@ -708,7 +758,7 @@ export const batchUpdateUsers = async (req: Request, res: Response) => {
try {
const { userIds, action } = req.body;
logger.info(`Batch ${action} users:`, userIds);
logger.info(`Batch ${action} users: ${JSON.stringify(userIds)}`);
if (action === 'delete') {
await prisma.user.deleteMany({
@ -793,7 +843,7 @@ export const getAIConfig = async (_req: Request, res: Response) => {
};
export const updateAIConfig = async (req: Request, res: Response) => {
logger.info('Update AI config:', req.body);
logger.info(`Update AI config: ${JSON.stringify(req.body)}`);
res.json({
code: 200,
message: 'success',
@ -828,7 +878,7 @@ export const getMomentumConfig = async (_req: Request, res: Response) => {
};
export const updateMomentumConfig = async (req: Request, res: Response) => {
logger.info('Update momentum config:', req.body);
logger.info(`Update momentum config: ${JSON.stringify(req.body)}`);
res.json({
code: 200,
message: 'success',
@ -851,7 +901,7 @@ export const getDataRetention = async (_req: Request, res: Response) => {
};
export const updateDataRetention = async (req: Request, res: Response) => {
logger.info('Update data retention:', req.body);
logger.info(`Update data retention: ${JSON.stringify(req.body)}`);
res.json({
code: 200,
message: 'success',

@ -0,0 +1,379 @@
import { Request, Response } from 'express';
import { stockDataSyncService } from '../services/stockDataSync';
import { momentumCalculationService, MomentumPeriod, MOMENTUM_PERIODS } from '../services/momentumCalculation';
import { asyncHandler, BadRequestError } from '../middleware/errorHandler';
import { ApiResponse } from '../types';
import logger from '../utils/logger';
import prisma from '../config/database';
/**
*
*/
export const dataSyncController = {
/**
*
* POST /api/v1/sync/stock-basics
*/
syncStockBasics: asyncHandler(async (_req: Request, res: Response) => {
logger.info('[DataSync API] 同步股票基础信息');
// 异步执行
stockDataSyncService.syncStockBasics()
.then((result) => {
logger.info(`[DataSync API] 股票基础信息同步完成: ${result.success} 成功, ${result.failed} 失败`);
})
.catch((error) => {
logger.error('[DataSync API] 股票基础信息同步失败:', error);
});
const response: ApiResponse = {
code: 200,
message: '股票基础信息同步任务已启动',
data: { status: 'running' },
};
res.json(response);
}),
/**
*
* POST /api/v1/sync/daily-quotes
*/
syncDailyQuotes: asyncHandler(async (req: Request, res: Response) => {
const { tradeDay } = req.body;
const date = tradeDay ? new Date(tradeDay) : new Date();
date.setHours(0, 0, 0, 0);
logger.info(`[DataSync API] 同步日行情数据: ${date.toISOString().split('T')[0]}`);
// 异步执行
stockDataSyncService.syncDailyQuotes(date)
.then((result) => {
logger.info(`[DataSync API] 日行情数据同步完成: ${result.success} 成功, ${result.failed} 失败`);
})
.catch((error) => {
logger.error('[DataSync API] 日行情数据同步失败:', error);
});
const response: ApiResponse = {
code: 200,
message: '日行情数据同步任务已启动',
data: {
tradeDay: date.toISOString().split('T')[0],
status: 'running',
},
};
res.json(response);
}),
/**
*
* POST /api/v1/sync/trade-dates
*/
syncTradeDates: asyncHandler(async (req: Request, res: Response) => {
const { year } = req.body;
const targetYear = year || new Date().getFullYear();
logger.info(`[DataSync API] 同步交易日历: ${targetYear}`);
// 异步执行
stockDataSyncService.syncTradeDates(targetYear)
.then((result) => {
logger.info(`[DataSync API] 交易日历同步完成: ${result.success} 成功, ${result.failed} 失败`);
})
.catch((error) => {
logger.error('[DataSync API] 交易日历同步失败:', error);
});
const response: ApiResponse = {
code: 200,
message: '交易日历同步任务已启动',
data: {
year: targetYear,
status: 'running',
},
};
res.json(response);
}),
/**
*
* POST /api/v1/sync/full
*/
executeFullSync: asyncHandler(async (req: Request, res: Response) => {
const { tradeDay, skipMomentum } = req.body;
const date = tradeDay ? new Date(tradeDay) : new Date();
date.setHours(0, 0, 0, 0);
logger.info(`[DataSync API] 执行完整数据同步: ${date.toISOString().split('T')[0]}`);
// 记录任务
const task = await prisma.dataSyncTask.create({
data: {
taskType: 'full_sync',
tradeDay: date,
status: 'running',
startTime: new Date(),
},
});
// 异步执行完整流程
(async () => {
try {
// 1. 数据同步
const syncResult = await stockDataSyncService.executeFullSync(date);
// 2. 计算多周期涨跌幅
await momentumCalculationService.calculateMultiPeriodReturns(date);
// 3. 动量计算(如果不跳过)
if (!skipMomentum) {
await momentumCalculationService.executeFullCalculation(date);
}
// 更新任务状态
await prisma.dataSyncTask.update({
where: { id: task.id },
data: {
status: 'completed',
endTime: new Date(),
message: JSON.stringify(syncResult),
},
});
logger.info('[DataSync API] 完整数据同步完成');
} catch (error) {
await prisma.dataSyncTask.update({
where: { id: task.id },
data: {
status: 'failed',
endTime: new Date(),
message: (error as Error).message,
},
});
logger.error('[DataSync API] 完整数据同步失败:', error);
}
})();
const response: ApiResponse = {
code: 200,
message: '完整数据同步任务已启动',
data: {
taskId: task.id,
tradeDay: date.toISOString().split('T')[0],
status: 'running',
},
};
res.json(response);
}),
/**
*
* GET /api/v1/sync/tasks
*/
getSyncTasks: asyncHandler(async (req: Request, res: Response) => {
const { status, limit = '10' } = req.query;
const where: any = {};
if (status) {
where.status = status;
}
const tasks = await prisma.dataSyncTask.findMany({
where,
orderBy: { createdAt: 'desc' },
take: parseInt(limit as string, 10),
});
const response: ApiResponse = {
code: 200,
message: 'success',
data: { tasks },
};
res.json(response);
}),
/**
*
* GET /api/v1/sync/tasks/:id
*/
getSyncTaskDetail: asyncHandler(async (req: Request, res: Response) => {
const { id } = req.params;
const task = await prisma.dataSyncTask.findUnique({
where: { id: parseInt(id, 10) },
});
if (!task) {
throw new BadRequestError('任务不存在');
}
const response: ApiResponse = {
code: 200,
message: 'success',
data: { task },
};
res.json(response);
}),
/**
*
* POST /api/v1/sync/calculate-returns
*/
calculateReturns: asyncHandler(async (req: Request, res: Response) => {
const { tradeDay } = req.body;
const date = tradeDay ? new Date(tradeDay) : new Date();
date.setHours(0, 0, 0, 0);
logger.info(`[DataSync API] 计算多周期涨跌幅: ${date.toISOString().split('T')[0]}`);
// 异步执行
momentumCalculationService.calculateMultiPeriodReturns(date)
.then(() => {
logger.info('[DataSync API] 多周期涨跌幅计算完成');
})
.catch((error) => {
logger.error('[DataSync API] 多周期涨跌幅计算失败:', error);
});
const response: ApiResponse = {
code: 200,
message: '多周期涨跌幅计算任务已启动',
data: {
tradeDay: date.toISOString().split('T')[0],
periods: MOMENTUM_PERIODS.filter(p => p !== 1),
status: 'running',
},
};
res.json(response);
}),
/**
*
* POST /api/v1/sync/calculate-momentum
*/
calculateMomentum: asyncHandler(async (req: Request, res: Response) => {
const { tradeDay, type } = req.body;
const date = tradeDay ? new Date(tradeDay) : new Date();
date.setHours(0, 0, 0, 0);
let momentumType: MomentumPeriod | undefined;
if (type) {
momentumType = parseInt(type, 10) as MomentumPeriod;
if (!MOMENTUM_PERIODS.includes(momentumType)) {
throw new BadRequestError(`无效的动量类型: ${type}`);
}
}
logger.info(`[DataSync API] 执行动量计算: ${date.toISOString().split('T')[0]}, 类型: ${momentumType || '全部'}`);
// 记录任务
const task = await prisma.dataSyncTask.create({
data: {
taskType: 'momentum_calculation',
tradeDay: date,
status: 'running',
startTime: new Date(),
},
});
// 异步执行
momentumCalculationService.executeFullCalculation(date, momentumType)
.then(() => {
prisma.dataSyncTask.update({
where: { id: task.id },
data: {
status: 'completed',
endTime: new Date(),
},
});
logger.info('[DataSync API] 动量计算完成');
})
.catch((error) => {
prisma.dataSyncTask.update({
where: { id: task.id },
data: {
status: 'failed',
endTime: new Date(),
message: (error as Error).message,
},
});
logger.error('[DataSync API] 动量计算失败:', error);
});
const response: ApiResponse = {
code: 200,
message: '动量计算任务已启动',
data: {
taskId: task.id,
tradeDay: date.toISOString().split('T')[0],
type: momentumType || 'all',
status: 'running',
},
};
res.json(response);
}),
/**
*
* GET /api/v1/sync/status
*/
getSyncStatus: asyncHandler(async (_req: Request, res: Response) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
// 获取各表数据量
const [
stockBasicCount,
dailyQuoteCount,
tradeDateCount,
momentumStockCount,
sectorMomentumCount,
recentTask,
] = await Promise.all([
prisma.stockBasic.count(),
prisma.stockDailyQuote.count({
where: { tradeDay: today },
}),
prisma.tradeDate.count(),
prisma.stockMomentum.count({
where: { tradeDay: today },
}),
prisma.sectorMomentum.count({
where: { tradeDay: today },
}),
prisma.dataSyncTask.findFirst({
orderBy: { createdAt: 'desc' },
}),
]);
const response: ApiResponse = {
code: 200,
message: 'success',
data: {
today: today.toISOString().split('T')[0],
dataCounts: {
stockBasic: stockBasicCount,
dailyQuote: dailyQuoteCount,
tradeDate: tradeDateCount,
momentumStock: momentumStockCount,
sectorMomentum: sectorMomentumCount,
},
recentTask: recentTask || null,
},
};
res.json(response);
}),
};

@ -0,0 +1,303 @@
import { Request, Response } from 'express';
import {
momentumCalculationService,
MOMENTUM_PERIODS,
MomentumPeriod
} from '../services/momentumCalculation';
import { asyncHandler, BadRequestError } from '../middleware/errorHandler';
import { ApiResponse } from '../types';
import logger from '../utils/logger';
import prisma from '../config/database';
/**
*
*/
export const momentumController = {
/**
*
* GET /api/v1/momentum/stocks
*/
getMomentumStocks: asyncHandler(async (req: Request, res: Response) => {
const {
tradeDay,
type = '20',
blemind2,
limit = '100'
} = req.query;
// 参数验证
const momentumType = parseInt(type as string, 10);
if (!MOMENTUM_PERIODS.includes(momentumType as MomentumPeriod)) {
throw new BadRequestError(`无效的动量类型: ${type},支持的类型: ${MOMENTUM_PERIODS.join(',')}`);
}
// 解析日期
const date = tradeDay
? new Date(tradeDay as string)
: new Date();
date.setHours(0, 0, 0, 0);
const pageLimit = Math.min(parseInt(limit as string, 10), 600);
logger.info(`[Momentum API] 获取动量个股: ${date.toISOString().split('T')[0]}, 类型: ${momentumType}`);
const stocks = await momentumCalculationService.getMomentumStocks(
date,
momentumType,
blemind2 as string | undefined,
pageLimit
);
const response: ApiResponse = {
code: 200,
message: 'success',
data: {
tradeDay: date.toISOString().split('T')[0],
type: momentumType,
count: stocks.length,
stocks,
},
};
res.json(response);
}),
/**
*
* GET /api/v1/momentum/sectors
*/
getSectorMomentum: asyncHandler(async (req: Request, res: Response) => {
const {
tradeDay,
type = '20',
limit = '50'
} = req.query;
// 参数验证
const momentumType = parseInt(type as string, 10);
if (!MOMENTUM_PERIODS.includes(momentumType as MomentumPeriod)) {
throw new BadRequestError(`无效的动量类型: ${type},支持的类型: ${MOMENTUM_PERIODS.join(',')}`);
}
// 解析日期
const date = tradeDay
? new Date(tradeDay as string)
: new Date();
date.setHours(0, 0, 0, 0);
const pageLimit = Math.min(parseInt(limit as string, 10), 200);
logger.info(`[Momentum API] 获取板块动量: ${date.toISOString().split('T')[0]}, 类型: ${momentumType}`);
const sectors = await momentumCalculationService.getSectorMomentum(
date,
momentumType,
pageLimit
);
const response: ApiResponse = {
code: 200,
message: 'success',
data: {
tradeDay: date.toISOString().split('T')[0],
type: momentumType,
count: sectors.length,
sectors,
},
};
res.json(response);
}),
/**
*
* POST /api/v1/momentum/calculate
*/
calculateMomentum: asyncHandler(async (req: Request, res: Response) => {
const { tradeDay, type } = req.body;
// 解析日期
const date = tradeDay
? new Date(tradeDay)
: new Date();
date.setHours(0, 0, 0, 0);
// 验证类型
let momentumType: MomentumPeriod | undefined;
if (type) {
momentumType = parseInt(type, 10) as MomentumPeriod;
if (!MOMENTUM_PERIODS.includes(momentumType)) {
throw new BadRequestError(`无效的动量类型: ${type},支持的类型: ${MOMENTUM_PERIODS.join(',')}`);
}
}
logger.info(`[Momentum API] 触发动量计算: ${date.toISOString().split('T')[0]}, 类型: ${momentumType || '全部'}`);
// 异步执行计算(不等待完成)
momentumCalculationService.executeFullCalculation(date, momentumType)
.then(() => {
logger.info(`[Momentum API] 动量计算完成: ${date.toISOString().split('T')[0]}`);
})
.catch((error) => {
logger.error(`[Momentum API] 动量计算失败:`, error);
});
const response: ApiResponse = {
code: 200,
message: '动量计算任务已启动',
data: {
tradeDay: date.toISOString().split('T')[0],
type: momentumType || 'all',
status: 'running',
},
};
res.json(response);
}),
/**
*
* GET /api/v1/momentum/periods
*/
getMomentumPeriods: asyncHandler(async (_req: Request, res: Response) => {
const periods = MOMENTUM_PERIODS.map(p => ({
value: p,
label: `${p}`,
description: getPeriodDescription(p),
}));
const response: ApiResponse = {
code: 200,
message: 'success',
data: {
periods,
},
};
res.json(response);
}),
/**
*
* POST /api/v1/momentum/calculate-returns
*/
calculateReturns: asyncHandler(async (req: Request, res: Response) => {
const { tradeDay } = req.body;
// 解析日期
const date = tradeDay
? new Date(tradeDay)
: new Date();
date.setHours(0, 0, 0, 0);
logger.info(`[Momentum API] 触发多周期涨跌幅计算: ${date.toISOString().split('T')[0]}`);
// 异步执行
momentumCalculationService.calculateMultiPeriodReturns(date)
.then(() => {
logger.info(`[Momentum API] 多周期涨跌幅计算完成`);
})
.catch((error) => {
logger.error(`[Momentum API] 多周期涨跌幅计算失败:`, error);
});
const response: ApiResponse = {
code: 200,
message: '多周期涨跌幅计算任务已启动',
data: {
tradeDay: date.toISOString().split('T')[0],
periods: MOMENTUM_PERIODS.filter(p => p !== 1),
status: 'running',
},
};
res.json(response);
}),
/**
*
* GET /api/v1/momentum/sector-trend
*/
getSectorTrend: asyncHandler(async (req: Request, res: Response) => {
const {
blemind2,
type = '20',
startDay,
endDay,
} = req.query;
if (!blemind2) {
throw new BadRequestError('板块名称(blemind2)不能为空');
}
// 参数验证
const momentumType = parseInt(type as string, 10);
if (!MOMENTUM_PERIODS.includes(momentumType as MomentumPeriod)) {
throw new BadRequestError(`无效的动量类型: ${type}`);
}
// 日期范围
const end = endDay ? new Date(endDay as string) : new Date();
const start = startDay
? new Date(startDay as string)
: new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000); // 默认30天
end.setHours(0, 0, 0, 0);
start.setHours(0, 0, 0, 0);
logger.info(`[Momentum API] 获取板块趋势: ${blemind2}, ${start.toISOString().split('T')[0]} ~ ${end.toISOString().split('T')[0]}`);
// 查询历史数据
const trends = await prisma.sectorMomentum.findMany({
where: {
blemind2: blemind2 as string,
type: momentumType,
tradeDay: {
gte: start,
lte: end,
},
},
orderBy: { tradeDay: 'asc' },
select: {
tradeDay: true,
trendValue: true,
trendValueChange: true,
sort: true,
sortChange: true,
stocksCount: true,
},
});
const response: ApiResponse = {
code: 200,
message: 'success',
data: {
blemind2,
type: momentumType,
startDay: start.toISOString().split('T')[0],
endDay: end.toISOString().split('T')[0],
count: trends.length,
trends,
},
};
res.json(response);
}),
};
/**
*
*/
function getPeriodDescription(period: number): string {
const descriptions: Record<number, string> = {
1: '当日强势股,捕捉日内热点',
3: '超短期动量,快速轮动策略',
5: '短期动量,一周趋势跟踪',
10: '中期动量,两周趋势跟踪',
15: '中期动量,半月趋势判断',
20: '中长期动量,月度趋势(默认)',
30: '长期动量,月度以上趋势',
60: '超长期动量,季度趋势',
};
return descriptions[period] || '';
}

@ -0,0 +1,191 @@
/**
*
*
*/
import cron from 'node-cron';
import { stockDataSyncService } from '../services/stockDataSync';
import { momentumCalculationService } from '../services/momentumCalculation';
import prisma from '../config/database';
import logger from '../utils/logger';
// 定时任务配置
const DATA_SYNC_CRON = process.env.DATA_SYNC_CRON || '0 18 * * 1-5'; // 工作日18:00
const MOMENTUM_CALC_CRON = process.env.MOMENTUM_CALC_CRON || '30 18 * * 1-5'; // 工作日18:30
export class MomentumCalculationJob {
private dataSyncTask: cron.ScheduledTask | null = null;
private momentumCalcTask: cron.ScheduledTask | null = null;
/**
*
*/
start(): void {
logger.info('[MomentumJob] 启动动量计算定时任务');
logger.info(`[MomentumJob] 数据同步时间: ${DATA_SYNC_CRON}`);
logger.info(`[MomentumJob] 动量计算时间: ${MOMENTUM_CALC_CRON}`);
// 数据同步任务
this.dataSyncTask = cron.schedule(DATA_SYNC_CRON, async () => {
await this.executeDataSync();
}, {
scheduled: true,
timezone: 'Asia/Shanghai',
});
// 动量计算任务
this.momentumCalcTask = cron.schedule(MOMENTUM_CALC_CRON, async () => {
await this.executeMomentumCalculation();
}, {
scheduled: true,
timezone: 'Asia/Shanghai',
});
}
/**
*
*/
stop(): void {
logger.info('[MomentumJob] 停止动量计算定时任务');
if (this.dataSyncTask) {
this.dataSyncTask.stop();
this.dataSyncTask = null;
}
if (this.momentumCalcTask) {
this.momentumCalcTask.stop();
this.momentumCalcTask = null;
}
}
/**
*
*/
private async executeDataSync(): Promise<void> {
const today = new Date();
today.setHours(0, 0, 0, 0);
logger.info(`[MomentumJob] 开始定时数据同步: ${today.toISOString().split('T')[0]}`);
try {
// 检查今天是否是交易日
const tradeDate = await prisma.tradeDate.findUnique({
where: { date: today },
});
if (!tradeDate || !tradeDate.isTrade) {
logger.info('[MomentumJob] 今天不是交易日,跳过数据同步');
return;
}
// 记录任务
const task = await prisma.dataSyncTask.create({
data: {
taskType: 'scheduled_sync',
tradeDay: today,
status: 'running',
startTime: new Date(),
},
});
// 执行数据同步
const result = await stockDataSyncService.executeFullSync(today);
// 更新任务状态
await prisma.dataSyncTask.update({
where: { id: task.id },
data: {
status: result.status === 'completed' ? 'completed' : 'failed',
endTime: new Date(),
message: JSON.stringify(result),
},
});
logger.info('[MomentumJob] 定时数据同步完成');
} catch (error) {
logger.error('[MomentumJob] 定时数据同步失败:', error);
}
}
/**
*
*/
private async executeMomentumCalculation(): Promise<void> {
const today = new Date();
today.setHours(0, 0, 0, 0);
logger.info(`[MomentumJob] 开始定时动量计算: ${today.toISOString().split('T')[0]}`);
try {
// 检查今天是否是交易日
const tradeDate = await prisma.tradeDate.findUnique({
where: { date: today },
});
if (!tradeDate || !tradeDate.isTrade) {
logger.info('[MomentumJob] 今天不是交易日,跳过动量计算');
return;
}
// 记录任务
const task = await prisma.dataSyncTask.create({
data: {
taskType: 'scheduled_momentum',
tradeDay: today,
status: 'running',
startTime: new Date(),
},
});
// 1. 计算多周期涨跌幅
await momentumCalculationService.calculateMultiPeriodReturns(today);
// 2. 执行动量计算
await momentumCalculationService.executeFullCalculation(today);
// 更新任务状态
await prisma.dataSyncTask.update({
where: { id: task.id },
data: {
status: 'completed',
endTime: new Date(),
},
});
logger.info('[MomentumJob] 定时动量计算完成');
} catch (error) {
logger.error('[MomentumJob] 定时动量计算失败:', error);
}
}
/**
*
*/
async runDataSyncNow(tradeDay?: Date): Promise<void> {
const date = tradeDay || new Date();
date.setHours(0, 0, 0, 0);
logger.info(`[MomentumJob] 手动触发数据同步: ${date.toISOString().split('T')[0]}`);
await stockDataSyncService.executeFullSync(date);
}
/**
*
*/
async runMomentumCalcNow(tradeDay?: Date): Promise<void> {
const date = tradeDay || new Date();
date.setHours(0, 0, 0, 0);
logger.info(`[MomentumJob] 手动触发动量计算: ${date.toISOString().split('T')[0]}`);
// 计算多周期涨跌幅
await momentumCalculationService.calculateMultiPeriodReturns(date);
// 执行动量计算
await momentumCalculationService.executeFullCalculation(date);
}
}
export const momentumCalculationJob = new MomentumCalculationJob();

@ -216,12 +216,16 @@ router.get('/trading-dates', async (req: Request, res: Response) => {
});
}
const data = await externalDataSourceService.getTradingDates(
const tradingDates = await externalDataSourceService.getTradingDates(
startDate as string,
endDate as string
);
const tradingDays = data.filter(d => d.isTrading);
// tradingDates 是交易日字符串数组 (YYYYMMDD)
const tradingDays = tradingDates.map(date => ({
date,
isTrading: true,
}));
res.json({
code: 200,
@ -229,9 +233,9 @@ router.get('/trading-dates', async (req: Request, res: Response) => {
data: {
startDate,
endDate,
totalDays: data.length,
tradingDays: tradingDays.length,
items: data,
totalDays: tradingDates.length,
tradingDays: tradingDates.length,
items: tradingDays,
},
});
} catch (error: any) {

@ -0,0 +1,69 @@
import { Router } from 'express';
import { dataSyncController } from '../controllers/dataSyncController';
const router = Router();
/**
* @route GET /api/v1/sync/status
* @desc
*/
router.get('/status', dataSyncController.getSyncStatus);
/**
* @route GET /api/v1/sync/tasks
* @desc
* @query status -
* @query limit -
*/
router.get('/tasks', dataSyncController.getSyncTasks);
/**
* @route GET /api/v1/sync/tasks/:id
* @desc
*/
router.get('/tasks/:id', dataSyncController.getSyncTaskDetail);
/**
* @route POST /api/v1/sync/stock-basics
* @desc
*/
router.post('/stock-basics', dataSyncController.syncStockBasics);
/**
* @route POST /api/v1/sync/daily-quotes
* @desc
* @body tradeDay - ()
*/
router.post('/daily-quotes', dataSyncController.syncDailyQuotes);
/**
* @route POST /api/v1/sync/trade-dates
* @desc
* @body year - ()
*/
router.post('/trade-dates', dataSyncController.syncTradeDates);
/**
* @route POST /api/v1/sync/calculate-returns
* @desc
* @body tradeDay - ()
*/
router.post('/calculate-returns', dataSyncController.calculateReturns);
/**
* @route POST /api/v1/sync/calculate-momentum
* @desc
* @body tradeDay - ()
* @body type - ()
*/
router.post('/calculate-momentum', dataSyncController.calculateMomentum);
/**
* @route POST /api/v1/sync/full
* @desc
* @body tradeDay - ()
* @body skipMomentum - ()
*/
router.post('/full', dataSyncController.executeFullSync);
export default router;

@ -6,6 +6,8 @@ import userRoutes from './userRoutes';
import adminRoutes from './adminRoutes';
import dataSourceRoutes from './dataSourceRoutes';
import customDataSourceRoutes from './customDataSourceRoutes';
import momentumRoutes from './momentumRoutes';
import dataSyncRoutes from './dataSyncRoutes';
const router = Router();
@ -24,6 +26,12 @@ router.use('/users', userRoutes);
// 管理员路由
router.use('/admin', adminRoutes);
// 动量计算路由
router.use('/momentum', momentumRoutes);
// 数据同步路由
router.use('/sync', dataSyncRoutes);
// 自定义数据源路由(内部使用)
router.use('/datasource', dataSourceRoutes);

@ -0,0 +1,56 @@
import { Router } from 'express';
import { momentumController } from '../controllers/momentumController';
const router = Router();
/**
* @route GET /api/v1/momentum/stocks
* @desc
* @query tradeDay - (YYYY-MM-DD)
* @query type - (1,3,5,10,15,20,30,60)20
* @query blemind2 - ()
* @query limit - 100600
*/
router.get('/stocks', momentumController.getMomentumStocks);
/**
* @route GET /api/v1/momentum/sectors
* @desc
* @query tradeDay - (YYYY-MM-DD)
* @query type - 20
* @query limit - 50
*/
router.get('/sectors', momentumController.getSectorMomentum);
/**
* @route GET /api/v1/momentum/sector-trend
* @desc
* @query blemind2 - ()
* @query type - 20
* @query startDay - (YYYY-MM-DD)
* @query endDay - (YYYY-MM-DD)
*/
router.get('/sector-trend', momentumController.getSectorTrend);
/**
* @route GET /api/v1/momentum/periods
* @desc
*/
router.get('/periods', momentumController.getMomentumPeriods);
/**
* @route POST /api/v1/momentum/calculate
* @desc
* @body tradeDay - ()
* @body type - ()
*/
router.post('/calculate', momentumController.calculateMomentum);
/**
* @route POST /api/v1/momentum/calculate-returns
* @desc
* @body tradeDay - ()
*/
router.post('/calculate-returns', momentumController.calculateReturns);
export default router;

@ -1,31 +1,37 @@
import prisma from '../config/database';
import { cache } from '../config/redis';
import logger from '../utils/logger';
import { externalDataSourceService } from './externalDataSourceService';
export class DataSyncService {
// 同步实时行情
// 延迟辅助函数
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 同步实时行情(模拟数据)
async syncRealTimeQuotes(): Promise<void> {
try {
logger.info('Starting real-time quotes sync...');
// 获取所有股票
const stocks = await prisma.stock.findMany();
const now = new Date();
// 使用模拟数据(实际项目中应从数据源获取)
for (const stock of stocks.slice(0, 100)) { // 限制前100只
for (const stock of stocks.slice(0, 100)) {
try {
const basePrice = 10 + Math.random() * 100;
const changePercent = (Math.random() * 10 - 5);
const price = basePrice * (1 + changePercent / 100);
// 使用 create因为每次时间都不一样
await prisma.stockQuote.create({
data: {
stockCode: stock.code,
price: basePrice,
open: basePrice * (1 + (Math.random() * 0.02 - 0.01)),
high: basePrice * (1 + Math.random() * 0.05),
low: basePrice * (1 - Math.random() * 0.05),
preClose: basePrice * (1 - changePercent / 100),
price: price,
open: basePrice * (1 + (Math.random() * 4 - 2) / 100),
high: price * (1 + Math.random() * 0.02),
low: price * (1 - Math.random() * 0.02),
preClose: basePrice,
volume: BigInt(Math.floor(Math.random() * 10000000)),
turnover: BigInt(Math.floor(Math.random() * 100000000)),
changePercent: changePercent,
@ -38,88 +44,23 @@ export class DataSyncService {
}
logger.info('Real-time quotes sync completed');
// 清除相关缓存
await cache.delPattern('market:*');
await cache.delPattern('sectors:*');
} catch (error) {
logger.error('Failed to sync real-time quotes:', error);
throw error;
}
}
// 同步个股K线数据
async syncKLineData(stockCode: string, period: string = 'day'): Promise<void> {
try {
logger.info(`Syncing K-line data for ${stockCode}...`);
// 使用模拟数据生成K线
const days = period === 'day' ? 60 : period === 'week' ? 52 : 24;
const basePrice = 10 + Math.random() * 100;
let currentPrice = basePrice;
for (let i = days; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const change = (Math.random() * 0.06 - 0.03); // ±3%
const open = currentPrice;
const close = currentPrice * (1 + change);
const high = Math.max(open, close) * (1 + Math.random() * 0.02);
const low = Math.min(open, close) * (1 - Math.random() * 0.02);
await prisma.stockKLine.upsert({
where: {
stockCode_period_date: {
stockCode: stockCode,
period: period,
date: date,
},
},
update: {
open: open,
high: high,
low: low,
close: close,
volume: BigInt(Math.floor(Math.random() * 10000000)),
},
create: {
stockCode: stockCode,
period: period,
date: date,
open: open,
high: high,
low: low,
close: close,
volume: BigInt(Math.floor(Math.random() * 10000000)),
},
});
currentPrice = close;
}
logger.info(`Synced K-line data for ${stockCode}`);
// 清除缓存
await cache.delPattern(`stock:${stockCode}:kline:${period}:*`);
} catch (error) {
logger.error(`Failed to sync K-line data for ${stockCode}:`, error);
throw error;
}
}
// 同步版块行情
// 同步版块行情(模拟数据)
async syncSectorQuotes(): Promise<void> {
try {
logger.info('Starting sector quotes sync...');
// 获取所有版块
const sectors = await prisma.sector.findMany();
const now = new Date();
for (const sector of sectors) {
try {
const changePercent = Math.random() * 10 - 5;
const changePercent = Math.random() * 6 - 3;
await prisma.sectorQuote.create({
data: {
@ -127,8 +68,8 @@ export class DataSyncService {
current: 1000 + Math.random() * 500,
change: changePercent * 10,
changePercent: changePercent,
volume: BigInt(Math.floor(Math.random() * 100000000)),
turnover: BigInt(Math.floor(Math.random() * 1000000000)),
volume: BigInt(Math.floor(Math.random() * 1000000000)),
turnover: BigInt(Math.floor(Math.random() * 10000000000)),
quoteTime: now,
},
});
@ -137,60 +78,79 @@ export class DataSyncService {
}
}
logger.info(`Sector quotes sync completed for ${sectors.length} sectors`);
// 清除缓存
await cache.delPattern('sectors:*');
logger.info('Sector quotes sync completed');
} catch (error) {
logger.error('Failed to sync sector quotes:', error);
throw error;
}
}
// 同步市场指数
async syncMarketIndices(): Promise<void> {
// 同步K线数据单只股票
async syncKLineData(stockCode: string, period: string = 'day'): Promise<void> {
try {
logger.info('Starting market indices sync...');
logger.info(`Starting K-line sync for ${stockCode} (${period})...`);
const indices = [
{ name: '上证指数', code: '000001' },
{ name: '深证成指', code: '399001' },
{ name: '创业板指', code: '399006' },
{ name: '科创50', code: '000688' },
];
// 获取最新K线日期
const latestKLine = await prisma.stockKLine.findFirst({
where: { stockCode, period },
orderBy: { date: 'desc' },
});
const startDate = latestKLine
? new Date(latestKLine.date.getTime() + 24 * 60 * 60 * 1000)
: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const endDate = new Date();
// 使用模拟数据
const days = Math.min(100, Math.ceil((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)));
for (let i = days; i >= 0; i--) {
const date = new Date(endDate);
date.setDate(date.getDate() - i);
const basePrice = 10 + Math.random() * 100;
const change = (Math.random() * 0.1 - 0.05);
const close = basePrice * (1 + change);
const open = basePrice * (1 + (Math.random() * 0.04 - 0.02));
const high = Math.max(open, close) * (1 + Math.random() * 0.02);
const low = Math.min(open, close) * (1 - Math.random() * 0.02);
for (const index of indices) {
try {
await prisma.marketIndex.upsert({
where: { code: index.code },
await prisma.stockKLine.upsert({
where: {
stockCode_period_date: {
stockCode,
period,
date,
},
},
update: {
current: 3000 + Math.random() * 500,
change: Math.random() * 50 - 25,
changePercent: Math.random() * 2 - 1,
volume: BigInt(Math.floor(Math.random() * 500000000)),
turnover: BigInt(Math.floor(Math.random() * 5000000000)),
open,
high,
low,
close,
volume: BigInt(Math.floor(Math.random() * 10000000)),
},
create: {
name: index.name,
code: index.code,
current: 3000 + Math.random() * 500,
change: Math.random() * 50 - 25,
changePercent: Math.random() * 2 - 1,
volume: BigInt(Math.floor(Math.random() * 500000000)),
turnover: BigInt(Math.floor(Math.random() * 5000000000)),
stockCode,
period,
date,
open,
high,
low,
close,
volume: BigInt(Math.floor(Math.random() * 10000000)),
},
});
} catch (error) {
logger.error(`Failed to sync market index ${index.code}:`, error);
logger.error(`Failed to upsert K-line for ${stockCode} on ${date}:`, error);
}
}
logger.info('Market indices sync completed');
// 清除缓存
await cache.del('market:indices');
logger.info(`K-line sync completed for ${stockCode}`);
} catch (error) {
logger.error('Failed to sync market indices:', error);
logger.error(`Failed to sync K-line for ${stockCode}:`, error);
throw error;
}
}
@ -212,7 +172,6 @@ export class DataSyncService {
await this.syncKLineData(stock.code, period);
successCount++;
// 添加延迟避免请求过快
await this.delay(100);
} catch (error) {
logger.error(`Failed to sync K-line for ${stock.code}:`, error);
@ -227,64 +186,188 @@ export class DataSyncService {
}
}
// 延迟辅助函数
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 同步所有股票基础信息
// 同步所有股票日行情数据(从外部数据源)
async syncAllStocks(): Promise<void> {
try {
logger.info('Starting sync all stocks...');
logger.info('[syncAllStocks] 开始同步所有股票日行情数据...');
// 使用预定义的A股股票列表模拟数据
const stocks = [
{ code: '000001', name: '平安银行' },
{ code: '000002', name: '万科A' },
{ code: '000063', name: '中兴通讯' },
{ code: '000100', name: 'TCL科技' },
{ code: '000333', name: '美的集团' },
{ code: '000568', name: '泸州老窖' },
{ code: '000651', name: '格力电器' },
{ code: '000725', name: '京东方A' },
{ code: '000768', name: '中航西飞' },
{ code: '000858', name: '五粮液' },
{ code: '600000', name: '浦发银行' },
{ code: '600009', name: '上海机场' },
{ code: '600016', name: '民生银行' },
{ code: '600028', name: '中国石化' },
{ code: '600030', name: '中信证券' },
{ code: '600036', name: '招商银行' },
{ code: '600048', name: '保利发展' },
{ code: '600104', name: '上汽集团' },
{ code: '600276', name: '恒瑞医药' },
{ code: '600519', name: '贵州茅台' },
];
// 检查外部数据源是否可用
if (!externalDataSourceService.isEnabled()) {
throw new Error('数据源未配置,无法同步股票数据');
}
let successCount = 0;
// 1. 获取本地数据库最新日期
const latestQuote = await prisma.stockDailyQuote.findFirst({
orderBy: { tradeDay: 'desc' },
select: { tradeDay: true },
});
for (const stock of stocks) {
const startDate = latestQuote?.tradeDay
? new Date(latestQuote.tradeDay.getTime() + 24 * 60 * 60 * 1000)
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 默认30天前
logger.info(`[syncAllStocks] 本地最新日期: ${latestQuote?.tradeDay?.toISOString().split('T')[0] || '无'}, 开始日期: ${startDate.toISOString().split('T')[0]}`);
// 2. 获取交易日历(返回交易日字符串数组 YYYYMMDD
const tradingDateList = await externalDataSourceService.getTradingDates(
startDate.toISOString().split('T')[0].replace(/-/g, ''),
new Date().toISOString().split('T')[0].replace(/-/g, '')
);
logger.info(`[syncAllStocks] 需要同步 ${tradingDateList.length} 个交易日: ${tradingDateList.join(', ')}`);
if (tradingDateList.length === 0) {
logger.info('[syncAllStocks] 没有需要同步的交易日');
return;
}
// 3. 获取所有股票代码
const symbols = await externalDataSourceService.getSymbols({
type: 'stock',
limit: 6000
});
// 保留完整的 symbol 信息,用于后续调用 API
const stockList = symbols
.filter(s => s.symbol_id && s.status === 'active') // 只保留活跃股票
.map(s => ({
code: s.symbol_id?.replace(/\.(SZ|SH|BJ)$/i, '') || '', // 纯代码,用于数据库
symbol_id: s.symbol_id || '', // 完整代码(带后缀),用于 API 调用
name: s.name || '',
exchange: s.exchange || '',
}))
.filter(s => s.code && s.symbol_id); // 过滤掉无效数据
logger.info(`[syncAllStocks] 获取到 ${stockList.length} 只活跃股票,样例: ${stockList.slice(0, 5).map(s => s.symbol_id).join(', ')}`);
// 4. 按日期遍历,批量获取并保存数据
let totalSyncedDays = 0;
let totalSyncedRecords = 0;
for (const tradeDate of tradingDateList) {
try {
await prisma.stock.upsert({
where: { code: stock.code },
update: {
name: stock.name,
},
create: {
code: stock.code,
name: stock.name,
},
});
successCount++;
logger.info(`[syncAllStocks] 同步 ${tradeDate} 的数据...`);
// 格式化日期 YYYY-MM-DD
const formattedDate = `${tradeDate.slice(0, 4)}-${tradeDate.slice(4, 6)}-${tradeDate.slice(6, 8)}`;
// 批量获取该日所有股票的行情数据
// 分批处理每批10只股票
const batchSize = 10;
let daySyncedCount = 0;
for (let i = 0; i < stockList.length; i += batchSize) {
const batch = stockList.slice(i, i + batchSize);
try {
// 调用外部数据源批量获取日行情
const quotes = await this.fetchDailyQuotes(batch, formattedDate);
// 批量保存到数据库
await this.saveDailyQuotes(quotes, formattedDate);
daySyncedCount += quotes.length;
totalSyncedRecords += quotes.length;
// 避免请求过快
await this.delay(200);
} catch (error) {
logger.error(`[syncAllStocks] 批量获取 ${formattedDate}${i/batchSize + 1} 批数据失败:`, error);
}
}
totalSyncedDays++;
logger.info(`[syncAllStocks] ${formattedDate} 同步完成: ${daySyncedCount} 条记录`);
} catch (error) {
logger.error(`Failed to upsert stock ${stock.code}:`, error);
logger.error(`[syncAllStocks] 同步 ${tradeDate} 失败:`, error);
}
}
logger.info(`Synced ${successCount} stocks`);
logger.info(`[syncAllStocks] 同步完成: ${totalSyncedDays} 天, ${totalSyncedRecords} 条记录`);
} catch (error) {
logger.error('Failed to sync all stocks:', error);
throw error;
logger.error('[syncAllStocks] 同步失败:', error);
throw new Error('数据源异常,股票同步失败: ' + (error as Error).message);
}
}
// 批量获取日行情数据(辅助方法)
private async fetchDailyQuotes(
stocks: { code: string; symbol_id: string; name: string; exchange: string }[],
tradeDate: string
): Promise<any[]> {
// 使用 K 线接口获取日线数据
const quotes: any[] = [];
for (const stock of stocks) {
try {
// 使用完整的 symbol_id带后缀如 000001.SZ调用 API
const klines = await externalDataSourceService.getKLines(stock.symbol_id, 'day', {
startDate: tradeDate.replace(/-/g, ''),
endDate: tradeDate.replace(/-/g, ''),
limit: 1,
});
if (klines && klines.length > 0) {
const k = klines[0];
// 使用纯代码(不带后缀)保存到数据库
quotes.push({
stockCode: stock.code,
stockName: stock.name,
open: k.open,
close: k.close,
high: k.high,
low: k.low,
volume: k.volume,
amount: k.amount || 0,
differrange: ((k.close - k.open) / k.open) * 100,
});
}
} catch (error) {
logger.warn(`[fetchDailyQuotes] 获取 ${stock.symbol_id} ${tradeDate} 数据失败`);
}
}
return quotes;
}
// 批量保存日行情数据(辅助方法)
private async saveDailyQuotes(quotes: any[], tradeDate: string): Promise<void> {
const date = new Date(tradeDate);
for (const quote of quotes) {
try {
await prisma.stockDailyQuote.upsert({
where: {
stockCode_tradeDay: {
stockCode: quote.stockCode,
tradeDay: date,
},
},
update: {
open: quote.open,
close: quote.close,
high: quote.high,
low: quote.low,
volume: BigInt(quote.volume || 0),
amount: BigInt(quote.amount || 0),
differrange: quote.differrange,
},
create: {
stockCode: quote.stockCode,
tradeDay: date,
open: quote.open,
close: quote.close,
high: quote.high,
low: quote.low,
volume: BigInt(quote.volume || 0),
amount: BigInt(quote.amount || 0),
differrange: quote.differrange,
},
});
} catch (error) {
logger.error(`[saveDailyQuotes] 保存 ${quote.stockCode} 失败:`, error);
}
}
}
@ -330,38 +413,56 @@ export class DataSyncService {
try {
logger.info('Starting calculate momentum scores...');
// 获取所有股票
const stocks = await prisma.stock.findMany();
const stocks = await prisma.stock.findMany({
include: {
klines: {
where: { period: 'day' },
orderBy: { date: 'desc' },
take: 20,
},
},
});
for (const stock of stocks) {
try {
// 获取最近20天的K线数据
const klines = await prisma.stockKLine.findMany({
if (stock.klines.length < 5) continue;
const prices = stock.klines.map(k => k.close).reverse();
const ma5 = prices.slice(-5).reduce((a, b) => a + b, 0) / 5;
const ma10 = prices.slice(-10).reduce((a, b) => a + b, 0) / 10;
const ma20 = prices.slice(-20).reduce((a, b) => a + b, 0) / 20;
const currentPrice = prices[prices.length - 1];
const priceChange = (currentPrice - prices[0]) / prices[0] * 100;
let score = 50;
if (currentPrice > ma5) score += 10;
if (currentPrice > ma10) score += 10;
if (currentPrice > ma20) score += 10;
if (priceChange > 5) score += 10;
if (priceChange > 10) score += 10;
const today = new Date();
today.setHours(0, 0, 0, 0);
await prisma.momentumStock.upsert({
where: {
stockCode_date: {
stockCode: stock.code,
date: today,
},
},
update: {
momentumScore: score,
},
create: {
stockCode: stock.code,
period: 'day',
momentumScore: score,
volumeRatio: 1 + Math.random() * 2,
breakThrough: score > 70,
date: today,
},
orderBy: { date: 'desc' },
take: 20,
});
if (klines.length < 20) {
continue;
}
// 计算动量分数(简化版本)
const latest = klines[0];
const prev20 = klines[klines.length - 1];
const priceChange = (latest.close - prev20.close) / prev20.close * 100;
// 计算成交量变化
const avgVolume = klines.reduce((sum, k) => sum + Number(k.volume), 0) / klines.length;
const volumeRatio = Number(latest.volume) / avgVolume;
// 动量分数 = 价格变化 * 0.6 + 成交量比 * 0.4
const momentumScore = Math.min(100, Math.max(0, priceChange * 0.6 + (volumeRatio - 1) * 10 * 0.4 + 50));
logger.info(`Stock ${stock.code} momentum score: ${momentumScore}`);
} catch (error) {
logger.error(`Failed to calculate momentum for ${stock.code}:`, error);
}
@ -374,13 +475,66 @@ export class DataSyncService {
}
}
// 同步市场指数
async syncMarketIndices(): Promise<void> {
try {
logger.info('Starting sync market indices...');
const indices = [
{ name: '上证指数', code: '000001' },
{ name: '深证成指', code: '399001' },
{ name: '创业板指', code: '399006' },
];
for (const index of indices) {
try {
await prisma.marketIndex.upsert({
where: { code: index.code },
update: {
current: 3000 + Math.random() * 500,
change: Math.random() * 40 - 20,
changePercent: Math.random() * 2 - 1,
volume: BigInt(Math.floor(Math.random() * 1000000000)),
turnover: BigInt(Math.floor(Math.random() * 10000000000)),
},
create: {
name: index.name,
code: index.code,
current: 3000 + Math.random() * 500,
change: Math.random() * 40 - 20,
changePercent: Math.random() * 2 - 1,
volume: BigInt(Math.floor(Math.random() * 1000000000)),
turnover: BigInt(Math.floor(Math.random() * 10000000000)),
},
});
} catch (error) {
logger.error(`Failed to upsert market index ${index.code}:`, error);
}
}
logger.info('Market indices sync completed');
} catch (error) {
logger.error('Failed to sync market indices:', error);
throw error;
}
}
// 初始化基础数据
async initBaseData(): Promise<void> {
try {
logger.info('Initializing base data...');
// 同步股票
await this.syncAllStocks();
// 检查外部数据源是否可用
if (externalDataSourceService.isEnabled()) {
try {
// 同步股票
await this.syncAllStocks();
} catch (error) {
logger.warn('Stock sync failed during init, will retry later:', error);
}
} else {
logger.warn('External data source not enabled, skipping stock sync during init');
}
// 同步版块
await this.syncSectors();
@ -391,7 +545,6 @@ export class DataSyncService {
logger.info('Base data initialization completed');
} catch (error) {
logger.error('Failed to initialize base data:', error);
throw error;
}
}
}

@ -1,10 +1,15 @@
import axios from 'axios';
import logger from '../utils/logger';
// 环境变量配置
const EXTERNAL_DATA_SOURCE_URL = process.env.EXTERNAL_DATA_SOURCE_URL || '';
const EXTERNAL_DATA_SOURCE_API_KEY = process.env.EXTERNAL_DATA_SOURCE_API_KEY || '';
const EXTERNAL_DATA_SOURCE_TIMEOUT = parseInt(process.env.EXTERNAL_DATA_SOURCE_TIMEOUT || '30000', 10);
// 引入配置,确保环境变量已加载
import config from '../config';
import { print } from 'ioredis';
import { log } from 'console';
// 环境变量配置(使用懒加载)
const getExternalDataSourceUrl = () => process.env.EXTERNAL_DATA_SOURCE_URL || '';
const getExternalDataSourceApiKey = () => process.env.EXTERNAL_DATA_SOURCE_API_KEY || '';
const getExternalDataSourceTimeout = () => parseInt(process.env.EXTERNAL_DATA_SOURCE_TIMEOUT || '30000', 10);
// K线数据项
export interface KLineItem {
@ -19,11 +24,16 @@ export interface KLineItem {
// 标的信息
export interface SymbolInfo {
symbol: string;
name: string;
type: string;
exchange?: string;
industry?: string;
symbol_id: string; // 股票代码,如 "000001.SZ"
symbol?: string; // 兼容字段
name: string; // 股票名称
symbol_type: string; // 类型,如 "stock"
type?: string; // 兼容字段
exchange: string; // 交易所,如 "SZ"
status: string; // 状态,如 "active"
industry?: string; // 行业
list_date?: string; // 上市日期
delist_date?: string; // 退市日期
}
// 交易日历项
@ -43,10 +53,18 @@ export class ExternalDataSourceService {
private timeout: number;
constructor() {
this.enabled = !!EXTERNAL_DATA_SOURCE_URL;
this.baseUrl = EXTERNAL_DATA_SOURCE_URL;
this.apiKey = EXTERNAL_DATA_SOURCE_API_KEY;
this.timeout = EXTERNAL_DATA_SOURCE_TIMEOUT;
// 延迟读取环境变量,确保 dotenv 已加载
this.baseUrl = getExternalDataSourceUrl();
this.apiKey = getExternalDataSourceApiKey();
this.timeout = getExternalDataSourceTimeout();
this.enabled = !!this.baseUrl;
logger.info(`[ExternalDataSource] 配置: ${JSON.stringify({ enabled: this.enabled, baseUrl: this.baseUrl, apiKey: this.apiKey ? '***' : '未设置', timeout: this.timeout })}`);
if (this.enabled) {
logger.info(`[ExternalDataSource] 已配置数据源: ${this.baseUrl}`);
} else {
logger.warn('[ExternalDataSource] 未配置数据源 URL');
}
}
/**
@ -92,6 +110,9 @@ export class ExternalDataSourceService {
/**
* K线
* @param symbol 000001.SZ
* @param period : 'day' | 'week' | 'month' freq: '1d' | '1w' | '1month'
* @param options : startDate(YYYYMMDD), endDate(YYYYMMDD), limit
*/
async getKLines(
symbol: string,
@ -106,24 +127,38 @@ export class ExternalDataSourceService {
throw new Error('External data source is not enabled');
}
// 周期映射: day->1d, week->1w, month->1month
const freqMap: Record<string, string> = {
day: '1d',
week: '1w',
month: '1month',
};
const params: Record<string, any> = {
period,
freq: freqMap[period] || '1d',
};
if (options?.startDate) params.startDate = options.startDate;
if (options?.endDate) params.endDate = options.endDate;
// 接口参数名是 start/end不是 startDate/endDate
if (options?.startDate) params.start = options.startDate;
if (options?.endDate) params.end = options.endDate;
if (options?.limit) params.limit = options.limit;
logger.info(`[ExternalDataSource] getKLines 获取 ${symbol} K线数据参数: ${JSON.stringify(params)}`);
const response = await axios.get(
`${this.baseUrl}/v1/stock/klines/${symbol}`,
{ ...this.getRequestConfig(), params }
);
logger.info(`[ExternalDataSource] getKLines 获取 ${symbol} K线数据响应: ${JSON.stringify(response.data)}`);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get KLines');
// 支持 code: 0 或 code: 200
const responseCode = response.data.code;
if (responseCode !== 0 && responseCode !== 200) {
throw new Error(response.data.message || `Failed to get KLines, code: ${responseCode}`);
}
return response.data.data?.items || [];
// 适配不同的响应结构
return response.data.data?.items || response.data.data || [];
}
/**
@ -140,27 +175,36 @@ export class ExternalDataSourceService {
throw new Error('External data source is not enabled');
}
// 周期映射: day->1d, week->1w, month->1month
const freqMap: Record<string, string> = {
day: '1d',
week: '1w',
month: '1month',
};
const response = await axios.post(
`${this.baseUrl}/v1/stock/klines/batch`,
{
symbols: options.symbols,
period: options.period || 'day',
startDate: options.startDate,
endDate: options.endDate,
freq: freqMap[options.period || 'day'] || '1d',
start: options.startDate,
end: options.endDate,
limit: options.limit,
},
this.getRequestConfig()
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get batch KLines');
// 支持 code: 0 或 code: 200
const responseCode = response.data.code;
if (responseCode !== 0 && responseCode !== 200) {
throw new Error(response.data.message || `Failed to get batch KLines, code: ${responseCode}`);
}
// 转换响应格式
const results = response.data.data?.results || [];
return results.map((r: any) => ({
symbol: r.symbol,
data: r.data?.items || [],
data: r.data?.items || r.data || [],
}));
}
@ -177,6 +221,8 @@ export class ExternalDataSourceService {
throw new Error('External data source is not enabled');
}
logger.info(`[ExternalDataSource] getSymbols 获取标的信息,参数: ${JSON.stringify(options)}`);
const params: Record<string, any> = {};
if (options?.type) params.type = options.type;
if (options?.exchange) params.exchange = options.exchange;
@ -188,34 +234,45 @@ export class ExternalDataSourceService {
{ ...this.getRequestConfig(), params }
);
logger.info(`[ExternalDataSource] getSymbols 获取标的信息,响应: ${JSON.stringify(response.data)}`);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get symbols');
}
logger.info(`[ExternalDataSource] getSymbols 获取标的信息,返回: ${JSON.stringify(response.data.data?.items)}`);
return response.data.data?.items || [];
}
/**
*
* (YYYYMMDD)
*/
async getTradingDates(startDate: string, endDate: string): Promise<TradingDate[]> {
async getTradingDates(startDate: string, endDate: string): Promise<string[]> {
if (!this.enabled) {
throw new Error('External data source is not enabled');
}
logger.info(`[ExternalDataSource] 获取交易日历,开始日期: ${startDate},结束日期: ${endDate}`);
const response = await axios.get(
`${this.baseUrl}/v1/stock/trading-dates`,
{
...this.getRequestConfig(),
params: { startDate, endDate },
params: { start: startDate, end: endDate },
}
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get trading dates');
// 支持 code: 0 或 code: 200 表示成功
const responseCode = response.data.code;
if (responseCode !== 0 && responseCode !== 200) {
throw new Error(response.data.message || `Failed to get trading dates, code: ${responseCode}`);
}
return response.data.data?.items || [];
// 响应格式: data.trading_dates: ["20260304", "20260305", ...]
const tradingDates = response.data.data?.trading_dates || [];
logger.info(`[ExternalDataSource] 获取到 ${tradingDates.length} 个交易日`);
return tradingDates;
}
/**
@ -227,6 +284,7 @@ export class ExternalDataSourceService {
throw new Error('External data source is not enabled');
}
logger.info(`[ExternalDataSource] [244] 获取最近交易日,基准日期: ${date || '当前日期'} baseUrl: ${this.baseUrl}`);
// 构造查询日期范围:从指定日期往前推 10 天
const targetDate = date ? new Date(date) : new Date();
const startDate = new Date(targetDate);
@ -246,19 +304,30 @@ export class ExternalDataSourceService {
}
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get nearest trading date');
// 支持 code: 0 或 code: 200 表示成功
const responseCode = response.data.code;
if (responseCode !== 0 && responseCode !== 200) {
throw new Error(response.data.message || `Failed to get nearest trading date, code: ${responseCode}`);
}
// 返回最后一个交易日(最近的)
const tradingDates = response.data.data?.trading_dates || [];
logger.info(`[ExternalDataSource] [270] 获取交易日历响应: ${JSON.stringify(response.data)}`);
// 适配不同的响应结构: data.trading_dates 或直接 trading_dates
const responseData = response.data.data || response.data;
const tradingDates = responseData.trading_dates || [];
logger.info(`[ExternalDataSource] [276] 交易日列表: ${JSON.stringify(tradingDates)}`);
if (tradingDates.length === 0) {
logger.warn('[ExternalDataSource] 未获取到交易日数据');
return null;
}
// 将 YYYYMMDD 转换为 YYYY-MM-DD
const lastDate = tradingDates[tradingDates.length - 1];
return `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`;
const formattedDate = `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`;
logger.info(`[ExternalDataSource] 获取最近交易日: ${lastDate} -> ${formattedDate}`);
return formattedDate;
}
/**

@ -0,0 +1,676 @@
/**
*
*
* 1. >=120>2%600
* 2. = (^2) /
* 3.
*/
import prisma from '../config/database';
import logger from '../utils/logger';
// 支持的动量周期类型
export const MOMENTUM_PERIODS = [1, 3, 5, 10, 15, 20, 30, 60] as const;
export type MomentumPeriod = typeof MOMENTUM_PERIODS[number];
// 动量个股筛选条件
const MIN_TRADE_DAYS = 120; // 最小上市天数
const MIN_AGENCIES_HOLD = 2; // 最小机构持股比例(%)
const MAX_MOMENTUM_STOCKS = 600; // 最大动量个股数量
// 动量计算结果接口
export interface StockMomentumResult {
stockCode: string;
name?: string;
blemind2?: string;
blemind3?: string;
tradeDay: Date;
sort: number;
type: number;
open?: number;
close?: number;
differrange?: number;
differrange10?: number;
differrange20?: number;
differrange60?: number;
backdifferrange10?: number;
backdifferrange20?: number;
backdifferrange60?: number;
tradeDays?: number;
agenciesHold?: number;
}
export interface SectorMomentumResult {
tradeDay: Date;
blemind2: string;
stocksCount: number;
totalStocks: number;
trendValue: number;
trendValueChange?: number;
sort: number;
sortChange?: number;
type: number;
lastTrendValue?: number;
lastSort?: number;
}
export class MomentumCalculationService {
/**
*
* @param tradeDay
*/
async calculateMultiPeriodReturns(tradeDay: Date): Promise<void> {
logger.info(`[Momentum] 开始计算多周期涨跌幅: ${tradeDay.toISOString().split('T')[0]}`);
try {
// 获取所有股票
const stocks = await prisma.stockBasic.findMany({
select: { code: true },
});
for (const period of MOMENTUM_PERIODS) {
if (period === 1) continue; // 1日涨跌幅已存在于daily_quote中
// 获取N个交易日前的日期
const baseDate = await this.getTradeDateBefore(tradeDay, period);
if (!baseDate) {
logger.warn(`[Momentum] 无法获取${period}个交易日前的日期`);
continue;
}
// 批量计算涨跌幅
for (const stock of stocks) {
await this.calculateSinglePeriodReturn(stock.code, tradeDay, baseDate, period);
}
}
logger.info(`[Momentum] 多周期涨跌幅计算完成`);
} catch (error) {
logger.error(`[Momentum] 计算多周期涨跌幅失败:`, error);
throw error;
}
}
/**
*
*/
private async calculateSinglePeriodReturn(
stockCode: string,
tradeDay: Date,
baseDate: Date,
period: number
): Promise<void> {
try {
// 获取当日和N日前的收盘价
const [currentQuote, baseQuote] = await Promise.all([
prisma.stockDailyQuote.findUnique({
where: { stockCode_tradeDay: { stockCode, tradeDay } },
select: { id: true, close: true },
}),
prisma.stockDailyQuote.findUnique({
where: {
stockCode_tradeDay: {
stockCode,
tradeDay: baseDate
}
},
select: { close: true },
}),
]);
if (!currentQuote || !baseQuote || baseQuote.close === 0) {
return;
}
// 计算涨跌幅
const returnRate = ((currentQuote.close - baseQuote.close) / baseQuote.close) * 100;
// 保存或更新
await prisma.stockReturn.upsert({
where: {
stockCode_tradeDay_period: {
stockCode,
tradeDay,
period,
},
},
update: {
returnRate,
basePrice: baseQuote.close,
},
create: {
stockCode,
quoteId: currentQuote.id,
tradeDay,
period,
returnRate,
basePrice: baseQuote.close,
},
});
} catch (error) {
logger.error(`[Momentum] 计算涨跌幅失败 ${stockCode} ${period}日:`, error);
}
}
/**
*
* @param tradeDay
* @param type ()
*/
async selectMomentumStocks(tradeDay: Date, type: MomentumPeriod): Promise<StockMomentumResult[]> {
logger.info(`[Momentum] 开始筛选动量个股: ${tradeDay.toISOString().split('T')[0]}, 类型: ${type}`);
try {
// 获取所有符合条件的股票(上市>=120天机构持股>2%
const eligibleStocks = await prisma.stockBasic.findMany({
where: {
AND: [
{ tradeDays: { gte: MIN_TRADE_DAYS } },
{ agenciesHold: { gt: MIN_AGENCIES_HOLD } },
],
},
select: {
code: true,
name: true,
blemind2: true,
blemind3: true,
tradeDays: true,
agenciesHold: true,
},
});
// 获取这些股票在指定日期的行情和涨跌幅
const stockCodes = eligibleStocks.map(s => s.code);
const stockMap = new Map(eligibleStocks.map(s => [s.code, s]));
let quotesWithReturns: any[] = [];
if (type === 1) {
// 1日动量使用当日涨跌幅
quotesWithReturns = await prisma.stockDailyQuote.findMany({
where: {
stockCode: { in: stockCodes },
tradeDay,
},
select: {
stockCode: true,
open: true,
close: true,
differrange: true,
},
});
} else {
// 其他周期使用stock_returns表
quotesWithReturns = await prisma.stockReturn.findMany({
where: {
stockCode: { in: stockCodes },
tradeDay,
period: type,
},
select: {
stockCode: true,
returnRate: true,
quote: {
select: {
open: true,
close: true,
differrange: true,
},
},
},
});
}
// 按涨跌幅降序排序
const sortedStocks = quotesWithReturns
.map(q => ({
...q,
returnRate: type === 1 ? q.differrange : q.returnRate,
basicInfo: stockMap.get(q.stockCode),
}))
.filter(q => q.basicInfo) // 确保有基础信息
.sort((a, b) => (b.returnRate || 0) - (a.returnRate || 0))
.slice(0, MAX_MOMENTUM_STOCKS);
// 计算最大回撤
const results: StockMomentumResult[] = [];
for (let i = 0; i < sortedStocks.length; i++) {
const s = sortedStocks[i];
const basicInfo = s.basicInfo;
// 计算最大回撤
const backdifferranges = await this.calculateMaxDrawdown(
s.stockCode,
tradeDay,
[10, 20, 60]
);
results.push({
stockCode: s.stockCode,
name: basicInfo?.name || undefined,
blemind2: basicInfo?.blemind2 || undefined,
blemind3: basicInfo?.blemind3 || undefined,
tradeDay,
sort: i + 1,
type,
open: type === 1 ? s.open : s.quote?.open,
close: type === 1 ? s.close : s.quote?.close,
differrange: type === 1 ? s.differrange : s.quote?.differrange,
differrange10: backdifferranges.differrange10,
differrange20: backdifferranges.differrange20,
differrange60: backdifferranges.differrange60,
backdifferrange10: backdifferranges.backdifferrange10,
backdifferrange20: backdifferranges.backdifferrange20,
backdifferrange60: backdifferranges.backdifferrange60,
tradeDays: basicInfo?.tradeDays,
agenciesHold: basicInfo?.agenciesHold || undefined,
});
}
logger.info(`[Momentum] 筛选出动量个股 ${results.length}`);
return results;
} catch (error) {
logger.error(`[Momentum] 筛选动量个股失败:`, error);
throw error;
}
}
/**
*
* @param stockCode
* @param tradeDay
* @param periods
*/
private async calculateMaxDrawdown(
stockCode: string,
tradeDay: Date,
periods: number[]
): Promise<Record<string, number | undefined>> {
const result: Record<string, number | undefined> = {
differrange10: undefined,
differrange20: undefined,
differrange60: undefined,
backdifferrange10: undefined,
backdifferrange20: undefined,
backdifferrange60: undefined,
};
try {
// 获取当前收盘价
const currentQuote = await prisma.stockDailyQuote.findUnique({
where: { stockCode_tradeDay: { stockCode, tradeDay } },
select: { close: true },
});
if (!currentQuote) return result;
for (const period of periods) {
// 获取N日前的日期
const startDate = await this.getTradeDateBefore(tradeDay, period);
if (!startDate) continue;
// 获取N日前的收盘价
const startQuote = await prisma.stockDailyQuote.findUnique({
where: { stockCode_tradeDay: { stockCode, tradeDay: startDate } },
select: { close: true },
});
if (!startQuote || startQuote.close === 0) continue;
// 计算涨跌幅
const returnRate = ((currentQuote.close - startQuote.close) / startQuote.close) * 100;
result[`differrange${period}`] = Number(returnRate.toFixed(2));
// 获取区间最高价
const highQuote = await prisma.stockDailyQuote.findFirst({
where: {
stockCode,
tradeDay: {
gte: startDate,
lte: tradeDay,
},
},
orderBy: { high: 'desc' },
select: { high: true },
});
if (highQuote && highQuote.high > 0) {
// 计算回撤
const drawdown = ((currentQuote.close - highQuote.high) / highQuote.high) * 100;
result[`backdifferrange${period}`] = Number(drawdown.toFixed(2));
}
}
} catch (error) {
logger.error(`[Momentum] 计算最大回撤失败 ${stockCode}:`, error);
}
return result;
}
/**
*
* @param tradeDay
* @param type
* @param momentumStocks
*/
async calculateSectorMomentum(
tradeDay: Date,
type: number,
momentumStocks: StockMomentumResult[]
): Promise<SectorMomentumResult[]> {
logger.info(`[Momentum] 开始计算板块动量: ${tradeDay.toISOString().split('T')[0]}, 类型: ${type}`);
try {
// 按blemind2分组统计
const sectorMap = new Map<string, { count: number; stocks: string[] }>();
for (const stock of momentumStocks) {
const blemind2 = stock.blemind2 || '其他';
if (!sectorMap.has(blemind2)) {
sectorMap.set(blemind2, { count: 0, stocks: [] });
}
const sector = sectorMap.get(blemind2)!;
sector.count++;
sector.stocks.push(stock.stockCode);
}
// 获取各板块的总股票数
const allSectors = await prisma.stockBasic.groupBy({
by: ['blemind2'],
where: {
blemind2: { not: null },
},
_count: { code: true },
});
const sectorTotalMap = new Map(
allSectors.map(s => [s.blemind2 || '其他', s._count.code])
);
// 获取昨日的板块动量数据(用于计算变化)
const lastTradeDay = await this.getLastTradeDay(tradeDay);
const lastMomentum = lastTradeDay ? await prisma.sectorMomentum.findMany({
where: {
tradeDay: lastTradeDay,
type,
},
select: {
blemind2: true,
trendValue: true,
sort: true,
},
}) : [];
const lastMomentumMap = new Map(
lastMomentum.map(m => [m.blemind2, { trendValue: m.trendValue, sort: m.sort }])
);
// 计算各板块动量值
const results: SectorMomentumResult[] = [];
for (const [blemind2, data] of sectorMap.entries()) {
const totalStocks = sectorTotalMap.get(blemind2) || 0;
if (totalStocks === 0) continue;
// 动量值 = (动量个股数^2) / 板块总个股数
const trendValue = Number(((data.count * data.count) / totalStocks).toFixed(4));
results.push({
tradeDay,
blemind2,
stocksCount: data.count,
totalStocks,
trendValue,
type,
sort: 0, // 稍后计算
lastTrendValue: lastMomentumMap.get(blemind2)?.trendValue,
lastSort: lastMomentumMap.get(blemind2)?.sort,
});
}
// 按动量值降序排序并计算排名
results.sort((a, b) => b.trendValue - a.trendValue);
for (let i = 0; i < results.length; i++) {
const result = results[i];
result.sort = i + 1;
// 计算变化
if (result.lastSort !== undefined && result.lastSort !== null) {
result.sortChange = result.lastSort - result.sort; // 昨日排名 - 今日排名
result.trendValueChange = Number((result.trendValue - (result.lastTrendValue || 0)).toFixed(4));
} else {
// 新上榜板块
result.sortChange = results.length - result.sort;
result.trendValueChange = result.trendValue;
}
}
logger.info(`[Momentum] 板块动量计算完成,共 ${results.length} 个板块`);
return results;
} catch (error) {
logger.error(`[Momentum] 计算板块动量失败:`, error);
throw error;
}
}
/**
*
*/
async saveMomentumResults(
tradeDay: Date,
type: MomentumPeriod,
stockResults: StockMomentumResult[],
sectorResults: SectorMomentumResult[]
): Promise<void> {
logger.info(`[Momentum] 保存动量计算结果: ${tradeDay.toISOString().split('T')[0]}, 类型: ${type}`);
try {
// 使用事务保存
await prisma.$transaction(async (tx) => {
// 清除旧数据
await tx.stockMomentum.deleteMany({
where: {
tradeDay,
type,
},
});
await tx.sectorMomentum.deleteMany({
where: {
tradeDay,
type,
},
});
// 保存动量个股
if (stockResults.length > 0) {
await tx.stockMomentum.createMany({
data: stockResults.map(s => ({
stockCode: s.stockCode,
tradeDay: s.tradeDay,
sort: s.sort,
type: s.type,
name: s.name,
blemind2: s.blemind2,
blemind3: s.blemind3,
open: s.open,
close: s.close,
differrange: s.differrange,
differrange10: s.differrange10,
differrange20: s.differrange20,
differrange60: s.differrange60,
backdifferrange10: s.backdifferrange10,
backdifferrange20: s.backdifferrange20,
backdifferrange60: s.backdifferrange60,
tradeDays: s.tradeDays,
agenciesHold: s.agenciesHold,
})),
});
}
// 保存板块动量
if (sectorResults.length > 0) {
await tx.sectorMomentum.createMany({
data: sectorResults.map(s => ({
tradeDay: s.tradeDay,
blemind2: s.blemind2,
stocksCount: s.stocksCount,
totalStocks: s.totalStocks,
trendValue: s.trendValue,
trendValueChange: s.trendValueChange ?? 0,
sort: s.sort,
sortChange: s.sortChange ?? 0,
type: s.type,
lastTrendValue: s.lastTrendValue ?? null,
lastSort: s.lastSort ?? null,
})),
});
}
});
logger.info(`[Momentum] 保存完成: 动量个股 ${stockResults.length} 只, 板块 ${sectorResults.length}`);
} catch (error) {
logger.error(`[Momentum] 保存动量结果失败:`, error);
throw error;
}
}
/**
*
* @param tradeDay
* @param type
*/
async executeFullCalculation(tradeDay: Date, type?: MomentumPeriod): Promise<void> {
logger.info(`[Momentum] 开始执行完整动量计算: ${tradeDay.toISOString().split('T')[0]}`);
const periodsToCalculate = type ? [type] : MOMENTUM_PERIODS;
for (const period of periodsToCalculate) {
try {
// 1. 筛选动量个股
const momentumStocks = await this.selectMomentumStocks(tradeDay, period);
// 2. 计算板块动量
const sectorMomentum = await this.calculateSectorMomentum(tradeDay, period, momentumStocks);
// 3. 保存结果
await this.saveMomentumResults(tradeDay, period, momentumStocks, sectorMomentum);
logger.info(`[Momentum] ${period}日动量计算完成`);
} catch (error) {
logger.error(`[Momentum] ${period}日动量计算失败:`, error);
// 继续计算其他周期
}
}
logger.info(`[Momentum] 完整动量计算完成`);
}
/**
* N
*/
private async getTradeDateBefore(tradeDay: Date, days: number): Promise<Date | null> {
try {
// 获取当前日期之前的N个交易日
const tradeDates = await prisma.tradeDate.findMany({
where: {
date: { lt: tradeDay },
isTrade: true,
},
orderBy: { date: 'desc' },
take: days,
select: { date: true },
});
if (tradeDates.length < days) {
return null;
}
return tradeDates[tradeDates.length - 1].date;
} catch (error) {
logger.error(`[Momentum] 获取交易日期失败:`, error);
return null;
}
}
/**
*
*/
private async getLastTradeDay(tradeDay: Date): Promise<Date | null> {
try {
const lastDate = await prisma.tradeDate.findFirst({
where: {
date: { lt: tradeDay },
isTrade: true,
},
orderBy: { date: 'desc' },
select: { date: true },
});
return lastDate?.date || null;
} catch (error) {
logger.error(`[Momentum] 获取上一交易日失败:`, error);
return null;
}
}
/**
*
*/
async getMomentumStocks(
tradeDay: Date,
type: number,
blemind2?: string,
limit: number = 100
): Promise<any[]> {
try {
const where: any = {
tradeDay,
type,
};
if (blemind2) {
where.blemind2 = blemind2;
}
const stocks = await prisma.stockMomentum.findMany({
where,
orderBy: { sort: 'asc' },
take: limit,
});
return stocks;
} catch (error) {
logger.error(`[Momentum] 获取动量个股失败:`, error);
return [];
}
}
/**
*
*/
async getSectorMomentum(
tradeDay: Date,
type: number,
limit: number = 50
): Promise<any[]> {
try {
const sectors = await prisma.sectorMomentum.findMany({
where: {
tradeDay,
type,
},
orderBy: { sort: 'asc' },
take: limit,
});
return sectors;
} catch (error) {
logger.error(`[Momentum] 获取板块动量失败:`, error);
return [];
}
}
}
export const momentumCalculationService = new MomentumCalculationService();

@ -0,0 +1,476 @@
/**
*
*
* 1. blemind2/blemind3
* 2.
* 3.
*/
import prisma from '../config/database';
import logger from '../utils/logger';
import axios from 'axios';
// 统一数据服务配置
const DATA_SERVICE_URL = process.env.DATA_SERVICE_URL || 'http://localhost:8080';
const DATA_SERVICE_API_KEY = process.env.DATA_SERVICE_API_KEY || '';
// 批量处理配置
const BATCH_SIZE = 100;
export class StockDataSyncService {
/**
*
*
*/
async syncStockBasics(): Promise<{ success: number; failed: number }> {
logger.info('[DataSync] 开始同步股票基础信息');
try {
// 从数据服务获取股票基础信息
const response = await axios.get(`${DATA_SERVICE_URL}/api/stocks/basics`, {
headers: this.getHeaders(),
timeout: 60000,
});
const stocks = response.data?.data || [];
logger.info(`[DataSync] 获取到 ${stocks.length} 只股票基础信息`);
let success = 0;
let failed = 0;
// 批量处理
for (let i = 0; i < stocks.length; i += BATCH_SIZE) {
const batch = stocks.slice(i, i + BATCH_SIZE);
for (const stock of batch) {
try {
await this.upsertStockBasic(stock);
success++;
} catch (error) {
logger.error(`[DataSync] 保存股票失败 ${stock.code}:`, error);
failed++;
}
}
logger.info(`[DataSync] 已处理 ${Math.min(i + BATCH_SIZE, stocks.length)}/${stocks.length}`);
}
logger.info(`[DataSync] 股票基础信息同步完成: 成功 ${success}, 失败 ${failed}`);
return { success, failed };
} catch (error) {
logger.error('[DataSync] 同步股票基础信息失败:', error);
throw error;
}
}
/**
*
*/
private async upsertStockBasic(stock: any): Promise<void> {
const {
code,
name,
blemind2,
blemind3,
listDate,
tradeDays,
agenciesHold,
market,
industry,
} = stock;
// 解析上市日期
let listDateObj: Date | null = null;
if (listDate) {
listDateObj = new Date(listDate);
if (isNaN(listDateObj.getTime())) {
listDateObj = null;
}
}
await prisma.stockBasic.upsert({
where: { code },
update: {
name,
blemind2,
blemind3,
listDate: listDateObj,
tradeDays: tradeDays || 0,
agenciesHold: agenciesHold ? parseFloat(agenciesHold) : null,
market,
industry,
},
create: {
code,
name,
blemind2,
blemind3,
listDate: listDateObj,
tradeDays: tradeDays || 0,
agenciesHold: agenciesHold ? parseFloat(agenciesHold) : null,
market,
industry,
},
});
}
/**
*
* @param tradeDay
*/
async syncDailyQuotes(tradeDay: Date): Promise<{ success: number; failed: number }> {
const dateStr = tradeDay.toISOString().split('T')[0];
logger.info(`[DataSync] 开始同步日行情数据: ${dateStr}`);
try {
// 从数据服务获取日行情数据
const response = await axios.get(`${DATA_SERVICE_URL}/api/quotes/daily`, {
params: { tradeDay: dateStr },
headers: this.getHeaders(),
timeout: 120000,
});
const quotes = response.data?.data || [];
logger.info(`[DataSync] 获取到 ${quotes.length} 条日行情数据`);
let success = 0;
let failed = 0;
// 批量处理
for (let i = 0; i < quotes.length; i += BATCH_SIZE) {
const batch = quotes.slice(i, i + BATCH_SIZE);
for (const quote of batch) {
try {
await this.upsertDailyQuote(quote, tradeDay);
success++;
} catch (error) {
logger.error(`[DataSync] 保存行情失败 ${quote.code}:`, error);
failed++;
}
}
if ((i / BATCH_SIZE) % 10 === 0) {
logger.info(`[DataSync] 已处理 ${Math.min(i + BATCH_SIZE, quotes.length)}/${quotes.length}`);
}
}
logger.info(`[DataSync] 日行情数据同步完成: 成功 ${success}, 失败 ${failed}`);
return { success, failed };
} catch (error) {
logger.error(`[DataSync] 同步日行情数据失败 ${dateStr}:`, error);
throw error;
}
}
/**
*
*/
private async upsertDailyQuote(quote: any, tradeDay: Date): Promise<void> {
const {
code,
open,
close,
high,
low,
volume,
amount,
differrange,
isLimit,
isDrop,
} = quote;
await prisma.stockDailyQuote.upsert({
where: {
stockCode_tradeDay: {
stockCode: code,
tradeDay,
},
},
update: {
open: parseFloat(open) || 0,
close: parseFloat(close) || 0,
high: parseFloat(high) || 0,
low: parseFloat(low) || 0,
volume: BigInt(volume || 0),
amount: BigInt(amount || 0),
differrange: parseFloat(differrange) || 0,
isLimit: !!isLimit,
isDrop: !!isDrop,
},
create: {
stockCode: code,
tradeDay,
open: parseFloat(open) || 0,
close: parseFloat(close) || 0,
high: parseFloat(high) || 0,
low: parseFloat(low) || 0,
volume: BigInt(volume || 0),
amount: BigInt(amount || 0),
differrange: parseFloat(differrange) || 0,
isLimit: !!isLimit,
isDrop: !!isDrop,
},
});
}
/**
*
* @param year
*/
async syncTradeDates(year: number): Promise<{ success: number; failed: number }> {
logger.info(`[DataSync] 开始同步交易日历: ${year}`);
try {
// 从数据服务获取交易日历
const response = await axios.get(`${DATA_SERVICE_URL}/api/trade-dates`, {
params: { year },
headers: this.getHeaders(),
timeout: 30000,
});
const dates = response.data?.data || [];
logger.info(`[DataSync] 获取到 ${dates.length} 个交易日`);
let success = 0;
let failed = 0;
for (const dateInfo of dates) {
try {
await this.upsertTradeDate(dateInfo);
success++;
} catch (error) {
logger.error(`[DataSync] 保存交易日失败:`, error);
failed++;
}
}
logger.info(`[DataSync] 交易日历同步完成: 成功 ${success}, 失败 ${failed}`);
return { success, failed };
} catch (error) {
logger.error(`[DataSync] 同步交易日历失败:`, error);
throw error;
}
}
/**
*
*/
private async upsertTradeDate(dateInfo: any): Promise<void> {
const { date, week, isTrade } = dateInfo;
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) {
throw new Error(`无效的日期格式: ${date}`);
}
await prisma.tradeDate.upsert({
where: { date: dateObj },
update: {
week: week || dateObj.getDay(),
isTrade: !!isTrade,
},
create: {
date: dateObj,
week: week || dateObj.getDay(),
isTrade: !!isTrade,
},
});
}
/**
*
* @param tradeDay
*/
async syncLimitStocks(tradeDay: Date): Promise<{ success: number; failed: number }> {
const dateStr = tradeDay.toISOString().split('T')[0];
logger.info(`[DataSync] 开始同步涨停跌停数据: ${dateStr}`);
try {
// 从数据服务获取涨停跌停数据
const response = await axios.get(`${DATA_SERVICE_URL}/api/stocks/limits`, {
params: { tradeDay: dateStr },
headers: this.getHeaders(),
timeout: 60000,
});
const limits = response.data?.data || [];
logger.info(`[DataSync] 获取到 ${limits.length} 条涨跌停数据`);
let success = 0;
let failed = 0;
for (const limit of limits) {
try {
await prisma.stockLimit.upsert({
where: {
stockCode_tradeDay: {
stockCode: limit.code,
tradeDay,
},
},
update: {
blemind2: limit.blemind2,
isLimit: !!limit.isLimit,
isDrop: !!limit.isDrop,
},
create: {
stockCode: limit.code,
tradeDay,
blemind2: limit.blemind2,
isLimit: !!limit.isLimit,
isDrop: !!limit.isDrop,
},
});
success++;
} catch (error) {
logger.error(`[DataSync] 保存涨跌停数据失败 ${limit.code}:`, error);
failed++;
}
}
logger.info(`[DataSync] 涨跌停数据同步完成: 成功 ${success}, 失败 ${failed}`);
return { success, failed };
} catch (error) {
logger.error(`[DataSync] 同步涨跌停数据失败:`, error);
throw error;
}
}
/**
*
* @param tradeDay
*/
async syncNewRecords(tradeDay: Date): Promise<{ success: number; failed: number }> {
const dateStr = tradeDay.toISOString().split('T')[0];
logger.info(`[DataSync] 开始同步新高新低数据: ${dateStr}`);
try {
// 从数据服务获取新高新低数据
const response = await axios.get(`${DATA_SERVICE_URL}/api/stocks/new-records`, {
params: { tradeDay: dateStr },
headers: this.getHeaders(),
timeout: 60000,
});
const records = response.data?.data || [];
logger.info(`[DataSync] 获取到 ${records.length} 条新高新低数据`);
let success = 0;
let failed = 0;
for (const record of records) {
try {
await prisma.stockNewRecord.upsert({
where: {
stockCode_tradeDay: {
stockCode: record.code,
tradeDay,
},
},
update: {
blemind2: record.blemind2,
isHigh: !!record.isHigh,
isLow: !!record.isLow,
daysToHigh: record.daysToHigh || 0,
daysToLow: record.daysToLow || 0,
},
create: {
stockCode: record.code,
tradeDay,
blemind2: record.blemind2,
isHigh: !!record.isHigh,
isLow: !!record.isLow,
daysToHigh: record.daysToHigh || 0,
daysToLow: record.daysToLow || 0,
},
});
success++;
} catch (error) {
logger.error(`[DataSync] 保存新高新低数据失败 ${record.code}:`, error);
failed++;
}
}
logger.info(`[DataSync] 新高新低数据同步完成: 成功 ${success}, 失败 ${failed}`);
return { success, failed };
} catch (error) {
logger.error(`[DataSync] 同步新高新低数据失败:`, error);
throw error;
}
}
/**
*
* @param tradeDay
*/
async executeFullSync(tradeDay: Date): Promise<Record<string, any>> {
const dateStr = tradeDay.toISOString().split('T')[0];
logger.info(`[DataSync] 开始执行完整数据同步: ${dateStr}`);
const results: Record<string, any> = {
tradeDay: dateStr,
startTime: new Date().toISOString(),
};
try {
// 1. 同步股票基础信息(如果数据库为空)
const stockCount = await prisma.stockBasic.count();
if (stockCount === 0) {
results.stockBasics = await this.syncStockBasics();
}
// 2. 同步交易日历(如果需要)
const year = tradeDay.getFullYear();
const tradeDateCount = await prisma.tradeDate.count({
where: {
date: {
gte: new Date(year, 0, 1),
lt: new Date(year + 1, 0, 1),
},
},
});
if (tradeDateCount === 0) {
results.tradeDates = await this.syncTradeDates(year);
}
// 3. 同步日行情数据
results.dailyQuotes = await this.syncDailyQuotes(tradeDay);
// 4. 同步涨停跌停数据
results.limitStocks = await this.syncLimitStocks(tradeDay);
// 5. 同步新高新低数据
results.newRecords = await this.syncNewRecords(tradeDay);
results.endTime = new Date().toISOString();
results.status = 'completed';
logger.info(`[DataSync] 完整数据同步完成: ${dateStr}`);
} catch (error) {
results.endTime = new Date().toISOString();
results.status = 'failed';
results.error = (error as Error).message;
logger.error(`[DataSync] 完整数据同步失败:`, error);
}
return results;
}
/**
*
*/
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (DATA_SERVICE_API_KEY) {
headers['X-API-Key'] = DATA_SERVICE_API_KEY;
}
return headers;
}
}
export const stockDataSyncService = new StockDataSyncService();

@ -18,7 +18,7 @@ import {
Layers,
Zap
} from 'lucide-react';
import { adminApi, createSyncTaskWebSocket, type DataCheckItem, type DataCheckSummary, type SyncTask } from '@/services/adminApi';
import { adminApi, pollSyncTaskStatus, type DataCheckItem, type DataCheckSummary, type SyncTask } from '@/services/adminApi';
import { Settings } from 'lucide-react';
interface BufferOptions {
@ -46,7 +46,7 @@ export default function DataCheck() {
autoCalculate: true,
});
const wsRef = useRef<WebSocket | null>(null);
const pollCancelRef = useRef<(() => void) | null>(null);
const checkIntervalRef = useRef<number | null>(null);
const checkTimeoutRef = useRef<number | null>(null);
const [checkTimeout, setCheckTimeout] = useState(false);
@ -86,39 +86,36 @@ export default function DataCheck() {
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current);
}
if (wsRef.current) {
wsRef.current.close();
if (pollCancelRef.current) {
pollCancelRef.current();
}
};
}, [fetchDataStatus, buffering]);
// 连接 WebSocket 获取实时进度
const connectWebSocket = useCallback((taskId: string) => {
if (wsRef.current) {
wsRef.current.close();
// 轮询获取同步任务进度(替代 WebSocket
const startPollTaskStatus = useCallback((taskId: string) => {
// 取消之前的轮询
if (pollCancelRef.current) {
pollCancelRef.current();
}
const ws = createSyncTaskWebSocket(taskId);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setCurrentTask(data);
if (data.status === 'completed' || data.status === 'failed') {
setBuffering(false);
setTimeout(() => fetchDataStatus(), 1000);
// 开始新的轮询
const cancelPoll = pollSyncTaskStatus(
taskId,
(task) => {
setCurrentTask(task);
if (task.status === 'completed' || task.status === 'failed') {
setBuffering(false);
setTimeout(() => fetchDataStatus(), 1000);
}
},
(error) => {
console.error('Poll task status error:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket closed');
};
);
wsRef.current = ws;
pollCancelRef.current = cancelPoll;
}, [fetchDataStatus]);
// 执行数据检查
@ -182,8 +179,8 @@ export default function DataCheck() {
types: bufferOptions.types.filter(t => t !== 'momentum') as ('stock' | 'sector' | 'kline')[],
});
// 连接 WebSocket 获取实时进度
connectWebSocket(result.taskId);
// 轮询获取同步进度
startPollTaskStatus(result.taskId);
// 同时启动动量计算(如果选中)
if (bufferOptions.autoCalculate && bufferOptions.types.includes('momentum')) {
@ -223,7 +220,7 @@ export default function DataCheck() {
break;
}
connectWebSocket(result.taskId);
startPollTaskStatus(result.taskId);
} catch (error: any) {
console.error('Buffer failed:', error);
setBuffering(false);

@ -12,7 +12,7 @@ import {
Calendar,
Settings
} from 'lucide-react';
import { adminApi, createSyncTaskWebSocket } from '@/services/adminApi';
import { adminApi, pollSyncTaskStatus } from '@/services/adminApi';
interface ImportTask {
id: string;
@ -42,7 +42,7 @@ export default function DataImport() {
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const pollCancelRef = useRef<(() => void) | null>(null);
useEffect(() => {
fetchTasks();
@ -50,8 +50,8 @@ export default function DataImport() {
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close();
if (pollCancelRef.current) {
pollCancelRef.current();
}
};
}, []);
@ -119,8 +119,8 @@ export default function DataImport() {
setTasks(prev => [newTask, ...prev]);
// 连接 WebSocket 获取进度
connectWebSocket(result.taskId, newTask.id);
// 轮询获取同步进度
startPollTaskStatus(result.taskId, newTask.id);
} catch (error: any) {
alert('上传失败: ' + error.message);
} finally {
@ -128,37 +128,35 @@ export default function DataImport() {
}
};
const connectWebSocket = (serverTaskId: string, localTaskId: string) => {
if (wsRef.current) {
wsRef.current.close();
const startPollTaskStatus = (serverTaskId: string, localTaskId: string) => {
// 取消之前的轮询
if (pollCancelRef.current) {
pollCancelRef.current();
}
const ws = createSyncTaskWebSocket(serverTaskId);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setTasks(prev => prev.map(t =>
t.id === localTaskId ? {
...t,
status: data.status,
progress: data.progress,
totalRecords: data.totalRecords || t.totalRecords,
importedRecords: data.processedRecords || t.importedRecords,
errorMessage: data.error,
} : t
));
if (data.status === 'completed' || data.status === 'failed') {
ws.close();
// 开始新的轮询
const cancelPoll = pollSyncTaskStatus(
serverTaskId,
(task) => {
setTasks(prev => prev.map(t =>
t.id === localTaskId ? {
...t,
status: task.status,
progress: task.progress || t.progress,
totalRecords: task.totalRecords || t.totalRecords,
importedRecords: task.processedRecords || t.importedRecords,
errorMessage: task.error,
} : t
));
// 任务完成或失败时停止轮询pollSyncTaskStatus 内部会自动停止)
},
(error) => {
console.error('Poll task status error:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
);
wsRef.current = ws;
pollCancelRef.current = cancelPoll;
};
const handleDeleteTask = (taskId: string) => {

@ -442,12 +442,48 @@ export const adminApi = {
},
};
// WebSocket 连接用于实时获取同步进度
export function createSyncTaskWebSocket(taskId: string): WebSocket {
const wsUrl = (import.meta.env.VITE_WS_URL || 'ws://localhost:3000').replace(/^http/, 'ws');
const token = localStorage.getItem('token');
return new WebSocket(`${wsUrl}/ws/sync-tasks/${taskId}?token=${token}`);
// 轮询同步任务状态(替代 WebSocket
export function pollSyncTaskStatus(
taskId: string,
onUpdate: (task: SyncTask) => void,
onError?: (error: Error) => void
): () => void {
let isActive = true;
let timeoutId: NodeJS.Timeout | null = null;
const poll = async () => {
if (!isActive) return;
try {
const task = await adminApi.getSyncTask(taskId);
onUpdate(task);
// 任务完成或失败时停止轮询
if (task.status === 'completed' || task.status === 'failed') {
return;
}
// 继续轮询
timeoutId = setTimeout(poll, 2000);
} catch (error) {
if (onError) {
onError(error as Error);
}
// 出错后继续轮询,但增加间隔
timeoutId = setTimeout(poll, 5000);
}
};
// 开始轮询
poll();
// 返回取消函数
return () => {
isActive = false;
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}
// Ensure export

@ -1,366 +0,0 @@
# AKShare HTTP API 部署指南
## 简介
AKShare HTTP API 是基于 [AKShare](https://www.akshare.xyz/) 开源财经数据库构建的 HTTP 服务,为股动量系统提供实时股票行情、历史 K 线、板块数据等数据支持。
## 目录
- [环境要求](#环境要求)
- [部署方式](#部署方式)
- [方式一Python 直接运行(推荐开发环境)](#方式一python-直接运行推荐开发环境)
- [方式二Docker 部署(推荐生产环境)](#方式二docker-部署推荐生产环境)
- [配置说明](#配置说明)
- [验证安装](#验证安装)
- [常见问题](#常见问题)
## 环境要求
### 基础环境
- **Python**: 3.9 或更高版本
- **操作系统**: Windows / macOS / Linux
- **内存**: 建议 2GB 以上
- **网络**: 可访问东方财富、新浪财经等数据源
### Python 依赖
```text
akshare >= 1.15.0
fastapi >= 0.100.0
uvicorn >= 0.23.0
pandas >= 2.0.0
numpy >= 1.24.0
```
## 部署方式
### 方式一Python 直接运行(推荐开发环境)
#### 1. 安装依赖
```bash
# 创建虚拟环境(推荐)
python -m venv venv
# Windows 激活虚拟环境
venv\Scripts\activate
# macOS/Linux 激活虚拟环境
source venv/bin/activate
# 安装依赖
pip install akshare fastapi uvicorn pandas numpy
```
#### 2. 准备代码
项目已包含 AKShare 服务代码,位于 `app/akshare/` 目录:
```
app/akshare/
├── main.py # HTTP API 服务主文件
├── start.py # 启动脚本
└── Dockerfile # Docker 构建文件
```
#### 3. 启动服务
**方法 A使用启动脚本Windows 推荐)**
```powershell
cd app/akshare
python start.py
```
**方法 B使用 Python 模块**
```bash
cd app/akshare
python -m uvicorn main:app --host 0.0.0.0 --port 8000
```
**方法 C使用 AKShare 内置命令(如果已安装)**
```bash
akshare --host 0.0.0.0 --port 8000
```
启动成功后,控制台会显示:
```
INFO: Started server process [xxxxx]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to stop)
```
#### 4. 后台运行Windows
创建批处理文件 `start-akshare.bat`
```batch
@echo off
cd /d D:\workspace\MomentumLab\app\akshare
start /min python start.py
```
双击运行即可后台启动。
### 方式二Docker 部署(推荐生产环境)
#### 1. 构建镜像
```bash
cd app/akshare
docker build -t akshare-http:latest .
```
#### 2. 运行容器
```bash
docker run -d \
--name akshare-http \
-p 8000:8000 \
--restart always \
akshare-http:latest
```
#### 3. 查看日志
```bash
docker logs -f akshare-http
```
#### 4. 使用 Docker Compose
`docker-compose.yml` 中添加:
```yaml
version: '3.8'
services:
akshare:
build:
context: ./app/akshare
dockerfile: Dockerfile
container_name: akshare-http
ports:
- "8000:8000"
restart: always
networks:
- app-network
networks:
app-network:
driver: bridge
```
启动:
```bash
docker-compose up -d akshare
```
## 配置说明
### 后端配置
在后端 `.env` 文件中配置 AKShare 地址:
```bash
# app/backend/.env
# 本地开发AKShare 在宿主机运行)
AKSHARE_URL=http://host.docker.internal:8000
# 或者使用本地地址
AKSHARE_URL=http://localhost:8000
# 远程服务器
AKSHARE_URL=http://192.168.1.100:8000
```
### 前端配置
在管理后台的「数据源配置」页面可以动态修改 AKShare 地址:
1. 登录管理后台
2. 进入「数据源配置」
3. 点击 AKShare 服务地址旁的编辑图标
4. 输入新地址并保存
> **注意**:前端修改的地址会立即生效,无需重启后端服务。
### 端口修改
如果 8000 端口被占用,可以修改启动端口:
```bash
# 使用 8001 端口
python -m uvicorn main:app --host 0.0.0.0 --port 8001
```
同时更新后端配置:
```bash
AKSHARE_URL=http://localhost:8001
```
## 验证安装
### 1. 健康检查
浏览器访问:
```
http://localhost:8000/
```
返回:
```json
{
"status": "healthy",
"service": "AKShare HTTP API",
"version": "1.0.0"
}
```
### 2. 测试数据接口
浏览器访问:
```
http://localhost:8000/stock_zh_a_spot
```
应该返回 A 股实时行情数据JSON 格式)。
### 3. 后端连接测试
在管理后台点击「测试连接」按钮,验证后端是否能正常连接到 AKShare。
## 常用 API 接口
| 接口 | 说明 | 示例 |
|------|------|------|
| `GET /` | 健康检查 | `curl http://localhost:8000/` |
| `GET /stock_zh_a_spot` | A 股实时行情 | `curl http://localhost:8000/stock_zh_a_spot` |
| `GET /stock_zh_a_hist` | 历史 K 线数据 | `curl "http://localhost:8000/stock_zh_a_hist?symbol=000001&period=daily"` |
| `GET /stock_zh_index_spot` | 指数实时行情 | `curl http://localhost:8000/stock_zh_index_spot` |
## 常见问题
### Q1: 启动时报错 `ModuleNotFoundError: No module named 'akshare'`
**原因**: 未安装 AKShare 或虚拟环境未激活
**解决**:
```bash
# 安装 AKShare
pip install akshare
# 或安装所有依赖
pip install -r requirements.txt
```
### Q2: 后端提示 "AKShare 连接失败"
**原因**:
1. AKShare 服务未启动
2. 地址配置不正确
3. 防火墙/网络问题
**解决**:
1. 确认 AKShare 服务已启动:
```bash
curl http://localhost:8000/
```
2. 检查后端配置是否正确:
- Docker 环境使用 `http://host.docker.internal:8000`
- 本地环境使用 `http://localhost:8000`
3. 检查防火墙是否允许 8000 端口
### Q3: 数据返回为空或格式错误
**原因**: AKShare 数据源更新,接口格式变化
**解决**:
```bash
# 更新 AKShare 到最新版本
pip install --upgrade akshare
```
### Q4: Windows 下中文显示乱码
**解决**:
```powershell
# 设置 UTF-8 编码
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# 或在 CMD 中
chcp 65001
```
### Q5: 如何开机自启动
**Windows**:
1. 创建批处理文件 `start-akshare.bat`
2. 按 `Win + R`,输入 `shell:startup`
3. 将批处理文件放入启动文件夹
**Linux (systemd)**:
创建 `/etc/systemd/system/akshare.service`
```ini
[Unit]
Description=AKShare HTTP API
After=network.target
[Service]
Type=simple
User=your-user
WorkingDirectory=/path/to/app/akshare
ExecStart=/path/to/python -m uvicorn main:app --host 0.0.0.0 --port 8000
Restart=always
[Install]
WantedBy=multi-user.target
```
启用:
```bash
sudo systemctl enable akshare
sudo systemctl start akshare
```
## 更新日志
### v1.0.0
- 基础 HTTP API 服务
- 支持 A 股实时行情
- 支持历史 K 线数据
- 支持板块数据
- 支持指数数据
## 相关链接
- [AKShare 官方文档](https://www.akshare.xyz/)
- [FastAPI 文档](https://fastapi.tiangolo.com/)
- [项目 GitHub](https://github.com/your-repo)
---
如有问题,请提交 Issue 或联系管理员。

@ -0,0 +1,583 @@
# Stock-System 数据结构关系文档
## 1. 文档简介
本文档详细梳理了 RuoYi-Vue 项目中 stock-system 模块的所有写入数据库的数据结构及其相互关系。该模块主要用于管理和分析股票相关数据,包括基础信息、交易数据、财务数据和分析指标等。
## 2. 核心数据实体
### 2.1 股票基础信息 (StockBasic)
- **表名**: `stock_basis`
- **核心字段**:
- `id`: 主键
- `code`: 股票代码 (关联其他表的关键字段)
- `name`: 股票名称
- `blemind2`: 所属东财行业指数2级
- `blemind3`: 所属东财行业指数3级
- `listdate`: 首发上市日期
### 2.2 股票财务数据 (StockFinancial)
- **表名**: `stock_financial`
- **核心字段**:
- `id`: 主键
- `code`: 股票代码 (关联 StockBasic)
- `period`: 报告期
- `jlrtbzzl`: 净利润同比增长率
- `jlrhbzzl`: 净利润环比增长率
- `jzcsylroe`: 净资产收益率ROE
- `epsbasic`: 每股收益EPS
- `jlr`: 净利润
- `jbmgsy`: 基本每股收益
- `mgjzc`: 每股净资产BPS
### 2.3 股票指数数据 (StockIndex)
- **表名**: `stock_index`
- **核心字段**:
- `id`: 主键
- `code`: 指数代码
- `name`: 指数名称
- `tradeDay`: 交易日期
- `open`: 开盘价
- `close`: 收盘价
- `high`: 最高价
- `low`: 最低价
- `differrange`: 涨跌幅
- `volume`: 成交量
- `amount`: 成交额
- `limitupnum`: 涨停家数
- `limitdownnum`: 跌停家数
- `mv`: 总市值
- `pettm`: 市盈率PE
### 2.4 股票交易数据 (Stocks)
- **表名**: `stocks`
- **核心字段**:
- `id`: 主键
- `code`: 股票代码 (关联 StockBasic)
- `name`: 股票名称
- `blemind2`: 所属东财行业指数2级
- `blemind3`: 所属东财行业指数3级
- `listdate`: 首发上市日期
- `tradeDay`: 交易日期
- `open`: 开盘价
- `close`: 收盘价
- `high`: 最高价
- `low`: 最低价
- `differrange`: 当日涨跌幅
- `volumn`: 成交量
- `amount`: 成交额
- `differrange3/5/10/15/20/30/60`: 不同周期涨跌幅
- `islimit`: 是否涨停
- `isdrop`: 是否跌停
### 2.5 股票趋势数据 (StocksInTrend)
- **表名**: `stocks_in_trend`
- **核心字段**:
- `id`: 主键
- `code`: 股票代码 (关联 StockBasic)
- `tradeDay`: 交易日期
- `sort`: 排名
- `type`: 动量数据类型 (10日、20日)
- `name`: 股票名称
- `blemind2`: 所属东财行业指数2级
- `blemind3`: 所属东财行业指数3级
- `open`: 开盘价
- `close`: 收盘价
- `differrange`: 当日涨跌幅
- `differrange10/20/60`: 不同周期涨跌幅
- `backdifferrange10/20/60`: 不同周期最大回撤
### 2.6 股票涨跌停数据 (StocksLimit)
- **表名**: `stocks_limit`
- **核心字段**:
- `id`: 主键
- `code`: 股票代码 (关联 StockBasic)
- `tradeDay`: 交易日期
- `islimit`: 是否涨停
- `isdrop`: 是否跌停
- `blemind2`: 所属东财行业指数2级
### 2.7 股票创新高/新低数据 (StocksNewRecord)
- **表名**: `stocks_new_record`
- **核心字段**:
- `id`: 主键
- `code`: 股票代码 (关联 StockBasic)
- `tradeDay`: 交易日期
- `isHigh`: 是否300天新高
- `isLow`: 是否300天新低
- `blemind2`: 所属东财行业指数2级
### 2.8 交易日数据 (TradeDates)
- **表名**: `trade_dates`
- **核心字段**:
- `date`: 交易日期
- `week`: 周
- `trade`: 是否可交易
### 2.9 行业趋势分析数据 (Trends)
- **表名**: `trends`
- **核心字段**:
- `id`: 主键
- `tradeDay`: 交易日期
- `blemind2`: 所属东财行业指数2级
- `stocksCount`: 动量个股数量
- `trendValue`: 动量值
- `trendValueChange`: 动量值变化
- `sort`: 板块排名
- `sortChange`: 板块排名变化
- `type`: 动量数据类型 (10日、20日)
## 3. 数据结构关系图
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ StockBasic │────>│ StockFinancial │────>│ Stocks │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ StockIndex │ │ StocksInTrend │────>│ Trends │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ TradeDates │ │ StocksLimit │
└─────────────────┘ └─────────────────┘
┌─────────────────┐
│ StocksNewRecord │
└─────────────────┘
```
## 4. 详细关系说明
### 4.1 基础数据层
- **StockBasic**: 核心基础表,存储股票基本信息,是其他表的关联基础
- **TradeDates**: 提供交易日信息,为其他表的时间维度提供参考
### 4.2 交易数据层
- **Stocks**: 存储股票每日交易数据,关联 StockBasic 和 StockFinancial
- **StockIndex**: 存储指数交易数据,反映市场整体情况
- **StockFinancial**: 存储股票财务数据,与 Stocks 表关联
### 4.3 分析数据层
- **StocksInTrend**: 基于 Stocks 表数据,计算股票趋势指标
- **StocksLimit**: 基于 Stocks 表数据,记录股票涨跌停情况
- **StocksNewRecord**: 基于 Stocks 表数据,记录股票创新高/新低情况
- **Trends**: 基于 StocksInTrend 表数据,分析行业趋势
## 5. 核心关键字段关系
### 5.1 股票标识关联
- **code**: 股票代码,贯穿所有股票相关表,是表间关联的核心字段
- **name**: 股票名称,辅助标识股票
### 5.2 时间维度关联
- **tradeDay**: 交易日期,所有交易和分析数据的时间维度
- **period**: 财务数据的报告期
- **date**: 交易日历日期
### 5.3 行业分类关联
- **blemind2**: 东财行业指数2级分类用于行业分析
- **blemind3**: 东财行业指数3级分类更细分的行业分析
### 5.4 分析指标关联
- **differrange**: 涨跌幅,核心分析指标
- **type**: 动量数据类型,区分不同周期的分析数据
- **sort**: 排名,用于趋势分析
- **trendValue**: 动量值,行业趋势分析的核心指标
## 6. 数据存储特点
### 6.1 时间序列数据
- 所有交易和分析数据都按交易日存储,形成时间序列
- 支持多周期分析3日、5日、10日、15日、20日、30日、60日
### 6.2 多维度分析
- 提供了不同周期的涨跌幅数据
- 支持按行业分类进行分析
- 包含多种技术指标(如动量值、最大回撤等)
### 6.3 数据层次结构
- **基础数据层**: 存储静态基础信息
- **交易数据层**: 存储动态交易数据
- **分析数据层**: 基于基础和交易数据生成的分析结果
### 6.4 关联完整性
- 通过股票代码 (`code`) 和交易日期 (`tradeDay`) 建立了完整的数据关联体系
- 各表之间的关联关系清晰,数据流向明确
## 7. 数据流向
1. **基础数据流入**:
- StockBasic 表导入股票基础信息
- TradeDates 表导入交易日历
2. **交易数据流入**:
- Stocks 表导入每日交易数据
- StockIndex 表导入指数数据
- StockFinancial 表导入财务报表数据
3. **分析数据生成**:
- 基于 Stocks 表数据计算生成 StocksInTrend、StocksLimit、StocksNewRecord
- 基于 StocksInTrend 表数据计算生成 Trends
## 8. 总结
Stock-System 模块采用了层次化的数据结构设计,从基础数据、交易数据到分析数据,形成了完整的数据链路。通过股票代码和交易日期两个核心维度,将各个表关联起来,构建了一个功能完备的股票数据管理系统。
该系统不仅存储了基础的股票信息和交易数据,还通过计算生成了丰富的分析指标,为股票分析和投资决策提供了全面的数据支持。数据结构设计合理,关联关系清晰,能够满足股票数据管理和分析的各种需求。
## 9. 接口与实现文件
### 9.1 股票基础信息 (StockBasic) 相关接口
#### 后端接口
| 接口路径 | 方法 | 功能描述 | 实现文件 |
|---------|------|---------|--------|
| /stocksystem/stockbasic/list | GET | 查询基础数据列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockBasicController.java |
| /stocksystem/stockbasic/export | POST | 导出基础数据列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockBasicController.java |
| /stocksystem/stockbasic/{id} | GET | 获取基础数据详细信息 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockBasicController.java |
| /stocksystem/stockbasic | POST | 新增基础数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockBasicController.java |
| /stocksystem/stockbasic | PUT | 修改基础数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockBasicController.java |
| /stocksystem/stockbasic/{ids} | DELETE | 删除基础数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockBasicController.java |
**实现类**: stock-system/src/main/java/com/ruoyi/stocksystem/domain/service/impl/StockBasicServiceImpl.java
#### 前端调用
**文件路径**: ruoyi-ui/src/api/stocksystem/stockbasic.js
| 方法名 | 接口路径 | 功能描述 |
|-------|---------|--------|
| listStockbasic | /stocksystem/stockbasic/list | 查询基础数据列表 |
| getStockbasic | /stocksystem/stockbasic/{id} | 查询基础数据详细 |
| addStockbasic | /stocksystem/stockbasic | 新增基础数据 |
| updateStockbasic | /stocksystem/stockbasic | 修改基础数据 |
| delStockbasic | /stocksystem/stockbasic/{id} | 删除基础数据 |
### 9.2 股票财务数据 (StockFinancial) 相关接口
#### 后端接口
| 接口路径 | 方法 | 功能描述 | 实现文件 |
|---------|------|---------|--------|
| /stocksystem/financial/list | GET | 查询A股财务数据列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockFinancialController.java |
| /stocksystem/financial/export | POST | 导出A股财务数据列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockFinancialController.java |
| /stocksystem/financial/{id} | GET | 获取A股财务数据详细信息 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockFinancialController.java |
| /stocksystem/financial | POST | 新增A股财务数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockFinancialController.java |
| /stocksystem/financial | PUT | 修改A股财务数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockFinancialController.java |
| /stocksystem/financial/{ids} | DELETE | 删除A股财务数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockFinancialController.java |
| /stocksystem/financial/importData | POST | 导入财报数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockFinancialController.java |
**实现类**: stock-system/src/main/java/com/ruoyi/stocksystem/domain/service/impl/StockFinancialServiceImpl.java
#### 前端调用
**文件路径**: ruoyi-ui/src/api/stocksystem/financial.js
| 方法名 | 接口路径 | 功能描述 |
|-------|---------|--------|
| listFinancial | /stocksystem/financial/list | 查询A股财务数据列表 |
| getFinancial | /stocksystem/financial/{id} | 查询A股财务数据详细 |
| addFinancial | /stocksystem/financial | 新增A股财务数据 |
| updateFinancial | /stocksystem/financial | 修改A股财务数据 |
| delFinancial | /stocksystem/financial/{id} | 删除A股财务数据 |
### 9.3 股票指数数据 (StockIndex) 相关接口
#### 后端接口
| 接口路径 | 方法 | 功能描述 | 实现文件 |
|---------|------|---------|--------|
| /stocksystem/stockindex/list | GET | 查询指数交易行情列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockIndexController.java |
| /stocksystem/stockindex/export | POST | 导出指数交易行情列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockIndexController.java |
| /stocksystem/stockindex/importData | POST | 导入指数数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockIndexController.java |
| /stocksystem/stockindex/{id} | GET | 获取指数交易行情详细信息 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockIndexController.java |
| /stocksystem/stockindex | POST | 新增指数交易行情 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockIndexController.java |
| /stocksystem/stockindex | PUT | 修改指数交易行情 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockIndexController.java |
| /stocksystem/stockindex/{ids} | DELETE | 删除指数交易行情 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StockIndexController.java |
**实现类**: stock-system/src/main/java/com/ruoyi/stocksystem/domain/service/impl/StockIndexServiceImpl.java
#### 前端调用
**文件路径**: ruoyi-ui/src/api/stocksystem/stockindex.js
| 方法名 | 接口路径 | 功能描述 |
|-------|---------|--------|
| listStockindex | /stocksystem/stockindex/list | 查询指数交易行情列表 |
| getStockindex | /stocksystem/stockindex/{id} | 查询指数交易行情详细 |
| addStockindex | /stocksystem/stockindex | 新增指数交易行情 |
| updateStockindex | /stocksystem/stockindex | 修改指数交易行情 |
| delStockindex | /stocksystem/stockindex/{id} | 删除指数交易行情 |
### 9.4 股票交易数据 (Stocks) 相关接口
#### 后端接口
| 接口路径 | 方法 | 功能描述 | 实现文件 |
|---------|------|---------|--------|
| /stocksystem/stocks/list | GET | 查询行情数据列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/listB | GET | 查询行情数据列表含基础数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/listStrongStocks | GET | 查询强势股列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/listLimitStocks | GET | 查询涨停股列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/listDropStocks | GET | 查询跌停股列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/distribution | GET | 查询涨跌分布 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/stockindestocksdistribution | GET | 查询板块涨跌分布 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/stockHistory | GET | 查询个股历史数据(k线) | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/stockIndexHistory | GET | 查询板块历史数据(k线) | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/groupLimitlist | GET | 查询每日涨跌停板列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/stockQueryData | GET | 获取联想的数据(股票代码) | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/stockNameQueryData | GET | 获取联想的数据(股票名称) | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/stockIndexsNameQueryData | GET | 获取联想的数据(指数名称) | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/getTradeDay | GET | 获取最近的交易日 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/export | POST | 导出行情数据列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
| /stocksystem/stocks/importData | POST | 导入行情数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java |
**实现类**: stock-system/src/main/java/com/ruoyi/stocksystem/domain/service/impl/StocksServiceImpl.java
#### 前端调用
**文件路径**: ruoyi-ui/src/api/stocksystem/stocks.js
| 方法名 | 接口路径 | 功能描述 |
|-------|---------|--------|
| listStocks | /stocksystem/stocks/listB | 查询行情数据列表含基础数据 |
| getStocks | /stocksystem/stocks/{id} | 查询行情数据详细 |
| analysis | /stocksystem/stocks/analysis | 分析行情数据 |
| addStocks | /stocksystem/stocks | 新增行情数据 |
| updateStocks | /stocksystem/stocks | 修改行情数据 |
| delStocks | /stocksystem/stocks/{id} | 删除行情数据 |
### 9.5 股票趋势数据 (StocksInTrend) 相关接口
#### 后端接口
| 接口路径 | 方法 | 功能描述 | 实现文件 |
|---------|------|---------|--------|
| /stocksystem/trendStocks/list | GET | 查询动量个股列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksInTrendController.java |
| /stocksystem/trendStocks/export | POST | 导出动量个股列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksInTrendController.java |
| /stocksystem/trendStocks/{id} | GET | 获取动量个股详细信息 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksInTrendController.java |
| /stocksystem/trendStocks | POST | 新增动量个股 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksInTrendController.java |
| /stocksystem/trendStocks | PUT | 修改动量个股 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksInTrendController.java |
| /stocksystem/trendStocks/{ids} | DELETE | 删除动量个股 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksInTrendController.java |
**实现类**: stock-system/src/main/java/com/ruoyi/stocksystem/domain/service/impl/StocksInTrendServiceImpl.java
#### 前端调用
**文件路径**: ruoyi-ui/src/api/stocksystem/trendStocks.js
| 方法名 | 接口路径 | 功能描述 |
|-------|---------|--------|
| listTrendStocks | /stocksystem/trendStocks/list | 查询动量个股列表 |
| getTrendStocks | /stocksystem/trendStocks/{id} | 查询动量个股详细 |
| addTrendStocks | /stocksystem/trendStocks | 新增动量个股 |
| updateTrendStocks | /stocksystem/trendStocks | 修改动量个股 |
| delTrendStocks | /stocksystem/trendStocks/{id} | 删除动量个股 |
### 9.6 股票涨跌停数据 (StocksLimit) 相关接口
#### 后端接口
| 接口路径 | 方法 | 功能描述 | 实现文件 |
|---------|------|---------|--------|
| /stocksystem/stockslimit/list | GET | 查询每日涨跌停板列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksLimitController.java |
| /stocksystem/stockslimit/grouplist | GET | 查询每日涨跌停板列表(按板块分组) | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksLimitController.java |
| /stocksystem/stockslimit/export | POST | 导出每日涨跌停板列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksLimitController.java |
| /stocksystem/stockslimit/importData | POST | 导入涨跌停数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksLimitController.java |
| /stocksystem/stockslimit/{id} | GET | 获取每日涨跌停板详细信息 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksLimitController.java |
| /stocksystem/stockslimit | POST | 新增每日涨跌停板 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksLimitController.java |
| /stocksystem/stockslimit | PUT | 修改每日涨跌停板 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksLimitController.java |
| /stocksystem/stockslimit/{ids} | DELETE | 删除每日涨跌停板 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksLimitController.java |
**实现类**: stock-system/src/main/java/com/ruoyi/stocksystem/domain/service/impl/StocksLimitServiceImpl.java
#### 前端调用
**文件路径**: ruoyi-ui/src/api/stocksystem/stockslimit.js
| 方法名 | 接口路径 | 功能描述 |
|-------|---------|--------|
| listStockslimit | /stocksystem/stockslimit/list | 查询每日涨跌停板列表 |
| getStockslimit | /stocksystem/stockslimit/{id} | 查询每日涨跌停板详细 |
| addStockslimit | /stocksystem/stockslimit | 新增每日涨跌停板 |
| updateStockslimit | /stocksystem/stockslimit | 修改每日涨跌停板 |
| delStockslimit | /stocksystem/stockslimit/{id} | 删除每日涨跌停板 |
### 9.7 股票创新高/新低数据 (StocksNewRecord) 相关接口
#### 后端接口
| 接口路径 | 方法 | 功能描述 | 实现文件 |
|---------|------|---------|--------|
| /stocksystem/newrecord/list | GET | 查询每日创新高新低列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksNewRecordController.java |
| /stocksystem/newrecord/export | POST | 导出每日创新高新低列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksNewRecordController.java |
| /stocksystem/newrecord/grouplist | GET | 查询每日新高新低列表(按板块分组) | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksNewRecordController.java |
| /stocksystem/newrecord/getAllNewRecords | GET | 查询每日新高新低列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksNewRecordController.java |
| /stocksystem/newrecord/importData | POST | 导入创新高新低数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksNewRecordController.java |
| /stocksystem/newrecord/{id} | GET | 获取每日创新高新低详细信息 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksNewRecordController.java |
| /stocksystem/newrecord | POST | 新增每日创新高新低 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksNewRecordController.java |
| /stocksystem/newrecord | PUT | 修改每日创新高新低 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksNewRecordController.java |
| /stocksystem/newrecord/{ids} | DELETE | 删除每日创新高新低 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksNewRecordController.java |
**实现类**: stock-system/src/main/java/com/ruoyi/stocksystem/domain/service/impl/StocksNewRecordServiceImpl.java
#### 前端调用
**文件路径**: ruoyi-ui/src/api/stocksystem/newrecord.js
| 方法名 | 接口路径 | 功能描述 |
|-------|---------|--------|
| listNewrecord | /stocksystem/newrecord/list | 查询每日创新高新低列表 |
| getNewrecord | /stocksystem/newrecord/{id} | 查询每日创新高新低详细 |
| addNewrecord | /stocksystem/newrecord | 新增每日创新高新低 |
| updateNewrecord | /stocksystem/newrecord | 修改每日创新高新低 |
| delNewrecord | /stocksystem/newrecord/{id} | 删除每日创新高新低 |
### 9.8 交易日数据 (TradeDates) 相关接口
#### 后端接口
| 接口路径 | 方法 | 功能描述 | 实现文件 |
|---------|------|---------|--------|
| /stocksystem/tradedates/list | GET | 查询可交易日期列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TradeDatesController.java |
| /stocksystem/tradedates/export | POST | 导出可交易日期列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TradeDatesController.java |
| /stocksystem/tradedates/{date} | GET | 获取可交易日期详细信息 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TradeDatesController.java |
| /stocksystem/tradedates | POST | 新增可交易日期 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TradeDatesController.java |
| /stocksystem/tradedates | PUT | 修改可交易日期 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TradeDatesController.java |
| /stocksystem/tradedates/{dates} | DELETE | 删除可交易日期 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TradeDatesController.java |
**实现类**: stock-system/src/main/java/com/ruoyi/stocksystem/domain/service/impl/TradeDatesServiceImpl.java
#### 前端调用
**注**: 交易日数据的前端调用主要通过其他模块间接使用,例如在获取最近交易日时调用 /stocksystem/stocks/getTradeDay 接口。
### 9.9 行业趋势分析数据 (Trends) 相关接口
#### 后端接口
| 接口路径 | 方法 | 功能描述 | 实现文件 |
|---------|------|---------|--------|
| /stocksystem/trends/list | GET | 查询动量结果列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/querylist | GET | 查询动量结果列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/querytrendstockslist | GET | 查询动量个股列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/listSection | GET | 查询行业趋势数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/listTradeVolume | GET | 查询行业交易量数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/listStockIndexLimitUp | GET | 查询行业涨停家数数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/listStockIndexLimitDown | GET | 查询行业跌停家数数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/listStockIndexHighRocord | GET | 查询行业创新高数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/listStockIndexLowRocord | GET | 查询行业创新低数据 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/listSectionByBlemind | GET | 查询板块动量值 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/export | POST | 导出动量结果列表 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/{id} | GET | 获取动量结果详细信息 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends | POST | 新增动量结果 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends | PUT | 修改动量结果 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
| /stocksystem/trends/{ids} | DELETE | 删除动量结果 | stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java |
**实现类**: stock-system/src/main/java/com/ruoyi/stocksystem/domain/service/impl/TrendsServiceImpl.java
#### 前端调用
**文件路径**: ruoyi-ui/src/api/stocksystem/trends.js
| 方法名 | 接口路径 | 功能描述 |
|-------|---------|--------|
| listTrends | /stocksystem/trends/list | 查询动量结果列表 |
| listTrendsSection | /stocksystem/trends/listSection | 查询时间段内动量结果列表 |
| listTradeVolume | /stocksystem/trends/listTradeVolume | 查询行业交易量数据 |
| listStockIndexLimitUp | /stocksystem/trends/listStockIndexLimitUp | 查询指数内涨停板数量 |
| listStockIndexLimitDown | /stocksystem/trends/listStockIndexLimitDown | 查询板块内跌停板数量 |
| listStockIndexHighRocord | /stocksystem/trends/listStockIndexHighRocord | 查询创新高板块 |
| listStockIndexLowRocord | /stocksystem/trends/listStockIndexLowRocord | 查询创新低板块 |
| listSectionByBlemind | /stocksystem/trends/listSectionByBlemind | 查询板块动量值 |
| listStockHistory | /stocksystem/stocks/stockHistory | 查询个股历史k线数据 |
| listStockIndexHistory | /stocksystem/stocks/stockIndexHistory | 板块历史k线数据 |
| getTrends | /stocksystem/trends/{id} | 查询动量结果详细 |
| addTrends | /stocksystem/trends | 新增动量结果 |
| updateTrends | /stocksystem/trends | 修改动量结果 |
| delTrends | /stocksystem/trends/{id} | 删除动量结果 |
## 10. Redis 缓存使用分析
### 10.1 缓存键定义
所有缓存键常量定义在 `com.ruoyi.common.constant.Constants` 类中,主要包括:
| 缓存键常量 | 含义 | 过期时间 |
|----------|------|--------|
| `EXPIRED_TIME` | 默认过期时间 | 3天60*60*24*3秒 |
| `TRENDS` | 动量趋势数据 | - |
| `HOME_HIGH_DISTRIBUTE` | 首页新高分布 | - |
| `HOME_LOW_DISTRIBUTE` | 首页新低分布 | - |
| `HOME_TRENDS_STOCKS` | 首页动量趋势个股 | - |
| `TRENDS_SECTION_BLEMIND` | 板块趋势值 | - |
| `HOME_STRONG_STOCKS` | 首页强势个股 | - |
| `HOME_LIMIT_STOCKS` | 首页涨停个股 | - |
| `HOME_DROP_LIMIT_STOCKS` | 首页跌停个股 | - |
| `HOME_STOCKS_DISTRIBUTE` | 首页涨跌分布 | - |
| `STOCKINDEX_STOCKS_DISTRIBUTE` | 板块涨跌分布 | - |
| `STOCK_CODE_QUERY` | 个股代码联想 | - |
| `STOCK_NAME_QUERY` | 个股名称联想 | - |
| `STOCKINDEX_NAME_QUERY` | 板块名称联想 | - |
### 10.2 缓存使用场景
#### 10.2.1 股票交易数据 (Stocks) 缓存
| 接口 | 缓存键 | 功能描述 | 过期时间 |
|-----|-------|---------|--------|
| /stocksystem/stocks/listStrongStocks | HOME_STRONG_STOCKS + 日期 + "_" + 类型 | 查询强势股 | 3天 |
| /stocksystem/stocks/listLimitStocks | HOME_LIMIT_STOCKS + 日期 + "_" + 类型 | 查询涨停股 | 3天 |
| /stocksystem/stocks/listDropStocks | HOME_DROP_LIMIT_STOCKS + 日期 + "_" + 类型 | 查询跌停股 | 3天 |
| /stocksystem/stocks/distribution | HOME_STOCKS_DISTRIBUTE + 日期 + "_" + 类型 | 查询涨跌分布 | 3天 |
| /stocksystem/stocks/stockindestocksdistribution | STOCKINDEX_STOCKS_DISTRIBUTE + 日期 + "_" + 板块 | 查询板块涨跌分布 | 3天 |
| /stocksystem/stocks/groupLimitlist | 动态生成 | 查询每日涨跌停板列表 | 3天 |
| /stocksystem/stocks/stockQueryData | STOCK_CODE_QUERY | 获取个股代码联想数据 | 12小时 |
| /stocksystem/stocks/stockNameQueryData | STOCK_NAME_QUERY | 获取个股名称联想数据 | 12小时 |
| /stocksystem/stocks/stockIndexsNameQueryData | STOCKINDEX_NAME_QUERY | 获取板块名称联想数据 | 12小时 |
| /stocksystem/stocks/getTradeDay | 动态生成 | 获取最近交易日 | - |
#### 10.2.2 股票创新高/新低数据 (StocksNewRecord) 缓存
| 接口 | 缓存键 | 功能描述 | 过期时间 |
|-----|-------|---------|--------|
| /stocksystem/newrecord/grouplist | HOME_HIGH_DISTRIBUTE/HOME_LOW_DISTRIBUTE + 日期 | 查询每日新高新低列表 | 3天 |
#### 10.2.3 行业趋势分析数据 (Trends) 缓存
| 接口 | 缓存键 | 功能描述 | 过期时间 |
|-----|-------|---------|--------|
| /stocksystem/trends/querytrendstockslist | HOME_TRENDS_STOCKS + 日期 + "_" + 类型 | 查询动量趋势个股 | 3天 |
| /stocksystem/trends/listSection | TRENDS + 日期 + "_" + 类型 | 查询行情数据列表含基础数据 | 3天 |
| /stocksystem/trends/listSectionByBlemind | TRENDS_SECTION_BLEMIND + 日期 + "_" + 板块 + "_" + 类型 | 查询板块动量值 | 3天 |
### 10.3 缓存使用模式
各控制器采用统一的缓存使用模式:
1. **生成缓存键**:根据业务参数(如日期、类型、板块等)动态生成缓存键
2. **查询缓存**:使用 `redisCache.getCacheList()``redisCache.getCacheObject()` 查询缓存
3. **缓存命中处理**:如果缓存存在,直接返回缓存数据
4. **缓存未命中处理**
- 查询数据库获取数据
- 使用 `redisCache.setCacheList()``redisCache.setCacheObject()` 将数据存入缓存
- 使用 `redisCache.expire()` 设置缓存过期时间
5. **特殊情况**:当存在搜索条件时,通常不走缓存,也不缓存结果
## 11. 附录
### 11.1 数据实体对应文件路径
| 数据实体 | 文件路径 |
|---------|--------|
| StockBasic | stock-system/src/main/java/com/ruoyi/stocksystem/domain/StockBasic.java |
| StockFinancial | stock-system/src/main/java/com/ruoyi/stocksystem/domain/StockFinancial.java |
| StockIndex | stock-system/src/main/java/com/ruoyi/stocksystem/domain/StockIndex.java |
| Stocks | stock-system/src/main/java/com/ruoyi/stocksystem/domain/Stocks.java |
| StocksInTrend | stock-system/src/main/java/com/ruoyi/stocksystem/domain/StocksInTrend.java |
| StocksLimit | stock-system/src/main/java/com/ruoyi/stocksystem/domain/StocksLimit.java |
| StocksNewRecord | stock-system/src/main/java/com/ruoyi/stocksystem/domain/StocksNewRecord.java |
| TradeDates | stock-system/src/main/java/com/ruoyi/stocksystem/domain/TradeDates.java |
| Trends | stock-system/src/main/java/com/ruoyi/stocksystem/domain/Trends.java |
### 11.2 技术实现特点
- 采用了典型的 MVC 架构
- 使用 MyBatis 作为 ORM 框架与数据库交互
- 支持 Excel 导入导出功能
- 提供了丰富的分析指标和查询功能
- 数据结构设计符合金融行业特点,支持高频数据处理
- 集成了 Redis 缓存,提高查询性能
- 提供了丰富的统计分析接口,支持多维度数据查询
- 缓存键设计合理,分层清晰,便于管理和维护
- 缓存过期时间设置合理,平衡了性能和数据新鲜度

@ -0,0 +1,344 @@
# 股票动量系统分析文档
## 1. 系统概述
### 1.1 系统定位
本动量系统是基于 RuoYi-Vue 框架开发的股票分析模块,用于识别市场中的强势板块和个股,通过量化计算生成动量排名,辅助投资决策。
### 1.2 核心功能
- **动量个股筛选**:基于多周期涨跌幅筛选强势个股
- **板块动量计算**:计算各行业板块的动量值并排名
- **趋势跟踪**:跟踪板块动量值和排名的变化
- **多周期分析**:支持 1/3/5/10/15/20/30/60 日不同周期的动量分析
---
## 2. 核心数据表结构
### 2.1 股票交易数据表 (stocks)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| code | varchar(45) | 股票代码 |
| trade_day | date | 交易日期 |
| open | decimal | 开盘价 |
| close | decimal | 收盘价 |
| high | decimal | 最高价 |
| low | decimal | 最低价 |
| differrange | decimal | 当日涨跌幅(%) |
| differrange3/5/10/15/20/30/60 | decimal | 多周期涨跌幅 |
| islimit | varchar | 是否涨停 |
| isdrop | varchar | 是否跌停 |
### 2.2 动量个股表 (stocks_in_trend)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| code | varchar(45) | 股票代码 |
| trade_day | date | 交易日期 |
| sort | int | 排名 |
| type | varchar(45) | 动量数据类型(10日/20日等) |
### 2.3 板块动量结果表 (trends)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| trade_day | date | 交易日期 |
| blemind2 | varchar(50) | 所属东财行业指数2级 |
| stocks_count | decimal | 动量个股数量 |
| trend_value | decimal | 动量值 |
| trend_value_change | decimal | 动量值变化 |
| sort | int | 板块排名 |
| sort_change | int | 板块排名变化 |
| type | varchar(45) | 动量数据类型 |
### 2.4 辅助数据表 (stocks_tmp)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| code | varchar(45) | 股票代码 |
| trade_day | date | 交易日期 |
| differrange3/5/15/30 | decimal | 3/5/15/30日区间涨跌幅 |
---
## 3. 动量计算规则详解
### 3.1 系统处理流程
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 导入日线数据 │ -> │ 计算多周期 │ -> │ 筛选动量个股 │ -> │ 计算板块动量 │
│ (stocks) │ │ 涨跌幅 │ │(stocks_in_trend)│ │ (trends) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
```
### 3.2 动量个股筛选规则
#### 3.2.1 入选条件
**代码位置**: `StocksController.java` (约第 1369 行)
```java
if(stock.getTradeDays() >= 120 && stock.getAgenciesHold().compareTo(new BigDecimal(2)) == 1) {
// 入选动量个股
}
```
| 条件 | 阈值 | 说明 |
|------|------|------|
| 上市天数 | ≥ 120 天 | 排除新股,确保有足够历史数据 |
| 机构持股比例 | > 2% | 筛选机构关注的优质股票 |
| 排名范围 | 前 600 名 | 只保留各周期涨跌幅排名靠前的股票 |
#### 3.2.2 筛选逻辑
1. 按指定周期涨跌幅降序排列所有股票
2. 遍历排名列表,检查每只股票的上市天数和机构持股比例
3. 满足条件的股票插入 `stocks_in_trend`
4. 最多取前 600 只符合条件的股票
### 3.3 板块动量值计算公式
#### 3.3.1 核心公式
**代码位置**: `StocksController.java` (约第 1403 行)
```java
BigDecimal c = new BigDecimal(stock.getBlemindCount()); // 板块内动量个股数量
BigDecimal allStocks = new BigDecimal(blemindCoundMap.get(stock.getBlemind2())); // 板块总个股数
BigDecimal result = c.multiply(c).divide(allStocks, 2, BigDecimal.ROUND_HALF_UP);
```
**数学表达式**:
$$
\text{板块动量值} = \frac{( ext{板块内动量个股数})^2}{ ext{板块总个股数}}
$$
#### 3.3.2 公式解读
| 要素 | 说明 |
|------|------|
| **平方加权** | 动量个股数量越多,动量值呈指数级增长,突出强势板块 |
| **归一化处理** | 除以板块总个股数,消除不同规模板块之间的差异 |
| **精度控制** | 结果保留 2 位小数,使用四舍五入 |
#### 3.3.3 计算示例
假设某日数据:
| 板块 | 动量个股数 | 板块总股数 | 动量值计算 | 动量值 |
|------|-----------|-----------|-----------|--------|
| 非银行金融 | 29 | 75 | $29^2 / 75 = 841 / 75$ | 11.21 |
| 食品 | 23 | 75 | $23^2 / 75 = 529 / 75$ | 7.05 |
| 房地产开发 | 27 | 116 | $27^2 / 116 = 729 / 116$ | 6.28 |
| 计算机软件 | 32 | 185 | $32^2 / 185 = 1024 / 185$ | 5.54 |
---
## 4. 多周期动量类型
### 4.1 支持的周期类型
| 类型 | 排序字段 | 适用场景 |
|------|----------|----------|
| 1日 | `differrange` | 当日强势股,捕捉日内热点 |
| 3日 | `differrange3` | 超短期动量,快速轮动策略 |
| 5日 | `differrange5` | 短期动量,一周趋势跟踪 |
| 10日 | `differrange10` | 中期动量,两周趋势跟踪 |
| 15日 | `differrange15` | 中期动量,半月趋势判断 |
| 20日 | `differrange20` | 中长期动量,月度趋势(默认) |
| 30日 | `differrange30` | 长期动量,月度以上趋势 |
| 60日 | `differrange60` | 超长期动量,季度趋势 |
### 4.2 多周期涨跌幅计算
**代码位置**: `StocksController.java` (约第 969-1038 行)
涨跌幅计算公式:
$$
\text{N日涨跌幅} = \frac{\text{当日收盘价} - \text{N日前收盘价}}{\text{N日前收盘价}} \times 100\%
$$
计算逻辑:
1. 获取当日股票收盘价
2. 查询 N 个交易日前的收盘价(基于 `trade_dates` 表)
3. 计算涨跌幅并存储到 `stocks_tmp`
---
## 5. 排名与变化计算
### 5.1 板块排名规则
**代码位置**: `StocksController.java` (约第 1410-1421 行)
按动量值降序排列,动量值越大排名越靠前:
```java
Collections.sort(trendsList, new Comparator<Trends>() {
@Override
public int compare(Trends s1, Trends s2) {
if(s1.getTrendValue().compareTo(s2.getTrendValue()) == 1)
return -1; // 动量值大的排前面
else if(s1.getTrendValue().compareTo(s2.getTrendValue()) == -1)
return 1;
else
return 0;
}
});
```
### 5.2 排名变化计算
**代码位置**: `StocksController.java` (约第 1434-1450 行)
```java
if(lastTrendsMap.containsKey(trend.getBlemind2())) {
trend.setSort(sort);
trend.setSortChange(lastTrendsMap.get(trend.getBlemind2()).getSort() - sort);
trend.setTrendValueChange(lastTrendsMap.get(trend.getBlemind2()).getTrendValue().subtract(trend.getTrendValue()));
} else {
trend.setSort(sort);
trend.setSortChange(blemindCoundMap.size() - sort);
trend.setTrendValueChange(trend.getTrendValue());
}
```
| 指标 | 计算方式 | 说明 |
|------|----------|------|
| 排名变化 | 昨日排名 - 今日排名 | 正数表示排名上升,负数表示下降 |
| 动量值变化 | 今日动量值 - 昨日动量值 | 正数表示动量增强,负数表示减弱 |
### 5.3 新上榜板块处理
对于昨日未上榜的板块:
- 排名变化 = 板块总数 - 当前排名
- 动量值变化 = 当前动量值(视为从无到有)
---
## 6. 最大回撤计算
### 6.1 计算逻辑
**代码位置**: `TrendsController.java` (约第 161-188 行)
计算动量个股相对于近期高点的回撤幅度:
```java
BigDecimal current = stocksInTrend1.getClose(); // 当前收盘价
BigDecimal high = stocks1.getClose(); // N日最高收盘价
BigDecimal diff = current.subtract(high);
BigDecimal hresult = diff.divide(high, 2, BigDecimal.ROUND_HALF_UP).multiply(new BigDecimal("100"));
```
### 6.2 回撤公式
$$
\text{回撤} = \frac{\text{当前收盘价} - \text{N日最高收盘价}}{\text{N日最高收盘价}} \times 100\%
$$
### 6.3 用途
- 评估动量个股的获利回吐压力
- 识别可能的 trend reversal 信号
- 辅助判断买入/卖出时机
---
## 7. 实际数据示例
### 7.1 动量个股表示例 (stocks_in_trend)
| id | code | trade_day | sort | type |
|----|------|-----------|------|------|
| 1 | 600340.SH | 2021-01-04 | 1 | 20 |
| 2 | 002607.SZ | 2021-01-04 | 2 | 20 |
| 3 | 600260.SH | 2021-01-04 | 3 | 20 |
| ... | ... | ... | ... | ... |
| 100 | 601375.SH | 2021-01-04 | 100 | 20 |
### 7.2 板块动量结果示例 (trends)
| trade_day | blemind2 | stocks_count | trend_value | sort | sort_change | type |
|-----------|----------|--------------|-------------|------|-------------|------|
| 2021-01-04 | 非银行金融 | 29 | 11.2133 | 1 | 0 | 20 |
| 2021-01-04 | 食品 | 23 | 7.0533 | 2 | 0 | 20 |
| 2021-01-04 | 房地产开发 | 27 | 6.2845 | 3 | 0 | 20 |
| 2021-01-04 | 计算机软件 | 32 | 5.5351 | 4 | 0 | 20 |
| 2021-01-05 | 非银行金融 | 28 | 10.4533 | 2 | -1 | 20 |
| 2021-01-05 | 食品 | 30 | 12.0000 | 1 | 1 | 20 |
---
## 8. API 接口说明
### 8.1 动量个股查询
```
GET /stocksystem/trendStocks/list
参数:
- tradeDay: 交易日期
- type: 动量类型 (1/3/5/10/15/20/30/60)
- blemind2: 行业板块(可选)
```
### 8.2 板块动量查询
```
GET /stocksystem/trends/list
参数:
- tradeDay: 交易日期
- type: 动量类型
```
### 8.3 行业趋势数据
```
GET /stocksystem/trends/listSection
参数:
- tradeDay: 交易日期
- type: 动量类型
```
### 8.4 数据分析触发
```
POST /stocksystem/stocks/analysis
参数:
- tradeDay: 交易日期
说明: 触发指定日期的动量计算流程
```
---
## 9. 系统特点与注意事项
### 9.1 系统特点
1. **多周期支持**: 支持 8 种不同时间周期的动量分析
2. **机构视角**: 通过机构持股比例过滤,聚焦机构关注的股票
3. **归一化计算**: 动量值公式考虑了板块规模差异
4. **变化跟踪**: 自动计算排名和动量值的变化趋势
5. **缓存机制**: 使用 Redis 缓存热点数据,提高查询性能
### 9.2 使用注意事项
1. **数据依赖**: 系统依赖完整的交易日历数据 (`trade_dates`)
2. **基础数据**: 需要完整的股票基础信息 (`stock_basis`)
3. **计算时机**: 建议在每日收盘后 18:00 后执行分析
4. **首次运行**: 首次导入历史数据时需要批量计算多周期涨跌幅
---
## 10. 核心代码文件清单
| 文件路径 | 说明 |
|----------|------|
| `stock-system/src/main/java/com/ruoyi/stocksystem/controller/StocksController.java` | 股票数据控制器,包含动量计算核心逻辑 |
| `stock-system/src/main/java/com/ruoyi/stocksystem/controller/TrendsController.java` | 动量结果控制器,包含回撤计算 |
| `stock-system/src/main/resources/mapper/stocksystem/StocksInTrendMapper.xml` | 动量个股数据访问层 |
| `stock-system/src/main/resources/mapper/stocksystem/TrendsMapper.xml` | 板块动量数据访问层 |
| `sql/nstocks/stocks_in_trend.sql` | 动量个股表结构 |
| `sql/nstocks/trends.sql` | 板块动量表结构 |
| `sql/nstocks/stocks_tmp.sql` | 辅助表结构 |
---
*文档生成时间: 2026-03-14*
*基于代码版本: RuoYi-Vue Stock-System*
Loading…
Cancel
Save