fix: 修复前端显示问题

This commit is contained in:
Cassianvale
2025-03-06 20:30:54 +08:00
parent 35cf295b29
commit ff5b820a57
4 changed files with 608 additions and 117 deletions

View File

@@ -76,6 +76,45 @@
<!-- 右侧结果区域 --> <!-- 右侧结果区域 -->
<n-grid-item :span="24" :lg-span="16"> <n-grid-item :span="24" :lg-span="16">
<div class="results-section"> <div class="results-section">
<div class="results-header">
<n-space align="center" justify="space-between">
<n-text>分析结果 ({{ analyzedStocks.length }})</n-text>
<n-space>
<n-select
v-model:value="displayMode"
size="small"
style="width: 120px"
:options="[
{ label: '卡片视图', value: 'card' },
{ label: '表格视图', value: 'table' }
]"
/>
<n-button
size="small"
:disabled="analyzedStocks.length === 0"
@click="copyAnalysisResults"
>
复制结果
</n-button>
<n-dropdown
trigger="click"
:disabled="analyzedStocks.length === 0"
:options="exportOptions"
@select="handleExportSelect"
>
<n-button size="small" :disabled="analyzedStocks.length === 0">
导出
<template #icon>
<n-icon>
<DownloadIcon />
</n-icon>
</template>
</n-button>
</n-dropdown>
</n-space>
</n-space>
</div>
<template v-if="analyzedStocks.length === 0 && !isAnalyzing"> <template v-if="analyzedStocks.length === 0 && !isAnalyzing">
<n-empty description="尚未分析股票" size="large"> <n-empty description="尚未分析股票" size="large">
<template #icon> <template #icon>
@@ -84,13 +123,25 @@
</n-empty> </n-empty>
</template> </template>
<template v-else> <template v-else-if="displayMode === 'card'">
<n-grid :cols="1" :x-gap="16" :y-gap="16" :lg-cols="2"> <n-grid :cols="1" :x-gap="16" :y-gap="16" :lg-cols="2">
<n-grid-item v-for="stock in analyzedStocks" :key="stock.code"> <n-grid-item v-for="stock in analyzedStocks" :key="stock.code">
<StockCard :stock="stock" /> <StockCard :stock="stock" />
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
</template> </template>
<template v-else>
<n-data-table
:columns="stockTableColumns"
:data="analyzedStocks"
:pagination="{ pageSize: 10 }"
:row-key="(row) => row.code"
:bordered="false"
:single-line="false"
striped
/>
</template>
</div> </div>
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
@@ -101,7 +152,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, onBeforeUnmount } from 'vue';
import { import {
NLayout, NLayout,
NLayoutContent, NLayoutContent,
@@ -115,12 +166,18 @@ import {
NInput, NInput,
NButton, NButton,
NEmpty, NEmpty,
useMessage useMessage,
NSpace,
NText,
NDataTable,
NDropdown,
type DataTableColumns
} from 'naive-ui'; } from 'naive-ui';
import { useClipboard } from '@vueuse/core' import { useClipboard } from '@vueuse/core'
import { import {
BarChart as BarChartIcon, BarChartOutline as BarChartIcon,
DocumentText as DocumentTextIcon DocumentTextOutline as DocumentTextIcon,
DownloadOutline as DownloadIcon
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import AnnouncementBanner from './AnnouncementBanner.vue'; import AnnouncementBanner from './AnnouncementBanner.vue';
@@ -148,6 +205,7 @@ const marketType = ref('A');
const stockCodes = ref(''); const stockCodes = ref('');
const isAnalyzing = ref(false); const isAnalyzing = ref(false);
const analyzedStocks = ref<StockInfo[]>([]); const analyzedStocks = ref<StockInfo[]>([]);
const displayMode = ref<'card' | 'table'>('card');
// API配置 // API配置
const apiConfig = ref<ApiConfig>({ const apiConfig = ref<ApiConfig>({
@@ -167,6 +225,136 @@ const marketOptions = [
{ label: 'LOF', value: 'LOF' } { label: 'LOF', value: 'LOF' }
]; ];
// 表格列定义
const stockTableColumns = ref<DataTableColumns<StockInfo>>([
{
title: '代码',
key: 'code',
width: 100,
fixed: 'left'
},
{
title: '状态',
key: 'analysisStatus',
width: 100,
render(row: StockInfo) {
const statusMap = {
'waiting': '等待分析',
'analyzing': '分析中',
'completed': '已完成',
'error': '出错'
};
return statusMap[row.analysisStatus] || row.analysisStatus;
}
},
{
title: '价格',
key: 'price',
width: 100,
render(row: StockInfo) {
return row.price !== undefined ? row.price.toFixed(2) : '--';
}
},
{
title: '涨跌幅',
key: 'changePercent',
width: 100,
render(row: StockInfo) {
if (row.changePercent === undefined) return '--';
const sign = row.changePercent > 0 ? '+' : '';
return `${sign}${row.changePercent.toFixed(2)}%`;
}
},
{
title: 'RSI',
key: 'rsi',
width: 80,
render(row: StockInfo) {
return row.rsi !== undefined ? row.rsi.toFixed(2) : '--';
}
},
{
title: '均线趋势',
key: 'ma_trend',
width: 100,
render(row: StockInfo) {
const trendMap: Record<string, string> = {
'UP': '上升',
'DOWN': '下降',
'NEUTRAL': '平稳'
};
return row.ma_trend ? trendMap[row.ma_trend] || row.ma_trend : '--';
}
},
{
title: 'MACD信号',
key: 'macd_signal',
width: 100,
render(row: StockInfo) {
const signalMap: Record<string, string> = {
'BUY': '买入',
'SELL': '卖出',
'HOLD': '持有',
'NEUTRAL': '中性'
};
return row.macd_signal ? signalMap[row.macd_signal] || row.macd_signal : '--';
}
},
{
title: '评分',
key: 'score',
width: 80,
render(row: StockInfo) {
return row.score !== undefined ? row.score : '--';
}
},
{
title: '推荐',
key: 'recommendation',
width: 100
},
{
title: '分析日期',
key: 'analysis_date',
width: 120,
render(row: StockInfo) {
if (!row.analysis_date) return '--';
try {
const date = new Date(row.analysis_date);
if (isNaN(date.getTime())) {
return row.analysis_date;
}
return date.toISOString().split('T')[0];
} catch (e) {
return row.analysis_date;
}
}
},
{
title: '分析结果',
key: 'analysis',
ellipsis: {
tooltip: true
}
}
]);
// 导出选项
const exportOptions = [
{
label: '导出为CSV',
key: 'csv'
},
{
label: '导出为Excel',
key: 'excel'
},
{
label: '导出为PDF',
key: 'pdf'
}
];
// 更新API配置 // 更新API配置
function updateApiConfig(config: ApiConfig) { function updateApiConfig(config: ApiConfig) {
apiConfig.value = { ...config }; apiConfig.value = { ...config };
@@ -200,6 +388,22 @@ function processStreamData(text: string) {
} else if (data.stock_code) { } else if (data.stock_code) {
// 更新消息 // 更新消息
handleStreamUpdate(data as StreamAnalysisUpdate); handleStreamUpdate(data as StreamAnalysisUpdate);
} else if (data.scan_completed) {
// 扫描完成消息
message.success(`分析完成,共扫描 ${data.total_scanned} 只股票,符合条件 ${data.total_matched} 只`);
// 将所有分析中的股票状态更新为已完成
analyzedStocks.value.forEach((stock, index) => {
if (stock.analysisStatus === 'analyzing') {
const updatedStock = {
...stock,
analysisStatus: 'completed' as const
};
analyzedStocks.value[index] = updatedStock;
}
});
isAnalyzing.value = false;
} }
} catch (e) { } catch (e) {
console.error('解析流数据出错:', e); console.error('解析流数据出错:', e);
@@ -414,13 +618,7 @@ async function analyzeStocks() {
processStreamData(buffer); processStreamData(buffer);
} }
// 将所有分析中的股票状态更新为已完成 // 注意不再需要在这里更新状态因为已经在processStreamData中处理了scan_completed消息
analyzedStocks.value.forEach((stock, index) => {
if (stock.analysisStatus === 'analyzing') {
const updatedStock = { ...stock, analysisStatus: 'completed' };
analyzedStocks.value[index] = updatedStock;
}
});
message.success('分析完成'); message.success('分析完成');
} catch (error: any) { } catch (error: any) {
@@ -547,6 +745,108 @@ function restoreLocalApiConfig() {
} }
} }
// 处理导出选择
function handleExportSelect(key: string) {
switch (key) {
case 'csv':
exportToCSV();
break;
case 'excel':
message.info('Excel导出功能即将推出');
break;
case 'pdf':
message.info('PDF导出功能即将推出');
break;
}
}
// 导出为CSV
function exportToCSV() {
if (analyzedStocks.value.length === 0) {
message.warning('没有可导出的分析结果');
return;
}
try {
// 创建CSV内容
const headers = ['代码', '名称', '价格', '涨跌幅', 'RSI', '均线趋势', 'MACD信号', '成交量状态', '评分', '推荐', '分析日期'];
let csvContent = headers.join(',') + '\n';
// 添加数据行
analyzedStocks.value.forEach(stock => {
const row = [
`"${stock.code}"`,
`"${stock.name || ''}"`,
stock.price !== undefined ? stock.price.toFixed(2) : '',
stock.changePercent !== undefined ? `${stock.changePercent > 0 ? '+' : ''}${stock.changePercent.toFixed(2)}%` : '',
stock.rsi !== undefined ? stock.rsi.toFixed(2) : '',
stock.ma_trend ? getChineseTrend(stock.ma_trend) : '',
stock.macd_signal ? getChineseSignal(stock.macd_signal) : '',
stock.volume_status ? getChineseVolumeStatus(stock.volume_status) : '',
stock.score !== undefined ? stock.score : '',
`"${stock.recommendation || ''}"`,
stock.analysis_date || ''
];
csvContent += row.join(',') + '\n';
});
// 创建Blob对象
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `股票分析结果_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
// 添加到文档并触发点击
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('已导出CSV文件');
} catch (error) {
message.error('导出失败');
console.error('导出CSV时出错:', error);
}
}
// 辅助函数:获取中文趋势描述
function getChineseTrend(trend: string): string {
const trendMap: Record<string, string> = {
'UP': '上升',
'DOWN': '下降',
'NEUTRAL': '平稳'
};
return trendMap[trend] || trend;
}
// 辅助函数:获取中文信号描述
function getChineseSignal(signal: string): string {
const signalMap: Record<string, string> = {
'BUY': '买入',
'SELL': '卖出',
'HOLD': '持有',
'NEUTRAL': '中性'
};
return signalMap[signal] || signal;
}
// 辅助函数:获取中文成交量状态描述
function getChineseVolumeStatus(status: string): string {
const statusMap: Record<string, string> = {
'HIGH': '放量',
'LOW': '缩量',
'NORMAL': '正常'
};
return statusMap[status] || status;
}
// 页面加载时获取默认配置和公告 // 页面加载时获取默认配置和公告
onMounted(async () => { onMounted(async () => {
try { try {
@@ -615,4 +915,15 @@ onMounted(async () => {
padding: 0.5rem; padding: 0.5rem;
min-height: 200px; min-height: 200px;
} }
.results-header {
margin-bottom: 1rem;
}
.n-data-table .analysis-cell {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style> </style>

View File

@@ -536,6 +536,36 @@ function getChineseVolumeStatus(status: string): string {
overflow-y: auto; overflow-y: auto;
word-break: break-word; word-break: break-word;
hyphens: auto; hyphens: auto;
/* 自定义滚动条样式 */
scrollbar-width: thin; /* Firefox */
scrollbar-color: rgba(32, 128, 240, 0.3) transparent; /* Firefox */
}
/* Webkit浏览器的滚动条样式 */
.analysis-result::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.analysis-result::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.analysis-result::-webkit-scrollbar-thumb {
background-color: rgba(32, 128, 240, 0.3);
border-radius: 3px;
transition: background-color 0.3s ease;
}
.analysis-result::-webkit-scrollbar-thumb:hover {
background-color: rgba(32, 128, 240, 0.5);
}
/* 在不滚动时隐藏滚动条,滚动时显示 */
.analysis-result:not(:hover)::-webkit-scrollbar-thumb {
background-color: rgba(32, 128, 240, 0.1);
} }
.analysis-streaming { .analysis-streaming {

View File

@@ -4,10 +4,12 @@ import os
import json import json
import asyncio import asyncio
import httpx import httpx
import re
from typing import Dict, List, Optional, Any, Generator, AsyncGenerator from typing import Dict, List, Optional, Any, Generator, AsyncGenerator
from dotenv import load_dotenv from dotenv import load_dotenv
from logger import get_logger from logger import get_logger
from utils.api_utils import APIUtils from utils.api_utils import APIUtils
from datetime import datetime
# 获取日志器 # 获取日志器
logger = get_logger() logger = get_logger()
@@ -55,6 +57,26 @@ class AIAnalyzer:
try: try:
logger.info(f"开始AI分析 {stock_code}, 流式模式: {stream}") logger.info(f"开始AI分析 {stock_code}, 流式模式: {stream}")
# 提取关键技术指标
latest_data = df.iloc[-1]
# 计算技术指标
rsi = latest_data.get('RSI')
price = latest_data.get('Close')
price_change = latest_data.get('Change')
# 确定MA趋势
ma_trend = 'UP' if latest_data.get('MA5', 0) > latest_data.get('MA20', 0) else 'DOWN'
# 确定MACD信号
macd = latest_data.get('MACD', 0)
macd_signal = latest_data.get('MACD_Signal', 0)
macd_signal_type = 'BUY' if macd > macd_signal else 'SELL'
# 确定成交量状态
volume_ratio = latest_data.get('Volume_Ratio', 1)
volume_status = 'HIGH' if volume_ratio > 1.5 else ('LOW' if volume_ratio < 0.5 else 'NORMAL')
# AI 分析内容 # AI 分析内容
# 最近14天的股票数据记录 # 最近14天的股票数据记录
recent_data = df.tail(14).to_dict('records') recent_data = df.tail(14).to_dict('records')
@@ -78,25 +100,19 @@ class AIAnalyzer:
近14日交易数据 近14日交易数据
{recent_data} {recent_data}
分析该基金的技术面状况,包括 提供
1. 趋势分析:判断基金当前的趋势方向 1. 净值走势分析(包含支撑位和压力位)
2. 量分析基于RSI和交易量评估基金动量 2. 成交量分析及其对净值的影响
3. 支撑与阻力位:确定关键价格位 3. 风险评估(包含波动率和折溢价分析)
4. 技术面总结 4. 短期和中期净值预测
5. 投资建议 5. 关键价格位分析
6. 申购赎回建议(包含止损位)
将分析结果格式化为JSON像这样 请基于技术指标和市场表现进行分析,给出具体数据支持。
{{
"trend_analysis": "趋势分析结果...",
"momentum_analysis": "动量分析结果...",
"support_resistance": "支撑阻力位分析...",
"technical_summary": "技术面总结...",
"investment_advice": "投资建议..."
}}
""" """
else: elif market_type == 'US':
prompt = f""" prompt = f"""
分析股 {stock_code} 分析{stock_code}
技术指标概要: 技术指标概要:
{technical_summary} {technical_summary}
@@ -104,25 +120,55 @@ class AIAnalyzer:
近14日交易数据 近14日交易数据
{recent_data} {recent_data}
分析该股票的技术面状况,包括 提供
1. 趋势分析:当前趋势方向及强度 1. 趋势分析(包含支撑位和压力位,美元计价)
2. 量分析基于MACD、RSI等指标 2. 成交量分析及其含义
3. 支撑与阻力位:关键价格位分析 3. 风险评估(包含波动率和美股市场特有风险)
4. 成交量分析:交易量的变化及意义 4. 短期和中期目标价位(美元)
5. 波动性评估ATR和波动率分析 5. 关键技术位分析
6. 技术面总结 6. 具体交易建议(包含止损位)
7. 投资建议:根据技术分析给出操作建议
将分析结果格式化为JSON像这样 请基于技术指标和美股市场特点进行分析,给出具体数据支持。
{{ """
"trend_analysis": "趋势分析结果...", elif market_type == 'HK':
"momentum_analysis": "动量分析结果...", prompt = f"""
"support_resistance": "支撑阻力位分析...", 分析港股 {stock_code}
"volume_analysis": "成交量分析...",
"volatility_assessment": "波动性评估...", 技术指标概要:
"technical_summary": "技术面总结...", {technical_summary}
"investment_advice": "投资建议..."
}} 近14日交易数据
{recent_data}
请提供:
1. 趋势分析(包含支撑位和压力位,港币计价)
2. 成交量分析及其含义
3. 风险评估(包含波动率和港股市场特有风险)
4. 短期和中期目标价位(港币)
5. 关键技术位分析
6. 具体交易建议(包含止损位)
请基于技术指标和港股市场特点进行分析,给出具体数据支持。
"""
else: # A股
prompt = f"""
分析A股 {stock_code}
技术指标概要:
{technical_summary}
近14日交易数据
{recent_data}
请提供:
1. 趋势分析(包含支撑位和压力位)
2. 成交量分析及其含义
3. 风险评估(包含波动率分析)
4. 短期和中期目标价位
5. 关键技术位分析
6. 具体交易建议(包含止损位)
请基于技术指标和A股市场特点进行分析给出具体数据支持。
""" """
# 格式化API URL # 格式化API URL
@@ -142,11 +188,27 @@ class AIAnalyzer:
"Authorization": f"Bearer {self.API_KEY}" "Authorization": f"Bearer {self.API_KEY}"
} }
# 获取当前日期作为分析日期
analysis_date = datetime.now().strftime("%Y-%m-%d")
# 异步请求API # 异步请求API
async with httpx.AsyncClient(timeout=self.API_TIMEOUT) as client: async with httpx.AsyncClient(timeout=self.API_TIMEOUT) as client:
# 记录请求 # 记录请求
logger.debug(f"发送AI请求: URL={api_url}, MODEL={self.API_MODEL}, STREAM={stream}") logger.debug(f"发送AI请求: URL={api_url}, MODEL={self.API_MODEL}, STREAM={stream}")
# 先发送技术指标数据
yield json.dumps({
"stock_code": stock_code,
"status": "analyzing",
"rsi": rsi,
"price": price,
"price_change": price_change,
"ma_trend": ma_trend,
"macd_signal": macd_signal_type,
"volume_status": volume_status,
"analysis_date": analysis_date
})
if stream: if stream:
# 流式响应处理 # 流式响应处理
async with client.stream("POST", api_url, json=request_data, headers=headers) as response: async with client.stream("POST", api_url, json=request_data, headers=headers) as response:
@@ -155,12 +217,17 @@ class AIAnalyzer:
error_data = json.loads(error_text) error_data = json.loads(error_text)
error_message = error_data.get('error', {}).get('message', '未知错误') error_message = error_data.get('error', {}).get('message', '未知错误')
logger.error(f"AI API请求失败: {response.status_code} - {error_message}") logger.error(f"AI API请求失败: {response.status_code} - {error_message}")
yield json.dumps({"error": f"API请求失败: {error_message}"}) yield json.dumps({
"stock_code": stock_code,
"error": f"API请求失败: {error_message}",
"status": "error"
})
return return
# 处理流式响应 # 处理流式响应
buffer = "" buffer = ""
collected_messages = [] collected_messages = []
chunk_count = 0
async for chunk in response.aiter_text(): async for chunk in response.aiter_text():
if chunk: if chunk:
@@ -169,6 +236,7 @@ class AIAnalyzer:
chunk_str = chunk_str[6:] # 去除"data: "前缀 chunk_str = chunk_str[6:] # 去除"data: "前缀
if chunk_str == "[DONE]": if chunk_str == "[DONE]":
logger.debug("收到流结束标记 [DONE]")
continue continue
try: try:
@@ -178,49 +246,48 @@ class AIAnalyzer:
content = delta.get("content", "") content = delta.get("content", "")
if content: if content:
chunk_count += 1
buffer += content buffer += content
# 尝试提取完整的JSON collected_messages.append(content)
if buffer.strip().startswith("{") and buffer.strip().endswith("}"):
try:
result_json = json.loads(buffer)
yield json.dumps({
"stock_code": stock_code,
"analysis": result_json
})
buffer = "" # 重置缓冲区
except json.JSONDecodeError:
# JSON不完整继续收集
pass
# 达到一定长度就输出 # 直接发送每个内容片段,不累积
if len(buffer) > 100: yield json.dumps({
yield json.dumps({ "stock_code": stock_code,
"stock_code": stock_code, "ai_analysis_chunk": content,
"ai_analysis_chunk": buffer "status": "analyzing"
}) })
collected_messages.append(buffer)
buffer = ""
except json.JSONDecodeError: except json.JSONDecodeError:
# 忽略无法解析的块 # 忽略无法解析的块
logger.error(f"JSON解析错误块内容: {chunk_str[:100]}...")
continue continue
# 处理最后的缓冲区 logger.info(f"AI流式处理完成共收到 {chunk_count} 个内容片段,总长度: {len(buffer)}")
if buffer:
# 如果buffer不为空且不以换行符结束发送一个换行符
if buffer and not buffer.endswith('\n'):
logger.debug("发送换行符")
yield json.dumps({ yield json.dumps({
"stock_code": stock_code, "stock_code": stock_code,
"ai_analysis_chunk": buffer "ai_analysis_chunk": "\n",
"status": "analyzing"
}) })
collected_messages.append(buffer)
# 尝试从整个内容中提取JSON # 完整的分析内容
full_content = "".join(collected_messages) full_content = buffer
# 如果没有成功解析JSON返回原始内容 # 尝试从分析内容中提取投资建议
if not full_content.strip().startswith("{"): recommendation = self._extract_recommendation(full_content)
yield json.dumps({
"stock_code": stock_code, # 计算分析评分
"raw_analysis": full_content score = self._calculate_analysis_score(full_content, technical_summary)
})
# 发送完成状态和评分、建议
yield json.dumps({
"stock_code": stock_code,
"status": "completed",
"score": score,
"recommendation": recommendation
})
else: else:
# 非流式响应处理 # 非流式响应处理
response = await client.post(api_url, json=request_data, headers=headers) response = await client.post(api_url, json=request_data, headers=headers)
@@ -229,32 +296,100 @@ class AIAnalyzer:
error_data = response.json() error_data = response.json()
error_message = error_data.get('error', {}).get('message', '未知错误') error_message = error_data.get('error', {}).get('message', '未知错误')
logger.error(f"AI API请求失败: {response.status_code} - {error_message}") logger.error(f"AI API请求失败: {response.status_code} - {error_message}")
yield json.dumps({"error": f"API请求失败: {error_message}"}) yield json.dumps({
"stock_code": stock_code,
"error": f"API请求失败: {error_message}",
"status": "error"
})
return return
response_data = response.json() response_data = response.json()
analysis_text = response_data.get("choices", [{}])[0].get("message", {}).get("content", "") analysis_text = response_data.get("choices", [{}])[0].get("message", {}).get("content", "")
try: # 尝试从分析内容中提取投资建议
# 尝试解析JSON recommendation = self._extract_recommendation(analysis_text)
analysis_json = json.loads(analysis_text)
yield json.dumps({
"stock_code": stock_code,
"analysis": analysis_json
})
except json.JSONDecodeError:
# 返回原始文本
yield json.dumps({
"stock_code": stock_code,
"raw_analysis": analysis_text
})
logger.info(f"完成对 {stock_code} 的AI分析") # 计算分析评分
score = self._calculate_analysis_score(analysis_text, technical_summary)
# 发送完整的分析结果
yield json.dumps({
"stock_code": stock_code,
"status": "completed",
"analysis": analysis_text,
"score": score,
"recommendation": recommendation,
"rsi": rsi,
"price": price,
"price_change": price_change,
"ma_trend": ma_trend,
"macd_signal": macd_signal_type,
"volume_status": volume_status,
"analysis_date": analysis_date
})
except Exception as e: except Exception as e:
logger.error(f"AI分析 {stock_code}出错: {str(e)}") logger.error(f"AI分析出错: {str(e)}", exc_info=True)
logger.exception(e) yield json.dumps({
yield json.dumps({"error": f"分析出错: {str(e)}"}) "stock_code": stock_code,
"error": f"分析出错: {str(e)}",
"status": "error"
})
def _extract_recommendation(self, analysis_text: str) -> str:
"""从分析文本中提取投资建议"""
# 查找投资建议部分
investment_advice_pattern = r"##\s*投资建议\s*\n(.*?)(?:\n##|\Z)"
match = re.search(investment_advice_pattern, analysis_text, re.DOTALL)
if match:
advice_text = match.group(1).strip()
# 提取关键建议
if "买入" in advice_text or "增持" in advice_text:
return "买入"
elif "卖出" in advice_text or "减持" in advice_text:
return "卖出"
elif "持有" in advice_text:
return "持有"
else:
return "观望"
return "观望" # 默认建议
def _calculate_analysis_score(self, analysis_text: str, technical_summary: dict) -> int:
"""计算分析评分"""
score = 50 # 基础分数
# 根据技术指标调整分数
if technical_summary['trend'] == 'upward':
score += 10
else:
score -= 10
if technical_summary['volume_trend'] == 'increasing':
score += 5
else:
score -= 5
rsi = technical_summary['rsi_level']
if rsi < 30: # 超卖
score += 15
elif rsi > 70: # 超买
score -= 15
# 根据分析文本中的关键词调整分数
if "强烈买入" in analysis_text or "显著上涨" in analysis_text:
score += 20
elif "买入" in analysis_text or "看涨" in analysis_text:
score += 10
elif "强烈卖出" in analysis_text or "显著下跌" in analysis_text:
score -= 20
elif "卖出" in analysis_text or "看跌" in analysis_text:
score -= 10
# 确保分数在0-100范围内
return max(0, min(100, score))
def _truncate_json_for_logging(self, json_obj, max_length=500): def _truncate_json_for_logging(self, json_obj, max_length=500):
""" """

View File

@@ -159,10 +159,10 @@ class StockAnalyzerService:
try: try:
logger.info(f"开始批量扫描 {len(stock_codes)} 只股票, 市场: {market_type}") logger.info(f"开始批量扫描 {len(stock_codes)} 只股票, 市场: {market_type}")
# 输出初始状态 # 输出初始状态 - 发送批量分析初始化消息
yield json.dumps({ yield json.dumps({
"status": "scanning", "stream_type": "batch",
"total_stocks": len(stock_codes), "stock_codes": stock_codes,
"market_type": market_type, "market_type": market_type,
"min_score": min_score "min_score": min_score
}) })
@@ -177,6 +177,12 @@ class StockAnalyzerService:
stock_with_indicators[code] = self.indicator.calculate_indicators(df) stock_with_indicators[code] = self.indicator.calculate_indicators(df)
except Exception as e: except Exception as e:
logger.error(f"计算 {code} 技术指标时出错: {str(e)}") logger.error(f"计算 {code} 技术指标时出错: {str(e)}")
# 发送错误状态
yield json.dumps({
"stock_code": code,
"error": f"计算技术指标时出错: {str(e)}",
"status": "error"
})
# 评分股票 # 评分股票
results = self.scorer.batch_score_stocks(stock_with_indicators) results = self.scorer.batch_score_stocks(stock_with_indicators)
@@ -184,30 +190,39 @@ class StockAnalyzerService:
# 过滤低于最低评分的股票 # 过滤低于最低评分的股票
filtered_results = [r for r in results if r[1] >= min_score] filtered_results = [r for r in results if r[1] >= min_score]
# 输出评分结果 # 为每只股票发送基本评分和推荐信息
yield json.dumps({ for code, score, rec in results:
"scan_results": [ df = stock_with_indicators.get(code)
{ if df is not None and len(df) > 0:
# 获取最新数据
latest_data = df.iloc[-1]
# 发送股票基本信息和评分
yield json.dumps({
"stock_code": code, "stock_code": code,
"score": score, "score": score,
"recommendation": rec "recommendation": rec,
} for code, score, rec in filtered_results "price": float(latest_data.get('Close', 0)),
], "price_change": float(latest_data.get('Change', 0)),
"total_matched": len(filtered_results), "rsi": float(latest_data.get('RSI', 0)) if 'RSI' in latest_data else None,
"total_scanned": len(results) "ma_trend": "UP" if latest_data.get('MA5', 0) > latest_data.get('MA20', 0) else "DOWN",
}) "macd_signal": "BUY" if latest_data.get('MACD', 0) > latest_data.get('MACD_Signal', 0) else "SELL",
"volume_status": "HIGH" if latest_data.get('Volume_Ratio', 1) > 1.5 else ("LOW" if latest_data.get('Volume_Ratio', 1) < 0.5 else "NORMAL"),
"status": "completed" if score < min_score else "waiting"
})
# 如果需要进一步分析对评分较高的股票进行AI分析 # 如果需要进一步分析对评分较高的股票进行AI分析
if stream and filtered_results: if stream and filtered_results:
top_stocks = filtered_results[:3] # 只分析前3只评分最高的股票 # 只分析前5只评分最高的股票,避免分析过多导致前端卡顿
top_stocks = filtered_results[:5]
for stock_code, score, _ in top_stocks: for stock_code, score, _ in top_stocks:
df = stock_with_indicators.get(stock_code) df = stock_with_indicators.get(stock_code)
if df is not None: if df is not None:
# 输出正在分析的股票信息 # 输出正在分析的股票信息
yield json.dumps({ yield json.dumps({
"analyzing": stock_code, "stock_code": stock_code,
"score": score "status": "analyzing"
}) })
# AI分析 # AI分析
@@ -216,7 +231,7 @@ class StockAnalyzerService:
# 输出扫描完成信息 # 输出扫描完成信息
yield json.dumps({ yield json.dumps({
"status": "completed", "scan_completed": True,
"total_scanned": len(results), "total_scanned": len(results),
"total_matched": len(filtered_results) "total_matched": len(filtered_results)
}) })