|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +""" |
| 3 | +=================================== |
| 4 | +数据缓存管理模块 |
| 5 | +=================================== |
| 6 | +
|
| 7 | +参考 daily_stock_analysis 项目实现 |
| 8 | +用于缓存实时行情和K线数据,减少重复请求 |
| 9 | +
|
| 10 | +特性: |
| 11 | +1. TTL (Time To Live) 过期机制 |
| 12 | +2. LRU (Least Recently Used) 淘汰策略 |
| 13 | +3. 按数据类型分区管理 |
| 14 | +""" |
| 15 | + |
| 16 | +import time |
| 17 | +import logging |
| 18 | +from typing import Dict, Any, Optional, List |
| 19 | +from collections import OrderedDict |
| 20 | +from dataclasses import dataclass, field |
| 21 | +from datetime import datetime |
| 22 | +import threading |
| 23 | + |
| 24 | +logger = logging.getLogger(__name__) |
| 25 | + |
| 26 | + |
| 27 | +@dataclass |
| 28 | +class CacheEntry: |
| 29 | + """缓存条目""" |
| 30 | + data: Any |
| 31 | + timestamp: float |
| 32 | + ttl: float |
| 33 | + hit_count: int = 0 |
| 34 | + |
| 35 | + def is_expired(self) -> bool: |
| 36 | + """检查是否过期""" |
| 37 | + return time.time() - self.timestamp > self.ttl |
| 38 | + |
| 39 | + def age(self) -> float: |
| 40 | + """返回缓存年龄(秒)""" |
| 41 | + return time.time() - self.timestamp |
| 42 | + |
| 43 | + |
| 44 | +class DataCache: |
| 45 | + """ |
| 46 | + 数据缓存管理器 |
| 47 | + |
| 48 | + 特性: |
| 49 | + - TTL 过期机制 |
| 50 | + - 最大容量限制 |
| 51 | + - LRU 淘汰策略 |
| 52 | + - 线程安全 |
| 53 | + """ |
| 54 | + |
| 55 | + def __init__( |
| 56 | + self, |
| 57 | + name: str = "default", |
| 58 | + default_ttl: float = 600.0, # 默认10分钟 |
| 59 | + max_size: int = 1000 # 最大缓存条目数 |
| 60 | + ): |
| 61 | + self.name = name |
| 62 | + self.default_ttl = default_ttl |
| 63 | + self.max_size = max_size |
| 64 | + self._cache: OrderedDict[str, CacheEntry] = OrderedDict() |
| 65 | + self._lock = threading.RLock() |
| 66 | + |
| 67 | + # 统计信息 |
| 68 | + self._hits = 0 |
| 69 | + self._misses = 0 |
| 70 | + |
| 71 | + def get(self, key: str) -> Optional[Any]: |
| 72 | + """ |
| 73 | + 获取缓存数据 |
| 74 | + |
| 75 | + Returns: |
| 76 | + 缓存的数据,不存在或过期返回 None |
| 77 | + """ |
| 78 | + with self._lock: |
| 79 | + if key not in self._cache: |
| 80 | + self._misses += 1 |
| 81 | + return None |
| 82 | + |
| 83 | + entry = self._cache[key] |
| 84 | + |
| 85 | + # 检查是否过期 |
| 86 | + if entry.is_expired(): |
| 87 | + del self._cache[key] |
| 88 | + self._misses += 1 |
| 89 | + logger.debug(f"[缓存] {self.name}:{key} 已过期,删除") |
| 90 | + return None |
| 91 | + |
| 92 | + # 更新访问顺序(LRU) |
| 93 | + self._cache.move_to_end(key) |
| 94 | + entry.hit_count += 1 |
| 95 | + self._hits += 1 |
| 96 | + |
| 97 | + logger.debug(f"[缓存命中] {self.name}:{key} (年龄: {entry.age():.0f}s/{entry.ttl:.0f}s)") |
| 98 | + return entry.data |
| 99 | + |
| 100 | + def set( |
| 101 | + self, |
| 102 | + key: str, |
| 103 | + data: Any, |
| 104 | + ttl: Optional[float] = None |
| 105 | + ) -> None: |
| 106 | + """ |
| 107 | + 设置缓存数据 |
| 108 | + |
| 109 | + Args: |
| 110 | + key: 缓存键 |
| 111 | + data: 缓存数据 |
| 112 | + ttl: 过期时间(秒),None 使用默认值 |
| 113 | + """ |
| 114 | + with self._lock: |
| 115 | + # 检查容量,执行 LRU 淘汰 |
| 116 | + while len(self._cache) >= self.max_size: |
| 117 | + oldest_key, _ = self._cache.popitem(last=False) |
| 118 | + logger.debug(f"[缓存] {self.name} 容量已满,淘汰: {oldest_key}") |
| 119 | + |
| 120 | + actual_ttl = ttl if ttl is not None else self.default_ttl |
| 121 | + self._cache[key] = CacheEntry( |
| 122 | + data=data, |
| 123 | + timestamp=time.time(), |
| 124 | + ttl=actual_ttl |
| 125 | + ) |
| 126 | + |
| 127 | + logger.debug(f"[缓存更新] {self.name}:{key} TTL={actual_ttl}s") |
| 128 | + |
| 129 | + def delete(self, key: str) -> bool: |
| 130 | + """删除缓存条目""" |
| 131 | + with self._lock: |
| 132 | + if key in self._cache: |
| 133 | + del self._cache[key] |
| 134 | + logger.debug(f"[缓存] {self.name}:{key} 已删除") |
| 135 | + return True |
| 136 | + return False |
| 137 | + |
| 138 | + def clear(self) -> int: |
| 139 | + """清空缓存""" |
| 140 | + with self._lock: |
| 141 | + count = len(self._cache) |
| 142 | + self._cache.clear() |
| 143 | + logger.info(f"[缓存] {self.name} 已清空 {count} 条记录") |
| 144 | + return count |
| 145 | + |
| 146 | + def cleanup_expired(self) -> int: |
| 147 | + """清理过期条目""" |
| 148 | + with self._lock: |
| 149 | + expired_keys = [ |
| 150 | + key for key, entry in self._cache.items() |
| 151 | + if entry.is_expired() |
| 152 | + ] |
| 153 | + for key in expired_keys: |
| 154 | + del self._cache[key] |
| 155 | + |
| 156 | + if expired_keys: |
| 157 | + logger.debug(f"[缓存] {self.name} 清理 {len(expired_keys)} 条过期记录") |
| 158 | + return len(expired_keys) |
| 159 | + |
| 160 | + def stats(self) -> Dict[str, Any]: |
| 161 | + """获取缓存统计信息""" |
| 162 | + with self._lock: |
| 163 | + total_requests = self._hits + self._misses |
| 164 | + hit_rate = self._hits / total_requests if total_requests > 0 else 0 |
| 165 | + |
| 166 | + return { |
| 167 | + 'name': self.name, |
| 168 | + 'size': len(self._cache), |
| 169 | + 'max_size': self.max_size, |
| 170 | + 'hits': self._hits, |
| 171 | + 'misses': self._misses, |
| 172 | + 'hit_rate': f"{hit_rate:.1%}", |
| 173 | + 'default_ttl': self.default_ttl |
| 174 | + } |
| 175 | + |
| 176 | + |
| 177 | +# ============================================ |
| 178 | +# 全局缓存实例 |
| 179 | +# ============================================ |
| 180 | + |
| 181 | +# A股实时行情缓存(20分钟TTL,全市场数据量大) |
| 182 | +_ashare_realtime_cache = DataCache( |
| 183 | + name="ashare_realtime", |
| 184 | + default_ttl=1200.0, # 20分钟 |
| 185 | + max_size=6000 # 约5000+股票 |
| 186 | +) |
| 187 | + |
| 188 | +# K线数据缓存(5分钟TTL,按需缓存) |
| 189 | +_kline_cache = DataCache( |
| 190 | + name="kline", |
| 191 | + default_ttl=300.0, # 5分钟 |
| 192 | + max_size=500 # 最多500个交易对 |
| 193 | +) |
| 194 | + |
| 195 | +# 股票基本信息缓存(1天TTL) |
| 196 | +_stock_info_cache = DataCache( |
| 197 | + name="stock_info", |
| 198 | + default_ttl=86400.0, # 24小时 |
| 199 | + max_size=6000 |
| 200 | +) |
| 201 | + |
| 202 | + |
| 203 | +def get_realtime_cache() -> DataCache: |
| 204 | + """获取实时行情缓存""" |
| 205 | + return _ashare_realtime_cache |
| 206 | + |
| 207 | + |
| 208 | +def get_kline_cache() -> DataCache: |
| 209 | + """获取K线数据缓存""" |
| 210 | + return _kline_cache |
| 211 | + |
| 212 | + |
| 213 | +def get_stock_info_cache() -> DataCache: |
| 214 | + """获取股票信息缓存""" |
| 215 | + return _stock_info_cache |
| 216 | + |
| 217 | + |
| 218 | +def generate_kline_cache_key( |
| 219 | + symbol: str, |
| 220 | + timeframe: str, |
| 221 | + limit: int, |
| 222 | + before_time: Optional[int] = None |
| 223 | +) -> str: |
| 224 | + """ |
| 225 | + 生成K线缓存键 |
| 226 | + |
| 227 | + 格式: symbol:timeframe:limit[:before_time] |
| 228 | + """ |
| 229 | + key = f"{symbol}:{timeframe}:{limit}" |
| 230 | + if before_time: |
| 231 | + key += f":{before_time}" |
| 232 | + return key |
0 commit comments