commit;
This commit is contained in:
parent
e8a6fdcac5
commit
52943431a0
10
src/app.py
10
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"}}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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秒)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue