stock-monitor/app/services/kline_service.py
ycg 569c1c8813 重构股票监控系统:数据库架构升级与功能完善
- 重构数据访问层:引入DAO模式,支持MySQL/SQLite双数据库
- 新增数据库架构:完整的股票数据、AI分析、自选股管理表结构
- 升级AI分析服务:集成豆包大模型,支持多维度分析
- 优化API路由:分离市场数据API,提供更清晰的接口设计
- 完善项目文档:添加数据库迁移指南、新功能指南等
- 清理冗余文件:删除旧的缓存文件和无用配置
- 新增调度器:支持定时任务和数据自动更新
- 改进前端模板:简化的股票展示页面

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 15:44:25 +08:00

466 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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