- 重构数据访问层:引入DAO模式,支持MySQL/SQLite双数据库 - 新增数据库架构:完整的股票数据、AI分析、自选股管理表结构 - 升级AI分析服务:集成豆包大模型,支持多维度分析 - 优化API路由:分离市场数据API,提供更清晰的接口设计 - 完善项目文档:添加数据库迁移指南、新功能指南等 - 清理冗余文件:删除旧的缓存文件和无用配置 - 新增调度器:支持定时任务和数据自动更新 - 改进前端模板:简化的股票展示页面 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
466 lines
18 KiB
Python
466 lines
18 KiB
Python
"""
|
||
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 |