feat: 初始化代码;目前akshare可以获取基本数据,待完善

master
Lxy 3 months ago
commit 1f3694908b

@ -0,0 +1,59 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Git
.git/
.gitignore
# Documentation
*.md
# Tests
.pytest_cache/
.coverage
htmlcov/
# Local config
.env
config.local.json
# Data
*.db
*.sqlite
*.sqlite3

@ -0,0 +1,12 @@
# 服务器配置
PORT=8080
API_KEY=demo-api-key-2024
# 数据库配置Docker 中使用)
DATABASE_URL=postgresql://postgres:postgres123@localhost:5432/marketdata
# Redis 配置
REDIS_URL=redis://localhost:6379/0
# 日志级别
LOG_LEVEL=INFO

@ -0,0 +1,712 @@
# 统一行情数据服务 - 部署文档
本文档详细说明行情数据服务的部署方式,支持 **Go****Python** 两种实现。
## 实现方式选择
| 特性 | Go实现 | Python实现 |
|------|--------|------------|
| 性能 | ⭐⭐⭐ 高性能 | ⭐⭐ 良好 |
| 开发效率 | ⭐⭐ 中等 | ⭐⭐⭐ 高 |
| 数据源对接 | ⭐⭐ 需自行封装 | ⭐⭐⭐ Tushare原生支持 |
| 生态丰富度 | ⭐⭐ 中等 | ⭐⭐⭐ 丰富 |
| 部署复杂度 | ⭐⭐⭐ 单二进制 | ⭐⭐ 依赖较多 |
**推荐**:
- 生产环境高并发场景选择 **Go实现**
- 快速原型、数据源对接优先选择 **Python实现**
---
# 一、Go 实现部署
## 1.1 部署架构
```
┌─────────────────────────────────────────────────────────────┐
│ 负载均衡器 (可选) │
│ Nginx/HAProxy │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────┼─────────────┐
│ │ │
┌───────▼──────┐ ┌────▼─────┐ ┌────▼─────┐
│ 服务实例1 │ │ 服务实例2 │ │ 服务实例3 │
│ :8080 │ │ :8080 │ │ :8080 │
└───────┬──────┘ └────┬─────┘ └────┬─────┘
│ │ │
└─────────────┼────────────┘
┌─────────────▼──────────────┐
│ PostgreSQL 主从 │
│ (分区表 + 读写分离) │
└────────────────────────────┘
```
## 1.2 环境要求
### 服务器配置
| 组件 | 最低配置 | 推荐配置 |
|------|---------|---------|
| 应用服务 | 2核4G | 4核8G |
| PostgreSQL | 4核8G | 8核16G |
| 存储 | 200GB SSD | 500GB SSD |
| 网络 | 10Mbps | 100Mbps |
### 软件依赖
- **操作系统**: Ubuntu 20.04+ / CentOS 8+ / Debian 11+
- **Go**: 1.21+
- **PostgreSQL**: 15+ (支持分区表)
- **Nginx**: 1.18+ (可选,用于负载均衡)
## 1.3 部署步骤
### 安装依赖
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install -y postgresql-15 postgresql-contrib-15 nginx
# 安装Go 1.21
wget https://go.dev/dl/go1.21.6.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.21.6.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
```
### 配置PostgreSQL
```bash
# 启动PostgreSQL
sudo systemctl start postgresql
sudo systemctl enable postgresql
# 创建数据库和用户
sudo -u postgres psql -c "CREATE USER marketdata WITH PASSWORD 'your_password';"
sudo -u postgres psql -c "CREATE DATABASE marketdata OWNER marketdata;"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE marketdata TO marketdata;"
# 启用分区表支持
sudo -u postgres psql -d marketdata -c "CREATE EXTENSION IF NOT EXISTS pg_partman;"
# 配置允许远程连接 (可选)
sudo sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/g" /etc/postgresql/15/main/postgresql.conf
echo "host all all 0.0.0.0/0 scram-sha-256" | sudo tee -a /etc/postgresql/15/main/pg_hba.conf
sudo systemctl restart postgresql
```
### 部署应用
```bash
# 创建应用目录
sudo mkdir -p /opt/market-data-service
sudo chown $USER:$USER /opt/market-data-service
# 解压项目
tar -xzf market-data-service.tar.gz -C /opt/
cd /opt/market-data-service
# 下载依赖
go mod download
# 构建
make build-all
# 创建配置文件目录
mkdir -p /opt/market-data-service/config
mkdir -p /opt/market-data-service/logs
```
### 配置环境变量
创建 `/opt/market-data-service/.env`:
```bash
# 应用配置
export PORT=8080
export GIN_MODE=release
export API_KEY=your_secure_api_key_here
# 数据库配置
export DATABASE_URL="postgres://marketdata:your_password@localhost:5432/marketdata?sslmode=disable"
# Tushare配置
export TUSHARE_TOKEN=your_tushare_token_here
# 日志配置
export LOG_LEVEL=info
export LOG_FILE=/opt/market-data-service/logs/app.log
```
### 初始化数据库
```bash
cd /opt/market-data-service
source .env
# 执行数据库初始化脚本
psql $DATABASE_URL -f memory/2026-03-07-database-schema.sql
# 同步基础数据
cd /opt/market-data-service
./bin/market-data-sync -type stocks
./bin/market-data-sync -type futures
./bin/market-data-sync -type calendar -start 20240101 -end 20241231
```
### 创建Systemd服务
创建 `/etc/systemd/system/market-data-service.service`:
```ini
[Unit]
Description=Market Data Service
After=network.target postgresql.service
[Service]
Type=simple
User=marketdata
Group=marketdata
WorkingDirectory=/opt/market-data-service
EnvironmentFile=/opt/market-data-service/.env
ExecStart=/opt/market-data-service/bin/market-data-service
Restart=always
RestartSec=5
StandardOutput=append:/opt/market-data-service/logs/service.log
StandardError=append:/opt/market-data-service/logs/error.log
[Install]
WantedBy=multi-user.target
```
```bash
# 创建用户
sudo useradd -r -s /bin/false marketdata
sudo chown -R marketdata:marketdata /opt/market-data-service
# 启动服务
sudo systemctl daemon-reload
sudo systemctl enable market-data-service
sudo systemctl start market-data-service
# 查看状态
sudo systemctl status market-data-service
sudo journalctl -u market-data-service -f
```
### Docker部署 (Go)
```dockerfile
# Dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o bin/market-data-service ./cmd/server
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/bin/market-data-service .
COPY --from=builder /app/config ./config
EXPOSE 8080
CMD ["./market-data-service"]
```
---
# 二、Python 实现部署
## 2.1 部署架构
Python实现的部署架构与Go相同但运行环境需要Python解释器和依赖库。
```
┌─────────────────────────────────────────────────────────────┐
│ Nginx (反向代理) │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────┼─────────────┐
│ │ │
┌───────▼──────┐ ┌────▼─────┐ ┌────▼─────┐
│ Python服务1 │ │ Python │ │ Python │
│ Uvicorn │ │ 服务2 │ │ 服务3 │
│ :8080 │ │ :8081 │ │ :8082 │
└───────┬──────┘ └────┬─────┘ └────┬─────┘
│ │ │
└─────────────┼────────────┘
┌─────────────▼──────────────┐
│ PostgreSQL │
└────────────────────────────┘
```
## 2.2 环境要求
- **操作系统**: Ubuntu 20.04+ / CentOS 8+ / Windows 10+
- **Python**: 3.10+
- **PostgreSQL**: 15+
- **Nginx**: 1.18+ (可选)
## 2.3 部署步骤
### 安装Python环境
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install -y python3.10 python3.10-venv python3-pip postgresql-15 nginx
# CentOS/RHEL
sudo yum install -y python310 python310-pip postgresql15-server nginx
```
### 创建虚拟环境
```bash
# 创建应用目录
sudo mkdir -p /opt/python-market-data-service
sudo chown $USER:$USER /opt/python-market-data-service
cd /opt/python-market-data-service
# 创建虚拟环境
python3.10 -m venv venv
# 激活虚拟环境
source venv/bin/activate
# 升级pip
pip install --upgrade pip
```
### 安装依赖
```bash
# 复制项目文件
cp -r /path/to/python_market_data_service/* /opt/python-market-data-service/
# 安装依赖
pip install -r requirements.txt
# 安装Tushare需单独安装
pip install tushare
```
### 配置环境变量
创建 `/opt/python-market-data-service/.env`:
```bash
# 应用配置
export PORT=8080
export DATABASE_URL="postgresql://marketdata:your_password@localhost:5432/marketdata"
export TUSHARE_TOKEN="your_tushare_token"
export API_KEY="your_api_key"
# Python路径
export PYTHONPATH=/opt/python-market-data-service
```
### 初始化数据库
```bash
source venv/bin/activate
source .env
# 使用Python初始化数据库SQLAlchemy会自动创建表
python -c "from app.repositories.database import init_db; init_db()"
# 或使用SQL脚本
psql $DATABASE_URL -f memory/2026-03-07-database-schema.sql
```
### 同步基础数据
```bash
source venv/bin/activate
source .env
# 同步股票列表
python scripts/sync_data.py --type stocks
# 同步期货列表
python scripts/sync_data.py --type futures
# 同步交易日历
python scripts/sync_data.py --type calendar --start 20240101 --end 20241231
```
### 启动服务
**方式1: 直接启动(开发模式)**
```bash
source venv/bin/activate
python -m app.main
```
**方式2: 使用Uvicorn生产模式**
```bash
source venv/bin/activate
# 单进程
uvicorn app.main:app --host 0.0.0.0 --port 8080
# 多进程(推荐)
uvicorn app.main:app --host 0.0.0.0 --port 8080 --workers 4
```
**方式3: 使用Gunicorn更高性能**
```bash
source venv/bin/activate
# 4个worker
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8080
```
### 创建Systemd服务 (Python)
创建 `/etc/systemd/system/python-market-data-service.service`:
```ini
[Unit]
Description=Python Market Data Service
After=network.target postgresql.service
[Service]
Type=simple
User=marketdata
Group=marketdata
WorkingDirectory=/opt/python-market-data-service
Environment=PATH=/opt/python-market-data-service/venv/bin
EnvironmentFile=/opt/python-market-data-service/.env
ExecStart=/opt/python-market-data-service/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080
Restart=always
RestartSec=5
StandardOutput=append:/opt/python-market-data-service/logs/service.log
StandardError=append:/opt/python-market-data-service/logs/error.log
[Install]
WantedBy=multi-user.target
```
```bash
# 创建用户和日志目录
sudo useradd -r -s /bin/false marketdata
sudo mkdir -p /opt/python-market-data-service/logs
sudo chown -R marketdata:marketdata /opt/python-market-data-service
# 启动服务
sudo systemctl daemon-reload
sudo systemctl enable python-market-data-service
sudo systemctl start python-market-data-service
# 查看状态
sudo systemctl status python-market-data-service
sudo journalctl -u python-market-data-service -f
```
### Docker部署 (Python)
创建 `Dockerfile`:
```dockerfile
# Dockerfile for Python
FROM python:3.10-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install tushare
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 8080
# 启动命令
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
```
创建 `docker-compose.yml`:
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- PORT=8080
- DATABASE_URL=postgresql://marketdata:password@postgres:5432/marketdata
- TUSHARE_TOKEN=${TUSHARE_TOKEN}
- API_KEY=${API_KEY}
depends_on:
- postgres
restart: always
volumes:
- ./logs:/app/logs
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_USER=marketdata
- POSTGRES_PASSWORD=password
- POSTGRES_DB=marketdata
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: always
volumes:
postgres_data:
```
```bash
# 构建并启动
docker-compose up -d
# 查看日志
docker-compose logs -f app
# 同步数据
docker-compose exec app python scripts/sync_data.py --type stocks
```
---
# 三、Nginx配置通用
## 3.1 配置Nginx
创建 `/etc/nginx/sites-available/market-data-service`:
```nginx
upstream market_data_backend {
server 127.0.0.1:8080;
# 多实例时添加
# server 127.0.0.1:8081;
# server 127.0.0.1:8082;
}
server {
listen 80;
server_name api.marketdata.example.com;
# WebSocket支持
location /v1/stream {
proxy_pass http://market_data_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-API-Key $http_x_api_key;
proxy_read_timeout 86400;
}
# REST API
location /v1/ {
proxy_pass http://market_data_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-API-Key $http_x_api_key;
# 限流
limit_req zone=api_limit burst=20 nodelay;
}
# 管理后台
location /admin {
proxy_pass http://market_data_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 健康检查
location /health {
proxy_pass http://market_data_backend/v1/admin/health;
access_log off;
}
}
# 限流配置
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
```
```bash
sudo ln -s /etc/nginx/sites-available/market-data-service /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
---
# 四、监控与维护(通用)
## 4.1 查看服务状态
```bash
# Go服务
sudo systemctl status market-data-service
# Python服务
sudo systemctl status python-market-data-service
# 实时日志
sudo tail -f /opt/market-data-service/logs/service.log
# 或
sudo tail -f /opt/python-market-data-service/logs/service.log
# 数据库连接数
psql $DATABASE_URL -c "SELECT count(*) FROM pg_stat_activity;"
```
## 4.2 备份策略
```bash
#!/bin/bash
# 数据库备份脚本
BACKUP_DIR=/opt/backups
DATE=$(date +%Y%m%d_%H%M%S)
pg_dump $DATABASE_URL | gzip > $BACKUP_DIR/marketdata_$DATE.sql.gz
# 保留最近7天备份
find $BACKUP_DIR -name "marketdata_*.sql.gz" -mtime +7 -delete
```
## 4.3 水平扩展
**Go:**
```bash
PORT=8081 ./bin/market-data-service &
PORT=8082 ./bin/market-data-service &
PORT=8083 ./bin/market-data-service &
```
**Python:**
```bash
# 使用不同端口启动多个实例
uvicorn app.main:app --host 0.0.0.0 --port 8081 &
uvicorn app.main:app --host 0.0.0.0 --port 8082 &
uvicorn app.main:app --host 0.0.0.0 --port 8083 &
```
---
# 五、故障排查
## 5.1 常见问题
**问题1**: 服务无法启动,提示数据库连接失败
```bash
# 检查PostgreSQL状态
sudo systemctl status postgresql
# 测试连接
psql $DATABASE_URL -c "SELECT 1;"
```
**问题2**: Python服务依赖安装失败
```bash
# 检查Python版本
python3 --version # 需 >= 3.10
# 安装系统依赖Ubuntu
sudo apt install -y python3-dev libpq-dev gcc
# 重新安装
pip install -r requirements.txt --force-reinstall
```
**问题3**: WebSocket连接断开
```bash
# 检查连接数
netstat -an | grep :8080 | wc -l
# 查看错误日志
sudo tail -f /opt/market-data-service/logs/error.log
# 或
sudo tail -f /opt/python-market-data-service/logs/service.log
```
**问题4**: 数据同步失败
```bash
# Go
./bin/market-data-sync -type stocks 2>&1 | head -20
# Python
python scripts/sync_data.py --type stocks 2>&1 | head -20
```
---
# 六、性能优化
## 6.1 数据库优化
```sql
-- 添加额外索引
CREATE INDEX CONCURRENTLY idx_klines_symbol_ts ON stock.klines_1m(symbol_id, ts DESC);
```
## 6.2 连接池配置
**Go:**
```bash
export DB_MAX_OPEN_CONNS=100
export DB_MAX_IDLE_CONNS=10
```
**Python:**
```python
# 在 app/repositories/database.py 中修改
engine = create_engine(
config.database.database_url,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
)
```
## 6.3 查询缓存
高频查询结果可缓存到RedisPython实现已预留Redis配置。
---
# 七、安全建议
1. **API Key管理**: 定期更换API Key使用环境变量存储
2. **数据库安全**: 使用强密码限制远程访问启用SSL
3. **网络安全**: 配置防火墙,仅开放必要端口
4. **日志脱敏**: 日志中避免输出敏感信息
---
**文档结束**

@ -0,0 +1,141 @@
# Docker 部署指南
## 快速开始
### 方式一:一键启动全部服务(推荐)
**Windows:**
```bash
start-docker.bat
```
**Linux/Mac:**
```bash
chmod +x start-docker.sh
./start-docker.sh
```
启动后会自动:
1. 构建 Docker 镜像
2. 启动 PostgreSQL 数据库
3. 启动 Redis
4. 启动行情数据服务
5. 自动创建数据库表
### 方式二:只启动数据库(本地开发)
如果你只想用 Docker 启动数据库,然后在本地运行 Python 服务:
```bash
start-db-only.bat
```
然后在本地启动服务:
```bash
python -m app.main
```
## 访问服务
启动成功后,可以通过以下地址访问:
| 服务 | 地址 |
|------|------|
| 行情数据服务 | http://localhost:8080 |
| 管理后台 | http://localhost:8080/admin |
| API 文档 (Swagger) | http://localhost:8080/docs |
| API 文档 (ReDoc) | http://localhost:8080/redoc |
## 数据库连接
```
主机: localhost
端口: 5432
数据库: marketdata
用户名: postgres
密码: postgres123
```
连接字符串:
```
postgresql://postgres:postgres123@localhost:5432/marketdata
```
## 常用命令
```bash
# 查看日志
docker compose logs -f
# 停止服务
docker compose down
# 停止并删除数据卷(清空数据)
docker compose down -v
# 重启服务
docker compose restart
# 进入数据库容器
docker exec -it market_data_postgres psql -U postgres -d marketdata
```
## 数据结构
启动时会自动创建以下表:
### 股票相关表
- `stock_symbols` - 股票标的表
- `stock_trading_calendar` - 股票交易日历
- `stock_klines_1m` - 股票1分钟K线
- `stock_klines_5m` - 股票5分钟K线
- `stock_klines_1d` - 股票日线K线
### 期货相关表
- `futures_symbols` - 期货合约表
- `futures_trading_calendar` - 期货交易日历
- `futures_klines_1m` - 期货1分钟K线
- `futures_klines_1d` - 期货日线K线
### 公共表
- `data_source_config` - 数据源配置
- `data_quality_checks` - 数据质量检查
## 故障排查
### 端口冲突
如果 5432 或 8080 端口被占用,修改 `docker-compose.yml` 中的端口映射:
```yaml
ports:
- "5433:5432" # 使用 5433 代替 5432
```
### 数据库连接失败
1. 检查容器是否运行:
```bash
docker ps
```
2. 查看数据库日志:
```bash
docker logs market_data_postgres
```
3. 检查数据库是否就绪:
```bash
docker exec -it market_data_postgres pg_isready -U postgres
```
### 数据持久化
数据默认存储在 Docker 卷中:
- `postgres_data` - PostgreSQL 数据
- `redis_data` - Redis 数据
即使删除容器,数据也不会丢失。要清空数据:
```bash
docker compose down -v
```

@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制项目代码
COPY . .
# 暴露端口
EXPOSE 8080
# 启动命令
CMD ["python", "-m", "app.main"]

@ -0,0 +1,325 @@
# Go 到 Python 迁移指南
本文档详细说明了从Go项目迁移到Python项目的对应关系。
## 技术栈对照表
| Go | Python | 说明 |
|----|--------|------|
| Gin | FastAPI | Web框架 |
| Gorilla WebSocket | FastAPI原生WebSocket | WebSocket支持 |
| database/sql + pq | SQLAlchemy + psycopg2 | 数据库访问 |
| encoding/json | Pydantic + json | JSON序列化 |
| os.Getenv | python-dotenv + pydantic-settings | 环境变量 |
| log | logging | 日志 |
| time | datetime | 时间处理 |
| sync.Mutex | threading.Lock/asyncio.Lock | 并发锁 |
| context.Context | 直接使用async/await | 上下文 |
## 项目结构对照表
```
Go Python
├── adapter/ app/adapters/
│ ├── adapter.go base.py
│ └── tushare/
│ ├── adapter.go tushare_adapter.py
│ └── client.go (集成到adapter)
├── api/
│ ├── types.go app/models/types.py
│ ├── router.go app/api/routes.py
│ ├── admin_types.go app/models/admin_types.py
│ └── admin_router.go app/api/admin_routes.py
├── cmd/
│ ├── server/main.go app/main.py
│ └── sync/main.go scripts/sync_data.py
├── internal/
│ ├── handler/
│ │ ├── handler.go (合并到routes)
│ │ └── admin.go (合并到admin_routes)
│ ├── service/
│ │ ├── service.go (拆分到各service)
│ │ ├── stock.go app/services/stock_service.py
│ │ ├── futures.go app/services/futures_service.py
│ │ ├── admin.go app/services/admin_service.py
│ │ ├── config.go app/services/config_service.py
│ │ ├── adapter.go app/services/adapter_service.py
│ │ └── test.go app/services/test_service.py
│ ├── repository/
│ │ ├── repository.go (合并)
│ │ ├── stock.go app/repositories/stock_repository.py
│ │ └── futures.go app/repositories/futures_repository.py
│ ├── model/model.go app/repositories/models.py
│ ├── websocket/server.go app/websocket/server.py
│ └── monitor/monitor.go app/monitor/monitor.py
├── pkg/
│ ├── config/config.go app/core/config.py
│ ├── logger/logger.go app/core/logger.py
│ └── errors/errors.go app/core/errors.py
└── config.json config.json (相同)
```
## 类型系统对照表
### 基础类型
| Go | Python |
|----|--------|
| `type Frequency string` | `class Frequency(str, Enum)` |
| `type AdjustType string` | `class AdjustType(str, Enum)` |
| `type AssetClass string` | `class AssetClass(str, Enum)` |
| `struct KLineItem` | `class KLineItem(BaseModel)` |
| `struct KLineData` | `class KLineData(BaseModel)` |
| `interface Handler` | 通过FastAPI依赖注入实现 |
### 类型转换示例
**Go:**
```go
type KLineItem struct {
Time time.Time `json:"time"`
Open float64 `json:"open"`
Volume int64 `json:"volume"`
}
```
**Python:**
```python
class KLineItem(BaseModel):
time: datetime = Field(..., description="时间戳")
open: float = Field(..., description="开盘价")
volume: int = Field(..., description="成交量")
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
```
## 接口路由对照表
### 股票接口
| Go路由 | Python路由 | 方法 |
|--------|------------|------|
| `stock.GET("/klines/:symbol", r.queryStockKLines)` | `@router.get("/stock/klines/{symbol}")` | GET |
| `stock.GET("/symbols", r.listStockSymbols)` | `@router.get("/stock/symbols")` | GET |
| `stock.POST("/klines/batch", r.batchQueryStockKLines)` | `@router.post("/stock/klines/batch")` | POST |
| `stock.GET("/trading-dates", r.getStockTradingDates)` | `@router.get("/stock/trading-dates")` | GET |
### 参数绑定对比
**Go (Gin):**
```go
func (r *Router) queryStockKLines(c *gin.Context) {
var req KLineQueryRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{...})
return
}
req.Symbol = c.Param("symbol")
// ...
}
```
**Python (FastAPI):**
```python
@router.get("/stock/klines/{symbol}")
def query_stock_klines(
symbol: str, # 路径参数
start: str = Query(...), # 查询参数
end: str = Query(...),
db: Session = Depends(get_db) # 依赖注入
):
service = StockService(db)
req = KLineQueryRequest(symbol=symbol, start=start, end=end)
data = service.query_klines(req)
return Response(code=0, message="success", data=data)
```
## 数据库访问对照表
### 查询K线数据
**Go:**
```go
query := fmt.Sprintf(`
SELECT ts, open, high, low, close, volume, amount
FROM %s
WHERE symbol_id = $1 AND ts >= $2 AND ts <= $3
ORDER BY ts ASC
`, tableName)
rows, err := r.db.QueryContext(ctx, query, symbol, start, end)
```
**Python (SQLAlchemy):**
```python
query = self.db.query(kline_model).filter(
kline_model.symbol_id == symbol,
kline_model.ts >= start,
kline_model.ts <= end
).order_by(kline_model.ts.asc())
results = query.all()
```
### 批量插入
**Go:**
```go
query := fmt.Sprintf(`
INSERT INTO %s (symbol_id, ts, open, ...)
VALUES %s
ON CONFLICT (symbol_id, ts) DO UPDATE SET...
`, tableName, strings.Join(valueStrs, ","))
_, err := r.db.ExecContext(ctx, query, args...)
```
**Python (SQLAlchemy):**
```python
for item in items:
existing = self.db.query(kline_model).filter(...).first()
if existing:
# 更新
existing.open = item.open
...
else:
# 插入
new_record = kline_model(...)
self.db.add(new_record)
self.db.commit()
```
## WebSocket对照表
### Go (Gorilla)
```go
type Hub struct {
clients map[*Client]bool
subscriptions map[string]map[*Client]bool
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
// ...
}
}
}
```
### Python (FastAPI)
```python
class WebSocketManager:
def __init__(self):
self.clients: Dict[str, WSClient] = {}
self.subscriptions: Dict[str, Set[str]] = {}
self.lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, client_id: str):
await websocket.accept()
async with self.lock:
self.clients[client_id] = WSClient(id=client_id, websocket=websocket)
```
## 配置管理对照表
### Go
```go
type Config struct {
Server ServerConfig `json:"server"`
Database DatabaseConfig `json:"database"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
// ...
json.Unmarshal(data, &cfg)
return &cfg, nil
}
```
### Python
```python
class Config(BaseModel):
server: ServerConfig = Field(default_factory=ServerConfig)
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
def load_config(config_path: str = "./config.json") -> Config:
with open(config_path, 'r') as f:
data = json.load(f)
return Config.model_validate(data)
```
## 启动方式对照表
### Go
```bash
go run ./cmd/server/main.go
```
### Python
```bash
# 方式1: 直接运行
python -m app.main
# 方式2: 使用uvicorn
uvicorn app.main:app --reload --port 8080
# 方式3: 生产环境
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker
```
## 数据同步工具对照表
### Go
```bash
go run ./cmd/sync -type stocks
go run ./cmd/sync -type klines -symbol 000001.SZ -start 20240301 -end 20240307
```
### Python
```bash
python scripts/sync_data.py --type stocks
python scripts/sync_data.py --type klines --symbol 000001.SZ --start 20240301 --end 20240307
```
## 依赖管理对照表
### Go (go.mod)
```go
require (
github.com/gin-gonic/gin v1.9.1
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
)
```
### Python (requirements.txt)
```
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
```
## 测试接口对照表
| 接口 | Go调用 | Python调用 |
|------|--------|------------|
| 健康检查 | `curl http://localhost:8080/v1/admin/health` | 相同 |
| 查询股票K线 | `curl "http://localhost:8080/v1/stock/klines/000001.SZ?start=20250301&end=20250307" -H "X-API-Key: key"` | 相同 |
| 批量查询 | `curl -X POST ... -d '{"symbols":["000001.SZ"],...}'` | 相同 |
所有API接口和响应格式与Go版本完全一致客户端无需任何修改即可切换到Python后端。

@ -0,0 +1,146 @@
# PostgreSQL 安装指南Windows
由于网络限制无法自动下载,请按以下步骤手动安装:
## 方法一:官方安装包(推荐)
### 1. 下载 PostgreSQL
访问官网下载地址:
```
https://www.enterprisedb.com/downloads/postgres-postgresql-downloads
```
或直接下载15.5 版本):
```
https://get.enterprisedb.com/postgresql/postgresql-15.5-1-windows-x64.exe
```
### 2. 安装步骤
1. 运行下载的安装程序
2. 安装目录:`C:\Program Files\PostgreSQL\15`
3. 数据目录:`C:\Program Files\PostgreSQL\15\data`
4. **密码设置**`postgres123`(与配置文件一致)
5. 端口:`5432`(默认)
6. Locale默认或 Chinese (Simplified)
### 3. 创建数据库
安装完成后,打开 **pgAdmin 4****SQL Shell (psql)**
```sql
-- 创建数据库
CREATE DATABASE marketdata;
-- 验证
\l
```
### 4. 启动服务
安装程序会自动创建 Windows 服务,确保服务已启动:
```powershell
# 查看服务状态
Get-Service postgresql*
# 启动服务
net start postgresql-x64-15
```
## 方法二:绿色免安装版
如果安装包下载慢,可以使用绿色版:
### 1. 下载绿色版
```
https://github.com/postgres/postgres/archive/refs/tags/REL_15_5.zip
```
或使用 scoop 安装(需要 scoop
```powershell
scoop install postgresql
```
### 2. 初始化数据库
```powershell
# 创建数据目录
mkdir D:\pgsql\data
# 初始化
initdb -D D:\pgsql\data -U postgres -W
# 启动服务
pg_ctl -D D:\pgsql\data start
# 创建数据库
createdb -U postgres marketdata
```
## 验证安装
```powershell
# 检查端口
netstat -ano | findstr 5432
# 连接测试
psql -U postgres -d marketdata -c "SELECT version();"
```
## 配置项目
安装完成后,修改项目配置:
**config.json**(已配置好):
```json
{
"database": {
"host": "localhost",
"port": 5432,
"user": "postgres",
"password": "postgres123",
"database": "marketdata"
}
}
```
## 初始化数据库表
PostgreSQL 安装并启动后,运行:
```bash
cd d:\fs_workspace\market-data-service\python_market_data_service
python test_db.py
```
这将自动创建所有需要的表。
## 常见问题
### 1. 端口被占用
```powershell
# 查看占用 5432 端口的进程
netstat -ano | findstr 5432
# 如果是旧版 PostgreSQL停止它
net stop postgresql-x64-14
```
### 2. 连接失败
- 检查服务是否运行:`services.msc` 中找到 PostgreSQL 服务
- 检查防火墙设置
- 检查 pg_hba.conf 配置
### 3. 密码错误
```powershell
# 重置密码
psql -U postgres
\password postgres
```
## 下一步
安装完成后,启动服务:
```bash
python -m app.main
```
访问 http://localhost:8080/admin 查看管理后台

@ -0,0 +1,395 @@
# 统一行情数据服务 - 项目进度报告
**项目**: 统一行情数据服务
**版本**: v1.0
**日期**: 2026-03-08
**状态**: P0 + P1 阶段已完成,支持 Go 和 Python 双实现
---
## 一、项目概览
### 1.1 项目目标
建设统一行情数据聚合层,对接多方数据源,提供标准化的数据接口,降低业务方接入成本。
### 1.2 核心功能
- ✅ 多周期K线查询1m/5m/15m/30m/60m/1d/1w/1month
- ✅ 股票复权支持前复权qfq/后复权hfq
- ✅ 数据源热切换Tushare适配完成预留Wind接口
- ✅ 股票/期货双轨设计
- ✅ WebSocket实时订阅
- ✅ 数据质量监控
- ✅ 交易日历查询
- ✅ 期货合约查询
- ✅ **双语言实现**Go + Python
---
## 二、完成进度
### 2.1 整体进度: 75%
```
P0 (基础功能) ████████████████████ 100% 已完成
P1 (期货+适配器) ████████████████████ 100% 已完成
P2 (WebSocket) ████████████████████ 100% 已完成
P3 (监控+管理) ██████████████░░░░░░ 70% 部分完成
Python实现 █████████████████░░░ 90% 已完成
期货主力连续 ░░░░░░░░░░░░░░░░░░░░ 0% 预留未实现
```
### 2.2 双实现状态对比
| 功能模块 | Go实现 | Python实现 | 说明 |
|----------|--------|------------|------|
| HTTP API | ✅ 100% | ✅ 100% | 接口完全一致 |
| WebSocket | ✅ 100% | ✅ 100% | 协议兼容 |
| 数据库访问 | ✅ 100% | ✅ 100% | SQLAlchemy ORM |
| Tushare适配 | ✅ 100% | ✅ 100% | Python原生支持 |
| 配置热加载 | ✅ 100% | ✅ 100% | Pydantic支持 |
| 数据同步工具 | ✅ 100% | ✅ 100% | 功能一致 |
| 管理后台UI | ✅ 100% | ✅ 100% | HTML嵌入 |
| 数据质量监控 | ✅ 100% | ✅ 100% | 功能一致 |
| Docker部署 | ✅ 100% | ✅ 100% | 双版本支持 |
### 2.3 详细清单
#### 数据层 (100%)
| 模块 | Go | Python | 说明 |
|------|----|--------|------|
| PostgreSQL Schema | ✅ | ✅ | stock/futures双Schema5周期K线表交易日历表 |
| 分区表设计 | ✅ | ✅ | 按月分区,已验证 |
| 股票Repository | ✅ | ✅ | CRUD + 批量插入 |
| 期货Repository | ✅ | ✅ | CRUD + 持仓量字段 |
| 交易日历存储 | ✅ | ✅ | 双市场独立日历 |
| SQLAlchemy模型 | N/A | ✅ | Python ORM模型 |
#### 适配器层 (90%)
| 模块 | Go | Python | 说明 |
|------|----|--------|------|
| 适配器接口定义 | ✅ | ✅ | DataSourceAdapter + Factory |
| Tushare客户端 | ✅ | ✅ | 支持股票/期货日线+分钟线 |
| Tushare适配器 | ✅ | ✅ | Python使用tushare库 |
| Wind适配器 | ⏳ | ⏳ | 接口预留,待实现 |
| 数据源热切换 | ✅ | ✅ | 配置表 + 切换接口 |
#### 服务层 (95%)
| 模块 | Go | Python | 说明 |
|------|----|--------|------|
| 股票Service | ✅ | ✅ | K线查询、标的列表、交易日历 |
| 期货Service | ✅ | ✅ | K线查询、标的列表、合约查询 |
| 复权计算 | ⏳ | ⏳ | 接口预留,系数表已创建 |
| 主力连续合约 | ❌ | ❌ | 表结构预留,首期不实现 |
| 管理Service | ✅ | ✅ | 数据源切换、健康检查 |
| 配置Service | ✅ | ✅ | 热加载、回调机制 |
| 适配器Service | ✅ | ✅ | 工厂模式管理 |
| 测试Service | ✅ | ✅ | API/WS测试 |
#### API层 (100%)
| 接口 | Go | Python | 方法 | 状态 |
|------|----|--------|------|------|
| 股票K线查询 | ✅ | ✅ | GET /v1/stock/klines/:symbol | ✅ |
| 股票标的列表 | ✅ | ✅ | GET /v1/stock/symbols | ✅ |
| 股票批量查询 | ✅ | ✅ | POST /v1/stock/klines/batch | ✅ |
| 股票交易日历 | ✅ | ✅ | GET /v1/stock/trading-dates | ✅ |
| 期货K线查询 | ✅ | ✅ | GET /v1/futures/klines/:symbol | ✅ |
| 期货标的列表 | ✅ | ✅ | GET /v1/futures/symbols | ✅ |
| 期货批量查询 | ✅ | ✅ | POST /v1/futures/klines/batch | ✅ |
| 期货合约查询 | ✅ | ✅ | GET /v1/futures/contracts | ✅ |
| 期货交易日历 | ✅ | ✅ | GET /v1/futures/trading-dates | ✅ |
| 主力连续K线 | ⏳ | ⏳ | GET /v1/futures/continuous/:underlying | 预留 |
| 数据源状态 | ✅ | ✅ | GET /v1/admin/source/status | ✅ |
| 数据源切换 | ✅ | ✅ | POST /v1/admin/source/switch | ✅ |
| 历史补录 | ✅ | ✅ | POST /v1/admin/backfill | ✅ |
| 健康检查 | ✅ | ✅ | GET /v1/admin/health | ✅ |
| WebSocket订阅 | ✅ | ✅ | WS /v1/stream | ✅ |
#### 基础设施 (90%)
| 模块 | Go | Python | 说明 |
|------|----|--------|------|
| WebSocket Hub | ✅ | ✅ | 支持100并发心跳保活 |
| 数据质量监控 | ✅ | ✅ | 每日检查,缺失告警 |
| 数据同步工具 | ✅ | ✅ | 支持stocks/futures/calendar/klines |
| API认证 | ✅ | ✅ | X-API-Key Header认证 |
| 限流 | ⏳ | ⏳ | Nginx层可配置 |
| 日志 | ✅ | ✅ | 结构化日志输出 |
| 配置管理 | ✅ | ✅ | 环境变量 + 文件 |
| Docker部署 | ✅ | ✅ | Dockerfile + docker-compose |
---
## 三、项目结构
### Go 项目结构
```
market-data-service/
├── api/ # API接口定义
│ ├── types.go # 类型定义 (580行)
│ ├── router.go # 路由注册 (350行)
│ ├── admin_types.go # 管理后台类型 (270行)
│ └── admin_router.go # 管理后台路由 (680行)
├── internal/
│ ├── handler/ # Handler实现
│ ├── service/ # 业务服务层
│ ├── repository/ # 数据访问层
│ ├── websocket/ # WebSocket服务
│ └── monitor/ # 数据质量监控
├── adapter/ # 数据源适配器
├── cmd/ # 程序入口
├── pkg/ # 公共包
├── docs/ # 文档
└── config.json # 配置文件
总代码量: ~4700行 (Go代码)
```
### Python 项目结构
```
python_market_data_service/
├── app/
│ ├── main.py # FastAPI主应用
│ ├── api/ # API路由
│ │ ├── routes.py # 主要API路由
│ │ └── admin_routes.py # 管理后台路由
│ ├── core/ # 核心模块
│ │ ├── config.py # 配置管理Pydantic
│ │ ├── errors.py # 错误定义
│ │ └── logger.py # 日志工具
│ ├── models/ # 数据模型Pydantic
│ │ ├── types.py # 基础类型
│ │ └── admin_types.py # 管理后台类型
│ ├── repositories/ # 数据访问层SQLAlchemy
│ │ ├── database.py # 数据库连接
│ │ ├── models.py # ORM模型
│ │ ├── stock_repository.py
│ │ └── futures_repository.py
│ ├── services/ # 业务逻辑层
│ │ ├── stock_service.py
│ │ ├── futures_service.py
│ │ ├── admin_service.py
│ │ ├── config_service.py
│ │ ├── adapter_service.py
│ │ └── test_service.py
│ ├── adapters/ # 数据源适配器
│ │ ├── base.py # 适配器基类
│ │ └── tushare_adapter.py
│ ├── websocket/ # WebSocket服务
│ │ └── server.py
│ └── monitor/ # 数据质量监控
│ └── monitor.py
├── scripts/
│ └── sync_data.py # 数据同步工具
├── requirements.txt # Python依赖
├── pyproject.toml # 项目配置
├── config.json # 配置文件与Go相同
├── README.md # Python项目说明
└── MIGRATION_GUIDE.md # 迁移对照指南
总代码量: ~5500行 (Python代码)
```
---
## 四、实现方式对比
### 技术栈对照
| 组件 | Go实现 | Python实现 | 对比说明 |
|------|--------|------------|----------|
| Web框架 | Gin | FastAPI | FastAPI自动生成文档 |
| WebSocket | Gorilla | FastAPI原生 | API一致 |
| 数据库 | database/sql | SQLAlchemy | ORM vs 原生SQL |
| 配置 | 自定义JSON | Pydantic Settings | Python类型安全 |
| 模型 | Go structs | Pydantic Models | Python验证更强 |
| 并发 | Goroutines | asyncio | 协程模型类似 |
### 性能对比(预估)
| 指标 | Go | Python | 说明 |
|------|----|--------|------|
| 单核QPS | ~10,000 | ~3,000 | Go编译型优势 |
| 内存占用 | ~50MB | ~100MB | Python解释器开销 |
| 启动时间 | ~100ms | ~2s | Python加载依赖 |
| 开发效率 | 中等 | 高 | Python生态丰富 |
### 推荐使用场景
| 场景 | 推荐实现 | 理由 |
|------|----------|------|
| 生产高并发 | Go | 性能更优,资源占用低 |
| 快速原型 | Python | 开发快Tushare原生支持 |
| 数据源对接 | Python | Tushare等库生态更好 |
| 学习研究 | Python | 代码更易读,调试方便 |
---
## 五、已知问题与限制
### 5.1 当前限制
1. **复权计算**: 已实现接口,复权系数需从数据源同步
2. **主力连续合约**: 首期不实现,表结构预留
3. **实时Tick**: Tushare不支持实时推送WebSocket推送需对接其他数据源
4. **限流**: 目前仅在Nginx层配置应用层未实现细粒度限流
### 5.2 双实现差异
1. **Go**: 需要手动管理连接池和并发
2. **Python**: 依赖SQLAlchemy连接池异步需async/await
3. **数据同步**: Go使用命令行参数Python使用argparse
### 5.3 待优化项
1. **查询性能**: 大时间范围查询需添加缓存层
2. **并发处理**: WebSocket Hub可优化为更细粒度的锁
3. **错误处理**: 部分错误信息可更详细
4. **监控完善**: 需添加Prometheus指标暴露
---
## 六、测试覆盖
### 6.1 已测试
- ✅ 数据库连接和基本CRUD双实现
- ✅ Tushare API调用双实现
- ✅ HTTP API路由双实现
- ✅ WebSocket连接和订阅双实现
- ✅ 配置热加载(双实现)
### 6.2 待测试
- ⏳ 完整集成测试
- ⏳ 压力测试 (并发查询)
- ⏳ 数据同步大批量测试
- ⏳ 故障恢复测试
- ⏳ 双实现兼容性测试
---
## 七、上线 checklist
### 7.1 必需项 (Blocking)
- [x] 数据库Schema初始化
- [x] 基础数据同步 (股票/期货/日历)
- [x] API接口可用性验证
- [x] 部署文档完成
- [x] Python实现完成
### 7.2 建议项 (Non-blocking)
- [ ] 复权系数数据导入
- [ ] 监控告警配置 (钉钉/邮件)
- [ ] 性能压测报告
- [ ] 灾备方案
- [ ] 运维手册
---
## 八、后续规划
### P3 阶段 (剩余10%)
| 任务 | 工时 | 优先级 | Go | Python |
|------|------|--------|----|--------|
| 复权系数计算完善 | 1天 | 高 | ⏳ | ⏳ |
| 监控告警集成 | 1天 | 中 | ⏳ | ⏳ |
| Prometheus指标 | 1天 | 中 | ⏳ | ⏳ |
| 管理后台UI完善 | 1天 | 低 | ✅ | ✅ |
| 性能测试对比 | 1天 | 中 | ⏳ | ⏳ |
### P4 阶段 (增强)
- Wind数据源适配双实现
- Redis缓存层
- 期货主力连续合约实现
- 分布式部署支持
- gRPC接口支持
---
## 九、使用说明
### 9.1 快速启动 (Go)
```bash
# 1. 配置环境变量
export TUSHARE_TOKEN="your_token"
export DATABASE_URL="postgres://..."
# 2. 初始化数据库
psql $DATABASE_URL -f memory/2026-03-07-database-schema.sql
# 3. 同步基础数据
make sync-stocks
make sync-futures
make sync-calendar
# 4. 启动服务
make run
```
### 9.2 快速启动 (Python)
```bash
# 1. 安装依赖
pip install -r requirements.txt
pip install tushare
# 2. 配置环境变量
export TUSHARE_TOKEN="your_token"
export DATABASE_URL="postgresql://..."
# 3. 初始化数据库
python -c "from app.repositories.database import init_db; init_db()"
# 4. 同步基础数据
python scripts/sync_data.py --type stocks
python scripts/sync_data.py --type futures
python scripts/sync_data.py --type calendar
# 5. 启动服务
python -m app.main
# 或
uvicorn app.main:app --reload
```
### 9.3 API调用示例
```bash
# 查询股票K线接口完全一致
curl "http://localhost:8080/v1/stock/klines/000001.SZ?start=20250301&end=20250307&freq=1d" \
-H "X-API-Key: your_key"
# WebSocket连接协议相同
wscat -H "X-API-Key: your_key" -c ws://localhost:8080/v1/stream
> {"action":"subscribe","symbols":["000001.SZ"]}
```
---
## 十、总结
**当前状态**: 核心功能已完成Go和Python双实现可用
**主要成果**:
1. ✅ 完整的股票/期货双轨数据服务Go
2. ✅ 完整的股票/期货双轨数据服务Python
3. ✅ Tushare数据源适配双实现
4. ✅ WebSocket实时订阅双实现
5. ✅ 数据质量监控(双实现)
6. ✅ 管理后台(双实现)
7. ✅ 完整的部署文档
**双实现价值**:
- **Go**: 生产环境高性能部署
- **Python**: 快速开发、数据源对接、学习研究
- **兼容**: 接口完全一致,可无缝切换
**风险提示**:
1. 复权功能需补充系数数据
2. 未经过大规模压力测试
3. 主力连续合约首期不可用
**建议**:
- 生产环境推荐使用 **Go实现**
- 开发测试可使用 **Python实现**
- 可先在小范围试用,验证稳定性后逐步扩大使用范围
---
**文档结束**

@ -0,0 +1,159 @@
# Docker 快速启动指南
## 前置要求
- 安装 [Docker Desktop](https://www.docker.com/products/docker-desktop)
## 启动步骤
### 1. 启动数据库
Windows:
```bash
start-db-only.bat
```
或者手动启动:
```bash
docker run -d --name market_data_postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres123 \
-e POSTGRES_DB=marketdata \
-p 5432:5432 \
postgres:15-alpine
```
### 2. 初始化数据库表
```bash
python test_db.py
```
输出示例:
```
==================================================
数据库连接测试
==================================================
✅ 数据库连接成功
PostgreSQL 版本: PostgreSQL 15.5 on x86_64-pc-linux-musl...
正在初始化数据库表...
✅ 数据库表创建成功
已创建的表 (13 个):
- data_quality_checks
- data_source_config
- futures_klines_1d
- futures_klines_1m
- futures_symbols
- futures_trading_calendar
- stock_klines_1d
- stock_klines_1m
- stock_klines_5m
- stock_symbols
- stock_trading_calendar
...
==================================================
数据库初始化完成!
==================================================
```
### 3. 启动服务
```bash
python -m app.main
```
## 验证服务
打开浏览器访问:
- http://localhost:8080/admin - 管理后台
- http://localhost:8080/docs - API 文档
## 完整 Docker Compose 启动
一键启动所有服务(数据库 + Redis + 应用):
```bash
docker compose up -d
```
查看日志:
```bash
docker compose logs -f
```
停止服务:
```bash
docker compose down
```
## 故障排查
### 问题 1: 端口被占用
错误:
```
Bind for 0.0.0.0:5432 failed: port is already allocated
```
解决:
```bash
# 查看占用端口的进程
netstat -ano | findstr 5432
# 停止冲突的容器
docker stop <container_id>
```
### 问题 2: 数据库连接失败
检查步骤:
```bash
# 1. 检查容器是否运行
docker ps | findstr postgres
# 2. 查看容器日志
docker logs market_data_postgres
# 3. 测试连接
docker exec -it market_data_postgres pg_isready -U postgres
```
### 问题 3: 表未创建
手动初始化:
```bash
python test_db.py
```
## 数据持久化
数据存储在 Docker 卷中:
```bash
# 查看卷
docker volume ls
# 备份数据
docker exec -it market_data_postgres pg_dump -U postgres marketdata > backup.sql
# 恢复数据
docker exec -i market_data_postgres psql -U postgres marketdata < backup.sql
```
## 完全重置
删除所有数据并重新启动:
```bash
# 停止并删除容器
docker compose down -v
# 重新启动
docker compose up -d
# 初始化表
python test_db.py
```

@ -0,0 +1,400 @@
# 统一行情数据服务 - 快速启动指南
本文档提供最快的启动方式,支持 **Go****Python** 双实现。
---
## 30秒快速启动Python
```bash
# 1. 进入Python项目目录
cd python_market_data_service
# 2. 创建并激活虚拟环境
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 3. 安装依赖
pip install -r requirements.txt
# 4. 启动服务
python -m app.main
```
访问 http://localhost:8080/admin
---
## 30秒快速启动Go
```bash
# 1. 下载依赖
go mod download
# 2. 启动服务
go run ./cmd/server
```
访问 http://localhost:8080/admin
---
## 完整启动步骤
### 前置条件
| 组件 | 版本 | 安装命令 |
|------|------|----------|
| Go | 1.21+ | [下载安装](https://go.dev/dl/) |
| Python | 3.10+ | [下载安装](https://www.python.org/) |
| PostgreSQL | 15+ | `docker run -d -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:15` |
### 方式一Python实现推荐开发
#### 步骤1环境准备
```bash
cd python_market_data_service
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows:
venv\Scripts\activate
# Linux/Mac:
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
pip install tushare
```
#### 步骤2配置环境变量
```bash
# 必需
export TUSHARE_TOKEN="your_tushare_token" # 从 https://tushare.pro 获取
export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/marketdata"
# 可选
export PORT=8080
export API_KEY=your-api-key
```
#### 步骤3初始化数据库
```bash
# 方式1自动创建表推荐
python -c "from app.repositories.database import init_db; init_db()"
# 方式2使用SQL脚本
psql $DATABASE_URL -f ../memory/2026-03-07-database-schema.sql
```
#### 步骤4启动服务
```bash
# 开发模式(热重载)
python -m app.main
# 或使用Uvicorn
uvicorn app.main:app --reload --port 8080
# 生产模式
uvicorn app.main:app --host 0.0.0.0 --port 8080 --workers 4
```
#### 步骤5验证
```bash
# 健康检查
curl http://localhost:8080/v1/admin/health
# 访问管理后台
open http://localhost:8080/admin
# API文档Python特有
open http://localhost:8080/docs
```
---
### 方式二Go实现推荐生产
#### 步骤1环境准备
```bash
# 检查Go版本
go version # 需 >= 1.21
# 设置国内镜像(推荐)
go env -w GOPROXY=https://goproxy.cn,direct
# 下载依赖
go mod download
```
#### 步骤2配置环境变量
```bash
# 必需
export TUSHARE_TOKEN="your_tushare_token"
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/marketdata?sslmode=disable"
# 可选
export PORT=8080
export GIN_MODE=debug
export CONFIG_PATH=./config.json
```
#### 步骤3初始化数据库
```bash
# 创建数据库
createdb marketdata
# 执行初始化脚本
psql $DATABASE_URL -f memory/2026-03-07-database-schema.sql
```
#### 步骤4启动服务
```bash
# 开发模式
go run ./cmd/server
# 或使用Makefile
make run
# 生产模式
make build
./bin/market-data-service
```
#### 步骤5验证
```bash
# 健康检查
curl http://localhost:8080/v1/admin/health
# 访问管理后台
open http://localhost:8080/admin
```
---
## Docker启动最简单
### Python版本
```bash
cd python_market_data_service
# 构建并启动
docker-compose up -d
# 查看日志
docker-compose logs -f app
```
### Go版本
```bash
# 构建镜像
docker build -t market-data-service .
# 运行
docker run -d \
-p 8080:8080 \
-e TUSHARE_TOKEN=your_token \
-e DATABASE_URL=postgres://... \
market-data-service
```
---
## 数据同步
### Python
```bash
# 同步股票列表
python scripts/sync_data.py --type stocks
# 同步期货列表
python scripts/sync_data.py --type futures
# 同步交易日历
python scripts/sync_data.py --type calendar --start 20240101 --end 20241231
# 同步K线数据
python scripts/sync_data.py --type klines --symbol 000001.SZ --start 20240301 --end 20240307 --freq 1d
```
### Go
```bash
# 同步股票列表
go run ./cmd/sync -type stocks
# 同步期货列表
go run ./cmd/sync -type futures
# 同步交易日历
go run ./cmd/sync -type calendar -start 20240101 -end 20241231
# 同步K线数据
go run ./cmd/sync -type klines -symbol 000001.SZ -start 20240301 -end 20240307 -freq 1d
```
---
## 常用命令速查
### 服务管理
| 操作 | Python | Go |
|------|--------|----|
| 启动服务 | `python -m app.main` | `go run ./cmd/server` |
| 热重载 | `uvicorn app.main:app --reload` | `fresh``air` |
| 生产启动 | `uvicorn app.main:app --workers 4` | `./bin/market-data-service` |
| 后台运行 | `nohup python -m app.main &` | `nohup ./market-server &` |
### 数据库
```bash
# 连接数据库
psql postgresql://postgres:postgres@localhost:5432/marketdata
# 查看表
\dt stock.*
\dt futures.*
# 查看股票列表
SELECT * FROM stock.symbols LIMIT 10;
```
### 测试API
```bash
# 健康检查
curl http://localhost:8080/v1/admin/health
# 查询股票K线
curl "http://localhost:8080/v1/stock/klines/000001.SZ?start=20250301&end=20250307&freq=1d" \
-H "X-API-Key: demo-api-key-2024"
# 热加载配置
curl -X POST http://localhost:8080/v1/admin/system/reload
# WebSocket测试
wscat -c ws://localhost:8080/v1/stream -H "X-API-Key: demo-api-key-2024"
> {"action":"subscribe","symbols":["000001.SZ"]}
```
---
## 故障排查
### 端口被占用
```bash
# 查找占用8080的进程
# Windows:
netstat -ano | findstr :8080
taskkill /PID <PID> /F
# Linux/Mac:
lsof -i :8080
kill -9 <PID>
# 或使用其他端口
# Python: uvicorn app.main:app --port 8081
# Go: PORT=8081 go run ./cmd/server
```
### 数据库连接失败
```bash
# 检查PostgreSQL是否运行
# Windows: 服务管理器查看 postgresql-x64-15
# Linux: sudo systemctl status postgresql
# Docker快速启动PostgreSQL
docker run -d --name postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
postgres:15
```
### Python依赖问题
```bash
# 升级pip
pip install --upgrade pip
# 重新安装依赖
pip install -r requirements.txt --force-reinstall
# 使用国内镜像
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
```
### Go依赖问题
```bash
# 清理缓存
go clean -cache
# 重新下载
go mod download
# 整理依赖
go mod tidy
```
---
## 生产部署
### 使用SystemdLinux
**Python:**
```ini
# /etc/systemd/system/python-market-data.service
[Unit]
Description=Python Market Data Service
After=network.target
[Service]
Type=simple
User=marketdata
WorkingDirectory=/opt/python-market-data-service
Environment=PATH=/opt/python-market-data-service/venv/bin
ExecStart=/opt/python-market-data-service/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080
Restart=always
[Install]
WantedBy=multi-user.target
```
**Go:**
```ini
# /etc/systemd/system/market-data.service
[Unit]
Description=Market Data Service
After=network.target
[Service]
Type=simple
User=marketdata
WorkingDirectory=/opt/market-data-service
ExecStart=/opt/market-data-service/bin/market-data-service
Restart=always
[Install]
WantedBy=multi-user.target
```
启用服务:
```bash
sudo systemctl daemon-reload
sudo systemctl enable market-data.service
sudo systemctl start market-data.service
sudo systemctl status market-data.service
```
---
## 下一步
- [完整部署文档](DEPLOY.md) - 详细部署指南
- [开发指南](docs/development-guide.md) - 如何开发新功能
- [API文档](docs/admin-api-quick-reference.md) - API接口参考
---
**提示**: 开发环境推荐Python启动快、热重载生产环境推荐Go性能高、资源省

@ -0,0 +1,237 @@
# 统一行情数据服务 - Python实现
Python版本的统一行情数据服务所有接口和功能与Go版本保持一致。
## 特性
- **多周期K线支持**1m/5m/15m/30m/60m/1d/1w/1month
- **股票复权支持**:前复权(qfq)/后复权(hfq)
- **数据源热切换**支持Wind、Tushare等多个数据源动态切换
- **双轨设计**:股票和期货接口独立,数据存储隔离
- **WebSocket实时订阅**:支持实时行情推送
- **数据质量监控**:自动检测数据缺失并告警
- **交易日历**:支持查询股票和期货的交易日历
- **期货合约查询**:根据品种获取可交易合约列表
## 技术栈
- **语言**: Python 3.10+
- **Web框架**: FastAPI
- **WebSocket**: FastAPI原生WebSocket + python-socketio
- **数据库**: PostgreSQL 15+ (SQLAlchemy ORM)
- **数据源**: Tushare (首期支持)
## 项目结构
```
python_market_data_service/
├── app/
│ ├── __init__.py
│ ├── main.py # 主程序入口
│ ├── api/ # API路由
│ │ ├── __init__.py
│ │ ├── routes.py # 主要API路由
│ │ └── admin_routes.py # 管理后台路由
│ ├── core/ # 核心模块
│ │ ├── __init__.py
│ │ ├── config.py # 配置管理
│ │ ├── errors.py # 错误定义
│ │ └── logger.py # 日志工具
│ ├── models/ # 数据模型
│ │ ├── __init__.py
│ │ ├── types.py # 基础类型
│ │ └── admin_types.py # 管理后台类型
│ ├── repositories/ # 数据访问层
│ │ ├── __init__.py
│ │ ├── database.py # 数据库连接
│ │ ├── models.py # 数据库模型
│ │ ├── stock_repository.py
│ │ └── futures_repository.py
│ ├── services/ # 业务逻辑层
│ │ ├── __init__.py
│ │ ├── stock_service.py
│ │ ├── futures_service.py
│ │ ├── admin_service.py
│ │ ├── config_service.py
│ │ ├── adapter_service.py
│ │ └── test_service.py
│ ├── adapters/ # 数据源适配器
│ │ ├── __init__.py
│ │ ├── base.py # 适配器基类
│ │ └── tushare_adapter.py
│ └── websocket/ # WebSocket服务
│ ├── __init__.py
│ └── server.py
├── scripts/
│ └── sync_data.py # 数据同步工具
├── tests/ # 测试文件
├── requirements.txt # 依赖列表
├── pyproject.toml # 项目配置
└── README.md # 本文件
```
## 快速开始
### 1. 环境准备
- Python 3.10+
- PostgreSQL 15+
- Tushare Token (从 [Tushare官网](https://tushare.pro) 获取)
### 2. 安装依赖
```bash
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows:
venv\Scripts\activate
# Linux/Mac:
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
# 安装Tushare需单独安装
pip install tushare
```
### 3. 配置环境变量
```bash
# Windows PowerShell
$env:TUSHARE_TOKEN="your_tushare_token"
$env:DATABASE_URL="postgresql://user:password@localhost:5432/marketdata"
# Linux/Mac
export TUSHARE_TOKEN="your_tushare_token"
export DATABASE_URL="postgresql://user:password@localhost:5432/marketdata"
```
### 4. 初始化数据库
```bash
# 创建数据库使用psql或pgAdmin
createdb marketdata
# 启动服务时会自动创建表结构
```
### 5. 启动服务
```bash
# 开发模式
python -m app.main
# 或使用uvicorn
uvicorn app.main:app --reload --port 8080
```
服务将启动在 `http://localhost:8080`
- API文档: `http://localhost:8080/docs`
- 管理后台: `http://localhost:8080/admin`
### 6. 同步基础数据
```bash
# 同步股票列表
python scripts/sync_data.py --type stocks
# 同步期货列表
python scripts/sync_data.py --type futures
# 同步交易日历
python scripts/sync_data.py --type calendar --start 20240101 --end 20241231
# 同步K线数据
python scripts/sync_data.py --type klines --symbol 000001.SZ --start 20240301 --end 20240307 --freq 1d
```
## API接口
### 股票接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/v1/stock/klines/:symbol` | GET | 查询K线数据 |
| `/v1/stock/symbols` | GET | 查询标的列表 |
| `/v1/stock/klines/batch` | POST | 批量查询K线 |
| `/v1/stock/trading-dates` | GET | 获取交易日历 |
### 期货接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/v1/futures/klines/:symbol` | GET | 查询K线数据 |
| `/v1/futures/symbols` | GET | 查询标的列表 |
| `/v1/futures/klines/batch` | POST | 批量查询K线 |
| `/v1/futures/continuous/:underlying` | GET | 查询主力连续合约(预留) |
| `/v1/futures/trading-dates` | GET | 获取交易日历 |
| `/v1/futures/contracts` | GET | 获取品种合约列表 |
### 管理接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/v1/admin/source/status` | GET | 获取数据源状态 |
| `/v1/admin/source/switch` | POST | 切换数据源 |
| `/v1/admin/backfill` | POST | 历史数据补录 |
| `/v1/admin/health` | GET | 健康检查 |
### 管理后台
服务启动后,访问 `http://localhost:8080/admin` 进入管理后台。
### WebSocket实时订阅
**连接地址**: `ws://localhost:8080/v1/stream`
**认证**: 连接时在Header中传递 `X-API-Key`
**客户端消息**:
```json
// 订阅
{
"action": "subscribe",
"symbols": ["000001.SZ", "CU2504.SHFE"]
}
// 取消订阅
{
"action": "unsubscribe",
"symbols": ["000001.SZ"]
}
```
**服务器消息**:
```json
// 订阅确认
{
"type": "ack",
"action": "subscribe",
"symbols": ["000001.SZ", "CU2504.SHFE"],
"ts": "2025-03-07T12:30:00Z"
}
// 心跳
{
"type": "heartbeat",
"ts": "2025-03-07T12:30:30Z"
}
```
**限制**: 单连接最大订阅100个标的
## 与Go版本的主要区别
1. **Web框架**: Gin -> FastAPI
2. **ORM**: 原生SQL -> SQLAlchemy
3. **WebSocket**: Gorilla -> FastAPI原生
4. **配置**: 文件+环境变量 -> Pydantic Settings
5. **API文档**: 自动生成Swagger/ReDoc
## License
MIT

@ -0,0 +1,2 @@
"""Market Data Service - Python实现"""
__version__ = "1.0.0"

@ -0,0 +1,13 @@
"""数据源适配器模块"""
from .base import DataSourceAdapter, TickData, KLineData, SymbolInfo, TradeCalData, TickCallback
from .akshare_adapter import AKShareAdapter
__all__ = [
"DataSourceAdapter",
"TickData",
"KLineData",
"SymbolInfo",
"TradeCalData",
"TickCallback",
"AKShareAdapter",
]

@ -0,0 +1,465 @@
"""AKShare数据源适配器 - 使用新浪接口"""
import asyncio
import time
from datetime import datetime
from typing import List, Optional
import akshare as ak
import pandas as pd
from app.adapters.base import (
DataSourceAdapter, TickData, KLineData, SymbolInfo,
TradeCalData, TickCallback
)
from app.core.logger import info, error, warning
class AKShareAdapter(DataSourceAdapter):
"""AKShare数据源适配器 - 使用新浪接口"""
def __init__(self):
self.config = {}
self._connected = False
self._max_retries = 3
self._retry_delay = 2 # 秒
async def connect(self, config: dict) -> None:
"""建立连接AKShare无需认证"""
self.config = config
self._connected = True
info("AKShare adapter connected (Sina API)")
async def subscribe_ticks(self, symbols: List[str], callback: TickCallback) -> None:
"""订阅实时TickAKShare不支持实时推送"""
raise NotImplementedError("AKShare does not support real-time tick subscription")
async def fetch_klines(
self,
symbol: str,
start: str,
end: str,
freq: str
) -> List[KLineData]:
info(f"Fetching KLines from Sina for {symbol} [{freq}] from {start} to {end}")
"""拉取历史K线"""
# 判断是股票还是期货
if ".SH" in symbol or ".SZ" in symbol or ".BJ" in symbol:
return await self._fetch_stock_klines(symbol, start, end, freq)
elif "." in symbol: # 期货格式: CU2504.SHFE
return await self._fetch_futures_klines(symbol, start, end, freq)
else:
raise ValueError(f"Unknown symbol format: {symbol}")
async def _fetch_stock_klines(
self,
symbol: str,
start: str,
end: str,
freq: str
) -> List[KLineData]:
"""获取股票K线 - 使用新浪接口"""
# 转换symbol格式: 000001.SZ -> sz000001
ts_code = self._normalize_stock_symbol(symbol)
if freq in ["1d", "day", "D", ""]:
return await self._fetch_stock_daily_sina(ts_code, symbol, start, end)
elif freq in ["1m", "5m", "15m", "30m", "60m"]:
return await self._fetch_stock_minute_sina(ts_code, symbol, start, end, freq)
else:
raise ValueError(f"Unsupported frequency: {freq}")
def _normalize_stock_symbol(self, symbol: str) -> str:
"""转换股票代码格式: 000001.SZ -> sz000001"""
if "." in symbol:
code, exchange = symbol.split(".")
exchange_map = {
"SH": "sh",
"SZ": "sz",
"BJ": "bj"
}
return exchange_map.get(exchange, "sz") + code
return symbol
def _denormalize_stock_symbol(self, symbol: str) -> str:
"""还原股票代码格式: sz000001 -> 000001.SZ"""
if symbol.startswith("sh"):
return symbol[2:] + ".SH"
elif symbol.startswith("sz"):
return symbol[2:] + ".SZ"
elif symbol.startswith("bj"):
return symbol[2:] + ".BJ"
return symbol
async def _fetch_with_retry(self, func, *args, **kwargs):
"""带重试机制的调用"""
last_exception = None
for attempt in range(self._max_retries):
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
except Exception as e:
last_exception = e
error_msg = str(e).lower()
# 检查是否是可重试的错误
if any(x in error_msg for x in ['connection', 'timeout', 'remote', 'reset', 'closed']):
if attempt < self._max_retries - 1:
warning(f"Sina API request failed (attempt {attempt + 1}/{self._max_retries}): {e}")
await asyncio.sleep(self._retry_delay * (attempt + 1)) # 指数退避
continue
# 不可重试的错误,直接抛出
raise
raise last_exception
async def _fetch_stock_daily_sina(
self,
ts_code: str,
original_symbol: str,
start_date: str,
end_date: str
) -> List[KLineData]:
"""获取股票日线 - 使用新浪接口"""
try:
# 新浪接口获取历史数据
# 使用 stock_zh_a_daily 接口(新浪)
df = await self._fetch_with_retry(
ak.stock_zh_a_daily,
symbol=ts_code,
start_date=start_date,
end_date=end_date,
adjust="qfq" # 前复权
)
if df is None or df.empty:
warning(f"No data returned from Sina for {original_symbol}")
return []
results = []
for _, row in df.iterrows():
# 新浪接口的字段名可能不同
trade_date = datetime.strptime(str(row['date']), "%Y-%m-%d")
results.append(KLineData(
symbol=original_symbol,
time=int(trade_date.timestamp()),
open=float(row['open']),
high=float(row['high']),
low=float(row['low']),
close=float(row['close']),
volume=int(row['volume']),
amount=float(row.get('amount', 0))
))
info(f"Fetched {len(results)} daily klines from Sina for {original_symbol}")
return results
except Exception as e:
error(f"Failed to fetch stock daily from Sina for {original_symbol}: {e}")
# 新浪接口失败时返回空列表
return []
async def _fetch_stock_minute_sina(
self,
ts_code: str,
original_symbol: str,
start_date: str,
end_date: str,
freq: str
) -> List[KLineData]:
"""获取股票分钟线 - 使用新浪接口"""
try:
# 新浪分钟线接口
# 使用 stock_zh_a_minute 接口
df = await self._fetch_with_retry(
ak.stock_zh_a_minute,
symbol=ts_code,
period=freq.replace("m", ""), # 1m -> 1
adjust="qfq"
)
if df is None or df.empty:
return []
# 过滤日期范围
df['date'] = pd.to_datetime(df['date'])
start_dt = datetime.strptime(start_date, "%Y%m%d")
end_dt = datetime.strptime(end_date, "%Y%m%d")
df = df[(df['date'] >= start_dt) & (df['date'] <= end_dt)]
results = []
for _, row in df.iterrows():
trade_time = datetime.strptime(str(row['date']), "%Y-%m-%d %H:%M:%S")
results.append(KLineData(
symbol=original_symbol,
time=int(trade_time.timestamp()),
open=float(row['open']),
high=float(row['high']),
low=float(row['low']),
close=float(row['close']),
volume=int(row['volume']),
amount=float(row.get('amount', 0))
))
return results
except Exception as e:
error(f"Failed to fetch stock minute from Sina for {original_symbol}: {e}")
return []
async def _fetch_futures_klines(
self,
symbol: str,
start: str,
end: str,
freq: str
) -> List[KLineData]:
"""获取期货K线 - 使用新浪接口"""
if freq in ["1d", "day", "D", ""]:
return await self._fetch_futures_daily_sina(symbol, start, end)
elif freq in ["1m", "5m", "15m", "30m", "60m"]:
return await self._fetch_futures_minute_sina(symbol, start, end, freq)
else:
raise ValueError(f"Unsupported frequency: {freq}")
async def _fetch_futures_daily_sina(
self,
symbol: str,
start_date: str,
end_date: str
) -> List[KLineData]:
"""获取期货日线 - 使用新浪接口"""
try:
# 解析合约代码: CU2504.SHFE -> cu2504
contract_code, exchange = symbol.split(".")
contract_code = contract_code.lower()
# 新浪期货历史行情接口
df = await self._fetch_with_retry(
ak.futures_zh_daily,
symbol=contract_code,
start_date=start_date,
end_date=end_date
)
if df is None or df.empty:
return []
results = []
for _, row in df.iterrows():
trade_date = datetime.strptime(str(row['date']), "%Y-%m-%d")
results.append(KLineData(
symbol=symbol,
time=int(trade_date.timestamp()),
open=float(row['open']),
high=float(row['high']),
low=float(row['low']),
close=float(row['close']),
volume=int(row['volume']),
amount=float(row.get('amount', 0)),
open_interest=int(row.get('hold', 0))
))
return results
except Exception as e:
error(f"Failed to fetch futures daily from Sina for {symbol}: {e}")
return []
async def _fetch_futures_minute_sina(
self,
symbol: str,
start_date: str,
end_date: str,
freq: str
) -> List[KLineData]:
"""获取期货分钟线 - 使用新浪接口"""
try:
# 解析合约代码
contract_code, exchange = symbol.split(".")
contract_code = contract_code.lower()
# 新浪期货分钟线接口
df = await self._fetch_with_retry(
ak.futures_zh_minute_sina,
symbol=contract_code,
period=freq.replace("m", "")
)
if df is None or df.empty:
return []
# 过滤日期范围
df['datetime'] = pd.to_datetime(df['datetime'])
start_dt = datetime.strptime(start_date, "%Y%m%d")
end_dt = datetime.strptime(end_date, "%Y%m%d")
df = df[(df['datetime'] >= start_dt) & (df['datetime'] <= end_dt)]
results = []
for _, row in df.iterrows():
trade_time = row['datetime']
results.append(KLineData(
symbol=symbol,
time=int(trade_time.timestamp()),
open=float(row['open']),
high=float(row['high']),
low=float(row['low']),
close=float(row['close']),
volume=int(row['volume']),
amount=0,
open_interest=0
))
return results
except Exception as e:
error(f"Failed to fetch futures minute from Sina for {symbol}: {e}")
return []
async def fetch_symbols(self, asset_type: str) -> List[SymbolInfo]:
"""获取标的列表"""
if asset_type == "stock":
return await self._fetch_stock_symbols_sina()
elif asset_type == "futures":
return await self._fetch_futures_symbols_sina()
else:
raise ValueError(f"Unsupported asset type: {asset_type}")
async def _fetch_stock_symbols_sina(self) -> List[SymbolInfo]:
"""获取A股股票列表 - 使用新浪接口"""
try:
# 新浪A股列表接口
df = await self._fetch_with_retry(ak.stock_zh_a_spot)
if df is None or df.empty:
return []
results = []
for _, row in df.iterrows():
# 新浪接口的代码格式
code = str(row['代码'])
if code.startswith('6') or code.startswith('5') or code.startswith('9'):
ts_code = f"{code}.SH"
exchange = "SH"
elif code.startswith('8') or code.startswith('4'):
ts_code = f"{code}.BJ"
exchange = "BJ"
else:
ts_code = f"{code}.SZ"
exchange = "SZ"
results.append(SymbolInfo(
symbol_id=ts_code,
name=str(row['名称']),
exchange=exchange
))
info(f"Fetched {len(results)} stock symbols from Sina")
return results
except Exception as e:
error(f"Failed to fetch stock symbols from Sina: {e}")
return []
async def _fetch_futures_symbols_sina(self) -> List[SymbolInfo]:
"""获取期货合约列表 - 使用新浪接口"""
try:
# 新浪期货列表接口
df = await self._fetch_with_retry(ak.futures_zh_realtime, subscribe_list=["0", "1", "2", "3"])
if df is None or df.empty:
return []
results = []
for _, row in df.iterrows():
symbol = str(row['symbol'])
underlying = ''.join([c for c in symbol if c.isalpha()]).upper()
contract_month = ''.join([c for c in symbol if c.isdigit()])
exchange = self._get_futures_exchange(underlying)
ts_code = f"{symbol.upper()}.{exchange}"
results.append(SymbolInfo(
symbol_id=ts_code,
name=str(row.get('name', symbol)),
exchange=exchange,
underlying=underlying,
contract_month=contract_month
))
info(f"Fetched {len(results)} futures symbols from Sina")
return results
except Exception as e:
error(f"Failed to fetch futures symbols from Sina: {e}")
return []
def _get_futures_exchange(self, underlying: str) -> str:
"""根据品种代码判断交易所"""
# 上海期货交易所
if underlying in ['CU', 'AL', 'ZN', 'PB', 'NI', 'SN', 'AU', 'AG', 'RB', 'HC',
'BU', 'RU', 'FU', 'SP', 'WR', 'SS', 'LU', 'NR']:
return 'SHFE'
# 大连商品交易所
elif underlying in ['A', 'B', 'M', 'Y', 'P', 'C', 'CS', 'JD', 'LH', 'JM',
'J', 'I', 'FB', 'BB', 'RR', 'PG', 'EB', 'EG', 'V', 'PP', 'L']:
return 'DCE'
# 郑州商品交易所
elif underlying in ['WH', 'PM', 'CF', 'SR', 'TA', 'OI', 'RI', 'MA', 'FG', 'RS',
'RM', 'JR', 'LR', 'SM', 'SF', 'CY', 'AP', 'CJ', 'UR', 'SA', 'PF', 'PK']:
return 'CZCE'
# 中国金融期货交易所
elif underlying in ['IF', 'IC', 'IH', 'T', 'TF', 'TS', 'IM']:
return 'CFFEX'
# 上海国际能源交易中心
elif underlying in ['SC', 'BC', 'EC']:
return 'INE'
else:
return 'SHFE' # 默认上海
async def fetch_trading_calendar(
self,
exchange: str,
start: str,
end: str
) -> List[TradeCalData]:
"""获取交易日历 - 使用新浪接口"""
try:
# 新浪交易日历接口
df = await self._fetch_with_retry(ak.tool_trade_date_hist_sina)
if df is None or df.empty:
return []
# 过滤日期范围
df['trade_date'] = pd.to_datetime(df['trade_date'])
start_dt = datetime.strptime(start, "%Y%m%d")
end_dt = datetime.strptime(end, "%Y%m%d")
df = df[(df['trade_date'] >= start_dt) & (df['trade_date'] <= end_dt)]
results = []
for _, row in df.iterrows():
cal_date = row['trade_date']
results.append(TradeCalData(
date=cal_date,
is_trading_day=True
))
return results
except Exception as e:
error(f"Failed to fetch trading calendar from Sina: {e}")
return []
async def health_check(self) -> bool:
"""健康检查"""
try:
if not self._connected:
return False
# 尝试获取股票列表作为健康检查
df = await self._fetch_with_retry(ak.stock_zh_a_spot)
return df is not None and not df.empty
except Exception as e:
error(f"Health check failed: {e}")
return False
async def close(self) -> None:
"""关闭连接"""
self._connected = False
info("AKShare adapter closed (Sina API)")

@ -0,0 +1,102 @@
"""数据源适配器基类 - 对应Go的adapter/adapter.go"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Callable, List, Optional
@dataclass
class TickData:
"""Tick数据"""
symbol: str
price: float
volume: int
time: int # Unix时间戳
@dataclass
class KLineData:
"""K线数据"""
symbol: str
time: int # Unix时间戳
open: float
high: float
low: float
close: float
volume: int
amount: float
open_interest: int = 0
@dataclass
class SymbolInfo:
"""标的信息"""
symbol_id: str
name: str
exchange: str
underlying: str = "" # 期货品种代码
contract_month: str = ""
list_date: str = ""
delist_date: str = ""
@dataclass
class TradeCalData:
"""交易日历数据"""
date: datetime
is_trading_day: bool
has_night_session: bool = False
# Tick数据回调类型
TickCallback = Callable[[str, TickData], None]
class DataSourceAdapter(ABC):
"""数据源适配器接口"""
@abstractmethod
async def connect(self, config: dict) -> None:
"""建立连接"""
pass
@abstractmethod
async def subscribe_ticks(self, symbols: List[str], callback: TickCallback) -> None:
"""订阅实时Tick"""
pass
@abstractmethod
async def fetch_klines(
self,
symbol: str,
start: str,
end: str,
freq: str
) -> List[KLineData]:
"""拉取历史K线"""
pass
@abstractmethod
async def fetch_symbols(self, asset_type: str) -> List[SymbolInfo]:
"""获取标的列表"""
pass
@abstractmethod
async def fetch_trading_calendar(
self,
exchange: str,
start: str,
end: str
) -> List[TradeCalData]:
"""获取交易日历"""
pass
@abstractmethod
async def health_check(self) -> bool:
"""健康检查"""
pass
@abstractmethod
async def close(self) -> None:
"""关闭连接"""
pass

@ -0,0 +1,5 @@
"""API路由模块"""
from .routes import router
from .admin_routes import admin_router
__all__ = ["router", "admin_router"]

@ -0,0 +1,232 @@
"""管理后台API路由 - 对应Go的api/admin_router.go"""
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from typing import Optional
from app.models import (
Response, ConfigListRequest, ConfigUpdateRequest,
ReloadRequest, AdapterToggleRequest, AdapterConfigUpdateRequest,
APITestRequest, WSTestRequest, TestHistoryRequest
)
from app.services import ConfigService, AdapterService, TestService
from app.core.config import get_config
admin_router = APIRouter()
# 服务实例
config_service = ConfigService()
adapter_service = AdapterService()
test_service = TestService()
def verify_admin_token(x_admin_token: Optional[str] = Header(None)):
"""验证Admin Token"""
# TODO: 实现Token验证
return x_admin_token
# ============================================
# 系统管理接口
# ============================================
@admin_router.get("/admin/system/status", response_model=Response)
def get_system_status(
token: str = Depends(verify_admin_token)
):
"""获取系统状态"""
try:
data = config_service.get_system_status()
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.post("/admin/system/reload", response_model=Response)
def reload_config(
req: Optional[ReloadRequest] = None,
token: str = Depends(verify_admin_token)
):
"""热加载配置"""
try:
if req is None:
req = ReloadRequest()
data = config_service.reload_config(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.post("/admin/system/restart", response_model=Response)
def restart_service(
token: str = Depends(verify_admin_token)
):
"""重启服务"""
# TODO: 实现服务重启逻辑
return Response(
code=0,
message="重启命令已发送",
data={"status": "restarting"}
)
# ============================================
# 配置管理接口
# ============================================
@admin_router.get("/admin/config", response_model=Response)
def get_config_list(
type: Optional[str] = Query(None, description="配置类型筛选"),
token: str = Depends(verify_admin_token)
):
"""获取配置列表"""
try:
from app.models import ConfigType
req = ConfigListRequest()
if type:
req.type = ConfigType(type)
data = config_service.get_config_list(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.put("/admin/config", response_model=Response)
def update_config(
req: ConfigUpdateRequest,
token: str = Depends(verify_admin_token)
):
"""更新配置"""
try:
data = config_service.update_config(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.post("/admin/config/reload", response_model=Response)
def reload_config_endpoint(
req: Optional[ReloadRequest] = None,
token: str = Depends(verify_admin_token)
):
"""热加载配置"""
return reload_config(req, token)
# ============================================
# 适配器管理接口
# ============================================
@admin_router.get("/admin/adapters", response_model=Response)
def get_adapter_list(
token: str = Depends(verify_admin_token)
):
"""获取适配器列表"""
try:
data = adapter_service.get_adapter_list()
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.post("/admin/adapters/toggle", response_model=Response)
def toggle_adapter(
req: AdapterToggleRequest,
token: str = Depends(verify_admin_token)
):
"""切换适配器状态"""
try:
adapter_service.toggle_adapter(req)
return Response(code=0, message="success")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.put("/admin/adapters/config", response_model=Response)
def update_adapter_config(
req: AdapterConfigUpdateRequest,
token: str = Depends(verify_admin_token)
):
"""更新适配器配置"""
try:
adapter_service.update_adapter_config(req)
return Response(code=0, message="success")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================
# 测试管理接口
# ============================================
@admin_router.get("/admin/tests/api", response_model=Response)
def get_api_test_list(
token: str = Depends(verify_admin_token)
):
"""获取API测试列表"""
try:
data = test_service.get_api_test_list()
# 设置基础URL
config = get_config()
data.base_url = f"http://localhost:{config.server.port}"
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.post("/admin/tests/api/run", response_model=Response)
async def run_api_test(
req: APITestRequest,
token: str = Depends(verify_admin_token)
):
"""执行API测试"""
try:
config = get_config()
base_url = f"http://localhost:{config.server.port}"
data = await test_service.run_api_test(base_url, req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.get("/admin/tests/ws", response_model=Response)
def get_ws_test_list(
token: str = Depends(verify_admin_token)
):
"""获取WebSocket测试列表"""
try:
data = test_service.get_ws_test_list()
config = get_config()
data.ws_url = f"ws://localhost:{config.server.port}/v1/stream"
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.post("/admin/tests/ws/run", response_model=Response)
async def run_ws_test(
req: WSTestRequest,
token: str = Depends(verify_admin_token)
):
"""执行WebSocket测试"""
try:
config = get_config()
ws_url = f"ws://localhost:{config.server.port}/v1/stream"
data = await test_service.run_ws_test(ws_url, req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@admin_router.get("/admin/tests/history", response_model=Response)
def get_test_history(
type: Optional[str] = Query(None, description="测试类型"),
limit: int = Query(default=20, ge=1, le=100, description="数量限制"),
token: str = Depends(verify_admin_token)
):
"""获取测试历史"""
try:
req = TestHistoryRequest(type=type, limit=limit)
data = test_service.get_test_history(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

@ -0,0 +1,304 @@
"""API路由 - 对应Go的api/router.go"""
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from typing import Optional
from sqlalchemy.orm import Session
from app.repositories import get_db
from app.services import StockService, FuturesService, AdminService
from app.models import (
Response, ErrorResponse, HealthResponse,
KLineQueryRequest, SymbolListRequest, BatchKLineRequest,
TradingDatesRequest, FuturesContractsRequest,
SourceSwitchRequest, BackfillRequest
)
from app.core.config import get_config
router = APIRouter()
# 获取配置
config = get_config()
# 认证依赖
def verify_api_key(x_api_key: Optional[str] = Header(None)):
"""验证API Key"""
if not x_api_key:
raise HTTPException(status_code=401, detail="Missing API Key")
# TODO: 验证API Key有效性
return x_api_key
# ============================================
# 股票接口
# ============================================
@router.get("/stock/klines/{symbol}", response_model=Response)
def query_stock_klines(
symbol: str,
start: str = Query(..., description="开始日期 YYYYMMDD", min_length=8, max_length=8),
end: str = Query(..., description="结束日期 YYYYMMDD", min_length=8, max_length=8),
freq: str = Query(default="1d", description="周期"),
adjust: str = Query(default="", description="复权类型"),
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""查询股票K线"""
try:
service = StockService(db)
req = KLineQueryRequest(
symbol=symbol,
start=start,
end=end,
freq=freq,
adjust=adjust
)
data = service.query_klines(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stock/symbols", response_model=Response)
def list_stock_symbols(
exchange: Optional[str] = Query(None, description="交易所筛选"),
keyword: Optional[str] = Query(None, description="关键词搜索"),
page: int = Query(default=1, ge=1, description="页码"),
size: int = Query(default=20, ge=1, le=100, description="每页数量"),
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""查询股票标的列表"""
try:
service = StockService(db)
req = SymbolListRequest(
exchange=exchange,
keyword=keyword,
page=page,
size=size
)
data = service.list_symbols(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/stock/klines/batch", response_model=Response)
def batch_query_stock_klines(
req: BatchKLineRequest,
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""批量查询股票K线"""
try:
service = StockService(db)
data = service.batch_query_klines(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stock/trading-dates", response_model=Response)
def get_stock_trading_dates(
start: str = Query(..., description="开始日期 YYYYMMDD", min_length=8, max_length=8),
end: str = Query(..., description="结束日期 YYYYMMDD", min_length=8, max_length=8),
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""获取股票交易日历"""
try:
service = StockService(db)
req = TradingDatesRequest(start=start, end=end)
data = service.get_trading_dates(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================
# 期货接口
# ============================================
@router.get("/futures/klines/{symbol}", response_model=Response)
def query_futures_klines(
symbol: str,
start: str = Query(..., description="开始日期 YYYYMMDD", min_length=8, max_length=8),
end: str = Query(..., description="结束日期 YYYYMMDD", min_length=8, max_length=8),
freq: str = Query(default="1d", description="周期"),
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""查询期货K线"""
try:
service = FuturesService(db)
req = KLineQueryRequest(
symbol=symbol,
start=start,
end=end,
freq=freq
)
data = service.query_klines(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/futures/symbols", response_model=Response)
def list_futures_symbols(
exchange: Optional[str] = Query(None, description="交易所筛选"),
underlying: Optional[str] = Query(None, description="品种筛选"),
keyword: Optional[str] = Query(None, description="关键词搜索"),
page: int = Query(default=1, ge=1, description="页码"),
size: int = Query(default=20, ge=1, le=100, description="每页数量"),
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""查询期货标的列表"""
try:
service = FuturesService(db)
req = SymbolListRequest(
exchange=exchange,
underlying=underlying,
keyword=keyword,
page=page,
size=size
)
data = service.list_symbols(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/futures/klines/batch", response_model=Response)
def batch_query_futures_klines(
req: BatchKLineRequest,
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""批量查询期货K线"""
try:
service = FuturesService(db)
data = service.batch_query_klines(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/futures/continuous/{underlying}", response_model=Response)
def query_continuous_klines(
underlying: str,
start: str = Query(..., description="开始日期 YYYYMMDD", min_length=8, max_length=8),
end: str = Query(..., description="结束日期 YYYYMMDD", min_length=8, max_length=8),
freq: str = Query(default="1d", description="周期"),
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""查询主力连续合约K线预留"""
# TODO: 实现主力连续合约查询
from app.models import KLineData
data = KLineData(
symbol=f"{underlying}.MAIN",
name=f"{underlying}主力连续",
freq=freq,
count=0,
items=[]
)
return Response(code=0, message="success", data=data)
@router.get("/futures/trading-dates", response_model=Response)
def get_futures_trading_dates(
start: str = Query(..., description="开始日期 YYYYMMDD", min_length=8, max_length=8),
end: str = Query(..., description="结束日期 YYYYMMDD", min_length=8, max_length=8),
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""获取期货交易日历"""
try:
service = FuturesService(db)
req = TradingDatesRequest(start=start, end=end)
data = service.get_trading_dates(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/futures/contracts", response_model=Response)
def get_futures_contracts(
underlying: str = Query(..., description="品种代码"),
exchange: Optional[str] = Query(None, description="交易所筛选"),
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""获取品种合约列表"""
try:
service = FuturesService(db)
req = FuturesContractsRequest(underlying=underlying, exchange=exchange)
data = service.get_contracts_by_underlying(req)
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================
# 管理接口
# ============================================
@router.get("/admin/source/status", response_model=Response)
def get_data_source_status(
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""获取数据源状态"""
try:
service = AdminService(db)
data = service.get_data_source_status()
return Response(code=0, message="success", data=data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/admin/source/switch", response_model=Response)
def switch_data_source(
req: SourceSwitchRequest,
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""切换数据源"""
try:
service = AdminService(db)
service.switch_data_source(req)
return Response(code=0, message="数据源切换成功")
except Exception as e:
raise HTTPException(status_code=422, detail=str(e))
@router.post("/admin/backfill", response_model=Response)
def backfill_data(
req: BackfillRequest,
db: Session = Depends(get_db),
api_key: str = Depends(verify_api_key)
):
"""历史数据补录"""
try:
service = AdminService(db)
task_id = service.backfill_data(req)
return Response(
code=0,
message="补录任务已启动",
data={"task_id": task_id}
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/admin/health", response_model=HealthResponse)
def health_check(
db: Session = Depends(get_db)
):
"""健康检查(无需认证)"""
try:
service = AdminService(db)
return service.health_check()
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))

@ -0,0 +1,135 @@
"""配置管理模块"""
import json
import os
from typing import Dict, Any, Optional
from functools import lru_cache
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings
class ServerConfig(BaseModel):
"""服务器配置"""
port: int = 8080
mode: str = "debug" # debug/release
api_key: str = "demo-api-key-2024"
class DatabaseConfig(BaseModel):
"""数据库配置"""
host: str = "localhost"
port: int = 5432
user: str = "postgres"
password: str = "postgres"
database: str = "marketdata"
@property
def database_url(self) -> str:
# 优先使用环境变量 DATABASE_URL
import os
env_url = os.getenv("DATABASE_URL")
if env_url:
return env_url
return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
class RedisConfig(BaseModel):
"""Redis配置"""
host: str = "localhost"
port: int = 6379
password: str = ""
db: int = 0
class SourceInfo(BaseModel):
"""数据源信息"""
type: str = "http"
config: Dict[str, str] = Field(default_factory=dict)
class SourceConfig(BaseModel):
"""源配置"""
active: str = "akshare"
list: Dict[str, SourceInfo] = Field(default_factory=dict)
class SourcesConfig(BaseModel):
"""数据源配置"""
stock: SourceConfig = Field(default_factory=lambda: SourceConfig(
active="akshare",
list={"akshare": SourceInfo(type="http", config={"timeout": "30"})}
))
futures: SourceConfig = Field(default_factory=lambda: SourceConfig(
active="akshare",
list={"akshare": SourceInfo(type="http", config={"timeout": "30"})}
))
class Config(BaseModel):
"""主配置类"""
server: ServerConfig = Field(default_factory=ServerConfig)
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
redis: RedisConfig = Field(default_factory=RedisConfig)
sources: SourcesConfig = Field(default_factory=SourcesConfig)
class Config:
populate_by_name = True
class Settings(BaseSettings):
"""环境变量配置"""
port: int = Field(default=8080, alias="PORT")
database_url: Optional[str] = Field(default=None, alias="DATABASE_URL")
api_key: Optional[str] = Field(default=None, alias="API_KEY")
class Config:
env_file = ".env"
case_sensitive = True
def load_config(config_path: str = "./config.json") -> Config:
"""从文件加载配置"""
if not os.path.exists(config_path):
return Config()
with open(config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return Config.model_validate(data)
def save_config(config: Config, config_path: str = "./config.json"):
"""保存配置到文件"""
os.makedirs(os.path.dirname(config_path) or '.', exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config.model_dump(), f, indent=2, ensure_ascii=False)
# 全局配置实例
_config: Optional[Config] = None
def get_config() -> Config:
"""获取当前配置"""
global _config
if _config is None:
_config = load_config()
return _config
def set_config(config: Config):
"""设置全局配置"""
global _config
_config = config
def reload_config(config_path: str = "./config.json") -> Config:
"""重新加载配置"""
global _config
_config = load_config(config_path)
return _config
@lru_cache()
def get_settings() -> Settings:
"""获取环境变量设置"""
return Settings()

@ -0,0 +1,78 @@
"""错误定义模块"""
from enum import IntEnum
from typing import Optional, Any, Dict
class ErrorCode(IntEnum):
"""错误码"""
OK = 0
BAD_REQUEST = 400
UNAUTHORIZED = 401
NOT_FOUND = 404
RATE_LIMIT = 429
INTERNAL = 500
class AppException(Exception):
"""应用异常基类"""
def __init__(
self,
message: str,
code: ErrorCode = ErrorCode.INTERNAL,
detail: Optional[str] = None
):
self.message = message
self.code = code
self.detail = detail
super().__init__(message)
def to_dict(self) -> Dict[str, Any]:
return {
"code": int(self.code),
"message": self.message,
"detail": self.detail
}
# 参数错误
class InvalidParamError(AppException):
def __init__(self, message: str = "参数错误", detail: Optional[str] = None):
super().__init__(message, ErrorCode.BAD_REQUEST, detail)
class InvalidSymbolError(AppException):
def __init__(self, message: str = "无效的标的代码"):
super().__init__(message, ErrorCode.BAD_REQUEST)
class InvalidDateError(AppException):
def __init__(self, message: str = "无效的日期格式"):
super().__init__(message, ErrorCode.BAD_REQUEST)
# 数据错误
class SymbolNotFoundError(AppException):
def __init__(self, message: str = "标的不存在"):
super().__init__(message, ErrorCode.NOT_FOUND)
class DataNotFoundError(AppException):
def __init__(self, message: str = "数据不存在"):
super().__init__(message, ErrorCode.NOT_FOUND)
class DataSourceUnavailableError(AppException):
def __init__(self, message: str = "数据源不可用"):
super().__init__(message, ErrorCode.INTERNAL)
# 权限错误
class UnauthorizedError(AppException):
def __init__(self, message: str = "未授权"):
super().__init__(message, ErrorCode.UNAUTHORIZED)
class RateLimitError(AppException):
def __init__(self, message: str = "请求过于频繁"):
super().__init__(message, ErrorCode.RATE_LIMIT)

@ -0,0 +1,47 @@
"""日志工具模块"""
import logging
import sys
from typing import Optional
def setup_logging(
level: int = logging.INFO,
format_string: Optional[str] = None
) -> logging.Logger:
"""设置日志配置"""
if format_string is None:
format_string = "%(asctime)s | %(levelname)-8s | %(message)s"
logging.basicConfig(
level=level,
format=format_string,
handlers=[
logging.StreamHandler(sys.stdout)
]
)
return logging.getLogger("market_data")
# 全局logger实例
logger = setup_logging()
def info(msg: str, *args, **kwargs):
"""信息日志"""
logger.info(msg, *args, **kwargs)
def error(msg: str, *args, **kwargs):
"""错误日志"""
logger.error(msg, *args, **kwargs)
def debug(msg: str, *args, **kwargs):
"""调试日志"""
logger.debug(msg, *args, **kwargs)
def warning(msg: str, *args, **kwargs):
"""警告日志"""
logger.warning(msg, *args, **kwargs)

File diff suppressed because it is too large Load Diff

@ -0,0 +1,346 @@
"""主应用入口 - 对应Go的cmd/server/main.go"""
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from app.api import router, admin_router
from app.websocket import WebSocketServer
from app.core.config import get_config, get_settings
from app.core.logger import info, error, setup_logging
from app.repositories.database import init_db
# 获取配置
config = get_config()
settings = get_settings()
# 设置日志
setup_logging()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时执行
info("Starting Market Data Service...")
# 初始化数据库
try:
init_db()
info("Database initialized")
except Exception as e:
error(f"Database initialization failed: {e}")
yield
# 关闭时执行
info("Shutting down Market Data Service...")
# 创建FastAPI应用
app = FastAPI(
title="统一行情数据服务",
description="提供股票和期货的标准化行情数据查询服务",
version="1.0.0",
lifespan=lifespan
)
# 添加CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册API路由
app.include_router(router, prefix="/v1")
app.include_router(admin_router, prefix="/v1")
# WebSocket服务器
ws_server = WebSocketServer()
@app.websocket("/v1/stream")
async def websocket_endpoint(websocket):
"""WebSocket端点"""
import uuid
client_id = str(uuid.uuid4())
await ws_server.handle(websocket, client_id)
# 管理后台页面HTML简化版
ADMIN_HTML = """<!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', sans-serif;
background: #f0f2f5;
color: #333;
}
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 200px;
background: #001529;
color: #fff;
position: fixed;
height: 100vh;
overflow-y: auto;
}
.logo {
padding: 16px;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.nav-menu { padding: 16px 0; }
.nav-item {
padding: 12px 24px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 8px;
}
.nav-item:hover { background: rgba(255,255,255,0.05); }
.nav-item.active { background: #1890ff; }
.main-content {
flex: 1;
margin-left: 200px;
padding: 24px;
}
.header {
background: #fff;
padding: 16px 24px;
margin: -24px -24px 24px -24px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.page-title { font-size: 20px; font-weight: 500; }
.card {
background: #fff;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.card-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #1890ff;
}
.stat-label { color: #666; margin-top: 4px; }
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary { background: #1890ff; color: #fff; }
.btn-primary:hover { background: #40a9ff; }
.btn-success { background: #52c41a; color: #fff; }
.btn-danger { background: #ff4d4f; color: #fff; }
.hidden { display: none; }
.page { display: none; }
.page.active { display: block; }
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="logo">📊 行情数据服务</div>
<nav class="nav-menu">
<div class="nav-item active" onclick="showPage('dashboard')">
<span>📈</span> 系统概览
</div>
<div class="nav-item" onclick="showPage('config')">
<span></span> 配置管理
</div>
<div class="nav-item" onclick="showPage('adapters')">
<span>🔌</span> 数据源适配
</div>
<div class="nav-item" onclick="showPage('tests')">
<span>🧪</span> 接口测试
</div>
</nav>
</aside>
<main class="main-content">
<div class="header">
<h1 class="page-title">系统概览</h1>
<div>
<button class="btn btn-success" onclick="reloadConfig()">🔄 热加载配置</button>
<button class="btn btn-danger" onclick="restartService()">🔁 重启服务</button>
</div>
</div>
<!-- 系统概览页面 -->
<div id="dashboard" class="page active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-status">运行中</div>
<div class="stat-label">运行状态</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-uptime">-</div>
<div class="stat-label">运行时长</div>
</div>
<div class="stat-card">
<div class="stat-value">1.0.0</div>
<div class="stat-label">系统版本</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-threads">-</div>
<div class="stat-label">线程数量</div>
</div>
</div>
<div class="card">
<div class="card-title">API文档</div>
<p>访问 <a href="/docs">/docs</a> 查看Swagger API文档</p>
<p>访问 <a href="/redoc">/redoc</a> 查看ReDoc API文档</p>
</div>
</div>
<!-- 配置管理页面 -->
<div id="config" class="page">
<div class="card">
<div class="card-title">配置管理</div>
<p>配置管理功能开发中...</p>
</div>
</div>
<!-- 数据源适配页面 -->
<div id="adapters" class="page">
<div class="card">
<div class="card-title">数据源适配</div>
<p>适配器管理功能开发中...</p>
</div>
</div>
<!-- 接口测试页面 -->
<div id="tests" class="page">
<div class="card">
<div class="card-title">接口测试</div>
<p>接口测试功能开发中...</p>
</div>
</div>
</main>
</div>
<script>
function showPage(pageName) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById(pageName).classList.add('active');
event.target.classList.add('active');
}
async function reloadConfig() {
try {
const response = await fetch('/v1/admin/system/reload', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
alert(data.message || '热加载完成');
} catch (e) {
alert('热加载失败: ' + e.message);
}
}
async function restartService() {
if (confirm('确定要重启服务吗?')) {
try {
const response = await fetch('/v1/admin/system/restart', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
alert(data.message || '重启命令已发送');
} catch (e) {
alert('重启失败: ' + e.message);
}
}
}
// 加载系统状态
async function loadSystemStatus() {
try {
const response = await fetch('/v1/admin/system/status');
const data = await response.json();
if (data.data) {
document.getElementById('stat-uptime').textContent = data.data.uptime;
document.getElementById('stat-threads').textContent = data.data.threads;
}
} catch (e) {
console.error('Failed to load system status:', e);
}
}
// 页面加载时获取状态
loadSystemStatus();
setInterval(loadSystemStatus, 30000); // 每30秒刷新
</script>
</body>
</html>
"""
@app.get("/admin", response_class=HTMLResponse)
async def admin_page():
"""管理后台页面"""
return ADMIN_HTML
@app.get("/")
async def root():
"""根路径重定向到管理后台"""
return {"message": "Market Data Service API", "docs": "/docs", "admin": "/admin"}
if __name__ == "__main__":
import uvicorn
# 从环境变量或配置获取端口
port = settings.port or config.server.port
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=port,
reload=config.server.mode == "debug"
)

@ -0,0 +1,135 @@
"""数据模型模块"""
from app.adapters.base import TradeCalData
from .types import (
Frequency,
AdjustType,
AssetClass,
SymbolType,
Exchange,
DataSourceStatus,
KLineItem,
KLineData,
KLineQueryRequest,
BatchKLineRequest,
BatchKLineResult,
BatchKLineData,
KLineSubData,
Symbol,
SymbolListRequest,
SymbolListData,
DataSourceInfo,
DataSourceStatusData,
SourceSwitchRequest,
BackfillRequest,
TradingDatesRequest,
TradingDatesData,
FuturesContractsRequest,
FuturesContractInfo,
FuturesContractsData,
Response,
ErrorResponse,
SuccessResponse,
HealthResponse,
)
from .admin_types import (
ConfigType,
ConfigItem,
ConfigSection,
ConfigListRequest,
ConfigListData,
ConfigUpdateRequest,
ConfigUpdateData,
AdapterInfo,
AdapterStatus,
AdapterListData,
AdapterToggleRequest,
AdapterConfigUpdateRequest,
SystemStatusData,
MemoryInfo,
RestartRequest,
ReloadRequest,
ReloadData,
APITestCase,
APITestCategory,
APITestListData,
APITestRequest,
APITestResult,
WSTestCase,
WSTestListData,
WSTestRequest,
WSTestResult,
WSMessage,
TestHistoryRequest,
TestHistoryData,
)
__all__ = [
# 数据适配器基础类型
"TradeCalData",
# 基础类型
"Frequency",
"AdjustType",
"AssetClass",
"SymbolType",
"Exchange",
"DataSourceStatus",
# K线数据
"KLineItem",
"KLineData",
"KLineQueryRequest",
"BatchKLineRequest",
"BatchKLineResult",
"BatchKLineData",
"KLineSubData",
# 标的
"Symbol",
"SymbolListRequest",
"SymbolListData",
# 数据源
"DataSourceInfo",
"DataSourceStatusData",
"SourceSwitchRequest",
"BackfillRequest",
# 交易日历
"TradingDatesRequest",
"TradingDatesData",
# 期货
"FuturesContractsRequest",
"FuturesContractInfo",
"FuturesContractsData",
# 响应
"Response",
"ErrorResponse",
"SuccessResponse",
"HealthResponse",
# 管理后台
"ConfigType",
"ConfigItem",
"ConfigSection",
"ConfigListRequest",
"ConfigListData",
"ConfigUpdateRequest",
"ConfigUpdateData",
"AdapterInfo",
"AdapterStatus",
"AdapterListData",
"AdapterToggleRequest",
"AdapterConfigUpdateRequest",
"SystemStatusData",
"MemoryInfo",
"RestartRequest",
"ReloadRequest",
"ReloadData",
"APITestCase",
"APITestCategory",
"APITestListData",
"APITestRequest",
"APITestResult",
"WSTestCase",
"WSTestListData",
"WSTestRequest",
"WSTestResult",
"WSMessage",
"TestHistoryRequest",
"TestHistoryData",
]

@ -0,0 +1,250 @@
"""管理后台类型定义 - 对应Go的api/admin_types.go"""
from datetime import datetime
from typing import List, Optional, Dict, Any, Literal
from enum import Enum
from pydantic import BaseModel, Field
# ============================================
# 配置管理类型
# ============================================
class ConfigType(str, Enum):
"""配置类型"""
SERVER = "server"
DATABASE = "database"
REDIS = "redis"
SOURCE = "source"
MONITOR = "monitor"
LOG = "log"
class ConfigItem(BaseModel):
"""配置项"""
key: str = Field(..., description="配置键")
value: Any = Field(..., description="配置值")
type: str = Field(..., description="值类型: string/int/bool/json")
description: str = Field(..., description="配置说明")
editable: bool = Field(default=True, description="是否可编辑")
required: bool = Field(default=True, description="是否必填")
class ConfigSection(BaseModel):
"""配置分组"""
name: str = Field(..., description="分组名称")
type: ConfigType = Field(..., description="分组类型")
description: str = Field(..., description="分组说明")
items: List[ConfigItem] = Field(default_factory=list, description="配置项列表")
class ConfigListRequest(BaseModel):
"""获取配置列表请求"""
type: Optional[ConfigType] = Field(None, description="配置类型筛选")
class ConfigListData(BaseModel):
"""配置列表响应"""
sections: List[ConfigSection] = Field(default_factory=list, description="配置分组列表")
version: str = Field(default="1.0.0", description="配置版本")
updated: datetime = Field(default_factory=datetime.now, description="最后更新时间")
class ConfigUpdateRequest(BaseModel):
"""更新配置请求"""
type: ConfigType = Field(..., description="配置类型")
items: Dict[str, Any] = Field(..., description="更新的配置项")
class ConfigUpdateData(BaseModel):
"""更新配置响应"""
success: bool = Field(..., description="是否成功")
need_restart: bool = Field(default=False, description="是否需要重启")
message: str = Field(..., description="提示信息")
# ============================================
# 适配器管理类型
# ============================================
class AdapterStatus(str, Enum):
"""适配器状态"""
ACTIVE = "active"
STANDBY = "standby"
DISABLED = "disabled"
ERROR = "error"
class AdapterInfo(BaseModel):
"""适配器信息"""
name: str = Field(..., description="适配器名称")
type: str = Field(..., description="适配器类型")
version: str = Field(..., description="版本")
description: str = Field(..., description="描述")
status: AdapterStatus = Field(..., description="状态")
config: Dict[str, Any] = Field(default_factory=dict, description="当前配置")
last_error: Optional[str] = Field(None, description="最后错误")
updated_at: datetime = Field(default_factory=datetime.now, description="更新时间")
class AdapterListData(BaseModel):
"""适配器列表响应"""
adapters: List[AdapterInfo] = Field(default_factory=list, description="适配器列表")
class AdapterToggleRequest(BaseModel):
"""启用/禁用适配器请求"""
name: str = Field(..., description="适配器名称")
enable: bool = Field(..., description="是否启用")
class AdapterConfigUpdateRequest(BaseModel):
"""更新适配器配置请求"""
name: str = Field(..., description="适配器名称")
config: Dict[str, str] = Field(..., description="配置")
# ============================================
# 系统管理类型
# ============================================
class MemoryInfo(BaseModel):
"""内存信息"""
alloc: int = Field(..., description="已分配内存")
total_alloc: int = Field(..., description="累计分配")
sys: int = Field(..., description="系统内存")
num_gc: int = Field(..., description="GC次数")
class SystemStatusData(BaseModel):
"""系统状态数据"""
status: str = Field(..., description="系统状态")
version: str = Field(..., description="系统版本")
start_time: datetime = Field(..., description="启动时间")
uptime: str = Field(..., description="运行时长")
python_version: str = Field(..., description="Python版本")
memory: MemoryInfo = Field(..., description="内存使用")
threads: int = Field(..., description="线程数量")
class RestartRequest(BaseModel):
"""重启服务请求"""
force: bool = Field(default=False, description="是否强制重启")
class ReloadRequest(BaseModel):
"""热加载配置请求"""
config_type: Optional[ConfigType] = Field(None, description="指定配置类型")
class ReloadData(BaseModel):
"""热加载响应"""
success: bool = Field(..., description="是否成功")
message: str = Field(..., description="提示信息")
# ============================================
# 接口测试类型
# ============================================
class APITestCase(BaseModel):
"""接口测试用例"""
id: str = Field(..., description="用例ID")
name: str = Field(..., description="用例名称")
method: str = Field(..., description="HTTP方法")
path: str = Field(..., description="请求路径")
description: str = Field(..., description="描述")
params: Dict[str, str] = Field(default_factory=dict, description="默认参数")
body: Optional[Any] = Field(None, description="请求体")
class APITestCategory(BaseModel):
"""测试分类"""
name: str = Field(..., description="分类名称")
items: List[APITestCase] = Field(default_factory=list, description="测试用例")
class APITestListData(BaseModel):
"""接口测试列表响应"""
categories: List[APITestCategory] = Field(default_factory=list, description="分类列表")
base_url: str = Field(default="", description="基础URL")
class APITestRequest(BaseModel):
"""执行接口测试请求"""
id: str = Field(..., description="用例ID")
params: Dict[str, str] = Field(default_factory=dict, description="自定义参数")
body: Optional[Any] = Field(None, description="自定义请求体")
class APITestResult(BaseModel):
"""接口测试结果"""
id: int = Field(..., description="测试ID")
case_id: str = Field(..., description="用例ID")
name: str = Field(..., description="用例名称")
success: bool = Field(..., description="是否成功")
status_code: int = Field(0, description="HTTP状态码")
latency: int = Field(..., description="延迟(ms)")
request: Any = Field(None, description="请求信息")
response: Any = Field(None, description="响应信息")
error: Optional[str] = Field(None, description="错误信息")
timestamp: datetime = Field(default_factory=datetime.now, description="测试时间")
# ============================================
# WebSocket测试类型
# ============================================
class WSTestCase(BaseModel):
"""WebSocket测试用例"""
id: str = Field(..., description="用例ID")
name: str = Field(..., description="用例名称")
description: str = Field(..., description="描述")
action: str = Field(..., description="动作类型")
symbols: List[str] = Field(default_factory=list, description="订阅标的")
class WSTestListData(BaseModel):
"""WebSocket测试列表响应"""
cases: List[WSTestCase] = Field(default_factory=list, description="测试用例")
ws_url: str = Field(default="", description="WebSocket地址")
class WSTestRequest(BaseModel):
"""WebSocket测试请求"""
id: str = Field(..., description="用例ID")
symbols: List[str] = Field(default_factory=list, description="自定义标的")
class WSMessage(BaseModel):
"""WebSocket消息"""
type: str = Field(..., description="消息类型")
data: Any = Field(None, description="消息内容")
timestamp: datetime = Field(default_factory=datetime.now, description="时间")
class WSTestResult(BaseModel):
"""WebSocket测试结果"""
id: str = Field(..., description="测试ID")
case_id: str = Field(..., description="用例ID")
success: bool = Field(..., description="是否成功")
latency: int = Field(..., description="连接延迟(ms)")
messages: List[WSMessage] = Field(default_factory=list, description="收到的消息")
error: Optional[str] = Field(None, description="错误信息")
timestamp: datetime = Field(default_factory=datetime.now, description="测试时间")
# ============================================
# 测试历史记录类型
# ============================================
class TestHistoryRequest(BaseModel):
"""获取测试历史请求"""
type: Optional[str] = Field(None, description="测试类型: api/ws")
limit: int = Field(default=20, ge=1, le=100, description="数量限制")
class TestHistoryData(BaseModel):
"""测试历史数据"""
api_tests: List[APITestResult] = Field(default_factory=list, description="API测试历史")
ws_tests: List[WSTestResult] = Field(default_factory=list, description="WebSocket测试历史")

@ -0,0 +1,306 @@
"""基础类型定义 - 对应Go的api/types.go"""
from datetime import datetime
from typing import List, Optional, Literal, Any
from enum import Enum
from pydantic import BaseModel, Field
# ============================================
# 基础枚举定义
# ============================================
class Frequency(str, Enum):
"""K线周期"""
FREQ_1M = "1m"
FREQ_5M = "5m"
FREQ_15M = "15m"
FREQ_30M = "30m"
FREQ_60M = "60m"
FREQ_1D = "1d"
FREQ_1W = "1w"
FREQ_1MONTH = "1month"
class AdjustType(str, Enum):
"""复权类型"""
NONE = "" # 不复权
QFQ = "qfq" # 前复权
HFQ = "hfq" # 后复权
class AssetClass(str, Enum):
"""资产类别"""
STOCK = "stock"
FUTURES = "futures"
ALL = "all"
class SymbolType(str, Enum):
"""标的类型"""
STOCK = "stock"
FUTURES = "futures"
INDEX = "index"
ETF = "etf"
CONTINUOUS = "continuous"
class Exchange(str, Enum):
"""交易所"""
# 股票交易所
SZ = "SZ" # 深交所
SH = "SH" # 上交所
BJ = "BJ" # 北交所
# 期货交易所
CFFEX = "CFFEX" # 中金所
SHFE = "SHFE" # 上期所
DCE = "DCE" # 大商所
CZCE = "CZCE" # 郑商所
INE = "INE" # 上期能源
GFEX = "GFEX" # 广期所
class DataSourceStatus(str, Enum):
"""数据源状态"""
HEALTHY = "healthy"
DEGRADED = "degraded"
DOWN = "down"
# ============================================
# K线数据模型
# ============================================
class KLineItem(BaseModel):
"""单条K线数据"""
time: datetime = Field(..., description="时间戳")
open: float = Field(..., description="开盘价")
high: float = Field(..., description="最高价")
low: float = Field(..., description="最低价")
close: float = Field(..., description="收盘价")
volume: int = Field(..., description="成交量")
amount: float = Field(..., description="成交额")
# 期货特有字段
open_interest: Optional[int] = Field(None, description="持仓量")
settlement: Optional[float] = Field(None, description="结算价")
# 股票特有字段
adj_factor: Optional[float] = Field(None, description="复权系数")
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
class KLineData(BaseModel):
"""K线响应数据"""
symbol: str = Field(..., description="标的代码")
name: Optional[str] = Field(None, description="标的名称")
freq: Frequency = Field(..., description="周期")
adjust: AdjustType = Field(default=AdjustType.NONE, description="复权类型")
count: int = Field(..., description="数据条数")
items: List[KLineItem] = Field(default_factory=list, description="K线数据")
class KLineQueryRequest(BaseModel):
"""K线查询请求"""
symbol: str = Field(..., description="标的代码")
start: str = Field(..., description="开始日期 YYYYMMDD", min_length=8, max_length=8)
end: str = Field(..., description="结束日期 YYYYMMDD", min_length=8, max_length=8)
freq: Frequency = Field(default=Frequency.FREQ_1D, description="周期")
adjust: AdjustType = Field(default=AdjustType.NONE, description="复权类型")
class BatchKLineRequest(BaseModel):
"""批量K线查询请求"""
symbols: List[str] = Field(..., description="标的列表最多100只", max_length=100)
start: str = Field(..., description="开始日期 YYYYMMDD", min_length=8, max_length=8)
end: str = Field(..., description="结束日期 YYYYMMDD", min_length=8, max_length=8)
freq: Frequency = Field(default=Frequency.FREQ_1D, description="周期")
adjust: AdjustType = Field(default=AdjustType.NONE, description="复权类型")
class KLineSubData(BaseModel):
"""批量查询中的数据部分"""
count: int = Field(..., description="数据条数")
items: List[KLineItem] = Field(default_factory=list, description="K线数据")
class BatchKLineResult(BaseModel):
"""批量查询单条结果"""
symbol: str = Field(..., description="标的代码")
success: bool = Field(..., description="是否成功")
error: Optional[str] = Field(None, description="错误信息")
data: Optional[KLineSubData] = Field(None, description="数据")
class BatchKLineData(BaseModel):
"""批量K线响应数据"""
results: List[BatchKLineResult] = Field(default_factory=list, description="结果列表")
# ============================================
# 标的模型
# ============================================
class Symbol(BaseModel):
"""标的详细信息"""
symbol_id: str = Field(..., description="统一标的编码")
symbol_type: SymbolType = Field(..., description="标的类型")
exchange: Exchange = Field(..., description="交易所")
name: str = Field(..., description="名称")
name_en: Optional[str] = Field(None, description="英文名称")
list_date: Optional[datetime] = Field(None, description="上市日期")
delist_date: Optional[datetime] = Field(None, description="退市日期")
# 股票特有
industry: Optional[str] = Field(None, description="行业分类")
# 期货特有
underlying: Optional[str] = Field(None, description="标的资产")
contract_month: Optional[str] = Field(None, description="合约月份")
continuous_type: Optional[str] = Field(None, description="连续合约类型")
status: str = Field(..., description="active/suspended/delisted")
class SymbolListRequest(BaseModel):
"""标的列表请求"""
exchange: Optional[Exchange] = Field(None, description="交易所筛选")
keyword: Optional[str] = Field(None, description="关键词搜索")
underlying: Optional[str] = Field(None, description="品种筛选(期货)")
page: int = Field(default=1, ge=1, description="页码")
size: int = Field(default=20, ge=1, le=100, description="每页数量")
class SymbolListData(BaseModel):
"""标的列表响应数据"""
total: int = Field(..., description="总数")
page: int = Field(..., description="当前页")
size: int = Field(..., description="每页数量")
items: List[Symbol] = Field(default_factory=list, description="标的列表")
# ============================================
# 管理接口模型
# ============================================
class DataSourceInfo(BaseModel):
"""数据源信息"""
active_source: str = Field(..., description="当前激活源")
standby_sources: List[str] = Field(default_factory=list, description="待命源列表")
last_tick_ts: Optional[datetime] = Field(None, description="最后Tick时间")
status: DataSourceStatus = Field(..., description="数据源状态")
class DataSourceStatusData(BaseModel):
"""数据源状态响应"""
stock: DataSourceInfo = Field(default_factory=lambda: DataSourceInfo(
active_source="akshare",
status=DataSourceStatus.HEALTHY
))
futures: DataSourceInfo = Field(default_factory=lambda: DataSourceInfo(
active_source="akshare",
status=DataSourceStatus.HEALTHY
))
class SourceSwitchRequest(BaseModel):
"""数据源切换请求"""
asset_class: AssetClass = Field(..., description="资产类别")
source: str = Field(..., description="目标数据源")
sync_backfill: bool = Field(default=False, description="是否同步补录")
start_date: Optional[str] = Field(None, description="补录开始日期 YYYYMMDD")
class BackfillRequest(BaseModel):
"""历史数据补录请求"""
asset_class: AssetClass = Field(..., description="资产类别")
symbols: List[str] = Field(..., description="标的列表,空数组表示全部")
start: str = Field(..., description="开始日期 YYYYMMDD", min_length=8, max_length=8)
end: str = Field(..., description="结束日期 YYYYMMDD", min_length=8, max_length=8)
freqs: List[Frequency] = Field(..., description="需要补录的周期")
source: Optional[str] = Field(None, description="指定数据源")
# ============================================
# 交易日历模型
# ============================================
class TradingDatesRequest(BaseModel):
"""可交易日期查询请求"""
start: str = Field(..., description="开始日期 YYYYMMDD", min_length=8, max_length=8)
end: str = Field(..., description="结束日期 YYYYMMDD", min_length=8, max_length=8)
class TradingDatesData(BaseModel):
"""可交易日期响应数据"""
start: str = Field(..., description="查询开始日期")
end: str = Field(..., description="查询结束日期")
total_days: int = Field(..., description="总天数")
trading_days: int = Field(..., description="交易日数量")
trading_dates: List[str] = Field(default_factory=list, description="交易日列表")
# ============================================
# 期货合约模型
# ============================================
class FuturesContractsRequest(BaseModel):
"""获取期货合约请求"""
underlying: str = Field(..., description="品种代码,如 Cu, RB, M")
exchange: Optional[str] = Field(None, description="交易所筛选")
class FuturesContractInfo(BaseModel):
"""期货合约信息"""
symbol_id: str = Field(..., description="合约代码")
symbol_type: str = Field(default="futures", description="类型")
exchange: Exchange = Field(..., description="交易所")
name: str = Field(..., description="合约名称")
underlying: str = Field(..., description="品种代码")
contract_month: str = Field(..., description="合约月份")
list_date: Optional[datetime] = Field(None, description="上市日期")
delist_date: Optional[datetime] = Field(None, description="退市日期")
status: str = Field(..., description="active/expired")
class FuturesContractsData(BaseModel):
"""期货合约列表响应"""
underlying: str = Field(..., description="品种代码")
count: int = Field(..., description="合约数量")
items: List[FuturesContractInfo] = Field(default_factory=list, description="合约列表")
# ============================================
# 响应模型
# ============================================
class Response(BaseModel):
"""通用响应结构"""
code: int = Field(default=0, description="0表示成功非0表示错误")
message: str = Field(default="success", description="提示信息")
data: Optional[Any] = Field(None, description="响应数据")
class ErrorResponse(BaseModel):
"""错误响应"""
code: int = Field(..., description="错误码")
message: str = Field(..., description="错误信息")
detail: Optional[str] = Field(None, description="详细错误")
class SuccessResponse(BaseModel):
"""成功响应"""
code: int = Field(default=0)
message: str = Field(default="success")
data: Optional[Any] = Field(None)
class HealthResponse(BaseModel):
"""健康检查响应"""
status: str = Field(..., description="状态")
timestamp: datetime = Field(default_factory=datetime.now, description="时间戳")

@ -0,0 +1,4 @@
"""数据质量监控模块"""
from .monitor import DataQualityMonitor, AlertSender, LogAlertSender
__all__ = ["DataQualityMonitor", "AlertSender", "LogAlertSender"]

@ -0,0 +1,255 @@
"""数据质量监控 - 对应Go的internal/monitor/monitor.go"""
import asyncio
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.repositories import StockRepository, FuturesRepository
from app.models import Frequency
from app.core.logger import info, error
@dataclass
class CheckResult:
"""检查结果"""
symbol: str
freq: str
check_date: str
check_type: str
status: str # pass/fail
expect_count: int
actual_count: int
detail: str
@dataclass
class QualityReport:
"""数据质量报告"""
asset_type: str
check_date: str
total_checks: int
pass_count: int
fail_count: int
pass_rate: float
class AlertSender(ABC):
"""告警发送接口"""
@abstractmethod
def send_alert(self, title: str, content: str) -> bool:
"""发送告警"""
pass
class LogAlertSender(AlertSender):
"""日志告警发送器"""
def send_alert(self, title: str, content: str) -> bool:
info(f"[ALERT] {title}: {content}")
return True
class DataQualityMonitor:
"""数据质量监控"""
def __init__(
self,
db: Session,
stock_repo: StockRepository,
futures_repo: FuturesRepository,
sender: Optional[AlertSender] = None
):
self.db = db
self.stock_repo = stock_repo
self.futures_repo = futures_repo
self.sender = sender or LogAlertSender()
async def daily_check(self, check_date: str):
"""每日数据质量检查"""
info(f"Starting daily data quality check for {check_date}")
# 检查股票数据
try:
await self._check_stock_data(check_date)
except Exception as e:
error(f"Stock data check failed: {e}")
# 检查期货数据
try:
await self._check_futures_data(check_date)
except Exception as e:
error(f"Futures data check failed: {e}")
info("Daily data quality check completed")
async def _check_stock_data(self, check_date: str):
"""检查股票数据质量"""
# 获取所有活跃股票
from app.models import SymbolListRequest
symbols, _, _ = self.stock_repo.list_symbols(SymbolListRequest(page=1, size=10000))
# 检查1分钟线完整性股票应有240条
for symbol in symbols[:100]: # 限制检查数量
result = self._check_kline_completeness(
"stock", symbol.symbol_id, "1m", check_date, 240
)
self._save_check_result("stock", result)
async def _check_futures_data(self, check_date: str):
"""检查期货数据质量"""
from app.models import SymbolListRequest
symbols, _, _ = self.futures_repo.list_symbols(SymbolListRequest(page=1, size=10000))
# 检查1分钟线完整性
for symbol in symbols[:100]:
result = self._check_kline_completeness(
"futures", symbol.symbol_id, "1m", check_date, 240
)
self._save_check_result("futures", result)
def _check_kline_completeness(
self,
asset_type: str,
symbol: str,
freq: str,
check_date: str,
expect_count: int
) -> CheckResult:
"""检查K线完整性"""
result = CheckResult(
symbol=symbol,
freq=freq,
check_date=check_date,
check_type="missing",
status="pass",
expect_count=expect_count,
actual_count=0,
detail=""
)
try:
# 解析日期
start = datetime.strptime(check_date, "%Y%m%d")
end = start + timedelta(days=1) - timedelta(seconds=1)
# 查询数据
if asset_type == "stock":
items = self.stock_repo.get_klines(
symbol, Frequency(freq), start, end
)
else:
items = self.futures_repo.get_klines(
symbol, Frequency(freq), start, end
)
actual_count = len(items)
result.actual_count = actual_count
# 判断缺失情况
if actual_count < expect_count:
result.status = "fail"
result.detail = f"Data missing: expected {expect_count}, actual {actual_count}"
# 发送告警
if self.sender:
self.sender.send_alert(
f"[{asset_type}] Data Missing Alert",
f"Symbol: {symbol}, Date: {check_date}, Expected: {expect_count}, Actual: {actual_count}"
)
except Exception as e:
result.status = "fail"
result.detail = f"Error querying data: {e}"
return result
def _save_check_result(self, asset_type: str, result: CheckResult):
"""保存检查结果"""
try:
# 根据资产类型选择schema
schema = "stock" if asset_type == "stock" else "futures"
query = text(f"""
INSERT INTO {schema}.data_quality_checks
(check_date, symbol_id, freq, check_type, status, expect_count, actual_count, detail)
VALUES (:check_date, :symbol_id, :freq, :check_type, :status, :expect_count, :actual_count, :detail)
ON CONFLICT (check_date, symbol_id, freq, check_type) DO UPDATE SET
status = EXCLUDED.status,
expect_count = EXCLUDED.expect_count,
actual_count = EXCLUDED.actual_count,
detail = EXCLUDED.detail,
created_at = NOW()
""")
self.db.execute(query, {
"check_date": result.check_date,
"symbol_id": result.symbol,
"freq": result.freq,
"check_type": result.check_type,
"status": result.status,
"expect_count": result.expect_count,
"actual_count": result.actual_count,
"detail": result.detail
})
self.db.commit()
except Exception as e:
error(f"Failed to save check result: {e}")
def get_quality_report(self, asset_type: str, check_date: str) -> QualityReport:
"""获取数据质量报告"""
schema = "stock" if asset_type == "stock" else "futures"
query = text(f"""
SELECT
COUNT(*) as total_checks,
SUM(CASE WHEN status = 'pass' THEN 1 ELSE 0 END) as pass_count,
SUM(CASE WHEN status = 'fail' THEN 1 ELSE 0 END) as fail_count
FROM {schema}.data_quality_checks
WHERE check_date = :check_date
""")
result = self.db.execute(query, {"check_date": check_date}).fetchone()
total = result.total_checks or 0
pass_count = result.pass_count or 0
fail_count = result.fail_count or 0
pass_rate = (pass_count / total * 100) if total > 0 else 0
return QualityReport(
asset_type=asset_type,
check_date=check_date,
total_checks=total,
pass_count=pass_count,
fail_count=fail_count,
pass_rate=pass_rate
)
async def start_daily_check_cron(self):
"""启动每日检查定时任务"""
while True:
try:
# 计算到下一个盘后的时间假设15:35
now = datetime.now()
next_check = now.replace(hour=15, minute=35, second=0, microsecond=0)
if next_check <= now:
next_check = next_check + timedelta(days=1)
wait_seconds = (next_check - now).total_seconds()
info(f"Next daily check scheduled at {next_check}, waiting {wait_seconds} seconds")
await asyncio.sleep(wait_seconds)
# 执行检查
check_date = now.strftime("%Y%m%d")
await self.daily_check(check_date)
except Exception as e:
error(f"Daily check cron error: {e}")
await asyncio.sleep(3600) # 出错后等待1小时重试

@ -0,0 +1,13 @@
"""数据访问层模块"""
from .database import get_db, SessionLocal, engine, Base
from .stock_repository import StockRepository
from .futures_repository import FuturesRepository
__all__ = [
"get_db",
"SessionLocal",
"engine",
"Base",
"StockRepository",
"FuturesRepository",
]

@ -0,0 +1,43 @@
"""数据库连接管理"""
import os
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
# 数据库配置
# 优先使用环境变量 DATABASE_URL
# 格式mysql+pymysql://user:password@host:port/database
DATABASE_URL = os.getenv(
"DATABASE_URL",
"mysql+pymysql://root:1qazse42W3@localhost:3306/marketdata"
)
# 创建数据库引擎
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
# 会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 声明基类
Base = declarative_base()
def get_db() -> Generator[Session, None, None]:
"""获取数据库会话用于FastAPI依赖注入"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""初始化数据库(创建所有表)"""
Base.metadata.create_all(bind=engine)

@ -0,0 +1,268 @@
"""期货数据仓库"""
from datetime import datetime
from typing import List, Tuple, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from app.models import (
KLineItem, Symbol, SymbolListRequest, SymbolListData,
TradingDatesData, TradeCalData, Frequency,
FuturesContractsData, FuturesContractInfo
)
from app.repositories.models import (
FuturesSymbol, FuturesKLine1M, FuturesKLine1D,
FuturesTradingCalendar
)
class FuturesRepository:
"""期货数据仓库"""
def __init__(self, db: Session):
self.db = db
def get_klines(
self,
symbol: str,
freq: Frequency,
start: datetime,
end: datetime
) -> List[KLineItem]:
"""获取K线数据"""
kline_model = self._get_kline_model(freq)
query = self.db.query(kline_model).filter(
kline_model.symbol_id == symbol,
kline_model.ts >= start,
kline_model.ts <= end
).order_by(kline_model.ts.asc())
results = query.all()
items = []
for r in results:
item = KLineItem(
time=r.ts,
open=float(r.open),
high=float(r.high),
low=float(r.low),
close=float(r.close),
volume=r.volume,
amount=float(r.amount),
open_interest=r.open_interest
)
items.append(item)
return items
def _get_kline_model(self, freq: Frequency):
"""根据周期获取K线模型"""
mapping = {
Frequency.FREQ_1M: FuturesKLine1M,
Frequency.FREQ_1D: FuturesKLine1D,
}
return mapping.get(freq, FuturesKLine1D)
def save_klines(
self,
freq: Frequency,
symbol: str,
items: List[KLineItem]
) -> None:
"""保存K线数据"""
if not items:
return
kline_model = self._get_kline_model(freq)
for item in items:
existing = self.db.query(kline_model).filter(
kline_model.symbol_id == symbol,
kline_model.ts == item.time
).first()
if existing:
existing.open = item.open
existing.high = item.high
existing.low = item.low
existing.close = item.close
existing.volume = item.volume
existing.amount = item.amount
existing.open_interest = item.open_interest
else:
new_record = kline_model(
symbol_id=symbol,
ts=item.time,
open=item.open,
high=item.high,
low=item.low,
close=item.close,
volume=item.volume,
amount=item.amount,
open_interest=item.open_interest
)
self.db.add(new_record)
self.db.commit()
def list_symbols(
self,
req: SymbolListRequest
) -> Tuple[List[Symbol], int]:
"""查询标的列表"""
query = self.db.query(FuturesSymbol)
# 筛选条件
if req.exchange:
query = query.filter(FuturesSymbol.exchange == req.exchange.value)
if req.underlying:
query = query.filter(FuturesSymbol.underlying == req.underlying)
if req.keyword:
keyword = f"%{req.keyword}%"
query = query.filter(
or_(
FuturesSymbol.symbol_id.ilike(keyword),
FuturesSymbol.name.ilike(keyword)
)
)
# 查询总数
total = query.count()
# 分页查询
results = query.order_by(FuturesSymbol.symbol_id).offset(
(req.page - 1) * req.size
).limit(req.size).all()
symbols = []
for r in results:
s = Symbol(
symbol_id=r.symbol_id,
symbol_type=r.symbol_type,
exchange=r.exchange,
name=r.name,
underlying=r.underlying,
contract_month=r.contract_month,
list_date=r.list_date,
delist_date=r.delist_date,
status=r.status
)
symbols.append(s)
return symbols, total
def get_trading_dates(self, start: str, end: str) -> TradingDatesData:
"""获取交易日历"""
results = self.db.query(FuturesTradingCalendar).filter(
FuturesTradingCalendar.trade_date >= start,
FuturesTradingCalendar.trade_date <= end,
FuturesTradingCalendar.is_trading_day == True
).order_by(FuturesTradingCalendar.trade_date.asc()).all()
dates = [r.trade_date for r in results]
# 计算总天数
start_date = datetime.strptime(start, "%Y%m%d")
end_date = datetime.strptime(end, "%Y%m%d")
total_days = (end_date - start_date).days + 1
return TradingDatesData(
start=start,
end=end,
total_days=total_days,
trading_days=len(dates),
trading_dates=dates
)
def get_contracts_by_underlying(
self,
underlying: str,
exchange: Optional[str] = None
) -> FuturesContractsData:
"""根据品种获取合约"""
query = self.db.query(FuturesSymbol).filter(
FuturesSymbol.underlying == underlying,
FuturesSymbol.status == "active"
)
if exchange:
query = query.filter(FuturesSymbol.exchange == exchange)
results = query.order_by(FuturesSymbol.contract_month.asc()).all()
contracts = []
for r in results:
c = FuturesContractInfo(
symbol_id=r.symbol_id,
exchange=r.exchange,
name=r.name,
underlying=r.underlying,
contract_month=r.contract_month,
list_date=r.list_date,
delist_date=r.delist_date,
status=r.status
)
contracts.append(c)
return FuturesContractsData(
underlying=underlying,
count=len(contracts),
items=contracts
)
def save_symbols(self, symbols: List[Symbol]) -> None:
"""保存标的列表"""
for s in symbols:
existing = self.db.query(FuturesSymbol).filter(
FuturesSymbol.symbol_id == s.symbol_id
).first()
if existing:
existing.name = s.name
existing.underlying = s.underlying
existing.contract_month = s.contract_month
existing.list_date = s.list_date
existing.delist_date = s.delist_date
existing.status = s.status
else:
new_symbol = FuturesSymbol(
symbol_id=s.symbol_id,
symbol_type=s.symbol_type.value if s.symbol_type else "futures",
exchange=s.exchange.value if s.exchange else "",
name=s.name,
underlying=s.underlying or "",
contract_month=s.contract_month or "",
list_date=s.list_date,
delist_date=s.delist_date,
status=s.status
)
self.db.add(new_symbol)
self.db.commit()
def save_trading_calendar(self, dates: List[TradeCalData]) -> None:
"""保存交易日历"""
for d in dates:
date_str = d.date.strftime("%Y%m%d")
existing = self.db.query(FuturesTradingCalendar).filter(
FuturesTradingCalendar.trade_date == date_str
).first()
if existing:
existing.is_trading_day = d.is_trading_day
existing.has_night_session = d.has_night_session
existing.week_day = d.date.weekday() + 1
else:
new_cal = FuturesTradingCalendar(
trade_date=date_str,
is_trading_day=d.is_trading_day,
has_night_session=d.has_night_session,
week_day=d.date.weekday() + 1
)
self.db.add(new_cal)
self.db.commit()

@ -0,0 +1,202 @@
"""数据库模型定义"""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column, Integer, String, Float, DateTime,
Boolean, Numeric, BigInteger, Index, Text
)
from app.repositories.database import Base
# ============================================
# 股票相关表
# ============================================
class StockSymbol(Base):
"""股票标的表"""
__tablename__ = "stock_symbols"
symbol_id = Column(String(20), primary_key=True, index=True, comment="标的代码")
symbol_type = Column(String(20), nullable=False, comment="标的类型")
exchange = Column(String(10), nullable=False, index=True, comment="交易所")
name = Column(String(100), nullable=False, comment="名称")
name_en = Column(String(100), nullable=True, comment="英文名称")
list_date = Column(DateTime, nullable=True, comment="上市日期")
delist_date = Column(DateTime, nullable=True, comment="退市日期")
industry = Column(String(50), nullable=True, comment="行业分类")
status = Column(String(20), nullable=False, default="active", comment="状态")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
class StockTradingCalendar(Base):
"""股票交易日历表"""
__tablename__ = "stock_trading_calendar"
trade_date = Column(String(8), primary_key=True, comment="交易日期")
is_trading_day = Column(Boolean, nullable=False, comment="是否交易日")
week_day = Column(Integer, nullable=True, comment="星期几")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
class StockKLine1M(Base):
"""股票1分钟K线"""
__tablename__ = "stock_klines_1m"
__table_args__ = (
Index("idx_stock_1m_symbol_ts", "symbol_id", "ts"),
)
id = Column(BigInteger, primary_key=True, autoincrement=True)
symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码")
ts = Column(DateTime, nullable=False, comment="时间戳")
open = Column(Numeric(18, 4), nullable=False, comment="开盘价")
high = Column(Numeric(18, 4), nullable=False, comment="最高价")
low = Column(Numeric(18, 4), nullable=False, comment="最低价")
close = Column(Numeric(18, 4), nullable=False, comment="收盘价")
volume = Column(BigInteger, nullable=False, comment="成交量")
amount = Column(Numeric(20, 4), nullable=False, comment="成交额")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
class StockKLine5M(Base):
"""股票5分钟K线"""
__tablename__ = "stock_klines_5m"
__table_args__ = (
Index("idx_stock_5m_symbol_ts", "symbol_id", "ts"),
)
id = Column(BigInteger, primary_key=True, autoincrement=True)
symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码")
ts = Column(DateTime, nullable=False, comment="时间戳")
open = Column(Numeric(18, 4), nullable=False, comment="开盘价")
high = Column(Numeric(18, 4), nullable=False, comment="最高价")
low = Column(Numeric(18, 4), nullable=False, comment="最低价")
close = Column(Numeric(18, 4), nullable=False, comment="收盘价")
volume = Column(BigInteger, nullable=False, comment="成交量")
amount = Column(Numeric(20, 4), nullable=False, comment="成交额")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
class StockKLine1D(Base):
"""股票日线K线"""
__tablename__ = "stock_klines_1d"
__table_args__ = (
Index("idx_stock_1d_symbol_ts", "symbol_id", "ts"),
)
id = Column(BigInteger, primary_key=True, autoincrement=True)
symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码")
ts = Column(DateTime, nullable=False, comment="时间戳")
open = Column(Numeric(18, 4), nullable=False, comment="开盘价")
high = Column(Numeric(18, 4), nullable=False, comment="最高价")
low = Column(Numeric(18, 4), nullable=False, comment="最低价")
close = Column(Numeric(18, 4), nullable=False, comment="收盘价")
volume = Column(BigInteger, nullable=False, comment="成交量")
amount = Column(Numeric(20, 4), nullable=False, comment="成交额")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
# ============================================
# 期货相关表
# ============================================
class FuturesSymbol(Base):
"""期货合约表"""
__tablename__ = "futures_symbols"
symbol_id = Column(String(20), primary_key=True, index=True, comment="合约代码")
symbol_type = Column(String(20), nullable=False, comment="标的类型")
exchange = Column(String(10), nullable=False, index=True, comment="交易所")
name = Column(String(100), nullable=False, comment="名称")
underlying = Column(String(10), nullable=False, index=True, comment="品种代码")
contract_month = Column(String(6), nullable=False, comment="合约月份")
list_date = Column(DateTime, nullable=True, comment="上市日期")
delist_date = Column(DateTime, nullable=True, comment="退市日期")
status = Column(String(20), nullable=False, default="active", comment="状态")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
class FuturesTradingCalendar(Base):
"""期货交易日历表"""
__tablename__ = "futures_trading_calendar"
trade_date = Column(String(8), primary_key=True, comment="交易日期")
is_trading_day = Column(Boolean, nullable=False, comment="是否交易日")
has_night_session = Column(Boolean, default=False, comment="是否有夜盘")
week_day = Column(Integer, nullable=True, comment="星期几")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
class FuturesKLine1M(Base):
"""期货1分钟K线"""
__tablename__ = "futures_klines_1m"
__table_args__ = (
Index("idx_futures_1m_symbol_ts", "symbol_id", "ts"),
)
id = Column(BigInteger, primary_key=True, autoincrement=True)
symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码")
ts = Column(DateTime, nullable=False, comment="时间戳")
open = Column(Numeric(18, 4), nullable=False, comment="开盘价")
high = Column(Numeric(18, 4), nullable=False, comment="最高价")
low = Column(Numeric(18, 4), nullable=False, comment="最低价")
close = Column(Numeric(18, 4), nullable=False, comment="收盘价")
volume = Column(BigInteger, nullable=False, comment="成交量")
amount = Column(Numeric(20, 4), nullable=False, comment="成交额")
open_interest = Column(BigInteger, nullable=True, comment="持仓量")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
class FuturesKLine1D(Base):
"""期货日线K线"""
__tablename__ = "futures_klines_1d"
__table_args__ = (
Index("idx_futures_1d_symbol_ts", "symbol_id", "ts"),
)
id = Column(BigInteger, primary_key=True, autoincrement=True)
symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码")
ts = Column(DateTime, nullable=False, comment="时间戳")
open = Column(Numeric(18, 4), nullable=False, comment="开盘价")
high = Column(Numeric(18, 4), nullable=False, comment="最高价")
low = Column(Numeric(18, 4), nullable=False, comment="最低价")
close = Column(Numeric(18, 4), nullable=False, comment="收盘价")
volume = Column(BigInteger, nullable=False, comment="成交量")
amount = Column(Numeric(20, 4), nullable=False, comment="成交额")
open_interest = Column(BigInteger, nullable=True, comment="持仓量")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
# ============================================
# 公共表
# ============================================
class DataSourceConfig(Base):
"""数据源配置表"""
__tablename__ = "data_source_config"
asset_class = Column(String(20), primary_key=True, comment="资产类别")
active_source = Column(String(50), nullable=False, comment="当前激活源")
standby_sources = Column(Text, nullable=True, comment="待命源列表(JSON)")
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
class DataQualityCheck(Base):
"""数据质量检查表"""
__tablename__ = "data_quality_checks"
id = Column(BigInteger, primary_key=True, autoincrement=True)
check_date = Column(String(8), nullable=False, index=True, comment="检查日期")
symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码")
freq = Column(String(10), nullable=False, comment="周期")
check_type = Column(String(20), nullable=False, comment="检查类型")
status = Column(String(10), nullable=False, comment="状态 pass/fail")
expect_count = Column(Integer, nullable=True, comment="期望数量")
actual_count = Column(Integer, nullable=True, comment="实际数量")
detail = Column(String(500), nullable=True, comment="详情")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")

@ -0,0 +1,222 @@
"""股票数据仓库"""
from datetime import datetime, time
from typing import List, Tuple, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from app.models import (
KLineItem, Symbol, SymbolListRequest, SymbolListData,
TradingDatesData, TradeCalData, AdjustType, Frequency
)
from app.repositories.models import (
StockSymbol, StockKLine1M, StockKLine5M, StockKLine1D,
StockTradingCalendar
)
class StockRepository:
"""股票数据仓库"""
def __init__(self, db: Session):
self.db = db
def get_klines(
self,
symbol: str,
freq: Frequency,
start: datetime,
end: datetime,
adjust: AdjustType = AdjustType.NONE
) -> List[KLineItem]:
"""获取K线数据"""
# 根据周期选择表
kline_model = self._get_kline_model(freq)
query = self.db.query(kline_model).filter(
kline_model.symbol_id == symbol,
kline_model.ts >= start,
kline_model.ts <= end
).order_by(kline_model.ts.asc())
results = query.all()
items = []
for r in results:
item = KLineItem(
time=r.ts,
open=float(r.open),
high=float(r.high),
low=float(r.low),
close=float(r.close),
volume=r.volume,
amount=float(r.amount)
)
items.append(item)
return items
def _get_kline_model(self, freq: Frequency):
"""根据周期获取K线模型"""
mapping = {
Frequency.FREQ_1M: StockKLine1M,
Frequency.FREQ_5M: StockKLine5M,
Frequency.FREQ_1D: StockKLine1D,
}
return mapping.get(freq, StockKLine1D)
def save_klines(self, freq: Frequency, items: List[KLineItem]) -> None:
"""保存K线数据"""
if not items:
return
kline_model = self._get_kline_model(freq)
for item in items:
# 使用upsert逻辑
existing = self.db.query(kline_model).filter(
kline_model.symbol_id == getattr(item, 'symbol', ''),
kline_model.ts == item.time
).first()
if existing:
existing.open = item.open
existing.high = item.high
existing.low = item.low
existing.close = item.close
existing.volume = item.volume
existing.amount = item.amount
else:
new_record = kline_model(
symbol_id=getattr(item, 'symbol', ''),
ts=item.time,
open=item.open,
high=item.high,
low=item.low,
close=item.close,
volume=item.volume,
amount=item.amount
)
self.db.add(new_record)
self.db.commit()
def list_symbols(
self,
req: SymbolListRequest
) -> Tuple[List[Symbol], int]:
"""查询标的列表"""
query = self.db.query(StockSymbol)
# 筛选条件
if req.exchange:
query = query.filter(StockSymbol.exchange == req.exchange.value)
if req.keyword:
keyword = f"%{req.keyword}%"
query = query.filter(
or_(
StockSymbol.symbol_id.ilike(keyword),
StockSymbol.name.ilike(keyword)
)
)
# 查询总数
total = query.count()
# 分页查询
results = query.order_by(StockSymbol.symbol_id).offset(
(req.page - 1) * req.size
).limit(req.size).all()
symbols = []
for r in results:
s = Symbol(
symbol_id=r.symbol_id,
symbol_type=r.symbol_type,
exchange=r.exchange,
name=r.name,
name_en=r.name_en,
list_date=r.list_date,
delist_date=r.delist_date,
industry=r.industry,
status=r.status
)
symbols.append(s)
return symbols, total
def get_trading_dates(self, start: str, end: str) -> TradingDatesData:
"""获取交易日历"""
results = self.db.query(StockTradingCalendar).filter(
StockTradingCalendar.trade_date >= start,
StockTradingCalendar.trade_date <= end,
StockTradingCalendar.is_trading_day == True
).order_by(StockTradingCalendar.trade_date.asc()).all()
dates = [r.trade_date for r in results]
# 计算总天数
start_date = datetime.strptime(start, "%Y%m%d")
end_date = datetime.strptime(end, "%Y%m%d")
total_days = (end_date - start_date).days + 1
return TradingDatesData(
start=start,
end=end,
total_days=total_days,
trading_days=len(dates),
trading_dates=dates
)
def save_symbols(self, symbols: List[Symbol]) -> None:
"""保存标的列表"""
for s in symbols:
existing = self.db.query(StockSymbol).filter(
StockSymbol.symbol_id == s.symbol_id
).first()
if existing:
existing.name = s.name
existing.name_en = s.name_en
existing.list_date = s.list_date
existing.delist_date = s.delist_date
existing.industry = s.industry
existing.status = s.status
else:
new_symbol = StockSymbol(
symbol_id=s.symbol_id,
symbol_type=s.symbol_type.value if s.symbol_type else "stock",
exchange=s.exchange.value if s.exchange else "",
name=s.name,
name_en=s.name_en,
list_date=s.list_date,
delist_date=s.delist_date,
industry=s.industry,
status=s.status
)
self.db.add(new_symbol)
self.db.commit()
def save_trading_calendar(self, dates: List[TradeCalData]) -> None:
"""保存交易日历"""
for d in dates:
date_str = d.date.strftime("%Y%m%d")
existing = self.db.query(StockTradingCalendar).filter(
StockTradingCalendar.trade_date == date_str
).first()
if existing:
existing.is_trading_day = d.is_trading_day
existing.week_day = d.date.weekday() + 1
else:
new_cal = StockTradingCalendar(
trade_date=date_str,
is_trading_day=d.is_trading_day,
week_day=d.date.weekday() + 1
)
self.db.add(new_cal)
self.db.commit()

@ -0,0 +1,16 @@
"""业务服务层模块"""
from .stock_service import StockService
from .futures_service import FuturesService
from .admin_service import AdminService
from .config_service import ConfigService
from .adapter_service import AdapterService
from .test_service import TestService
__all__ = [
"StockService",
"FuturesService",
"AdminService",
"ConfigService",
"AdapterService",
"TestService",
]

@ -0,0 +1,174 @@
"""适配器管理服务 - 对应Go的internal/service/adapter.go"""
import asyncio
from datetime import datetime
from typing import Dict, List, Optional, Callable
from threading import RLock
from app.models import (
AdapterListData, AdapterInfo, AdapterStatus,
AdapterToggleRequest, AdapterConfigUpdateRequest
)
from app.adapters import DataSourceAdapter, AKShareAdapter
from app.core.logger import info, error
class AdapterService:
"""适配器管理服务"""
def __init__(self):
self.lock = RLock()
# 已注册的适配器工厂
self.factories: Dict[str, Callable[[], DataSourceAdapter]] = {}
# 适配器配置
self.configs: Dict[str, dict] = {}
# 当前激活的适配器实例
self.active_adapters: Dict[str, DataSourceAdapter] = {}
# 适配器元数据
self.metadata: Dict[str, dict] = {}
# 注册内置适配器
self._register_builtin_adapters()
def _register_builtin_adapters(self):
"""注册内置适配器"""
# 注册AKShare适配器
self.register_adapter("akshare", lambda: AKShareAdapter())
# 设置AKShare元数据
self.metadata["akshare"] = {
"name": "akshare",
"type": "http",
"version": "1.0.0",
"description": "AKShare 开源金融数据接口无需Token",
"updated_at": datetime.now()
}
# AKShare默认配置无需token
self.configs["akshare"] = {
"enabled": True,
"config": {
"timeout": 30
}
}
def get_adapter_list(self) -> AdapterListData:
"""获取适配器列表"""
with self.lock:
adapters = []
for name, meta in self.metadata.items():
cfg = self.configs.get(name, {"enabled": False, "config": {}})
# 确定状态
if not cfg["enabled"]:
status = AdapterStatus.DISABLED
elif name in self.active_adapters:
status = AdapterStatus.ACTIVE
else:
status = AdapterStatus.STANDBY
adapters.append(AdapterInfo(
name=meta["name"],
type=meta["type"],
version=meta["version"],
description=meta["description"],
status=status,
config=cfg.get("config", {}),
updated_at=meta.get("updated_at", datetime.now())
))
return AdapterListData(adapters=adapters)
def toggle_adapter(self, req: AdapterToggleRequest) -> None:
"""启用/禁用适配器"""
with self.lock:
if req.name not in self.configs:
raise ValueError(f"Adapter not found: {req.name}")
self.configs[req.name]["enabled"] = req.enable
# 如果禁用,关闭适配器连接
if not req.enable and req.name in self.active_adapters:
adapter = self.active_adapters.pop(req.name)
asyncio.create_task(adapter.close())
# 更新元数据
if req.name in self.metadata:
self.metadata[req.name]["updated_at"] = datetime.now()
def update_adapter_config(self, req: AdapterConfigUpdateRequest) -> None:
"""更新适配器配置"""
with self.lock:
if req.name not in self.configs:
raise ValueError(f"Adapter not found: {req.name}")
# 更新配置
self.configs[req.name]["config"].update(req.config)
# 如果适配器已激活,重新连接
if req.name in self.active_adapters:
adapter = self.active_adapters.pop(req.name)
asyncio.create_task(adapter.close())
# 如果启用状态,重新连接
if self.configs[req.name]["enabled"]:
asyncio.create_task(self._connect_adapter(req.name))
# 更新元数据
if req.name in self.metadata:
self.metadata[req.name]["updated_at"] = datetime.now()
def get_active_adapter(self, asset_class: str) -> Optional[DataSourceAdapter]:
"""获取当前激活的适配器"""
with self.lock:
# 使用 AKShare唯一适配器
if "akshare" in self.active_adapters:
return self.active_adapters["akshare"]
# 如果没有激活的适配器返回None调用方需要负责初始化
return None
def get_available_adapters(self) -> List[str]:
"""获取所有可用的适配器名称"""
with self.lock:
names = []
for name, meta in self.metadata.items():
if name in self.factories:
names.append(f"{name}|{meta['description']}")
return names
def register_adapter(self, name: str, factory: Callable[[], DataSourceAdapter]):
"""注册适配器"""
with self.lock:
self.factories[name] = factory
async def _connect_adapter(self, name: str):
"""连接适配器"""
with self.lock:
if name not in self.factories:
raise ValueError(f"Adapter factory not found: {name}")
if name not in self.configs:
raise ValueError(f"Adapter config not found: {name}")
factory = self.factories[name]
cfg = self.configs[name]
adapter = factory()
await adapter.connect(cfg["config"])
with self.lock:
self.active_adapters[name] = adapter
async def health_check(self, name: str) -> bool:
"""适配器健康检查"""
with self.lock:
if name not in self.active_adapters:
return False
adapter = self.active_adapters[name]
return await adapter.health_check()

@ -0,0 +1,142 @@
"""管理服务 - 对应Go的internal/service/admin.go"""
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.models import (
DataSourceStatusData, DataSourceInfo, SourceSwitchRequest,
BackfillRequest, HealthResponse, DataSourceStatus
)
from app.core.config import get_config, save_config
from app.core.logger import info
class AdminService:
"""管理服务"""
def __init__(self, db: Session):
self.db = db
def get_data_source_status(self) -> DataSourceStatusData:
"""获取数据源状态"""
config = get_config()
try:
# 查询数据库中的数据源配置
result = self.db.execute(text("""
SELECT asset_class, active_source, standby_sources, updated_at
FROM data_source_config
"""))
data = DataSourceStatusData()
has_data = False
for row in result:
has_data = True
asset_class, active_source, standby_sources, updated_at = row
info_obj = DataSourceInfo(
active_source=active_source,
standby_sources=standby_sources or [],
status=DataSourceStatus.HEALTHY
)
if asset_class == "stock":
data.stock = info_obj
elif asset_class == "futures":
data.futures = info_obj
# 如果没有数据库配置,使用配置文件中的设置
if not has_data:
data.stock.active_source = config.sources.stock.active
data.futures.active_source = config.sources.futures.active
return data
except Exception as e:
info(f"Data source config not found, using config file: {e}")
# 使用配置文件中的设置
data = DataSourceStatusData()
data.stock.active_source = config.sources.stock.active
data.futures.active_source = config.sources.futures.active
return data
def switch_data_source(self, req: SourceSwitchRequest) -> None:
"""切换数据源"""
from app.core.config import get_config, save_config
config = get_config()
# 更新内存中的配置
if req.asset_class.value == "all":
config.sources.stock.active = req.source
config.sources.futures.active = req.source
elif req.asset_class.value == "stock":
config.sources.stock.active = req.source
elif req.asset_class.value == "futures":
config.sources.futures.active = req.source
# 保存到配置文件
try:
save_config(config)
except Exception as e:
info(f"Failed to save config: {e}")
# 同时尝试保存到数据库(如果支持)
try:
asset_classes = []
if req.asset_class.value == "all":
asset_classes = ["stock", "futures"]
else:
asset_classes = [req.asset_class.value]
for ac in asset_classes:
# MySQL: INSERT ... ON DUPLICATE KEY UPDATE
self.db.execute(
text("""
INSERT INTO data_source_config
(asset_class, active_source, updated_at)
VALUES (:asset_class, :source, NOW())
ON DUPLICATE KEY UPDATE
active_source = VALUES(active_source),
updated_at = VALUES(updated_at)
"""),
{"asset_class": ac, "source": req.source}
)
self.db.commit()
except Exception as e:
info(f"Database update failed (using config file only): {e}")
self.db.rollback()
# 如果需要同步补录,启动后台任务
if req.sync_backfill:
info(f"Starting backfill for {req.asset_class} from {req.start_date}")
# TODO: 启动异步补录任务
def backfill_data(self, req: BackfillRequest) -> str:
"""历史数据补录"""
task_id = str(uuid.uuid4())
info(f"Starting backfill task {task_id} for {req.asset_class}")
# TODO: 将补录任务存入数据库启动后台Worker执行
return task_id
def health_check(self) -> HealthResponse:
"""健康检查"""
try:
# 检查数据库连接
self.db.execute(text("SELECT 1"))
return HealthResponse(
status="healthy",
timestamp=datetime.now()
)
except Exception as e:
return HealthResponse(
status=f"unhealthy: {str(e)}",
timestamp=datetime.now()
)

@ -0,0 +1,332 @@
"""配置管理服务 - 对应Go的internal/service/config.go"""
import platform
import psutil
import threading
from datetime import datetime, timedelta
from typing import Optional, List, Callable, Dict, Any
from app.models import (
ConfigListRequest, ConfigListData, ConfigSection, ConfigItem,
ConfigUpdateRequest, ConfigUpdateData, ConfigType,
ReloadRequest, ReloadData, SystemStatusData, MemoryInfo
)
from app.core.config import get_config, reload_config, save_config, Config
from app.core.logger import info
class ConfigService:
"""配置管理服务"""
def __init__(self):
self.config = get_config()
self.start_time = datetime.now()
self.version = "1.0.0"
self.callbacks: Dict[ConfigType, List[Callable]] = {}
self.lock = threading.RLock()
def get_config_list(self, req: ConfigListRequest) -> ConfigListData:
"""获取配置列表"""
sections = []
# 服务器配置
if not req.type or req.type == ConfigType.SERVER:
sections.append(ConfigSection(
name="服务器配置",
type=ConfigType.SERVER,
description="HTTP服务器相关配置",
items=[
ConfigItem(
key="port",
value=self.config.server.port,
type="int",
description="服务端口",
editable=True,
required=True
),
ConfigItem(
key="mode",
value=self.config.server.mode,
type="string",
description="运行模式: debug/release",
editable=True,
required=True
),
ConfigItem(
key="api_key",
value=self.config.server.api_key,
type="string",
description="API认证密钥",
editable=True,
required=True
),
]
))
# 数据库配置
if not req.type or req.type == ConfigType.DATABASE:
sections.append(ConfigSection(
name="数据库配置",
type=ConfigType.DATABASE,
description="PostgreSQL数据库连接配置",
items=[
ConfigItem(
key="host",
value=self.config.database.host,
type="string",
description="数据库主机地址",
editable=True,
required=True
),
ConfigItem(
key="port",
value=self.config.database.port,
type="int",
description="数据库端口",
editable=True,
required=True
),
ConfigItem(
key="user",
value=self.config.database.user,
type="string",
description="数据库用户名",
editable=True,
required=True
),
ConfigItem(
key="password",
value="********",
type="password",
description="数据库密码",
editable=True,
required=True
),
ConfigItem(
key="database",
value=self.config.database.database,
type="string",
description="数据库名",
editable=True,
required=True
),
]
))
# Redis配置
if not req.type or req.type == ConfigType.REDIS:
sections.append(ConfigSection(
name="Redis配置",
type=ConfigType.REDIS,
description="Redis缓存配置",
items=[
ConfigItem(
key="host",
value=self.config.redis.host,
type="string",
description="Redis主机地址",
editable=True,
required=False
),
ConfigItem(
key="port",
value=self.config.redis.port,
type="int",
description="Redis端口",
editable=True,
required=False
),
ConfigItem(
key="password",
value="********",
type="password",
description="Redis密码",
editable=True,
required=False
),
ConfigItem(
key="db",
value=self.config.redis.db,
type="int",
description="Redis数据库编号",
editable=True,
required=False
),
]
))
# 数据源配置
if not req.type or req.type == ConfigType.SOURCE:
sections.append(ConfigSection(
name="数据源配置",
type=ConfigType.SOURCE,
description="股票和期货数据源配置",
items=[
ConfigItem(
key="stock_active",
value=self.config.sources.stock.active,
type="string",
description="股票数据源适配器",
editable=True,
required=True
),
ConfigItem(
key="futures_active",
value=self.config.sources.futures.active,
type="string",
description="期货数据源适配器",
editable=True,
required=True
),
]
))
return ConfigListData(
sections=sections,
version=self.version,
updated=datetime.now()
)
def update_config(self, req: ConfigUpdateRequest) -> ConfigUpdateData:
"""更新配置"""
need_restart = False
with self.lock:
if req.type == ConfigType.SERVER:
if "port" in req.items:
self.config.server.port = int(req.items["port"])
need_restart = True
if "mode" in req.items:
self.config.server.mode = req.items["mode"]
if "api_key" in req.items:
self.config.server.api_key = req.items["api_key"]
elif req.type == ConfigType.DATABASE:
if "host" in req.items:
self.config.database.host = req.items["host"]
need_restart = True
if "port" in req.items:
self.config.database.port = int(req.items["port"])
need_restart = True
if "user" in req.items:
self.config.database.user = req.items["user"]
need_restart = True
if "password" in req.items:
password = req.items["password"]
if password != "********":
self.config.database.password = password
need_restart = True
if "database" in req.items:
self.config.database.database = req.items["database"]
need_restart = True
elif req.type == ConfigType.SOURCE:
if "stock_active" in req.items:
self.config.sources.stock.active = req.items["stock_active"]
if "futures_active" in req.items:
self.config.sources.futures.active = req.items["futures_active"]
# 保存到文件
try:
save_config(self.config)
self._trigger_callbacks(req.type)
message = "配置更新成功"
if need_restart:
message += ",部分配置需要重启服务后生效"
return ConfigUpdateData(
success=True,
need_restart=need_restart,
message=message
)
except Exception as e:
return ConfigUpdateData(
success=False,
need_restart=False,
message=f"配置保存失败: {e}"
)
def reload_config(self, req: ReloadRequest) -> ReloadData:
"""热加载配置"""
try:
with self.lock:
new_config = reload_config()
# 根据类型选择性更新
if req.config_type is None:
self.config = new_config
else:
if req.config_type == ConfigType.SERVER:
self.config.server = new_config.server
elif req.config_type == ConfigType.DATABASE:
self.config.database = new_config.database
elif req.config_type == ConfigType.REDIS:
self.config.redis = new_config.redis
elif req.config_type == ConfigType.SOURCE:
self.config.sources = new_config.sources
self._trigger_callbacks(req.config_type)
return ReloadData(
success=True,
message="配置热加载成功"
)
except Exception as e:
return ReloadData(
success=False,
message=f"加载配置失败: {e}"
)
def get_system_status(self) -> SystemStatusData:
"""获取系统状态"""
# 获取内存信息
mem = psutil.virtual_memory()
# 计算运行时长
uptime = datetime.now() - self.start_time
uptime_str = self._format_duration(uptime)
return SystemStatusData(
status="running",
version=self.version,
start_time=self.start_time,
uptime=uptime_str,
python_version=platform.python_version(),
memory=MemoryInfo(
alloc=mem.used,
total_alloc=mem.total,
sys=mem.total,
num_gc=0 # Python不需要显式GC计数
),
threads=threading.active_count()
)
def _format_duration(self, d: timedelta) -> str:
"""格式化持续时间"""
days = d.days
hours, remainder = divmod(d.seconds, 3600)
minutes, _ = divmod(remainder, 60)
if days > 0:
return f"{days}{hours}小时{minutes}分钟"
if hours > 0:
return f"{hours}小时{minutes}分钟"
return f"{minutes}分钟"
def register_callback(self, config_type: ConfigType, callback: Callable):
"""注册配置变更回调"""
with self.lock:
if config_type not in self.callbacks:
self.callbacks[config_type] = []
self.callbacks[config_type].append(callback)
def _trigger_callbacks(self, config_type: Optional[ConfigType]):
"""触发回调"""
with self.lock:
# 触发特定类型的回调
if config_type and config_type in self.callbacks:
for cb in self.callbacks[config_type]:
try:
cb()
except Exception as e:
info(f"Callback error: {e}")

@ -0,0 +1,248 @@
"""期货业务服务 - 对应Go的internal/service/futures.go"""
import asyncio
from datetime import datetime, timedelta
from typing import List
from sqlalchemy.orm import Session
from app.models import (
KLineQueryRequest, KLineData, SymbolListRequest, SymbolListData,
BatchKLineRequest, BatchKLineData, BatchKLineResult, KLineSubData,
TradingDatesRequest, TradingDatesData,
FuturesContractsRequest, FuturesContractsData, Frequency, KLineItem
)
from app.repositories import FuturesRepository
from app.services.adapter_service import AdapterService
from app.core.logger import error, info
class FuturesService:
"""期货业务服务"""
def __init__(self, db: Session):
self.repository = FuturesRepository(db)
self.db = db
def query_klines(self, req: KLineQueryRequest) -> KLineData:
"""查询K线数据"""
# 解析日期
try:
start = datetime.strptime(req.start, "%Y%m%d")
end = datetime.strptime(req.end, "%Y%m%d")
end = end + timedelta(days=1) - timedelta(seconds=1)
except ValueError as e:
raise ValueError(f"Invalid date format: {e}")
# 获取K线数据从数据库
items = self.repository.get_klines(req.symbol, req.freq, start, end)
# 如果数据库没有数据,尝试从适配器获取
if not items:
info(f"No data in DB for {req.symbol}, fetching from adapter...")
items = self._fetch_from_adapter(req.symbol, req.start, req.end, req.freq)
# 保存到数据库
if items:
self._save_klines_to_db(req.symbol, req.freq, items)
return KLineData(
symbol=req.symbol,
freq=req.freq,
count=len(items),
items=items
)
def _fetch_from_adapter(self, symbol: str, start: str, end: str, freq: Frequency) -> List[KLineItem]:
"""从适配器获取K线数据"""
try:
# 获取适配器服务
adapter_service = AdapterService()
# 确保适配器已连接
adapter = adapter_service.get_active_adapter("futures")
if not adapter:
# 尝试连接akshare
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(adapter_service._connect_adapter("akshare"))
loop.close()
adapter = adapter_service.get_active_adapter("futures")
if not adapter:
error("No active adapter available")
return []
# 转换频率格式
freq_str = self._convert_freq_to_str(freq)
# 异步获取数据
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
klines = loop.run_until_complete(
adapter.fetch_klines(symbol, start, end, freq_str)
)
loop.close()
# 转换为KLineItem
items = []
for k in klines:
items.append(KLineItem(
time=datetime.fromtimestamp(k.time),
open=k.open,
high=k.high,
low=k.low,
close=k.close,
volume=k.volume,
amount=k.amount,
open_interest=k.open_interest
))
info(f"Fetched {len(items)} klines from adapter for {symbol}")
return items
except Exception as e:
error(f"Failed to fetch from adapter: {e}")
return []
def _convert_freq_to_str(self, freq: Frequency) -> str:
"""转换频率枚举为字符串"""
mapping = {
Frequency.FREQ_1M: "1m",
Frequency.FREQ_5M: "5m",
Frequency.FREQ_15M: "15m",
Frequency.FREQ_30M: "30m",
Frequency.FREQ_60M: "60m",
Frequency.FREQ_1D: "1d",
Frequency.FREQ_1W: "1w",
Frequency.FREQ_1MONTH: "1month",
}
return mapping.get(freq, "1d")
def _save_klines_to_db(self, symbol: str, freq: Frequency, items: List[KLineItem]) -> None:
"""保存K线数据到数据库"""
try:
self.repository.save_klines(freq, symbol, items)
info(f"Saved {len(items)} klines to DB for {symbol}")
except Exception as e:
error(f"Failed to save klines to DB: {e}")
def list_symbols(self, req: SymbolListRequest) -> SymbolListData:
"""查询标的列表"""
if req.page <= 0:
req.page = 1
if req.size <= 0:
req.size = 20
if req.size > 100:
req.size = 100
symbols, total = self.repository.list_symbols(req)
# 如果数据库没有数据,尝试从适配器获取
if not symbols:
info("No symbols in DB, fetching from adapter...")
symbols = self._fetch_symbols_from_adapter()
if symbols:
# 保存到数据库
self._save_symbols_to_db(symbols)
# 重新查询
symbols, total = self.repository.list_symbols(req)
return SymbolListData(
total=total,
page=req.page,
size=req.size,
items=symbols
)
def _fetch_symbols_from_adapter(self) -> List:
"""从适配器获取期货列表"""
try:
adapter_service = AdapterService()
# 确保适配器已连接
adapter = adapter_service.get_active_adapter("futures")
if not adapter:
asyncio.run(adapter_service._connect_adapter("akshare"))
adapter = adapter_service.get_active_adapter("futures")
if not adapter:
error("No active adapter available")
return []
# 异步获取数据
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
symbols_info = loop.run_until_complete(
adapter.fetch_symbols("futures")
)
loop.close()
# 转换为Symbol模型
from app.models import Symbol, SymbolType
symbols = []
for s in symbols_info:
symbols.append(Symbol(
symbol_id=s.symbol_id,
symbol_type=SymbolType.FUTURES,
exchange=s.exchange,
name=s.name,
underlying=s.underlying
))
info(f"Fetched {len(symbols)} symbols from adapter")
return symbols
except Exception as e:
error(f"Failed to fetch symbols from adapter: {e}")
return []
def _save_symbols_to_db(self, symbols: List) -> None:
"""保存期货列表到数据库"""
try:
self.repository.save_symbols(symbols)
info(f"Saved {len(symbols)} symbols to DB")
except Exception as e:
error(f"Failed to save symbols to DB: {e}")
def batch_query_klines(self, req: BatchKLineRequest) -> BatchKLineData:
"""批量查询K线"""
results = []
for symbol in req.symbols:
single_req = KLineQueryRequest(
symbol=symbol,
start=req.start,
end=req.end,
freq=req.freq
)
try:
data = self.query_klines(single_req)
results.append(BatchKLineResult(
symbol=symbol,
success=True,
data=KLineSubData(count=data.count, items=data.items)
))
except Exception as e:
error(f"Batch query failed for {symbol}: {e}")
results.append(BatchKLineResult(
symbol=symbol,
success=False,
error=str(e)
))
return BatchKLineData(results=results)
def get_trading_dates(self, req: TradingDatesRequest) -> TradingDatesData:
"""获取交易日历"""
return self.repository.get_trading_dates(req.start, req.end)
def get_contracts_by_underlying(
self,
req: FuturesContractsRequest
) -> FuturesContractsData:
"""根据品种获取合约"""
return self.repository.get_contracts_by_underlying(
req.underlying,
req.exchange
)

@ -0,0 +1,265 @@
"""股票业务服务 - 对应Go的internal/service/stock.go"""
import asyncio
from datetime import datetime, timedelta
from typing import List
from sqlalchemy.orm import Session
from app.models import (
KLineQueryRequest, KLineData, SymbolListRequest, SymbolListData,
BatchKLineRequest, BatchKLineData, BatchKLineResult, KLineSubData,
TradingDatesRequest, TradingDatesData, AdjustType, Frequency, KLineItem
)
from app.repositories import StockRepository
from app.services.adapter_service import AdapterService
from app.core.logger import error, info
class StockService:
"""股票业务服务"""
def __init__(self, db: Session):
self.repository = StockRepository(db)
self.db = db
def query_klines(self, req: KLineQueryRequest) -> KLineData:
"""查询K线数据"""
# 解析日期
try:
start = datetime.strptime(req.start, "%Y%m%d")
end = datetime.strptime(req.end, "%Y%m%d")
end = end + timedelta(days=1) - timedelta(seconds=1) # 包含结束日期全天
except ValueError as e:
raise ValueError(f"Invalid date format: {e}")
# 获取K线数据从数据库
items = self.repository.get_klines(
req.symbol,
req.freq,
start,
end,
req.adjust
)
# 如果数据库没有数据,尝试从适配器获取
if not items:
info(f"No data in DB for {req.symbol}, fetching from adapter...")
items = self._fetch_from_adapter(req.symbol, req.start, req.end, req.freq)
# 保存到数据库
if items:
self._save_klines_to_db(req.symbol, req.freq, items)
# 处理复权(简化实现,实际需要复权系数表)
if req.adjust != AdjustType.NONE:
items = self._apply_adjust(req.symbol, items, req.adjust)
return KLineData(
symbol=req.symbol,
freq=req.freq,
adjust=req.adjust,
count=len(items),
items=items
)
def _fetch_from_adapter(self, symbol: str, start: str, end: str, freq: Frequency) -> List[KLineItem]:
"""从适配器获取K线数据"""
try:
# 获取适配器服务
adapter_service = AdapterService()
# 确保适配器已连接
adapter = adapter_service.get_active_adapter("stock")
if not adapter:
# 尝试连接akshare
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(adapter_service._connect_adapter("akshare"))
loop.close()
adapter = adapter_service.get_active_adapter("stock")
if not adapter:
error("No active adapter available")
return []
# 转换频率格式
freq_str = self._convert_freq_to_str(freq)
# 异步获取数据
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
klines = loop.run_until_complete(
adapter.fetch_klines(symbol, start, end, freq_str)
)
loop.close()
# 转换为KLineItem
items = []
for k in klines:
items.append(KLineItem(
time=datetime.fromtimestamp(k.time),
open=k.open,
high=k.high,
low=k.low,
close=k.close,
volume=k.volume,
amount=k.amount
))
info(f"Fetched {len(items)} klines from adapter for {symbol}")
return items
except Exception as e:
error(f"Failed to fetch from adapter: {e}")
return []
def _convert_freq_to_str(self, freq: Frequency) -> str:
"""转换频率枚举为字符串"""
mapping = {
Frequency.FREQ_1M: "1m",
Frequency.FREQ_5M: "5m",
Frequency.FREQ_15M: "15m",
Frequency.FREQ_30M: "30m",
Frequency.FREQ_60M: "60m",
Frequency.FREQ_1D: "1d",
Frequency.FREQ_1W: "1w",
Frequency.FREQ_1MONTH: "1month",
}
return mapping.get(freq, "1d")
def _save_klines_to_db(self, symbol: str, freq: Frequency, items: List[KLineItem]) -> None:
"""保存K线数据到数据库"""
try:
# 添加symbol属性
for item in items:
item.symbol = symbol
self.repository.save_klines(freq, items)
info(f"Saved {len(items)} klines to DB for {symbol}")
except Exception as e:
error(f"Failed to save klines to DB: {e}")
def _apply_adjust(
self,
symbol: str,
items: List,
adjust_type: AdjustType
) -> List:
"""应用复权计算TODO: 实现复权逻辑)"""
# 复权计算需要从数据库获取复权系数
# 这里简化处理,直接返回原始数据
return items
def list_symbols(self, req: SymbolListRequest) -> SymbolListData:
"""查询标的列表"""
# 设置默认值
if req.page <= 0:
req.page = 1
if req.size <= 0:
req.size = 20
if req.size > 100:
req.size = 100
symbols, total = self.repository.list_symbols(req)
# 如果数据库没有数据,尝试从适配器获取
if not symbols:
info("No symbols in DB, fetching from adapter...")
symbols = self._fetch_symbols_from_adapter()
if symbols:
# 保存到数据库
self._save_symbols_to_db(symbols)
# 重新查询
symbols, total = self.repository.list_symbols(req)
return SymbolListData(
total=total,
page=req.page,
size=req.size,
items=symbols
)
def _fetch_symbols_from_adapter(self) -> List:
"""从适配器获取股票列表"""
try:
adapter_service = AdapterService()
# 确保适配器已连接
adapter = adapter_service.get_active_adapter("stock")
if not adapter:
asyncio.run(adapter_service._connect_adapter("akshare"))
adapter = adapter_service.get_active_adapter("stock")
if not adapter:
error("No active adapter available")
return []
# 异步获取数据
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
symbols_info = loop.run_until_complete(
adapter.fetch_symbols("stock")
)
loop.close()
# 转换为Symbol模型
from app.models import Symbol, SymbolType
symbols = []
for s in symbols_info:
symbols.append(Symbol(
symbol_id=s.symbol_id,
symbol_type=SymbolType.STOCK,
exchange=s.exchange,
name=s.name,
underlying=s.underlying
))
info(f"Fetched {len(symbols)} symbols from adapter")
return symbols
except Exception as e:
error(f"Failed to fetch symbols from adapter: {e}")
return []
def _save_symbols_to_db(self, symbols: List) -> None:
"""保存股票列表到数据库"""
try:
from app.models import TradeCalData
self.repository.save_symbols(symbols)
info(f"Saved {len(symbols)} symbols to DB")
except Exception as e:
error(f"Failed to save symbols to DB: {e}")
def batch_query_klines(self, req: BatchKLineRequest) -> BatchKLineData:
"""批量查询K线"""
results = []
for symbol in req.symbols:
single_req = KLineQueryRequest(
symbol=symbol,
start=req.start,
end=req.end,
freq=req.freq,
adjust=req.adjust
)
try:
data = self.query_klines(single_req)
results.append(BatchKLineResult(
symbol=symbol,
success=True,
data=KLineSubData(count=data.count, items=data.items)
))
except Exception as e:
error(f"Batch query failed for {symbol}: {e}")
results.append(BatchKLineResult(
symbol=symbol,
success=False,
error=str(e)
))
return BatchKLineData(results=results)
def get_trading_dates(self, req: TradingDatesRequest) -> TradingDatesData:
"""获取交易日历"""
return self.repository.get_trading_dates(req.start, req.end)

@ -0,0 +1,534 @@
"""测试服务 - 对应Go的internal/service/test.go"""
import asyncio
import json
from datetime import datetime, timedelta
from typing import List, Optional
from threading import RLock
import httpx
import websockets
from app.models import (
APITestListData, APITestCategory, APITestCase,
APITestRequest, APITestResult,
WSTestListData, WSTestCase, WSTestRequest, WSTestResult, WSMessage,
TestHistoryRequest, TestHistoryData
)
from app.core.logger import info, error
class TestService:
"""测试服务"""
def __init__(self):
self.lock = RLock()
self.api_history: List[APITestResult] = []
self.ws_history: List[WSTestResult] = []
self.history_size = 100
def get_api_test_list(self) -> APITestListData:
"""获取API测试列表"""
# 固定交易时间2026年3月2日到2026年3月6日
test_start = datetime(2026, 3, 2)
test_end = datetime(2026, 3, 6)
categories = [
APITestCategory(
name="股票接口",
items=[
APITestCase(
id="stock_klines",
name="查询股票K线",
method="GET",
path="/v1/stock/klines/{symbol}",
description="查询指定股票的K线数据",
params={
"symbol": "000001.SZ",
"start": test_start.strftime("%Y%m%d"),
"end": test_end.strftime("%Y%m%d"),
"freq": "1d",
"adjust": "qfq"
}
),
APITestCase(
id="stock_symbols",
name="查询股票列表",
method="GET",
path="/v1/stock/symbols",
description="获取所有可用股票标的",
params={"page": "1", "size": "20"}
),
APITestCase(
id="stock_batch",
name="批量查询股票K线",
method="POST",
path="/v1/stock/klines/batch",
description="批量查询多只股票K线",
body={
"symbols": ["000001.SZ", "000002.SZ"],
"start": test_start.strftime("%Y%m%d"),
"end": test_end.strftime("%Y%m%d"),
"freq": "1d"
}
),
APITestCase(
id="stock_calendar",
name="查询交易日历",
method="GET",
path="/v1/stock/trading-dates",
description="查询股票交易日历",
params={
"start": test_start.strftime("%Y%m%d"),
"end": test_end.strftime("%Y%m%d")
}
),
]
),
APITestCategory(
name="期货接口",
items=[
APITestCase(
id="futures_klines",
name="查询期货K线",
method="GET",
path="/v1/futures/klines/{symbol}",
description="查询指定期货合约的K线数据",
params={
"symbol": "CU2504.SHFE",
"start": test_start.strftime("%Y%m%d"),
"end": test_end.strftime("%Y%m%d"),
"freq": "1d"
}
),
APITestCase(
id="futures_symbols",
name="查询期货列表",
method="GET",
path="/v1/futures/symbols",
description="获取所有可用期货标的",
params={"page": "1", "size": "20"}
),
APITestCase(
id="futures_batch",
name="批量查询期货K线",
method="POST",
path="/v1/futures/klines/batch",
description="批量查询多个期货合约K线",
body={
"symbols": ["CU2504.SHFE", "RB2505.SHFE"],
"start": test_start.strftime("%Y%m%d"),
"end": test_end.strftime("%Y%m%d"),
"freq": "1d"
}
),
APITestCase(
id="futures_contracts",
name="查询合约列表",
method="GET",
path="/v1/futures/contracts",
description="根据品种查询可交易合约",
params={"underlying": "CU", "exchange": "SHFE"}
),
APITestCase(
id="futures_calendar",
name="查询期货交易日历",
method="GET",
path="/v1/futures/trading-dates",
description="查询期货交易日历",
params={
"start": test_start.strftime("%Y%m%d"),
"end": test_end.strftime("%Y%m%d")
}
),
]
),
APITestCategory(
name="管理接口",
items=[
APITestCase(
id="admin_health",
name="健康检查",
method="GET",
path="/v1/admin/health",
description="检查服务健康状态",
params={}
),
APITestCase(
id="admin_source_status",
name="数据源状态",
method="GET",
path="/v1/admin/source/status",
description="获取当前数据源状态",
params={}
),
APITestCase(
id="admin_source_switch",
name="切换数据源",
method="POST",
path="/v1/admin/source/switch",
description="切换到指定数据源akshare",
body={
"asset_class": "all",
"source": "akshare",
"sync_backfill": False
}
),
APITestCase(
id="admin_system_status",
name="系统状态",
method="GET",
path="/v1/admin/system/status",
description="获取系统运行状态和资源使用情况",
params={}
),
APITestCase(
id="admin_config_list",
name="查询配置列表",
method="GET",
path="/v1/admin/config",
description="获取所有配置项列表",
params={}
),
APITestCase(
id="admin_config_update",
name="更新配置",
method="PUT",
path="/v1/admin/config",
description="更新系统配置",
body={
"key": "server.mode",
"value": "debug",
"description": "服务器运行模式"
}
),
APITestCase(
id="admin_reload_config",
name="热加载配置",
method="POST",
path="/v1/admin/system/reload",
description="重新加载配置文件",
body={}
),
]
),
APITestCategory(
name="适配器管理",
items=[
APITestCase(
id="admin_adapters_list",
name="适配器列表",
method="GET",
path="/v1/admin/adapters",
description="获取所有数据源适配器列表",
params={}
),
APITestCase(
id="admin_adapter_toggle",
name="切换适配器状态",
method="POST",
path="/v1/admin/adapters/toggle",
description="启用或禁用适配器",
body={
"name": "akshare",
"enable": True
}
),
APITestCase(
id="admin_adapter_config",
name="更新适配器配置",
method="PUT",
path="/v1/admin/adapters/config",
description="更新适配器配置参数",
body={
"name": "akshare",
"config": {
"timeout": "60"
}
}
),
]
),
APITestCategory(
name="测试管理",
items=[
APITestCase(
id="admin_test_history",
name="测试历史",
method="GET",
path="/v1/admin/tests/history",
description="获取测试执行历史记录",
params={"type": "api", "limit": "20"}
),
]
),
]
return APITestListData(categories=categories, base_url="")
async def run_api_test(self, base_url: str, req: APITestRequest) -> APITestResult:
"""执行API测试"""
# 获取测试用例
test_list = self.get_api_test_list()
test_case = None
for cat in test_list.categories:
for item in cat.items:
if item.id == req.id:
test_case = item
break
if test_case:
break
if not test_case:
raise ValueError(f"Test case not found: {req.id}")
# 合并参数
params = dict(test_case.params)
if req.params:
params.update(req.params)
# 构建URL
url = base_url + test_case.path
for k, v in params.items():
url = url.replace(f"{{{k}}}", str(v))
# 添加查询参数
if test_case.method == "GET" and params:
query_parts = []
for k, v in params.items():
if f"{{{k}}}" not in test_case.path:
query_parts.append(f"{k}={v}")
if query_parts:
url += "?" + "&".join(query_parts)
# 准备请求体
body = req.body if req.body is not None else test_case.body
# 执行请求
start_time = datetime.now()
async with httpx.AsyncClient() as client:
try:
headers = {"X-API-Key": "test-api-key"}
if test_case.method == "GET":
response = await client.get(url, headers=headers, timeout=30)
elif test_case.method == "POST":
response = await client.post(
url, json=body, headers=headers, timeout=30
)
else:
raise ValueError(f"Unsupported method: {test_case.method}")
latency = int((datetime.now() - start_time).total_seconds() * 1000)
result = APITestResult(
id=int(datetime.now().timestamp()),
case_id=req.id,
name=test_case.name,
success=200 <= response.status_code < 300,
status_code=response.status_code,
latency=latency,
request={
"method": test_case.method,
"url": url,
"body": body
},
response=response.json() if response.headers.get("content-type", "").startswith("application/json") else response.text,
timestamp=datetime.now()
)
self._add_api_history(result)
return result
except Exception as e:
latency = int((datetime.now() - start_time).total_seconds() * 1000)
result = APITestResult(
id=int(datetime.now().timestamp()),
case_id=req.id,
name=test_case.name,
success=False,
latency=latency,
request={
"method": test_case.method,
"url": url,
"body": body
},
error=str(e),
timestamp=datetime.now()
)
self._add_api_history(result)
return result
def get_ws_test_list(self) -> WSTestListData:
"""获取WebSocket测试列表"""
cases = [
WSTestCase(
id="ws_subscribe_stock",
name="订阅股票行情",
description="订阅单只股票实时行情",
action="subscribe",
symbols=["000001.SZ"]
),
WSTestCase(
id="ws_subscribe_futures",
name="订阅期货行情",
description="订阅单个期货合约实时行情",
action="subscribe",
symbols=["CU2504.SHFE"]
),
WSTestCase(
id="ws_subscribe_multi",
name="批量订阅",
description="同时订阅多个标的",
action="subscribe",
symbols=["000001.SZ", "000002.SZ", "CU2504.SHFE"]
),
WSTestCase(
id="ws_subscribe_many",
name="压力测试-大量订阅",
description="订阅大量标的测试性能",
action="subscribe",
symbols=[
"000001.SZ", "000002.SZ", "000063.SZ", "000333.SZ",
"000538.SZ", "000568.SZ", "000651.SZ", "000725.SZ",
"000768.SZ", "000858.SZ"
]
),
WSTestCase(
id="ws_unsubscribe",
name="取消订阅",
description="取消订阅标的",
action="unsubscribe",
symbols=["000001.SZ"]
),
WSTestCase(
id="ws_unsubscribe_all",
name="取消全部订阅",
description="取消所有已订阅标的",
action="unsubscribe",
symbols=["000001.SZ", "000002.SZ", "CU2504.SHFE"]
),
WSTestCase(
id="ws_heartbeat",
name="心跳检测",
description="测试WebSocket连接心跳",
action="subscribe",
symbols=["000001.SZ"]
),
WSTestCase(
id="ws_invalid_symbol",
name="无效标的测试",
description="测试订阅无效标的的错误处理",
action="subscribe",
symbols=["INVALID.CODE"]
),
WSTestCase(
id="ws_empty_symbols",
name="空订阅测试",
description="测试空标的列表的处理",
action="subscribe",
symbols=[]
),
WSTestCase(
id="ws_resubscribe",
name="重新订阅",
description="取消后重新订阅同一标的",
action="subscribe",
symbols=["000001.SZ"]
),
]
return WSTestListData(cases=cases, ws_url="")
async def run_ws_test(self, ws_url: str, req: WSTestRequest) -> WSTestResult:
"""执行WebSocket测试"""
# 获取测试用例
test_list = self.get_ws_test_list()
test_case = None
for item in test_list.cases:
if item.id == req.id:
test_case = item
break
if not test_case:
raise ValueError(f"Test case not found: {req.id}")
# 使用自定义标的
symbols = req.symbols if req.symbols else test_case.symbols
result = WSTestResult(
id=f"ws_{int(datetime.now().timestamp())}",
case_id=req.id,
timestamp=datetime.now(),
messages=[]
)
# 连接WebSocket
start_time = datetime.now()
try:
async with websockets.connect(
ws_url,
extra_headers={"X-API-Key": "test-api-key"}
) as ws:
result.latency = int((datetime.now() - start_time).total_seconds() * 1000)
result.success = True
# 发送订阅消息
msg = {
"action": test_case.action,
"symbols": symbols
}
await ws.send(json.dumps(msg))
# 等待响应最多3条消息
for _ in range(3):
try:
msg_data = await asyncio.wait_for(ws.recv(), timeout=5)
result.messages.append(WSMessage(
type="received",
data=json.loads(msg_data),
timestamp=datetime.now()
))
except asyncio.TimeoutError:
break
except Exception as e:
result.latency = int((datetime.now() - start_time).total_seconds() * 1000)
result.success = False
result.error = str(e)
self._add_ws_history(result)
return result
def get_test_history(self, req: TestHistoryRequest) -> TestHistoryData:
"""获取测试历史"""
with self.lock:
limit = req.limit or 20
api_tests = []
ws_tests = []
if not req.type or req.type == "api":
api_tests = self.api_history[-limit:]
if not req.type or req.type == "ws":
ws_tests = self.ws_history[-limit:]
return TestHistoryData(api_tests=api_tests, ws_tests=ws_tests)
def _add_api_history(self, result: APITestResult):
"""添加API测试历史"""
with self.lock:
self.api_history.append(result)
if len(self.api_history) > self.history_size:
self.api_history = self.api_history[-self.history_size:]
def _add_ws_history(self, result: WSTestResult):
"""添加WebSocket测试历史"""
with self.lock:
self.ws_history.append(result)
if len(self.ws_history) > self.history_size:
self.ws_history = self.ws_history[-self.history_size:]

@ -0,0 +1,4 @@
"""WebSocket服务模块"""
from .server import WebSocketServer, ws_manager
__all__ = ["WebSocketServer", "ws_manager"]

@ -0,0 +1,210 @@
"""WebSocket服务 - 对应Go的internal/websocket/server.go"""
import asyncio
import json
from datetime import datetime
from typing import Dict, Set, Optional
from dataclasses import dataclass, field
from fastapi import WebSocket, WebSocketDisconnect
from app.core.logger import info, error
@dataclass
class WSClient:
"""WebSocket客户端"""
id: str
websocket: WebSocket
subscriptions: Set[str] = field(default_factory=set)
async def send(self, message: dict):
"""发送消息"""
try:
await self.websocket.send_json(message)
except Exception as e:
error(f"Failed to send message to client {self.id}: {e}")
class WebSocketManager:
"""WebSocket连接管理器"""
def __init__(self):
self.clients: Dict[str, WSClient] = {}
self.subscriptions: Dict[str, Set[str]] = {} # symbol -> set of client_ids
self.max_symbols_per_client = 100
self.lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, client_id: str) -> WSClient:
"""建立连接"""
await websocket.accept()
client = WSClient(id=client_id, websocket=websocket)
async with self.lock:
self.clients[client_id] = client
info(f"WebSocket client connected: {client_id}, total: {len(self.clients)}")
return client
async def disconnect(self, client_id: str):
"""断开连接"""
async with self.lock:
if client_id in self.clients:
client = self.clients.pop(client_id)
# 清理订阅
for symbol in client.subscriptions:
if symbol in self.subscriptions:
self.subscriptions[symbol].discard(client_id)
if not self.subscriptions[symbol]:
del self.subscriptions[symbol]
info(f"WebSocket client disconnected: {client_id}, total: {len(self.clients)}")
async def subscribe(self, client_id: str, symbols: list) -> bool:
"""订阅标的"""
async with self.lock:
if client_id not in self.clients:
return False
client = self.clients[client_id]
# 检查订阅数量限制
if len(client.subscriptions) + len(symbols) > self.max_symbols_per_client:
return False
for symbol in symbols:
client.subscriptions.add(symbol)
if symbol not in self.subscriptions:
self.subscriptions[symbol] = set()
self.subscriptions[symbol].add(client_id)
return True
async def unsubscribe(self, client_id: str, symbols: list):
"""取消订阅"""
async with self.lock:
if client_id not in self.clients:
return
client = self.clients[client_id]
for symbol in symbols:
client.subscriptions.discard(symbol)
if symbol in self.subscriptions:
self.subscriptions[symbol].discard(client_id)
if not self.subscriptions[symbol]:
del self.subscriptions[symbol]
async def broadcast_to_symbol(self, symbol: str, message: dict):
"""向订阅了某标的的所有客户端广播"""
client_ids = set()
async with self.lock:
if symbol in self.subscriptions:
client_ids = self.subscriptions[symbol].copy()
# 在锁外发送消息
for client_id in client_ids:
if client_id in self.clients:
try:
await self.clients[client_id].send(message)
except Exception as e:
error(f"Failed to broadcast to {client_id}: {e}")
def get_stats(self) -> dict:
"""获取统计信息"""
return {
"total_clients": len(self.clients),
"total_subscriptions": len(self.subscriptions)
}
# 全局WebSocket管理器实例
ws_manager = WebSocketManager()
class WebSocketServer:
"""WebSocket服务器"""
def __init__(self):
self.manager = ws_manager
async def handle(self, websocket: WebSocket, client_id: str):
"""处理WebSocket连接"""
client = await self.manager.connect(websocket, client_id)
try:
while True:
# 接收消息
data = await websocket.receive_text()
try:
msg = json.loads(data)
action = msg.get("action")
symbols = msg.get("symbols", [])
if action == "subscribe":
success = await self.manager.subscribe(client_id, symbols)
if success:
await client.send({
"type": "ack",
"action": "subscribe",
"symbols": symbols,
"ts": datetime.now().isoformat()
})
else:
await client.send({
"type": "error",
"code": 1003,
"message": "Too many subscriptions or subscription failed",
"ts": datetime.now().isoformat()
})
elif action == "unsubscribe":
await self.manager.unsubscribe(client_id, symbols)
await client.send({
"type": "ack",
"action": "unsubscribe",
"symbols": symbols,
"ts": datetime.now().isoformat()
})
else:
await client.send({
"type": "error",
"code": 1001,
"message": "Unknown action",
"ts": datetime.now().isoformat()
})
except json.JSONDecodeError:
await client.send({
"type": "error",
"code": 1000,
"message": "Invalid message format",
"ts": datetime.now().isoformat()
})
except WebSocketDisconnect:
await self.manager.disconnect(client_id)
except Exception as e:
error(f"WebSocket error for client {client_id}: {e}")
await self.manager.disconnect(client_id)
async def send_heartbeat(self):
"""发送心跳(可由定时任务调用)"""
message = {
"type": "heartbeat",
"ts": datetime.now().isoformat()
}
# 向所有客户端发送心跳
clients_copy = list(self.manager.clients.values())
for client in clients_copy:
try:
await client.send(message)
except Exception:
pass

@ -0,0 +1,80 @@
#!/usr/bin/env python
import urllib.request
import json
# 获取 API Key
with open('config.json') as f:
cfg = json.load(f)
api_key = cfg['server']['api_key']
print("=" * 50)
print("Testing API Test List")
print("=" * 50)
# 测试获取 API 测试列表
try:
req = urllib.request.Request(
'http://localhost:8080/v1/admin/tests/api',
headers={'X-Admin-Token': api_key}
)
response = urllib.request.urlopen(req)
data = json.loads(response.read().decode())
print(f"✓ API Test List: {len(data['data']['categories'])} categories")
for cat in data['data']['categories']:
print(f" - {cat['name']}: {len(cat['items'])} items")
for item in cat['items'][:2]:
print(f" - [{item['method']}] {item['name']}")
if len(cat['items']) > 2:
print(f" ... and {len(cat['items'])-2} more")
except Exception as e:
print(f"✗ Error: {e}")
print("\n" + "=" * 50)
print("Testing WebSocket Test List")
print("=" * 50)
# 测试获取 WebSocket 测试列表
try:
req = urllib.request.Request(
'http://localhost:8080/v1/admin/tests/ws',
headers={'X-Admin-Token': api_key}
)
response = urllib.request.urlopen(req)
data = json.loads(response.read().decode())
print(f"✓ WS Test List: {len(data['data']['cases'])} cases")
for case in data['data']['cases'][:5]:
print(f" - {case['name']}: {case['action']} {case.get('symbols', [])}")
except Exception as e:
print(f"✗ Error: {e}")
print("\n" + "=" * 50)
print("Testing Run API Test (health check)")
print("=" * 50)
# 测试执行单个 API 测试
try:
req = urllib.request.Request(
'http://localhost:8080/v1/admin/tests/api/run',
data=json.dumps({'id': 'admin_health'}).encode('utf-8'),
headers={
'Content-Type': 'application/json',
'X-Admin-Token': api_key
},
method='POST'
)
response = urllib.request.urlopen(req)
data = json.loads(response.read().decode())
if data['code'] == 0:
result = data['data']
print(f"✓ Test Result: {'PASS' if result['success'] else 'FAIL'}")
print(f" - Latency: {result['latency']}ms")
print(f" - Status: {result['status_code']}")
print(f" - URL: {result['request']['url']}")
else:
print(f"✗ Error: {data['message']}")
except Exception as e:
print(f"✗ Error: {e}")
print("\n" + "=" * 50)
print("All tests completed!")
print("=" * 50)

@ -0,0 +1,44 @@
{
"server": {
"port": 8080,
"mode": "debug",
"api_key": "demo-api-key-2024"
},
"database": {
"host": "localhost",
"port": 3306,
"user": "root",
"password": "1qazse42W3",
"database": "marketdata"
},
"redis": {
"host": "localhost",
"port": 6379,
"password": "",
"db": 0
},
"sources": {
"stock": {
"active": "akshare",
"list": {
"akshare": {
"type": "http",
"config": {
"timeout": "30"
}
}
}
},
"futures": {
"active": "akshare",
"list": {
"akshare": {
"type": "http",
"config": {
"timeout": "30"
}
}
}
}
}
}

@ -0,0 +1,24 @@
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: market_data_mysql
environment:
MYSQL_ROOT_PASSWORD: postgres123
MYSQL_DATABASE: marketdata
MYSQL_USER: postgres
MYSQL_PASSWORD: postgres123
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
command: --default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 5
volumes:
mysql_data:

@ -0,0 +1,47 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: market_data_postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres123
POSTGRES_DB: marketdata
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: market_data_redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
market-data-service:
build: .
container_name: market_data_service
environment:
DATABASE_URL: "postgresql://postgres:postgres123@postgres:5432/marketdata"
REDIS_URL: "redis://redis:6379/0"
PORT: "8080"
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
command: python -m app.main
volumes:
postgres_data:
redis_data:

@ -0,0 +1,39 @@
"""初始化 MySQL 数据库"""
import sys
import pymysql
# 先创建数据库(如果不存在)
conn = pymysql.connect(
host='localhost',
port=3306,
user='root',
password='1qazse42W3',
charset='utf8mb4'
)
try:
with conn.cursor() as cursor:
cursor.execute("CREATE DATABASE IF NOT EXISTS marketdata CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
print("[OK] 数据库 'marketdata' 创建成功或已存在")
conn.commit()
finally:
conn.close()
# 使用 SQLAlchemy 创建表
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.repositories.database import init_db, engine
from sqlalchemy import inspect
print("\n正在创建数据表...")
init_db()
# 验证表是否创建成功
inspector = inspect(engine)
tables = inspector.get_table_names()
print(f"\n[OK] 已创建 {len(tables)} 个表:")
for table in sorted(tables):
print(f" - {table}")
print("\n[OK] MySQL 数据库初始化完成!")

Binary file not shown.

@ -0,0 +1,44 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "market-data-service"
version = "1.0.0"
description = "统一行情数据服务 - Python实现"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"python-socketio>=5.12.1",
"websockets>=14.1",
"sqlalchemy>=2.0.36",
"psycopg2-binary>=2.9.10",
"pandas>=2.2.3",
"numpy>=2.1.3",
"pydantic>=2.10.0",
"pydantic-settings>=2.6.1",
"python-dotenv>=1.0.1",
"PyYAML>=6.0.2",
"httpx>=0.28.0",
"apscheduler>=3.11.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.4",
"pytest-asyncio>=0.24.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]

@ -0,0 +1,37 @@
# Web Framework
fastapi==0.115.0
uvicorn[standard]==0.32.0
python-socketio==5.12.1
websockets==14.1
# Database
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
alembic==1.14.0
# Data Processing
pandas==2.2.3
numpy==2.1.3
# Data Source
akshare>=1.12.0
# Configuration
pydantic==2.10.0
pydantic-settings==2.6.1
python-dotenv==1.0.1
PyYAML==6.0.2
# Utilities
python-multipart==0.0.19
httpx==0.28.0
aiohttp==3.11.10
aioredis==2.0.1
# Monitoring
apscheduler==3.11.0
# Testing
pytest==8.3.4
pytest-asyncio==0.24.0
httpx==0.28.0

@ -0,0 +1,230 @@
"""数据同步工具 - 对应Go的cmd/sync/main.go"""
import asyncio
import os
import sys
from datetime import datetime, timedelta
from argparse import ArgumentParser
# 添加项目根目录到路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.adapters import AKShareAdapter
from app.repositories import SessionLocal
from app.repositories.stock_repository import StockRepository
from app.repositories.futures_repository import FuturesRepository
from app.models import Symbol, SymbolType, TradeCalData
from app.core.logger import info, error
def parse_date(date_str: str) -> datetime:
"""解析日期字符串"""
return datetime.strptime(date_str, "%Y%m%d")
def format_date(date: datetime) -> str:
"""格式化日期为字符串"""
return date.strftime("%Y%m%d")
def is_stock(symbol: str) -> bool:
"""判断是否为股票代码"""
return symbol.endswith(".SH") or symbol.endswith(".SZ") or symbol.endswith(".BJ")
async def sync_stocks(adapter: AKShareAdapter, db):
"""同步股票基础信息"""
info("Syncing stock basic info...")
try:
symbols_data = await adapter.fetch_symbols("stock")
repo = StockRepository(db)
symbols = []
for d in symbols_data:
list_date = None
if d.list_date:
try:
list_date = datetime.strptime(d.list_date, "%Y%m%d")
except:
pass
symbols.append(Symbol(
symbol_id=d.symbol_id,
symbol_type=SymbolType.STOCK,
exchange=d.exchange,
name=d.name,
list_date=list_date,
status="active"
))
repo.save_symbols(symbols)
info(f"Synced {len(symbols)} stocks")
except Exception as e:
error(f"Failed to sync stocks: {e}")
raise
async def sync_futures(adapter: AKShareAdapter, db):
"""同步期货基础信息"""
info("Syncing futures basic info...")
try:
symbols_data = await adapter.fetch_symbols("futures")
repo = FuturesRepository(db)
symbols = []
for d in symbols_data:
list_date = None
delist_date = None
if d.list_date:
try:
list_date = datetime.strptime(d.list_date, "%Y%m%d")
except:
pass
if d.delist_date:
try:
delist_date = datetime.strptime(d.delist_date, "%Y%m%d")
except:
pass
status = "active"
if delist_date and datetime.now() > delist_date:
status = "expired"
symbols.append(Symbol(
symbol_id=d.symbol_id,
symbol_type=SymbolType.FUTURES,
exchange=d.exchange,
name=d.name,
underlying=d.underlying,
contract_month=d.contract_month,
list_date=list_date,
delist_date=delist_date,
status=status
))
repo.save_symbols(symbols)
info(f"Synced {len(symbols)} futures")
except Exception as e:
error(f"Failed to sync futures: {e}")
raise
async def sync_calendar(adapter: AKShareAdapter, db, start: str, end: str):
"""同步交易日历"""
info(f"Syncing trading calendar from {start} to {end}...")
try:
# 同步股票交易日历(上交所)
stock_data = await adapter.fetch_trading_calendar("SH", start, end)
stock_repo = StockRepository(db)
stock_dates = [
TradeCalData(date=d.date, is_trading_day=d.is_trading_day)
for d in stock_data
]
stock_repo.save_trading_calendar(stock_dates)
# 同步期货交易日历
futures_repo = FuturesRepository(db)
futures_repo.save_trading_calendar(stock_dates)
info(f"Synced {len(stock_dates)} calendar days")
except Exception as e:
error(f"Failed to sync calendar: {e}")
raise
async def sync_klines(adapter: AKShareAdapter, db, symbol: str, start: str, end: str, freq: str):
"""同步K线数据"""
info(f"Syncing {freq} klines for {symbol} from {start} to {end}...")
try:
# 获取K线数据
klines_data = await adapter.fetch_klines(symbol, start, end, freq)
# 转换为KLineItem并保存
from app.models import KLineItem
items = [
KLineItem(
time=datetime.fromtimestamp(d.time),
open=d.open,
high=d.high,
low=d.low,
close=d.close,
volume=d.volume,
amount=d.amount,
open_interest=d.open_interest if d.open_interest > 0 else None
)
for d in klines_data
]
# 判断股票还是期货并保存
from app.models import Frequency
if is_stock(symbol):
repo = StockRepository(db)
# 为每个item设置symbol
for item in items:
item.symbol = symbol
repo.save_klines(Frequency(freq), items)
else:
repo = FuturesRepository(db)
repo.save_klines(Frequency(freq), symbol, items)
info(f"Synced {len(items)} klines")
except Exception as e:
error(f"Failed to sync klines: {e}")
raise
async def main():
"""主函数"""
parser = ArgumentParser(description="Market Data Sync Tool")
parser.add_argument(
"--type", "-t",
required=True,
choices=["stocks", "futures", "calendar", "klines"],
help="同步类型"
)
parser.add_argument("--start", "-s", help="开始日期 YYYYMMDD")
parser.add_argument("--end", "-e", help="结束日期 YYYYMMDD")
parser.add_argument("--symbol", help="标的代码klines类型需要")
parser.add_argument("--freq", "-f", default="1d", help="K线周期")
args = parser.parse_args()
# 初始化适配器AKShare 无需 token
adapter = AKShareAdapter()
await adapter.connect({"timeout": 30})
# 创建数据库会话
db = SessionLocal()
try:
if args.type == "stocks":
await sync_stocks(adapter, db)
elif args.type == "futures":
await sync_futures(adapter, db)
elif args.type == "calendar":
# 设置默认日期范围
start = args.start or (datetime.now() - timedelta(days=30)).strftime("%Y%m%d")
end = args.end or (datetime.now() + timedelta(days=180)).strftime("%Y%m%d")
await sync_calendar(adapter, db, start, end)
elif args.type == "klines":
if not args.symbol:
error("symbol is required for klines sync")
sys.exit(1)
start = args.start or (datetime.now() - timedelta(days=7)).strftime("%Y%m%d")
end = args.end or datetime.now().strftime("%Y%m%d")
await sync_klines(adapter, db, args.symbol, start, end, args.freq)
finally:
db.close()
await adapter.close()
if __name__ == "__main__":
asyncio.run(main())

@ -0,0 +1,78 @@
@echo off
chcp 65001 >nul
echo ==========================================
echo 行情数据服务 - 环境初始化
echo ==========================================
echo.
REM 检查 Docker
where docker >nul 2>&1
if errorlevel 1 (
echo [错误] Docker 未安装!请先安装 Docker Desktop
echo 下载地址: https://www.docker.com/products/docker-desktop
exit /b 1
)
echo [1/4] 正在启动 PostgreSQL 容器...
docker ps | findstr market_data_postgres >nul
if %errorlevel% == 0 (
echo [提示] PostgreSQL 容器已在运行
) else (
docker start market_data_postgres >nul 2>&1
if errorlevel 1 (
echo 正在创建新容器...
docker run -d --name market_data_postgres ^
-e POSTGRES_USER=postgres ^
-e POSTGRES_PASSWORD=postgres123 ^
-e POSTGRES_DB=marketdata ^
-p 5432:5432 ^
-v postgres_data:/var/lib/postgresql/data ^
--restart unless-stopped ^
postgres:15-alpine >nul
) else (
echo [提示] 已启动现有容器
)
)
echo.
echo [2/4] 等待数据库就绪...
set /a count=0
:wait_loop
timeout /t 1 /nobreak >nul
set /a count+=1
docker exec market_data_postgres pg_isready -U postgres >nul 2>&1
if errorlevel 1 (
if %count% lss 30 (
echo 等待中... (%count%/30)
goto wait_loop
) else (
echo [错误] 数据库启动超时!
exit /b 1
)
)
echo 数据库已就绪!
echo.
echo [3/4] 正在初始化数据库表...
python test_db.py
if errorlevel 1 (
echo [错误] 数据库初始化失败!
exit /b 1
)
echo.
echo [4/4] 正在安装依赖...
pip install -q -r requirements.txt
echo.
echo ==========================================
echo 初始化完成!
echo ==========================================
echo.
echo 现在可以启动服务了:
echo python -m app.main
echo.
echo 访问地址:
echo http://localhost:8080
echo.
pause

@ -0,0 +1,54 @@
@echo off
chcp 65001 >nul
echo ==========================================
echo 启动 PostgreSQL 数据库 (Docker)
echo ==========================================
echo.
REM 检查 Docker 是否安装
docker --version >nul 2>&1
if errorlevel 1 (
echo [错误] Docker 未安装,请先安装 Docker Desktop
exit /b 1
)
REM 启动 PostgreSQL
echo 正在启动 PostgreSQL 容器...
docker run -d \
--name market_data_postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres123 \
-e POSTGRES_DB=marketdata \
-p 5432:5432 \
-v postgres_data:/var/lib/postgresql/data \
--restart unless-stopped \
postgres:15-alpine
if errorlevel 1 (
echo [提示] 容器可能已存在,尝试启动现有容器...
docker start market_data_postgres
)
echo.
echo [提示] 等待数据库初始化...
timeout /t 3 /nobreak >nul
echo.
echo ==========================================
echo 数据库启动成功!
echo ==========================================
echo.
echo 连接信息:
echo - 主机: localhost
echo - 端口: 5432
echo - 数据库: marketdata
echo - 用户名: postgres
echo - 密码: postgres123
echo.
echo 常用命令:
echo - 停止: docker stop market_data_postgres
echo - 启动: docker start market_data_postgres
echo - 删除: docker rm -f market_data_postgres
echo - 连接: docker exec -it market_data_postgres psql -U postgres -d marketdata
echo.
pause

@ -0,0 +1,63 @@
@echo off
chcp 65001 >nul
echo ==========================================
echo 行情数据服务 - Docker 启动脚本
echo ==========================================
echo.
REM 检查 Docker 是否安装
docker --version >nul 2>&1
if errorlevel 1 (
echo [错误] Docker 未安装,请先安装 Docker Desktop
exit /b 1
)
REM 检查 Docker Compose 是否可用
docker compose version >nul 2>&1
if errorlevel 1 (
echo [错误] Docker Compose 不可用
exit /b 1
)
echo [1/3] 正在构建镜像...
docker compose build
echo.
echo [2/3] 正在启动服务...
docker compose up -d
echo.
echo [3/3] 等待数据库初始化...
timeout /t 5 /nobreak >nul
REM 检查服务状态
docker ps | findstr market_data >nul
if errorlevel 1 (
echo [错误] 服务启动失败,请检查日志
echo 查看日志: docker compose logs
exit /b 1
)
echo.
echo ==========================================
echo 服务启动成功!
echo ==========================================
echo.
echo 访问地址:
echo - 主服务: http://localhost:8080
echo - 管理后台: http://localhost:8080/admin
echo - API 文档: http://localhost:8080/docs
echo - ReDoc: http://localhost:8080/redoc
echo.
echo 数据库连接信息:
echo - PostgreSQL: localhost:5432
echo - 数据库: marketdata
echo - 用户名: postgres
echo - 密码: postgres123
echo.
echo 常用命令:
echo - 查看日志: docker compose logs -f
echo - 停止服务: docker compose down
echo - 重启服务: docker compose restart
echo.
pause

@ -0,0 +1,59 @@
#!/bin/bash
echo "=========================================="
echo " 行情数据服务 - Docker 启动脚本"
echo "=========================================="
echo ""
# 检查 Docker 是否安装
if ! command -v docker &> /dev/null; then
echo "[错误] Docker 未安装,请先安装 Docker"
exit 1
fi
# 检查 Docker Compose 是否可用
if ! docker compose version &> /dev/null; then
echo "[错误] Docker Compose 不可用"
exit 1
fi
echo "[1/3] 正在构建镜像..."
docker compose build
echo ""
echo "[2/3] 正在启动服务..."
docker compose up -d
echo ""
echo "[3/3] 等待数据库初始化..."
sleep 5
# 检查服务状态
if ! docker ps | grep -q "market_data"; then
echo "[错误] 服务启动失败,请检查日志"
echo "查看日志: docker compose logs"
exit 1
fi
echo ""
echo "=========================================="
echo " 服务启动成功!"
echo "=========================================="
echo ""
echo "访问地址:"
echo " - 主服务: http://localhost:8080"
echo " - 管理后台: http://localhost:8080/admin"
echo " - API 文档: http://localhost:8080/docs"
echo " - ReDoc: http://localhost:8080/redoc"
echo ""
echo "数据库连接信息:"
echo " - PostgreSQL: localhost:5432"
echo " - 数据库: marketdata"
echo " - 用户名: postgres"
echo " - 密码: postgres123"
echo ""
echo "常用命令:"
echo " - 查看日志: docker compose logs -f"
echo " - 停止服务: docker compose down"
echo " - 重启服务: docker compose restart"
echo ""

@ -0,0 +1,20 @@
#!/usr/bin/env python3
import urllib.request
import json
req = urllib.request.Request(
'http://localhost:8080/v1/admin/adapters',
headers={'X-Admin-Token': 'demo-api-key-2024'}
)
try:
response = urllib.request.urlopen(req, timeout=10)
data = json.loads(response.read().decode())
print('✓ Success!')
print(f"Code: {data['code']}")
print(f"Message: {data['message']}")
print(f"Adapters count: {len(data['data']['adapters'])}")
for adapter in data['data']['adapters']:
print(f" - {adapter['name']}: {adapter['status']} ({adapter['type']})")
except Exception as e:
print(f'✗ Error: {e}')

@ -0,0 +1,19 @@
#!/usr/bin/env python3
import urllib.request
import json
req = urllib.request.Request(
'http://localhost:8080/v1/admin/adapters',
headers={'X-Admin-Token': 'demo-api-key-2024'}
)
try:
response = urllib.request.urlopen(req, timeout=10)
data = json.loads(response.read().decode())
print('Success!')
print("Code:", data['code'])
print("Adapters count:", len(data['data']['adapters']))
for adapter in data['data']['adapters']:
print(" -", adapter['name'] + ":", adapter['status'])
except Exception as e:
print('Error:', e)

@ -0,0 +1,46 @@
"""测试数据库连接"""
import os
import sys
# 添加项目根目录到路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.repositories.database import init_db, engine
from sqlalchemy import text
print("=" * 50)
print("数据库连接测试")
print("=" * 50)
try:
# 测试连接
with engine.connect() as conn:
result = conn.execute(text("SELECT version()"))
version = result.scalar()
print(f"✅ 数据库连接成功")
print(f" PostgreSQL 版本: {version}")
# 初始化数据库(创建表)
print("\n正在初始化数据库表...")
init_db()
print("✅ 数据库表创建成功")
# 显示所有表
from sqlalchemy import inspect
inspector = inspect(engine)
tables = inspector.get_table_names()
print(f"\n已创建的表 ({len(tables)} 个):")
for table in sorted(tables):
print(f" - {table}")
print("\n" + "=" * 50)
print("数据库初始化完成!")
print("=" * 50)
except Exception as e:
print(f"❌ 错误: {e}")
print("\n请检查:")
print("1. Docker 是否已启动: docker ps")
print("2. 数据库端口是否正确: netstat -ano | findstr 5432")
print("3. 数据库密码是否正确")
sys.exit(1)

@ -0,0 +1,17 @@
#!/usr/bin/env python3
import urllib.request
import json
# Test source status
req = urllib.request.Request(
'http://localhost:8080/v1/admin/source/status',
headers={'X-API-Key': 'demo-api-key-2024'}
)
try:
response = urllib.request.urlopen(req, timeout=10)
data = json.loads(response.read().decode())
print('Source Status:')
print(json.dumps(data, indent=2, ensure_ascii=False))
except Exception as e:
print('Error:', e)

@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

@ -0,0 +1,19 @@
This is the MIT license: http://www.opensource.org/licenses/mit-license.php
Copyright (c) Alex Grönholm
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

@ -0,0 +1,147 @@
Metadata-Version: 2.1
Name: APScheduler
Version: 3.11.0
Summary: In-process task scheduler with Cron-like capabilities
Author-email: Alex Grönholm <alex.gronholm@nextday.fi>
License: MIT
Project-URL: Documentation, https://apscheduler.readthedocs.io/en/3.x/
Project-URL: Changelog, https://apscheduler.readthedocs.io/en/3.x/versionhistory.html
Project-URL: Source code, https://github.com/agronholm/apscheduler
Project-URL: Issue tracker, https://github.com/agronholm/apscheduler/issues
Keywords: scheduling,cron
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.8
Description-Content-Type: text/x-rst
License-File: LICENSE.txt
Requires-Dist: tzlocal>=3.0
Requires-Dist: backports.zoneinfo; python_version < "3.9"
Provides-Extra: etcd
Requires-Dist: etcd3; extra == "etcd"
Requires-Dist: protobuf<=3.21.0; extra == "etcd"
Provides-Extra: gevent
Requires-Dist: gevent; extra == "gevent"
Provides-Extra: mongodb
Requires-Dist: pymongo>=3.0; extra == "mongodb"
Provides-Extra: redis
Requires-Dist: redis>=3.0; extra == "redis"
Provides-Extra: rethinkdb
Requires-Dist: rethinkdb>=2.4.0; extra == "rethinkdb"
Provides-Extra: sqlalchemy
Requires-Dist: sqlalchemy>=1.4; extra == "sqlalchemy"
Provides-Extra: tornado
Requires-Dist: tornado>=4.3; extra == "tornado"
Provides-Extra: twisted
Requires-Dist: twisted; extra == "twisted"
Provides-Extra: zookeeper
Requires-Dist: kazoo; extra == "zookeeper"
Provides-Extra: test
Requires-Dist: APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]; extra == "test"
Requires-Dist: pytest; extra == "test"
Requires-Dist: anyio>=4.5.2; extra == "test"
Requires-Dist: PySide6; (platform_python_implementation == "CPython" and python_version < "3.14") and extra == "test"
Requires-Dist: gevent; python_version < "3.14" and extra == "test"
Requires-Dist: pytz; extra == "test"
Requires-Dist: twisted; python_version < "3.14" and extra == "test"
Provides-Extra: doc
Requires-Dist: packaging; extra == "doc"
Requires-Dist: sphinx; extra == "doc"
Requires-Dist: sphinx-rtd-theme>=1.3.0; extra == "doc"
.. image:: https://github.com/agronholm/apscheduler/workflows/Python%20codeqa/test/badge.svg?branch=3.x
:target: https://github.com/agronholm/apscheduler/actions?query=workflow%3A%22Python+codeqa%2Ftest%22+branch%3A3.x
:alt: Build Status
.. image:: https://coveralls.io/repos/github/agronholm/apscheduler/badge.svg?branch=3.x
:target: https://coveralls.io/github/agronholm/apscheduler?branch=3.x
:alt: Code Coverage
.. image:: https://readthedocs.org/projects/apscheduler/badge/?version=3.x
:target: https://apscheduler.readthedocs.io/en/master/?badge=3.x
:alt: Documentation
Advanced Python Scheduler (APScheduler) is a Python library that lets you schedule your Python code
to be executed later, either just once or periodically. You can add new jobs or remove old ones on
the fly as you please. If you store your jobs in a database, they will also survive scheduler
restarts and maintain their state. When the scheduler is restarted, it will then run all the jobs
it should have run while it was offline [#f1]_.
Among other things, APScheduler can be used as a cross-platform, application specific replacement
to platform specific schedulers, such as the cron daemon or the Windows task scheduler. Please
note, however, that APScheduler is **not** a daemon or service itself, nor does it come with any
command line tools. It is primarily meant to be run inside existing applications. That said,
APScheduler does provide some building blocks for you to build a scheduler service or to run a
dedicated scheduler process.
APScheduler has three built-in scheduling systems you can use:
* Cron-style scheduling (with optional start/end times)
* Interval-based execution (runs jobs on even intervals, with optional start/end times)
* One-off delayed execution (runs jobs once, on a set date/time)
You can mix and match scheduling systems and the backends where the jobs are stored any way you
like. Supported backends for storing jobs include:
* Memory
* `SQLAlchemy <http://www.sqlalchemy.org/>`_ (any RDBMS supported by SQLAlchemy works)
* `MongoDB <http://www.mongodb.org/>`_
* `Redis <http://redis.io/>`_
* `RethinkDB <https://www.rethinkdb.com/>`_
* `ZooKeeper <https://zookeeper.apache.org/>`_
* `Etcd <https://etcd.io/>`_
APScheduler also integrates with several common Python frameworks, like:
* `asyncio <http://docs.python.org/3.4/library/asyncio.html>`_ (:pep:`3156`)
* `gevent <http://www.gevent.org/>`_
* `Tornado <http://www.tornadoweb.org/>`_
* `Twisted <http://twistedmatrix.com/>`_
* `Qt <http://qt-project.org/>`_ (using either
`PyQt <http://www.riverbankcomputing.com/software/pyqt/intro>`_ ,
`PySide6 <https://wiki.qt.io/Qt_for_Python>`_ ,
`PySide2 <https://wiki.qt.io/Qt_for_Python>`_ or
`PySide <http://qt-project.org/wiki/PySide>`_)
There are third party solutions for integrating APScheduler with other frameworks:
* `Django <https://github.com/jarekwg/django-apscheduler>`_
* `Flask <https://github.com/viniciuschiele/flask-apscheduler>`_
.. [#f1] The cutoff period for this is also configurable.
Documentation
-------------
Documentation can be found `here <https://apscheduler.readthedocs.io/>`_.
Source
------
The source can be browsed at `Github <https://github.com/agronholm/apscheduler/tree/3.x>`_.
Reporting bugs
--------------
A `bug tracker <https://github.com/agronholm/apscheduler/issues>`_ is provided by Github.
Getting help
------------
If you have problems or other questions, you can either:
* Ask in the `apscheduler <https://gitter.im/apscheduler/Lobby>`_ room on Gitter
* Ask on the `APScheduler GitHub discussion forum <https://github.com/agronholm/apscheduler/discussions>`_, or
* Ask on `StackOverflow <http://stackoverflow.com/questions/tagged/apscheduler>`_ and tag your
question with the ``apscheduler`` tag

@ -0,0 +1,86 @@
APScheduler-3.11.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
APScheduler-3.11.0.dist-info/LICENSE.txt,sha256=YWP3mH37ONa8MgzitwsvArhivEESZRbVUu8c1DJH51g,1130
APScheduler-3.11.0.dist-info/METADATA,sha256=Mve2P3vZbWWDb5V-XfZO80hkih9E6s00Nn5ptU2__9w,6374
APScheduler-3.11.0.dist-info/RECORD,,
APScheduler-3.11.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
APScheduler-3.11.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
APScheduler-3.11.0.dist-info/entry_points.txt,sha256=HSDTxgulLTgymfXK2UNCPP1ib5rlQSFgZJEg72vto3s,1181
APScheduler-3.11.0.dist-info/top_level.txt,sha256=O3oMCWxG-AHkecUoO6Ze7-yYjWrttL95uHO8-RFdYvE,12
apscheduler/__init__.py,sha256=hOpI9oJuk5l5I_VtdsHPous2Qr-ZDX573e7NaYRWFUs,380
apscheduler/__pycache__/__init__.cpython-311.pyc,,
apscheduler/__pycache__/events.cpython-311.pyc,,
apscheduler/__pycache__/job.cpython-311.pyc,,
apscheduler/__pycache__/util.cpython-311.pyc,,
apscheduler/events.py,sha256=W_Wg5aTBXDxXhHtimn93ZEjV3x0ntF-Y0EAVuZPhiXY,3591
apscheduler/executors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
apscheduler/executors/__pycache__/__init__.cpython-311.pyc,,
apscheduler/executors/__pycache__/asyncio.cpython-311.pyc,,
apscheduler/executors/__pycache__/base.cpython-311.pyc,,
apscheduler/executors/__pycache__/debug.cpython-311.pyc,,
apscheduler/executors/__pycache__/gevent.cpython-311.pyc,,
apscheduler/executors/__pycache__/pool.cpython-311.pyc,,
apscheduler/executors/__pycache__/tornado.cpython-311.pyc,,
apscheduler/executors/__pycache__/twisted.cpython-311.pyc,,
apscheduler/executors/asyncio.py,sha256=g0ArcxefoTnEqtyr_IRc-M3dcj0bhuvHcxwRp2s3nDE,1768
apscheduler/executors/base.py,sha256=HErgd8d1g0-BjXnylLcFyoo6GU3wHgW9GJVaFNMV7dI,7116
apscheduler/executors/debug.py,sha256=15_ogSBzl8RRCfBYDnkIV2uMH8cLk1KImYmBa_NVGpc,573
apscheduler/executors/gevent.py,sha256=_ZFpbn7-tH5_lAeL4sxEyPhxyUTtUUSrH8s42EHGQ2w,761
apscheduler/executors/pool.py,sha256=q_shxnvXLjdcwhtKyPvQSYngOjAeKQO8KCvZeb19RSQ,2683
apscheduler/executors/tornado.py,sha256=lb6mshRj7GMLz3d8StwESnlZsAfrNmW78Wokcg__Lk8,1581
apscheduler/executors/twisted.py,sha256=YUEDnaPbP_M0lXCmNAW_yPiLKwbO9vD3KMiBFQ2D4h0,726
apscheduler/job.py,sha256=GzOGMfOM6STwd3HWArVAylO-1Kb0f2qA_PRuXs5LPk4,11153
apscheduler/jobstores/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
apscheduler/jobstores/__pycache__/__init__.cpython-311.pyc,,
apscheduler/jobstores/__pycache__/base.cpython-311.pyc,,
apscheduler/jobstores/__pycache__/etcd.cpython-311.pyc,,
apscheduler/jobstores/__pycache__/memory.cpython-311.pyc,,
apscheduler/jobstores/__pycache__/mongodb.cpython-311.pyc,,
apscheduler/jobstores/__pycache__/redis.cpython-311.pyc,,
apscheduler/jobstores/__pycache__/rethinkdb.cpython-311.pyc,,
apscheduler/jobstores/__pycache__/sqlalchemy.cpython-311.pyc,,
apscheduler/jobstores/__pycache__/zookeeper.cpython-311.pyc,,
apscheduler/jobstores/base.py,sha256=ZDOgMtHLaF3TPUOQwmkBIDcpnHU0aUhtzZOGmMGaJn8,4416
apscheduler/jobstores/etcd.py,sha256=O7C40CGlnn3cPinchJEs2sWcqnzEZQt3c6WnhgPRSdQ,5703
apscheduler/jobstores/memory.py,sha256=HmOs7FbrOoQNywz-yfq2v5esGDHeKE_mvMNFDeGZ31E,3595
apscheduler/jobstores/mongodb.py,sha256=mCIwcKiWcicM2qdAQn51QBEkGlNfbk_73Oi6soShNcM,5319
apscheduler/jobstores/redis.py,sha256=El-H2eUfZjPZca7vwy10B9gZv5RzRucbkDu7Ti07vyM,5482
apscheduler/jobstores/rethinkdb.py,sha256=SdT3jPrhxnmBoL4IClDfHsez4DpREnYEsHndIP8idHA,5922
apscheduler/jobstores/sqlalchemy.py,sha256=2jaq3ZcoXEyIqqvYf3eloaP-_ZAqojt0EuWWvQ2LMRg,6799
apscheduler/jobstores/zookeeper.py,sha256=32bEZNJNniPwmYXBITZ3eSRBq6hipqPKDqh4q4NiZvc,6439
apscheduler/schedulers/__init__.py,sha256=POEy7n3BZgccZ44atMvxj0w5PejN55g-55NduZUZFqQ,406
apscheduler/schedulers/__pycache__/__init__.cpython-311.pyc,,
apscheduler/schedulers/__pycache__/asyncio.cpython-311.pyc,,
apscheduler/schedulers/__pycache__/background.cpython-311.pyc,,
apscheduler/schedulers/__pycache__/base.cpython-311.pyc,,
apscheduler/schedulers/__pycache__/blocking.cpython-311.pyc,,
apscheduler/schedulers/__pycache__/gevent.cpython-311.pyc,,
apscheduler/schedulers/__pycache__/qt.cpython-311.pyc,,
apscheduler/schedulers/__pycache__/tornado.cpython-311.pyc,,
apscheduler/schedulers/__pycache__/twisted.cpython-311.pyc,,
apscheduler/schedulers/asyncio.py,sha256=Jo7tgHP1STnMSxNVAWPSkFpmBLngavivTsG9sF0QoWM,1893
apscheduler/schedulers/background.py,sha256=sRNrikUhpyblvA5RCpKC5Djvf3-b6NHvnXTblxlqIaM,1476
apscheduler/schedulers/base.py,sha256=hvnvcI1DOC9bmvrFk8UiLlGxsXKHtMpEHLDEe63mQ_s,48342
apscheduler/schedulers/blocking.py,sha256=138rf9X1C-ZxWVTVAO_pyfYMBKhkqO2qZqJoyGInv5c,872
apscheduler/schedulers/gevent.py,sha256=zS5nHQUkQMrn0zKOaFnUyiG0fXTE01yE9GXVNCdrd90,987
apscheduler/schedulers/qt.py,sha256=6BHOCi8e6L3wXTWwQDjNl8w_GJF_dY6iiO3gEtCJgmI,1241
apscheduler/schedulers/tornado.py,sha256=dQBQKrTtZLPHuhuzZgrT-laU-estPRWGv9W9kgZETnY,1890
apscheduler/schedulers/twisted.py,sha256=sRkI3hosp-OCLVluR_-wZFCz9auxqqWYauZhtOAoRU4,1778
apscheduler/triggers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
apscheduler/triggers/__pycache__/__init__.cpython-311.pyc,,
apscheduler/triggers/__pycache__/base.cpython-311.pyc,,
apscheduler/triggers/__pycache__/calendarinterval.cpython-311.pyc,,
apscheduler/triggers/__pycache__/combining.cpython-311.pyc,,
apscheduler/triggers/__pycache__/date.cpython-311.pyc,,
apscheduler/triggers/__pycache__/interval.cpython-311.pyc,,
apscheduler/triggers/base.py,sha256=8iKllubaexF456IK9jfi56QTrVIfDDPLavUc8wTlnL0,1333
apscheduler/triggers/calendarinterval.py,sha256=BaH5rbTSVbPk3VhFwA3zORLSuZtYmFudS8GF0YxB51E,7411
apscheduler/triggers/combining.py,sha256=LO0YKgBk8V5YfQ-L3qh8Fb6w0BvNOBghTFeAvZx3_P8,4044
apscheduler/triggers/cron/__init__.py,sha256=ByWq4Q96gUWr4AwKoRRA9BD5ZVBvwQ6BtQMhafdStjw,9753
apscheduler/triggers/cron/__pycache__/__init__.cpython-311.pyc,,
apscheduler/triggers/cron/__pycache__/expressions.cpython-311.pyc,,
apscheduler/triggers/cron/__pycache__/fields.cpython-311.pyc,,
apscheduler/triggers/cron/expressions.py,sha256=89n_HxA0826xBJb8RprVzUDECs0dnZ_rX2wVkVsq6l8,9056
apscheduler/triggers/cron/fields.py,sha256=RVbf6Lcyvg-3CqNzEZsfxzQ_weONCIiq5LGDzA3JUAw,3618
apscheduler/triggers/date.py,sha256=ZS_TMjUCSldqlZsUUjlwvuWeMKeDXqqAMcZVFGYpam4,1698
apscheduler/triggers/interval.py,sha256=u6XLrxlaWA41zvIByQvRLHTAuvkibG2fAZAxrWK3118,4679
apscheduler/util.py,sha256=Lz2ddoeIpufXzW-HWnW5J08ijkXWGElDLVJf0DiPa84,13564

@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (75.6.0)
Root-Is-Purelib: true
Tag: py3-none-any

@ -0,0 +1,25 @@
[apscheduler.executors]
asyncio = apscheduler.executors.asyncio:AsyncIOExecutor
debug = apscheduler.executors.debug:DebugExecutor
gevent = apscheduler.executors.gevent:GeventExecutor
processpool = apscheduler.executors.pool:ProcessPoolExecutor
threadpool = apscheduler.executors.pool:ThreadPoolExecutor
tornado = apscheduler.executors.tornado:TornadoExecutor
twisted = apscheduler.executors.twisted:TwistedExecutor
[apscheduler.jobstores]
etcd = apscheduler.jobstores.etcd:EtcdJobStore
memory = apscheduler.jobstores.memory:MemoryJobStore
mongodb = apscheduler.jobstores.mongodb:MongoDBJobStore
redis = apscheduler.jobstores.redis:RedisJobStore
rethinkdb = apscheduler.jobstores.rethinkdb:RethinkDBJobStore
sqlalchemy = apscheduler.jobstores.sqlalchemy:SQLAlchemyJobStore
zookeeper = apscheduler.jobstores.zookeeper:ZooKeeperJobStore
[apscheduler.triggers]
and = apscheduler.triggers.combining:AndTrigger
calendarinterval = apscheduler.triggers.calendarinterval:CalendarIntervalTrigger
cron = apscheduler.triggers.cron:CronTrigger
date = apscheduler.triggers.date:DateTrigger
interval = apscheduler.triggers.interval:IntervalTrigger
or = apscheduler.triggers.combining:OrTrigger

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

Loading…
Cancel
Save