This commit is contained in:
满脸小星星 2025-11-13 11:36:00 +08:00
parent e8a6fdcac5
commit 52943431a0
5 changed files with 296 additions and 47 deletions

View File

@ -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"}}
]
}
},

View File

@ -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}

View File

@ -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秒

View File

@ -140,6 +140,72 @@ class IndustryAnalyzer:
logger.error(f"获取行业股票失败: {e}")
return []
def get_latest_industry_valuation(self, industry_name: str) -> Dict:
"""
查询指定行业最新的平均PEPB数值
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

View File

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