|
|
|
|
|
# A股智投分析平台 - 后端实现文档
|
|
|
|
|
|
|
|
|
|
|
|
## 一、技术选型
|
|
|
|
|
|
|
|
|
|
|
|
### 1.1 推荐方案: Node.js + Express
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
技术栈:
|
|
|
|
|
|
- Node.js 20.x LTS
|
|
|
|
|
|
- Express 4.x
|
|
|
|
|
|
- TypeScript 5.x
|
|
|
|
|
|
- Prisma ORM
|
|
|
|
|
|
- Socket.io (WebSocket)
|
|
|
|
|
|
- Redis (缓存)
|
|
|
|
|
|
- MySQL 8.0 (数据库)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 1.2 备选方案: Python + FastAPI
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
技术栈:
|
|
|
|
|
|
- Python 3.11
|
|
|
|
|
|
- FastAPI
|
|
|
|
|
|
- SQLAlchemy
|
|
|
|
|
|
- WebSockets
|
|
|
|
|
|
- Celery (定时任务)
|
|
|
|
|
|
- Redis
|
|
|
|
|
|
- PostgreSQL
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 二、项目结构
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
backend/
|
|
|
|
|
|
├── src/
|
|
|
|
|
|
│ ├── config/ # 配置文件
|
|
|
|
|
|
│ │ ├── database.ts # 数据库配置
|
|
|
|
|
|
│ │ ├── redis.ts # Redis配置
|
|
|
|
|
|
│ │ └── constants.ts # 常量定义
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ ├── controllers/ # 控制器层
|
|
|
|
|
|
│ │ ├── marketController.ts
|
|
|
|
|
|
│ │ ├── sectorController.ts
|
|
|
|
|
|
│ │ ├── stockController.ts
|
|
|
|
|
|
│ │ └── userController.ts
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ ├── services/ # 业务逻辑层
|
|
|
|
|
|
│ │ ├── marketService.ts
|
|
|
|
|
|
│ │ ├── sectorService.ts
|
|
|
|
|
|
│ │ ├── stockService.ts
|
|
|
|
|
|
│ │ ├── dataSyncService.ts
|
|
|
|
|
|
│ │ └── calculationService.ts
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ ├── models/ # 数据模型层
|
|
|
|
|
|
│ │ ├── Stock.ts
|
|
|
|
|
|
│ │ ├── Sector.ts
|
|
|
|
|
|
│ │ ├── KLine.ts
|
|
|
|
|
|
│ │ └── User.ts
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ ├── routes/ # 路由定义
|
|
|
|
|
|
│ │ ├── marketRoutes.ts
|
|
|
|
|
|
│ │ ├── sectorRoutes.ts
|
|
|
|
|
|
│ │ ├── stockRoutes.ts
|
|
|
|
|
|
│ │ └── userRoutes.ts
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ ├── middleware/ # 中间件
|
|
|
|
|
|
│ │ ├── auth.ts # 认证中间件
|
|
|
|
|
|
│ │ ├── errorHandler.ts # 错误处理
|
|
|
|
|
|
│ │ ├── rateLimiter.ts # 限流
|
|
|
|
|
|
│ │ └── logger.ts # 日志
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ ├── utils/ # 工具函数
|
|
|
|
|
|
│ │ ├── logger.ts
|
|
|
|
|
|
│ │ ├── validator.ts
|
|
|
|
|
|
│ │ ├── formatter.ts
|
|
|
|
|
|
│ │ └── maCalculator.ts # 均线计算
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ ├── websocket/ # WebSocket服务
|
|
|
|
|
|
│ │ └── stockSocket.ts
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ ├── jobs/ # 定时任务
|
|
|
|
|
|
│ │ ├── syncMarketData.ts
|
|
|
|
|
|
│ │ ├── calculateMomentum.ts
|
|
|
|
|
|
│ │ └── updateRankings.ts
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ ├── types/ # 类型定义
|
|
|
|
|
|
│ │ └── index.ts
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ └── app.ts # 应用入口
|
|
|
|
|
|
│
|
|
|
|
|
|
├── prisma/ # Prisma ORM
|
|
|
|
|
|
│ └── schema.prisma
|
|
|
|
|
|
│
|
|
|
|
|
|
├── tests/ # 测试文件
|
|
|
|
|
|
│ ├── unit/
|
|
|
|
|
|
│ └── integration/
|
|
|
|
|
|
│
|
|
|
|
|
|
├── scripts/ # 脚本文件
|
|
|
|
|
|
│ └── init-db.ts
|
|
|
|
|
|
│
|
|
|
|
|
|
├── .env # 环境变量
|
|
|
|
|
|
├── .env.example # 环境变量示例
|
|
|
|
|
|
├── Dockerfile
|
|
|
|
|
|
├── docker-compose.yml
|
|
|
|
|
|
├── package.json
|
|
|
|
|
|
├── tsconfig.json
|
|
|
|
|
|
└── README.md
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 三、核心服务实现
|
|
|
|
|
|
|
|
|
|
|
|
### 3.1 市场数据服务
|
|
|
|
|
|
|
|
|
|
|
|
**文件**: `src/services/marketService.ts`
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
|
|
import Redis from 'ioredis';
|
|
|
|
|
|
|
|
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
|
|
const redis = new Redis(process.env.REDIS_URL);
|
|
|
|
|
|
|
|
|
|
|
|
export class MarketService {
|
|
|
|
|
|
// 获取市场指数
|
|
|
|
|
|
async getMarketIndices() {
|
|
|
|
|
|
const cacheKey = 'market:indices';
|
|
|
|
|
|
const cached = await redis.get(cacheKey);
|
|
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
return JSON.parse(cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const indices = await prisma.marketIndex.findMany({
|
|
|
|
|
|
orderBy: { sortOrder: 'asc' }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await redis.setex(cacheKey, 60, JSON.stringify(indices));
|
|
|
|
|
|
return indices;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取涨跌家数统计
|
|
|
|
|
|
async getUpDownStats() {
|
|
|
|
|
|
const cacheKey = 'market:updown:stats';
|
|
|
|
|
|
const cached = await redis.get(cacheKey);
|
|
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
return JSON.parse(cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const stats = await prisma.stockQuote.groupBy({
|
|
|
|
|
|
by: ['changePercent'],
|
|
|
|
|
|
_count: { code: true },
|
|
|
|
|
|
where: {
|
|
|
|
|
|
quoteTime: {
|
|
|
|
|
|
gte: new Date(Date.now() - 5 * 60 * 1000) // 5分钟内
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = {
|
|
|
|
|
|
up: stats.filter(s => s.changePercent > 0).reduce((a, b) => a + b._count.code, 0),
|
|
|
|
|
|
down: stats.filter(s => s.changePercent < 0).reduce((a, b) => a + b._count.code, 0),
|
|
|
|
|
|
flat: stats.filter(s => s.changePercent === 0).reduce((a, b) => a + b._count.code, 0)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await redis.setex(cacheKey, 60, JSON.stringify(result));
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取涨跌幅分布
|
|
|
|
|
|
async getPriceDistribution() {
|
|
|
|
|
|
const cacheKey = 'market:price:distribution';
|
|
|
|
|
|
const cached = await redis.get(cacheKey);
|
|
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
return JSON.parse(cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ranges = [
|
|
|
|
|
|
{ range: '<-7%', min: -100, max: -7 },
|
|
|
|
|
|
{ range: '-7~-5%', min: -7, max: -5 },
|
|
|
|
|
|
{ range: '-5~-3%', min: -5, max: -3 },
|
|
|
|
|
|
{ range: '-3~0%', min: -3, max: 0 },
|
|
|
|
|
|
{ range: '0~3%', min: 0, max: 3 },
|
|
|
|
|
|
{ range: '3~5%', min: 3, max: 5 },
|
|
|
|
|
|
{ range: '5~7%', min: 5, max: 7 },
|
|
|
|
|
|
{ range: '>7%', min: 7, max: 100 }
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const distribution = await Promise.all(
|
|
|
|
|
|
ranges.map(async r => {
|
|
|
|
|
|
const count = await prisma.stockQuote.count({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
changePercent: {
|
|
|
|
|
|
gte: r.min,
|
|
|
|
|
|
lt: r.max
|
|
|
|
|
|
},
|
|
|
|
|
|
quoteTime: {
|
|
|
|
|
|
gte: new Date(Date.now() - 5 * 60 * 1000)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return { ...r, count };
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await redis.setex(cacheKey, 60, JSON.stringify(distribution));
|
|
|
|
|
|
return distribution;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3.2 版块数据服务
|
|
|
|
|
|
|
|
|
|
|
|
**文件**: `src/services/sectorService.ts`
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
export class SectorService {
|
|
|
|
|
|
// 获取版块列表(带动量排名)
|
|
|
|
|
|
async getSectorsWithMomentum() {
|
|
|
|
|
|
const cacheKey = 'sectors:momentum';
|
|
|
|
|
|
const cached = await redis.get(cacheKey);
|
|
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
return JSON.parse(cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sectors = await prisma.sector.findMany({
|
|
|
|
|
|
include: {
|
|
|
|
|
|
quotes: {
|
|
|
|
|
|
orderBy: { quoteTime: 'desc' },
|
|
|
|
|
|
take: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 计算动量分数和排名
|
|
|
|
|
|
const sectorsWithMomentum = sectors.map(sector => {
|
|
|
|
|
|
const latestQuote = sector.quotes[0];
|
|
|
|
|
|
const momentumScore = this.calculateMomentumScore(sector);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...sector,
|
|
|
|
|
|
changePercent: latestQuote?.changePercent || 0,
|
|
|
|
|
|
momentumScore,
|
|
|
|
|
|
rank: 0 // 稍后计算
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 按动量分数排序
|
|
|
|
|
|
sectorsWithMomentum.sort((a, b) => b.momentumScore - a.momentumScore);
|
|
|
|
|
|
|
|
|
|
|
|
// 分配排名
|
|
|
|
|
|
sectorsWithMomentum.forEach((sector, index) => {
|
|
|
|
|
|
sector.rank = index + 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await redis.setex(cacheKey, 60, JSON.stringify(sectorsWithMomentum));
|
|
|
|
|
|
return sectorsWithMomentum;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算动量分数
|
|
|
|
|
|
private calculateMomentumScore(sector: any): number {
|
|
|
|
|
|
// 基于涨跌幅、成交量、趋势等因素计算
|
|
|
|
|
|
const latestQuote = sector.quotes[0];
|
|
|
|
|
|
if (!latestQuote) return 50;
|
|
|
|
|
|
|
|
|
|
|
|
let score = 50;
|
|
|
|
|
|
|
|
|
|
|
|
// 涨跌幅贡献 (0-30分)
|
|
|
|
|
|
score += Math.min(Math.max(latestQuote.changePercent * 3, -15), 15);
|
|
|
|
|
|
|
|
|
|
|
|
// 成交量贡献 (0-20分)
|
|
|
|
|
|
const volumeRatio = latestQuote.volume / (latestQuote.avgVolume || latestQuote.volume);
|
|
|
|
|
|
score += Math.min((volumeRatio - 1) * 10, 20);
|
|
|
|
|
|
|
|
|
|
|
|
return Math.min(Math.max(score, 0), 100);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取版块历史排名
|
|
|
|
|
|
async getSectorRankHistory(sectorCode: string, days: number = 30) {
|
|
|
|
|
|
const cacheKey = `sector:${sectorCode}:rank:history:${days}`;
|
|
|
|
|
|
const cached = await redis.get(cacheKey);
|
|
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
return JSON.parse(cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const history = await prisma.sectorQuote.findMany({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
sectorCode,
|
|
|
|
|
|
quoteTime: {
|
|
|
|
|
|
gte: new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
orderBy: { quoteTime: 'asc' },
|
|
|
|
|
|
select: {
|
|
|
|
|
|
quoteTime: true,
|
|
|
|
|
|
rank: true,
|
|
|
|
|
|
momentumScore: true
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = history.map(h => ({
|
|
|
|
|
|
date: h.quoteTime.toISOString().split('T')[0],
|
|
|
|
|
|
rank: h.rank,
|
|
|
|
|
|
momentumScore: h.momentumScore
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
await redis.setex(cacheKey, 300, JSON.stringify(result));
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取版块内动量股票
|
|
|
|
|
|
async getSectorMomentumStocks(sectorCode: string) {
|
|
|
|
|
|
const stocks = await prisma.stock.findMany({
|
|
|
|
|
|
where: { sectorCode },
|
|
|
|
|
|
include: {
|
|
|
|
|
|
quotes: {
|
|
|
|
|
|
orderBy: { quoteTime: 'desc' },
|
|
|
|
|
|
take: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return stocks
|
|
|
|
|
|
.map(stock => ({
|
|
|
|
|
|
...stock,
|
|
|
|
|
|
...stock.quotes[0],
|
|
|
|
|
|
momentumScore: this.calculateStockMomentum(stock)
|
|
|
|
|
|
}))
|
|
|
|
|
|
.sort((a, b) => b.momentumScore - a.momentumScore);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private calculateStockMomentum(stock: any): number {
|
|
|
|
|
|
const quote = stock.quotes[0];
|
|
|
|
|
|
if (!quote) return 50;
|
|
|
|
|
|
|
|
|
|
|
|
let score = 50;
|
|
|
|
|
|
score += Math.min(Math.max(quote.changePercent * 4, -20), 20);
|
|
|
|
|
|
|
|
|
|
|
|
const volumeRatio = quote.volume / (quote.avgVolume || quote.volume);
|
|
|
|
|
|
score += Math.min((volumeRatio - 1) * 15, 25);
|
|
|
|
|
|
|
|
|
|
|
|
return Math.min(Math.max(score, 0), 100);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3.3 股票数据服务
|
|
|
|
|
|
|
|
|
|
|
|
**文件**: `src/services/stockService.ts`
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
export class StockService {
|
|
|
|
|
|
// 搜索股票
|
|
|
|
|
|
async searchStocks(keyword: string) {
|
|
|
|
|
|
const stocks = await prisma.stock.findMany({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
OR: [
|
|
|
|
|
|
{ name: { contains: keyword } },
|
|
|
|
|
|
{ code: { contains: keyword } }
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
take: 10,
|
|
|
|
|
|
include: {
|
|
|
|
|
|
quotes: {
|
|
|
|
|
|
orderBy: { quoteTime: 'desc' },
|
|
|
|
|
|
take: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return stocks.map(s => ({
|
|
|
|
|
|
...s,
|
|
|
|
|
|
...s.quotes[0]
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取个股详情
|
|
|
|
|
|
async getStockDetail(code: string) {
|
|
|
|
|
|
const cacheKey = `stock:${code}:detail`;
|
|
|
|
|
|
const cached = await redis.get(cacheKey);
|
|
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
return JSON.parse(cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const stock = await prisma.stock.findUnique({
|
|
|
|
|
|
where: { code },
|
|
|
|
|
|
include: {
|
|
|
|
|
|
quotes: {
|
|
|
|
|
|
orderBy: { quoteTime: 'desc' },
|
|
|
|
|
|
take: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!stock) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const klines = await prisma.kLineData.findMany({
|
|
|
|
|
|
where: { stockCode: code, period: 'day' },
|
|
|
|
|
|
orderBy: { date: 'desc' },
|
|
|
|
|
|
take: 60
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 计算技术指标
|
|
|
|
|
|
const indicators = this.calculateIndicators(klines);
|
|
|
|
|
|
|
|
|
|
|
|
const result = {
|
|
|
|
|
|
...stock,
|
|
|
|
|
|
...stock.quotes[0],
|
|
|
|
|
|
...indicators
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await redis.setex(cacheKey, 60, JSON.stringify(result));
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取K线数据
|
|
|
|
|
|
async getKLineData(code: string, period: string = 'day', days: number = 60) {
|
|
|
|
|
|
const cacheKey = `stock:${code}:kline:${period}:${days}`;
|
|
|
|
|
|
const cached = await redis.get(cacheKey);
|
|
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
return JSON.parse(cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const klines = await prisma.kLineData.findMany({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
stockCode: code,
|
|
|
|
|
|
period
|
|
|
|
|
|
},
|
|
|
|
|
|
orderBy: { date: 'desc' },
|
|
|
|
|
|
take: days
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 计算均线
|
|
|
|
|
|
const klinesWithMA = this.calculateMA(klines.reverse());
|
|
|
|
|
|
|
|
|
|
|
|
await redis.setex(cacheKey, 300, JSON.stringify(klinesWithMA));
|
|
|
|
|
|
return klinesWithMA;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算均线
|
|
|
|
|
|
private calculateMA(klines: any[]) {
|
|
|
|
|
|
const periods = [5, 10, 20, 30, 60];
|
|
|
|
|
|
|
|
|
|
|
|
return klines.map((kline, index) => {
|
|
|
|
|
|
const ma: Record<string, number> = {};
|
|
|
|
|
|
|
|
|
|
|
|
for (const period of periods) {
|
|
|
|
|
|
if (index >= period - 1) {
|
|
|
|
|
|
const sum = klines
|
|
|
|
|
|
.slice(index - period + 1, index + 1)
|
|
|
|
|
|
.reduce((acc, k) => acc + k.close, 0);
|
|
|
|
|
|
ma[`ma${period}`] = Number((sum / period).toFixed(2));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { ...kline, ...ma };
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算技术指标
|
|
|
|
|
|
private calculateIndicators(klines: any[]) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
macd: this.calculateMACD(klines),
|
|
|
|
|
|
kdj: this.calculateKDJ(klines),
|
|
|
|
|
|
rsi: this.calculateRSI(klines)
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MACD计算
|
|
|
|
|
|
private calculateMACD(klines: any[]) {
|
|
|
|
|
|
const closes = klines.map(k => k.close).reverse();
|
|
|
|
|
|
const ema12 = this.EMA(closes, 12);
|
|
|
|
|
|
const ema26 = this.EMA(closes, 26);
|
|
|
|
|
|
const dif = ema12.map((v, i) => v - ema26[i]);
|
|
|
|
|
|
const dea = this.EMA(dif, 9);
|
|
|
|
|
|
const macd = dif.map((v, i) => (v - dea[i]) * 2);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
dif: Number(dif[dif.length - 1].toFixed(3)),
|
|
|
|
|
|
dea: Number(dea[dea.length - 1].toFixed(3)),
|
|
|
|
|
|
macd: Number(macd[macd.length - 1].toFixed(3))
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// KDJ计算
|
|
|
|
|
|
private calculateKDJ(klines: any[], n: number = 9) {
|
|
|
|
|
|
// KDJ计算逻辑
|
|
|
|
|
|
// ...
|
|
|
|
|
|
return { k: 75.2, d: 68.5, j: 88.6 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RSI计算
|
|
|
|
|
|
private calculateRSI(klines: any[]) {
|
|
|
|
|
|
// RSI计算逻辑
|
|
|
|
|
|
// ...
|
|
|
|
|
|
return { rsi6: 72.5, rsi12: 68.3, rsi24: 65.1 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// EMA计算
|
|
|
|
|
|
private EMA(data: number[], n: number): number[] {
|
|
|
|
|
|
const k = 2 / (n + 1);
|
|
|
|
|
|
const ema: number[] = [data[0]];
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 1; i < data.length; i++) {
|
|
|
|
|
|
ema.push(data[i] * k + ema[i - 1] * (1 - k));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ema;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3.4 数据同步服务
|
|
|
|
|
|
|
|
|
|
|
|
**文件**: `src/services/dataSyncService.ts`
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
|
|
|
|
|
|
export class DataSyncService {
|
|
|
|
|
|
private akshareBaseUrl = 'http://localhost:8000'; // AKShare服务地址
|
|
|
|
|
|
|
|
|
|
|
|
// 同步实时行情
|
|
|
|
|
|
async syncRealTimeQuotes() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 从AKShare获取实时行情
|
|
|
|
|
|
const response = await axios.get(`${this.akshareBaseUrl}/stock_zh_a_spot`);
|
|
|
|
|
|
const quotes = response.data;
|
|
|
|
|
|
|
|
|
|
|
|
// 批量插入数据库
|
|
|
|
|
|
await prisma.$transaction(
|
|
|
|
|
|
quotes.map((quote: any) =>
|
|
|
|
|
|
prisma.stockQuote.create({
|
|
|
|
|
|
data: {
|
|
|
|
|
|
stockCode: quote.code,
|
|
|
|
|
|
price: quote.price,
|
|
|
|
|
|
open: quote.open,
|
|
|
|
|
|
high: quote.high,
|
|
|
|
|
|
low: quote.low,
|
|
|
|
|
|
preClose: quote.pre_close,
|
|
|
|
|
|
volume: quote.volume,
|
|
|
|
|
|
turnover: quote.turnover,
|
|
|
|
|
|
changePercent: quote.change_percent,
|
|
|
|
|
|
quoteTime: new Date()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`Synced ${quotes.length} quotes`);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Sync quotes failed:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 同步K线数据
|
|
|
|
|
|
async syncKLineData(stockCode: string, period: string = 'day') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await axios.get(
|
|
|
|
|
|
`${this.akshareBaseUrl}/stock_zh_a_hist`,
|
|
|
|
|
|
{
|
|
|
|
|
|
params: {
|
|
|
|
|
|
symbol: stockCode,
|
|
|
|
|
|
period: period === 'day' ? 'daily' : period,
|
|
|
|
|
|
start_date: '20230101',
|
|
|
|
|
|
end_date: new Date().toISOString().split('T')[0].replace(/-/g, '')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const klines = response.data;
|
|
|
|
|
|
|
|
|
|
|
|
await prisma.$transaction(
|
|
|
|
|
|
klines.map((k: any) =>
|
|
|
|
|
|
prisma.kLineData.upsert({
|
|
|
|
|
|
where: {
|
|
|
|
|
|
stockCode_period_date: {
|
|
|
|
|
|
stockCode: stockCode,
|
|
|
|
|
|
period: period,
|
|
|
|
|
|
date: new Date(k.date)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
update: {
|
|
|
|
|
|
open: k.open,
|
|
|
|
|
|
high: k.high,
|
|
|
|
|
|
low: k.low,
|
|
|
|
|
|
close: k.close,
|
|
|
|
|
|
volume: k.volume
|
|
|
|
|
|
},
|
|
|
|
|
|
create: {
|
|
|
|
|
|
stockCode: stockCode,
|
|
|
|
|
|
period: period,
|
|
|
|
|
|
date: new Date(k.date),
|
|
|
|
|
|
open: k.open,
|
|
|
|
|
|
high: k.high,
|
|
|
|
|
|
low: k.low,
|
|
|
|
|
|
close: k.close,
|
|
|
|
|
|
volume: k.volume
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`Synced ${klines.length} klines for ${stockCode}`);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(`Sync kline failed for ${stockCode}:`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 四、WebSocket 实现
|
|
|
|
|
|
|
|
|
|
|
|
**文件**: `src/websocket/stockSocket.ts`
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
import { Server } from 'socket.io';
|
|
|
|
|
|
|
|
|
|
|
|
export class StockSocket {
|
|
|
|
|
|
private io: Server;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(server: any) {
|
|
|
|
|
|
this.io = new Server(server, {
|
|
|
|
|
|
cors: {
|
|
|
|
|
|
origin: '*',
|
|
|
|
|
|
methods: ['GET', 'POST']
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.setupHandlers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private setupHandlers() {
|
|
|
|
|
|
this.io.on('connection', (socket) => {
|
|
|
|
|
|
console.log('Client connected:', socket.id);
|
|
|
|
|
|
|
|
|
|
|
|
// 订阅股票
|
|
|
|
|
|
socket.on('subscribe', (channels: string[]) => {
|
|
|
|
|
|
channels.forEach(channel => {
|
|
|
|
|
|
socket.join(channel);
|
|
|
|
|
|
console.log(`Client ${socket.id} subscribed to ${channel}`);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 取消订阅
|
|
|
|
|
|
socket.on('unsubscribe', (channels: string[]) => {
|
|
|
|
|
|
channels.forEach(channel => {
|
|
|
|
|
|
socket.leave(channel);
|
|
|
|
|
|
console.log(`Client ${socket.id} unsubscribed from ${channel}`);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('disconnect', () => {
|
|
|
|
|
|
console.log('Client disconnected:', socket.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 推送股票行情
|
|
|
|
|
|
broadcastStockQuote(stockCode: string, data: any) {
|
|
|
|
|
|
this.io.to(`stock:${stockCode}`).emit('quote', {
|
|
|
|
|
|
channel: `stock:${stockCode}`,
|
|
|
|
|
|
type: 'quote',
|
|
|
|
|
|
data
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 推送版块行情
|
|
|
|
|
|
broadcastSectorQuote(sectorCode: string, data: any) {
|
|
|
|
|
|
this.io.to(`sector:${sectorCode}`).emit('quote', {
|
|
|
|
|
|
channel: `sector:${sectorCode}`,
|
|
|
|
|
|
type: 'quote',
|
|
|
|
|
|
data
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 五、定时任务
|
|
|
|
|
|
|
|
|
|
|
|
**文件**: `src/jobs/syncMarketData.ts`
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
import cron from 'node-cron';
|
|
|
|
|
|
import { DataSyncService } from '../services/dataSyncService';
|
|
|
|
|
|
|
|
|
|
|
|
const dataSyncService = new DataSyncService();
|
|
|
|
|
|
|
|
|
|
|
|
// 每3秒同步实时行情(交易时间)
|
|
|
|
|
|
cron.schedule('*/3 * * * * *', async () => {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const hour = now.getHours();
|
|
|
|
|
|
const minute = now.getMinutes();
|
|
|
|
|
|
|
|
|
|
|
|
// 交易时间: 9:30-11:30, 13:00-15:00
|
|
|
|
|
|
const isTradingTime = (
|
|
|
|
|
|
(hour === 9 && minute >= 30) ||
|
|
|
|
|
|
(hour === 10) ||
|
|
|
|
|
|
(hour === 11 && minute <= 30) ||
|
|
|
|
|
|
(hour === 13) ||
|
|
|
|
|
|
(hour === 14)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (isTradingTime) {
|
|
|
|
|
|
await dataSyncService.syncRealTimeQuotes();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 每小时同步K线数据
|
|
|
|
|
|
cron.schedule('0 * * * *', async () => {
|
|
|
|
|
|
const stocks = await prisma.stock.findMany();
|
|
|
|
|
|
for (const stock of stocks) {
|
|
|
|
|
|
await dataSyncService.syncKLineData(stock.code);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 每日收盘后计算版块排名
|
|
|
|
|
|
cron.schedule('0 15 * * 1-5', async () => {
|
|
|
|
|
|
await sectorService.calculateAndUpdateRankings();
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 六、环境变量
|
|
|
|
|
|
|
|
|
|
|
|
**文件**: `.env`
|
|
|
|
|
|
|
|
|
|
|
|
```env
|
|
|
|
|
|
# 服务器配置
|
|
|
|
|
|
PORT=3000
|
|
|
|
|
|
NODE_ENV=production
|
|
|
|
|
|
|
|
|
|
|
|
# 数据库配置
|
|
|
|
|
|
DATABASE_URL=mysql://user:password@localhost:3306/aguzhitou
|
|
|
|
|
|
|
|
|
|
|
|
# Redis配置
|
|
|
|
|
|
REDIS_URL=redis://localhost:6379
|
|
|
|
|
|
|
|
|
|
|
|
# JWT配置
|
|
|
|
|
|
JWT_SECRET=your-secret-key
|
|
|
|
|
|
JWT_EXPIRES_IN=7d
|
|
|
|
|
|
|
|
|
|
|
|
# AKShare配置
|
|
|
|
|
|
AKSHARE_URL=http://localhost:8000
|
|
|
|
|
|
|
|
|
|
|
|
# 日志配置
|
|
|
|
|
|
LOG_LEVEL=info
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 七、部署脚本
|
|
|
|
|
|
|
|
|
|
|
|
**文件**: `docker-compose.yml`
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
version: '3.8'
|
|
|
|
|
|
|
|
|
|
|
|
services:
|
|
|
|
|
|
app:
|
|
|
|
|
|
build: .
|
|
|
|
|
|
ports:
|
|
|
|
|
|
- "3000:3000"
|
|
|
|
|
|
environment:
|
|
|
|
|
|
- DATABASE_URL=mysql://root:rootpass@mysql:3306/aguzhitou
|
|
|
|
|
|
- REDIS_URL=redis://redis:6379
|
|
|
|
|
|
- JWT_SECRET=${JWT_SECRET}
|
|
|
|
|
|
depends_on:
|
|
|
|
|
|
- mysql
|
|
|
|
|
|
- redis
|
|
|
|
|
|
restart: always
|
|
|
|
|
|
|
|
|
|
|
|
mysql:
|
|
|
|
|
|
image: mysql:8.0
|
|
|
|
|
|
environment:
|
|
|
|
|
|
- MYSQL_ROOT_PASSWORD=rootpass
|
|
|
|
|
|
- MYSQL_DATABASE=aguzhitou
|
|
|
|
|
|
volumes:
|
|
|
|
|
|
- mysql_data:/var/lib/mysql
|
|
|
|
|
|
ports:
|
|
|
|
|
|
- "3306:3306"
|
|
|
|
|
|
restart: always
|
|
|
|
|
|
|
|
|
|
|
|
redis:
|
|
|
|
|
|
image: redis:7-alpine
|
|
|
|
|
|
volumes:
|
|
|
|
|
|
- redis_data:/data
|
|
|
|
|
|
ports:
|
|
|
|
|
|
- "6379:6379"
|
|
|
|
|
|
restart: always
|
|
|
|
|
|
|
|
|
|
|
|
nginx:
|
|
|
|
|
|
image: nginx:alpine
|
|
|
|
|
|
ports:
|
|
|
|
|
|
- "80:80"
|
|
|
|
|
|
- "443:443"
|
|
|
|
|
|
volumes:
|
|
|
|
|
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
|
|
|
|
|
- ./ssl:/etc/nginx/ssl
|
|
|
|
|
|
depends_on:
|
|
|
|
|
|
- app
|
|
|
|
|
|
restart: always
|
|
|
|
|
|
|
|
|
|
|
|
volumes:
|
|
|
|
|
|
mysql_data:
|
|
|
|
|
|
redis_data:
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 八、测试
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
// 示例测试
|
|
|
|
|
|
describe('StockService', () => {
|
|
|
|
|
|
let stockService: StockService;
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
stockService = new StockService();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should calculate MA correctly', () => {
|
|
|
|
|
|
const klines = [
|
|
|
|
|
|
{ close: 10 }, { close: 11 }, { close: 12 },
|
|
|
|
|
|
{ close: 13 }, { close: 14 }, { close: 15 }
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const result = stockService['calculateMA'](klines);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result[5].ma5).toBe(13); // (11+12+13+14+15)/5 = 13
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 九、待实现清单
|
|
|
|
|
|
|
|
|
|
|
|
- [ ] 数据库表创建
|
|
|
|
|
|
- [ ] Prisma schema 定义
|
|
|
|
|
|
- [ ] API 路由实现
|
|
|
|
|
|
- [ ] WebSocket 服务
|
|
|
|
|
|
- [ ] 定时任务配置
|
|
|
|
|
|
- [ ] 单元测试
|
|
|
|
|
|
- [ ] Docker 部署
|
|
|
|
|
|
- [ ] CI/CD 配置
|
|
|
|
|
|
- [ ] 监控告警
|
|
|
|
|
|
- [ ] 日志收集
|