feat:初始化代码

master
Lxy 3 months ago
commit a8a84c00da

@ -0,0 +1,230 @@
# 智能期货期权分析系统 - 部署指南
## 系统架构
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Nginx │──────▶ 前端 (React) │ │ 后端 (NestJS) │
│ (反向代理) │ │ Port: 80 │ │ Port: 3000 │
└─────────────────┘ └─────────────────┘ └────────┬────────┘
┌─────────────────┐ │
│ PostgreSQL │◀───────┤
│ Port: 5432 │ │
└─────────────────┘ │
┌─────────────────┐ │
│ Redis │◀───────┘
│ Port: 6379 │
└─────────────────┘
```
## 快速开始
### 方式一Docker Compose (推荐)
```bash
# 1. 克隆代码
git clone <repository-url>
cd AlphaFuturesProMax
# 2. 设置环境变量
cp app/server/.env.example app/server/.env
# 编辑 .env 文件,设置必要的配置
# 3. 启动服务
docker-compose up -d
# 4. 查看日志
docker-compose logs -f backend
# 5. 停止服务
docker-compose down
```
### 方式二:手动部署
#### 1. 环境要求
- Node.js 20+
- PostgreSQL 15+
- Redis 7+
#### 2. 后端部署
```bash
# 进入后端目录
cd app/server
# 安装依赖
npm install
# 设置环境变量
cp .env.example .env
# 编辑 .env 配置数据库连接等信息
# 执行数据库迁移
npx prisma migrate deploy
# 生成Prisma客户端
npx prisma generate
# 导入种子数据
npx prisma db seed
# 启动服务
npm run start:prod
```
#### 3. 前端部署
```bash
# 进入前端目录
cd app
# 安装依赖
npm install
# 设置API地址
# 编辑 .env.production 文件
VITE_API_BASE_URL=http://your-api-server:3000/api/v1
VITE_WS_URL=http://your-api-server:3000/market
# 构建
npm run build
# 部署到Nginx
# 将 dist 目录复制到Nginx的web目录
```
## 环境变量配置
### 后端环境变量 (.env)
```bash
# 应用配置
NODE_ENV=production
PORT=3000
# 数据库
DATABASE_URL=postgresql://user:password@localhost:5432/futures_analysis
# Redis
REDIS_URL=redis://localhost:6379
# JWT密钥 (生产环境必须修改!)
JWT_SECRET=your-super-secret-key-change-this
# OpenAI (可选用于AI分析)
OPENAI_API_KEY=your-openai-key
```
### 前端环境变量 (.env.production)
```bash
VITE_API_BASE_URL=http://api.yourdomain.com/api/v1
VITE_WS_URL=http://api.yourdomain.com/market
```
## API文档
启动后端服务后访问以下地址查看API文档
```
http://localhost:3000/docs
```
## 功能清单
### ✅ 已实现功能
#### 后端功能
- [x] 用户认证系统 (JWT)
- [x] 行情数据服务 (REST + WebSocket)
- [x] 技术指标计算 (MACD, RSI, KDJ, BOLL, SAR, OBV, DMI, CCI, WR)
- [x] 期权分析模块 (Black-Scholes定价、希腊值、波动率曲面)
- [x] AI智能分析 (支持OpenAI API)
- [x] 热点事件管理
- [x] 自选股功能
- [x] 价格预警系统
#### 前端功能
- [x] 市场概览仪表盘
- [x] 品种筛选和搜索
- [x] K线图展示 (含MACD、成交量)
- [x] 热点事件分析
- [x] 风险提醒
### 🚧 待实现功能
- [ ] 交易信号系统
- [ ] 量化策略回测
- [ ] 移动端APP
- [ ] 管理后台
## 监控和维护
### 查看服务状态
```bash
# Docker方式
docker-compose ps
# 查看日志
docker-compose logs -f [service-name]
```
### 数据库备份
```bash
# Docker方式
docker-compose exec postgres pg_dump -U futures_user futures_analysis > backup.sql
# 恢复
docker-compose exec -T postgres psql -U futures_user futures_analysis < backup.sql
```
## 常见问题
### 1. 端口冲突
编辑 `docker-compose.yml` 修改端口映射:
```yaml
services:
backend:
ports:
- "3001:3000" # 修改为3001
```
### 2. 数据库连接失败
检查 `DATABASE_URL` 是否正确确保PostgreSQL服务已启动。
### 3. 前端无法访问API
检查前端环境变量 `VITE_API_BASE_URL` 是否正确配置。
## 生产环境建议
1. **安全**
- 修改默认的JWT密钥
- 启用HTTPS
- 配置防火墙规则
2. **性能**
- 启用Redis缓存
- 配置CDN加速静态资源
- 数据库索引优化
3. **监控**
- 配置日志收集 (ELK/Loki)
- 设置告警规则
- 监控API响应时间
## 技术栈
- **前端**: React 18 + TypeScript + Vite + Tailwind CSS + shadcn/ui
- **后端**: NestJS + TypeScript
- **数据库**: PostgreSQL 15
- **缓存**: Redis 7
- **部署**: Docker + Docker Compose

@ -0,0 +1,255 @@
# 智能期货期权分析系统 - 功能实现总结
## 📋 已完成的功能
### 1. 后端服务 (NestJS + TypeScript)
#### ✅ 基础架构
- 模块化项目结构,易于扩展
- 全局异常处理、日志记录
- API限流保护
- Swagger API文档自动生成
- JWT认证与授权
#### ✅ 数据库设计 (Prisma)
- 用户系统
- 期货品种管理
- K线数据存储
- 实时Tick数据缓存
- 热点事件管理
- 自选股功能
- 价格预警系统
- 期权合约管理
#### ✅ 用户认证系统
- 用户注册/登录/登出
- JWT Token认证
- 密码加密存储
- 用户信息管理
#### ✅ 行情数据服务
- RESTful API获取品种列表、K线数据、实时行情
- WebSocket实时推送Tick数据
- 市场概览数据聚合
- Redis缓存加速
#### ✅ 技术指标计算引擎
- MACD (异同移动平均线)
- RSI (相对强弱指标)
- KDJ (随机指标)
- BOLL (布林带)
- SAR (抛物线转向)
- OBV (能量潮)
- DMI (趋向指标)
- CCI (商品通道指数)
- WR (威廉指标)
- 移动平均线 (MA5/MA10/MA20/MA60)
#### ✅ 期权分析模块
- Black-Scholes期权定价模型
- 希腊值计算 (Delta, Gamma, Theta, Vega, Rho)
- 隐含波动率计算
- 期权链数据生成
- 波动率曲面分析
- 期权策略盈亏计算
#### ✅ AI智能分析模块
- 集成OpenAI API进行智能分析
- 基于规则的技术面分析 (Fallback)
- 趋势判断和交易建议
- 支撑阻力位计算
- 风险评估
#### ✅ 业务功能
- 热点事件管理 (增删改查)
- 自选股功能 (增删改查、价格预警)
- 价格预警系统 (定时检查、触发通知)
### 2. 前端API服务
#### ✅ HTTP客户端封装
- Axios拦截器统一处理
- Token自动注入
- 统一错误处理
- API模块化组织
#### ✅ WebSocket客户端
- Socket.io连接管理
- 自动重连机制
- 订阅/取消订阅
- 事件监听封装
### 3. 部署配置
#### ✅ Docker支持
- 前端Dockerfile (Nginx)
- 后端Dockerfile (Node.js)
- Docker Compose编排
- 健康检查配置
#### ✅ 环境配置
- 环境变量模板
- 多环境支持 (dev/prod)
- 数据库迁移脚本
- 种子数据导入
## 📁 项目结构
```
AlphaFuturesProMax/
├── app/ # 前端应用
│ ├── src/
│ │ ├── components/ # UI组件
│ │ ├── services/ # API和WebSocket服务
│ │ │ ├── api.ts # HTTP API封装
│ │ │ └── websocket.ts # WebSocket服务
│ │ └── ...
│ ├── Dockerfile # 前端Docker配置
│ └── nginx.conf # Nginx配置
├── app/server/ # 后端服务
│ ├── src/
│ │ ├── auth/ # 认证模块
│ │ ├── market/ # 行情数据模块
│ │ ├── indicators/ # 技术指标模块
│ │ ├── options/ # 期权分析模块
│ │ ├── ai/ # AI分析模块
│ │ ├── events/ # 热点事件模块
│ │ ├── watchlist/ # 自选股模块
│ │ ├── alert/ # 价格预警模块
│ │ ├── user/ # 用户模块
│ │ ├── common/ # 公共模块
│ │ │ ├── prisma/ # Prisma服务
│ │ │ └── redis/ # Redis服务
│ │ └── config/ # 配置文件
│ ├── prisma/
│ │ ├── schema.prisma # 数据库模型
│ │ └── seed.ts # 种子数据
│ ├── Dockerfile # 后端Docker配置
│ └── package.json
├── docker-compose.yml # Docker编排配置
├── DEPLOY.md # 部署文档
└── docs/ # 项目文档
```
## 🚀 快速启动
### Docker方式 (推荐)
```bash
# 1. 启动所有服务
docker-compose up -d
# 2. 查看服务状态
docker-compose ps
# 3. 查看日志
docker-compose logs -f backend
# 4. 访问应用
# 前端: http://localhost
# API: http://localhost:3000/api/v1
# 文档: http://localhost:3000/docs
```
### 手动部署
`DEPLOY.md` 详细说明
## 📡 API接口
### 认证接口
- `POST /api/v1/auth/register` - 注册
- `POST /api/v1/auth/login` - 登录
- `POST /api/v1/auth/logout` - 登出
- `GET /api/v1/auth/profile` - 获取用户信息
### 行情接口
- `GET /api/v1/market/products` - 品种列表
- `GET /api/v1/market/products/:symbol` - 品种详情
- `GET /api/v1/market/products/:symbol/kline` - K线数据
- `GET /api/v1/market/products/:symbol/tick` - 实时行情
- `GET /api/v1/market/overview` - 市场概览
### 技术指标接口
- `GET /api/v1/indicators/:symbol` - 所有指标
- `GET /api/v1/indicators/:symbol/macd` - MACD
- `GET /api/v1/indicators/:symbol/rsi` - RSI
- `GET /api/v1/indicators/:symbol/bollinger` - 布林带
- `GET /api/v1/indicators/:symbol/kdj` - KDJ
### 期权接口
- `POST /api/v1/options/pricing` - 期权定价
- `GET /api/v1/options/chain/:underlying` - 期权链
- `GET /api/v1/options/volatility-surface/:underlying` - 波动率曲面
- `POST /api/v1/options/strategy/payoff` - 策略盈亏
### AI分析接口
- `GET /api/v1/ai/analyze/:symbol` - AI分析品种
### 事件接口
- `GET /api/v1/events` - 热点事件列表
- `GET /api/v1/events/:id` - 事件详情
### WebSocket
- `ws://localhost:3000/market` - 行情实时推送
## 🔧 扩展建议
### 数据源接入
目前使用模拟数据,可接入真实数据源:
- Wind金融终端API
- 同花顺iFinD API
- 交易所官方API
实现方式:
1. 在 `market-data-feed.service.ts` 中实现新的数据源适配器
2. 修改配置 `MARKET_DATA_PROVIDER`
### 新增技术指标
`indicators.service.ts` 中添加新的计算方法:
```typescript
calculateNewIndicator(klineData: KLineData[], params: any): Result[] {
// 实现计算逻辑
}
```
### 新增期权模型
`options.service.ts` 中添加新的定价模型:
```typescript
calculateBinomialModel(S: number, K: number, ...): OptionPricingResult {
// 实现二叉树模型
}
```
## 📊 性能优化
### 已实现
- Redis缓存加速
- WebSocket实时推送
- API响应压缩
- 数据库索引优化
### 建议优化
- 数据库读写分离
- API限流
- 静态资源CDN
- 应用监控 (Prometheus + Grafana)
## 🛡️ 安全建议
1. 生产环境必须修改JWT密钥
2. 启用HTTPS
3. 配置CORS白名单
4. 数据库使用强密码
5. 定期更新依赖包
## 📝 待实现功能
- [ ] 交易信号系统 (多指标共振)
- [ ] 量化策略回测引擎
- [ ] 移动端APP (React Native/Flutter)
- [ ] 管理后台
- [ ] 会员系统
- [ ] 模拟交易功能

@ -0,0 +1,31 @@
# 前端构建
FROM node:20-alpine AS builder
WORKDIR /app
# 复制package文件
COPY package*.json ./
# 安装依赖
RUN npm install
# 复制源代码
COPY . .
# 构建生产版本
RUN npm run build
# Nginx服务
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
# 启动nginx
CMD ["nginx", "-g", "daemon off;"]

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "postcss.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能期货期权分析系统</title>
<script type="module" crossorigin src="./assets/index-BYfknKCx.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BYX1qVyV.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能期货期权分析系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

@ -0,0 +1,31 @@
Using Node.js 20, Tailwind CSS v3.4.19, and Vite v7.2.4
Tailwind CSS has been set up with the shadcn theme
Setup complete: /mnt/okcomputer/output/app
Components (40+):
accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb,
button-group, button, calendar, card, carousel, chart, checkbox, collapsible,
command, context-menu, dialog, drawer, dropdown-menu, empty, field, form,
hover-card, input-group, input-otp, input, item, kbd, label, menubar,
navigation-menu, pagination, popover, progress, radio-group, resizable,
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
spinner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
Usage:
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
Structure:
src/sections/ Page sections
src/hooks/ Custom hooks
src/types/ Type definitions
src/App.css Styles specific to the Webapp
src/App.tsx Root React component
src/index.css Global styles
src/main.tsx Entry point for rendering the Webapp
index.html Entry point for the Webapp
tailwind.config.js Configures Tailwind's theme, plugins, etc.
vite.config.ts Main build and dev server settings for Vite
postcss.config.js Config file for CSS post-processing tools

@ -0,0 +1,59 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# 缓存静态资源
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff";
}
# API代理
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
}
# WebSocket代理
location /socket.io/ {
proxy_pass http://backend:3000/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
# 前端路由
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
# 健康检查
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

8264
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,82 @@
{
"name": "my-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"axios": "^1.6.5",
"socket.io-client": "^4.7.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"echarts": "^5.4.3",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-resizable-panels": "^4.2.2",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"kimi-plugin-inspect-react": "^1.0.3",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -0,0 +1,47 @@
# 应用配置
NODE_ENV=development
PORT=3000
API_PREFIX=/api/v1
# 数据库配置
DATABASE_URL=postgresql://user:password@localhost:5432/futures_analysis?schema=public
# Redis配置
REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=
# JWT配置
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
# 行情数据源配置
MARKET_DATA_PROVIDER=mock
MARKET_DATA_API_KEY=
MARKET_DATA_API_SECRET=
MARKET_DATA_WS_URL=
# OpenAI配置 (用于AI分析)
OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_MODEL=gpt-4-turbo-preview
# 邮件配置
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
# 日志配置
LOG_LEVEL=debug
LOG_FILE=logs/app.log
# 安全配置
CORS_ORIGIN=http://localhost:5173
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
# 监控配置
METRICS_ENABLED=true
METRICS_PORT=9090

@ -0,0 +1,47 @@
# 应用配置
NODE_ENV=development
PORT=3000
API_PREFIX=/api/v1
# 数据库配置
DATABASE_URL=postgresql://user:password@localhost:5432/futures_analysis?schema=public
# Redis配置
REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=
# JWT配置
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
# 行情数据源配置
MARKET_DATA_PROVIDER=mock
MARKET_DATA_API_KEY=
MARKET_DATA_API_SECRET=
MARKET_DATA_WS_URL=
# OpenAI配置 (用于AI分析)
OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_MODEL=gpt-4-turbo-preview
# 邮件配置
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
# 日志配置
LOG_LEVEL=debug
LOG_FILE=logs/app.log
# 安全配置
CORS_ORIGIN=http://localhost:5173
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
# 监控配置
METRICS_ENABLED=true
METRICS_PORT=9090

@ -0,0 +1,44 @@
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
# 复制package文件
COPY package*.json ./
COPY prisma ./prisma/
# 安装依赖
RUN npm install
# 复制源代码
COPY . .
# 生成Prisma客户端
RUN npx prisma generate
# 构建应用
RUN npm run build
# 生产阶段
FROM node:20-alpine
WORKDIR /app
# 安装生产依赖和必要库
COPY package*.json ./
RUN npm install --omit=dev && npm cache clean --force
# 复制构建产物
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/prisma ./prisma
# 暴露端口
EXPOSE 3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/v1/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# 启动命令 - 先安装openssl再启动
CMD ["sh", "-c", "apk add --no-cache openssl1.1-compat 2>/dev/null || apk add --no-cache openssl; node dist/src/main.js"]

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"webpack": false
}
}

@ -0,0 +1,103 @@
{
"name": "futures-analysis-server",
"version": "1.0.0",
"description": "智能期货期权分析系统 - 后端服务",
"author": "AlphaFutures Team",
"private": true,
"license": "MIT",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:studio": "prisma studio",
"db:seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.1",
"@nestjs/websockets": "^10.3.0",
"@prisma/client": "^5.9.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"compression": "^1.7.4",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"openai": "^4.28.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"uuid": "^9.0.1",
"ws": "^8.16.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/compression": "^1.7.5",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.11.5",
"@types/passport-jwt": "^4.0.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"prettier": "^3.2.4",
"prisma": "^5.9.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.4",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"engines": {
"node": ">=18.0.0"
}
}

@ -0,0 +1,265 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 用户表
model User {
id Int @id @default(autoincrement())
username String @unique @db.VarChar(50)
email String @unique @db.VarChar(100)
passwordHash String @map("password_hash") @db.VarChar(255)
phone String? @db.VarChar(20)
avatarUrl String? @map("avatar_url") @db.VarChar(255)
membershipLevel Int @default(0) @map("membership_level")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 关联
watchlist Watchlist[]
priceAlerts PriceAlert[]
paperTrades PaperTrade[]
sessions UserSession[]
@@map("users")
}
// 用户会话表
model UserSession {
id Int @id @default(autoincrement())
userId Int @map("user_id")
token String @unique @db.VarChar(500)
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@map("user_sessions")
}
// 期货品种表
model Product {
id Int @id @default(autoincrement())
symbol String @unique @db.VarChar(20)
name String @db.VarChar(100)
category String @db.VarChar(50)
exchange String? @db.VarChar(50)
unit String? @db.VarChar(20)
minChange Decimal? @map("min_change") @db.Decimal(10, 4)
description String? @db.Text
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
// 关联
klineData KlineData[]
tickData TickData[]
events EventProduct[]
signals TradingSignal[]
options OptionContract[]
@@map("products")
}
// K线数据表
model KlineData {
id Int @id @default(autoincrement())
productId Int @map("product_id")
period String @db.VarChar(10) // 1m, 5m, 15m, 30m, 1h, 1d, 1w
time DateTime
open Decimal @db.Decimal(18, 6)
high Decimal @db.Decimal(18, 6)
low Decimal @db.Decimal(18, 6)
close Decimal @db.Decimal(18, 6)
volume BigInt
openInterest BigInt? @map("open_interest")
createdAt DateTime @default(now()) @map("created_at")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, period, time])
@@index([productId, period, time])
@@map("kline_data")
}
// Tick实时数据表
model TickData {
id Int @id @default(autoincrement())
productId Int @map("product_id")
price Decimal @db.Decimal(18, 6)
change Decimal @db.Decimal(18, 6)
changePercent Decimal @map("change_percent") @db.Decimal(10, 4)
open Decimal @db.Decimal(18, 6)
high Decimal @db.Decimal(18, 6)
low Decimal @db.Decimal(18, 6)
volume BigInt
openInterest BigInt? @map("open_interest")
bidPrice Decimal? @map("bid_price") @db.Decimal(18, 6)
askPrice Decimal? @map("ask_price") @db.Decimal(18, 6)
bidVolume BigInt? @map("bid_volume")
askVolume BigInt? @map("ask_volume")
timestamp DateTime
createdAt DateTime @default(now()) @map("created_at")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@index([productId, timestamp])
@@map("tick_data")
}
// 自选股表
model Watchlist {
id Int @id @default(autoincrement())
userId Int @map("user_id")
symbol String @db.VarChar(20)
alertPrice Decimal? @map("alert_price") @db.Decimal(18, 6)
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, symbol])
@@index([userId])
@@map("watchlist")
}
// 价格预警表
model PriceAlert {
id Int @id @default(autoincrement())
userId Int @map("user_id")
symbol String @db.VarChar(20)
alertType String @map("alert_type") @db.VarChar(20) // above, below
alertPrice Decimal @map("alert_price") @db.Decimal(18, 6)
isActive Boolean @default(true) @map("is_active")
triggeredAt DateTime? @map("triggered_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([symbol, isActive])
@@map("price_alerts")
}
// 热点事件表
model HotEvent {
id Int @id @default(autoincrement())
title String @db.VarChar(255)
content String? @db.Text
summary String? @db.Text
impact String @db.VarChar(20) // bullish, bearish, neutral
impactLevel Int @map("impact_level")
source String? @db.VarChar(100)
analysis String? @db.Text
risks String[] @db.VarChar(255)
eventTime DateTime? @map("event_time")
createdAt DateTime @default(now()) @map("created_at")
// 关联
affectedProducts EventProduct[]
@@map("hot_events")
}
// 事件影响品种关联表
model EventProduct {
id Int @id @default(autoincrement())
eventId Int @map("event_id")
productId Int @map("product_id")
impactConfidence Decimal? @map("impact_confidence") @db.Decimal(3, 2)
event HotEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([eventId, productId])
@@map("event_products")
}
// 交易信号表
model TradingSignal {
id Int @id @default(autoincrement())
productId Int @map("product_id")
timeframe String @db.VarChar(10)
signalType String @map("signal_type") @db.VarChar(20) // buy, sell, neutral
strength Int
indicators Json? // 触发信号的指标
description String? @db.Text
entryPrice Decimal? @map("entry_price") @db.Decimal(18, 6)
stopLoss Decimal? @map("stop_loss") @db.Decimal(18, 6)
targetPrice Decimal? @map("target_price") @db.Decimal(18, 6)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@index([productId, timeframe, createdAt])
@@map("trading_signals")
}
// 期权合约表
model OptionContract {
id Int @id @default(autoincrement())
productId Int @map("product_id")
symbol String @db.VarChar(20)
type String @db.VarChar(10) // call, put
strikePrice Decimal @map("strike_price") @db.Decimal(18, 6)
expiryDate DateTime @map("expiry_date")
price Decimal? @db.Decimal(18, 6)
iv Decimal? @db.Decimal(10, 4) // 隐含波动率
delta Decimal? @db.Decimal(10, 4)
gamma Decimal? @db.Decimal(10, 4)
theta Decimal? @db.Decimal(10, 4)
vega Decimal? @db.Decimal(10, 4)
rho Decimal? @db.Decimal(10, 4)
volume BigInt?
openInterest BigInt? @map("open_interest")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, type, strikePrice, expiryDate])
@@index([productId, expiryDate])
@@map("option_contracts")
}
// 模拟交易记录表
model PaperTrade {
id Int @id @default(autoincrement())
userId Int @map("user_id")
symbol String @db.VarChar(20)
direction String @db.VarChar(10) // long, short
entryPrice Decimal @map("entry_price") @db.Decimal(18, 6)
exitPrice Decimal? @map("exit_price") @db.Decimal(18, 6)
quantity Int
entryTime DateTime @default(now()) @map("entry_time")
exitTime DateTime? @map("exit_time")
pnl Decimal? @db.Decimal(18, 6)
pnlPercent Decimal? @map("pnl_percent") @db.Decimal(10, 4)
status String @default("open") @db.VarChar(20) // open, closed
notes String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, status])
@@map("paper_trades")
}
// 系统配置表
model SystemConfig {
id Int @id @default(autoincrement())
key String @unique @db.VarChar(100)
value String @db.Text
description String? @db.Text
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("system_configs")
}

@ -0,0 +1,150 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 开始种子数据...');
// 创建期货品种
const products = [
{ symbol: 'SC', name: '原油', category: 'energy', exchange: 'INE', unit: '元/桶', minChange: 0.1 },
{ symbol: 'AU', name: '黄金', category: 'metal', exchange: 'SHFE', unit: '元/克', minChange: 0.02 },
{ symbol: 'CU', name: '铜', category: 'metal', exchange: 'SHFE', unit: '元/吨', minChange: 10 },
{ symbol: 'AG', name: '白银', category: 'metal', exchange: 'SHFE', unit: '元/千克', minChange: 1 },
{ symbol: 'I', name: '铁矿石', category: 'metal', exchange: 'DCE', unit: '元/吨', minChange: 0.5 },
{ symbol: 'M', name: '豆粕', category: 'agriculture', exchange: 'DCE', unit: '元/吨', minChange: 1 },
{ symbol: 'P', name: '棕榈油', category: 'agriculture', exchange: 'DCE', unit: '元/吨', minChange: 2 },
{ symbol: 'EC', name: '集运指数', category: 'financial', exchange: 'INE', unit: '点', minChange: 0.1 },
{ symbol: 'RB', name: '螺纹钢', category: 'metal', exchange: 'SHFE', unit: '元/吨', minChange: 1 },
{ symbol: 'FG', name: '玻璃', category: 'industrial', exchange: 'ZCE', unit: '元/吨', minChange: 1 },
{ symbol: 'SA', name: '纯碱', category: 'industrial', exchange: 'ZCE', unit: '元/吨', minChange: 1 },
{ symbol: 'MA', name: '甲醇', category: 'energy', exchange: 'ZCE', unit: '元/吨', minChange: 1 },
{ symbol: 'TA', name: 'PTA', category: 'industrial', exchange: 'ZCE', unit: '元/吨', minChange: 2 },
{ symbol: 'CF', name: '棉花', category: 'agriculture', exchange: 'ZCE', unit: '元/吨', minChange: 5 },
{ symbol: 'SR', name: '白糖', category: 'agriculture', exchange: 'ZCE', unit: '元/吨', minChange: 1 },
];
for (const product of products) {
await prisma.product.upsert({
where: { symbol: product.symbol },
update: product,
create: product,
});
}
console.log(`✅ 已创建 ${products.length} 个期货品种`);
// 创建热点事件
const events = [
{
title: '地缘政治风险升级',
summary: '美以袭伊朗,"海上油阀"被关,中东局势紧张升级',
content: '详细内容...',
impact: 'bullish',
impactLevel: 5,
analysis: '地缘政治风险急剧升温,霍尔木兹海峡封锁风险上升。原油供应中断担忧推动油价上涨,避险资产黄金、白银同步走强。短期油价易涨难跌,建议关注原油、黄金相关品种做多机会。',
risks: ['冲突升级可能', '供应中断风险', '波动率激增'],
affectedSymbols: ['SC', 'AU', 'AG'],
},
{
title: '黄金价格创历史新高',
summary: 'COMEX黄金突破3100美元关口避险需求强劲',
content: '详细内容...',
impact: 'bullish',
impactLevel: 4,
analysis: '特朗普关税政策"2.0"版本引发市场担忧,叠加地缘政治风险,黄金避险属性凸显。技术面突破历史高点,多头趋势强劲。预计金价短期维持强势,回调即是买入机会。',
risks: ['获利回吐压力', '美元反弹风险', '美联储政策转向'],
affectedSymbols: ['AU', 'AG'],
},
{
title: '铜供应紧张预期',
summary: '全球铜市场预计出现18万吨供应缺口美国或加征铜进口关税',
content: '详细内容...',
impact: 'bullish',
impactLevel: 4,
analysis: '铜精矿TC现货指数持续回落矿山供应增长放缓。美国可能加征铜进口关税刺激美铜价格上涨全球套利行为收紧供应。需求端新能源产业用铜需求快速增长供需矛盾支撑铜价。',
risks: ['需求放缓风险', '美元走强压制', '库存累积'],
affectedSymbols: ['CU'],
},
{
title: '美联储通胀数据超预期',
summary: '核心PCE数据超预期降息预期降温',
content: '详细内容...',
impact: 'bearish',
impactLevel: 3,
analysis: '美国2月核心PCE通胀数据超预期市场对美联储降息预期降温。美元指数短期获得支撑对大宗商品形成一定压制。但地缘政治风险对冲了部分利空影响。',
risks: ['加息预期升温', '美元持续走强', '风险资产抛售'],
affectedSymbols: ['AU', 'AG', 'SC', 'CU'],
},
{
title: 'OPEC+减产延期预期',
summary: 'OPEC+可能延长减产协议至二季度末',
content: '详细内容...',
impact: 'bullish',
impactLevel: 3,
analysis: 'OPEC+成员国倾向于延长减产协议以支撑油价。全球原油需求预期改善,叠加供应端约束,原油市场供需格局趋紧。关注减产协议正式落地情况。',
risks: ['减产执行力度', '非OPEC增产', '需求不及预期'],
affectedSymbols: ['SC'],
},
];
for (const event of events) {
const existing = await prisma.hotEvent.findFirst({
where: { title: event.title },
});
if (!existing) {
// 查找产品ID
const products = await prisma.product.findMany({
where: { symbol: { in: event.affectedSymbols } },
});
await prisma.hotEvent.create({
data: {
title: event.title,
summary: event.summary,
content: event.content,
impact: event.impact,
impactLevel: event.impactLevel,
analysis: event.analysis,
risks: event.risks,
affectedProducts: {
create: products.map((p) => ({
productId: p.id,
impactConfidence: 0.8,
})),
},
},
});
}
}
console.log(`✅ 已创建 ${events.length} 个热点事件`);
// 创建系统配置
const configs = [
{ key: 'MARKET_STATUS', value: 'open', description: '市场状态' },
{ key: 'TRADING_HOURS', value: '09:00-11:30,13:30-15:00,21:00-02:30', description: '交易时间' },
];
for (const config of configs) {
await prisma.systemConfig.upsert({
where: { key: config.key },
update: config,
create: config,
});
}
console.log(`✅ 已创建 ${configs.length} 个系统配置`);
console.log('✨ 种子数据完成!');
}
main()
.catch((e) => {
console.error('❌ 种子数据失败:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

@ -0,0 +1,31 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { AiService } from './ai.service';
@ApiTags('AI智能分析')
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Get('analyze/:symbol')
@ApiOperation({ summary: 'AI智能分析品种' })
@ApiParam({ name: 'symbol', description: '品种代码' })
@ApiResponse({ status: 200, description: '分析成功' })
async analyzeSymbol(@Param('symbol') symbol: string) {
return this.aiService.analyzeSymbol(symbol);
}
@Get('batch-analyze')
@ApiOperation({ summary: '批量AI分析' })
@ApiResponse({ status: 200, description: '分析成功' })
async batchAnalyze(@Query('symbols') symbols: string) {
const symbolList = symbols.split(',');
const results = await Promise.all(
symbolList.map((s) => this.aiService.analyzeSymbol(s.trim())),
);
return symbolList.reduce((acc, symbol, index) => {
acc[symbol] = results[index];
return acc;
}, {});
}
}

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { MarketModule } from '../market/market.module';
import { IndicatorsModule } from '../indicators/indicators.module';
@Module({
imports: [ConfigModule, MarketModule, IndicatorsModule],
controllers: [AiController],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

@ -0,0 +1,366 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
import { MarketService } from '../market/market.service';
import { IndicatorsService } from '../indicators/indicators.service';
export interface AIAnalysisResult {
summary: string;
trend: 'bullish' | 'bearish' | 'neutral';
confidence: number;
keyFactors: string[];
supportLevels: number[];
resistanceLevels: number[];
recommendation: {
action: 'buy' | 'sell' | 'hold';
entryPrice?: number;
stopLoss?: number;
targetPrice?: number;
reasoning: string;
};
riskFactors: string[];
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private openai: OpenAI | null = null;
constructor(
private readonly configService: ConfigService,
private readonly marketService: MarketService,
private readonly indicatorsService: IndicatorsService,
) {
const apiKey = this.configService.get('openai.apiKey');
if (apiKey) {
this.openai = new OpenAI({
apiKey,
baseURL: this.configService.get('openai.baseURL'),
});
}
}
/**
* AI
*/
async analyzeSymbol(symbol: string): Promise<AIAnalysisResult> {
try {
// 获取市场数据
const [product, klineData, indicators] = await Promise.all([
this.marketService.getProduct(symbol),
this.marketService.getKlineData(symbol, '1d', 60),
this.indicatorsService.calculateAllIndicators(symbol, '1d'),
]);
// 计算技术指标信号
const technicalSignals = this.analyzeTechnicalIndicators(indicators);
// 如果有OpenAI API使用AI分析
if (this.openai) {
return await this.analyzeWithAI(symbol, product, klineData, indicators, technicalSignals);
}
// 否则使用规则分析
return this.analyzeWithRules(symbol, product, klineData, indicators, technicalSignals);
} catch (error) {
this.logger.error(`AI分析失败 ${symbol}:`, error.message);
return this.getDefaultAnalysis(symbol);
}
}
/**
* 使OpenAI
*/
private async analyzeWithAI(
symbol: string,
product: any,
klineData: any[],
indicators: any,
technicalSignals: any,
): Promise<AIAnalysisResult> {
const prompt = this.buildAnalysisPrompt(symbol, product, klineData, indicators, technicalSignals);
try {
const response = await this.openai!.chat.completions.create({
model: this.configService.get('openai.model') || 'gpt-4-turbo-preview',
messages: [
{
role: 'system',
content: '你是一位专业的期货分析师,擅长技术分析和基本面分析。请基于提供的数据给出专业的分析和交易建议。',
},
{
role: 'user',
content: prompt,
},
],
temperature: 0.7,
max_tokens: 2000,
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('AI返回空响应');
}
return this.parseAIResponse(content, symbol, product);
} catch (error) {
this.logger.error('AI分析请求失败:', error.message);
return this.analyzeWithRules(symbol, product, klineData, indicators, technicalSignals);
}
}
/**
*
*/
private analyzeWithRules(
symbol: string,
product: any,
klineData: any[],
indicators: any,
technicalSignals: any,
): AIAnalysisResult {
const currentPrice = product.price;
const lastKline = klineData[klineData.length - 1];
// 综合评分
let bullishScore = 0;
let bearishScore = 0;
if (technicalSignals.macd === 'bullish') bullishScore += 2;
if (technicalSignals.macd === 'bearish') bearishScore += 2;
if (technicalSignals.rsi === 'oversold') bullishScore += 1;
if (technicalSignals.rsi === 'overbought') bearishScore += 1;
if (technicalSignals.kdj === 'golden_cross') bullishScore += 2;
if (technicalSignals.kdj === 'dead_cross') bearishScore += 2;
// 趋势判断
const trendChange = lastKline.close - klineData[klineData.length - 20].close;
if (trendChange > 0) bullishScore += 1;
else bearishScore += 1;
// 确定整体趋势
let trend: 'bullish' | 'bearish' | 'neutral' = 'neutral';
let confidence = 50;
if (bullishScore > bearishScore) {
trend = 'bullish';
confidence = 50 + (bullishScore - bearishScore) * 10;
} else if (bearishScore > bullishScore) {
trend = 'bearish';
confidence = 50 + (bearishScore - bullishScore) * 10;
}
confidence = Math.min(confidence, 95);
// 计算支撑阻力位
const supportLevels = this.calculateSupportLevels(klineData);
const resistanceLevels = this.calculateResistanceLevels(klineData);
// 生成建议
const recommendation = this.generateRecommendation(
trend, currentPrice, supportLevels, resistanceLevels, confidence,
);
return {
summary: this.generateSummary(symbol, trend, technicalSignals),
trend,
confidence,
keyFactors: this.generateKeyFactors(technicalSignals),
supportLevels,
resistanceLevels,
recommendation,
riskFactors: this.generateRiskFactors(trend, technicalSignals),
};
}
/**
*
*/
private analyzeTechnicalIndicators(indicators: any): any {
const signals: any = {};
// MACD信号
const macdLast = indicators.macd[indicators.macd.length - 1];
const macdPrev = indicators.macd[indicators.macd.length - 2];
if (macdPrev.dif < macdPrev.dea && macdLast.dif > macdLast.dea) {
signals.macd = 'bullish';
} else if (macdPrev.dif > macdPrev.dea && macdLast.dif < macdLast.dea) {
signals.macd = 'bearish';
} else {
signals.macd = 'neutral';
}
// RSI信号
const rsiLast = indicators.rsi[indicators.rsi.length - 1];
signals.rsi = rsiLast.status;
// KDJ信号
const kdjLast = indicators.kdj[indicators.kdj.length - 1];
signals.kdj = kdjLast.signal;
// 布林带信号
const bollLast = indicators.bollinger[indicators.bollinger.length - 1];
signals.bollinger = bollLast.position;
return signals;
}
private buildAnalysisPrompt(
symbol: string,
product: any,
klineData: any[],
indicators: any,
signals: any,
): string {
const recentKlines = klineData.slice(-20);
const currentPrice = product.price;
return `
${symbol} (${product.name})
: ${currentPrice}
: ${product.changePercent}%
20K线:
${recentKlines.map(k =>
`日期: ${k.time}, 开盘: ${k.open}, 最高: ${k.high}, 最低: ${k.low}, 收盘: ${k.close}, 成交量: ${k.volume}`
).join('\n')}
:
MACD: DIF=${indicators.macd[indicators.macd.length - 1].dif}, DEA=${indicators.macd[indicators.macd.length - 1].dea}
RSI: ${indicators.rsi[indicators.rsi.length - 1].value}
KDJ: K=${indicators.kdj[indicators.kdj.length - 1].k}, D=${indicators.kdj[indicators.kdj.length - 1].d}, J=${indicators.kdj[indicators.kdj.length - 1].j}
: =${indicators.bollinger[indicators.bollinger.length - 1].upper}, =${indicators.bollinger[indicators.bollinger.length - 1].middle}, =${indicators.bollinger[indicators.bollinger.length - 1].lower}
(JSON):
{
"summary": "技术面综述",
"trend": "bullish/bearish/neutral",
"confidence": 75,
"keyFactors": ["关键因素1", "关键因素2"],
"supportLevels": [1, 2],
"resistanceLevels": [1, 2],
"recommendation": {
"action": "buy/sell/hold",
"entryPrice": ,
"stopLoss": ,
"targetPrice": ,
"reasoning": "建议理由"
},
"riskFactors": ["风险因素1", "风险因素2"]
}
`;
}
private parseAIResponse(content: string, symbol: string, product: any): AIAnalysisResult {
try {
// 尝试从响应中提取JSON
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
return {
summary: parsed.summary || '',
trend: parsed.trend || 'neutral',
confidence: parsed.confidence || 50,
keyFactors: parsed.keyFactors || [],
supportLevels: parsed.supportLevels || [],
resistanceLevels: parsed.resistanceLevels || [],
recommendation: parsed.recommendation || { action: 'hold', reasoning: '' },
riskFactors: parsed.riskFactors || [],
};
}
} catch (error) {
this.logger.error('解析AI响应失败:', error.message);
}
return this.getDefaultAnalysis(symbol);
}
private calculateSupportLevels(klineData: any[]): number[] {
const lows = klineData.slice(-20).map(k => k.low);
lows.sort((a, b) => a - b);
return [lows[2], lows[5]].filter(Boolean);
}
private calculateResistanceLevels(klineData: any[]): number[] {
const highs = klineData.slice(-20).map(k => k.high);
highs.sort((a, b) => b - a);
return [highs[2], highs[5]].filter(Boolean);
}
private generateRecommendation(
trend: string,
currentPrice: number,
supports: number[],
resistances: number[],
confidence: number,
): AIAnalysisResult['recommendation'] {
const volatility = currentPrice * 0.02;
if (trend === 'bullish') {
return {
action: 'buy',
entryPrice: currentPrice,
stopLoss: supports[0] || currentPrice - volatility,
targetPrice: resistances[0] || currentPrice + volatility * 2,
reasoning: `技术分析显示多头趋势,信心指数${confidence}%。建议在支撑位附近入场,严格设置止损。`,
};
} else if (trend === 'bearish') {
return {
action: 'sell',
entryPrice: currentPrice,
stopLoss: resistances[0] || currentPrice + volatility,
targetPrice: supports[0] || currentPrice - volatility * 2,
reasoning: `技术分析显示空头趋势,信心指数${confidence}%。建议逢高做空,严格设置止损。`,
};
}
return {
action: 'hold',
reasoning: '市场方向不明,建议观望等待更清晰的趋势信号。',
};
}
private generateSummary(symbol: string, trend: string, signals: any): string {
const trendText = trend === 'bullish' ? '偏多' : trend === 'bearish' ? '偏空' : '震荡';
return `${symbol}当前技术面${trendText}。MACD显示${signals.macd === 'bullish' ? '金叉' : signals.macd === 'bearish' ? '死叉' : '中性'}RSI处于${signals.rsi}区域KDJ${signals.kdj === 'golden_cross' ? '金叉' : signals.kdj === 'dead_cross' ? '死叉' : '中性'}`;
}
private generateKeyFactors(signals: any): string[] {
const factors: string[] = [];
if (signals.macd === 'bullish') factors.push('MACD金叉');
if (signals.macd === 'bearish') factors.push('MACD死叉');
if (signals.rsi === 'oversold') factors.push('RSI超卖');
if (signals.rsi === 'overbought') factors.push('RSI超买');
if (signals.kdj === 'golden_cross') factors.push('KDJ金叉');
if (signals.kdj === 'dead_cross') factors.push('KDJ死叉');
return factors.length > 0 ? factors : ['技术指标中性'];
}
private generateRiskFactors(trend: string, signals: any): string[] {
const risks: string[] = ['市场系统性风险', '黑天鹅事件'];
if (trend === 'bullish') risks.push('获利回吐压力');
if (trend === 'bearish') risks.push('超跌反弹风险');
if (signals.rsi === 'overbought') risks.push('技术指标超买');
if (signals.rsi === 'oversold') risks.push('技术指标超卖');
return risks;
}
private getDefaultAnalysis(symbol: string): AIAnalysisResult {
return {
summary: `${symbol} 数据暂时无法获取完整分析`,
trend: 'neutral',
confidence: 50,
keyFactors: ['数据不足'],
supportLevels: [],
resistanceLevels: [],
recommendation: {
action: 'hold',
reasoning: '数据不足,建议观望',
},
riskFactors: ['数据获取失败'],
};
}
}

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AlertService } from './alert.service';
import { MarketModule } from '../market/market.module';
@Module({
imports: [MarketModule],
providers: [AlertService],
exports: [AlertService],
})
export class AlertModule {}

@ -0,0 +1,161 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '@common/prisma/prisma.service';
import { RedisService } from '@common/redis/redis.service';
import { MarketService } from '../market/market.service';
export interface PriceAlert {
id: string;
symbol: string;
alertType: 'above' | 'below';
alertPrice: number;
isActive: boolean;
}
@Injectable()
export class AlertService {
private readonly logger = new Logger(AlertService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly marketService: MarketService,
) {}
/**
*
*/
async createAlert(
userId: number,
symbol: string,
alertType: 'above' | 'below',
alertPrice: number,
): Promise<PriceAlert> {
// 验证品种存在
await this.marketService.getProduct(symbol);
const alert = await this.prisma.priceAlert.create({
data: {
userId,
symbol: symbol.toUpperCase(),
alertType,
alertPrice,
isActive: true,
},
});
return {
id: alert.id.toString(),
symbol: alert.symbol,
alertType: alert.alertType as 'above' | 'below',
alertPrice: Number(alert.alertPrice),
isActive: alert.isActive,
};
}
/**
*
*/
async getUserAlerts(userId: number): Promise<PriceAlert[]> {
const alerts = await this.prisma.priceAlert.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
return alerts.map((alert) => ({
id: alert.id.toString(),
symbol: alert.symbol,
alertType: alert.alertType as 'above' | 'below',
alertPrice: Number(alert.alertPrice),
isActive: alert.isActive,
}));
}
/**
*
*/
async deleteAlert(userId: number, alertId: string): Promise<void> {
const alert = await this.prisma.priceAlert.findFirst({
where: {
id: parseInt(alertId),
userId,
},
});
if (!alert) {
throw new Error('预警不存在');
}
await this.prisma.priceAlert.delete({
where: { id: parseInt(alertId) },
});
}
/**
* /
*/
async toggleAlert(userId: number, alertId: string, isActive: boolean): Promise<PriceAlert> {
const alert = await this.prisma.priceAlert.update({
where: {
id: parseInt(alertId),
},
data: { isActive },
});
return {
id: alert.id.toString(),
symbol: alert.symbol,
alertType: alert.alertType as 'above' | 'below',
alertPrice: Number(alert.alertPrice),
isActive: alert.isActive,
};
}
/**
*
*/
@Cron(CronExpression.EVERY_MINUTE)
async checkPriceAlerts() {
try {
const activeAlerts = await this.prisma.priceAlert.findMany({
where: { isActive: true },
});
for (const alert of activeAlerts) {
try {
const tick = await this.marketService.getTickData(alert.symbol);
const currentPrice = tick.price;
const alertPrice = Number(alert.alertPrice);
let triggered = false;
if (alert.alertType === 'above' && currentPrice >= alertPrice) {
triggered = true;
} else if (alert.alertType === 'below' && currentPrice <= alertPrice) {
triggered = true;
}
if (triggered) {
// 标记为已触发
await this.prisma.priceAlert.update({
where: { id: alert.id },
data: {
isActive: false,
triggeredAt: new Date(),
},
});
// TODO: 发送通知(邮件/短信/推送)
this.logger.log(
`价格预警触发: ${alert.symbol} ${alert.alertType} ${alertPrice}, 当前价格: ${currentPrice}`,
);
}
} catch (error) {
this.logger.error(`检查预警失败 ${alert.symbol}:`, error.message);
}
}
} catch (error) {
this.logger.error('检查价格预警失败:', error.message);
}
}
}

@ -0,0 +1,87 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
// 配置模块
import appConfig from './config/app.config';
import databaseConfig from './config/database.config';
import redisConfig from './config/redis.config';
import jwtConfig from './config/jwt.config';
import marketDataConfig from './config/market-data.config';
import openaiConfig from './config/openai.config';
// 功能模块
import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';
import { MarketModule } from './market/market.module';
import { IndicatorsModule } from './indicators/indicators.module';
import { OptionsModule } from './options/options.module';
import { AiModule } from './ai/ai.module';
import { EventsModule } from './events/events.module';
import { WatchlistModule } from './watchlist/watchlist.module';
import { AlertModule } from './alert/alert.module';
import { CommonModule } from './common/common.module';
// 拦截器
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
@Module({
imports: [
// 配置模块
ConfigModule.forRoot({
isGlobal: true,
load: [
appConfig,
databaseConfig,
redisConfig,
jwtConfig,
marketDataConfig,
openaiConfig,
],
envFilePath: ['.env.local', '.env'],
}),
// 限流模块
ThrottlerModule.forRoot([{
ttl: 60000, // 1分钟
limit: 100, // 每IP每分钟最多100请求
}]),
// 定时任务模块
ScheduleModule.forRoot(),
// 公共模块
CommonModule,
// 业务模块
AuthModule,
UserModule,
MarketModule,
IndicatorsModule,
OptionsModule,
AiModule,
EventsModule,
WatchlistModule,
AlertModule,
],
providers: [
// 全局拦截器
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
// 全局限流守卫
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

@ -0,0 +1,76 @@
import {
Controller,
Post,
Body,
Get,
Put,
Headers,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { RegisterDto, LoginDto, AuthResponseDto, UserProfileDto } from './dto/auth.dto';
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: '用户注册' })
@ApiResponse({ status: 201, description: '注册成功', type: AuthResponseDto })
@ApiResponse({ status: 400, description: '参数错误或用户已存在' })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '用户登录' })
@ApiResponse({ status: 200, description: '登录成功', type: AuthResponseDto })
@ApiResponse({ status: 401, description: '用户名或密码错误' })
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '用户登出' })
async logout(@Headers('authorization') auth: string) {
const token = auth?.replace('Bearer ', '');
return this.authService.logout(token);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新Token' })
async refreshToken(@Body('refreshToken') refreshToken: string) {
return this.authService.refreshToken(refreshToken);
}
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '获取用户信息' })
@ApiResponse({ status: 200, description: '获取成功', type: UserProfileDto })
async getProfile(@CurrentUser('userId') userId: number) {
return this.authService.getProfile(userId);
}
@Put('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '更新用户信息' })
async updateProfile(
@CurrentUser('userId') userId: number,
@Body() data: Partial<{ phone: string; avatarUrl: string }>,
) {
return this.authService.updateProfile(userId, data);
}
}

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => ({
secret: configService.get('jwt.secret'),
signOptions: {
expiresIn: configService.get('jwt.expiresIn'),
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

@ -0,0 +1,212 @@
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '@common/prisma/prisma.service';
import { RedisService } from '@common/redis/redis.service';
import { RegisterDto, LoginDto } from './dto/auth.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly redis: RedisService,
) {}
async register(dto: RegisterDto) {
// 检查用户名是否已存在
const existingUser = await this.prisma.user.findFirst({
where: {
OR: [
{ username: dto.username },
{ email: dto.email },
],
},
});
if (existingUser) {
throw new BadRequestException('用户名或邮箱已存在');
}
// 加密密码
const passwordHash = await bcrypt.hash(dto.password, 10);
// 创建用户
const user = await this.prisma.user.create({
data: {
username: dto.username,
email: dto.email,
passwordHash,
phone: dto.phone,
},
select: {
id: true,
username: true,
email: true,
avatarUrl: true,
membershipLevel: true,
createdAt: true,
},
});
// 生成Token
const tokens = await this.generateTokens(user.id, user.username);
return {
user,
...tokens,
};
}
async login(dto: LoginDto) {
// 查找用户
const user = await this.prisma.user.findFirst({
where: {
OR: [
{ username: dto.username },
{ email: dto.username },
],
},
});
if (!user) {
throw new UnauthorizedException('用户名或密码错误');
}
// 验证密码
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('用户名或密码错误');
}
// 检查用户状态
if (!user.isActive) {
throw new UnauthorizedException('账户已被禁用');
}
// 生成Token
const tokens = await this.generateTokens(user.id, user.username);
// 保存会话到Redis
const ttl = parseInt(this.configService.get('jwt.expiresIn'), 10) || 3600;
await this.redis.setSession(tokens.token, user.id, ttl);
return {
user: {
id: user.id,
username: user.username,
email: user.email,
avatarUrl: user.avatarUrl,
membershipLevel: user.membershipLevel,
},
...tokens,
};
}
async logout(token: string) {
await this.redis.deleteSession(token);
return { message: '登出成功' };
}
async refreshToken(refreshToken: string) {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get('jwt.secret'),
});
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('用户不存在或已被禁用');
}
const tokens = await this.generateTokens(user.id, user.username);
// 更新会话
const ttl = parseInt(this.configService.get('jwt.expiresIn'), 10) || 3600;
await this.redis.setSession(tokens.token, user.id, ttl);
return tokens;
} catch (error) {
throw new UnauthorizedException('无效的刷新令牌');
}
}
async getProfile(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
email: true,
phone: true,
avatarUrl: true,
membershipLevel: true,
createdAt: true,
},
});
if (!user) {
throw new UnauthorizedException('用户不存在');
}
return user;
}
async updateProfile(userId: number, data: Partial<{ phone: string; avatarUrl: string }>) {
const user = await this.prisma.user.update({
where: { id: userId },
data,
select: {
id: true,
username: true,
email: true,
phone: true,
avatarUrl: true,
membershipLevel: true,
},
});
return user;
}
private async generateTokens(userId: number, username: string) {
const payload = { sub: userId, username };
const [token, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload),
this.jwtService.signAsync(payload, {
expiresIn: this.configService.get('jwt.refreshExpiresIn') || '7d',
}),
]);
return {
token,
refreshToken,
expiresIn: parseInt(this.configService.get('jwt.expiresIn'), 10) || 3600,
};
}
async validateUser(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
email: true,
isActive: true,
membershipLevel: true,
},
});
if (!user || !user.isActive) {
return null;
}
return user;
}
}

@ -0,0 +1,14 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return null;
}
return data ? user[data] : user;
},
);

@ -0,0 +1,88 @@
import { IsString, IsEmail, IsOptional, MinLength, IsPhoneNumber, IsUrl } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ description: '用户名', example: 'trader001' })
@IsString()
@MinLength(3)
username: string;
@ApiProperty({ description: '邮箱', example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ description: '密码', example: 'password123' })
@IsString()
@MinLength(6)
password: string;
@ApiProperty({ description: '手机号', required: false, example: '13800138000' })
@IsOptional()
@IsPhoneNumber('CN')
phone?: string;
}
export class LoginDto {
@ApiProperty({ description: '用户名或邮箱', example: 'trader001' })
@IsString()
username: string;
@ApiProperty({ description: '密码', example: 'password123' })
@IsString()
password: string;
}
export class AuthResponseDto {
@ApiProperty()
user: {
id: number;
username: string;
email: string;
avatarUrl: string | null;
membershipLevel: number;
};
@ApiProperty()
token: string;
@ApiProperty()
refreshToken: string;
@ApiProperty()
expiresIn: number;
}
export class UserProfileDto {
@ApiProperty()
id: number;
@ApiProperty()
username: string;
@ApiProperty()
email: string;
@ApiProperty({ nullable: true })
phone: string | null;
@ApiProperty({ nullable: true })
avatarUrl: string | null;
@ApiProperty()
membershipLevel: number;
@ApiProperty()
createdAt: Date;
}
export class UpdateProfileDto {
@ApiProperty({ required: false })
@IsOptional()
@IsPhoneNumber('CN')
phone?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsUrl()
avatarUrl?: string;
}

@ -0,0 +1,19 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
return super.canActivate(context);
}
handleRequest(err, user, info) {
if (err || !user) {
throw err || new UnauthorizedException('请先登录');
}
return user;
}
}

@ -0,0 +1,38 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service';
interface JwtPayload {
sub: number;
username: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('jwt.secret'),
});
}
async validate(payload: JwtPayload) {
const user = await this.authService.validateUser(payload.sub);
if (!user) {
throw new UnauthorizedException('用户不存在或已被禁用');
}
return {
userId: payload.sub,
username: payload.username,
membershipLevel: user.membershipLevel,
};
}
}

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
@Module({
imports: [PrismaModule, RedisModule],
exports: [PrismaModule, RedisModule],
})
export class CommonModule {}

@ -0,0 +1,30 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.url;
const now = Date.now();
return next.handle().pipe(
tap(() => {
const response = context.switchToHttp().getResponse();
const statusCode = response.statusCode;
const delay = Date.now() - now;
console.log(
`[${new Date().toISOString()}] ${method} ${url} ${statusCode} - ${delay}ms`,
);
}),
);
}
}

@ -0,0 +1,33 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response<T> {
code: number;
message: string;
data: T;
timestamp: number;
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, Response<T>> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
return next.handle().pipe(
map((data) => ({
code: 200,
message: 'success',
data,
timestamp: Date.now(),
})),
);
}
}

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

@ -0,0 +1,23 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error', 'warn'],
});
}
async onModuleInit() {
await this.$connect();
console.log('📦 数据库连接成功');
}
async onModuleDestroy() {
await this.$disconnect();
console.log('📦 数据库连接已关闭');
}
}

@ -0,0 +1,30 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [
{
provide: 'REDIS_CLIENT',
useFactory: (configService: ConfigService) => {
const redisUrl = configService.get('redis.url');
const redisPassword = configService.get('redis.password');
return new Redis(redisUrl, {
password: redisPassword || undefined,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
});
},
inject: [ConfigService],
},
RedisService,
],
exports: [RedisService],
})
export class RedisModule {}

@ -0,0 +1,119 @@
import { Injectable, Inject } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisService {
constructor(
@Inject('REDIS_CLIENT')
private readonly redis: Redis,
) {}
// 基础操作
async get(key: string): Promise<string | null> {
return this.redis.get(key);
}
async set(key: string, value: string, ttl?: number): Promise<void> {
if (ttl) {
await this.redis.setex(key, ttl, value);
} else {
await this.redis.set(key, value);
}
}
async del(key: string): Promise<void> {
await this.redis.del(key);
}
async exists(key: string): Promise<boolean> {
const result = await this.redis.exists(key);
return result === 1;
}
async expire(key: string, seconds: number): Promise<void> {
await this.redis.expire(key, seconds);
}
// Hash操作
async hget(key: string, field: string): Promise<string | null> {
return this.redis.hget(key, field);
}
async hset(key: string, field: string, value: string): Promise<void> {
await this.redis.hset(key, field, value);
}
async hgetall(key: string): Promise<Record<string, string>> {
return this.redis.hgetall(key);
}
async hdel(key: string, field: string): Promise<void> {
await this.redis.hdel(key, field);
}
// List操作
async lpush(key: string, value: string): Promise<void> {
await this.redis.lpush(key, value);
}
async rpush(key: string, value: string): Promise<void> {
await this.redis.rpush(key, value);
}
async lrange(key: string, start: number, stop: number): Promise<string[]> {
return this.redis.lrange(key, start, stop);
}
// Sorted Set操作
async zadd(key: string, score: number, member: string): Promise<void> {
await this.redis.zadd(key, score, member);
}
async zrange(key: string, start: number, stop: number): Promise<string[]> {
return this.redis.zrange(key, start, stop);
}
async zrevrange(key: string, start: number, stop: number): Promise<string[]> {
return this.redis.zrevrange(key, start, stop);
}
async zremrangebyscore(key: string, min: number, max: number): Promise<void> {
await this.redis.zremrangebyscore(key, min, max);
}
// 行情数据专用方法
async setTick(symbol: string, data: Record<string, string>, ttl: number = 60): Promise<void> {
const key = `market:tick:${symbol}`;
await this.redis.hset(key, data);
await this.redis.expire(key, ttl);
}
async getTick(symbol: string): Promise<Record<string, string>> {
const key = `market:tick:${symbol}`;
return this.redis.hgetall(key);
}
async addKline(symbol: string, period: string, data: string, maxCount: number = 500): Promise<void> {
const key = `market:kline:${symbol}:${period}`;
const score = Date.now();
await this.redis.zadd(key, score, data);
await this.redis.zremrangebyrank(key, 0, -(maxCount + 1));
await this.redis.expire(key, 7 * 24 * 60 * 60);
}
async getKlines(symbol: string, period: string, count: number = 100): Promise<string[]> {
const key = `market:kline:${symbol}:${period}`;
return this.redis.zrevrange(key, 0, count - 1);
}
// Session管理
async setSession(token: string, userId: number, ttl: number): Promise<void> {
const key = `session:${token}`;
await this.redis.setex(key, ttl, userId.toString());
}
async deleteSession(token: string): Promise<void> {
const key = `session:${token}`;
await this.redis.del(key);
}
}

@ -0,0 +1,11 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT, 10) || 3000,
apiPrefix: process.env.API_PREFIX || '/api/v1',
corsOrigin: process.env.CORS_ORIGIN || '*',
logLevel: process.env.LOG_LEVEL || 'debug',
metricsEnabled: process.env.METRICS_ENABLED === 'true',
metricsPort: parseInt(process.env.METRICS_PORT, 10) || 9090,
}));

@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
url: process.env.DATABASE_URL,
}));

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
secret: process.env.JWT_SECRET || 'default-secret-change-in-production',
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
}));

@ -0,0 +1,14 @@
import { registerAs } from '@nestjs/config';
export default registerAs('marketData', () => ({
provider: process.env.MARKET_DATA_PROVIDER || 'mock',
apiKey: process.env.MARKET_DATA_API_KEY,
apiSecret: process.env.MARKET_DATA_API_SECRET,
wsUrl: process.env.MARKET_DATA_WS_URL,
// 重连配置
reconnectInterval: 5000,
maxReconnectAttempts: 10,
// 数据更新频率 (毫秒)
tickUpdateInterval: 1000,
klineUpdateInterval: 5000,
}));

@ -0,0 +1,14 @@
import { registerAs } from '@nestjs/config';
export default registerAs('openai', () => ({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
model: process.env.OPENAI_MODEL || 'gpt-4-turbo-preview',
// AI分析配置
maxTokens: 2000,
temperature: 0.7,
// 请求限制
maxRequestsPerMinute: 20,
// 缓存配置
cacheTTL: 300, // 5分钟
}));

@ -0,0 +1,6 @@
import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({
url: process.env.REDIS_URL || 'redis://localhost:6379',
password: process.env.REDIS_PASSWORD,
}));

@ -0,0 +1,42 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { EventsService } from './events.service';
@ApiTags('热点事件')
@Controller('events')
export class EventsController {
constructor(private readonly eventsService: EventsService) {}
@Get()
@ApiOperation({ summary: '获取热点事件列表' })
@ApiQuery({ name: 'impact', required: false, enum: ['bullish', 'bearish', 'neutral'] })
@ApiQuery({ name: 'startDate', required: false })
@ApiQuery({ name: 'endDate', required: false })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'size', required: false, type: Number })
@ApiResponse({ status: 200, description: '获取成功' })
async getEvents(
@Query('impact') impact?: string,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('page') page = '1',
@Query('size') size = '20',
) {
return this.eventsService.getEvents(
impact,
startDate ? new Date(startDate) : undefined,
endDate ? new Date(endDate) : undefined,
parseInt(page, 10),
parseInt(size, 10),
);
}
@Get(':id')
@ApiOperation({ summary: '获取事件详情' })
@ApiParam({ name: 'id', description: '事件ID' })
@ApiResponse({ status: 200, description: '获取成功' })
@ApiResponse({ status: 404, description: '事件不存在' })
async getEventById(@Param('id') id: string) {
return this.eventsService.getEventById(id);
}
}

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { MarketModule } from '../market/market.module';
@Module({
imports: [MarketModule],
controllers: [EventsController],
providers: [EventsService],
exports: [EventsService],
})
export class EventsModule {}

@ -0,0 +1,170 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@common/prisma/prisma.service';
export interface HotEvent {
id: string;
title: string;
summary: string;
content?: string;
time: string;
impact: 'bullish' | 'bearish' | 'neutral';
impactLevel: number;
affectedProducts: string[];
analysis?: string;
risks?: string[];
source?: string;
}
@Injectable()
export class EventsService {
constructor(private readonly prisma: PrismaService) {}
async getEvents(
impact?: string,
startDate?: Date,
endDate?: Date,
page = 1,
size = 20,
): Promise<{ list: HotEvent[]; total: number }> {
const where: any = {};
if (impact && impact !== 'all') {
where.impact = impact;
}
if (startDate || endDate) {
where.eventTime = {};
if (startDate) where.eventTime.gte = startDate;
if (endDate) where.eventTime.lte = endDate;
}
const [events, total] = await Promise.all([
this.prisma.hotEvent.findMany({
where,
include: {
affectedProducts: {
include: {
product: true,
},
},
},
orderBy: { eventTime: 'desc' },
skip: (page - 1) * size,
take: size,
}),
this.prisma.hotEvent.count({ where }),
]);
const list = events.map((e) => ({
id: e.id.toString(),
title: e.title,
summary: e.summary || '',
content: e.content || undefined,
time: e.eventTime?.toISOString() || e.createdAt.toISOString(),
impact: e.impact as 'bullish' | 'bearish' | 'neutral',
impactLevel: e.impactLevel,
affectedProducts: e.affectedProducts.map((ep) => ep.product.symbol),
analysis: e.analysis || undefined,
risks: e.risks || undefined,
source: e.source || undefined,
}));
return { list, total };
}
async getEventById(id: string): Promise<HotEvent | null> {
const event = await this.prisma.hotEvent.findUnique({
where: { id: parseInt(id) },
include: {
affectedProducts: {
include: {
product: true,
},
},
},
});
if (!event) return null;
return {
id: event.id.toString(),
title: event.title,
summary: event.summary || '',
content: event.content || undefined,
time: event.eventTime?.toISOString() || event.createdAt.toISOString(),
impact: event.impact as 'bullish' | 'bearish' | 'neutral',
impactLevel: event.impactLevel,
affectedProducts: event.affectedProducts.map((ep) => ep.product.symbol),
analysis: event.analysis || undefined,
risks: event.risks || undefined,
source: event.source || undefined,
};
}
// 创建示例事件数据(用于初始化)
async seedEvents() {
const events = [
{
title: '地缘政治风险升级',
summary: '美以袭伊朗,"海上油阀"被关,中东局势紧张升级',
content: '详细内容...',
impact: 'bullish',
impactLevel: 5,
analysis: '地缘政治风险急剧升温,霍尔木兹海峡封锁风险上升。原油供应中断担忧推动油价上涨,避险资产黄金、白银同步走强。',
risks: ['冲突升级可能', '供应中断风险', '波动率激增'],
affectedSymbols: ['SC', 'AU', 'AG'],
},
{
title: '黄金价格创历史新高',
summary: 'COMEX黄金突破3100美元关口避险需求强劲',
content: '详细内容...',
impact: 'bullish',
impactLevel: 4,
analysis: '特朗普关税政策引发市场担忧,叠加地缘政治风险,黄金避险属性凸显。技术面突破历史高点,多头趋势强劲。',
risks: ['获利回吐压力', '美元反弹风险', '美联储政策转向'],
affectedSymbols: ['AU', 'AG'],
},
{
title: '铜供应紧张预期',
summary: '全球铜市场预计出现18万吨供应缺口',
content: '详细内容...',
impact: 'bullish',
impactLevel: 4,
analysis: '铜精矿TC现货指数持续回落矿山供应增长放缓。美国可能加征铜进口关税刺激美铜价格上涨。',
risks: ['需求放缓风险', '美元走强压制', '库存累积'],
affectedSymbols: ['CU'],
},
];
for (const event of events) {
const existing = await this.prisma.hotEvent.findFirst({
where: { title: event.title },
});
if (!existing) {
// 查找产品ID
const products = await this.prisma.product.findMany({
where: { symbol: { in: event.affectedSymbols } },
});
await this.prisma.hotEvent.create({
data: {
title: event.title,
summary: event.summary,
content: event.content,
impact: event.impact,
impactLevel: event.impactLevel,
analysis: event.analysis,
risks: event.risks,
affectedProducts: {
create: products.map((p) => ({
productId: p.id,
impactConfidence: 0.8,
})),
},
},
});
}
}
}
}

@ -0,0 +1,20 @@
import { IsString, IsOptional, IsEnum, IsArray } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IndicatorType } from '../interfaces/indicator.interface';
export class GetIndicatorsDto {
@ApiProperty({ description: '周期', enum: ['1m', '5m', '15m', '30m', '1h', '1d', '1w'] })
@IsString()
@IsEnum(['1m', '5m', '15m', '30m', '1h', '1d', '1w'])
period: string;
@ApiProperty({
description: '指标列表',
isArray: true,
enum: ['macd', 'rsi', 'bollinger', 'kdj', 'sar', 'obv', 'dmi', 'cci', 'wr', 'ma5', 'ma10', 'ma20', 'ma60'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
indicators?: IndicatorType[];
}

@ -0,0 +1,98 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { IndicatorsService } from './indicators.service';
import { GetIndicatorsDto } from './dto/indicator.dto';
@ApiTags('技术指标')
@Controller('indicators')
export class IndicatorsController {
constructor(private readonly indicatorsService: IndicatorsService) {}
@Get(':symbol')
@ApiOperation({ summary: '获取技术指标' })
@ApiParam({ name: 'symbol', description: '品种代码' })
@ApiResponse({ status: 200, description: '获取成功' })
async getIndicators(
@Param('symbol') symbol: string,
@Query() dto: GetIndicatorsDto,
) {
const results = await this.indicatorsService.calculateAllIndicators(
symbol,
dto.period,
);
// 如果指定了特定指标,只返回这些指标
if (dto.indicators && dto.indicators.length > 0) {
const filtered: any = {};
dto.indicators.forEach((ind) => {
if (results[ind]) {
filtered[ind] = results[ind];
}
});
return filtered;
}
return results;
}
@Get(':symbol/macd')
@ApiOperation({ summary: '获取MACD指标' })
@ApiParam({ name: 'symbol', description: '品种代码' })
async getMACD(
@Param('symbol') symbol: string,
@Query('period') period: string = '1d',
) {
const klineData = await this.indicatorsService['marketService'].getKlineData(
symbol,
period,
200,
);
return this.indicatorsService.calculateMACD(klineData);
}
@Get(':symbol/rsi')
@ApiOperation({ summary: '获取RSI指标' })
@ApiParam({ name: 'symbol', description: '品种代码' })
async getRSI(
@Param('symbol') symbol: string,
@Query('period') period: string = '1d',
@Query('rsiPeriod') rsiPeriod: number = 14,
) {
const klineData = await this.indicatorsService['marketService'].getKlineData(
symbol,
period,
200,
);
return this.indicatorsService.calculateRSI(klineData, rsiPeriod);
}
@Get(':symbol/bollinger')
@ApiOperation({ summary: '获取布林带指标' })
@ApiParam({ name: 'symbol', description: '品种代码' })
async getBollinger(
@Param('symbol') symbol: string,
@Query('period') period: string = '1d',
) {
const klineData = await this.indicatorsService['marketService'].getKlineData(
symbol,
period,
200,
);
return this.indicatorsService.calculateBollinger(klineData);
}
@Get(':symbol/kdj')
@ApiOperation({ summary: '获取KDJ指标' })
@ApiParam({ name: 'symbol', description: '品种代码' })
async getKDJ(
@Param('symbol') symbol: string,
@Query('period') period: string = '1d',
) {
const klineData = await this.indicatorsService['marketService'].getKlineData(
symbol,
period,
200,
);
return this.indicatorsService.calculateKDJ(klineData);
}
}

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { IndicatorsController } from './indicators.controller';
import { IndicatorsService } from './indicators.service';
import { MarketModule } from '../market/market.module';
@Module({
imports: [MarketModule],
controllers: [IndicatorsController],
providers: [IndicatorsService],
exports: [IndicatorsService],
})
export class IndicatorsModule {}

@ -0,0 +1,392 @@
import { Injectable } from '@nestjs/common';
import { MarketService } from '../market/market.service';
import { KLineData } from '../market/interfaces/market.interface';
import {
MACDResult,
RSIResult,
BollingerResult,
KDJResult,
SARResult,
OBVResult,
DMIResult,
CCIResult,
WRResult,
IndicatorResults,
} from './interfaces/indicator.interface';
@Injectable()
export class IndicatorsService {
constructor(private readonly marketService: MarketService) {}
// 计算MACD
calculateMACD(
klineData: KLineData[],
fastPeriod = 12,
slowPeriod = 26,
signalPeriod = 9,
): MACDResult[] {
const closes = klineData.map((k) => k.close);
const ema12 = this.calculateEMA(closes, fastPeriod);
const ema26 = this.calculateEMA(closes, slowPeriod);
const dif = ema12.map((v, i) => v - ema26[i]);
const dea = this.calculateEMA(dif, signalPeriod);
const macd = dif.map((v, i) => (v - dea[i]) * 2);
return klineData.map((k, i) => ({
time: k.time,
dif: Number(dif[i].toFixed(4)),
dea: Number(dea[i].toFixed(4)),
macd: Number(macd[i].toFixed(4)),
}));
}
// 计算RSI
calculateRSI(klineData: KLineData[], period = 14): RSIResult[] {
const closes = klineData.map((k) => k.close);
const rsi = this.calculateRSIValues(closes, period);
return klineData.map((k, i) => ({
time: k.time,
value: Number(rsi[i].toFixed(2)),
status: this.getRSIStatus(rsi[i]),
}));
}
// 计算布林带
calculateBollinger(
klineData: KLineData[],
period = 20,
stdDev = 2,
): BollingerResult[] {
const closes = klineData.map((k) => k.close);
const results: BollingerResult[] = [];
for (let i = period - 1; i < closes.length; i++) {
const slice = closes.slice(i - period + 1, i + 1);
const middle = this.calculateSMA(slice, period);
const std = this.calculateStdDev(slice, middle);
const upper = middle + stdDev * std;
const lower = middle - stdDev * std;
const currentPrice = closes[i];
results.push({
time: klineData[i].time,
upper: Number(upper.toFixed(2)),
middle: Number(middle.toFixed(2)),
lower: Number(lower.toFixed(2)),
position: currentPrice > upper ? 'upper' : currentPrice < lower ? 'lower' : 'middle',
});
}
return results;
}
// 计算KDJ
calculateKDJ(klineData: KLineData[], n = 9, m1 = 3, m2 = 3): KDJResult[] {
const highs = klineData.map((k) => k.high);
const lows = klineData.map((k) => k.low);
const closes = klineData.map((k) => k.close);
const rsv: number[] = [];
const k: number[] = [];
const d: number[] = [];
for (let i = 0; i < closes.length; i++) {
if (i < n - 1) {
rsv.push(50);
k.push(50);
d.push(50);
continue;
}
const periodHighs = highs.slice(i - n + 1, i + 1);
const periodLows = lows.slice(i - n + 1, i + 1);
const highest = Math.max(...periodHighs);
const lowest = Math.min(...periodLows);
const rsvValue = highest === lowest ? 50 : ((closes[i] - lowest) / (highest - lowest)) * 100;
rsv.push(rsvValue);
const kValue = (2 / 3) * (k[i - 1] || 50) + (1 / 3) * rsvValue;
k.push(kValue);
const dValue = (2 / 3) * (d[i - 1] || 50) + (1 / 3) * kValue;
d.push(dValue);
}
const j = k.map((kv, i) => 3 * kv - 2 * d[i]);
return klineData.map((kline, i) => ({
time: kline.time,
k: Number(k[i].toFixed(2)),
d: Number(d[i].toFixed(2)),
j: Number(j[i].toFixed(2)),
signal: this.getKDJSignal(k[i], d[i], k[i - 1], d[i - 1]),
}));
}
// 计算SAR (抛物线转向)
calculateSAR(klineData: KLineData[], af = 0.02, maxAf = 0.2): SARResult[] {
const highs = klineData.map((k) => k.high);
const lows = klineData.map((k) => k.low);
const results: SARResult[] = [];
let isLong = true;
let sar = lows[0];
let ep = highs[0];
let afValue = af;
for (let i = 1; i < klineData.length; i++) {
sar = sar + afValue * (ep - sar);
if (isLong) {
if (lows[i] < sar) {
isLong = false;
sar = ep;
ep = lows[i];
afValue = af;
} else {
if (highs[i] > ep) {
ep = highs[i];
afValue = Math.min(afValue + af, maxAf);
}
}
} else {
if (highs[i] > sar) {
isLong = true;
sar = ep;
ep = highs[i];
afValue = af;
} else {
if (lows[i] < ep) {
ep = lows[i];
afValue = Math.min(afValue + af, maxAf);
}
}
}
results.push({
time: klineData[i].time,
value: Number(sar.toFixed(2)),
isLong,
});
}
return results;
}
// 计算OBV
calculateOBV(klineData: KLineData[]): OBVResult[] {
const obv: number[] = [0];
for (let i = 1; i < klineData.length; i++) {
const prevClose = klineData[i - 1].close;
const currentClose = klineData[i].close;
const volume = klineData[i].volume;
if (currentClose > prevClose) {
obv.push(obv[i - 1] + volume);
} else if (currentClose < prevClose) {
obv.push(obv[i - 1] - volume);
} else {
obv.push(obv[i - 1]);
}
}
return klineData.map((k, i) => ({
time: k.time,
value: obv[i],
}));
}
// 计算DMI
calculateDMI(klineData: KLineData[], period = 14): DMIResult[] {
const highs = klineData.map((k) => k.high);
const lows = klineData.map((k) => k.low);
const closes = klineData.map((k) => k.close);
const tr: number[] = [];
const plusDM: number[] = [];
const minusDM: number[] = [];
for (let i = 1; i < klineData.length; i++) {
const highDiff = highs[i] - highs[i - 1];
const lowDiff = lows[i - 1] - lows[i];
tr.push(Math.max(
highs[i] - lows[i],
Math.abs(highs[i] - closes[i - 1]),
Math.abs(lows[i] - closes[i - 1]),
));
plusDM.push(highDiff > lowDiff && highDiff > 0 ? highDiff : 0);
minusDM.push(lowDiff > highDiff && lowDiff > 0 ? lowDiff : 0);
}
const atr = this.calculateEMA(tr, period);
const plusDI = this.calculateEMA(plusDM, period).map((v, i) => (v / atr[i]) * 100);
const minusDI = this.calculateEMA(minusDM, period).map((v, i) => (v / atr[i]) * 100);
const dx = plusDI.map((v, i) => Math.abs(v - minusDI[i]) / (v + minusDI[i]) * 100);
const adx = this.calculateEMA(dx, period);
return klineData.slice(1).map((k, i) => ({
time: k.time,
plusDI: Number(plusDI[i].toFixed(2)),
minusDI: Number(minusDI[i].toFixed(2)),
adx: Number(adx[i].toFixed(2)),
}));
}
// 计算CCI
calculateCCI(klineData: KLineData[], period = 14): CCIResult[] {
const results: CCIResult[] = [];
for (let i = period - 1; i < klineData.length; i++) {
const tp = klineData.slice(i - period + 1, i + 1).map(
(k) => (k.high + k.low + k.close) / 3,
);
const smaTP = tp.reduce((a, b) => a + b, 0) / period;
const meanDeviation = tp.reduce((a, b) => a + Math.abs(b - smaTP), 0) / period;
const cci = (tp[tp.length - 1] - smaTP) / (0.015 * meanDeviation);
results.push({
time: klineData[i].time,
value: Number(cci.toFixed(2)),
});
}
return results;
}
// 计算WR (威廉指标)
calculateWR(klineData: KLineData[], period = 14): WRResult[] {
const results: WRResult[] = [];
for (let i = period - 1; i < klineData.length; i++) {
const periodData = klineData.slice(i - period + 1, i + 1);
const highest = Math.max(...periodData.map((k) => k.high));
const lowest = Math.min(...periodData.map((k) => k.low));
const close = klineData[i].close;
const wr = highest === lowest ? -50 : ((highest - close) / (highest - lowest)) * -100;
results.push({
time: klineData[i].time,
value: Number(wr.toFixed(2)),
});
}
return results;
}
// 计算移动平均线
calculateMA(klineData: KLineData[], period: number): { time: string; value: number }[] {
const closes = klineData.map((k) => k.close);
const ma = this.calculateSMAArray(closes, period);
return klineData.slice(period - 1).map((k, i) => ({
time: k.time,
value: Number(ma[i].toFixed(2)),
}));
}
// 获取所有指标
async calculateAllIndicators(
symbol: string,
period: string,
): Promise<IndicatorResults> {
const klineData = await this.marketService.getKlineData(symbol, period, 200);
return {
macd: this.calculateMACD(klineData),
rsi: this.calculateRSI(klineData),
bollinger: this.calculateBollinger(klineData),
kdj: this.calculateKDJ(klineData),
sar: this.calculateSAR(klineData),
obv: this.calculateOBV(klineData),
dmi: this.calculateDMI(klineData),
cci: this.calculateCCI(klineData),
wr: this.calculateWR(klineData),
ma5: this.calculateMA(klineData, 5),
ma10: this.calculateMA(klineData, 10),
ma20: this.calculateMA(klineData, 20),
ma60: this.calculateMA(klineData, 60),
};
}
// 辅助方法
private calculateEMA(data: number[], period: number): number[] {
const k = 2 / (period + 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;
}
private calculateSMA(data: number[], period: number): number {
const sum = data.slice(-period).reduce((a, b) => a + b, 0);
return sum / period;
}
private calculateSMAArray(data: number[], period: number): number[] {
const sma: number[] = [];
for (let i = period - 1; i < data.length; i++) {
const sum = data.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0);
sma.push(sum / period);
}
return sma;
}
private calculateStdDev(data: number[], mean: number): number {
const squareDiffs = data.map((value) => Math.pow(value - mean, 2));
const avgSquareDiff = squareDiffs.reduce((a, b) => a + b, 0) / data.length;
return Math.sqrt(avgSquareDiff);
}
private calculateRSIValues(closes: number[], period: number): number[] {
const gains: number[] = [];
const losses: number[] = [];
for (let i = 1; i < closes.length; i++) {
const change = closes[i] - closes[i - 1];
gains.push(change > 0 ? change : 0);
losses.push(change < 0 ? Math.abs(change) : 0);
}
const rsi: number[] = [];
let avgGain = gains.slice(0, period).reduce((a, b) => a + b, 0) / period;
let avgLoss = losses.slice(0, period).reduce((a, b) => a + b, 0) / period;
for (let i = period; i < gains.length; i++) {
avgGain = (avgGain * (period - 1) + gains[i]) / period;
avgLoss = (avgLoss * (period - 1) + losses[i]) / period;
const rs = avgGain / avgLoss;
rsi.push(100 - 100 / (1 + rs));
}
// 填充前面的数据
return [...Array(period).fill(50), ...rsi];
}
private getRSIStatus(rsi: number): 'overbought' | 'oversold' | 'normal' {
if (rsi > 70) return 'overbought';
if (rsi < 30) return 'oversold';
return 'normal';
}
private getKDJSignal(
k: number,
d: number,
prevK: number,
prevD: number,
): 'golden_cross' | 'dead_cross' | 'neutral' {
if (prevK < prevD && k > d) return 'golden_cross';
if (prevK > prevD && k < d) return 'dead_cross';
return 'neutral';
}
}

@ -0,0 +1,92 @@
export interface MACDResult {
time: string;
dif: number;
dea: number;
macd: number;
}
export interface RSIResult {
time: string;
value: number;
status: 'overbought' | 'oversold' | 'normal';
}
export interface BollingerResult {
time: string;
upper: number;
middle: number;
lower: number;
position: 'upper' | 'middle' | 'lower';
}
export interface KDJResult {
time: string;
k: number;
d: number;
j: number;
signal: 'golden_cross' | 'dead_cross' | 'neutral';
}
export interface SARResult {
time: string;
value: number;
isLong: boolean;
}
export interface OBVResult {
time: string;
value: number;
}
export interface DMIResult {
time: string;
plusDI: number;
minusDI: number;
adx: number;
}
export interface CCIResult {
time: string;
value: number;
}
export interface WRResult {
time: string;
value: number;
}
export interface MAResult {
time: string;
value: number;
}
export interface IndicatorResults {
macd: MACDResult[];
rsi: RSIResult[];
bollinger: BollingerResult[];
kdj: KDJResult[];
sar: SARResult[];
obv: OBVResult[];
dmi: DMIResult[];
cci: CCIResult[];
wr: WRResult[];
ma5: MAResult[];
ma10: MAResult[];
ma20: MAResult[];
ma60: MAResult[];
}
export type IndicatorType =
| 'macd'
| 'rsi'
| 'bollinger'
| 'kdj'
| 'sar'
| 'obv'
| 'dmi'
| 'cci'
| 'wr'
| 'ma5'
| 'ma10'
| 'ma20'
| 'ma60';

@ -0,0 +1,81 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import * as compression from 'compression';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// 安全中间件
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
}));
// 压缩响应
app.use(compression());
// CORS配置
app.enableCors({
origin: configService.get('CORS_ORIGIN') || true,
credentials: true,
});
// API版本控制
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
prefix: 'api/v',
});
// 全局验证管道
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}));
// API前缀
app.setGlobalPrefix(configService.get('API_PREFIX') || 'api/v1');
// Swagger文档
const config = new DocumentBuilder()
.setTitle('智能期货期权分析系统 API')
.setDescription('提供行情数据、技术分析、期权定价等功能的RESTful API')
.setVersion('1.0.0')
.addBearerAuth()
.addTag('认证', '用户登录注册')
.addTag('行情', '期货行情数据')
.addTag('指标', '技术分析指标')
.addTag('期权', '期权分析与定价')
.addTag('AI', '智能分析')
.addTag('事件', '热点事件')
.addTag('用户', '用户管理')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
// 启动服务器
const port = configService.get('PORT') || 3000;
await app.listen(port);
console.log(`🚀 服务已启动: http://localhost:${port}`);
console.log(`📚 API文档: http://localhost:${port}/docs`);
console.log(`📊 环境: ${configService.get('NODE_ENV')}`);
}
bootstrap();

@ -0,0 +1,50 @@
import { IsString, IsOptional, IsInt, Min, Max, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class GetProductsDto {
@ApiProperty({ required: false, description: '品种分类' })
@IsOptional()
@IsString()
category?: string;
@ApiProperty({ required: false, default: 1, description: '页码' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiProperty({ required: false, default: 20, description: '每页数量' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
size?: number = 20;
}
export class GetKlineDto {
@ApiProperty({ description: '周期', enum: ['1m', '5m', '15m', '30m', '1h', '1d', '1w'] })
@IsString()
@IsEnum(['1m', '5m', '15m', '30m', '1h', '1d', '1w'])
period: string;
@ApiProperty({ required: false, default: 100, description: '返回条数' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
count?: number = 100;
@ApiProperty({ required: false, description: '开始时间 (ISO8601)' })
@IsOptional()
@IsString()
startTime?: string;
@ApiProperty({ required: false, description: '结束时间 (ISO8601)' })
@IsOptional()
@IsString()
endTime?: string;
}

@ -0,0 +1,46 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { WsException } from '@nestjs/websockets';
@Injectable()
export class WsJwtGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
const client = context.switchToWs().getClient();
const token = this.extractToken(client);
if (!token) {
throw new WsException('未提供认证令牌');
}
const payload = this.jwtService.verify(token, {
secret: this.configService.get('jwt.secret'),
});
// 将用户信息附加到客户端
client.user = payload;
return true;
} catch (error) {
throw new WsException('无效的认证令牌');
}
}
private extractToken(client: any): string | null {
// 从handshake auth中获取token
const auth = client.handshake?.auth?.token;
if (auth) return auth.replace('Bearer ', '');
// 从query中获取token
const token = client.handshake?.query?.token;
if (token) return token as string;
return null;
}
}

@ -0,0 +1,57 @@
export interface Product {
id: string;
symbol: string;
name: string;
category: string;
price: number;
change: number;
changePercent: number;
open: number;
high: number;
low: number;
volume: number;
openInterest?: number;
}
export interface KLineData {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface TickData {
symbol: string;
price: number;
change: number;
changePercent: number;
open: number;
high: number;
low: number;
volume: number;
openInterest?: number;
bidPrice?: number;
askPrice?: number;
bidVolume?: number;
askVolume?: number;
timestamp: number;
}
export interface MarketOverview {
heatIndex: number;
heatChange: number;
upCount: number;
downCount: number;
capitalFlow: number;
volatilityIndex: number;
volatilityChange: number;
}
export interface WebSocketMessage {
type: 'tick' | 'kline' | 'trade' | 'signal';
symbol: string;
data: any;
timestamp: number;
}

@ -0,0 +1,188 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { MarketService } from './market.service';
import { MarketGateway } from './market.gateway';
import { PrismaService } from '@common/prisma/prisma.service';
import { TickData, KLineData } from './interfaces/market.interface';
@Injectable()
export class MarketDataFeedService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MarketDataFeedService.name);
private readonly mockSymbols = ['SC', 'AU', 'CU', 'AG', 'I', 'M', 'P', 'EC'];
private mockDataInterval: NodeJS.Timeout;
private isRunning = false;
constructor(
private readonly configService: ConfigService,
private readonly marketService: MarketService,
private readonly marketGateway: MarketGateway,
private readonly prisma: PrismaService,
) {}
async onModuleInit() {
// 启动模拟数据feed
const provider = this.configService.get('marketData.provider');
if (provider === 'mock') {
this.startMockDataFeed();
this.logger.log('📊 启动模拟行情数据服务');
} else {
// TODO: 实现真实数据源连接
this.logger.log(`📊 使用数据源: ${provider}`);
}
}
onModuleDestroy() {
this.stopMockDataFeed();
}
// 启动模拟数据推送
private startMockDataFeed() {
if (this.isRunning) return;
this.isRunning = true;
const interval = this.configService.get('marketData.tickUpdateInterval') || 1000;
this.mockDataInterval = setInterval(() => {
this.generateAndBroadcastMockData();
}, interval);
}
// 停止模拟数据推送
private stopMockDataFeed() {
this.isRunning = false;
if (this.mockDataInterval) {
clearInterval(this.mockDataInterval);
}
}
// 生成并广播模拟数据
private async generateAndBroadcastMockData() {
for (const symbol of this.mockSymbols) {
try {
const tick = await this.generateMockTick(symbol);
// 存储到Redis
await this.marketService.storeTickData(symbol, tick);
// 广播给订阅的客户端
this.marketGateway.broadcastTick(symbol, tick);
} catch (error) {
this.logger.error(`生成模拟数据失败 ${symbol}:`, error.message);
}
}
}
// 定时推送市场概览
@Cron(CronExpression.EVERY_30_SECONDS)
async broadcastMarketOverview() {
try {
const overview = await this.marketService.getMarketOverview();
this.marketGateway.broadcastMarketOverview(overview);
} catch (error) {
this.logger.error('广播市场概览失败:', error.message);
}
}
// 生成模拟Tick数据
private async generateMockTick(symbol: string): Promise<TickData> {
// 获取当前价格或生成基础价格
const currentTick = await this.marketService.getTickData(symbol).catch(() => null);
let basePrice = 500;
if (currentTick) {
basePrice = currentTick.price;
} else {
// 根据品种设置不同的基础价格
const basePrices: Record<string, number> = {
SC: 528.6, // 原油
AU: 685.2, // 黄金
CU: 80610, // 铜
AG: 8250, // 白银
I: 785.5, // 铁矿石
M: 2985, // 豆粕
P: 8750, // 棕榈油
EC: 2150, // 集运指数
};
basePrice = basePrices[symbol] || 500;
}
// 随机波动 (0.1% - 0.5%)
const volatility = basePrice * (0.001 + Math.random() * 0.004);
const change = (Math.random() - 0.5) * volatility;
const price = basePrice + change;
return {
symbol,
price: Number(price.toFixed(2)),
change: Number(change.toFixed(2)),
changePercent: Number(((change / basePrice) * 100).toFixed(2)),
open: currentTick?.open || basePrice,
high: Math.max(currentTick?.high || price, price) * (1 + Math.random() * 0.001),
low: Math.min(currentTick?.low || price, price) * (1 - Math.random() * 0.001),
volume: Math.floor(Math.random() * 10000) + (currentTick?.volume || 0),
openInterest: Math.floor(Math.random() * 100000),
bidPrice: price - 0.1,
askPrice: price + 0.1,
bidVolume: Math.floor(Math.random() * 100),
askVolume: Math.floor(Math.random() * 100),
timestamp: Date.now(),
};
}
// 生成模拟K线数据
async generateMockKline(symbol: string, period: string, count: number = 100): Promise<KLineData[]> {
const data: KLineData[] = [];
const product = await this.prisma.product.findUnique({
where: { symbol },
});
let basePrice = 500;
if (product) {
const tick = await this.marketService.getTickData(symbol);
basePrice = tick.price;
}
const now = new Date();
const periodMinutes = this.parsePeriod(period);
for (let i = count; i >= 0; i--) {
const time = new Date(now.getTime() - i * periodMinutes * 60 * 1000);
const volatility = basePrice * 0.008;
const change = (Math.random() - 0.48) * volatility;
const open = basePrice;
const close = basePrice + change;
const high = Math.max(open, close) + Math.random() * volatility * 0.5;
const low = Math.min(open, close) - Math.random() * volatility * 0.5;
const volume = Math.floor(Math.random() * 10000 + 5000);
data.push({
time: time.toISOString(),
open: Number(open.toFixed(2)),
high: Number(high.toFixed(2)),
low: Number(low.toFixed(2)),
close: Number(close.toFixed(2)),
volume,
});
basePrice = close;
}
return data;
}
private parsePeriod(period: string): number {
const periodMap: Record<string, number> = {
'1m': 1,
'5m': 5,
'15m': 15,
'30m': 30,
'1h': 60,
'1d': 60 * 24,
'1w': 60 * 24 * 7,
};
return periodMap[period] || 5;
}
}

@ -0,0 +1,58 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import { MarketService } from './market.service';
import { GetProductsDto, GetKlineDto } from './dto/market.dto';
@ApiTags('行情')
@Controller('market')
export class MarketController {
constructor(private readonly marketService: MarketService) {}
@Get('products')
@ApiOperation({ summary: '获取品种列表' })
@ApiResponse({ status: 200, description: '获取成功' })
async getProducts(@Query() dto: GetProductsDto) {
return this.marketService.getProducts(dto.category, dto.page, dto.size);
}
@Get('products/:symbol')
@ApiOperation({ summary: '获取品种详情' })
@ApiParam({ name: 'symbol', description: '品种代码' })
@ApiResponse({ status: 200, description: '获取成功' })
@ApiResponse({ status: 404, description: '品种不存在' })
async getProduct(@Param('symbol') symbol: string) {
return this.marketService.getProduct(symbol);
}
@Get('products/:symbol/kline')
@ApiOperation({ summary: '获取K线数据' })
@ApiParam({ name: 'symbol', description: '品种代码' })
@ApiResponse({ status: 200, description: '获取成功' })
async getKline(
@Param('symbol') symbol: string,
@Query() dto: GetKlineDto,
) {
return this.marketService.getKlineData(
symbol,
dto.period,
dto.count,
dto.startTime ? new Date(dto.startTime) : undefined,
dto.endTime ? new Date(dto.endTime) : undefined,
);
}
@Get('products/:symbol/tick')
@ApiOperation({ summary: '获取实时行情' })
@ApiParam({ name: 'symbol', description: '品种代码' })
@ApiResponse({ status: 200, description: '获取成功' })
async getTick(@Param('symbol') symbol: string) {
return this.marketService.getTickData(symbol);
}
@Get('overview')
@ApiOperation({ summary: '获取市场概览' })
@ApiResponse({ status: 200, description: '获取成功' })
async getOverview() {
return this.marketService.getMarketOverview();
}
}

@ -0,0 +1,134 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
MessageBody,
ConnectedSocket,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MarketService } from './market.service';
import { WsJwtGuard } from './guards/ws-jwt.guard';
interface SubscribeMessageData {
symbols: string[];
channels: ('tick' | 'kline' | 'depth')[];
}
@WebSocketGateway({
namespace: 'market',
cors: {
origin: '*',
},
})
export class MarketGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(MarketGateway.name);
private clientSubscriptions: Map<string, Set<string>> = new Map(); // clientId -> symbols
constructor(
private readonly configService: ConfigService,
private readonly marketService: MarketService,
) {}
handleConnection(client: Socket) {
this.logger.log(`客户端连接: ${client.id}`);
this.clientSubscriptions.set(client.id, new Set());
}
handleDisconnect(client: Socket) {
this.logger.log(`客户端断开: ${client.id}`);
this.clientSubscriptions.delete(client.id);
}
@SubscribeMessage('subscribe')
async handleSubscribe(
@MessageBody() data: SubscribeMessageData,
@ConnectedSocket() client: Socket,
) {
const { symbols, channels } = data;
const subscriptions = this.clientSubscriptions.get(client.id) || new Set();
symbols.forEach((symbol) => {
subscriptions.add(symbol);
// 加入品种房间
client.join(`symbol:${symbol}`);
// 根据频道加入对应房间
channels.forEach((channel) => {
client.join(`${channel}:${symbol}`);
});
});
this.clientSubscriptions.set(client.id, subscriptions);
// 发送当前数据
const ticks = await this.marketService.getBatchTicks(symbols);
client.emit('subscribed', {
symbols,
channels,
initialData: ticks,
});
this.logger.log(`客户端 ${client.id} 订阅: ${symbols.join(', ')}`);
}
@SubscribeMessage('unsubscribe')
handleUnsubscribe(
@MessageBody() data: { symbols: string[] },
@ConnectedSocket() client: Socket,
) {
const { symbols } = data;
const subscriptions = this.clientSubscriptions.get(client.id) || new Set();
symbols.forEach((symbol) => {
subscriptions.delete(symbol);
client.leave(`symbol:${symbol}`);
client.leave(`tick:${symbol}`);
client.leave(`kline:${symbol}`);
client.leave(`depth:${symbol}`);
});
this.clientSubscriptions.set(client.id, subscriptions);
client.emit('unsubscribed', { symbols });
}
// 广播Tick数据
broadcastTick(symbol: string, data: any) {
this.server.to(`tick:${symbol}`).emit('tick', {
symbol,
data,
timestamp: Date.now(),
});
}
// 广播K线数据
broadcastKline(symbol: string, period: string, data: any) {
this.server.to(`kline:${symbol}`).emit('kline', {
symbol,
period,
data,
timestamp: Date.now(),
});
}
// 广播市场概览
broadcastMarketOverview(data: any) {
this.server.emit('market:overview', {
data,
timestamp: Date.now(),
});
}
// 获取在线客户端数量
getConnectedClientsCount(): number {
return this.clientSubscriptions.size;
}
}

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { MarketController } from './market.controller';
import { MarketService } from './market.service';
import { MarketGateway } from './market.gateway';
import { MarketDataFeedService } from './market-data-feed.service';
@Module({
controllers: [MarketController],
providers: [MarketService, MarketGateway, MarketDataFeedService],
exports: [MarketService],
})
export class MarketModule {}

@ -0,0 +1,257 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '@common/prisma/prisma.service';
import { RedisService } from '@common/redis/redis.service';
import { KLineData, TickData, MarketOverview, Product } from './interfaces/market.interface';
@Injectable()
export class MarketService {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
// 获取品种列表
async getProducts(category?: string, page = 1, size = 20): Promise<{ list: Product[]; total: number }> {
const where = category && category !== 'all' ? { category } : {};
const [products, total] = await Promise.all([
this.prisma.product.findMany({
where,
skip: (page - 1) * size,
take: size,
orderBy: { id: 'asc' },
}),
this.prisma.product.count({ where }),
]);
// 合并Redis中的实时数据
const list = await Promise.all(
products.map(async (product) => {
const tick = await this.redis.getTick(product.symbol);
return this.mergeProductWithTick(product, tick);
}),
);
return { list, total };
}
// 获取单个品种详情
async getProduct(symbol: string): Promise<Product> {
const product = await this.prisma.product.findUnique({
where: { symbol: symbol.toUpperCase() },
});
if (!product) {
throw new NotFoundException(`品种 ${symbol} 不存在`);
}
const tick = await this.redis.getTick(product.symbol);
return this.mergeProductWithTick(product, tick);
}
// 获取K线数据
async getKlineData(
symbol: string,
period: string,
count = 100,
startTime?: Date,
endTime?: Date,
): Promise<KLineData[]> {
const product = await this.prisma.product.findUnique({
where: { symbol: symbol.toUpperCase() },
});
if (!product) {
throw new NotFoundException(`品种 ${symbol} 不存在`);
}
// 优先从Redis获取
const cachedKlines = await this.redis.getKlines(symbol, period, count);
if (cachedKlines.length >= count / 2) {
return cachedKlines.map((k) => JSON.parse(k)).reverse();
}
// 从数据库查询
const where: any = {
productId: product.id,
period,
};
if (startTime) {
where.time = { ...where.time, gte: startTime };
}
if (endTime) {
where.time = { ...where.time, lte: endTime };
}
const klines = await this.prisma.klineData.findMany({
where,
orderBy: { time: 'desc' },
take: count,
});
return klines.reverse().map((k) => ({
time: k.time.toISOString(),
open: Number(k.open),
high: Number(k.high),
low: Number(k.low),
close: Number(k.close),
volume: Number(k.volume),
}));
}
// 获取实时Tick数据
async getTickData(symbol: string): Promise<TickData> {
const product = await this.prisma.product.findUnique({
where: { symbol: symbol.toUpperCase() },
});
if (!product) {
throw new NotFoundException(`品种 ${symbol} 不存在`);
}
const tick = await this.redis.getTick(symbol);
if (!tick || Object.keys(tick).length === 0) {
// 如果没有缓存数据,返回模拟数据
return this.generateMockTick(product.symbol);
}
return {
symbol: product.symbol,
price: parseFloat(tick.price) || 0,
change: parseFloat(tick.change) || 0,
changePercent: parseFloat(tick.changePercent) || 0,
open: parseFloat(tick.open) || 0,
high: parseFloat(tick.high) || 0,
low: parseFloat(tick.low) || 0,
volume: parseInt(tick.volume) || 0,
openInterest: tick.openInterest ? parseInt(tick.openInterest) : undefined,
bidPrice: tick.bidPrice ? parseFloat(tick.bidPrice) : undefined,
askPrice: tick.askPrice ? parseFloat(tick.askPrice) : undefined,
bidVolume: tick.bidVolume ? parseInt(tick.bidVolume) : undefined,
askVolume: tick.askVolume ? parseInt(tick.askVolume) : undefined,
timestamp: parseInt(tick.timestamp) || Date.now(),
};
}
// 获取市场概览
async getMarketOverview(): Promise<MarketOverview> {
const products = await this.prisma.product.findMany();
const ticks = await Promise.all(
products.map((p) => this.redis.getTick(p.symbol)),
);
const validTicks = ticks.filter((t) => t && t.price);
let upCount = 0;
let downCount = 0;
let totalChange = 0;
let totalVolume = 0;
validTicks.forEach((tick) => {
const change = parseFloat(tick.change) || 0;
if (change > 0) upCount++;
else if (change < 0) downCount++;
totalChange += Math.abs(change);
totalVolume += parseInt(tick.volume) || 0;
});
// 计算市场热度指数 (0-100)
const heatIndex = validTicks.length > 0
? Math.min(100, Math.max(0, 50 + (upCount - downCount) * 2))
: 50;
// 计算波动率指数 (基于涨跌幅标准差)
const volatilityIndex = validTicks.length > 0
? (totalChange / validTicks.length) * 10
: 20;
return {
heatIndex: Number(heatIndex.toFixed(1)),
heatChange: Number(((Math.random() - 0.5) * 10).toFixed(1)),
upCount,
downCount,
capitalFlow: Number((totalVolume / 10000).toFixed(1)),
volatilityIndex: Number(volatilityIndex.toFixed(1)),
volatilityChange: Number(((Math.random() - 0.5) * 5).toFixed(1)),
};
}
// 批量获取Tick数据
async getBatchTicks(symbols: string[]): Promise<Record<string, TickData>> {
const result: Record<string, TickData> = {};
await Promise.all(
symbols.map(async (symbol) => {
try {
result[symbol] = await this.getTickData(symbol);
} catch (error) {
// 忽略不存在的品种
}
}),
);
return result;
}
// 存储Tick数据
async storeTickData(symbol: string, data: TickData): Promise<void> {
await this.redis.setTick(symbol, {
price: data.price.toString(),
change: data.change.toString(),
changePercent: data.changePercent.toString(),
open: data.open.toString(),
high: data.high.toString(),
low: data.low.toString(),
volume: data.volume.toString(),
openInterest: data.openInterest?.toString(),
bidPrice: data.bidPrice?.toString(),
askPrice: data.askPrice?.toString(),
bidVolume: data.bidVolume?.toString(),
askVolume: data.askVolume?.toString(),
timestamp: data.timestamp.toString(),
});
}
// 存储K线数据
async storeKlineData(symbol: string, period: string, data: KLineData): Promise<void> {
await this.redis.addKline(symbol, period, JSON.stringify(data));
}
private mergeProductWithTick(product: any, tick: Record<string, string>): Product {
const basePrice = 500; // 基础价格,实际应从历史数据获取
return {
id: product.id.toString(),
symbol: product.symbol,
name: product.name,
category: product.category,
price: tick.price ? parseFloat(tick.price) : basePrice,
change: tick.change ? parseFloat(tick.change) : 0,
changePercent: tick.changePercent ? parseFloat(tick.changePercent) : 0,
open: tick.open ? parseFloat(tick.open) : basePrice,
high: tick.high ? parseFloat(tick.high) : basePrice,
low: tick.low ? parseFloat(tick.low) : basePrice,
volume: tick.volume ? parseInt(tick.volume) : 0,
openInterest: tick.openInterest ? parseInt(tick.openInterest) : 0,
};
}
private generateMockTick(symbol: string): TickData {
const basePrice = 500;
const change = (Math.random() - 0.5) * 20;
return {
symbol,
price: basePrice + change,
change,
changePercent: (change / basePrice) * 100,
open: basePrice,
high: basePrice + Math.abs(change) + 10,
low: basePrice - Math.abs(change) - 10,
volume: Math.floor(Math.random() * 100000),
timestamp: Date.now(),
};
}
}

@ -0,0 +1,59 @@
import { IsString, IsNumber, IsEnum, IsOptional, IsDateString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class OptionPricingDto {
@ApiProperty({ description: '标的资产代码' })
@IsString()
underlying: string;
@ApiProperty({ description: '行权价' })
@IsNumber()
strike: number;
@ApiProperty({ description: '到期日 (YYYY-MM-DD)' })
@IsDateString()
expiry: string;
@ApiProperty({ description: '期权类型', enum: ['call', 'put'] })
@IsEnum(['call', 'put'])
type: 'call' | 'put';
@ApiProperty({ description: '标的资产价格', required: false })
@IsOptional()
@IsNumber()
underlyingPrice?: number;
@ApiProperty({ description: '波动率 (如 0.25 表示 25%)', required: false })
@IsOptional()
@IsNumber()
volatility?: number;
@ApiProperty({ description: '无风险利率 (如 0.025 表示 2.5%)', required: false })
@IsOptional()
@IsNumber()
riskFreeRate?: number;
}
export class GetOptionChainDto {
@ApiProperty({ description: '到期日 (YYYY-MM-DD)', required: false })
@IsOptional()
@IsDateString()
expiry?: string;
}
export class CalculateStrategyDto {
@ApiProperty({ description: '策略名称', example: 'long_call_spread' })
@IsString()
strategy: string;
@ApiProperty({ description: '标的资产代码' })
@IsString()
underlying: string;
@ApiProperty({ description: '到期日 (YYYY-MM-DD)' })
@IsDateString()
expiry: string;
@ApiProperty({ description: '行权价差', type: [Number] })
strikes: number[];
}

@ -0,0 +1,82 @@
// 期权希腊值
export interface Greeks {
delta: number;
gamma: number;
theta: number;
vega: number;
rho: number;
}
// 期权定价结果
export interface OptionPricingResult extends Greeks {
price: number;
impliedVol?: number;
}
// 期权合约
export interface OptionContract {
symbol: string;
underlying: string;
type: 'call' | 'put';
strikePrice: number;
expiryDate: string;
price?: number;
iv?: number;
delta?: number;
gamma?: number;
theta?: number;
vega?: number;
rho?: number;
volume?: number;
openInterest?: number;
}
// 期权链
export interface OptionChain {
underlying: string;
underlyingPrice: number;
expiryDate: string;
strikes: number[];
calls: OptionContract[];
puts: OptionContract[];
}
// 波动率曲面数据点
export interface VolatilityPoint {
strike: number;
expiry: string;
iv: number;
}
// 波动率曲面
export interface VolatilitySurface {
underlying: string;
points: VolatilityPoint[];
}
// 期权策略
export interface OptionStrategy {
name: string;
description: string;
legs: StrategyLeg[];
maxProfit?: number;
maxLoss?: number;
breakeven: number[];
}
export interface StrategyLeg {
type: 'call' | 'put';
strike: number;
expiry: string;
position: 1 | -1; // 1 for buy, -1 for sell
quantity: number;
}
// 策略盈亏分析
export interface StrategyPayoff {
pricePoints: number[];
payoffs: number[];
maxProfit: number;
maxLoss: number;
breakeven: number[];
}

@ -0,0 +1,219 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { OptionsService } from './options.service';
import { OptionPricingDto, GetOptionChainDto, CalculateStrategyDto } from './dto/options.dto';
@ApiTags('期权分析')
@Controller('options')
export class OptionsController {
constructor(private readonly optionsService: OptionsService) {}
@Post('pricing')
@ApiOperation({ summary: '期权定价计算 (Black-Scholes模型)' })
@ApiResponse({ status: 200, description: '计算成功' })
async calculatePricing(@Body() dto: OptionPricingDto) {
const underlyingPrice = dto.underlyingPrice ||
await this.optionsService['marketService'].getTickData(dto.underlying)
.then(t => t.price);
const T = this.calculateTimeToExpiry(dto.expiry);
const r = dto.riskFreeRate || 0.025;
const sigma = dto.volatility || 0.25;
return this.optionsService.calculateBlackScholes(
underlyingPrice,
dto.strike,
T,
r,
sigma,
dto.type,
);
}
@Post('implied-vol')
@ApiOperation({ summary: '计算隐含波动率' })
@ApiResponse({ status: 200, description: '计算成功' })
async calculateIV(
@Body() dto: OptionPricingDto & { marketPrice: number },
) {
const underlyingPrice = dto.underlyingPrice ||
await this.optionsService['marketService'].getTickData(dto.underlying)
.then(t => t.price);
const T = this.calculateTimeToExpiry(dto.expiry);
const r = dto.riskFreeRate || 0.025;
const iv = this.optionsService.calculateImpliedVolatility(
underlyingPrice,
dto.strike,
T,
r,
dto.marketPrice,
dto.type,
);
return { impliedVolatility: iv };
}
@Get('chain/:underlying')
@ApiOperation({ summary: '获取期权链' })
@ApiParam({ name: 'underlying', description: '标的资产代码' })
@ApiResponse({ status: 200, description: '获取成功' })
async getOptionChain(
@Param('underlying') underlying: string,
@Query() dto: GetOptionChainDto,
) {
return this.optionsService.getOptionChain(underlying, dto.expiry);
}
@Get('volatility-surface/:underlying')
@ApiOperation({ summary: '获取波动率曲面' })
@ApiParam({ name: 'underlying', description: '标的资产代码' })
@ApiResponse({ status: 200, description: '获取成功' })
async getVolatilitySurface(@Param('underlying') underlying: string) {
return this.optionsService.getVolatilitySurface(underlying);
}
@Post('strategy/payoff')
@ApiOperation({ summary: '计算期权策略盈亏' })
@ApiResponse({ status: 200, description: '计算成功' })
async calculateStrategyPayoff(@Body() dto: {
underlying: string;
strategy: string;
legs: Array<{
type: 'call' | 'put';
strike: number;
position: 1 | -1;
}>;
expiry: string;
}) {
const underlyingData = await this.optionsService['marketService']
.getTickData(dto.underlying);
const underlyingPrice = underlyingData.price;
const T = this.calculateTimeToExpiry(dto.expiry);
const r = 0.025;
const sigma = 0.25;
const strikes: number[] = [];
const optionPrices: number[] = [];
const quantities: number[] = [];
const types: ('call' | 'put')[] = [];
for (const leg of dto.legs) {
const price = this.optionsService.calculateBlackScholes(
underlyingPrice, leg.strike, T, r, sigma, leg.type,
).price;
strikes.push(leg.strike);
optionPrices.push(price);
quantities.push(leg.position);
types.push(leg.type);
}
return this.optionsService.calculateStrategyPayoff(
underlyingPrice,
strikes,
optionPrices,
quantities,
types,
);
}
@Get('strategies')
@ApiOperation({ summary: '获取常用期权策略' })
@ApiResponse({ status: 200, description: '获取成功' })
getCommonStrategies() {
return [
{
id: 'long_call',
name: '买入看涨期权',
description: '看涨策略,风险有限,收益无限',
legs: [{ type: 'call', position: 1 }],
},
{
id: 'long_put',
name: '买入看跌期权',
description: '看跌策略,风险有限,收益有限',
legs: [{ type: 'put', position: 1 }],
},
{
id: 'covered_call',
name: '备兑看涨',
description: '持有标的资产同时卖出看涨期权',
legs: [{ type: 'call', position: -1 }],
},
{
id: 'protective_put',
name: '保护性看跌',
description: '持有标的资产同时买入看跌期权',
legs: [{ type: 'put', position: 1 }],
},
{
id: 'bull_call_spread',
name: '牛市看涨价差',
description: '买入低行权价看涨,卖出高行权价看涨',
legs: [
{ type: 'call', position: 1 },
{ type: 'call', position: -1 },
],
},
{
id: 'bear_put_spread',
name: '熊市看跌价差',
description: '买入高行权价看跌,卖出低行权价看跌',
legs: [
{ type: 'put', position: 1 },
{ type: 'put', position: -1 },
],
},
{
id: 'long_straddle',
name: '买入跨式',
description: '同时买入相同行权价的看涨和看跌',
legs: [
{ type: 'call', position: 1 },
{ type: 'put', position: 1 },
],
},
{
id: 'short_strangle',
name: '卖出宽跨式',
description: '卖出不同行权价的看涨和看跌',
legs: [
{ type: 'call', position: -1 },
{ type: 'put', position: -1 },
],
},
{
id: 'iron_condor',
name: '铁鹰式',
description: '卖出内侧期权,买入外侧期权',
legs: [
{ type: 'call', position: -1 },
{ type: 'call', position: 1 },
{ type: 'put', position: -1 },
{ type: 'put', position: 1 },
],
},
{
id: 'butterfly',
name: '蝶式价差',
description: '买入低高价期权卖出2份中间价期权',
legs: [
{ type: 'call', position: 1 },
{ type: 'call', position: -2 },
{ type: 'call', position: 1 },
],
},
];
}
private calculateTimeToExpiry(expiryDate: string): number {
const expiry = new Date(expiryDate);
const now = new Date();
const diffTime = expiry.getTime() - now.getTime();
const diffDays = diffTime / (1000 * 60 * 60 * 24);
return Math.max(diffDays / 365, 0.001);
}
}

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { OptionsController } from './options.controller';
import { OptionsService } from './options.service';
import { MarketModule } from '../market/market.module';
@Module({
imports: [MarketModule],
controllers: [OptionsController],
providers: [OptionsService],
exports: [OptionsService],
})
export class OptionsModule {}

@ -0,0 +1,338 @@
import { Injectable } from '@nestjs/common';
import { MarketService } from '../market/market.service';
import {
OptionPricingResult,
OptionContract,
OptionChain,
VolatilitySurface,
StrategyPayoff,
} from './interfaces/options.interface';
@Injectable()
export class OptionsService {
constructor(private readonly marketService: MarketService) {}
/**
* Black-Scholes
*/
calculateBlackScholes(
S: number, // 标的资产价格
K: number, // 行权价
T: number, // 到期时间(年)
r: number, // 无风险利率
sigma: number, // 波动率
type: 'call' | 'put',
): OptionPricingResult {
const d1 = (Math.log(S / K) + (r + 0.5 * sigma * sigma) * T) / (sigma * Math.sqrt(T));
const d2 = d1 - sigma * Math.sqrt(T);
const nd1 = this.cumulativeNormalDistribution(d1);
const nd2 = this.cumulativeNormalDistribution(d2);
const nPd1 = this.normalProbabilityDensity(d1);
let price: number;
let delta: number;
if (type === 'call') {
price = S * nd1 - K * Math.exp(-r * T) * nd2;
delta = nd1;
} else {
price = K * Math.exp(-r * T) * (1 - nd2) - S * (1 - nd1);
delta = nd1 - 1;
}
const gamma = nPd1 / (S * sigma * Math.sqrt(T));
const theta = -(S * nPd1 * sigma) / (2 * Math.sqrt(T))
- r * K * Math.exp(-r * T) * (type === 'call' ? nd2 : 1 - nd2);
const vega = S * Math.sqrt(T) * nPd1;
const rho = type === 'call'
? K * T * Math.exp(-r * T) * nd2
: -K * T * Math.exp(-r * T) * (1 - nd2);
return {
price: Number(price.toFixed(4)),
delta: Number(delta.toFixed(4)),
gamma: Number(gamma.toFixed(4)),
theta: Number((theta / 365).toFixed(4)), // 转换为日值
vega: Number((vega / 100).toFixed(4)), // 转换为1%波动率变化
rho: Number((rho / 100).toFixed(4)), // 转换为1%利率变化
};
}
/**
* (使)
*/
calculateImpliedVolatility(
S: number,
K: number,
T: number,
r: number,
marketPrice: number,
type: 'call' | 'put',
): number {
let sigma = 0.3; // 初始猜测值
const tolerance = 0.0001;
const maxIterations = 100;
for (let i = 0; i < maxIterations; i++) {
const result = this.calculateBlackScholes(S, K, T, r, sigma, type);
const priceDiff = result.price - marketPrice;
if (Math.abs(priceDiff) < tolerance) {
return Number(sigma.toFixed(4));
}
// 使用vega作为导数
const vega = result.vega * 100; // 转回原始值
if (vega === 0) break;
sigma = sigma - priceDiff / vega;
// 限制波动率范围
if (sigma <= 0) sigma = 0.01;
if (sigma > 5) sigma = 5;
}
return Number(sigma.toFixed(4));
}
/**
*
*/
async getOptionChain(underlying: string, expiry?: string): Promise<OptionChain> {
const underlyingData = await this.marketService.getTickData(underlying);
const underlyingPrice = underlyingData.price;
// 如果没有指定到期日使用默认的30天后
const expiryDate = expiry || this.getDefaultExpiryDate();
// 生成行权价列表基于标的资产价格上下波动20%
const strikes = this.generateStrikes(underlyingPrice);
const T = this.calculateTimeToExpiry(expiryDate);
const r = 0.025; // 默认无风险利率 2.5%
const sigma = 0.25; // 默认波动率 25%
const calls: OptionContract[] = [];
const puts: OptionContract[] = [];
for (const strike of strikes) {
const callResult = this.calculateBlackScholes(
underlyingPrice, strike, T, r, sigma, 'call',
);
const putResult = this.calculateBlackScholes(
underlyingPrice, strike, T, r, sigma, 'put',
);
calls.push({
symbol: `${underlying}${expiryDate.replace(/-/g, '')}C${strike}`,
underlying,
type: 'call',
strikePrice: strike,
expiryDate,
...callResult,
iv: sigma,
volume: Math.floor(Math.random() * 1000),
openInterest: Math.floor(Math.random() * 5000),
});
puts.push({
symbol: `${underlying}${expiryDate.replace(/-/g, '')}P${strike}`,
underlying,
type: 'put',
strikePrice: strike,
expiryDate,
...putResult,
iv: sigma,
volume: Math.floor(Math.random() * 1000),
openInterest: Math.floor(Math.random() * 5000),
});
}
return {
underlying,
underlyingPrice,
expiryDate,
strikes,
calls,
puts,
};
}
/**
*
*/
calculateStrategyPayoff(
underlyingPrice: number,
strikes: number[],
optionPrices: number[],
quantities: number[],
types: ('call' | 'put')[],
): StrategyPayoff {
const priceRange = 0.3; // 价格范围 ±30%
const steps = 50;
const minPrice = underlyingPrice * (1 - priceRange);
const maxPrice = underlyingPrice * (1 + priceRange);
const stepSize = (maxPrice - minPrice) / steps;
const pricePoints: number[] = [];
const payoffs: number[] = [];
for (let i = 0; i <= steps; i++) {
const price = minPrice + i * stepSize;
pricePoints.push(Number(price.toFixed(2)));
let payoff = 0;
for (let j = 0; j < strikes.length; j++) {
const intrinsicValue = this.calculateIntrinsicValue(
price, strikes[j], types[j],
);
payoff += (intrinsicValue - optionPrices[j]) * quantities[j];
}
payoffs.push(Number(payoff.toFixed(2)));
}
const maxProfit = Math.max(...payoffs);
const maxLoss = Math.min(...payoffs);
const breakeven = this.calculateBreakevenPoints(pricePoints, payoffs);
return {
pricePoints,
payoffs,
maxProfit: maxProfit > 0 ? maxProfit : Infinity,
maxLoss: maxLoss < 0 ? maxLoss : -Infinity,
breakeven,
};
}
/**
*
*/
async getVolatilitySurface(underlying: string): Promise<VolatilitySurface> {
const underlyingData = await this.marketService.getTickData(underlying);
const price = underlyingData.price;
const expiries = [
this.getExpiryDate(30),
this.getExpiryDate(60),
this.getExpiryDate(90),
this.getExpiryDate(180),
];
const points: any[] = [];
for (const expiry of expiries) {
const strikes = this.generateStrikes(price, 0.15);
const T = this.calculateTimeToExpiry(expiry);
for (const strike of strikes) {
// 模拟隐含波动率(实际应从市场数据获取)
const moneyness = strike / price;
const baseIV = 0.25;
const skew = (moneyness - 1) * 0.1; // 波动率偏斜
const iv = baseIV + skew + (Math.random() - 0.5) * 0.02;
points.push({
strike,
expiry,
iv: Number(iv.toFixed(4)),
});
}
}
return {
underlying,
points,
};
}
// 辅助方法
private cumulativeNormalDistribution(x: number): number {
const t = 1 / (1 + 0.2316419 * Math.abs(x));
const d = 0.3989423 * Math.exp((-x * x) / 2);
const prob =
d * t *
(0.3193815 +
t * (-0.3565638 +
t * (1.781478 +
t * (-1.821256 + t * 1.330274))));
if (x > 0) {
return 1 - prob;
}
return prob;
}
private normalProbabilityDensity(x: number): number {
return Math.exp((-x * x) / 2) / Math.sqrt(2 * Math.PI);
}
private getDefaultExpiryDate(): string {
const date = new Date();
date.setDate(date.getDate() + 30);
return date.toISOString().split('T')[0];
}
private getExpiryDate(daysFromNow: number): string {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
return date.toISOString().split('T')[0];
}
private generateStrikes(underlyingPrice: number, range = 0.2): number[] {
const strikes: number[] = [];
const step = underlyingPrice * 0.05; // 5% 间隔
const count = Math.floor((underlyingPrice * range) / step);
for (let i = -count; i <= count; i++) {
const strike = underlyingPrice + i * step;
// 四舍五入到合理精度
const roundedStrike = underlyingPrice > 1000
? Math.round(strike / 100) * 100
: underlyingPrice > 100
? Math.round(strike / 10) * 10
: Math.round(strike);
strikes.push(roundedStrike);
}
return strikes.sort((a, b) => a - b);
}
private calculateTimeToExpiry(expiryDate: string): number {
const expiry = new Date(expiryDate);
const now = new Date();
const diffTime = expiry.getTime() - now.getTime();
const diffDays = diffTime / (1000 * 60 * 60 * 24);
return Math.max(diffDays / 365, 0.001); // 最小0.001年
}
private calculateIntrinsicValue(
underlyingPrice: number,
strike: number,
type: 'call' | 'put',
): number {
if (type === 'call') {
return Math.max(0, underlyingPrice - strike);
} else {
return Math.max(0, strike - underlyingPrice);
}
}
private calculateBreakevenPoints(pricePoints: number[], payoffs: number[]): number[] {
const breakeven: number[] = [];
for (let i = 1; i < payoffs.length; i++) {
if ((payoffs[i - 1] < 0 && payoffs[i] >= 0) ||
(payoffs[i - 1] > 0 && payoffs[i] <= 0)) {
// 线性插值
const ratio = Math.abs(payoffs[i - 1]) /
(Math.abs(payoffs[i - 1]) + Math.abs(payoffs[i]));
const breakevenPrice = pricePoints[i - 1] +
ratio * (pricePoints[i] - pricePoints[i - 1]);
breakeven.push(Number(breakevenPrice.toFixed(2)));
}
}
return breakeven;
}
}

@ -0,0 +1,18 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { UserService } from './user.service';
@ApiTags('用户')
@Controller('user')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('stats')
async getUserStats(@CurrentUser('userId') userId: number) {
return this.userService.getUserStats(userId);
}
}

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@common/prisma/prisma.service';
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
async getUserStats(userId: number) {
const [watchlistCount, alertCount] = await Promise.all([
this.prisma.watchlist.count({ where: { userId } }),
this.prisma.priceAlert.count({ where: { userId } }),
]);
return {
watchlistCount,
alertCount,
};
}
}

@ -0,0 +1,68 @@
import {
Controller,
Get,
Post,
Delete,
Put,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { WatchlistService } from './watchlist.service';
@ApiTags('自选股')
@Controller('watchlist')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class WatchlistController {
constructor(private readonly watchlistService: WatchlistService) {}
@Get()
@ApiOperation({ summary: '获取自选股列表' })
async getWatchlist(@CurrentUser('userId') userId: number) {
return this.watchlistService.getWatchlist(userId);
}
@Post()
@ApiOperation({ summary: '添加自选股' })
async addToWatchlist(
@CurrentUser('userId') userId: number,
@Body() dto: { symbol: string; alertPrice?: number },
) {
return this.watchlistService.addToWatchlist(
userId,
dto.symbol,
dto.alertPrice,
);
}
@Delete(':symbol')
@ApiOperation({ summary: '删除自选股' })
@ApiParam({ name: 'symbol', description: '品种代码' })
async removeFromWatchlist(
@CurrentUser('userId') userId: number,
@Param('symbol') symbol: string,
) {
await this.watchlistService.removeFromWatchlist(userId, symbol);
return { message: '删除成功' };
}
@Put(':symbol/alert')
@ApiOperation({ summary: '更新价格预警' })
@ApiParam({ name: 'symbol', description: '品种代码' })
async updateAlertPrice(
@CurrentUser('userId') userId: number,
@Param('symbol') symbol: string,
@Body() dto: { alertPrice: number },
) {
return this.watchlistService.updateAlertPrice(
userId,
symbol,
dto.alertPrice,
);
}
}

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WatchlistController } from './watchlist.controller';
import { WatchlistService } from './watchlist.service';
import { MarketModule } from '../market/market.module';
@Module({
imports: [MarketModule],
controllers: [WatchlistController],
providers: [WatchlistService],
exports: [WatchlistService],
})
export class WatchlistModule {}

@ -0,0 +1,149 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '@common/prisma/prisma.service';
import { MarketService } from '../market/market.service';
export interface WatchlistItem {
id: string;
symbol: string;
name: string;
price: number;
change: number;
changePercent: number;
alertPrice?: number;
addTime: string;
}
@Injectable()
export class WatchlistService {
constructor(
private readonly prisma: PrismaService,
private readonly marketService: MarketService,
) {}
async getWatchlist(userId: number): Promise<WatchlistItem[]> {
const items = await this.prisma.watchlist.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
const result: WatchlistItem[] = [];
for (const item of items) {
try {
const product = await this.marketService.getProduct(item.symbol);
result.push({
id: item.id.toString(),
symbol: item.symbol,
name: product.name,
price: product.price,
change: product.change,
changePercent: product.changePercent,
alertPrice: item.alertPrice ? Number(item.alertPrice) : undefined,
addTime: item.createdAt.toISOString(),
});
} catch (error) {
// 如果品种不存在,跳过
}
}
return result;
}
async addToWatchlist(
userId: number,
symbol: string,
alertPrice?: number,
): Promise<WatchlistItem> {
// 检查品种是否存在
const product = await this.marketService.getProduct(symbol);
// 检查是否已存在
const existing = await this.prisma.watchlist.findUnique({
where: {
userId_symbol: {
userId,
symbol: symbol.toUpperCase(),
},
},
});
if (existing) {
throw new ConflictException('该品种已在自选股中');
}
const item = await this.prisma.watchlist.create({
data: {
userId,
symbol: symbol.toUpperCase(),
alertPrice: alertPrice || null,
},
});
return {
id: item.id.toString(),
symbol: item.symbol,
name: product.name,
price: product.price,
change: product.change,
changePercent: product.changePercent,
alertPrice: item.alertPrice ? Number(item.alertPrice) : undefined,
addTime: item.createdAt.toISOString(),
};
}
async removeFromWatchlist(userId: number, symbol: string): Promise<void> {
const item = await this.prisma.watchlist.findUnique({
where: {
userId_symbol: {
userId,
symbol: symbol.toUpperCase(),
},
},
});
if (!item) {
throw new NotFoundException('该品种不在自选股中');
}
await this.prisma.watchlist.delete({
where: { id: item.id },
});
}
async updateAlertPrice(
userId: number,
symbol: string,
alertPrice: number,
): Promise<WatchlistItem> {
const item = await this.prisma.watchlist.findUnique({
where: {
userId_symbol: {
userId,
symbol: symbol.toUpperCase(),
},
},
});
if (!item) {
throw new NotFoundException('该品种不在自选股中');
}
const updated = await this.prisma.watchlist.update({
where: { id: item.id },
data: { alertPrice },
});
const product = await this.marketService.getProduct(symbol);
return {
id: updated.id.toString(),
symbol: updated.symbol,
name: product.name,
price: product.price,
change: product.change,
changePercent: product.changePercent,
alertPrice: updated.alertPrice ? Number(updated.alertPrice) : undefined,
addTime: updated.createdAt.toISOString(),
};
}
}

@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["src/*"],
"@config/*": ["src/config/*"],
"@common/*": ["src/common/*"]
}
}
}

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

@ -0,0 +1,353 @@
import { useState, useEffect } from 'react';
import { Navbar } from '@/components/Navbar';
import { MarketOverviewPanel } from '@/components/MarketOverview';
import { HotEventsPanel } from '@/components/HotEvents';
import { ProductCard } from '@/components/ProductCard';
import { ProductDetail } from '@/components/ProductDetail';
import { RiskAlertsPanel } from '@/components/RiskAlerts';
import { marketOverview, hotEvents, futuresProducts, riskAlerts } from '@/data/mockData';
import { Filter, Search, TrendingUp, TrendingDown, Grid3X3, List } from 'lucide-react';
import type { FuturesProduct } from '@/types';
// 视图类型
type ViewType = 'grid' | 'list';
// 周期筛选选项 (预留用于后续扩展)
// const cycleFilters = [
// { key: 'all', label: '全部' },
// { key: 'm5', label: '5分钟' },
// { key: 'm15', label: '15分钟' },
// { key: 'm30', label: '30分钟' },
// { key: 'm60', label: '60分钟' },
// ];
// 品种分类
const categoryFilters = [
{ key: 'all', label: '全部' },
{ key: 'energy', label: '能源' },
{ key: 'metal', label: '金属' },
{ key: 'agriculture', label: '农产品' },
{ key: 'financial', label: '金融' },
];
// 排序选项
const sortOptions = [
{ key: 'successRate', label: '成功率' },
{ key: 'trendScore', label: '趋势强度' },
{ key: 'changePercent', label: '涨跌幅' },
];
function App() {
const [activeTab, setActiveTab] = useState('overview');
const [selectedProduct, setSelectedProduct] = useState<FuturesProduct | null>(null);
const [viewType, setViewType] = useState<ViewType>('grid');
// 筛选状态
const [categoryFilter, setCategoryFilter] = useState('all');
// const [cycleFilter, setCycleFilter] = useState('all'); // 预留用于周期筛选
const [sortBy, setSortBy] = useState('successRate');
const [searchQuery, setSearchQuery] = useState('');
// 筛选和排序品种
const filteredProducts = futuresProducts
.filter((product) => {
// 分类筛选
if (categoryFilter !== 'all' && product.category !== categoryFilter) {
return false;
}
// 搜索筛选
if (searchQuery && !product.name.includes(searchQuery) && !product.code.includes(searchQuery)) {
return false;
}
return true;
})
.sort((a, b) => {
// 排序
if (sortBy === 'successRate') {
return b.successRate - a.successRate;
} else if (sortBy === 'trendScore') {
return b.trendScore - a.trendScore;
} else if (sortBy === 'changePercent') {
return b.changePercent - a.changePercent;
}
return 0;
});
// 页面加载动画
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setIsLoaded(true);
}, []);
// 渲染市场概览页面
const renderOverview = () => (
<div className={`space-y-6 transition-all duration-500 ${isLoaded ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-white"></h2>
<span className="text-sm text-[#71717a]"></span>
</div>
<MarketOverviewPanel data={marketOverview} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<HotEventsPanel events={hotEvents} />
</div>
<div>
<RiskAlertsPanel alerts={riskAlerts} />
</div>
</div>
</div>
);
// 渲染热点事件页面
const renderEvents = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-white"></h2>
<span className="text-sm text-[#71717a]"> {hotEvents.length} </span>
</div>
<HotEventsPanel events={hotEvents} />
</div>
);
// 渲染品种分析页面
const renderProducts = () => (
<div className="space-y-6">
{selectedProduct ? (
<ProductDetail
product={selectedProduct}
onBack={() => setSelectedProduct(null)}
/>
) : (
<>
{/* 筛选工具栏 */}
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10] space-y-4">
<div className="flex flex-wrap items-center gap-4">
{/* 搜索 */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#71717a]" />
<input
type="text"
placeholder="搜索品种名称或代码..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-[#09090b] border border-[#27272a] text-white text-sm placeholder:text-[#71717a] focus:outline-none focus:border-[#7dd75a] transition-colors"
/>
</div>
{/* 视图切换 */}
<div className="flex items-center gap-1 p-1 rounded-lg bg-[#09090b] border border-[#27272a]">
<button
onClick={() => setViewType('grid')}
className={`p-2 rounded-md transition-colors ${viewType === 'grid' ? 'bg-[#7dd75a] text-[#09090b]' : 'text-[#a1a1aa] hover:text-white'}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewType('list')}
className={`p-2 rounded-md transition-colors ${viewType === 'list' ? 'bg-[#7dd75a] text-[#09090b]' : 'text-[#a1a1aa] hover:text-white'}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
{/* 分类筛选 */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-[#71717a]" />
<span className="text-sm text-[#a1a1aa]">:</span>
<div className="flex gap-1">
{categoryFilters.map((filter) => (
<button
key={filter.key}
onClick={() => setCategoryFilter(filter.key)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
categoryFilter === filter.key
? 'bg-[#7dd75a] text-[#09090b] font-medium'
: 'bg-[#09090b] text-[#a1a1aa] border border-[#27272a] hover:border-[#3f3f46]'
}`}
>
{filter.label}
</button>
))}
</div>
</div>
{/* 排序 */}
<div className="flex items-center gap-2">
<span className="text-sm text-[#a1a1aa]">:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-[#09090b] border border-[#27272a] text-white text-sm focus:outline-none focus:border-[#7dd75a]"
>
{sortOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* 结果统计 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm text-[#a1a1aa]">
<span className="text-white font-medium">{filteredProducts.length}</span>
</span>
<div className="flex items-center gap-2 text-sm">
<span className="flex items-center gap-1 text-[#7dd75a]">
<TrendingUp className="w-3 h-3" />
{filteredProducts.filter(p => p.change >= 0).length}
</span>
<span className="flex items-center gap-1 text-[#ef4444]">
<TrendingDown className="w-3 h-3" />
{filteredProducts.filter(p => p.change < 0).length}
</span>
</div>
</div>
</div>
{/* 品种列表 */}
{viewType === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
onClick={() => setSelectedProduct(product)}
/>
))}
</div>
) : (
<div className="space-y-2">
{filteredProducts.map((product) => (
<div
key={product.id}
onClick={() => setSelectedProduct(product)}
className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10] hover:border-[#7dd75a]/50 transition-all cursor-pointer"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
<div className="font-medium text-white">{product.name}</div>
<div className="text-xs text-[#71717a]">{product.code}</div>
</div>
<div className="flex gap-2">
{Object.entries(product.cycles).map(([key, trend]) => (
<span
key={key}
className={`px-2 py-1 rounded text-xs ${
trend === 'up' ? 'bg-[#7dd75a]/10 text-[#7dd75a]' :
trend === 'down' ? 'bg-[#ef4444]/10 text-[#ef4444]' :
'bg-[#a1a1aa]/10 text-[#a1a1aa]'
}`}
>
{key === 'm5' ? '5分' : key === 'm15' ? '15分' : key === 'm30' ? '30分' : '60分'}
</span>
))}
</div>
</div>
<div className="text-right">
<div className={`font-mono font-medium ${product.change >= 0 ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
¥{product.price.toLocaleString()}
</div>
<div className={`text-sm ${product.change >= 0 ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
{product.change >= 0 ? '+' : ''}{product.changePercent}%
</div>
</div>
</div>
</div>
))}
</div>
)}
{filteredProducts.length === 0 && (
<div className="text-center py-12">
<div className="text-[#71717a] mb-2"></div>
<button
onClick={() => {
setCategoryFilter('all');
setSearchQuery('');
}}
className="text-[#7dd75a] hover:underline"
>
</button>
</div>
)}
</>
)}
</div>
);
// 渲染风险提醒页面
const renderRisks = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-white"></h2>
<span className="text-sm text-[#71717a]"></span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<RiskAlertsPanel alerts={riskAlerts} />
<div className="p-6 rounded-xl border border-[#27272a] bg-[#0d0d10]">
<h3 className="font-semibold text-white mb-4"></h3>
<div className="space-y-4">
<div className="p-4 rounded-lg bg-[#ef4444]/5 border border-[#ef4444]/20">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-[#ef4444]" />
<span className="font-medium text-white"></span>
</div>
<p className="text-sm text-[#a1a1aa]">
30%
</p>
</div>
<div className="p-4 rounded-lg bg-[#f59e0b]/5 border border-[#f59e0b]/20">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-[#f59e0b]" />
<span className="font-medium text-white"></span>
</div>
<p className="text-sm text-[#a1a1aa]">
</p>
</div>
<div className="p-4 rounded-lg bg-[#7dd75a]/5 border border-[#7dd75a]/20">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-[#7dd75a]" />
<span className="font-medium text-white"></span>
</div>
<p className="text-sm text-[#a1a1aa]">
</p>
</div>
</div>
</div>
</div>
</div>
);
return (
<div className="min-h-screen bg-[#09090b]">
<Navbar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="pt-20 pb-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
{activeTab === 'overview' && renderOverview()}
{activeTab === 'events' && renderEvents()}
{activeTab === 'products' && renderProducts()}
{activeTab === 'risks' && renderRisks()}
</div>
</main>
</div>
);
}
export default App;

@ -0,0 +1,178 @@
import { useState } from 'react';
import { Flame, TrendingUp, TrendingDown, Minus, AlertTriangle, ChevronRight, Calendar, Target } from 'lucide-react';
import type { HotEvent } from '@/types';
interface HotEventsProps {
events: HotEvent[];
}
// 影响等级星级
function ImpactStars({ level }: { level: number }) {
return (
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className={`w-4 h-4 rounded-sm ${i < level ? 'bg-[#7dd75a]' : 'bg-[#3f3f46]'}`}
/>
))}
</div>
);
}
// 影响方向图标
function ImpactBadge({ impact }: { impact: 'bullish' | 'bearish' | 'neutral' }) {
const config = {
bullish: { icon: TrendingUp, text: '利多', color: 'text-[#7dd75a]', bg: 'bg-[#7dd75a]/10' },
bearish: { icon: TrendingDown, text: '利空', color: 'text-[#ef4444]', bg: 'bg-[#ef4444]/10' },
neutral: { icon: Minus, text: '中性', color: 'text-[#a1a1aa]', bg: 'bg-[#a1a1aa]/10' },
};
const { icon: Icon, text, color, bg } = config[impact];
return (
<div className={`flex items-center gap-1 px-2 py-1 rounded-md ${bg}`}>
<Icon className={`w-3 h-3 ${color}`} />
<span className={`text-xs font-medium ${color}`}>{text}</span>
</div>
);
}
export function HotEventsPanel({ events }: HotEventsProps) {
const [selectedEvent, setSelectedEvent] = useState<HotEvent>(events[0]);
return (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
{/* 事件列表 */}
<div className="lg:col-span-3 space-y-3">
<div className="flex items-center gap-2 mb-4">
<Flame className="w-5 h-5 text-[#7dd75a]" />
<h3 className="text-lg font-semibold text-white"></h3>
<span className="text-xs text-[#a1a1aa] ml-auto"> {events.length} </span>
</div>
{events.map((event) => (
<div
key={event.id}
onClick={() => setSelectedEvent(event)}
className={`p-4 rounded-xl border cursor-pointer transition-all duration-200 ${
selectedEvent?.id === event.id
? 'border-[#7dd75a] bg-[#7dd75a]/5'
: 'border-[#27272a] bg-[#0d0d10] hover:border-[#3f3f46]'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-medium text-white">{event.title}</h4>
<ImpactBadge impact={event.impact} />
</div>
<p className="text-sm text-[#a1a1aa] mb-3 line-clamp-2">{event.summary}</p>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1 text-[#71717a]">
<Calendar className="w-3 h-3" />
<span>{event.time}</span>
</div>
<div className="flex items-center gap-2">
<Target className="w-3 h-3 text-[#71717a]" />
<span className="text-[#71717a]">:</span>
<div className="flex gap-1">
{event.affectedProducts.slice(0, 3).map((product) => (
<span
key={product}
className="px-1.5 py-0.5 rounded bg-[#27272a] text-[#e6e6e6]"
>
{product}
</span>
))}
{event.affectedProducts.length > 3 && (
<span className="text-[#71717a]">+{event.affectedProducts.length - 3}</span>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<ImpactStars level={event.impactLevel} />
<ChevronRight className={`w-4 h-4 transition-transform ${
selectedEvent?.id === event.id ? 'text-[#7dd75a] rotate-90' : 'text-[#71717a]'
}`} />
</div>
</div>
</div>
))}
</div>
{/* 事件分析面板 */}
<div className="lg:col-span-2">
<div className="sticky top-4 p-5 rounded-xl border border-[#27272a] bg-[#0d0d10]">
{selectedEvent ? (
<>
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-lg bg-[#7dd75a]/10">
<AlertTriangle className="w-4 h-4 text-[#7dd75a]" />
</div>
<h3 className="font-semibold text-white"></h3>
</div>
<div className="space-y-4">
<div>
<h4 className="text-sm text-[#a1a1aa] mb-2"></h4>
<p className="text-sm text-white leading-relaxed">{selectedEvent.analysis}</p>
</div>
<div>
<h4 className="text-sm text-[#a1a1aa] mb-2"></h4>
<div className="flex flex-wrap gap-2">
{selectedEvent.affectedProducts.map((product) => (
<span
key={product}
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${
selectedEvent.impact === 'bullish'
? 'bg-[#7dd75a]/10 text-[#7dd75a]'
: selectedEvent.impact === 'bearish'
? 'bg-[#ef4444]/10 text-[#ef4444]'
: 'bg-[#a1a1aa]/10 text-[#a1a1aa]'
}`}
>
{product}
</span>
))}
</div>
</div>
<div>
<h4 className="text-sm text-[#a1a1aa] mb-2"></h4>
<ul className="space-y-2">
{selectedEvent.risks.map((risk, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-[#e6e6e6]">
<span className="w-1.5 h-1.5 rounded-full bg-[#f59e0b] mt-1.5 flex-shrink-0" />
<span>{risk}</span>
</li>
))}
</ul>
</div>
<div className="pt-4 border-t border-[#27272a]">
<div className="flex items-center justify-between">
<span className="text-sm text-[#a1a1aa]"></span>
<ImpactStars level={selectedEvent.impactLevel} />
</div>
</div>
</div>
</>
) : (
<div className="text-center py-8 text-[#71717a]">
<AlertTriangle className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</div>
</div>
</div>
);
}

@ -0,0 +1,373 @@
import { useEffect, useRef, useState } from 'react';
import * as echarts from 'echarts';
import type { KLineData, MACDData } from '@/types';
interface KLineChartProps {
klineData: KLineData[];
macdData: MACDData[];
resistance: number[];
support: number[];
height?: number;
}
export function KLineChart({ klineData, macdData, resistance, support, height = 500 }: KLineChartProps) {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!chartRef.current || klineData.length === 0) return;
// 初始化图表
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
const chart = chartInstance.current;
// 处理数据
const dates = klineData.map(d => {
const date = new Date(d.time);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
});
const klineValues = klineData.map(d => [d.open, d.close, d.low, d.high]);
const volumes = klineData.map(d => d.volume);
// MACD数据
const difData = macdData.map(d => d.dif);
const deaData = macdData.map(d => d.dea);
const macdBarData = macdData.map(d => ({
value: d.macd,
itemStyle: {
color: d.macd >= 0 ? '#7dd75a' : '#ef4444',
},
}));
// 生成关键点位线
const markLines: any[] = [];
// 压力位
resistance.forEach((price, index) => {
markLines.push({
yAxis: price,
name: `压力${index + 1}`,
lineStyle: {
color: '#ef4444',
type: 'solid',
width: 1.5,
},
label: {
formatter: `压力{${index + 1}}: {c}`,
color: '#ef4444',
fontSize: 11,
},
});
});
// 支撑位
support.forEach((price, index) => {
markLines.push({
yAxis: price,
name: `支撑${index + 1}`,
lineStyle: {
color: '#7dd75a',
type: 'solid',
width: 1.5,
},
label: {
formatter: `支撑{${index + 1}}: {c}`,
color: '#7dd75a',
fontSize: 11,
},
});
});
// 计算MA
const calculateMA = (dayCount: number) => {
const result: number[] = [];
for (let i = 0; i < klineData.length; i++) {
if (i < dayCount - 1) {
result.push(Number(klineData[i].close.toFixed(2)));
continue;
}
let sum = 0;
for (let j = 0; j < dayCount; j++) {
sum += klineData[i - j].close;
}
result.push(Number((sum / dayCount).toFixed(2)));
}
return result;
};
const ma5 = calculateMA(5);
const ma10 = calculateMA(10);
const ma20 = calculateMA(20);
const option: echarts.EChartsOption = {
backgroundColor: 'transparent',
animation: true,
animationDuration: 500,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#0d5d1b',
},
},
backgroundColor: 'rgba(9, 9, 11, 0.95)',
borderColor: '#7dd75a',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12,
},
formatter: (params: any) => {
const kline = params.find((p: any) => p.seriesName === 'K线');
const vol = params.find((p: any) => p.seriesName === '成交量');
const macd = params.find((p: any) => p.seriesName === 'MACD');
if (!kline) return '';
const data = kline.data;
const open = data[1];
const close = data[2];
const low = data[3];
const high = data[4];
const change = ((close - open) / open * 100).toFixed(2);
const changeColor = close >= open ? '#7dd75a' : '#ef4444';
return `
<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 8px; color: #e6e6e6;">${kline.axisValue}</div>
<div style="display: grid; grid-template-columns: auto auto; gap: 4px 16px; font-size: 12px;">
<span style="color: #a1a1aa;">:</span><span style="color: #fff;">${open.toFixed(2)}</span>
<span style="color: #a1a1aa;">:</span><span style="color: #fff;">${high.toFixed(2)}</span>
<span style="color: #a1a1aa;">:</span><span style="color: #fff;">${low.toFixed(2)}</span>
<span style="color: #a1a1aa;">:</span><span style="color: ${changeColor};">${close.toFixed(2)} (${change}%)</span>
${vol ? `<span style="color: #a1a1aa;">成交量:</span><span style="color: #fff;">${vol.data.toLocaleString()}</span>` : ''}
${macd ? `<span style="color: #a1a1aa;">MACD:</span><span style="color: ${macd.data >= 0 ? '#7dd75a' : '#ef4444'};">${macd.data.toFixed(4)}</span>` : ''}
</div>
</div>
`;
},
},
axisPointer: {
link: [{ xAxisIndex: 'all' }],
label: {
backgroundColor: '#0d5d1b',
},
},
grid: [
{
left: '3%',
right: '3%',
top: '5%',
height: '50%',
containLabel: true,
},
{
left: '3%',
right: '3%',
top: '58%',
height: '15%',
containLabel: true,
},
{
left: '3%',
right: '3%',
top: '76%',
height: '18%',
containLabel: true,
},
],
xAxis: [
{
type: 'category',
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: '#3f3f46' } },
axisLabel: { color: '#a1a1aa', fontSize: 10 },
axisTick: { show: false },
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 1,
data: dates,
boundaryGap: true,
axisLine: { show: false },
axisLabel: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 2,
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: '#3f3f46' } },
axisLabel: { color: '#a1a1aa', fontSize: 10 },
axisTick: { show: false },
splitLine: { show: false },
},
],
yAxis: [
{
scale: true,
axisLine: { lineStyle: { color: '#3f3f46' } },
axisLabel: { color: '#a1a1aa', fontSize: 10 },
splitLine: { lineStyle: { color: '#27272a', type: 'dashed' } },
position: 'right',
},
{
scale: true,
gridIndex: 1,
axisLine: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
{
scale: true,
gridIndex: 2,
axisLine: { lineStyle: { color: '#3f3f46' } },
axisLabel: { color: '#a1a1aa', fontSize: 10 },
splitLine: { lineStyle: { color: '#27272a', type: 'dashed' } },
position: 'right',
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1, 2],
start: 50,
end: 100,
},
{
type: 'slider',
xAxisIndex: [0, 1, 2],
start: 50,
end: 100,
height: 20,
bottom: 0,
borderColor: '#3f3f46',
fillerColor: 'rgba(125, 215, 90, 0.2)',
handleStyle: { color: '#7dd75a' },
textStyle: { color: '#a1a1aa' },
},
],
series: [
{
name: 'K线',
type: 'candlestick',
data: klineValues,
itemStyle: {
color: '#7dd75a',
color0: '#ef4444',
borderColor: '#7dd75a',
borderColor0: '#ef4444',
},
markLine: {
symbol: 'none',
data: markLines,
animation: true,
},
},
{
name: 'MA5',
type: 'line',
data: ma5,
smooth: true,
showSymbol: false,
lineStyle: { color: '#fbbf24', width: 1 },
},
{
name: 'MA10',
type: 'line',
data: ma10,
smooth: true,
showSymbol: false,
lineStyle: { color: '#60a5fa', width: 1 },
},
{
name: 'MA20',
type: 'line',
data: ma20,
smooth: true,
showSymbol: false,
lineStyle: { color: '#c084fc', width: 1 },
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: (params: any) => {
const index = params.dataIndex;
const close = klineData[index]?.close;
const open = klineData[index]?.open;
return close >= open ? 'rgba(125, 215, 90, 0.6)' : 'rgba(239, 68, 68, 0.6)';
},
},
},
{
name: 'MACD',
type: 'bar',
xAxisIndex: 2,
yAxisIndex: 2,
data: macdBarData,
},
{
name: 'DIF',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: difData,
smooth: true,
showSymbol: false,
lineStyle: { color: '#fbbf24', width: 1.5 },
},
{
name: 'DEA',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: deaData,
smooth: true,
showSymbol: false,
lineStyle: { color: '#60a5fa', width: 1.5 },
},
],
};
chart.setOption(option);
setLoading(false);
// 响应式
const handleResize = () => {
chart.resize();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [klineData, macdData, resistance, support]);
return (
<div className="relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#09090b]/80 z-10">
<div className="flex items-center gap-2 text-[#7dd75a]">
<div className="w-5 h-5 border-2 border-[#7dd75a] border-t-transparent rounded-full animate-spin" />
<span className="text-sm">...</span>
</div>
</div>
)}
<div ref={chartRef} style={{ height: `${height}px` }} className="w-full" />
</div>
);
}

@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from 'react';
import { TrendingUp, TrendingDown, Activity, DollarSign, BarChart3, Zap } from 'lucide-react';
import type { MarketOverview } from '@/types';
interface MarketOverviewProps {
data: MarketOverview;
}
// 数字动画组件
function AnimatedNumber({ value, decimals = 1, suffix = '' }: { value: number; decimals?: number; suffix?: string }) {
const [displayValue, setDisplayValue] = useState(0);
const startTime = useRef<number | null>(null);
const duration = 1000;
useEffect(() => {
const animate = (timestamp: number) => {
if (!startTime.current) startTime.current = timestamp;
const progress = Math.min((timestamp - startTime.current) / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
setDisplayValue(value * easeOut);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
return () => {
startTime.current = null;
};
}, [value]);
return (
<span>
{displayValue.toFixed(decimals)}{suffix}
</span>
);
}
// 迷你趋势图
function MiniChart({ isUp }: { isUp: boolean }) {
const points = isUp
? '0,30 10,25 20,28 30,20 40,22 50,15 60,18 70,10 80,12 90,5 100,8'
: '0,10 10,15 20,12 30,20 40,18 50,25 60,22 70,30 80,28 90,35 100,32';
return (
<svg viewBox="0 0 100 40" className="w-full h-10">
<polyline
fill="none"
stroke={isUp ? '#7dd75a' : '#ef4444'}
strokeWidth="2"
points={points}
/>
<defs>
<linearGradient id={`gradient-${isUp}`} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={isUp ? '#7dd75a' : '#ef4444'} stopOpacity="0.3" />
<stop offset="100%" stopColor={isUp ? '#7dd75a' : '#ef4444'} stopOpacity="0" />
</linearGradient>
</defs>
<polygon
fill={`url(#gradient-${isUp})`}
points={`${points} 100,40 0,40`}
/>
</svg>
);
}
export function MarketOverviewPanel({ data }: MarketOverviewProps) {
const cards = [
{
title: '市场热度指数',
value: data.heatIndex,
change: data.heatChange,
suffix: '',
decimals: 1,
icon: Activity,
isUp: data.heatChange > 0,
description: data.heatChange > 0 ? '多头情绪高涨' : '市场情绪偏冷',
},
{
title: '涨跌分布',
value: data.upCount,
change: data.downCount,
suffix: '',
decimals: 0,
icon: BarChart3,
isUp: true,
description: `涨: ${data.upCount} | 跌: ${data.downCount}`,
isDistribution: true,
},
{
title: '资金流向',
value: data.capitalFlow,
change: 0,
suffix: '亿',
decimals: 1,
icon: DollarSign,
isUp: data.capitalFlow > 0,
description: data.capitalFlow > 0 ? '资金净流入' : '资金净流出',
},
{
title: '波动率指数',
value: data.volatilityIndex,
change: data.volatilityChange,
suffix: '',
decimals: 1,
icon: Zap,
isUp: data.volatilityChange > 0,
description: data.volatilityChange > 0 ? '波动扩大' : '波动收窄',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card, index) => (
<div
key={card.title}
className="bg-[#0d0d10] border border-[#27272a] rounded-xl p-5 hover:border-[#7dd75a]/50 transition-all duration-300 hover:shadow-lg hover:shadow-[#7dd75a]/5 group"
style={{
animationDelay: `${index * 100}ms`,
}}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`p-2 rounded-lg ${card.isUp ? 'bg-[#7dd75a]/10' : 'bg-[#ef4444]/10'}`}>
<card.icon className={`w-4 h-4 ${card.isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`} />
</div>
<span className="text-[#a1a1aa] text-sm">{card.title}</span>
</div>
{!card.isDistribution && card.change !== 0 && (
<div className={`flex items-center gap-1 text-xs ${card.change > 0 ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
{card.change > 0 ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span>{card.change > 0 ? '+' : ''}{card.change}%</span>
</div>
)}
</div>
<div className="mb-3">
<span className={`text-2xl font-bold font-mono ${card.isUp ? 'text-white' : 'text-[#ef4444]'}`}>
{card.isDistribution ? (
<span className="flex items-baseline gap-2">
<span className="text-[#7dd75a]">{data.upCount}</span>
<span className="text-[#a1a1aa] text-lg">/</span>
<span className="text-[#ef4444]">{data.downCount}</span>
</span>
) : (
<>
<AnimatedNumber value={card.value} decimals={card.decimals} suffix={card.suffix} />
</>
)}
</span>
</div>
<div className="text-xs text-[#a1a1aa] mb-3">{card.description}</div>
<MiniChart isUp={card.isUp} />
</div>
))}
</div>
);
}

@ -0,0 +1,150 @@
import { useState, useEffect } from 'react';
import { BarChart3, Bell, Clock, Menu, X } from 'lucide-react';
interface NavbarProps {
activeTab: string;
onTabChange: (tab: string) => void;
}
const navItems = [
{ id: 'overview', label: '市场概览' },
{ id: 'events', label: '热点事件' },
{ id: 'products', label: '品种分析' },
{ id: 'risks', label: '风险提醒' },
];
export function Navbar({ activeTab, onTabChange }: NavbarProps) {
const [currentTime, setCurrentTime] = useState(new Date());
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => {
clearInterval(timer);
window.removeEventListener('scroll', handleScroll);
};
}, []);
const formatTime = (date: Date) => {
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
isScrolled
? 'bg-[#09090b]/95 backdrop-blur-md shadow-lg shadow-black/20'
: 'bg-[#09090b]'
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[#7dd75a]/10">
<BarChart3 className="w-5 h-5 text-[#7dd75a]" />
</div>
<div>
<h1 className="text-lg font-bold text-white"></h1>
<p className="text-xs text-[#71717a] hidden sm:block"></p>
</div>
</div>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 relative ${
activeTab === item.id
? 'text-[#7dd75a]'
: 'text-[#a1a1aa] hover:text-white'
}`}
>
{item.label}
{activeTab === item.id && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-0.5 bg-[#7dd75a] rounded-full" />
)}
</button>
))}
</div>
{/* Right Section */}
<div className="flex items-center gap-4">
{/* Time */}
<div className="hidden sm:flex items-center gap-2 text-sm text-[#71717a]">
<Clock className="w-4 h-4" />
<span className="font-mono">{formatTime(currentTime)}</span>
</div>
{/* Notification */}
<button className="p-2 rounded-lg hover:bg-[#27272a] transition-colors relative">
<Bell className="w-4 h-4 text-[#a1a1aa]" />
<span className="absolute top-1 right-1 w-2 h-2 bg-[#ef4444] rounded-full" />
</button>
{/* Mobile Menu Button */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="md:hidden p-2 rounded-lg hover:bg-[#27272a] transition-colors"
>
{isMobileMenuOpen ? (
<X className="w-5 h-5 text-[#a1a1aa]" />
) : (
<Menu className="w-5 h-5 text-[#a1a1aa]" />
)}
</button>
</div>
</div>
</div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden border-t border-[#27272a] bg-[#09090b]/95 backdrop-blur-md">
<div className="px-4 py-3 space-y-1">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => {
onTabChange(item.id);
setIsMobileMenuOpen(false);
}}
className={`w-full px-4 py-3 rounded-lg text-left text-sm font-medium transition-all duration-200 ${
activeTab === item.id
? 'bg-[#7dd75a]/10 text-[#7dd75a]'
: 'text-[#a1a1aa] hover:bg-[#27272a] hover:text-white'
}`}
>
{item.label}
</button>
))}
<div className="pt-3 border-t border-[#27272a] mt-3">
<div className="flex items-center gap-2 text-sm text-[#71717a] px-4 py-2">
<Clock className="w-4 h-4" />
<span className="font-mono">{formatTime(currentTime)}</span>
</div>
</div>
</div>
</div>
)}
</nav>
);
}

@ -0,0 +1,168 @@
import { TrendingUp, TrendingDown, Minus, Clock, Target, BarChart3, ChevronRight } from 'lucide-react';
import type { FuturesProduct, TrendDirection } from '@/types';
interface ProductCardProps {
product: FuturesProduct;
onClick?: () => void;
}
// 趋势标签
function TrendBadge({ direction, label }: { direction: TrendDirection; label: string }) {
const config = {
up: { icon: TrendingUp, color: 'text-[#7dd75a]', bg: 'bg-[#7dd75a]/10', border: 'border-[#7dd75a]/30' },
down: { icon: TrendingDown, color: 'text-[#ef4444]', bg: 'bg-[#ef4444]/10', border: 'border-[#ef4444]/30' },
sideways: { icon: Minus, color: 'text-[#a1a1aa]', bg: 'bg-[#a1a1aa]/10', border: 'border-[#a1a1aa]/30' },
};
const { icon: Icon, color, bg, border } = config[direction];
return (
<div className={`flex items-center gap-1 px-2 py-1 rounded-md ${bg} ${border} border`}>
<Icon className={`w-3 h-3 ${color}`} />
<span className={`text-xs font-medium ${color}`}>{label}</span>
</div>
);
}
// 建议标签
function RecommendationBadge({ recommendation }: { recommendation: 'long' | 'short' | 'wait' }) {
const config = {
long: { text: '逢低做多', color: 'text-[#7dd75a]', bg: 'bg-[#7dd75a]/10' },
short: { text: '逢高做空', color: 'text-[#ef4444]', bg: 'bg-[#ef4444]/10' },
wait: { text: '观望等待', color: 'text-[#a1a1aa]', bg: 'bg-[#a1a1aa]/10' },
};
const { text, color, bg } = config[recommendation];
return (
<span className={`px-2 py-1 rounded-md text-xs font-medium ${color} ${bg}`}>
{text}
</span>
);
}
// 成功率进度条
function SuccessRateBar({ rate }: { rate: number }) {
const getColor = (r: number) => {
if (r >= 70) return 'bg-[#7dd75a]';
if (r >= 50) return 'bg-[#f59e0b]';
return 'bg-[#ef4444]';
};
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-[#27272a] rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${getColor(rate)}`}
style={{ width: `${rate}%` }}
/>
</div>
<span className={`text-xs font-medium ${rate >= 70 ? 'text-[#7dd75a]' : rate >= 50 ? 'text-[#f59e0b]' : 'text-[#ef4444]'}`}>
{rate}%
</span>
</div>
);
}
export function ProductCard({ product, onClick }: ProductCardProps) {
const isUp = product.change >= 0;
const cycleLabels: Record<string, string> = {
m5: '5分',
m15: '15分',
m30: '30分',
m60: '60分',
};
return (
<div
onClick={onClick}
className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10] hover:border-[#7dd75a]/50 transition-all duration-300 cursor-pointer group hover:shadow-lg hover:shadow-[#7dd75a]/5"
>
{/* 头部 */}
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-white">{product.name}</h4>
<span className="text-xs text-[#71717a]">({product.code})</span>
</div>
<RecommendationBadge recommendation={product.recommendation} />
</div>
<div className="text-right">
<div className={`text-xl font-bold font-mono ${isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
¥{product.price.toLocaleString()}
</div>
<div className={`flex items-center justify-end gap-1 text-sm ${isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
{isUp ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span>{isUp ? '+' : ''}{product.change.toFixed(2)} ({isUp ? '+' : ''}{product.changePercent}%)</span>
</div>
</div>
</div>
{/* 多周期趋势 */}
<div className="mb-3">
<div className="flex items-center gap-1 text-xs text-[#a1a1aa] mb-2">
<Clock className="w-3 h-3" />
<span></span>
</div>
<div className="flex gap-2">
{Object.entries(product.cycles).map(([key, trend]) => (
<TrendBadge key={key} direction={trend} label={cycleLabels[key]} />
))}
</div>
</div>
{/* 成功率和趋势评分 */}
<div className="space-y-2 mb-3">
<div className="flex items-center justify-between text-xs">
<span className="text-[#a1a1aa] flex items-center gap-1">
<BarChart3 className="w-3 h-3" />
</span>
</div>
<SuccessRateBar rate={product.successRate} />
<div className="flex items-center justify-between text-xs">
<span className="text-[#a1a1aa]"></span>
<span className={`font-medium ${product.trendScore >= 80 ? 'text-[#7dd75a]' : product.trendScore >= 60 ? 'text-[#f59e0b]' : 'text-[#ef4444]'}`}>
{product.trendScore}/100
</span>
</div>
<div className="h-1.5 bg-[#27272a] rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
product.trendScore >= 80 ? 'bg-[#7dd75a]' : product.trendScore >= 60 ? 'bg-[#f59e0b]' : 'bg-[#ef4444]'
}`}
style={{ width: `${product.trendScore}%` }}
/>
</div>
</div>
{/* 关键点位 */}
<div className="pt-3 border-t border-[#27272a]">
<div className="flex items-center gap-1 text-xs text-[#a1a1aa] mb-2">
<Target className="w-3 h-3" />
<span></span>
</div>
<div className="flex justify-between text-xs">
<div>
<span className="text-[#71717a]">: </span>
<span className="text-[#ef4444] font-mono">{product.keyLevels.resistance[0]?.toLocaleString() || '-'}</span>
</div>
<div>
<span className="text-[#71717a]">: </span>
<span className="text-[#7dd75a] font-mono">{product.keyLevels.support[0]?.toLocaleString() || '-'}</span>
</div>
</div>
</div>
{/* 悬停指示 */}
<div className="flex items-center justify-end mt-3 pt-2 border-t border-[#27272a]/50">
<span className="text-xs text-[#71717a] group-hover:text-[#7dd75a] transition-colors flex items-center gap-1">
<ChevronRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
</span>
</div>
</div>
);
}

@ -0,0 +1,380 @@
import { useState, useEffect } from 'react';
import { ArrowLeft, TrendingUp, TrendingDown, Target, Clock, BarChart3, Activity, Zap } from 'lucide-react';
import { KLineChart } from './KLineChart';
import type { FuturesProduct, CycleAnalysis, TechnicalIndicators, TradingAdvice } from '@/types';
import { generateCycleAnalysis, generateTechnicalIndicators, generateTradingAdvice } from '@/data/mockData';
interface ProductDetailProps {
product: FuturesProduct;
onBack: () => void;
}
// 周期切换按钮
function PeriodButton({
label,
isActive,
onClick
}: {
label: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-[#7dd75a] text-[#09090b]'
: 'bg-[#27272a] text-[#a1a1aa] hover:bg-[#3f3f46] hover:text-white'
}`}
>
{label}
</button>
);
}
// 指标卡片
function IndicatorCard({
title,
value,
status,
signal
}: {
title: string;
value: string;
status: string;
signal?: 'positive' | 'negative' | 'neutral';
}) {
const signalColors = {
positive: 'text-[#7dd75a]',
negative: 'text-[#ef4444]',
neutral: 'text-[#a1a1aa]',
};
return (
<div className="p-3 rounded-lg border border-[#27272a] bg-[#09090b]">
<div className="text-xs text-[#71717a] mb-1">{title}</div>
<div className={`text-sm font-medium ${signal ? signalColors[signal] : 'text-white'}`}>{value}</div>
<div className="text-xs text-[#a1a1aa] mt-1">{status}</div>
</div>
);
}
// 关键点位行
function KeyLevelRow({
label,
value,
type
}: {
label: string;
value: number;
type: 'resistance' | 'support';
}) {
return (
<div className="flex items-center justify-between py-2 border-b border-[#27272a]/50 last:border-0">
<span className="text-sm text-[#a1a1aa]">{label}</span>
<span className={`text-sm font-mono font-medium ${type === 'resistance' ? 'text-[#ef4444]' : 'text-[#7dd75a]'}`}>
{value.toLocaleString()}
</span>
</div>
);
}
export function ProductDetail({ product, onBack }: ProductDetailProps) {
const [selectedPeriod, setSelectedPeriod] = useState<'m5' | 'm15' | 'm30' | 'm60'>('m15');
const [cycleData, setCycleData] = useState<CycleAnalysis | null>(null);
const [indicators, setIndicators] = useState<TechnicalIndicators | null>(null);
const [advice, setAdvice] = useState<TradingAdvice | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
// 模拟数据加载
setTimeout(() => {
setCycleData(generateCycleAnalysis(product.id, selectedPeriod));
setIndicators(generateTechnicalIndicators(product.id));
setAdvice(generateTradingAdvice(product.id));
setLoading(false);
}, 300);
}, [product.id, selectedPeriod]);
const isUp = product.change >= 0;
const periods = [
{ key: 'm5', label: '5分钟' },
{ key: 'm15', label: '15分钟' },
{ key: 'm30', label: '30分钟' },
{ key: 'm60', label: '60分钟' },
];
const cycleLabels: Record<string, string> = {
m5: '5分钟',
m15: '15分钟',
m30: '30分钟',
m60: '60分钟',
};
return (
<div className="space-y-4">
{/* 返回按钮和标题 */}
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-[#27272a] bg-[#0d0d10] text-[#a1a1aa] hover:text-white hover:border-[#3f3f46] transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span></span>
</button>
<div>
<h2 className="text-xl font-bold text-white">{product.name} ({product.code})</h2>
<p className="text-sm text-[#71717a]"></p>
</div>
</div>
{/* 价格信息栏 */}
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
<div className="flex flex-wrap items-center gap-6">
<div>
<div className={`text-3xl font-bold font-mono ${isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
¥{product.price.toLocaleString()}
</div>
<div className={`flex items-center gap-1 text-sm ${isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<span>{isUp ? '+' : ''}{product.change.toFixed(2)} ({isUp ? '+' : ''}{product.changePercent}%)</span>
</div>
</div>
<div className="h-12 w-px bg-[#27272a] hidden sm:block" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<div className="text-xs text-[#71717a]"></div>
<div className="text-sm font-mono text-white">{product.open.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-[#71717a]"></div>
<div className="text-sm font-mono text-[#7dd75a]">{product.high.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-[#71717a]"></div>
<div className="text-sm font-mono text-[#ef4444]">{product.low.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-[#71717a]"></div>
<div className="text-sm font-mono text-white">{product.openInterest.toLocaleString()}</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* K线图区域 */}
<div className="lg:col-span-2 space-y-4">
{/* 周期切换 */}
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-[#7dd75a]" />
<span className="text-sm text-[#a1a1aa]"></span>
<div className="flex gap-2 ml-2">
{periods.map((p) => (
<PeriodButton
key={p.key}
label={p.label}
isActive={selectedPeriod === p.key}
onClick={() => setSelectedPeriod(p.key as any)}
/>
))}
</div>
</div>
{/* 图表 */}
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
{loading ? (
<div className="h-[500px] flex items-center justify-center">
<div className="flex items-center gap-2 text-[#7dd75a]">
<div className="w-5 h-5 border-2 border-[#7dd75a] border-t-transparent rounded-full animate-spin" />
<span>...</span>
</div>
</div>
) : cycleData ? (
<KLineChart
klineData={cycleData.klineData}
macdData={cycleData.macdData}
resistance={cycleData.keyLevels.resistance}
support={cycleData.keyLevels.support}
height={500}
/>
) : null}
</div>
</div>
{/* 分析面板 */}
<div className="space-y-4">
{/* 交易建议 */}
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
<div className="flex items-center gap-2 mb-4">
<div className={`p-2 rounded-lg ${
advice?.action === 'long' ? 'bg-[#7dd75a]/10' :
advice?.action === 'short' ? 'bg-[#ef4444]/10' : 'bg-[#a1a1aa]/10'
}`}>
<Target className={`w-4 h-4 ${
advice?.action === 'long' ? 'text-[#7dd75a]' :
advice?.action === 'short' ? 'text-[#ef4444]' : 'text-[#a1a1aa]'
}`} />
</div>
<h3 className="font-semibold text-white"></h3>
</div>
{advice && (
<div className="space-y-3">
<div className={`p-3 rounded-lg ${
advice.action === 'long' ? 'bg-[#7dd75a]/10 border border-[#7dd75a]/30' :
advice.action === 'short' ? 'bg-[#ef4444]/10 border border-[#ef4444]/30' :
'bg-[#a1a1aa]/10 border border-[#a1a1aa]/30'
}`}>
<div className="text-xs text-[#71717a] mb-1"></div>
<div className={`text-lg font-bold ${
advice.action === 'long' ? 'text-[#7dd75a]' :
advice.action === 'short' ? 'text-[#ef4444]' : 'text-[#a1a1aa]'
}`}>
{advice.action === 'long' ? '逢低做多' : advice.action === 'short' ? '逢高做空' : '观望等待'}
</div>
<div className="text-xs text-[#a1a1aa] mt-1">{advice.reason}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-[#09090b]">
<div className="text-xs text-[#71717a]"></div>
<div className="text-sm font-mono text-white">{advice.entryPrice.toLocaleString()}</div>
</div>
<div className="p-2 rounded-lg bg-[#09090b]">
<div className="text-xs text-[#71717a]"></div>
<div className="text-sm font-mono text-[#7dd75a]">{advice.targetPrice.toLocaleString()}</div>
</div>
<div className="p-2 rounded-lg bg-[#09090b]">
<div className="text-xs text-[#71717a]"></div>
<div className="text-sm font-mono text-[#ef4444]">{advice.stopLoss.toLocaleString()}</div>
</div>
<div className="p-2 rounded-lg bg-[#09090b]">
<div className="text-xs text-[#71717a]"></div>
<div className={`text-sm font-medium ${
advice.riskLevel === 'low' ? 'text-[#7dd75a]' :
advice.riskLevel === 'medium' ? 'text-[#f59e0b]' : 'text-[#ef4444]'
}`}>
{advice.riskLevel === 'low' ? '低' : advice.riskLevel === 'medium' ? '中' : '高'}
</div>
</div>
</div>
</div>
)}
</div>
{/* 技术指标 */}
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-lg bg-[#60a5fa]/10">
<Activity className="w-4 h-4 text-[#60a5fa]" />
</div>
<h3 className="font-semibold text-white"></h3>
</div>
{indicators && (
<div className="grid grid-cols-2 gap-2">
<IndicatorCard
title="MACD"
value={indicators.macd.signal === 'golden_cross' ? '金叉' : indicators.macd.signal === 'dead_cross' ? '死叉' : '中性'}
status={`DIF: ${indicators.macd.value}`}
signal={indicators.macd.signal === 'golden_cross' ? 'positive' : indicators.macd.signal === 'dead_cross' ? 'negative' : 'neutral'}
/>
<IndicatorCard
title="RSI"
value={`${indicators.rsi.value}`}
status={indicators.rsi.status === 'overbought' ? '超买' : indicators.rsi.status === 'oversold' ? '超卖' : '正常'}
signal={indicators.rsi.status === 'oversold' ? 'positive' : indicators.rsi.status === 'overbought' ? 'negative' : 'neutral'}
/>
<IndicatorCard
title="布林带"
value={indicators.bollinger.position === 'upper' ? '上轨' : indicators.bollinger.position === 'lower' ? '下轨' : '中轨'}
status={`区间: ${indicators.bollinger.lower.toFixed(0)}-${indicators.bollinger.upper.toFixed(0)}`}
signal={indicators.bollinger.position === 'lower' ? 'positive' : indicators.bollinger.position === 'upper' ? 'negative' : 'neutral'}
/>
<IndicatorCard
title="KDJ"
value={indicators.kdj.signal === 'golden_cross' ? '金叉' : indicators.kdj.signal === 'dead_cross' ? '死叉' : '中性'}
status={`K: ${indicators.kdj.k} D: ${indicators.kdj.d}`}
signal={indicators.kdj.signal === 'golden_cross' ? 'positive' : indicators.kdj.signal === 'dead_cross' ? 'negative' : 'neutral'}
/>
</div>
)}
</div>
{/* 关键点位 */}
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-lg bg-[#f59e0b]/10">
<BarChart3 className="w-4 h-4 text-[#f59e0b]" />
</div>
<h3 className="font-semibold text-white"></h3>
</div>
<div className="space-y-1">
<div className="text-xs text-[#ef4444] font-medium mb-2"></div>
{product.keyLevels.resistance.map((level, index) => (
<KeyLevelRow key={index} label={`压力 ${index + 1}`} value={level} type="resistance" />
))}
</div>
<div className="mt-4 space-y-1">
<div className="text-xs text-[#7dd75a] font-medium mb-2"></div>
{product.keyLevels.support.map((level, index) => (
<KeyLevelRow key={index} label={`支撑 ${index + 1}`} value={level} type="support" />
))}
</div>
</div>
{/* 多周期一致性 */}
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-lg bg-[#c084fc]/10">
<Zap className="w-4 h-4 text-[#c084fc]" />
</div>
<h3 className="font-semibold text-white"></h3>
</div>
<div className="space-y-2">
{Object.entries(product.cycles).map(([key, trend]) => (
<div key={key} className="flex items-center justify-between py-1.5">
<span className="text-sm text-[#a1a1aa]">{cycleLabels[key]}</span>
<div className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
trend === 'up' ? 'bg-[#7dd75a]/10 text-[#7dd75a]' :
trend === 'down' ? 'bg-[#ef4444]/10 text-[#ef4444]' :
'bg-[#a1a1aa]/10 text-[#a1a1aa]'
}`}>
{trend === 'up' ? <TrendingUp className="w-3 h-3" /> : trend === 'down' ? <TrendingDown className="w-3 h-3" /> : <div className="w-3 h-0.5 bg-current" />}
<span>{trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡'}</span>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-[#27272a]">
<div className="flex items-center justify-between">
<span className="text-sm text-[#a1a1aa]"></span>
<span className={`text-sm font-bold ${product.trendScore >= 80 ? 'text-[#7dd75a]' : product.trendScore >= 60 ? 'text-[#f59e0b]' : 'text-[#ef4444]'}`}>
{product.trendScore}%
</span>
</div>
<div className="mt-2 h-2 bg-[#27272a] rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
product.trendScore >= 80 ? 'bg-[#7dd75a]' : product.trendScore >= 60 ? 'bg-[#f59e0b]' : 'bg-[#ef4444]'
}`}
style={{ width: `${product.trendScore}%` }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

@ -0,0 +1,100 @@
import { AlertTriangle, Bell, Shield, TrendingDown, AlertOctagon } from 'lucide-react';
import type { RiskAlert } from '@/types';
interface RiskAlertsProps {
alerts: RiskAlert[];
}
// 风险等级徽章
function RiskLevelBadge({ level }: { level: 'high' | 'medium' | 'low' }) {
const config = {
high: { text: '高风险', color: 'text-[#ef4444]', bg: 'bg-[#ef4444]/10', icon: AlertOctagon },
medium: { text: '中风险', color: 'text-[#f59e0b]', bg: 'bg-[#f59e0b]/10', icon: AlertTriangle },
low: { text: '低风险', color: 'text-[#7dd75a]', bg: 'bg-[#7dd75a]/10', icon: Shield },
};
const { text, color, bg, icon: Icon } = config[level];
return (
<div className={`flex items-center gap-1 px-2 py-1 rounded-md ${bg}`}>
<Icon className={`w-3 h-3 ${color}`} />
<span className={`text-xs font-medium ${color}`}>{text}</span>
</div>
);
}
export function RiskAlertsPanel({ alerts }: RiskAlertsProps) {
if (alerts.length === 0) {
return (
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-lg bg-[#7dd75a]/10">
<Shield className="w-4 h-4 text-[#7dd75a]" />
</div>
<h3 className="font-semibold text-white"></h3>
</div>
<div className="text-center py-6 text-[#71717a]">
<Shield className="w-10 h-10 mx-auto mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
</div>
);
}
return (
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-[#ef4444]/10">
<Bell className="w-4 h-4 text-[#ef4444]" />
</div>
<h3 className="font-semibold text-white"></h3>
</div>
<span className="px-2 py-1 rounded-full bg-[#ef4444]/10 text-[#ef4444] text-xs font-medium">
{alerts.length}
</span>
</div>
<div className="space-y-3">
{alerts.map((alert) => (
<div
key={alert.id}
className="p-3 rounded-lg border border-[#27272a] bg-[#09090b] hover:border-[#ef4444]/30 transition-colors"
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex-1">
<h4 className="text-sm font-medium text-white mb-1">{alert.title}</h4>
<p className="text-xs text-[#a1a1aa] line-clamp-2">{alert.description}</p>
</div>
<RiskLevelBadge level={alert.riskLevel} />
</div>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="text-[#71717a]">:</span>
<div className="flex gap-1">
{alert.affectedProducts.slice(0, 2).map((product) => (
<span key={product} className="px-1.5 py-0.5 rounded bg-[#27272a] text-[#e6e6e6]">
{product}
</span>
))}
{alert.affectedProducts.length > 2 && (
<span className="text-[#71717a]">+{alert.affectedProducts.length - 2}</span>
)}
</div>
</div>
<span className="text-[#71717a]">{alert.time}</span>
</div>
<div className="mt-2 pt-2 border-t border-[#27272a]/50">
<div className="flex items-center gap-1 text-xs">
<TrendingDown className="w-3 h-3 text-[#f59e0b]" />
<span className="text-[#f59e0b]">: {alert.suggestion}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}

@ -0,0 +1,64 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save