""" K线数据服务 获取和管理股票的K线数据(日K、周K、月K) """ import pandas as pd import logging from datetime import datetime, date, timedelta from typing import List, Dict, Optional, Tuple from app import pro from app.database import DatabaseManager logger = logging.getLogger(__name__) class KlineService: def __init__(self): self.db_manager = DatabaseManager() self.logger = logging.getLogger(__name__) def get_kline_data(self, stock_code: str, kline_type: str = 'daily', start_date: str = None, end_date: str = None, limit: int = 100) -> List[Dict]: """获取K线数据 Args: stock_code: 股票代码 kline_type: K线类型 (daily/weekly/monthly) start_date: 开始日期 (YYYYMMDD) end_date: 结束日期 (YYYYMMDD) limit: 返回数据条数限制 Returns: K线数据列表 """ try: # 优先从数据库获取 kline_data = self._get_kline_from_db(stock_code, kline_type, start_date, end_date, limit) if kline_data: return kline_data # 从API获取数据 self.logger.info(f"从API获取 {stock_code} 的{self._get_kline_name(kline_type)}数据") api_data = self._fetch_kline_from_api(stock_code, kline_type, start_date, end_date, limit) # 保存到数据库 if api_data: self._save_kline_to_db(api_data, kline_type) return api_data return [] except Exception as e: self.logger.error(f"获取K线数据失败: {stock_code}, {kline_type}, 错误: {e}") return [] def _get_kline_from_db(self, stock_code: str, kline_type: str, start_date: str = None, end_date: str = None, limit: int = 100) -> List[Dict]: """从数据库获取K线数据""" try: with self.db_manager.get_connection() as conn: cursor = conn.cursor(dictionary=True) # 构建查询条件 conditions = ["stock_code = %s", "kline_type = %s"] params = [stock_code, kline_type] if start_date: conditions.append("trade_date >= %s") params.append(start_date) if end_date: conditions.append("trade_date <= %s") params.append(end_date) query = f""" SELECT * FROM kline_data WHERE {' AND '.join(conditions)} ORDER BY trade_date DESC LIMIT %s """ params.append(limit) cursor.execute(query, params) klines = cursor.fetchall() # 转换日期格式并处理数据类型 result = [] for kline in klines: result.append({ 'date': kline['trade_date'].strftime('%Y-%m-%d'), 'open': float(kline['open_price']), 'high': float(kline['high_price']), 'low': float(kline['low_price']), 'close': float(kline['close_price']), 'volume': int(kline['volume']), 'amount': float(kline['amount']), 'change_percent': float(kline['change_percent']) if kline['change_percent'] else None, 'turnover_rate': float(kline['turnover_rate']) if kline['turnover_rate'] else None, 'pe_ratio': float(kline['pe_ratio']) if kline['pe_ratio'] else None, 'pb_ratio': float(kline['pb_ratio']) if kline['pb_ratio'] else None }) cursor.close() return result except Exception as e: self.logger.error(f"从数据库获取K线数据失败: {e}") return [] def _fetch_kline_from_api(self, stock_code: str, kline_type: str, start_date: str = None, end_date: str = None, limit: int = 100) -> List[Dict]: """从tushare API获取K线数据""" try: # 确定ts_code格式 if stock_code.startswith('6'): ts_code = f"{stock_code}.SH" elif stock_code.startswith(('0', '3')): ts_code = f"{stock_code}.SZ" elif stock_code.startswith('68'): ts_code = f"{stock_code}.SH" else: self.logger.error(f"不支持的股票代码: {stock_code}") return [] # 根据K线类型选择API接口 if kline_type == 'daily': df = self._fetch_daily_data(ts_code, start_date, end_date, limit) elif kline_type == 'weekly': df = self._fetch_weekly_data(ts_code, start_date, end_date, limit) elif kline_type == 'monthly': df = self._fetch_monthly_data(ts_code, start_date, end_date, limit) else: self.logger.error(f"不支持的K线类型: {kline_type}") return [] if df is None or df.empty: self.logger.warning(f"未获取到 {stock_code} 的{self._get_kline_name(kline_type)}数据") return [] # 转换为标准格式 result = [] for _, row in df.iterrows(): try: kline_data = { 'stock_code': stock_code, 'trade_date': pd.to_datetime(row['trade_date']).date(), 'open_price': float(row['open']), 'high_price': float(row['high']), 'low_price': float(row['low']), 'close_price': float(row['close']), 'volume': int(row['vol']) if pd.notna(row.get('vol')) else 0, 'amount': float(row.get('amount', 0)) / 10000 if pd.notna(row.get('amount')) else 0, # 转换为万元 'change_percent': float(row['pct_chg']) / 100 if pd.notna(row.get('pct_chg')) else 0, # 转换为小数 'change_amount': float(row.get('change', 0)) if pd.notna(row.get('change')) else 0, } # 获取额外的估值指标 self._add_valuation_data(kline_data, ts_code, row['trade_date']) result.append(kline_data) except Exception as e: self.logger.error(f"处理K线数据行失败: {e}") continue self.logger.info(f"从API获取到 {len(result)} 条{self._get_kline_name(kline_type)}数据") return result except Exception as e: self.logger.error(f"从API获取K线数据失败: {stock_code}, {kline_type}, 错误: {e}") return [] def _fetch_daily_data(self, ts_code: str, start_date: str = None, end_date: str = None, limit: int = 100) -> pd.DataFrame: """获取日线数据""" try: return pro.daily( ts_code=ts_code, start_date=start_date, end_date=end_date, limit=limit ) except Exception as e: self.logger.error(f"获取日线数据失败: {ts_code}, 错误: {e}") return pd.DataFrame() def _fetch_weekly_data(self, ts_code: str, start_date: str = None, end_date: str = None, limit: int = 100) -> pd.DataFrame: """获取周线数据""" try: return pro.weekly( ts_code=ts_code, start_date=start_date, end_date=end_date, limit=limit ) except Exception as e: self.logger.error(f"获取周线数据失败: {ts_code}, 错误: {e}") return pd.DataFrame() def _fetch_monthly_data(self, ts_code: str, start_date: str = None, end_date: str = None, limit: int = 100) -> pd.DataFrame: """获取月线数据""" try: return pro.monthly( ts_code=ts_code, start_date=start_date, end_date=end_date, limit=limit ) except Exception as e: self.logger.error(f"获取月线数据失败: {ts_code}, 错误: {e}") return pd.DataFrame() def _add_valuation_data(self, kline_data: Dict, ts_code: str, trade_date: str): """添加估值数据""" try: # 获取当日的基本数据 daily_basic = pro.daily_basic( ts_code=ts_code, trade_date=trade_date, fields='ts_code,trade_date,pe,pb,dv_ratio,turnover_rate' ) if not daily_basic.empty: row = daily_basic.iloc[0] kline_data['pe_ratio'] = float(row['pe']) if pd.notna(row['pe']) else None kline_data['pb_ratio'] = float(row['pb']) if pd.notna(row['pb']) else None kline_data['turnover_rate'] = float(row['turnover_rate']) if pd.notna(row['turnover_rate']) else None kline_data['dividend_yield'] = float(row['dv_ratio']) / 100 if pd.notna(row['dv_ratio']) else 0 except Exception as e: # 估值数据获取失败不影响主要数据 pass def _save_kline_to_db(self, kline_data_list: List[Dict], kline_type: str): """保存K线数据到数据库""" try: if not kline_data_list: return with self.db_manager.get_connection() as conn: cursor = conn.cursor() # 使用INSERT ... ON DUPLICATE KEY UPDATE批量保存 query = """ INSERT INTO kline_data ( stock_code, kline_type, trade_date, open_price, high_price, low_price, close_price, volume, amount, change_percent, change_amount, turnover_rate, pe_ratio, pb_ratio, created_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) ON DUPLICATE KEY UPDATE open_price = VALUES(open_price), high_price = VALUES(high_price), low_price = VALUES(low_price), close_price = VALUES(close_price), volume = VALUES(volume), amount = VALUES(amount), change_percent = VALUES(change_percent), change_amount = VALUES(change_amount), turnover_rate = VALUES(turnover_rate), pe_ratio = VALUES(pe_ratio), pb_ratio = VALUES(pb_ratio), updated_at = NOW() """ # 批量插入数据 batch_data = [] for kline_data in kline_data_list: batch_data.append(( kline_data['stock_code'], kline_type, kline_data['trade_date'], kline_data['open_price'], kline_data['high_price'], kline_data['low_price'], kline_data['close_price'], kline_data['volume'], kline_data['amount'], kline_data['change_percent'], kline_data['change_amount'], kline_data.get('turnover_rate'), kline_data.get('pe_ratio'), kline_data.get('pb_ratio') )) cursor.executemany(query, batch_data) conn.commit() cursor.close() self.logger.info(f"成功保存 {len(kline_data_list)} 条{self._get_kline_name(kline_type)}数据") except Exception as e: self.logger.error(f"保存K线数据到数据库失败: {e}") def _get_kline_name(self, kline_type: str) -> str: """获取K线类型的中文名称""" type_names = { 'daily': '日K', 'weekly': '周K', 'monthly': '月K' } return type_names.get(kline_type, kline_type) def batch_update_kline_data(self, stock_codes: List[str] = None, kline_type: str = 'daily', days_back: int = 30) -> Dict: """批量更新K线数据 Args: stock_codes: 股票代码列表,None表示更新所有股票 kline_type: K线类型 days_back: 更新最近多少天的数据 Returns: 更新结果统计 """ try: if stock_codes is None: # 获取所有活跃股票 with self.db_manager.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT stock_code FROM stocks WHERE is_active = TRUE") stock_codes = [row[0] for row in cursor.fetchall()] cursor.close() total_count = len(stock_codes) success_count = 0 failed_count = 0 # 计算日期范围 end_date = datetime.now().strftime('%Y%m%d') start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y%m%d') self.logger.info(f"开始批量更新 {total_count} 只股票的{self._get_kline_name(kline_type)}数据") for i, stock_code in enumerate(stock_codes): try: kline_data = self._fetch_kline_from_api( stock_code, kline_type, start_date, end_date, days_back ) if kline_data: self._save_kline_to_db(kline_data, kline_type) success_count += 1 else: failed_count += 1 # 进度日志 if (i + 1) % 50 == 0: self.logger.info(f"进度: {i + 1}/{total_count}, 成功: {success_count}, 失败: {failed_count}") except Exception as e: self.logger.error(f"更新股票 {stock_code} 的K线数据失败: {e}") failed_count += 1 continue result = { 'total': total_count, 'success': success_count, 'failed': failed_count, 'kline_type': kline_type, 'days_back': days_back } self.logger.info(f"批量更新完成: {result}") return result except Exception as e: self.logger.error(f"批量更新K线数据失败: {e}") return {'error': str(e)} def get_market_overview(self, limit: int = 20) -> Dict: """获取市场概览数据""" try: today = datetime.now().strftime('%Y-%m-%d') with self.db_manager.get_connection() as conn: cursor = conn.cursor(dictionary=True) # 获取涨跌统计 query = """ SELECT COUNT(*) as total_count, SUM(CASE WHEN change_percent > 0 THEN 1 ELSE 0 END) as up_count, SUM(CASE WHEN change_percent < 0 THEN 1 ELSE 0 END) as down_count, SUM(CASE WHEN change_percent = 0 THEN 1 ELSE 0 END) as flat_count, SUM(CASE WHEN change_percent >= 0.095 THEN 1 ELSE 0 END) as limit_up_count, SUM(CASE WHEN change_percent <= -0.095 THEN 1 ELSE 0 END) as limit_down_count, AVG(change_percent) as avg_change, SUM(volume) as total_volume, SUM(amount) as total_amount FROM kline_data WHERE kline_type = 'daily' AND trade_date = %s """ cursor.execute(query, (today,)) stats = cursor.fetchone() # 获取涨幅榜 cursor.execute(""" SELECT stock_code, change_percent, close_price, volume FROM kline_data WHERE kline_type = 'daily' AND trade_date = %s AND change_percent IS NOT NULL ORDER BY change_percent DESC LIMIT %s """, (today, limit)) top_gainers = cursor.fetchall() # 获取跌幅榜 cursor.execute(""" SELECT stock_code, change_percent, close_price, volume FROM kline_data WHERE kline_type = 'daily' AND trade_date = %s AND change_percent IS NOT NULL ORDER BY change_percent ASC LIMIT %s """, (today, limit)) top_losers = cursor.fetchall() # 获取成交量榜 cursor.execute(""" SELECT stock_code, volume, amount, change_percent, close_price FROM kline_data WHERE kline_type = 'daily' AND trade_date = %s ORDER BY volume DESC LIMIT %s """, (today, limit)) volume_leaders = cursor.fetchall() cursor.close() return { 'date': today, 'statistics': stats, 'top_gainers': top_gainers, 'top_losers': top_losers, 'volume_leaders': volume_leaders } except Exception as e: self.logger.error(f"获取市场概览失败: {e}") return {} def clean_old_kline_data(self, days_to_keep: int = 365): """清理旧的K线数据""" try: cutoff_date = (datetime.now() - timedelta(days=days_to_keep)).date() with self.db_manager.get_connection() as conn: cursor = conn.cursor() # 删除指定日期之前的数据 query = "DELETE FROM kline_data WHERE trade_date < %s" cursor.execute(query, (cutoff_date,)) deleted_count = cursor.rowcount conn.commit() cursor.close() self.logger.info(f"清理了 {deleted_count} 条旧的K线数据") return deleted_count except Exception as e: self.logger.error(f"清理旧K线数据失败: {e}") return 0