stock-monitor/app/services/stock_service_db.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

603 lines
27 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.

"""
基于数据库的股票服务
"""
import pandas as pd
from datetime import datetime, date
from app import pro
from app.dao import StockDAO, WatchlistDAO, ConfigDAO
from app.config import Config
import logging
logger = logging.getLogger(__name__)
class StockServiceDB:
def __init__(self):
self.stock_dao = StockDAO()
self.watchlist_dao = WatchlistDAO()
self.config_dao = ConfigDAO()
self.logger = logging.getLogger(__name__)
def get_stock_info(self, stock_code: str, force_refresh: bool = False):
"""获取股票信息"""
try:
today = self.stock_dao.get_today_date()
# 检查缓存
if not force_refresh:
cached_data = self.stock_dao.get_stock_data(stock_code, today)
if cached_data:
self.logger.info(f"从数据库获取股票 {stock_code} 的数据")
return self._format_stock_data(cached_data, self.watchlist_dao.get_watchlist_item(stock_code))
# 从API获取数据
self.logger.info(f"从API获取股票 {stock_code} 的数据...")
api_data = self._fetch_stock_from_api(stock_code)
if 'error' in api_data:
return api_data
# 保存到数据库
stock_info = api_data['stock_info']
success = self.stock_dao.save_stock_data(stock_code, stock_info, today)
if not success:
self.logger.warning(f"保存股票数据失败: {stock_code}")
# 获取目标值
targets = self.watchlist_dao.get_watchlist_item(stock_code)
if targets:
api_data['targets'] = {
"target_market_value": {
"min": targets['target_market_value_min'],
"max": targets['target_market_value_max']
}
}
return api_data
except Exception as e:
self.logger.error(f"获取股票信息失败: {stock_code}, 错误: {e}")
return {"error": f"获取股票数据失败: {str(e)}"}
def _fetch_stock_from_api(self, stock_code: str):
"""从API获取股票数据"""
try:
# 验证股票代码格式
if len(stock_code) != 6:
return {"error": "股票代码格式错误"}
# 确定交易所
if stock_code.startswith('6'):
ts_code = f"{stock_code}.SH"
elif stock_code.startswith(('0', '3')):
ts_code = f"{stock_code}.SZ"
else:
return {"error": "不支持的股票代码"}
# 获取基本信息
basic_info = pro.daily_basic(ts_code=ts_code, fields='ts_code,total_mv', limit=1)
if basic_info.empty:
return {"error": "股票代码不存在"}
# 获取股票名称
stock_name = pro.stock_basic(ts_code=ts_code, fields='name').iloc[0]['name']
# 确保股票信息在数据库中
market = 'SH' if stock_code.startswith('6') else 'SZ'
self.stock_dao.add_or_update_stock(stock_code, stock_name, market)
# 获取最新财务指标
fina_indicator = pro.fina_indicator(
ts_code=ts_code,
period=datetime.now().strftime('%Y%m%d'),
fields='roe,grossprofit_margin,netprofit_margin,debt_to_assets,op_income_yoy,netprofit_yoy,bps,ocfps'
)
if fina_indicator.empty:
fina_indicator = pro.fina_indicator(ts_code=ts_code, limit=1)
# 获取实时行情
today = datetime.now().strftime('%Y%m%d')
daily_data = pro.daily(ts_code=basic_info['ts_code'].iloc[0], start_date=today, end_date=today)
if daily_data.empty:
daily_data = pro.daily(ts_code=basic_info['ts_code'].iloc[0], limit=1)
if daily_data.empty:
return {"error": "无法获取股票行情数据"}
# 获取估值指标
daily_basic = pro.daily_basic(
ts_code=basic_info['ts_code'].iloc[0],
fields='ts_code,trade_date,pe,pe_ttm,pb,ps,dv_ratio',
limit=1
)
if daily_basic.empty:
return {"error": "无法获取股票基础数据"}
latest_basic = daily_basic.iloc[0]
latest_fina = fina_indicator.iloc[0] if not fina_indicator.empty else pd.Series()
# 计算市值
current_price = float(daily_data['close'].iloc[0])
market_value = float(basic_info['total_mv'].iloc[0]) / 10000
# 处理各种指标
dv_ratio = float(latest_basic['dv_ratio']) if pd.notna(latest_basic['dv_ratio']) else 0
dividend_yield = round(dv_ratio / 100, 4)
stock_info = {
"code": stock_code,
"name": stock_name,
"market_value": round(market_value, 2),
"pe_ratio": round(float(latest_basic['pe']), 2) if pd.notna(latest_basic['pe']) else 0,
"pe_ttm": round(float(latest_basic['pe_ttm']), 2) if pd.notna(latest_basic.get('pe_ttm')) else 0,
"pb_ratio": round(float(latest_basic['pb']), 2) if pd.notna(latest_basic['pb']) else 0,
"ps_ratio": round(float(latest_basic['ps']), 2) if pd.notna(latest_basic['ps']) else 0,
"dividend_yield": dividend_yield,
"price": round(current_price, 2),
"change_percent": round(float(daily_data['pct_chg'].iloc[0]) / 100, 4),
# 财务指标
"roe": round(float(latest_fina['roe']) / 100, 4) if pd.notna(latest_fina.get('roe')) else 0,
"gross_profit_margin": round(float(latest_fina['grossprofit_margin']) / 100, 4) if pd.notna(latest_fina.get('grossprofit_margin')) else 0,
"net_profit_margin": round(float(latest_fina['netprofit_margin']) / 100, 4) if pd.notna(latest_fina.get('netprofit_margin')) else 0,
"debt_to_assets": round(float(latest_fina['debt_to_assets']) / 100, 4) if pd.notna(latest_fina.get('debt_to_assets')) else 0,
"revenue_yoy": round(float(latest_fina['op_income_yoy']) / 100, 4) if pd.notna(latest_fina.get('op_income_yoy')) else 0,
"net_profit_yoy": round(float(latest_fina['netprofit_yoy']) / 100, 4) if pd.notna(latest_fina.get('netprofit_yoy')) else 0,
"bps": round(float(latest_fina['bps']), 3) if pd.notna(latest_fina.get('bps')) else 0,
"ocfps": round(float(latest_fina['ocfps']), 3) if pd.notna(latest_fina.get('ocfps')) else 0,
"from_cache": False
}
return {"stock_info": stock_info, "targets": {}}
except Exception as e:
self.logger.error(f"从API获取股票数据失败: {stock_code}, 错误: {e}")
return {"error": f"获取股票数据失败: {str(e)}"}
def _format_stock_data(self, stock_data: dict, watchlist_item: dict = None):
"""格式化股票数据为API返回格式"""
stock_info = {
"code": stock_data['stock_code'],
"name": stock_data['stock_name'],
"market_value": stock_data['market_value'],
"pe_ratio": stock_data['pe_ratio'],
"pb_ratio": stock_data['pb_ratio'],
"ps_ratio": stock_data['ps_ratio'],
"dividend_yield": stock_data['dividend_yield'],
"price": stock_data['price'],
"change_percent": stock_data['change_percent'],
"roe": stock_data['roe'],
"gross_profit_margin": stock_data['gross_profit_margin'],
"net_profit_margin": stock_data['net_profit_margin'],
"debt_to_assets": stock_data['debt_to_assets'],
"revenue_yoy": stock_data['revenue_yoy'],
"net_profit_yoy": stock_data['net_profit_yoy'],
"bps": stock_data['bps'],
"ocfps": stock_data['ocfps'],
"from_cache": stock_data['from_cache']
}
targets = {}
if watchlist_item:
targets = {
"target_market_value": {
"min": watchlist_item['target_market_value_min'],
"max": watchlist_item['target_market_value_max']
}
}
return {"stock_info": stock_info, "targets": targets}
def get_watchlist(self):
"""获取监控列表"""
try:
watchlist_data = self.watchlist_dao.get_watchlist_with_data()
result = []
for item in watchlist_data:
# 处理股票信息
stock_info = {
"code": item['stock_code'],
"name": item['stock_name']
}
# 如果有股票数据,添加更多信息
if item.get('price') is not None:
stock_info.update({
"price": float(item['price']) if item['price'] else None,
"change_percent": float(item['change_percent']) if item['change_percent'] else None,
"market_value": float(item['current_market_value']) if item['current_market_value'] else None,
"pe_ratio": float(item['pe_ratio']) if item['pe_ratio'] else None,
"pb_ratio": float(item['pb_ratio']) if item['pb_ratio'] else None,
"from_cache": bool(item.get('from_cache', False))
})
# 处理目标市值
targets = {}
if item.get('target_market_value_min') is not None or item.get('target_market_value_max') is not None:
targets["target_market_value"] = {
"min": item.get('target_market_value_min'),
"max": item.get('target_market_value_max')
}
result.append({
"stock_info": stock_info,
"targets": targets
})
return result
except Exception as e:
self.logger.error(f"获取监控列表失败: {e}")
return []
def add_watch(self, stock_code: str, target_market_value_min: float = None, target_market_value_max: float = None):
"""添加股票到监控列表"""
try:
success = self.watchlist_dao.add_to_watchlist(
stock_code, target_market_value_min, target_market_value_max
)
return {"status": "success" if success else "failed"}
except Exception as e:
self.logger.error(f"添加监控股票失败: {stock_code}, 错误: {e}")
return {"error": f"添加监控股票失败: {str(e)}"}
def remove_watch(self, stock_code: str):
"""从监控列表移除股票"""
try:
success = self.watchlist_dao.remove_from_watchlist(stock_code)
return {"status": "success" if success else "failed"}
except Exception as e:
self.logger.error(f"移除监控股票失败: {stock_code}, 错误: {e}")
return {"error": f"移除监控股票失败: {str(e)}"}
def update_target(self, stock_code: str, target_market_value_min: float = None, target_market_value_max: float = None):
"""更新股票的目标市值"""
try:
success = self.watchlist_dao.update_watchlist_item(
stock_code, target_market_value_min, target_market_value_max
)
return {"status": "success" if success else "failed"}
except Exception as e:
self.logger.error(f"更新目标市值失败: {stock_code}, 错误: {e}")
return {"error": f"更新目标市值失败: {str(e)}"}
def get_index_info(self):
"""获取主要指数数据(此功能保持不变,不需要数据库存储)"""
try:
index_codes = {
'000001.SH': '上证指数',
'399001.SZ': '深证成指',
'399006.SZ': '创业板指',
'000016.SH': '上证50',
'000300.SH': '沪深300',
'000905.SH': '中证500',
'000852.SH': '中证1000',
'899050.BJ': '北证50',
}
result = []
for ts_code, name in index_codes.items():
try:
df = pro.index_daily(ts_code=ts_code, limit=1)
if not df.empty:
data = df.iloc[0]
# 获取K线数据(最近20天)
kline_df = pro.index_daily(ts_code=ts_code, limit=20)
kline_data = []
if not kline_df.empty:
for _, row in kline_df.iterrows():
kline_data.append({
'date': row['trade_date'],
'open': float(row['open']),
'close': float(row['close']),
'high': float(row['high']),
'low': float(row['low']),
'vol': float(row['vol'])
})
result.append({
'code': ts_code,
'name': name,
'price': float(data['close']),
'change': float(data['pct_chg']),
'kline_data': kline_data
})
except Exception as e:
self.logger.error(f"获取指数 {ts_code} 数据失败: {str(e)}")
continue
return result
except Exception as e:
self.logger.error(f"获取指数数据失败: {str(e)}")
return []
def batch_update_watchlist_data(self):
"""批量更新监控列表的股票数据"""
try:
# 获取需要更新的股票
stocks_to_update = self.watchlist_dao.get_stocks_needing_update()
updated_count = 0
failed_count = 0
for stock_code in stocks_to_update:
try:
result = self.get_stock_info(stock_code, force_refresh=True)
if 'error' not in result:
updated_count += 1
else:
failed_count += 1
except Exception as e:
self.logger.error(f"更新股票数据失败: {stock_code}, 错误: {e}")
failed_count += 1
# 更新最后更新日期
self.config_dao.set_last_data_update_date(self.stock_dao.get_today_date())
return {
"total": len(stocks_to_update),
"updated": updated_count,
"failed": failed_count
}
except Exception as e:
self.logger.error(f"批量更新监控列表数据失败: {e}")
return {"error": f"批量更新失败: {str(e)}"}
# 保持原有的其他方法不变,这些方法不需要数据库存储
def get_company_detail(self, stock_code: str):
"""获取公司详情从API获取实时数据"""
try:
# 处理股票代码格式
if stock_code.startswith('6'):
ts_code = f"{stock_code}.SH"
elif stock_code.startswith(('0', '3')):
ts_code = f"{stock_code}.SZ"
else:
return {"error": "不支持的股票代码"}
# 获取公司基本信息
basic = pro.stock_basic(ts_code=ts_code, fields='name,industry,area,list_date')
if basic.empty:
return {"error": "无法获取公司信息"}
company_info = basic.iloc[0]
# 获取公司详细信息
try:
company_detail = pro.stock_company(ts_code=ts_code)
if not company_detail.empty:
detail_info = company_detail.iloc[0]
company_detail_dict = {
"com_name": str(detail_info.get('com_name', '')),
"chairman": str(detail_info.get('chairman', '')),
"manager": str(detail_info.get('manager', '')),
"secretary": str(detail_info.get('secretary', '')),
"reg_capital": float(detail_info.get('reg_capital', 0)) if pd.notna(detail_info.get('reg_capital')) else 0,
"setup_date": str(detail_info.get('setup_date', '')),
"province": str(detail_info.get('province', '')),
"city": str(detail_info.get('city', '')),
"introduction": str(detail_info.get('introduction', '')),
"website": f"http://{str(detail_info.get('website', '')).strip('http://').strip('https://')}" if detail_info.get('website') else "",
"email": str(detail_info.get('email', '')),
"office": str(detail_info.get('office', '')),
"employees": int(detail_info.get('employees', 0)) if pd.notna(detail_info.get('employees')) else 0,
"main_business": str(detail_info.get('main_business', '')),
"business_scope": str(detail_info.get('business_scope', ''))
}
else:
company_detail_dict = {
"com_name": "", "chairman": "", "manager": "", "secretary": "",
"reg_capital": 0, "setup_date": "", "province": "", "city": "",
"introduction": "", "website": "", "email": "", "office": "",
"employees": 0, "main_business": "", "business_scope": ""
}
except Exception as e:
self.logger.error(f"获取公司详细信息失败: {str(e)}")
company_detail_dict = {
"com_name": "", "chairman": "", "manager": "", "secretary": "",
"reg_capital": 0, "setup_date": "", "province": "", "city": "",
"introduction": "", "website": "", "email": "", "office": "",
"employees": 0, "main_business": "", "business_scope": ""
}
# 获取最新财务指标
try:
fina = pro.fina_indicator(ts_code=ts_code, period=datetime.now().strftime('%Y%m%d'))
if fina.empty:
fina = pro.fina_indicator(ts_code=ts_code, limit=1)
if fina.empty:
return {"error": "无法获取财务数据"}
fina_info = fina.iloc[0]
except Exception as e:
self.logger.error(f"获取财务指标失败: {str(e)}")
return {"error": "获取财务指标失败"}
# 获取市值信息用于PE、PB等指标
try:
daily_basic = pro.daily_basic(ts_code=ts_code, fields='pe,pb,ps,dv_ratio', limit=1)
if not daily_basic.empty:
latest_basic = daily_basic.iloc[0]
else:
latest_basic = pd.Series({'pe': 0, 'pb': 0, 'ps': 0, 'dv_ratio': 0})
except Exception as e:
self.logger.error(f"获取PE/PB失败: {str(e)}")
latest_basic = pd.Series({'pe': 0, 'pb': 0, 'ps': 0, 'dv_ratio': 0})
result = {
"basic_info": {
"name": str(company_info['name']),
"industry": str(company_info['industry']),
"list_date": str(company_info['list_date']),
"area": str(company_info['area']),
**company_detail_dict
},
"financial_info": {
# 估值指标
"pe_ratio": float(latest_basic['pe']) if pd.notna(latest_basic['pe']) else 0,
"pb_ratio": float(latest_basic['pb']) if pd.notna(latest_basic['pb']) else 0,
"ps_ratio": float(latest_basic['ps']) if pd.notna(latest_basic['ps']) else 0,
"dividend_yield": float(latest_basic['dv_ratio'])/100 if pd.notna(latest_basic['dv_ratio']) else 0,
# 盈利能力
"roe": float(fina_info['roe']) if pd.notna(fina_info.get('roe')) else 0,
"grossprofit_margin": float(fina_info['grossprofit_margin']) if pd.notna(fina_info.get('grossprofit_margin')) else 0,
"netprofit_margin": float(fina_info['netprofit_margin']) if pd.notna(fina_info.get('netprofit_margin')) else 0,
# 成长能力
"netprofit_yoy": float(fina_info['netprofit_yoy']) if pd.notna(fina_info.get('netprofit_yoy')) else 0,
"or_yoy": float(fina_info['or_yoy']) if pd.notna(fina_info.get('or_yoy')) else 0,
# 偿债能力
"debt_to_assets": float(fina_info['debt_to_assets']) if pd.notna(fina_info.get('debt_to_assets')) else 0,
# 每股指标
"eps": float(fina_info['eps']) if pd.notna(fina_info.get('eps')) else 0,
"bps": float(fina_info['bps']) if pd.notna(fina_info.get('bps')) else 0,
"ocfps": float(fina_info['ocfps']) if pd.notna(fina_info.get('ocfps')) else 0,
}
}
return result
except Exception as e:
self.logger.error(f"获取公司详情失败: {stock_code}, 错误: {e}")
return {"error": f"获取公司详情失败: {str(e)}"}
def get_top_holders(self, stock_code: str):
"""获取前十大股东数据从API获取实时数据"""
try:
# 处理股票代码格式
if stock_code.startswith('6'):
ts_code = f"{stock_code}.SH"
elif stock_code.startswith(('0', '3')):
ts_code = f"{stock_code}.SZ"
else:
return {"error": "不支持的股票代码"}
# 获取最新一期的股东数据
df = pro.top10_holders(ts_code=ts_code, limit=10)
if df.empty:
return {"error": "暂无股东数据"}
# 按持股比例降序排序
df = df.sort_values('hold_ratio', ascending=False)
# 获取最新的报告期
latest_end_date = df['end_date'].max()
latest_data = df[df['end_date'] == latest_end_date]
holders = []
for _, row in latest_data.iterrows():
holders.append({
"holder_name": str(row['holder_name']),
"hold_amount": float(row['hold_amount']) if pd.notna(row['hold_amount']) else 0,
"hold_ratio": float(row['hold_ratio']) if pd.notna(row['hold_ratio']) else 0,
"hold_change": float(row['hold_change']) if pd.notna(row['hold_change']) else 0,
"ann_date": str(row['ann_date']),
"end_date": str(row['end_date'])
})
result = {
"holders": holders,
"total_ratio": sum(holder['hold_ratio'] for holder in holders),
"report_date": str(latest_end_date)
}
return result
except Exception as e:
self.logger.error(f"获取股东数据失败: {stock_code}, 错误: {e}")
return {"error": f"获取股东数据失败: {str(e)}"}
def get_value_analysis_data(self, stock_code: str):
"""获取价值投资分析数据优先从数据库如果没有则从API获取"""
try:
# 先尝试从数据库获取今日数据
today = self.stock_dao.get_today_date()
cached_data = self.stock_dao.get_stock_data(stock_code, today)
if cached_data and not cached_data['from_cache']:
# 如果有今日的API数据非缓存直接使用
return self._format_value_analysis_data(cached_data)
# 否则从API获取
api_result = self.get_stock_info(stock_code, force_refresh=True)
if 'error' in api_result:
return api_result
stock_info = api_result['stock_info']
return self._format_value_analysis_data_from_info(stock_info)
except Exception as e:
self.logger.error(f"获取价值投资分析数据失败: {stock_code}, 错误: {e}")
return {"error": f"获取价值投资分析数据失败: {str(e)}"}
def _format_value_analysis_data(self, stock_data: dict):
"""格式化价值投资分析数据"""
return {
"stock_info": {
"code": stock_data['stock_code'],
"name": stock_data['stock_name'],
"current_price": stock_data['price'],
"trade_date": stock_data.get('data_date', self.stock_dao.get_today_date())
},
"valuation": {
"pe_ratio": stock_data['pe_ratio'],
"pb_ratio": stock_data['pb_ratio'],
"ps_ratio": stock_data['ps_ratio'],
"dividend_yield": stock_data['dividend_yield'],
"total_market_value": stock_data['market_value']
},
"profitability": {
"roe": stock_data['roe'],
"gross_margin": stock_data['gross_profit_margin'],
"net_margin": stock_data['net_profit_margin']
},
"growth": {
"net_profit_growth": stock_data['net_profit_yoy'],
"revenue_growth": stock_data['revenue_yoy']
},
"solvency": {
"debt_to_assets": stock_data['debt_to_assets']
},
"per_share": {
"eps": stock_data.get('eps', 0), # 这个字段在基础数据中没有,需要计算
"bps": stock_data['bps'],
"ocfps": stock_data['ocfps']
}
}
def _format_value_analysis_data_from_info(self, stock_info: dict):
"""从股票信息格式化价值投资分析数据"""
return {
"stock_info": {
"code": stock_info['code'],
"name": stock_info['name'],
"current_price": stock_info['price'],
"trade_date": self.stock_dao.get_today_date()
},
"valuation": {
"pe_ratio": stock_info['pe_ratio'],
"pb_ratio": stock_info['pb_ratio'],
"ps_ratio": stock_info['ps_ratio'],
"dividend_yield": stock_info['dividend_yield'],
"total_market_value": stock_info['market_value']
},
"profitability": {
"roe": stock_info['roe'],
"gross_margin": stock_info['gross_profit_margin'],
"net_margin": stock_info['net_profit_margin']
},
"growth": {
"net_profit_growth": stock_info['net_profit_yoy'],
"revenue_growth": stock_info['revenue_yoy']
},
"solvency": {
"debt_to_assets": stock_info['debt_to_assets']
},
"per_share": {
"eps": stock_info.get('eps', 0),
"bps": stock_info['bps'],
"ocfps": stock_info['ocfps']
}
}