fix: 修复前端显示问题
This commit is contained in:
@@ -76,6 +76,45 @@
|
||||
<!-- 右侧结果区域 -->
|
||||
<n-grid-item :span="24" :lg-span="16">
|
||||
<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">
|
||||
<n-empty description="尚未分析股票" size="large">
|
||||
<template #icon>
|
||||
@@ -84,13 +123,25 @@
|
||||
</n-empty>
|
||||
</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-item v-for="stock in analyzedStocks" :key="stock.code">
|
||||
<StockCard :stock="stock" />
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</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>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
@@ -101,7 +152,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
@@ -115,12 +166,18 @@ import {
|
||||
NInput,
|
||||
NButton,
|
||||
NEmpty,
|
||||
useMessage
|
||||
useMessage,
|
||||
NSpace,
|
||||
NText,
|
||||
NDataTable,
|
||||
NDropdown,
|
||||
type DataTableColumns
|
||||
} from 'naive-ui';
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import {
|
||||
BarChart as BarChartIcon,
|
||||
DocumentText as DocumentTextIcon
|
||||
BarChartOutline as BarChartIcon,
|
||||
DocumentTextOutline as DocumentTextIcon,
|
||||
DownloadOutline as DownloadIcon
|
||||
} from '@vicons/ionicons5';
|
||||
|
||||
import AnnouncementBanner from './AnnouncementBanner.vue';
|
||||
@@ -148,6 +205,7 @@ const marketType = ref('A');
|
||||
const stockCodes = ref('');
|
||||
const isAnalyzing = ref(false);
|
||||
const analyzedStocks = ref<StockInfo[]>([]);
|
||||
const displayMode = ref<'card' | 'table'>('card');
|
||||
|
||||
// API配置
|
||||
const apiConfig = ref<ApiConfig>({
|
||||
@@ -167,6 +225,136 @@ const marketOptions = [
|
||||
{ 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配置
|
||||
function updateApiConfig(config: ApiConfig) {
|
||||
apiConfig.value = { ...config };
|
||||
@@ -200,6 +388,22 @@ function processStreamData(text: string) {
|
||||
} else if (data.stock_code) {
|
||||
// 更新消息
|
||||
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) {
|
||||
console.error('解析流数据出错:', e);
|
||||
@@ -414,13 +618,7 @@ async function analyzeStocks() {
|
||||
processStreamData(buffer);
|
||||
}
|
||||
|
||||
// 将所有分析中的股票状态更新为已完成
|
||||
analyzedStocks.value.forEach((stock, index) => {
|
||||
if (stock.analysisStatus === 'analyzing') {
|
||||
const updatedStock = { ...stock, analysisStatus: 'completed' };
|
||||
analyzedStocks.value[index] = updatedStock;
|
||||
}
|
||||
});
|
||||
// 注意:不再需要在这里更新状态,因为已经在processStreamData中处理了scan_completed消息
|
||||
|
||||
message.success('分析完成');
|
||||
} 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 () => {
|
||||
try {
|
||||
@@ -615,4 +915,15 @@ onMounted(async () => {
|
||||
padding: 0.5rem;
|
||||
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>
|
||||
|
||||
@@ -536,6 +536,36 @@ function getChineseVolumeStatus(status: string): string {
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
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 {
|
||||
|
||||
@@ -4,10 +4,12 @@ import os
|
||||
import json
|
||||
import asyncio
|
||||
import httpx
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any, Generator, AsyncGenerator
|
||||
from dotenv import load_dotenv
|
||||
from logger import get_logger
|
||||
from utils.api_utils import APIUtils
|
||||
from datetime import datetime
|
||||
|
||||
# 获取日志器
|
||||
logger = get_logger()
|
||||
@@ -55,6 +57,26 @@ class AIAnalyzer:
|
||||
try:
|
||||
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 分析内容
|
||||
# 最近14天的股票数据记录
|
||||
recent_data = df.tail(14).to_dict('records')
|
||||
@@ -78,25 +100,19 @@ class AIAnalyzer:
|
||||
近14日交易数据:
|
||||
{recent_data}
|
||||
|
||||
请分析该基金的技术面状况,包括:
|
||||
1. 趋势分析:判断基金当前的趋势方向
|
||||
2. 动量分析:基于RSI和交易量评估基金动量
|
||||
3. 支撑与阻力位:确定关键价格位
|
||||
4. 技术面总结
|
||||
5. 投资建议
|
||||
|
||||
将分析结果格式化为JSON,像这样:
|
||||
{{
|
||||
"trend_analysis": "趋势分析结果...",
|
||||
"momentum_analysis": "动量分析结果...",
|
||||
"support_resistance": "支撑阻力位分析...",
|
||||
"technical_summary": "技术面总结...",
|
||||
"investment_advice": "投资建议..."
|
||||
}}
|
||||
请提供:
|
||||
1. 净值走势分析(包含支撑位和压力位)
|
||||
2. 成交量分析及其对净值的影响
|
||||
3. 风险评估(包含波动率和折溢价分析)
|
||||
4. 短期和中期净值预测
|
||||
5. 关键价格位分析
|
||||
6. 申购赎回建议(包含止损位)
|
||||
|
||||
请基于技术指标和市场表现进行分析,给出具体数据支持。
|
||||
"""
|
||||
else:
|
||||
elif market_type == 'US':
|
||||
prompt = f"""
|
||||
分析股票 {stock_code}:
|
||||
分析美股 {stock_code}:
|
||||
|
||||
技术指标概要:
|
||||
{technical_summary}
|
||||
@@ -104,25 +120,55 @@ class AIAnalyzer:
|
||||
近14日交易数据:
|
||||
{recent_data}
|
||||
|
||||
请分析该股票的技术面状况,包括:
|
||||
1. 趋势分析:当前趋势方向及强度
|
||||
2. 动量分析:基于MACD、RSI等指标
|
||||
3. 支撑与阻力位:关键价格位分析
|
||||
4. 成交量分析:交易量的变化及意义
|
||||
5. 波动性评估:ATR和波动率分析
|
||||
6. 技术面总结
|
||||
7. 投资建议:根据技术分析给出操作建议
|
||||
请提供:
|
||||
1. 趋势分析(包含支撑位和压力位,美元计价)
|
||||
2. 成交量分析及其含义
|
||||
3. 风险评估(包含波动率和美股市场特有风险)
|
||||
4. 短期和中期目标价位(美元)
|
||||
5. 关键技术位分析
|
||||
6. 具体交易建议(包含止损位)
|
||||
|
||||
请基于技术指标和美股市场特点进行分析,给出具体数据支持。
|
||||
"""
|
||||
elif market_type == 'HK':
|
||||
prompt = f"""
|
||||
分析港股 {stock_code}:
|
||||
|
||||
将分析结果格式化为JSON,像这样:
|
||||
{{
|
||||
"trend_analysis": "趋势分析结果...",
|
||||
"momentum_analysis": "动量分析结果...",
|
||||
"support_resistance": "支撑阻力位分析...",
|
||||
"volume_analysis": "成交量分析...",
|
||||
"volatility_assessment": "波动性评估...",
|
||||
"technical_summary": "技术面总结...",
|
||||
"investment_advice": "投资建议..."
|
||||
}}
|
||||
技术指标概要:
|
||||
{technical_summary}
|
||||
|
||||
近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
|
||||
@@ -142,11 +188,27 @@ class AIAnalyzer:
|
||||
"Authorization": f"Bearer {self.API_KEY}"
|
||||
}
|
||||
|
||||
# 获取当前日期作为分析日期
|
||||
analysis_date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# 异步请求API
|
||||
async with httpx.AsyncClient(timeout=self.API_TIMEOUT) as client:
|
||||
# 记录请求
|
||||
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:
|
||||
# 流式响应处理
|
||||
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_message = error_data.get('error', {}).get('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
|
||||
|
||||
# 处理流式响应
|
||||
buffer = ""
|
||||
collected_messages = []
|
||||
chunk_count = 0
|
||||
|
||||
async for chunk in response.aiter_text():
|
||||
if chunk:
|
||||
@@ -169,6 +236,7 @@ class AIAnalyzer:
|
||||
chunk_str = chunk_str[6:] # 去除"data: "前缀
|
||||
|
||||
if chunk_str == "[DONE]":
|
||||
logger.debug("收到流结束标记 [DONE]")
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -178,49 +246,48 @@ class AIAnalyzer:
|
||||
content = delta.get("content", "")
|
||||
|
||||
if content:
|
||||
chunk_count += 1
|
||||
buffer += content
|
||||
# 尝试提取完整的JSON
|
||||
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
|
||||
collected_messages.append(content)
|
||||
|
||||
# 达到一定长度就输出
|
||||
if len(buffer) > 100:
|
||||
yield json.dumps({
|
||||
"stock_code": stock_code,
|
||||
"ai_analysis_chunk": buffer
|
||||
})
|
||||
collected_messages.append(buffer)
|
||||
buffer = ""
|
||||
# 直接发送每个内容片段,不累积
|
||||
yield json.dumps({
|
||||
"stock_code": stock_code,
|
||||
"ai_analysis_chunk": content,
|
||||
"status": "analyzing"
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
# 忽略无法解析的块
|
||||
logger.error(f"JSON解析错误,块内容: {chunk_str[:100]}...")
|
||||
continue
|
||||
|
||||
# 处理最后的缓冲区
|
||||
if buffer:
|
||||
logger.info(f"AI流式处理完成,共收到 {chunk_count} 个内容片段,总长度: {len(buffer)}")
|
||||
|
||||
# 如果buffer不为空且不以换行符结束,发送一个换行符
|
||||
if buffer and not buffer.endswith('\n'):
|
||||
logger.debug("发送换行符")
|
||||
yield json.dumps({
|
||||
"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("{"):
|
||||
yield json.dumps({
|
||||
"stock_code": stock_code,
|
||||
"raw_analysis": full_content
|
||||
})
|
||||
# 尝试从分析内容中提取投资建议
|
||||
recommendation = self._extract_recommendation(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:
|
||||
# 非流式响应处理
|
||||
response = await client.post(api_url, json=request_data, headers=headers)
|
||||
@@ -229,32 +296,100 @@ class AIAnalyzer:
|
||||
error_data = response.json()
|
||||
error_message = error_data.get('error', {}).get('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
|
||||
|
||||
response_data = response.json()
|
||||
analysis_text = response_data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
try:
|
||||
# 尝试解析JSON
|
||||
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分析")
|
||||
|
||||
# 尝试从分析内容中提取投资建议
|
||||
recommendation = self._extract_recommendation(analysis_text)
|
||||
|
||||
# 计算分析评分
|
||||
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:
|
||||
logger.error(f"AI分析 {stock_code} 时出错: {str(e)}")
|
||||
logger.exception(e)
|
||||
yield json.dumps({"error": f"分析出错: {str(e)}"})
|
||||
logger.error(f"AI分析出错: {str(e)}", exc_info=True)
|
||||
yield json.dumps({
|
||||
"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):
|
||||
"""
|
||||
|
||||
@@ -159,10 +159,10 @@ class StockAnalyzerService:
|
||||
try:
|
||||
logger.info(f"开始批量扫描 {len(stock_codes)} 只股票, 市场: {market_type}")
|
||||
|
||||
# 输出初始状态
|
||||
# 输出初始状态 - 发送批量分析初始化消息
|
||||
yield json.dumps({
|
||||
"status": "scanning",
|
||||
"total_stocks": len(stock_codes),
|
||||
"stream_type": "batch",
|
||||
"stock_codes": stock_codes,
|
||||
"market_type": market_type,
|
||||
"min_score": min_score
|
||||
})
|
||||
@@ -177,6 +177,12 @@ class StockAnalyzerService:
|
||||
stock_with_indicators[code] = self.indicator.calculate_indicators(df)
|
||||
except Exception as 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)
|
||||
@@ -184,30 +190,39 @@ class StockAnalyzerService:
|
||||
# 过滤低于最低评分的股票
|
||||
filtered_results = [r for r in results if r[1] >= min_score]
|
||||
|
||||
# 输出评分结果
|
||||
yield json.dumps({
|
||||
"scan_results": [
|
||||
{
|
||||
# 为每只股票发送基本评分和推荐信息
|
||||
for code, score, rec in 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,
|
||||
"score": score,
|
||||
"recommendation": rec
|
||||
} for code, score, rec in filtered_results
|
||||
],
|
||||
"total_matched": len(filtered_results),
|
||||
"total_scanned": len(results)
|
||||
})
|
||||
"recommendation": rec,
|
||||
"price": float(latest_data.get('Close', 0)),
|
||||
"price_change": float(latest_data.get('Change', 0)),
|
||||
"rsi": float(latest_data.get('RSI', 0)) if 'RSI' in latest_data else None,
|
||||
"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分析
|
||||
if stream and filtered_results:
|
||||
top_stocks = filtered_results[:3] # 只分析前3只评分最高的股票
|
||||
# 只分析前5只评分最高的股票,避免分析过多导致前端卡顿
|
||||
top_stocks = filtered_results[:5]
|
||||
|
||||
for stock_code, score, _ in top_stocks:
|
||||
df = stock_with_indicators.get(stock_code)
|
||||
if df is not None:
|
||||
# 输出正在分析的股票信息
|
||||
yield json.dumps({
|
||||
"analyzing": stock_code,
|
||||
"score": score
|
||||
"stock_code": stock_code,
|
||||
"status": "analyzing"
|
||||
})
|
||||
|
||||
# AI分析
|
||||
@@ -216,7 +231,7 @@ class StockAnalyzerService:
|
||||
|
||||
# 输出扫描完成信息
|
||||
yield json.dumps({
|
||||
"status": "completed",
|
||||
"scan_completed": True,
|
||||
"total_scanned": len(results),
|
||||
"total_matched": len(filtered_results)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user