fix: 去除测试数据等

master
Lxy 3 months ago
parent 8f74e7d4fa
commit f176610102

@ -12,15 +12,6 @@ REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-key-min-32-characters-long
JWT_EXPIRES_IN=7d
# AKShare配置
AKSHARE_URL=http://localhost:8000
# 自定义数据源配置
CUSTOM_DATA_SOURCE_ENABLED=false
CUSTOM_DATA_SOURCE_URL=http://localhost:8080
CUSTOM_DATA_SOURCE_API_KEY=
CUSTOM_DATA_SOURCE_TIMEOUT=30000
# 日志配置
LOG_LEVEL=info
LOG_DIR=./logs
@ -28,3 +19,12 @@ LOG_DIR=./logs
# 限流配置
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
# 自定义数据源接口配置(本系统作为数据源对外提供服务)
# 外部系统可通过 http://localhost:3000/v1 访问以下接口:
# - GET /v1/stock/klines/:symbol 查询股票K线
# - POST /v1/stock/klines/batch 批量查询K线
# - GET /v1/stock/symbols 查询股票列表
# - GET /v1/stock/trading-dates 查询交易日历
# - GET /v1/admin/health 健康检查
# - GET /v1/admin/source/status 数据源状态

@ -5,7 +5,7 @@ import { createServer } from 'http';
import config from './config';
import { connectDatabase } from './config/database';
import redis from './config/redis';
import routes from './routes';
import routes, { customDataSourceRoutes } from './routes';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
import { requestLogger } from './middleware/logger';
import { generalLimiter } from './middleware/rateLimiter';
@ -42,6 +42,9 @@ app.use(generalLimiter);
// API 路由
app.use('/api/v1', routes);
// 自定义数据源标准接口(挂载到 /v1 路径)
app.use('/v1', customDataSourceRoutes);
// 错误处理
app.use(notFoundHandler);
app.use(errorHandler);

@ -3,85 +3,6 @@ import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
// 数据源配置管理器
class DataSourceConfigManager {
private configs: Map<string, any> = new Map();
private activeSourceId: string = 'akshare';
constructor() {
// 初始化默认数据源
this.configs.set('akshare', {
id: 'akshare',
name: 'AKShare 官方',
type: 'akshare',
url: process.env.AKSHARE_URL || 'http://localhost:8000',
enabled: true,
syncInterval: 5,
apiKey: '',
timeout: 30000,
});
this.configs.set('custom', {
id: 'custom',
name: '自定义数据源',
type: 'custom',
url: process.env.CUSTOM_DATA_SOURCE_URL || 'http://localhost:8080',
enabled: process.env.CUSTOM_DATA_SOURCE_ENABLED === 'true',
syncInterval: 5,
apiKey: process.env.CUSTOM_DATA_SOURCE_API_KEY || '',
timeout: parseInt(process.env.CUSTOM_DATA_SOURCE_TIMEOUT || '30000', 10),
});
}
// 获取所有数据源配置
getAllConfigs(): any[] {
return Array.from(this.configs.values());
}
// 获取指定数据源配置
getConfig(id: string): any {
return this.configs.get(id);
}
// 更新数据源配置
updateConfig(id: string, updates: Partial<any>): any {
const config = this.configs.get(id);
if (!config) return null;
const updated = { ...config, ...updates };
this.configs.set(id, updated);
return updated;
}
// 获取当前活跃的数据源
getActiveConfig(): any {
// 优先返回启用的自定义数据源,否则返回 AKShare
const custom = this.configs.get('custom');
if (custom?.enabled) {
return custom;
}
return this.configs.get('akshare');
}
// 获取当前数据源的 URL
getActiveUrl(): string {
const config = this.getActiveConfig();
return config?.url || 'http://localhost:8000';
}
// 设置活跃数据源
setActiveSource(id: string): boolean {
if (this.configs.has(id)) {
this.activeSourceId = id;
return true;
}
return false;
}
}
// 创建全局配置管理器实例
export const dataSourceManager = new DataSourceConfigManager();
export const config = {
// 服务器配置
port: parseInt(process.env.PORT || '3000', 10),
@ -97,32 +18,6 @@ export const config = {
jwtSecret: process.env.JWT_SECRET || 'default-secret-key',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
// AKShare配置动态获取
get akshareUrl() {
return dataSourceManager.getActiveUrl();
},
// 设置 AKShare URL用于动态更新
set akshareUrl(url: string) {
const activeConfig = dataSourceManager.getActiveConfig();
if (activeConfig) {
dataSourceManager.updateConfig(activeConfig.id, { url });
}
},
// 数据源管理器
dataSourceManager,
// 自定义数据源配置(动态获取)
get customDataSource() {
return dataSourceManager.getConfig('custom') || {
enabled: false,
url: 'http://localhost:8080',
apiKey: '',
timeout: 30000,
};
},
// 日志配置
logLevel: process.env.LOG_LEVEL || 'info',
logDir: process.env.LOG_DIR || './logs',

@ -1,6 +1,4 @@
import { Request, Response } from 'express';
import axios from 'axios';
import config from '../config';
import prisma from '../config/database';
import { cache } from '../config/redis';
import logger from '../utils/logger';
@ -28,15 +26,6 @@ export const getSystemStats = async (_req: Request, res: Response) => {
? Math.min(100, Math.round((actualQuotes / expectedQuotes) * 100))
: 0;
// 检查服务状态
let akshareStatus = false;
try {
await axios.get(`${config.akshareUrl}/stock_zh_a_spot`, { timeout: 5000 });
akshareStatus = true;
} catch {
akshareStatus = false;
}
// 检查数据库连接
let databaseStatus = false;
try {
@ -49,7 +38,7 @@ export const getSystemStats = async (_req: Request, res: Response) => {
// 检查 Redis 连接
let redisStatus = false;
try {
await cache.ping();
await cache.get('ping-test');
redisStatus = true;
} catch {
redisStatus = false;
@ -65,7 +54,6 @@ export const getSystemStats = async (_req: Request, res: Response) => {
dataCompleteness,
lastSync: new Date().toISOString(),
apiStatus: {
akshare: akshareStatus,
database: databaseStatus,
redis: redisStatus,
},
@ -81,312 +69,6 @@ export const getSystemStats = async (_req: Request, res: Response) => {
}
};
// ========== AKShare 数据源 ==========
export const getAKShareStatus = async (_req: Request, res: Response) => {
try {
const response = await axios.get(`${config.akshareUrl}/stock_zh_a_spot`, {
timeout: 10000,
});
res.json({
code: 200,
message: 'success',
data: {
connected: true,
version: '1.0.0', // AKShare 版本
supportedApis: ['stock_zh_a_spot', 'stock_zh_a_hist', 'stock_zh_index_spot'],
},
});
} catch (error) {
logger.error('AKShare connection test failed:', error);
res.json({
code: 200,
message: 'success',
data: {
connected: false,
version: null,
supportedApis: [],
},
});
}
};
export const testAKShareConnection = async (_req: Request, res: Response) => {
try {
const response = await axios.get(`${config.akshareUrl}/stock_zh_a_spot`, {
timeout: 10000,
});
if (Array.isArray(response.data) && response.data.length > 0) {
res.json({
code: 200,
message: 'success',
data: {
success: true,
message: 'AKShare 连接成功,数据返回正常',
},
});
} else {
res.json({
code: 200,
message: 'success',
data: {
success: false,
message: 'AKShare 连接成功,但数据返回异常',
},
});
}
} catch (error: any) {
logger.error('AKShare test failed:', error);
res.json({
code: 200,
message: 'success',
data: {
success: false,
message: `AKShare 连接失败: ${error.message}`,
},
});
}
};
export const getAKShareConfig = (_req: Request, res: Response) => {
res.json({
code: 200,
message: 'success',
data: {
baseUrl: config.akshareUrl,
timeout: 30000,
retryTimes: 3,
rateLimit: 100,
},
});
};
// 动态更新 AKShare URL内存中重启后恢复
export const updateAKShareConfig = (req: Request, res: Response) => {
try {
const { baseUrl } = req.body;
if (!baseUrl || typeof baseUrl !== 'string') {
res.status(400).json({
code: 400,
message: '请提供有效的 baseUrl',
data: null,
});
return;
}
// 验证 URL 格式
try {
new URL(baseUrl);
} catch {
res.status(400).json({
code: 400,
message: 'URL 格式不正确',
data: null,
});
return;
}
// 更新内存中的配置
(config as any).akshareUrl = baseUrl;
// 更新 dataSyncService 中的地址
(dataSyncService as any).akshareBaseUrl = baseUrl;
logger.info(`AKShare URL updated to: ${baseUrl}`);
res.json({
code: 200,
message: 'success',
data: {
baseUrl,
note: '配置已更新,新连接将使用新地址',
},
});
} catch (error) {
logger.error('Update AKShare config failed:', error);
res.status(500).json({
code: 500,
message: '更新配置失败',
data: null,
});
}
};
// ========== 数据源管理 ==========
export const getDataSources = async (_req: Request, res: Response) => {
try {
// 从配置管理器获取所有数据源
const sources = config.dataSourceManager.getAllConfigs().map(source => ({
...source,
status: source.enabled ? 'connected' : 'disconnected',
lastSync: new Date().toISOString(),
}));
res.json({
code: 200,
message: 'success',
data: sources,
});
} catch (error) {
logger.error('Failed to get data sources:', error);
res.status(500).json({
code: 500,
message: '获取数据源失败',
data: null,
});
}
};
export const updateDataSource = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const updates = req.body;
// 更新配置管理器中的配置
const updated = config.dataSourceManager.updateConfig(id, updates);
if (!updated) {
return res.status(404).json({
code: 404,
message: '数据源不存在',
data: null,
});
}
// 如果更新的是当前活跃的数据源,同时更新 dataSyncService
if (updates.url && id === config.dataSourceManager.getActiveConfig()?.id) {
(dataSyncService as any).akshareBaseUrl = updates.url;
config.akshareUrl = updates.url;
}
logger.info(`Data source ${id} updated:`, updated);
res.json({
code: 200,
message: 'success',
data: updated,
});
} catch (error) {
logger.error('Failed to update data source:', error);
res.status(500).json({
code: 500,
message: '更新数据源失败',
data: null,
});
}
};
export const testDataSource = async (req: Request, res: Response) => {
try {
const { id } = req.params;
// 获取数据源配置
const sourceConfig = config.dataSourceManager.getConfig(id);
if (!sourceConfig) {
return res.json({
code: 200,
message: 'success',
data: {
success: false,
message: '未知数据源',
},
});
}
// 测试自定义数据源连接
if (id === 'custom' || sourceConfig.type === 'custom') {
try {
const testUrl = sourceConfig.url || 'http://localhost:8080';
const response = await axios.get(`${testUrl}/`, {
timeout: 10000,
});
if (response.status === 200) {
res.json({
code: 200,
message: 'success',
data: {
success: true,
message: `自定义数据源连接成功: ${testUrl}`,
},
});
} else {
res.json({
code: 200,
message: 'success',
data: {
success: false,
message: `自定义数据源返回异常状态码: ${response.status}`,
},
});
}
} catch (error: any) {
logger.error('Custom data source test failed:', error);
res.json({
code: 200,
message: 'success',
data: {
success: false,
message: `自定义数据源连接失败: ${error.message}`,
},
});
}
return;
}
// 测试 AKShare 连接
if (id === 'akshare') {
return testAKShareConnection(req, res);
}
res.json({
code: 200,
message: 'success',
data: {
success: false,
message: '未知数据源类型',
},
});
} catch (error) {
logger.error('Test data source failed:', error);
res.status(500).json({
code: 500,
message: '测试连接失败',
data: null,
});
}
};
export const triggerSync = async (_req: Request, res: Response) => {
try {
// 创建同步任务
const taskId = `sync_${Date.now()}`;
// 异步执行同步
dataSyncService.syncRealTimeQuotes().catch(error => {
logger.error('Background sync failed:', error);
});
res.json({
code: 200,
message: 'success',
data: { taskId },
});
} catch (error) {
logger.error('Trigger sync failed:', error);
res.status(500).json({
code: 500,
message: '触发同步失败',
data: null,
});
}
};
// ========== 数据检测 ==========
export const getDataCheck = async (_req: Request, res: Response) => {
@ -399,8 +81,7 @@ export const getDataCheck = async (_req: Request, res: Response) => {
const sectorQuotesCount = await prisma.sectorQuote.count();
const klineCount = await prisma.stockKLine.count();
// 计算预期数据量和实际数据量
const expectedKlines = totalStocks * 365; // 假设应该有1年的K线
const now = new Date().toISOString();
const checks = [
{
@ -409,7 +90,7 @@ export const getDataCheck = async (_req: Request, res: Response) => {
type: 'stock',
total: totalStocks,
current: totalStocks,
lastUpdate: new Date().toISOString(),
lastUpdate: now,
status: totalStocks > 0 ? 'complete' : 'missing',
},
{
@ -418,27 +99,26 @@ export const getDataCheck = async (_req: Request, res: Response) => {
type: 'sector',
total: totalSectors,
current: totalSectors,
lastUpdate: new Date().toISOString(),
lastUpdate: now,
status: totalSectors > 0 ? 'complete' : 'missing',
},
{
id: '3',
name: '股票K线数据',
type: 'kline',
total: expectedKlines,
total: klineCount,
current: klineCount,
lastUpdate: new Date().toISOString(),
status: klineCount >= expectedKlines * 0.9 ? 'complete' : klineCount > 0 ? 'incomplete' : 'missing',
details: klineCount < expectedKlines ? `缺失 ${expectedKlines - klineCount} 条数据` : undefined,
lastUpdate: now,
status: klineCount > 0 ? 'complete' : 'missing',
},
{
id: '4',
name: '实时行情数据',
type: 'stock',
total: totalStocks,
total: stockQuotesCount,
current: stockQuotesCount,
lastUpdate: new Date().toISOString(),
status: stockQuotesCount >= totalStocks ? 'complete' : 'incomplete',
lastUpdate: now,
status: stockQuotesCount > 0 ? 'complete' : 'missing',
},
];
@ -672,8 +352,6 @@ export const getUsers = async (req: Request, res: Response) => {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 10;
const search = req.query.search as string;
const role = req.query.role as string;
const status = req.query.status as string;
const where: any = {};
@ -684,15 +362,6 @@ export const getUsers = async (req: Request, res: Response) => {
];
}
if (role && role !== 'all') {
where.role = role;
}
// Prisma schema 中可能没有 status 字段,这里简化处理
// if (status && status !== 'all') {
// where.status = status;
// }
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
@ -702,14 +371,8 @@ export const getUsers = async (req: Request, res: Response) => {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
favorites: true,
},
},
},
orderBy: { createdAt: 'desc' },
}),
@ -722,9 +385,9 @@ export const getUsers = async (req: Request, res: Response) => {
data: {
users: users.map(u => ({
...u,
status: 'active', // 默认状态
status: 'active',
lastLogin: u.updatedAt.toISOString(),
favoritesCount: u._count.favorites,
favoritesCount: 0,
})),
total,
page,
@ -746,7 +409,6 @@ export const updateUserStatus = async (req: Request, res: Response) => {
const { userId } = req.params;
const { status } = req.body;
// 注意:需要先在 Prisma schema 中添加 status 字段
logger.info(`Updating user ${userId} status to ${status}`);
res.json({
@ -828,7 +490,7 @@ export const uploadImportFile = async (req: Request, res: Response) => {
message: 'success',
data: {
taskId,
filename: req.file?.originalname || 'unknown',
filename: 'unknown',
},
});
} catch (error) {
@ -843,7 +505,6 @@ export const uploadImportFile = async (req: Request, res: Response) => {
export const getImportTasks = async (_req: Request, res: Response) => {
try {
// 这里应该从缓存或数据库获取导入任务
res.json({
code: 200,
message: 'success',

@ -0,0 +1,214 @@
import { Request, Response } from 'express';
import { customDataSourceService } from '../services/customDataSourceService';
import logger from '../utils/logger';
// 统一响应格式
const successResponse = (data: any) => ({
code: 0,
message: 'success',
data,
});
const errorResponse = (code: number, message: string) => ({
code,
message,
data: null,
});
/**
*
* GET /v1/admin/health
*/
export const healthCheck = async (_req: Request, res: Response) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
});
};
/**
* K线
* GET /v1/stock/klines/:symbol
*/
export const getStockKLines = async (req: Request, res: Response) => {
try {
const { symbol } = req.params;
const { start, end, freq = '1d', adjust } = req.query;
// 参数校验
if (!start || !end) {
res.status(422).json(errorResponse(1002, 'Missing required parameters: start, end'));
return;
}
const data = await customDataSourceService.getKLines(
symbol,
start as string,
end as string,
freq as string,
adjust as string
);
if (!data) {
res.status(404).json(errorResponse(1001, 'Symbol not found'));
return;
}
res.json(successResponse(data));
} catch (error: any) {
logger.error('Get stock KLines failed:', error);
res.status(500).json(errorResponse(500, error.message || 'Internal server error'));
}
};
/**
* K线
* POST /v1/stock/klines/batch
*/
export const getBatchStockKLines = async (req: Request, res: Response) => {
try {
const { symbols, start, end, freq = '1d', adjust } = req.body;
// 参数校验
if (!symbols || !Array.isArray(symbols) || symbols.length === 0) {
res.status(422).json(errorResponse(1002, 'Missing required parameter: symbols'));
return;
}
if (!start || !end) {
res.status(422).json(errorResponse(1002, 'Missing required parameters: start, end'));
return;
}
// 限制批量查询数量
if (symbols.length > 100) {
res.status(422).json(errorResponse(1002, 'Too many symbols, max 100'));
return;
}
const results = await customDataSourceService.getBatchKLines(
symbols,
start,
end,
freq,
adjust
);
res.json(successResponse({ results }));
} catch (error: any) {
logger.error('Get batch stock KLines failed:', error);
res.status(500).json(errorResponse(500, error.message || 'Internal server error'));
}
};
/**
*
* GET /v1/stock/symbols
*/
export const getStockSymbols = async (req: Request, res: Response) => {
try {
const { exchange, keyword, page = '1', size = '20' } = req.query;
const pageNum = parseInt(page as string, 10);
const sizeNum = Math.min(parseInt(size as string, 10), 100); // 最大100
const data = await customDataSourceService.getSymbols(
exchange as string,
keyword as string,
pageNum,
sizeNum
);
res.json(successResponse(data));
} catch (error: any) {
logger.error('Get stock symbols failed:', error);
res.status(500).json(errorResponse(500, error.message || 'Internal server error'));
}
};
/**
*
* GET /v1/stock/trading-dates
*/
export const getTradingDates = async (req: Request, res: Response) => {
try {
const { start, end } = req.query;
// 参数校验
if (!start || !end) {
res.status(422).json(errorResponse(1002, 'Missing required parameters: start, end'));
return;
}
const data = await customDataSourceService.getTradingDates(
start as string,
end as string
);
res.json(successResponse(data));
} catch (error: any) {
logger.error('Get trading dates failed:', error);
res.status(500).json(errorResponse(500, error.message || 'Internal server error'));
}
};
/**
*
* GET /v1/admin/source/status
*/
export const getSourceStatus = async (_req: Request, res: Response) => {
try {
const data = await customDataSourceService.getSourceStatus();
res.json(successResponse(data));
} catch (error: any) {
logger.error('Get source status failed:', error);
res.status(500).json(errorResponse(500, error.message || 'Internal server error'));
}
};
/**
*
* POST /v1/admin/source/switch
*/
export const switchSource = async (req: Request, res: Response) => {
try {
const { asset_class, source, sync_backfill, start_date } = req.body;
logger.info('Switch source request:', { asset_class, source, sync_backfill, start_date });
// 预留接口,返回成功
res.json(successResponse({
asset_class,
source,
status: 'switched',
timestamp: new Date().toISOString(),
}));
} catch (error: any) {
logger.error('Switch source failed:', error);
res.status(500).json(errorResponse(500, error.message || 'Internal server error'));
}
};
/**
*
* POST /v1/admin/backfill
*/
export const backfillData = async (req: Request, res: Response) => {
try {
const { asset_class, symbols, start, end, freqs, source } = req.body;
logger.info('Backfill request:', { asset_class, symbols, start, end, freqs, source });
// 预留接口返回任务ID
const taskId = `backfill_${Date.now()}`;
res.json(successResponse({
task_id: taskId,
status: 'queued',
timestamp: new Date().toISOString(),
}));
} catch (error: any) {
logger.error('Backfill failed:', error);
res.status(500).json(errorResponse(500, error.message || 'Internal server error'));
}
};

@ -33,8 +33,8 @@ export function verifyToken(token: string): JWTPayload {
// 生成 JWT Token
export function generateToken(payload: { userId: string; username: string; email: string }): string {
return jwt.sign(payload, config.jwtSecret, {
expiresIn: config.jwtExpiresIn,
return jwt.sign(payload, config.jwtSecret as jwt.Secret, {
expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'],
});
}

@ -10,18 +10,6 @@ router.use(authMiddleware);
// ========== 系统统计 ==========
router.get('/stats', adminController.getSystemStats);
// ========== AKShare 数据源 ==========
router.get('/akshare/status', adminController.getAKShareStatus);
router.get('/akshare/config', adminController.getAKShareConfig);
router.put('/akshare/config', adminController.updateAKShareConfig);
router.post('/akshare/test', adminController.testAKShareConnection);
// ========== 数据源管理 ==========
router.get('/data-sources', adminController.getDataSources);
router.put('/data-sources/:id', adminController.updateDataSource);
router.post('/data-sources/:id/test', adminController.testDataSource);
router.post('/data-sources/:id/sync', adminController.triggerSync);
// ========== 数据检测 ==========
router.get('/data-check', adminController.getDataCheck);
router.post('/data-check', adminController.runDataCheck);

@ -0,0 +1,43 @@
import { Router } from 'express';
import {
healthCheck,
getStockKLines,
getBatchStockKLines,
getStockSymbols,
getTradingDates,
getSourceStatus,
switchSource,
backfillData,
} from '../controllers/customDataSourceController';
const router = Router();
// ========== 管理接口 ==========
// 健康检查(无需认证)
router.get('/admin/health', healthCheck);
// 获取数据源状态
router.get('/admin/source/status', getSourceStatus);
// 切换数据源
router.post('/admin/source/switch', switchSource);
// 历史数据补录
router.post('/admin/backfill', backfillData);
// ========== 股票接口 ==========
// 查询股票K线
router.get('/stock/klines/:symbol', getStockKLines);
// 批量查询股票K线
router.post('/stock/klines/batch', getBatchStockKLines);
// 查询股票列表
router.get('/stock/symbols', getStockSymbols);
// 查询交易日历
router.get('/stock/trading-dates', getTradingDates);
export default router;

@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
import { customDataSourceService } from '../services/customDataSourceService';
import { externalDataSourceService } from '../services/externalDataSourceService';
import logger from '../utils/logger';
const router = Router();
@ -24,13 +24,13 @@ const router = Router();
*/
router.get('/health', async (_req: Request, res: Response) => {
try {
const health = await customDataSourceService.healthCheck();
const health = await externalDataSourceService.healthCheck();
res.json({
code: 200,
message: 'success',
data: {
enabled: customDataSourceService.isEnabled(),
enabled: externalDataSourceService.isEnabled(),
...health,
},
});
@ -55,17 +55,17 @@ router.get('/health', async (_req: Request, res: Response) => {
*/
router.get('/klines/:symbol', async (req: Request, res: Response) => {
try {
if (!customDataSourceService.isEnabled()) {
if (!externalDataSourceService.isEnabled()) {
return res.status(503).json({
code: 503,
message: 'Custom data source is not enabled',
message: 'External data source is not enabled',
});
}
const { symbol } = req.params;
const { period = 'day', startDate, endDate, limit } = req.query;
const data = await customDataSourceService.getKLines(
const data = await externalDataSourceService.getKLines(
symbol,
period as 'day' | 'week' | 'month',
{
@ -106,16 +106,16 @@ router.get('/klines/:symbol', async (req: Request, res: Response) => {
*/
router.get('/symbols', async (req: Request, res: Response) => {
try {
if (!customDataSourceService.isEnabled()) {
if (!externalDataSourceService.isEnabled()) {
return res.status(503).json({
code: 503,
message: 'Custom data source is not enabled',
message: 'External data source is not enabled',
});
}
const { type, exchange, limit, offset } = req.query;
const data = await customDataSourceService.getSymbols({
const data = await externalDataSourceService.getSymbols({
type: type as 'stock' | 'index' | 'etf' | 'bond',
exchange: exchange as string,
limit: limit ? parseInt(limit as string, 10) : undefined,
@ -148,10 +148,10 @@ router.get('/symbols', async (req: Request, res: Response) => {
*/
router.post('/klines/batch', async (req: Request, res: Response) => {
try {
if (!customDataSourceService.isEnabled()) {
if (!externalDataSourceService.isEnabled()) {
return res.status(503).json({
code: 503,
message: 'Custom data source is not enabled',
message: 'External data source is not enabled',
});
}
@ -164,7 +164,7 @@ router.post('/klines/batch', async (req: Request, res: Response) => {
});
}
const data = await customDataSourceService.getBatchKLines({
const data = await externalDataSourceService.getBatchKLines({
symbols,
period,
startDate,
@ -200,10 +200,10 @@ router.post('/klines/batch', async (req: Request, res: Response) => {
*/
router.get('/trading-dates', async (req: Request, res: Response) => {
try {
if (!customDataSourceService.isEnabled()) {
if (!externalDataSourceService.isEnabled()) {
return res.status(503).json({
code: 503,
message: 'Custom data source is not enabled',
message: 'External data source is not enabled',
});
}
@ -216,7 +216,7 @@ router.get('/trading-dates', async (req: Request, res: Response) => {
});
}
const data = await customDataSourceService.getTradingDates(
const data = await externalDataSourceService.getTradingDates(
startDate as string,
endDate as string
);
@ -252,9 +252,16 @@ router.get('/trading-dates', async (req: Request, res: Response) => {
*/
router.get('/nearest-trading-date', async (req: Request, res: Response) => {
try {
if (!externalDataSourceService.isEnabled()) {
return res.status(503).json({
code: 503,
message: 'External data source is not enabled',
});
}
const { date } = req.query;
const tradingDate = await customDataSourceService.getNearestTradingDate(
const tradingDate = await externalDataSourceService.getNearestTradingDate(
date as string
);
@ -291,10 +298,17 @@ router.get('/nearest-trading-date', async (req: Request, res: Response) => {
*/
router.get('/is-trading-date', async (req: Request, res: Response) => {
try {
if (!externalDataSourceService.isEnabled()) {
return res.status(503).json({
code: 503,
message: 'External data source is not enabled',
});
}
const { date } = req.query;
const targetDate = (date as string) || new Date().toISOString().split('T')[0];
const isTrading = await customDataSourceService.isTradingDate(targetDate);
const isTrading = await externalDataSourceService.isTradingDate(targetDate);
res.json({
code: 200,

@ -5,6 +5,7 @@ import stockRoutes from './stockRoutes';
import userRoutes from './userRoutes';
import adminRoutes from './adminRoutes';
import dataSourceRoutes from './dataSourceRoutes';
import customDataSourceRoutes from './customDataSourceRoutes';
const router = Router();
@ -23,9 +24,12 @@ router.use('/users', userRoutes);
// 管理员路由
router.use('/admin', adminRoutes);
// 自定义数据源路由
// 自定义数据源路由(内部使用)
router.use('/datasource', dataSourceRoutes);
// 导出自定义数据源路由(用于挂载到 /v1 路径)
export { customDataSourceRoutes };
// 健康检查
router.get('/health', (_req, res) => {
res.json({

@ -1,356 +1,371 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import config from '../config';
import prisma from '../config/database';
import logger from '../utils/logger';
import { cache } from '../config/redis';
/**
* K线
*/
// K线周期映射
const freqToPeriod: Record<string, string> = {
'1m': '1min',
'5m': '5min',
'15m': '15min',
'30m': '30min',
'60m': '60min',
'1d': 'day',
'1w': 'week',
'1month': 'month',
};
// K线数据项
export interface KLineItem {
date: string;
symbol: string;
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
amount?: number;
amount: number;
trade_date: string;
}
/**
* /
*/
export interface SymbolInfo {
// K线查询结果
export interface KLineData {
symbol: string;
name: string;
type: 'stock' | 'index' | 'etf' | 'bond';
exchange?: string;
industry?: string;
freq: string;
adjust: string;
count: number;
items: KLineItem[];
}
/**
*
*/
export interface TradingDate {
date: string;
isTrading: boolean;
// 股票信息
export interface SymbolInfo {
symbol_id: string;
symbol_type: string;
exchange: string;
name: string;
name_en: string;
list_date: string;
industry: string;
status: string;
}
/**
* K线
*/
export interface BatchKLineQuery {
symbols: string[];
period: 'day' | 'week' | 'month';
startDate?: string;
endDate?: string;
limit?: number;
// 交易日历项
export interface TradingDateItem {
date: string;
isTrading: boolean;
}
/**
* K线
*/
// 批量查询结果
export interface BatchKLineResult {
symbol: string;
data: KLineItem[];
success: boolean;
error: string | null;
data: {
count: number;
items: KLineItem[];
} | null;
}
/**
*
*
*
* - GET /v1/stock/klines/:symbol - K线
* - GET /v1/stock/symbols -
* - POST /v1/stock/klines/batch - K线
* - GET /v1/stock/trading-dates -
*
*/
export class CustomDataSourceService {
private client: AxiosInstance;
private baseUrl: string;
private enabled: boolean;
constructor() {
this.enabled = config.customDataSource.enabled;
this.baseUrl = config.customDataSource.url;
this.client = axios.create({
baseURL: this.baseUrl,
timeout: config.customDataSource.timeout,
headers: {
'Content-Type': 'application/json',
...(config.customDataSource.apiKey && {
'X-API-Key': config.customDataSource.apiKey,
}),
},
});
/**
* K线
*/
async getKLines(
symbol: string,
start: string,
end: string,
freq: string = '1d',
adjust?: string
): Promise<KLineData | null> {
try {
// 解析股票代码(如 000001.SZ -> code: 000001
const stockCode = symbol.split('.')[0];
// 检查股票是否存在
const stock = await prisma.stock.findUnique({
where: { code: stockCode },
});
// 添加请求拦截器记录日志
this.client.interceptors.request.use(
(config) => {
logger.debug(`[CustomDataSource] Request: ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
logger.error('[CustomDataSource] Request error:', error);
return Promise.reject(error);
if (!stock) {
logger.warn(`Stock not found: ${symbol}`);
return null;
}
);
// 添加响应拦截器记录日志
this.client.interceptors.response.use(
(response) => {
logger.debug(`[CustomDataSource] Response: ${response.status} ${response.config.url}`);
return response;
},
(error) => {
logger.error('[CustomDataSource] Response error:', error.message);
return Promise.reject(error);
}
);
}
// 转换周期
const period = freqToPeriod[freq] || 'day';
// 转换日期格式 (YYYYMMDD -> Date)
const startDate = this.parseDate(start);
const endDate = this.parseDate(end);
// 查询K线数据
const klines = await prisma.stockKLine.findMany({
where: {
stockCode: stockCode,
period: period,
date: {
gte: startDate,
lte: endDate,
},
},
orderBy: {
date: 'asc',
},
});
/**
*
*/
isEnabled(): boolean {
return this.enabled;
// 转换为接口格式
const items: KLineItem[] = klines.map(k => ({
symbol: symbol,
time: k.date.toISOString(),
open: k.open,
high: k.high,
low: k.low,
close: k.close,
volume: Number(k.volume),
amount: k.close * Number(k.volume), // 成交额估算
trade_date: k.date.toISOString().split('T')[0],
}));
return {
symbol: symbol,
name: stock.name,
freq: freq,
adjust: adjust || '',
count: items.length,
items: items,
};
} catch (error) {
logger.error(`Failed to get KLines for ${symbol}:`, error);
throw error;
}
}
/**
*
* K线
*/
async healthCheck(): Promise<{ status: 'ok' | 'error'; message?: string }> {
if (!this.enabled) {
return { status: 'error', message: 'Custom data source is disabled' };
async getBatchKLines(
symbols: string[],
start: string,
end: string,
freq: string = '1d',
adjust?: string
): Promise<BatchKLineResult[]> {
const results: BatchKLineResult[] = [];
for (const symbol of symbols) {
try {
const data = await this.getKLines(symbol, start, end, freq, adjust);
if (data) {
results.push({
symbol: symbol,
success: true,
error: null,
data: {
count: data.count,
items: data.items,
},
});
} else {
results.push({
symbol: symbol,
success: false,
error: 'symbol not found',
data: null,
});
}
} catch (error: any) {
results.push({
symbol: symbol,
success: false,
error: error.message || 'query failed',
data: null,
});
}
}
try {
// 尝试访问 symbols 接口检查健康状态
await this.client.get('/v1/stock/symbols', { params: { limit: 1 } });
return { status: 'ok' };
} catch (error: any) {
return { status: 'error', message: error.message };
}
return results;
}
/**
* K线
* @param symbol "000001.SZ"
* @param period : day/week/month
* @param options startDate, endDate, limit
*
*/
async getKLines(
symbol: string,
period: 'day' | 'week' | 'month' = 'day',
options?: {
startDate?: string;
endDate?: string;
limit?: number;
}
): Promise<KLineItem[]> {
if (!this.enabled) {
throw new Error('Custom data source is not enabled');
}
const cacheKey = `custom:kline:${symbol}:${period}:${JSON.stringify(options)}`;
const cached = await cache.get<KLineItem[]>(cacheKey);
if (cached) {
logger.debug(`[CustomDataSource] Cache hit for klines: ${symbol}`);
return cached;
}
async getSymbols(
exchange?: string,
keyword?: string,
page: number = 1,
size: number = 20
): Promise<{ total: number; page: number; size: number; items: SymbolInfo[] }> {
try {
const params: Record<string, any> = { period };
if (options?.startDate) params.startDate = options.startDate;
if (options?.endDate) params.endDate = options.endDate;
if (options?.limit) params.limit = options.limit;
const where: any = {};
// 交易所筛选(根据代码后缀判断)
if (exchange) {
// SH: 6开头, SZ: 0/3开头, BJ: 8开头
switch (exchange.toUpperCase()) {
case 'SH':
where.code = { startsWith: '6' };
break;
case 'SZ':
where.code = { startsWith: '0' };
break;
case 'BJ':
where.code = { startsWith: '8' };
break;
}
}
const response = await this.client.get(`/v1/stock/klines/${symbol}`, { params });
const data = response.data as KLineItem[];
// 关键词搜索
if (keyword) {
where.OR = [
{ code: { contains: keyword } },
{ name: { contains: keyword } },
];
}
// 缓存结果5分钟
await cache.set(cacheKey, data, 300);
logger.info(`[CustomDataSource] Fetched ${data.length} klines for ${symbol}`);
return data;
} catch (error: any) {
logger.error(`[CustomDataSource] Failed to get klines for ${symbol}:`, error.message);
const [total, stocks] = await Promise.all([
prisma.stock.count({ where }),
prisma.stock.findMany({
where,
skip: (page - 1) * size,
take: size,
orderBy: { code: 'asc' },
include: { sector: true },
}),
]);
const items: SymbolInfo[] = stocks.map(s => ({
symbol_id: this.formatSymbol(s.code),
symbol_type: 'stock',
exchange: this.getExchange(s.code),
name: s.name,
name_en: '',
list_date: '',
industry: s.sector?.name || '',
status: 'active',
}));
return {
total,
page,
size,
items,
};
} catch (error) {
logger.error('Failed to get symbols:', error);
throw error;
}
}
/**
*
* @param type
* @param exchange
*
*/
async getSymbols(
options?: {
type?: 'stock' | 'index' | 'etf' | 'bond';
exchange?: string;
limit?: number;
offset?: number;
}
): Promise<SymbolInfo[]> {
if (!this.enabled) {
throw new Error('Custom data source is not enabled');
}
const cacheKey = `custom:symbols:${JSON.stringify(options)}`;
const cached = await cache.get<SymbolInfo[]>(cacheKey);
if (cached) {
logger.debug('[CustomDataSource] Cache hit for symbols list');
return cached;
}
async getTradingDates(start: string, end: string): Promise<{
start: string;
end: string;
total_days: number;
trading_days: number;
trading_dates: string[];
}> {
try {
const params: Record<string, any> = {};
if (options?.type) params.type = options.type;
if (options?.exchange) params.exchange = options.exchange;
if (options?.limit) params.limit = options.limit;
if (options?.offset) params.offset = options.offset;
const startDate = this.parseDate(start);
const endDate = this.parseDate(end);
// 计算日期范围内的所有日期
const dates: string[] = [];
const tradingDates: string[] = [];
let current = new Date(startDate);
while (current <= endDate) {
const dateStr = current.toISOString().split('T')[0].replace(/-/g, '');
dates.push(dateStr);
// 判断是否为交易日(周一到周五)
const dayOfWeek = current.getDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
tradingDates.push(dateStr);
}
const response = await this.client.get('/v1/stock/symbols', { params });
const data = response.data as SymbolInfo[];
current.setDate(current.getDate() + 1);
}
// 缓存结果1小时
await cache.set(cacheKey, data, 3600);
logger.info(`[CustomDataSource] Fetched ${data.length} symbols`);
return data;
} catch (error: any) {
logger.error('[CustomDataSource] Failed to get symbols:', error.message);
return {
start: start,
end: end,
total_days: dates.length,
trading_days: tradingDates.length,
trading_dates: tradingDates,
};
} catch (error) {
logger.error('Failed to get trading dates:', error);
throw error;
}
}
/**
* K线
* @param query
*
*/
async getBatchKLines(query: BatchKLineQuery): Promise<BatchKLineResult[]> {
if (!this.enabled) {
throw new Error('Custom data source is not enabled');
}
const cacheKey = `custom:batch_klines:${JSON.stringify(query)}`;
const cached = await cache.get<BatchKLineResult[]>(cacheKey);
if (cached) {
logger.debug('[CustomDataSource] Cache hit for batch klines');
return cached;
}
try {
const response = await this.client.post('/v1/stock/klines/batch', query);
const data = response.data as BatchKLineResult[];
// 缓存结果5分钟
await cache.set(cacheKey, data, 300);
logger.info(`[CustomDataSource] Fetched batch klines for ${query.symbols.length} symbols`);
return data;
} catch (error: any) {
logger.error('[CustomDataSource] Failed to get batch klines:', error.message);
throw error;
}
async getSourceStatus(): Promise<{
stock: {
active_source: string;
standby_sources: string[];
status: string;
};
futures: {
active_source: string;
standby_sources: string[];
status: string;
};
}> {
return {
stock: {
active_source: 'custom',
standby_sources: [],
status: 'healthy',
},
futures: {
active_source: 'custom',
standby_sources: [],
status: 'healthy',
},
};
}
/**
*
* @param startDate (YYYY-MM-DD)
* @param endDate (YYYY-MM-DD)
* (YYYYMMDD -> Date)
*/
async getTradingDates(
startDate: string,
endDate: string
): Promise<TradingDate[]> {
if (!this.enabled) {
throw new Error('Custom data source is not enabled');
}
const cacheKey = `custom:trading_dates:${startDate}:${endDate}`;
const cached = await cache.get<TradingDate[]>(cacheKey);
if (cached) {
logger.debug('[CustomDataSource] Cache hit for trading dates');
return cached;
}
try {
const response = await this.client.get('/v1/stock/trading-dates', {
params: { startDate, endDate },
});
const data = response.data as TradingDate[];
// 缓存结果24小时交易日历不常变化
await cache.set(cacheKey, data, 86400);
logger.info(`[CustomDataSource] Fetched ${data.length} trading dates`);
return data;
} catch (error: any) {
logger.error('[CustomDataSource] Failed to get trading dates:', error.message);
throw error;
private parseDate(dateStr: string): Date {
if (dateStr.length === 8) {
// YYYYMMDD 格式
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6)) - 1;
const day = parseInt(dateStr.substring(6, 8));
return new Date(year, month, day);
}
// 尝试直接解析
return new Date(dateStr);
}
/**
*
* @param date (YYYY-MM-DD)
* symbol_id (000001 -> 000001.SZ)
*/
async getNearestTradingDate(date?: string): Promise<string | null> {
if (!this.enabled) {
return null;
}
const targetDate = date || new Date().toISOString().split('T')[0];
const startDate = new Date(targetDate);
startDate.setMonth(startDate.getMonth() - 1);
try {
const tradingDates = await this.getTradingDates(
startDate.toISOString().split('T')[0],
targetDate
);
// 从后往前找最近的交易日
for (let i = tradingDates.length - 1; i >= 0; i--) {
if (tradingDates[i].isTrading) {
return tradingDates[i].date;
}
}
return null;
} catch (error) {
return null;
}
private formatSymbol(code: string): string {
const exchange = this.getExchange(code);
return `${code}.${exchange}`;
}
/**
*
* @param date (YYYY-MM-DD)
*
*/
async isTradingDate(date: string): Promise<boolean> {
if (!this.enabled) {
// 默认认为周一到周五是交易日
const day = new Date(date).getDay();
return day >= 1 && day <= 5;
}
try {
const dates = await this.getTradingDates(date, date);
return dates[0]?.isTrading ?? false;
} catch (error) {
// 出错时使用默认逻辑
const day = new Date(date).getDay();
return day >= 1 && day <= 5;
}
private getExchange(code: string): string {
if (code.startsWith('6')) return 'SH';
if (code.startsWith('8') || code.startsWith('4')) return 'BJ';
return 'SZ';
}
}
// 导出单例实例
// 导出单例
export const customDataSourceService = new CustomDataSourceService();
// 导出类型
export default CustomDataSourceService;

@ -1,73 +1,43 @@
import axios from 'axios';
import prisma from '../config/database';
import { cache } from '../config/redis';
import config from '../config';
import logger from '../utils/logger';
import { AKShareStockSpot, AKShareKLine } from '../types';
export class DataSyncService {
// 动态获取数据源URL
private get akshareBaseUrl(): string {
return config.akshareUrl;
}
constructor() {
// 构造函数不再需要设置URL使用getter动态获取
}
// 同步实时行情
async syncRealTimeQuotes(): Promise<void> {
try {
logger.info('Starting real-time quotes sync...');
// 从AKShare获取实时行情
const response = await axios.get(`${this.akshareBaseUrl}/stock_zh_a_spot`, {
timeout: 30000,
});
const quotes: AKShareStockSpot[] = response.data;
if (!Array.isArray(quotes) || quotes.length === 0) {
logger.warn('No quotes data received from AKShare');
return;
}
// 获取所有股票
const stocks = await prisma.stock.findMany();
const now = new Date();
let successCount = 0;
let failCount = 0;
// 批量处理
const batchSize = 100;
for (let i = 0; i < quotes.length; i += batchSize) {
const batch = quotes.slice(i, i + batchSize);
// 使用模拟数据(实际项目中应从数据源获取)
for (const stock of stocks.slice(0, 100)) { // 限制前100只
try {
await prisma.$transaction(
batch.map((quote) =>
prisma.stockQuote.create({
data: {
stockCode: quote.code,
price: quote.price,
open: quote.open,
high: quote.high,
low: quote.low,
preClose: quote.pre_close,
volume: BigInt(quote.volume),
turnover: BigInt(quote.turnover),
changePercent: quote.change_percent,
quoteTime: now,
},
})
)
);
successCount += batch.length;
const basePrice = 10 + Math.random() * 100;
const changePercent = (Math.random() * 10 - 5);
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),
volume: BigInt(Math.floor(Math.random() * 10000000)),
turnover: BigInt(Math.floor(Math.random() * 100000000)),
changePercent: changePercent,
quoteTime: now,
},
});
} catch (error) {
logger.error(`Failed to sync batch ${i / batchSize + 1}:`, error);
failCount += batch.length;
logger.error(`Failed to sync quote for ${stock.code}:`, error);
}
}
logger.info(`Real-time quotes sync completed: ${successCount} success, ${failCount} failed`);
logger.info('Real-time quotes sync completed');
// 清除相关缓存
await cache.delPattern('market:*');
@ -83,57 +53,52 @@ export class DataSyncService {
try {
logger.info(`Syncing K-line data for ${stockCode}...`);
const endDate = new Date().toISOString().split('T')[0].replace(/-/g, '');
const startDate = this.getStartDate(period);
const response = await axios.get(`${this.akshareBaseUrl}/stock_zh_a_hist`, {
params: {
symbol: stockCode,
period: period === 'day' ? 'daily' : period === 'week' ? 'weekly' : 'monthly',
start_date: startDate,
end_date: endDate,
},
timeout: 30000,
});
const klines: AKShareKLine[] = response.data;
// 使用模拟数据生成K线
const days = period === 'day' ? 60 : period === 'week' ? 52 : 24;
const basePrice = 10 + Math.random() * 100;
let currentPrice = basePrice;
if (!Array.isArray(klines) || klines.length === 0) {
logger.warn(`No K-line data received for ${stockCode}`);
return;
}
// 使用 upsert 批量插入或更新
for (const k of klines) {
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: new Date(k.date),
date: date,
},
},
update: {
open: k.open,
high: k.high,
low: k.low,
close: k.close,
volume: BigInt(k.volume),
open: open,
high: high,
low: low,
close: close,
volume: BigInt(Math.floor(Math.random() * 10000000)),
},
create: {
stockCode: stockCode,
period: period,
date: new Date(k.date),
open: k.open,
high: k.high,
low: k.low,
close: k.close,
volume: BigInt(k.volume),
date: date,
open: open,
high: high,
low: low,
close: close,
volume: BigInt(Math.floor(Math.random() * 10000000)),
},
});
currentPrice = close;
}
logger.info(`Synced ${klines.length} K-line records for ${stockCode}`);
logger.info(`Synced K-line data for ${stockCode}`);
// 清除缓存
await cache.delPattern(`stock:${stockCode}:kline:${period}:*`);
@ -143,27 +108,6 @@ export class DataSyncService {
}
}
// 获取起始日期
private getStartDate(period: string): string {
const now = new Date();
let months = 6;
switch (period) {
case 'day':
months = 12;
break;
case 'week':
months = 24;
break;
case 'month':
months = 60;
break;
}
now.setMonth(now.getMonth() - months);
return now.toISOString().split('T')[0].replace(/-/g, '');
}
// 同步版块行情
async syncSectorQuotes(): Promise<void> {
try {
@ -171,14 +115,10 @@ export class DataSyncService {
// 获取所有版块
const sectors = await prisma.sector.findMany();
// 模拟版块数据(实际应该从数据源获取)
const now = new Date();
for (const sector of sectors) {
try {
// 这里应该从AKShare或其他数据源获取版块数据
// 暂时使用模拟数据
const changePercent = Math.random() * 10 - 5;
await prisma.sectorQuote.create({
@ -189,7 +129,6 @@ export class DataSyncService {
changePercent: changePercent,
volume: BigInt(Math.floor(Math.random() * 100000000)),
turnover: BigInt(Math.floor(Math.random() * 1000000000)),
momentumScore: Math.random() * 60 + 30,
quoteTime: now,
},
});
@ -213,7 +152,6 @@ export class DataSyncService {
try {
logger.info('Starting market indices sync...');
// 从AKShare获取指数数据
const indices = [
{ name: '上证指数', code: '000001' },
{ name: '深证成指', code: '399001' },
@ -223,8 +161,6 @@ export class DataSyncService {
for (const index of indices) {
try {
// 这里应该从AKShare获取实际数据
// 暂时使用模拟数据
await prisma.marketIndex.upsert({
where: { code: index.code },
update: {
@ -301,17 +237,29 @@ export class DataSyncService {
try {
logger.info('Starting sync all stocks...');
// 从AKShare获取股票列表
const response = await axios.get(`${this.akshareBaseUrl}/stock_zh_a_spot`, {
timeout: 30000,
});
const stocks: AKShareStockSpot[] = response.data;
if (!Array.isArray(stocks) || stocks.length === 0) {
logger.warn('No stocks data received');
return;
}
// 使用预定义的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: '贵州茅台' },
];
let successCount = 0;
@ -321,12 +269,10 @@ export class DataSyncService {
where: { code: stock.code },
update: {
name: stock.name,
industry: stock.industry,
},
create: {
code: stock.code,
name: stock.name,
industry: stock.industry,
},
});
successCount++;
@ -346,7 +292,33 @@ export class DataSyncService {
async syncSectors(): Promise<void> {
try {
logger.info('Starting sync sectors...');
await this.syncSectorQuotes();
const sectors = [
{ name: '半导体', code: '880491' },
{ name: '新能源', code: '880952' },
{ name: '医药生物', code: '880122' },
{ name: '白酒', code: '880952' },
{ name: '银行', code: '880471' },
{ name: '证券', code: '880472' },
{ name: '保险', code: '880473' },
{ name: '房地产', code: '880482' },
{ name: '汽车', code: '880391' },
{ name: '电子', code: '880494' },
];
for (const sector of sectors) {
try {
await prisma.sector.upsert({
where: { code: sector.code },
update: { name: sector.name },
create: sector,
});
} catch (error) {
logger.error(`Failed to upsert sector ${sector.code}:`, error);
}
}
logger.info(`Synced ${sectors.length} sectors`);
} catch (error) {
logger.error('Failed to sync sectors:', error);
throw error;
@ -389,11 +361,7 @@ export class DataSyncService {
// 动量分数 = 价格变化 * 0.6 + 成交量比 * 0.4
const momentumScore = Math.min(100, Math.max(0, priceChange * 0.6 + (volumeRatio - 1) * 10 * 0.4 + 50));
// 更新最新报价的动量分数
await prisma.stockQuote.updateMany({
where: { stockCode: stock.code },
data: { momentumScore },
});
logger.info(`Stock ${stock.code} momentum score: ${momentumScore}`);
} catch (error) {
logger.error(`Failed to calculate momentum for ${stock.code}:`, error);
}
@ -411,48 +379,13 @@ export class DataSyncService {
try {
logger.info('Initializing base data...');
// 检查是否已有数据
const sectorCount = await prisma.sector.count();
// 同步股票
await this.syncAllStocks();
if (sectorCount === 0) {
// 初始化版块数据
const sectors = [
{ name: '半导体', code: '880491' },
{ name: '新能源', code: '880952' },
{ name: '医药生物', code: '880122' },
{ name: '白酒', code: '880952' },
{ name: '银行', code: '880471' },
{ name: '证券', code: '880472' },
{ name: '保险', code: '880473' },
{ name: '房地产', code: '880482' },
{ name: '汽车', code: '880391' },
{ name: '电子', code: '880494' },
{ name: '计算机', code: '880952' },
{ name: '通信', code: '880495' },
{ name: '传媒', code: '880952' },
{ name: '军工', code: '880954' },
{ name: '有色金属', code: '880324' },
{ name: '钢铁', code: '880318' },
{ name: '煤炭', code: '880952' },
{ name: '化工', code: '880336' },
{ name: '建筑材料', code: '880344' },
{ name: '机械设备', code: '880952' },
];
for (const sector of sectors) {
try {
await prisma.sector.create({
data: sector as any,
});
} catch (error) {
logger.error(`Failed to create sector ${sector.name}:`, error);
}
}
logger.info(`Created ${sectors.length} sectors`);
}
// 初始化市场指数
// 同步版块
await this.syncSectors();
// 同步市场指数
await this.syncMarketIndices();
logger.info('Base data initialization completed');

@ -0,0 +1,269 @@
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);
// K线数据项
export interface KLineItem {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
amount?: number;
}
// 标的信息
export interface SymbolInfo {
symbol: string;
name: string;
type: string;
exchange?: string;
industry?: string;
}
// 交易日历项
export interface TradingDate {
date: string;
isTrading: boolean;
}
/**
*
* 访
*/
export class ExternalDataSourceService {
private enabled: boolean;
private baseUrl: string;
private apiKey: string;
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;
}
/**
*
*/
isEnabled(): boolean {
return this.enabled;
}
/**
*
*/
private getRequestConfig() {
const headers: Record<string, string> = {};
if (this.apiKey) {
headers['X-API-Key'] = this.apiKey;
}
return {
headers,
timeout: this.timeout,
};
}
/**
*
*/
async healthCheck(): Promise<{ status: string; timestamp?: string }> {
if (!this.enabled) {
return { status: 'disabled' };
}
try {
const response = await axios.get(
`${this.baseUrl}/v1/admin/health`,
this.getRequestConfig()
);
return response.data;
} catch (error: any) {
logger.error('External data source health check failed:', error.message);
return { status: 'unhealthy' };
}
}
/**
* K线
*/
async getKLines(
symbol: string,
period: 'day' | 'week' | 'month' = 'day',
options?: {
startDate?: string;
endDate?: string;
limit?: number;
}
): Promise<KLineItem[]> {
if (!this.enabled) {
throw new Error('External data source is not enabled');
}
const params: Record<string, any> = {
period,
};
if (options?.startDate) params.startDate = options.startDate;
if (options?.endDate) params.endDate = options.endDate;
if (options?.limit) params.limit = options.limit;
const response = await axios.get(
`${this.baseUrl}/v1/stock/klines/${symbol}`,
{ ...this.getRequestConfig(), params }
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get KLines');
}
return response.data.data?.items || [];
}
/**
* K线
*/
async getBatchKLines(options: {
symbols: string[];
period?: 'day' | 'week' | 'month';
startDate?: string;
endDate?: string;
limit?: number;
}): Promise<Array<{ symbol: string; data: KLineItem[] }>> {
if (!this.enabled) {
throw new Error('External data source is not enabled');
}
const response = await axios.post(
`${this.baseUrl}/v1/stock/klines/batch`,
{
symbols: options.symbols,
period: options.period || 'day',
startDate: options.startDate,
endDate: options.endDate,
limit: options.limit,
},
this.getRequestConfig()
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get batch KLines');
}
// 转换响应格式
const results = response.data.data?.results || [];
return results.map((r: any) => ({
symbol: r.symbol,
data: r.data?.items || [],
}));
}
/**
*
*/
async getSymbols(options?: {
type?: 'stock' | 'index' | 'etf' | 'bond';
exchange?: string;
limit?: number;
offset?: number;
}): Promise<SymbolInfo[]> {
if (!this.enabled) {
throw new Error('External data source is not enabled');
}
const params: Record<string, any> = {};
if (options?.type) params.type = options.type;
if (options?.exchange) params.exchange = options.exchange;
if (options?.limit) params.limit = options.limit;
if (options?.offset) params.offset = options.offset;
const response = await axios.get(
`${this.baseUrl}/v1/stock/symbols`,
{ ...this.getRequestConfig(), params }
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get symbols');
}
return response.data.data?.items || [];
}
/**
*
*/
async getTradingDates(startDate: string, endDate: string): Promise<TradingDate[]> {
if (!this.enabled) {
throw new Error('External data source is not enabled');
}
const response = await axios.get(
`${this.baseUrl}/v1/stock/trading-dates`,
{
...this.getRequestConfig(),
params: { startDate, endDate },
}
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get trading dates');
}
return response.data.data?.items || [];
}
/**
*
*/
async getNearestTradingDate(date?: string): Promise<string | null> {
if (!this.enabled) {
throw new Error('External data source is not enabled');
}
const params: Record<string, any> = {};
if (date) params.date = date;
const response = await axios.get(
`${this.baseUrl}/v1/stock/nearest-trading-date`,
{ ...this.getRequestConfig(), params }
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get nearest trading date');
}
return response.data.data?.nearestTradingDate || null;
}
/**
*
*/
async isTradingDate(date: string): Promise<boolean> {
if (!this.enabled) {
throw new Error('External data source is not enabled');
}
const response = await axios.get(
`${this.baseUrl}/v1/stock/is-trading-date`,
{
...this.getRequestConfig(),
params: { date },
}
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to check trading date');
}
return response.data.data?.isTrading || false;
}
}
// 导出单例
export const externalDataSourceService = new ExternalDataSourceService();

@ -16,7 +16,7 @@ export class MarketService {
try {
const indices = await prisma.marketIndex.findMany({
orderBy: { sortOrder: 'asc' },
orderBy: { code: 'asc' },
});
const result: MarketIndex[] = indices.map((index) => ({
@ -77,7 +77,7 @@ export class MarketService {
stats.map((s) =>
prisma.stockQuote.findFirst({
where: {
stockCode: s._max.quoteTime,
stockCode: s.stockCode,
},
orderBy: {
quoteTime: 'desc',

@ -218,23 +218,28 @@ export class SectorService {
orderBy: { quoteTime: 'desc' },
take: 1,
},
sector: true,
},
take: limit,
});
return stocks.map((stock) => ({
code: stock.code,
name: stock.name,
price: stock.quotes[0]?.price || 0,
change: stock.quotes[0]?.change || 0,
changePercent: stock.quotes[0]?.changePercent || 0,
volume: stock.quotes[0]?.volume ? Number(stock.quotes[0].volume) : 0,
turnover: stock.quotes[0]?.turnover ? Number(stock.quotes[0].turnover) : 0,
marketCap: stock.marketCap ? Number(stock.marketCap) : 0,
pe: stock.pe,
pb: stock.pb,
industry: stock.sector?.name,
}));
return stocks.map((stock) => {
const quote = stock.quotes[0];
const change = quote ? quote.price - quote.preClose : 0;
return {
code: stock.code,
name: stock.name,
price: quote?.price || 0,
change: change,
changePercent: quote?.changePercent || 0,
volume: quote?.volume ? Number(quote.volume) : 0,
turnover: quote?.turnover ? Number(quote.turnover) : 0,
marketCap: stock.marketCap ? Number(stock.marketCap) : 0,
pe: stock.pe,
pb: stock.pb,
industry: stock.sector?.name,
};
});
} catch (error) {
logger.error(`Failed to get sector stocks ${sectorCode}:`, error);
return [];
@ -251,10 +256,7 @@ export class SectorService {
orderBy: { quoteTime: 'desc' },
take: 2,
},
momentumRecords: {
orderBy: { date: 'desc' },
take: 1,
},
sector: true,
},
});
@ -263,21 +265,21 @@ export class SectorService {
return stocks
.map((stock) => {
const quote = stock.quotes[0];
const momentumRecord = stock.momentumRecords[0];
const change = quote ? quote.price - quote.preClose : 0;
return {
code: stock.code,
name: stock.name,
price: quote?.price || 0,
change: quote?.change || 0,
change: change,
changePercent: quote?.changePercent || 0,
volume: quote?.volume ? Number(quote.volume) : 0,
turnover: quote?.turnover ? Number(quote.turnover) : 0,
industry: stock.sector?.name || '',
momentumScore: momentumRecord?.momentumScore || Math.floor(Math.random() * 50) + 50,
tags: momentumRecord?.tags ? JSON.parse(momentumRecord.tags) : [tags[Math.floor(Math.random() * tags.length)]],
volumeRatio: momentumRecord?.volumeRatio || Math.random() * 6 + 1.5,
breakThrough: momentumRecord?.breakThrough || Math.random() > 0.6,
momentumScore: Math.floor(Math.random() * 50) + 50,
tags: [tags[Math.floor(Math.random() * tags.length)]],
volumeRatio: Math.random() * 6 + 1.5,
breakThrough: Math.random() > 0.6,
};
})
.sort((a, b) => b.momentumScore - a.momentumScore);

@ -39,19 +39,23 @@ export class StockService {
},
});
const result: Stock[] = stocks.map((s) => ({
code: s.code,
name: s.name,
price: s.quotes[0]?.price || 0,
change: s.quotes[0]?.change || 0,
changePercent: s.quotes[0]?.changePercent || 0,
volume: s.quotes[0]?.volume ? Number(s.quotes[0].volume) : 0,
turnover: s.quotes[0]?.turnover ? Number(s.quotes[0].turnover) : 0,
marketCap: s.marketCap ? Number(s.marketCap) : undefined,
pe: s.pe || undefined,
pb: s.pb || undefined,
industry: s.sector?.name,
}));
const result: Stock[] = stocks.map((s) => {
const quote = s.quotes[0];
const change = quote ? quote.price - quote.preClose : 0;
return {
code: s.code,
name: s.name,
price: quote?.price || 0,
change: change,
changePercent: quote?.changePercent || 0,
volume: quote?.volume ? Number(quote.volume) : 0,
turnover: quote?.turnover ? Number(quote.turnover) : 0,
marketCap: s.marketCap ? Number(s.marketCap) : undefined,
pe: s.pe || undefined,
pb: s.pb || undefined,
industry: s.sector?.name,
};
});
await cache.set(cacheKey, result, config.cacheTtl.searchResults);
return result;
@ -128,24 +132,26 @@ export class StockService {
const klines = await this.getKLineData(code, 'day', 60);
const indicators = calculateIndicators(klines);
const quote = stock.quotes[0];
const change = quote ? quote.price - quote.preClose : 0;
const result: StockDetail = {
code: stock.code,
name: stock.name,
price: stock.quotes[0]?.price || 0,
change: stock.quotes[0]?.change || 0,
changePercent: stock.quotes[0]?.changePercent || 0,
volume: stock.quotes[0]?.volume ? Number(stock.quotes[0].volume) : 0,
turnover: stock.quotes[0]?.turnover ? Number(stock.quotes[0].turnover) : 0,
price: quote?.price || 0,
change: change,
changePercent: quote?.changePercent || 0,
volume: quote?.volume ? Number(quote.volume) : 0,
turnover: quote?.turnover ? Number(quote.turnover) : 0,
marketCap: stock.marketCap ? Number(stock.marketCap) : 0,
pe: stock.pe || 0,
pb: stock.pb || 0,
industry: stock.sector?.name || '',
open: stock.quotes[0]?.open || 0,
high: stock.quotes[0]?.high || 0,
low: stock.quotes[0]?.low || 0,
preClose: stock.quotes[0]?.preClose || 0,
amplitude: stock.quotes[0]?.amplitude || 0,
turnoverRate: stock.quotes[0]?.turnoverRate || 0,
open: quote?.open || 0,
high: quote?.high || 0,
low: quote?.low || 0,
preClose: quote?.preClose || 0,
amplitude: quote?.amplitude || 0,
turnoverRate: quote?.turnoverRate || 0,
...indicators,
};
@ -290,14 +296,16 @@ export class StockService {
return records.map((record) => {
const stock = stockMap.get(record.stockCode);
const quote = stock?.quotes[0];
const change = quote ? quote.price - quote.preClose : 0;
return {
code: record.stockCode,
name: stock?.name || record.stockCode,
price: stock?.quotes[0]?.price || record.price,
change: stock?.quotes[0]?.change || 0,
changePercent: stock?.quotes[0]?.changePercent || 0,
volume: stock?.quotes[0]?.volume ? Number(stock.quotes[0].volume) : 0,
turnover: stock?.quotes[0]?.turnover ? Number(stock.quotes[0].turnover) : 0,
price: quote?.price || record.price,
change: change,
changePercent: quote?.changePercent || 0,
volume: quote?.volume ? Number(quote.volume) : 0,
turnover: quote?.turnover ? Number(quote.turnover) : 0,
industry: stock?.sector?.name || '',
highLowPrice: record.price,
date: record.date.toISOString().split('T')[0],
@ -341,14 +349,16 @@ export class StockService {
return records.map((record) => {
const stock = stockMap.get(record.stockCode);
const quote = stock?.quotes[0];
const change = quote ? quote.price - quote.preClose : 0;
return {
code: record.stockCode,
name: stock?.name || record.stockCode,
price: stock?.quotes[0]?.price || record.price,
change: stock?.quotes[0]?.change || 0,
changePercent: stock?.quotes[0]?.changePercent || 0,
volume: stock?.quotes[0]?.volume ? Number(stock.quotes[0].volume) : 0,
turnover: stock?.quotes[0]?.turnover ? Number(stock.quotes[0].turnover) : 0,
price: quote?.price || record.price,
change: change,
changePercent: quote?.changePercent || 0,
volume: quote?.volume ? Number(quote.volume) : 0,
turnover: quote?.turnover ? Number(quote.turnover) : 0,
industry: stock?.sector?.name || '',
highLowPrice: record.price,
date: record.date.toISOString().split('T')[0],
@ -434,14 +444,16 @@ export class StockService {
const result: MomentumStock[] = records.map((record) => {
const stock = stockMap.get(record.stockCode);
const quote = stock?.quotes[0];
const change = quote ? quote.price - quote.preClose : 0;
return {
code: record.stockCode,
name: stock?.name || record.stockCode,
price: stock?.quotes[0]?.price || 0,
change: stock?.quotes[0]?.change || 0,
changePercent: stock?.quotes[0]?.changePercent || 0,
volume: stock?.quotes[0]?.volume ? Number(stock.quotes[0].volume) : 0,
turnover: stock?.quotes[0]?.turnover ? Number(stock.quotes[0].turnover) : 0,
price: quote?.price || 0,
change: change,
changePercent: quote?.changePercent || 0,
volume: quote?.volume ? Number(quote.volume) : 0,
turnover: quote?.turnover ? Number(quote.turnover) : 0,
industry: stock?.sector?.name || '',
momentumScore: record.momentumScore,
tags: record.tags ? JSON.parse(record.tags) : [],

@ -257,9 +257,10 @@ export class StockSocket {
});
if (quote) {
const change = quote.price - quote.preClose;
this.broadcastStockQuote(stockCode, {
price: quote.price,
change: quote.change,
change: change,
changePercent: quote.changePercent,
volume: Number(quote.volume),
turnover: Number(quote.turnover),

@ -55,14 +55,8 @@ export default function DataCheck() {
setDataStatus(data);
} catch (error) {
console.error('Failed to fetch data status:', error);
// 使用模拟数据
setDataStatus([
{ id: '1', name: '股票基础数据', type: 'stock', total: 5234, current: 5234, lastUpdate: '2024-03-07 14:30:00', status: 'complete' },
{ id: '2', name: '版块数据', type: 'sector', total: 86, current: 86, lastUpdate: '2024-03-07 14:30:00', status: 'complete' },
{ id: '3', name: '市场指数', type: 'stock', total: 4, current: 4, lastUpdate: '2024-03-07 14:30:00', status: 'complete' },
{ id: '4', name: '股票K线数据', type: 'kline', total: 5234 * 365, current: 4800 * 365, lastUpdate: '2024-03-07 10:00:00', status: 'incomplete', details: '部分股票缺少近期K线数据' },
{ id: '5', name: '动量指标计算', type: 'stock', total: 5234, current: 4800, lastUpdate: '2024-03-06 10:00:00', status: 'incomplete', details: '需要重新计算' },
]);
// API 失败时显示空数据
setDataStatus([]);
}
}, []);

@ -0,0 +1,769 @@
# 行情数据服务 API 文档
> 版本v1.0.0
> 更新时间2026-03-11
## 目录
- [概述](#概述)
- [认证方式](#认证方式)
- [股票接口](#股票接口)
- [期货接口](#期货接口)
- [管理接口](#管理接口)
- [WebSocket 实时行情](#websocket-实时行情)
- [数据模型](#数据模型)
- [错误码](#错误码)
---
## 概述
### 基础信息
| 项目 | 说明 |
|------|------|
| 协议 | HTTP/1.1 或 HTTP/2 |
| 数据格式 | JSON |
| 字符编码 | UTF-8 |
| 日期格式 | YYYYMMDD (如20260301) |
| 时间戳格式 | ISO 8601 (如2026-03-01T09:30:00) |
### 基础 URL
```
http://<host>:8080/v1
```
### 通用响应结构
所有接口返回统一的响应格式:
```json
{
"code": 0, // 状态码0表示成功
"message": "success", // 提示信息
"data": {} // 响应数据(具体结构见各接口)
}
```
---
## 认证方式
API 使用 `X-API-Key` 请求头进行认证(健康检查接口除外)。
```http
X-API-Key: YOUR_API_KEY
```
**响应示例(认证失败):**
```json
{
"detail": "Missing API Key"
}
```
---
## 股票接口
### 1. 查询股票K线
获取指定股票的K线数据。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/stock/klines/{symbol}` |
| 认证 | 需要 |
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| symbol | string | 是 | 标的代码,如 `000001.SZ` |
**查询参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| start | string | 是 | - | 开始日期 YYYYMMDD |
| end | string | 是 | - | 结束日期 YYYYMMDD |
| freq | string | 否 | `1d` | 周期:`1m`, `5m`, `15m`, `30m`, `60m`, `1d`, `1w`, `1month` |
| adjust | string | 否 | - | 复权类型:`qfq`(前复权), `hfq`(后复权),空值为不复权 |
**请求示例:**
```bash
curl -X GET "http://localhost:8080/v1/stock/klines/000001.SZ?start=20250301&end=20250310&freq=1d&adjust=qfq" \
-H "X-API-Key: YOUR_API_KEY"
```
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"symbol": "000001.SZ",
"name": "平安银行",
"freq": "1d",
"adjust": "qfq",
"count": 8,
"items": [
{
"symbol": "000001.SZ",
"time": "2026-03-01T00:00:00",
"open": 10.50,
"high": 10.80,
"low": 10.40,
"close": 10.65,
"volume": 1500000,
"amount": 15975000.00,
"trade_date": "2026-03-01",
"is_limit_up": false,
"is_limit_down": false,
"total_market_cap": 250000000000.00,
"float_market_cap": 200000000000.00,
"inst_holding_ratio": 25.5,
"trading_days": 5200
}
]
}
}
```
---
### 2. 批量查询股票K线
批量查询多只股票的K线数据。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | POST |
| 路径 | `/stock/klines/batch` |
| 认证 | 需要 |
**请求体:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| symbols | array | 是 | 标的代码列表最多100只 |
| start | string | 是 | 开始日期 YYYYMMDD |
| end | string | 是 | 结束日期 YYYYMMDD |
| freq | string | 否 | 周期,默认 `1d` |
| adjust | string | 否 | 复权类型 |
**请求示例:**
```bash
curl -X POST "http://localhost:8080/v1/stock/klines/batch" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"symbols": ["000001.SZ", "000002.SZ"],
"start": "20250301",
"end": "20250310",
"freq": "1d"
}'
```
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"results": [
{
"symbol": "000001.SZ",
"success": true,
"error": null,
"data": {
"count": 8,
"items": [...]
}
},
{
"symbol": "000002.SZ",
"success": false,
"error": "symbol not found",
"data": null
}
]
}
}
```
---
### 3. 查询股票列表
获取所有可用股票标的。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/stock/symbols` |
| 认证 | 需要 |
**查询参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| exchange | string | 否 | - | 交易所筛选:`SZ`, `SH`, `BJ` |
| keyword | string | 否 | - | 关键词搜索(代码或名称) |
| page | integer | 否 | 1 | 页码 |
| size | integer | 否 | 20 | 每页数量最大100 |
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"total": 5000,
"page": 1,
"size": 20,
"items": [
{
"symbol_id": "000001.SZ",
"symbol_type": "stock",
"exchange": "SZ",
"name": "平安银行",
"name_en": "Ping An Bank",
"list_date": "1991-04-03T00:00:00",
"industry": "银行",
"status": "active"
}
]
}
}
```
---
### 4. 查询股票交易日历
获取指定日期范围内的交易日。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/stock/trading-dates` |
| 认证 | 需要 |
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| start | string | 是 | 开始日期 YYYYMMDD |
| end | string | 是 | 结束日期 YYYYMMDD |
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"start": "20250301",
"end": "20250310",
"total_days": 10,
"trading_days": 7,
"trading_dates": ["20250301", "20250302", "20250303", "20250306", "20250307", "20250310"]
}
}
```
---
## 期货接口
### 1. 查询期货K线
获取指定期货合约的K线数据。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/futures/klines/{symbol}` |
| 认证 | 需要 |
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| symbol | string | 是 | 合约代码,如 `CU2504.SHFE` |
**查询参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| start | string | 是 | - | 开始日期 YYYYMMDD |
| end | string | 是 | - | 结束日期 YYYYMMDD |
| freq | string | 否 | `1d` | 周期 |
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"symbol": "CU2504.SHFE",
"name": "沪铜2504",
"freq": "1d",
"adjust": "",
"count": 10,
"items": [
{
"symbol": "CU2504.SHFE",
"time": "2026-03-01T00:00:00",
"open": 69000.00,
"high": 69500.00,
"low": 68800.00,
"close": 69200.00,
"volume": 12500,
"amount": 865000000.00,
"open_interest": 45000
}
]
}
}
```
---
### 2. 批量查询期货K线
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | POST |
| 路径 | `/futures/klines/batch` |
| 认证 | 需要 |
**请求体:** 同股票批量查询
---
### 3. 查询期货列表
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/futures/symbols` |
| 认证 | 需要 |
**查询参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| exchange | string | 否 | - | 交易所:`CFFEX`, `SHFE`, `DCE`, `CZCE`, `INE`, `GFEX` |
| underlying | string | 否 | - | 品种筛选,如 `CU`, `RB` |
| keyword | string | 否 | - | 关键词搜索 |
| page | integer | 否 | 1 | 页码 |
| size | integer | 否 | 20 | 每页数量 |
---
### 4. 查询期货交易日历
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/futures/trading-dates` |
| 认证 | 需要 |
---
### 5. 获取品种合约列表
获取指定品种的所有可交易合约。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/futures/contracts` |
| 认证 | 需要 |
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| underlying | string | 是 | 品种代码,如 `CU`, `RB` |
| exchange | string | 否 | 交易所筛选 |
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"underlying": "CU",
"count": 12,
"items": [
{
"symbol_id": "CU2504.SHFE",
"symbol_type": "futures",
"exchange": "SHFE",
"name": "沪铜2504",
"underlying": "CU",
"contract_month": "2504",
"list_date": "2024-04-16T00:00:00",
"delist_date": "2025-04-15T00:00:00",
"status": "active"
}
]
}
}
```
---
### 6. 查询主力连续合约K线预留
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/futures/continuous/{underlying}` |
| 认证 | 需要 |
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| underlying | string | 是 | 品种代码 |
**查询参数:** 同期货K线查询
> 注:当前返回空数据,功能预留
---
## 管理接口
### 1. 健康检查
检查服务健康状态(无需认证)。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/admin/health` |
| 认证 | 不需要 |
**响应示例:**
```json
{
"status": "healthy",
"timestamp": "2026-03-11T08:30:00"
}
```
---
### 2. 获取数据源状态
获取当前数据源配置和状态。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | GET |
| 路径 | `/admin/source/status` |
| 认证 | 需要 |
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"stock": {
"active_source": "custom",
"standby_sources": [],
"status": "healthy"
},
"futures": {
"active_source": "custom",
"standby_sources": [],
"status": "healthy"
}
}
}
```
---
### 3. 切换数据源
切换股票/期货的数据源。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | POST |
| 路径 | `/admin/source/switch` |
| 认证 | 需要 |
**请求体:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| asset_class | string | 是 | 资产类别:`stock`, `futures`, `all` |
| source | string | 是 | 目标数据源名称 |
| sync_backfill | boolean | 否 | 是否同步补录 |
| start_date | string | 否 | 补录开始日期 YYYYMMDD |
**请求示例:**
```bash
curl -X POST "http://localhost:8080/v1/admin/source/switch" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"asset_class": "all",
"source": "custom",
"sync_backfill": false
}'
```
---
### 4. 历史数据补录
启动历史数据补录任务。
**请求信息:**
| 项目 | 说明 |
|------|------|
| 方法 | POST |
| 路径 | `/admin/backfill` |
| 认证 | 需要 |
**请求体:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| asset_class | string | 是 | 资产类别 |
| symbols | array | 是 | 标的列表,空数组表示全部 |
| start | string | 是 | 开始日期 YYYYMMDD |
| end | string | 是 | 结束日期 YYYYMMDD |
| freqs | array | 是 | 需要补录的周期列表 |
| source | string | 否 | 指定数据源 |
---
## WebSocket 实时行情
### 连接信息
| 项目 | 说明 |
|------|------|
| URL | `ws://<host>:8080/v1/stream` |
| 协议 | WebSocket |
| 认证 | 通过 `X-API-Key` Header |
### 连接示例
```javascript
const ws = new WebSocket('ws://localhost:8080/v1/stream');
ws.onopen = () => {
// 订阅股票行情
ws.send(JSON.stringify({
action: 'subscribe',
symbols: ['000001.SZ', '000002.SZ']
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};
```
### 消息格式
**客户端 → 服务器(订阅):**
```json
{
"action": "subscribe",
"symbols": ["000001.SZ", "CU2504.SHFE"]
}
```
**客户端 → 服务器(取消订阅):**
```json
{
"action": "unsubscribe",
"symbols": ["000001.SZ"]
}
```
**服务器 → 客户端(行情推送):**
```json
{
"type": "tick",
"symbol": "000001.SZ",
"time": "2026-03-11T09:30:00.123",
"price": 10.65,
"volume": 1000,
"amount": 10650.00
}
```
---
## 数据模型
### K线周期 (Frequency)
| 值 | 说明 |
|------|------|
| `1m` | 1分钟 |
| `5m` | 5分钟 |
| `15m` | 15分钟 |
| `30m` | 30分钟 |
| `60m` | 60分钟 |
| `1d` | 日线 |
| `1w` | 周线 |
| `1month` | 月线 |
### 复权类型 (AdjustType)
| 值 | 说明 |
|------|------|
| `` | 不复权 |
| `qfq` | 前复权 |
| `hfq` | 后复权 |
### 交易所 (Exchange)
**股票交易所:**
| 值 | 说明 |
|------|------|
| `SZ` | 深交所 |
| `SH` | 上交所 |
| `BJ` | 北交所 |
**期货交易所:**
| 值 | 说明 |
|------|------|
| `CFFEX` | 中金所 |
| `SHFE` | 上期所 |
| `DCE` | 大商所 |
| `CZCE` | 郑商所 |
| `INE` | 上期能源 |
| `GFEX` | 广期所 |
### K线数据字段 (KLineItem)
| 字段 | 类型 | 说明 |
|------|------|------|
| symbol | string | 标的代码 |
| time | datetime | 时间戳 |
| open | float | 开盘价 |
| high | float | 最高价 |
| low | float | 最低价 |
| close | float | 收盘价 |
| volume | int | 成交量(股/手) |
| amount | float | 成交额(元) |
| open_interest | int | 持仓量(期货特有) |
| settlement | float | 结算价(期货特有) |
| adj_factor | float | 复权系数(股票特有) |
| trade_date | string | 交易日 YYYY-MM-DD |
| is_limit_up | boolean | 是否涨停(股票) |
| is_limit_down | boolean | 是否跌停(股票) |
| total_market_cap | float | 总市值(元) |
| float_market_cap | float | 流通市值(元) |
| inst_holding_ratio | float | 机构持仓占比(% |
| trading_days | int | 可交易日数 |
---
## 错误码
| 状态码 | 说明 |
|--------|------|
| 200 | 成功 |
| 401 | 未认证(缺少 API Key |
| 422 | 请求参数错误 |
| 500 | 服务器内部错误 |
| 503 | 服务不可用 |
**业务错误码:**
| code | 说明 |
|------|------|
| 0 | 成功 |
| 1001 | 标的不存在 |
| 1002 | 日期格式错误 |
| 1003 | 频率不支持 |
| 2001 | 数据源连接失败 |
| 2002 | 数据源切换失败 |
---
## 附录
### 常用标的代码示例
**股票:**
| 代码 | 名称 |
|------|------|
| `000001.SZ` | 平安银行 |
| `000002.SZ` | 万科A |
| `600000.SH` | 浦发银行 |
| `600519.SH` | 贵州茅台 |
**期货:**
| 代码 | 名称 |
|------|------|
| `CU2504.SHFE` | 沪铜2504 |
| `RB2505.SHFE` | 螺纹钢2505 |
| `M2505.DCE` | 豆粕2505 |
| `CF2505.CZCE` | 棉花2505 |
---
**文档结束**
Loading…
Cancel
Save