ADD: 支持ETF、 LOF基金!

This commit is contained in:
兰志宏
2025-03-05 16:32:30 +08:00
parent f5ebe29782
commit 25601b9bd6
4 changed files with 273 additions and 93 deletions

51
fund_service.py Normal file
View File

@@ -0,0 +1,51 @@
import akshare as ak
import pandas as pd
class FundService:
def search_funds(self, keyword, market_type='ETF'):
"""
搜索基金代码
:param keyword: 搜索关键词
:return: 匹配的基金列表
"""
try:
# 获取ETF和LOF数据
if market_type == 'ETF':
df = ak.fund_etf_spot_em()
else:
df = ak.fund_lof_spot_em()
# 转换列名
df = df.rename(columns={
"代码": "symbol",
"名称": "name",
"最新价": "price",
"涨跌额": "price_change",
"涨跌幅": "price_change_percent",
"成交量": "volume",
"流通市值": "market_value",
"总市值": "total_value",
"基金折价率": "discount_rate",
})
# 模糊匹配搜索(同时匹配代码和名称)
mask = (df['name'].str.contains(keyword, case=False, na=False) |
df['symbol'].str.contains(keyword, case=False, na=False))
results = df[mask]
# 格式化返回结果并处理 NaN 值
formatted_results = []
for _, row in results.iterrows():
formatted_results.append({
'name': row['name'] if pd.notna(row['name']) else '',
'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '',
'price': float(row['price']) if pd.notna(row['price']) else 0.0,
'volume': float(row['volume']) if pd.notna(row['volume']) else 0.0,
'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0,
'total_value': float(row['total_value']) if pd.notna(row['total_value']) else 0.0,
})
return formatted_results
except Exception as e:
raise Exception(f"搜索基金代码失败: {str(e)}")

View File

@@ -37,8 +37,8 @@ class StockAnalyzer:
}
def get_stock_data(self, stock_code, market_type='A', start_date=None, end_date=None, ):
"""获取股票数据"""
def get_stock_data(self, stock_code, market_type='A', start_date=None, end_date=None):
"""获取股票或基金数据"""
import akshare as ak
if start_date is None:
@@ -55,7 +55,6 @@ class StockAnalyzer:
end_date=end_date,
adjust="qfq"
)
# A股数据列名映射
elif market_type == 'HK':
df = ak.stock_hk_daily(
symbol=stock_code,
@@ -68,10 +67,22 @@ class StockAnalyzer:
end_date=end_date,
adjust="qfq"
)
# elif market_type == 'CRYPTO':
# df = ak.crypto_js_spot(
# symbol=stock_code
# )
elif market_type == 'ETF':
df = ak.fund_etf_hist_em(
symbol=stock_code,
period="daily",
start_date=start_date,
end_date=end_date,
adjust="qfq"
)
elif market_type == 'LOF':
df = ak.fund_lof_hist_em(
symbol=stock_code,
period="daily",
start_date=start_date,
end_date=end_date,
adjust="qfq"
)
else:
raise ValueError(f"不支持的市场类型: {market_type}")
@@ -214,10 +225,10 @@ class StockAnalyzer:
print(f"计算评分时出错: {str(e)}")
raise
def get_ai_analysis(self, df, stock_code, stream=False):
def get_ai_analysis(self, df, stock_code, market_type='A', stream=False):
"""使用 OpenAI 进行 AI 分析"""
try:
logger.info(f"开始AI分析股票 {stock_code}, 流式模式: {stream}")
logger.info(f"开始AI分析 {stock_code}, 流式模式: {stream}")
recent_data = df.tail(14).to_dict('records')
technical_summary = {
@@ -227,25 +238,87 @@ class StockAnalyzer:
'rsi_level': df.iloc[-1]['RSI']
}
prompt = f"""
分析股票 {stock_code}
# 根据市场类型调整分析提示
if market_type in ['ETF', 'LOF']:
prompt = f"""
分析基金 {stock_code}
技术指标概要:
{technical_summary}
近14日交易数据
{recent_data}
请提供:
1. 趋势分析(包含支撑位和压力位)
2. 成交量分析及其含义
3. 风险评估(包含波动率分析)
4. 短期和中期目标价位
5. 关键技术位分析
6. 具体交易建议(包含止损位)
请基于技术指标和市场动态进行分析,给出具体数据支持。
"""
技术指标概要:
{technical_summary}
近14日交易数据
{recent_data}
请提供:
1. 净值走势分析(包含支撑位和压力位)
2. 成交量分析及其对净值的影响
3. 风险评估(包含波动率和折溢价分析)
4. 短期和中期净值预测
5. 关键价格位分析
6. 申购赎回建议(包含止损位)
请基于技术指标和市场表现进行分析,给出具体数据支持。
"""
elif market_type == 'US':
prompt = f"""
分析美股 {stock_code}
技术指标概要:
{technical_summary}
近14日交易数据
{recent_data}
请提供:
1. 趋势分析(包含支撑位和压力位,美元计价)
2. 成交量分析及其含义
3. 风险评估(包含波动率和美股市场特有风险)
4. 短期和中期目标价位(美元)
5. 关键技术位分析
6. 具体交易建议(包含止损位)
请基于技术指标和美股市场特点进行分析,给出具体数据支持。
"""
elif market_type == 'HK':
prompt = f"""
分析港股 {stock_code}
技术指标概要:
{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股市场特点进行分析给出具体数据支持。
"""
logger.debug(f"生成的AI分析提示词: {self._truncate_json_for_logging(prompt, 100)}...")
@@ -381,7 +454,7 @@ class StockAnalyzer:
def _process_ai_stream(self, response, stock_code) -> Generator[str, None, None]:
"""处理AI流式响应"""
logger.info(f"开始处理股票 {stock_code} 的AI流式响应")
logger.info(f"开始处理 {stock_code} 的AI流式响应")
buffer = ""
chunk_count = 0
@@ -457,49 +530,45 @@ class StockAnalyzer:
return '强烈建议卖出'
def analyze_stock(self, stock_code, market_type='A', stream=False):
"""分析单个股票"""
"""分析股票或基金"""
try:
logger.info(f"开始分析股票: {stock_code}, 市场: {market_type}, 流式模式: {stream}")
logger.info(f"开始分析: {stock_code}, 市场: {market_type}, 流式模式: {stream}")
# 获取股票数据
logger.debug(f"获取股票 {stock_code} 数据")
# 获取数据
logger.debug(f"获取 {stock_code} 数据")
df = self.get_stock_data(stock_code, market_type)
# 计算技术指标
logger.debug(f"计算股票 {stock_code} 技术指标")
logger.debug(f"计算 {stock_code} 技术指标")
df = self.calculate_indicators(df)
# 评分系统
logger.debug(f"计算股票 {stock_code} 评分")
logger.debug(f"计算 {stock_code} 评分")
score = self.calculate_score(df)
logger.info(f"股票 {stock_code} 评分结果: {score}")
logger.info(f"{stock_code} 评分结果: {score}")
# 获取最新数据
latest = df.iloc[-1]
prev = df.iloc[-2]
# 处理 RSI 的 NaN 值
rsi_value = latest['RSI']
if pd.isna(rsi_value):
rsi_value = None
# 生成报告(保持原有格式)
# 生成报告
report = {
'stock_code': stock_code,
'market_type': market_type, # 添加市场类型
'analysis_date': datetime.now().strftime('%Y-%m-%d'),
'score': score,
'price': latest['close'],
'price_change': (latest['close'] - prev['close']) / prev['close'] * 100,
'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
'rsi': rsi_value, # 使用处理后的 RSI 值
'rsi': latest['RSI'] if not pd.isna(latest['RSI']) else None,
'macd_signal': 'BUY' if latest['MACD'] > latest['Signal'] else 'SELL',
'volume_status': 'HIGH' if latest['Volume_Ratio'] > 1.5 else 'NORMAL',
'recommendation': self.get_recommendation(score)
}
logger.debug(f"生成股票 {stock_code} 基础报告: {self._truncate_json_for_logging(report, 100)}...")
logger.debug(f"生成 {stock_code} 基础报告: {self._truncate_json_for_logging(report, 100)}...")
if stream:
logger.info(f"以流式模式返回股票 {stock_code} 分析结果")
logger.info(f"以流式模式返回 {stock_code} 分析结果")
# 先返回基本报告结构
base_report = dict(report)
base_report['ai_analysis'] = ''
@@ -509,21 +578,21 @@ class StockAnalyzer:
yield base_report_json
# 然后流式返回AI分析部分
logger.debug(f"开始获取股票 {stock_code} 的流式AI分析")
logger.debug(f"开始获取 {stock_code} 的流式AI分析")
ai_chunks_count = 0
for ai_chunk in self.get_ai_analysis(df, stock_code, stream=True):
for ai_chunk in self.get_ai_analysis(df, stock_code, market_type, stream=True):
ai_chunks_count += 1
yield ai_chunk
logger.info(f"股票 {stock_code} 流式AI分析完成共发送 {ai_chunks_count} 个块")
logger.info(f" {stock_code} 流式AI分析完成共发送 {ai_chunks_count} 个块")
else:
logger.info(f"以非流式模式返回股票 {stock_code} 分析结果")
logger.debug(f"开始获取股票 {stock_code} 的AI分析")
report['ai_analysis'] = self.get_ai_analysis(df, stock_code)
logger.info(f"以非流式模式返回 {stock_code} 分析结果")
logger.debug(f"开始获取 {stock_code} 的AI分析")
report['ai_analysis'] = self.get_ai_analysis(df, stock_code, market_type)
logger.debug(f"AI分析结果长度: {len(report['ai_analysis'])}")
return report
except Exception as e:
error_msg = f"分析股票 {stock_code} 时出错: {str(e)}"
error_msg = f"分析 {stock_code} 时出错: {str(e)}"
logger.error(error_msg)
logger.exception(e)
@@ -535,23 +604,23 @@ class StockAnalyzer:
raise
def scan_market(self, stock_list, min_score=60, market_type='A', stream=False):
"""扫描市场,寻找符合条件的股票"""
logger.info(f"开始扫描市场,股票数量: {len(stock_list)}, 最低分数: {min_score}, 市场: {market_type}, 流式模式: {stream}")
"""扫描市场,寻找符合条件的"""
logger.info(f"开始扫描市场,数量: {len(stock_list)}, 最低分数: {min_score}, 市场: {market_type}, 流式模式: {stream}")
if not stream:
recommendations = []
for stock_code in stock_list:
try:
logger.debug(f"分析股票: {stock_code}")
logger.debug(f"分析: {stock_code}")
report = self.analyze_stock(stock_code, market_type)
if report['score'] >= min_score:
logger.info(f"股票 {stock_code} 评分 {report['score']} >= {min_score},添加到推荐列表")
logger.info(f" {stock_code} 评分 {report['score']} >= {min_score},添加到推荐列表")
recommendations.append(report)
else:
logger.debug(f"股票 {stock_code} 评分 {report['score']} < {min_score},不添加到推荐列表")
logger.debug(f" {stock_code} 评分 {report['score']} < {min_score},不添加到推荐列表")
except Exception as e:
logger.error(f"分析股票 {stock_code} 时出错: {str(e)}")
logger.error(f"分析 {stock_code} 时出错: {str(e)}")
logger.exception(e)
continue
@@ -565,16 +634,16 @@ class StockAnalyzer:
stock_count = 0
for stock_code in stock_list:
stock_count += 1
logger.debug(f"流式分析股票 {stock_code} ({stock_count}/{len(stock_list)})")
logger.debug(f"流式分析 {stock_code} ({stock_count}/{len(stock_list)})")
try:
# 分析单只股票并获取流式结果
chunk_count = 0
for chunk in self.analyze_stock(stock_code, market_type, stream=True):
chunk_count += 1
yield chunk
logger.debug(f"股票 {stock_code} 流式分析完成,共 {chunk_count} 个块")
logger.debug(f" {stock_code} 流式分析完成,共 {chunk_count} 个块")
except Exception as e:
error_msg = f"分析股票 {stock_code} 时出错: {str(e)}"
error_msg = f"分析 {stock_code} 时出错: {str(e)}"
logger.error(error_msg)
logger.exception(e)
error_json = json.dumps({'stock_code': stock_code, 'error': error_msg})

View File

@@ -301,29 +301,28 @@
<option value="A">A股</option>
<option value="HK">港股</option>
<option value="US">美股</option>
<option value="ETF">ETF基金</option>
<option value="LOF">LOF基金</option>
</select>
</div>
<!-- 美股搜索框 -->
<div id="usStockSearch" class="mb-4 hidden">
<!-- 搜索框 -->
<div id="searchContainer" class="mb-4 hidden">
<div class="relative">
<input type="text"
id="searchInput"
class="w-full p-2 border rounded bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入美股名称搜索(中文和英文都试下)"
oninput="handleSearchInput(event)">
<!-- 添加搜索 loading 图标 -->
placeholder="输入名称回车搜索"
onkeydown="handleKeyDown(event)">
<div id="searchLoading" class="absolute right-3 top-2.5 hidden">
<svg class="animate-spin h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- 搜索结果下拉框 -->
<div id="searchResults"
class="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg hidden">
<div id="searchResults" class="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg hidden max-h-80 overflow-y-auto">
</div>
</div>
<!-- 添加错误提示 -->
<div id="searchError" class="hidden absolute z-10 w-full mt-1 p-3 bg-red-50 text-red-600 rounded-md border border-red-200">
</div>
</div>
@@ -342,6 +341,17 @@
};
}
function handleKeyDown(event) {
// 检查是否按下回车键且不在输入法编辑状态
if (event.key === 'Enter' && !event.isComposing) {
event.preventDefault(); // 阻止默认行为
const keyword = event.target.value.trim();
if (keyword) {
debouncedSearch(keyword);
}
}
}
// 使用防抖包装搜索函数
const debouncedSearch = debounce(async (keyword) => {
if (!keyword) {
@@ -350,20 +360,26 @@
return;
}
// 显示 loading
const marketType = document.getElementById('marketType').value;
document.getElementById('searchLoading').classList.remove('hidden');
// 隐藏之前的错误信息
document.getElementById('searchError').classList.add('hidden');
try {
const response = await fetch(`/search_us_stocks?keyword=${encodeURIComponent(keyword)}`);
let endpoint = '';
if (marketType === 'US') {
endpoint = '/search_us_stocks';
} else if (['ETF', 'LOF'].includes(marketType)) {
endpoint = '/search_funds';
}
const response = await fetch(`${endpoint}?market_type=${marketType}&keyword=${encodeURIComponent(keyword)}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '搜索失败');
}
displaySearchResults(data.results);
displaySearchResults(data.results, marketType);
} catch (error) {
console.error('搜索出错:', error);
const errorDiv = document.getElementById('searchError');
@@ -373,12 +389,8 @@
} finally {
document.getElementById('searchLoading').classList.add('hidden');
}
}, 500); // 设置500ms的防抖延迟
}, 500);
// 修改输入事件处理函数
function handleSearchInput(event) {
debouncedSearch(event.target.value);
}
</script>
<div class="mb-4">
<label for="batchStocks" class="block text-sm font-medium text-gray-700 mb-2">
@@ -395,12 +407,28 @@
function handleMarketTypeChange() {
const marketType = document.getElementById('marketType').value;
const searchDiv = document.getElementById('usStockSearch');
searchDiv.classList.toggle('hidden', marketType !== 'US');
const searchContainer = document.getElementById('searchContainer');
const searchInput = document.getElementById('searchInput');
const batchStocks = document.getElementById('batchStocks');
// 清空搜索结果
// 显示/隐藏搜索框
searchContainer.classList.toggle('hidden', !['US', 'ETF', 'LOF'].includes(marketType));
// 更新搜索框提示文本
if (marketType === 'US') {
searchInput.placeholder = '输入美股名称回车搜索(中文和英文都试下)';
} else if (marketType === 'ETF') {
searchInput.placeholder = '输入ETF基金名称回车搜索';
} else if (marketType === 'LOF') {
searchInput.placeholder = '输入LOF基金名称回车搜索';
}
// 清空搜索结果和输入
document.getElementById('searchResults').innerHTML = '';
document.getElementById('searchInput').value = '';
searchInput.value = '';
// 清空代码输入框
batchStocks.value = '';
}
function debounceSearch(event) {
@@ -410,7 +438,7 @@
}, 300);
}
function displaySearchResults(results) {
function displaySearchResults(results, marketType) {
const resultsDiv = document.getElementById('searchResults');
if (!results || results.length === 0) {
@@ -418,18 +446,32 @@
return;
}
let html = '<div class="py-1">';
results.forEach(stock => {
let html = '<div class="divide-y divide-gray-100">'; // 添加分割线
results.forEach(item => {
let rightContent = '';
if (marketType === 'US') {
rightContent = `
<div class="font-medium">$${item.price}</div>
<div class="text-sm text-gray-500">市值: ${formatMarketValue(item.market_value)}</div>
`;
} else {
rightContent = `
<div class="font-medium">¥${item.price}</div>
<div class="text-sm text-gray-500">
${item.discount_rate ? `折价率: ${item.discount_rate}%` : ''}
</div>
`;
}
html += `
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex justify-between items-center"
onclick="selectStock('${stock.symbol}')">
onclick="selectStock('${item.symbol}')">
<div>
<div class="font-medium">${stock.name}</div>
<div class="text-sm text-gray-500">${stock.symbol}</div>
<div class="font-medium">${item.name}</div>
<div class="text-sm text-gray-500">${item.symbol}</div>
</div>
<div class="text-right">
<div class="font-medium">$${stock.price}</div>
<div class="text-sm text-gray-500">市值: ${formatMarketValue(stock.market_value)}</div>
${rightContent}
</div>
</div>
`;
@@ -686,10 +728,10 @@
errorCard = document.createElement('div');
errorCard.id = `error-${stockCode}`;
errorCard.className = 'bg-red-50 p-4 rounded-lg text-red-600';
errorCard.innerHTML = `分析股票 ${stockCode} 出错: ${chunk.error}`;
errorCard.innerHTML = `分析 ${stockCode} 出错: ${chunk.error}`;
container.appendChild(errorCard);
} else {
errorCard.innerHTML = `分析股票 ${stockCode} 出错: ${chunk.error}`;
errorCard.innerHTML = `分析 ${stockCode} 出错: ${chunk.error}`;
}
return;
}

View File

@@ -1,6 +1,7 @@
from flask import Flask, render_template, request, jsonify, Response, stream_with_context
from stock_analyzer import StockAnalyzer
from us_stock_service import USStockService
from fund_service import FundService # 新增导入
import threading
import os
import traceback
@@ -18,6 +19,7 @@ logger = get_logger()
app = Flask(__name__)
analyzer = StockAnalyzer()
us_stock_service = USStockService()
fund_service = FundService() # 新增服务实例
@app.route('/')
def index():
@@ -102,7 +104,7 @@ def analyze():
return Response(stream_with_context(generate()), mimetype='application/json')
except Exception as e:
error_msg = f"分析股票时出错: {str(e)}"
error_msg = f"分析时出错: {str(e)}"
logger.error(error_msg)
logger.exception(e)
return jsonify({'error': error_msg}), 500
@@ -121,6 +123,22 @@ def search_us_stocks():
print(f"搜索美股代码时出错: {str(e)}")
return jsonify({'error': str(e)}), 500
# 添加基金搜索路由
@app.route('/search_funds', methods=['GET'])
def search_funds():
try:
keyword = request.args.get('keyword', '')
market_type = request.args.get('market_type', '')
if not keyword:
return jsonify({'error': '请输入搜索关键词'}), 400
results = fund_service.search_funds(keyword, market_type)
return jsonify({'results': results})
except Exception as e:
logger.error(f"搜索基金代码时出错: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/test_api_connection', methods=['POST'])
def test_api_connection():
"""测试API连接"""