@ -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
|
||||
@ -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,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,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,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,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,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>
|
||||
@ -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,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 34 KiB |