From 52943431a015e07d26b06ab5164dfbd50d2aabd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BB=A1=E8=84=B8=E5=B0=8F=E6=98=9F=E6=98=9F?= Date: Thu, 13 Nov 2025 11:36:00 +0800 Subject: [PATCH] commit; --- src/app.py | 10 +- src/static/js/bigscreen_v2.js | 26 +- src/valuation_analysis/financial_analysis.py | 19 +- src/valuation_analysis/industry_analysis.py | 66 ++++++ .../stock_price_collector.py | 222 +++++++++++++++--- 5 files changed, 296 insertions(+), 47 deletions(-) diff --git a/src/app.py b/src/app.py index 21cc589..64c1e17 100644 --- a/src/app.py +++ b/src/app.py @@ -2221,11 +2221,11 @@ def industry_analysis(): "markLine": { "symbol": "none", "data": [ - {"name": "历史最小值", "yAxis": percentiles['min'], "lineStyle": {"color": "#28a745", "type": "dashed"}}, - {"name": "历史最大值", "yAxis": percentiles['max'], "lineStyle": {"color": "#dc3545", "type": "dashed"}}, - {"name": "历史均值", "yAxis": percentiles['mean'], "lineStyle": {"color": "#9400d3", "type": "dashed"}}, - {"name": "历史Q1", "yAxis": percentiles['q1'], "lineStyle": {"color": "#28a745", "type": "dashed"}}, - {"name": "历史Q3", "yAxis": percentiles['q3'], "lineStyle": {"color": "#dc3545", "type": "dashed"}} + {"name": "历史最小值", "yAxis": percentiles['min'], "lineStyle": {"color": "#2f6b3c", "type": "dashed"}}, + {"name": "历史最大值", "yAxis": percentiles['max'], "lineStyle": {"color": "#c23b22", "type": "dashed"}}, + {"name": "历史均值", "yAxis": percentiles['mean'], "lineStyle": {"color": "#5470c6", "type": "dashed"}}, + {"name": "历史Q1", "yAxis": percentiles['q1'], "lineStyle": {"color": "#2CB74B", "type": "dashed"}}, + {"name": "历史Q3", "yAxis": percentiles['q3'], "lineStyle": {"color": "#F23A4B", "type": "dashed"}} ] } }, diff --git a/src/static/js/bigscreen_v2.js b/src/static/js/bigscreen_v2.js index 2bc0ca2..5b8d043 100644 --- a/src/static/js/bigscreen_v2.js +++ b/src/static/js/bigscreen_v2.js @@ -1031,7 +1031,30 @@ $(function() { const chart = echarts.init(dom); chartInstances[domId] = chart; const mainSeries = data.series.filter(s => s.name.indexOf('平均PE') !== -1 || s.name.indexOf('PE') !== -1); - mainSeries.forEach(s => { s.symbol = 'none'; }); + const colorMap = [ + {matcher: name => name.includes('平均PE') && !name.includes('历史'), color: '#5470c6'}, + {matcher: name => name.includes('历史最小值'), color: '#2CB74B'}, + {matcher: name => name.includes('历史最大值'), color: '#c23b22'}, + {matcher: name => name.includes('历史Q1'), color: '#2CB74B'}, + {matcher: name => name.includes('历史Q3'), color: '#F23A4B'} + ]; + const resolveColor = name => { + for (const item of colorMap) { + if (item.matcher(name)) { + return item.color; + } + } + return '#5470c6'; + }; + mainSeries.forEach(s => { + s.symbol = 'none'; + const seriesColor = resolveColor(s.name || ''); + s.lineStyle = Object.assign({width: 2, color: seriesColor}, s.lineStyle || {}); + s.itemStyle = Object.assign({color: seriesColor}, s.itemStyle || {}); + if (s.areaStyle) { + s.areaStyle = Object.assign({}, s.areaStyle, {color: seriesColor, opacity: 0.08}); + } + }); let allValues = []; mainSeries.forEach(s => allValues = allValues.concat(s.data.filter(v => v !== null && v !== undefined))); let min = Math.min(...allValues); @@ -1043,6 +1066,7 @@ $(function() { grid: {left: '5%', right: '5%', top: 30, bottom: 20, containLabel: true}, xAxis: {type: 'category', data: data.xAxis[0].data, axisLabel: {rotate: 0, color:'#666', interval: 'auto'}}, yAxis: {type: 'value', name: data.yAxis[0].name, axisLabel: {color:'#666'}, min: min}, + color: mainSeries.map(s => resolveColor(s.name || '')), series: mainSeries, dataZoom: [ {type: 'inside', start: 0, end: 100, zoomOnTouch: true, moveOnMouseWheel: true} diff --git a/src/valuation_analysis/financial_analysis.py b/src/valuation_analysis/financial_analysis.py index 193bd41..5566032 100644 --- a/src/valuation_analysis/financial_analysis.py +++ b/src/valuation_analysis/financial_analysis.py @@ -518,10 +518,23 @@ class FinancialAnalyzer: # 获取概念板块数据 industry_analyzer = IndustryAnalyzer() concepts = industry_analyzer.get_stock_concepts(stock_code) + industry_list = industry_analyzer.get_stock_industry(stock_code) # 获取同行业股票列表 industry_stocks = self.get_industry_stocks(stock_code) + # 获取行业平均估值 + industry_valuation = None + if industry_list: + valuation_result = industry_analyzer.get_latest_industry_valuation(industry_list[0]) + if valuation_result.get('success'): + industry_valuation = { + 'industry_name': valuation_result.get('industry_name'), + 'avg_pe': valuation_result.get('avg_pe'), + 'avg_pb': valuation_result.get('avg_pb'), + 'stock_count': valuation_result.get('stock_count') + } + # 获取动量指标数据 momentum_result = self.get_momentum_indicators(stock_code, industry_stocks) @@ -857,8 +870,7 @@ class FinancialAnalyzer: } - industryList = industry_analyzer.get_stock_industry(stock_code) - concepts += industryList + concepts += industry_list # 在返回结果之前,缓存数据 result = { @@ -872,7 +884,8 @@ class FinancialAnalyzer: 'liquidity': liquidity, 'momentum': momentum_result.get('indicators', []), 'concepts': concepts, - 'price_data': price_data + 'price_data': price_data, + 'industry_valuation': industry_valuation } # 缓存结果,有效期1天(86400秒) diff --git a/src/valuation_analysis/industry_analysis.py b/src/valuation_analysis/industry_analysis.py index 797428f..70f537b 100644 --- a/src/valuation_analysis/industry_analysis.py +++ b/src/valuation_analysis/industry_analysis.py @@ -140,6 +140,72 @@ class IndustryAnalyzer: logger.error(f"获取行业股票失败: {e}") return [] + def get_latest_industry_valuation(self, industry_name: str) -> Dict: + """ + 查询指定行业最新的平均PE、PB数值 + + Args: + industry_name: 行业名称 + + Returns: + 包含最新平均估值数据的字典 + """ + try: + stock_codes = self.get_industry_stocks(industry_name) + if not stock_codes: + return { + "success": False, + "message": f"未找到行业 {industry_name} 的股票列表" + } + + query = text(""" + WITH latest_timestamp AS ( + SELECT + symbol, + MAX(`timestamp`) AS latest_ts + FROM gp_day_basic + WHERE symbol IN :stock_codes + GROUP BY symbol + ) + SELECT + AVG(b.pe_ttm) AS avg_pe_ttm, + AVG(b.pb) AS avg_pb, + MAX(b.`timestamp`) AS data_date, + COUNT(*) AS stock_count + FROM gp_day_basic b + INNER JOIN latest_timestamp l + ON b.symbol = l.symbol AND b.`timestamp` = l.latest_ts + """) + + with self.engine.connect() as conn: + result = conn.execute(query, {"stock_codes": tuple(stock_codes)}).fetchone() + + if not result or result["stock_count"] == 0: + return { + "success": False, + "message": f"未查询到行业 {industry_name} 的最新估值数据" + } + + avg_pe = result["avg_pe_ttm"] + avg_pb = result["avg_pb"] + data_date = result["data_date"] + stock_count = result["stock_count"] + + return { + "success": True, + "industry_name": industry_name, + "data_date": data_date.strftime('%Y-%m-%d') if data_date else None, + "avg_pe": round(float(avg_pe), 2) if avg_pe is not None else None, + "avg_pb": round(float(avg_pb), 2) if avg_pb is not None else None, + "stock_count": int(stock_count) + } + except Exception as e: + logger.error(f"获取行业 {industry_name} 最新估值数据失败: {e}") + return { + "success": False, + "message": f"获取行业 {industry_name} 最新估值数据失败: {e}" + } + def get_industry_valuation_data(self, industry_name: str, start_date: str, metric: str = 'pe') -> pd.DataFrame: """ 获取行业估值数据,返回每日行业平均PE/PB/PS diff --git a/src/valuation_analysis/stock_price_collector.py b/src/valuation_analysis/stock_price_collector.py index 84c1996..69a8c99 100644 --- a/src/valuation_analysis/stock_price_collector.py +++ b/src/valuation_analysis/stock_price_collector.py @@ -16,7 +16,8 @@ import os import sys from pathlib import Path from sqlalchemy import create_engine, text -from typing import Dict +from typing import Dict, Optional, Union +from decimal import Decimal # 添加项目根目录到Python路径 current_file = Path(__file__) @@ -121,6 +122,75 @@ class StockPriceCollector: "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } logger.info("东方财富实时股价数据采集器初始化完成") + + @staticmethod + def _safe_float(value: Union[str, int, float, Decimal]) -> Optional[float]: + """安全地将值转换为浮点数""" + if value is None: + return None + try: + if isinstance(value, Decimal): + return float(value) + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + stripped = value.strip() + if stripped in {"", "-"}: + return None + return float(stripped) + except (ValueError, TypeError): + return None + return None + + @staticmethod + def _safe_int(value: Union[str, int, float, Decimal]) -> Optional[int]: + """安全地将值转换为整数""" + if value is None: + return None + try: + if isinstance(value, Decimal): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + stripped = value.strip() + if stripped in {"", "-"}: + return None + return int(float(stripped)) + except (ValueError, TypeError): + return None + return None + + @staticmethod + def _format_decimal(value: Optional[float], precision: int = 2) -> Optional[str]: + """按照指定精度格式化小数""" + if value is None: + return None + return format(value, f".{precision}f") + + @staticmethod + def _to_db_symbol(stock_code: str) -> Optional[str]: + """ + 转换股票代码为数据库格式(如 603986.SH -> SH603986) + """ + if not stock_code: + return None + code = stock_code.upper().strip() + if "." in code: + number, market = code.split(".") + return f"{market}{number}" + if code.startswith(("SH", "SZ", "BJ")): + return code + if code.isdigit() and len(code) == 6: + if code.startswith(("6", "9")): + return f"SH{code}" + if code.startswith(("0", "3")): + return f"SZ{code}" + if code.startswith("4"): + return f"BJ{code}" + return code def _ensure_table_exists(self) -> bool: """ @@ -432,48 +502,124 @@ class StockPriceCollector: try: # 转换股票代码格式 formatted_code = self._convert_stock_code(stock_code) if convert_code else stock_code - + db_symbol = self._to_db_symbol(formatted_code) + + if not db_symbol: + logger.warning(f"无法转换股票代码 {stock_code}") + return None + query = text(""" SELECT - stock_code, - stock_name, - latest_price, - change_percent, - change_amount, - volume, - amount, - amplitude, - turnover_rate, - pe_ratio, - high_price, - low_price, - total_market_value, - float_market_value, - pb_ratio, - list_date, - update_time - FROM - stock_price_data - WHERE - stock_code = :stock_code + d.symbol AS symbol, + d.timestamp AS day_timestamp, + d.open AS day_open, + d.high AS day_high, + d.low AS day_low, + d.close AS day_close, + d.chg AS day_chg, + d.percent AS day_percent, + d.volume AS day_volume, + d.amount AS day_amount, + d.pre_close AS day_pre_close, + d.turnoverrate AS day_turnoverrate, + b.timestamp AS basic_timestamp, + b.turnover_rate AS basic_turnover_rate, + b.pe_ttm AS basic_pe_ttm, + b.pb AS basic_pb, + b.total_mv AS basic_total_mv, + b.circ_mv AS basic_circ_mv + FROM ( + SELECT + symbol, + timestamp, + open, + high, + low, + close, + chg, + percent, + volume, + amount, + pre_close, + turnoverrate + FROM gp_day_data + WHERE symbol = :symbol + ORDER BY timestamp DESC + LIMIT 1 + ) d + JOIN ( + SELECT + symbol, + timestamp, + turnover_rate, + pe_ttm, + pb, + total_mv, + circ_mv + FROM gp_day_basic + WHERE symbol = :symbol + ORDER BY timestamp DESC + LIMIT 1 + ) b ON d.symbol = b.symbol """) - + with self.engine.connect() as conn: - result = conn.execute(query, {"stock_code": formatted_code}).fetchone() - - if result: - # 将结果转换为字典 - data = dict(result._mapping) - # 处理日期类型 - if data['list_date']: - data['list_date'] = data['list_date'].strftime('%Y-%m-%d') - if data['update_time']: - data['update_time'] = data['update_time'].strftime('%Y-%m-%d %H:%M:%S') - return data - else: - logger.warning(f"未找到股票 {stock_code} 的价格数据") + result = conn.execute(query, {"symbol": db_symbol}).fetchone() + + if not result: + logger.warning(f"未找到股票 {stock_code} 的日线或基础数据") return None - + + row = dict(result._mapping) + + close_price = self._safe_float(row.get("day_close")) + change_amount = self._safe_float(row.get("day_chg")) + change_percent = self._safe_float(row.get("day_percent")) + high_price = self._safe_float(row.get("day_high")) + low_price = self._safe_float(row.get("day_low")) + pre_close = self._safe_float(row.get("day_pre_close")) + turnover_rate_daily = self._safe_float(row.get("day_turnoverrate")) + amount_value = self._safe_float(row.get("day_amount")) + volume_value = self._safe_int(row.get("day_volume")) + + turnover_rate = self._safe_float(row.get("basic_turnover_rate")) or turnover_rate_daily + pe_ratio = self._safe_float(row.get("basic_pe_ttm")) + pb_ratio = self._safe_float(row.get("basic_pb")) + + total_mv = self._safe_float(row.get("basic_total_mv")) + circ_mv = self._safe_float(row.get("basic_circ_mv")) + + amplitude = None + if all(value is not None for value in (high_price, low_price, pre_close)) and pre_close != 0: + amplitude = (high_price - low_price) / abs(pre_close) * 100 + + update_timestamp = row.get("day_timestamp") or row.get("basic_timestamp") + update_time_str = ( + update_timestamp.strftime('%Y-%m-%d %H:%M:%S') + if update_timestamp else None + ) + + result = { + 'stock_code': formatted_code, + 'stock_name': None, + 'latest_price': self._format_decimal(close_price), + 'change_percent': self._format_decimal(change_percent), + 'change_amount': self._format_decimal(change_amount), + 'volume': volume_value, + 'amount': self._format_decimal(amount_value) if amount_value is not None else None, + 'amplitude': self._format_decimal(amplitude), + 'turnover_rate': self._format_decimal(turnover_rate), + 'pe_ratio': self._format_decimal(pe_ratio), + 'high_price': self._format_decimal(high_price), + 'low_price': self._format_decimal(low_price), + 'total_market_value': self._format_decimal(total_mv * 10000) if total_mv is not None else None, + 'float_market_value': self._format_decimal(circ_mv * 10000) if circ_mv is not None else None, + 'pb_ratio': self._format_decimal(pb_ratio), + 'list_date': None, + 'update_time': update_time_str + } + + return result except Exception as e: logger.error(f"获取股票价格数据失败: {e}") return None