增加品种管理、重复数据等处理、增加批量查询合约接口

master
Lxy 3 weeks ago
parent e0b8305406
commit cd45819343

@ -17,9 +17,12 @@ from app.schemas import (
ContractInfo as ContractSchema, ContractListResponse,
DataSourceConfigItem, DataSourceConfigUpdate, DataSourceCreate,
ApiResponse, HealthResponse, DataSourceStatus,
BatchSyncRequest, BatchSyncResult,
ProductInfo as ProductSchema, ProductTreeResponse,
)
from app.services.kline_service import kline_service
from app.services.contract_service import contract_service
from app.services.product_service import product_service
from app.services.datasource.manager import DataSourceManager
from app.models import DataSourceConfig
@ -126,6 +129,55 @@ async def health_check():
)
# ========== 品种接口 ==========
@app.get("/api/v1/products")
async def list_products(
exchange: Optional[str] = Query(None, description="交易所代码"),
category: Optional[str] = Query(None, description="品种分类"),
is_active: Optional[bool] = Query(None, description="是否活跃"),
):
"""获取品种列表"""
products = product_service.get_products(
exchange=exchange, category=category, is_active=is_active
)
return {"code": 0, "data": products}
@app.get("/api/v1/products/tree")
async def get_product_tree():
"""获取品种树结构"""
tree = product_service.get_product_tree()
return {"code": 0, "data": {"categories": tree}}
@app.get("/api/v1/products/{product_code}/contracts")
async def get_product_contracts(
product_code: str,
is_active: Optional[bool] = Query(None, description="是否活跃"),
):
"""获取指定品种的所有合约"""
contracts = product_service.get_product_contracts(
product_code=product_code, is_active=is_active
)
return {"code": 0, "data": contracts}
@app.post("/api/v1/contracts/{symbol}/set-main")
async def set_main_contract(symbol: str):
"""设置主力合约"""
success = product_service.set_main_contract(symbol)
if success:
return {"code": 0, "message": "设置成功"}
return {"code": 1, "message": "设置失败,合约不存在"}
@app.post("/api/v1/contracts/update-main")
async def update_main_contracts():
"""根据持仓量自动更新主力合约标识"""
count = product_service.update_main_contracts()
return {"code": 0, "message": f"更新了 {count} 个主力合约"}
# ========== 合约接口 ==========
@app.get("/api/v1/contracts", response_model=ContractListResponse)
async def list_contracts(
@ -142,6 +194,35 @@ async def list_contracts(
)
@app.get("/api/v1/contracts/products", response_model=ApiResponse)
async def list_products(
exchange: Optional[str] = Query(None, description="交易所代码"),
):
"""获取品种列表(去重后的品种信息)"""
logger.info(f"[API-获取品种列表] exchange={exchange}")
products = contract_service.get_products(exchange=exchange)
return {"code": 0, "message": "ok", "data": {"items": products, "total": len(products)}}
@app.get("/api/v1/contracts/by-month", response_model=ContractListResponse)
async def get_contracts_by_month(
product: str = Query(..., description="品种代码"),
start_month: str = Query(..., description="起始月份 YYYY-MM 或 YYYYMM"),
limit: int = Query(5, ge=1, le=20, description="返回合约数量"),
):
"""根据品种和起始月份查询合约列表"""
logger.info(f"[API-按月份查询合约] product={product}, start_month={start_month}, limit={limit}")
contracts = contract_service.get_contracts_by_month(
product=product,
start_month=start_month,
limit=limit
)
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)
@ -209,6 +290,24 @@ async def sync_kline(req: KlineRequest):
return {"code": 1, "message": f"同步失败: {str(e)}", "data": None}
@app.post("/api/v1/kline/batch-sync", response_model=BatchSyncResult)
async def batch_sync_kline(req: BatchSyncRequest):
"""批量同步K线数据"""
logger.info(f"[API-批量同步K线] 请求参数: symbols={req.symbols}, period={req.period}, start_date={req.start_date}, end_date={req.end_date}")
try:
result = kline_service.batch_sync(
symbols=req.symbols,
period=req.period,
start_date=req.start_date,
end_date=req.end_date,
)
logger.info(f"[API-批量同步K线] 同步完成: 成功={result['success']}, 失败={result['failed']}, 总记录={result['total_records']}")
return BatchSyncResult(**result)
except Exception as e:
logger.error(f"[API-批量同步K线] 同步失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"批量同步失败: {str(e)}")
# ========== 数据源管理接口 ==========
@app.get("/api/v1/datasources")
async def list_datasources():

@ -1,8 +1,30 @@
from app.database import Base
from sqlalchemy import Column, Integer, String, Float, DateTime, BigInteger, Boolean, Text, Index
from sqlalchemy import Column, Integer, String, Float, DateTime, BigInteger, Boolean, Text, Index, func
from datetime import datetime
class ProductInfo(Base):
"""品种元数据表"""
__tablename__ = "product_info"
id = Column(Integer, primary_key=True, autoincrement=True)
product_code = Column(String(10), unique=True, nullable=False, comment="品种代码,如 rb")
product_name = Column(String(50), nullable=False, comment="品种中文名,如 螺纹钢")
exchange = Column(String(10), nullable=False, comment="所属交易所")
multiplier = Column(Integer, default=10, comment="合约乘数")
price_tick = Column(Float, comment="最小变动价位")
margin_ratio = Column(Float, default=0.1, comment="保证金比例(%)")
category = Column(String(20), 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_product_category", "category"),
Index("idx_product_exchange", "exchange"),
)
class ContractInfo(Base):
"""期货合约信息表"""
__tablename__ = "contract_info"
@ -18,12 +40,21 @@ class ContractInfo(Base):
limit_down_ratio = Column(Float, comment="跌停板比例")
expire_date = Column(DateTime, comment="到期日")
is_active = Column(Boolean, default=True, comment="是否活跃")
# 新增字段
year_month = Column(String(7), comment="交割年月,如 2024-01")
delivery_month = Column(Integer, comment="交割月份,如 1")
is_main = Column(Boolean, default=False, comment="是否主力合约")
listing_date = Column(DateTime, comment="上市日期")
volume = Column(BigInteger, default=0, comment="成交量(用于计算主力)")
open_interest = Column(BigInteger, default=0, 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"),
Index("idx_contract_year_month", "year_month"),
Index("idx_contract_is_main", "is_main"),
)

@ -3,6 +3,37 @@ from typing import Optional, List
from datetime import datetime
# ========== 品种相关 ==========
class ProductInfo(BaseModel):
id: int
product_code: str
product_name: str
exchange: str
multiplier: Optional[int] = None
price_tick: Optional[float] = None
category: Optional[str] = None
is_active: Optional[bool] = None
contract_count: int = 0
main_contract: Optional[str] = None
class Config:
from_attributes = True
class ProductTreeItem(BaseModel):
"""品种树节点"""
product_code: str
product_name: str
exchange: str
category: str
contracts: List[dict] # [{"symbol": "rb2401", "year_month": "2024-01", "is_main": true}]
class ProductTreeResponse(BaseModel):
"""品种树响应"""
categories: List[dict] # [{"category": "能源化工", "products": [...]}]
# ========== 合约相关 ==========
class ContractInfo(BaseModel):
id: int
@ -13,6 +44,10 @@ class ContractInfo(BaseModel):
multiplier: Optional[int] = None
price_tick: Optional[float] = None
is_active: Optional[bool] = None
year_month: Optional[str] = None
delivery_month: Optional[int] = None
is_main: Optional[bool] = None
open_interest: Optional[int] = None
class Config:
from_attributes = True
@ -53,6 +88,23 @@ class KlineResponse(BaseModel):
items: List[KlineItem]
class BatchSyncRequest(BaseModel):
"""批量同步K线请求"""
symbols: List[str] # 合约列表
period: str = "daily" # daily/weekly/5m/15m/30m/60m
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
class BatchSyncResult(BaseModel):
"""批量同步结果"""
total: int # 总合约数
success: int # 成功数
failed: int # 失败数
total_records: int # 总记录数
details: List[dict] # 详细信息
# ========== 数据源相关 ==========
class DataSourceConfigItem(BaseModel):
id: int

@ -3,6 +3,9 @@ from sqlalchemy.orm import Session
from app.models import ContractInfo
from app.services.datasource.manager import DataSourceManager
from app.database import SessionLocal
import logging
logger = logging.getLogger(__name__)
class ContractService:
@ -106,5 +109,203 @@ class ContractService:
finally:
db.close()
def get_products(
self,
exchange: Optional[str] = None
) -> List[dict]:
"""获取品种列表(按品种代码去重)"""
logger.info(f"[获取品种列表] exchange={exchange}")
db = SessionLocal()
try:
# 按品种分组,获取每个品种的基本信息
query = db.query(
ContractInfo.product,
ContractInfo.exchange
).filter(
ContractInfo.product.isnot(None),
ContractInfo.product != ''
)
if exchange:
query = query.filter(ContractInfo.exchange == exchange)
# 按品种分组
query = query.group_by(
ContractInfo.product,
ContractInfo.exchange
).order_by(ContractInfo.product)
results = query.all()
# 按品种代码+交易所组合去重(同一品种代码可能在不同交易所)
seen_products = set()
products = []
for row in results:
key = (row.product, row.exchange)
if key not in seen_products:
seen_products.add(key)
# 获取该品种的详细信息(优先主力合约,其次最新合约)
product_info = self._get_product_info(db, row.product, row.exchange)
products.append({
"product": row.product,
"exchange": row.exchange,
"name": product_info.get("name", row.product),
"multiplier": product_info.get("multiplier"),
"price_tick": product_info.get("price_tick"),
})
logger.info(f"[获取品种列表] 返回 {len(products)} 个品种")
return products
finally:
db.close()
def _get_product_info(self, db: Session, product: str, exchange: str) -> dict:
"""
获取品种的详细信息
优先使用主力合约的信息如果没有主力合约则使用最新的合约
"""
# 1. 优先查找主力合约
main_contract = db.query(ContractInfo).filter(
ContractInfo.product == product,
ContractInfo.exchange == exchange,
ContractInfo.is_active == True,
ContractInfo.is_main == True
).first()
if main_contract:
return {
"name": main_contract.name,
"multiplier": main_contract.multiplier,
"price_tick": main_contract.price_tick,
}
# 2. 如果没有主力合约,查找最新的活跃合约(按合约代码排序)
latest_contract = db.query(ContractInfo).filter(
ContractInfo.product == product,
ContractInfo.exchange == exchange,
ContractInfo.is_active == True
).order_by(ContractInfo.symbol.desc()).first()
if latest_contract:
return {
"name": latest_contract.name,
"multiplier": latest_contract.multiplier,
"price_tick": latest_contract.price_tick,
}
# 3. 如果都没有,返回默认值
return {
"name": product,
"multiplier": None,
"price_tick": None,
}
def get_contracts_by_month(
self,
product: str,
start_month: str,
limit: int = 5
) -> List[ContractInfo]:
"""
根据品种和起始月份查询合约列表
start_month 格式: YYYY-MM YYYYMM
返回从指定月份开始的 limit 个合约
"""
logger.info(f"[按月份查询合约] product={product}, start_month={start_month}, limit={limit}")
db = SessionLocal()
try:
# 查询指定品种的合约
query = db.query(ContractInfo).filter(
ContractInfo.product == product
)
contracts = query.all()
if not contracts:
logger.warning(f"[按月份查询合约] 品种 {product} 没有任何合约数据")
return []
# 解析并排序所有合约
contract_with_month = []
for contract in contracts:
month_tuple = self._extract_contract_month(contract.symbol, contract.expire_date)
if month_tuple:
contract_with_month.append((contract, month_tuple))
if not contract_with_month:
logger.warning(f"[按月份查询合约] 品种 {product} 的合约无法解析月份")
return []
# 按月份排序
contract_with_month.sort(key=lambda x: x[1])
# 解析起始月份
if len(start_month) == 7: # YYYY-MM
year = int(start_month[:4])
month = int(start_month[5:7])
elif len(start_month) == 6: # YYYYMM
year = int(start_month[:4])
month = int(start_month[4:6])
else:
raise ValueError(f"月份格式错误: {start_month},应为 YYYY-MM 或 YYYYMM")
start_tuple = (year, month)
# 过滤 >= start_month 的合约
filtered = [(c, m) for c, m in contract_with_month if m >= start_tuple]
# 如果没有找到,返回最接近的合约(往前找)
if not filtered:
logger.info(f"[按月份查询合约] 没有找到 >= {start_tuple} 的合约,返回最早的 {limit} 个合约")
filtered = contract_with_month[:limit]
else:
filtered = filtered[:limit]
result = [c[0] for c in filtered]
logger.info(f"[按月份查询合约] 返回 {len(result)} 个合约")
return result
finally:
db.close()
def _extract_contract_month(self, symbol: str, expire_date):
"""
从合约代码或到期日中提取月份
返回 (year, month) 元组
"""
import re
from datetime import datetime
# 优先使用 expire_date
if expire_date:
if isinstance(expire_date, datetime):
return (expire_date.year, expire_date.month)
# 从合约代码中解析,如 rb2401 -> 2024-01
match = re.search(r'(\d{2})(\d{2})$', symbol)
if match:
year_suffix = int(match.group(1))
month = int(match.group(2))
# 判断世纪(期货合约通常在当前年份附近)
current_year = datetime.now().year
current_century = current_year // 100 * 100
# 如果月份 > 当前月份,可能是上一年的合约
current_month = datetime.now().month
if month > current_month:
year = current_century + year_suffix - 100
else:
year = current_century + year_suffix
# 处理跨世纪情况
if year > current_year + 10:
year -= 100
return (year, month)
return None
contract_service = ContractService()

@ -343,4 +343,53 @@ class KlineService:
]
def batch_sync(
self,
symbols: List[str],
period: str,
start_date: str,
end_date: str
) -> dict:
"""批量同步K线数据
Args:
symbols: 合约代码列表
period: 周期 (daily/weekly/5m/15m/30m/60m)
start_date: 开始日期 YYYY-MM-DD
end_date: 结束日期 YYYY-MM-DD
Returns:
批量同步结果
"""
results = {
"total": len(symbols),
"success": 0,
"failed": 0,
"total_records": 0,
"details": []
}
for symbol in symbols:
detail = {"symbol": symbol, "status": "success", "records": 0, "error": None}
try:
if period == "daily":
count = self.sync_daily(symbol, start_date, end_date)
elif period == "weekly":
count = self.sync_weekly(symbol, start_date, end_date)
else:
count = self.sync_intraday(symbol, period, start_date, end_date)
detail["records"] = count
results["success"] += 1
results["total_records"] += count
except Exception as e:
detail["status"] = "failed"
detail["error"] = str(e)
results["failed"] += 1
results["details"].append(detail)
return results
kline_service = KlineService()

@ -0,0 +1,255 @@
"""
品种服务管理品种元数据品种树主力合约计算
"""
from typing import List, Optional, Dict
from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from app.models import ProductInfo, ContractInfo
from app.database import SessionLocal
class ProductService:
"""品种信息服务"""
def get_products(
self,
exchange: Optional[str] = None,
category: Optional[str] = None,
is_active: Optional[bool] = None
) -> List[dict]:
"""获取品种列表,包含合约数量和主力合约信息"""
db = SessionLocal()
try:
query = db.query(
ProductInfo,
func.count(ContractInfo.symbol).label('contract_count'),
func.max(ContractInfo.symbol).label('main_contract') # 临时使用,后续优化
).outerjoin(
ContractInfo,
and_(
ProductInfo.product_code == ContractInfo.product,
ContractInfo.is_active == True
)
).group_by(ProductInfo.id)
if exchange:
query = query.filter(ProductInfo.exchange == exchange)
if category:
query = query.filter(ProductInfo.category == category)
if is_active is not None:
query = query.filter(ProductInfo.is_active == is_active)
results = query.all()
return [
{
"id": p.id,
"product_code": p.product_code,
"product_name": p.product_name,
"exchange": p.exchange,
"multiplier": p.multiplier,
"price_tick": p.price_tick,
"category": p.category,
"is_active": p.is_active,
"contract_count": count,
"main_contract": main,
}
for p, count, main in results
]
finally:
db.close()
def get_product_tree(self) -> List[dict]:
"""获取品种树结构(按分类分组)"""
db = SessionLocal()
try:
# 获取所有品种
products = db.query(ProductInfo).order_by(
ProductInfo.category,
ProductInfo.product_code
).all()
# 按分类分组
tree = {}
for p in products:
if p.category not in tree:
tree[p.category] = []
# 获取该品种的活跃合约
contracts = db.query(ContractInfo).filter(
and_(
ContractInfo.product == p.product_code,
ContractInfo.is_active == True
)
).order_by(ContractInfo.year_month).all()
contract_list = [
{
"symbol": c.symbol,
"year_month": c.year_month,
"delivery_month": c.delivery_month,
"is_main": c.is_main,
"name": c.name,
}
for c in contracts
]
tree[p.category].append({
"product_code": p.product_code,
"product_name": p.product_name,
"exchange": p.exchange,
"contract_count": len(contract_list),
"main_contract": next((c["symbol"] for c in contract_list if c["is_main"]), None),
"contracts": contract_list,
})
# 转换为列表格式
return [
{"category": cat, "products": prods}
for cat, prods in tree.items()
]
finally:
db.close()
def get_product_contracts(
self,
product_code: str,
is_active: Optional[bool] = None
) -> List[dict]:
"""获取指定品种的所有合约"""
db = SessionLocal()
try:
query = db.query(ContractInfo).filter(
ContractInfo.product == product_code
)
if is_active is not None:
query = query.filter(ContractInfo.is_active == is_active)
contracts = query.order_by(ContractInfo.year_month).all()
return [
{
"id": c.id,
"symbol": c.symbol,
"name": c.name,
"exchange": c.exchange,
"year_month": c.year_month,
"delivery_month": c.delivery_month,
"is_main": c.is_main,
"is_active": c.is_active,
"expire_date": c.expire_date,
"multiplier": c.multiplier,
"price_tick": c.price_tick,
}
for c in contracts
]
finally:
db.close()
def set_main_contract(self, symbol: str) -> bool:
"""设置主力合约"""
db = SessionLocal()
try:
# 获取合约信息
contract = db.query(ContractInfo).filter(
ContractInfo.symbol == symbol
).first()
if not contract:
return False
# 取消同品种其他合约的主力标识
db.query(ContractInfo).filter(
and_(
ContractInfo.product == contract.product,
ContractInfo.symbol != symbol
)
).update({"is_main": False})
# 设置当前合约为主力
contract.is_main = True
db.commit()
return True
except Exception:
db.rollback()
return False
finally:
db.close()
def update_main_contracts(self) -> int:
"""根据持仓量自动更新主力合约标识
规则
1. 优先按持仓量最大的活跃合约为主力
2. 当持仓量数据缺失全为0按交割月取最近的活跃合约
"""
db = SessionLocal()
try:
products = db.query(ProductInfo).all()
updated_count = 0
for product in products:
# 获取该品种所有活跃合约
active_contracts = db.query(ContractInfo).filter(
and_(
ContractInfo.product == product.product_code,
ContractInfo.is_active == True
)
).all()
if not active_contracts:
continue
# 检查是否有持仓量数据
has_volume_data = any(c.open_interest > 0 for c in active_contracts)
if has_volume_data:
# 方案1按持仓量排序
main_contract = max(active_contracts, key=lambda c: c.open_interest)
else:
# 方案2按交割月取最近的当前时间之后最近的合约
from datetime import datetime
now = datetime.utcnow()
# 过滤出未来交割的合约
future_contracts = []
for c in active_contracts:
if c.year_month:
try:
expiry = datetime.strptime(c.year_month, '%Y-%m')
if expiry >= now:
future_contracts.append(c)
except ValueError:
pass
if future_contracts:
# 取最近的
main_contract = min(future_contracts, key=lambda c: c.year_month)
else:
# 如果没有未来合约,取最后一个活跃的
main_contract = active_contracts[-1]
# 取消同品种其他合约的主力标识
db.query(ContractInfo).filter(
and_(
ContractInfo.product == product.product_code,
ContractInfo.symbol != main_contract.symbol
)
).update({"is_main": False})
# 设置主力合约
if not main_contract.is_main:
main_contract.is_main = True
updated_count += 1
db.commit()
return updated_count
except Exception:
db.rollback()
return 0
finally:
db.close()
product_service = ProductService()

@ -0,0 +1,237 @@
"""
数据库迁移脚本为合约管理优化添加新字段和品种表
"""
import sys
import os
import re
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import engine, Base, SessionLocal
from app.models import ProductInfo, ContractInfo
from sqlalchemy import text
# 品种分类映射(根据交易所和品种代码)
CATEGORY_MAP = {
# 能源化工
"rb": "能源化工", "hc": "能源化工", "fu": "能源化工", "bu": "能源化工",
"ru": "能源化工", "nr": "能源化工", "sp": "能源化工", "pg": "能源化工",
"pp": "能源化工", "l": "能源化工", "v": "能源化工", "eg": "能源化工",
"sc": "能源化工", "lu": "能源化工", "low": "能源化工",
# 金属
"cu": "金属", "al": "金属", "zn": "金属", "pb": "金属", "ni": "金属",
"sn": "金属", "au": "金属", "ag": "金属", "ss": "能源化工",
# 农产品
"a": "农产品", "b": "农产品", "c": "农产品", "cs": "农产品",
"m": "农产品", "y": "农产品", "p": "农产品", "rm": "农产品",
"oi": "农产品", "cf": "农产品", "sr": "农产品", "ta": "能源化工",
"ma": "能源化工", "fg": "能源化工", "sa": "农产品", "ur": "农产品",
"sf": "金属", "sm": "金属", "ap": "农产品", "cj": "农产品",
"rr": "农产品", "jd": "农产品", "lh": "农产品",
# 金融
"if": "金融", "ih": "金融", "ic": "金融", "im": "金融",
"t": "金融", "tf": "金融", "ts": "金融", "tl": "金融",
# 其他
"px": "能源化工", "pr": "能源化工", "br": "能源化工",
"lc": "金属", "si": "金属", "ec": "金融",
}
# 品种中文名映射
PRODUCT_NAME_MAP = {
# 能源化工
"rb": "螺纹钢", "hc": "热卷", "fu": "燃料油", "bu": "沥青",
"ru": "橡胶", "nr": "20号胶", "sp": "纸浆", "pg": "液化气",
"pp": "聚丙烯", "l": "塑料", "v": "PVC", "eg": "乙二醇",
"sc": "原油", "lu": "低硫燃料油", "low": "低硫燃料油",
"ta": "PTA", "ma": "甲醇", "fg": "玻璃", "ur": "尿素",
"sa": "纯碱", "px": "对二甲苯", "pr": "丙二醇", "br": "合成橡胶",
# 金属
"cu": "", "al": "", "zn": "", "pb": "", "ni": "",
"sn": "", "au": "黄金", "ag": "白银", "ss": "不锈钢",
"sf": "硅铁", "sm": "锰硅", "lc": "碳酸锂", "si": "工业硅",
# 农产品
"a": "豆一", "b": "豆二", "c": "玉米", "cs": "淀粉",
"m": "豆粕", "y": "豆油", "p": "棕榈油", "rm": "菜粕",
"oi": "菜油", "cf": "棉花", "sr": "白糖", "ap": "苹果",
"cj": "红枣", "rr": "粳米", "jd": "鸡蛋", "lh": "生猪",
# 金融
"if": "沪深300", "ih": "上证50", "ic": "中证500", "im": "中证1000",
"t": "10年国债", "tf": "5年国债", "ts": "2年国债", "tl": "30年国债", "ec": "工业硅",
# 能源
"sc": "原油", "lu": "低硫燃油",
}
# 交易所映射
EXCHANGE_MAP = {
"SHFE": "上海期货交易所",
"DCE": "大连商品交易所",
"CZCE": "郑州商品交易所",
"CFFEX": "中国金融期货交易所",
"INE": "上海国际能源交易中心",
"GFEX": "广州期货交易所",
}
def extract_year_month(symbol: str) -> tuple:
"""从合约代码解析交割年月
示例:
rb2401 -> (2024-01, 1)
cu2312 -> (2023-12, 12)
IF2403 -> (2024-03, 3)
"""
# 匹配末尾的数字(年份+月份)
match = re.search(r'(\d{2})(\d{2})$', symbol)
if not match:
return None, None
year_suffix, month = match.groups()
year_suffix = int(year_suffix)
month = int(month)
# 年份处理:假设是 20xx 年
year = 2000 + year_suffix if year_suffix < 100 else year_suffix
return f"{year}-{month:02d}", month
def extract_product_code(symbol: str) -> str:
"""从合约代码提取品种代码
示例:
rb2401 -> rb
cu2312 -> cu
IF2403 -> IF
"""
# 去掉末尾的数字
return re.sub(r'\d+$', '', symbol)
def migrate():
"""执行迁移"""
print("🚀 开始数据库迁移...")
db = SessionLocal()
try:
# 1. 创建新表(如果不存在)
print("📦 创建 product_info 表...")
ProductInfo.__table__.create(engine, checkfirst=True)
# 2. 添加新字段到 contract_infoSQLite 不支持 ALTER TABLE ADD COLUMN 的某些操作,需要检查)
print("🔧 检查 contract_info 表结构...")
# 检查字段是否存在
inspector_result = db.execute(text("PRAGMA table_info(contract_info)")).fetchall()
existing_columns = [row[1] for row in inspector_result]
new_columns = {
"year_month": "ALTER TABLE contract_info ADD COLUMN year_month VARCHAR(7)",
"delivery_month": "ALTER TABLE contract_info ADD COLUMN delivery_month INTEGER",
"is_main": "ALTER TABLE contract_info ADD COLUMN is_main BOOLEAN DEFAULT 0",
"listing_date": "ALTER TABLE contract_info ADD COLUMN listing_date DATETIME",
"volume": "ALTER TABLE contract_info ADD COLUMN volume BIGINT DEFAULT 0",
"open_interest": "ALTER TABLE contract_info ADD COLUMN open_interest BIGINT DEFAULT 0",
}
for col_name, sql in new_columns.items():
if col_name not in existing_columns:
print(f" 添加字段: {col_name}")
db.execute(text(sql))
else:
print(f" ✅ 字段已存在: {col_name}")
db.commit()
# 3. 数据迁移:填充衍生字段
print("📝 迁移现有合约数据...")
contracts = db.query(ContractInfo).all()
updated_count = 0
for contract in contracts:
# 解析 year_month 和 delivery_month
if not contract.year_month and contract.symbol:
year_month, delivery_month = extract_year_month(contract.symbol)
if year_month:
contract.year_month = year_month
contract.delivery_month = delivery_month
# 提取 product_code
if not contract.product and contract.symbol:
contract.product = extract_product_code(contract.symbol)
updated_count += 1
if updated_count > 0:
db.commit()
print(f" ✅ 更新 {updated_count} 条合约记录")
# 4. 生成品种元数据
print(" 生成品种元数据...")
products = db.query(
ContractInfo.product,
ContractInfo.exchange,
ContractInfo.multiplier,
ContractInfo.price_tick
).distinct().all()
product_count = 0
for prod_code, exchange, multiplier, price_tick in products:
if not prod_code:
continue
# 检查是否已存在
existing = db.query(ProductInfo).filter(
ProductInfo.product_code == prod_code
).first()
if existing:
continue
# 查找中文名
# 优先使用映射表
product_name = PRODUCT_NAME_MAP.get(prod_code.lower(), prod_code)
category = CATEGORY_MAP.get(prod_code.lower(), "其他")
product = ProductInfo(
product_code=prod_code,
product_name=product_name,
exchange=exchange,
multiplier=multiplier or 10,
price_tick=price_tick,
category=category,
is_active=True,
)
db.add(product)
db.commit() # 逐条提交避免批量冲突
product_count += 1
if product_count > 0:
print(f" ✅ 创建 {product_count} 个品种元数据")
# 5. 创建索引
print("📑 创建索引...")
try:
db.execute(text("CREATE INDEX IF NOT EXISTS idx_contract_year_month ON contract_info(year_month)"))
db.execute(text("CREATE INDEX IF NOT EXISTS idx_contract_is_main ON contract_info(is_main)"))
db.execute(text("CREATE INDEX IF NOT EXISTS idx_product_category ON product_info(category)"))
db.execute(text("CREATE INDEX IF NOT EXISTS idx_product_exchange ON product_info(exchange)"))
db.commit()
except Exception as e:
print(f" ⚠️ 索引创建警告: {e}")
print("\n✅ 迁移完成!")
except Exception as e:
db.rollback()
print(f"\n❌ 迁移失败: {e}")
import traceback
traceback.print_exc()
finally:
db.close()
if __name__ == "__main__":
migrate()

@ -8,14 +8,22 @@ const api = axios.create({
// 健康检查
export const getHealth = () => axios.get('/api/health')
// 品种
export const getProducts = (params) => api.get('/products', { params })
export const getProductTree = () => api.get('/products/tree')
export const getProductContracts = (productCode, params) => api.get(`/products/${productCode}/contracts`, { params })
// 合约
export const getContracts = (params) => api.get('/contracts', { params })
export const getContract = (symbol) => api.get(`/contracts/${symbol}`)
export const syncContracts = () => api.post('/contracts/sync')
export const setMainContract = (symbol) => api.post(`/contracts/${symbol}/set-main`)
export const updateMainContracts = () => api.post('/contracts/update-main')
// K线
export const getKline = (params) => api.get('/kline', { params })
export const syncKline = (data) => api.post('/kline/sync', data)
export const batchSyncKline = (data) => api.post('/kline/batch-sync', data)
// 数据源
export const getDatasources = () => api.get('/datasources')

@ -1,44 +1,89 @@
<template>
<div>
<div class="contract-management">
<el-card>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>合约管理</span>
<div>
<el-button @click="updateMainContracts"></el-button>
<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" />
<div class="contract-layout">
<!-- 左侧品种树 -->
<div class="product-tree-panel">
<el-input
v-model="searchText"
placeholder="搜索品种..."
clearable
prefix-icon="Search"
style="margin-bottom: 12px;"
/>
<el-tree
ref="treeRef"
:data="productTree"
:props="treeProps"
node-key="product_code"
default-expand-all
:expand-on-click-node="false"
@node-click="handleProductClick"
class="product-tree"
>
<template #default="{ node, data }">
<div class="tree-node">
<span class="node-label">{{ data.product_name }}</span>
<span class="node-code">{{ data.product_code }}</span>
<el-badge
:value="data.contract_count"
:max="99"
type="info"
class="node-badge"
/>
</div>
</template>
</el-tree>
</div>
<!-- 右侧合约列表 -->
<div class="contract-list-panel">
<div v-if="selectedProduct" class="product-header">
<h3>
{{ selectedProduct.product_name }} ({{ selectedProduct.product_code }})
<el-tag size="small" type="info">{{ selectedProduct.exchange }}</el-tag>
<el-tag v-if="selectedProduct.main_contract" size="small" type="warning" style="margin-left: 8px;">
主力: {{ selectedProduct.main_contract }}
</el-tag>
</h3>
<div class="product-info">
<span>乘数: {{ selectedProduct.multiplier || '-' }}</span>
<span style="margin-left: 16px;">最小变动: {{ selectedProduct.price_tick || '-' }}</span>
</div>
</div>
<div v-else class="empty-hint">
<el-empty description="请从左侧选择一个品种查看合约" />
</div>
<el-table
v-if="selectedProduct"
:data="productContracts"
v-loading="loading"
style="width: 100%"
stripe
>
<el-table-column label="合约" width="100">
<template #default="{ row }">
<span v-if="row.is_main" style="color: #e6a23c; font-weight: bold;">
👑 {{ row.symbol }}
</span>
<span v-else>{{ row.symbol }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="year_month" label="交割月" width="100" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
@ -46,10 +91,28 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="multiplier" label="乘数" width="70" />
<el-table-column prop="price_tick" label="最小变动" width="90" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button
v-if="!row.is_main"
size="small"
@click="setAsMain(row.symbol)"
>
设主力
</el-button>
<el-button
size="small"
type="primary"
@click="goToKline(row.symbol)"
>
查询
</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 10px; color: #909399; font-size: 13px;">
{{ contracts.length }} 条记录
</div>
</div>
</el-card>
</div>
@ -58,24 +121,53 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { getContracts, syncContracts as apiSyncContracts } from '../api'
const contracts = ref([])
const router = useRouter()
const treeRef = ref(null)
//
const searchText = ref('')
const productTree = ref([])
const selectedProduct = ref(null)
const productContracts = ref([])
const loading = ref(false)
const filterExchange = ref('')
const filterProduct = ref('')
const filterActive = ref(null)
const loadContracts = async () => {
loading.value = true
const treeProps = {
children: 'children',
label: 'product_name',
}
//
const loadProductTree = async () => {
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 fetch('/api/v1/products/tree')
const data = await res.json()
const res = await getContracts(params)
contracts.value = res.data.items || []
//
productTree.value = data.data.categories.map(cat => ({
category: cat.category,
product_name: cat.category,
product_code: `cat_${cat.category}`,
children: cat.products,
}))
} catch (e) {
ElMessage.error('加载品种树失败')
}
}
//
const handleProductClick = async (data) => {
if (!data.contracts) return //
selectedProduct.value = data
loading.value = true
try {
const res = await fetch(`/api/v1/products/${data.product_code}/contracts?is_active=true`)
const result = await res.json()
productContracts.value = result.data || []
} catch (e) {
ElMessage.error('加载合约失败')
} finally {
@ -83,12 +175,50 @@ const loadContracts = async () => {
}
}
//
const setAsMain = async (symbol) => {
try {
const res = await fetch(`/api/v1/contracts/${symbol}/set-main`, { method: 'POST' })
const data = await res.json()
if (data.code === 0) {
ElMessage.success('设置成功')
//
if (selectedProduct.value) {
handleProductClick(selectedProduct.value)
}
} else {
ElMessage.error(data.message)
}
} catch (e) {
ElMessage.error('设置失败')
}
}
//
const updateMainContracts = async () => {
try {
const res = await fetch('/api/v1/contracts/update-main', { method: 'POST' })
const data = await res.json()
ElMessage.success(data.message)
loadProductTree()
} catch (e) {
ElMessage.error('更新失败')
}
}
// K线
const goToKline = (symbol) => {
router.push({ path: '/kline', query: { symbol } })
}
//
const syncContracts = async () => {
try {
const res = await apiSyncContracts()
if (res.data.code === 0) {
ElMessage.success(`同步成功,共 ${res.data.data.synced}`)
loadContracts()
loadProductTree()
} else {
ElMessage.error(res.data.message)
}
@ -98,6 +228,86 @@ const syncContracts = async () => {
}
onMounted(() => {
loadContracts()
loadProductTree()
})
</script>
<style scoped>
.contract-management {
height: calc(100vh - 140px);
}
.contract-layout {
display: flex;
gap: 20px;
height: 100%;
}
.product-tree-panel {
width: 260px;
flex-shrink: 0;
border-right: 1px solid #e6e6e6;
padding-right: 16px;
overflow-y: auto;
}
.contract-list-panel {
flex: 1;
overflow-y: auto;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.node-label {
flex: 1;
font-weight: 500;
}
.node-code {
color: #909399;
font-size: 12px;
font-family: monospace;
}
.node-badge {
margin-left: auto;
}
.product-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e6e6e6;
}
.product-header h3 {
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 8px;
}
.product-info {
color: #606266;
font-size: 13px;
}
.empty-hint {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
}
:deep(.el-tree-node__content) {
height: 36px;
}
:deep(.el-tree-node__content:hover) {
background-color: #f5f7fa;
}
</style>

@ -41,9 +41,83 @@
<el-form-item>
<el-button type="primary" @click="queryKline"></el-button>
<el-button @click="syncKline"></el-button>
<el-button type="success" @click="showBatchDialog"></el-button>
</el-form-item>
</el-form>
<!-- 批量缓存对话框 -->
<el-dialog v-model="batchVisible" title="批量缓存K线数据" width="600px">
<el-form :model="batchForm" label-width="100px">
<el-form-item label="选择合约">
<el-select
v-model="batchForm.symbols"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="请选择合约(可多选)"
style="width: 100%;"
>
<el-option
v-for="c in contractOptions"
:key="c.symbol"
:label="`${c.symbol} ${c.name}`"
:value="c.symbol"
/>
</el-select>
<div style="margin-top: 8px; color: #909399; font-size: 12px;">
已选择 {{ batchForm.symbols.length }} 个合约
</div>
</el-form-item>
<el-form-item label="周期">
<el-radio-group v-model="batchForm.period">
<el-radio label="daily">日K</el-radio>
<el-radio label="weekly">周K</el-radio>
<el-radio label="60m">60分钟</el-radio>
<el-radio label="30m">30分钟</el-radio>
<el-radio label="15m">15分钟</el-radio>
<el-radio label="5m">5分钟</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="日期区间">
<el-date-picker
v-model="batchForm.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%;"
/>
</el-form-item>
</el-form>
<!-- 进度显示 -->
<div v-if="batchProgress.visible" style="margin-top: 16px;">
<el-progress
:percentage="batchProgress.percent"
:status="batchProgress.status"
/>
<div style="margin-top: 8px; font-size: 12px; color: #606266;">
{{ batchProgress.message }}
</div>
</div>
<template #footer>
<el-button @click="batchVisible = false">取消</el-button>
<el-button
type="primary"
@click="executeBatchSync"
:loading="batchProgress.visible"
:disabled="batchForm.symbols.length === 0 || !batchForm.dateRange"
>
开始缓存
</el-button>
</template>
</el-dialog>
<!-- K线图 -->
<div ref="chartRef" style="width: 100%; height: 500px; margin-top: 20px;" v-loading="chartLoading"></div>
@ -75,9 +149,10 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { getKline, getContracts, syncKline as apiSyncKline } from '../api'
import { getKline, getContracts, syncKline as apiSyncKline, batchSyncKline } from '../api'
const chartRef = ref(null)
let chartInstance = null
@ -95,6 +170,20 @@ const tableData = ref([])
const loading = ref(false)
const chartLoading = ref(false)
//
const batchVisible = ref(false)
const batchForm = ref({
symbols: [],
period: 'daily',
dateRange: [],
})
const batchProgress = ref({
visible: false,
percent: 0,
status: '',
message: '',
})
const formatVolume = (v) => {
if (!v) return '-'
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
@ -160,6 +249,71 @@ const syncKline = async () => {
}
}
//
const showBatchDialog = () => {
batchForm.value = {
symbols: [],
period: 'daily',
dateRange: [],
}
batchProgress.value = {
visible: false,
percent: 0,
status: '',
message: '',
}
batchVisible.value = true
}
const executeBatchSync = async () => {
if (batchForm.value.symbols.length === 0) {
ElMessage.warning('请至少选择一个合约')
return
}
if (!batchForm.value.dateRange || batchForm.value.dateRange.length !== 2) {
ElMessage.warning('请选择日期区间')
return
}
batchProgress.value = {
visible: true,
percent: 0,
status: '',
message: '正在同步...',
}
try {
const res = await batchSyncKline({
symbols: batchForm.value.symbols,
period: batchForm.value.period,
start_date: batchForm.value.dateRange[0],
end_date: batchForm.value.dateRange[1],
})
if (res.data.code === 0) {
const result = res.data.data
batchProgress.value.percent = 100
batchProgress.value.status = 'success'
batchProgress.value.message = `同步完成!成功 ${result.success} 个,失败 ${result.failed} 个,共 ${result.total_records} 条记录`
ElMessage.success(`批量同步完成!成功 ${result.success}/${result.total} 个合约`)
// 3
setTimeout(() => {
batchVisible.value = false
}, 3000)
} else {
batchProgress.value.status = 'exception'
batchProgress.value.message = res.data.message
ElMessage.error(res.data.message)
}
} catch (e) {
batchProgress.value.status = 'exception'
batchProgress.value.message = '同步失败:' + (e.message || '未知错误')
ElMessage.error('批量同步失败')
}
}
const renderChart = () => {
nextTick(() => {
if (!chartRef.value) return
@ -231,8 +385,16 @@ const renderChart = () => {
})
}
const route = useRoute()
onMounted(() => {
loadContracts()
window.addEventListener('resize', () => chartInstance?.resize())
// URL query
if (route.query.symbol) {
form.value.symbol = route.query.symbol
queryKline()
}
})
</script>

Loading…
Cancel
Save