From cd458193436f10c3950f8fb43042ff1a0317a53b Mon Sep 17 00:00:00 2001 From: Lxy Date: Sun, 10 May 2026 23:55:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=93=81=E7=A7=8D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E9=87=8D=E5=A4=8D=E6=95=B0=E6=8D=AE=E7=AD=89?= =?UTF-8?q?=E5=A4=84=E7=90=86=E3=80=81=E5=A2=9E=E5=8A=A0=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=90=88=E7=BA=A6=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 99 +++++++ backend/app/models/__init__.py | 33 ++- backend/app/schemas/__init__.py | 52 ++++ backend/app/services/contract_service.py | 201 ++++++++++++++ backend/app/services/kline_service.py | 49 ++++ backend/app/services/product_service.py | 255 ++++++++++++++++++ backend/migrate_contracts.py | 237 +++++++++++++++++ frontend/src/api/index.js | 8 + frontend/src/views/ContractView.vue | 320 +++++++++++++++++++---- frontend/src/views/KlineView.vue | 164 +++++++++++- 10 files changed, 1361 insertions(+), 57 deletions(-) create mode 100644 backend/app/services/product_service.py create mode 100644 backend/migrate_contracts.py diff --git a/backend/app/main.py b/backend/app/main.py index 09ef25b..8c07622 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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(): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ea736d8..49426c8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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"), ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 06bfb50..935de6b 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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 diff --git a/backend/app/services/contract_service.py b/backend/app/services/contract_service.py index cf02a7d..484cbc3 100644 --- a/backend/app/services/contract_service.py +++ b/backend/app/services/contract_service.py @@ -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() diff --git a/backend/app/services/kline_service.py b/backend/app/services/kline_service.py index 8b1c585..75bc20e 100644 --- a/backend/app/services/kline_service.py +++ b/backend/app/services/kline_service.py @@ -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() diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py new file mode 100644 index 0000000..6585d71 --- /dev/null +++ b/backend/app/services/product_service.py @@ -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() diff --git a/backend/migrate_contracts.py b/backend/migrate_contracts.py new file mode 100644 index 0000000..3824c26 --- /dev/null +++ b/backend/migrate_contracts.py @@ -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_info(SQLite 不支持 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() diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index ec71904..5f64e75 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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') diff --git a/frontend/src/views/ContractView.vue b/frontend/src/views/ContractView.vue index 8e47a71..57c74bf 100644 --- a/frontend/src/views/ContractView.vue +++ b/frontend/src/views/ContractView.vue @@ -1,55 +1,118 @@