#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ K线数据存储模块 按周期和Symbol存储K线数据 """ from collections import defaultdict from datetime import datetime from typing import List, Dict, Optional import threading class KlineStore: """K线数据存储(只负责数据CRUD,不包含业务逻辑)""" # 支持的周期 PERIODS = ['H4', 'H1', 'M15', 'M5', 'M1'] # 各周期最大存储条数 MAX_KLINES = { 'H4': 1500, # 4小时,6个月约1100根,留余量 'H1': 1000, # 1小时,1个月约720根 'M15': 500, # 15分钟,3天约288根 'M5': 400, # 5分钟,24小时288根 'M1': 100 # 1分钟,1小时60根 } # 各周期时间间隔(秒) PERIOD_INTERVALS = { 'H4': 4 * 60 * 60, # 4小时 'H1': 1 * 60 * 60, # 1小时 'M15': 15 * 60, # 15分钟 'M5': 5 * 60, # 5分钟 'M1': 1 * 60 # 1分钟 } def __init__(self): # 存储结构: {SYMBOL: {PERIOD: [KlineData, ...]}} # 这里存储的是字典格式,由 KlineService 转换 self._klines = defaultdict(lambda: defaultdict(list)) self._lock = threading.RLock() # 标记每个symbol每个周期是否已收到全量数据 self._initialized = defaultdict(lambda: defaultdict(bool)) # 记录每个symbol的M1数据最后更新时间(本地时间) self._m1_update_time = {} print("[KlineStore] K线存储已初始化") def save_klines(self, symbol: str, period: str, klines: List[Dict], is_full: bool = False) -> Dict: """ 保存K线数据(纯存储操作) Args: symbol: 交易品种 period: 周期 klines: K线字典列表 is_full: 是否为全量数据 Returns: {"status": "ok", "count": N, "total": M, "is_full": bool} """ period = period.upper() if period not in self.PERIODS: return {"status": "error", "message": f"不支持的周期: {period}"} with self._lock: if is_full: self._klines[symbol][period] = [] print(f"[KlineStore] 收到 {symbol} {period} 全量数据,清空该周期历史数据") new_count = 0 update_count = 0 for k in klines: # 检查是否已存在相同时间戳的数据 existing = self._klines[symbol][period] ts = self._normalize_timestamp(k.get('timestamp') or k.get('time')) found_idx = -1 for i, existing_kline in enumerate(existing): if self._normalize_timestamp(existing_kline.get('timestamp') or existing_kline.get('time')) == ts: found_idx = i break if found_idx >= 0: existing[found_idx] = k update_count += 1 else: existing.append(k) new_count += 1 # 按时间排序 self._klines[symbol][period].sort( key=lambda x: self._normalize_timestamp(x.get('timestamp') or x.get('time')) ) # 限制最大条数 max_count = self.MAX_KLINES.get(period, 500) if len(self._klines[symbol][period]) > max_count: self._klines[symbol][period] = self._klines[symbol][period][-max_count:] self._initialized[symbol][period] = True if period == 'M1' and (new_count > 0 or update_count > 0): self._m1_update_time[symbol] = datetime.now() total = len(self._klines[symbol][period]) print(f"[KlineStore] {symbol} {period} 保存了 {new_count} 条新数据, 更新 {update_count} 条, 当前共 {total} 条") return { "status": "ok", "count": new_count, "total": total, "is_full": is_full } def get_klines(self, symbol: str, period: str, count: int = 100) -> List[Dict]: """获取K线数据""" period = period.upper() with self._lock: klines = self._klines[symbol][period][-count:] return list(klines) def get_all_klines(self, symbol: str, period: str) -> List[Dict]: """获取所有K线数据""" period = period.upper() with self._lock: return list(self._klines[symbol][period]) def get_latest_price(self, symbol: str) -> Optional[float]: """获取最新价格(从K线的最新close,优先M1)""" with self._lock: actual_symbol = None if symbol in self._klines: actual_symbol = symbol else: symbol_base = symbol.replace('#', '') for s in self._klines: if s.replace('#', '') == symbol_base: actual_symbol = s break if not actual_symbol: return None for period in ['M1', 'M5', 'M15', 'H1', 'H4']: klines = self._klines[actual_symbol][period] if klines: return float(klines[-1].get('close', 0)) return None def is_initialized(self, symbol: str, period: str) -> bool: """检查某个周期的数据是否已初始化""" period = period.upper() return self._initialized[symbol][period] def check_all_initialized(self, symbol: str) -> bool: """检查所有周期是否都已初始化""" return all(self._initialized[symbol][p] for p in self.PERIODS) def clear_symbol(self, symbol: str): """清除某个Symbol的数据""" with self._lock: if symbol in self._klines: del self._klines[symbol] if symbol in self._initialized: del self._initialized[symbol] def get_status(self) -> Dict: """获取存储状态""" with self._lock: status = {} for symbol in self._klines: status[symbol] = {} for period in self.PERIODS: count = len(self._klines[symbol][period]) initialized = self._initialized[symbol][period] status[symbol][period] = { "count": count, "initialized": initialized } return status def get_symbols(self) -> List[str]: """获取所有有实际数据的symbol列表""" with self._lock: symbols = [] for symbol in self._klines: has_data = False for period in self.PERIODS: if len(self._klines[symbol][period]) > 0: has_data = True break if has_data: symbols.append(symbol) return symbols def get_latest_kline_time(self, symbol: str, period: str = 'M1') -> Optional[datetime]: """获取指定品种和周期的最新K线时间戳""" period = period.upper() with self._lock: klines = self._klines[symbol][period] if not klines: return None latest_ts = klines[-1].get('timestamp') or klines[-1].get('time') return self._parse_timestamp(latest_ts) def check_m1_updated_within(self, symbol: str, seconds: int = 180) -> Dict: """检查M1 K线是否在指定秒数内更新""" with self._lock: has_m1_data = len(self._klines[symbol]['M1']) > 0 if not has_m1_data: return { "has_data": False, "latest_time": None, "update_time": None, "seconds_ago": None, "is_stale": True, "market_status": "closed" } latest_time = self.get_latest_kline_time(symbol, 'M1') update_time = self._m1_update_time.get(symbol) if update_time is None: return { "has_data": True, "latest_time": latest_time, "update_time": None, "seconds_ago": None, "is_stale": True, "market_status": "closed" } now = datetime.now() seconds_ago = int((now - update_time).total_seconds()) if seconds_ago > seconds: market_status = "stale" else: market_status = "active" return { "has_data": True, "latest_time": latest_time, "update_time": update_time, "seconds_ago": seconds_ago, "is_stale": seconds_ago > seconds, "market_status": market_status } def get_period_interval(self, period: str) -> int: """获取周期时间间隔(秒)""" return self.PERIOD_INTERVALS.get(period.upper(), 60) def get_m1_update_time(self, symbol: str) -> Optional[datetime]: """获取M1数据最后更新时间""" return self._m1_update_time.get(symbol) def _normalize_timestamp(self, ts) -> str: """标准化时间戳为字符串""" if isinstance(ts, datetime): return ts.strftime("%Y-%m-%d %H:%M:%S") return str(ts) if ts else "" def _parse_timestamp(self, ts) -> Optional[datetime]: """解析时间戳为datetime对象""" if ts is None: return None if isinstance(ts, datetime): return ts ts_str = str(ts) for fmt in ["%Y-%m-%d %H:%M:%S", "%Y.%m.%d %H:%M", "%Y.%m.%d %H:%M:%S", "%Y-%m-%d %H:%M"]: try: return datetime.strptime(ts_str, fmt) except: continue return None