Merge branch 'alphaFuthures'

master
Lxy 4 weeks ago
commit e020d0a6bb

@ -15,3 +15,8 @@ build/
docker-compose.yml
Dockerfile
.dockerignore
android_app/
data/
logs/
*.log
.env.local

24
.env

@ -0,0 +1,24 @@
# 期货智析缓冲平台 - 环境配置
# 数据库配置
BUFFER_DB_PATH=/app/data/buffer.db
# 服务配置
BUFFER_HOST=0.0.0.0
BUFFER_PORT=8600
# 缓存配置(秒)
CACHE_TTL=300
# 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
BUFFER_LOG_LEVEL=INFO
# 并发采集数
MAX_WORKERS=2
# Docker端口映射
HOST_PORT=9600
CONTAINER_PORT=8600
# 数据目录
DATA_DIR=E:\docker_workspace\futures_datas

@ -0,0 +1,273 @@
# 用户权限系统使用指南
## 📋 系统概述
期货智析系统现已集成完整的用户权限管理系统,所有页面需要进行登录才能访问。系统支持两种用户角色:
- **管理员 (admin)**: 可以访问品种分析界面和系统管理界面
- **普通用户 (user)**: 只能访问品种分析界面
## 🔐 默认账户
系统首次启动时会自动创建默认管理员账户:
| 字段 | 值 |
|------|------|
| **用户名** | `lxy_root` |
| **密码** | `admin123` |
| **角色** | 管理员 (admin) |
| **邮箱** | `admin@system.local` |
⚠️ **重要**: 首次登录后请立即修改默认密码!
## 🚀 使用流程
### 1. 访问系统
打开浏览器访问:`http://localhost:9600`
系统会自动重定向到登录页面。
### 2. 登录
1. 输入用户名和密码
2. 点击"登录"按钮
3. 系统验证成功后会根据用户角色进行跳转
### 3. 角色跳转逻辑
#### 普通用户
- 登录后直接进入 **品种分析** 界面
- 访问地址:`http://localhost:9600/futures-analysis`
#### 管理员用户
- 登录后进入 **角色选择** 页面
- 可选择进入:
- **品种分析** - 智能期货分析工具
- **系统管理** - 后台管理配置界面
### 4. 退出登录
在任意页面点击"退出登录"按钮,或在角色选择页面点击退出。
## 📁 文件结构
```
app/
├── user_models.py # 用户数据库模型
├── auth_service.py # 认证服务(密码加密、会话管理)
├── api/
│ └── auth.py # 认证API路由
├── static/
│ ├── login.html # 登录页面
│ └── role_select.html # 角色选择页面
└── main.py # 主入口(已集成认证)
```
## 🔧 API接口
### 1. 用户登录
**POST** `/api/v1/auth/login`
**请求体:**
```json
{
"username": "lxy_root",
"password": "admin123"
}
```
**响应:**
```json
{
"success": true,
"token": "jr1xNX4egsLRStvVYLnkJj8uruB902VwCG4z3Wuy6dc",
"user": {
"id": 1,
"username": "lxy_root",
"role": "admin",
"email": "admin@system.local"
},
"message": "登录成功"
}
```
### 2. 验证令牌
**GET** `/api/v1/auth/verify`
**请求头:**
```
Authorization: Bearer <token>
```
**响应:**
```json
{
"success": true,
"user": {
"id": 1,
"username": "lxy_root",
"role": "admin",
"email": "admin@system.local",
"last_login": "2026-05-24T15:30:00"
}
}
```
### 3. 获取当前用户信息
**GET** `/api/v1/auth/me`
**请求头:**
```
Authorization: Bearer <token>
```
### 4. 用户登出
**POST** `/api/v1/auth/logout`
**请求头:**
```
Authorization: Bearer <token>
```
## 🗄️ 数据库表
系统创建了两个新表:
### 1. users 表 - 用户信息
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键 |
| username | VARCHAR(50) | 用户名(唯一) |
| password_hash | VARCHAR(255) | 密码哈希 |
| email | VARCHAR(100) | 邮箱 |
| role | VARCHAR(20) | 角色 (admin/user) |
| is_active | BOOLEAN | 是否启用 |
| created_at | DATETIME | 创建时间 |
| last_login | DATETIME | 最后登录时间 |
### 2. sessions 表 - 会话管理
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键 |
| user_id | INTEGER | 用户ID |
| token | VARCHAR(255) | 会话令牌(唯一) |
| created_at | DATETIME | 创建时间 |
| expires_at | DATETIME | 过期时间 |
| is_valid | BOOLEAN | 是否有效 |
## 🔒 安全特性
1. **密码加密**: 使用 SHA-256 + 随机盐值加密存储
2. **会话管理**: 使用安全令牌24小时自动过期
3. **权限控制**: 基于角色的访问控制 (RBAC)
4. **自动清理**: 过期会话自动标记为无效
## 👥 用户管理
### 创建新用户
可以通过Python脚本或直接操作数据库创建用户
```python
from app.database import SessionLocal
from app import auth_service
db = SessionLocal()
try:
auth_service.create_user(
db=db,
username='new_user',
password='secure_password',
email='user@example.com',
role='user' # 或 'admin'
)
finally:
db.close()
```
### 修改用户角色
```python
from app.database import SessionLocal
from app.user_models import User
db = SessionLocal()
try:
user = db.query(User).filter(User.username == 'username').first()
user.role = 'admin' # 或 'user'
db.commit()
finally:
db.close()
```
### 禁用用户
```python
user.is_active = False
db.commit()
```
## 🎯 页面访问控制
| 页面 | 需要登录 | 需要角色 |
|------|---------|---------|
| `/login` | ❌ | ❌ |
| `/futures-analysis` | ✅ | 任意 |
| `/role-select` | ✅ | admin |
| `/ui` | ✅ | admin |
| `/ai-config` | ✅ | 任意 |
| `/api/v1/*` | 部分接口 | 视接口而定 |
## 🐛 故障排查
### 1. 登录后无法访问页面
- 检查浏览器控制台是否有错误
- 确认localStorage中保存了auth_token
- 尝试清除浏览器缓存后重新登录
### 2. 令牌过期
- 会话令牌24小时过期
- 过期后需要重新登录
- 可在代码中修改 `expires_hours` 参数
### 3. 忘记管理员密码
可以通过数据库重置:
```python
from app.database import SessionLocal
from app import auth_service
db = SessionLocal()
try:
from app.user_models import User
admin = db.query(User).filter(User.username == 'lxy_root').first()
admin.password_hash = auth_service.hash_password('new_password')
db.commit()
print("密码已重置")
finally:
db.close()
```
## 📝 最佳实践
1. ✅ 首次登录后修改默认密码
2. ✅ 定期清理过期会话
3. ✅ 为不同用户分配最小权限原则
4. ✅ 定期检查用户登录日志
5. ✅ 备份用户数据库表
## 🔗 相关文档
- [部署指南](./DEPLOY.md)
- [系统架构](./DESIGN.md)
- [API文档](http://localhost:9600/docs)

@ -0,0 +1,236 @@
# 期货智析平台 - Docker 部署指南
## 📋 部署前准备
### 1. 环境要求
- Docker Desktop for Windows已安装并运行
- Windows 10/11 专业版或家庭版WSL2后端
- 至少 2GB 可用内存
- 至少 5GB 磁盘空间
### 2. 检查Docker状态
```powershell
docker --version
docker-compose --version
```
## 🚀 快速部署
### 方法一:使用部署脚本(推荐)
1. 双击运行 `deploy.bat`
2. 等待自动完成构建和部署
3. 访问 http://localhost:9600
### 方法二:手动部署
```powershell
# 进入项目目录
cd e:\docker_workspace\cobot2.0_WorkHorse\app\working\workspaces\default\share_data\project\market_data_colector_platform\buffer_platform
# 停止旧容器
docker-compose down
# 构建镜像
docker-compose build --no-cache
# 启动服务
docker-compose up -d
```
## 🌐 访问地址
部署成功后,可访问以下地址:
| 服务 | 地址 |
|------|------|
| 主页 | http://localhost:9600 |
| 品种分析 | http://localhost:9600/futures-analysis |
| AI配置 | http://localhost:9600/ai-config |
| API文档 | http://localhost:9600/docs |
| 健康检查 | http://localhost:9600/api/v1/health |
## 🛠️ 日常管理
### 使用管理脚本
双击运行 `manage.bat`,可选择:
1. 启动服务
2. 停止服务
3. 重启服务
4. 查看日志
5. 查看状态
6. 进入容器
7. 清理资源
### 常用Docker命令
```powershell
# 查看容器状态
docker-compose ps
# 查看实时日志
docker-compose logs -f
# 查看最近100行日志
docker-compose logs --tail=100
# 重启服务
docker-compose restart
# 停止服务
docker-compose stop
# 启动服务
docker-compose start
# 停止并删除容器(保留数据)
docker-compose down
# 完全清理(包括数据卷)
docker-compose down -v
# 进入容器bash
docker exec -it futures-buffer-platform /bin/bash
# 查看容器资源使用
docker stats futures-buffer-platform
```
## 📁 目录结构
```
buffer_platform/
├── data/ # SQLite数据库自动创建
├── logs/ # 日志文件(自动创建)
├── app/
│ └── static/ # 前端静态文件
├── docker-compose.yml # Docker编排配置
├── Dockerfile # Docker镜像构建文件
├── deploy.bat # 一键部署脚本
└── manage.bat # 管理脚本
```
## 🔧 配置说明
### 环境变量
`docker-compose.yml` 中可配置:
| 变量 | 说明 | 默认值 |
|------|------|--------|
| BUFFER_DB_PATH | 数据库路径 | /app/data/buffer.db |
| BUFFER_HOST | 服务监听地址 | 0.0.0.0 |
| BUFFER_PORT | 服务端口 | 8600 |
| CACHE_TTL | 缓存过期时间(秒) | 300 |
| BUFFER_LOG_LEVEL | 日志级别 | INFO |
| MAX_WORKERS | 并发采集数 | 2 |
| TZ | 时区 | Asia/Shanghai |
### 端口映射
- 宿主机端口9600
- 容器端口8600
如需修改端口,编辑 `docker-compose.yml` 中的 `ports` 配置:
```yaml
ports:
- "你想要的端口:8600"
```
## 🔍 故障排查
### 1. 容器无法启动
```powershell
# 查看详细日志
docker-compose logs
# 检查端口占用
netstat -ano | findstr "9600"
```
### 2. 数据库问题
```powershell
# 进入容器检查数据库
docker exec -it futures-buffer-platform ls -la /app/data
```
### 3. 重新构建镜像
```powershell
# 清理旧镜像
docker-compose down
docker system prune -a
# 重新构建
docker-compose build --no-cache
docker-compose up -d
```
### 4. 查看健康状态
```powershell
# 查看容器健康检查状态
docker inspect --format='{{.State.Health.Status}}' futures-buffer-platform
```
## 🔄 更新部署
当代码有更新时:
```powershell
# 方法一:使用部署脚本
deploy.bat
# 方法二:手动更新
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
## 📊 监控和日志
### 实时日志
```powershell
docker-compose logs -f buffer-platform
```
### 容器资源监控
```powershell
docker stats futures-buffer-platform
```
### 日志位置
- 容器内:/app/logs/
- 宿主机:./logs/
## 🗄️ 数据备份
### 备份数据库
```powershell
docker cp futures-buffer-platform:/app/data/buffer.db ./backup_buffer.db
```
### 恢复数据库
```powershell
docker cp ./backup_buffer.db futures-buffer-platform:/app/data/buffer.db
docker-compose restart
```
## 🎯 生产环境建议
1. **修改默认端口**:避免端口冲突
2. **配置日志轮转**在docker-compose.yml中添加
```yaml
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
3. **数据卷备份**定期备份data目录
4. **监控资源**使用docker stats监控资源使用
5. **设置重启策略**:已配置为 `unless-stopped`
## 📞 技术支持
如遇问题,请检查:
1. Docker Desktop是否正常运行
2. 端口9600是否被占用
3. 查看容器日志:`docker-compose logs`
4. 检查健康状态:`docker-compose ps`

@ -1,56 +1,62 @@
# 数据缓冲平台 Docker 部署文档
# 期货智析缓冲平台 - Docker部署文档
## 目录结构
```
buffer_platform/
├── app/ # 应用代码
├── config/ # 配置文件
├── data/ # 本地数据目录
├── Dockerfile # Docker 镜像构建文件
├── docker-compose.yml # Docker Compose 配置文件
├── .dockerignore # Docker 构建忽略文件
└── requirements.txt # Python 依赖
├── static/ # 前端静态文件
├── data/ # 本地数据目录(开发用)
├── Dockerfile # Docker镜像构建文件
├── docker-compose.yml # Docker Compose配置文件
├── .env # 环境变量配置
├── .dockerignore # Docker构建忽略文件
├── requirements.txt # Python依赖
├── deploy.bat # 一键部署脚本
├── start.bat # 启动服务
├── stop.bat # 停止服务
├── logs.bat # 查看日志
└── backup.bat # 数据库备份
```
## 环境要求
- Docker Desktop for Windows
- Docker Compose v3.8+
- Windows 10/11 或 Windows Server
- Windows 10/11
## 快速部署
### 1. 构建并启动容器
### 方式一:一键部署(推荐)
```powershell
cd d:\alpha_workspace\buffer_platform
docker-compose up -d --build
cd e:\docker_workspace\cobot2.0_WorkHorse\app\working\workspaces\default\share_data\project\market_data_colector_platform\buffer_platform
deploy.bat
```
### 2. 查看容器状态
### 方式二:手动部署
```powershell
docker-compose ps
```
### 3. 查看日志
# 1. 创建数据目录
mkdir E:\docker_workspace\futures_datas
mkdir E:\docker_workspace\futures_datas\logs
```powershell
# 查看实时日志
docker-compose logs -f
# 2. 构建并启动
docker-compose up -d --build
# 查看最近 100 行日志
docker-compose logs --tail=100
# 3. 查看状态
docker-compose ps
```
## 访问地址
| 服务 | 地址 |
| 页面 | 地址 |
|------|------|
| 前端页面 | http://localhost:9600/ui |
| API 文档 | http://localhost:9600/docs |
| 健康检查 | http://localhost:9600/api/v1/health |
| **品种分析** | http://localhost:9600/futures-analysis |
| **配置管理** | http://localhost:9600/ui |
| **AI配置** | http://localhost:9600/ai-config |
| **API文档** | http://localhost:9600/docs |
| **健康检查** | http://localhost:9600/api/v1/health |
## 数据持久化
@ -58,7 +64,8 @@ docker-compose logs --tail=100
| 宿主机路径 | 容器路径 | 说明 |
|-----------|----------|------|
| `E:\docker_workspace\futures_datas` | `/app/data` | SQLite 数据库及缓存数据 |
| `E:\docker_workspace\futures_datas` | `/app/data` | SQLite数据库及缓存数据 |
| `E:\docker_workspace\futures_datas\logs` | `/app/logs` | 日志文件 |
### 数据目录结构
@ -66,67 +73,58 @@ docker-compose logs --tail=100
```
E:\docker_workspace\futures_datas\
└── buffer.db # SQLite 数据库文件
├── buffer.db # SQLite数据库文件
├── futures_analysis.db # AI分析数据库
└── logs/ # 日志目录
└── (应用日志文件)
```
## 常用操作
## 常用管理命令
### 停止服务
### 使用批处理脚本
```powershell
docker-compose stop
deploy.bat # 一键部署
start.bat # 启动服务
stop.bat # 停止服务
logs.bat # 查看日志
backup.bat # 数据库备份
```
### 启动服务
### 使用Docker Compose命令
```powershell
# 启动服务
docker-compose start
```
### 重启服务
# 停止服务
docker-compose stop
```powershell
# 重启服务
docker-compose restart
```
### 停止并删除容器
```powershell
docker-compose down
```
### 停止并删除容器及数据卷
> ⚠️ 警告:此操作将删除所有持久化数据!
# 查看状态
docker-compose ps
```powershell
docker-compose down -v
```
# 查看日志
docker-compose logs -f
### 重新构建并启动
# 停止并删除容器
docker-compose down
```powershell
# 重新构建并启动
docker-compose up -d --build
```
### 更新镜像
```powershell
# 拉取最新代码后
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
## 环境变量配置
可在 `docker-compose.yml` 中修改以下环境变量
可在 `.env` 文件或 `docker-compose.yml` 中修改:
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `BUFFER_DB_PATH` | `/app/data/buffer.db` | 数据库文件路径 |
| `BUFFER_HOST` | `0.0.0.0` | 服务监听地址 |
| `BUFFER_PORT` | `8600` | 容器内服务端口 |
| `BUFFER_PORT` | `8600` | 容器内端口 |
| `CACHE_TTL` | `300` | 缓存过期时间(秒) |
| `BUFFER_LOG_LEVEL` | `INFO` | 日志级别 |
| `MAX_WORKERS` | `2` | 并发采集数 |
@ -137,19 +135,25 @@ docker-compose up -d
```yaml
ports:
- "9600:8600" # 修改 9600 为其他端口
- "9600:8600" # 将9600改为其他端口
```
## 数据备份
## 数据备份与恢复
### 方式一:使用备份脚本
### 备份数据库
```powershell
backup.bat
```
### 方式二:手动备份
```powershell
# 停止服务
docker-compose stop
# 复制数据文件
xcopy E:\docker_workspace\futures_datas\buffer.db E:\backup\buffer_$(Get-Date -Format 'yyyyMMdd').db
# 复制数据
xcopy E:\docker_workspace\futures_datas\buffer.db E:\backup\buffer_%date:~0,4%%date:~5,2%%date:~8,2%.db
# 启动服务
docker-compose start
@ -161,8 +165,8 @@ docker-compose start
# 停止服务
docker-compose stop
# 复制备份文件到数据目录
copy E:\backup\buffer_20260517.db E:\docker_workspace\futures_datas\buffer.db
# 恢复数据库
copy E:\backup\buffer_20260523.db E:\docker_workspace\futures_datas\buffer.db
# 启动服务
docker-compose start
@ -178,40 +182,50 @@ docker-compose logs
# 检查容器状态
docker ps -a
# 删除容器重新构建
docker-compose down
docker-compose up -d --build
```
### 端口冲突
```powershell
# 检查端口占用
netstat -ano | findstr "9600"
# 修改docker-compose.yml中的端口映射
```
### 数据库权限问题
确保 `E:\docker_workspace\futures_datas` 目录存在且有写入权限:
确保数据目录存在
```powershell
# 创建数据目录
mkdir E:\docker_workspace\futures_datas
mkdir E:\docker_workspace\futures_datas\logs
```
### 端口冲突
### 进入容器
```powershell
# 检查端口占用
netstat -ano | findstr "9600"
docker exec -it futures-buffer-platform /bin/bash
```
### 进入容器
### 查看资源使用
```powershell
docker exec -it buffer-platform /bin/bash
docker stats futures-buffer-platform
```
## 健康检查
服务内置健康检查端点:
```powershell
# PowerShell
Invoke-WebRequest -Uri http://localhost:9600/api/v1/health
# 或使用 curl
curl http://localhost:9600/api/v1/health
# 浏览器访问
http://localhost:9600/api/v1/health
```
正常响应:
@ -222,3 +236,48 @@ curl http://localhost:9600/api/v1/health
"service": "market-data-buffer"
}
```
## 更新应用
```powershell
# 1. 备份数据库
backup.bat
# 2. 停止服务
docker-compose stop
# 3. 删除旧容器
docker-compose rm -f
# 4. 重新构建
docker-compose build --no-cache
# 5. 启动新容器
docker-compose up -d
# 6. 验证服务
curl http://localhost:9600/api/v1/health
```
## 安全建议
1. **定期备份数据库** - 使用 `backup.bat` 定期备份
2. **监控日志** - 使用 `logs.bat` 查看异常
3. **限制端口访问** - 防火墙只开放必要端口
4. **数据目录权限** - 确保数据目录有适当权限
## 技术支持
遇到问题请查看日志:
```powershell
logs.bat
```
或进入容器检查:
```powershell
docker exec -it futures-buffer-platform /bin/bash
cd /app/logs
ls -la
```

@ -21,8 +21,11 @@ RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com
COPY . .
RUN mkdir -p /app/data
RUN mkdir -p /app/data /app/logs
EXPOSE 8600
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8600"]
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8600/api/v1/health')" || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8600", "--log-level", "info"]

@ -36,5 +36,35 @@ def init_analysis_db():
# 确保导入所有模型类,使其注册到 AnalysisBase
from app import analysis_models
# 直接导入 analysis_models 模块中的所有类
from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings, AIAnalysisCache
from app.analysis_models import (
FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings,
AIAnalysisCache, ReviewDate, SymbolRanking, TradingPlan,
SymbolScoreV2, TradingPlanV2, SectorHeat, ReviewPlanV2,
TradeRecord, TradeImportBatch,
)
AnalysisBase.metadata.create_all(bind=analysis_engine)
# 迁移为已有表添加新增列SQLite 不支持 IF NOT EXISTS用异常处理
_migrate_add_columns()
def _migrate_add_columns():
"""为已有表添加新增列(兼容旧数据库)"""
import sqlite3
conn = sqlite3.connect(str(ANALYSIS_DB_PATH))
cursor = conn.cursor()
migrations = [
("symbol_scores_v2", "data_date", "VARCHAR(16)"),
("review_plans_v2", "actual_data_date", "VARCHAR(16)"),
("review_plans_v2", "data_date_matches", "INTEGER"),
]
for table, column, col_type in migrations:
try:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
except sqlite3.OperationalError:
pass # 列已存在
conn.commit()
conn.close()

@ -100,3 +100,226 @@ class AIAnalysisCache(AnalysisBase):
def __repr__(self):
return f"<AIAnalysisCache {self.symbol} {self.created_at}>"
class ReviewDate(AnalysisBase):
"""复盘日期表"""
__tablename__ = "review_dates"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date = Column(String(16), nullable=False, unique=True, index=True, comment="复盘日期 YYYY-MM-DD")
week_day = Column(String(8), nullable=True, comment="星期")
created_at = Column(DateTime, nullable=False, default=datetime.now)
def __repr__(self):
return f"<ReviewDate {self.review_date}>"
class SymbolRanking(AnalysisBase):
"""品种排名表"""
__tablename__ = "symbol_rankings"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
symbol = Column(String(32), nullable=False, comment="品种合约代码")
name = Column(String(64), nullable=True, comment="品种名称")
rank_type = Column(String(32), nullable=False, comment="排名类型: volume/amplitude/change/open_interest")
rank = Column(Integer, nullable=False, comment="排名")
value = Column(String(64), nullable=True, comment="数值(如成交量、振幅等)")
price = Column(String(32), nullable=True, comment="价格")
change_pct = Column(String(16), nullable=True, comment="涨跌幅")
def __repr__(self):
return f"<SymbolRanking {self.symbol} {self.rank_type} rank={self.rank}>"
class TradingPlan(AnalysisBase):
"""交易计划表"""
__tablename__ = "trading_plans"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
symbol = Column(String(32), nullable=False, comment="品种合约代码")
name = Column(String(64), nullable=True, comment="品种名称")
plan_type = Column(String(16), nullable=False, comment="计划类型: long/short")
score = Column(Integer, nullable=False, comment="评分 0-100")
logic = Column(Text, nullable=True, comment="多空逻辑")
reason = Column(Text, nullable=True, comment="入选理由")
entry_price = Column(String(64), nullable=True, comment="入场价位")
stop_loss = Column(String(32), nullable=True, comment="止损价位")
take_profit = Column(String(64), nullable=True, comment="止盈价位")
confidence = Column(String(16), nullable=True, comment="置信度: 高/中高/中/低")
position_suggestion = Column(String(32), nullable=True, comment="仓位建议")
created_at = Column(DateTime, nullable=False, default=datetime.now)
def __repr__(self):
return f"<TradingPlan {self.symbol} {self.plan_type} score={self.score}>"
# ==================== V2 复盘计划模型 ====================
class SymbolScoreV2(AnalysisBase):
"""V2 品种多维度评分表"""
__tablename__ = "symbol_scores_v2"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
symbol = Column(String(32), nullable=False, comment="品种合约代码")
name = Column(String(64), nullable=True, comment="品种名称")
close_price = Column(Float, nullable=True, comment="收盘价")
prev_close = Column(Float, nullable=True, comment="昨收价")
high_price = Column(Float, nullable=True, comment="当日最高")
low_price = Column(Float, nullable=True, comment="当日最低")
volume = Column(Float, nullable=True, comment="当日成交量")
avg_volume_5 = Column(Float, nullable=True, comment="近5日均量")
# 5维度评分
amplitude_score = Column(Float, nullable=True, comment="振幅得分 0-100")
volume_score = Column(Float, nullable=True, comment="量能得分 0-100")
change_score = Column(Float, nullable=True, comment="涨跌幅得分 0-100")
trend_score = Column(Float, nullable=True, comment="趋势得分 -100~100")
activity_score = Column(Float, nullable=True, comment="活跃度得分 0-100")
# 综合
composite_score = Column(Float, nullable=True, comment="综合评分 0-100")
# 原始数据
amplitude_pct = Column(Float, nullable=True, comment="振幅百分比")
change_pct = Column(Float, nullable=True, comment="涨跌幅百分比")
volume_ratio = Column(Float, nullable=True, comment="量比")
# 趋势明细
trend_60m = Column(Float, nullable=True, comment="60分钟趋势分")
trend_15m = Column(Float, nullable=True, comment="15分钟趋势分")
trend_5m = Column(Float, nullable=True, comment="5分钟趋势分")
# 方向标签
direction = Column(String(32), nullable=True, comment="方向标签: 多头共振/空头共振/偏多震荡/偏空震荡/多空交织")
direction_tag = Column(String(16), nullable=True, comment="方向标签简写: 强多/偏多/震荡/偏空/强空")
category = Column(String(16), nullable=True, comment="分类: green(交易机会)/yellow(重点关注)/red(规避)")
# 关键点位
pivot = Column(Float, nullable=True, comment="枢轴点")
r1 = Column(Float, nullable=True, comment="阻力位1")
r2 = Column(Float, nullable=True, comment="阻力位2")
s1 = Column(Float, nullable=True, comment="支撑位1")
s2 = Column(Float, nullable=True, comment="支撑位2")
# 排名
rank = Column(Integer, nullable=True, comment="综合排名")
# 实际数据日期
data_date = Column(String(16), nullable=True, comment="实际数据日期 YYYY-MM-DD日线最后一根K线的日期")
def __repr__(self):
return f"<SymbolScoreV2 {self.symbol} score={self.composite_score}>"
class TradingPlanV2(AnalysisBase):
"""V2 交易计划表"""
__tablename__ = "trading_plans_v2"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
symbol = Column(String(32), nullable=False, comment="品种合约代码")
name = Column(String(64), nullable=True, comment="品种名称")
direction = Column(String(16), nullable=False, comment="方向: long/short")
composite_score = Column(Float, nullable=True, comment="综合评分")
# 交易计划
entry_low = Column(Float, nullable=True, comment="入场区间下限")
entry_high = Column(Float, nullable=True, comment="入场区间上限")
stop_loss = Column(Float, nullable=True, comment="止损位")
target1 = Column(Float, nullable=True, comment="目标位1")
target2 = Column(Float, nullable=True, comment="目标位2")
trigger = Column(String(128), nullable=True, comment="触发条件")
# 评分明细
amplitude_score = Column(Float, nullable=True, comment="振幅得分")
volume_score = Column(Float, nullable=True, comment="量能得分")
trend_score = Column(Float, nullable=True, comment="趋势得分")
activity_score = Column(Float, nullable=True, comment="活跃度得分")
# 分类
category = Column(String(16), nullable=True, comment="分类: green/yellow/red")
def __repr__(self):
return f"<TradingPlanV2 {self.symbol} {self.direction} score={self.composite_score}>"
class SectorHeat(AnalysisBase):
"""板块热度表"""
__tablename__ = "sector_heat"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
sector_name = Column(String(32), nullable=False, comment="板块名称")
avg_score = Column(Float, nullable=True, comment="板块均分")
avg_trend = Column(Float, nullable=True, comment="平均趋势分")
direction = Column(String(16), nullable=True, comment="方向: 多头/空头/震荡")
heat_level = Column(Integer, nullable=True, comment="热度等级 0-3")
leader_symbol = Column(String(32), nullable=True, comment="龙头品种代码")
leader_score = Column(Float, nullable=True, comment="龙头品种评分")
members = Column(JSON, nullable=True, comment="板块成员 [{symbol, name, score}]")
def __repr__(self):
return f"<SectorHeat {self.sector_name} avg={self.avg_score}>"
class ReviewPlanV2(AnalysisBase):
"""V2 复盘计划总表 - 存储每次生成的完整报告元数据"""
__tablename__ = "review_plans_v2"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date = Column(String(16), nullable=False, unique=True, index=True, comment="复盘日期 YYYY-MM-DD")
week_day = Column(String(8), nullable=True, comment="星期")
data_basis = Column(String(128), nullable=True, comment="数据基准说明")
core_conclusion = Column(String(128), nullable=True, comment="核心结论")
bull_count = Column(Integer, nullable=True, comment="多头品种数")
bear_count = Column(Integer, nullable=True, comment="空头品种数")
neutral_count = Column(Integer, nullable=True, comment="震荡品种数")
opportunity_count = Column(Integer, nullable=True, comment="交易机会数")
risk_warnings = Column(JSON, nullable=True, comment="风险提示列表")
actual_data_date = Column(String(16), nullable=True, comment="实际数据日期 YYYY-MM-DD")
data_date_matches = Column(Integer, nullable=True, comment="数据日期是否与复盘日期一致 1=是 0=否")
created_at = Column(DateTime, nullable=False, default=datetime.now)
def __repr__(self):
return f"<ReviewPlanV2 {self.review_date}>"
# ==================== 交易复盘模型 ====================
class TradeRecord(AnalysisBase):
"""交易记录表 - 从期货结算单导入的逐笔交易明细"""
__tablename__ = "trade_records"
id = Column(Integer, primary_key=True, autoincrement=True)
trade_type = Column(String(8), nullable=False, comment="类型: 期货/期权")
symbol = Column(String(32), nullable=False, index=True, comment="合约代码 AG2606")
variety = Column(String(16), nullable=False, index=True, comment="品种代码 AG")
symbol_name = Column(String(64), nullable=True, comment="品种名称 沪银")
direction = Column(String(8), nullable=False, comment="买卖方向: 买/卖")
offset = Column(String(8), nullable=True, comment="开平标志: 开/平")
price = Column(Float, nullable=True, comment="成交价/权利金单价")
volume = Column(Float, nullable=True, comment="手数/成交量")
amount = Column(Float, nullable=True, comment="成交额/权利金")
close_pnl = Column(Float, nullable=True, default=0.0, comment="平仓盈亏")
commission = Column(Float, nullable=True, default=0.0, comment="手续费")
trade_date = Column(String(16), nullable=True, index=True, comment="成交日期 YYYY-MM-DD")
trade_time = Column(String(32), nullable=True, comment="成交时间 HH:MM:SS")
import_batch = Column(String(64), nullable=False, index=True, comment="导入批次号 UUID")
source_file = Column(String(128), nullable=True, comment="来源文件名")
created_at = Column(DateTime, nullable=False, default=datetime.now)
__table_args__ = (
Index('ix_trade_records_date_variety', 'trade_date', 'variety'),
)
def __repr__(self):
return f"<TradeRecord {self.symbol} {self.direction} {self.offset} {self.trade_date}>"
class TradeImportBatch(AnalysisBase):
"""交易导入批次表 - 记录每次导入的元信息"""
__tablename__ = "trade_import_batches"
id = Column(Integer, primary_key=True, autoincrement=True)
batch_id = Column(String(64), nullable=False, unique=True, index=True, comment="批次号 UUID")
source_file = Column(String(128), nullable=False, comment="来源文件名")
futures_count = Column(Integer, nullable=False, default=0, comment="期货交易记录数")
options_count = Column(Integer, nullable=False, default=0, comment="期权交易记录数")
trade_dates = Column(String(256), nullable=True, comment="涉及交易日期范围")
created_at = Column(DateTime, nullable=False, default=datetime.now)
def __repr__(self):
return f"<TradeImportBatch {self.batch_id} {self.source_file}>"

@ -0,0 +1,149 @@
"""
用户权限系统 - API路由
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
from app.database import get_db
from app.user_models import User
from app import auth_service
router = APIRouter(prefix="/auth", tags=["用户认证"])
security = HTTPBearer()
# 请求模型
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
success: bool
token: Optional[str] = None
user: Optional[dict] = None
message: Optional[str] = None
class UserInfo(BaseModel):
id: int
username: str
role: str
email: Optional[str]
# 登录接口
@router.post("/login", response_model=LoginResponse)
def login(request: LoginRequest, db: Session = Depends(get_db)):
"""用户登录"""
user = auth_service.authenticate_user(db, request.username, request.password)
if not user:
return LoginResponse(
success=False,
message="用户名或密码错误"
)
# 创建会话
token = auth_service.create_session(db, user.id)
auth_service.update_last_login(db, user)
return LoginResponse(
success=True,
token=token,
user={
"id": user.id,
"username": user.username,
"role": user.role,
"email": user.email
},
message="登录成功"
)
# 登出接口
@router.post("/logout")
def logout(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)):
"""用户登出"""
auth_service.invalidate_session(db, credentials.credentials)
return {"success": True, "message": "已登出"}
# 验证令牌接口
@router.get("/verify")
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)):
"""验证用户令牌"""
user = auth_service.validate_session(db, credentials.credentials)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的会话令牌"
)
return {
"success": True,
"user": {
"id": user.id,
"username": user.username,
"role": user.role,
"email": user.email,
"last_login": user.last_login.isoformat() if user.last_login else None
}
}
# 获取当前用户信息
@router.get("/me")
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)):
"""获取当前用户信息"""
user = auth_service.validate_session(db, credentials.credentials)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的会话令牌"
)
return {
"id": user.id,
"username": user.username,
"role": user.role,
"email": user.email,
"is_active": user.is_active,
"created_at": user.created_at.isoformat(),
"last_login": user.last_login.isoformat() if user.last_login else None
}
# 依赖注入:验证用户已登录
def get_current_user_dependency(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)):
"""依赖注入:获取当前用户"""
user = auth_service.validate_session(db, credentials.credentials)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录"
)
return user
# 依赖注入:仅管理员可访问
def require_admin(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)):
"""依赖注入:验证管理员权限"""
user = auth_service.validate_session(db, credentials.credentials)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录"
)
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
return user

@ -7,7 +7,7 @@ import shutil
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Body
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Body, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel
@ -48,12 +48,12 @@ def get_config():
@router.post("/upload")
def upload_config(
async def upload_config(
file: Optional[UploadFile] = File(None),
json_config: Optional[dict] = Body(None, embed=False),
request: Request = None,
):
"""
上传品种配置文件JSON格式
上传品种配置文件(JSON格式)
格式示例:
{
"futures": {"沪银": "AG2606", "沪金": "AU2606"},
@ -64,12 +64,14 @@ def upload_config(
try:
if file:
content = file.file.read()
content = await file.read()
data = json.loads(content)
elif json_config:
data = json_config
else:
raise HTTPException(status_code=400, detail="请提供配置文件或JSON数据")
# 直接从请求体读取 JSON
body = await request.body()
if not body:
raise HTTPException(status_code=400, detail="请提供配置文件或JSON数据")
data = json.loads(body)
if not isinstance(data, dict):
raise HTTPException(status_code=400, detail="配置文件必须是 JSON 对象")

@ -3,6 +3,7 @@
"""
import logging
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
@ -134,40 +135,65 @@ def get_latest(
symbol: str,
data_type: str = "futures",
period: Optional[str] = None,
end_time: Optional[str] = None,
db: Session = Depends(get_db),
):
"""
从缓存获取最新数据
可指定单个 period不指定则返回所有已缓存周期
可指定单个 period,不指定则返回所有已缓存周期
可选指定 end_time 过滤K线数据(ISO格式),默认为当前时间
end_time 可以是日期(YYYY-MM-DD)或日期时间(YYYY-MM-DDTHH:MM:SS)
"""
cached = get_cached_data(db, symbol, data_type, [period] if period else None)
if not cached:
raise HTTPException(status_code=404, detail=f"未找到 {symbol} 的缓存数据")
timeframes = []
for p, candles in cached["timeframes"].items():
# 转换数据格式: time -> datetime
normalized_candles = []
for c in candles:
candle_dict = dict(c)
if 'time' in candle_dict and 'datetime' not in candle_dict:
candle_dict['datetime'] = candle_dict.pop('time')
normalized_candles.append(candle_dict)
timeframes.append(TimeframeData(
period=p,
candles=[CandleItem(**c) for c in normalized_candles],
candle_count=len(normalized_candles),
fetched_at=cached.get("timestamp", ""),
))
return SymbolDataResponse(
symbol=symbol,
data_type=data_type,
current_price=cached.get("current_price"),
timeframes=timeframes,
source="cache" if cached.get("is_fresh", False) else "cache_stale",
)
import traceback
# 解析时间参数,默认为当前时间
end_dt = None
if end_time:
try:
# 尝试解析ISO格式时间
end_dt = datetime.fromisoformat(end_time)
logger.info(f"成功解析 end_time: {end_time} -> {end_dt}")
except Exception as e:
logger.error(f"end_time 解析失败: {end_time}, 错误: {e}")
logger.error(f"错误堆栈: {traceback.format_exc()}")
raise HTTPException(status_code=400, detail=f"end_time 格式错误: {str(e)}")
try:
cached = get_cached_data(db, symbol, data_type, [period] if period else None, end_time=end_dt)
if not cached:
raise HTTPException(status_code=404, detail=f"未找到 {symbol} 的缓存数据")
timeframes = []
for p, candles in cached["timeframes"].items():
# 转换数据格式: time -> datetime
normalized_candles = []
for c in candles:
candle_dict = dict(c)
if 'time' in candle_dict and 'datetime' not in candle_dict:
candle_dict['datetime'] = candle_dict.pop('time')
normalized_candles.append(candle_dict)
timeframes.append(TimeframeData(
period=p,
candles=[CandleItem(**c) for c in normalized_candles],
candle_count=len(normalized_candles),
fetched_at=cached.get("timestamp", ""),
))
return SymbolDataResponse(
symbol=symbol,
data_type=data_type,
current_price=cached.get("current_price"),
timeframes=timeframes,
source="cache" if cached.get("is_fresh", False) else "cache_stale",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取数据失败: symbol={symbol}, period={period}, end_time={end_time}")
logger.error(f"错误: {e}")
logger.error(f"错误堆栈: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.get("/latest/{symbol}/{period}")
@ -222,3 +248,26 @@ def cache_status(symbol: str, db: Session = Depends(get_db)):
"cached_periods": periods_info,
"status": "ok",
}
@router.get("/latest-timestamps")
def get_latest_timestamps(db: Session = Depends(get_db)):
"""
获取所有品种的最新数据时间戳
"""
from app.models import SymbolTimestamp
timestamps = db.query(SymbolTimestamp).all()
data = []
for ts in timestamps:
data.append({
"symbol": ts.symbol,
"data_type": ts.data_type,
"last_refresh_at": ts.last_refresh_at.isoformat() if ts.last_refresh_at else None
})
return {
"success": True,
"data": data
}

@ -44,6 +44,9 @@ def get_futures_list(db: Session = Depends(get_db)):
for name, symbol_code in futures_config.items():
cached = get_cached_data(db, symbol_code, "futures")
if cached and cached.get("timeframes"):
# 优先使用缓存中的 current_price 字段
current_price = cached.get("current_price")
all_candles = []
for period, candles in cached.get("timeframes", {}).items():
all_candles.extend(candles)
@ -55,22 +58,26 @@ def get_futures_list(db: Session = Depends(get_db)):
high_price = float(latest_candle.get("high", 0))
low_price = float(latest_candle.get("low", 0))
change = close_price - open_price
# 如果 current_price 为空,则使用收盘价
if not current_price:
current_price = close_price
change = current_price - open_price
change_pct = (change / open_price * 100) if open_price > 0 else 0
futures_data.append({
"symbol": symbol_code,
"name": name,
"price": close_price,
"price": current_price,
"change": round(change, 2),
"changePct": round(change_pct, 2),
"suggestion": _get_suggestion(close_price, open_price, change_pct),
"suggestion": _get_suggestion(current_price, open_price, change_pct),
"suggestionType": "up" if change >= 0 else "down",
"periods": _get_period_trends(all_candles),
"successRate": _calc_success_rate(all_candles),
"trendScore": _calc_trend_score(all_candles),
"resistance": round(2 * ((high_price + low_price + close_price) / 3) - low_price, 2),
"support": round(2 * ((high_price + low_price + close_price) / 3) - high_price, 2),
"resistance": round(2 * ((high_price + low_price + current_price) / 3) - low_price, 2),
"support": round(2 * ((high_price + low_price + current_price) / 3) - high_price, 2),
"open": open_price,
"high": high_price,
"low": low_price,
@ -106,6 +113,10 @@ def get_futures_detail(symbol: str, db: Session = Depends(get_db)):
if not cached:
raise HTTPException(status_code=404, detail=f"未找到 {symbol} 的缓存数据")
# 优先使用缓存中的 current_price 字段
current_price = cached.get("current_price")
# 从所有K线数据中获取最新的K线用于计算指标
all_candles = []
for period, candles in cached.get("timeframes", {}).items():
all_candles.extend(candles)
@ -113,30 +124,35 @@ def get_futures_detail(symbol: str, db: Session = Depends(get_db)):
if not all_candles:
raise HTTPException(status_code=404, detail=f"未找到 {symbol} 的K线数据")
# 如果 current_price 为空,则从K线数据中获取最新收盘价
if not current_price:
current_price = float(all_candles[-1].get("close", 0))
# 使用最新一条K线数据计算指标
latest_candle = all_candles[-1]
open_price = float(latest_candle.get("open", 0))
close_price = float(latest_candle.get("close", 0))
close_price = float(latest_candle.get("close", current_price))
high_price = float(latest_candle.get("high", 0))
low_price = float(latest_candle.get("low", 0))
change = close_price - open_price
change = current_price - open_price
change_pct = (change / open_price * 100) if open_price > 0 else 0
# Pivot Point 公式计算关键点位
pp = (high_price + low_price + close_price) / 3
pp = (high_price + low_price + current_price) / 3
r1 = round(2 * pp - low_price, 2)
r2 = round(pp + (high_price - low_price), 2)
s1 = round(2 * pp - high_price, 2)
s2 = round(pp - (high_price - low_price), 2)
suggestion = _get_suggestion(close_price, open_price, change_pct)
suggestion = _get_suggestion(current_price, open_price, change_pct)
suggestion_type = "up" if change >= 0 else "down"
trend_score = _calc_trend_score(all_candles)
data = {
"symbol": symbol,
"name": _get_futures_name(symbol),
"price": close_price,
"price": current_price,
"change": round(change, 2),
"changePct": round(change_pct, 2),
"suggestion": suggestion,
@ -146,7 +162,7 @@ def get_futures_detail(symbol: str, db: Session = Depends(get_db)):
"high": high_price,
"low": low_price,
"volume": sum(float(c.get("volume", 0)) for c in all_candles),
"entryPrice": round(close_price * 0.995, 2) if change >= 0 else round(close_price * 1.005, 2),
"entryPrice": round(current_price * 0.995, 2) if change >= 0 else round(current_price * 1.005, 2),
"targetPrice": r1 if change >= 0 else s1,
"stopLoss": s1 if change >= 0 else r1,
"riskLevel": "" if trend_score >= 80 else "" if trend_score >= 60 else "",
@ -200,6 +216,10 @@ def get_kline_data(symbol: str, period: str = "15", db: Session = Depends(get_db
def _get_futures_name(symbol: str) -> str:
"""根据合约代码获取品种名称"""
# 从合约代码中提取品种代码(去除数字部分)
import re
symbol_base = re.sub(r'\d+', '', symbol).upper()
name_map = {
"AU": "黄金", "AG": "白银", "CU": "", "AL": "",
"ZN": "", "NI": "", "SN": "", "PB": "",
@ -216,7 +236,7 @@ def _get_futures_name(symbol: str) -> str:
"IF": "沪深300", "IC": "中证500", "IH": "上证50", "IM": "中证1000",
"T": "10年期国债", "TF": "5年期国债", "TS": "2年期国债", "TL": "30年期国债",
}
return name_map.get(symbol, symbol)
return name_map.get(symbol_base, symbol)
def _get_suggestion(close: float, open: float, change_pct: float) -> str:

@ -0,0 +1,615 @@
"""
复盘计划接口 - 提供复盘与交易计划相关数据 (V1 + V2)
"""
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.analysis_db import get_analysis_db
from app.analysis_models import (
ReviewDate, SymbolRanking, TradingPlan,
ReviewPlanV2, SymbolScoreV2, TradingPlanV2, SectorHeat,
)
from app.services.plan_generator import generate_plan, save_plan_to_db, get_plan_data
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/review", tags=["复盘计划"])
# ==================== 请求体模型 ====================
class GeneratePlanRequest(BaseModel):
review_date: str
# ==================== V1 复盘日期管理 ====================
@router.get("/dates")
def get_review_dates(limit: int = 10, db: Session = Depends(get_analysis_db)):
"""获取复盘日期列表"""
dates = db.query(ReviewDate).order_by(
ReviewDate.review_date.desc()
).limit(limit).all()
return {
"success": True,
"data": [{
"id": d.id,
"review_date": d.review_date,
"week_day": d.week_day
} for d in dates]
}
@router.post("/dates")
def create_review_date(
review_date: str,
week_day: Optional[str] = None,
db: Session = Depends(get_analysis_db)
):
"""创建复盘日期"""
existing = db.query(ReviewDate).filter(
ReviewDate.review_date == review_date
).first()
if existing:
return {
"success": False,
"message": f"日期 {review_date} 已存在"
}
if not week_day:
try:
dt = datetime.strptime(review_date, "%Y-%m-%d")
week_days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
week_day = week_days[dt.weekday()]
except:
week_day = ""
new_date = ReviewDate(
review_date=review_date,
week_day=week_day
)
db.add(new_date)
db.commit()
db.refresh(new_date)
return {
"success": True,
"data": {
"id": new_date.id,
"review_date": new_date.review_date,
"week_day": new_date.week_day
}
}
# ==================== V1 品种排名 ====================
@router.get("/rankings/{review_date_id}")
def get_rankings(review_date_id: int, db: Session = Depends(get_analysis_db)):
"""获取指定日期的品种排名"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
rankings = db.query(SymbolRanking).filter(
SymbolRanking.review_date_id == review_date_id
).order_by(
SymbolRanking.rank_type,
SymbolRanking.rank
).all()
rank_types = ["volume", "amplitude", "change", "open_interest"]
rank_type_names = {
"volume": "成交量排名",
"amplitude": "振幅排名",
"change": "涨跌幅排名",
"open_interest": "持仓量排名"
}
result = {}
for rt in rank_types:
result[rt] = {
"title": rank_type_names.get(rt, rt),
"items": []
}
for r in rankings:
if r.rank_type in result:
result[r.rank_type]["items"].append({
"id": r.id,
"symbol": r.symbol,
"name": r.name or r.symbol,
"rank": r.rank,
"value": r.value,
"price": r.price,
"change_pct": r.change_pct
})
return {
"success": True,
"data": {
"review_date": review_date.review_date,
"week_day": review_date.week_day,
"rankings": result
}
}
@router.post("/rankings")
def create_or_update_rankings(
review_date_id: int,
rankings: List[dict],
db: Session = Depends(get_analysis_db)
):
"""创建或更新品种排名"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
db.query(SymbolRanking).filter(
SymbolRanking.review_date_id == review_date_id
).delete()
created = []
for r in rankings:
new_ranking = SymbolRanking(
review_date_id=review_date_id,
symbol=r.get("symbol"),
name=r.get("name"),
rank_type=r.get("rank_type"),
rank=r.get("rank", 1),
value=r.get("value"),
price=r.get("price"),
change_pct=r.get("change_pct")
)
db.add(new_ranking)
created.append({
"id": new_ranking.id,
"symbol": new_ranking.symbol,
"rank_type": new_ranking.rank_type,
"rank": new_ranking.rank
})
db.commit()
return {
"success": True,
"data": created
}
# ==================== V1 交易计划 ====================
@router.get("/plans/{review_date_id}")
def get_trading_plans(review_date_id: int, db: Session = Depends(get_analysis_db)):
"""获取指定日期的交易计划"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
plans = db.query(TradingPlan).filter(
TradingPlan.review_date_id == review_date_id
).order_by(
TradingPlan.score.desc()
).all()
return {
"success": True,
"data": {
"review_date": review_date.review_date,
"week_day": review_date.week_day,
"plans": [{
"id": p.id,
"symbol": p.symbol,
"name": p.name or p.symbol,
"plan_type": p.plan_type,
"score": p.score,
"logic": p.logic,
"reason": p.reason,
"entry_price": p.entry_price,
"stop_loss": p.stop_loss,
"take_profit": p.take_profit,
"confidence": p.confidence,
"position_suggestion": p.position_suggestion,
"created_at": p.created_at.isoformat() if p.created_at else None
} for p in plans]
}
}
@router.post("/plans")
def create_or_update_plans(
review_date_id: int,
plans: List[dict],
db: Session = Depends(get_analysis_db)
):
"""创建或更新交易计划"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
db.query(TradingPlan).filter(
TradingPlan.review_date_id == review_date_id
).delete()
created = []
for p in plans:
new_plan = TradingPlan(
review_date_id=review_date_id,
symbol=p.get("symbol"),
name=p.get("name"),
plan_type=p.get("plan_type"),
score=p.get("score", 0),
logic=p.get("logic"),
reason=p.get("reason"),
entry_price=p.get("entry_price"),
stop_loss=p.get("stop_loss"),
take_profit=p.get("take_profit"),
confidence=p.get("confidence"),
position_suggestion=p.get("position_suggestion")
)
db.add(new_plan)
created.append({
"id": new_plan.id,
"symbol": new_plan.symbol,
"plan_type": new_plan.plan_type,
"score": new_plan.score
})
db.commit()
return {
"success": True,
"data": created
}
@router.post("/plans/batch")
def batch_create_review_data(
review_data: dict,
db: Session = Depends(get_analysis_db)
):
"""批量创建复盘数据(日期+排名+计划)"""
review_date_str = review_data.get("review_date")
week_day = review_data.get("week_day")
existing = db.query(ReviewDate).filter(
ReviewDate.review_date == review_date_str
).first()
if existing:
review_date_id = existing.id
else:
if not week_day:
try:
dt = datetime.strptime(review_date_str, "%Y-%m-%d")
week_days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
week_day = week_days[dt.weekday()]
except:
week_day = ""
new_date = ReviewDate(
review_date=review_date_str,
week_day=week_day
)
db.add(new_date)
db.commit()
db.refresh(new_date)
review_date_id = new_date.id
rankings = review_data.get("rankings", [])
if rankings:
db.query(SymbolRanking).filter(
SymbolRanking.review_date_id == review_date_id
).delete()
for r in rankings:
new_ranking = SymbolRanking(
review_date_id=review_date_id,
symbol=r.get("symbol"),
name=r.get("name"),
rank_type=r.get("rank_type"),
rank=r.get("rank", 1),
value=r.get("value"),
price=r.get("price"),
change_pct=r.get("change_pct")
)
db.add(new_ranking)
plans = review_data.get("plans", [])
if plans:
db.query(TradingPlan).filter(
TradingPlan.review_date_id == review_date_id
).delete()
for p in plans:
new_plan = TradingPlan(
review_date_id=review_date_id,
symbol=p.get("symbol"),
name=p.get("name"),
plan_type=p.get("plan_type"),
score=p.get("score", 0),
logic=p.get("logic"),
reason=p.get("reason"),
entry_price=p.get("entry_price"),
stop_loss=p.get("stop_loss"),
take_profit=p.get("take_profit"),
confidence=p.get("confidence"),
position_suggestion=p.get("position_suggestion")
)
db.add(new_plan)
db.commit()
return {
"success": True,
"data": {
"review_date_id": review_date_id,
"review_date": review_date_str,
"rankings_count": len(rankings),
"plans_count": len(plans)
}
}
@router.get("/summary/{review_date_id}")
def get_review_summary(review_date_id: int, db: Session = Depends(get_analysis_db)):
"""获取复盘摘要(包含排名和计划的完整数据)"""
review_date = db.query(ReviewDate).filter(
ReviewDate.id == review_date_id
).first()
if not review_date:
raise HTTPException(status_code=404, detail="复盘日期不存在")
rankings = db.query(SymbolRanking).filter(
SymbolRanking.review_date_id == review_date_id
).order_by(
SymbolRanking.rank_type,
SymbolRanking.rank
).all()
plans = db.query(TradingPlan).filter(
TradingPlan.review_date_id == review_date_id
).order_by(
TradingPlan.score.desc()
).all()
rank_types = ["volume", "amplitude", "change", "open_interest"]
rank_type_names = {
"volume": "成交量排名 Top 5",
"amplitude": "振幅排名 Top 5",
"change": "涨跌幅排名 Top 5",
"open_interest": "持仓量排名 Top 5"
}
rankings_result = {}
for rt in rank_types:
rankings_result[rt] = {
"title": rank_type_names.get(rt, rt),
"items": []
}
for r in rankings:
if r.rank_type in rankings_result:
rankings_result[r.rank_type]["items"].append({
"symbol": r.symbol,
"name": r.name or r.symbol,
"rank": r.rank,
"value": r.value,
"price": r.price,
"change_pct": r.change_pct
})
plans_result = [{
"id": p.id,
"symbol": p.symbol,
"name": p.name or p.symbol,
"plan_type": p.plan_type,
"score": p.score,
"logic": p.logic,
"reason": p.reason,
"entry_price": p.entry_price,
"stop_loss": p.stop_loss,
"take_profit": p.take_profit,
"confidence": p.confidence,
"position_suggestion": p.position_suggestion,
"created_at": p.created_at.isoformat() if p.created_at else None
} for p in plans]
return {
"success": True,
"data": {
"review_date": review_date.review_date,
"week_day": review_date.week_day,
"rankings": rankings_result,
"plans": plans_result
}
}
@router.delete("/clear")
def clear_review_data(db: Session = Depends(get_analysis_db)):
"""清除所有V1复盘与交易计划数据"""
try:
db.query(TradingPlan).delete()
db.query(SymbolRanking).delete()
db.query(ReviewDate).delete()
db.commit()
return {
"success": True,
"message": "复盘数据已清除"
}
except Exception as e:
db.rollback()
return {
"success": False,
"message": f"清除失败: {str(e)}"
}
# ==================== V2 生成交易计划 ====================
@router.post("/generate")
def generate_review_plan(
req: GeneratePlanRequest,
buffer_db: Session = Depends(get_db),
analysis_db: Session = Depends(get_analysis_db)
):
"""生成V2交易计划"""
try:
review_date_str = req.review_date
dt = datetime.strptime(review_date_str, "%Y-%m-%d")
week_days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
week_day = week_days[dt.weekday()]
# buffer_db 用于获取K线数据analysis_db 用于保存计划
plan_data = generate_plan(buffer_db, review_date_str, week_day)
plan_id = save_plan_to_db(analysis_db, plan_data)
return {
"success": True,
"data": {
"plan_id": plan_id,
"summary": {
"review_date": plan_data["review_date"],
"week_day": plan_data["week_day"],
"core_conclusion": plan_data.get("core_conclusion"),
"bull_count": plan_data.get("bull_count"),
"bear_count": plan_data.get("bear_count"),
"neutral_count": plan_data.get("neutral_count"),
"opportunity_count": plan_data.get("opportunity_count"),
"symbol_count": plan_data.get("symbol_count"),
}
}
}
except Exception as e:
logger.exception("生成交易计划失败")
return {
"success": False,
"message": f"生成失败: {str(e)}"
}
# ==================== V2 查询接口 ====================
@router.get("/v2/plans")
def list_v2_plans(
limit: int = 20,
db: Session = Depends(get_analysis_db)
):
"""获取V2复盘计划列表"""
try:
plans = db.query(ReviewPlanV2).order_by(
ReviewPlanV2.review_date.desc()
).limit(limit).all()
return {
"success": True,
"data": [{
"id": p.id,
"review_date": p.review_date,
"week_day": p.week_day,
"core_conclusion": p.core_conclusion,
"opportunity_count": p.opportunity_count,
"created_at": p.created_at.isoformat() if p.created_at else None,
} for p in plans]
}
except Exception as e:
logger.exception("获取V2计划列表失败")
return {
"success": False,
"message": f"查询失败: {str(e)}"
}
@router.get("/v2/plan/{plan_id}")
def get_v2_plan(plan_id: int, db: Session = Depends(get_analysis_db)):
"""根据ID获取完整V2计划数据"""
try:
result = get_plan_data(db, plan_id)
if not result:
return {
"success": False,
"message": f"计划 {plan_id} 不存在"
}
return {
"success": True,
"data": result
}
except Exception as e:
logger.exception(f"获取V2计划 {plan_id} 失败")
return {
"success": False,
"message": f"查询失败: {str(e)}"
}
@router.get("/v2/plan/by-date/{date}")
def get_v2_plan_by_date(date: str, db: Session = Depends(get_analysis_db)):
"""根据日期获取V2计划数据"""
try:
plan_meta = db.query(ReviewPlanV2).filter(
ReviewPlanV2.review_date == date
).first()
if not plan_meta:
return {
"success": False,
"message": f"日期 {date} 的计划不存在"
}
result = get_plan_data(db, plan_meta.id)
return {
"success": True,
"data": result
}
except Exception as e:
logger.exception(f"获取V2计划 by-date {date} 失败")
return {
"success": False,
"message": f"查询失败: {str(e)}"
}
@router.delete("/v2/clear")
def clear_v2_data(db: Session = Depends(get_analysis_db)):
"""清除所有V2数据"""
try:
db.query(SectorHeat).delete()
db.query(TradingPlanV2).delete()
db.query(SymbolScoreV2).delete()
db.query(ReviewPlanV2).delete()
db.commit()
return {
"success": True,
"message": "V2复盘数据已清除"
}
except Exception as e:
db.rollback()
return {
"success": False,
"message": f"清除失败: {str(e)}"
}

@ -0,0 +1,461 @@
"""
交易复盘接口 - 提供交易记录导入查询汇总统计等功能
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.analysis_db import get_analysis_db
from app.analysis_models import TradeRecord, TradeImportBatch
from app.services.trade_parser import (
parse_settlement_file,
save_to_db,
calc_daily_summary,
calc_variety_summary,
calc_overall_statistics,
get_trade_pairs,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/trade-review", tags=["交易复盘"])
# ==================== 文件导入 ====================
@router.post("/import")
async def import_settlement(
file: UploadFile = File(...),
db: Session = Depends(get_analysis_db),
):
"""上传并解析期货结算单文件,导入交易记录"""
if not file.filename.endswith(('.xls', '.xlsx')):
raise HTTPException(status_code=400, detail="仅支持 .xls/.xlsx 格式的结算单文件")
try:
content = await file.read()
df_futures, df_options = parse_settlement_file(content, file.filename)
if df_futures.empty and df_options.empty:
return {"success": False, "message": "文件中未找到有效的交易记录(请确认是期货公司导出的结算单)"}
result = save_to_db(db, df_futures, df_options, file.filename)
return {
"success": True,
"message": f"导入成功:期货 {result['futures_count']} 条,期权 {result['options_count']}",
"data": result,
}
except Exception as e:
logger.exception("导入结算单失败")
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
# ==================== 交易记录查询 ====================
@router.get("/records")
def get_records(
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
symbol: Optional[str] = Query(None, description="合约代码"),
variety: Optional[str] = Query(None, description="品种代码"),
trade_type: Optional[str] = Query(None, description="类型: 期货/期权"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: Session = Depends(get_analysis_db),
):
"""查询交易记录(支持筛选和分页)"""
query = db.query(TradeRecord)
if start_date:
query = query.filter(TradeRecord.trade_date >= start_date)
if end_date:
query = query.filter(TradeRecord.trade_date <= end_date)
if symbol:
query = query.filter(TradeRecord.symbol == symbol)
if variety:
query = query.filter(TradeRecord.variety == variety)
if trade_type:
query = query.filter(TradeRecord.trade_type == trade_type)
total = query.count()
records = query.order_by(
TradeRecord.trade_date.desc(),
TradeRecord.trade_time.desc(),
).offset((page - 1) * page_size).limit(page_size).all()
return {
"success": True,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"records": [{
"id": r.id,
"trade_type": r.trade_type,
"symbol": r.symbol,
"variety": r.variety,
"symbol_name": r.symbol_name,
"direction": r.direction,
"offset": r.offset,
"price": r.price,
"volume": r.volume,
"amount": r.amount,
"close_pnl": r.close_pnl,
"commission": r.commission,
"trade_date": r.trade_date,
"trade_time": r.trade_time,
"import_batch": r.import_batch,
"source_file": r.source_file,
} for r in records],
},
}
# ==================== 批次管理 ====================
@router.get("/batches")
def get_batches(
db: Session = Depends(get_analysis_db),
):
"""查询导入批次列表"""
batches = db.query(TradeImportBatch).order_by(
TradeImportBatch.created_at.desc()
).all()
return {
"success": True,
"data": [{
"id": b.id,
"batch_id": b.batch_id,
"source_file": b.source_file,
"futures_count": b.futures_count,
"options_count": b.options_count,
"trade_dates": b.trade_dates,
"created_at": b.created_at.strftime('%Y-%m-%d %H:%M:%S') if b.created_at else '',
} for b in batches],
}
@router.delete("/records/{batch_id}")
def delete_batch_records(
batch_id: str,
db: Session = Depends(get_analysis_db),
):
"""按批次删除交易记录"""
count = db.query(TradeRecord).filter(TradeRecord.import_batch == batch_id).count()
if count == 0:
raise HTTPException(status_code=404, detail="未找到该批次的交易记录")
db.query(TradeRecord).filter(TradeRecord.import_batch == batch_id).delete()
db.query(TradeImportBatch).filter(TradeImportBatch.batch_id == batch_id).delete()
db.commit()
return {"success": True, "message": f"已删除 {count} 条交易记录"}
# ==================== 汇总统计 ====================
@router.get("/latest-trade-date")
def get_latest_trade_date(
db: Session = Depends(get_analysis_db),
):
"""获取最后一个有交易记录的交易日"""
from datetime import date
latest = db.query(TradeRecord.trade_date).filter(
TradeRecord.trade_date.isnot(None),
TradeRecord.trade_date != '',
).distinct().order_by(TradeRecord.trade_date.desc()).first()
if latest:
return {"success": True, "data": {"trade_date": latest[0]}}
else:
return {"success": True, "data": {"trade_date": date.today().strftime('%Y-%m-%d')}}
@router.delete("/records-by-date/{trade_date}")
def delete_records_by_date(
trade_date: str,
db: Session = Depends(get_analysis_db),
):
"""删除指定交易日的所有交易记录"""
count = db.query(TradeRecord).filter(TradeRecord.trade_date == trade_date).count()
if count == 0:
raise HTTPException(status_code=404, detail=f"未找到 {trade_date} 的交易记录")
db.query(TradeRecord).filter(TradeRecord.trade_date == trade_date).delete()
db.commit()
return {"success": True, "message": f"已删除 {trade_date}{count} 条交易记录"}
@router.get("/daily-summary")
def get_daily_summary(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
db: Session = Depends(get_analysis_db),
):
"""获取每日交易盈亏汇总"""
data = calc_daily_summary(db, start_date, end_date)
return {"success": True, "data": data}
@router.get("/variety-summary")
def get_variety_summary(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
db: Session = Depends(get_analysis_db),
):
"""获取品种交易盈亏汇总"""
data = calc_variety_summary(db, start_date, end_date)
return {"success": True, "data": data}
@router.get("/statistics")
def get_statistics(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
db: Session = Depends(get_analysis_db),
):
"""获取整体交易统计"""
data = calc_overall_statistics(db, start_date, end_date)
return {"success": True, "data": data}
@router.get("/trade-pairs")
def get_trade_pairs_api(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
symbol: Optional[str] = Query(None),
db: Session = Depends(get_analysis_db),
):
"""获取开平仓配对后的逐笔交易"""
pairs = get_trade_pairs(db, start_date, end_date)
if symbol:
pairs = [p for p in pairs if p["symbol"] == symbol]
return {"success": True, "data": pairs}
# ==================== K线 + 交易标记 ====================
@router.get("/kline-with-trades/{symbol}")
def get_kline_with_trades(
symbol: str,
period: str = Query("daily", description="K线周期: daily/60min/15min/5min"),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
db: Session = Depends(get_analysis_db),
):
"""获取K线数据 + 该品种的交易标记买卖点。symbol 可以是完整合约代码(AG2608)或品种代码(AG)"""
import json
import re
from app.database import SessionLocal as MainSessionLocal
from app.models import MarketData
logger.info(f"[K线查询] 请求参数: symbol={symbol}, period={period}, start_date={start_date}, end_date={end_date}")
# 判断是品种代码还是完整合约代码
variety_code = symbol.upper()
if re.match(r'^[A-Za-z]+\d', symbol):
# 完整合约代码如 AG2608
variety_code = re.match(r'^([A-Za-z]+)', symbol).group(1).upper()
kline_symbol = symbol
else:
# 品种代码如 AG从配置中查找当前合约
kline_symbol = _resolve_symbol_from_config(variety_code)
logger.info(f"[K线查询] 解析结果: variety_code={variety_code}, kline_symbol={kline_symbol}")
main_db = MainSessionLocal()
try:
# 尝试精确匹配先尝试原始symbol再尝试小写
market_data = main_db.query(MarketData).filter(
MarketData.symbol == kline_symbol,
MarketData.period == period,
).first()
logger.info(f"[K线查询] 精确匹配查询: symbol={kline_symbol}, period={period}, found={market_data is not None}")
if not market_data or not market_data.candles_json:
# 尝试小写匹配
market_data = main_db.query(MarketData).filter(
MarketData.symbol == kline_symbol.lower(),
MarketData.period == period,
).first()
logger.info(f"[K线查询] 小写匹配查询: symbol={kline_symbol.lower()}, period={period}, found={market_data is not None}")
if not market_data or not market_data.candles_json:
# 尝试模糊匹配:查找以该品种代码开头的合约(不区分大小写)
market_data = main_db.query(MarketData).filter(
MarketData.symbol.like(f'{variety_code}%'),
MarketData.period == period,
).first()
logger.info(f"[K线查询] 模糊匹配查询: like '{variety_code}%', period={period}, found={market_data is not None}")
if market_data:
logger.info(f"[K线查询] 模糊匹配找到: symbol={market_data.symbol}")
if not market_data or not market_data.candles_json:
# 尝试小写模糊匹配
market_data = main_db.query(MarketData).filter(
MarketData.symbol.like(f'{variety_code.lower()}%'),
MarketData.period == period,
).first()
logger.info(f"[K线查询] 小写模糊匹配查询: like '{variety_code.lower()}%', period={period}, found={market_data is not None}")
if market_data:
logger.info(f"[K线查询] 小写模糊匹配找到: symbol={market_data.symbol}")
if not market_data or not market_data.candles_json:
logger.warning(f"[K线查询] 未找到K线数据: {kline_symbol} {period}")
return {"success": False, "message": f"未找到 {kline_symbol}{period} K线数据"}
candles = json.loads(market_data.candles_json)
kline_symbol = market_data.symbol
logger.info(f"[K线查询] 成功获取K线数据: symbol={kline_symbol}, candles_count={len(candles)}")
finally:
main_db.close()
# 获取该品种的交易记录
logger.info(f"[K线查询] 开始查询交易记录: variety={variety_code}, start_date={start_date}, end_date={end_date}")
query = db.query(TradeRecord).filter(TradeRecord.variety == variety_code)
if start_date:
query = query.filter(TradeRecord.trade_date >= start_date)
if end_date:
query = query.filter(TradeRecord.trade_date <= end_date)
trades = query.order_by(TradeRecord.trade_date, TradeRecord.trade_time).all()
logger.info(f"[K线查询] 交易记录查询完成: 找到 {len(trades)} 条记录")
if len(trades) > 0:
logger.info(f"[K线查询] 交易记录示例: {[{'symbol': t.symbol, 'variety': t.variety, 'date': t.trade_date, 'direction': t.direction} for t in trades[:3]]}")
trade_markers = []
for t in trades:
trade_markers.append({
"date": t.trade_date,
"time": t.trade_time,
"symbol": t.symbol,
"direction": t.direction,
"offset": t.offset,
"price": t.price,
"volume": t.volume,
"close_pnl": t.close_pnl,
"commission": t.commission,
})
logger.info(f"[K线查询] 返回数据: symbol={kline_symbol}, period={period}, candles={len(candles)}, markers={len(trade_markers)}")
return {
"success": True,
"data": {
"symbol": kline_symbol,
"period": period,
"candles": candles,
"trade_markers": trade_markers,
},
}
def _resolve_symbol_from_config(variety_code: str) -> str:
"""从品种配置中查找品种代码对应的当前合约"""
import json as _json
from pathlib import Path
config_path = Path(__file__).resolve().parent.parent.parent / "config" / "symbols_config.json"
if not config_path.exists():
return variety_code
with open(config_path, "r", encoding="utf-8") as f:
config = _json.load(f)
for name, contract in config.get("futures", {}).items():
if contract.upper().startswith(variety_code.upper()):
return contract
return variety_code
# ==================== AI 逐笔交易分析 ====================
class AnalyzeTradeRequest(BaseModel):
symbol: str
open_date: str
open_time: Optional[str] = None
close_date: Optional[str] = None
close_time: Optional[str] = None
direction: str # 多/空
open_price: Optional[float] = None
close_price: Optional[float] = None
@router.post("/analyze-trade")
async def analyze_trade(
req: AnalyzeTradeRequest,
db: Session = Depends(get_analysis_db),
):
"""AI 分析单笔交易结合多周期K线数据"""
from app.services.ai_analysis import AIAnalysisService
# 收集多周期K线数据
from app.database import SessionLocal as MainSessionLocal
from app.models import MarketData
import json
periods = ["daily", "60min", "15min", "5min"]
kline_context = {}
main_db = MainSessionLocal()
try:
for period in periods:
market_data = main_db.query(MarketData).filter(
MarketData.symbol == req.symbol,
MarketData.period == period,
).first()
if market_data and market_data.candles_json:
candles = json.loads(market_data.candles_json)
kline_context[period] = candles[-50:] if len(candles) > 50 else candles
finally:
main_db.close()
if not kline_context:
return {"success": False, "message": f"未找到 {req.symbol} 的K线数据无法分析"}
# 构建分析提示
prompt = f"""请分析以下期货交易交易的优缺点:
交易信息
- 品种{req.symbol}
- 方向{req.direction}
- 开仓时间{req.open_date} {req.open_time or ''}
- 开仓价格{req.open_price or '未知'}
- 平仓时间{req.close_date or '未知'} {req.close_time or ''}
- 平仓价格{req.close_price or '未知'}
各周期K线数据格式[日期, 开盘, 收盘, 最低, 最高, 成交量]
"""
for period, candles in kline_context.items():
prompt += f"\n{period} 周期(最近{len(candles)}根K线\n"
for c in candles[-10:]:
prompt += f" {c[0]}: 开{c[1]}{c[2]}{c[3]}{c[4]}{c[5]}\n"
prompt += """
请从以下维度分析这笔交易
1. 入场时机分析入场时各周期的趋势如何入场点是否合理
2. 出场时机分析出场时的市场状态是否过早/过晚出场
3. 持仓期间分析持仓期间市场走势是否有更好的操作机会
4. 综合评价这笔交易的主要优点和不足之处
5. 改进建议类似行情下如何优化操作
"""
try:
# 使用 AIAnalysisService 的模型配置和调用能力
service = AIAnalysisService.__new__(AIAnalysisService)
model = service.get_active_model()
if not model:
return {"success": False, "message": "未配置AI模型或模型未激活请先在AI配置页面设置"}
response = service.call_ai_model(prompt, model)
if not response:
return {"success": False, "message": "AI模型返回空响应请稍后重试"}
return {"success": True, "data": {"analysis": response, "symbol": req.symbol}}
except Exception as e:
logger.exception("AI分析交易失败")
return {"success": False, "message": f"AI分析失败: {str(e)}"}

@ -0,0 +1,129 @@
"""
用户权限系统 - 认证服务
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.orm import Session
from app.user_models import User, Session as UserSession
# 密码加密工具
def hash_password(password: str) -> str:
"""对密码进行哈希加密"""
salt = secrets.token_hex(16)
pwd_hash = hashlib.sha256((salt + password).encode()).hexdigest()
return f"{salt}${pwd_hash}"
def verify_password(password: str, hashed: str) -> bool:
"""验证密码"""
try:
salt, pwd_hash = hashed.split('$')
return hashlib.sha256((salt + password).encode()).hexdigest() == pwd_hash
except:
return False
def generate_token() -> str:
"""生成会话令牌"""
return secrets.token_urlsafe(32)
# 用户操作
def create_user(db: Session, username: str, password: str, email: str = None, role: str = 'user') -> User:
"""创建新用户"""
user = User(
username=username,
password_hash=hash_password(password),
email=email,
role=role,
is_active=True
)
db.add(user)
db.commit()
db.refresh(user)
return user
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
"""验证用户登录"""
user = db.query(User).filter(User.username == username).first()
if not user:
return None
if not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user
def update_last_login(db: Session, user: User):
"""更新最后登录时间"""
user.last_login = datetime.utcnow()
db.commit()
# 会话操作
def create_session(db: Session, user_id: int, expires_hours: int = 24) -> str:
"""创建用户会话"""
token = generate_token()
session = UserSession(
user_id=user_id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=expires_hours),
is_valid=True
)
db.add(session)
db.commit()
return token
def validate_session(db: Session, token: str) -> Optional[User]:
"""验证会话令牌"""
session = db.query(UserSession).filter(
UserSession.token == token,
UserSession.is_valid == True,
UserSession.expires_at > datetime.utcnow()
).first()
if not session:
return None
user = db.query(User).filter(User.id == session.user_id).first()
if not user or not user.is_active:
return None
return user
def invalidate_session(db: Session, token: str):
"""使会话失效(登出)"""
session = db.query(UserSession).filter(UserSession.token == token).first()
if session:
session.is_valid = False
db.commit()
def cleanup_expired_sessions(db: Session):
"""清理过期会话"""
db.query(UserSession).filter(
UserSession.expires_at < datetime.utcnow()
).update({"is_valid": False})
db.commit()
# 默认用户创建
def create_default_admin(db: Session):
"""创建默认管理员账户"""
admin = db.query(User).filter(User.username == 'lxy_root').first()
if not admin:
create_user(
db=db,
username='lxy_root',
password='admin123',
email='admin@system.local',
role='admin'
)
print("✓ 默认管理员账户已创建: lxy_root / admin123")

@ -5,14 +5,15 @@ import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, RedirectResponse
from app.database import engine, Base
from app.user_models import Base as UserBase
from app.config import HOST, PORT, LOG_LEVEL
from app.api import data, tasks, config, futures_analysis, ai_config
from app.api import data, tasks, config, futures_analysis, ai_config, auth, review_plan, trade_review
from app.services.scheduler import start_scheduler, stop_scheduler
# 配置日志
@ -29,15 +30,25 @@ async def lifespan(app: FastAPI):
# 启动时:建表 + 启动调度器
logger.info("创建数据库表...")
Base.metadata.create_all(bind=engine)
UserBase.metadata.create_all(bind=engine)
from app.analysis_db import init_analysis_db
init_analysis_db()
logger.info("期货智析数据库初始化完成")
# 创建默认管理员账户
from app.database import SessionLocal
from app import auth_service
db = SessionLocal()
try:
auth_service.create_default_admin(db)
finally:
db.close()
logger.info("启动定时调度器...")
start_scheduler()
# 恢复已启用的任务
from app.database import SessionLocal
from app.services.cache import list_tasks
from app.services.scheduler import add_job
@ -75,23 +86,42 @@ app.add_middleware(
allow_headers=["*"],
)
# 静态文件服务
# 静态文件服务(禁用缓存以确保前端更新立即生效)
STATIC_DIR = Path(__file__).resolve().parent / "static"
STATIC_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
# 登录页面(无需认证)
@app.get("/login")
def login_page():
"""登录页面"""
return FileResponse(str(STATIC_DIR / "login.html"))
# 角色选择页面(需要管理员权限)
@app.get("/role-select")
def role_select_page():
"""角色选择页面"""
return FileResponse(str(STATIC_DIR / "role_select.html"))
# 品种配置管理页面(需要管理员权限)
@app.get("/ui")
def ui_page():
"""品种配置管理页面"""
return FileResponse(str(STATIC_DIR / "index.html"))
# 注册路由
app.include_router(auth.router, prefix="/api/v1")
app.include_router(data.router, prefix="/api/v1")
app.include_router(tasks.router, prefix="/api/v1")
app.include_router(config.router, prefix="/api/v1")
app.include_router(futures_analysis.router, prefix="/api/v1")
app.include_router(ai_config.router, prefix="/api/v1")
app.include_router(review_plan.router, prefix="/api/v1")
app.include_router(trade_review.router, prefix="/api/v1")
@app.get("/futures-analysis")
@ -111,13 +141,11 @@ def health():
return {"status": "ok", "service": "market-data-buffer"}
# 根路径重定向到登录页
@app.get("/")
def root():
return {
"message": "数据缓冲平台 API",
"docs": "/docs",
"health": "/api/v1/health",
}
"""根路径重定向到登录页"""
return RedirectResponse(url="/login")
if __name__ == "__main__":

@ -188,10 +188,20 @@ def get_cached_data(
symbol: str,
data_type: str = "futures",
periods: Optional[List[str]] = None,
end_time: Optional[datetime] = None,
max_candles: int = 100,
) -> Optional[Dict]:
"""
从缓存中获取完整的多周期数据
Args:
db: 数据库会话
symbol: 品种代码
data_type: 数据类型
periods: 周期列表
end_time: 结束时间(可选),默认为当前时间
max_candles: 每个周期最大K线数量,默认100
Returns:
与采集脚本相同格式的数据 None
"""
@ -208,10 +218,51 @@ def get_cached_data(
newest = max(r.fetched_at for r in records)
is_fresh = (now - newest).total_seconds() < CACHE_TTL_SECONDS
# 如果未指定结束时间,默认为当前时间
filter_end_time = end_time if end_time else now
# 确保filter_end_time是naive datetime(无时区)
if filter_end_time.tzinfo is not None:
filter_end_time = filter_end_time.replace(tzinfo=None)
timeframes = {}
current_price = None
for r in records:
timeframes[r.period] = json.loads(r.candles_json)
candles = json.loads(r.candles_json)
# 过滤结束时间之前的K线数据
filtered_candles = []
for candle in candles:
candle_time = candle.get('datetime') or candle.get('time')
if candle_time:
# 解析K线时间
if isinstance(candle_time, str):
try:
candle_dt = datetime.fromisoformat(candle_time.replace('Z', '+00:00'))
# 转换为naive datetime进行比较
if candle_dt.tzinfo is not None:
candle_dt = candle_dt.replace(tzinfo=None)
except:
filtered_candles.append(candle)
continue
else:
candle_dt = candle_time
# 如果是aware datetime,转换为naive
if candle_dt.tzinfo is not None:
candle_dt = candle_dt.replace(tzinfo=None)
# 只保留结束时间之前的数据
if candle_dt <= filter_end_time:
filtered_candles.append(candle)
else:
filtered_candles.append(candle)
# 限制K线数量,超过max_candles则取最新的max_candles条
if len(filtered_candles) > max_candles:
filtered_candles = filtered_candles[-max_candles:]
timeframes[r.period] = filtered_candles
if current_price is None:
current_price = r.current_price

@ -0,0 +1,862 @@
"""
V2 交易计划生成器 - 5维度综合评分 + 多周期共振分析
"""
import json
import logging
from collections import Counter
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from datetime import datetime
from sqlalchemy.orm import Session
from app.services.cache import get_cached_data
from app.analysis_models import (
ReviewPlanV2, SymbolScoreV2, TradingPlanV2, SectorHeat,
)
logger = logging.getLogger(__name__)
# 品种配置加载
CONFIG_DIR = Path(__file__).resolve().parent.parent.parent / "config"
# 板块分类
SECTOR_MAP = {
"贵金属": ["沪银", "沪金"],
"有色金属": ["沪铜", "沪锌", "沪锡", "沪铅", "沪铝", "铝合金", "沪镍"],
"黑色系": ["焦炭", "焦煤", "螺纹钢", "热卷", "铁矿石"],
"能源": ["原油", "低硫燃油", "燃油"],
"化工": ["PTA", "甲醇", "尿素", "PVC", "乙二醇", "合成橡胶", "烧碱", "纯碱", "玻璃", "橡胶"],
"农产品": ["白糖", "棉花", "棕榈油", "豆粕"],
"新能源": ["氧化铝", "多晶硅", "碳酸锂", "工业硅"],
"股指": ["中证1000"],
"航运": ["集运欧线"],
"橡胶": ["20号胶"],
}
# 权重
WEIGHTS = {
"amplitude": 0.20,
"volume": 0.15,
"change": 0.15,
"trend": 0.35,
"activity": 0.15,
}
def load_symbols_config() -> Dict[str, str]:
"""加载品种配置 {中文名: 合约代码}"""
config_path = CONFIG_DIR / "symbols_config.json"
if not config_path.exists():
logger.warning(f"品种配置文件不存在: {config_path}")
return {}
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("futures", {})
def _get_candle_date(candle: dict) -> Optional[str]:
"""从K线数据中提取日期字符串 (YYYY-MM-DD)"""
dt_str = candle.get("datetime") or candle.get("time")
if not dt_str:
return None
try:
if isinstance(dt_str, str):
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
else:
dt = dt_str
return dt.strftime("%Y-%m-%d")
except Exception:
return None
def _get_candle_values(candles: List[dict]) -> Tuple[list, list, list, list, list]:
"""从K线列表提取 OHLCV"""
opens, highs, lows, closes, volumes = [], [], [], [], []
for c in candles:
opens.append(float(c.get("open", 0)))
highs.append(float(c.get("high", 0)))
lows.append(float(c.get("low", 0)))
closes.append(float(c.get("close", 0)))
volumes.append(float(c.get("volume", 0)))
return opens, highs, lows, closes, volumes
def _calc_single_period_trend(candles: List[dict]) -> float:
"""
计算单周期趋势分 (-50 ~ +50)
MA排列: 多头+50 / 空头-50
MACD: DIF>DEA +30 / DIF<DEA -30
综合: -50 ~ +50 (归一化后映射)
"""
if len(candles) < 5:
return 0.0
last = candles[-1]
ma10 = last.get("ma10")
ma20 = last.get("ma20")
macd_dif = last.get("macd_dif", 0) or 0
macd_dea = last.get("macd_dea", 0) or 0
score = 0.0
# MA排列
if ma10 and ma20:
if ma10 > ma20:
score += 50
else:
score -= 50
# MACD
if macd_dif > macd_dea:
score += 30
else:
score -= 30
# 归一化到 -50 ~ +50
return max(-50, min(50, score * 50 / 80))
def _calc_trend_score(t60: float, t15: float, t5: float) -> Tuple[float, str, str]:
"""
三周期共振判定
Returns: (趋势总分 0-100, 方向标签, 方向简写)
"""
# 共振判定
if t60 > 20 and t15 > 20 and t5 > 20:
direction = "多头共振"
tag = "强多"
raw = 80 + min(20, (t60 + t15 + t5) / 3 * 20 / 50)
elif t60 < -20 and t15 < -20 and t5 < -20:
direction = "空头共振"
tag = "强空"
raw = 80 + min(20, abs(t60 + t15 + t5) / 3 * 20 / 50)
elif t60 > 0 and t15 > 0:
direction = "偏多震荡"
tag = "偏多"
raw = 50 + min(20, (t60 + t15) / 2 * 20 / 50)
elif t60 < 0 and t15 < 0:
direction = "偏空震荡"
tag = "偏空"
raw = 50 + min(20, abs(t60 + t15) / 2 * 20 / 50)
else:
direction = "多空交织"
tag = "震荡"
raw = max(0, 40 - abs(t60 + t15 + t5) / 3)
return round(raw, 1), direction, tag
def _get_price_precision(price: float) -> int:
"""根据价格大小确定合适的小数位数"""
if price >= 10000:
return 0 # 如白银8250、股指5000+
elif price >= 1000:
return 1 # 如原油528、黄金685(实际2位)
elif price >= 100:
return 2 # 如大部分化工品
else:
return 2 # 小数值的品种
def _round_by_price(value: float, price: float) -> float:
"""根据价格精度四舍五入"""
precision = _get_price_precision(price)
return round(value, precision)
def _calc_pivot_points(high: float, low: float, close: float) -> dict:
"""计算枢轴点和支撑/阻力位(根据价格精度自动调整小数位)"""
pivot = (high + low + close) / 3
r1 = 2 * pivot - low
s1 = 2 * pivot - high
r2 = pivot + (high - low)
s2 = pivot - (high - low)
# 根据价格精度四舍五入
p = _get_price_precision(close)
return {
"pivot": round(pivot, p),
"r1": round(r1, p),
"r2": round(r2, p),
"s1": round(s1, p),
"s2": round(s2, p),
}
def _normalize_scores(values: List[float], reverse: bool = False) -> List[float]:
"""将一组值归一化到 0-100"""
if not values:
return []
min_v = min(values)
max_v = max(values)
if max_v == min_v:
return [50.0] * len(values)
result = []
for v in values:
if reverse:
score = (max_v - v) / (max_v - min_v) * 100
else:
score = (v - min_v) / (max_v - min_v) * 100
result.append(round(score, 1))
return result
def _normalize_abs_scores(values: List[float]) -> List[float]:
"""将绝对值归一化到 0-100涨跌幅用绝对值排名"""
abs_values = [abs(v) for v in values]
return _normalize_scores(abs_values)
def _generate_trigger(score_data: dict, direction: str) -> str:
"""
根据分析结果动态生成触发条件
Args:
score_data: 品种评分数据
direction: 方向 (long/short)
Returns:
触发条件文本
"""
conditions = []
direction_tag = score_data.get("direction_tag", "震荡")
volume_ratio = score_data.get("volume_ratio", 1.0)
amplitude_pct = score_data.get("amplitude_pct", 0)
change_pct = score_data.get("change_pct", 0)
# 根据方向标签生成基础条件
if direction == "long":
if direction_tag == "强多":
conditions.append("价格站稳支撑位上方")
elif direction_tag == "偏多":
conditions.append("回踩支撑企稳")
else:
conditions.append("突破枢轴点P位")
else:
if direction_tag == "强空":
conditions.append("价格跌破阻力位下方")
elif direction_tag == "偏空":
conditions.append("反弹阻力受阻")
else:
conditions.append("跌破枢轴点P位")
# 根据量比添加量能条件
if volume_ratio > 1.5:
conditions.append("量能持续放大")
elif volume_ratio > 1.2:
conditions.append("5分钟K线放量确认")
else:
conditions.append("等待量能配合")
# 根据振幅添加波动条件
if amplitude_pct > 2:
conditions.append("注意波动风险")
elif amplitude_pct > 1:
conditions.append("关注区间突破")
# 根据涨跌幅添加趋势条件
if abs(change_pct) > 2:
if change_pct > 0:
conditions.append("强势延续确认")
else:
conditions.append("弱势延续确认")
return " + ".join(conditions[:3]) # 最多返回3个条件
def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict:
"""
生成V2复盘与交易计划
Returns:
{
"review_date_id": int,
"summary": {...},
"scores": [...],
"plans": [...],
"sectors": [...],
}
"""
symbols_config = load_symbols_config()
if not symbols_config:
raise ValueError("品种配置文件为空")
# 反转映射: 合约代码 -> 中文名
code_to_name = {v: k for k, v in symbols_config.items()}
all_symbols = list(symbols_config.values())
logger.info(f"开始生成交易计划: {review_date_str} ({week_day}), 共 {len(all_symbols)} 个品种")
# 将复盘日期作为数据截止时间(当天 23:59:59
try:
end_time = datetime.strptime(review_date_str, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
except ValueError:
end_time = datetime.now()
# ==================== 第一步: 数据收集 ====================
symbol_data = {}
periods_needed = ["daily", "60min", "15min", "5min"]
for symbol in all_symbols:
try:
data = get_cached_data(
db, symbol, "futures",
periods=periods_needed,
end_time=end_time,
max_candles=100,
)
if data and data.get("timeframes"):
symbol_data[symbol] = data
else:
logger.warning(f"{symbol} 无数据")
except Exception as e:
logger.error(f"获取 {symbol} 数据失败: {e}")
if not symbol_data:
raise ValueError("无法从数据库获取任何品种数据,请先进行数据更新")
logger.info(f"成功获取 {len(symbol_data)}/{len(all_symbols)} 个品种数据")
# ==================== 第二步: 多维度打分 ====================
raw_scores = []
for symbol, data in symbol_data.items():
name = code_to_name.get(symbol, symbol)
tf = data.get("timeframes", {})
daily = tf.get("daily", [])
h1 = tf.get("60min", [])
m15 = tf.get("15min", [])
m5 = tf.get("5min", [])
# 日线数据
if not daily or len(daily) < 2:
continue
_, d_highs, d_lows, d_closes, d_volumes = _get_candle_values(daily)
today_high = d_highs[-1]
today_low = d_lows[-1]
today_close = d_closes[-1]
prev_close = d_closes[-2] if len(d_closes) >= 2 else today_close
today_volume = d_volumes[-1]
# 提取实际数据日期日线最后一根K线的日期
data_date = _get_candle_date(daily[-1])
# 近5日均量
recent_vols = d_volumes[-6:-1] if len(d_volumes) >= 6 else d_volumes[:-1]
avg_vol_5 = sum(recent_vols) / len(recent_vols) if recent_vols else today_volume
# A. 振幅
amplitude_pct = (today_high - today_low) / today_close * 100 if today_close else 0
# B. 量比
volume_ratio = today_volume / avg_vol_5 if avg_vol_5 > 0 else 1.0
# C. 涨跌幅
change_pct = (today_close - prev_close) / prev_close * 100 if prev_close else 0
# D. 多周期趋势
t60 = _calc_single_period_trend(h1) if h1 else 0
t15 = _calc_single_period_trend(m15) if m15 else 0
t5 = _calc_single_period_trend(m5) if m5 else 0
trend_total, direction, direction_tag = _calc_trend_score(t60, t15, t5)
# E. 活跃度 (5min K线近10根)
if m5 and len(m5) >= 5:
recent_m5 = m5[-10:] if len(m5) >= 10 else m5
_, _, _, m5_closes, m5_vols = _get_candle_values(recent_m5)
# 价格变动率
if m5_closes and m5_closes[0] > 0:
price_change_rate = abs(m5_closes[-1] - m5_closes[0]) / m5_closes[0] * 100
else:
price_change_rate = 0
# 量能活跃度
avg_m5_vol = sum(m5_vols) / len(m5_vols) if m5_vols else 0
vol_activity = min(100, avg_m5_vol / 100) if avg_m5_vol > 0 else 0
price_activity = min(100, price_change_rate * 50)
activity_score = (vol_activity + price_activity) / 2
else:
activity_score = 0
# 关键点位
pivots = _calc_pivot_points(today_high, today_low, today_close)
raw_scores.append({
"symbol": symbol,
"name": name,
"data_date": data_date,
"close_price": today_close,
"prev_close": prev_close,
"high_price": today_high,
"low_price": today_low,
"volume": today_volume,
"avg_volume_5": avg_vol_5,
"amplitude_pct": round(amplitude_pct, 2),
"volume_ratio": round(volume_ratio, 2),
"change_pct": round(change_pct, 2),
"amplitude_raw": amplitude_pct,
"volume_raw": volume_ratio,
"change_raw": abs(change_pct),
"trend_score": trend_total,
"trend_60m": round(t60, 1),
"trend_15m": round(t15, 1),
"trend_5m": round(t5, 1),
"direction": direction,
"direction_tag": direction_tag,
"activity_raw": activity_score,
"pivots": pivots,
})
if not raw_scores:
raise ValueError("没有足够的数据进行评分计算")
# ==================== 第三步: 归一化评分 ====================
amp_scores = _normalize_scores([s["amplitude_raw"] for s in raw_scores])
vol_scores = _normalize_scores([s["volume_raw"] for s in raw_scores])
chg_scores = _normalize_abs_scores([s["change_raw"] for s in raw_scores])
act_scores = _normalize_scores([s["activity_raw"] for s in raw_scores])
for i, s in enumerate(raw_scores):
s["amplitude_score"] = amp_scores[i]
s["volume_score"] = vol_scores[i]
s["change_score"] = chg_scores[i]
s["activity_score"] = act_scores[i]
# 综合评分 = 振幅×0.20 + 量能×0.15 + 涨跌幅×0.15 + 趋势×0.35 + 活跃度×0.15
# 趋势分映射: trend_score 0-100 直接使用
s["composite_score"] = round(
amp_scores[i] * WEIGHTS["amplitude"]
+ vol_scores[i] * WEIGHTS["volume"]
+ chg_scores[i] * WEIGHTS["change"]
+ s["trend_score"] * WEIGHTS["trend"]
+ act_scores[i] * WEIGHTS["activity"],
1
)
# 排序
raw_scores.sort(key=lambda x: x["composite_score"], reverse=True)
# ==================== 第四步: 分类 ====================
for rank, s in enumerate(raw_scores, 1):
s["rank"] = rank
score = s["composite_score"]
trend = s["trend_score"]
if score >= 55 and trend >= 50:
s["category"] = "green"
elif score >= 45 or (score >= 40 and 30 <= trend < 50):
s["category"] = "yellow"
else:
s["category"] = "red"
# ==================== 第五步: 生成交易计划 ====================
plans = []
green_items = [s for s in raw_scores if s["category"] == "green"]
for s in green_items:
# 根据方向标签判断实际方向(强多/偏多→做多,强空/偏空→做空)
direction_tag = s.get("direction_tag", "震荡")
if direction_tag in ["强空", "偏空"]:
direction = "short"
elif direction_tag in ["强多", "偏多"]:
direction = "long"
else:
# 震荡情况下根据涨跌幅判断
direction = "long" if s["change_pct"] > 0 else "short"
pivots = s["pivots"]
price = s["close_price"]
if direction == "long":
entry_low = _round_by_price(pivots["s1"], price)
entry_high = _round_by_price(pivots["pivot"], price)
stop_loss = _round_by_price(pivots["s2"], price)
target1 = _round_by_price(pivots["r1"], price)
target2 = _round_by_price(pivots["r2"], price)
else:
entry_low = _round_by_price(pivots["pivot"], price)
entry_high = _round_by_price(pivots["r1"], price)
stop_loss = _round_by_price(pivots["r2"], price)
target1 = _round_by_price(pivots["s1"], price)
target2 = _round_by_price(pivots["s2"], price)
# 根据分析结果动态生成触发条件
trigger = _generate_trigger(s, direction)
plans.append({
"symbol": s["symbol"],
"name": s["name"],
"direction": direction,
"composite_score": s["composite_score"],
"entry_low": entry_low,
"entry_high": entry_high,
"stop_loss": stop_loss,
"target1": target1,
"target2": target2,
"trigger": trigger,
"amplitude_score": s["amplitude_score"],
"volume_score": s["volume_score"],
"trend_score": s["trend_score"],
"activity_score": s["activity_score"],
"category": s["category"],
})
# ==================== 第六步: 板块热度 ====================
sectors = _calc_sector_heat(raw_scores, code_to_name)
# ==================== 第七步: 汇总 ====================
bull_count = sum(1 for s in raw_scores if s["trend_score"] >= 50)
bear_count = sum(1 for s in raw_scores if s["trend_score"] < 30)
neutral_count = len(raw_scores) - bull_count - bear_count
# 统计实际数据日期(取出现最多的日期作为基准数据日期)
date_counts = Counter(s.get("data_date") for s in raw_scores if s.get("data_date"))
actual_data_date = date_counts.most_common(1)[0][0] if date_counts else review_date_str
# 判断数据日期是否与复盘日期一致
data_date_matches = (actual_data_date == review_date_str)
# 核心结论
top3 = raw_scores[:3]
top_names = [f"{s['symbol']} {s['name']}" for s in top3]
main_direction = "多头" if bull_count > bear_count else "空头" if bear_count > bull_count else "震荡"
core_conclusion = f"{main_direction}格局{'延续' if bull_count > 15 else '分化'}{'贵金属+黑色系共振做多' if bull_count > 20 else '板块轮动明显'}"
# 风险提示
risk_warnings = []
if top3:
top_sym = top3[0]
risk_warnings.append(
f"极值品种严禁追{'' if top_sym['trend_score'] >= 50 else ''}"
f"{top_sym['symbol']}({top_sym['composite_score']}分) "
f"{'周一大概率高开' if top_sym['change_pct'] > 2 else '注意风险控制'}"
)
if bear_count > 0:
bear_syms = [s for s in raw_scores if s["category"] == "red" and s["trend_score"] < 30][:2]
if bear_syms:
names = "/".join(s["symbol"] for s in bear_syms)
risk_warnings.append(f"空头品种谨慎:{names} 趋势偏空,需严格止损")
risk_warnings.append("关注宏观数据和消息面变化,可能影响开盘方向")
if any(s["volume_ratio"] > 2.5 for s in raw_scores):
high_vol = [s for s in raw_scores if s["volume_ratio"] > 2.5]
risk_warnings.append(
f"量能异常品种:{', '.join(s['symbol'] for s in high_vol[:3])} "
f"量比超2.5倍,需确认量能可持续性"
)
# 构建数据基准描述
if data_date_matches:
data_basis = f"{actual_data_date} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析"
else:
data_basis = f"复盘日期: {review_date_str} | 数据截至: {actual_data_date} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析"
return {
"review_date": review_date_str,
"week_day": week_day,
"actual_data_date": actual_data_date,
"data_date_matches": data_date_matches,
"data_basis": data_basis,
"core_conclusion": core_conclusion,
"bull_count": bull_count,
"bear_count": bear_count,
"neutral_count": neutral_count,
"opportunity_count": len(green_items),
"risk_warnings": risk_warnings,
"scores": raw_scores,
"plans": plans,
"sectors": sectors,
"symbol_count": len(symbol_data),
}
def _calc_sector_heat(scores: List[dict], code_to_name: dict) -> List[dict]:
"""计算板块热度"""
# 构建 symbol -> score 映射
name_to_score = {}
for s in scores:
name_to_score[s["name"]] = s
sectors = []
for sector_name, members_names in SECTOR_MAP.items():
members = []
for mn in members_names:
if mn in name_to_score:
sc = name_to_score[mn]
members.append({
"symbol": sc["symbol"],
"name": sc["name"],
"score": sc["composite_score"],
"trend": sc["trend_score"],
"change_pct": sc["change_pct"],
})
if not members:
continue
avg_score = round(sum(m["score"] for m in members) / len(members), 1)
avg_trend = round(sum(m["trend"] for m in members) / len(members), 0)
if avg_trend > 20:
direction = "多头"
elif avg_trend < -20:
direction = "空头"
else:
direction = "震荡"
# 热度等级
if avg_score >= 75:
heat = 3
elif avg_score >= 60:
heat = 2
elif avg_score >= 45:
heat = 1
else:
heat = 0
# 龙头
leader = max(members, key=lambda m: m["score"])
sectors.append({
"sector_name": sector_name,
"avg_score": avg_score,
"avg_trend": int(avg_trend),
"direction": direction,
"heat_level": heat,
"leader_symbol": leader["symbol"],
"leader_score": leader["score"],
"members": sorted(members, key=lambda m: m["score"], reverse=True),
})
sectors.sort(key=lambda s: s["avg_score"], reverse=True)
return sectors
def save_plan_to_db(db: Session, plan_data: dict) -> int:
"""将计划数据保存到数据库"""
review_date_str = plan_data["review_date"]
week_day = plan_data["week_day"]
# 清理旧数据
existing = db.query(ReviewPlanV2).filter_by(review_date=review_date_str).first()
if existing:
review_plan_id = existing.id
# 清理关联数据
db.query(SymbolScoreV2).filter_by(review_date_id=review_plan_id).delete()
db.query(TradingPlanV2).filter_by(review_date_id=review_plan_id).delete()
db.query(SectorHeat).filter_by(review_date_id=review_plan_id).delete()
# 更新元数据
existing.week_day = week_day
existing.data_basis = plan_data.get("data_basis")
existing.core_conclusion = plan_data.get("core_conclusion")
existing.bull_count = plan_data.get("bull_count")
existing.bear_count = plan_data.get("bear_count")
existing.neutral_count = plan_data.get("neutral_count")
existing.opportunity_count = plan_data.get("opportunity_count")
existing.risk_warnings = plan_data.get("risk_warnings")
existing.actual_data_date = plan_data.get("actual_data_date")
existing.data_date_matches = 1 if plan_data.get("data_date_matches") else 0
else:
review_plan = ReviewPlanV2(
review_date=review_date_str,
week_day=week_day,
data_basis=plan_data.get("data_basis"),
core_conclusion=plan_data.get("core_conclusion"),
bull_count=plan_data.get("bull_count"),
bear_count=plan_data.get("bear_count"),
neutral_count=plan_data.get("neutral_count"),
opportunity_count=plan_data.get("opportunity_count"),
risk_warnings=plan_data.get("risk_warnings"),
actual_data_date=plan_data.get("actual_data_date"),
data_date_matches=1 if plan_data.get("data_date_matches") else 0,
)
db.add(review_plan)
db.flush()
review_plan_id = review_plan.id
# 保存评分数据
for s in plan_data.get("scores", []):
pivots = s.get("pivots", {})
score_record = SymbolScoreV2(
review_date_id=review_plan_id,
symbol=s["symbol"],
name=s["name"],
close_price=s.get("close_price"),
prev_close=s.get("prev_close"),
high_price=s.get("high_price"),
low_price=s.get("low_price"),
volume=s.get("volume"),
avg_volume_5=s.get("avg_volume_5"),
amplitude_score=s.get("amplitude_score"),
volume_score=s.get("volume_score"),
change_score=s.get("change_score"),
trend_score=s.get("trend_score"),
activity_score=s.get("activity_score"),
composite_score=s.get("composite_score"),
amplitude_pct=s.get("amplitude_pct"),
change_pct=s.get("change_pct"),
volume_ratio=s.get("volume_ratio"),
trend_60m=s.get("trend_60m"),
trend_15m=s.get("trend_15m"),
trend_5m=s.get("trend_5m"),
direction=s.get("direction"),
direction_tag=s.get("direction_tag"),
category=s.get("category"),
pivot=pivots.get("pivot"),
r1=pivots.get("r1"),
r2=pivots.get("r2"),
s1=pivots.get("s1"),
s2=pivots.get("s2"),
rank=s.get("rank"),
data_date=s.get("data_date"),
)
db.add(score_record)
# 保存交易计划
for p in plan_data.get("plans", []):
plan_record = TradingPlanV2(
review_date_id=review_plan_id,
symbol=p["symbol"],
name=p["name"],
direction=p["direction"],
composite_score=p.get("composite_score"),
entry_low=p.get("entry_low"),
entry_high=p.get("entry_high"),
stop_loss=p.get("stop_loss"),
target1=p.get("target1"),
target2=p.get("target2"),
trigger=p.get("trigger"),
amplitude_score=p.get("amplitude_score"),
volume_score=p.get("volume_score"),
trend_score=p.get("trend_score"),
activity_score=p.get("activity_score"),
category=p.get("category"),
)
db.add(plan_record)
# 保存板块热度
for sec in plan_data.get("sectors", []):
sector_record = SectorHeat(
review_date_id=review_plan_id,
sector_name=sec["sector_name"],
avg_score=sec.get("avg_score"),
avg_trend=sec.get("avg_trend"),
direction=sec.get("direction"),
heat_level=sec.get("heat_level"),
leader_symbol=sec.get("leader_symbol"),
leader_score=sec.get("leader_score"),
members=sec.get("members"),
)
db.add(sector_record)
db.commit()
logger.info(f"交易计划已保存: {review_date_str}, ID={review_plan_id}")
return review_plan_id
def get_plan_data(db: Session, review_plan_id: int) -> Optional[dict]:
"""从数据库读取完整的计划数据"""
plan_meta = db.query(ReviewPlanV2).filter_by(id=review_plan_id).first()
if not plan_meta:
return None
scores = db.query(SymbolScoreV2).filter_by(
review_date_id=review_plan_id
).order_by(SymbolScoreV2.composite_score.desc()).all()
plans = db.query(TradingPlanV2).filter_by(
review_date_id=review_plan_id
).order_by(TradingPlanV2.composite_score.desc()).all()
sectors = db.query(SectorHeat).filter_by(
review_date_id=review_plan_id
).order_by(SectorHeat.avg_score.desc()).all()
return {
"meta": {
"id": plan_meta.id,
"review_date": plan_meta.review_date,
"week_day": plan_meta.week_day,
"data_basis": plan_meta.data_basis,
"actual_data_date": plan_meta.actual_data_date,
"data_date_matches": bool(plan_meta.data_date_matches),
"core_conclusion": plan_meta.core_conclusion,
"bull_count": plan_meta.bull_count,
"bear_count": plan_meta.bear_count,
"neutral_count": plan_meta.neutral_count,
"opportunity_count": plan_meta.opportunity_count,
"risk_warnings": plan_meta.risk_warnings,
"created_at": plan_meta.created_at.isoformat() if plan_meta.created_at else None,
},
"scores": [_score_to_dict(s) for s in scores],
"plans": [_plan_to_dict(p) for p in plans],
"sectors": [_sector_to_dict(s) for s in sectors],
}
def _score_to_dict(s: SymbolScoreV2) -> dict:
return {
"id": s.id,
"symbol": s.symbol,
"name": s.name,
"data_date": s.data_date,
"close_price": s.close_price,
"prev_close": s.prev_close,
"high_price": s.high_price,
"low_price": s.low_price,
"volume": s.volume,
"avg_volume_5": s.avg_volume_5,
"amplitude_score": s.amplitude_score,
"volume_score": s.volume_score,
"change_score": s.change_score,
"trend_score": s.trend_score,
"activity_score": s.activity_score,
"composite_score": s.composite_score,
"amplitude_pct": s.amplitude_pct,
"change_pct": s.change_pct,
"volume_ratio": s.volume_ratio,
"trend_60m": s.trend_60m,
"trend_15m": s.trend_15m,
"trend_5m": s.trend_5m,
"direction": s.direction,
"direction_tag": s.direction_tag,
"category": s.category,
"pivot": s.pivot,
"r1": s.r1,
"r2": s.r2,
"s1": s.s1,
"s2": s.s2,
"rank": s.rank,
}
def _plan_to_dict(p: TradingPlanV2) -> dict:
return {
"id": p.id,
"symbol": p.symbol,
"name": p.name,
"direction": p.direction,
"composite_score": p.composite_score,
"entry_low": p.entry_low,
"entry_high": p.entry_high,
"stop_loss": p.stop_loss,
"target1": p.target1,
"target2": p.target2,
"trigger": p.trigger,
"amplitude_score": p.amplitude_score,
"volume_score": p.volume_score,
"trend_score": p.trend_score,
"activity_score": p.activity_score,
"category": p.category,
}
def _sector_to_dict(s: SectorHeat) -> dict:
return {
"id": s.id,
"sector_name": s.sector_name,
"avg_score": s.avg_score,
"avg_trend": s.avg_trend,
"direction": s.direction,
"heat_level": s.heat_level,
"leader_symbol": s.leader_symbol,
"leader_score": s.leader_score,
"members": s.members,
}

@ -0,0 +1,609 @@
"""
交易结算单解析服务
复用 data/trading_calculate.py 的解析逻辑支持从 .xls 结算单文件中提取交易记录并存入数据库
"""
import re
import uuid
import json
from datetime import datetime
from collections import defaultdict
from pathlib import Path
import xlrd
import pandas as pd
from sqlalchemy.orm import Session as DBSession
from app.analysis_models import TradeRecord, TradeImportBatch
# 品种代码 -> 中文名 映射(反向映射:从合约代码前缀查中文名)
_VARIETY_NAME_MAP = {}
def _load_variety_name_map():
"""从 symbols_config.json 加载品种名称映射"""
global _VARIETY_NAME_MAP
if _VARIETY_NAME_MAP:
return
config_path = Path(__file__).resolve().parent.parent.parent / "config" / "symbols_config.json"
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# 正向: {"沪银": "AG2608"} -> 反向: {"AG": "沪银"}
for name, contract in config.get("futures", {}).items():
variety = extract_variety(contract)
if variety and variety not in _VARIETY_NAME_MAP:
_VARIETY_NAME_MAP[variety] = name
def extract_variety(contract):
"""提取品种代码(所有前导字母)"""
if pd.isna(contract) or not contract:
return ''
contract = str(contract).strip()
match = re.match(r'^([A-Za-z]+)', contract)
if match:
return match.group(1).upper()
return contract[:2].upper()
def safe_float(value, default=0.0):
"""安全转换为float"""
if pd.isna(value) or value is None or value == '':
return default
try:
return float(value)
except (ValueError, TypeError):
return default
def find_header(sheet, keywords):
"""
动态识别表头行
:param sheet: xlrd sheet对象
:param keywords: 关键词列表
:return: 表头行索引
"""
for row_idx in range(min(20, sheet.nrows)):
row_values = [str(sheet.cell_value(row_idx, col_idx)).strip()
for col_idx in range(sheet.ncols)]
row_text = ' '.join(row_values)
has_keyword = any(kw in row_text for kw in keywords)
has_aux = any(aux in row_text for aux in ["买/卖", "成交价", "权利金"])
if has_keyword and has_aux:
return row_idx
return None
def read_sheet(xls_file_path, sheet_name, trade_type):
"""
读取单个Sheet的数据
:param xls_file_path: Excel文件路径str bytes
:param sheet_name: Sheet名称
:param trade_type: 交易类型期货/期权
:return: DataFrame
"""
try:
wb = xlrd.open_workbook(xls_file_path)
if sheet_name not in wb.sheet_names():
return pd.DataFrame()
sheet = wb.sheet_by_name(sheet_name)
if sheet.nrows == 0:
return pd.DataFrame()
if trade_type == '期货':
keywords = ["合约"]
else:
keywords = ["品种合约"]
header_row = find_header(sheet, keywords)
if header_row is None:
return pd.DataFrame()
headers = [str(sheet.cell_value(header_row, col_idx)).strip()
for col_idx in range(sheet.ncols)]
data = []
for row_idx in range(header_row + 1, sheet.nrows):
row_data = {}
first_cell = str(sheet.cell_value(row_idx, 0)).strip()
if first_cell == '合计' or first_cell == '':
continue
for col_idx, header in enumerate(headers):
if col_idx < sheet.ncols:
value = sheet.cell_value(row_idx, col_idx)
if str(value).strip() == '--':
value = None
row_data[header] = value
row_data['类型'] = trade_type
data.append(row_data)
return pd.DataFrame(data)
except Exception:
return pd.DataFrame()
def parse_settlement_file(file_content: bytes, filename: str) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
解析结算单文件返回期货和期权交易DataFrame
:param file_content: 文件二进制内容
:param filename: 文件名
:return: (df_futures, df_options)
"""
import tempfile
# xlrd 需要文件路径,写入临时文件
with tempfile.NamedTemporaryFile(suffix='.xls', delete=False) as tmp:
tmp.write(file_content)
tmp_path = tmp.name
try:
df_futures = read_sheet(tmp_path, '成交明细', '期货')
df_options = read_sheet(tmp_path, '期权成交明细', '期权')
return df_futures, df_options
finally:
Path(tmp_path).unlink(missing_ok=True)
def _normalize_date(date_val) -> str:
"""将各种日期格式统一为 YYYY-MM-DD"""
if pd.isna(date_val) or date_val is None or date_val == '':
return ''
s = str(date_val).strip()
# 已经是 YYYY-MM-DD
if re.match(r'^\d{4}-\d{2}-\d{2}$', s):
return s
# YYYY/MM/DD
if re.match(r'^\d{4}/\d{2}/\d{2}$', s):
return s.replace('/', '-')
# YYYYMMDD
if re.match(r'^\d{8}$', s):
return f"{s[:4]}-{s[4:6]}-{s[6:8]}"
# xlrd 日期数字 (Excel serial date)
try:
f = float(s)
if 30000 < f < 60000: # 合理的 Excel 日期数字范围
dt = xlrd.xldate_as_datetime(f, 0)
return dt.strftime('%Y-%m-%d')
except (ValueError, TypeError):
pass
return s
def _normalize_time(time_val) -> str:
"""将各种时间格式统一为 HH:MM:SS"""
if pd.isna(time_val) or time_val is None or time_val == '':
return ''
s = str(time_val).strip()
# 已经是 HH:MM:SS
if re.match(r'^\d{1,2}:\d{2}:\d{2}$', s):
return s
# HH:MM
if re.match(r'^\d{1,2}:\d{2}$', s):
return s + ':00'
# xlrd 时间数字
try:
f = float(s)
if 0 <= f < 1:
hours = int(f * 24)
minutes = int((f * 24 - hours) * 60)
seconds = int(((f * 24 - hours) * 60 - minutes) * 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
except (ValueError, TypeError):
pass
return s
def save_to_db(
db: DBSession,
df_futures: pd.DataFrame,
df_options: pd.DataFrame,
filename: str,
) -> dict:
"""
将解析后的交易记录保存到数据库
:return: {"batch_id": str, "futures_count": int, "options_count": int, "trade_dates": str}
"""
_load_variety_name_map()
batch_id = str(uuid.uuid4())
all_dates = set()
futures_count = 0
options_count = 0
records = []
# 处理期货记录
if not df_futures.empty:
for _, row in df_futures.iterrows():
contract = str(row.get('合约', '')).strip()
if not contract or contract == '合计':
continue
variety = extract_variety(contract)
trade_date = _normalize_date(row.get('实际成交日期', ''))
trade_time = _normalize_time(row.get('成交时间', ''))
bs_flag = str(row.get('买/卖', '')).strip()
oc_flag = str(row.get('开/平', '')).strip()
rec = TradeRecord(
trade_type='期货',
symbol=contract,
variety=variety,
symbol_name=_VARIETY_NAME_MAP.get(variety, ''),
direction='' if '' in bs_flag else '',
offset='' if '' in oc_flag else ('' if '' in oc_flag else ''),
price=safe_float(row.get('成交价')),
volume=safe_float(row.get('手数')),
amount=safe_float(row.get('成交额')),
close_pnl=safe_float(row.get('平仓盈亏')),
commission=safe_float(row.get('手续费')),
trade_date=trade_date,
trade_time=trade_time,
import_batch=batch_id,
source_file=filename,
)
records.append(rec)
futures_count += 1
if trade_date:
all_dates.add(trade_date)
# 处理期权记录
if not df_options.empty:
for _, row in df_options.iterrows():
contract = str(row.get('品种合约', '')).strip()
if not contract or contract == '合计':
continue
variety = extract_variety(contract)
trade_date = _normalize_date(row.get('成交日期', ''))
trade_time = _normalize_time(row.get('成交时间', ''))
bs_flag = str(row.get('买/卖', '')).strip()
rec = TradeRecord(
trade_type='期权',
symbol=contract,
variety=variety,
symbol_name=_VARIETY_NAME_MAP.get(variety, ''),
direction='' if '' in bs_flag else '',
offset='',
price=safe_float(row.get('权利金单价')),
volume=safe_float(row.get('成交量')),
amount=safe_float(row.get('权利金')),
close_pnl=0.0,
commission=safe_float(row.get('手续费')),
trade_date=trade_date,
trade_time=trade_time,
import_batch=batch_id,
source_file=filename,
)
records.append(rec)
options_count += 1
if trade_date:
all_dates.add(trade_date)
if records:
db.add_all(records)
# 日期范围描述
sorted_dates = sorted(all_dates)
trade_dates_str = ''
if sorted_dates:
if len(sorted_dates) == 1:
trade_dates_str = sorted_dates[0]
else:
trade_dates_str = f"{sorted_dates[0]} ~ {sorted_dates[-1]}"
# 保存批次记录
batch = TradeImportBatch(
batch_id=batch_id,
source_file=filename,
futures_count=futures_count,
options_count=options_count,
trade_dates=trade_dates_str,
)
db.add(batch)
db.commit()
return {
"batch_id": batch_id,
"futures_count": futures_count,
"options_count": options_count,
"trade_dates": trade_dates_str,
}
def calc_daily_summary(db: DBSession, start_date: str = None, end_date: str = None) -> list[dict]:
"""按日期汇总交易盈亏"""
query = db.query(TradeRecord)
if start_date:
query = query.filter(TradeRecord.trade_date >= start_date)
if end_date:
query = query.filter(TradeRecord.trade_date <= end_date)
query = query.order_by(TradeRecord.trade_date)
records = query.all()
daily = defaultdict(lambda: {
"trade_date": "",
"total_trades": 0,
"total_pnl": 0.0,
"total_commission": 0.0,
"win_count": 0,
"loss_count": 0,
"max_win": 0.0,
"max_loss": 0.0,
"buy_volume": 0.0,
"sell_volume": 0.0,
"varieties": set(),
})
for r in records:
d = r.trade_date or '未知'
day = daily[d]
day["trade_date"] = d
day["total_trades"] += 1
pnl = (r.close_pnl or 0) - (r.commission or 0)
day["total_pnl"] += pnl
day["total_commission"] += (r.commission or 0)
if pnl > 0:
day["win_count"] += 1
day["max_win"] = max(day["max_win"], pnl)
elif pnl < 0:
day["loss_count"] += 1
day["max_loss"] = min(day["max_loss"], pnl)
if r.direction == '':
day["buy_volume"] += (r.volume or 0)
else:
day["sell_volume"] += (r.volume or 0)
day["varieties"].add(r.variety)
result = []
for d in sorted(daily.keys()):
day = daily[d]
total = day["win_count"] + day["loss_count"]
result.append({
"trade_date": day["trade_date"],
"total_trades": day["total_trades"],
"total_pnl": round(day["total_pnl"], 2),
"total_commission": round(day["total_commission"], 2),
"win_count": day["win_count"],
"loss_count": day["loss_count"],
"win_rate": round(day["win_count"] / total * 100, 1) if total > 0 else 0,
"max_win": round(day["max_win"], 2),
"max_loss": round(day["max_loss"], 2),
"buy_volume": day["buy_volume"],
"sell_volume": day["sell_volume"],
"variety_count": len(day["varieties"]),
})
return result
def calc_variety_summary(db: DBSession, start_date: str = None, end_date: str = None) -> list[dict]:
"""按品种汇总交易盈亏"""
query = db.query(TradeRecord)
if start_date:
query = query.filter(TradeRecord.trade_date >= start_date)
if end_date:
query = query.filter(TradeRecord.trade_date <= end_date)
records = query.all()
varieties = defaultdict(lambda: {
"variety": "",
"symbol_name": "",
"total_trades": 0,
"total_pnl": 0.0,
"total_commission": 0.0,
"total_close_pnl": 0.0,
"total_volume": 0.0,
"total_amount": 0.0,
"buy_count": 0,
"sell_count": 0,
"win_count": 0,
"loss_count": 0,
})
for r in records:
v = varieties[r.variety]
v["variety"] = r.variety
v["symbol_name"] = r.symbol_name or v["symbol_name"]
v["total_trades"] += 1
v["total_close_pnl"] += (r.close_pnl or 0)
v["total_commission"] += (r.commission or 0)
v["total_pnl"] += (r.close_pnl or 0) - (r.commission or 0)
v["total_volume"] += (r.volume or 0)
v["total_amount"] += (r.amount or 0)
if r.direction == '':
v["buy_count"] += 1
else:
v["sell_count"] += 1
pnl = (r.close_pnl or 0) - (r.commission or 0)
if pnl > 0:
v["win_count"] += 1
elif pnl < 0:
v["loss_count"] += 1
result = []
for variety in sorted(varieties.keys()):
v = varieties[variety]
total = v["win_count"] + v["loss_count"]
result.append({
"variety": v["variety"],
"symbol_name": v["symbol_name"],
"total_trades": v["total_trades"],
"total_pnl": round(v["total_pnl"], 2),
"total_close_pnl": round(v["total_close_pnl"], 2),
"total_commission": round(v["total_commission"], 2),
"total_volume": v["total_volume"],
"total_amount": round(v["total_amount"], 2),
"buy_count": v["buy_count"],
"sell_count": v["sell_count"],
"win_count": v["win_count"],
"loss_count": v["loss_count"],
"win_rate": round(v["win_count"] / total * 100, 1) if total > 0 else 0,
})
return result
def calc_overall_statistics(db: DBSession, start_date: str = None, end_date: str = None) -> dict:
"""计算整体交易统计"""
daily = calc_daily_summary(db, start_date, end_date)
if not daily:
return {
"total_trades": 0,
"total_pnl": 0,
"total_commission": 0,
"win_rate": 0,
"profit_loss_ratio": 0,
"max_single_win": 0,
"max_single_loss": 0,
"max_consecutive_wins": 0,
"max_consecutive_losses": 0,
"trading_days": 0,
"avg_daily_pnl": 0,
"max_daily_pnl": 0,
"min_daily_pnl": 0,
}
total_trades = sum(d["total_trades"] for d in daily)
total_pnl = sum(d["total_pnl"] for d in daily)
total_commission = sum(d["total_commission"] for d in daily)
total_wins = sum(d["win_count"] for d in daily)
total_losses = sum(d["loss_count"] for d in daily)
total_decided = total_wins + total_losses
# 最大连续盈/亏
max_consec_wins = 0
max_consec_losses = 0
cur_wins = 0
cur_losses = 0
for d in daily:
if d["total_pnl"] > 0:
cur_wins += 1
cur_losses = 0
max_consec_wins = max(max_consec_wins, cur_wins)
elif d["total_pnl"] < 0:
cur_losses += 1
cur_wins = 0
max_consec_losses = max(max_consec_losses, cur_losses)
else:
cur_wins = 0
cur_losses = 0
# 平均日盈亏
pnl_values = [d["total_pnl"] for d in daily]
# 盈亏比 - 用逐笔数据计算
query = db.query(TradeRecord)
if start_date:
query = query.filter(TradeRecord.trade_date >= start_date)
if end_date:
query = query.filter(TradeRecord.trade_date <= end_date)
all_records = query.all()
wins_pnl = []
losses_pnl = []
for r in all_records:
pnl = (r.close_pnl or 0) - (r.commission or 0)
if pnl > 0:
wins_pnl.append(pnl)
elif pnl < 0:
losses_pnl.append(pnl)
avg_win_val = sum(wins_pnl) / len(wins_pnl) if wins_pnl else 0
avg_loss_val = abs(sum(losses_pnl) / len(losses_pnl)) if losses_pnl else 0
profit_loss_ratio = round(avg_win_val / avg_loss_val, 2) if avg_loss_val > 0 else 0
return {
"total_trades": total_trades,
"total_pnl": round(total_pnl, 2),
"total_commission": round(total_commission, 2),
"win_rate": round(total_wins / total_decided * 100, 1) if total_decided > 0 else 0,
"profit_loss_ratio": profit_loss_ratio,
"max_single_win": round(max(wins_pnl), 2) if wins_pnl else 0,
"max_single_loss": round(min(losses_pnl), 2) if losses_pnl else 0,
"max_consecutive_wins": max_consec_wins,
"max_consecutive_losses": max_consec_losses,
"trading_days": len(daily),
"avg_daily_pnl": round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else 0,
"max_daily_pnl": round(max(pnl_values), 2) if pnl_values else 0,
"min_daily_pnl": round(min(pnl_values), 2) if pnl_values else 0,
}
def get_trade_pairs(db: DBSession, start_date: str = None, end_date: str = None) -> list[dict]:
"""
将开平仓配对生成逐笔交易对
按合约+品种分组按时间排序买开/卖开 卖平/买平 配对
"""
query = db.query(TradeRecord)
if start_date:
query = query.filter(TradeRecord.trade_date >= start_date)
if end_date:
query = query.filter(TradeRecord.trade_date <= end_date)
query = query.order_by(TradeRecord.symbol, TradeRecord.trade_date, TradeRecord.trade_time)
records = query.all()
# 按合约分组
by_symbol = defaultdict(list)
for r in records:
by_symbol[r.symbol].append(r)
pairs = []
pair_id = 0
for symbol, recs in by_symbol.items():
open_positions = [] # 未平仓记录
for r in recs:
if r.offset == '':
open_positions.append(r)
elif r.offset == '' and open_positions:
# 配对:取最早的一条开仓
open_rec = open_positions.pop(0)
pair_id += 1
pnl = (r.close_pnl or 0) - (open_rec.commission or 0) - (r.commission or 0)
direction = '' if open_rec.direction == '' else ''
pairs.append({
"id": pair_id,
"symbol": symbol,
"variety": r.variety,
"symbol_name": r.symbol_name,
"direction": direction,
"open_date": open_rec.trade_date,
"open_time": open_rec.trade_time,
"open_price": open_rec.price,
"close_date": r.trade_date,
"close_time": r.trade_time,
"close_price": r.price,
"volume": open_rec.volume,
"close_pnl": round((r.close_pnl or 0), 2),
"commission": round((open_rec.commission or 0) + (r.commission or 0), 2),
"net_pnl": round(pnl, 2),
"open_batch": open_rec.import_batch,
"close_batch": r.import_batch,
})
elif r.offset == '' or r.offset is None:
# 期权等无开平标记的,按买卖交替配对
if open_positions:
open_rec = open_positions.pop(0)
pair_id += 1
pairs.append({
"id": pair_id,
"symbol": symbol,
"variety": r.variety,
"symbol_name": r.symbol_name,
"direction": '' if open_rec.direction == '' else '',
"open_date": open_rec.trade_date,
"open_time": open_rec.trade_time,
"open_price": open_rec.price,
"close_date": r.trade_date,
"close_time": r.trade_time,
"close_price": r.price,
"volume": open_rec.volume,
"close_pnl": 0,
"commission": round((open_rec.commission or 0) + (r.commission or 0), 2),
"net_pnl": 0,
"open_batch": open_rec.import_batch,
"close_batch": r.import_batch,
})
else:
open_positions.append(r)
return pairs

@ -1,20 +1,25 @@
:root {
--bg-primary: #0d0f14;
--bg-secondary: #151820;
--bg-card: #1a1d28;
--bg-card-hover: #222633;
--border-color: #2a2d3a;
--text-primary: #e8eaed;
--text-secondary: #9aa0ab;
--text-muted: #6b7280;
--green: #22c55e;
--green-bg: rgba(34, 197, 94, 0.15);
--green-border: rgba(34, 197, 94, 0.3);
--red: #ef4444;
--red-bg: rgba(239, 68, 68, 0.15);
--blue: #3b82f6;
--purple: #8b5cf6;
--orange: #f59e0b;
--bg-primary: #F5F5F7;
--bg-secondary: #FFFFFF;
--bg-card: #FFFFFF;
--bg-card-hover: #F8F8FA;
--border-color: #E5E5EA;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-muted: #8E8E93;
--apple-blue: #007AFF;
--apple-blue-hover: #0066D6;
--apple-blue-bg: rgba(0, 122, 255, 0.1);
--apple-green: #34C759;
--apple-green-bg: rgba(52, 199, 89, 0.1);
--apple-green-border: rgba(52, 199, 89, 0.3);
--apple-red: #FF3B30;
--apple-red-bg: rgba(255, 59, 48, 0.1);
--apple-orange: #FF9500;
--apple-purple: #AF52DE;
--card-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
--card-shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.1);
--input-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
* {
@ -24,10 +29,12 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app-container {
@ -40,9 +47,14 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 16px 32px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
position: sticky;
top: 0;
z-index: 100;
}
.nav-left {
@ -52,29 +64,30 @@ body {
}
.back-link {
color: var(--text-secondary);
color: var(--apple-blue);
text-decoration: none;
font-size: 14px;
font-size: 15px;
display: flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
transition: opacity 0.2s;
}
.back-link:hover {
color: var(--text-primary);
opacity: 0.7;
}
.page-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.page-title i {
color: var(--green);
.page-title svg {
color: var(--apple-blue);
}
.nav-right {
@ -84,22 +97,28 @@ body {
}
.nav-icon-btn {
color: var(--text-secondary);
color: var(--apple-blue);
text-decoration: none;
font-size: 16px;
padding: 6px;
border-radius: 6px;
padding: 8px;
border-radius: 10px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.nav-icon-btn:hover {
color: var(--text-primary);
background: var(--bg-card);
background: var(--apple-blue-bg);
}
.nav-icon-btn svg {
width: 18px;
height: 18px;
}
.main-content {
flex: 1;
padding: 24px;
padding: 32px 24px;
max-width: 900px;
margin: 0 auto;
width: 100%;
@ -108,22 +127,28 @@ body {
.config-container {
display: flex;
flex-direction: column;
gap: 20px;
gap: 24px;
}
.config-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
border-radius: 20px;
padding: 24px;
box-shadow: var(--card-shadow);
transition: box-shadow 0.3s ease;
}
.config-card:hover {
box-shadow: var(--card-shadow-hover);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
@ -131,87 +156,99 @@ body {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.card-header h3 i {
color: var(--green);
.card-header h3 svg {
color: var(--apple-blue);
}
/* 提供商网格 */
.provider-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
gap: 16px;
}
.provider-card {
padding: 16px;
padding: 20px 16px;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 10px;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.provider-card:hover {
border-color: var(--text-muted);
background: var(--bg-card-hover);
border-color: var(--apple-blue);
background: var(--apple-blue-bg);
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.12);
transform: translateY(-2px);
}
.provider-card.active {
border-color: var(--green);
background: var(--green-bg);
border-color: var(--apple-blue);
background: var(--apple-blue-bg);
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.15);
}
.provider-card i {
font-size: 28px;
margin-bottom: 8px;
.provider-card svg {
margin-bottom: 10px;
color: var(--text-secondary);
transition: color 0.3s;
}
.provider-card.active i {
color: var(--green);
.provider-card.active svg {
color: var(--apple-blue);
}
.provider-card .provider-name {
font-size: 13px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
/* 表单 */
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.form-group label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.form-control {
padding: 10px 14px;
background: var(--bg-secondary);
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
border-radius: 12px;
color: var(--text-primary);
font-size: 14px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
transition: all 0.2s;
box-shadow: var(--input-shadow);
}
.form-control:focus {
border-color: var(--green);
border-color: var(--apple-blue);
box-shadow: 0 0 0 4px var(--apple-blue-bg);
background: var(--bg-secondary);
}
.form-control::placeholder {
color: var(--text-muted);
}
.input-with-toggle {
@ -222,95 +259,137 @@ body {
.input-with-toggle .form-control {
flex: 1;
padding-right: 40px;
padding-right: 44px;
}
.toggle-visibility {
position: absolute;
right: 10px;
right: 12px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
padding: 6px;
border-radius: 8px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.toggle-visibility:hover {
color: var(--apple-blue);
background: var(--apple-blue-bg);
}
.toggle-visibility svg {
width: 18px;
height: 18px;
}
.form-range {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--bg-secondary);
background: var(--border-color);
outline: none;
-webkit-appearance: none;
appearance: none;
}
.form-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--green);
background: var(--apple-blue);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.3);
transition: all 0.2s;
}
.form-range::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.4);
}
.form-range::-moz-range-thumb {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--apple-blue);
cursor: pointer;
border: none;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.3);
}
.range-labels {
display: flex;
justify-content: space-between;
font-size: 11px;
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.form-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
margin-top: 20px;
}
.test-result {
font-size: 13px;
font-size: 14px;
font-weight: 500;
}
.test-result.success {
color: var(--green);
color: var(--apple-green);
}
.test-result.error {
color: var(--red);
color: var(--apple-red);
}
/* 当前启用的AI卡片 */
.active-ai-card {
border-color: var(--green-border);
background: var(--green-bg);
border-color: var(--apple-green-border);
background: linear-gradient(135deg, var(--apple-green-bg) 0%, var(--bg-secondary) 100%);
}
.active-ai-card .card-header h3 {
color: var(--green);
color: var(--apple-green);
}
.active-ai-card .card-header i {
color: var(--green);
.active-ai-card .card-header svg {
color: var(--apple-green);
}
.active-ai-display {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.active-ai-icon {
width: 48px;
height: 48px;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(34, 197, 94, 0.15);
border: 1px solid var(--green-border);
border-radius: 12px;
color: var(--green);
font-size: 24px;
background: var(--apple-blue-bg);
border: 1px solid rgba(0, 122, 255, 0.2);
border-radius: 14px;
color: var(--apple-blue);
}
.active-ai-icon svg {
width: 26px;
height: 26px;
}
.active-ai-info {
@ -320,28 +399,27 @@ body {
}
.active-ai-name {
font-size: 16px;
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.active-ai-model {
font-size: 13px;
font-size: 14px;
color: var(--text-secondary);
}
/* 设置列表 */
.settings-list {
display: flex;
flex-direction: column;
gap: 16px;
gap: 0;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
padding: 16px 0;
border-bottom: 1px solid var(--border-color);
}
@ -352,24 +430,24 @@ body {
.setting-info {
display: flex;
flex-direction: column;
gap: 2px;
gap: 4px;
}
.setting-name {
font-size: 14px;
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.setting-desc {
font-size: 12px;
font-size: 13px;
color: var(--text-muted);
}
/* 开关 */
.switch {
position: relative;
width: 48px;
height: 26px;
width: 51px;
height: 31px;
}
.switch input {
@ -385,49 +463,52 @@ body {
left: 0;
right: 0;
bottom: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 26px;
transition: 0.3s;
background: var(--border-color);
border-radius: 31px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
height: 27px;
width: 27px;
left: 2px;
bottom: 2px;
background: var(--text-muted);
background: white;
border-radius: 50%;
transition: 0.3s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
input:checked + .slider {
background: var(--green-bg);
border-color: var(--green-border);
background: var(--apple-green);
}
input:checked + .slider:before {
transform: translateX(22px);
background: var(--green);
transform: translateX(20px);
}
/* 模型列表 */
.models-list {
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
}
.model-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-secondary);
padding: 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
border-radius: 14px;
transition: all 0.2s;
}
.model-item:hover {
background: var(--bg-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.model-info {
@ -443,8 +524,8 @@ input:checked + .slider:before {
}
.model-status.active {
background: var(--green);
box-shadow: 0 0 8px var(--green);
background: var(--apple-green);
box-shadow: 0 0 8px var(--apple-green);
}
.model-status.inactive {
@ -452,109 +533,181 @@ input:checked + .slider:before {
}
.model-name {
font-size: 14px;
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.model-provider {
font-size: 12px;
font-size: 13px;
color: var(--text-muted);
}
.model-actions {
display: flex;
gap: 8px;
align-items: center;
}
.model-actions button {
padding: 6px 12px;
font-size: 12px;
border-radius: 6px;
.model-actions button,
.btn-set-active,
.btn-delete {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 10px;
border: none;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-set-active {
background: var(--green-bg);
color: var(--green);
border: 1px solid var(--green-border);
background: var(--apple-blue-bg);
color: var(--apple-blue);
}
.btn-set-active:hover {
background: var(--green);
background: var(--apple-blue);
color: white;
}
.btn-delete {
background: var(--red-bg);
color: var(--red);
border: 1px solid var(--red-border);
background: var(--apple-red-bg);
color: var(--apple-red);
}
.btn-delete:hover {
background: var(--red);
background: var(--apple-red);
color: white;
}
/* 按钮 */
.btn-delete svg {
width: 14px;
height: 14px;
}
.active-badge {
font-size: 13px;
color: var(--apple-green);
font-weight: 500;
padding: 8px 14px;
background: var(--apple-green-bg);
border-radius: 10px;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
padding: 12px 24px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
-webkit-font-smoothing: antialiased;
}
.btn svg {
width: 16px;
height: 16px;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
padding: 8px 14px;
font-size: 13px;
border-radius: 10px;
}
.btn-sm svg {
width: 14px;
height: 14px;
}
.btn-lg {
padding: 12px 28px;
font-size: 15px;
padding: 14px 32px;
font-size: 16px;
border-radius: 14px;
}
.btn-lg svg {
width: 18px;
height: 18px;
}
.btn-primary {
background: var(--green);
background: var(--apple-blue);
color: white;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.25);
}
.btn-primary:hover {
background: #16a34a;
background: var(--apple-blue-hover);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.35);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
background: var(--bg-primary);
color: var(--apple-blue);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-card-hover);
background: var(--apple-blue-bg);
border-color: var(--apple-blue);
}
.save-actions {
display: flex;
justify-content: center;
gap: 16px;
padding: 20px 0;
padding: 24px 0;
}
.empty-state {
text-align: center;
padding: 32px;
color: var(--text-muted);
font-size: 14px;
}
/* 响应式 */
@media (max-width: 768px) {
.top-nav {
padding: 14px 20px;
}
.main-content {
padding: 24px 16px;
}
.config-card {
padding: 20px;
border-radius: 16px;
}
.form-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.provider-grid {
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.provider-card {
padding: 16px 12px;
}
.save-actions {
@ -565,4 +718,29 @@ input:checked + .slider:before {
width: 100%;
justify-content: center;
}
.model-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.model-actions {
width: 100%;
}
.model-actions button {
flex: 1;
}
}
@media (max-width: 480px) {
.provider-grid {
grid-template-columns: repeat(2, 1fr);
}
.active-ai-display {
flex-direction: column;
text-align: center;
}
}

@ -5,23 +5,32 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI模型配置 - 期货智析</title>
<link rel="stylesheet" href="/static/ai_config.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="app-container">
<header class="top-nav">
<div class="nav-left">
<a href="/static/futures_analysis.html" class="back-link">
<i class="fas fa-arrow-left"></i> 返回期货智析
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
返回期货智析
</a>
<div class="page-title">
<i class="fas fa-robot"></i>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="6" width="14" height="10" rx="2" stroke="currentColor" stroke-width="1.5"/>
<circle cx="8" cy="11" r="1.5" fill="currentColor"/>
<circle cx="12" cy="11" r="1.5" fill="currentColor"/>
<path d="M7 6V4C7 2.89543 7.89543 2 9 2H11C12.1046 2 13 2.89543 13 4V6" stroke="currentColor" stroke-width="1.5"/>
</svg>
<span>AI模型配置</span>
</div>
</div>
<div class="nav-right">
<a href="/" class="nav-icon-btn" title="返回首页">
<i class="fas fa-home"></i>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 7.5L9 1.5L15 7.5V15C15 15.5523 14.5523 16 14 16H10V11H8V16H4C3.44772 16 3 15.5523 3 15V7.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
</div>
</header>
@ -31,11 +40,22 @@
<!-- 当前启用的AI -->
<div class="config-card active-ai-card" id="active-ai-card" style="display:none;">
<div class="card-header">
<h3><i class="fas fa-check-circle"></i> 当前启用</h3>
<h3>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="8" stroke="currentColor" stroke-width="1.5"/>
<path d="M5.5 9L7.5 11L12.5 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
当前启用
</h3>
</div>
<div class="active-ai-display">
<div class="active-ai-icon" id="active-ai-icon">
<i class="fas fa-robot"></i>
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="8" width="18" height="12" rx="2" stroke="currentColor" stroke-width="1.5"/>
<circle cx="10" cy="14" r="1.5" fill="currentColor"/>
<circle cx="16" cy="14" r="1.5" fill="currentColor"/>
<path d="M9 8V6C9 4.89543 9.89543 4 11 4H15C16.1046 4 17 4.89543 17 6V8" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
<div class="active-ai-info">
<div class="active-ai-name" id="active-ai-name">--</div>
@ -47,7 +67,12 @@
<!-- AI提供商选择 -->
<div class="config-card">
<div class="card-header">
<h3><i class="fas fa-cloud"></i> AI提供商</h3>
<h3>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 12C2.34315 12 1 10.6569 1 9C1 7.51487 2.08333 6.28542 3.5 6.03687C4.10687 3.57091 6.33434 1.75 9 1.75C12.1756 1.75 14.75 4.32436 14.75 7.5C14.75 7.61229 14.7466 7.7238 14.7401 7.83428C16.0567 8.04571 17 9.20887 17 10.5C17 12.1569 15.6569 13.5 14 13.5H4Z" stroke="currentColor" stroke-width="1.5"/>
</svg>
AI提供商
</h3>
</div>
<div class="provider-grid" id="provider-grid">
<!-- 动态生成 -->
@ -57,7 +82,15 @@
<!-- API配置 -->
<div class="config-card">
<div class="card-header">
<h3><i class="fas fa-key"></i> API配置</h3>
<h3>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="5" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.5"/>
<path d="M5 5V4C5 2.34315 6.34315 1 8 1H10C11.6569 1 13 2.34315 13 4V5" stroke="currentColor" stroke-width="1.5"/>
<circle cx="9" cy="9.5" r="1.5" fill="currentColor"/>
<path d="M9 11V12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
API配置
</h3>
</div>
<div class="form-grid">
<div class="form-group">
@ -79,7 +112,10 @@
<div class="input-with-toggle">
<input type="password" id="api-key" class="form-control" placeholder="sk-...">
<button type="button" class="toggle-visibility" onclick="toggleApiKeyVisibility()">
<i class="fas fa-eye"></i>
<svg class="eye-icon" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 9C1 9 4 3 9 3C14 3 17 9 17 9C17 9 14 15 9 15C4 15 1 9 1 9Z" stroke="currentColor" stroke-width="1.5"/>
<circle cx="9" cy="9" r="2.5" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
</div>
@ -102,7 +138,12 @@
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="testConnection()">
<i class="fas fa-plug"></i> 测试连接
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 2L2 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 2L14 6L10 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
测试连接
</button>
<span id="test-result" class="test-result"></span>
</div>
@ -111,7 +152,17 @@
<!-- 模型参数 -->
<div class="config-card">
<div class="card-header">
<h3><i class="fas fa-sliders-h"></i> 模型参数</h3>
<h3>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 5H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M2 9H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M2 13H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="6" cy="5" r="2" fill="currentColor"/>
<circle cx="12" cy="9" r="2" fill="currentColor"/>
<circle cx="8" cy="13" r="2" fill="currentColor"/>
</svg>
模型参数
</h3>
</div>
<div class="form-grid">
<div class="form-group">
@ -132,7 +183,13 @@
<!-- 分析设置 -->
<div class="config-card">
<div class="card-header">
<h3><i class="fas fa-cogs"></i> 分析设置</h3>
<h3>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5"/>
<path d="M10.5 10.5L15 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
分析设置
</h3>
</div>
<div class="settings-list">
<div class="setting-item">
@ -183,9 +240,21 @@
<!-- 已保存的模型 -->
<div class="config-card">
<div class="card-header">
<h3><i class="fas fa-database"></i> 已保存的模型</h3>
<h3>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="9" cy="4" rx="7" ry="2.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M2 4V7C2 8.65685 5.13401 10 9 10C12.866 10 16 8.65685 16 7V4" stroke="currentColor" stroke-width="1.5"/>
<path d="M2 7V10C2 11.6569 5.13401 13 9 13C12.866 13 16 11.6569 16 10V7" stroke="currentColor" stroke-width="1.5"/>
<path d="M2 10V13C2 14.6569 5.13401 16 9 16C12.866 16 16 14.6569 16 13V10" stroke="currentColor" stroke-width="1.5"/>
</svg>
已保存的模型
</h3>
<button class="btn btn-primary btn-sm" onclick="addNewModel()">
<i class="fas fa-plus"></i> 添加模型
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 1V13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M1 7H13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
添加模型
</button>
</div>
<div class="models-list" id="models-list">
@ -196,10 +265,19 @@
<!-- 保存按钮 -->
<div class="save-actions">
<button class="btn btn-primary btn-lg" onclick="saveConfig()">
<i class="fas fa-save"></i> 保存配置
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5 16H3.5C2.67157 16 2 15.3284 2 14.5V3.5C2 2.67157 2.67157 2 3.5 2H11.5L16 6.5V14.5C16 15.3284 15.3284 16 14.5 16Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M11 16V11H7V16" stroke="currentColor" stroke-width="1.5"/>
<path d="M2 2H11V7H2V2Z" stroke="currentColor" stroke-width="1.5"/>
</svg>
保存配置
</button>
<button class="btn btn-secondary btn-lg" onclick="loadConfig()">
<i class="fas fa-sync"></i> 重新加载
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1V5H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.51 8.99C3.90192 10.279 4.64537 11.4272 5.65284 12.2985C6.6603 13.1698 7.88941 13.7278 9.19961 13.9085C10.5098 14.0892 11.8475 13.8851 13.0663 13.3185C14.2852 12.7518 15.3381 11.8446 16.1054 10.6995C16.8727 9.55439 17.3233 8.21691 17.4085 6.83162C17.4938 5.44634 17.2104 4.06538 16.5907 2.84419C15.9711 1.62299 15.0404 0.61021 13.8981 0.913653C12.7558 1.2171 11.8753 2.29247 11.5 3.5L1 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
重新加载
</button>
</div>
</div>

@ -35,34 +35,91 @@ async function loadProviders() {
function getDefaultProviders() {
return [
{ id: 'openai', name: 'OpenAI', icon: 'fas fa-brain' },
{ id: 'anthropic', name: 'Claude', icon: 'fas fa-robot' },
{ id: 'google', name: 'Gemini', icon: 'fas fa-gem' },
{ id: 'aliyun', name: '通义千问', icon: 'fas fa-cloud' },
{ id: 'aliyun_coding', name: '通义灵码', icon: 'fas fa-code' },
{ id: 'bailian', name: '阿里百炼', icon: 'fas fa-flask' },
{ id: 'baidu', name: '文心一言', icon: 'fas fa-comments' },
{ id: 'zhipu', name: '智谱清言', icon: 'fas fa-lightbulb' }
{ id: 'openai', name: 'OpenAI', icon: 'brain' },
{ id: 'anthropic', name: 'Claude', icon: 'robot' },
{ id: 'google', name: 'Gemini', icon: 'gem' },
{ id: 'aliyun', name: '通义千问', icon: 'cloud' },
{ id: 'aliyun_coding', name: '通义灵码', icon: 'code' },
{ id: 'bailian', name: '阿里百炼', icon: 'flask' },
{ id: 'baidu', name: '文心一言', icon: 'comments' },
{ id: 'zhipu', name: '智谱清言', icon: 'lightbulb' }
];
}
function getProviderSVG(iconName) {
const svgMap = {
'brain': `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 4C11.5 4 8 7.5 8 12C8 14.5 9.5 16.5 11 18V24C11 26 13 28 16 28C19 28 21 26 21 24V18C22.5 16.5 24 14.5 24 12C24 7.5 20.5 4 16 4Z" stroke="currentColor" stroke-width="2"/>
<path d="M13 28V30H19V28" stroke="currentColor" stroke-width="2"/>
<circle cx="13" cy="12" r="1.5" fill="currentColor"/>
<circle cx="19" cy="12" r="1.5" fill="currentColor"/>
</svg>`,
'robot': `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="10" width="20" height="14" rx="3" stroke="currentColor" stroke-width="2"/>
<circle cx="13" cy="17" r="2" fill="currentColor"/>
<circle cx="19" cy="17" r="2" fill="currentColor"/>
<path d="M10 6V4C10 2.89543 10.8954 2 12 2H20C21.1046 2 22 2.89543 22 4V6" stroke="currentColor" stroke-width="2"/>
<path d="M12 24H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>`,
'gem': `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 4L28 12L16 28L4 12L16 4Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M4 12H28" stroke="currentColor" stroke-width="2"/>
<path d="M16 4L12 12L16 28L20 12L16 4Z" stroke="currentColor" stroke-width="2"/>
</svg>`,
'cloud': `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 22C5.79086 22 4 20.2091 4 18C4 15.9584 5.53246 14.2884 7.5 14.0284C8.16974 10.6833 11.1509 8.16667 14.6667 8.16667C18.8756 8.16667 22.25 11.591 22.25 15.75C22.25 15.9028 22.2457 16.0549 22.2373 16.2059C24.0427 16.5125 25.4167 18.1191 25.4167 20.0833C25.4167 22.4167 23.5 24.5 21.25 24.5H8Z" stroke="currentColor" stroke-width="2"/>
</svg>`,
'code': `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 10L4 16L10 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 10L28 16L22 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 6L14 26" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>`,
'flask': `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4H20V12L26 24C26.5 25 26 26 25 27C24 28 23 28 22 28H10C9 28 8 27 7 26C6 25 5.5 24 6 23L12 12V4Z" stroke="currentColor" stroke-width="2"/>
<path d="M10 4H22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M8 22H24" stroke="currentColor" stroke-width="2"/>
</svg>`,
'comments': `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6C4 4.89543 4.89543 4 6 4H26C27.1046 4 28 4.89543 28 6V18C28 19.1046 27.1046 20 26 20H10L6 24V20C4.89543 20 4 19.1046 4 18V6Z" stroke="currentColor" stroke-width="2"/>
<path d="M10 10H22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M10 14H18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>`,
'lightbulb': `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 4C10.4772 4 6 8.47715 6 14C6 17.5 8 20.5 11 22V25C11 26.1046 11.8954 27 13 27H19C20.1046 27 21 26.1046 21 25V22C24 20.5 26 17.5 26 14C26 8.47715 21.5228 4 16 4Z" stroke="currentColor" stroke-width="2"/>
<path d="M13 27V29H19V27" stroke="currentColor" stroke-width="2"/>
</svg>`,
'cog': `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="4" stroke="currentColor" stroke-width="2"/>
<path d="M16 4V8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M16 24V28" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4 16H8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M24 16H28" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M7.05029 7.05029L9.87873 9.87873" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M22.1213 22.1213L24.9497 24.9497" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M7.05029 24.9497L9.87873 22.1213" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M22.1213 9.87873L24.9497 7.05029" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>`
};
return svgMap[iconName] || svgMap['cog'];
}
function renderProviders(providers) {
const grid = document.getElementById('provider-grid');
const iconMap = {
'openai': 'fas fa-brain',
'anthropic': 'fas fa-robot',
'google': 'fas fa-gem',
'aliyun': 'fas fa-cloud',
'aliyun_coding': 'fas fa-code',
'bailian': 'fas fa-flask',
'baidu': 'fas fa-comments',
'zhipu': 'fas fa-lightbulb',
'custom': 'fas fa-cog'
'openai': 'brain',
'anthropic': 'robot',
'google': 'gem',
'aliyun': 'cloud',
'aliyun_coding': 'code',
'bailian': 'flask',
'baidu': 'comments',
'zhipu': 'lightbulb',
'custom': 'cog'
};
grid.innerHTML = providers.map(p => `
<div class="provider-card ${p.id === selectedProvider ? 'active' : ''}" data-provider="${p.id}">
<i class="${iconMap[p.id] || 'fas fa-cog'}"></i>
${getProviderSVG(iconMap[p.id] || 'cog')}
<div class="provider-name">${p.name}</div>
</div>
`).join('');
@ -135,14 +192,12 @@ function populateForm(config) {
document.getElementById('temp-value').textContent = activeModel.temperature || 0.7;
document.getElementById('max-tokens').value = activeModel.max_tokens || 2000;
// 选中当前启用的提供商卡片
const providerCard = document.querySelector(`.provider-card[data-provider="${activeModel.provider}"]`);
if (providerCard) {
document.querySelectorAll('.provider-card').forEach(c => c.classList.remove('active'));
providerCard.classList.add('active');
}
// 更新模型下拉框
updateProviderModels();
}
@ -172,17 +227,16 @@ function renderModelsList(models) {
document.getElementById('active-ai-model').textContent = activeModel.model_name || activeModel.model_id || '--';
const iconMap = {
'openai': 'fab fa-openai',
'anthropic': 'fas fa-robot',
'google': 'fab fa-google',
'aliyun': 'fas fa-cloud',
'aliyun_coding': 'fas fa-code',
'bailian': 'fas fa-flask',
'baidu': 'fas fa-comments',
'zhipu': 'fas fa-lightbulb'
'openai': 'brain',
'anthropic': 'robot',
'google': 'gem',
'aliyun': 'cloud',
'aliyun_coding': 'code',
'bailian': 'flask',
'baidu': 'comments',
'zhipu': 'lightbulb'
};
const icon = document.getElementById('active-ai-icon').querySelector('i');
icon.className = iconMap[activeModel.provider] || 'fas fa-robot';
document.getElementById('active-ai-icon').innerHTML = getProviderSVG(iconMap[activeModel.provider] || 'robot');
} else {
activeCard.style.display = 'none';
}
@ -198,7 +252,13 @@ function renderModelsList(models) {
</div>
<div class="model-actions">
${!model.enabled ? `<button class="btn-set-active" onclick="setActiveModel(${index})">设为默认</button>` : '<span class="active-badge">当前启用</span>'}
<button class="btn-delete" onclick="deleteModel(${index})"><i class="fas fa-trash"></i></button>
<button class="btn-delete" onclick="deleteModel(${index})">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 3H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M5 3V2C5 1.44772 5.44772 1 6 1H8C8.55228 1 9 1.44772 9 2V3" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 3L3.5 12C3.5 12.5523 3.94772 13 4.5 13H9.5C10.0523 13 10.5 12.5523 10.5 12L11 3" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
</div>
`).join('');
@ -220,13 +280,20 @@ function getProviderName(apiBase) {
function toggleApiKeyVisibility() {
const input = document.getElementById('api-key');
const icon = document.querySelector('.toggle-visibility i');
const svgIcon = document.querySelector('.toggle-visibility .eye-icon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fas fa-eye-slash';
svgIcon.innerHTML = `
<path d="M3 3L15 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M11.4141 6.41406C12.8406 7.51393 13.9453 8.93657 14.6605 10.5658C14.6957 10.6462 14.7012 10.7373 14.6761 10.8214C14.2061 12.3998 13.2893 13.8157 12.0285 14.921C10.7677 16.0263 9.20885 16.7814 7.53077 17.0963C5.85269 17.4113 4.11977 17.2734 2.50625 16.7004C1.69972 16.4166 0.95692 16.0016 0.3125 15.475" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M1 8.5C1 8.5 4 2.5 8.5 2.5C10.5 2.5 12.2667 3.5 13.6667 4.83333" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
`;
} else {
input.type = 'password';
icon.className = 'fas fa-eye';
svgIcon.innerHTML = `
<path d="M1 9C1 9 4 3 9 3C14 3 17 9 17 9C17 9 14 15 9 15C4 15 1 9 1 9Z" stroke="currentColor" stroke-width="1.5"/>
<circle cx="9" cy="9" r="2.5" stroke="currentColor" stroke-width="1.5"/>
`;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -917,6 +917,10 @@
<option value="daily">日线</option>
</select>
</div>
<div class="form-group">
<label class="form-label">结束日期</label>
<input type="date" class="form-input" id="queryEndTime">
</div>
<div class="form-group">
<button class="btn btn-primary" onclick="queryCache()" style="height: 42px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
@ -932,6 +936,50 @@
</div>
<div id="queryResult" class="hidden" style="margin-top: 20px;"></div>
</div>
<div class="card">
<div class="card-header">
<div>
<div class="card-title">批量导出数据</div>
<div class="card-subtitle">导出所有配置品种的K线数据</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">数据类型</label>
<select class="form-select" id="batchExportDataType">
<option value="futures">期货</option>
<option value="stock">股票</option>
</select>
</div>
<div class="form-group">
<label class="form-label">周期</label>
<select class="form-select" id="batchExportPeriod">
<option value="">全部周期</option>
<option value="5min">5分钟</option>
<option value="15min">15分钟</option>
<option value="30min">30分钟</option>
<option value="60min">60分钟</option>
<option value="daily">日线</option>
</select>
</div>
<div class="form-group">
<label class="form-label">结束日期</label>
<input type="date" class="form-input" id="batchExportEndTime">
</div>
<div class="form-group">
<button class="btn btn-success" onclick="batchExportData()" style="height: 42px;" id="btnBatchExport">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
批量导出
</button>
</div>
</div>
<div id="batchExportProgress" class="hidden" style="margin-top: 16px;">
<div class="progress-bar"><div class="progress-fill" id="batchExportProgressFill" style="width: 0%"></div></div>
<p id="batchExportProgressText" style="text-align: center; color: var(--gray-500); font-size: 14px; margin-top: 8px;"></p>
</div>
<div id="batchExportResult" class="hidden" style="margin-top: 16px;"></div>
</div>
</div>
<!-- Tasks -->
@ -1176,6 +1224,42 @@
</div>
</div>
<!-- Edit Symbol Modal -->
<div class="modal-overlay" id="editSymbolModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">修改变种</div>
<button class="modal-close" onclick="closeEditSymbolModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 24px; height: 24px;">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="editSymbolType">
<input type="hidden" id="editSymbolOldName">
<input type="hidden" id="editSymbolOldCode">
<div class="form-group">
<label class="form-label">品种名称</label>
<input class="form-input" id="editSymbolName" placeholder="如: 原油">
</div>
<div class="form-group">
<label class="form-label">合约代码</label>
<input class="form-input" id="editSymbolCode" placeholder="如: SC2609">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeEditSymbolModal()">取消</button>
<button class="btn btn-primary" onclick="saveEditSymbol()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
保存
</button>
</div>
</div>
</div>
<!-- Edit Task Modal -->
<div class="modal-overlay" id="editTaskModal">
<div class="modal">
@ -1453,22 +1537,46 @@
}
}
async function editSymbol(type, oldCode, oldName) {
const newName = prompt('请输入新的品种名称:', oldName);
if (!newName || newName === oldName) return;
function openEditSymbolModal(type, code, name) {
document.getElementById('editSymbolType').value = type;
document.getElementById('editSymbolOldName').value = name;
document.getElementById('editSymbolOldCode').value = code;
document.getElementById('editSymbolName').value = name;
document.getElementById('editSymbolCode').value = code;
document.getElementById('editSymbolModal').classList.add('active');
}
const newCode = prompt('请输入新的品种代码:', oldCode);
if (!newCode) return;
function closeEditSymbolModal() {
document.getElementById('editSymbolModal').classList.remove('active');
}
const symbols = type === 'futures' ? (currentConfig.futures || {}) : (currentConfig.stock || {});
delete symbols[oldName];
symbols[newName] = newCode;
async function saveEditSymbol() {
const type = document.getElementById('editSymbolType').value;
const oldName = document.getElementById('editSymbolOldName').value;
const oldCode = document.getElementById('editSymbolOldCode').value;
const newName = document.getElementById('editSymbolName').value.trim();
const newCode = document.getElementById('editSymbolCode').value.trim();
if (!newName || !newCode) {
return showToast('请填写品种名称和合约代码', 'error');
}
// 深拷贝配置,避免直接修改 currentConfig
const fullConfig = {
futures: currentConfig.futures || {},
stock: currentConfig.stock || {}
futures: JSON.parse(JSON.stringify(currentConfig.futures || {})),
stock: JSON.parse(JSON.stringify(currentConfig.stock || {}))
};
fullConfig[type] = symbols;
const symbols = fullConfig[type];
if (newName !== oldName && symbols[newName]) {
return showToast('品种名称已存在', 'error');
}
delete symbols[oldName];
symbols[newName] = newCode;
console.log('保存配置:', JSON.stringify(fullConfig, null, 2));
try {
const res = await fetch(`${API}/config/upload`, {
@ -1477,18 +1585,25 @@
body: JSON.stringify(fullConfig),
});
const data = await res.json();
if (res.ok) {
showToast('品种修改成功', 'success');
closeEditSymbolModal();
loadConfig();
} else {
const data = await res.json();
console.error('修改失败响应:', data);
showToast(data.detail || '修改失败', 'error');
}
} catch (e) {
console.error('修改异常:', e);
showToast(`修改失败: ${e.message}`, 'error');
}
}
async function editSymbol(type, code, name) {
openEditSymbolModal(type, code, name);
}
async function deleteSymbol(type, code) {
if (!confirm('确定删除此品种?')) return;
@ -1809,21 +1924,44 @@
async function queryCache() {
const symbol = document.getElementById('querySymbol').value.trim();
const period = document.getElementById('queryPeriod').value;
const endDate = document.getElementById('queryEndTime').value;
if (!symbol) return showToast('请输入品种代码', 'error');
addLog(`查询缓存: ${symbol} ${period || '全部'}`);
addLog(`查询缓存: ${symbol} ${period || '全部'} 结束日期: ${endDate || '今天'}`);
try {
const url = period ? `${API}/data/latest/${symbol}/${period}` : `${API}/data/latest/${symbol}`;
// 构建查询参数
const params = new URLSearchParams();
if (period) params.append('period', period);
if (endDate) {
// 将日期转换为当天结束时间 23:59:59
const endDateTime = new Date(endDate);
endDateTime.setHours(23, 59, 59, 999);
params.append('end_time', endDateTime.toISOString());
}
const queryString = params.toString();
const url = `${API}/data/latest/${symbol}${queryString ? '?' + queryString : ''}`;
const res = await fetch(url);
const data = await res.json();
// 先检查响应状态
if (!res.ok) {
showToast(data.detail || '未找到缓存数据', 'error');
const errorText = await res.text();
console.error('API错误响应:', errorText);
try {
const errorData = JSON.parse(errorText);
showToast(errorData.detail || '查询失败', 'error');
} catch {
showToast(`服务器错误: ${res.status}`, 'error');
}
document.getElementById('btnExportData').disabled = true;
return;
}
const data = await res.json();
addLog(`查询成功: ${symbol}, 缓存 ${data.timeframes ? data.timeframes.length : 0} 个周期`, 'success');
currentQueryData = data;
@ -1838,6 +1976,7 @@
renderKlineChart(data.timeframes[0], symbol);
} catch (e) {
console.error('查询异常:', e);
showToast(`查询失败: ${e.message}`, 'error');
document.getElementById('btnExportData').disabled = true;
}
@ -1850,12 +1989,25 @@
}
const symbol = document.getElementById('querySymbol').value.trim() || 'unknown';
const period = document.getElementById('queryPeriod').value;
const endDate = document.getElementById('queryEndTime').value;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `${symbol}_多周期数据_${timestamp}.json`;
// 根据是否选择结束日期和周期,生成不同的文件名
let filename;
if (endDate) {
filename = `${symbol}_${period || '多周期'}_截至${endDate}_${timestamp}.json`;
} else {
const today = new Date().toISOString().slice(0, 10);
filename = `${symbol}_${period || '多周期'}_截至${today}_${timestamp}.json`;
}
const exportObj = {
symbol: currentQueryData.symbol || symbol,
type: currentQueryData.type || 'futures',
period: period || 'all',
end_date: endDate || new Date().toISOString().slice(0, 10),
current_price: currentQueryData.current_price,
timestamp: currentQueryData.timestamp || new Date().toISOString(),
timeframes: {}
@ -1884,6 +2036,163 @@
showToast(`已导出 ${periodCount} 个周期数据`, 'success');
}
// 批量导出数据
async function batchExportData() {
const dataType = document.getElementById('batchExportDataType').value;
const period = document.getElementById('batchExportPeriod').value;
const endDate = document.getElementById('batchExportEndTime').value;
// 获取所有配置的品种
try {
const res = await fetch(`${API}/config/symbols`);
const data = await res.json();
if (!data.symbols || data.symbols.length === 0) {
showToast('暂无可导出的品种,请先添加品种配置', 'error');
return;
}
// 过滤指定数据类型的品种
const symbols = data.symbols.filter(s => s.data_type === dataType);
if (symbols.length === 0) {
showToast(`暂无${dataType === 'futures' ? '期货' : '股票'}品种配置`, 'error');
return;
}
const symbolList = symbols.map(s => s.symbol);
// 显示进度条
const progressContainer = document.getElementById('batchExportProgress');
const progressFill = document.getElementById('batchExportProgressFill');
const progressText = document.getElementById('batchExportProgressText');
const resultContainer = document.getElementById('batchExportResult');
progressContainer.classList.remove('hidden');
resultContainer.classList.add('hidden');
const endDateDisplay = endDate || new Date().toISOString().slice(0, 10);
addLog(`开始批量导出: ${dataType === 'futures' ? '期货' : '股票'} ${symbolList.length} 个品种, 结束日期: ${endDateDisplay}`);
let successCount = 0;
let failedCount = 0;
let totalCandles = 0;
const failedSymbols = [];
// 逐个获取并导出数据
for (let i = 0; i < symbolList.length; i++) {
const symbol = symbolList[i];
const progress = ((i + 1) / symbolList.length * 100).toFixed(0);
progressFill.style.width = `${progress}%`;
progressText.textContent = `正在导出: ${symbol} (${i + 1}/${symbolList.length})`;
try {
// 构建查询参数
const params = new URLSearchParams();
if (period) params.append('period', period);
if (endDate) {
const endDateTime = new Date(endDate);
endDateTime.setHours(23, 59, 59, 999);
params.append('end_time', endDateTime.toISOString());
}
const queryString = params.toString();
const url = `${API}/data/latest/${symbol}${queryString ? '?' + queryString : ''}`;
const fetchRes = await fetch(url);
if (!fetchRes.ok) {
failedCount++;
failedSymbols.push(symbol);
continue;
}
const fetchData = await fetchRes.json();
if (!fetchData.timeframes || fetchData.timeframes.length === 0) {
failedCount++;
failedSymbols.push(symbol);
continue;
}
// 导出单个品种数据
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `${symbol}_${period || '多周期'}_截至${endDateDisplay}_${timestamp}.json`;
const exportObj = {
symbol: fetchData.symbol || symbol,
type: fetchData.type || dataType,
period: period || 'all',
end_date: endDateDisplay,
current_price: fetchData.current_price,
timestamp: fetchData.timestamp || new Date().toISOString(),
timeframes: {}
};
fetchData.timeframes.forEach(tf => {
exportObj.timeframes[tf.period] = tf.candles || [];
});
const jsonStr = JSON.stringify(exportObj, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const candleCount = fetchData.timeframes.reduce((sum, tf) => sum + (tf.candles ? tf.candles.length : 0), 0);
successCount++;
totalCandles += candleCount;
// 添加小延迟避免浏览器阻止多次下载
await new Promise(resolve => setTimeout(resolve, 200));
} catch (e) {
console.error(`导出 ${symbol} 失败:`, e);
failedCount++;
failedSymbols.push(symbol);
}
}
// 显示结果
progressContainer.classList.add('hidden');
resultContainer.classList.remove('hidden');
let resultHtml = `
<div style="padding: 16px; background: var(--success-light); border-radius: 8px; border-left: 4px solid var(--success);">
<h4 style="color: var(--success); margin-bottom: 12px;">批量导出完成</h4>
<div style="display: grid; gap: 8px;">
<div>成功: <strong>${successCount}</strong> 个品种</div>
<div>失败: <strong>${failedCount}</strong> 个品种</div>
<div>总计: <strong>${totalCandles}</strong> 条K线</div>
</div>
`;
if (failedSymbols.length > 0) {
resultHtml += `
<div style="margin-top: 12px; padding: 12px; background: white; border-radius: 6px;">
<div style="color: var(--danger); font-weight: 500; margin-bottom: 8px;">失败的品种:</div>
<div style="color: var(--gray-600); font-size: 13px;">${failedSymbols.join(', ')}</div>
</div>
`;
}
resultHtml += '</div>';
resultContainer.innerHTML = resultHtml;
addLog(`批量导出完成: 成功 ${successCount}, 失败 ${failedCount}, 总计 ${totalCandles} 条K线`, successCount > 0 ? 'success' : 'error');
showToast(`批量导出完成: ${successCount} 个品种成功`, successCount > 0 ? 'success' : 'error');
} catch (e) {
console.error('批量导出失败:', e);
showToast(`批量导出失败: ${e.message}`, 'error');
addLog(`批量导出失败: ${e.message}`, 'error');
}
}
function renderKlineChart(timeframe, symbol) {
const resultContainer = document.getElementById('queryResult');
@ -2889,6 +3198,15 @@
}
// Init
// 页面加载时设置默认日期为今天
function initDefaultDate() {
const today = new Date().toISOString().slice(0, 10);
document.getElementById('queryEndTime').value = today;
document.getElementById('batchExportEndTime').value = today;
}
// 初始化
initDefaultDate();
loadConfig();
loadTasks();
</script>

@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 期货智析系统</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 420px;
padding: 40px;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.login-logo {
font-size: 48px;
margin-bottom: 10px;
}
.login-title {
font-size: 28px;
font-weight: 700;
color: #1D1D1F;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 14px;
color: #86868B;
}
.form-group {
margin-bottom: 24px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 600;
color: #1D1D1F;
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #E5E5E7;
border-radius: 12px;
font-size: 15px;
transition: all 0.3s;
outline: none;
}
.form-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.login-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
margin-top: 10px;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.error-message {
background: #FFF3F3;
color: #DC2626;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
margin-bottom: 20px;
display: none;
border-left: 4px solid #DC2626;
}
.error-message.show {
display: block;
animation: shake 0.5s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
.footer {
text-align: center;
margin-top: 30px;
font-size: 12px;
color: #86868B;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<div class="login-logo"></div>
<h1 class="login-title">期货智析</h1>
<p class="login-subtitle">智能期货期权分析系统</p>
</div>
<div class="error-message" id="error-message"></div>
<form id="login-form">
<div class="form-group">
<label class="form-label" for="username">用户名</label>
<input type="text" id="username" class="form-input" placeholder="请输入用户名" required autocomplete="username">
</div>
<div class="form-group">
<label class="form-label" for="password">密码</label>
<input type="password" id="password" class="form-input" placeholder="请输入密码" required autocomplete="current-password">
</div>
<button type="submit" class="login-btn" id="login-btn">
登录
</button>
</form>
<div class="footer">
<p>© 2026 期货智析系统 · 安全登录</p>
</div>
</div>
<script>
const loginForm = document.getElementById('login-form');
const loginBtn = document.getElementById('login-btn');
const errorMessage = document.getElementById('error-message');
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
loginBtn.disabled = true;
loginBtn.innerHTML = '<span class="loading-spinner"></span>登录中...';
errorMessage.classList.remove('show');
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.success) {
// 保存令牌
localStorage.setItem('auth_token', data.token);
localStorage.setItem('user_info', JSON.stringify(data.user));
// 根据角色跳转
if (data.user.role === 'admin') {
window.location.href = '/role-select';
} else {
window.location.href = '/futures-analysis';
}
} else {
showError(data.message || '登录失败,请重试');
}
} catch (error) {
showError('网络错误,请检查连接后重试');
} finally {
loginBtn.disabled = false;
loginBtn.innerHTML = '登录';
}
});
function showError(message) {
errorMessage.textContent = message;
errorMessage.classList.add('show');
setTimeout(() => {
errorMessage.classList.remove('show');
}, 5000);
}
// 检查是否已登录
window.addEventListener('DOMContentLoaded', async () => {
const token = localStorage.getItem('auth_token');
if (token) {
try {
const response = await fetch('/api/v1/auth/verify', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
if (data.user.role === 'admin') {
window.location.href = '/role-select';
} else {
window.location.href = '/futures-analysis';
}
}
} catch (error) {
// 令牌无效,继续显示登录页
localStorage.removeItem('auth_token');
localStorage.removeItem('user_info');
}
}
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>复盘计划 - 期货智析</title>
<link rel="stylesheet" href="/static/review_plan.css">
</head>
<body>
<!-- 顶部导航栏 -->
<header class="header">
<div class="logo">期货智析 - 复盘计划</div>
<nav class="nav-items">
<a href="/futures-analysis" class="nav-item">返回分析页面</a>
</nav>
</header>
<!-- 主容器 -->
<main class="container">
<!-- 工具栏 -->
<div class="review-toolbar">
<input type="date" id="date-selector" class="date-selector" />
<button id="btn-generate" class="btn btn-primary">
<span class="btn-icon">📊</span>
<span class="btn-text">复盘与计划</span>
</button>
<button id="btn-clear" class="btn btn-secondary">
<span class="btn-icon">🗑️</span>
<span class="btn-text">清除数据</span>
</button>
</div>
<!-- 加载指示器 -->
<div id="loading-indicator" class="loading-indicator" style="display: none;">
<div class="spinner"></div>
<p class="loading-text">正在生成复盘计划,请稍候...</p>
</div>
<!-- 内容区域 -->
<div id="content-area" class="content-area" style="display: none;">
<!-- Hero 区域 -->
<section id="hero-section" class="hero-section">
<!-- 由 JS 动态填充 -->
</section>
<!-- 统计卡片网格 -->
<section id="stats-grid" class="stats-grid">
<!-- 由 JS 动态填充核心结论、Top3机会、市场概览(多/空/中性数量) -->
</section>
<!-- 风险提示横幅 -->
<section id="risk-banner" class="risk-banner">
<div class="risk-banner-header">
<span class="risk-icon">⚠️</span>
<span class="risk-title">风险提示</span>
</div>
<ul id="risk-list" class="risk-list">
<!-- 由 JS 动态填充 -->
</ul>
</section>
<!-- 粘性导航条 -->
<nav class="sticky-nav" id="sticky-nav">
<a href="#hero-section" class="sticky-nav-link">顶部</a>
<a href="#green-section" class="sticky-nav-link">交易机会</a>
<a href="#yellow-section" class="sticky-nav-link">重点关注</a>
<a href="#red-section" class="sticky-nav-link">规避</a>
<a href="#table-section" class="sticky-nav-link">全品种排名</a>
<a href="#details-section" class="sticky-nav-link">品种详情</a>
<a href="#sectors-section" class="sticky-nav-link">板块热度</a>
</nav>
<!-- 交易机会品种(绿色区域) -->
<section id="green-section" class="content-section green-section">
<div class="section-header">
<span class="section-badge badge-green"></span>
<h2 class="section-title">交易机会品种</h2>
</div>
<div id="green-grid" class="card-grid">
<!-- 由 JS 动态填充 -->
</div>
</section>
<!-- 重点关注品种(黄色区域) -->
<section id="yellow-section" class="content-section yellow-section">
<div class="section-header">
<span class="section-badge badge-yellow"></span>
<h2 class="section-title">重点关注品种</h2>
</div>
<div class="list-table-wrap">
<table class="list-table yellow-table">
<thead>
<tr>
<th>#</th>
<th>品种</th>
<th>收盘价</th>
<th>涨跌幅</th>
<th>综合评分</th>
<th>振幅%</th>
<th>量比</th>
<th>方向</th>
<th>数据日期</th>
</tr>
</thead>
<tbody id="yellow-list-body">
<!-- 由 JS 动态填充 -->
</tbody>
</table>
</div>
</section>
<!-- 规避品种(红色区域) -->
<section id="red-section" class="content-section red-section">
<div class="section-header">
<span class="section-badge badge-red"></span>
<h2 class="section-title">规避品种</h2>
</div>
<div class="list-table-wrap">
<table class="list-table red-table">
<thead>
<tr>
<th>#</th>
<th>品种</th>
<th>收盘价</th>
<th>涨跌幅</th>
<th>综合评分</th>
<th>振幅%</th>
<th>量比</th>
<th>方向</th>
<th>数据日期</th>
</tr>
</thead>
<tbody id="red-list-body">
<!-- 由 JS 动态填充 -->
</tbody>
</table>
</div>
</section>
<!-- 全品种排名表格 -->
<section id="table-section" class="content-section table-section">
<div class="section-header">
<h2 class="section-title">全品种排名</h2>
<div class="sort-controls" id="sort-controls">
<span class="sort-label">排序:</span>
<button class="sort-btn active" data-sort="composite_score" data-order="desc">综合评分</button>
<button class="sort-btn" data-sort="activity_score" data-order="desc">活跃度</button>
<button class="sort-btn" data-sort="volume_ratio" data-order="desc">量比</button>
<button class="sort-btn" data-sort="direction_tag" data-order="asc">方向</button>
</div>
</div>
<div class="table-wrapper">
<table id="ranking-table" class="ranking-table">
<thead>
<tr>
<th>排名</th>
<th>品种</th>
<th>最新价</th>
<th>涨跌幅</th>
<th>综合评分</th>
<th>振幅%</th>
<th>量比</th>
<th>趋势</th>
<th>方向</th>
<th>活跃度</th>
<th>数据日期</th>
</tr>
</thead>
<tbody>
<!-- 由 JS 动态填充 -->
</tbody>
</table>
</div>
</section>
<!-- 品种详情卡片 -->
<section id="details-section" class="content-section details-section">
<div class="section-header">
<h2 class="section-title">品种详情</h2>
</div>
<div id="details-grid" class="card-grid">
<!-- 由 JS 动态填充 -->
</div>
</section>
<!-- 板块热度图 -->
<section id="sectors-section" class="content-section sectors-section">
<div class="section-header">
<h2 class="section-title">板块热度</h2>
</div>
<div id="sectors-grid" class="sectors-grid">
<!-- 由 JS 动态填充 -->
</div>
</section>
<!-- 页脚免责声明 -->
<footer class="footer-disclaimer">
<p>⚠️ 免责声明:以上内容均由 AI 基于多维因子自动生成,仅供学习参考,不构成任何投资建议。投资有风险,入市需谨慎。</p>
</footer>
</div>
<!-- 空状态(默认显示) -->
<div id="empty-state" class="empty-state">
<div class="empty-icon">📊</div>
<p class="empty-title">暂无复盘数据</p>
<p class="empty-text">请选择复盘日期,然后点击"复盘与计划"按钮生成数据</p>
</div>
</main>
<!-- 品种详情弹窗 -->
<div id="detail-modal" class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal">
<div class="modal-header">
<span id="modal-title" class="modal-title">品种详情</span>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div id="modal-body" class="modal-body">
<!-- 由 JS 动态填充 -->
</div>
</div>
</div>
<script src="/static/review_plan.js"></script>
</body>
</html>

@ -0,0 +1,741 @@
const API_BASE = '/api/v1/review';
const DATA_API_BASE = '/api/v1/data';
let currentPlanId = null;
let currentPlanData = null;
// ==================== 初始化 ====================
document.addEventListener('DOMContentLoaded', function () {
setupEventListeners();
loadLatestPlan();
});
function setupEventListeners() {
document.getElementById('date-selector').addEventListener('change', function (e) {
const selectedDate = e.target.value;
if (selectedDate) {
loadPlanByDate(selectedDate);
}
});
document.getElementById('btn-generate').addEventListener('click', function () {
handleGenerate();
});
document.getElementById('btn-clear').addEventListener('click', function () {
handleClear();
});
setupSortButtons();
}
// ==================== 生成复盘 ====================
function handleGenerate() {
const dateSelector = document.getElementById('date-selector');
let reviewDate = dateSelector.value;
if (!reviewDate) {
// 未选日期时默认使用当天
reviewDate = new Date().toISOString().split('T')[0];
}
doGenerate(reviewDate);
}
function doGenerate(dateStr) {
showLoading();
fetch(`${API_BASE}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review_date: dateStr })
})
.then(res => res.json())
.then(result => {
hideLoading();
if (result.success) {
const s = result.data.summary;
alert(
`复盘与计划生成成功!\n` +
`日期: ${s.review_date} ${s.week_day}\n` +
`品种数: ${s.symbol_count}\n` +
`交易机会: ${s.opportunity_count}`
);
loadLatestPlan();
} else {
alert('生成失败: ' + (result.message || '未知错误'));
}
})
.catch(err => {
hideLoading();
console.error('生成复盘计划失败:', err);
alert('生成失败,请稍后重试');
});
}
// ==================== 清除数据 ====================
function handleClear() {
if (!confirm('确定要清除所有复盘与交易计划数据吗?此操作不可恢复。')) return;
fetch(`${API_BASE}/v2/clear`, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
if (data.success) {
alert('复盘数据已清除');
document.getElementById('date-selector').value = '';
currentPlanId = null;
currentPlanData = null;
hideAllSections();
showSection('empty-state');
loadLatestPlan();
} else {
alert(data.message || '清除失败');
}
})
.catch(err => {
console.error('清除数据失败:', err);
alert('清除失败,请稍后重试');
});
}
// ==================== 加载数据 ====================
function loadLatestPlan() {
fetch(`${API_BASE}/v2/plans?limit=1`)
.then(res => res.json())
.then(data => {
if (data.success && data.data && data.data.length > 0) {
const plan = data.data[0];
const dateSelector = document.getElementById('date-selector');
if (dateSelector && plan.review_date) {
dateSelector.value = plan.review_date;
}
loadAndRender(plan.id);
} else {
hideAllSections();
showSection('empty-state');
}
})
.catch(err => {
console.error('加载最新计划失败:', err);
});
}
function loadPlanByDate(date) {
showLoading();
fetch(`${API_BASE}/v2/plan/by-date/${date}`)
.then(res => res.json())
.then(data => {
hideLoading();
if (data.success && data.data) {
currentPlanId = data.data.meta.id;
currentPlanData = data.data;
renderAll(data.data);
} else {
hideAllSections();
showSection('empty-state');
}
})
.catch(err => {
hideLoading();
console.error('按日期加载计划失败:', err);
});
}
function loadAndRender(planId) {
showLoading();
fetch(`${API_BASE}/v2/plan/${planId}`)
.then(res => res.json())
.then(result => {
hideLoading();
if (result.success && result.data) {
currentPlanId = planId;
currentPlanData = result.data;
renderAll(result.data);
} else {
alert('加载计划详情失败');
}
})
.catch(err => {
hideLoading();
console.error('加载计划详情失败:', err);
alert('加载失败,请稍后重试');
});
}
// ==================== 渲染总入口 ====================
function renderAll(data) {
// 显示内容区域
const contentArea = document.getElementById('content-area');
if (contentArea) contentArea.style.display = '';
hideSection('empty-state');
const { meta, scores, plans, sectors } = data;
renderHero(meta);
renderStats(meta, scores);
renderRisk(meta);
renderGreenSection(plans, scores);
renderYellowSection(scores);
renderRedSection(scores);
renderRankingTable(scores);
renderDetails(scores);
renderSectors(sectors);
}
// ==================== Hero 区域 ====================
function renderHero(meta) {
const el = document.getElementById('hero-section');
if (!el) return;
// 当数据日期与复盘日期不一致时,显示提示
let dataDateNotice = '';
if (meta.actual_data_date && !meta.data_date_matches) {
dataDateNotice = `<div class="data-date-notice">⚠ 当前页面收盘价、涨跌幅为 <b>${meta.actual_data_date}</b>(最近交易日)数据,非 ${meta.review_date} 当日数据</div>`;
}
el.innerHTML = `
<div class="hero-title">
<h1>${meta.review_date} <span class="hero-weekday">${meta.week_day || ''}</span></h1>
</div>
<div class="hero-subtitle">${meta.data_basis || ''}</div>
${dataDateNotice}
<div class="hero-conclusion">${meta.core_conclusion || ''}</div>
`;
showSection('hero-section');
}
// ==================== 统计卡片 ====================
function renderStats(meta, scores) {
const el = document.getElementById('stats-grid');
if (!el) return;
const top3 = (scores || []).slice(0, 3);
const top3Html = top3.map((s, i) =>
`<span class="stat-top-item">${i + 1}. ${s.symbol} ${s.name || ''} <b>${s.composite_score}</b></span>`
).join('');
const bullCount = meta.bull_count || 0;
const bearCount = meta.bear_count || 0;
const neutralCount = meta.neutral_count || 0;
const oppCount = meta.opportunity_count || 0;
el.innerHTML = `
<div class="stat-card">
<div class="stat-card-label">核心结论</div>
<div class="stat-card-value">${meta.core_conclusion || '-'}</div>
</div>
<div class="stat-card">
<div class="stat-card-label">综合评分 Top 3</div>
<div class="stat-card-value stat-top-list">${top3Html || '暂无'}</div>
</div>
<div class="stat-card">
<div class="stat-card-label">市场概览</div>
<div class="stat-card-value stat-overview">
<span class="stat-bull">多头 <b>${bullCount}</b></span>
<span class="stat-bear">空头 <b>${bearCount}</b></span>
<span class="stat-neutral">中性 <b>${neutralCount}</b></span>
<span class="stat-opp">机会 <b>${oppCount}</b></span>
</div>
</div>
`;
showSection('stats-grid');
}
// ==================== 风险提示 ====================
function renderRisk(meta) {
const el = document.getElementById('risk-banner');
if (!el) return;
const warnings = meta.risk_warnings || [];
if (warnings.length === 0) {
el.innerHTML = `<div class="risk-title">⚠️ 风险提示</div><div class="risk-list">暂无特别风险提示</div>`;
} else {
const items = warnings.map(w => `<li class="risk-item">${w}</li>`).join('');
el.innerHTML = `
<div class="risk-title"> 风险提示</div>
<ul class="risk-list">${items}</ul>
`;
}
showSection('risk-banner');
}
// ==================== 绿色机会区 ====================
function renderGreenSection(plans, scores) {
const el = document.getElementById('green-section');
if (!el) return;
const grid = document.getElementById('green-grid');
if (!grid) return;
if (!plans || plans.length === 0) {
grid.innerHTML = `<div class="empty-hint">暂无交易机会</div>`;
showSection('green-section');
return;
}
const scoreMap = {};
(scores || []).forEach(s => { scoreMap[s.symbol] = s; });
const cards = plans.map(plan => {
const s = scoreMap[plan.symbol] || {};
const dirClass = plan.direction === 'long' ? 'dir-long' : 'dir-short';
const dirText = plan.direction === 'long' ? '做多' : '做空';
const dataDateTag = s.data_date ? `<span class="data-date-tag" title="数据日期">${s.data_date}</span>` : '';
return `
<div class="opp-card green-card" onclick="showDetail('${plan.symbol}','${(plan.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<div class="opp-card-header">
<span class="symbol-link">${plan.symbol} ${plan.name || ''}</span>
<span class="score-badge">${plan.composite_score}</span>
</div>
<div class="opp-card-rows">
<div class="opp-row"><span class="opp-label">方向</span><span class="${dirClass}">${dirText}</span></div>
<div class="opp-row"><span class="opp-label">收盘价</span><span>${formatPrice(s.close_price)} ${dataDateTag}</span></div>
<div class="opp-row"><span class="opp-label">涨跌幅</span>${formatChange(s.change_pct)}</div>
<div class="opp-row"><span class="opp-label">量比</span><span>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div>
</div>
<div class="score-bars">
<div class="score-bar-row"><span class="score-bar-label">振幅</span>${scoreBar(plan.amplitude_score)}</div>
<div class="score-bar-row"><span class="score-bar-label">量能</span>${scoreBar(plan.volume_score)}</div>
<div class="score-bar-row"><span class="score-bar-label">趋势</span>${scoreBar(plan.trend_score)}</div>
<div class="score-bar-row"><span class="score-bar-label">活跃度</span>${scoreBar(plan.activity_score)}</div>
</div>
<div class="plan-box">
<div class="plan-box-row"><span class="plan-box-label">入场区间</span><span class="plan-box-val">${formatPrice(plan.entry_low)} - ${formatPrice(plan.entry_high)}</span></div>
<div class="plan-box-row"><span class="plan-box-label">止损</span><span class="plan-box-val stop">${formatPrice(plan.stop_loss)}</span></div>
<div class="plan-box-row"><span class="plan-box-label">目标1</span><span class="plan-box-val target">${formatPrice(plan.target1)}</span></div>
<div class="plan-box-row"><span class="plan-box-label">目标2</span><span class="plan-box-val target">${formatPrice(plan.target2)}</span></div>
<div class="plan-box-row"><span class="plan-box-label">触发条件</span><span class="plan-box-val trigger">${plan.trigger || '-'}</span></div>
</div>
</div>`;
}).join('');
grid.innerHTML = cards;
showSection('green-section');
}
// ==================== 黄色关注区 ====================
function renderYellowSection(scores) {
const tbody = document.getElementById('yellow-list-body');
if (!tbody) return;
const yellowItems = (scores || []).filter(s => s.category === 'yellow');
if (yellowItems.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" class="empty-hint">暂无关注品种</td></tr>`;
showSection('yellow-section');
return;
}
tbody.innerHTML = yellowItems.map((s, i) => `
<tr onclick="showDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<td>${i + 1}</td>
<td><span class="symbol-link">${s.symbol} ${s.name || ''}</span></td>
<td>${formatPrice(s.close_price)}</td>
<td>${formatChange(s.change_pct)}</td>
<td><div class="table-score-bar-wrap">${scoreBar(s.composite_score)}<span class="table-score-num">${s.composite_score}</span></div></td>
<td>${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</td>
<td>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</td>
<td>${directionTag(s.direction_tag)}</td>
<td class="data-date-cell">${s.data_date || '-'}</td>
</tr>
`).join('');
showSection('yellow-section');
}
// ==================== 红色风险区 ====================
function renderRedSection(scores) {
const tbody = document.getElementById('red-list-body');
if (!tbody) return;
const redItems = (scores || []).filter(s => s.category === 'red');
if (redItems.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" class="empty-hint">暂无规避品种</td></tr>`;
showSection('red-section');
return;
}
tbody.innerHTML = redItems.map((s, i) => `
<tr onclick="showDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<td>${i + 1}</td>
<td><span class="symbol-link">${s.symbol} ${s.name || ''}</span></td>
<td>${formatPrice(s.close_price)}</td>
<td>${formatChange(s.change_pct)}</td>
<td><div class="table-score-bar-wrap">${scoreBar(s.composite_score)}<span class="table-score-num">${s.composite_score}</span></div></td>
<td>${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</td>
<td>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</td>
<td>${directionTag(s.direction_tag)}</td>
<td class="data-date-cell">${s.data_date || '-'}</td>
</tr>
`).join('');
showSection('red-section');
}
// ==================== 排名表格(支持多列排序) ====================
let currentSortField = 'composite_score';
let currentSortOrder = 'desc';
function renderRankingTable(scores) {
const table = document.getElementById('ranking-table');
if (!table) return;
const tbody = table.querySelector('tbody');
if (!tbody) return;
if (!scores || scores.length === 0) {
tbody.innerHTML = `<tr><td colspan="11" class="empty-hint">暂无排名数据</td></tr>`;
showSection('table-section');
return;
}
// 排序
const sorted = [...scores].sort((a, b) => {
let va = a[currentSortField];
let vb = b[currentSortField];
// 方向排序特殊处理
if (currentSortField === 'direction_tag') {
const dirOrder = { '强多': 1, '偏多': 2, '多头共振': 1, '震荡': 3, '偏空': 4, '强空': 5, '空头共振': 5 };
va = dirOrder[va] || 3;
vb = dirOrder[vb] || 3;
}
if (va == null) va = -Infinity;
if (vb == null) vb = -Infinity;
if (currentSortOrder === 'asc') return va > vb ? 1 : va < vb ? -1 : 0;
return va < vb ? 1 : va > vb ? -1 : 0;
});
tbody.innerHTML = sorted.map((s, i) => {
const rank = i + 1;
const rankClass = rank <= 3 ? 'rank-top' : '';
return `
<tr onclick="showDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<td class="${rankClass}">${rank}</td>
<td><span class="symbol-link">${s.symbol} ${s.name || ''}</span></td>
<td>${formatPrice(s.close_price)}</td>
<td>${formatChange(s.change_pct)}</td>
<td><div class="table-score-bar-wrap">${scoreBar(s.composite_score)}<span class="table-score-num">${s.composite_score}</span></div></td>
<td>${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</td>
<td>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</td>
<td>${s.trend_score != null ? s.trend_score : '-'}</td>
<td>${directionTag(s.direction_tag)}</td>
<td>${s.activity_score != null ? s.activity_score : '-'}</td>
<td class="data-date-cell">${s.data_date || '-'}</td>
</tr>`;
}).join('');
showSection('table-section');
}
function setupSortButtons() {
const controls = document.getElementById('sort-controls');
if (!controls) return;
controls.addEventListener('click', function (e) {
const btn = e.target.closest('.sort-btn');
if (!btn) return;
const field = btn.dataset.sort;
const defaultOrder = btn.dataset.order || 'desc';
// 切换排序方向
if (currentSortField === field) {
currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc';
} else {
currentSortField = field;
currentSortOrder = defaultOrder;
}
// 更新按钮状态
controls.querySelectorAll('.sort-btn').forEach(b => {
b.classList.remove('active');
b.textContent = b.textContent.replace(/ [▲▼]$/, '');
});
btn.classList.add('active');
const arrow = currentSortOrder === 'desc' ? ' ▼' : ' ▲';
btn.textContent = btn.textContent + arrow;
// 重新渲染
if (currentPlanData) {
renderRankingTable(currentPlanData.scores);
}
});
}
// ==================== 品种详情卡片 ====================
function renderDetails(scores) {
const el = document.getElementById('details-section');
if (!el) return;
if (!scores || scores.length === 0) {
el.innerHTML = `<div class="section-title">📋 品种详情</div><div class="empty-hint">暂无详情数据</div>`;
showSection('details-section');
return;
}
const cards = scores.map(s => {
const pivotTags = [];
if (s.pivot != null) pivotTags.push(`<span class="pivot-tag pivot">Pivot ${formatPrice(s.pivot)}</span>`);
if (s.r1 != null) pivotTags.push(`<span class="pivot-tag r1">R1 ${formatPrice(s.r1)}</span>`);
if (s.r2 != null) pivotTags.push(`<span class="pivot-tag r2">R2 ${formatPrice(s.r2)}</span>`);
if (s.s1 != null) pivotTags.push(`<span class="pivot-tag s1">S1 ${formatPrice(s.s1)}</span>`);
if (s.s2 != null) pivotTags.push(`<span class="pivot-tag s2">S2 ${formatPrice(s.s2)}</span>`);
return `
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-symbol">${s.symbol} ${s.name || ''}</span>
<span class="detail-score">${s.composite_score}</span>
</div>
<div class="detail-metrics">
<div class="detail-metric"><span class="dm-label">收盘价</span><span class="dm-val">${formatPrice(s.close_price)}${s.data_date ? ' <span class="data-date-tag" title="">' + s.data_date + '</span>' : ''}</span></div>
<div class="detail-metric"><span class="dm-label">振幅</span><span class="dm-val">${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">涨跌幅</span><span class="dm-val">${formatChange(s.change_pct)}</span></div>
<div class="detail-metric"><span class="dm-label">量比</span><span class="dm-val">${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">振幅评分</span><span class="dm-val">${s.amplitude_score != null ? s.amplitude_score : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">量能评分</span><span class="dm-val">${s.volume_score != null ? s.volume_score : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">趋势评分</span><span class="dm-val">${s.trend_score != null ? s.trend_score : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">活跃度</span><span class="dm-val">${s.activity_score != null ? s.activity_score : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">60m趋势</span><span class="dm-val">${s.trend_60m != null ? s.trend_60m : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">15m趋势</span><span class="dm-val">${s.trend_15m != null ? s.trend_15m : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">5m趋势</span><span class="dm-val">${s.trend_5m != null ? s.trend_5m : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">方向</span><span class="dm-val">${directionTag(s.direction_tag)}</span></div>
</div>
<div class="detail-pivots">${pivotTags.join('')}</div>
</div>`;
}).join('');
el.innerHTML = `
<div class="section-title">📋 品种详情</div>
<div class="detail-grid">${cards}</div>
`;
showSection('details-section');
}
// ==================== 板块热力 ====================
function renderSectors(sectors) {
const el = document.getElementById('sectors-section');
if (!el) return;
if (!sectors || sectors.length === 0) {
el.innerHTML = `<div class="section-title">🔥 板块热力</div><div class="empty-hint">暂无板块数据</div>`;
showSection('sectors-section');
return;
}
const cards = sectors.map(sec => {
const heatLevel = sec.heat_level || 0;
const fireIcons = '🔥'.repeat(Math.min(heatLevel, 5));
const iceIcons = heatLevel <= 1 ? '🧊' : '';
const heatIndicator = heatLevel >= 3 ? fireIcons : (heatLevel <= 1 ? iceIcons : fireIcons);
const members = (sec.members || []).map(m =>
`<span class="sector-member">${m.symbol} ${m.name || ''} <b>${m.score}</b></span>`
).join('');
return `
<div class="sector-card heat-${heatLevel}">
<div class="sector-header">
<span class="sector-name">${sec.sector_name}</span>
<span class="sector-heat">${heatIndicator}</span>
</div>
<div class="sector-stats">
<div class="sector-stat"><span class="ss-label">均分</span><span class="ss-val">${sec.avg_score != null ? sec.avg_score.toFixed(1) : '-'}</span></div>
<div class="sector-stat"><span class="ss-label">方向</span><span class="ss-val">${sec.direction || '-'}</span></div>
<div class="sector-stat"><span class="ss-label">领涨</span><span class="ss-val">${sec.leader_symbol || '-'} ${sec.leader_score != null ? sec.leader_score.toFixed(1) : ''}</span></div>
</div>
<div class="sector-members">${members || '暂无成员'}</div>
</div>`;
}).join('');
el.innerHTML = `
<div class="section-title">🔥 板块热力</div>
<div class="sector-grid">${cards}</div>
`;
showSection('sectors-section');
}
// ==================== 品种详情弹窗 ====================
function showDetail(symbol, name) {
const scores = currentPlanData ? currentPlanData.scores : [];
const plans = currentPlanData ? currentPlanData.plans : [];
const s = scores.find(x => x.symbol === symbol);
if (!s) return;
// 查找对应的交易计划
const plan = plans.find(p => p.symbol === symbol);
const modal = document.getElementById('detail-modal');
if (!modal) return;
document.getElementById('modal-title').textContent = `${symbol} ${name || s.name || ''}`;
const body = document.getElementById('modal-body');
// 交易计划部分(仅绿色品种有)
let planHtml = '';
if (plan) {
const dirClass = plan.direction === 'long' ? 'dir-long' : 'dir-short';
const dirText = plan.direction === 'long' ? '做多' : '做空';
planHtml = `
<div class="modal-section">
<div class="modal-section-title">交易计划</div>
<div class="modal-plan-box">
<div class="modal-plan-row"><span class="mp-label">方向</span><span class="${dirClass} mp-val">${dirText}</span></div>
<div class="modal-plan-row"><span class="mp-label">入场区间</span><span class="mp-val">${formatPrice(plan.entry_low)} - ${formatPrice(plan.entry_high)}</span></div>
<div class="modal-plan-row"><span class="mp-label">止损</span><span class="mp-val stop">${formatPrice(plan.stop_loss)}</span></div>
<div class="modal-plan-row"><span class="mp-label">目标1</span><span class="mp-val target">${formatPrice(plan.target1)}</span></div>
<div class="modal-plan-row"><span class="mp-label">目标2</span><span class="mp-val target">${formatPrice(plan.target2)}</span></div>
<div class="modal-plan-row"><span class="mp-label">触发条件</span><span class="mp-val">${plan.trigger || '-'}</span></div>
</div>
</div>`;
}
const pivotTags = [];
if (s.pivot != null) pivotTags.push(`<span class="pivot-tag pivot">Pivot ${formatPrice(s.pivot)}</span>`);
if (s.r1 != null) pivotTags.push(`<span class="pivot-tag r1">R1 ${formatPrice(s.r1)}</span>`);
if (s.r2 != null) pivotTags.push(`<span class="pivot-tag r2">R2 ${formatPrice(s.r2)}</span>`);
if (s.s1 != null) pivotTags.push(`<span class="pivot-tag s1">S1 ${formatPrice(s.s1)}</span>`);
if (s.s2 != null) pivotTags.push(`<span class="pivot-tag s2">S2 ${formatPrice(s.s2)}</span>`);
body.innerHTML = `
<div class="modal-section">
<div class="modal-section-title">基本信息</div>
<div class="modal-metrics-grid">
<div class="modal-metric"><span class="mm-label">收盘价</span><span class="mm-val">${formatPrice(s.close_price)}${s.data_date ? ' <span class="data-date-tag">' + s.data_date + '</span>' : ''}</span></div>
<div class="modal-metric"><span class="mm-label">振幅</span><span class="mm-val">${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">涨跌幅</span><span class="mm-val">${formatChange(s.change_pct)}</span></div>
<div class="modal-metric"><span class="mm-label">量比</span><span class="mm-val">${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">综合评分</span><span class="mm-val"><b>${s.composite_score}</b></span></div>
<div class="modal-metric"><span class="mm-label">排名</span><span class="mm-val">#${s.rank || '-'}</span></div>
</div>
</div>
${planHtml}
<div class="modal-section">
<div class="modal-section-title">评分明细</div>
<div class="modal-metrics-grid">
<div class="modal-metric"><span class="mm-label">振幅评分</span><span class="mm-val">${s.amplitude_score}</span></div>
<div class="modal-metric"><span class="mm-label">量能评分</span><span class="mm-val">${s.volume_score}</span></div>
<div class="modal-metric"><span class="mm-label">趋势评分</span><span class="mm-val">${s.trend_score}</span></div>
<div class="modal-metric"><span class="mm-label">活跃度</span><span class="mm-val">${s.activity_score}</span></div>
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">多周期趋势</div>
<div class="modal-metrics-grid">
<div class="modal-metric"><span class="mm-label">60分钟</span><span class="mm-val">${s.trend_60m != null ? s.trend_60m : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">15分钟</span><span class="mm-val">${s.trend_15m != null ? s.trend_15m : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">5分钟</span><span class="mm-val">${s.trend_5m != null ? s.trend_5m : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">方向</span><span class="mm-val">${directionTag(s.direction_tag)}</span></div>
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">枢轴点位</div>
<div class="modal-pivots">${pivotTags.join('')}</div>
</div>
`;
modal.classList.add('show');
}
function closeModal() {
const modal = document.getElementById('detail-modal');
if (modal) modal.classList.remove('show');
}
// ==================== 工具函数 ====================
function scoreBarColor(score) {
if (score > 70) return 'var(--gn)';
if (score > 40) return 'var(--ac)';
if (score > 20) return 'var(--yw)';
return 'var(--rd)';
}
function scoreBar(score) {
const color = scoreBarColor(score);
const pct = Math.min(Math.max(score, 0), 100);
return `<div class="score-bar"><div class="score-bar-fill" style="width:${pct}%;background:${color}"></div><span class="score-bar-text">${score}</span></div>`;
}
function directionTag(tag) {
if (!tag) return '<span class="dir-badge">-</span>';
let cls = 'dir-neutral';
if (tag.includes('强多') || tag.includes('多头共振')) cls = 'dir-strong-long';
else if (tag.includes('多')) cls = 'dir-long';
else if (tag.includes('强空') || tag.includes('空头共振')) cls = 'dir-strong-short';
else if (tag.includes('空')) cls = 'dir-short';
return `<span class="dir-badge ${cls}">${tag}</span>`;
}
function formatPrice(price) {
if (price == null || isNaN(price)) return '-';
return Number(price).toFixed(2);
}
function formatChange(pct) {
if (pct == null || isNaN(pct)) return '<span class="change-neutral">-</span>';
const val = Number(pct);
const sign = val > 0 ? '+' : '';
const cls = val > 0 ? 'change-up' : (val < 0 ? 'change-down' : 'change-neutral');
return `<span class="${cls}">${sign}${val.toFixed(2)}%</span>`;
}
function showSection(id) {
const el = document.getElementById(id);
if (el) el.style.display = '';
}
function hideSection(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
function hideAllSections() {
const contentArea = document.getElementById('content-area');
if (contentArea) contentArea.style.display = 'none';
const ids = [
'hero-section', 'stats-grid', 'risk-banner',
'green-section', 'yellow-section', 'red-section',
'table-section', 'details-section', 'sectors-section',
'summary-content'
];
ids.forEach(id => hideSection(id));
}
function showLoading() {
let overlay = document.getElementById('loading-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'loading-overlay';
overlay.innerHTML = '<div class="loading-spinner">⏳ 加载中...</div>';
document.body.appendChild(overlay);
}
overlay.style.display = 'flex';
}
function hideLoading() {
const overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'none';
}

@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>选择入口 - 期货智析系统</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
text-align: center;
max-width: 800px;
width: 100%;
}
.header {
color: white;
margin-bottom: 50px;
}
.header h1 {
font-size: 36px;
font-weight: 700;
margin-bottom: 10px;
}
.header p {
font-size: 16px;
opacity: 0.9;
}
.user-info {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 12px 24px;
display: inline-block;
margin-top: 15px;
color: white;
font-size: 14px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
margin-bottom: 40px;
}
.card {
background: white;
border-radius: 20px;
padding: 40px 30px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
text-decoration: none;
color: inherit;
display: block;
}
.card:hover {
transform: translateY(-10px);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
.card-icon {
font-size: 64px;
margin-bottom: 20px;
}
.card-title {
font-size: 24px;
font-weight: 700;
color: #1D1D1F;
margin-bottom: 12px;
}
.card-description {
font-size: 14px;
color: #86868B;
line-height: 1.6;
}
.card-badge {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 6px 16px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
margin-top: 15px;
}
.logout-btn {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 12px 32px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>◈ 欢迎使用期货智析系统</h1>
<p>请选择您要进入的模块</p>
<div class="user-info">
👤 <span id="username">管理员</span> · 管理员权限
</div>
</div>
<div class="cards">
<a href="/futures-analysis" class="card">
<div class="card-icon">📊</div>
<div class="card-title">品种分析</div>
<div class="card-description">
智能期货品种分析工具提供K线图表、技术指标、AI智能分析等功能帮助您做出更明智的交易决策。
</div>
<div class="card-badge">进入分析 →</div>
</a>
<a href="/ui" class="card">
<div class="card-icon">⚙️</div>
<div class="card-title">系统管理</div>
<div class="card-description">
管理系统配置、品种设置、定时任务、数据源配置等后台管理功能。
</div>
<div class="card-badge">进入管理 →</div>
</a>
</div>
<button class="logout-btn" onclick="logout()">退出登录</button>
</div>
<script>
// 验证登录状态
window.addEventListener('DOMContentLoaded', async () => {
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/login';
return;
}
try {
const response = await fetch('/api/v1/auth/verify', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
window.location.href = '/login';
return;
}
const data = await response.json();
document.getElementById('username').textContent = data.user.username;
if (data.user.role !== 'admin') {
window.location.href = '/futures-analysis';
}
} catch (error) {
window.location.href = '/login';
}
});
async function logout() {
const token = localStorage.getItem('auth_token');
if (token) {
try {
await fetch('/api/v1/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
} catch (error) {
console.error('登出失败:', error);
}
}
localStorage.removeItem('auth_token');
localStorage.removeItem('user_info');
window.location.href = '/login';
}
</script>
</body>
</html>

@ -0,0 +1,38 @@
"""
用户权限系统 - 数据库模型
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from app.database import Base
class User(Base):
"""用户表"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
email = Column(String(100), unique=True, nullable=True)
role = Column(String(20), nullable=False, default='user') # 'admin' 或 'user'
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True)
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}', role='{self.role}')>"
class Session(Base):
"""用户会话表"""
__tablename__ = "sessions"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, nullable=False, index=True)
token = Column(String(255), unique=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=False)
is_valid = Column(Boolean, default=True)
def __repr__(self):
return f"<Session(id={self.id}, user_id={self.user_id}, token='{self.token}')>"

@ -0,0 +1,41 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
echo ========================================
echo 数据库备份工具
echo ========================================
echo.
REM 生成备份文件名
set BACKUP_DATE=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
set BACKUP_DATE=%BACKUP_DATE: =0%
set BACKUP_FILE=buffer_backup_%BACKUP_DATE%.db
echo 备份文件: %BACKUP_FILE%
echo.
REM 停止服务
echo [1/3] 停止服务...
docker-compose stop
echo ✓ 服务已停止
REM 备份数据库
echo [2/3] 备份数据库...
if exist "E:\docker_workspace\futures_datas\buffer.db" (
copy "E:\docker_workspace\futures_datas\buffer.db" "E:\docker_workspace\futures_datas\%BACKUP_FILE%"
echo ✓ 数据库已备份到: E:\docker_workspace\futures_datas\%BACKUP_FILE%
) else (
echo ✗ 数据库文件不存在
)
REM 启动服务
echo [3/3] 启动服务...
docker-compose start
echo ✓ 服务已启动
echo.
echo ========================================
echo 备份完成!
echo ========================================
pause

@ -1,32 +0,0 @@
import sqlite3
import os
db_path = 'data/futures_analysis.db'
print(f'数据库文件路径: {os.path.abspath(db_path)}')
print(f'数据库存在: {os.path.exists(db_path)}')
if os.path.exists(db_path):
print(f'数据库大小: {os.path.getsize(db_path)} 字节')
conn = sqlite3.connect(db_path)
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [row[0] for row in cursor.fetchall()]
print(f'数据库表列表: {tables}')
if 'ai_analysis_cache' in tables:
cursor = conn.execute("SELECT COUNT(*) FROM ai_analysis_cache")
count = cursor.fetchone()[0]
print(f'AI分析记录数: {count}')
cursor = conn.execute("SELECT id, symbol, created_at FROM ai_analysis_cache ORDER BY created_at DESC LIMIT 5")
records = cursor.fetchall()
print(f'最近5条记录:')
for r in records:
print(f' ID: {r[0]}, 合约: {r[1]}, 时间: {r[2]}')
else:
print('❌ ai_analysis_cache 表不存在!')
conn.close()
else:
print('❌ 数据库文件不存在!')

@ -1,29 +1,19 @@
{
"futures": {
"原油": "SC2606",
"燃油": "FU2606",
"燃油": "FU2609",
"低硫燃油": "LU2607",
"沪银": "AG2606",
"沪金": "AU2606",
"沪铜": "CU2606",
"沪镍": "NI2606",
"沪锡": "SN2606",
"沪铝": "AL2606",
"沪锌": "PB2606",
"氧化铝": "AO2609",
"工业硅": "SI2609",
"多晶硅": "PS2606",
"碳酸锂": "LC2609",
"纯碱": "SA2609",
"烧碱": "SH2607",
"玻璃": "FG2609",
"橡胶": "RU2609",
"合成橡胶": "BR2606",
"20号胶": "NR2607",
"螺纹钢": "RB2610",
"铁矿石": "I2609",
"焦煤": "JM2606",
"焦炭": "J2606",
"PTA": "TA2609",
"棕榈油": "P2609",
"豆粕": "M2609",
@ -31,6 +21,23 @@
"棉花": "CF2609",
"甲醇": "MA2609",
"尿素": "UR2609",
"中证1000": "IM2606"
}
"中证1000": "IM2606",
"焦煤": "JM2609",
"焦炭": "J2609",
"沪银": "AG2608",
"沪金": "AU2608",
"合成橡胶": "BR2607",
"沪铜": "CU2607",
"沪铝": "AL2607",
"原油": "SC2607",
"沪锌": "ZN2607",
"沪铅": "PB2607",
"铝合金": "AD2607",
"热卷": "hc2610",
"PVC": "v2609",
"乙二醇": "eg2609",
"集运欧线": "ec2606",
"多晶硅": "PS2609"
},
"stock": {}
}

Binary file not shown.

Binary file not shown.

@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - 全平台 (Apple Light)</title>
<style>
:root {
--bg-page: #F5F5F7; --bg-card: #FFFFFF; --bg-inset: #F5F5F7;
--text-primary: #1D1D1F; --text-secondary: #6E6E73; --text-tertiary: #86868B;
--border-light: rgba(0,0,0,0.05);
--color-up: #FF3B30; --color-down: #34C759; --color-neutral: #FF9F0A; --color-brand: #007AFF; --color-ai: #AF52DE;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04); --shadow-md: 0 4px 12px rgba(0,0,0,0.06); --shadow-lg: 0 8px 24px rgba(0,0,0,0.1);
--radius-card: 20px; --radius-pill: 100px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; background: var(--bg-page); color: var(--text-primary); -webkit-font-smoothing: antialiased; padding-bottom: 40px; }
a { text-decoration: none; color: var(--color-brand); }
.header { background: rgba(255,255,255,0.8); backdrop-filter: blur(20px); border-bottom: 1px solid var(--border-light); padding: 0 40px; height: 64px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 100; }
.logo { font-size: 18px; font-weight: 700; letter-spacing: -0.02em; }
.nav-items { display: flex; gap: 24px; font-size: 14px; font-weight: 500; }
.nav-item { color: var(--text-secondary); cursor: pointer; padding: 8px 0; border-bottom: 2px solid transparent; transition: all 0.2s; }
.nav-item:hover { color: var(--text-primary); }
.nav-item.active { color: var(--text-primary); border-bottom-color: var(--color-brand); }
.live-badge { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--color-down); background: rgba(52,199,89,0.1); padding: 4px 10px; border-radius: var(--radius-pill); }
.dot { width: 6px; height: 6px; background: var(--color-down); border-radius: 50%; animation: pulse 2s infinite; }
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } }
.container { max-width: 1200px; margin: 0 auto; padding: 32px 24px; }
.view { display: none; }
.view.active { display: block; }
.toolbar { display: flex; gap: 16px; margin-bottom: 24px; }
.search-box { flex: 1; background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; padding: 0 16px; height: 44px; font-size: 14px; color: var(--text-secondary); box-shadow: var(--shadow-sm); }
.pills { display: flex; gap: 8px; }
.pill { padding: 6px 14px; border-radius: var(--radius-pill); font-size: 13px; font-weight: 500; background: #E8E8ED; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.pill.active { background: var(--text-primary); color: white; }
.card { background: var(--bg-card); border-radius: var(--radius-card); padding: 24px; box-shadow: var(--shadow-md); transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); cursor: pointer; }
.card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 32px; }
.stat-card { background: var(--bg-card); border-radius: 16px; padding: 16px; display: flex; gap: 12px; align-items: center; box-shadow: var(--shadow-sm); }
.stat-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: bold; }
.stat-icon.blue { background: rgba(0,122,255,0.1); color: var(--color-brand); }
.stat-icon.green { background: rgba(52,199,89,0.1); color: var(--color-down); }
.stat-icon.red { background: rgba(255,59,48,0.1); color: var(--color-up); }
.stat-icon.orange { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.stat-val { font-size: 16px; font-weight: 700; }
.stat-label { font-size: 11px; color: var(--text-tertiary); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
.card-left { display: flex; gap: 12px; align-items: flex-start; }
.code-box { width: 36px; height: 36px; background: #F5F5F7; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-tertiary); }
.info-group { display: flex; flex-direction: column; }
.name-row { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 15px; }
.contract-code { font-size: 12px; color: var(--text-tertiary); margin-top: 2px; }
.price-area { text-align: right; }
.price { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
.price.up { color: var(--color-up); }
.price.down { color: var(--color-down); }
.change { font-size: 13px; font-weight: 600; margin-top: 4px; }
.change.up { color: var(--color-up); }
.change.down { color: var(--color-down); }
.action-pill { display: inline-block; padding: 4px 12px; border-radius: var(--radius-pill); font-size: 12px; font-weight: 600; margin-bottom: 16px; }
.action-pill.do-more { background: rgba(52,199,89,0.1); color: var(--color-down); }
.action-pill.watch { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.metrics { display: flex; gap: 24px; margin-bottom: 16px; }
.metric { flex: 1; }
.metric-label { font-size: 11px; color: var(--text-tertiary); margin-bottom: 4px; }
.bar-bg { height: 4px; background: #F5F5F7; border-radius: 2px; margin-top: 6px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 2px; }
.timeframes { display: flex; gap: 8px; margin-bottom: 16px; }
.tf { padding: 4px 10px; border-radius: 8px; font-size: 11px; color: var(--text-tertiary); background: #F5F5F7; cursor: pointer; }
.tf.active { background: #E8F2FF; color: var(--color-brand); font-weight: 500; }
.card-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #F5F5F7; padding-top: 12px; font-size: 12px; }
.support-resist { color: var(--text-tertiary); }
.support-resist span { margin-right: 8px; }
.support-resist .red { color: var(--color-up); }
.support-resist .green { color: var(--color-down); }
.fav-icon { font-size: 15px; color: #D1D1D6; cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; }
.fav-icon.active { color: #FF9F0A; transform: scale(1.1); }
/* Market Overview */
.event-banner { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 24px; }
.event-card { flex: 1; min-width: 240px; background: var(--bg-card); border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); border: 1px solid var(--border-light); }
.event-card.bearish { border-left: 3px solid var(--color-up); }
.event-card.bullish { border-left: 3px solid var(--color-down); }
.event-header { display: flex; justify-content: space-between; margin-bottom: 8px; align-items: center; }
.event-tag { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; }
.event-tag.urgent { background: #FFF0F0; color: var(--color-up); }
.event-tag.policy { background: #E8F2FF; color: var(--color-brand); }
.event-timer { font-size: 11px; color: var(--text-tertiary); font-weight: 500; }
.event-title { font-size: 14px; font-weight: 600; margin-bottom: 6px; line-height: 1.3; }
.event-impact { display: flex; gap: 8px; align-items: center; font-size: 12px; margin-bottom: 8px; }
.impact-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
.impact-badge.bull { background: rgba(255,59,48,0.1); color: var(--color-up); }
.impact-badge.bear { background: rgba(52,199,89,0.1); color: var(--color-down); }
.mini-contracts { display: flex; gap: 4px; }
.mini-tag { font-size: 10px; background: #F5F5F7; padding: 2px 6px; border-radius: 4px; color: var(--text-secondary); }
.overview-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; }
.section-header { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; display: flex; justify-content: space-between; }
.heatmap-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
.heat-cell { padding: 12px; border-radius: 12px; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; min-height: 80px; transition: transform 0.2s; }
.heat-cell:hover { transform: scale(1.03); }
.heat-cell .name { font-weight: 600; margin-bottom: 4px; }
.heat-cell .val { font-size: 12px; font-weight: 700; }
.heat-up-3 { background: #FF3B30; color: white; }
.heat-up-1 { background: rgba(255,59,48,0.15); color: var(--color-up); }
.heat-down-2 { background: rgba(52,199,89,0.3); color: #10803D; }
.heat-down-1 { background: rgba(52,199,89,0.1); color: var(--color-down); }
.heat-flat { background: #E8E8ED; color: var(--text-secondary); }
.limit-list { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.limit-col h4 { font-size: 11px; color: var(--text-tertiary); margin-bottom: 8px; }
.limit-row { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
.limit-row span:first-child { font-weight: 500; }
.limit-row span:last-child { font-weight: 600; }
.side-panel { display: flex; flex-direction: column; gap: 16px; }
.panel-box { background: var(--bg-card); border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); }
.panel-box-title { font-size: 13px; font-weight: 600; margin-bottom: 12px; }
.flow-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 12px; }
.flow-code { width: 30px; font-weight: 600; }
.flow-bar-bg { flex: 1; height: 6px; background: #F5F5F7; border-radius: 3px; overflow: hidden; }
.flow-bar { height: 100%; border-radius: 3px; }
.flow-val { width: 50px; text-align: right; font-weight: 600; }
.calendar-item { display: flex; gap: 12px; margin-bottom: 12px; align-items: flex-start; }
.cal-time { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
.cal-event { font-size: 13px; font-weight: 500; margin-bottom: 2px; }
.cal-stars { color: var(--color-neutral); font-size: 10px; }
/* Risk Alerts */
.alerts-layout { display: grid; grid-template-columns: 300px 1fr 280px; gap: 20px; }
.alerts-sidebar { display: flex; flex-direction: column; gap: 16px; }
.rule-group { background: var(--bg-card); border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); }
.rule-group-title { font-size: 12px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 12px; }
.rule-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-size: 13px; cursor: pointer; }
.toggle { width: 36px; height: 20px; background: #E8E8ED; border-radius: 10px; position: relative; cursor: pointer; transition: background 0.2s; }
.toggle.on { background: var(--color-brand); }
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: white; border-radius: 50%; transition: transform 0.2s; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.toggle.on::after { transform: translateX(16px); }
.alert-feed { display: flex; flex-direction: column; gap: 12px; }
.alert-filters { display: flex; gap: 8px; margin-bottom: 12px; }
.alert-card { background: var(--bg-card); border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); position: relative; overflow: hidden; }
.alert-card.urgent { border-left: 3px solid var(--color-up); }
.alert-time { font-size: 11px; color: var(--text-tertiary); margin-bottom: 6px; display: flex; justify-content: space-between; }
.alert-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.alert-desc { font-size: 12px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 8px; }
.alert-actions { display: flex; gap: 12px; font-size: 12px; font-weight: 500; }
.alert-actions span { color: var(--color-brand); cursor: pointer; }
.alert-actions span:hover { text-decoration: underline; }
.ai-scan-card { background: linear-gradient(135deg, #AF52DE 0%, #8E44AD 100%); border-radius: 16px; padding: 16px; color: white; box-shadow: var(--shadow-md); margin-bottom: 16px; }
.ai-scan-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.ai-scan-item { background: rgba(255,255,255,0.15); border-radius: 8px; padding: 8px; margin-bottom: 8px; font-size: 11px; line-height: 1.4; }
</style>
</head>
<body>
<header class="header">
<div class="logo">◈ 期货智析</div>
<div class="nav-items">
<div class="nav-item active" onclick="showView('list-view', this)">品种分析</div>
<div class="nav-item" onclick="showView('market-view', this)">市场概览</div>
<div class="nav-item" onclick="showView('alerts-view', this)">风险预警</div>
</div>
<div class="live-badge"><div class="dot"></div> LIVE</div>
</header>
<script>
function toggleFav(el) { el.classList.toggle('active'); event.stopPropagation(); }
function showView(viewId, navItem) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(viewId).classList.add('active');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
navItem.classList.add('active');
}
</script>
<!-- 品种分析 -->
<div id="list-view" class="view active">
<div class="container">
<div class="toolbar"><input type="text" class="search-box" placeholder="🔍 搜索品种名称或代码..."><div class="pills"><div class="pill active">全部</div><div class="pill">能源</div><div class="pill">金属</div><div class="pill">农产品</div></div></div>
<div class="stats-row"><div class="stat-card"><div class="stat-icon blue"></div><div><div class="stat-val">32</div><div class="stat-label">监控品种</div></div></div><div class="stat-card"><div class="stat-icon green"></div><div><div class="stat-val">7</div><div class="stat-label">上涨趋势</div></div></div><div class="stat-card"><div class="stat-icon red"></div><div><div class="stat-val">10</div><div class="stat-label">下跌趋势</div></div></div><div class="stat-card"><div class="stat-icon orange"></div><div><div class="stat-val">15</div><div class="stat-label">震荡整理</div></div></div></div>
<div class="grid">
<div class="card"><div class="card-header"><div class="card-left"><div class="code-box">SC</div><div class="info-group"><div class="name-row">原油 <span class="fav-icon" onclick="toggleFav(this)"></span></div><div class="contract-code">SC2606</div></div></div><div class="price-area"><div class="price down">¥644.5</div><div class="change down">↓ -2.02%</div></div></div><div class="action-pill watch">观望</div><div class="metrics"><div class="metric"><div class="metric-label">成功率 60%</div><div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div></div><div class="metric"><div class="metric-label">趋势评分 30</div><div class="bar-bg"><div class="bar-fill" style="width:30%; background:var(--color-up);"></div></div></div></div><div class="timeframes"><div class="tf active">15M</div><div class="tf">1H</div><div class="tf">4H</div></div><div class="card-footer"><div class="support-resist"><span>压力 <b class="red">642.4</b></span><span>支撑 <b class="green">631.7</b></span></div><a href="#" class="link">详情 →</a></div></div>
<div class="card"><div class="card-header"><div class="card-left"><div class="code-box">AG</div><div class="info-group"><div class="name-row">沪银 <span class="fav-icon" onclick="toggleFav(this)"></span></div><div class="contract-code">AG2606</div></div></div><div class="price-area"><div class="price up">¥18,643</div><div class="change up">↑ +2.27%</div></div></div><div class="action-pill do-more">做多</div><div class="metrics"><div class="metric"><div class="metric-label">成功率 85%</div><div class="bar-bg"><div class="bar-fill" style="width:85%; background:var(--color-down);"></div></div></div><div class="metric"><div class="metric-label">趋势评分 65</div><div class="bar-bg"><div class="bar-fill" style="width:65%; background:var(--color-down);"></div></div></div></div><div class="timeframes"><div class="tf active">15M</div><div class="tf">1H</div><div class="tf">4H</div></div><div class="card-footer"><div class="support-resist"><span>压力 <b class="red">18,685</b></span><span>支撑 <b class="green">18,584</b></span></div><a href="#" class="link">详情 →</a></div></div>
</div>
</div>
</div>
<!-- 市场概览 -->
<div id="market-view" class="view">
<div class="container">
<div class="event-banner">
<div class="event-card bearish"><div class="event-header"><span class="event-tag urgent">🔥 突发</span><span class="event-timer">T-00:15</span></div><div class="event-title">EIA 原油库存意外大幅下降 500 万桶</div><div class="event-impact"><span class="impact-badge bull">利多原油</span></div><div class="mini-contracts"><span class="mini-tag">SC</span><span class="mini-tag">LU</span></div></div>
<div class="event-card bullish"><div class="event-header"><span class="event-tag policy">🏛️ 政策</span><span class="event-timer">已公布</span></div><div class="event-title">央行宣布降准 0.25%,释放长期资金</div><div class="event-impact"><span class="impact-badge bear">利多有色</span></div><div class="mini-contracts"><span class="mini-tag">IF</span><span class="mini-tag">CU</span></div></div>
</div>
<div class="overview-layout">
<div class="left-col">
<div class="section-header"><span>🔥 市场热力图</span></div>
<div class="heatmap-grid">
<div class="heat-cell heat-up-3" style="grid-column: span 2;"><div class="name">SC 原油</div><div class="val">+3.8%</div></div>
<div class="heat-cell heat-up-1"><div class="name">AG 沪银</div><div class="val">+2.8%</div></div>
<div class="heat-cell heat-down-2"><div class="name">SA 纯碱</div><div class="val">-3.1%</div></div>
<div class="heat-cell heat-flat"><div class="name">M 豆粕</div><div class="val">0.0%</div></div>
<div class="heat-cell heat-up-2"><div class="name">IF 股指</div><div class="val">+1.8%</div></div>
<div class="heat-cell heat-down-1"><div class="name">RB 螺纹</div><div class="val">-0.5%</div></div>
</div>
<div class="limit-list"><div class="limit-col"><h4>🔴 领涨</h4><div class="limit-row"><span>SC 原油</span><span style="color:var(--color-up)">+3.8%</span></div><div class="limit-row"><span>AG 沪银</span><span style="color:var(--color-up)">+2.8%</span></div></div><div class="limit-col"><h4>🟢 领跌</h4><div class="limit-row"><span>SA 纯碱</span><span style="color:var(--color-down)">-3.1%</span></div><div class="limit-row"><span>FG 玻璃</span><span style="color:var(--color-down)">-1.9%</span></div></div></div>
</div>
<div class="side-panel">
<div class="panel-box"><div class="panel-box-title">💰 主力资金流向</div><div class="flow-row"><div class="flow-code">SC</div><div class="flow-bar-bg"><div class="flow-bar" style="width:80%;background:var(--color-up);"></div></div><div class="flow-val" style="color:var(--color-up)">+5.2亿</div></div><div class="flow-row"><div class="flow-code">SA</div><div class="flow-bar-bg"><div class="flow-bar" style="width:70%;background:var(--color-down);"></div></div><div class="flow-val" style="color:var(--color-down)">-4.5亿</div></div></div>
<div class="panel-box"><div class="panel-box-title">📅 宏观日历</div><div class="calendar-item"><div class="cal-time">23:30</div><div><div class="cal-event">EIA 原油库存</div><div class="cal-stars">★★★</div></div></div><div class="calendar-item"><div class="cal-time">周五</div><div><div class="cal-event">非农数据</div><div class="cal-stars">★★★★</div></div></div></div>
</div>
</div>
</div>
</div>
<!-- 风险预警 -->
<div id="alerts-view" class="view">
<div class="container">
<div class="alerts-layout">
<!-- 左侧AI 风险指数 & 统计 -->
<div class="side-panel">
<div class="ai-scan-card"><div class="ai-scan-title">✦ 今日风险指数: 72/100</div><div class="ai-scan-item"><b>疑似洗盘</b><br>纯碱 10:45 急跌后迅速拉回,量能异常,疑似主力洗盘。</div><div class="ai-scan-item"><b>流动性枯竭</b><br>胶板 2610 连续 3 分钟无成交,注意滑点风险。</div></div>
<div class="panel-box"><div class="panel-box-title">📊 预警统计</div><div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px;"><span>今日触发</span><span style="font-weight:600;">24 次</span></div><div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px;"><span>误报率</span><span style="font-weight:600;color:var(--color-down);">12%</span></div></div>
</div>
<!-- 中间:预警流 -->
<div class="alert-feed">
<div class="alert-filters"><div class="pill active">全部</div><div class="pill">紧急 (3)</div><div class="pill">事件驱动 (2)</div></div>
<div class="alert-card urgent"><div class="alert-time">14:32 · 关联事件EIA 库存</div><div class="alert-title">🔥 燃油快速突破 60 日均线</div><div class="alert-desc">关联事件EIA 库存超预期下降 | 5 分钟涨幅 1.5%</div><div class="alert-actions"><span>查看详情</span><span>AI 分析</span><span>加入自选</span></div></div>
<div class="alert-card urgent"><div class="alert-time">13:15</div><div class="alert-title">🔴 沪银成交量异常放大</div><div class="alert-desc">当前成交量为过去 20 日均值的 4.2 倍 | 疑似主力入场</div><div class="alert-actions"><span>查看详情</span><span>AI 分析</span></div></div>
<div class="alert-card"><div class="alert-time">11:20</div><div class="alert-title">螺纹 MACD 出现金叉</div><div class="alert-desc">15 分钟级别 MACD 快线上穿慢线</div><div class="alert-actions"><span>查看详情</span><span>AI 分析</span></div></div>
<div class="alert-card"><div class="alert-time">09:30</div><div class="alert-title">开盘跳空 5 品种</div><div class="alert-desc">原油、沪银、铁矿等高开超 1%</div><div class="alert-actions"><span>查看详情</span></div></div>
</div>
<!-- 右侧:规则设置 -->
<div class="alerts-sidebar">
<div class="rule-group"><div class="rule-group-title">价格与指标</div><div class="rule-item" onclick="this.querySelector('.toggle').classList.toggle('on')"><span>价格突破 60 日均线</span><div class="toggle on"></div></div><div class="rule-item" onclick="this.querySelector('.toggle').classList.toggle('on')"><span>MACD 金叉/死叉</span><div class="toggle on"></div></div></div>
<div class="rule-group"><div class="rule-group-title">量能与事件</div><div class="rule-item" onclick="this.querySelector('.toggle').classList.toggle('on')"><span>成交量突增 > 3 倍</span><div class="toggle on"></div></div><div class="rule-item" onclick="this.querySelector('.toggle').classList.toggle('on')"><span>热点事件关联异动</span><div class="toggle on"></div></div></div>
</div>
</div>
</div>
</div>
</body>
</html>

@ -0,0 +1,459 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析</title>
<style>
:root{--bg-page:#F5F5F7;--bg-card:#fff;--bg-inset:#F5F5F7;--text-primary:#1D1D1F;--text-secondary:#6E6E73;--text-tertiary:#86868B;--border-light:rgba(0,0,0,0.05);--color-up:#FF3B30;--color-down:#34C759;--color-neutral:#FF9F0A;--color-brand:#007AFF;--color-ai:#AF52DE;--shadow-sm:0 1px 2px rgba(0,0,0,0.04);--shadow-md:0 4px 12px rgba(0,0,0,0.06);--shadow-lg:0 8px 24px rgba(0,0,0,0.1);--radius-card:20px;--radius-pill:100px}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Helvetica Neue",sans-serif;background:var(--bg-page);color:var(--text-primary);-webkit-font-smoothing:antialiased;padding-bottom:40px}
a{text-decoration:none;color:var(--color-brand)}
.header{background:rgba(255,255,255,0.8);backdrop-filter:blur(20px);border-bottom:1px solid var(--border-light);padding:0 40px;height:64px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
.logo{font-size:18px;font-weight:700}
.nav-items{display:flex;gap:20px;font-size:14px;font-weight:500}
.nav-item{color:var(--text-secondary);cursor:pointer;padding:8px 0;border-bottom:2px solid transparent;transition:all .2s}
.nav-item:hover{color:var(--text-primary)}
.nav-item.active{color:var(--text-primary);border-bottom-color:var(--color-brand)}
.live-badge{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--color-down);background:rgba(52,199,89,0.1);padding:4px 10px;border-radius:var(--radius-pill)}
.dot{width:6px;height:6px;background:var(--color-down);border-radius:50%;animation:pulse 2s infinite}
@keyframes pulse{0%{opacity:1}50%{opacity:.5}100%{opacity:1}}
.container{max-width:1200px;margin:0 auto;padding:32px 24px}
.view{display:none}.view.active{display:block}
.toolbar{display:flex;gap:16px;margin-bottom:24px}
.search-box{flex:1;background:var(--bg-card);border:1px solid var(--border-light);border-radius:12px;padding:0 16px;height:44px;font-size:14px;color:var(--text-secondary);box-shadow:var(--shadow-sm)}
.pills{display:flex;gap:8px}
.pill{padding:6px 14px;border-radius:var(--radius-pill);font-size:13px;font-weight:500;background:#E8E8ED;color:var(--text-secondary);cursor:pointer}
.pill.active{background:var(--text-primary);color:#fff}
.card{background:var(--bg-card);border-radius:var(--radius-card);padding:24px;box-shadow:var(--shadow-md);cursor:pointer}
.card:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg)}
.stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:32px}
.stat-card{background:var(--bg-card);border-radius:16px;padding:16px;display:flex;gap:12px;align-items:center;box-shadow:var(--shadow-sm)}
.stat-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold}
.stat-icon.blue{background:rgba(0,122,255,0.1);color:var(--color-brand)}
.stat-icon.green{background:rgba(52,199,89,0.1);color:var(--color-down)}
.stat-icon.red{background:rgba(255,59,48,0.1);color:var(--color-up)}
.stat-icon.orange{background:rgba(255,159,10,0.1);color:var(--color-neutral)}
.stat-val{font-size:16px;font-weight:700}
.stat-label{font-size:11px;color:var(--text-tertiary)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:20px}
.card-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px}
.card-left{display:flex;gap:12px;align-items:flex-start}
.code-box{width:36px;height:36px;background:#F5F5F7;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;color:var(--text-tertiary)}
.info-group{display:flex;flex-direction:column}
.name-row{display:flex;align-items:center;gap:6px;font-weight:600;font-size:15px}
.contract-code{font-size:12px;color:var(--text-tertiary);margin-top:2px}
.price-area{text-align:right}
.price{font-size:26px;font-weight:700;line-height:1}
.price.up{color:var(--color-up)}.price.down{color:var(--color-down)}
.change{font-size:13px;font-weight:600;margin-top:4px}
.change.up{color:var(--color-up)}.change.down{color:var(--color-down)}
.action-pill{display:inline-block;padding:4px 12px;border-radius:var(--radius-pill);font-size:12px;font-weight:600;margin-bottom:16px}
.action-pill.do-more{background:rgba(52,199,89,0.1);color:var(--color-down)}
.action-pill.watch{background:rgba(255,159,10,0.1);color:var(--color-neutral)}
.metrics{display:flex;gap:24px;margin-bottom:16px}
.metric{flex:1}
.metric-label{font-size:11px;color:var(--text-tertiary);margin-bottom:4px}
.bar-bg{height:4px;background:#F5F5F7;border-radius:2px;margin-top:6px;overflow:hidden}
.bar-fill{height:100%;border-radius:2px}
.timeframes{display:flex;gap:8px;margin-bottom:16px}
.tf{padding:4px 10px;border-radius:8px;font-size:11px;color:var(--text-tertiary);background:#F5F5F7;cursor:pointer}
.tf.active{background:#E8F2FF;color:var(--color-brand);font-weight:500}
.card-footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid #F5F5F7;padding-top:12px;font-size:12px}
.support-resist{color:var(--text-tertiary)}
.support-resist span{margin-right:8px}
.support-resist .red{color:var(--color-up)}.support-resist .green{color:var(--color-down)}
.fav-icon{font-size:15px;color:#D1D1D6;cursor:pointer;display:inline-flex;align-items:center}
.fav-icon.active{color:#FF9F0A}
.event-banner{display:flex;gap:16px;overflow-x:auto;padding-bottom:8px;margin-bottom:24px}
.event-card{flex:1;min-width:240px;background:var(--bg-card);border-radius:16px;padding:16px;box-shadow:var(--shadow-sm);border:1px solid var(--border-light)}
.event-card.bearish{border-left:3px solid var(--color-up)}
.event-card.bullish{border-left:3px solid var(--color-down)}
.event-header{display:flex;justify-content:space-between;margin-bottom:8px;align-items:center}
.event-tag{font-size:10px;font-weight:700;padding:2px 6px;border-radius:4px}
.event-tag.urgent{background:#FFF0F0;color:var(--color-up)}
.event-tag.policy{background:#E8F2FF;color:var(--color-brand)}
.event-timer{font-size:11px;color:var(--text-tertiary);font-weight:500}
.event-title{font-size:14px;font-weight:600;margin-bottom:6px}
.event-impact{display:flex;gap:8px;align-items:center;font-size:12px;margin-bottom:8px}
.impact-badge{font-size:10px;padding:2px 6px;border-radius:4px;font-weight:600}
.impact-badge.bull{background:rgba(255,59,48,0.1);color:var(--color-up)}
.impact-badge.bear{background:rgba(52,199,89,0.1);color:var(--color-down)}
.mini-contracts{display:flex;gap:4px}
.mini-tag{font-size:10px;background:#F5F5F7;padding:2px 6px;border-radius:4px;color:var(--text-secondary)}
.overview-layout{display:grid;grid-template-columns:2fr 1fr;gap:20px}
.section-header{font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:12px}
.heatmap-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px}
.heat-cell{padding:12px;border-radius:12px;display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:80px}
.heat-cell .name{font-weight:600;margin-bottom:4px}
.heat-cell .val{font-size:12px;font-weight:700}
.heat-up-3{background:#FF3B30;color:#fff}
.heat-up-1{background:rgba(255,59,48,0.15);color:var(--color-up)}
.heat-down-2{background:rgba(52,199,89,0.3);color:#10803D}
.heat-flat{background:#E8E8ED;color:var(--text-secondary)}
.side-panel{display:flex;flex-direction:column;gap:16px}
.panel-box{background:var(--bg-card);border-radius:16px;padding:16px;box-shadow:var(--shadow-sm)}
.panel-box-title{font-size:13px;font-weight:600;margin-bottom:12px}
.flow-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:12px}
.flow-code{width:30px;font-weight:600}
.flow-bar-bg{flex:1;height:6px;background:#F5F5F7;border-radius:3px;overflow:hidden}
.flow-bar{height:100%;border-radius:3px}
.flow-val{width:50px;text-align:right;font-weight:600}
.calendar-item{display:flex;gap:12px;margin-bottom:12px;align-items:flex-start}
.cal-time{font-size:11px;color:var(--text-tertiary);margin-top:2px}
.cal-event{font-size:13px;font-weight:500}
.cal-stars{color:var(--color-neutral);font-size:10px}
.alerts-layout{display:grid;grid-template-columns:300px 1fr 280px;gap:20px}
.rule-group{background:var(--bg-card);border-radius:16px;padding:16px;box-shadow:var(--shadow-sm)}
.rule-group-title{font-size:12px;font-weight:600;color:var(--text-tertiary);margin-bottom:12px}
.rule-item{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:13px;cursor:pointer}
.toggle{width:36px;height:20px;background:#E8E8ED;border-radius:10px;position:relative;cursor:pointer}
.toggle.on{background:var(--color-brand)}
.toggle::after{content:;position:absolute;top:2px;left:2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:transform .2s}
.toggle.on::after{transform:translateX(16px)}
.alert-feed{display:flex;flex-direction:column;gap:12px}
.alert-filters{display:flex;gap:8px;margin-bottom:12px}
.alert-card{background:var(--bg-card);border-radius:16px;padding:16px;box-shadow:var(--shadow-sm)}
.alert-card.urgent{border-left:3px solid var(--color-up)}
.alert-time{font-size:11px;color:var(--text-tertiary);margin-bottom:6px}
.alert-title{font-size:14px;font-weight:600;margin-bottom:4px}
.alert-desc{font-size:12px;color:var(--text-secondary);line-height:1.4;margin-bottom:8px}
.alert-actions{display:flex;gap:12px;font-size:12px;font-weight:500}
.alert-actions span{color:var(--color-brand);cursor:pointer}
.ai-scan-card{background:linear-gradient(135deg,#AF52DE,#8E44AD);border-radius:16px;padding:16px;color:#fff;box-shadow:var(--shadow-md);margin-bottom:16px}
.ai-scan-title{font-size:13px;font-weight:600;margin-bottom:8px}
.ai-scan-item{background:rgba(255,255,255,0.15);border-radius:8px;padding:8px;margin-bottom:8px;font-size:11px}
.summary-header{background:var(--bg-card);border-radius:20px;padding:20px 24px;box-shadow:var(--shadow-sm);margin-bottom:24px;display:flex;justify-content:space-between;align-items:center}
.summary-header h2{font-size:20px;font-weight:700}
.summary-header .date{font-size:14px;color:var(--text-secondary);background:var(--bg-inset);padding:6px 14px;border-radius:8px;font-weight:500}
.summary-layout{display:grid;grid-template-columns:1fr 360px;gap:20px}
.summary-left{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.rank-section{background:var(--bg-card);border-radius:16px;padding:16px;box-shadow:var(--shadow-sm)}
.rank-title{font-size:13px;font-weight:600;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border-light)}
.rank-row{display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px dashed rgba(0,0,0,0.04);cursor:pointer;transition:background .15s}.rank-row:hover{background:var(--bg-inset);border-radius:6px}
.rank-row:last-child{border-bottom:none}
.rank-rank{width:20px;font-size:12px;font-weight:700;color:var(--text-tertiary);text-align:center}
.rank-rank.top{color:var(--color-brand)}
.rank-name{flex:1;margin-left:8px;font-size:13px;font-weight:500}
.rank-val{font-size:13px;font-weight:600}
.rank-val.up{color:var(--color-up)}.rank-val.down{color:var(--color-down)}
.plan-header{font-size:15px;font-weight:700;margin-bottom:16px;color:var(--color-ai)}
.plan-card{background:var(--bg-card);border-radius:16px;padding:20px;box-shadow:var(--shadow-md);margin-bottom:16px;border:1px solid var(--border-light);position:relative}
.plan-card::before{content:"AI";position:absolute;top:-9px;left:16px;background:var(--color-ai);color:#fff;font-size:10px;padding:2px 8px;border-radius:8px;font-weight:600}
.plan-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;margin-top:2px}
.plan-code{font-size:16px;font-weight:700}
.plan-badge{font-size:10px;padding:4px 12px;border-radius:var(--radius-pill);font-weight:700}
.plan-badge.long{background:rgba(52,199,89,0.15);color:var(--color-down)}
.plan-badge.short{background:rgba(255,59,48,0.15);color:var(--color-up)}
.plan-ai{background:#F8F5FF;border-radius:12px;padding:12px;margin-bottom:12px;font-size:12px;line-height:1.5;color:var(--text-secondary)}
.plan-ai strong{color:var(--color-ai)}
.plan-targets{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px}
.plan-target{background:var(--bg-inset);border-radius:10px;padding:10px 8px;text-align:center}
.plan-target-label{font-size:10px;color:var(--text-tertiary);margin-bottom:4px}
.plan-target-val{font-size:14px;font-weight:700}
.plan-target-val.green{color:var(--color-down)}.plan-target-val.red{color:var(--color-up)}.plan-target-val.neutral{color:var(--text-primary)}
.plan-actions{display:flex;gap:8px}
.plan-btn{flex:1;padding:8px 0;border:none;border-radius:10px;font-size:12px;font-weight:600;cursor:pointer}
.plan-btn.primary{background:var(--color-brand);color:#fff}
.plan-btn.secondary{background:var(--bg-inset);color:var(--text-secondary)}
.plan-note{font-size:10px;color:var(--text-tertiary);text-align:center;margin-top:8px}.plan-list{display:flex;flex-direction:column;gap:6px}.plan-list-item{background:var(--bg-card);border-radius:12px;padding:12px 16px;border:1px solid var(--border-light);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:all .2s}.plan-list-item:hover{box-shadow:var(--shadow-md);transform:translateY(-1px)}.plan-list-item.expanded{border-color:var(--color-ai);box-shadow:var(--shadow-md)}.plan-list-left{display:flex;align-items:center;gap:10px}.plan-list-code{font-size:14px;font-weight:700}.plan-list-score{font-size:12px;font-weight:600;color:var(--color-ai);background:#F8F5FF;padding:2px 8px;border-radius:6px}.plan-list-arrow{font-size:12px;color:var(--text-tertiary);transition:transform .2s}.plan-list-item.expanded .plan-list-arrow{transform:rotate(180deg)}.plan-detail{max-height:0;overflow:hidden;transition:max-height .35s ease}.plan-detail.open{max-height:600px}
.modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.4);backdrop-filter:blur(8px);z-index:1000;display:none;align-items:center;justify-content:center}
.modal-overlay.show{display:flex}
.modal{background:var(--bg-card);display:flex;flex-direction:column;border-radius:24px;width:92%;max-width:860px;max-height:85vh;overflow-y:auto;box-shadow:var(--shadow-lg);animation:modalIn .25s ease}
@keyframes modalIn{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
.modal-header{display:flex;justify-content:space-between;align-items:center;padding:24px 28px 20px;border-bottom:1px solid var(--border-light)}
.modal-title{font-size:20px;font-weight:700}
.modal-close{width:32px;height:32px;border-radius:50%;border:none;background:var(--bg-inset);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center}
.modal-close:hover{background:#E8E8ED}
.modal-body{padding:16px 28px 24px}
.modal-section{margin-bottom:16px}
.modal-section-title{font-size:12px;font-weight:600;color:var(--text-tertiary);margin-bottom:8px}
.modal-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.modal-stat{background:var(--bg-inset);border-radius:12px;padding:12px;text-align:center}
.modal-stat-label{font-size:11px;color:var(--text-tertiary);margin-bottom:4px}
.modal-stat-val{font-size:18px;font-weight:700}
.modal-stat-val.up{color:var(--color-up)}.modal-stat-val.down{color:var(--color-down)}
.modal-ai-box{background:#F8F5FF;border-radius:12px;padding:14px;font-size:13px;line-height:1.6}
.modal-ai-box strong{color:var(--color-ai)}
.modal-footer{padding:16px 24px 20px;border-top:1px solid var(--border-light);display:flex;gap:8px}
.kline-chart{width:100%;height:480px;background:var(--bg-inset);border-radius:12px;margin-bottom:12px}
.modal-section-title-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
</style></head><body>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<header class="header">
<div class="logo">期货智析</div>
<div class="nav-items">
<div class="nav-item active" onclick="showView('list-view',this)">品种分析</div>
<div class="nav-item" onclick="showView('market-view',this)">市场概览</div>
<div class="nav-item" onclick="showView('alerts-view',this)">风险预警</div>
<div class="nav-item" onclick="showView('summary-view',this)">复盘计划</div>
</div>
<div class="live-badge"><div class="dot"></div>LIVE</div>
</header>
<script>
function toggleFav(e){e.classList.toggle("active");event.stopPropagation()}
function showView(id,n){
document.querySelectorAll(".view").forEach(function(v){v.classList.remove("active")});
document.getElementById(id).classList.add("active");
document.querySelectorAll(".nav-item").forEach(function(x){x.classList.remove("active")});
n.classList.add("active")}
var modalData={
SC:{name:"SC 原油",price:"644.5",chg:"-2.02%",dir:"down",vol:"58.2万手",oi:"28.4万手",amp:"5.2%",rsi:"62",macd:"金叉",trend:"偏空",signal:"观望",
logic:"EIA库存超预期下降+OPEC+延长减产,但宏观需求预期偏弱。"},
RB:{name:"RB 螺纹",price:"3,485",chg:"+0.85%",dir:"up",vol:"52.1万手",oi:"38.5万手",amp:"2.1%",rsi:"48",macd:"死叉",trend:"震荡",signal:"观望",
logic:"淡季累库压力较大,政策预期与基本面博弈。"},
AG:{name:"AG 沪银",price:"18,643",chg:"+2.27%",dir:"up",vol:"45.8万手",oi:"18.2万手",amp:"4.5%",rsi:"71",macd:"金叉",trend:"偏多",signal:"偏多",
logic:"降息预期+央行购金+避险情绪共振,多头趋势延续。"},
SA:{name:"SA 纯碱",price:"1,825",chg:"-3.10%",dir:"down",vol:"38.6万手",oi:"15.8万手",amp:"6.8%",rsi:"28",macd:"死叉",trend:"偏空",signal:"偏空",
logic:"高库存+光伏需求下滑+新产能投放,基本面偏弱。"},
IF:{name:"IF 股指",price:"3,856",chg:"+1.80%",dir:"up",vol:"32.4万手",oi:"42.8万手",amp:"2.4%",rsi:"58",macd:"金叉",trend:"偏多",signal:"偏多",
logic:"央行降准+外资流入,情绪回暖。"},
FG:{name:"FG 玻璃",price:"1,258",chg:"-1.90%",dir:"down",vol:"22.3万手",oi:"12.1万手",amp:"3.9%",rsi:"35",macd:"死叉",trend:"偏空",signal:"偏空",
logic:"地产竣工走弱+库存高位,需求端承压。"},
LU:{name:"LU 燃油",price:"4,520",chg:"+0.65%",dir:"up",vol:"18.5万手",oi:"8.2万手",amp:"3.2%",rsi:"52",macd:"中性",trend:"震荡",signal:"观望",
logic:"跟随原油走势,自身供需平衡。"},
M:{name:"M 豆粕",price:"3,185",chg:"+0.35%",dir:"up",vol:"28.6万手",oi:"24.2万手",amp:"1.8%",rsi:"45",macd:"中性",trend:"震荡",signal:"观望",
logic:"美豆震荡+国内养殖需求平稳。"},
MA:{name:"MA 甲醇",price:"2,456",chg:"-0.55%",dir:"down",vol:"25.1万手",oi:"20.8万手",amp:"2.0%",rsi:"42",macd:"中性",trend:"震荡",signal:"观望",
logic:"港口库存偏高+煤价成本支撑。"}
};
var klineData={
SC:[
[20260520,642,658,640,655,125000],[20260521,656,662,648,650,118000],[20260522,650,655,645,652,132000],[20260523,653,660,648,658,140000],[20260524,659,668,655,660,155000],[20260525,661,665,650,652,148000],[20260526,652,658,642,645,138000],[20260527,645,652,638,648,142000],[20260528,649,655,640,642,135000],[20260529,642,650,635,645,150000],[20260530,646,658,642,655,162000],[20260531,656,668,650,662,175000],[20260601,663,670,640,644.5,188000]
],
RB:[
[20260520,3520,3550,3500,3530,85000],[20260521,3532,3560,3510,3545,82000],[20260522,3545,3570,3520,3555,88000],[20260523,3556,3580,3540,3570,92000],[20260524,3572,3590,3550,3560,95000],[20260525,3560,3575,3530,3540,90000],[20260526,3540,3555,3510,3520,86000],[20260527,3520,3540,3490,3500,88000],[20260528,3500,3520,3480,3495,84000],[20260529,3495,3510,3470,3480,92000],[20260530,3482,3500,3460,3475,88000],[20260531,3475,3495,3455,3485,86000],[20260601,3486,3510,3470,3485,90000]
],
AG:[
[20260520,18200,18450,18100,18350,55000],[20260521,18360,18500,18250,18420,58000],[20260522,18430,18600,18350,18550,62000],[20260523,18560,18700,18480,18620,65000],[20260524,18630,18800,18550,18750,70000],[20260525,18760,18900,18650,18700,68000],[20260526,18710,18780,18550,18600,64000],[20260527,18610,18700,18480,18550,62000],[20260528,18560,18650,18400,18480,60000],[20260529,18490,18580,18350,18420,63000],[20260530,18430,18600,18380,18520,66000],[20260531,18530,18700,18480,18650,70000],[20260601,18660,18800,18580,18643,75000]
],
SA:[
[20260520,1920,1950,1900,1935,42000],[20260521,1936,1960,1910,1920,45000],[20260522,1920,1940,1890,1900,48000],[20260523,1900,1920,1870,1880,52000],[20260524,1880,1900,1860,1870,55000],[20260525,1870,1890,1850,1860,50000],[20260526,1860,1880,1840,1850,48000],[20260527,1850,1870,1830,1845,46000],[20260528,1845,1860,1820,1830,44000],[20260529,1830,1850,1810,1820,47000],[20260530,1820,1840,1800,1810,50000],[20260531,1810,1830,1790,1800,48000],[20260601,1800,1850,1780,1825,52000]
],
IF:[
[20260520,3820,3850,3800,3835,35000],[20260521,3836,3860,3810,3850,36000],[20260522,3851,3870,3830,3860,37000],[20260523,3862,3880,3840,3870,38000],[20260524,3872,3890,3850,3880,40000],[20260525,3880,3895,3860,3875,38000],[20260526,3875,3890,3850,3860,36000],[20260527,3860,3880,3840,3855,35000],[20260528,3855,3870,3830,3845,34000],[20260529,3845,3860,3820,3840,36000],[20260530,3840,3860,3820,3850,37000],[20260531,3850,3870,3835,3860,38000],[20260601,3862,3880,3845,3856,40000]
],
FG:[
[20260520,1320,1350,1300,1335,28000],[20260521,1336,1355,1310,1320,30000],[20260522,1320,1340,1290,1300,32000],[20260523,1300,1320,1280,1290,35000],[20260524,1290,1310,1270,1280,38000],[20260525,1280,1300,1260,1270,36000],[20260526,1270,1290,1250,1260,34000],[20260527,1260,1280,1240,1255,32000],[20260528,1255,1270,1235,1250,30000],[20260529,1250,1265,1230,1240,33000],[20260530,1240,1260,1225,1235,31000],[20260531,1235,1255,1220,1230,30000],[20260601,1230,1270,1215,1258,32000]
],
LU:[
[20260520,4480,4520,4460,4500,22000],[20260521,4502,4530,4480,4510,23000],[20260522,4512,4540,4490,4520,24000],[20260523,4522,4550,4500,4535,25000],[20260524,4536,4560,4510,4545,26000],[20260525,4546,4565,4520,4530,24000],[20260526,4530,4550,4500,4510,22000],[20260527,4510,4535,4490,4505,23000],[20260528,4505,4525,4480,4495,21000],[20260529,4495,4515,4470,4490,22000],[20260530,4490,4510,4470,4500,23000],[20260531,4500,4525,4480,4510,24000],[20260601,4510,4540,4490,4520,25000]
],
M:[
[20260520,3150,3180,3130,3165,32000],[20260521,3166,3190,3140,3175,33000],[20260522,3176,3200,3155,3190,35000],[20260523,3192,3210,3170,3200,36000],[20260524,3202,3220,3180,3210,38000],[20260525,3212,3225,3190,3200,36000],[20260526,3200,3215,3180,3190,34000],[20260527,3190,3205,3170,3185,33000],[20260528,3185,3200,3165,3180,32000],[20260529,3180,3195,3160,3175,34000],[20260530,3175,3190,3155,3180,33000],[20260531,3180,3195,3160,3185,34000],[20260601,3186,3200,3170,3185,35000]
],
MA:[
[20260520,2480,2510,2460,2495,28000],[20260521,2496,2520,2470,2485,30000],[20260522,2486,2510,2460,2475,32000],[20260523,2476,2500,2450,2465,34000],[20260524,2466,2490,2440,2455,36000],[20260525,2455,2480,2435,2445,34000],[20260526,2445,2470,2425,2440,32000],[20260527,2440,2460,2420,2435,30000],[20260528,2435,2455,2415,2430,28000],[20260529,2430,2450,2410,2425,30000],[20260530,2425,2445,2405,2420,29000],[20260531,2420,2440,2400,2415,28000],[20260601,2415,2470,2400,2456,30000]
]
};
var klineChart=null;
function renderKline(code){
if(!klineData[code])return;
var container=document.getElementById('kline-container');
if(!container)return;
if(!klineChart){klineChart=echarts.init(container)}
var raw=klineData[code];
var dates=raw.map(function(r){return r[0].toString()});
var ohlc=raw.map(function(r){return[r[1],r[2],r[3],r[4]]});
var vols=raw.map(function(r){return r[5]});
var ma5=[],ma10=[];
for(var i=0;i<ohlc.length;i++){
var c=ohlc[i][3];var s5=0,c5=0,s10=0,c10=0;
for(var j=0;j<5&&i-j>=0;j++){s5+=ohlc[i-j][3];c5++}
for(var j=0;j<10&&i-j>=0;j++){s10+=ohlc[i-j][3];c10++}
ma5.push(c5>0?s5/c5:null);ma10.push(c10>0?s10/c10:null);
}
var opt={
grid:[{left:50,right:20,top:20,height:'55%'},{left:50,right:20,top:'78%',height:'18%'}],
xAxis:[{type:'category',data:dates,gridIndex:0,axisLine:{lineStyle:{color:'#eee'}},axisLabel:{show:false}},{type:'category',data:dates,gridIndex:1,axisLine:{lineStyle:{color:'#eee'}},axisLabel:{show:false}}],
yAxis:[{scale:true,splitArea:{show:true,areaStyle:{color:['#fff','#f9f9f9']}},axisLine:{lineStyle:{color:'#eee'}}},{scale:true,gridIndex:1,splitArea:{show:true,areaStyle:{color:['#fff','#f9f9f9']}},axisLine:{lineStyle:{color:'#eee'}},axisLabel:{show:false}}],
dataZoom:[{type:'inside',xAxisIndex:[0,1],start:30,end:100}],
tooltip:{trigger:'axis',axisPointer:{type:'cross'},formatter:function(p){if(!p[0])return'';var d=p[0].dataIndex;var o=ohlc[d];return dates[d].substring(4)+'<br>开: '+o[0]+' 高: '+o[1]+'<br>低: '+o[2]+' 收: '+o[3]+'<br>量: '+vols[d]}},
series:[
{name:'K线',type:'candlestick',data:ohlc,itemStyle:{color:'#FF3B30',color0:'#34C759',borderColor:'#FF3B30',borderColor0:'#34C759'}},
{name:'MA5',type:'line',data:ma5,smooth:true,lineStyle:{width:1.5,color:'#AF52DE'}},
{name:'MA10',type:'line',data:ma10,smooth:true,lineStyle:{width:1.5,color:'#FF9F0A'}},
{name:'成交量',type:'bar',xAxisIndex:1,yAxisIndex:1,data:vols.map(function(v,i){return{value:v,itemStyle:{color:ohlc[i][3]>=ohlc[i][0]?'rgba(255,59,48,0.5)':'rgba(52,199,89,0.5)'}}})}
],
legend:{data:['K线','MA5','MA10','成交量'],bottom:0,itemWidth:12,itemHeight:8,textStyle:{fontSize:10}}
};
klineChart.setOption(opt,true);
}
function showDetail(code){
var d=modalData[code];if(!d)return;
var el=document.getElementById('modal-body');
el.innerHTML='<div class="modal-section"><div class="modal-section-title">实时行情</div><div class="modal-grid">'+
'<div class="modal-stat"><div class="modal-stat-label">最新价</div><div class="modal-stat-val '+d.dir+'">'+d.price+'</div></div>'+
'<div class="modal-stat"><div class="modal-stat-label">涨跌幅</div><div class="modal-stat-val '+d.dir+'">'+d.chg+'</div></div>'+
'<div class="modal-stat"><div class="modal-stat-label">成交量</div><div class="modal-stat-val">'+d.vol+'</div></div>'+
'<div class="modal-stat"><div class="modal-stat-label">持仓量</div><div class="modal-stat-val">'+d.oi+'</div></div>'+
'<div class="modal-stat"><div class="modal-stat-label">振幅</div><div class="modal-stat-val">'+d.amp+'</div></div>'+
'<div class="modal-stat"><div class="modal-stat-label">RSI</div><div class="modal-stat-val">'+d.rsi+'</div></div></div></div>'+
'<div class="modal-section"><div class="modal-section-title">技术信号</div><div class="modal-grid">'+
'<div class="modal-stat"><div class="modal-stat-label">MACD</div><div class="modal-stat-val">'+d.macd+'</div></div>'+
'<div class="modal-stat"><div class="modal-stat-label">趋势</div><div class="modal-stat-val">'+d.trend+'</div></div>'+
'<div class="modal-stat"><div class="modal-stat-label">信号</div><div class="modal-stat-val "+(d.signal=="偏多"?"down":d.signal=="偏空"?"up":"")+"">'+d.signal+'</div></div>'+
'<div class="modal-stat"><div class="modal-stat-label">AI建议</div><div class="modal-stat-val" style="color:var(--color-ai)">查看分析</div></div></div></div>'+
'<div class="modal-section"><div class="modal-section-title">分析逻辑</div><div class="modal-ai-box">'+d.logic+'</div></div>';
document.getElementById('modal-title').textContent=d.name;
setTimeout(function(){renderKline(code)},100);
document.getElementById('detail-modal').classList.add('show');
}
function closeModal(){document.getElementById("detail-modal").classList.remove("show");if(klineChart){klineChart.dispose();klineChart=null}}
function togglePlan(el){
var detail=el.nextElementSibling;
var isOpen=detail.classList.contains("open");
document.querySelectorAll(".plan-detail.open").forEach(function(d){d.classList.remove("open");d.previousElementSibling.classList.remove("expanded")});
if(!isOpen){detail.classList.add("open");el.classList.add("expanded")}}
</script>
<div id="list-view" class="view active"><div class="container">
<div class="toolbar"><input type="text" class="search-box" placeholder="搜索合约代码或名称..."><div class="pills"><div class="pill active">全部</div><div class="pill">能源</div><div class="pill">金属</div><div class="pill">农产品</div><div class="pill">化工</div></div></div>
<div class="stats-row">
<div class="stat-card"><div class="stat-icon blue">=</div><div><div class="stat-val">32</div><div class="stat-label">监控品种</div></div></div>
<div class="stat-card"><div class="stat-icon green">&#x2197;</div><div><div class="stat-val">7</div><div class="stat-label">上涨趋势</div></div></div>
<div class="stat-card"><div class="stat-icon red">&#x2198;</div><div><div class="stat-val">10</div><div class="stat-label">下跌趋势</div></div></div>
<div class="stat-card"><div class="stat-icon orange">&#x2194;</div><div><div class="stat-val">15</div><div class="stat-label">震荡整理</div></div></div>
</div>
<div class="grid">
<div class="card"><div class="card-header"><div class="card-left"><div class="code-box">SC</div><div class="info-group"><div class="name-row">原油 <span class="fav-icon" onclick="toggleFav(this)">&#x2605;</span></div><div class="contract-code">SC2606</div></div></div><div class="price-area"><div class="price down">644.5</div><div class="change down">-2.02%</div></div></div><div class="action-pill watch">观望</div><div class="metrics"><div class="metric"><div class="metric-label">成功率 60%</div><div class="bar-bg"><div class="bar-fill" style="width:60%;background:var(--color-neutral)"></div></div></div></div><div class="timeframes"><div class="tf active">15M</div><div class="tf">1H</div><div class="tf">4H</div></div><div class="card-footer"><div class="support-resist"><span>压力 <b class="red">642.4</b></span></div><a href="#">详情</a></div></div>
<div class="card"><div class="card-header"><div class="card-left"><div class="code-box">AG</div><div class="info-group"><div class="name-row">沪银 <span class="fav-icon active" onclick="toggleFav(this)">&#x2605;</span></div><div class="contract-code">AG2606</div></div></div><div class="price-area"><div class="price up">18,643</div><div class="change up">+2.27%</div></div></div><div class="action-pill do-more">做多</div><div class="metrics"><div class="metric"><div class="metric-label">成功率 85%</div><div class="bar-bg"><div class="bar-fill" style="width:85%;background:var(--color-down)"></div></div></div></div><div class="timeframes"><div class="tf active">15M</div><div class="tf">1H</div><div class="tf">4H</div></div><div class="card-footer"><div class="support-resist"><span>压力 <b class="red">18,685</b></span></div><a href="#">详情</a></div></div>
</div></div></div>
<div id="market-view" class="view"><div class="container">
<div class="event-banner">
<div class="event-card bearish"><div class="event-header"><span class="event-tag urgent">突发</span><span class="event-timer">T-00:15</span></div><div class="event-title">EIA 原油库存意外大幅下降 500 万桶</div><div class="event-impact"><span class="impact-badge bull">利多原油</span></div><div class="mini-contracts"><span class="mini-tag">SC</span><span class="mini-tag">LU</span></div></div>
<div class="event-card bullish"><div class="event-header"><span class="event-tag policy">政策</span><span class="event-timer">已公布</span></div><div class="event-title">央行宣布降准 0.25%</div><div class="event-impact"><span class="impact-badge bear">利多有色</span></div><div class="mini-contracts"><span class="mini-tag">IF</span><span class="mini-tag">CU</span></div></div>
</div>
<div class="overview-layout"><div class="left-col">
<div class="section-header">市场热力图</div>
<div class="heatmap-grid">
<div class="heat-cell heat-up-3" style="grid-column:span 2"><div class="name">SC 原油</div><div class="val">+3.8%</div></div>
<div class="heat-cell heat-up-1"><div class="name">AG 沪银</div><div class="val">+2.8%</div></div>
<div class="heat-cell heat-down-2"><div class="name">SA 纯碱</div><div class="val">-3.1%</div></div>
<div class="heat-cell heat-flat"><div class="name">M 豆粕</div><div class="val">0.0%</div></div>
<div class="heat-cell heat-up-1"><div class="name">IF 股指</div><div class="val">+1.8%</div></div>
<div class="heat-cell heat-flat"><div class="name">RB 螺纹</div><div class="val">-0.5%</div></div>
</div></div>
<div class="side-panel">
<div class="panel-box"><div class="panel-box-title">主力资金流向</div>
<div class="flow-row"><div class="flow-code">SC</div><div class="flow-bar-bg"><div class="flow-bar" style="width:80%;background:var(--color-up)"></div></div><div class="flow-val" style="color:var(--color-up)">+5.2亿</div></div>
<div class="flow-row"><div class="flow-code">SA</div><div class="flow-bar-bg"><div class="flow-bar" style="width:70%;background:var(--color-down)"></div></div><div class="flow-val" style="color:var(--color-down)">-4.5亿</div></div>
</div>
<div class="panel-box"><div class="panel-box-title">宏观日历</div>
<div class="calendar-item"><div class="cal-time">23:30</div><div><div class="cal-event">EIA 原油库存</div><div class="cal-stars">&#x2605;&#x2605;&#x2605;</div></div></div>
<div class="calendar-item"><div class="cal-time">周五</div><div><div class="cal-event">非农数据</div><div class="cal-stars">&#x2605;&#x2605;&#x2605;&#x2605;</div></div></div>
</div></div></div></div></div>
<div id="alerts-view" class="view"><div class="container"><div class="alerts-layout">
<div class="side-panel">
<div class="ai-scan-card"><div class="ai-scan-title">今日风险指数: 72/100</div>
<div class="ai-scan-item"><b>疑似洗盘</b><br>纯碱 10:45 急跌后迅速拉回。</div>
<div class="ai-scan-item"><b>流动性枯竭</b><br>胶板 2610 连续 3 分钟无成交。</div></div>
<div class="panel-box"><div class="panel-box-title">预警统计</div>
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px"><span>今日触发</span><span style="font-weight:600">24 次</span></div>
<div style="display:flex;justify-content:space-between;font-size:12px"><span>误报率</span><span style="font-weight:600;color:var(--color-down)">12%</span></div></div></div>
<div class="alert-feed">
<div class="alert-filters"><div class="pill active">全部</div><div class="pill">紧急</div><div class="pill">事件驱动</div></div>
<div class="alert-card urgent"><div class="alert-time">14:32 - 关联事件: EIA</div><div class="alert-title">燃油快速突破 60 日均线</div><div class="alert-desc">EIA 库存超预期下降 | 5 分钟涨幅 1.5%</div><div class="alert-actions"><span>查看详情</span><span>AI 分析</span><span>加入自选</span></div></div>
<div class="alert-card urgent"><div class="alert-time">13:15</div><div class="alert-title">沪银成交量异常放大</div><div class="alert-desc">成交量为 20 日均值的 4.2 倍</div><div class="alert-actions"><span>查看详情</span><span>AI 分析</span></div></div>
<div class="alert-card"><div class="alert-time">11:20</div><div class="alert-title">螺纹 MACD 金叉</div><div class="alert-desc">15 分钟级别 MACD 快线上穿慢线</div><div class="alert-actions"><span>查看详情</span></div></div>
</div>
<div class="alerts-sidebar">
<div class="rule-group"><div class="rule-group-title">价格与指标</div>
<div class="rule-item" onclick="this.querySelector('.toggle').classList.toggle('on')"><span>价格突破 60 日均线</span><div class="toggle on"></div></div>
<div class="rule-item" onclick="this.querySelector('.toggle').classList.toggle('on')"><span>MACD 金叉/死叉</span><div class="toggle on"></div></div>
</div>
<div class="rule-group"><div class="rule-group-title">量能与事件</div>
<div class="rule-item" onclick="this.querySelector('.toggle').classList.toggle('on')"><span>成交量突增 > 3 倍</span><div class="toggle on"></div></div>
<div class="rule-item" onclick="this.querySelector('.toggle').classList.toggle('on')"><span>热点事件关联异动</span><div class="toggle"></div></div>
</div></div>
</div></div></div>
<div id="summary-view" class="view"><div class="container">
<div class="summary-header"><h2>复盘与交易计划</h2><div class="date">2026-06-01 (周一)</div></div>
<div class="summary-layout"><div class="summary-left">
<div class="rank-section"><div class="rank-title">成交量排名 Top 5</div>
<div class="rank-row" onclick="showDetail('SC')"><div class="rank-rank top">1</div><div class="rank-name">SC 原油</div><div class="rank-val up">58.2 万手</div></div>
<div class="rank-row" onclick="showDetail('RB')"><div class="rank-rank top">2</div><div class="rank-name">RB 螺纹</div><div class="rank-val up">52.1 万手</div></div>
<div class="rank-row" onclick="showDetail('AG')"><div class="rank-rank top">3</div><div class="rank-name">AG 沪银</div><div class="rank-val up">45.8 万手</div></div>
<div class="rank-row" onclick="showDetail('SA')"><div class="rank-rank">4</div><div class="rank-name">SA 纯碱</div><div class="rank-val up">38.6 万手</div></div>
<div class="rank-row" onclick="showDetail('IF')"><div class="rank-rank">5</div><div class="rank-name">IF 股指</div><div class="rank-val">32.4 万手</div></div>
</div>
<div class="rank-section"><div class="rank-title">振幅排名 Top 5</div>
<div class="rank-row" onclick="showDetail('SA')"><div class="rank-rank top">1</div><div class="rank-name">SA 纯碱</div><div class="rank-val">6.8%</div></div>
<div class="rank-row" onclick="showDetail('SC')"><div class="rank-rank top">2</div><div class="rank-name">SC 原油</div><div class="rank-val">5.2%</div></div>
<div class="rank-row" onclick="showDetail('AG')"><div class="rank-rank top">3</div><div class="rank-name">AG 沪银</div><div class="rank-val">4.5%</div></div>
<div class="rank-row" onclick="showDetail('FG')"><div class="rank-rank">4</div><div class="rank-name">FG 玻璃</div><div class="rank-val">3.9%</div></div>
<div class="rank-row" onclick="showDetail('LU')"><div class="rank-rank">5</div><div class="rank-name">LU 燃油</div><div class="rank-val">3.2%</div></div>
</div>
<div class="rank-section"><div class="rank-title">涨跌幅排名 Top 5</div>
<div class="rank-row" onclick="showDetail('SC')"><div class="rank-rank top">1</div><div class="rank-name">SC 原油</div><div class="rank-val up">+3.8%</div></div>
<div class="rank-row" onclick="showDetail('AG')"><div class="rank-rank top">2</div><div class="rank-name">AG 沪银</div><div class="rank-val up">+2.8%</div></div>
<div class="rank-row" onclick="showDetail('IF')"><div class="rank-rank top">3</div><div class="rank-name">IF 股指</div><div class="rank-val up">+1.8%</div></div>
<div class="rank-row" onclick="showDetail('SA')"><div class="rank-rank">4</div><div class="rank-name">SA 纯碱</div><div class="rank-val down">-3.1%</div></div>
<div class="rank-row" onclick="showDetail('FG')"><div class="rank-rank">5</div><div class="rank-name">FG 玻璃</div><div class="rank-val down">-1.9%</div></div>
</div>
<div class="rank-section"><div class="rank-title">持仓量排名 Top 5</div>
<div class="rank-row" onclick="showDetail('IF')"><div class="rank-rank top">1</div><div class="rank-name">IF 股指</div><div class="rank-val">42.8 万手</div></div>
<div class="rank-row" onclick="showDetail('RB')"><div class="rank-rank top">2</div><div class="rank-name">RB 螺纹</div><div class="rank-val">38.5 万手</div></div>
<div class="rank-row" onclick="showDetail('SC')"><div class="rank-rank top">3</div><div class="rank-name">SC 原油</div><div class="rank-val">28.4 万手</div></div>
<div class="rank-row" onclick="showDetail('M')"><div class="rank-rank">4</div><div class="rank-name">M 豆粕</div><div class="rank-val">24.2 万手</div></div>
<div class="rank-row" onclick="showDetail('MA')"><div class="rank-rank">5</div><div class="rank-name">MA 甲醇</div><div class="rank-val">20.8 万手</div></div>
</div></div>
<div>
<div class="plan-header">交易机会</div>
<div class="plan-list">
<div class="plan-list-item" onclick="togglePlan(this)">
<div class="plan-list-left"><span class="plan-list-code">SC 原油</span><span class="plan-badge long">做多</span></div>
<div style="display:flex;align-items:center;gap:8px"><span class="plan-list-score">88</span><span class="plan-list-arrow">&#x25BC;</span></div>
</div>
<div class="plan-detail">
<div class="plan-card" style="margin-bottom:0">
<div class="plan-ai"><strong>评分: 88/100</strong><br>多空逻辑: EIA 库存超预期下降 + OPEC+ 延长减产 + 地缘局势升温,供给端紧缩共振。<br>入选理由: 量价齐升5/15/30min 周期共振向上MACD 金叉确认。</div>
<div class="plan-targets">
<div class="plan-target"><div class="plan-target-label">入场</div><div class="plan-target-val neutral">645-648</div></div>
<div class="plan-target"><div class="plan-target-label">止损</div><div class="plan-target-val red">638</div></div>
<div class="plan-target"><div class="plan-target-label">止盈</div><div class="plan-target-val green">670-685</div></div>
</div>
<div class="plan-actions"><button class="plan-btn primary">加入自选</button></div>
<div class="plan-note">置信度: 高 | 仓位建议: 15%-20%</div>
</div></div>
<div class="plan-list-item" onclick="togglePlan(this)">
<div class="plan-list-left"><span class="plan-list-code">SA 纯碱</span><span class="plan-badge short">做空</span></div>
<div style="display:flex;align-items:center;gap:8px"><span class="plan-list-score">82</span><span class="plan-list-arrow">&#x25BC;</span></div>
</div>
<div class="plan-detail">
<div class="plan-card" style="margin-bottom:0">
<div class="plan-ai"><strong>评分: 82/100</strong><br>多空逻辑: 高库存 + 光伏玻璃需求下滑 + 新产能投产预期,基本面偏弱。<br>入选理由: 日内振幅 6.8% 居首15min 级别顶背离确认,资金净流出 4.5 亿。</div>
<div class="plan-targets">
<div class="plan-target"><div class="plan-target-label">入场</div><div class="plan-target-val neutral">1,820-1,830</div></div>
<div class="plan-target"><div class="plan-target-label">止损</div><div class="plan-target-val red">1,860</div></div>
<div class="plan-target"><div class="plan-target-label">止盈</div><div class="plan-target-val green">1,750-1,720</div></div>
</div>
<div class="plan-actions"><button class="plan-btn primary">加入自选</button></div>
<div class="plan-note">置信度: 中高 | 仓位建议: 10%-15%</div>
</div></div>
<div class="plan-list-item" onclick="togglePlan(this)">
<div class="plan-list-left"><span class="plan-list-code">AG 沪银</span><span class="plan-badge long">做多</span></div>
<div style="display:flex;align-items:center;gap:8px"><span class="plan-list-score">79</span><span class="plan-list-arrow">&#x25BC;</span></div>
</div>
<div class="plan-detail">
<div class="plan-card" style="margin-bottom:0">
<div class="plan-ai"><strong>评分: 79/100</strong><br>多空逻辑: 全球降息预期 + 央行持续增持黄金 + 避险情绪升温。<br>入选理由: 成交量 45.8 万手放量突破,多周期均线多头排列。</div>
<div class="plan-targets">
<div class="plan-target"><div class="plan-target-label">入场</div><div class="plan-target-val neutral">18,500-18,600</div></div>
<div class="plan-target"><div class="plan-target-label">止损</div><div class="plan-target-val red">18,350</div></div>
<div class="plan-target"><div class="plan-target-label">止盈</div><div class="plan-target-val green">19,000-19,200</div></div>
</div>
<div class="plan-actions"><button class="plan-btn primary">加入自选</button></div>
<div class="plan-note">置信度: 中 | 仓位建议: 10%</div>
</div></div>
</div>
<div class="plan-note" style="font-size:11px;margin-top:16px">以上由 AI 基于多维因子自动生成,仅供参考,不构成投资建议。</div>
</div></div></div></div>
<div id="detail-modal" class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal"><div class="modal-header"><div class="modal-title" id="modal-title">品种详情</div><button class="modal-close" onclick="closeModal()">&#x2715;</button></div><div class="kline-chart" id="kline-container"></div><div class="modal-body" id="modal-body"></div><div class="modal-footer"><button class="plan-btn primary" onclick="closeModal()">加入自选</button><button class="plan-btn secondary" onclick="closeModal()">关闭</button></div></div></div>
</body></html>

@ -0,0 +1,462 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - Web Prototype (Apple Style)</title>
<style>
:root {
/* v3.0 Apple Style Colors */
--bg-page: #F5F5F7;
--bg-card: #FFFFFF;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-tertiary: #86868B;
--color-up: #FF3B30;
--color-down: #34C759;
--color-neutral: #FF9F0A;
--color-brand: #007AFF;
--color-ai: #AF52DE;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.08);
/* Radius */
--radius-card: 20px;
--radius-btn: 12px;
--radius-pill: 9999px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", sans-serif;
background-color: var(--bg-page);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
padding-bottom: 40px;
}
/* Header - Glassmorphism */
.header {
position: sticky;
top: 0;
z-index: 100;
height: 64px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid rgba(0,0,0,0.05);
display: flex;
align-items: center;
padding: 0 32px;
}
.logo { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; margin-right: 40px; }
.nav-items { display: flex; gap: 24px; flex: 1; }
.nav-item { font-size: 14px; color: var(--text-secondary); cursor: pointer; transition: color 0.2s; }
.nav-item:hover { color: var(--text-primary); }
.nav-item.active { color: var(--color-brand); font-weight: 500; }
.live-badge { font-size: 11px; color: var(--color-down); display: flex; align-items: center; gap: 4px; }
.dot { width: 6px; height: 6px; background: var(--color-down); border-radius: 50%; }
/* Main Container */
.container { max-width: 1200px; margin: 0 auto; padding: 24px 32px; }
/* Search & Filter */
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 16px; }
.search-box {
background: #FFFFFF;
border: none;
border-radius: var(--radius-btn);
padding: 10px 16px;
font-size: 14px;
width: 300px;
box-shadow: var(--shadow-sm);
outline: none;
transition: box-shadow 0.2s;
}
.search-box:focus { box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); }
.pills { display: flex; gap: 8px; }
.pill {
padding: 6px 16px;
border-radius: var(--radius-pill);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: #FFFFFF;
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
}
.pill:hover { background: #F5F5F7; }
.pill.active { background: var(--color-brand); color: #FFFFFF; box-shadow: 0 4px 10px rgba(0,122,255,0.3); }
/* Stats Row */
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 32px; }
.stat-card {
background: var(--bg-card);
border-radius: 16px;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: var(--shadow-sm);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
.stat-icon.blue { background: rgba(0,122,255,0.1); color: var(--color-brand); }
.stat-icon.red { background: rgba(255,59,48,0.1); color: var(--color-up); }
.stat-icon.green { background: rgba(52,199,89,0.1); color: var(--color-down); }
.stat-icon.orange { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.stat-val { font-size: 24px; font-weight: 700; line-height: 1.2; }
.stat-label { font-size: 13px; color: var(--text-secondary); }
/* Grid */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
/* Card */
.card {
background: var(--bg-card);
border-radius: var(--radius-card);
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
position: relative;
overflow: hidden;
cursor: pointer;
}
.card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
.code-box { width: 36px; height: 36px; background: #F5F5F7; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 8px; }
.price-area { text-align: right; }
.price { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
.price.up { color: var(--color-up); }
.price.down { color: var(--color-down); }
.change { font-size: 13px; font-weight: 600; margin-top: 4px; }
.change.up { color: var(--color-up); }
.change.down { color: var(--color-down); }
.action-pill {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-pill);
font-size: 12px;
font-weight: 600;
margin-bottom: 16px;
}
.action-pill.do-more { background: rgba(52,199,89,0.1); color: var(--color-down); }
.action-pill.watch { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.metrics { display: flex; gap: 24px; margin-bottom: 16px; }
.metric { flex: 1; }
.metric-label { font-size: 11px; color: var(--text-tertiary); margin-bottom: 4px; }
.metric-val { font-size: 14px; font-weight: 600; }
.bar-bg { height: 4px; background: #F5F5F7; border-radius: 2px; margin-top: 6px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 2px; }
.timeframes { display: flex; gap: 8px; margin-bottom: 16px; }
.tf { padding: 4px 10px; border-radius: 8px; font-size: 11px; color: var(--text-tertiary); background: #F5F5F7; cursor: pointer; }
.tf.active { background: #E8F2FF; color: var(--color-brand); font-weight: 500; }
.card-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #F5F5F7; padding-top: 12px; font-size: 12px; }
.support-resist { color: var(--text-tertiary); }
.support-resist span { margin-right: 8px; }
.support-resist .red { color: var(--color-up); }
.support-resist .green { color: var(--color-down); }
.link { color: var(--color-brand); font-weight: 500; text-decoration: none; }
.link:hover { text-decoration: underline; }
</style>
</head>
<body>
<header class="header">
<div class="logo">◈ 期货智析</div>
<div class="nav-items">
<div class="nav-item active">品种分析</div>
<div class="nav-item">自选</div>
<div class="nav-item">市场概览</div>
<div class="nav-item">风险预警</div>
</div>
<div class="live-badge">
<div class="dot"></div> LIVE
</div>
</header>
<div class="container">
<!-- Toolbar -->
<div class="toolbar">
<input type="text" class="search-box" placeholder="🔍 搜索品种名称或代码...">
<div class="pills">
<div class="pill active">全部 32</div>
<div class="pill">能源</div>
<div class="pill">金属</div>
<div class="pill">农产品</div>
<div class="pill">金融</div>
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon blue"></div>
<div><div class="stat-val">32</div><div class="stat-label">监控品种</div></div>
</div>
<div class="stat-card">
<div class="stat-icon green"></div>
<div><div class="stat-val">7</div><div class="stat-label">上涨趋势</div></div>
</div>
<div class="stat-card">
<div class="stat-icon red"></div>
<div><div class="stat-val">10</div><div class="stat-label">下跌趋势</div></div>
</div>
<div class="stat-card">
<div class="stat-icon orange"></div>
<div><div class="stat-val">15</div><div class="stat-label">震荡整理</div></div>
</div>
</div>
<!-- Grid -->
<div class="grid">
<!-- Card 1: Crude Oil -->
<div class="card">
<div class="card-header">
<div>
<div class="code-box">SC</div>
<div style="font-weight:600;">原油</div>
<div style="font-size:12px; color:var(--text-tertiary);">SC2606</div>
</div>
<div class="price-area">
<div class="price down">¥644.5</div>
<div class="change down">↓ -13.3 (-2.02%)</div>
</div>
</div>
<div class="action-pill watch">观望</div>
<div class="metrics">
<div class="metric">
<div class="metric-label">成功率 60%</div>
<div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div>
</div>
<div class="metric">
<div class="metric-label">趋势评分 30</div>
<div class="bar-bg"><div class="bar-fill" style="width:30%; background:var(--color-up);"></div></div>
</div>
</div>
<div class="timeframes">
<div class="tf">5M</div>
<div class="tf active">15M</div>
<div class="tf">30M</div>
<div class="tf">1H</div>
</div>
<div class="card-footer">
<div class="support-resist">
<span>压力 <b class="red">642.4</b></span>
<span>支撑 <b class="green">631.7</b></span>
</div>
<a href="#" class="link">详情 →</a>
</div>
</div>
<!-- Card 2: Fuel Oil -->
<div class="card">
<div class="card-header">
<div>
<div class="code-box">FU</div>
<div style="font-weight:600;">燃油</div>
<div style="font-size:12px; color:var(--text-tertiary);">FU2606</div>
</div>
<div class="price-area">
<div class="price up">¥4,680</div>
<div class="change up">↑ +72 (+1.56%)</div>
</div>
</div>
<div class="action-pill do-more">做多</div>
<div class="metrics">
<div class="metric">
<div class="metric-label">成功率 85%</div>
<div class="bar-bg"><div class="bar-fill" style="width:85%; background:var(--color-down);"></div></div>
</div>
<div class="metric">
<div class="metric-label">趋势评分 65</div>
<div class="bar-bg"><div class="bar-fill" style="width:65%; background:var(--color-down);"></div></div>
</div>
</div>
<div class="timeframes">
<div class="tf">5M</div>
<div class="tf active">15M</div>
<div class="tf">30M</div>
<div class="tf">1H</div>
</div>
<div class="card-footer">
<div class="support-resist">
<span>压力 <b class="red">4,600</b></span>
<span>支撑 <b class="green">4,530</b></span>
</div>
<a href="#" class="link">详情 →</a>
</div>
</div>
<!-- Card 3: Silver -->
<div class="card">
<div class="card-header">
<div>
<div class="code-box">AG</div>
<div style="font-weight:600;">沪银</div>
<div style="font-size:12px; color:var(--text-tertiary);">AG2606</div>
</div>
<div class="price-area">
<div class="price up">¥18,643</div>
<div class="change up">↑ +413 (+2.27%)</div>
</div>
</div>
<div class="action-pill do-more">做多 (短线反弹)</div>
<div class="metrics">
<div class="metric">
<div class="metric-label">成功率 60%</div>
<div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div>
</div>
<div class="metric">
<div class="metric-label">趋势评分 60</div>
<div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div>
</div>
</div>
<div class="timeframes">
<div class="tf">5M</div>
<div class="tf active">15M</div>
<div class="tf">30M</div>
<div class="tf">1H</div>
</div>
<div class="card-footer">
<div class="support-resist">
<span>压力 <b class="red">18,685</b></span>
<span>支撑 <b class="green">18,584</b></span>
</div>
<a href="#" class="link">详情 →</a>
</div>
</div>
<!-- Card 4: Gold -->
<div class="card">
<div class="card-header">
<div>
<div class="code-box">AU</div>
<div style="font-weight:600;">沪金</div>
<div style="font-size:12px; color:var(--text-tertiary);">AU2606</div>
</div>
<div class="price-area">
<div class="price up">¥993.34</div>
<div class="change up">↑ +4.36 (+0.44%)</div>
</div>
</div>
<div class="action-pill watch">观望</div>
<div class="metrics">
<div class="metric">
<div class="metric-label">成功率 60%</div>
<div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div>
</div>
<div class="metric">
<div class="metric-label">趋势评分 40</div>
<div class="bar-bg"><div class="bar-fill" style="width:40%; background:var(--color-up);"></div></div>
</div>
</div>
<div class="timeframes">
<div class="tf">5M</div>
<div class="tf active">15M</div>
<div class="tf">30M</div>
<div class="tf">1H</div>
</div>
<div class="card-footer">
<div class="support-resist">
<span>压力 <b class="red">993</b></span>
<span>支撑 <b class="green">989.5</b></span>
</div>
<a href="#" class="link">详情 →</a>
</div>
</div>
<!-- Card 5: Copper -->
<div class="card">
<div class="card-header">
<div>
<div class="code-box">CU</div>
<div style="font-weight:600;">沪铜</div>
<div style="font-size:12px; color:var(--text-tertiary);">CU2606</div>
</div>
<div class="price-area">
<div class="price up">¥104,770</div>
<div class="change up">↑ +1,090 (+1.05%)</div>
</div>
</div>
<div class="action-pill do-more">做多</div>
<div class="metrics">
<div class="metric">
<div class="metric-label">成功率 85%</div>
<div class="bar-bg"><div class="bar-fill" style="width:85%; background:var(--color-down);"></div></div>
</div>
<div class="metric">
<div class="metric-label">趋势评分 65</div>
<div class="bar-bg"><div class="bar-fill" style="width:65%; background:var(--color-down);"></div></div>
</div>
</div>
<div class="timeframes">
<div class="tf">5M</div>
<div class="tf active">15M</div>
<div class="tf">30M</div>
<div class="tf">1H</div>
</div>
<div class="card-footer">
<div class="support-resist">
<span>压力 <b class="red">105,250</b></span>
<span>支撑 <b class="green">104,450</b></span>
</div>
<a href="#" class="link">详情 →</a>
</div>
</div>
<!-- Card 6: Nickel -->
<div class="card">
<div class="card-header">
<div>
<div class="code-box">NI</div>
<div style="font-weight:600;">沪镍</div>
<div style="font-size:12px; color:var(--text-tertiary);">NI2606</div>
</div>
<div class="price-area">
<div class="price down">¥143,330</div>
<div class="change down">↓ -440 (-0.31%)</div>
</div>
</div>
<div class="action-pill watch">观望</div>
<div class="metrics">
<div class="metric">
<div class="metric-label">成功率 60%</div>
<div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div>
</div>
<div class="metric">
<div class="metric-label">趋势评分 45</div>
<div class="bar-bg"><div class="bar-fill" style="width:45%; background:var(--color-up);"></div></div>
</div>
</div>
<div class="timeframes">
<div class="tf">5M</div>
<div class="tf active">15M</div>
<div class="tf">30M</div>
<div class="tf">1H</div>
</div>
<div class="card-footer">
<div class="support-resist">
<span>压力 <b class="red">144,550</b></span>
<span>支撑 <b class="green">143,940</b></span>
</div>
<a href="#" class="link">详情 →</a>
</div>
</div>
</div>
</div>
</body>
</html>

@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - 品种详情页 (Apple Style)</title>
<style>
:root {
--bg-page: #F5F5F7;
--bg-card: #FFFFFF;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-tertiary: #86868B;
--border-light: rgba(0,0,0,0.05);
/* China Market: Red=Up, Green=Down */
--color-up: #FF3B30;
--color-down: #34C759;
--color-brand: #007AFF;
--color-ai: #AF52DE;
--color-ai-bg: rgba(175, 82, 222, 0.08);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--radius-card: 16px;
--radius-btn: 8px;
--radius-pill: 9999px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", sans-serif;
background-color: var(--bg-page);
color: var(--text-primary);
padding-bottom: 40px;
}
/* Header */
.header {
height: 64px;
background: rgba(255,255,255,0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn {
font-size: 14px;
color: var(--color-brand);
font-weight: 500;
cursor: pointer;
margin-right: 20px;
display: flex;
align-items: center;
gap: 4px;
}
.header-title { font-size: 18px; font-weight: 600; }
.header-actions { margin-left: auto; display: flex; gap: 12px; }
.icon-btn { width: 32px; height: 32px; border-radius: 50%; background: #F5F5F7; display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-secondary); }
/* Main Content */
.container { max-width: 1200px; margin: 0 auto; padding: 24px; display: grid; grid-template-columns: 1fr 340px; gap: 24px; }
/* Info Bar */
.info-bar {
grid-column: 1 / -1;
background: var(--bg-card);
border-radius: var(--radius-card);
padding: 20px 24px;
box-shadow: var(--shadow-sm);
display: flex;
justify-content: space-between;
align-items: center;
}
.info-left { display: flex; align-items: baseline; gap: 16px; }
.symbol { font-size: 20px; font-weight: 700; }
.contract { font-size: 13px; color: var(--text-tertiary); background: #F5F5F7; padding: 4px 8px; border-radius: 6px; }
.price-main { font-size: 32px; font-weight: 700; letter-spacing: -0.02em; }
.price-up { color: var(--color-up); }
.price-down { color: var(--color-down); }
.price-change { font-size: 15px; font-weight: 600; margin-left: 8px; }
.ohlc { display: flex; gap: 24px; font-size: 13px; color: var(--text-secondary); }
.ohlc span b { font-weight: 600; color: var(--text-primary); margin-left: 4px; }
/* Chart Area */
.chart-section {
background: var(--bg-card);
border-radius: var(--radius-card);
padding: 20px;
box-shadow: var(--shadow-md);
}
.chart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.timeframes { display: flex; gap: 4px; background: #F5F5F7; padding: 4px; border-radius: 8px; }
.tf-btn { padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 500; color: var(--text-tertiary); cursor: pointer; }
.tf-btn.active { background: #FFFFFF; color: var(--text-primary); box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.chart-placeholder {
height: 360px;
background: #FAFAFA;
border-radius: 12px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 14px;
}
/* Simulated Chart Grid */
.grid-line { position: absolute; background: #F0F0F0; }
.grid-h { width: 100%; height: 1px; }
.grid-v { height: 100%; width: 1px; }
/* Side Panel */
.side-panel { display: flex; flex-direction: column; gap: 20px; }
.panel-card {
background: var(--bg-card);
border-radius: var(--radius-card);
padding: 20px;
box-shadow: var(--shadow-sm);
transition: transform 0.2s;
}
.panel-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.card-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.ai-tag { font-size: 10px; background: var(--color-ai-bg); color: var(--color-ai); padding: 2px 6px; border-radius: 4px; }
.analysis-text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 16px;
background: #FAFAFA;
padding: 12px;
border-radius: 8px;
border-left: 3px solid var(--color-ai);
}
.data-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.data-item { display: flex; justify-content: space-between; font-size: 12px; }
.label { color: var(--text-tertiary); }
.val { font-weight: 600; }
.indicator-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.indicator-box { background: #F5F5F7; padding: 12px; border-radius: 10px; }
.ind-name { font-size: 11px; color: var(--text-tertiary); margin-bottom: 4px; font-weight: 500; }
.ind-val { font-size: 14px; font-weight: 700; }
.pill-row { display: flex; gap: 8px; margin-top: 12px; }
.status-pill { padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; background: #F5F5F7; color: var(--text-secondary); }
.status-pill.red { background: rgba(255,59,48,0.08); color: var(--color-up); }
.status-pill.green { background: rgba(52,199,89,0.08); color: var(--color-down); }
.status-pill.orange { background: rgba(255,159,10,0.08); color: var(--color-neutral); }
/* Key Points */
.kp-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #F5F5F7; font-size: 13px; }
.kp-row:last-child { border-bottom: none; }
.kp-tag { font-weight: 600; color: var(--text-secondary); }
.kp-val { font-weight: 700; }
/* History */
.history-bar {
grid-column: 1 / -1;
background: var(--bg-card);
border-radius: var(--radius-card);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-sm);
}
.hist-title { font-size: 14px; font-weight: 600; color: var(--text-secondary); }
.hist-link { font-size: 13px; color: var(--color-brand); font-weight: 500; cursor: pointer; }
</style>
</head>
<body>
<header class="header">
<div class="back-btn">← 返回</div>
<div class="header-title">原油 SC2606</div>
<div class="header-actions">
<div class="icon-btn">🔔</div>
<div class="icon-btn"></div>
</div>
</header>
<div class="container">
<!-- Info Bar -->
<div class="info-bar">
<div class="info-left">
<span class="symbol">SC2606</span>
<span class="contract">SC</span>
<div>
<span class="price-main price-down">644.5</span>
<span class="price-change price-down">↓ -13.3 (-2.02%)</span>
</div>
</div>
<div class="ohlc">
<span><b>657.8</b></span>
<span><b class="price-up">664.8</b></span>
<span><b class="price-down">630.2</b></span>
<span><b>1,668,293</b></span>
</div>
</div>
<!-- Chart Section -->
<div class="chart-section">
<div class="chart-header">
<div class="timeframes">
<div class="tf-btn">分时</div>
<div class="tf-btn">5M</div>
<div class="tf-btn active">15M</div>
<div class="tf-btn">30M</div>
<div class="tf-btn">1H</div>
</div>
<div style="font-size:12px; color:var(--text-tertiary);">MA5: 652.1 MA10: 655.4 MA20: 660.2</div>
</div>
<div class="chart-placeholder">
<!-- Grid Lines -->
<div class="grid-line grid-h" style="top: 20%;"></div>
<div class="grid-line grid-h" style="top: 40%;"></div>
<div class="grid-line grid-h" style="top: 60%;"></div>
<div class="grid-line grid-h" style="top: 80%;"></div>
<div class="grid-line grid-v" style="left: 20%;"></div>
<div class="grid-line grid-v" style="left: 40%;"></div>
<div class="grid-line grid-v" style="left: 60%;"></div>
<div class="grid-line grid-v" style="left: 80%;"></div>
<!-- Simulated Candles via CSS/SVG is complex, using text placeholder for prototype -->
<div style="text-align:center;">
<div style="font-size:48px; color:var(--color-down); opacity:0.1; margin-bottom:10px;">📉</div>
<div>K 线图区域 (ECharts / TradingView)</div>
<div style="font-size:12px; margin-top:4px;">15 分钟周期 | 2026-05-23</div>
</div>
</div>
</div>
<!-- Side Panel -->
<div class="side-panel">
<!-- AI Analysis -->
<div class="panel-card">
<div class="card-title">
<span> AI 思维分析</span>
<span class="ai-tag">智能</span>
</div>
<div class="analysis-text">
SC2606 呈多周期空头排列,均线全面压制且量能极度萎缩,短期 30/60 分钟 KDJ 进入超卖区但无底背离与放量配合,整体处于缩量阴跌格局。
</div>
<div class="data-grid">
<div class="data-item"><span class="label">建议方向</span><span class="val price-down">观望</span></div>
<div class="data-item"><span class="label">置信度</span><span class="val">30%</span></div>
<div class="data-item"><span class="label">入场区间</span><span class="val">640.5 - 642.5</span></div>
<div class="data-item"><span class="label">止损位</span><span class="val price-up">645.5</span></div>
</div>
</div>
<!-- Indicators -->
<div class="panel-card">
<div class="card-title"> 技术指标</div>
<div class="indicator-grid">
<div class="indicator-box">
<div class="ind-name">MACD</div>
<div class="ind-val price-down">Down</div>
<div style="font-size:10px; color:var(--text-tertiary);">零轴下</div>
</div>
<div class="indicator-box">
<div class="ind-name">KDJ</div>
<div class="ind-val" style="color:var(--color-neutral);">超卖</div>
<div style="font-size:10px; color:var(--text-tertiary);">未金叉</div>
</div>
<div class="indicator-box">
<div class="ind-name">RSI</div>
<div class="ind-val">--</div>
<div style="font-size:10px; color:var(--text-tertiary);">--</div>
</div>
<div class="indicator-box">
<div class="ind-name">BOLL</div>
<div class="ind-val">--</div>
<div style="font-size:10px; color:var(--text-tertiary);">--</div>
</div>
</div>
</div>
<!-- Key Points -->
<div class="panel-card">
<div class="card-title">📍 关键点位</div>
<div class="kp-row">
<span class="kp-tag">R1 (阻力)</span>
<span class="kp-val price-up">642.4</span>
</div>
<div class="kp-row">
<span class="kp-tag">R2 (阻力)</span>
<span class="kp-val price-up">648.8</span>
</div>
<div class="kp-row" style="background: #F5F5F7; margin: 4px -12px; padding: 8px 12px; border-radius: 6px;">
<span class="kp-tag" style="color:var(--color-brand);">PP (中枢)</span>
<span class="kp-val" style="color:var(--color-brand);">638.1</span>
</div>
<div class="kp-row">
<span class="kp-tag">S1 (支撑)</span>
<span class="kp-val price-down">631.7</span>
</div>
<div class="kp-row">
<span class="kp-tag">S2 (支撑)</span>
<span class="kp-val price-down">627.4</span>
</div>
</div>
<!-- Multi-TF -->
<div class="panel-card">
<div class="card-title">🕒 多周期趋势</div>
<div class="pill-row">
<div class="status-pill red">60M 偏空</div>
<div class="status-pill orange">30M 震荡</div>
<div class="status-pill red">15M 偏空</div>
<div class="status-pill orange">5M 震荡</div>
</div>
</div>
</div>
<!-- History Bar -->
<div class="history-bar">
<div>
<span class="hist-title">🕰 历史分析记录</span>
<span style="font-size:12px; color:var(--text-tertiary); margin-left:12px;">SC2606 呈多周期空头排列...</span>
</div>
<span class="hist-link">查看全部 →</span>
</div>
</div>
</body>
</html>

@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - AI 分析报告 (Apple Style)</title>
<style>
:root {
--bg-page: #F5F5F7;
--bg-card: #FFFFFF;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-tertiary: #86868B;
--color-up: #FF3B30;
--color-down: #34C759;
--color-neutral: #FF9F0A;
--color-brand: #007AFF;
--color-ai: #AF52DE;
--shadow-modal: 0 24px 80px rgba(0,0,0,0.15);
--radius-modal: 24px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", sans-serif;
background-color: var(--bg-page);
color: var(--text-primary);
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* Background Simulation */
.bg-blur {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect fill="%23F5F5F7" width="100" height="100"/><rect fill="%23E5E5EA" x="10" y="10" width="80" height="80" rx="16"/></svg>');
filter: blur(10px) brightness(0.95);
z-index: 0;
}
/* Modal */
.modal {
position: relative;
z-index: 10;
width: 560px;
max-height: 85vh;
background: var(--bg-card);
border-radius: var(--radius-modal);
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255,255,255,0.95);
}
.modal-title { font-size: 18px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.modal-date { font-size: 12px; color: var(--text-tertiary); font-weight: 400; }
.close-btn { width: 28px; height: 28px; background: #F5F5F7; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-secondary); font-size: 14px; }
.modal-body {
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.modal-body::-webkit-scrollbar { width: 6px; }
.modal-body::-webkit-scrollbar-thumb { background: #E5E5EA; border-radius: 3px; }
/* Quote */
.quote {
background: rgba(175, 82, 222, 0.06);
border-left: 4px solid var(--color-ai);
padding: 16px;
border-radius: 0 12px 12px 0;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
/* Section */
.section-title {
font-size: 15px;
font-weight: 700;
color: var(--color-ai);
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
/* Table */
.table-container {
border: 1px solid rgba(0,0,0,0.05);
border-radius: 12px;
overflow: hidden;
}
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: #F5F5F7; text-align: left; padding: 10px 12px; font-weight: 600; color: var(--text-secondary); }
td { padding: 12px; border-top: 1px solid rgba(0,0,0,0.05); color: var(--text-primary); }
tr:last-child td { border-bottom: none; }
.status-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.status-down { background: rgba(52,199,89,0.1); color: var(--color-down); }
.status-up { background: rgba(255,59,48,0.1); color: var(--color-up); }
.status-neutral { background: rgba(142,142,147,0.1); color: var(--text-secondary); }
/* Stats Row */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stat-box {
background: #F5F5F7;
padding: 12px;
border-radius: 12px;
text-align: center;
}
.stat-label { font-size: 11px; color: var(--text-tertiary); margin-bottom: 4px; }
.stat-val { font-size: 16px; font-weight: 700; }
.stat-val.red { color: var(--color-up); }
.stat-val.blue { color: var(--color-brand); }
/* KDJ Grid */
.kdj-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.kdj-card {
background: #F5F5F7;
padding: 12px;
border-radius: 10px;
}
.kdj-label { font-size: 11px; color: var(--text-tertiary); font-weight: 500; margin-bottom: 4px; }
.kdj-text { font-size: 13px; font-weight: 600; }
/* Key Points */
.kp-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #F5F5F7;
}
.kp-row:last-child { border-bottom: none; }
.kp-tag { font-weight: 600; color: var(--text-secondary); font-size: 13px; }
.kp-val { font-weight: 700; font-size: 15px; }
/* Risk */
.risk-item {
display: flex;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 8px;
}
.risk-icon { color: var(--color-neutral); margin-top: 2px; }
</style>
</head>
<body>
<div class="bg-blur"></div>
<div class="modal">
<div class="modal-header">
<div class="modal-title">
<span style="font-size:18px;">📄</span> SC2606 AI 分析报告
</div>
<div class="modal-date">2026/5/23 18:04</div>
<div class="close-btn"></div>
</div>
<div class="modal-body">
<!-- Quote -->
<div class="quote">
"SC2606 呈多周期空头排列,均线全面压制且量能极度萎缩,短期 30/60 分钟 KDJ 进入超卖区但无底背离与放量配合,整体处于缩量阴跌格局。建议观望或极轻仓逢高试空。"
</div>
<!-- Table -->
<div>
<div class="section-title">🔵 AI 思维分析</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>周期</th>
<th>MACD</th>
<th>成交量</th>
<th>KDJ 状态</th>
<th>结论</th>
</tr>
</thead>
<tbody>
<tr>
<td><b>60 分钟</b></td>
<td><span class="status-badge status-down">Down</span></td>
<td>极度缩量</td>
<td><span class="status-badge status-down">超卖</span></td>
<td>空头趋势明确,下跌动能衰竭但无资金承接。</td>
</tr>
<tr>
<td><b>30 分钟</b></td>
<td><span class="status-badge status-down">Down</span></td>
<td>缩量</td>
<td><span class="status-badge status-down">超卖</span></td>
<td>超卖引发技术性反弹预期,但缺乏量能验证。</td>
</tr>
<tr>
<td><b>15 分钟</b></td>
<td><span class="status-badge status-down">Down</span></td>
<td>缩量</td>
<td><span class="status-badge status-neutral">中性偏弱</span></td>
<td>反弹力度羸弱,受制于 MA10/20 压制。</td>
</tr>
<tr>
<td><b>5 分钟</b></td>
<td><span class="status-badge status-neutral">Neutral</span></td>
<td>极度缩量</td>
<td><span class="status-badge status-neutral">中性</span></td>
<td>微观结构处于无序震荡,等待放量选择方向。</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-box">
<div class="stat-label">入场区间</div>
<div class="stat-val">640.5-642.5</div>
</div>
<div class="stat-box">
<div class="stat-label">止损位</div>
<div class="stat-val red">645.5</div>
</div>
<div class="stat-box">
<div class="stat-label">建议仓位</div>
<div class="stat-val">轻仓</div>
</div>
<div class="stat-box">
<div class="stat-label">纪律评分</div>
<div class="stat-val blue">4/11</div>
</div>
</div>
<!-- KDJ -->
<div>
<div class="section-title">🟣 KDJ 诊断</div>
<div class="kdj-grid">
<div class="kdj-card">
<div class="kdj-label">当前状态</div>
<div class="kdj-text">30/60 分钟超卖区</div>
</div>
<div class="kdj-card">
<div class="kdj-label">背离情况</div>
<div class="kdj-text">无底背离 (价格新低 KDJ 未新低)</div>
</div>
<div class="kdj-card">
<div class="kdj-label">钝化</div>
<div class="kdj-text"></div>
</div>
<div class="kdj-card">
<div class="kdj-label">建议</div>
<div class="kdj-text" style="font-size:11px; font-weight:400; line-height:1.4;">超卖区仅提示反弹概率,非反转信号。严禁左侧接飞刀。</div>
</div>
</div>
</div>
<!-- Key Points -->
<div>
<div class="section-title">📍 关键点位</div>
<div style="display:flex; gap:8px;">
<div class="stat-box" style="flex:1;">
<div class="stat-label">R2</div>
<div class="stat-val red">648.8</div>
</div>
<div class="stat-box" style="flex:1;">
<div class="stat-label">R1</div>
<div class="stat-val red">642.4</div>
</div>
<div class="stat-box" style="flex:1; background:rgba(0,122,255,0.08);">
<div class="stat-label" style="color:var(--color-brand);">PP</div>
<div class="stat-val blue">638.1</div>
</div>
<div class="stat-box" style="flex:1;">
<div class="stat-label">S1</div>
<div class="stat-val" style="color:var(--color-down);">631.7</div>
</div>
<div class="stat-box" style="flex:1;">
<div class="stat-label">S2</div>
<div class="stat-val" style="color:var(--color-down);">627.4</div>
</div>
</div>
</div>
<!-- Risk -->
<div>
<div class="section-title">⚠️ 风险提示</div>
<div class="risk-item">
<span class="risk-icon"></span>
<span>技术指标具有滞后性,历史表现不代表未来。</span>
</div>
<div class="risk-item">
<span class="risk-icon"></span>
<span>需结合基本面和市场情绪综合判断。</span>
</div>
<div class="risk-item">
<span class="risk-icon"></span>
<span>SC2606 为远月合约,流动性极差,买卖价差大,实际交易滑点可能远超技术测算。</span>
</div>
</div>
</div>
</div>
</body>
</html>

@ -0,0 +1,280 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - Mobile Detail (Apple Style)</title>
<style>
:root {
--bg-page: #F5F5F7;
--bg-card: #FFFFFF;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-tertiary: #86868B;
--color-up: #FF3B30;
--color-down: #34C759;
--color-brand: #007AFF;
--color-ai: #AF52DE;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", sans-serif;
background-color: #E5E5E5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.phone {
width: 390px;
height: 844px;
background: var(--bg-page);
border-radius: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
/* Status Bar */
.status-bar { height: 44px; display: flex; justify-content: space-between; align-items: center; padding: 0 24px; font-size: 14px; font-weight: 600; color: var(--text-primary); background: var(--bg-page); z-index: 10; }
/* Nav Bar */
.nav-bar { height: 44px; padding: 0 16px; display: flex; align-items: center; justify-content: space-between; background: var(--bg-page); position: sticky; top: 0; z-index: 10; }
.nav-back { font-size: 15px; color: var(--color-brand); font-weight: 500; display: flex; align-items: center; gap: 2px; }
.nav-title { font-size: 16px; font-weight: 600; position: absolute; left: 50%; transform: translateX(-50%); }
.nav-actions { display: flex; gap: 16px; font-size: 16px; color: var(--color-brand); }
/* Content Area */
.content {
flex: 1;
overflow-y: auto;
padding-bottom: 20px;
}
.content::-webkit-scrollbar { display: none; }
/* Ticker Header */
.ticker {
padding: 16px;
background: var(--bg-card);
margin: 0 0 12px 0;
box-shadow: var(--shadow-sm);
}
.price-main { font-size: 34px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
.price-up { color: var(--color-up); }
.price-down { color: var(--color-down); }
.change-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 14px; font-weight: 600; }
.ohlc-row { display: flex; gap: 16px; margin-top: 12px; font-size: 12px; color: var(--text-tertiary); border-top: 1px solid #F5F5F7; padding-top: 10px; }
.ohlc-item b { color: var(--text-primary); font-weight: 600; margin-left: 4px; }
/* Chart */
.chart-area {
height: 260px;
background: var(--bg-card);
margin: 0 0 12px 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 14px;
}
.chart-toolbar {
position: absolute;
top: 12px;
left: 12px;
display: flex;
gap: 8px;
}
.tf-btn { padding: 4px 8px; background: rgba(255,255,255,0.8); border-radius: 6px; font-size: 11px; font-weight: 500; color: var(--text-secondary); }
.tf-btn.active { background: var(--color-brand); color: #fff; }
/* Tabs */
.tabs {
display: flex;
background: #F5F5F7;
margin: 0 16px 12px 16px;
border-radius: 8px;
padding: 2px;
}
.tab {
flex: 1;
text-align: center;
padding: 6px 0;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
}
.tab.active { background: #FFFFFF; color: var(--text-primary); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
/* Cards */
.card {
background: var(--bg-card);
margin: 0 16px 16px 16px;
padding: 16px;
border-radius: 16px;
box-shadow: var(--shadow-sm);
}
.card-title { font-size: 15px; font-weight: 700; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
.ai-tag { font-size: 10px; background: rgba(175, 82, 222, 0.1); color: var(--color-ai); padding: 2px 6px; border-radius: 4px; font-weight: 600; }
.analysis-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; background: #FAFAFA; padding: 12px; border-radius: 8px; border-left: 3px solid var(--color-ai); margin-bottom: 12px; }
.data-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.data-item { display: flex; justify-content: space-between; font-size: 13px; }
.label { color: var(--text-tertiary); }
.val { font-weight: 600; }
/* Indicators Grid */
.ind-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.ind-box { background: #F5F5F7; padding: 12px; border-radius: 10px; text-align: center; }
.ind-name { font-size: 11px; color: var(--text-tertiary); margin-bottom: 4px; }
.ind-val { font-size: 14px; font-weight: 700; }
/* Key Points */
.kp-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #F5F5F7; font-size: 13px; }
.kp-row:last-child { border-bottom: none; }
.kp-tag { font-weight: 600; color: var(--text-secondary); }
.kp-val { font-weight: 700; }
/* History */
.history-card { margin: 0 16px 40px 16px; padding: 16px; background: var(--bg-card); border-radius: 16px; box-shadow: var(--shadow-sm); display: flex; justify-content: space-between; align-items: center; }
.hist-label { font-size: 14px; font-weight: 600; }
.hist-link { font-size: 13px; color: var(--color-brand); font-weight: 500; }
</style>
</head>
<body>
<div class="phone">
<div class="status-bar">
<span>9:41</span>
<span></span>
</div>
<div class="nav-bar">
<div class="nav-back"></div>
<div class="nav-title">原油 SC2606</div>
<div class="nav-actions">
<span>🔔</span>
<span></span>
</div>
</div>
<div class="content">
<!-- Ticker -->
<div class="ticker">
<div class="price-main price-down">644.5</div>
<div class="change-row">
<span class="price-down">↓ 13.3</span>
<span class="price-down" style="background:rgba(52,199,89,0.1); padding:2px 6px; border-radius:4px;">-2.02%</span>
</div>
<div class="ohlc-row">
<div class="ohlc-item"><b>657.8</b></div>
<div class="ohlc-item"><b class="price-up">664.8</b></div>
<div class="ohlc-item"><b class="price-down">630.2</b></div>
<div class="ohlc-item"><b>1.6M</b></div>
</div>
</div>
<!-- Chart -->
<div class="chart-area">
<div class="chart-toolbar">
<div class="tf-btn">5M</div>
<div class="tf-btn active">15M</div>
<div class="tf-btn">1H</div>
<div class="tf-btn"></div>
</div>
<div style="text-align:center;">
<div style="font-size:40px; margin-bottom:8px;"></div>
<div>K 线图区域</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<div class="tab active">AI 分析</div>
<div class="tab">指标</div>
<div class="tab">点位</div>
</div>
<!-- AI Card -->
<div class="card">
<div class="card-title">
<span>🧠 思维分析</span>
<span class="ai-tag">智能</span>
</div>
<div class="analysis-text">
SC2606 呈多周期空头排列,均线全面压制且量能极度萎缩。建议观望或极轻仓逢高试空。
</div>
<div class="data-grid">
<div class="data-item"><span class="label">置信度</span><span class="val">30%</span></div>
<div class="data-item"><span class="label">止损位</span><span class="val price-up">645.5</span></div>
<div class="data-item"><span class="label">入场</span><span class="val">640.5-642.5</span></div>
<div class="data-item"><span class="label">仓位</span><span class="val">轻仓</span></div>
</div>
</div>
<!-- Indicators Card -->
<div class="card">
<div class="card-title">📊 技术指标</div>
<div class="ind-grid">
<div class="ind-box">
<div class="ind-name">MACD</div>
<div class="ind-val price-down">Down</div>
</div>
<div class="ind-box">
<div class="ind-name">KDJ</div>
<div class="ind-val" style="color:var(--color-up);">超卖</div>
</div>
<div class="ind-box">
<div class="ind-name">RSI</div>
<div class="ind-val">--</div>
</div>
<div class="ind-box">
<div class="ind-name">BOLL</div>
<div class="ind-val">--</div>
</div>
</div>
</div>
<!-- Points Card -->
<div class="card">
<div class="card-title">📍 关键点位</div>
<div class="kp-row">
<span class="kp-tag">R1</span>
<span class="kp-val price-up">642.4</span>
</div>
<div class="kp-row">
<span class="kp-tag">PP (中枢)</span>
<span class="kp-val" style="color:var(--color-brand);">638.1</span>
</div>
<div class="kp-row">
<span class="kp-tag">S1</span>
<span class="kp-val price-down">631.7</span>
</div>
</div>
<!-- History -->
<div class="history-card">
<div class="hist-label">🕰 历史分析记录</div>
<div class="hist-link">查看 ></div>
</div>
</div>
</div>
</body>
</html>

@ -0,0 +1,369 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - Mobile Prototype (Apple Style)</title>
<style>
:root {
/* v3.0 Apple Style Colors */
--bg-page: #F5F5F7;
--bg-card: #FFFFFF;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-tertiary: #86868B;
--color-up: #FF3B30;
--color-down: #34C759;
--color-neutral: #FF9F0A;
--color-brand: #007AFF;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
/* Radius */
--radius-card: 16px;
--radius-btn: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", sans-serif;
background-color: #E5E5E5; /* Darker bg to show phone */
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.phone {
width: 390px;
height: 844px;
background: var(--bg-page);
border-radius: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
/* Status Bar */
.status-bar {
height: 44px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 24px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
background: var(--bg-page);
}
/* Nav Bar */
.nav-bar {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-page);
position: sticky;
top: 0;
z-index: 10;
}
.nav-title { font-size: 20px; font-weight: 700; }
.nav-icons { display: flex; gap: 16px; }
.icon-btn { width: 32px; height: 32px; background: #FFFFFF; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow-sm); font-size: 14px; cursor: pointer;}
/* Content Scroll */
.content {
flex: 1;
overflow-y: auto;
padding: 0 20px 100px 20px; /* Padding bottom for tab bar */
}
.content::-webkit-scrollbar { display: none; }
/* Filter Pills */
.filter-row {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 12px;
margin-bottom: 12px;
}
.filter-row::-webkit-scrollbar { display: none; }
.f-pill {
padding: 6px 14px;
background: #FFFFFF;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
box-shadow: var(--shadow-sm);
}
.f-pill.active { background: var(--color-brand); color: #FFFFFF; box-shadow: 0 4px 10px rgba(0,122,255,0.3); }
/* Stats Mini */
.stats-mini {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.s-card {
flex: 1;
background: #FFFFFF;
border-radius: 12px;
padding: 12px;
text-align: center;
box-shadow: var(--shadow-sm);
}
.s-val { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.s-label { font-size: 10px; color: var(--text-tertiary); margin-top: 2px; }
.s-card.up .s-val { color: var(--color-down); }
.s-card.down .s-val { color: var(--color-up); }
/* List Cards */
.card {
background: var(--bg-card);
border-radius: var(--radius-card);
padding: 16px;
margin-bottom: 16px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
gap: 12px;
}
.card-top { display: flex; justify-content: space-between; align-items: flex-start; }
.code-badge { background: #F5F5F7; padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 4px; display: inline-block; }
.name { font-size: 16px; font-weight: 600; }
.price-col { text-align: right; }
.price { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.price.up { color: var(--color-up); }
.price.down { color: var(--color-down); }
.change { font-size: 12px; font-weight: 600; }
.change.up { color: var(--color-up); }
.change.down { color: var(--color-down); }
.action-row { display: flex; justify-content: space-between; align-items: center; }
.action-tag { padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.tag-watch { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.tag-do { background: rgba(52,199,89,0.1); color: var(--color-down); }
.score-bar { height: 3px; background: #F5F5F7; border-radius: 2px; width: 80px; overflow: hidden; }
.score-fill { height: 100%; border-radius: 2px; }
.card-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-tertiary); border-top: 1px solid #F5F5F7; padding-top: 10px; }
.meta-item span { color: var(--text-primary); font-weight: 600; }
.meta-item .red { color: var(--color-up); }
.meta-item .green { color: var(--color-down); }
/* Tab Bar */
.tab-bar {
height: 83px;
background: rgba(255,255,255,0.9);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(0,0,0,0.05);
display: flex;
justify-content: space-around;
align-items: flex-start;
padding-top: 12px;
position: absolute;
bottom: 0;
width: 100%;
}
.tab-item { display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer; }
.tab-icon { width: 24px; height: 24px; background: #E5E5EA; border-radius: 6px; }
.tab-icon.active { background: var(--color-brand); }
.tab-label { font-size: 10px; color: var(--text-tertiary); font-weight: 500; }
.tab-label.active { color: var(--color-brand); }
</style>
</head>
<body>
<div class="phone">
<!-- Status Bar -->
<div class="status-bar">
<span>9:41</span>
<span>🔋</span>
</div>
<!-- Nav Bar -->
<div class="nav-bar">
<div class="nav-title">期货智析</div>
<div class="nav-icons">
<div class="icon-btn">🔍</div>
<div class="icon-btn"></div>
</div>
</div>
<!-- Content -->
<div class="content">
<!-- Filters -->
<div class="filter-row">
<div class="f-pill active">全部</div>
<div class="f-pill">自选</div>
<div class="f-pill">能源</div>
<div class="f-pill">金属</div>
<div class="f-pill">农产品</div>
</div>
<!-- Stats -->
<div class="stats-mini">
<div class="s-card"><div class="s-val">32</div><div class="s-label">监控</div></div>
<div class="s-card up"><div class="s-val">7</div><div class="s-label">上涨</div></div>
<div class="s-card down"><div class="s-val">10</div><div class="s-label">下跌</div></div>
<div class="s-card"><div class="s-val">15</div><div class="s-label">震荡</div></div>
</div>
<!-- List -->
<div class="card">
<div class="card-top">
<div>
<div class="code-badge">SC 原油</div>
<div class="name">SC2606</div>
</div>
<div class="price-col">
<div class="price down">644.5</div>
<div class="change down">↓ 2.02%</div>
</div>
</div>
<div class="action-row">
<div class="action-tag tag-watch">观望</div>
<div style="display:flex; align-items:center; gap:6px;">
<span style="font-size:11px; color:var(--text-tertiary);">评分</span>
<div class="score-bar"><div class="score-fill" style="width:30%; background:var(--color-up);"></div></div>
</div>
</div>
<div class="card-meta">
<div class="meta-item">压力 <span class="red">642.4</span></div>
<div class="meta-item">支撑 <span class="green">631.7</span></div>
</div>
</div>
<div class="card">
<div class="card-top">
<div>
<div class="code-badge">FU 燃油</div>
<div class="name">FU2606</div>
</div>
<div class="price-col">
<div class="price up">4,680</div>
<div class="change up">↑ 1.56%</div>
</div>
</div>
<div class="action-row">
<div class="action-tag tag-do">做多</div>
<div style="display:flex; align-items:center; gap:6px;">
<span style="font-size:11px; color:var(--text-tertiary);">评分</span>
<div class="score-bar"><div class="score-fill" style="width:65%; background:var(--color-down);"></div></div>
</div>
</div>
<div class="card-meta">
<div class="meta-item">压力 <span class="red">4,600</span></div>
<div class="meta-item">支撑 <span class="green">4,530</span></div>
</div>
</div>
<div class="card">
<div class="card-top">
<div>
<div class="code-badge">AG 沪银</div>
<div class="name">AG2606</div>
</div>
<div class="price-col">
<div class="price up">18,643</div>
<div class="change up">↑ 2.27%</div>
</div>
</div>
<div class="action-row">
<div class="action-tag tag-do">做多 (反弹)</div>
<div style="display:flex; align-items:center; gap:6px;">
<span style="font-size:11px; color:var(--text-tertiary);">评分</span>
<div class="score-bar"><div class="score-fill" style="width:60%; background:var(--color-neutral);"></div></div>
</div>
</div>
<div class="card-meta">
<div class="meta-item">压力 <span class="red">18,685</span></div>
<div class="meta-item">支撑 <span class="green">18,584</span></div>
</div>
</div>
<div class="card">
<div class="card-top">
<div>
<div class="code-badge">AU 沪金</div>
<div class="name">AU2606</div>
</div>
<div class="price-col">
<div class="price up">993.34</div>
<div class="change up">↑ 0.44%</div>
</div>
</div>
<div class="action-row">
<div class="action-tag tag-watch">观望</div>
<div style="display:flex; align-items:center; gap:6px;">
<span style="font-size:11px; color:var(--text-tertiary);">评分</span>
<div class="score-bar"><div class="score-fill" style="width:40%; background:var(--color-up);"></div></div>
</div>
</div>
<div class="card-meta">
<div class="meta-item">压力 <span class="red">993</span></div>
<div class="meta-item">支撑 <span class="green">989.5</span></div>
</div>
</div>
<div class="card">
<div class="card-top">
<div>
<div class="code-badge">CU 沪铜</div>
<div class="name">CU2606</div>
</div>
<div class="price-col">
<div class="price up">104,770</div>
<div class="change up">↑ 1.05%</div>
</div>
</div>
<div class="action-row">
<div class="action-tag tag-do">做多</div>
<div style="display:flex; align-items:center; gap:6px;">
<span style="font-size:11px; color:var(--text-tertiary);">评分</span>
<div class="score-bar"><div class="score-fill" style="width:65%; background:var(--color-down);"></div></div>
</div>
</div>
<div class="card-meta">
<div class="meta-item">压力 <span class="red">105,250</span></div>
<div class="meta-item">支撑 <span class="green">104,450</span></div>
</div>
</div>
</div>
<!-- Tab Bar -->
<div class="tab-bar">
<div class="tab-item">
<div class="tab-icon active"></div>
<div class="tab-label active">首页</div>
</div>
<div class="tab-item">
<div class="tab-icon"></div>
<div class="tab-label">自选</div>
</div>
<div class="tab-item">
<div class="tab-icon"></div>
<div class="tab-label">分析</div>
</div>
<div class="tab-item">
<div class="tab-icon"></div>
<div class="tab-label">我的</div>
</div>
</div>
</div>
</body>
</html>

@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - Mobile Report (Apple Style)</title>
<style>
:root {
--bg-page: #F5F5F7;
--bg-card: #FFFFFF;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-tertiary: #86868B;
--color-up: #FF3B30;
--color-down: #34C759;
--color-brand: #007AFF;
--color-ai: #AF52DE;
--shadow-sheet: 0 -10px 40px rgba(0,0,0,0.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", sans-serif;
background-color: #E5E5E5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.phone {
width: 390px;
height: 844px;
background: var(--bg-page);
border-radius: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
/* Background Content (Blurred) */
.bg-content {
padding: 60px 20px;
filter: blur(4px) brightness(0.9);
pointer-events: none;
user-select: none;
}
.skeleton { background: #D1D1D6; border-radius: 8px; margin-bottom: 16px; }
.sk-title { height: 30px; width: 60%; margin-bottom: 24px; }
.sk-card { height: 150px; width: 100%; margin-bottom: 16px; }
.sk-text { height: 12px; width: 100%; margin-bottom: 8px; }
.sk-text.short { width: 40%; }
/* Modal Overlay */
.overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3);
z-index: 10;
}
/* Bottom Sheet */
.sheet {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-card);
border-radius: 24px 24px 0 0;
box-shadow: var(--shadow-sheet);
z-index: 20;
max-height: 85%;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
/* Handle */
.handle-bar {
width: 40px;
height: 5px;
background: #E5E5EA;
border-radius: 3px;
margin: 12px auto 0 auto;
}
/* Header */
.sheet-header {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.sheet-title { font-size: 18px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.close-btn { width: 24px; height: 24px; background: #F5F5F7; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); font-size: 12px; }
/* Body */
.sheet-body {
padding: 20px;
overflow-y: auto;
}
.sheet-body::-webkit-scrollbar { display: none; }
.quote {
background: rgba(175, 82, 222, 0.08);
border-left: 3px solid var(--color-ai);
padding: 12px;
border-radius: 0 8px 8px 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 20px;
}
.section-title { font-size: 15px; font-weight: 700; color: var(--color-ai); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
/* Table */
.table-wrap {
background: #F5F5F7;
border-radius: 12px;
padding: 2px;
margin-bottom: 20px;
}
.row {
display: flex;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid #E5E5EA;
font-size: 12px;
}
.row:last-child { border-bottom: none; }
.row-label { color: var(--text-tertiary); font-weight: 500; }
.row-val { font-weight: 700; }
.tag { padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; }
.tag-down { background: rgba(52,199,89,0.15); color: var(--color-down); }
.tag-up { background: rgba(255,59,48,0.15); color: var(--color-up); }
/* Stats Grid */
.stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 20px; }
.s-box { background: #F5F5F7; padding: 12px; border-radius: 10px; text-align: center; }
.s-label { font-size: 11px; color: var(--text-tertiary); margin-bottom: 4px; }
.s-val { font-size: 16px; font-weight: 700; }
/* Risk */
.risk-list { display: flex; flex-direction: column; gap: 8px; }
.risk-item { display: flex; gap: 8px; font-size: 12px; color: var(--text-secondary); line-height: 1.4; }
.risk-icon { color: #FF9500; font-size: 14px; }
</style>
</head>
<body>
<div class="phone">
<!-- Blurred Background -->
<div class="bg-content">
<div class="skeleton sk-title"></div>
<div class="skeleton sk-card"></div>
<div class="skeleton sk-card"></div>
<div class="skeleton sk-text"></div>
<div class="skeleton sk-text short"></div>
</div>
<!-- Overlay -->
<div class="overlay"></div>
<!-- Bottom Sheet -->
<div class="sheet">
<div class="handle-bar"></div>
<div class="sheet-header">
<div class="sheet-title">
<span></span> AI 分析报告
</div>
<div class="close-btn"></div>
</div>
<div class="sheet-body">
<div class="quote">
"SC2606 呈多周期空头排列,均线全面压制且量能极度萎缩。建议观望或极轻仓逢高试空。"
</div>
<div class="section-title">🔵 思维分析</div>
<div class="table-wrap">
<div class="row">
<span class="row-label">60M 周期</span>
<span class="row-val"><span class="tag tag-down">超卖</span> 空头趋势明确</span>
</div>
<div class="row">
<span class="row-label">30M 周期</span>
<span class="row-val"><span class="tag tag-down">超卖</span> 缺乏量能验证</span>
</div>
<div class="row">
<span class="row-label">15M 周期</span>
<span class="row-val"><span class="tag" style="background:#E5E5EA; color:#6E6E73;">中性</span> 受均线压制</span>
</div>
</div>
<div class="stats-grid">
<div class="s-box">
<div class="s-label">入场区间</div>
<div class="s-val">640.5-642.5</div>
</div>
<div class="s-box">
<div class="s-label">止损位</div>
<div class="s-val" style="color:var(--color-up);">645.5</div>
</div>
<div class="s-box">
<div class="s-label">仓位</div>
<div class="s-val">轻仓</div>
</div>
<div class="s-box">
<div class="s-label">评分</div>
<div class="s-val" style="color:var(--color-brand);">4/11</div>
</div>
</div>
<div class="section-title">⚠️ 风险提示</div>
<div class="risk-list">
<div class="risk-item">
<span class="risk-icon"></span>
<span>技术指标具有滞后性,历史表现不代表未来。</span>
</div>
<div class="risk-item">
<span class="risk-icon"></span>
<span>SC2606 为远月合约,流动性极差,注意滑点风险。</span>
</div>
</div>
<div style="height: 40px;"></div> <!-- Bottom padding for scroll -->
</div>
</div>
</div>
</body>
</html>

@ -0,0 +1,338 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>期货智析 - Android 平板版</title>
<style>
:root {
--bg-page: #F1F3F5;
--bg-card: #FFFFFF;
--bg-inset: #F8F9FA;
--text-primary: #1D1D1F;
--text-secondary: #5C5C5C;
--text-tertiary: #8E8E93;
--border-light: #E5E5EA;
--color-up: #FF3B30;
--color-down: #34C759;
--color-neutral: #FF9F0A;
--color-brand: #007AFF;
--color-ai: #AF52DE;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.1);
--radius-card: 16px;
--radius-pill: 100px;
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
body { font-family: "Roboto", "Segoe UI Variable", sans-serif; background: var(--bg-page); color: var(--text-primary); overflow-x: hidden; }
a { text-decoration: none; color: var(--color-brand); }
.top-bar { background: var(--bg-card); padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-light); position: sticky; top: 0; z-index: 100; }
.logo { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.live-badge { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--color-down); background: rgba(52,199,89,0.1); padding: 6px 12px; border-radius: var(--radius-pill); }
.dot { width: 6px; height: 6px; background: var(--color-down); border-radius: 50%; animation: pulse 2s infinite; }
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } }
.tabs { display: flex; justify-content: space-around; background: var(--bg-card); padding: 8px 12px; border-bottom: 1px solid var(--border-light); }
.tab { padding: 8px 16px; font-size: 14px; font-weight: 500; color: var(--text-secondary); border-radius: 8px; cursor: pointer; }
.tab.active { background: rgba(0,122,255,0.1); color: var(--color-brand); font-weight: 600; }
.container { padding: 16px; padding-bottom: 32px; }
.view { display: none; }
.view.active { display: block; }
/* --- Components --- */
.toolbar { display: flex; gap: 12px; margin-bottom: 20px; }
.search-box { flex: 1; background: var(--bg-inset); border: 1px solid var(--border-light); border-radius: 12px; padding: 0 16px; height: 44px; font-size: 14px; }
.pills { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.pill { padding: 8px 16px; border-radius: 12px; font-size: 14px; font-weight: 500; background: var(--bg-card); color: var(--text-secondary); cursor: pointer; white-space: nowrap; border: 1px solid var(--border-light); }
.pill.active { background: var(--color-brand); color: white; border-color: var(--color-brand); }
.stats-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 20px; }
.stat-card { background: var(--bg-card); border-radius: 12px; padding: 16px; display: flex; gap: 12px; align-items: center; box-shadow: var(--shadow-sm); }
.stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: bold; }
.stat-icon.blue { background: rgba(0,122,255,0.1); color: var(--color-brand); }
.stat-icon.green { background: rgba(52,199,89,0.1); color: var(--color-down); }
.stat-icon.red { background: rgba(255,59,48,0.1); color: var(--color-up); }
.stat-icon.orange { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.stat-val { font-size: 18px; font-weight: 700; }
.stat-label { font-size: 12px; color: var(--text-tertiary); }
.grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
.card { background: var(--bg-card); border-radius: var(--radius-card); padding: 20px; box-shadow: var(--shadow-md); }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
.card-left { display: flex; gap: 12px; align-items: flex-start; }
.code-box { width: 44px; height: 44px; background: var(--bg-inset); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; color: var(--text-secondary); }
.info-group { display: flex; flex-direction: column; }
.name-row { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 16px; }
.contract-code { font-size: 13px; color: var(--text-tertiary); margin-top: 2px; }
.price-area { text-align: right; }
.price { font-size: 28px; font-weight: 700; line-height: 1; }
.price.up { color: var(--color-up); }
.price.down { color: var(--color-down); }
.change { font-size: 14px; font-weight: 600; margin-top: 4px; }
.change.up { color: var(--color-up); }
.change.down { color: var(--color-down); }
.action-pill { display: inline-block; padding: 6px 14px; border-radius: var(--radius-pill); font-size: 13px; font-weight: 600; margin-bottom: 16px; }
.action-pill.do-more { background: rgba(52,199,89,0.1); color: var(--color-down); }
.action-pill.watch { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.metrics { display: flex; gap: 24px; margin-bottom: 16px; }
.metric { flex: 1; }
.metric-label { font-size: 12px; color: var(--text-tertiary); margin-bottom: 4px; }
.bar-bg { height: 6px; background: var(--bg-inset); border-radius: 3px; margin-top: 6px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 3px; }
.timeframes { display: flex; gap: 8px; margin-bottom: 16px; }
.tf { padding: 6px 12px; border-radius: 8px; font-size: 12px; color: var(--text-tertiary); background: var(--bg-inset); cursor: pointer; }
.tf.active { background: #E8F2FF; color: var(--color-brand); font-weight: 500; }
.card-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #F5F5F7; padding-top: 14px; font-size: 13px; }
.support-resist { color: var(--text-tertiary); }
.support-resist span { margin-right: 10px; }
.support-resist .red { color: var(--color-up); }
.support-resist .green { color: var(--color-down); }
.link { color: var(--color-brand); font-weight: 500; }
.fav-icon { font-size: 16px; color: #D1D1D6; cursor: pointer; padding: 4px; }
.fav-icon.active { color: #FF9F0A; }
/* --- Market Overview --- */
.event-banner { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 20px; }
.event-card { min-width: 220px; flex: 1; background: var(--bg-card); border-radius: 12px; padding: 16px; box-shadow: var(--shadow-sm); border: 1px solid var(--border-light); }
.event-card.bearish { border-left: 4px solid var(--color-up); }
.event-card.bullish { border-left: 4px solid var(--color-down); }
.event-header { display: flex; justify-content: space-between; margin-bottom: 6px; align-items: center; }
.event-tag { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; }
.event-tag.urgent { background: #FFF0F0; color: var(--color-up); }
.event-tag.policy { background: #E8F2FF; color: var(--color-brand); }
.event-timer { font-size: 12px; color: var(--text-tertiary); font-weight: 500; }
.event-title { font-size: 15px; font-weight: 600; margin-bottom: 6px; line-height: 1.3; }
.event-impact { display: flex; gap: 8px; align-items: center; font-size: 13px; margin-bottom: 8px; }
.impact-badge { font-size: 11px; padding: 3px 8px; border-radius: 4px; font-weight: 600; }
.impact-badge.bull { background: rgba(255,59,48,0.1); color: var(--color-up); }
.impact-badge.bear { background: rgba(52,199,89,0.1); color: var(--color-down); }
.mini-contracts { display: flex; gap: 6px; }
.mini-tag { font-size: 11px; background: var(--bg-inset); padding: 3px 8px; border-radius: 4px; color: var(--text-secondary); }
.overview-layout { display: flex; flex-direction: column; gap: 20px; }
.section-header { font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; display: flex; justify-content: space-between; }
.heatmap-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 20px; }
.heat-cell { padding: 16px; border-radius: 12px; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; min-height: 100px; }
.heat-cell .name { font-weight: 600; margin-bottom: 6px; font-size: 15px; }
.heat-cell .val { font-size: 14px; font-weight: 700; }
.heat-up-3 { background: #FF3B30; color: white; }
.heat-up-1 { background: rgba(255,59,48,0.15); color: var(--color-up); }
.heat-down-2 { background: rgba(52,199,89,0.3); color: #10803D; }
.heat-down-1 { background: rgba(52,199,89,0.1); color: var(--color-down); }
.heat-flat { background: #E8E8ED; color: var(--text-secondary); }
.limit-list { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.limit-col h4 { font-size: 12px; color: var(--text-tertiary); margin-bottom: 10px; font-weight: 600; }
.limit-row { display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 8px; }
.limit-row span:first-child { font-weight: 500; }
.limit-row span:last-child { font-weight: 600; }
.side-panel { display: flex; flex-direction: column; gap: 16px; }
.panel-box { background: var(--bg-card); border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); }
.panel-box-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
.flow-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; font-size: 14px; }
.flow-code { width: 40px; font-weight: 600; }
.flow-bar-bg { flex: 1; height: 8px; background: var(--bg-inset); border-radius: 4px; overflow: hidden; }
.flow-bar { height: 100%; border-radius: 4px; }
.flow-val { width: 60px; text-align: right; font-weight: 600; }
.calendar-item { display: flex; gap: 12px; margin-bottom: 12px; align-items: flex-start; }
.cal-time { font-size: 12px; color: var(--text-tertiary); margin-top: 2px; }
.cal-event { font-size: 14px; font-weight: 500; margin-bottom: 2px; }
.cal-stars { color: var(--color-neutral); font-size: 12px; }
/* --- Risk Alerts --- */
.alerts-layout { display: grid; grid-template-columns: 1fr; gap: 16px; }
.alerts-sidebar { background: var(--bg-card); border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); }
.rule-group { margin-bottom: 16px; }
.rule-group-title { font-size: 13px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 12px; border-bottom: 1px solid var(--border-light); padding-bottom: 6px; }
.rule-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-size: 15px; padding: 4px 0; }
.toggle { width: 44px; height: 24px; background: #E8E8ED; border-radius: 12px; position: relative; cursor: pointer; transition: background 0.2s; }
.toggle.on { background: var(--color-brand); }
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: white; border-radius: 50%; transition: transform 0.2s; box-shadow: 0 2px 4px rgba(0,0,0,0.15); }
.toggle.on::after { transform: translateX(20px); }
.alert-feed { display: flex; flex-direction: column; gap: 16px; }
.alert-filters { display: flex; gap: 8px; margin-bottom: 8px; }
.alert-card { background: var(--bg-card); border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); position: relative; overflow: hidden; }
.alert-card.urgent { border-left: 4px solid var(--color-up); }
.alert-time { font-size: 12px; color: var(--text-tertiary); margin-bottom: 6px; display: flex; justify-content: space-between; }
.alert-title { font-size: 16px; font-weight: 600; margin-bottom: 6px; }
.alert-desc { font-size: 14px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 10px; }
.alert-actions { display: flex; gap: 16px; font-size: 14px; font-weight: 500; }
.alert-actions span { color: var(--color-brand); cursor: pointer; padding: 8px 0; }
.ai-scan-card { background: linear-gradient(135deg, #AF52DE 0%, #8E44AD 100%); border-radius: 16px; padding: 16px; color: white; box-shadow: var(--shadow-md); }
.ai-scan-title { font-size: 14px; font-weight: 600; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.ai-scan-item { background: rgba(255,255,255,0.15); border-radius: 10px; padding: 10px; margin-bottom: 10px; font-size: 13px; line-height: 1.4; }
</style>
</head>
<body>
<div class="top-bar">
<div class="logo">◈ 期货智析</div>
<div class="live-badge"><div class="dot"></div> LIVE</div>
</div>
<div class="tabs">
<div class="tab active" onclick="showView('list-view', this)">品种分析</div>
<div class="tab" onclick="showView('market-view', this)">市场概览</div>
<div class="tab" onclick="showView('alerts-view', this)">风险预警</div>
</div>
<script>
function toggleFav(el) { el.classList.toggle('active'); event.stopPropagation(); }
function showView(viewId, tab) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(viewId).classList.add('active');
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
}
</script>
<!-- === List View === -->
<div id="list-view" class="view active">
<div class="container">
<div class="toolbar">
<input type="text" class="search-box" placeholder="🔍 搜索...">
</div>
<div class="pills" style="margin-bottom: 20px;">
<div class="pill active">全部</div><div class="pill">能源</div><div class="pill">金属</div><div class="pill">农产品</div>
</div>
<div class="stats-row">
<div class="stat-card"><div class="stat-icon blue"></div><div><div class="stat-val">32</div><div class="stat-label">监控品种</div></div></div>
<div class="stat-card"><div class="stat-icon green"></div><div><div class="stat-val">7</div><div class="stat-label">上涨趋势</div></div></div>
<div class="stat-card"><div class="stat-icon red"></div><div><div class="stat-val">10</div><div class="stat-label">下跌趋势</div></div></div>
<div class="stat-card"><div class="stat-icon orange"></div><div><div class="stat-val">15</div><div class="stat-label">震荡整理</div></div></div>
</div>
<div class="grid">
<div class="card">
<div class="card-header"><div class="card-left"><div class="code-box">SC</div><div class="info-group"><div class="name-row">原油 <span class="fav-icon" onclick="toggleFav(this)"></span></div><div class="contract-code">SC2606</div></div></div><div class="price-area"><div class="price down">¥644.5</div><div class="change down">↓ -2.02%</div></div></div>
<div class="action-pill watch">观望</div>
<div class="metrics"><div class="metric"><div class="metric-label">成功率 60%</div><div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div></div><div class="metric"><div class="metric-label">趋势评分 30</div><div class="bar-bg"><div class="bar-fill" style="width:30%; background:var(--color-up);"></div></div></div></div>
<div class="timeframes"><div class="tf active">15M</div><div class="tf">1H</div><div class="tf">4H</div></div>
<div class="card-footer"><div class="support-resist"><span>压力 <b class="red">642.4</b></span><span>支撑 <b class="green">631.7</b></span></div><a href="#" class="link">详情 →</a></div>
</div>
<div class="card">
<div class="card-header"><div class="card-left"><div class="code-box">AG</div><div class="info-group"><div class="name-row">沪银 <span class="fav-icon" onclick="toggleFav(this)"></span></div><div class="contract-code">AG2606</div></div></div><div class="price-area"><div class="price up">¥18,643</div><div class="change up">↑ +2.27%</div></div></div>
<div class="action-pill do-more">做多</div>
<div class="metrics"><div class="metric"><div class="metric-label">成功率 85%</div><div class="bar-bg"><div class="bar-fill" style="width:85%; background:var(--color-down);"></div></div></div><div class="metric"><div class="metric-label">趋势评分 65</div><div class="bar-bg"><div class="bar-fill" style="width:65%; background:var(--color-down);"></div></div></div></div>
<div class="timeframes"><div class="tf active">15M</div><div class="tf">1H</div><div class="tf">4H</div></div>
<div class="card-footer"><div class="support-resist"><span>压力 <b class="red">18,685</b></span><span>支撑 <b class="green">18,584</b></span></div><a href="#" class="link">详情 →</a></div>
</div>
</div>
</div>
</div>
<!-- === Market View === -->
<div id="market-view" class="view">
<div class="container">
<div class="event-banner">
<div class="event-card bearish">
<div class="event-header"><span class="event-tag urgent">🔥 突发</span><span class="event-timer">T-00:15</span></div>
<div class="event-title">EIA 原油库存意外大幅下降 500 万桶</div>
<div class="event-impact"><span class="impact-badge bull">利多原油</span></div>
<div class="mini-contracts"><span class="mini-tag">SC</span><span class="mini-tag">LU</span></div>
</div>
<div class="event-card bullish">
<div class="event-header"><span class="event-tag policy">🏛️ 政策</span><span class="event-timer">已公布</span></div>
<div class="event-title">央行宣布降准 0.25%</div>
<div class="event-impact"><span class="impact-badge bear">利多有色</span></div>
<div class="mini-contracts"><span class="mini-tag">IF</span><span class="mini-tag">CU</span></div>
</div>
</div>
<div class="section-header"><span>🔥 市场热力图</span></div>
<div class="heatmap-grid">
<div class="heat-cell heat-up-3"><div class="name">SC 原油</div><div class="val">+3.8%</div></div>
<div class="heat-cell heat-up-1"><div class="name">AG 沪银</div><div class="val">+2.8%</div></div>
<div class="heat-cell heat-down-2"><div class="name">SA 纯碱</div><div class="val">-3.1%</div></div>
<div class="heat-cell heat-down-1"><div class="name">RB 螺纹</div><div class="val">-0.5%</div></div>
<div class="heat-cell heat-flat"><div class="name">M 豆粕</div><div class="val">0.0%</div></div>
<div class="heat-cell heat-up-2"><div class="name">IF 股指</div><div class="val">+1.8%</div></div>
</div>
<div class="limit-list">
<div class="limit-col"><h4>🔴 领涨</h4><div class="limit-row"><span>SC 原油</span><span style="color:var(--color-up)">+3.8%</span></div><div class="limit-row"><span>AG 沪银</span><span style="color:var(--color-up)">+2.8%</span></div></div>
<div class="limit-col"><h4>🟢 领跌</h4><div class="limit-row"><span>SA 纯碱</span><span style="color:var(--color-down)">-3.1%</span></div><div class="limit-row"><span>FG 玻璃</span><span style="color:var(--color-down)">-1.9%</span></div></div>
</div>
<div class="side-panel" style="margin-top: 20px;">
<div class="panel-box">
<div class="panel-box-title">💰 主力资金流向</div>
<div class="flow-row"><div class="flow-code">SC</div><div class="flow-bar-bg"><div class="flow-bar" style="width:80%;background:var(--color-up);"></div></div><div class="flow-val" style="color:var(--color-up)">+5.2亿</div></div>
<div class="flow-row"><div class="flow-code">SA</div><div class="flow-bar-bg"><div class="flow-bar" style="width:70%;background:var(--color-down);"></div></div><div class="flow-val" style="color:var(--color-down)">-4.5亿</div></div>
</div>
<div class="panel-box">
<div class="panel-box-title">📅 宏观日历</div>
<div class="calendar-item"><div class="cal-time">23:30</div><div class="cal-content"><div class="cal-event">EIA 原油库存</div><div class="cal-stars">★★★</div></div></div>
<div class="calendar-item"><div class="cal-time">周五</div><div class="cal-content"><div class="cal-event">非农数据</div><div class="cal-stars">★★★★</div></div></div>
</div>
</div>
</div>
</div>
<!-- === Alerts View === -->
<div id="alerts-view" class="view">
<div class="container">
<div class="alerts-layout">
<div class="alerts-sidebar">
<div class="rule-group">
<div class="rule-group-title">价格与指标</div>
<div class="rule-item"><span>价格突破 60 日均线</span><div class="toggle on"></div></div>
<div class="rule-item"><span>MACD 金叉/死叉</span><div class="toggle on"></div></div>
</div>
<div class="rule-group">
<div class="rule-group-title">量能与事件</div>
<div class="rule-item"><span>成交量突增 > 3 倍</span><div class="toggle on"></div></div>
<div class="rule-item"><span>热点事件关联异动</span><div class="toggle"></div></div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px;">
<div class="ai-scan-card">
<div class="ai-scan-title">✦ 今日风险指数: 72/100</div>
<div class="ai-scan-item"><b>疑似洗盘</b><br>纯碱 10:45 急跌后迅速拉回,量能异常。</div>
<div class="ai-scan-item"><b>流动性枯竭</b><br>胶板 2610 连续 3 分钟无成交。</div>
</div>
<div class="alert-filters">
<div class="pill active" style="font-size:13px;padding:6px 12px;">全部</div>
<div class="pill" style="font-size:13px;padding:6px 12px;">紧急 (3)</div>
<div class="pill" style="font-size:13px;padding:6px 12px;">事件驱动</div>
</div>
<div class="alert-feed">
<div class="alert-card urgent">
<div class="alert-time">14:32 · 关联事件EIA 库存</div>
<div class="alert-title">🔥 燃油快速突破 60 日均线</div>
<div class="alert-desc">关联事件EIA 库存超预期下降 | 5 分钟涨幅 1.5%</div>
<div class="alert-actions"><span>查看详情</span><span>AI 分析</span><span>加入自选</span></div>
</div>
<div class="alert-card">
<div class="alert-time">13:15</div>
<div class="alert-title">🔴 沪银成交量异常放大</div>
<div class="alert-desc">当前成交量为过去 20 日均值的 4.2 倍</div>
<div class="alert-actions"><span>查看详情</span><span>AI 分析</span></div>
</div>
<div class="alert-card">
<div class="alert-time">11:20</div>
<div class="alert-title">螺纹 MACD 出现金叉</div>
<div class="alert-desc">15 分钟级别 MACD 快线上穿慢线</div>
<div class="alert-actions"><span>查看详情</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - Tablet Detail</title>
<style>
:root {
--bg-page: #F5F5F7;
--bg-card: #FFFFFF;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-tertiary: #86868B;
--color-up: #FF3B30;
--color-down: #34C759;
--color-brand: #007AFF;
--color-ai: #AF52DE;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", sans-serif;
background-color: #E5E5E5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.tablet {
width: 1024px;
height: 640px;
background: var(--bg-page);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
border: 8px solid #FFFFFF;
}
/* Header */
.header {
height: 56px;
background: rgba(255,255,255,0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(0,0,0,0.05);
display: flex;
align-items: center;
padding: 0 20px;
justify-content: space-between;
}
.back-btn { font-size: 14px; color: var(--color-brand); font-weight: 500; display: flex; align-items: center; gap: 4px; }
.title { font-size: 16px; font-weight: 600; position: absolute; left: 50%; transform: translateX(-50%); }
.actions { display: flex; gap: 12px; font-size: 16px; color: var(--color-brand); }
/* Content Layout */
.content {
flex: 1;
display: grid;
grid-template-columns: 1.2fr 1fr; /* Left Chart, Right Data */
gap: 16px;
padding: 16px 20px;
overflow: hidden;
}
/* Left Column (Chart) */
.chart-section {
background: var(--bg-card);
border-radius: 16px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chart-header {
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #F5F5F7;
}
.price-display { display: flex; align-items: baseline; gap: 12px; }
.price-main { font-size: 28px; font-weight: 700; color: var(--color-down); }
.price-change { font-size: 14px; font-weight: 600; color: var(--color-down); background: rgba(52,199,89,0.1); padding: 2px 6px; border-radius: 4px; }
.timeframes { display: flex; gap: 4px; background: #F5F5F7; padding: 4px; border-radius: 8px; }
.tf { padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 500; color: var(--text-tertiary); cursor: pointer; }
.tf.active { background: #FFFFFF; color: var(--text-primary); box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.chart-area {
flex: 1;
background: #FAFAFA;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 14px;
}
/* Right Column (Panels) */
.panels {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
padding-right: 4px;
}
.panels::-webkit-scrollbar { width: 4px; }
.panels::-webkit-scrollbar-thumb { background: #E5E5EA; border-radius: 2px; }
.panel-card {
background: var(--bg-card);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow-sm);
}
.panel-title { font-size: 14px; font-weight: 700; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
.ai-tag { font-size: 10px; background: rgba(175, 82, 222, 0.1); color: var(--color-ai); padding: 2px 6px; border-radius: 4px; font-weight: 600; }
.analysis-text { font-size: 12px; color: var(--text-secondary); line-height: 1.5; background: #FAFAFA; padding: 10px; border-radius: 8px; border-left: 3px solid var(--color-ai); margin-bottom: 12px; }
.data-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 8px; }
.data-label { color: var(--text-tertiary); }
.data-val { font-weight: 600; }
.ind-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.ind-box { background: #F5F5F7; padding: 10px; border-radius: 8px; text-align: center; }
.ind-name { font-size: 10px; color: var(--text-tertiary); margin-bottom: 2px; }
.ind-val { font-size: 12px; font-weight: 700; }
.kp-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #F5F5F7; font-size: 12px; }
.kp-row:last-child { border-bottom: none; }
.kp-tag { font-weight: 600; color: var(--text-secondary); }
.kp-val { font-weight: 700; }
/* History Bar */
.history-bar {
height: 48px;
background: var(--bg-card);
border-top: 1px solid rgba(0,0,0,0.05);
display: flex;
align-items: center;
padding: 0 20px;
justify-content: space-between;
font-size: 13px;
color: var(--text-secondary);
}
.link { color: var(--color-brand); font-weight: 500; }
</style>
</head>
<body>
<div class="tablet">
<div class="header">
<div class="back-btn">← 返回</div>
<div class="title">原油 SC2606</div>
<div class="actions">
<span>🔔</span>
<span></span>
</div>
</div>
<div class="content">
<!-- Left Chart -->
<div class="chart-section">
<div class="chart-header">
<div class="price-display">
<span class="price-main">644.5</span>
<span class="price-change">↓ -2.02%</span>
</div>
<div class="timeframes">
<div class="tf">5M</div>
<div class="tf active">15M</div>
<div class="tf">30M</div>
<div class="tf">1H</div>
</div>
</div>
<div class="chart-area">
<div style="text-align:center;">
<div style="font-size:40px; margin-bottom:8px;"></div>
<div>K 线图区域</div>
<div style="font-size:11px; margin-top:4px; color:#86868B;">MA5: 652 | MA10: 655 | MA20: 660</div>
</div>
</div>
</div>
<!-- Right Panels -->
<div class="panels">
<!-- AI -->
<div class="panel-card">
<div class="panel-title"><span>🧠 AI 分析</span><span class="ai-tag">智能</span></div>
<div class="analysis-text">SC2606 呈多周期空头排列,建议观望或极轻仓逢高试空。</div>
<div class="data-row"><span class="data-label">置信度</span><span class="data-val">30%</span></div>
<div class="data-row"><span class="data-label">止损位</span><span class="data-val" style="color:var(--color-up);">645.5</span></div>
</div>
<!-- Indicators -->
<div class="panel-card">
<div class="panel-title">📊 技术指标</div>
<div class="ind-grid">
<div class="ind-box"><div class="ind-name">MACD</div><div class="ind-val" style="color:var(--color-down);">Down</div></div>
<div class="ind-box"><div class="ind-name">KDJ</div><div class="ind-val" style="color:var(--color-up);">超卖</div></div>
<div class="ind-box"><div class="ind-name">RSI</div><div class="ind-val">--</div></div>
<div class="ind-box"><div class="ind-name">BOLL</div><div class="ind-val">--</div></div>
</div>
</div>
<!-- Key Points -->
<div class="panel-card">
<div class="panel-title"> 关键点位</div>
<div class="kp-row"><span class="kp-tag">R1</span><span class="kp-val" style="color:var(--color-up);">642.4</span></div>
<div class="kp-row"><span class="kp-tag">PP</span><span class="kp-val" style="color:var(--color-brand);">638.1</span></div>
<div class="kp-row"><span class="kp-tag">S1</span><span class="kp-val" style="color:var(--color-down);">631.7</span></div>
</div>
<!-- Trend -->
<div class="panel-card">
<div class="panel-title">🕒 多周期</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<span style="padding:4px 8px; background:rgba(255,59,48,0.1); color:var(--color-up); border-radius:4px; font-size:11px; font-weight:500;">60M 偏空</span>
<span style="padding:4px 8px; background:rgba(255,159,10,0.1); color:var(--color-neutral); border-radius:4px; font-size:11px; font-weight:500;">30M 震荡</span>
<span style="padding:4px 8px; background:rgba(255,59,48,0.1); color:var(--color-up); border-radius:4px; font-size:11px; font-weight:500;">15M 偏空</span>
</div>
</div>
</div>
</div>
<div class="history-bar">
<span>🕰 历史分析记录SC2606 呈多周期空头排列...</span>
<span class="link">查看全部 →</span>
</div>
</div>
</body>
</html>

@ -0,0 +1,348 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - Tablet List (Compact 3-Col)</title>
<style>
:root {
--bg-page: #F5F5F7;
--bg-card: #FFFFFF;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-tertiary: #86868B;
--color-up: #FF3B30;
--color-down: #34C759;
--color-neutral: #FF9F0A;
--color-brand: #007AFF;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--radius-card: 16px; /* Slightly smaller radius for compact */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", sans-serif;
background-color: #E5E5E5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.tablet {
width: 1024px;
height: 640px;
background: var(--bg-page);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
border: 8px solid #FFFFFF;
}
/* Header */
.header {
height: 56px;
background: rgba(255,255,255,0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(0,0,0,0.05);
display: flex;
align-items: center;
padding: 0 20px;
justify-content: space-between;
}
.logo { font-size: 16px; font-weight: 700; }
.search-bar {
background: #F5F5F7;
border-radius: 8px;
padding: 6px 12px;
width: 300px;
font-size: 13px;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 8px;
}
.user-avatar { width: 32px; height: 32px; background: #E5E5EA; border-radius: 50%; }
/* Content */
.content {
flex: 1;
padding: 16px 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Stats Row (Compact) */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stat-card {
background: var(--bg-card);
border-radius: 12px;
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: var(--shadow-sm);
}
.stat-icon {
width: 32px; height: 32px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px;
}
.stat-icon.blue { background: rgba(0,122,255,0.1); color: var(--color-brand); }
.stat-icon.green { background: rgba(52,199,89,0.1); color: var(--color-down); }
.stat-icon.red { background: rgba(255,59,48,0.1); color: var(--color-up); }
.stat-icon.orange { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.stat-val { font-size: 18px; font-weight: 700; line-height: 1.2; }
.stat-label { font-size: 11px; color: var(--text-secondary); }
/* Filters */
.filters { display: flex; gap: 8px; }
.pill {
padding: 6px 14px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
background: #FFFFFF;
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
}
.pill.active { background: var(--color-brand); color: #FFFFFF; }
/* Grid (3 Columns) */
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
/* Compact Card */
.card {
background: var(--bg-card);
border-radius: var(--radius-card);
padding: 16px;
box-shadow: var(--shadow-sm);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 12px;
}
.card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; }
.code-box {
width: 32px; height: 32px;
background: #F5F5F7;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; color: var(--text-tertiary);
}
.price-area { text-align: right; }
.price { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; line-height: 1; }
.price.up { color: var(--color-up); }
.price.down { color: var(--color-down); }
.change { font-size: 11px; font-weight: 600; margin-top: 2px; }
.change.up { color: var(--color-up); }
.change.down { color: var(--color-down); }
.action-pill {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
width: fit-content;
}
.action-pill.do-more { background: rgba(52,199,89,0.1); color: var(--color-down); }
.action-pill.watch { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.metrics { display: flex; gap: 12px; }
.metric { flex: 1; }
.metric-label { font-size: 10px; color: var(--text-tertiary); margin-bottom: 4px; display: flex; justify-content: space-between; }
.bar-bg { height: 3px; background: #F5F5F7; border-radius: 2px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 2px; }
.timeframes { display: flex; gap: 6px; }
.tf { padding: 3px 8px; border-radius: 6px; font-size: 10px; color: var(--text-tertiary); background: #F5F5F7; }
.tf.active { background: #E8F2FF; color: var(--color-brand); font-weight: 500; }
.card-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #F5F5F7; padding-top: 10px; font-size: 11px; }
.support-resist { color: var(--text-tertiary); }
.support-resist span { margin-right: 8px; }
.support-resist .red { color: var(--color-up); }
.support-resist .green { color: var(--color-down); }
.link { color: var(--color-brand); font-weight: 500; text-decoration: none; }
/* Bottom Nav */
.nav-bar {
height: 64px;
background: rgba(255,255,255,0.95);
border-top: 1px solid rgba(0,0,0,0.05);
display: flex;
justify-content: space-around;
align-items: center;
}
.nav-item { display: flex; flex-direction: column; align-items: center; gap: 2px; color: var(--text-tertiary); font-size: 10px; }
.nav-item.active { color: var(--color-brand); }
.nav-icon { width: 20px; height: 20px; background: #E5E5EA; border-radius: 4px; }
.nav-item.active .nav-icon { background: var(--color-brand); }
.fab {
width: 48px; height: 48px;
background: var(--color-brand);
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
color: white; font-size: 20px;
box-shadow: 0 4px 12px rgba(0,122,255,0.3);
margin-top: -24px;
}
</style>
</head>
<body>
<div class="tablet">
<div class="header">
<div class="logo">◈ 期货智析</div>
<div class="search-bar">🔍 搜索品种...</div>
<div class="user-avatar"></div>
</div>
<div class="content">
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon blue"></div>
<div><div class="stat-val">32</div><div class="stat-label">监控</div></div>
</div>
<div class="stat-card">
<div class="stat-icon green"></div>
<div><div class="stat-val">7</div><div class="stat-label">上涨</div></div>
</div>
<div class="stat-card">
<div class="stat-icon red"></div>
<div><div class="stat-val">10</div><div class="stat-label">下跌</div></div>
</div>
<div class="stat-card">
<div class="stat-icon orange"></div>
<div><div class="stat-val">15</div><div class="stat-label">震荡</div></div>
</div>
</div>
<div class="filters">
<div class="pill active">全部</div>
<div class="pill">能源</div>
<div class="pill">金属</div>
<div class="pill">农产品</div>
</div>
<div class="grid">
<!-- 6 Cards -->
<div class="card">
<div class="card-header">
<div class="code-box">SC</div>
<div class="price-area"><div class="price down">644.5</div><div class="change down">↓ 2.02%</div></div>
</div>
<div class="action-pill watch">观望</div>
<div class="metrics">
<div class="metric"><div class="metric-label">成功率 <b>60%</b></div><div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div></div>
<div class="metric"><div class="metric-label">评分 <b>30</b></div><div class="bar-bg"><div class="bar-fill" style="width:30%; background:var(--color-up);"></div></div></div>
</div>
<div class="timeframes"><div class="tf">5M</div><div class="tf active">15M</div><div class="tf">30M</div></div>
<div class="card-footer"><div class="support-resist"><span><b class="red">642</b></span><span><b class="green">631</b></span></div><a href="#" class="link">详情 →</a></div>
</div>
<div class="card">
<div class="card-header">
<div class="code-box">FU</div>
<div class="price-area"><div class="price up">4,680</div><div class="change up">↑ 1.56%</div></div>
</div>
<div class="action-pill do-more">做多</div>
<div class="metrics">
<div class="metric"><div class="metric-label">成功率 <b>85%</b></div><div class="bar-bg"><div class="bar-fill" style="width:85%; background:var(--color-down);"></div></div></div>
<div class="metric"><div class="metric-label">评分 <b>65</b></div><div class="bar-bg"><div class="bar-fill" style="width:65%; background:var(--color-down);"></div></div></div>
</div>
<div class="timeframes"><div class="tf">5M</div><div class="tf active">15M</div><div class="tf">30M</div></div>
<div class="card-footer"><div class="support-resist"><span><b class="red">4.6k</b></span><span><b class="green">4.5k</b></span></div><a href="#" class="link">详情 →</a></div>
</div>
<div class="card">
<div class="card-header">
<div class="code-box">AG</div>
<div class="price-area"><div class="price up">18,643</div><div class="change up">↑ 2.27%</div></div>
</div>
<div class="action-pill do-more">做多</div>
<div class="metrics">
<div class="metric"><div class="metric-label">成功率 <b>60%</b></div><div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div></div>
<div class="metric"><div class="metric-label">评分 <b>60</b></div><div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div></div>
</div>
<div class="timeframes"><div class="tf">5M</div><div class="tf active">15M</div><div class="tf">30M</div></div>
<div class="card-footer"><div class="support-resist"><span><b class="red">18.6k</b></span><span><b class="green">18.5k</b></span></div><a href="#" class="link">详情 →</a></div>
</div>
<div class="card">
<div class="card-header">
<div class="code-box">AU</div>
<div class="price-area"><div class="price up">993.34</div><div class="change up">↑ 0.44%</div></div>
</div>
<div class="action-pill watch">观望</div>
<div class="metrics">
<div class="metric"><div class="metric-label">成功率 <b>60%</b></div><div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div></div>
<div class="metric"><div class="metric-label">评分 <b>40</b></div><div class="bar-bg"><div class="bar-fill" style="width:40%; background:var(--color-up);"></div></div></div>
</div>
<div class="timeframes"><div class="tf">5M</div><div class="tf active">15M</div><div class="tf">30M</div></div>
<div class="card-footer"><div class="support-resist"><span><b class="red">993</b></span><span><b class="green">989</b></span></div><a href="#" class="link">详情 →</a></div>
</div>
<div class="card">
<div class="card-header">
<div class="code-box">CU</div>
<div class="price-area"><div class="price up">104,770</div><div class="change up">↑ 1.05%</div></div>
</div>
<div class="action-pill do-more">做多</div>
<div class="metrics">
<div class="metric"><div class="metric-label">成功率 <b>85%</b></div><div class="bar-bg"><div class="bar-fill" style="width:85%; background:var(--color-down);"></div></div></div>
<div class="metric"><div class="metric-label">评分 <b>65</b></div><div class="bar-bg"><div class="bar-fill" style="width:65%; background:var(--color-down);"></div></div></div>
</div>
<div class="timeframes"><div class="tf">5M</div><div class="tf active">15M</div><div class="tf">30M</div></div>
<div class="card-footer"><div class="support-resist"><span><b class="red">105k</b></span><span><b class="green">104k</b></span></div><a href="#" class="link">详情 →</a></div>
</div>
<div class="card">
<div class="card-header">
<div class="code-box">NI</div>
<div class="price-area"><div class="price down">143,330</div><div class="change down">↓ 0.31%</div></div>
</div>
<div class="action-pill watch">观望</div>
<div class="metrics">
<div class="metric"><div class="metric-label">成功率 <b>60%</b></div><div class="bar-bg"><div class="bar-fill" style="width:60%; background:var(--color-neutral);"></div></div></div>
<div class="metric"><div class="metric-label">评分 <b>45</b></div><div class="bar-bg"><div class="bar-fill" style="width:45%; background:var(--color-up);"></div></div></div>
</div>
<div class="timeframes"><div class="tf">5M</div><div class="tf active">15M</div><div class="tf">30M</div></div>
<div class="card-footer"><div class="support-resist"><span><b class="red">144k</b></span><span><b class="green">143k</b></span></div><a href="#" class="link">详情 →</a></div>
</div>
</div>
</div>
<div class="nav-bar">
<div class="nav-item active"><div class="nav-icon"></div><span>首页</span></div>
<div class="nav-item"><div class="nav-icon"></div><span>自选</span></div>
<div class="fab">+</div>
<div class="nav-item"><div class="nav-icon"></div><span>分析</span></div>
<div class="nav-item"><div class="nav-icon"></div><span>我的</span></div>
</div>
</div>
</body>
</html>

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货智析 - Tablet Report (Right Drawer)</title>
<style>
:root {
--bg-page: #F5F5F7;
--bg-card: #FFFFFF;
--text-primary: #1D1D1F;
--text-secondary: #6E6E73;
--text-tertiary: #86868B;
--color-up: #FF3B30;
--color-down: #34C759;
--color-brand: #007AFF;
--color-ai: #AF52DE;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", sans-serif;
background-color: #E5E5E5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.tablet {
width: 1024px;
height: 640px;
background: var(--bg-page);
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
border: 8px solid #FFFFFF;
}
/* Background (Blurred) */
.bg-content {
padding: 60px 20px;
filter: blur(4px) brightness(0.9);
pointer-events: none;
}
.skeleton { background: #D1D1D6; border-radius: 8px; margin-bottom: 16px; }
.sk-title { height: 30px; width: 40%; margin-bottom: 24px; }
.sk-card { height: 120px; width: 100%; margin-bottom: 16px; }
.sk-text { height: 12px; width: 100%; margin-bottom: 8px; }
/* Right Drawer */
.drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 450px;
background: var(--bg-card);
box-shadow: -10px 0 40px rgba(0,0,0,0.1);
z-index: 20;
display: flex;
flex-direction: column;
animation: slideLeft 0.3s ease-out;
}
@keyframes slideLeft {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.drawer-header {
padding: 20px;
border-bottom: 1px solid rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.drawer-title { font-size: 18px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.close-btn { width: 28px; height: 28px; background: #F5F5F7; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); font-size: 14px; cursor: pointer; }
.drawer-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.drawer-body::-webkit-scrollbar { width: 4px; }
.drawer-body::-webkit-scrollbar-thumb { background: #E5E5EA; border-radius: 2px; }
.quote {
background: rgba(175, 82, 222, 0.08);
border-left: 3px solid var(--color-ai);
padding: 12px;
border-radius: 0 8px 8px 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 20px;
}
.section-title { font-size: 14px; font-weight: 700; color: var(--color-ai); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
/* Table */
.table-wrap { background: #F5F5F7; border-radius: 10px; padding: 2px; margin-bottom: 20px; }
.row { display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid #E5E5EA; font-size: 12px; }
.row:last-child { border-bottom: none; }
.row-label { color: var(--text-tertiary); font-weight: 500; }
.row-val { font-weight: 700; }
.tag { padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; }
.tag-down { background: rgba(52,199,89,0.15); color: var(--color-down); }
.tag-up { background: rgba(255,59,48,0.15); color: var(--color-up); }
/* Stats Grid */
.stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 20px; }
.s-box { background: #F5F5F7; padding: 12px; border-radius: 10px; text-align: center; }
.s-label { font-size: 11px; color: var(--text-tertiary); margin-bottom: 4px; }
.s-val { font-size: 16px; font-weight: 700; }
/* Risk */
.risk-list { display: flex; flex-direction: column; gap: 8px; }
.risk-item { display: flex; gap: 8px; font-size: 12px; color: var(--text-secondary); line-height: 1.4; }
.risk-icon { color: #FF9500; font-size: 14px; }
</style>
</head>
<body>
<div class="tablet">
<!-- Blurred Background -->
<div class="bg-content">
<div class="skeleton sk-title"></div>
<div class="skeleton sk-card"></div>
<div class="skeleton sk-card"></div>
<div class="skeleton sk-text"></div>
<div class="skeleton sk-text"></div>
</div>
<!-- Right Drawer -->
<div class="drawer">
<div class="drawer-header">
<div class="drawer-title">
<span></span> AI 分析报告
</div>
<div class="close-btn"></div>
</div>
<div class="drawer-body">
<div class="quote">
"SC2606 呈多周期空头排列,均线全面压制且量能极度萎缩。建议观望或极轻仓逢高试空。"
</div>
<div class="section-title">🔵 思维分析</div>
<div class="table-wrap">
<div class="row">
<span class="row-label">60M 周期</span>
<span class="row-val"><span class="tag tag-down">超卖</span> 空头趋势明确</span>
</div>
<div class="row">
<span class="row-label">30M 周期</span>
<span class="row-val"><span class="tag tag-down">超卖</span> 缺乏量能验证</span>
</div>
<div class="row">
<span class="row-label">15M 周期</span>
<span class="row-val"><span class="tag" style="background:#E5E5EA; color:#6E6E73;">中性</span> 受均线压制</span>
</div>
</div>
<div class="stats-grid">
<div class="s-box">
<div class="s-label">入场区间</div>
<div class="s-val">640.5-642.5</div>
</div>
<div class="s-box">
<div class="s-label">止损位</div>
<div class="s-val" style="color:var(--color-up);">645.5</div>
</div>
<div class="s-box">
<div class="s-label">仓位</div>
<div class="s-val">轻仓</div>
</div>
<div class="s-box">
<div class="s-label">评分</div>
<div class="s-val" style="color:var(--color-brand);">4/11</div>
</div>
</div>
<div class="section-title">⚠️ 风险提示</div>
<div class="risk-list">
<div class="risk-item">
<span class="risk-icon"></span>
<span>技术指标具有滞后性,历史表现不代表未来。</span>
</div>
<div class="risk-item">
<span class="risk-icon"></span>
<span>SC2606 为远月合约,流动性极差,注意滑点风险。</span>
</div>
</div>
<div style="height: 40px;"></div>
</div>
</div>
</div>
</body>
</html>

@ -0,0 +1,381 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1440, initial-scale=1.0">
<title>期货智析 - Windows 桌面端 (完整版)</title>
<style>
:root {
--bg-mica: #F3F3F3; --bg-solid: #FFFFFF; --bg-hover: rgba(0,0,0,0.03); --bg-active: rgba(0,122,255,0.06);
--bg-overlay: rgba(0,0,0,0.35); --border-light: #E5E5E5; --border-focus: #007AFF;
--text-primary: #1A1A1A; --text-secondary: #616161; --text-tertiary: #8E8E93;
--color-up: #FF3B30; --color-down: #34C759; --color-neutral: #FF9F0A; --color-brand: #007AFF; --color-ai: #AF52DE;
--shadow-card: 0 2px 6px rgba(0,0,0,0.04), 0 0 1px rgba(0,0,0,0.06);
--shadow-dialog: 0 16px 48px rgba(0,0,0,0.18), 0 4px 12px rgba(0,0,0,0.1);
--radius-window: 8px; --radius-card: 6px; --radius-dialog: 8px;
--font-win: "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-win); background: #E8E8E8; color: var(--text-primary); display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 40px; }
.app-window { width: 1440px; height: 900px; background: var(--bg-mica); border-radius: var(--radius-window); box-shadow: 0 24px 48px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.1); display: flex; flex-direction: column; overflow: hidden; }
/* 标题栏 */
.title-bar { height: 32px; background: var(--bg-solid); border-bottom: 1px solid var(--border-light); display: flex; align-items: center; justify-content: space-between; padding: 0 0 0 12px; user-select: none; }
.title-left { display: flex; align-items: center; gap: 10px; font-size: 12px; font-weight: 500; }
.app-icon { width: 14px; height: 14px; background: var(--color-brand); border-radius: 3px; }
.window-controls { display: flex; height: 100%; }
.win-btn { width: 46px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.win-btn:hover { background: var(--bg-hover); } .win-btn.close:hover { background: #C42B1C; color: white; }
.win-btn svg { width: 10px; height: 10px; }
/* 布局 */
.main-layout { flex: 1; display: flex; overflow: hidden; }
.sidebar { width: 220px; background: var(--bg-solid); border-right: 1px solid var(--border-light); display: flex; flex-direction: column; padding: 8px 0; }
.nav-section { padding: 4px 12px; margin-bottom: 8px; }
.nav-label { font-size: 11px; font-weight: 600; color: var(--text-tertiary); padding: 4px 8px; margin-bottom: 4px; }
.nav-item { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 4px; cursor: pointer; font-size: 13px; color: var(--text-secondary); transition: all 0.15s; }
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); } .nav-item.active { background: var(--bg-active); color: var(--color-brand); font-weight: 500; }
.nav-badge { margin-left: auto; font-size: 10px; background: var(--bg-hover); padding: 1px 6px; border-radius: 10px; color: var(--text-tertiary); }
.nav-item.active .nav-badge { background: rgba(0,122,255,0.1); color: var(--color-brand); }
.sidebar-footer { margin-top: auto; padding: 12px; border-top: 1px solid var(--border-light); }
.status-pill { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-tertiary); padding: 4px 8px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--color-down); }
.content-area { display: none; flex-direction: column; flex: 1; overflow: hidden; } .content-area.active { display: flex; }
.scroll-content { flex: 1; overflow-y: auto; padding: 16px; }
.scroll-content::-webkit-scrollbar { width: 6px; } .scroll-content::-webkit-scrollbar-thumb { background: #C1C1C1; border-radius: 3px; }
/* 通用组件 */
.toolbar { height: 48px; background: var(--bg-mica); border-bottom: 1px solid var(--border-light); display: flex; align-items: center; gap: 12px; padding: 0 16px; }
.search-box { flex: 1; max-width: 320px; background: var(--bg-solid); border: 1px solid var(--border-light); border-radius: 4px; padding: 6px 10px; font-size: 13px; outline: none; }
.search-box:focus { border-color: var(--border-focus); } .search-box::placeholder { color: var(--text-tertiary); }
.filter-pills { display: flex; gap: 6px; }
.pill { padding: 4px 12px; border-radius: 4px; font-size: 12px; background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); cursor: pointer; }
.pill:hover { background: var(--bg-hover); } .pill.active { background: var(--color-brand); color: white; border-color: var(--color-brand); }
.toolbar-actions { margin-left: auto; display: flex; gap: 8px; }
.tool-btn { width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-light); background: var(--bg-solid); cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); }
.tool-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
.card { background: var(--bg-solid); border: 1px solid var(--border-light); border-radius: var(--radius-card); padding: 14px; cursor: pointer; transition: all 0.15s; }
.card:hover { border-color: #D0D0D0; box-shadow: var(--shadow-card); }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.card-left { display: flex; gap: 10px; align-items: center; }
.code-box { width: 34px; height: 34px; background: var(--bg-mica); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; color: var(--text-tertiary); border: 1px solid var(--border-light); }
.info-group { display: flex; flex-direction: column; } .name-row { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 4px; } .contract-code { font-size: 11px; color: var(--text-tertiary); }
.price-area { text-align: right; } .price { font-size: 20px; font-weight: 700; } .price.up { color: var(--color-up); } .price.down { color: var(--color-down); } .change { font-size: 11px; font-weight: 600; margin-top: 2px; } .change.up { color: var(--color-up); } .change.down { color: var(--color-down); }
.action-pill { display: inline-block; padding: 3px 10px; border-radius: 3px; font-size: 11px; font-weight: 600; margin-bottom: 10px; } .action-pill.do-more { background: rgba(52,199,89,0.1); color: var(--color-down); } .action-pill.watch { background: rgba(255,159,10,0.1); color: var(--color-neutral); }
.metrics { display: flex; gap: 16px; margin-bottom: 10px; } .metric { flex: 1; } .metric-label { font-size: 10px; color: var(--text-tertiary); } .bar-bg { height: 3px; background: var(--bg-mica); border-radius: 1.5px; margin-top: 4px; overflow: hidden; } .bar-fill { height: 100%; border-radius: 1.5px; }
.timeframes { display: flex; gap: 6px; margin-bottom: 10px; } .tf { padding: 3px 8px; border-radius: 3px; font-size: 10px; background: var(--bg-mica); border: 1px solid var(--border-light); cursor: pointer; } .tf.active { background: #E8F2FF; color: var(--color-brand); border-color: rgba(0,122,255,0.2); }
.card-footer { display: flex; justify-content: space-between; border-top: 1px solid var(--border-light); padding-top: 10px; font-size: 11px; color: var(--text-tertiary); } .link { color: var(--color-brand); }
.fav-icon { font-size: 13px; color: #C5C5C5; cursor: pointer; } .fav-icon:hover { color: #FF9F0A; } .fav-icon.active { color: #FF9F0A; }
.status-bar { height: 24px; background: var(--bg-solid); border-top: 1px solid var(--border-light); display: flex; align-items: center; justify-content: space-between; padding: 0 16px; font-size: 11px; color: var(--text-tertiary); }
.status-left, .status-right { display: flex; gap: 16px; } .status-item { display: flex; align-items: center; gap: 4px; }
/* 详情页 */
.detail-toolbar { height: 40px; background: var(--bg-mica); border-bottom: 1px solid var(--border-light); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
.back-btn { padding: 4px 10px; border-radius: 4px; border: 1px solid var(--border-light); background: var(--bg-solid); font-size: 12px; cursor: pointer; }
.detail-title { font-size: 14px; font-weight: 600; } .detail-title span { color: var(--text-tertiary); font-weight: 400; margin-left: 8px; }
.detail-content { flex: 1; display: flex; overflow: hidden; }
.chart-panel { flex: 1.6; padding: 16px; overflow-y: auto; border-right: 1px solid var(--border-light); } .data-panel { flex: 1.4; padding: 16px; overflow-y: auto; background: var(--bg-solid); }
.chart-placeholder { width: 100%; height: 320px; background: var(--bg-solid); border: 1px solid var(--border-light); border-radius: var(--radius-card); display: flex; align-items: center; justify-content: center; color: var(--text-tertiary); margin-bottom: 16px; }
.chart-tf-bar { display: flex; gap: 6px; margin-bottom: 12px; } .chart-tf { padding: 4px 10px; border-radius: 4px; font-size: 11px; background: var(--bg-solid); border: 1px solid var(--border-light); cursor: pointer; } .chart-tf.active { background: #E8F2FF; color: var(--color-brand); }
.indicator-section { margin-bottom: 16px; } .section-title { font-size: 12px; font-weight: 600; margin-bottom: 8px; } .indicator-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.indicator-card { background: var(--bg-mica); border: 1px solid var(--border-light); border-radius: 4px; padding: 10px; } .indicator-label { font-size: 10px; color: var(--text-tertiary); } .indicator-value { font-size: 14px; font-weight: 600; } .indicator-signal { font-size: 10px; } .indicator-signal.buy { color: var(--color-down); } .indicator-signal.sell { color: var(--color-up); }
.panel-card { background: var(--bg-mica); border: 1px solid var(--border-light); border-radius: var(--radius-card); padding: 12px; margin-bottom: 12px; } .panel-card .card-title { font-size: 12px; font-weight: 600; margin-bottom: 10px; }
.data-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 11px; border-bottom: 1px solid var(--border-light); } .data-row:last-child { border-bottom: none; } .data-value.up { color: var(--color-up); } .data-value.down { color: var(--color-down); }
.ai-trigger { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px; background: linear-gradient(135deg, var(--color-brand), var(--color-ai)); color: white; border-radius: 4px; font-weight: 600; cursor: pointer; margin-top: 8px; }
.ai-preview { background: var(--bg-solid); border: 1px solid var(--border-light); border-radius: var(--radius-card); padding: 12px; display: flex; align-items: center; gap: 12px; cursor: pointer; } .ai-preview:hover { border-color: var(--color-ai); box-shadow: 0 0 0 1px var(--color-ai); } .ai-preview-icon { width: 36px; height: 36px; background: linear-gradient(135deg, var(--color-brand), var(--color-ai)); border-radius: 6px; display: flex; align-items: center; justify-content: center; color: white; } .ai-preview-text { flex: 1; } .ai-preview-title { font-size: 12px; font-weight: 600; } .ai-preview-desc { font-size: 11px; color: var(--text-tertiary); } .ai-preview-arrow { color: var(--text-tertiary); }
/* 弹窗 */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: var(--bg-overlay); z-index: 1000; align-items: center; justify-content: center; } .modal-overlay.active { display: flex; }
.modal-dialog { width: 520px; background: var(--bg-solid); border-radius: var(--radius-dialog); box-shadow: var(--shadow-dialog); border: 1px solid var(--border-light); overflow: hidden; animation: modalIn 0.2s; }
@keyframes modalIn { from { opacity: 0; transform: scale(0.96) translateY(8px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header { height: 40px; background: var(--bg-mica); border-bottom: 1px solid var(--border-light); display: flex; align-items: center; justify-content: space-between; padding: 0 16px; } .modal-title { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 6px; } .modal-title .ai-dot { width: 8px; height: 8px; background: var(--color-ai); border-radius: 50%; } .modal-close { width: 28px; height: 28px; border: none; background: transparent; cursor: pointer; } .modal-close:hover { background: var(--bg-hover); }
.modal-body { padding: 16px; max-height: 480px; overflow-y: auto; }
.ai-summary { background: linear-gradient(135deg, rgba(175,82,222,0.06), rgba(0,122,255,0.06)); border: 1px solid rgba(175,82,222,0.15); border-radius: 6px; padding: 12px; margin-bottom: 16px; } .ai-summary .label { font-size: 10px; color: var(--color-ai); font-weight: 600; margin-bottom: 4px; } .ai-summary .content { font-size: 12px; line-height: 1.6; }
.ai-section { margin-bottom: 16px; } .ai-section-title { font-size: 11px; font-weight: 600; margin-bottom: 8px; color: var(--text-secondary); }
.ai-metric-row { display: flex; gap: 8px; margin-bottom: 12px; } .ai-metric-card { flex: 1; background: var(--bg-mica); border: 1px solid var(--border-light); border-radius: 4px; padding: 10px; text-align: center; } .ai-metric-val { font-size: 18px; font-weight: 700; } .ai-metric-label { font-size: 10px; color: var(--text-tertiary); }
.ai-price-list { display: flex; flex-direction: column; gap: 6px; } .ai-price-item { display: flex; justify-content: space-between; padding: 6px 10px; background: var(--bg-mica); border-radius: 4px; font-size: 12px; } .ai-price-item .type { color: var(--text-secondary); }
.ai-risk { background: rgba(255,59,48,0.04); border: 1px solid rgba(255,59,48,0.1); border-radius: 4px; padding: 10px; display: flex; gap: 8px; align-items: flex-start; } .ai-risk-text { font-size: 11px; }
.modal-footer { height: 48px; background: var(--bg-mica); border-top: 1px solid var(--border-light); display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 0 16px; } .btn { padding: 6px 16px; border-radius: 4px; font-size: 12px; cursor: pointer; border: 1px solid var(--border-light); background: var(--bg-solid); } .btn:hover { background: var(--bg-hover); } .btn-primary { background: var(--color-brand); color: white; border-color: var(--color-brand); }
/* ========== 市场概览 ========== */
.event-banner { display: flex; gap: 12px; padding: 16px; background: var(--bg-mica); border-bottom: 1px solid var(--border-light); overflow-x: auto; }
.event-card { flex: 1; min-width: 280px; background: var(--bg-solid); border: 1px solid var(--border-light); border-radius: var(--radius-card); padding: 12px; border-left: 3px solid var(--color-ai); cursor: pointer; transition: 0.15s; }
.event-card:hover { box-shadow: var(--shadow-card); transform: translateY(-1px); } .event-card.bullish { border-left-color: var(--color-down); } .event-card.bearish { border-left-color: var(--color-up); }
.event-header { display: flex; justify-content: space-between; margin-bottom: 6px; } .event-tag { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 600; } .event-tag.urgent { background: rgba(255,59,48,0.1); color: var(--color-up); } .event-tag.policy { background: rgba(0,122,255,0.1); color: var(--color-brand); }
.event-title { font-size: 12px; font-weight: 600; margin-bottom: 6px; } .event-impact { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } .impact-badge { font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: 600; } .impact-badge.bull { background: rgba(255,59,48,0.1); color: var(--color-up); } .impact-badge.bear { background: rgba(52,199,89,0.1); color: var(--color-down); }
.event-contracts { display: flex; gap: 4px; } .mini-contract { font-size: 9px; padding: 2px 4px; background: var(--bg-mica); border: 1px solid var(--border-light); border-radius: 3px; }
.event-timer { font-size: 10px; color: var(--text-tertiary); font-family: monospace; }
.overview-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 16px; padding: 16px; height: calc(100% - 90px); }
.overview-left { display: flex; flex-direction: column; gap: 12px; }
.section-header { font-size: 12px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
.heatmap-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; min-height: 280px; }
.heat-cell { border-radius: 4px; padding: 10px; display: flex; flex-direction: column; justify-content: space-between; cursor: pointer; transition: 0.1s; border: 1px solid rgba(0,0,0,0.05); } .heat-cell:hover { transform: scale(1.02); box-shadow: 0 4px 8px rgba(0,0,0,0.15); }
.heat-up-3 { background: #D32F2F; color: #fff; } .heat-up-2 { background: #FF3B30; color: #fff; } .heat-up-1 { background: #FF8A80; color: #fff; }
.heat-down-3 { background: #2E7D32; color: #fff; } .heat-down-2 { background: #34C759; color: #fff; } .heat-down-1 { background: #81C784; color: #fff; } .heat-flat { background: #E5E5E5; }
.limit-list { background: var(--bg-solid); border: 1px solid var(--border-light); border-radius: var(--radius-card); padding: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.limit-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border-light); font-size: 11px; } .limit-row:last-child { border-bottom: none; }
.side-panel { display: flex; flex-direction: column; gap: 12px; }
.panel-box { background: var(--bg-solid); border: 1px solid var(--border-light); border-radius: var(--radius-card); padding: 12px; } .panel-box-title { font-size: 11px; font-weight: 600; margin-bottom: 10px; display: flex; align-items: center; gap: 4px; }
.flow-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 11px; } .flow-code { width: 30px; font-weight: 600; } .flow-bar-bg { flex: 1; height: 4px; background: var(--bg-mica); border-radius: 2px; overflow: hidden; } .flow-bar { height: 100%; border-radius: 2px; } .flow-val { width: 50px; text-align: right; font-weight: 600; font-size: 10px; }
.calendar-item { display: flex; gap: 10px; margin-bottom: 10px; font-size: 11px; } .cal-time { width: 40px; color: var(--text-tertiary); } .cal-stars { color: var(--color-neutral); font-size: 10px; }
/* ========== 风险预警 ========== */
.alerts-layout { display: grid; grid-template-columns: 300px 1fr 280px; height: 100%; overflow: hidden; }
.alerts-left { border-right: 1px solid var(--border-light); background: var(--bg-solid); padding: 16px; overflow-y: auto; }
.alerts-center { padding: 16px; overflow-y: auto; }
.alerts-right { border-left: 1px solid var(--border-light); background: var(--bg-solid); padding: 16px; overflow-y: auto; }
.rule-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: 600; font-size: 13px; }
.rule-category { font-size: 10px; color: var(--text-tertiary); font-weight: 600; margin: 12px 0 6px; text-transform: uppercase; }
.rule-item { display: flex; justify-content: space-between; align-items: center; padding: 8px; border-radius: 4px; font-size: 12px; cursor: pointer; transition: 0.15s; margin-bottom: 2px; }
.rule-item:hover { background: var(--bg-hover); }
.rule-switch { width: 32px; height: 16px; border-radius: 8px; position: relative; transition: 0.2s; }
.rule-switch.on { background: var(--color-down); } .rule-switch.off { background: #CCC; }
.rule-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; background: white; border-radius: 50%; transition: 0.2s; }
.rule-switch.on::after { left: 18px; }
.alert-filters { display: flex; gap: 8px; margin-bottom: 16px; }
.timeline { position: relative; padding-left: 20px; } .timeline::before { content: ''; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: var(--border-light); }
.alert-card { background: var(--bg-solid); border: 1px solid var(--border-light); border-radius: var(--radius-card); padding: 12px; margin-bottom: 12px; position: relative; transition: 0.15s; }
.alert-card:hover { box-shadow: var(--shadow-card); }
.alert-card::before { content: ''; position: absolute; left: -17px; top: 16px; width: 8px; height: 8px; border-radius: 50%; border: 2px solid var(--bg-solid); }
.alert-card.urgent { border-left: 3px solid var(--color-up); } .alert-card.urgent::before { background: var(--color-up); }
.alert-card.event { border-left: 3px solid var(--color-ai); background: linear-gradient(90deg, rgba(175,82,222,0.04), transparent); } .alert-card.event::before { background: var(--color-ai); border: none; content: '🔥'; font-size: 10px; left: -19px; top: 14px; width: 12px; height: 12px; }
.alert-card.normal::before { background: var(--color-brand); }
.alert-time { font-size: 10px; color: var(--text-tertiary); margin-bottom: 4px; }
.alert-title { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
.alert-desc { font-size: 11px; color: var(--text-secondary); margin-bottom: 8px; }
.alert-actions { display: flex; gap: 8px; font-size: 11px; } .alert-action { color: var(--color-brand); cursor: pointer; } .alert-action:hover { text-decoration: underline; }
.ai-scan-card { background: linear-gradient(135deg, rgba(175,82,222,0.1), rgba(0,122,255,0.05)); border: 1px solid var(--color-ai); border-radius: var(--radius-card); padding: 12px; margin-bottom: 12px; }
.ai-scan-title { font-size: 12px; font-weight: 600; color: var(--color-ai); margin-bottom: 8px; display: flex; align-items: center; gap: 4px; }
.ai-scan-item { font-size: 11px; background: var(--bg-solid); border-radius: 4px; padding: 8px; margin-bottom: 6px; border: 1px solid var(--border-light); }
</style>
</head>
<body>
<script>
function toggleFav(el){el.classList.toggle('active')}
function toggleSwitch(el){const s=el.querySelector('.rule-switch');s.classList.toggle('on');s.classList.toggle('off');}
function showView(id) {
document.querySelectorAll('.content-area').forEach(el => el.classList.remove('active'));
document.getElementById(id).classList.add('active');
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
if(id==='list-view'||id==='detail-view') document.querySelectorAll('.nav-item')[0].classList.add('active');
if(id==='market-view') document.querySelectorAll('.nav-item')[1].classList.add('active');
if(id==='alerts-view') document.querySelectorAll('.nav-item')[2].classList.add('active');
}
function showDetail(){showView('detail-view');}
function hideDetail(){showView('list-view');}
function showModal(){document.querySelector('.modal-overlay').classList.add('active');}
function hideModal(){document.querySelector('.modal-overlay').classList.remove('active');}
</script>
<div class="app-window">
<div class="title-bar">
<div class="title-left"><div class="app-icon"></div><span>期货智析 Pro</span></div>
<div class="window-controls">
<div class="win-btn minimize"><svg viewBox="0 0 10 10"><path d="M1 5h8" stroke="currentColor" stroke-width="1"/></svg></div>
<div class="win-btn maximize"><svg viewBox="0 0 10 10"><rect x="1.5" y="1.5" width="7" height="7" fill="none" stroke="currentColor" stroke-width="1"/></svg></div>
<div class="win-btn close"><svg viewBox="0 0 10 10"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.2"/></svg></div>
</div>
</div>
<div class="main-layout">
<aside class="sidebar">
<div class="nav-section">
<div class="nav-item active" onclick="showView('list-view')"><span>📊</span> 品种分析</div>
<div class="nav-item" onclick="showView('market-view')"><span>🌍</span> 市场概览</div>
<div class="nav-item" onclick="showView('alerts-view')"><span>⚠️</span> 风险预警 <span class="nav-badge" style="background:var(--color-up);color:white">3</span></div>
</div>
<div class="nav-section">
<div class="nav-label">板块分类</div>
<div class="nav-item"><span>🛢️</span> 能源化工 <span class="nav-badge">8</span></div>
<div class="nav-item"><span>⚙️</span> 有色金属 <span class="nav-badge">10</span></div>
<div class="nav-item"><span>🌾</span> 农产品 <span class="nav-badge">9</span></div>
</div>
<div class="nav-section">
<div class="nav-label">工具</div>
<div class="nav-item"><span>⚙️</span> 设置</div>
</div>
<div class="sidebar-footer"><div class="status-pill"><div class="status-dot"></div> 行情正常 | 12ms</div></div>
</aside>
<!-- 列表页 -->
<main class="content-area active" id="list-view">
<div class="toolbar">
<input class="search-box" placeholder="搜索品种或代码...">
<div class="filter-pills"><div class="pill active">全部</div><div class="pill">能源</div><div class="pill">金属</div></div>
<div class="toolbar-actions"><button class="tool-btn"></button></div>
</div>
<div class="scroll-content">
<div class="grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;">
<div class="card" onclick="showDetail()"><div class="card-header"><div class="card-left"><div class="code-box">SC</div><div class="info-group"><div class="name-row">原油 <span class="fav-icon" onclick="toggleFav(this)"></span></div><div class="contract-code">SC2606</div></div></div><div class="price-area"><div class="price down">644.5</div><div class="change down">↓ -2.02%</div></div></div><div class="action-pill watch">观望</div><div class="card-footer"><a href="#" class="link">详情 →</a></div></div>
<div class="card"><div class="card-header"><div class="card-left"><div class="code-box">FU</div><div class="info-group"><div class="name-row">燃油 <span class="fav-icon active" onclick="toggleFav(this)"></span></div><div class="contract-code">FU2606</div></div></div><div class="price-area"><div class="price up">4,680</div><div class="change up">↑ +1.56%</div></div></div><div class="action-pill do-more">做多</div><div class="card-footer"><a href="#" class="link">详情 →</a></div></div>
<div class="card"><div class="card-header"><div class="card-left"><div class="code-box">AG</div><div class="info-group"><div class="name-row">沪银 <span class="fav-icon" onclick="toggleFav(this)"></span></div><div class="contract-code">AG2606</div></div></div><div class="price-area"><div class="price up">18,643</div><div class="change up">↑ +2.27%</div></div></div><div class="action-pill do-more">做多</div><div class="card-footer"><a href="#" class="link">详情 →</a></div></div>
<div class="card"><div class="card-header"><div class="card-left"><div class="code-box">AU</div><div class="info-group"><div class="name-row">沪金 <span class="fav-icon" onclick="toggleFav(this)"></span></div><div class="contract-code">AU2606</div></div></div><div class="price-area"><div class="price up">993</div><div class="change up">↑ +0.4%</div></div></div><div class="action-pill watch">观望</div><div class="card-footer"><a href="#" class="link">详情 →</a></div></div>
</div>
</div>
<div class="status-bar"><div class="status-left"><div class="status-item">📡 CTP</div></div><div class="status-right"><span>自动刷新 ON</span></div></div>
</main>
<!-- 详情页 -->
<main class="content-area" id="detail-view">
<div class="detail-toolbar"><div class="back-btn" onclick="hideDetail()">← 返回</div><div class="detail-title">原油 <span>SC2606</span></div><div style="margin-left:auto;"><span class="fav-icon active" onclick="toggleFav(this)"></span> <span class="price down" style="font-size:18px;">644.5</span></div></div>
<div class="detail-content">
<div class="chart-panel">
<div class="chart-tf-bar"><div class="chart-tf active">15M</div><div class="chart-tf">1H</div><div class="chart-tf">4H</div></div>
<div class="chart-placeholder">📈 K 线图区域</div>
<div class="ai-preview" onclick="showModal()"><div class="ai-preview-icon"></div><div class="ai-preview-text"><div class="ai-preview-title">AI 多因子分析报告</div><div class="ai-preview-desc">综合评分 38 · 偏空</div></div><div class="ai-preview-arrow"></div></div>
</div>
<div class="data-panel">
<div class="panel-card"><div class="card-title">持仓数据</div><div class="data-row"><span>持仓量</span><span class="data-value">28.4 万手</span></div><div class="data-row"><span>日增仓</span><span class="data-value down">-1.2 万</span></div></div>
<div class="panel-card"><div class="card-title">资金流向</div><div class="data-row"><span>主力净流入</span><span class="data-value down">-2.8 亿</span></div></div>
<button class="ai-trigger" onclick="showModal()">✦ 生成 AI 报告</button>
</div>
</div>
<div class="status-bar"><div class="status-left"><div class="status-item">📡 CTP</div></div><div class="status-right"><span>SC2606</span></div></div>
</main>
<!-- 市场概览页 -->
<main class="content-area" id="market-view">
<div class="event-banner">
<div class="event-card bearish">
<div class="event-header"><span class="event-tag urgent">🔥 突发</span><span class="event-timer">T-00:15</span></div>
<div class="event-title">EIA 原油库存意外大幅下降 500 万桶</div>
<div class="event-impact"><span class="impact-badge bull">利多原油</span><span>供给收缩预期强烈</span></div>
<div class="event-contracts"><span class="mini-contract">SC</span><span class="mini-contract">LU</span></div>
</div>
<div class="event-card bullish">
<div class="event-header"><span class="event-tag policy">🏛️ 政策</span><span class="event-timer">已公布</span></div>
<div class="event-title">央行宣布降准 0.25%,释放长期资金</div>
<div class="event-impact"><span class="impact-badge bear">利多有色/股指</span><span>流动性改善</span></div>
<div class="event-contracts"><span class="mini-contract">IF</span><span class="mini-contract">CU</span></div>
</div>
</div>
<div class="overview-grid">
<div class="overview-left">
<div class="section-header"><span>🔥 市场热力图</span><span style="color:var(--text-tertiary);font-size:11px;">按涨跌幅</span></div>
<div class="heatmap-grid">
<div class="heat-cell heat-up-3" style="grid-column:span 2;"><div style="font-weight:700;">SC 原油</div><div>+3.8%</div></div>
<div class="heat-cell heat-up-1"><div>AG 沪银</div><div>+2.8%</div></div>
<div class="heat-cell heat-down-2"><div>SA 纯碱</div><div>-3.1%</div></div>
<div class="heat-cell heat-down-1"><div>RB 螺纹</div><div>-0.5%</div></div>
<div class="heat-cell heat-flat"><div>M 豆粕</div><div>0.0%</div></div>
<div class="heat-cell heat-up-2"><div>IF 股指</div><div>+1.8%</div></div>
<div class="heat-cell heat-down-1"><div>FG 玻璃</div><div>-1.9%</div></div>
</div>
<div class="limit-list">
<div><div style="font-size:10px;color:var(--text-tertiary);margin-bottom:4px;">🔴 领涨</div><div class="limit-row"><span>SC 原油</span><span style="color:var(--color-up)">+3.8%</span></div><div class="limit-row"><span>AG 沪银</span><span style="color:var(--color-up)">+2.8%</span></div></div>
<div><div style="font-size:10px;color:var(--text-tertiary);margin-bottom:4px;">🟢 领跌</div><div class="limit-row"><span>SA 纯碱</span><span style="color:var(--color-down)">-3.1%</span></div><div class="limit-row"><span>FG 玻璃</span><span style="color:var(--color-down)">-1.9%</span></div></div>
</div>
</div>
<div class="side-panel">
<div class="panel-box">
<div class="panel-box-title">💰 主力资金流向</div>
<div class="flow-row"><div class="flow-code">SC</div><div class="flow-bar-bg"><div class="flow-bar" style="width:80%;background:var(--color-up);"></div></div><div class="flow-val" style="color:var(--color-up);">+5.2亿</div></div>
<div class="flow-row"><div class="flow-code">SA</div><div class="flow-bar-bg"><div class="flow-bar" style="width:70%;background:var(--color-down);"></div></div><div class="flow-val" style="color:var(--color-down);">-4.5亿</div></div>
</div>
<div class="panel-box" style="flex:1;">
<div class="panel-box-title">📅 宏观日历</div>
<div class="calendar-item"><div class="cal-time">23:30</div><div class="cal-content"><div class="cal-event">EIA 原油库存</div><div class="cal-stars">★★★</div></div></div>
<div class="calendar-item"><div class="cal-time">周五</div><div class="cal-content"><div class="cal-event">非农数据</div><div class="cal-stars">★★★★</div></div></div>
</div>
</div>
</div>
<div class="status-bar"><div class="status-left"><div class="status-item">📡 概览数据</div></div><div class="status-right"><span>刷新 ON</span></div></div>
</main>
<!-- 风险预警页 -->
<main class="content-area" id="alerts-view">
<div class="alerts-layout">
<div class="alerts-left">
<div class="rule-header"><span>⚙️ 预警规则</span><button class="btn btn-primary" style="padding:2px 8px;font-size:11px;">+ 新建</button></div>
<input class="search-box" style="width:100%;margin-bottom:8px;max-width:none;" placeholder="搜索规则...">
<div class="rule-category">价格与指标</div>
<div class="rule-item" onclick="toggleSwitch(this)"><span>价格突破 60 日均线</span><div class="rule-switch on"></div></div>
<div class="rule-item" onclick="toggleSwitch(this)"><span>MACD 金叉/死叉</span><div class="rule-switch on"></div></div>
<div class="rule-category">量能与事件</div>
<div class="rule-item" onclick="toggleSwitch(this)"><span>成交量突增 > 3 倍</span><div class="rule-switch on"></div></div>
<div class="rule-item" onclick="toggleSwitch(this)"><span>热点事件关联异动</span><div class="rule-switch on"></div></div>
</div>
<div class="alerts-center">
<div class="alert-filters"><div class="pill active">全部</div><div class="pill" style="border-color:var(--color-up);color:var(--color-up);">紧急 (3)</div><div class="pill">事件驱动 (2)</div></div>
<div class="timeline">
<div class="alert-card urgent event">
<div class="alert-time">14:32 · 关联事件EIA 库存</div>
<div class="alert-title">🔥 [热点] 燃油快速突破 60 日均线</div>
<div class="alert-desc">关联事件EIA 库存超预期下降 | 5 分钟涨幅 1.5%</div>
<div class="alert-actions"><span class="alert-action">查看详情</span><span class="alert-action">AI 分析</span><span class="alert-action">加入自选</span></div>
</div>
<div class="alert-card urgent">
<div class="alert-time">13:15</div>
<div class="alert-title">🔴 沪银成交量异常放大</div>
<div class="alert-desc">当前成交量为过去 20 日均值的 4.2 倍 | 疑似主力入场</div>
<div class="alert-actions"><span class="alert-action">查看详情</span><span class="alert-action">AI 分析</span></div>
</div>
<div class="alert-card normal">
<div class="alert-time">11:20</div>
<div class="alert-title">螺纹 MACD 出现金叉</div>
<div class="alert-desc">15 分钟级别 MACD 快线上穿慢线</div>
<div class="alert-actions"><span class="alert-action">查看详情</span><span class="alert-action">AI 分析</span></div>
</div>
<div class="alert-card normal">
<div class="alert-time">09:30</div>
<div class="alert-title">开盘跳空 5 品种</div>
<div class="alert-desc">原油、沪银、铁矿等高开超 1%</div>
<div class="alert-actions"><span class="alert-action">查看详情</span></div>
</div>
</div>
</div>
<div class="alerts-right">
<div class="panel-box-title" style="font-size:12px;font-weight:600;margin-bottom:12px;">🤖 AI 异动侦测</div>
<div class="ai-scan-card">
<div class="ai-scan-title">✦ 今日风险指数: 72/100</div>
<div class="ai-scan-item">
<div style="font-weight:600;margin-bottom:2px;">疑似洗盘</div>
<div style="color:var(--text-secondary);font-size:10px;">纯碱 10:45 急跌后迅速拉回,量能异常,疑似主力洗盘。</div>
</div>
<div class="ai-scan-item">
<div style="font-weight:600;margin-bottom:2px;">流动性枯竭</div>
<div style="color:var(--text-secondary);font-size:10px;">胶板 2610 连续 3 分钟无成交,注意滑点风险。</div>
</div>
</div>
<div class="panel-box">
<div class="panel-box-title">📊 预警统计</div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:11px;"><span>今日触发</span><span style="font-weight:600;">24 次</span></div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:11px;"><span>误报率</span><span style="font-weight:600;color:var(--color-down);">12%</span></div>
</div>
</div>
</div>
<div class="status-bar"><div class="status-left"><div class="status-item">📡 预警服务</div></div><div class="status-right"><span>推送 ON</span></div></div>
</main>
</div>
</div>
<!-- 弹窗 -->
<div class="modal-overlay" id="aiModal" onclick="if(event.target===this)hideModal()">
<div class="modal-dialog">
<div class="modal-header"><div class="modal-title"><div class="ai-dot"></div>AI 分析报告</div><button class="modal-close" onclick="hideModal()"></button></div>
<div class="modal-body">
<div class="ai-summary"><div class="label">✦ AI 研判</div><div class="content">SC2606 当前处于 15 分钟级别空头趋势MACD 死叉向下。持仓量持续减少。建议观望。</div></div>
<div class="ai-section">
<div class="ai-metric-row"><div class="ai-metric-card"><div class="ai-metric-val" style="color:var(--color-up);">38</div><div class="ai-metric-label">综合评分</div></div><div class="ai-metric-card"><div class="ai-metric-val" style="color:var(--color-up);">偏空</div><div class="ai-metric-label">方向</div></div></div>
</div>
<div class="ai-section"><div class="ai-section-title">关键价位</div>
<div class="ai-price-list"><div class="ai-price-item"><span>止损位</span><span style="color:var(--color-up)">656.0</span></div><div class="ai-price-item"><span>目标位</span><span style="color:var(--color-down)">628.0</span></div></div>
</div>
<div class="ai-section"><div class="ai-risk"><span>⚠️</span><div class="ai-risk-text">EIA 数据即将公布,建议控制仓位,避免重仓隔夜。</div></div></div>
</div>
<div class="modal-footer"><button class="btn" onclick="hideModal()">关闭</button><button class="btn btn-primary" onclick="hideModal()">确认</button></div>
</div>
</div>
</body>
</html>

@ -0,0 +1,534 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
期货结算单数据处理脚本
读取期货结算单Excel提取期货和期权交易数据计算盈亏和持仓合并输出
"""
import os
import glob
import re
from datetime import datetime
from collections import defaultdict
import xlrd
import pandas as pd
def find_header(sheet, keywords):
"""
动态识别表头行
:param sheet: xlrd sheet对象
:param keywords: 关键词列表["合约", "品种合约"]
:return: 表头行索引
"""
for row_idx in range(min(20, sheet.nrows)): # 只扫描前20行
row_values = [str(sheet.cell_value(row_idx, col_idx)).strip()
for col_idx in range(sheet.ncols)]
row_text = ' '.join(row_values)
# 检查是否包含关键词
has_keyword = any(kw in row_text for kw in keywords)
# 检查是否包含辅助关键词
has_aux = any(aux in row_text for aux in ["买/卖", "成交价", "权利金"])
if has_keyword and has_aux:
return row_idx
return None
def read_sheet(xls_file, sheet_name, trade_type):
"""
读取单个Sheet的数据
:param xls_file: Excel文件路径
:param sheet_name: Sheet名称
:param trade_type: 交易类型期货/期权
:return: DataFrame
"""
try:
wb = xlrd.open_workbook(xls_file)
if sheet_name not in wb.sheet_names():
print(f" 警告: {xls_file} 中未找到 {sheet_name}")
return pd.DataFrame()
sheet = wb.sheet_by_name(sheet_name)
if sheet.nrows == 0:
return pd.DataFrame()
# 根据类型确定关键词
if trade_type == '期货':
keywords = ["合约"]
else: # 期权
keywords = ["品种合约"]
# 查找表头行
header_row = find_header(sheet, keywords)
if header_row is None:
print(f" 警告: {sheet_name} 中未找到表头")
return pd.DataFrame()
# 读取表头
headers = [str(sheet.cell_value(header_row, col_idx)).strip()
for col_idx in range(sheet.ncols)]
# 读取数据行
data = []
for row_idx in range(header_row + 1, sheet.nrows):
row_data = {}
first_cell = str(sheet.cell_value(row_idx, 0)).strip()
# 跳过合计行
if first_cell == '合计' or first_cell == '':
continue
for col_idx, header in enumerate(headers):
if col_idx < sheet.ncols:
value = sheet.cell_value(row_idx, col_idx)
# 处理"--"
if str(value).strip() == '--':
value = None
row_data[header] = value
# 添加类型标签
row_data['类型'] = trade_type
data.append(row_data)
df = pd.DataFrame(data)
print(f" 读取 {sheet_name}: {len(df)} 条记录")
return df
except Exception as e:
print(f" 错误: 读取 {sheet_name} 失败 - {e}")
return pd.DataFrame()
def extract_variety(contract):
"""
提取品种代码所有前导字母
:param contract: 合约代码
:return: 品种代码
"""
if pd.isna(contract) or not contract:
return ''
contract = str(contract).strip()
match = re.match(r'^([A-Za-z]+)', contract)
if match:
return match.group(1).upper()
return contract[:2].upper()
def safe_float(value, default=0.0):
"""
安全转换为float
"""
if pd.isna(value) or value is None or value == '':
return default
try:
return float(value)
except (ValueError, TypeError):
return default
def calc_pnl_futures(df):
"""
计算期货盈亏
"""
if df.empty:
return {'平仓盈亏': 0, '手续费': 0, '净盈亏': 0}
# 平仓盈亏
pnl_close = 0
if '平仓盈亏' in df.columns:
for val in df['平仓盈亏']:
if not pd.isna(val):
pnl_close += safe_float(val)
# 手续费
commission = 0
if '手续费' in df.columns:
for val in df['手续费']:
commission += safe_float(val)
net_pnl = pnl_close - commission
return {
'平仓盈亏': pnl_close,
'手续费': commission,
'净盈亏': net_pnl
}
def calc_pnl_options(df):
"""
计算期权盈亏
"""
if df.empty:
return {'权利金净收入': 0, '手续费': 0, '净盈亏': 0}
# 权利金(带方向)
premium_total = 0
if '权利金' in df.columns:
for val in df['权利金']:
premium_total += safe_float(val)
# 手续费
commission = 0
if '手续费' in df.columns:
for val in df['手续费']:
commission += safe_float(val)
net_pnl = premium_total - commission
return {
'权利金净收入': premium_total,
'手续费': commission,
'净盈亏': net_pnl
}
def calc_position_futures(df):
"""
计算期货持仓
"""
positions = defaultdict(float)
if df.empty:
return positions
for _, row in df.iterrows():
contract = str(row.get('合约', '')).strip()
if not contract or contract == '合计':
continue
bs_flag = str(row.get('买/卖', '')).strip()
oc_flag = str(row.get('开/平', '')).strip()
volume = safe_float(row.get('手数', 0))
# 买+开 → +手数;卖+开 → -手数;买+平 → -手数;卖+平 → +手数
if '' in bs_flag and '' in oc_flag:
positions[contract] += volume
elif '' in bs_flag and '' in oc_flag:
positions[contract] -= volume
elif '' in bs_flag and '' in oc_flag:
positions[contract] -= volume
elif '' in bs_flag and '' in oc_flag:
positions[contract] += volume
# 过滤0持仓
return {k: v for k, v in positions.items() if abs(v) > 0.001}
def calc_position_options(df):
"""
计算期权持仓
"""
positions = defaultdict(float)
if df.empty:
return positions
for _, row in df.iterrows():
contract = str(row.get('品种合约', '')).strip()
if not contract or contract == '合计':
continue
bs_flag = str(row.get('买/卖', '')).strip()
volume = safe_float(row.get('成交量', 0))
if '' in bs_flag:
positions[contract] += volume
elif '' in bs_flag:
positions[contract] -= volume
# 过滤0持仓
return {k: v for k, v in positions.items() if abs(v) > 0.001}
def merge_trades(df_futures, df_options):
"""
合并期货和期权交易记录
"""
# 统一字段
columns_map_futures = {
'合约': '合约代码',
'买/卖': '买卖',
'成交价': '价格/权利金单价',
'手数': '手数/成交量',
'成交额': '金额/权利金',
'实际成交日期': '实际成交日期',
'成交时间': '成交时间'
}
columns_map_options = {
'品种合约': '合约代码',
'买/卖': '买卖',
'权利金单价': '价格/权利金单价',
'成交量': '手数/成交量',
'权利金': '金额/权利金',
'成交日期': '实际成交日期',
'成交时间': '成交时间'
}
# 处理期货数据
if not df_futures.empty:
df_f = df_futures.rename(columns=columns_map_futures).copy()
df_f['品种'] = df_f['合约代码'].apply(extract_variety)
df_f['方向'] = df_f['买卖'].apply(lambda x: '' if '' in str(x) else '')
df_f['开平仓'] = df_f.get('开/平', '')
# 确保必要字段存在
for col in ['平仓盈亏', '手续费']:
if col not in df_f.columns:
df_f[col] = 0
else:
df_f = pd.DataFrame()
# 处理期权数据
if not df_options.empty:
df_o = df_options.rename(columns=columns_map_options).copy()
df_o['品种'] = df_o['合约代码'].apply(extract_variety)
df_o['方向'] = df_o['买卖'].apply(lambda x: '' if '' in str(x) else '')
df_o['开平仓'] = ''
df_o['平仓盈亏'] = 0
# 确保必要字段存在
for col in ['手续费']:
if col not in df_o.columns:
df_o[col] = 0
else:
df_o = pd.DataFrame()
# 合并
common_cols = ['品种', '合约代码', '类型', '买卖', '方向', '成交时间',
'实际成交日期', '价格/权利金单价', '手数/成交量', '金额/权利金',
'手续费', '平仓盈亏', '开平仓']
if not df_f.empty:
df_f = df_f[[c for c in common_cols if c in df_f.columns]]
if not df_o.empty:
df_o = df_o[[c for c in common_cols if c in df_o.columns]]
if not df_f.empty and not df_o.empty:
return pd.concat([df_f, df_o], ignore_index=True)
elif not df_f.empty:
return df_f
elif not df_o.empty:
return df_o
else:
return pd.DataFrame()
def save_output(trades_df, df_futures, df_options, output_dir):
"""
保存输出文件
"""
os.makedirs(output_dir, exist_ok=True)
# 文件1trades_merged.csv
csv_path = os.path.join(output_dir, 'trades_merged.csv')
if not trades_df.empty:
trades_df.to_csv(csv_path, index=False, encoding='utf-8-sig')
print(f"已保存交易明细: {csv_path}")
else:
print("警告: 无交易数据")
# 计算分析数据
analysis_data = {}
# Sheet1: 品种盈亏汇总
variety_summary = []
# 期货品种汇总
if not df_futures.empty:
df_f = df_futures.copy()
df_f['品种'] = df_f['合约'].apply(extract_variety)
for variety, group in df_f.groupby('品种'):
pnl = calc_pnl_futures(group)
total_volume = sum(safe_float(v) for v in group.get('手数', []))
total_amount = sum(safe_float(v) for v in group.get('成交额', []))
buy_count = sum(1 for v in group.get('买/卖', []) if '' in str(v))
sell_count = sum(1 for v in group.get('买/卖', []) if '' in str(v))
variety_summary.append({
'品种': variety,
'类型': '期货',
'总手数': total_volume,
'总成交额': total_amount,
'总手续费': pnl['手续费'],
'平仓盈亏': pnl['平仓盈亏'],
'净盈亏': pnl['净盈亏'],
'买入次数': buy_count,
'卖出次数': sell_count
})
# 期权品种汇总
if not df_options.empty:
df_o = df_options.copy()
df_o['品种'] = df_o['品种合约'].apply(extract_variety)
for variety, group in df_o.groupby('品种'):
pnl = calc_pnl_options(group)
total_volume = sum(safe_float(v) for v in group.get('成交量', []))
total_premium = sum(abs(safe_float(v)) for v in group.get('权利金', []))
buy_count = sum(1 for v in group.get('买/卖', []) if '' in str(v))
sell_count = sum(1 for v in group.get('买/卖', []) if '' in str(v))
variety_summary.append({
'品种': variety,
'类型': '期权',
'总手数': total_volume,
'总成交额/权利金': total_premium,
'总手续费': pnl['手续费'],
'平仓盈亏': 0,
'净盈亏': pnl['净盈亏'],
'买入次数': buy_count,
'卖出次数': sell_count
})
df_variety = pd.DataFrame(variety_summary)
# Sheet2: 期货持仓
futures_positions = calc_position_futures(df_futures)
futures_pos_data = []
for contract, pos in futures_positions.items():
futures_pos_data.append({
'合约': contract,
'净持仓': pos,
'方向': '' if pos > 0 else ''
})
df_futures_pos = pd.DataFrame(futures_pos_data)
# Sheet3: 期权持仓
options_positions = calc_position_options(df_options)
options_pos_data = []
for contract, pos in options_positions.items():
options_pos_data.append({
'品种合约': contract,
'净持仓': pos,
'方向': '权利' if pos > 0 else '义务'
})
df_options_pos = pd.DataFrame(options_pos_data)
# 文件2analysis.xlsx
xlsx_path = os.path.join(output_dir, 'analysis.xlsx')
with pd.ExcelWriter(xlsx_path, engine='openpyxl') as writer:
if not df_variety.empty:
df_variety.to_excel(writer, sheet_name='品种盈亏汇总', index=False)
if not df_futures_pos.empty:
df_futures_pos.to_excel(writer, sheet_name='期货持仓', index=False)
if not df_options_pos.empty:
df_options_pos.to_excel(writer, sheet_name='期权持仓', index=False)
print(f"已保存分析结果: {xlsx_path}")
return {
'variety_summary': variety_summary,
'futures_positions': futures_positions,
'options_positions': options_positions
}
def print_summary(df_futures, df_options, results):
"""
打印摘要
"""
print("\n" + "="*50)
print("=== 交易摘要 ===")
# 期货统计
if not df_futures.empty:
pnl_f = calc_pnl_futures(df_futures)
varieties_f = df_futures['合约'].apply(extract_variety).nunique()
print(f"期货:{len(df_futures)}笔交易,{varieties_f}个品种")
print(f" 总盈亏:{pnl_f['净盈亏']:.2f}元(含手续费{pnl_f['手续费']:.2f}元)")
else:
print("期货:无交易")
# 期权统计
if not df_options.empty:
pnl_o = calc_pnl_options(df_options)
varieties_o = df_options['品种合约'].apply(extract_variety).nunique()
print(f"期权:{len(df_options)}笔交易,{varieties_o}个品种")
print(f" 总盈亏:{pnl_o['净盈亏']:.2f}元(含手续费{pnl_o['手续费']:.2f}元)")
else:
print("期权:无交易")
# 持仓统计
print("\n=== 收盘持仓 ===")
futures_pos = results['futures_positions']
if futures_pos:
for contract, pos in sorted(futures_pos.items()):
direction = '' if pos > 0 else ''
print(f"期货:{contract} {direction} {abs(pos):.0f}")
else:
print("期货:无持仓")
options_pos = results['options_positions']
if options_pos:
for contract, pos in sorted(options_pos.items()):
direction = '权利' if pos > 0 else '义务'
print(f"期权:{contract} {direction} {abs(pos):.0f}")
else:
print("期权:无持仓")
print("="*50 + "\n")
def main():
"""
主函数
"""
# 数据目录
data_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
output_dir = os.path.dirname(os.path.abspath(__file__))
print(f"数据目录: {data_dir}")
print(f"输出目录: {output_dir}\n")
# 查找所有xls文件
xls_files = glob.glob(os.path.join(data_dir, '*.xls'))
if not xls_files:
print(f"错误: 在 {data_dir} 中未找到 .xls 文件")
return
print(f"找到 {len(xls_files)} 个结算单文件\n")
# 读取所有文件
all_futures = []
all_options = []
for xls_file in sorted(xls_files):
filename = os.path.basename(xls_file)
print(f"处理: {filename}")
# 读取期货成交明细
df_f = read_sheet(xls_file, '成交明细', '期货')
if not df_f.empty:
all_futures.append(df_f)
# 读取期权成交明细
df_o = read_sheet(xls_file, '期权成交明细', '期权')
if not df_o.empty:
all_options.append(df_o)
# 合并数据
df_futures = pd.concat(all_futures, ignore_index=True) if all_futures else pd.DataFrame()
df_options = pd.concat(all_options, ignore_index=True) if all_options else pd.DataFrame()
print(f"\n汇总: 期货 {len(df_futures)} 条, 期权 {len(df_options)}\n")
# 合并交易记录
trades_merged = merge_trades(df_futures, df_options)
# 保存输出
results = save_output(trades_merged, df_futures, df_options, output_dir)
# 打印摘要
print_summary(df_futures, df_options, results)
if __name__ == '__main__':
main()

@ -0,0 +1,351 @@
# 期货智析 — UI 设计规范 v2.0 (Dark Terminal)
> **方案 A专业交易终端风格**
> 基于深色模式重新设计,参考 Bloomberg Terminal、TradingView Pro 等专业交易工具。
> 核心目标:减少视觉疲劳、突出数据本身、增强专业感。
---
## 1. 设计原则
| 原则 | 说明 |
|------|------|
| **深色优先** | 全局深色背景,降低长时间盯盘的视觉疲劳 |
| **数据高亮** | 关键价格、涨跌幅使用高对比色,在深色背景上更醒目 |
| **硬朗专业** | 减小圆角6px减少柔和阴影使用边框和分隔线划分区域 |
| **蓝色品牌** | 品牌色从紫色改为蓝色,更符合金融/科技专业工具的调性 |
| **信息分层** | 利用背景色阶(`#0D1117` → `#161B22``#21262D`)构建清晰的层级 |
---
## 2. 色彩系统
### 2.1 背景色阶(核心)
| 名称 | HEX | 用途 |
|------|-----|------|
| Page BG | `#0D1117` | 页面最底层背景 |
| Card BG | `#161B22` | 卡片、面板背景 |
| Hover BG | `#21262D` | 卡片 Hover 态、次级区块 |
| Input BG | `#010409` | 输入框、深色嵌套区域 |
| Border | `#30363D` | 卡片边框、分割线 |
| Border-Light | `#484F58` | 次要边框、表格线 |
### 2.2 文字色阶
| 名称 | HEX | 用途 |
|------|-----|------|
| Text Primary | `#C9D1D9` | 主标题、重要数值 |
| Text Secondary | `#8B949E` | 副标题、标签、辅助说明 |
| Text Tertiary | `#6E7681` | 禁用态、占位符、时间戳 |
| Text Inverse | `#FFFFFF` | 深色按钮上的文字 |
### 2.3 功能色Dark Mode 适配)
> 相比浅色模式,深色模式下的功能色需要提高亮度以保证对比度。
| 名称 | HEX | 用途 |
|------|-----|------|
| 上涨 | `#F85149` | 价格上涨、做多信号 |
| 上涨-淡 | `rgba(248, 81, 73, 0.15)` | 上涨背景遮罩 |
| 下跌 | `#3FB950` | 价格下跌、做空信号 |
| 下跌-淡 | `rgba(63, 185, 80, 0.15)` | 下跌背景遮罩 |
| 观望/中性 | `#D29922` | 观望信号、震荡状态 |
| 观望-淡 | `rgba(210, 153, 34, 0.15)` | 观望背景遮罩 |
### 2.4 品牌色(蓝色系)
| 名称 | HEX | 用途 |
|------|-----|------|
| Brand Primary | `#58A6FF` | 导航高亮、主按钮、选中态、链接 |
| Brand Secondary | `#1F6FEB` | 按钮 Hover、强调 |
| Brand Pale | `rgba(88, 166, 255, 0.15)` | 选中背景、轻量强调 |
| Brand Border | `#58A6FF` | 选中边框 |
### 2.5 AI / 智能色系
> 保持紫色系以区分 AI 内容,但在深色模式下调整饱和度。
| 名称 | HEX | 用途 |
|------|-----|------|
| AI Primary | `#A371F7` | AI 模块标题、智能按钮 |
| AI BG | `rgba(163, 113, 247, 0.1)` | AI 分析区域背景 |
| AI Border | `#A371F7` | AI 模块边框 |
### 2.6 色调样例板
```
┌──────────────────────────────────────────────────────────────┐
│ 背景色阶(深 → 浅) │
│ ██████ #0D1117 ██████ #161B22 ██████ #21262D
│ ██████ #30363D ██████ #484F58
├──────────────────────────────────────────────────────────────┤
│ 文字色阶(亮 → 暗) │
│ ██████ #C9D1D9 ██████ #8B949E ██████ #6E7681
│ ██████ #FFFFFF
├──────────────────────────────────────────────────────────────┤
│ 功能色(高亮) │
│ ██████ #F85149 ██████ #3FB950 ██████ #D29922
──────────────────────────────────────────────────────────────┤
│ 品牌色(蓝) & AI色
│ ██████ #58A6FF ██████ #1F6FEB ██████ #A371F7
└──────────────────────────────────────────────────────────────┘
```
### 2.7 CSS 变量定义
```css
:root {
/* 背景 */
--bg-page: #0D1117;
--bg-card: #161B22;
--bg-hover: #21262D;
--bg-input: #010409;
--border-default: #30363D;
--border-light: #484F58;
/* 文字 */
--text-primary: #C9D1D9;
--text-secondary: #8B949E;
--text-tertiary: #6E7681;
--text-inverse: #FFFFFF;
/* 功能色 */
--color-up: #F85149;
--color-up-bg: rgba(248, 81, 73, 0.15);
--color-down: #3FB950;
--color-down-bg: rgba(63, 185, 80, 0.15);
--color-neutral: #D29922;
--color-neutral-bg: rgba(210, 153, 34, 0.15);
/* 品牌色 */
--color-brand: #58A6FF;
--color-brand-hover: #1F6FEB;
--color-brand-bg: rgba(88, 166, 255, 0.15);
/* AI色 */
--color-ai: #A371F7;
--color-ai-bg: rgba(163, 113, 247, 0.1);
}
```
---
## 3. 字体系统
### 3.1 字体族
```css
font-family:
'SF Mono', 'Fira Code', 'Roboto Mono',
'PingFang SC', 'Microsoft YaHei',
monospace, sans-serif;
```
> **注**:金融终端推荐使用等宽字体显示数字和代码,增强专业感。
### 3.2 字号阶梯
| 层级 | 字号 | 字重 | 行高 | 用途 |
|------|------|------|------|------|
| H1 | 20px | 600 | 28px | 页面主标题(深色下不宜过大) |
| H2 | 16px | 600 | 24px | 模块标题 |
| H3 | 14px | 600 | 20px | 卡片标题 |
| Body | 13px | 400 | 20px | 正文内容 |
| Body-S | 12px | 400 | 18px | 辅助说明 |
| Caption | 11px | 400 | 16px | 极小标注 |
| Num-L | 24px | 700 | — | 卡片价格(改用 24px 更紧凑) |
| Num-M | 18px | 700 | — | 详情页价格 |
| Num-S | 12px | 600 | — | 辅助数值 |
---
## 4. 间距与圆角
### 4.1 间距(紧凑化)
深色模式下,适当减小间距可以增加信息密度感。
| Token | 值 | 用途 |
|-------|-----|------|
| `space-xs` | 4px | 图标与文字 |
| `space-sm` | 8px | 卡片内元素 |
| `space-md` | 12px | 卡片间距 |
| `space-lg` | 16px | 区块间距 |
| `space-xl` | 24px | 页面级间距 |
### 4.2 圆角(硬朗化)
| Token | 值 | 用途 |
|-------|-----|------|
| `radius-sm` | 4px | 小按钮、标签 |
| `radius-md` | 6px | 卡片、面板、输入框 |
| `radius-lg` | 8px | 弹窗 |
| `radius-full` | 9999px | Pill 标签 |
---
## 5. 组件规范
### 5.1 顶部导航栏
```
┌────────────────────────────────────────────────────────────────────┐
│ 期货智析 [品种分析] 自选 市场概览 风险预警 [●LIVE] │
└────────────────────────────────────────────────────────────────────┘
```
| 元素 | 规范 |
|------|------|
| 高度 | 48px更紧凑 |
| 背景 | `#0D1117` + 底边框 `#30363D` |
| Logo | 图标 + 文字"期货智析",字号 14px字重 600颜色 `#C9D1D9` |
| 导航项 | 选中态:蓝色文字 `#58A6FF` + 下划线;未选中:灰色 `#8B949E` |
| LIVE 徽章 | 绿色圆点 `#3FB950` + "LIVE"文字,字号 10px |
### 5.2 搜索与筛选栏
```
┌────────────────────────────────────────────────────────────────────┐
│ 🔍 搜索品种... [●网格] [○列表] │
│ [●全部] [☆自选] [能源] [金属] [农产品] [刷新] [分析] [▼] │
└────────────────────────────────────────────────────────────────────┘
```
| 元素 | 规范 |
|------|------|
| 搜索框 | 高度 32px圆角 6px背景 `#010409`,边框 `#30363D`,文字 `#C9D1D9` |
| 视图切换 | 图标按钮,选中态蓝色 `#58A6FF` |
| 分类 Pill | 高度 24px圆角 4px选中态蓝色背景 `rgba(88,166,255,0.15)` + 蓝色文字 |
| 操作按钮 | "刷新"蓝色文字,"分析"蓝色实心按钮 |
### 5.3 统计概览卡片
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ ≡ 32 │ │ ↗ 7 │ │ ↘ 10 │ │ ↔ 15 │
│ 监控品种 │ │ 上涨趋势 │ │ 下跌趋势 │ │ 震荡整理 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
```
| 元素 | 规范 |
|------|------|
| 背景 | `#161B22`,边框 `#30363D` |
| 数值 | 20px/700`#C9D1D9` |
| 标签 | 12px/400`#8B949E` |
| 图标 | 16×16px颜色对应状态涨红/跌绿/震荡橙) |
### 5.4 品种卡片(核心组件 - Dark
```
┌──────────────────────────────────────────────────────────┐
│ SC 原油 ¥644.5 │
│ SC2606 ↓ -13.3 (-2.02%) │
├──────────────────────────────────────────────────────────┤
│ [观望] │
│ 成功率 ━━━━━━ 60% 趋势评分 30 │
│ [5M] [15M] [30M] [1H] │
│ 压力 642.4 支撑 631.7 [↻] [🔔] [★] → │
└──────────────────────────────────────────────────────────┘
```
| 元素 | 规范 |
|------|------|
| 卡片背景 | `#161B22`,边框 `#30363D`Hover 时边框变 `#58A6FF` |
| 品种标识 | 32×32px圆角 4px背景 `#21262D`,文字 `#8B949E` |
| 价格 | 24px/700`#C9D1D9`(默认),涨跌时变红/绿 |
| 涨跌幅 | 12px/600`#F85149` / 绿 `#3FB950` |
| 操作建议 | Pill 标签,高度 20px圆角 4px背景用半透明色`rgba(210,153,34,0.15)`),文字用纯色 |
| 进度条 | 高度 2px更细颜色对应状态 |
| 周期 Pill | 高度 20px圆角 4px选中态蓝色边框 + 文字 |
| 底部工具 | 图标颜色 `#8B949E`Hover 变 `#C9D1D9` |
### 5.5 K 线图表区Dark
| 元素 | 规范 |
|------|------|
| 图表背景 | `#0D1117`(与页面背景一致,无边框感) |
| 网格线 | `#21262D`(极淡) |
| 蜡烛图 | 阳线 `#F85149`(空心或实心),阴线 `#3FB950` |
| 均线 | MA5 `#D29922`MA10 `#58A6FF`MA20 `#A371F7` |
| 成交量 | 阳线量柱 `#F85149` (40% 透明度),阴线量柱 `#3FB950` (40% 透明度) |
| 坐标轴 | 文字 `#6E7681` |
### 5.6 右侧面板Dark
```
┌────────────────────────────────
│ 🔊 AI 思维分析 [智能分析] │
├────────────────────────────────┤
│ SC2606 呈多周期空头排列... │
├────────────────────────────────┤
│ 入场区间 640.5-642.5 │
│ 止损位 645.5 │
└────────────────────────────────
```
| 元素 | 规范 |
|------|------|
| 面板背景 | `#161B22`,边框 `#30363D` |
| 标题 | 14px/600`#C9D1D9` |
| AI 按钮 | 紫色渐变背景或紫色文字 |
| 数据行 | 标签 `#6E7681`,数值 `#C9D1D9` |
| 分隔线 | `#30363D` |
### 5.7 弹窗Dark
| 元素 | 规范 |
|------|------|
| 遮罩 | `rgba(0,0,0,0.7)`(更深) |
| 弹窗背景 | `#161B22`,边框 `#30363D` |
| 标题栏 | 背景 `#0D1117`,底边框 `#30363D` |
| 表格头 | 背景 `#0D1117`,文字 `#8B949E` |
| 表格行 | 背景 `#161B22`Hover `#21262D` |
| 风险提示 | 左边框 `#D29922`,背景 `rgba(210,153,34,0.1)` |
---
## 6. 交互规范
### 6.1 状态反馈
| 交互 | 反馈方式 |
|------|---------|
| 卡片 Hover | 背景变 `#21262D`,边框变 `#58A6FF` |
| 按钮 Hover | 背景亮度增加 10% |
| 按钮 Active | 背景亮度降低 10% |
| 数据刷新 | 骨架屏(`#21262D` 脉冲动画) |
### 6.2 滚动条
| 元素 | 规范 |
|------|------|
| 轨道 | `#0D1117` |
| 滑块 | `#30363D`,圆角 4px |
| 滑块 Hover | `#484F58` |
---
## 7. 暗色模式专属优化
1. **对比度检查**:所有文字与背景对比度至少 4.5:1WCAG AA
2. **避免纯黑纯白**:不使用 `#000000``#FFFFFF` 作为大面积背景/文字,减少眩光。
3. **阴影替代**:深色模式下阴影不可见,改用边框颜色变化来体现层级和选中态。
4. **图片适配**:如有图标/图片,增加 10% 亮度或添加半透明遮罩使其融入深色背景。
---
## 8. 开发资源清单
| 资源 | 说明 |
|------|------|
| CSS 变量 | 已提供 `:root` 定义 |
| 字体 | 推荐 `SF Mono` / `Fira Code` 用于数字 |
| 图标 | 推荐 `Lucide``Phosphor Icons`(线性风格,适合深色) |
| 图表 | `Lightweight Charts`(原生支持深色主题)或 `ECharts` |
| 组件库 | 建议基于 `Ant Design Dark Theme``Radix UI` 定制 |
---
*文档版本: v2.0 (Dark Terminal) | 最后更新: 2026-05-23*
*基于方案 A专业交易终端风格*

File diff suppressed because it is too large Load Diff

@ -0,0 +1,156 @@
# 每日收盘交易计划 - 执行逻辑说明 (v2)
> **任务名称**:每日收盘交易计划 (16:01)
> **任务 ID**`3b960afd-45b3-489a-9029-fdbadbdc4f64`
> **调度时间**`1 8 * * mon-fri`UTC 08:01 = 北京时间 16:01
> **超时限制**1200 秒20 分钟)
> **分发渠道**console + 收件箱
> **状态**:✅ 已启用
---
## 📋 完整执行流程
### 第一步:数据收集
```
数据源share_data/symbols_config.json38个期货品种
采集工具batch_get_market_data (Market Data Collector MCP)
```
| 周期 | 用途 |
|------|------|
| 日线 (daily) | 计算当日振幅、涨跌幅、近20日均振幅 |
| 60分钟 (60min) | 趋势判断MA排列 + MACD |
| 15分钟 (15min) | 趋势判断 + 入场时机 |
| 5分钟 (5min) | 活跃度评估 |
**参数**`include_indicators=True`(包含 MA、MACD 指标)
---
### 第二步:多维度打分排名(全品种扫描)
对每个品种计算 **5 个维度** 并排名:
| 维度 | 权重 | 计算方法 | 排名依据 | 意义 |
|------|------|---------|---------|------|
| **A. 当日振幅** | 20% | (今日最高 - 今日最低) / 今日收盘 × 100% | 从大到小 | 振幅越大,日内操作空间越大 |
| **B. 成交量** | 15% | 今日成交量 / 近5日均量量比 | 从大到小 | 放量品种更值得关注 |
| **C. 涨跌幅** | 15% | (今日收盘 - 昨日收盘) / 昨日收盘 × 100% | 绝对值从大到小 | 大幅波动可能有趋势延续或反转机会 |
| **D. 多周期趋势共振** | 35% | 60m/15m/5m 的 MA排列 + MACD状态 → 三周期共振 | 趋势分 0-100 | 趋势越明确,操作胜率越高 |
| **E. 活跃度** | 15% | 5min K线近10根的量活跃度 + 价格变动率 | 综合评分 0-100 | 活跃品种流动性好、滑点小 |
#### 趋势分计算细则维度D
| 周期 | MA排列方向 | MACD状态 | 单周期趋势分 |
|------|-----------|---------|-------------|
| 60min | 多头排列 +50 / 空头排列 -50 | DIF>DEA +30 / DIF<DEA -30 | -50 ~ +50 |
| 15min | 同上 | 同上 | -50 ~ +50 |
| 5min | 同上 | 同上 | -50 ~ +50 |
**三周期共振判定**
| 条件 | 方向标签 | 趋势分映射 |
|------|---------|-----------|
| 60m/15m/5m 趋势分均 > 20 | 多头共振 | 80-100 |
| 60m/15m/5m 趋势分均 < -20 | | 80-100 |
| 60m>0 且 15m>0 | 偏多震荡 | 50-70 |
| 60m<0 15m<0 | | 50-70 |
| 其他 | 多空交织 | 0-40 |
---
### 第三步:综合评分与筛选
#### 综合评分公式
```
composite = 振幅得分×0.20 + 成交量得分×0.15 + 涨跌幅得分×0.15 + 趋势得分×0.35 + 活跃度×0.15
```
#### 筛选规则
| 标签 | 条件 | 处理 |
|------|------|------|
| 🟢 **交易机会** | 综合≥55 且 趋势明确 | 纳入交易计划 |
| 👀 **重点关注** | 综合45-55 或 形态构筑中 | 列入观察清单 |
| 🔴 **规避** | 综合<45 多空交织无方向 | 不建议参与 |
---
### 第四步:制定具体交易计划
针对 🟢 交易机会品种Top 3-5逐一出具
```
每个品种包含:
├── 品种/合约 & 方向(多/空)
├── 入场区间(具体支撑/阻力位)
├── 止损位(必须明确,证伪点)
├── 目标位(第一目标 / 第二目标)
├── 触发条件突破XX进场、回踩XX不破进场
└── 综合评分及各维度得分明细
```
---
### 第五步:交付
| 交付方式 | 内容 |
|---------|------|
| **文件保存** | `reports/daily_plan/YYYY-MM-DD_交易计划.md` |
| **文件发送** | 通过 `send_file_to_user` 发送完整报告 |
| **聊天窗口** | 精简摘要:今日一句话总结 + Top3 核心机会 + 最大风险提示 |
| **收件箱** | 自动保存一份(`save_result_to_inbox: true` |
#### 完整报告结构
1. **全品种多维度排名表**(振幅/成交量/涨跌幅/趋势/综合评分)
2. **🟢 交易机会品种详细计划**
3. **👀 重点关注品种清单**
4. **🔴 规避品种及原因**
5. **板块热度分析**(按黑色系/贵金属/有色/能源化工/农产品/建材分组)
6. **风险提示**
---
## 🔧 技术配置
| 配置项 | 值 |
|--------|-----|
| 任务类型 | agent |
| 会话 ID | 1779157619683 |
| 用户 ID | default |
| 分发模式 | final仅最终结果 |
| 最大并发 | 1 |
| 超时 | 1200 秒 |
| 错过宽限 | 60 秒 |
| 共享会话 | ✅ |
| 收件箱 | ✅ |
---
## ⏰ 调度说明
| 项目 | 值 |
|------|-----|
| Cron 表达式 | `1 8 * * mon-fri` |
| UTC 时间 | 每周一至周五 08:01 |
| **北京时间** | **每周一至周五 16:01**收盘后1分钟 |
---
## 📊 v1 → v2 变更摘要
| 变更项 | v1旧版 | v2新版 |
|--------|-----------|-----------|
| 数据收集 | 优先本地缓存缺失才调MCP | **直接调用 batch_get_market_data 获取全品种数据** |
| 分析范围 | 仅有机会品种 | **全品种扫描 + 多维度打分排名** |
| 筛选维度 | 多周期共振 + 量价 | **新增:当日振幅排名、成交量排名、涨跌幅排名** |
| 评分体系 | 无量化评分 | **5维度加权综合评分振幅20%+量15%+涨跌幅15%+趋势35%+活跃度15%** |
| 输出格式 | Top3-5计划 | **全品种排名表 + 分级清单(🟢👀🔴) + Top3-5详细计划 + 板块热度** |
---
*文档生成时间2026-06-14*

@ -0,0 +1,55 @@
@echo off
chcp 65001 >nul
echo ====================================
echo 期货智析平台 - Docker 部署脚本
echo ====================================
echo.
echo [1/4] 检查Docker环境...
docker --version >nul 2>&1
if errorlevel 1 (
echo [错误] 未检测到Docker请先安装Docker Desktop
pause
exit /b 1
)
echo [√] Docker已安装
echo.
echo [2/4] 停止并清理旧容器...
docker-compose down
echo [√] 清理完成
echo.
echo [3/4] 构建Docker镜像...
docker-compose build --no-cache
if errorlevel 1 (
echo [错误] 镜像构建失败
pause
exit /b 1
)
echo [√] 镜像构建完成
echo.
echo [4/4] 启动Docker容器...
docker-compose up -d
if errorlevel 1 (
echo [错误] 容器启动失败
pause
exit /b 1
)
echo.
echo ====================================
echo 部署完成!
echo ====================================
echo.
echo 访问地址: http://localhost:9600
echo 品种分析: http://localhost:9600/futures-analysis
echo API文档: http://localhost:9600/docs
echo 健康检查: http://localhost:9600/api/v1/health
echo.
echo 查看日志: docker-compose logs -f
echo 停止服务: docker-compose down
echo 重启服务: docker-compose restart
echo.
pause

@ -1,23 +1,36 @@
version: '3.8'
services:
buffer-platform:
build: .
container_name: buffer-platform
build:
context: .
dockerfile: Dockerfile
container_name: futures-buffer-platform
restart: unless-stopped
ports:
- "9600:8600"
volumes:
- E:\docker_workspace\futures_datas:/app/data
environment:
- BUFFER_DB_PATH=/app/data/buffer.db
- BUFFER_HOST=0.0.0.0
- BUFFER_PORT=8600
- CACHE_TTL=300
- BUFFER_LOG_LEVEL=INFO
restart: unless-stopped
- MAX_WORKERS=2
- TZ=Asia/Shanghai
volumes:
# 数据持久化 - SQLite数据库
- ./data:/app/data
# 静态文件(开发模式,生产环境应该打包到镜像中)
- ./app/static:/app/app/static
# 日志持久化(可选)
- ./logs:/app/logs
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8600/api/v1/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
start_period: 10s
networks:
- futures-network
networks:
futures-network:
driver: bridge

@ -0,0 +1,25 @@
@echo off
chcp 65001 >nul
echo ========================================
echo 查看期货智析缓冲平台日志
echo ========================================
echo.
echo 请选择日志查看模式:
echo 1. 查看最近100行日志
echo 2. 实时跟踪日志
echo 3. 查看最近1小时日志
echo.
set /p MODE=请输入选项 (1/2/3):
if "%MODE%"=="1" (
docker-compose logs --tail=100
) else if "%MODE%"=="2" (
docker-compose logs -f
) else if "%MODE%"=="3" (
docker-compose logs --since 1h
) else (
echo 无效选项
)
pause

@ -0,0 +1,98 @@
@echo off
chcp 65001 >nul
echo ====================================
echo 期货智析平台 - Docker 管理脚本
echo ====================================
echo.
echo 请选择操作:
echo 1. 启动服务
echo 2. 停止服务
echo 3. 重启服务
echo 4. 查看日志
echo 5. 查看状态
echo 6. 进入容器
echo 7. 清理资源
echo 0. 退出
echo.
set /p choice=请输入选项 (0-7):
if "%choice%"=="1" goto start
if "%choice%"=="2" goto stop
if "%choice%"=="3" goto restart
if "%choice%"=="4" goto logs
if "%choice%"=="5" goto status
if "%choice%"=="6" goto shell
if "%choice%"=="7" goto clean
if "%choice%"=="0" goto end
goto menu
:start
echo.
echo [启动服务...]
docker-compose up -d
echo.
echo 服务已启动!访问 http://localhost:9600
pause
goto menu
:stop
echo.
echo [停止服务...]
docker-compose stop
echo.
echo 服务已停止!
pause
goto menu
:restart
echo.
echo [重启服务...]
docker-compose restart
echo.
echo 服务已重启!
pause
goto menu
:logs
echo.
echo [查看日志 - 按Ctrl+C退出]
docker-compose logs -f --tail=100
goto menu
:status
echo.
echo [服务状态]
docker-compose ps
echo.
echo [容器资源使用]
docker stats --no-stream futures-buffer-platform
pause
goto menu
:shell
echo.
echo [进入容器...]
docker exec -it futures-buffer-platform /bin/bash
goto menu
:clean
echo.
echo [警告] 这将停止并删除容器,但保留数据卷
set /p confirm=确认继续? (y/n):
if /i not "%confirm%"=="y" goto menu
echo.
echo [清理资源...]
docker-compose down
echo.
echo 资源已清理!
pause
goto menu
:menu
cls
goto start
:end
echo.
echo 再见!
exit

@ -0,0 +1,25 @@
@echo off
chcp 65001 >nul
echo ========================================
echo 启动期货智析缓冲平台
echo ========================================
echo.
docker-compose start
echo.
echo 等待服务启动...
timeout /t 3 /nobreak >nul
echo.
echo ========================================
echo 访问地址:
echo 品种分析页面: http://localhost:9600/futures-analysis
echo 配置管理页面: http://localhost:9600/ui
echo AI配置页面: http://localhost:9600/ai-config
echo API文档: http://localhost:9600/docs
echo ========================================
echo.
docker-compose ps
pause

@ -0,0 +1,13 @@
@echo off
chcp 65001 >nul
echo ========================================
echo 停止期货智析缓冲平台
echo ========================================
echo.
docker-compose stop
echo.
echo ✓ 服务已停止
echo.
echo 使用 start.bat 重新启动服务
pause
Loading…
Cancel
Save