""" K 线数据服务 v2 - 缓存优先策略 """ import logging from datetime import datetime from typing import List, Optional from sqlalchemy import text from sqlalchemy.orm import Session from app.db.init_db import TimescaleSessionLocal from app.services.cache_service import cache_service from app.services.amazing_data_service import amazing_data_service logger = logging.getLogger(__name__) class KlineService: """K 线数据服务""" @staticmethod def get_kline_data( symbol: str, period: str, start: datetime, end: datetime, page: int = 1, page_size: int = 1000 ) -> List[dict]: """ 获取 K 线数据(支持分页) Args: symbol: 品种代码 period: 周期 (1m, 5m, 1h, 1d 等) start: 开始时间 end: 结束时间 page: 页码 (默认 1) page_size: 每页数量 (默认 1000) Raises: ValueError: 当 start > end 时抛出异常 """ # 边界条件验证:开始时间不能大于结束时间 if start > end: raise ValueError("开始时间不能大于结束时间 (startTime > endTime)") with TimescaleSessionLocal() as db: offset = (page - 1) * page_size query = text(""" SELECT time, open, high, low, close, volume, amount, open_interest FROM kline_data WHERE symbol = :symbol AND period = :period AND time >= :start AND time <= :end ORDER BY time ASC LIMIT :limit OFFSET :offset """) result = db.execute( query, { "symbol": symbol, "period": period, "start": start, "end": end, "limit": page_size, "offset": offset } ) rows = result.fetchall() return [ { "time": row[0].isoformat(), "open": float(row[1]), "high": float(row[2]), "low": float(row[3]), "close": float(row[4]), "volume": int(row[5]), "amount": float(row[6]) if row[6] else None, "open_interest": int(row[7]) if row[7] else None } for row in rows ] @staticmethod def get_latest_kline(symbol: str, period: str) -> Optional[dict]: """获取最新一条 K 线数据""" with TimescaleSessionLocal() as db: query = text(""" SELECT time, open, high, low, close, volume, amount, open_interest FROM kline_data WHERE symbol = :symbol AND period = :period ORDER BY time DESC LIMIT 1 """) result = db.execute(query, {"symbol": symbol, "period": period}) row = result.fetchone() if row: return { "time": row[0].isoformat(), "open": float(row[1]), "high": float(row[2]), "low": float(row[3]), "close": float(row[4]), "volume": int(row[5]), "amount": float(row[6]) if row[6] else None, "open_interest": int(row[7]) if row[7] else None } return None @staticmethod def insert_kline_data( symbol: str, period: str, kline_data: List[dict] ) -> int: """ 批量插入 K 线数据 Returns: 插入的记录数 """ with TimescaleSessionLocal() as db: query = text(""" INSERT INTO kline_data (time, symbol, period, open, high, low, close, volume, amount, open_interest) VALUES (:time, :symbol, :period, :open, :high, :low, :close, :volume, :amount, :open_interest) """) count = 0 for kline in kline_data: db.execute( query, { "time": kline["time"], "symbol": symbol, "period": period, "open": kline["open"], "high": kline["high"], "low": kline["low"], "close": kline["close"], "volume": kline["volume"], "amount": kline.get("amount", 0), "open_interest": kline.get("open_interest", 0) } ) count += 1 db.commit() return count @staticmethod def get_symbols() -> List[str]: """获取所有品种代码""" with TimescaleSessionLocal() as db: query = text(""" SELECT DISTINCT symbol FROM kline_data ORDER BY symbol """) result = db.execute(query) return [row[0] for row in result.fetchall()] @staticmethod def get_periods() -> List[str]: """获取所有周期""" with TimescaleSessionLocal() as db: query = text(""" SELECT DISTINCT period FROM kline_data ORDER BY period """) result = db.execute(query) return [row[0] for row in result.fetchall()] # ==================== V2 缓存优先策略 ==================== @staticmethod async def get_kline_data_v2( symbol: str, period: str, start_date: datetime, end_date: datetime, page: int = 1, page_size: int = 1000, use_cache: bool = True ) -> List[dict]: """ 获取 K 线数据 v2 - 缓存优先策略 核心逻辑: 1. 先查询 Redis 缓存 2. 缓存命中直接返回 3. 缓存未命中则调用 amazingData 获取数据 4. 写入缓存并返回 Args: symbol: 品种代码 period: 周期 (1m, 5m, 1h, 1d 等) start_date: 开始时间 end_date: 结束时间 page: 页码 page_size: 每页数量 use_cache: 是否使用缓存 Returns: K 线数据列表 """ # 边界条件验证 if start_date > end_date: raise ValueError("开始时间不能大于结束时间 (startTime > endTime)") # 1. 尝试从缓存获取 if use_cache: cached_data = await cache_service.get_kline( symbol=symbol, period=period, start_date=start_date, end_date=end_date ) if cached_data: logger.info(f"Cache hit for {symbol} {period} {start_date} to {end_date}") # 分页处理 start_idx = (page - 1) * page_size end_idx = start_idx + page_size return cached_data[start_idx:end_idx] logger.info(f"Cache miss for {symbol} {period}, fetching from amazingData") # 2. 缓存未命中,调用 amazingData 获取数据 try: # 确保连接 if not amazing_data_service.ensure_connected(): logger.warning("amazingData not connected, trying database fallback") # 回退到数据库查询 return KlineService.get_kline_data(symbol, period, start_date, end_date, page, page_size) # 从 amazingData 获取数据 kline_data = amazing_data_service.get_kline_data( symbol=symbol, period=period, start_date=start_date.strftime("%Y-%m-%d"), end_date=end_date.strftime("%Y-%m-%d") ) if not kline_data: logger.warning(f"No data from amazingData for {symbol} {period}") # 回退到数据库查询 return KlineService.get_kline_data(symbol, period, start_date, end_date, page, page_size) # 3. 写入缓存 if use_cache: await cache_service.set_kline( symbol=symbol, period=period, start_date=start_date, end_date=end_date, data=kline_data ) logger.info(f"Cached {len(kline_data)} records for {symbol} {period}") # 4. 分页返回 start_idx = (page - 1) * page_size end_idx = start_idx + page_size return kline_data[start_idx:end_idx] except Exception as e: logger.error(f"Failed to fetch from amazingData: {e}") # 回退到数据库查询 return KlineService.get_kline_data(symbol, period, start_date, end_date, page, page_size) @staticmethod async def get_latest_kline_v2( symbol: str, period: str, use_cache: bool = True ) -> Optional[dict]: """ 获取最新一条 K 线数据(缓存优先) Args: symbol: 品种代码 period: 周期 use_cache: 是否使用缓存 Returns: 最新一条 K 线数据,如果没有则返回 None """ from datetime import timedelta # 查询最近 7 天的数据 end_date = datetime.utcnow() start_date = end_date - timedelta(days=7) # 获取数据 data = await KlineService.get_kline_data_v2( symbol=symbol, period=period, start_date=start_date, end_date=end_date, page=1, page_size=1, use_cache=use_cache ) return data[0] if data else None