feat: 初始化代码

master
Lxy 3 weeks ago
commit e0b8305406

@ -0,0 +1,13 @@
# Tushare API Token (必填,到 https://tushare.pro 注册获取)
TUSHARE_TOKEN=your_tushare_token_here
# 数据库配置(默认值即可)
DB_HOST=postgres
DB_PORT=5432
DB_NAME=futures_data
DB_USER=futures
DB_PASSWORD=futures123
# Redis 配置
REDIS_HOST=redis
REDIS_PORT=6379

63
.gitignore vendored

@ -0,0 +1,63 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
env/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment variables
.env
.env.local
.env.*.local
# Database
*.db
*.sqlite3
# Logs
*.log
logs/
# Frontend
node_modules/
dist/
build/
.npm
.yarn
# Docker
.dockerignore
# Misc
.DS_Store
Thumbs.db

@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --registry=https://registry.npmmirror.com
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

@ -0,0 +1,90 @@
# 期货统一数据平台
期货行情统一数据平台对接多个数据源Tushare/CTP等提供统一的数据出口。
## 功能
- **数据源管理**: 可配置启用/禁用的数据源,优先级管理
- **历史K线**: 日K/周K/60m/30m/15m/5m 数据存储与查询
- **合约管理**: 合约信息同步与查询
- **管理后台**: 数据源监控、接口测试、K线展示
- **RESTful API**: 统一的数据接口供其他系统调用
## 快速开始
### 1. 配置环境变量
```bash
cp .env.example .env
# 编辑 .env填入你的 Tushare Token
```
### 2. Docker 启动
```bash
docker compose up -d --build
```
启动后访问:
- 管理后台: http://localhost:3000
- 后端API: http://localhost:8000
- API文档: http://localhost:8000/docs
### 3. 初始配置
1. 打开 http://localhost:3000
2. 进入"数据源监控"页面
3. 点击 tushare 行的"配置"按钮,填入你的 Tushare Token
4. 点击"测试连接"确认
5. 开启"启用"开关
## 项目结构
```
├── backend/ # 后端 (FastAPI + SQLAlchemy)
│ ├── app/
│ │ ├── main.py # 入口 & API路由
│ │ ├── config.py # 配置
│ │ ├── database.py # 数据库连接
│ │ ├── models/ # 数据库模型
│ │ ├── schemas/ # Pydantic 模型
│ │ ├── services/ # 业务逻辑
│ │ │ ├── datasource/ # 数据源适配器
│ │ │ ├── kline_service.py
│ │ │ └── contract_service.py
│ │ └── api/
│ └── requirements.txt
├── frontend/ # 前端 (Vue3 + Element Plus)
│ ├── src/
│ │ ├── views/ # 页面
│ │ └── api/ # API调用
│ └── package.json
├── docker-compose.yml
└── .env
```
## API 接口
### 合约
- `GET /api/v1/contracts` - 合约列表
- `GET /api/v1/contracts/{symbol}` - 合约详情
- `POST /api/v1/contracts/sync` - 同步合约
### K线
- `GET /api/v1/kline?symbol=rb2401&period=daily&limit=100` - 查询K线
- `POST /api/v1/kline/sync` - 同步K线数据
### 数据源
- `GET /api/v1/datasources` - 数据源列表
- `PUT /api/v1/datasources/{name}` - 更新配置
- `POST /api/v1/datasources/{name}/test` - 测试连接
## 技术栈
| 层次 | 技术 |
|------|------|
| 后端 | Python / FastAPI / SQLAlchemy |
| 数据库 | PostgreSQL + TimescaleDB |
| 缓存 | Redis |
| 前端 | Vue3 / Element Plus / ECharts |
| 部署 | Docker Compose |

@ -0,0 +1,50 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# 项目信息
PROJECT_NAME: str = "期货统一数据平台"
VERSION: str = "0.1.0"
API_PREFIX: str = "/api/v1"
# 数据库配置
DB_HOST: str = "postgres"
DB_PORT: int = 5432
DB_NAME: str = "futures_data"
DB_USER: str = "futures"
DB_PASSWORD: str = "futures123"
DATABASE_URL: str = "" # 直接指定完整 URL支持 sqlite:///
@property
def database_url(self) -> str:
if self.DATABASE_URL:
return self.DATABASE_URL
return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
# Redis 配置
REDIS_HOST: str = "redis"
REDIS_PORT: int = 6379
REDIS_DB: int = 0
REDIS_PASSWORD: Optional[str] = None
@property
def REDIS_URL(self) -> str:
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# Tushare 配置
TUSHARE_TOKEN: str = ""
# 服务配置
HOST: str = "0.0.0.0"
PORT: int = 8000
DEBUG: bool = True
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import settings
db_url = settings.database_url
connect_args = {}
if db_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine = create_engine(db_url, connect_args=connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

@ -0,0 +1,339 @@
from fastapi import FastAPI, Depends, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from typing import Optional
from datetime import datetime
import logging
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
from app.config import settings
from app.database import get_db, engine, Base
from app.schemas import (
KlineRequest, KlineResponse, KlineItem,
ContractInfo as ContractSchema, ContractListResponse,
DataSourceConfigItem, DataSourceConfigUpdate, DataSourceCreate,
ApiResponse, HealthResponse, DataSourceStatus,
)
from app.services.kline_service import kline_service
from app.services.contract_service import contract_service
from app.services.datasource.manager import DataSourceManager
from app.models import DataSourceConfig
logger = logging.getLogger(__name__)
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
docs_url="/docs",
redoc_url="/redoc",
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ========== 启动事件 ==========
@app.on_event("startup")
async def startup():
# 创建数据库表
Base.metadata.create_all(bind=engine)
# 加载数据源配置
DataSourceManager.load_enabled_sources()
# 初始化默认数据源配置(如果不存在)
_init_default_datasource()
def _init_default_datasource():
"""初始化默认的数据源配置(如果不存在)"""
from app.database import SessionLocal
db = SessionLocal()
try:
# 初始化 Tushare
existing = db.query(DataSourceConfig).filter(
DataSourceConfig.source_name == "tushare"
).first()
if not existing:
import json
cfg = DataSourceConfig(
source_name="tushare",
display_name="Tushare",
is_enabled=False,
config_json=json.dumps({"token": ""}),
priority=1,
status="unknown",
)
db.add(cfg)
# 初始化 Akshare
existing_ak = db.query(DataSourceConfig).filter(
DataSourceConfig.source_name == "akshare"
).first()
if not existing_ak:
import json
cfg_ak = DataSourceConfig(
source_name="akshare",
display_name="AKShare",
is_enabled=False,
config_json=json.dumps({"max_retries": 3}),
priority=2,
status="unknown",
)
db.add(cfg_ak)
db.commit()
finally:
db.close()
# ========== 健康检查 ==========
@app.get("/api/health", response_model=HealthResponse)
async def health_check():
services = {}
try:
from sqlalchemy import text
from app.database import engine
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
services["database"] = "ok"
except Exception as e:
services["database"] = f"error: {str(e)}"
try:
import redis
r = redis.from_url(settings.REDIS_URL)
r.ping()
services["redis"] = "ok"
except Exception as e:
services["redis"] = "not configured" # Redis 非必须
status = "healthy" if all(v == "ok" for v in services.values()) else "degraded"
return HealthResponse(
status=status,
services=services,
version=settings.VERSION,
)
# ========== 合约接口 ==========
@app.get("/api/v1/contracts", response_model=ContractListResponse)
async def list_contracts(
exchange: Optional[str] = Query(None, description="交易所代码"),
product: Optional[str] = Query(None, description="品种代码"),
is_active: Optional[bool] = Query(None, description="是否活跃"),
):
contracts = contract_service.get_contracts(
exchange=exchange, product=product, is_active=is_active
)
return ContractListResponse(
total=len(contracts),
items=[ContractSchema.model_validate(c) for c in contracts],
)
@app.get("/api/v1/contracts/{symbol}", response_model=ContractSchema)
async def get_contract(symbol: str):
contract = contract_service.get_contract(symbol)
if not contract:
raise HTTPException(status_code=404, detail="合约不存在")
return ContractSchema.model_validate(contract)
@app.post("/api/v1/contracts/sync")
async def sync_contracts():
"""从数据源同步合约列表"""
try:
count = contract_service.sync_contracts()
return {"code": 0, "message": "同步成功", "data": {"synced": count}}
except Exception as e:
return {"code": 1, "message": f"同步失败: {str(e)}", "data": None}
# ========== K线接口 ==========
@app.get("/api/v1/kline", response_model=KlineResponse)
async def get_kline(
symbol: str = Query(..., description="合约代码"),
period: str = Query("daily", description="周期: daily/weekly/5m/15m/30m/60m"),
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
limit: int = Query(500, ge=1, le=5000, description="返回条数"),
):
logger.info(f"[API-查询K线] 请求参数: symbol={symbol}, period={period}, start_date={start_date}, end_date={end_date}, limit={limit}")
items = kline_service.get_kline(
symbol=symbol,
period=period,
start_date=start_date,
end_date=end_date,
limit=limit,
)
logger.info(f"[API-查询K线] 返回 {len(items)} 条记录")
return KlineResponse(
symbol=symbol,
period=period,
total=len(items),
items=[KlineItem(**item) for item in items],
)
@app.post("/api/v1/kline/sync")
async def sync_kline(req: KlineRequest):
"""从数据源同步K线数据"""
logger.info(f"[API-同步K线] 请求参数: symbol={req.symbol}, period={req.period}, start_date={req.start_date}, end_date={req.end_date}")
try:
start = req.start_date or "2020-01-01"
end = req.end_date or datetime.now().strftime("%Y-%m-%d")
logger.info(f"[API-同步K线] 使用日期范围: {start} ~ {end}")
if req.period == "daily":
count = kline_service.sync_daily(req.symbol, start, end)
elif req.period == "weekly":
count = kline_service.sync_weekly(req.symbol, start, end)
else:
count = kline_service.sync_intraday(req.symbol, req.period, start, end)
logger.info(f"[API-同步K线] 同步成功,共同步 {count} 条记录")
return {"code": 0, "message": "同步成功", "data": {"synced": count}}
except Exception as e:
logger.error(f"[API-同步K线] 同步失败: {e}", exc_info=True)
return {"code": 1, "message": f"同步失败: {str(e)}", "data": None}
# ========== 数据源管理接口 ==========
@app.get("/api/v1/datasources")
async def list_datasources():
"""获取所有数据源状态"""
sources = DataSourceManager.get_all_sources_status()
return {"code": 0, "data": sources}
@app.post("/api/v1/datasources")
async def create_datasource(req: DataSourceCreate):
"""创建数据源配置"""
from app.database import SessionLocal
db = SessionLocal()
try:
existing = db.query(DataSourceConfig).filter(
DataSourceConfig.source_name == req.source_name
).first()
if existing:
return {"code": 1, "message": "数据源已存在"}
cfg = DataSourceConfig(
source_name=req.source_name,
display_name=req.display_name or req.source_name,
is_enabled=False,
config_json=req.config_json or {},
priority=req.priority,
status="unknown",
)
db.add(cfg)
db.commit()
return {"code": 0, "message": "创建成功", "data": {"id": cfg.id}}
except Exception as e:
db.rollback()
return {"code": 1, "message": f"创建失败: {str(e)}"}
finally:
db.close()
@app.put("/api/v1/datasources/{source_name}")
async def update_datasource(source_name: str, req: DataSourceConfigUpdate):
"""更新数据源配置"""
from app.database import SessionLocal
db = SessionLocal()
try:
cfg = db.query(DataSourceConfig).filter(
DataSourceConfig.source_name == source_name
).first()
if not cfg:
return {"code": 1, "message": "数据源不存在"}
if req.is_enabled is not None:
cfg.is_enabled = req.is_enabled
if req.config_json is not None:
import json
cfg.config_json = json.dumps(req.config_json)
if req.priority is not None:
cfg.priority = req.priority
db.commit()
# 重新加载数据源
DataSourceManager.load_enabled_sources()
return {"code": 0, "message": "更新成功"}
except Exception as e:
db.rollback()
return {"code": 1, "message": f"更新失败: {str(e)}"}
finally:
db.close()
@app.post("/api/v1/datasources/{source_name}/test")
async def test_datasource(source_name: str):
"""测试数据源连接"""
source = DataSourceManager.get_source(source_name)
if not source:
# 尝试创建临时实例测试
from app.database import SessionLocal
import json
db = SessionLocal()
try:
cfg = db.query(DataSourceConfig).filter(
DataSourceConfig.source_name == source_name
).first()
if not cfg:
return {"code": 1, "message": "数据源不存在"}
config = json.loads(cfg.config_json) if cfg.config_json else {}
# 动态获取数据源类
source_class = DataSourceManager._source_map.get(source_name)
if not source_class:
return {"code": 1, "message": "不支持的数据源类型"}
source = source_class(config)
finally:
db.close()
ok, msg = source.health_check()
if ok:
# 更新状态
from app.database import SessionLocal
db = SessionLocal()
try:
cfg = db.query(DataSourceConfig).filter(
DataSourceConfig.source_name == source_name
).first()
if cfg:
cfg.status = "ok"
cfg.error_msg = None
db.commit()
finally:
db.close()
return {"code": 0, "message": "连接成功", "data": {"status": "ok"}}
else:
from app.database import SessionLocal
db = SessionLocal()
try:
cfg = db.query(DataSourceConfig).filter(
DataSourceConfig.source_name == source_name
).first()
if cfg:
cfg.status = "error"
cfg.error_msg = msg
db.commit()
finally:
db.close()
return {"code": 1, "message": f"连接失败: {msg}", "data": {"status": "error"}}

@ -0,0 +1,113 @@
from app.database import Base
from sqlalchemy import Column, Integer, String, Float, DateTime, BigInteger, Boolean, Text, Index
from datetime import datetime
class ContractInfo(Base):
"""期货合约信息表"""
__tablename__ = "contract_info"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(20), unique=True, nullable=False, comment="合约代码,如 rb2401")
exchange = Column(String(10), nullable=False, comment="交易所代码: SHFE/DCE/CZCE/INE/CFFEX")
name = Column(String(50), comment="合约名称")
product = Column(String(20), comment="品种代码,如 rb")
multiplier = Column(Integer, default=10, comment="合约乘数")
price_tick = Column(Float, comment="最小变动价位")
limit_up_ratio = Column(Float, comment="涨停板比例")
limit_down_ratio = Column(Float, comment="跌停板比例")
expire_date = Column(DateTime, comment="到期日")
is_active = Column(Boolean, default=True, comment="是否活跃")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
Index("idx_contract_product", "product"),
Index("idx_contract_exchange", "exchange"),
)
class KlineDaily(Base):
"""日K线表"""
__tablename__ = "kline_daily"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(20), nullable=False)
trade_date = Column(DateTime, nullable=False)
open = Column(Float)
high = Column(Float)
low = Column(Float)
close = Column(Float)
volume = Column(BigInteger, comment="成交量")
turnover = Column(Float, comment="成交额")
open_interest = Column(BigInteger, comment="持仓量")
settle = Column(Float, comment="结算价")
pre_settle = Column(Float, comment="昨结算")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
Index("idx_kline_daily_symbol_date", "symbol", "trade_date", unique=True),
)
class KlineWeekly(Base):
"""周K线表"""
__tablename__ = "kline_weekly"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(20), nullable=False)
trade_date = Column(DateTime, nullable=False, comment="周最后一天")
open = Column(Float)
high = Column(Float)
low = Column(Float)
close = Column(Float)
volume = Column(BigInteger)
turnover = Column(Float)
open_interest = Column(BigInteger)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
Index("idx_kline_weekly_symbol_date", "symbol", "trade_date", unique=True),
)
class KlineIntraday(Base):
"""分钟级K线表5m/15m/30m/60m共用通过period区分"""
__tablename__ = "kline_intraday"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(20), nullable=False)
period = Column(String(10), nullable=False, comment="周期: 5m/15m/30m/60m")
trade_time = Column(DateTime, nullable=False)
open = Column(Float)
high = Column(Float)
low = Column(Float)
close = Column(Float)
volume = Column(BigInteger)
turnover = Column(Float)
open_interest = Column(BigInteger)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
Index("idx_kline_intraday_symbol_period_time", "symbol", "period", "trade_time", unique=True),
)
class DataSourceConfig(Base):
"""数据源配置表"""
__tablename__ = "data_source_config"
id = Column(Integer, primary_key=True, autoincrement=True)
source_name = Column(String(30), unique=True, nullable=False, comment="数据源名称: tushare/ctp")
display_name = Column(String(50), comment="显示名称")
is_enabled = Column(Boolean, default=False, comment="是否启用")
config_json = Column(Text, comment="JSON格式的配置信息")
priority = Column(Integer, default=0, comment="优先级,数字越小优先级越高")
last_sync_time = Column(DateTime, comment="最后同步时间")
status = Column(String(20), default="unknown", comment="状态: ok/error/unknown")
error_msg = Column(Text, comment="错误信息")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

@ -0,0 +1,102 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
# ========== 合约相关 ==========
class ContractInfo(BaseModel):
id: int
symbol: str
exchange: str
name: Optional[str] = None
product: Optional[str] = None
multiplier: Optional[int] = None
price_tick: Optional[float] = None
is_active: Optional[bool] = None
class Config:
from_attributes = True
class ContractListResponse(BaseModel):
total: int
items: List[ContractInfo]
# ========== K线相关 ==========
class KlineItem(BaseModel):
trade_time: datetime
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
close: Optional[float] = None
volume: Optional[int] = None
turnover: Optional[float] = None
open_interest: Optional[int] = None
class Config:
from_attributes = True
class KlineRequest(BaseModel):
symbol: str
period: str = "daily" # daily/weekly/5m/15m/30m/60m
start_date: Optional[str] = None # YYYY-MM-DD or YYYY-MM-DD HH:MM:SS
end_date: Optional[str] = None
limit: int = 500 # 默认返回最近500条
class KlineResponse(BaseModel):
symbol: str
period: str
total: int
items: List[KlineItem]
# ========== 数据源相关 ==========
class DataSourceConfigItem(BaseModel):
id: int
source_name: str
display_name: Optional[str] = None
is_enabled: bool
priority: int
status: str
error_msg: Optional[str] = None
last_sync_time: Optional[datetime] = None
class Config:
from_attributes = True
class DataSourceConfigUpdate(BaseModel):
is_enabled: Optional[bool] = None
config_json: Optional[dict] = None
priority: Optional[int] = None
class DataSourceCreate(BaseModel):
source_name: str
display_name: Optional[str] = None
config_json: Optional[dict] = None
priority: int = 0
class DataSourceStatus(BaseModel):
source_name: str
is_enabled: bool
status: str
error_msg: Optional[str] = None
last_sync_time: Optional[datetime] = None
# ========== 通用 ==========
class ApiResponse(BaseModel):
code: int = 0
message: str = "ok"
data: Optional[dict] = None
class HealthResponse(BaseModel):
status: str
services: dict
version: str

@ -0,0 +1,110 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models import ContractInfo
from app.services.datasource.manager import DataSourceManager
from app.database import SessionLocal
class ContractService:
"""合约信息服务"""
def __init__(self):
self.manager = DataSourceManager()
def sync_contracts(self) -> int:
"""从数据源同步合约列表到数据库"""
source = self.manager.get_primary_source()
if not source:
raise Exception("没有可用的数据源")
# Tushare 需要遍历所有交易所
exchanges = ["CFFEX", "SHFE", "DCE", "CZCE", "INE", "GFEX"]
all_contracts = []
for ex in exchanges:
try:
contracts = source.get_contract_list(exchange=ex)
all_contracts.extend(contracts)
except Exception:
continue # 某个交易所失败不影响其他
# 去重:基于 symbol
seen_symbols = set()
unique_contracts = []
for c in all_contracts:
if c["symbol"] not in seen_symbols:
seen_symbols.add(c["symbol"])
unique_contracts.append(c)
all_contracts = unique_contracts
db = SessionLocal()
count = 0
try:
for c in all_contracts:
contract = db.query(ContractInfo).filter(
ContractInfo.symbol == c["symbol"]
).first()
if contract:
contract.exchange = c["exchange"]
contract.name = c["name"]
contract.product = c["product"]
contract.multiplier = c["multiplier"]
contract.price_tick = c["price_tick"]
contract.expire_date = c["expire_date"]
contract.is_active = c["is_active"]
else:
contract = ContractInfo(
symbol=c["symbol"],
exchange=c["exchange"],
name=c["name"],
product=c["product"],
multiplier=c["multiplier"],
price_tick=c["price_tick"],
expire_date=c["expire_date"],
is_active=c["is_active"],
)
db.add(contract)
count += 1
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
return count
def get_contracts(
self,
exchange: Optional[str] = None,
product: Optional[str] = None,
is_active: Optional[bool] = None
) -> List[ContractInfo]:
"""查询合约列表"""
db = SessionLocal()
try:
query = db.query(ContractInfo)
if exchange:
query = query.filter(ContractInfo.exchange == exchange)
if product:
query = query.filter(ContractInfo.product == product)
if is_active is not None:
query = query.filter(ContractInfo.is_active == is_active)
query = query.order_by(ContractInfo.symbol)
return query.all()
finally:
db.close()
def get_contract(self, symbol: str) -> Optional[ContractInfo]:
"""查询单个合约"""
db = SessionLocal()
try:
return db.query(ContractInfo).filter(ContractInfo.symbol == symbol).first()
finally:
db.close()
contract_service = ContractService()

@ -0,0 +1,318 @@
import akshare as ak
import pandas as pd
import random
import time
import logging
import requests
from typing import List, Optional, Dict
from datetime import datetime
from app.services.datasource.base import DataSourceBase
logger = logging.getLogger(__name__)
class SmartRequester:
"""
反爬综合管理器集成 Headers 伪装拟人化延时重试机制
(IP 代理部分暂按文档要求空缺后续扩展)
"""
def __init__(self, max_retries: int = 3):
self.max_retries = max_retries
self.session = requests.Session()
# User-Agent 池
self.user_agents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.42",
]
def get_random_headers(self, referer: str = "https://finance.sina.com.cn/") -> Dict[str, str]:
"""生成随机请求头,模拟真实浏览器"""
return {
"User-Agent": random.choice(self.user_agents),
"Referer": referer,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
def request(self, url, referer="https://finance.sina.com.cn/", method="GET", **kwargs) -> Optional[requests.Response]:
"""执行智能请求"""
last_error = None
for attempt in range(self.max_retries):
try:
# 1. 拟人化延时
if attempt == 0:
time.sleep(random.uniform(0.5, 1.5))
else:
delay = (2 ** attempt) + random.uniform(1, 3)
logging.warning(f"{attempt+1} 次重试,等待 {delay:.1f} 秒...")
time.sleep(delay)
# 2. 轮换 Headers
headers = kwargs.pop("headers", {})
random_headers = self.get_random_headers(referer=referer)
headers.update(random_headers)
kwargs["headers"] = headers
# 3. 发送请求
logging.debug(f"请求: {method} {url}")
response = self.session.request(method, url, timeout=10, **kwargs)
# 4. 检查状态
if response.status_code == 200:
logging.info("✅ 请求成功")
return response
elif response.status_code == 403:
logging.warning("⚠️ 收到 403 Forbidden将尝试重试")
raise requests.exceptions.HTTPError("403 Forbidden")
else:
response.raise_for_status()
except Exception as e:
last_error = e
logging.warning(f"❌ 请求失败: {str(e)}")
logging.error(f"🚫 所有 {self.max_retries} 次尝试均失败")
return None
class AkshareSource(DataSourceBase):
"""AKShare 数据源适配器"""
def __init__(self, config: dict):
super().__init__(config)
self.name = "akshare"
self.requester = SmartRequester(max_retries=config.get("max_retries", 3))
def initialize(self) -> bool:
"""初始化检查"""
try:
ak.__version__
self._initialized = True
return True
except Exception as e:
self._initialized = False
logging.error(f"AkshareSource 初始化失败: {e}")
return False
def get_contract_list(self, exchange: Optional[str] = None) -> List[dict]:
"""获取期货合约列表"""
if not self._initialized:
self.initialize()
results = []
try:
exchanges_to_fetch = ["CZCE", "DCE", "SHFE", "INE", "CFFEX", "GFEX"]
if exchange:
exchanges_to_fetch = [exchange]
for ex in exchanges_to_fetch:
try:
func_name = f"futures_contract_info_{ex.lower()}"
if hasattr(ak, func_name):
df = getattr(ak, func_name)()
else:
continue
if df is not None and not df.empty:
# 统一列名映射
col_map = {}
if '合约代码' in df.columns:
col_map['合约代码'] = 'symbol'
if '产品代码' in df.columns:
col_map['产品代码'] = 'product'
if '品种' in df.columns:
col_map['品种'] = 'product'
if '交易单位' in df.columns:
col_map['交易单位'] = 'multiplier'
if '最小变动价位' in df.columns:
col_map['最小变动价位'] = 'price_tick'
if '上市日' in df.columns:
col_map['上市日'] = 'list_date'
if '到期日' in df.columns:
col_map['到期日'] = 'expire_date'
df = df.rename(columns=col_map)
for _, row in df.iterrows():
symbol = row.get("symbol")
if not symbol:
continue
# 提取品种代码 (通常 symbol 最后两位是年月,前面是品种,如 rb2401 -> rb)
product = row.get("product", "")
if not product and len(symbol) > 2:
# 简单提取字母部分
import re
match = re.match(r"([a-zA-Z]+)", symbol)
if match:
product = match.group(1).lower()
multiplier = row.get("multiplier")
if multiplier:
try:
multiplier = int(float(str(multiplier).replace(',', '')))
except:
multiplier = 10
else:
multiplier = 10 # 默认值
price_tick = row.get("price_tick")
if price_tick:
try:
price_tick = float(str(price_tick).replace(',', ''))
except:
price_tick = None
results.append({
"symbol": symbol,
"exchange": ex,
"name": symbol, # AKShare 通常不返回中文名称,用代码代替
"product": product,
"multiplier": multiplier,
"price_tick": price_tick,
"expire_date": self._parse_date(row.get("expire_date")),
"is_active": True,
})
except Exception as e:
logging.warning(f"获取 {ex} 合约列表失败: {e}")
continue
except Exception as e:
logging.error(f"获取合约列表异常: {e}")
return results
def _parse_date(self, date_str) -> Optional[datetime]:
"""解析日期"""
if not date_str:
return None
try:
if isinstance(date_str, str):
return datetime.strptime(date_str, "%Y-%m-%d")
elif isinstance(date_str, pd.Timestamp):
return date_str.to_pydatetime()
return None
except:
return None
def get_kline_daily(self, symbol: str, start_date: str, end_date: str) -> pd.DataFrame:
"""获取日 K 线数据"""
logger.info(f"[AKShare-日K线] 开始获取 symbol={symbol}, start_date={start_date}, end_date={end_date}")
if not self._initialized:
logger.info(f"[AKShare-日K线] 初始化 AKShare")
self.initialize()
try:
# AKShare 期货日 K 线接口futures_zh_daily_sina
logger.info(f"[AKShare-日K线] 调用 ak.futures_zh_daily_sina(symbol='{symbol}')")
df = ak.futures_zh_daily_sina(symbol=symbol)
if df is None or df.empty:
logger.warning(f"[AKShare-日K线] AKShare 返回空数据symbol={symbol}")
return pd.DataFrame()
logger.info(f"[AKShare-日K线] AKShare 返回 {len(df)} 条原始数据")
logger.debug(f"[AKShare-日K线] 原始数据列: {df.columns.tolist()}")
logger.debug(f"[AKShare-日K线] 原始数据样例:\n{df.head()}")
# 过滤日期范围
df['date'] = pd.to_datetime(df['date'])
logger.debug(f"[AKShare-日K线] 日期范围: {df['date'].min()} ~ {df['date'].max()}")
mask = (df['date'] >= start_date) & (df['date'] <= end_date)
df_filtered = df.loc[mask].copy()
logger.info(f"[AKShare-日K线] 日期过滤后剩余 {len(df_filtered)} 条记录")
# 统一列名
df_filtered = df_filtered.rename(columns={
"date": "trade_date",
"open": "open",
"high": "high",
"low": "low",
"close": "close",
"volume": "volume",
"hold": "open_interest",
"settle": "settle",
})
# AKShare 日线通常没有 turnover 和 pre_settle置空
df_filtered["turnover"] = None
df_filtered["pre_settle"] = None
df_filtered["trade_date"] = pd.to_datetime(df_filtered["trade_date"])
logger.info(f"[AKShare-日K线] 最终返回 {len(df_filtered)} 条记录")
return df_filtered[["trade_date", "open", "high", "low", "close", "volume", "turnover", "open_interest", "settle", "pre_settle"]]
except Exception as e:
logger.error(f"[AKShare-日K线] 获取 {symbol} 日 K 线失败: {e}", exc_info=True)
return pd.DataFrame()
def get_kline_weekly(self, symbol: str, start_date: str, end_date: str) -> pd.DataFrame:
"""获取周 K 线数据 (通过日 K 聚合)"""
daily_df = self.get_kline_daily(symbol, start_date, end_date)
if daily_df.empty:
return pd.DataFrame()
daily_df = daily_df.set_index("trade_date")
weekly = daily_df.resample("W-FRI").agg({
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
"turnover": "sum",
"open_interest": "last",
}).dropna()
weekly = weekly.reset_index()
weekly = weekly.rename(columns={"trade_date": "trade_date"})
return weekly
def get_kline_intraday(self, symbol: str, period: str, start_date: str, end_date: str) -> pd.DataFrame:
"""获取分钟级 K 线数据"""
if not self._initialized:
self.initialize()
try:
# AKShare 期货分钟 K 线接口futures_zh_minute_sina
period_map = {"5m": "5", "15m": "15", "30m": "30", "60m": "60"}
freq = period_map.get(period, "5")
df = ak.futures_zh_minute_sina(symbol=symbol, period=freq)
if df is None or df.empty:
return pd.DataFrame()
# 过滤日期
df['datetime'] = pd.to_datetime(df['datetime'])
mask = (df['datetime'] >= start_date) & (df['datetime'] <= end_date)
df = df.loc[mask].copy()
# 统一列名
df = df.rename(columns={
"datetime": "trade_time",
"open": "open",
"high": "high",
"low": "low",
"close": "close",
"volume": "volume",
"hold": "open_interest",
})
df["turnover"] = None
return df[["trade_time", "open", "high", "low", "close", "volume", "turnover", "open_interest"]]
except Exception as e:
logging.error(f"获取 {symbol} 分钟 K 线失败: {e}")
return pd.DataFrame()

@ -0,0 +1,76 @@
from abc import ABC, abstractmethod
from typing import List, Optional
from datetime import datetime
import pandas as pd
class DataSourceBase(ABC):
"""数据源基类,所有数据源适配器必须实现这些接口"""
def __init__(self, config: dict):
self.config = config
self.name = self.__class__.__name__
self._initialized = False
@abstractmethod
def initialize(self) -> bool:
"""初始化数据源连接,返回是否成功"""
pass
@abstractmethod
def get_contract_list(self, exchange: Optional[str] = None) -> List[dict]:
"""
获取合约列表
返回: [{"symbol": "rb2401", "exchange": "SHFE", "name": "螺纹钢2401", ...}]
"""
pass
@abstractmethod
def get_kline_daily(
self,
symbol: str,
start_date: str,
end_date: str
) -> pd.DataFrame:
"""
获取日K线数据
返回 DataFrame 包含: trade_date, open, high, low, close, volume, turnover, open_interest, settle, pre_settle
"""
pass
@abstractmethod
def get_kline_weekly(
self,
symbol: str,
start_date: str,
end_date: str
) -> pd.DataFrame:
"""
获取周K线数据
返回 DataFrame 包含: trade_date, open, high, low, close, volume, turnover, open_interest
"""
pass
@abstractmethod
def get_kline_intraday(
self,
symbol: str,
period: str, # 5m/15m/30m/60m
start_date: str,
end_date: str
) -> pd.DataFrame:
"""
获取分钟级K线数据
返回 DataFrame 包含: trade_time, open, high, low, close, volume, turnover, open_interest
"""
pass
def health_check(self) -> tuple[bool, str]:
"""健康检查,返回 (是否健康, 错误信息)"""
try:
ok = self.initialize()
if ok:
return True, ""
return False, "初始化失败"
except Exception as e:
return False, str(e)

@ -0,0 +1,97 @@
from typing import Dict, Optional, List
import json
from app.services.datasource.base import DataSourceBase
from app.services.datasource.tushare import TushareSource
from app.services.datasource.akshare import AkshareSource
from app.database import SessionLocal
from app.models import DataSourceConfig
class DataSourceManager:
"""数据源管理器:管理多个数据源的注册、切换和调用"""
_sources: Dict[str, DataSourceBase] = {}
_source_map = {
"tushare": TushareSource,
"akshare": AkshareSource,
# "ctp": CtpSource, # 后续扩展
}
@classmethod
def register(cls, name: str, source_class):
"""注册新的数据源类型"""
cls._source_map[name] = source_class
@classmethod
def get_source(cls, name: str) -> Optional[DataSourceBase]:
"""获取已初始化的数据源实例"""
return cls._sources.get(name)
@classmethod
def load_enabled_sources(cls):
"""从数据库加载启用的数据源"""
db = SessionLocal()
try:
configs = db.query(DataSourceConfig).filter(
DataSourceConfig.is_enabled == True
).order_by(DataSourceConfig.priority).all()
for cfg in configs:
if cfg.source_name in cls._source_map:
source_class = cls._source_map[cfg.source_name]
try:
config = json.loads(cfg.config_json) if cfg.config_json else {}
except json.JSONDecodeError:
config = {}
source = source_class(config)
cls._sources[cfg.source_name] = source
finally:
db.close()
@classmethod
def get_primary_source(cls) -> Optional[DataSourceBase]:
"""获取优先级最高的已启用数据源"""
if not cls._sources:
cls.load_enabled_sources()
# 按优先级排序
db = SessionLocal()
try:
primary_cfg = db.query(DataSourceConfig).filter(
DataSourceConfig.is_enabled == True
).order_by(DataSourceConfig.priority).first()
if primary_cfg and primary_cfg.source_name in cls._sources:
return cls._sources[primary_cfg.source_name]
return None
finally:
db.close()
@classmethod
def get_all_sources_status(cls) -> List[dict]:
"""获取所有数据源状态"""
db = SessionLocal()
try:
configs = db.query(DataSourceConfig).all()
result = []
for cfg in configs:
# 解析 config_json
try:
config_json = json.loads(cfg.config_json) if cfg.config_json else {}
except json.JSONDecodeError:
config_json = {}
status = {
"source_name": cfg.source_name,
"display_name": cfg.display_name,
"is_enabled": cfg.is_enabled,
"priority": cfg.priority,
"status": cfg.status,
"error_msg": cfg.error_msg,
"last_sync_time": cfg.last_sync_time,
"config_json": config_json,
}
result.append(status)
return result
finally:
db.close()

@ -0,0 +1,201 @@
import tushare as ts
import pandas as pd
from typing import List, Optional
from datetime import datetime
from app.services.datasource.base import DataSourceBase
from app.config import settings
class TushareSource(DataSourceBase):
"""Tushare 数据源适配器"""
def __init__(self, config: dict):
super().__init__(config)
self.name = "tushare"
self.pro = None
self._token = config.get("token", settings.TUSHARE_TOKEN)
def initialize(self) -> bool:
"""初始化 Tushare 连接"""
try:
ts.set_token(self._token)
self.pro = ts.pro_api()
# 简单测试连接
self.pro.trade_cal(exchange="DCE", start_date="20240101", end_date="20240105")
self._initialized = True
return True
except Exception as e:
self._initialized = False
raise e
def _format_date(self, date_str: str) -> str:
"""将 YYYY-MM-DD 转换为 YYYYMMDD"""
return date_str.replace("-", "")
def get_contract_list(self, exchange: Optional[str] = None) -> List[dict]:
"""获取期货合约列表"""
if not self._initialized:
self.initialize()
# Tushare 获取合约信息
df = self.pro.fut_basic(
exchange=exchange or "CFFEX", # 需要分别查询每个交易所
fut_type="1", # 1=标准合约
fut_series=""
)
results = []
if df is not None and not df.empty:
for _, row in df.iterrows():
results.append({
"symbol": row.get("symbol", ""),
"exchange": self._map_exchange(row.get("exchange", "")),
"name": row.get("name", ""),
"product": row.get("underlying_symbol", ""),
"multiplier": int(row.get("contract_multiplier", 10)) if row.get("contract_multiplier") else 10,
"price_tick": float(row.get("price_tick", 0)) if row.get("price_tick") else None,
"expire_date": self._parse_date(row.get("delist_date", "")),
"is_active": row.get("list_status", "") == "L",
})
return results
def _map_exchange(self, exchange: str) -> str:
"""交易所代码映射"""
mapping = {
"CFFEX": "CFFEX",
"SHFE": "SHFE",
"DCE": "DCE",
"CZCE": "ZCE", # Tushare 用 CZCE我们统一用 ZCE
"INE": "INE",
"GFEX": "GFEX",
}
return mapping.get(exchange, exchange)
def _parse_date(self, date_str: str) -> Optional[datetime]:
"""解析日期字符串"""
if not date_str:
return None
try:
return datetime.strptime(str(date_str), "%Y%m%d")
except Exception:
return None
def get_kline_daily(
self,
symbol: str,
start_date: str,
end_date: str
) -> pd.DataFrame:
"""获取日K线数据"""
if not self._initialized:
self.initialize()
start = self._format_date(start_date)
end = self._format_date(end_date)
df = self.pro.fut_daily(
ts_code=symbol,
start_date=start,
end_date=end
)
if df is None or df.empty:
return pd.DataFrame()
# 统一列名
df = df.rename(columns={
"trade_date": "trade_date",
"open": "open",
"high": "high",
"low": "low",
"close": "close",
"vol": "volume",
"amount": "turnover",
"oi": "open_interest",
"settle": "settle",
"pre_settle": "pre_settle",
})
df["trade_date"] = pd.to_datetime(df["trade_date"])
return df[["trade_date", "open", "high", "low", "close", "volume", "turnover", "open_interest", "settle", "pre_settle"]]
def get_kline_weekly(
self,
symbol: str,
start_date: str,
end_date: str
) -> pd.DataFrame:
"""
获取周K线数据
Tushare 没有直接的周K接口通过日K聚合
"""
daily_df = self.get_kline_daily(symbol, start_date, end_date)
if daily_df.empty:
return pd.DataFrame()
# 按周聚合
daily_df = daily_df.set_index("trade_date")
weekly = daily_df.resample("W-FRI").agg({
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
"turnover": "sum",
"open_interest": "last",
}).dropna()
weekly = weekly.reset_index()
weekly = weekly.rename(columns={"trade_date": "trade_date"})
return weekly
def get_kline_intraday(
self,
symbol: str,
period: str,
start_date: str,
end_date: str
) -> pd.DataFrame:
"""
获取分钟级K线数据
Tushare fut_mins 接口
"""
if not self._initialized:
self.initialize()
# Tushare fut_mins 接口
start = self._format_date(start_date)
end = self._format_date(end_date)
# 分钟数映射
freq_map = {"5m": "5", "15m": "15", "30m": "30", "60m": "60"}
freq = freq_map.get(period, "5")
try:
df = self.pro.fut_mins(
ts_code=symbol,
freq=freq,
start_date=start,
end_date=end
)
except Exception:
# 部分交易所可能不支持分钟数据
return pd.DataFrame()
if df is None or df.empty:
return pd.DataFrame()
df = df.rename(columns={
"trade_time": "trade_time",
"open": "open",
"high": "high",
"low": "low",
"close": "close",
"vol": "volume",
"amount": "turnover",
"oi": "open_interest",
})
df["trade_time"] = pd.to_datetime(df["trade_time"])
return df[["trade_time", "open", "high", "low", "close", "volume", "turnover", "open_interest"]]

@ -0,0 +1,346 @@
from typing import List, Optional
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import and_
import pandas as pd
import logging
from app.models import KlineDaily, KlineWeekly, KlineIntraday, ContractInfo
from app.services.datasource.manager import DataSourceManager
from app.database import SessionLocal
logger = logging.getLogger(__name__)
class KlineService:
"""K线数据服务负责从数据源拉取数据、存储到数据库、查询缓存"""
def __init__(self):
self.manager = DataSourceManager()
def _ensure_source(self):
"""确保数据源已加载"""
source = self.manager.get_primary_source()
if not source:
raise Exception("没有可用的数据源,请先在管理后台配置并启用数据源")
return source
# ========== 同步数据 ==========
def sync_daily(
self,
symbol: str,
start_date: str,
end_date: str
) -> int:
"""同步日K线数据到数据库"""
logger.info(f"[同步日K线] 开始同步 symbol={symbol}, start_date={start_date}, end_date={end_date}")
source = self._ensure_source()
logger.info(f"[同步日K线] 使用数据源: {source.name}")
df = source.get_kline_daily(symbol, start_date, end_date)
logger.info(f"[同步日K线] 从数据源获取到 {len(df)} 条记录")
if df.empty:
logger.warning(f"[同步日K线] 数据源返回空数据symbol={symbol}")
return 0
db = SessionLocal()
count = 0
try:
for _, row in df.iterrows():
kline = db.query(KlineDaily).filter(
and_(
KlineDaily.symbol == symbol,
KlineDaily.trade_date == row["trade_date"]
)
).first()
if kline:
kline.open = row.get("open")
kline.high = row.get("high")
kline.low = row.get("low")
kline.close = row.get("close")
kline.volume = row.get("volume")
kline.turnover = row.get("turnover")
kline.open_interest = row.get("open_interest")
kline.settle = row.get("settle")
kline.pre_settle = row.get("pre_settle")
kline.updated_at = datetime.utcnow()
else:
kline = KlineDaily(
symbol=symbol,
trade_date=row["trade_date"],
open=row.get("open"),
high=row.get("high"),
low=row.get("low"),
close=row.get("close"),
volume=row.get("volume"),
turnover=row.get("turnover"),
open_interest=row.get("open_interest"),
settle=row.get("settle"),
pre_settle=row.get("pre_settle"),
)
db.add(kline)
count += 1
db.commit()
logger.info(f"[同步日K线] 成功同步 {count} 条记录到数据库")
except Exception as e:
db.rollback()
logger.error(f"[同步日K线] 同步失败: {e}", exc_info=True)
raise
finally:
db.close()
return count
def sync_weekly(
self,
symbol: str,
start_date: str,
end_date: str
) -> int:
"""同步周K线数据"""
source = self._ensure_source()
df = source.get_kline_weekly(symbol, start_date, end_date)
if df.empty:
return 0
db = SessionLocal()
count = 0
try:
for _, row in df.iterrows():
kline = db.query(KlineWeekly).filter(
and_(
KlineWeekly.symbol == symbol,
KlineWeekly.trade_date == row["trade_date"]
)
).first()
if kline:
kline.open = row.get("open")
kline.high = row.get("high")
kline.low = row.get("low")
kline.close = row.get("close")
kline.volume = row.get("volume")
kline.turnover = row.get("turnover")
kline.open_interest = row.get("open_interest")
kline.updated_at = datetime.utcnow()
else:
kline = KlineWeekly(
symbol=symbol,
trade_date=row["trade_date"],
open=row.get("open"),
high=row.get("high"),
low=row.get("low"),
close=row.get("close"),
volume=row.get("volume"),
turnover=row.get("turnover"),
open_interest=row.get("open_interest"),
)
db.add(kline)
count += 1
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
return count
def sync_intraday(
self,
symbol: str,
period: str,
start_date: str,
end_date: str
) -> int:
"""同步分钟级K线数据"""
source = self._ensure_source()
df = source.get_kline_intraday(symbol, period, start_date, end_date)
if df.empty:
return 0
db = SessionLocal()
count = 0
try:
for _, row in df.iterrows():
kline = db.query(KlineIntraday).filter(
and_(
KlineIntraday.symbol == symbol,
KlineIntraday.period == period,
KlineIntraday.trade_time == row["trade_time"]
)
).first()
if kline:
kline.open = row.get("open")
kline.high = row.get("high")
kline.low = row.get("low")
kline.close = row.get("close")
kline.volume = row.get("volume")
kline.turnover = row.get("turnover")
kline.open_interest = row.get("open_interest")
kline.updated_at = datetime.utcnow()
else:
kline = KlineIntraday(
symbol=symbol,
period=period,
trade_time=row["trade_time"],
open=row.get("open"),
high=row.get("high"),
low=row.get("low"),
close=row.get("close"),
volume=row.get("volume"),
turnover=row.get("turnover"),
open_interest=row.get("open_interest"),
)
db.add(kline)
count += 1
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
return count
# ========== 查询数据 ==========
def get_kline(
self,
symbol: str,
period: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 500
) -> List[dict]:
"""查询K线数据优先查库如果数据库没有数据则自动同步"""
logger.info(f"[查询K线] 开始查询 symbol={symbol}, period={period}, start_date={start_date}, end_date={end_date}, limit={limit}")
db = SessionLocal()
try:
if period == "daily":
items = self._query_daily(db, symbol, start_date, end_date, limit)
elif period == "weekly":
items = self._query_weekly(db, symbol, start_date, end_date, limit)
else:
items = self._query_intraday(db, symbol, period, start_date, end_date, limit)
# 如果数据库中没有数据,自动同步
if len(items) == 0:
logger.info(f"[查询K线] 数据库中没有 {symbol}{period} K线数据开始自动同步")
try:
sync_start = start_date or "2020-01-01"
sync_end = end_date or datetime.now().strftime("%Y-%m-%d")
logger.info(f"[查询K线] 自动同步日期范围: {sync_start} ~ {sync_end}")
if period == "daily":
count = self.sync_daily(symbol, sync_start, sync_end)
elif period == "weekly":
count = self.sync_weekly(symbol, sync_start, sync_end)
else:
count = self.sync_intraday(symbol, period, sync_start, sync_end)
if count > 0:
logger.info(f"[查询K线] 自动同步成功,共同步 {count} 条记录,重新查询数据库")
# 重新查询数据库获取同步后的数据
if period == "daily":
items = self._query_daily(db, symbol, start_date, end_date, limit)
elif period == "weekly":
items = self._query_weekly(db, symbol, start_date, end_date, limit)
else:
items = self._query_intraday(db, symbol, period, start_date, end_date, limit)
else:
logger.warning(f"[查询K线] 自动同步完成,但数据源返回空数据")
except Exception as e:
logger.error(f"[查询K线] 自动同步失败: {e}", exc_info=True)
# 同步失败不影响查询,继续返回空结果
return items
finally:
db.close()
def _query_daily(self, db: Session, symbol: str, start_date: str, end_date: str, limit: int) -> List[dict]:
logger.info(f"[查询日K线] symbol={symbol}, start_date={start_date}, end_date={end_date}, limit={limit}")
query = db.query(KlineDaily).filter(KlineDaily.symbol == symbol)
if start_date:
query = query.filter(KlineDaily.trade_date >= start_date)
if end_date:
query = query.filter(KlineDaily.trade_date <= end_date)
query = query.order_by(KlineDaily.trade_date.desc()).limit(limit)
items = query.all()
logger.info(f"[查询日K线] 从数据库查询到 {len(items)} 条记录")
return [
{
"trade_time": item.trade_date,
"open": item.open,
"high": item.high,
"low": item.low,
"close": item.close,
"volume": item.volume,
"turnover": item.turnover,
"open_interest": item.open_interest,
}
for item in items
]
def _query_weekly(self, db: Session, symbol: str, start_date: str, end_date: str, limit: int) -> List[dict]:
query = db.query(KlineWeekly).filter(KlineWeekly.symbol == symbol)
if start_date:
query = query.filter(KlineWeekly.trade_date >= start_date)
if end_date:
query = query.filter(KlineWeekly.trade_date <= end_date)
query = query.order_by(KlineWeekly.trade_date.desc()).limit(limit)
items = query.all()
return [
{
"trade_time": item.trade_date,
"open": item.open,
"high": item.high,
"low": item.low,
"close": item.close,
"volume": item.volume,
"turnover": item.turnover,
"open_interest": item.open_interest,
}
for item in items
]
def _query_intraday(self, db: Session, symbol: str, period: str, start_date: str, end_date: str, limit: int) -> List[dict]:
query = db.query(KlineIntraday).filter(
and_(
KlineIntraday.symbol == symbol,
KlineIntraday.period == period
)
)
if start_date:
query = query.filter(KlineIntraday.trade_time >= start_date)
if end_date:
query = query.filter(KlineIntraday.trade_time <= end_date)
query = query.order_by(KlineIntraday.trade_time.desc()).limit(limit)
items = query.all()
return [
{
"trade_time": item.trade_time,
"open": item.open,
"high": item.high,
"low": item.low,
"close": item.close,
"volume": item.volume,
"turnover": item.turnover,
"open_interest": item.open_interest,
}
for item in items
]
kline_service = KlineService()

@ -0,0 +1,14 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
alembic==1.14.0
pydantic==2.10.3
pydantic-settings==2.7.0
tushare==1.4.21
akshare
redis==5.2.1
httpx==0.28.1
python-dotenv==1.0.1
pandas==2.2.3
apscheduler==3.10.4

@ -0,0 +1,66 @@
version: '3.8'
services:
postgres:
image: timescale/timescaledb:latest-pg16
environment:
POSTGRES_DB: futures_data
POSTGRES_USER: futures
POSTGRES_PASSWORD: futures123
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U futures -d futures_data"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
backend:
build:
context: ./backend
dockerfile: ../Dockerfile.backend
ports:
- "8000:8000"
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: futures_data
DB_USER: futures
DB_PASSWORD: futures123
REDIS_HOST: redis
REDIS_PORT: 6379
TUSHARE_TOKEN: ${TUSHARE_TOKEN:-}
volumes:
- ./backend/app:/app/app
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build:
context: ./frontend
dockerfile: ../Dockerfile.frontend
ports:
- "3000:3000"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
volumes:
pg_data:

@ -0,0 +1,202 @@
# 期货统一数据平台 - 项目文档
> **版本**: 0.1.0
> **状态**: 开发中 (Dev)
> **维护者**: 小马哥
---
## 1. 项目概述
本项目是一个**期货统一数据平台**,旨在解决多数据源接入、数据格式不统一、历史行情查询困难等问题。平台提供标准化的 RESTful API 供外部系统或 AI 调用,并配备管理后台进行数据源配置、合约管理及行情可视化。
### 核心目标
- **统一出口**:无论底层是 Tushare、CTP 还是其他数据源,对外提供统一的数据格式。
- **灵活配置**:支持多数据源切换、优先级管理。
- **高效缓存**:针对历史 K 线数据(日 K/周 K/分钟级)进行分级存储。
- **易于扩展**:基于适配器模式,新增数据源只需实现标准接口。
---
## 2. 技术架构
### 2.1 技术栈
| 层次 | 技术选型 | 说明 |
|------|----------|------|
| **前端** | Vue3 + Vite + Element Plus | 现代化 SPA响应式布局 |
| **图表** | Apache ECharts 5 | 专业金融 K 线图表渲染 |
| **后端** | Python 3.11 + FastAPI | 高性能异步 Web 框架 |
| **数据库** | PostgreSQL + TimescaleDB | 专为时序数据优化,支持高效压缩与查询 |
| **本地开发** | SQLite | 无需外部依赖,快速验证 |
| **缓存/消息** | Redis | 实时行情缓存与发布订阅 |
| **数据源** | Tushare / AKShare / CTP (规划) | 外部数据接入 |
| **部署** | Docker Compose | 一键环境搭建 |
### 2.2 数据源架构
```text
[外部系统/AI] <--> [统一 API 网关] <--> [业务逻辑层] <--> [数据源适配器]
|
[缓存层 (Redis)]
|
[持久层 (PostgreSQL)]
```
- **适配器模式**`DataSourceBase` 定义了 `get_kline_daily`、`get_contract_list` 等标准方法。
- **管理器模式**`DataSourceManager` 负责加载配置、优先级排序和实例管理。
---
## 3. 部署与启动指南
### 3.1 环境准备
- Docker & Docker Compose生产/测试环境)
- Python 3.11+(本地开发环境)
- Node.js 20+(前端开发)
### 3.2 Docker Compose 部署(推荐)
适用于拥有完整基础设施PostgreSQL, Redis的环境。
```bash
cd share_data/project/futures-data-platform
# 1. 配置环境变量
cp .env.example .env
# 编辑 .env填入你的 Tushare Token: TUSHARE_TOKEN=xxxxxx
# 2. 启动服务
docker compose up -d --build
# 3. 查看日志
docker compose logs -f backend
```
**服务地址:**
- 管理后台http://localhost:3000
- 后端 APIhttp://localhost:8000
- Swagger 文档http://localhost:8000/docs
### 3.3 本地开发模式SQLite
适用于快速调试,无需启动数据库容器。
```bash
cd backend
# 1. 安装依赖
pip install -r requirements.txt
# 2. 使用 SQLite 启动后端
export DATABASE_URL="sqlite:///./futures.db"
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 3. 启动前端 (新终端)
cd frontend
npm install
npm run dev
```
---
## 4. 操作指南
### 4.1 配置数据源
1. 进入**"数据源监控"**页面。
2. 点击 `tushare``akshare` 行的**"配置"**按钮。
3. 在弹出的配置对话框中:
- **启用状态**:使用开关快速启用/禁用该数据源
- **显示名称**:自定义数据源在界面上的显示名称
- **Token** (Tushare):填入你的 Tushare API Token
- **最大重试次数** (AKShare):设置反爬重试策略
- **优先级**:数字越小优先级越高,系统将优先使用高优先级数据源
4. 点击**"测试连接"**,确认状态为 `ok`
5. 点击**"保存"**完成配置。
> **提示**:也可在表格的"启用"列直接点击开关快速切换启用/禁用状态,无需打开配置对话框。
### 4.2 同步合约信息
1. 进入**"合约管理"**页面。
2. 点击右上角**"同步合约"**按钮。
3. 系统将遍历所有交易所CFFEX, SHFE, DCE 等)拉取合约信息并入库。
4. 同步完成后,列表将显示所有活跃合约。
### 4.3 查询与展示 K 线
1. 进入**"K 线查询"**页面。
2. 选择合约(如 `rb2401`)和周期(如 `日 K`)。
3. 点击**"同步数据"**(首次查询建议同步,后续直接查询数据库)。
4. 点击**"查询"**,下方将渲染 ECharts K 线图和详细数据表格。
---
## 5. 注意事项
### 5.1 Tushare 积分限制
- 不同积分等级对数据调用的频率和数据范围有限制。
- 高频调用分钟级数据或全量历史数据可能需要较高级别的积分(如 2000 分以上)。
- **建议**:在"接口测试"中先测试单个合约的同步,确认返回数据正常后再批量同步。
### 5.2 AKShare 反爬策略
AKShare 数据源内置了 `SmartRequester` 反爬管理器,包含以下策略:
- **随机 User-Agent**:每次请求轮换浏览器指纹。
- **拟人化延时**:首次请求随机延迟 0.5~1.5s,重试时指数级增加。
- **智能重试**:遇到 403 或网络错误自动重试(默认 3 次)。
- **Referer 伪装**:自动匹配目标网站的 Referer。
> 注IP 代理功能预留接口,当前版本主要依赖频率控制和头部伪装。
### 5.3 数据一致性
- 平台默认**"优先读库"**策略。如果数据库中已有缓存数据,直接返回;若无,则调用数据源。
- `sync_kline` 接口采用 `upsert` 逻辑(插入或更新),支持增量同步,不会破坏已有数据。
### 5.4 生产环境配置
- **数据库**:务必使用 Docker Compose 中的 PostgreSQL + TimescaleDBSQLite 仅用于本地开发。
- **安全性**:生产环境应移除 `cors.allow_origins=["*"]` 的宽泛策略,改为白名单;数据库密码应通过 Secret 管理。
---
## 6. 后续规划与改进
### Phase 1: 基础完善 (当前阶段)
- [x] 历史 K 线数据同步与查询(日 K/周 K/分钟)
- [x] 合约信息管理
- [x] Tushare 数据源接入
- [x] AKShare 数据源接入 (含反爬策略)
- [x] 管理后台基础功能
### Phase 2: 实时行情与推送
- [ ] **实时行情引擎**:开发 CTP 适配器,接入实时 Tick 和 K 线。
- [ ] **WebSocket 订阅**:实现实时数据推送服务,支持客户端按合约订阅。
- [ ] **行情快照缓存**:利用 Redis 实现最新行情快照的快速读取。
### Phase 3: 性能与高可用
- [ ] **TimescaleDB 优化**配置超表Hypertables和自动压缩策略。
- [ ] **定时任务调度**:使用 APScheduler 实现每日收盘后的自动数据同步。
- [ ] **多数据源回退**:主数据源异常时,自动切换到备用数据源。
### Phase 4: 高级功能
- [ ] **数据清洗与校验**:增加数据质量监控,自动识别异常跳空或缺失数据。
- [ ] **AI 辅助接口**:提供专为大模型优化的自然语言转 SQL 接口。
---
## 7. 开发规范
- **代码风格**:后端遵循 PEP 8使用 `black` 格式化;前端遵循 ESLint + Prettier。
- **提交规范**
- `feat`: 新功能
- `fix`: 修复
- `docs`: 文档更新
- `refactor`: 重构
- **API 响应格式**
```json
{
"code": 0,
"message": "ok",
"data": { ... }
}
```
---
> 文档最后更新2026-05-07

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>期货统一数据平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,24 @@
{
"name": "futures-data-platform-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"echarts": "^5.5.1",
"element-plus": "^2.9.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"vite": "^6.4.2"
}
}

@ -0,0 +1,97 @@
<template>
<el-container style="height: 100vh">
<el-aside width="200px">
<div class="logo">期货数据平台</div>
<el-menu
:default-active="$route.path"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item index="/datasource">
<el-icon><Monitor /></el-icon>
<span>数据源监控</span>
</el-menu-item>
<el-menu-item index="/contracts">
<el-icon><Document /></el-icon>
<span>合约管理</span>
</el-menu-item>
<el-menu-item index="/kline">
<el-icon><TrendCharts /></el-icon>
<span>K线查询</span>
</el-menu-item>
<el-menu-item index="/api-test">
<el-icon><Tools /></el-icon>
<span>接口测试</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="background: #fff; border-bottom: 1px solid #e6e6e6; display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 18px; font-weight: bold;">期货统一数据平台</span>
<el-tag v-if="healthStatus" :type="healthStatus === 'healthy' ? 'success' : 'warning'" size="small">
{{ healthStatus === 'healthy' ? '系统正常' : '部分异常' }}
</el-tag>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Monitor, Document, TrendCharts, Tools } from '@element-plus/icons-vue'
import axios from 'axios'
const healthStatus = ref('')
const checkHealth = async () => {
try {
const res = await axios.get('/api/health')
healthStatus.value = res.data.status
} catch {
healthStatus.value = 'error'
}
}
onMounted(() => {
checkHealth()
setInterval(checkHealth, 30000)
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.logo {
height: 60px;
line-height: 60px;
text-align: center;
font-size: 16px;
font-weight: bold;
color: #fff;
background: #263445;
}
.el-aside {
background: #304156;
}
.el-main {
background: #f0f2f5;
padding: 20px;
}
</style>

@ -0,0 +1,26 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/v1',
timeout: 30000,
})
// 健康检查
export const getHealth = () => axios.get('/api/health')
// 合约
export const getContracts = (params) => api.get('/contracts', { params })
export const getContract = (symbol) => api.get(`/contracts/${symbol}`)
export const syncContracts = () => api.post('/contracts/sync')
// K线
export const getKline = (params) => api.get('/kline', { params })
export const syncKline = (data) => api.post('/kline/sync', data)
// 数据源
export const getDatasources = () => api.get('/datasources')
export const createDatasource = (data) => api.post('/datasources', data)
export const updateDatasource = (name, data) => api.put(`/datasources/${name}`, data)
export const testDatasource = (name) => api.post(`/datasources/${name}/test`)
export default api

@ -0,0 +1,29 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import DataSourceView from './views/DataSourceView.vue'
import ContractView from './views/ContractView.vue'
import KlineView from './views/KlineView.vue'
import ApiTestView from './views/ApiTestView.vue'
const routes = [
{ path: '/', redirect: '/datasource' },
{ path: '/datasource', name: 'datasource', component: DataSourceView },
{ path: '/contracts', name: 'contracts', component: ContractView },
{ path: '/kline', name: 'kline', component: KlineView },
{ path: '/api-test', name: 'api-test', component: ApiTestView },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
const app = createApp(App)
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')

@ -0,0 +1,193 @@
<template>
<div>
<el-card>
<template #header>
<span>接口测试</span>
</template>
<el-tabs v-model="activeTab">
<!-- 健康检查 -->
<el-tab-pane label="健康检查" name="health">
<el-button type="primary" @click="testHealth" :loading="loading">GET /health</el-button>
<pre v-if="result" class="result">{{ JSON.stringify(result, null, 2) }}</pre>
</el-tab-pane>
<!-- 合约列表 -->
<el-tab-pane label="合约列表" name="contracts">
<el-form :inline="true">
<el-form-item label="交易所">
<el-input v-model="contractParams.exchange" placeholder="如 SHFE" style="width: 120px;" />
</el-form-item>
<el-form-item label="品种">
<el-input v-model="contractParams.product" placeholder="如 rb" style="width: 120px;" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="testContracts" :loading="loading">GET /api/v1/contracts</el-button>
</el-form-item>
</el-form>
<pre v-if="result" class="result">{{ JSON.stringify(result, null, 2) }}</pre>
</el-tab-pane>
<!-- K线查询 -->
<el-tab-pane label="K线查询" name="kline">
<el-form :inline="true">
<el-form-item label="合约">
<el-input v-model="klineParams.symbol" placeholder="rb2401" style="width: 120px;" />
</el-form-item>
<el-form-item label="周期">
<el-select v-model="klineParams.period" style="width: 100px;">
<el-option label="日K" value="daily" />
<el-option label="周K" value="weekly" />
<el-option label="60m" value="60m" />
<el-option label="5m" value="5m" />
</el-select>
</el-form-item>
<el-form-item label="条数">
<el-input v-model.number="klineParams.limit" style="width: 80px;" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="testKline" :loading="loading">GET /api/v1/kline</el-button>
</el-form-item>
</el-form>
<pre v-if="result" class="result">{{ JSON.stringify(result, null, 2) }}</pre>
</el-tab-pane>
<!-- 数据源列表 -->
<el-tab-pane label="数据源" name="datasources">
<el-button type="primary" @click="testDatasources" :loading="loading">GET /api/v1/datasources</el-button>
<pre v-if="result" class="result">{{ JSON.stringify(result, null, 2) }}</pre>
</el-tab-pane>
<!-- 自定义请求 -->
<el-tab-pane label="自定义请求" name="custom">
<el-form :inline="true">
<el-form-item label="方法">
<el-select v-model="customMethod" style="width: 90px;">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
</el-select>
</el-form-item>
<el-form-item label="路径">
<el-input v-model="customPath" placeholder="/api/v1/contracts" style="width: 300px;" />
</el-form-item>
<el-form-item label="Body">
<el-input v-model="customBody" type="textarea" :rows="3" placeholder='{"key": "value"}' style="width: 400px;" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="testCustom" :loading="loading">发送</el-button>
</el-form-item>
</el-form>
<pre v-if="result" class="result">{{ JSON.stringify(result, null, 2) }}</pre>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getHealth, getContracts, getKline, getDatasources } from '../api'
import axios from 'axios'
const activeTab = ref('health')
const loading = ref(false)
const result = ref(null)
const contractParams = ref({ exchange: '', product: '' })
const klineParams = ref({ symbol: 'rb2401', period: 'daily', limit: 10 })
const customMethod = ref('GET')
const customPath = ref('/api/v1/contracts')
const customBody = ref('')
const testHealth = async () => {
loading.value = true
try {
const res = await getHealth()
result.value = res.data
} catch (e) {
result.value = { error: e.message }
} finally {
loading.value = false
}
}
const testContracts = async () => {
loading.value = true
try {
const params = {}
if (contractParams.value.exchange) params.exchange = contractParams.value.exchange
if (contractParams.value.product) params.product = contractParams.value.product
const res = await getContracts(params)
result.value = res.data
} catch (e) {
result.value = { error: e.response?.data || e.message }
} finally {
loading.value = false
}
}
const testKline = async () => {
loading.value = true
try {
const params = {
symbol: klineParams.value.symbol,
period: klineParams.value.period,
limit: klineParams.value.limit,
}
const res = await getKline(params)
result.value = res.data
} catch (e) {
result.value = { error: e.response?.data || e.message }
} finally {
loading.value = false
}
}
const testDatasources = async () => {
loading.value = true
try {
const res = await getDatasources()
result.value = res.data
} catch (e) {
result.value = { error: e.message }
} finally {
loading.value = false
}
}
const testCustom = async () => {
loading.value = true
try {
let body = null
if (customBody.value) {
body = JSON.parse(customBody.value)
}
const res = await axios({
method: customMethod.value.toLowerCase(),
url: customPath.value,
data: body,
})
result.value = res.data
} catch (e) {
result.value = { error: e.response?.data || e.message }
} finally {
loading.value = false
}
}
</script>
<style scoped>
.result {
background: #f5f7fa;
padding: 15px;
border-radius: 4px;
margin-top: 15px;
max-height: 500px;
overflow: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
white-space: pre-wrap;
}
</style>

@ -0,0 +1,103 @@
<template>
<div>
<el-card>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>合约管理</span>
<div>
<el-button type="primary" @click="syncContracts"></el-button>
</div>
</div>
</template>
<el-form :inline="true" style="margin-bottom: 20px;">
<el-form-item label="交易所">
<el-select v-model="filterExchange" placeholder="全部" clearable @change="loadContracts" style="width: 130px;">
<el-option label="中金所" value="CFFEX" />
<el-option label="上期所" value="SHFE" />
<el-option label="大商所" value="DCE" />
<el-option label="郑商所" value="ZCE" />
<el-option label="能源中心" value="INE" />
<el-option label="广期所" value="GFEX" />
</el-select>
</el-form-item>
<el-form-item label="品种">
<el-input v-model="filterProduct" placeholder="品种代码" clearable @change="loadContracts" style="width: 120px;" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterActive" placeholder="全部" clearable @change="loadContracts" style="width: 100px;">
<el-option label="活跃" :value="true" />
<el-option label="已到期" :value="false" />
</el-select>
</el-form-item>
</el-form>
<el-table :data="contracts" v-loading="loading" style="width: 100%">
<el-table-column prop="symbol" label="合约代码" width="120" />
<el-table-column prop="name" label="合约名称" width="150" />
<el-table-column prop="exchange" label="交易所" width="100" />
<el-table-column prop="product" label="品种" width="80" />
<el-table-column prop="multiplier" label="乘数" width="80" />
<el-table-column prop="price_tick" label="最小变动" width="100" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '活跃' : '到期' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 10px; color: #909399; font-size: 13px;">
{{ contracts.length }} 条记录
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getContracts, syncContracts as apiSyncContracts } from '../api'
const contracts = ref([])
const loading = ref(false)
const filterExchange = ref('')
const filterProduct = ref('')
const filterActive = ref(null)
const loadContracts = async () => {
loading.value = true
try {
const params = {}
if (filterExchange.value) params.exchange = filterExchange.value
if (filterProduct.value) params.product = filterProduct.value
if (filterActive.value !== null) params.is_active = filterActive.value
const res = await getContracts(params)
contracts.value = res.data.items || []
} catch (e) {
ElMessage.error('加载合约失败')
} finally {
loading.value = false
}
}
const syncContracts = async () => {
try {
const res = await apiSyncContracts()
if (res.data.code === 0) {
ElMessage.success(`同步成功,共 ${res.data.data.synced}`)
loadContracts()
} else {
ElMessage.error(res.data.message)
}
} catch (e) {
ElMessage.error('同步失败: ' + (e.response?.data?.message || e.message))
}
}
onMounted(() => {
loadContracts()
})
</script>

@ -0,0 +1,173 @@
<template>
<div>
<el-card>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>数据源管理</span>
<el-button type="primary" @click="loadDatasources"></el-button>
</div>
</template>
<el-table :data="datasources" v-loading="loading" style="width: 100%">
<el-table-column prop="source_name" label="数据源" width="120" />
<el-table-column prop="display_name" label="显示名称" width="150" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="{ row }">
<el-switch
v-model="row.is_enabled"
@change="toggleSource(row)"
size="small"
/>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="80" />
<el-table-column label="最后同步" width="180">
<template #default="{ row }">
{{ row.last_sync_time || '-' }}
</template>
</el-table-column>
<el-table-column prop="error_msg" label="错误信息">
<template #default="{ row }">
<span style="color: #f56c6c; font-size: 12px;">{{ row.error_msg || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="testConnection(row)"></el-button>
<el-button size="small" type="primary" @click="showEditDialog(row)"></el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 配置对话框 -->
<el-dialog v-model="editVisible" title="数据源配置" width="500px">
<el-form :model="editForm" label-width="100px">
<el-form-item label="显示名称">
<el-input v-model="editForm.display_name" />
</el-form-item>
<!-- 启用状态开关 -->
<el-form-item label="启用状态">
<el-switch v-model="editForm.is_enabled" active-text="" inactive-text="" />
</el-form-item>
<!-- Tushare 特有配置 -->
<el-form-item v-if="editForm.source_name === 'tushare'" label="Token">
<el-input v-model="editForm.token" type="password" show-password placeholder="Tushare API Token" />
</el-form-item>
<!-- Akshare 特有配置 -->
<el-form-item v-if="editForm.source_name === 'akshare'" label="最大重试次数">
<el-input-number v-model="editForm.max_retries" :min="1" :max="10" />
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="editForm.priority" :min="0" :max="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="saveConfig"></el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getDatasources, updateDatasource, testDatasource } from '../api'
const datasources = ref([])
const loading = ref(false)
const editVisible = ref(false)
const editForm = ref({ source_name: '', display_name: '', token: '', max_retries: 3, is_enabled: false, priority: 0 })
const loadDatasources = async () => {
loading.value = true
try {
const res = await getDatasources()
datasources.value = res.data.data || []
} catch (e) {
ElMessage.error('加载数据源失败')
} finally {
loading.value = false
}
}
const getStatusType = (status) => {
const map = { ok: 'success', error: 'danger', unknown: 'info' }
return map[status] || 'info'
}
const toggleSource = async (row) => {
try {
await updateDatasource(row.source_name, { is_enabled: row.is_enabled })
ElMessage.success(`${row.source_name}${row.is_enabled ? '启用' : '禁用'}`)
loadDatasources()
} catch (e) {
ElMessage.error('操作失败')
row.is_enabled = !row.is_enabled
}
}
const testConnection = async (row) => {
try {
const res = await testDatasource(row.source_name)
if (res.data.code === 0) {
ElMessage.success('连接成功')
} else {
ElMessage.error(res.data.message)
}
loadDatasources()
} catch (e) {
ElMessage.error('测试失败')
}
}
const showEditDialog = (row) => {
// 使 Object.assign
Object.assign(editForm.value, {
source_name: row.source_name,
display_name: row.display_name || '',
token: row.config_json?.token || '',
max_retries: row.config_json?.max_retries || 3,
is_enabled: row.is_enabled || false,
priority: row.priority || 0,
})
editVisible.value = true
}
const saveConfig = async () => {
try {
let configJson = {}
if (editForm.value.source_name === 'tushare') {
configJson = { token: editForm.value.token }
} else if (editForm.value.source_name === 'akshare') {
configJson = { max_retries: editForm.value.max_retries }
}
await updateDatasource(editForm.value.source_name, {
is_enabled: editForm.value.is_enabled,
display_name: editForm.value.display_name,
config_json: configJson,
priority: editForm.value.priority,
})
ElMessage.success('保存成功')
editVisible.value = false
loadDatasources()
} catch (e) {
ElMessage.error('保存失败')
}
}
onMounted(() => {
loadDatasources()
})
</script>

@ -0,0 +1,238 @@
<template>
<div>
<el-card>
<template #header>
<span>K线查询</span>
</template>
<el-form :inline="true">
<el-form-item label="合约代码">
<el-select
v-model="form.symbol"
filterable
placeholder="输入合约代码"
style="width: 180px;"
@change="queryKline"
>
<el-option
v-for="c in contractOptions"
:key="c.symbol"
:label="`${c.symbol} ${c.name}`"
:value="c.symbol"
/>
</el-select>
</el-form-item>
<el-form-item label="周期">
<el-select v-model="form.period" style="width: 100px;" @change="queryKline">
<el-option label="日K" value="daily" />
<el-option label="周K" value="weekly" />
<el-option label="60分钟" value="60m" />
<el-option label="30分钟" value="30m" />
<el-option label="15分钟" value="15m" />
<el-option label="5分钟" value="5m" />
</el-select>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.startDate" type="date" value-format="YYYY-MM-DD" style="width: 140px;" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.endDate" type="date" value-format="YYYY-MM-DD" style="width: 140px;" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="queryKline"></el-button>
<el-button @click="syncKline"></el-button>
</el-form-item>
</el-form>
<!-- K线图 -->
<div ref="chartRef" style="width: 100%; height: 500px; margin-top: 20px;" v-loading="chartLoading"></div>
<!-- 数据表格 -->
<el-table :data="tableData" style="width: 100%; margin-top: 20px;" max-height="300" v-loading="loading">
<el-table-column prop="trade_time" label="时间" width="180">
<template #default="{ row }">
{{ row.trade_time?.split('T')[0] || row.trade_time }}
</template>
</el-table-column>
<el-table-column prop="open" label="开盘" />
<el-table-column prop="high" label="最高" />
<el-table-column prop="low" label="最低" />
<el-table-column prop="close" label="收盘" />
<el-table-column prop="volume" label="成交量">
<template #default="{ row }">
{{ formatVolume(row.volume) }}
</template>
</el-table-column>
<el-table-column prop="open_interest" label="持仓量">
<template #default="{ row }">
{{ formatVolume(row.open_interest) }}
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { getKline, getContracts, syncKline as apiSyncKline } from '../api'
const chartRef = ref(null)
let chartInstance = null
const form = ref({
symbol: 'rb2401',
period: 'daily',
startDate: '',
endDate: '',
})
const contractOptions = ref([])
const klineData = ref([])
const tableData = ref([])
const loading = ref(false)
const chartLoading = ref(false)
const formatVolume = (v) => {
if (!v) return '-'
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toLocaleString()
}
const loadContracts = async () => {
try {
const res = await getContracts({ is_active: true })
contractOptions.value = res.data.items?.slice(0, 200) || []
} catch {
// ignore
}
}
const queryKline = async () => {
if (!form.value.symbol) return
loading.value = true
chartLoading.value = true
try {
const params = {
symbol: form.value.symbol,
period: form.value.period,
limit: 200,
}
if (form.value.startDate) params.start_date = form.value.startDate
if (form.value.endDate) params.end_date = form.value.endDate
const res = await getKline(params)
klineData.value = res.data.items || []
// API
tableData.value = [...klineData.value].reverse()
renderChart()
} catch (e) {
ElMessage.error('查询失败: ' + (e.response?.data?.detail || e.message))
} finally {
loading.value = false
chartLoading.value = false
}
}
const syncKline = async () => {
if (!form.value.symbol) {
ElMessage.warning('请先选择合约')
return
}
try {
const res = await apiSyncKline({
symbol: form.value.symbol,
period: form.value.period,
start_date: form.value.startDate || '2024-01-01',
end_date: form.value.endDate || new Date().toISOString().split('T')[0],
})
if (res.data.code === 0) {
ElMessage.success(`同步成功,${res.data.data.synced}`)
queryKline()
} else {
ElMessage.error(res.data.message)
}
} catch (e) {
ElMessage.error('同步失败')
}
}
const renderChart = () => {
nextTick(() => {
if (!chartRef.value) return
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value)
}
const data = [...klineData.value].reverse()
if (data.length === 0) {
chartInstance.setOption({
title: { text: '暂无数据', left: 'center', top: 'middle' },
})
return
}
const dates = data.map(d => d.trade_time?.split('T')[0] || d.trade_time)
const values = data.map(d => [d.open, d.close, d.low, d.high])
const volumes = data.map(d => ({
value: d.volume || 0,
open: d.open,
close: d.close,
}))
chartInstance.setOption({
title: { text: `${form.value.symbol} - ${form.value.period}`, left: 'center' },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
grid: [
{ left: '10%', right: '8%', height: '55%' },
{ left: '10%', right: '8%', top: '70%', height: '20%' },
],
xAxis: [
{ type: 'category', data: dates, gridIndex: 0, axisLabel: { show: false } },
{ type: 'category', data: dates, gridIndex: 1 },
],
yAxis: [
{ scale: true, gridIndex: 0, splitArea: { show: true } },
{ scale: true, gridIndex: 1, splitNumber: 2, axisLabel: { show: false } },
],
dataZoom: [{ type: 'inside', xAxisIndex: [0, 1], start: 50, end: 100 }],
series: [
{
name: 'K线',
type: 'candlestick',
data: values,
itemStyle: {
color: '#ef5350', //
color0: '#26a69a', // 绿
borderColor: '#ef5350',
borderColor0: '#26a69a',
},
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes.map(v => ({
value: v.value,
itemStyle: {
color: v.close >= v.open ? '#ef5350' : '#26a69a',
},
})),
},
],
})
})
}
onMounted(() => {
loadContracts()
window.addEventListener('resize', () => chartInstance?.resize())
})
</script>

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Loading…
Cancel
Save