添加了基金对比功能
This commit is contained in:
Binary file not shown.
394
Backend/app.py
394
Backend/app.py
@@ -1,15 +1,28 @@
|
||||
from flask import Flask, request, jsonify
|
||||
from flask import Flask, request, jsonify, g
|
||||
from flask_cors import CORS
|
||||
from database import init_db, get_db
|
||||
from models import FundBasicInfo, FundTrend, FundEstimate, FundPortfolio, FundExtraData, FundWatchlist, FundWatchlistGroup
|
||||
from database import init_db, SessionLocal
|
||||
from models import FundBasicInfo, FundTrend, FundEstimate, FundPortfolio, FundExtraData, FundWatchlist, FundWatchlistGroup, FundRiskMetrics
|
||||
from fund_api import FundAPI
|
||||
from fund_list_cache import get_fund_list_cache
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import math
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app) # 允许跨域请求
|
||||
|
||||
def get_db():
|
||||
if 'db' not in g:
|
||||
g.db = SessionLocal()
|
||||
return g.db
|
||||
|
||||
@app.teardown_appcontext
|
||||
def teardown_db(exception):
|
||||
db = g.pop('db', None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
# 初始化数据库
|
||||
init_db()
|
||||
fund_api = FundAPI()
|
||||
@@ -116,7 +129,7 @@ def get_fund_detail(fund_code):
|
||||
"""获取基金详细信息"""
|
||||
if not fund_code:
|
||||
return jsonify({"error": "Fund code is required"}), 400
|
||||
db = next(get_db()) # 获取数据库会话
|
||||
db = get_db() # 获取数据库会话
|
||||
|
||||
# 使用新的 get_fund_data 方法获取清洗后的完整数据
|
||||
fund_data = fund_api.get_fund_data(fund_code)
|
||||
@@ -274,7 +287,7 @@ def get_fund_basic(fund_code):
|
||||
}
|
||||
return jsonify(result)
|
||||
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
basic = db.query(FundBasicInfo).filter(FundBasicInfo.fund_code == fund_code).first()
|
||||
if basic:
|
||||
basic_info = _json_loads(basic.basic_json, {})
|
||||
@@ -296,7 +309,7 @@ def get_fund_trend(fund_code):
|
||||
"accumulated_net_worth": fund_data.get('accumulated_net_worth', [])
|
||||
})
|
||||
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
trend = db.query(FundTrend).filter(FundTrend.fund_code == fund_code).first()
|
||||
if trend:
|
||||
return jsonify({
|
||||
@@ -312,7 +325,7 @@ def get_fund_trend(fund_code):
|
||||
@app.route('/api/watchlist', methods=['GET'])
|
||||
def get_watchlist():
|
||||
"""获取自选基金列表(按分组和排序顺序)"""
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
|
||||
# 获取所有分组
|
||||
groups = db.query(FundWatchlistGroup).order_by(FundWatchlistGroup.sort_order).all()
|
||||
@@ -358,7 +371,7 @@ def get_watchlist():
|
||||
@app.route('/api/watchlist/<fund_code>', methods=['GET'])
|
||||
def check_watchlist(fund_code):
|
||||
"""检查基金是否在自选列表中"""
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
exists = db.query(FundWatchlist).filter(FundWatchlist.fund_code == fund_code).first() is not None
|
||||
return jsonify({'in_watchlist': exists})
|
||||
|
||||
@@ -375,7 +388,7 @@ def add_to_watchlist():
|
||||
if not fund_code:
|
||||
return jsonify({'error': 'Fund code is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
|
||||
# 检查是否已存在
|
||||
existing = db.query(FundWatchlist).filter(FundWatchlist.fund_code == fund_code).first()
|
||||
@@ -414,7 +427,7 @@ def add_to_watchlist():
|
||||
@app.route('/api/watchlist/<fund_code>', methods=['DELETE'])
|
||||
def remove_from_watchlist(fund_code):
|
||||
"""从自选列表移除基金"""
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
|
||||
item = db.query(FundWatchlist).filter(FundWatchlist.fund_code == fund_code).first()
|
||||
if not item:
|
||||
@@ -438,7 +451,7 @@ def batch_delete_from_watchlist():
|
||||
if not fund_codes:
|
||||
return jsonify({'error': 'Fund codes are required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
deleted_count = db.query(FundWatchlist).filter(
|
||||
@@ -469,7 +482,7 @@ def reorder_watchlist():
|
||||
if not order:
|
||||
return jsonify({'error': 'Order array is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
for index, fund_code in enumerate(order):
|
||||
@@ -491,7 +504,7 @@ def reorder_watchlist():
|
||||
@app.route('/api/watchlist/groups', methods=['GET'])
|
||||
def get_groups():
|
||||
"""获取所有分组"""
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
groups = db.query(FundWatchlistGroup).order_by(FundWatchlistGroup.sort_order).all()
|
||||
|
||||
result = [{
|
||||
@@ -512,7 +525,7 @@ def create_group():
|
||||
if not name:
|
||||
return jsonify({'error': 'Group name is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
|
||||
# 获取最大排序值
|
||||
max_order = db.query(FundWatchlistGroup).order_by(FundWatchlistGroup.sort_order.desc()).first()
|
||||
@@ -545,7 +558,7 @@ def update_group(group_id):
|
||||
if not name:
|
||||
return jsonify({'error': 'Group name is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
group = db.query(FundWatchlistGroup).filter(FundWatchlistGroup.id == group_id).first()
|
||||
|
||||
if not group:
|
||||
@@ -563,7 +576,7 @@ def update_group(group_id):
|
||||
@app.route('/api/watchlist/groups/<int:group_id>', methods=['DELETE'])
|
||||
def delete_group(group_id):
|
||||
"""删除分组(分组内的基金会变为未分组)"""
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
group = db.query(FundWatchlistGroup).filter(FundWatchlistGroup.id == group_id).first()
|
||||
|
||||
if not group:
|
||||
@@ -589,7 +602,7 @@ def reorder_groups():
|
||||
if not order:
|
||||
return jsonify({'error': 'Order array is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
for index, group_id in enumerate(order):
|
||||
@@ -613,7 +626,7 @@ def move_fund_to_group():
|
||||
if not fund_code:
|
||||
return jsonify({'error': 'Fund code is required'}), 400
|
||||
|
||||
db = next(get_db())
|
||||
db = get_db()
|
||||
fund = db.query(FundWatchlist).filter(FundWatchlist.fund_code == fund_code).first()
|
||||
|
||||
if not fund:
|
||||
@@ -628,5 +641,350 @@ def move_fund_to_group():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 风险指标计算 ====================
|
||||
|
||||
def calculate_risk_metrics(net_worth_trend):
|
||||
"""
|
||||
计算基金风险指标:最大回撤、夏普比率、年化波动率、年化收益率
|
||||
net_worth_trend: [{'date': '2024-01-01', 'net_worth': 1.0}, ...]
|
||||
"""
|
||||
if not net_worth_trend or len(net_worth_trend) < 30:
|
||||
return None
|
||||
|
||||
# 按日期排序
|
||||
sorted_data = sorted(net_worth_trend, key=lambda x: x.get('date', ''))
|
||||
|
||||
# 转换为净值数组和日期数组
|
||||
dates = []
|
||||
values = []
|
||||
for item in sorted_data:
|
||||
if item.get('net_worth') is not None:
|
||||
dates.append(item.get('date'))
|
||||
values.append(float(item.get('net_worth')))
|
||||
|
||||
if len(values) < 30:
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
def get_period_data(months):
|
||||
"""获取指定时间段的数据"""
|
||||
if months == 'all':
|
||||
return values, dates
|
||||
|
||||
cutoff_date = (now - timedelta(days=months * 30)).strftime('%Y-%m-%d')
|
||||
period_values = []
|
||||
period_dates = []
|
||||
for i, d in enumerate(dates):
|
||||
if d >= cutoff_date:
|
||||
period_values.append(values[i])
|
||||
period_dates.append(d)
|
||||
return period_values, period_dates
|
||||
|
||||
def calc_max_drawdown(period_values):
|
||||
"""计算最大回撤"""
|
||||
if len(period_values) < 2:
|
||||
return None
|
||||
|
||||
peak = period_values[0]
|
||||
max_dd = 0
|
||||
|
||||
for value in period_values:
|
||||
if value > peak:
|
||||
peak = value
|
||||
drawdown = (peak - value) / peak * 100
|
||||
if drawdown > max_dd:
|
||||
max_dd = drawdown
|
||||
|
||||
return round(max_dd, 2)
|
||||
|
||||
def calc_daily_returns(period_values):
|
||||
"""计算日收益率序列"""
|
||||
if len(period_values) < 2:
|
||||
return []
|
||||
returns = []
|
||||
for i in range(1, len(period_values)):
|
||||
if period_values[i-1] != 0:
|
||||
ret = (period_values[i] - period_values[i-1]) / period_values[i-1]
|
||||
returns.append(ret)
|
||||
return returns
|
||||
|
||||
def calc_annual_return(period_values, trading_days):
|
||||
"""计算年化收益率"""
|
||||
if len(period_values) < 2 or period_values[0] == 0:
|
||||
return None
|
||||
total_return = (period_values[-1] - period_values[0]) / period_values[0]
|
||||
if trading_days <= 0:
|
||||
return None
|
||||
annual_return = ((1 + total_return) ** (252 / trading_days) - 1) * 100
|
||||
return round(annual_return, 2)
|
||||
|
||||
def calc_volatility(daily_returns):
|
||||
"""计算年化波动率"""
|
||||
if len(daily_returns) < 10:
|
||||
return None
|
||||
mean_return = sum(daily_returns) / len(daily_returns)
|
||||
variance = sum((r - mean_return) ** 2 for r in daily_returns) / len(daily_returns)
|
||||
daily_vol = math.sqrt(variance)
|
||||
annual_vol = daily_vol * math.sqrt(252) * 100
|
||||
return round(annual_vol, 2)
|
||||
|
||||
def calc_sharpe_ratio(annual_return, volatility, risk_free_rate=2.0):
|
||||
"""计算夏普比率,假设无风险利率为2%"""
|
||||
if volatility is None or volatility == 0 or annual_return is None:
|
||||
return None
|
||||
sharpe = (annual_return - risk_free_rate) / volatility
|
||||
return round(sharpe, 2)
|
||||
|
||||
result = {}
|
||||
|
||||
# 计算不同时间段的最大回撤
|
||||
for period, months in [('3m', 3), ('6m', 6), ('1y', 12), ('3y', 36), ('all', 'all')]:
|
||||
period_values, _ = get_period_data(months)
|
||||
result[f'max_drawdown_{period}'] = calc_max_drawdown(period_values)
|
||||
|
||||
# 计算1年和3年的年化收益率、波动率、夏普比率
|
||||
for period, months in [('1y', 12), ('3y', 36)]:
|
||||
period_values, period_dates = get_period_data(months)
|
||||
trading_days = len(period_values)
|
||||
|
||||
daily_returns = calc_daily_returns(period_values)
|
||||
annual_return = calc_annual_return(period_values, trading_days)
|
||||
volatility = calc_volatility(daily_returns)
|
||||
sharpe = calc_sharpe_ratio(annual_return, volatility)
|
||||
|
||||
result[f'annual_return_{period}'] = annual_return
|
||||
result[f'volatility_{period}'] = volatility
|
||||
result[f'sharpe_ratio_{period}'] = sharpe
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def is_data_fresh(updated_time, days=7):
|
||||
"""检查数据是否在指定天数内"""
|
||||
if not updated_time:
|
||||
return False
|
||||
return (datetime.now() - updated_time).days < days
|
||||
|
||||
|
||||
@app.route('/api/fund/<fund_code>/compare-data', methods=['GET'])
|
||||
def get_fund_compare_data(fund_code):
|
||||
"""
|
||||
获取基金对比数据,优先使用数据库缓存(1周内)
|
||||
返回完整的基金详情数据和风险指标
|
||||
"""
|
||||
db = get_db()
|
||||
force_refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
|
||||
try:
|
||||
# 检查缓存数据是否新鲜(1周内)
|
||||
trend_record = db.query(FundTrend).filter(FundTrend.fund_code == fund_code).first()
|
||||
risk_record = db.query(FundRiskMetrics).filter(FundRiskMetrics.fund_code == fund_code).first()
|
||||
|
||||
use_cache = (
|
||||
not force_refresh and
|
||||
trend_record and
|
||||
is_data_fresh(trend_record.updated_time, days=7)
|
||||
)
|
||||
|
||||
if use_cache:
|
||||
# 使用缓存数据
|
||||
data = _build_cached_response(db, fund_code)
|
||||
if data:
|
||||
# 检查风险指标是否存在且新鲜
|
||||
risk_data_valid = (
|
||||
risk_record and
|
||||
is_data_fresh(risk_record.updated_time, days=7) and
|
||||
risk_record.sharpe_ratio_1y is not None
|
||||
)
|
||||
|
||||
if risk_data_valid:
|
||||
data['risk_metrics'] = {
|
||||
'max_drawdown_3m': risk_record.max_drawdown_3m,
|
||||
'max_drawdown_6m': risk_record.max_drawdown_6m,
|
||||
'max_drawdown_1y': risk_record.max_drawdown_1y,
|
||||
'max_drawdown_3y': risk_record.max_drawdown_3y,
|
||||
'max_drawdown_all': risk_record.max_drawdown_all,
|
||||
'sharpe_ratio_1y': risk_record.sharpe_ratio_1y,
|
||||
'sharpe_ratio_3y': risk_record.sharpe_ratio_3y,
|
||||
'volatility_1y': risk_record.volatility_1y,
|
||||
'volatility_3y': risk_record.volatility_3y,
|
||||
'annual_return_1y': risk_record.annual_return_1y,
|
||||
'annual_return_3y': risk_record.annual_return_3y,
|
||||
}
|
||||
else:
|
||||
# 风险指标缺失,从缓存的净值数据计算
|
||||
net_worth_trend = data.get('net_worth_trend', [])
|
||||
risk_metrics = calculate_risk_metrics(net_worth_trend)
|
||||
|
||||
if risk_metrics:
|
||||
# 保存到数据库
|
||||
if risk_record:
|
||||
for key, value in risk_metrics.items():
|
||||
setattr(risk_record, key, value)
|
||||
risk_record.updated_time = datetime.now()
|
||||
else:
|
||||
risk_record = FundRiskMetrics(
|
||||
fund_code=fund_code,
|
||||
**risk_metrics
|
||||
)
|
||||
db.add(risk_record)
|
||||
db.commit()
|
||||
data['risk_metrics'] = risk_metrics
|
||||
else:
|
||||
data['risk_metrics'] = {}
|
||||
|
||||
data['data_source'] = 'cache'
|
||||
data['cache_time'] = trend_record.updated_time.isoformat() if trend_record.updated_time else None
|
||||
return jsonify(data)
|
||||
|
||||
# 从API获取新数据
|
||||
api_data = fund_api.get_fund_data(fund_code)
|
||||
if not api_data:
|
||||
# 如果API失败,尝试返回缓存数据
|
||||
if trend_record:
|
||||
data = _build_cached_response(db, fund_code)
|
||||
if data:
|
||||
# 即使API失败,也尝试计算风险指标
|
||||
if risk_record and risk_record.sharpe_ratio_1y is not None:
|
||||
data['risk_metrics'] = {
|
||||
'max_drawdown_3m': risk_record.max_drawdown_3m,
|
||||
'max_drawdown_6m': risk_record.max_drawdown_6m,
|
||||
'max_drawdown_1y': risk_record.max_drawdown_1y,
|
||||
'max_drawdown_3y': risk_record.max_drawdown_3y,
|
||||
'max_drawdown_all': risk_record.max_drawdown_all,
|
||||
'sharpe_ratio_1y': risk_record.sharpe_ratio_1y,
|
||||
'sharpe_ratio_3y': risk_record.sharpe_ratio_3y,
|
||||
'volatility_1y': risk_record.volatility_1y,
|
||||
'volatility_3y': risk_record.volatility_3y,
|
||||
'annual_return_1y': risk_record.annual_return_1y,
|
||||
'annual_return_3y': risk_record.annual_return_3y,
|
||||
}
|
||||
else:
|
||||
# 从缓存净值数据计算
|
||||
net_worth_trend = data.get('net_worth_trend', [])
|
||||
risk_metrics = calculate_risk_metrics(net_worth_trend)
|
||||
if risk_metrics:
|
||||
# 保存到数据库
|
||||
if risk_record:
|
||||
for key, value in risk_metrics.items():
|
||||
setattr(risk_record, key, value)
|
||||
risk_record.updated_time = datetime.now()
|
||||
else:
|
||||
risk_record = FundRiskMetrics(fund_code=fund_code, **risk_metrics)
|
||||
db.add(risk_record)
|
||||
db.commit()
|
||||
data['risk_metrics'] = risk_metrics or {}
|
||||
data['data_source'] = 'stale_cache'
|
||||
return jsonify(data)
|
||||
return jsonify({'error': 'Failed to fetch fund data'}), 500
|
||||
|
||||
# 计算风险指标
|
||||
net_worth_trend = api_data.get('net_worth_trend', [])
|
||||
risk_metrics = calculate_risk_metrics(net_worth_trend)
|
||||
|
||||
# 保存到数据库
|
||||
_save_fund_data_to_db(db, fund_code, api_data)
|
||||
|
||||
# 保存风险指标
|
||||
if risk_metrics:
|
||||
if risk_record:
|
||||
for key, value in risk_metrics.items():
|
||||
setattr(risk_record, key, value)
|
||||
risk_record.updated_time = datetime.now()
|
||||
else:
|
||||
risk_record = FundRiskMetrics(
|
||||
fund_code=fund_code,
|
||||
**risk_metrics
|
||||
)
|
||||
db.add(risk_record)
|
||||
db.commit()
|
||||
|
||||
# 返回数据
|
||||
api_data['risk_metrics'] = risk_metrics or {}
|
||||
api_data['data_source'] = 'api'
|
||||
return jsonify(api_data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching fund compare data: {e}")
|
||||
db.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
def _save_fund_data_to_db(db: Session, fund_code: str, data: dict):
|
||||
"""保存基金数据到数据库"""
|
||||
try:
|
||||
# 保存基本信息
|
||||
basic_info = data.get('basic_info', {})
|
||||
basic_record = db.query(FundBasicInfo).filter(FundBasicInfo.fund_code == fund_code).first()
|
||||
if basic_record:
|
||||
basic_record.fund_name = basic_info.get('fund_name', '')
|
||||
basic_record.fund_type = basic_info.get('fund_type', '')
|
||||
basic_record.basic_json = _json_dumps(basic_info)
|
||||
basic_record.performance_json = _json_dumps(data.get('performance', {}))
|
||||
basic_record.updated_time = datetime.now()
|
||||
else:
|
||||
basic_record = FundBasicInfo(
|
||||
fund_code=fund_code,
|
||||
fund_name=basic_info.get('fund_name', ''),
|
||||
fund_type=basic_info.get('fund_type', ''),
|
||||
basic_json=_json_dumps(basic_info),
|
||||
performance_json=_json_dumps(data.get('performance', {}))
|
||||
)
|
||||
db.add(basic_record)
|
||||
|
||||
# 保存走势数据
|
||||
trend_record = db.query(FundTrend).filter(FundTrend.fund_code == fund_code).first()
|
||||
if trend_record:
|
||||
trend_record.net_worth_trend_json = _json_dumps(data.get('net_worth_trend', []))
|
||||
trend_record.accumulated_net_worth_json = _json_dumps(data.get('accumulated_net_worth', []))
|
||||
trend_record.position_trend_json = _json_dumps(data.get('position_trend', []))
|
||||
trend_record.total_return_trend_json = _json_dumps(data.get('total_return_trend', []))
|
||||
trend_record.ranking_trend_json = _json_dumps(data.get('ranking_trend', []))
|
||||
trend_record.ranking_percentage_json = _json_dumps(data.get('ranking_percentage', []))
|
||||
trend_record.scale_fluctuation_json = _json_dumps(data.get('scale_fluctuation', {}))
|
||||
trend_record.updated_time = datetime.now()
|
||||
else:
|
||||
trend_record = FundTrend(
|
||||
fund_code=fund_code,
|
||||
net_worth_trend_json=_json_dumps(data.get('net_worth_trend', [])),
|
||||
accumulated_net_worth_json=_json_dumps(data.get('accumulated_net_worth', [])),
|
||||
position_trend_json=_json_dumps(data.get('position_trend', [])),
|
||||
total_return_trend_json=_json_dumps(data.get('total_return_trend', [])),
|
||||
ranking_trend_json=_json_dumps(data.get('ranking_trend', [])),
|
||||
ranking_percentage_json=_json_dumps(data.get('ranking_percentage', [])),
|
||||
scale_fluctuation_json=_json_dumps(data.get('scale_fluctuation', {}))
|
||||
)
|
||||
db.add(trend_record)
|
||||
|
||||
# 保存额外数据
|
||||
extra_record = db.query(FundExtraData).filter(FundExtraData.fund_code == fund_code).first()
|
||||
if extra_record:
|
||||
extra_record.holder_structure_json = _json_dumps(data.get('holder_structure', {}))
|
||||
extra_record.asset_allocation_json = _json_dumps(data.get('asset_allocation', {}))
|
||||
extra_record.performance_evaluation_json = _json_dumps(data.get('performance_evaluation', {}))
|
||||
extra_record.fund_managers_json = _json_dumps(data.get('fund_managers', []))
|
||||
extra_record.subscription_redemption_json = _json_dumps(data.get('subscription_redemption', {}))
|
||||
extra_record.same_type_funds_json = _json_dumps(data.get('same_type_funds', []))
|
||||
extra_record.updated_time = datetime.now()
|
||||
else:
|
||||
extra_record = FundExtraData(
|
||||
fund_code=fund_code,
|
||||
holder_structure_json=_json_dumps(data.get('holder_structure', {})),
|
||||
asset_allocation_json=_json_dumps(data.get('asset_allocation', {})),
|
||||
performance_evaluation_json=_json_dumps(data.get('performance_evaluation', {})),
|
||||
fund_managers_json=_json_dumps(data.get('fund_managers', [])),
|
||||
subscription_redemption_json=_json_dumps(data.get('subscription_redemption', {})),
|
||||
same_type_funds_json=_json_dumps(data.get('same_type_funds', []))
|
||||
)
|
||||
db.add(extra_record)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"Error saving fund data to db: {e}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
@@ -99,3 +99,27 @@ class FundWatchlist(Base):
|
||||
sort_order = Column(Integer, default=0) # 排序顺序,数字越小越靠前
|
||||
created_time = Column(DateTime, default=datetime.now)
|
||||
updated_time = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class FundRiskMetrics(Base):
|
||||
"""基金风险指标表 - 存储计算的夏普比率、最大回撤等"""
|
||||
__tablename__ = 'fund_risk_metrics'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
fund_code = Column(String(6), unique=True, nullable=False)
|
||||
# 最大回撤(百分比)
|
||||
max_drawdown_3m = Column(Float) # 近3月
|
||||
max_drawdown_6m = Column(Float) # 近6月
|
||||
max_drawdown_1y = Column(Float) # 近1年
|
||||
max_drawdown_3y = Column(Float) # 近3年
|
||||
max_drawdown_all = Column(Float) # 成立来
|
||||
# 夏普比率(年化)
|
||||
sharpe_ratio_1y = Column(Float) # 近1年
|
||||
sharpe_ratio_3y = Column(Float) # 近3年
|
||||
# 年化波动率
|
||||
volatility_1y = Column(Float) # 近1年
|
||||
volatility_3y = Column(Float) # 近3年
|
||||
# 年化收益率
|
||||
annual_return_1y = Column(Float) # 近1年
|
||||
annual_return_3y = Column(Float) # 近3年
|
||||
updated_time = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
BIN
Data/funds.db
BIN
Data/funds.db
Binary file not shown.
@@ -7,6 +7,25 @@
|
||||
<h1>GoFundBot</h1>
|
||||
<p>一个有趣的基金分析机器人</p>
|
||||
</div>
|
||||
<!-- 模式切换 -->
|
||||
<div class="header-right">
|
||||
<div class="mode-switch">
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: viewMode === 'detail' }"
|
||||
@click="viewMode = 'detail'"
|
||||
>
|
||||
📋 基金详情
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: viewMode === 'compare' }"
|
||||
@click="viewMode = 'compare'"
|
||||
>
|
||||
📈 基金对比
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -14,11 +33,27 @@
|
||||
<div class="main-layout">
|
||||
<!-- 左侧:自选列表 -->
|
||||
<aside class="sidebar-left">
|
||||
<FundWatchlist @view-fund="handleFundSelected" />
|
||||
<FundWatchlist
|
||||
@view-fund="handleFundSelected"
|
||||
@add-to-compare="handleAddToCompare"
|
||||
:compareMode="viewMode === 'compare'"
|
||||
:compareFunds="compareFunds"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧:搜索和详情 -->
|
||||
<!-- 右侧:根据模式显示不同内容 -->
|
||||
<div class="content-area">
|
||||
<!-- 对比模式 -->
|
||||
<template v-if="viewMode === 'compare'">
|
||||
<FundComparison
|
||||
:compareFunds="compareFunds"
|
||||
@remove-fund="handleRemoveFromCompare"
|
||||
@clear-funds="handleClearCompare"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 详情模式 -->
|
||||
<template v-else>
|
||||
<FundSearch @fund-selected="handleFundSelected" />
|
||||
<FundDetail v-if="selectedFundCode" :fundCode="selectedFundCode" />
|
||||
<div v-else class="welcome">
|
||||
@@ -26,6 +61,7 @@
|
||||
<p>请在搜索框中输入基金代码或名称</p>
|
||||
<p class="welcome-hint">或从左侧自选列表中选择基金开始分析</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -41,22 +77,55 @@ import { ref, onMounted } from 'vue'
|
||||
import FundSearch from './components/FundSearch.vue'
|
||||
import FundDetail from './components/FundDetail.vue'
|
||||
import FundWatchlist from './components/FundWatchlist.vue'
|
||||
import FundComparison from './components/FundComparison.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
FundSearch,
|
||||
FundDetail,
|
||||
FundWatchlist
|
||||
FundWatchlist,
|
||||
FundComparison
|
||||
},
|
||||
setup() {
|
||||
const selectedFundCode = ref('')
|
||||
const currentTime = ref('')
|
||||
const viewMode = ref('detail') // 'detail' 或 'compare'
|
||||
const compareFunds = ref([]) // 用于对比的基金列表
|
||||
|
||||
const handleFundSelected = (fundCode) => {
|
||||
selectedFundCode.value = fundCode
|
||||
}
|
||||
|
||||
// 添加基金到对比列表
|
||||
const handleAddToCompare = (fund) => {
|
||||
// 最多5只基金
|
||||
if (compareFunds.value.length >= 5) {
|
||||
alert('最多只能对比5只基金')
|
||||
return
|
||||
}
|
||||
// 检查是否已存在
|
||||
if (compareFunds.value.some(f => f.code === fund.code)) {
|
||||
// 如果已存在则移除
|
||||
compareFunds.value = compareFunds.value.filter(f => f.code !== fund.code)
|
||||
return
|
||||
}
|
||||
compareFunds.value.push({
|
||||
code: fund.code,
|
||||
name: fund.name
|
||||
})
|
||||
}
|
||||
|
||||
// 从对比列表移除基金
|
||||
const handleRemoveFromCompare = (fundCode) => {
|
||||
compareFunds.value = compareFunds.value.filter(f => f.code !== fundCode)
|
||||
}
|
||||
|
||||
// 清空对比列表
|
||||
const handleClearCompare = () => {
|
||||
compareFunds.value = []
|
||||
}
|
||||
|
||||
// 更新时间
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
@@ -72,7 +141,12 @@ export default {
|
||||
return {
|
||||
selectedFundCode,
|
||||
currentTime,
|
||||
handleFundSelected
|
||||
viewMode,
|
||||
compareFunds,
|
||||
handleFundSelected,
|
||||
handleAddToCompare,
|
||||
handleRemoveFromCompare,
|
||||
handleClearCompare
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +180,9 @@ export default {
|
||||
.header-content {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
@@ -118,6 +195,41 @@ export default {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
max-width: 1600px;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div v-if="fundInfo" class="fund-basic-info">
|
||||
<div class="info-header">
|
||||
<div class="header-left">
|
||||
<div class="header-left-group">
|
||||
<div class="title-row">
|
||||
<h2>{{ fundInfo.name || '未知基金' }}</h2>
|
||||
<span class="fund-code">{{ fundCode }}</span>
|
||||
<!-- 自选按钮 -->
|
||||
@@ -16,6 +17,30 @@
|
||||
<span class="btn-text">{{ isInWatchlist ? '已自选' : '自选' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 风险指标区域 (移至此处) -->
|
||||
<div v-if="riskMetrics" class="risk-metrics-inline">
|
||||
<div class="risk-item">
|
||||
<span class="risk-label">夏普比率(1年)</span>
|
||||
<span class="risk-value" :class="getSharpeClass(riskMetrics.sharpe_ratio_1y)">
|
||||
{{ riskMetrics.sharpe_ratio_1y || '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="risk-item">
|
||||
<span class="risk-label">最大回撤(1年)</span>
|
||||
<span class="risk-value negative">
|
||||
{{ riskMetrics.max_drawdown_1y ? '-' + riskMetrics.max_drawdown_1y + '%' : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="risk-item">
|
||||
<span class="risk-label">年化波动率</span>
|
||||
<span class="risk-value">
|
||||
{{ riskMetrics.volatility_1y ? riskMetrics.volatility_1y + '%' : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="net-worth-box">
|
||||
<div class="label">单位净值</div>
|
||||
@@ -88,6 +113,11 @@ export default {
|
||||
fundData: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
// 新增:接收风险指标
|
||||
riskMetrics: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -205,6 +235,13 @@ export default {
|
||||
const num = parseFloat(value)
|
||||
return num > 0 ? 'positive' : num < 0 ? 'negative' : ''
|
||||
},
|
||||
getSharpeClass(value) {
|
||||
if (!value) return ''
|
||||
const num = parseFloat(value)
|
||||
if (num >= 1) return 'positive'
|
||||
if (num >= 0) return ''
|
||||
return 'negative'
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '--'
|
||||
return dateStr
|
||||
@@ -254,14 +291,21 @@ export default {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
.header-left-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
.title-row h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
@@ -361,6 +405,43 @@ export default {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 风险指标区域 */
|
||||
.risk-metrics-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.risk-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.risk-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.risk-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.risk-value.positive {
|
||||
color: #ffd700;
|
||||
background: rgba(255, 215, 0, 0.2);
|
||||
}
|
||||
|
||||
.risk-value.negative {
|
||||
color: #2ed573;
|
||||
background: rgba(46, 213, 115, 0.2);
|
||||
}
|
||||
|
||||
.info-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
|
||||
@@ -165,7 +165,7 @@ export default {
|
||||
|
||||
const processData = () => {
|
||||
const rawData = (props.netWorthTrend || []).map(item => [item.x, item.y]);
|
||||
const filtered = filterByDate(rawData, selectedRange.value);
|
||||
const filtered = filterByDate(rawData, selectedRange.value).slice().sort((a, b) => a[0] - b[0]);
|
||||
|
||||
if (filtered.length === 0) return { netWorth: [], drawdownInfo: null }
|
||||
|
||||
@@ -400,7 +400,7 @@ export default {
|
||||
label: {
|
||||
offset: [0, 15],
|
||||
formatter: `最大回撤${drawdownInfo.val}%`,
|
||||
backgroundColor: '#00bfa5',
|
||||
backgroundColor: 'rgba(0, 191, 165, 0.7)',
|
||||
position: 'top'
|
||||
}
|
||||
});
|
||||
@@ -412,7 +412,7 @@ export default {
|
||||
label: {
|
||||
offset: [0, -15],
|
||||
formatter: `${drawdownInfo.days}天修复`,
|
||||
backgroundColor: '#ff5252',
|
||||
backgroundColor: 'rgba(255, 82, 82, 0.7)',
|
||||
position: 'bottom'
|
||||
}
|
||||
});
|
||||
|
||||
989
Frontend/src/components/FundComparison.vue
Normal file
989
Frontend/src/components/FundComparison.vue
Normal file
@@ -0,0 +1,989 @@
|
||||
<template>
|
||||
<div class="fund-comparison" v-if="selectedFunds.length > 0 || compareFunds.length > 0">
|
||||
<div class="comparison-header">
|
||||
<h2>
|
||||
<span class="header-icon">📈</span>
|
||||
基金对比
|
||||
<span class="count-badge" v-if="selectedFunds.length">{{ selectedFunds.length }}/{{ maxFunds }}</span>
|
||||
</h2>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn btn-clear"
|
||||
@click="clearSelection"
|
||||
:disabled="selectedFunds.length === 0"
|
||||
>
|
||||
清空对比
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基金选择区域 -->
|
||||
<div class="selection-area">
|
||||
<div class="selection-tags">
|
||||
<div
|
||||
v-for="fund in selectedFunds"
|
||||
:key="fund.code"
|
||||
class="fund-tag"
|
||||
:style="{ borderColor: fund.color }"
|
||||
>
|
||||
<span class="tag-color" :style="{ background: fund.color }"></span>
|
||||
<span class="tag-name">{{ fund.name }}</span>
|
||||
<span class="tag-code">({{ fund.code }})</span>
|
||||
<button class="tag-remove" @click="removeFund(fund.code)">×</button>
|
||||
</div>
|
||||
<div v-if="selectedFunds.length < maxFunds && selectedFunds.length > 0" class="add-fund-hint">
|
||||
<span>👈 继续从左侧添加基金 (还可添加{{ maxFunds - selectedFunds.length }}只)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对比内容区域 -->
|
||||
<div class="comparison-content" v-if="selectedFunds.length >= 2">
|
||||
<!-- 净值走势图表 -->
|
||||
<div class="chart-section">
|
||||
<div class="section-header">
|
||||
<h3>📊 净值走势对比</h3>
|
||||
<div class="time-ranges">
|
||||
<div
|
||||
v-for="range in timeRanges"
|
||||
:key="range.value"
|
||||
class="range-item"
|
||||
:class="{ active: selectedRange === range.value }"
|
||||
@click="setTimeRange(range.value)"
|
||||
>
|
||||
{{ range.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div v-if="loading" class="chart-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>加载数据中...</span>
|
||||
</div>
|
||||
<div ref="chartEl" class="chart-el"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 多维度数据对比表格 -->
|
||||
<div class="data-table-section">
|
||||
<h3>📋 多维度对比</h3>
|
||||
<div class="table-wrapper">
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky-col">指标</th>
|
||||
<th v-for="fund in selectedFunds" :key="fund.code">
|
||||
<div class="fund-header">
|
||||
<span class="color-dot" :style="{ background: fund.color }"></span>
|
||||
<span class="fund-name-th">{{ fund.name }}</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- 收益率指标 -->
|
||||
<tr class="section-row">
|
||||
<td colspan="100%" class="section-title">📈 收益率</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">近3月</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code" :class="getValueClass(fund.returns?.m3)">
|
||||
{{ formatReturn(fund.returns?.m3) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">近6月</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code" :class="getValueClass(fund.returns?.m6)">
|
||||
{{ formatReturn(fund.returns?.m6) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">近1年</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code" :class="getValueClass(fund.returns?.y1)">
|
||||
{{ formatReturn(fund.returns?.y1) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">近3年</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code" :class="getValueClass(fund.returns?.y3)">
|
||||
{{ formatReturn(fund.returns?.y3) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">成立来</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code" :class="getValueClass(fund.returns?.all)">
|
||||
{{ formatReturn(fund.returns?.all) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 基金基本信息 -->
|
||||
<tr class="section-row">
|
||||
<td colspan="100%" class="section-title">💼 基金信息</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">基金规模</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ fund.scale || '--' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">基金类型</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ fund.fundType || '--' }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 风险指标 -->
|
||||
<tr class="section-row">
|
||||
<td colspan="100%" class="section-title">⚠️ 风险指标</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">最大回撤(近1年)</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code" class="negative">
|
||||
{{ formatDrawdown(fund.riskMetrics?.max_drawdown_1y) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">最大回撤(近3年)</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code" class="negative">
|
||||
{{ formatDrawdown(fund.riskMetrics?.max_drawdown_3y) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">夏普比率(近1年)</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code" :class="getSharpeClass(fund.riskMetrics?.sharpe_ratio_1y)">
|
||||
{{ formatSharpe(fund.riskMetrics?.sharpe_ratio_1y) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">夏普比率(近3年)</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code" :class="getSharpeClass(fund.riskMetrics?.sharpe_ratio_3y)">
|
||||
{{ formatSharpe(fund.riskMetrics?.sharpe_ratio_3y) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">年化波动率(近1年)</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ formatVolatility(fund.riskMetrics?.volatility_1y) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 评分指标 -->
|
||||
<tr class="section-row">
|
||||
<td colspan="100%" class="section-title">⭐ 评分指标</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">综合评分</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
<span class="score-badge" :class="getScoreClass(fund.evaluation?.avgScore)">
|
||||
{{ fund.evaluation?.avgScore ?? '--' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">选证能力</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ getEvalScore(fund, 0) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">收益率</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ getEvalScore(fund, 1) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">抗风险</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ getEvalScore(fund, 2) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">稳定性</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ getEvalScore(fund, 3) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">择时能力</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ getEvalScore(fund, 4) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 基金经理 -->
|
||||
<tr class="section-row">
|
||||
<td colspan="100%" class="section-title">👤 基金经理</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">基金经理</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ fund.manager?.name || '--' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">从业经验</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ fund.manager?.experience || '--' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">管理规模</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
{{ fund.manager?.managedSize || '--' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="sticky-col">经理评分</td>
|
||||
<td v-for="fund in selectedFunds" :key="fund.code">
|
||||
<span v-if="fund.manager?.avgScore" class="score-badge" :class="getScoreClass(fund.manager.avgScore)">
|
||||
{{ fund.manager.avgScore }}
|
||||
</span>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单只基金提示 -->
|
||||
<div v-else-if="selectedFunds.length === 1" class="single-fund-hint">
|
||||
<p>👈 请再选择至少1只基金进行对比</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { fundAPI } from '../services/api'
|
||||
|
||||
export default {
|
||||
name: 'FundComparison',
|
||||
props: {
|
||||
compareFunds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['remove-fund', 'clear-funds'],
|
||||
setup(props, { emit }) {
|
||||
const chartEl = ref(null)
|
||||
const selectedRange = ref('1y')
|
||||
const loading = ref(false)
|
||||
const maxFunds = 5
|
||||
let chartInstance = null
|
||||
|
||||
const colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4']
|
||||
|
||||
const timeRanges = [
|
||||
{ label: '近3月', value: '3m' },
|
||||
{ label: '近6月', value: '6m' },
|
||||
{ label: '近1年', value: '1y' },
|
||||
{ label: '近3年', value: '3y' },
|
||||
{ label: '成立来', value: 'all' }
|
||||
]
|
||||
|
||||
const selectedFunds = ref([])
|
||||
|
||||
// 获取基金对比数据(使用缓存API)
|
||||
const fetchFundCompareData = async (fundCode) => {
|
||||
try {
|
||||
const response = await fundAPI.getFundCompareData(fundCode)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error(`获取基金 ${fundCode} 对比数据失败:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 将后端数据转换为统一格式 {x: timestamp, y: value}
|
||||
const normalizeData = (data) => {
|
||||
if (!data || data.length === 0) return []
|
||||
return data.map(item => ({
|
||||
x: new Date(item.date).getTime(),
|
||||
y: item.net_worth
|
||||
})).filter(item => !isNaN(item.x) && item.y !== null && item.y !== undefined)
|
||||
}
|
||||
|
||||
// 计算收益率
|
||||
const calculateReturns = (trendData) => {
|
||||
if (!trendData || trendData.length === 0) return {}
|
||||
|
||||
const sortedData = [...trendData].sort((a, b) => a.x - b.x)
|
||||
const latestValue = sortedData[sortedData.length - 1].y
|
||||
const now = Date.now()
|
||||
|
||||
const getReturnForPeriod = (months) => {
|
||||
let targetTime
|
||||
if (months === 'all') {
|
||||
targetTime = sortedData[0].x
|
||||
} else {
|
||||
const date = new Date(now)
|
||||
date.setMonth(date.getMonth() - months)
|
||||
targetTime = date.getTime()
|
||||
}
|
||||
|
||||
let closest = sortedData[0]
|
||||
for (const item of sortedData) {
|
||||
if (item.x >= targetTime) {
|
||||
closest = item
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (closest.y === 0) return null
|
||||
return ((latestValue - closest.y) / closest.y * 100).toFixed(2)
|
||||
}
|
||||
|
||||
return {
|
||||
m3: getReturnForPeriod(3),
|
||||
m6: getReturnForPeriod(6),
|
||||
y1: getReturnForPeriod(12),
|
||||
y3: getReturnForPeriod(36),
|
||||
all: getReturnForPeriod('all')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载基金完整数据(使用缓存API)
|
||||
const loadFundData = async (fund) => {
|
||||
// 获取对比数据(包含走势、详情、风险指标)
|
||||
const data = await fetchFundCompareData(fund.code)
|
||||
|
||||
if (!data) return fund
|
||||
|
||||
// 处理走势数据
|
||||
if (data.net_worth_trend && data.net_worth_trend.length > 0) {
|
||||
fund.trendData = normalizeData(data.net_worth_trend)
|
||||
fund.returns = calculateReturns(fund.trendData)
|
||||
}
|
||||
|
||||
// 基本信息
|
||||
const basicInfo = data.basic_info || {}
|
||||
fund.fundType = basicInfo.fund_type || '--'
|
||||
|
||||
// 规模波动数据 - 结构是 series: [{y: value, mom: "..."}, ...]
|
||||
const scaleData = data.scale_fluctuation || {}
|
||||
if (scaleData.series && scaleData.series.length > 0) {
|
||||
const latestItem = scaleData.series[scaleData.series.length - 1]
|
||||
if (latestItem && latestItem.y !== undefined && latestItem.y !== null) {
|
||||
fund.scale = latestItem.y.toFixed(2) + '亿'
|
||||
} else {
|
||||
fund.scale = '--'
|
||||
}
|
||||
}
|
||||
|
||||
// 风险指标
|
||||
fund.riskMetrics = data.risk_metrics || {}
|
||||
|
||||
// 数据来源标记
|
||||
fund.dataSource = data.data_source || 'unknown'
|
||||
fund.cacheTime = data.cache_time
|
||||
|
||||
// 基金评价数据 - categories: ["选证能力","收益率","抗风险","稳定性","择时能力"]
|
||||
const evalData = data.performance_evaluation || {}
|
||||
fund.evaluation = {
|
||||
avgScore: evalData.avr || null,
|
||||
data: evalData.data || [],
|
||||
categories: evalData.categories || []
|
||||
}
|
||||
|
||||
// 基金经理信息
|
||||
const managers = data.fund_managers || []
|
||||
if (managers.length > 0) {
|
||||
const manager = managers[0]
|
||||
fund.manager = {
|
||||
name: manager.name || '--',
|
||||
experience: manager.work_experience || '--',
|
||||
managedSize: manager.managed_fund_size || '--',
|
||||
avgScore: manager.ability_assessment?.average_score || null
|
||||
}
|
||||
}
|
||||
|
||||
return fund
|
||||
}
|
||||
|
||||
// 过滤数据按时间范围
|
||||
const filterByDate = (data, range) => {
|
||||
if (!data || data.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
let startDate = new Date(0)
|
||||
|
||||
if (range === '3m') {
|
||||
startDate = new Date(now.getTime())
|
||||
startDate.setMonth(startDate.getMonth() - 3)
|
||||
} else if (range === '6m') {
|
||||
startDate = new Date(now.getTime())
|
||||
startDate.setMonth(startDate.getMonth() - 6)
|
||||
} else if (range === '1y') {
|
||||
startDate = new Date(now.getTime())
|
||||
startDate.setFullYear(startDate.getFullYear() - 1)
|
||||
} else if (range === '3y') {
|
||||
startDate = new Date(now.getTime())
|
||||
startDate.setFullYear(startDate.getFullYear() - 3)
|
||||
}
|
||||
|
||||
return data.filter(item => item.x >= startDate.getTime())
|
||||
}
|
||||
|
||||
// 转换为百分比变化
|
||||
const toPercentChange = (data) => {
|
||||
if (!data || data.length === 0) return []
|
||||
const sortedData = [...data].sort((a, b) => a.x - b.x)
|
||||
const startVal = sortedData[0].y
|
||||
if (startVal === 0) return []
|
||||
|
||||
return sortedData.map(item => [
|
||||
item.x,
|
||||
parseFloat(((item.y - startVal) / startVal * 100).toFixed(2))
|
||||
])
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
const initChart = () => {
|
||||
if (!chartEl.value) return
|
||||
|
||||
const rect = chartEl.value.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
setTimeout(initChart, 100)
|
||||
return
|
||||
}
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
}
|
||||
chartInstance = echarts.init(chartEl.value)
|
||||
updateChart()
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
const updateChart = () => {
|
||||
if (!chartInstance || selectedFunds.value.length < 2) return
|
||||
|
||||
const series = []
|
||||
|
||||
selectedFunds.value.forEach((fund) => {
|
||||
if (!fund.trendData || fund.trendData.length === 0) return
|
||||
|
||||
const filteredData = filterByDate(fund.trendData, selectedRange.value)
|
||||
const percentData = toPercentChange(filteredData)
|
||||
|
||||
if (percentData.length > 0) {
|
||||
series.push({
|
||||
name: fund.name,
|
||||
type: 'line',
|
||||
data: percentData,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: fund.color },
|
||||
itemStyle: { color: fund.color }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (series.length === 0) return
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function (params) {
|
||||
let res = '<div style="font-weight:bold;margin-bottom:5px;">' +
|
||||
echarts.format.formatTime('yyyy-MM-dd', params[0].value[0]) + '</div>'
|
||||
params.forEach(item => {
|
||||
const val = item.value[1]
|
||||
const color = val >= 0 ? '#f5222d' : '#52c41a'
|
||||
res += `<div style="display:flex;align-items:center;margin:3px 0;">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px;"></span>
|
||||
<span style="flex:1;">${item.seriesName}</span>
|
||||
<span style="font-weight:bold;color:${color};margin-left:10px;">${val >= 0 ? '+' : ''}${val}%</span>
|
||||
</div>`
|
||||
})
|
||||
return res
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
top: 5,
|
||||
data: selectedFunds.value.map(f => f.name),
|
||||
textStyle: { fontSize: 12 }
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: 40,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
boundaryGap: false,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { formatter: '{MM}-{dd}' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
splitLine: { lineStyle: { type: 'dashed' } },
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
},
|
||||
series: series
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const setTimeRange = (range) => {
|
||||
selectedRange.value = range
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const removeFund = (fundCode) => {
|
||||
emit('remove-fund', fundCode)
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
emit('clear-funds')
|
||||
}
|
||||
|
||||
const getValueClass = (value) => {
|
||||
if (value === null || value === undefined || value === '--') return ''
|
||||
const num = parseFloat(value)
|
||||
if (num > 0) return 'positive'
|
||||
if (num < 0) return 'negative'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getSharpClass = (value) => {
|
||||
if (value === null || value === undefined) return ''
|
||||
const num = parseFloat(value)
|
||||
if (num >= 1) return 'positive'
|
||||
if (num < 0) return 'negative'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getScoreClass = (score) => {
|
||||
if (!score) return ''
|
||||
if (score >= 80) return 'score-high'
|
||||
if (score >= 60) return 'score-mid'
|
||||
return 'score-low'
|
||||
}
|
||||
|
||||
const formatReturn = (value) => {
|
||||
if (value === null || value === undefined) return '--'
|
||||
const num = parseFloat(value)
|
||||
return (num >= 0 ? '+' : '') + value + '%'
|
||||
}
|
||||
|
||||
// 格式化最大回撤
|
||||
const formatDrawdown = (value) => {
|
||||
if (value === null || value === undefined) return '--'
|
||||
return '-' + value.toFixed(2) + '%'
|
||||
}
|
||||
|
||||
// 格式化夏普比率
|
||||
const formatSharpe = (value) => {
|
||||
if (value === null || value === undefined) return '--'
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
// 格式化波动率
|
||||
const formatVolatility = (value) => {
|
||||
if (value === null || value === undefined) return '--'
|
||||
return value.toFixed(2) + '%'
|
||||
}
|
||||
|
||||
// 夏普比率样式类
|
||||
const getSharpeClass = (value) => {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (value >= 1) return 'positive'
|
||||
if (value >= 0) return ''
|
||||
return 'negative'
|
||||
}
|
||||
|
||||
const getEvalScore = (fund, index) => {
|
||||
if (!fund.evaluation?.data || !fund.evaluation.data[index]) return '--'
|
||||
return fund.evaluation.data[index].toFixed(1)
|
||||
}
|
||||
|
||||
// 监听比较基金列表变化
|
||||
watch(() => props.compareFunds, async (newFunds) => {
|
||||
if (!newFunds || newFunds.length === 0) {
|
||||
selectedFunds.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
const updatedFunds = []
|
||||
for (let i = 0; i < newFunds.length; i++) {
|
||||
const fund = newFunds[i]
|
||||
const existing = selectedFunds.value.find(f => f.code === fund.code)
|
||||
|
||||
if (existing) {
|
||||
existing.color = colors[i % colors.length]
|
||||
updatedFunds.push(existing)
|
||||
} else {
|
||||
const newFund = {
|
||||
code: fund.code,
|
||||
name: fund.name,
|
||||
color: colors[i % colors.length],
|
||||
trendData: null,
|
||||
returns: {},
|
||||
evaluation: {},
|
||||
manager: null,
|
||||
scale: '--',
|
||||
fundType: '--',
|
||||
riskMetrics: {},
|
||||
dataSource: null,
|
||||
cacheTime: null
|
||||
}
|
||||
await loadFundData(newFund)
|
||||
updatedFunds.push(newFund)
|
||||
}
|
||||
}
|
||||
|
||||
selectedFunds.value = updatedFunds
|
||||
loading.value = false
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (selectedFunds.value.length >= 2) {
|
||||
setTimeout(() => initChart(), 50)
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
watch(selectedRange, () => updateChart())
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', () => chartInstance?.resize())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) chartInstance.dispose()
|
||||
window.removeEventListener('resize', () => chartInstance?.resize())
|
||||
})
|
||||
|
||||
return {
|
||||
chartEl,
|
||||
selectedFunds,
|
||||
selectedRange,
|
||||
timeRanges,
|
||||
loading,
|
||||
maxFunds,
|
||||
setTimeRange,
|
||||
removeFund,
|
||||
clearSelection,
|
||||
getValueClass,
|
||||
getSharpClass,
|
||||
getScoreClass,
|
||||
formatReturn,
|
||||
formatDrawdown,
|
||||
formatSharpe,
|
||||
formatVolatility,
|
||||
getSharpeClass,
|
||||
getEvalScore
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fund-comparison {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comparison-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: linear-gradient(135deg, #f8f9ff 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.comparison-header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-icon { font-size: 18px; }
|
||||
|
||||
.count-badge {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-clear {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-clear:hover:not(:disabled) { background: #e8e8e8; }
|
||||
|
||||
.selection-area {
|
||||
padding: 12px 20px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.selection-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.fund-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: white;
|
||||
border: 2px solid;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tag-color {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
color: #333;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-code { color: #999; font-size: 12px; }
|
||||
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.tag-remove:hover { color: #f5222d; }
|
||||
|
||||
.add-fund-hint { color: #999; font-size: 13px; }
|
||||
|
||||
.single-fund-hint {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.comparison-content { padding: 0; }
|
||||
|
||||
.chart-section {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.time-ranges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.range-item {
|
||||
padding: 4px 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.range-item:hover { background: #f5f5f5; }
|
||||
|
||||
.range-item.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.chart-el { width: 100%; height: 100%; }
|
||||
|
||||
.chart-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #f0f0f0;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.data-table-section {
|
||||
padding: 15px 20px 20px;
|
||||
}
|
||||
|
||||
.data-table-section h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
background: #fafafa;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: white;
|
||||
text-align: left !important;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
min-width: 90px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.section-row td {
|
||||
background: #f8f9ff !important;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
text-align: left !important;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.section-title { font-size: 12px; }
|
||||
|
||||
.fund-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fund-name-th {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.positive { color: #f5222d; font-weight: 500; }
|
||||
.negative { color: #52c41a; font-weight: 500; }
|
||||
|
||||
.score-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.score-high { background: #fff1f0; color: #f5222d; }
|
||||
.score-mid { background: #fff7e6; color: #fa8c16; }
|
||||
.score-low { background: #f6ffed; color: #52c41a; }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.comparison-table { font-size: 12px; }
|
||||
.comparison-table th, .comparison-table td { padding: 8px 8px; }
|
||||
.tag-name { max-width: 60px; }
|
||||
.section-header { flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="fund-detail">
|
||||
<!-- 基金基础信息组件 -->
|
||||
<FundBasicInfo :fundCode="currentFundCode" :fundData="fundDetail" />
|
||||
<FundBasicInfo :fundCode="currentFundCode" :fundData="fundDetail" :riskMetrics="riskMetrics" />
|
||||
|
||||
<!-- 主要内容区域 - Dashboard 布局 -->
|
||||
<div v-if="fundDetail" class="dashboard">
|
||||
@@ -192,6 +192,128 @@ export default {
|
||||
const modalVisible = ref(false)
|
||||
const modalType = ref('')
|
||||
|
||||
// 计算风险指标
|
||||
const riskMetrics = computed(() => {
|
||||
if (!fundDetail.value?.net_worth_trend || fundDetail.value.net_worth_trend.length < 30) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const trend = fundDetail.value.net_worth_trend
|
||||
const sortedData = [...trend].sort((a, b) => {
|
||||
const dateA = new Date(a.date).getTime()
|
||||
const dateB = new Date(b.date).getTime()
|
||||
return dateA - dateB
|
||||
})
|
||||
|
||||
// 转换为净值数组
|
||||
const values = sortedData.map(item => parseFloat(item.net_worth)).filter(v => !isNaN(v))
|
||||
const dates = sortedData.map(item => item.date)
|
||||
|
||||
if (values.length < 30) return null
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// 获取指定时间段的数据
|
||||
const getDataForPeriod = (months) => {
|
||||
const cutoffDate = new Date(now)
|
||||
cutoffDate.setMonth(cutoffDate.getMonth() - months)
|
||||
const cutoffStr = cutoffDate.toISOString().split('T')[0]
|
||||
|
||||
const periodValues = []
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
if (dates[i] >= cutoffStr) {
|
||||
periodValues.push(values[i])
|
||||
}
|
||||
}
|
||||
return periodValues
|
||||
}
|
||||
|
||||
// 计算最大回撤
|
||||
const calcMaxDrawdown = (periodValues) => {
|
||||
if (periodValues.length < 2) return null
|
||||
|
||||
let peak = periodValues[0]
|
||||
let maxDrawdown = 0
|
||||
|
||||
for (const value of periodValues) {
|
||||
if (value > peak) peak = value
|
||||
const drawdown = (peak - value) / peak * 100
|
||||
if (drawdown > maxDrawdown) maxDrawdown = drawdown
|
||||
}
|
||||
|
||||
return maxDrawdown.toFixed(2)
|
||||
}
|
||||
|
||||
// 计算日收益率
|
||||
const calcDailyReturns = (periodValues) => {
|
||||
if (periodValues.length < 2) return []
|
||||
const returns = []
|
||||
for (let i = 1; i < periodValues.length; i++) {
|
||||
if (periodValues[i-1] !== 0) {
|
||||
returns.push((periodValues[i] - periodValues[i-1]) / periodValues[i-1])
|
||||
}
|
||||
}
|
||||
return returns
|
||||
}
|
||||
|
||||
// 计算年化收益率
|
||||
const calcAnnualReturn = (periodValues, tradingDays) => {
|
||||
if (periodValues.length < 2 || periodValues[0] === 0 || tradingDays <= 0) return null
|
||||
const totalReturn = (periodValues[periodValues.length - 1] - periodValues[0]) / periodValues[0]
|
||||
const annualReturn = (Math.pow(1 + totalReturn, 252 / tradingDays) - 1) * 100
|
||||
return annualReturn.toFixed(2)
|
||||
}
|
||||
|
||||
// 计算年化波动率
|
||||
const calcVolatility = (dailyReturns) => {
|
||||
if (dailyReturns.length < 10) return null
|
||||
const mean = dailyReturns.reduce((a, b) => a + b, 0) / dailyReturns.length
|
||||
const variance = dailyReturns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / dailyReturns.length
|
||||
const dailyVol = Math.sqrt(variance)
|
||||
const annualVol = dailyVol * Math.sqrt(252) * 100
|
||||
return annualVol.toFixed(2)
|
||||
}
|
||||
|
||||
// 计算夏普比率(无风险利率2%)
|
||||
const calcSharpeRatio = (annualReturn, volatility) => {
|
||||
if (!annualReturn || !volatility || parseFloat(volatility) === 0) return null
|
||||
const sharpe = (parseFloat(annualReturn) - 2.0) / parseFloat(volatility)
|
||||
return sharpe.toFixed(2)
|
||||
}
|
||||
|
||||
// 计算近1年指标
|
||||
const values1y = getDataForPeriod(12)
|
||||
const dailyReturns1y = calcDailyReturns(values1y)
|
||||
const annualReturn1y = calcAnnualReturn(values1y, values1y.length)
|
||||
const volatility1y = calcVolatility(dailyReturns1y)
|
||||
const sharpe1y = calcSharpeRatio(annualReturn1y, volatility1y)
|
||||
const maxDrawdown1y = calcMaxDrawdown(values1y)
|
||||
|
||||
// 计算近3年指标
|
||||
const values3y = getDataForPeriod(36)
|
||||
const dailyReturns3y = calcDailyReturns(values3y)
|
||||
const annualReturn3y = calcAnnualReturn(values3y, values3y.length)
|
||||
const volatility3y = calcVolatility(dailyReturns3y)
|
||||
const sharpe3y = calcSharpeRatio(annualReturn3y, volatility3y)
|
||||
const maxDrawdown3y = calcMaxDrawdown(values3y)
|
||||
|
||||
return {
|
||||
sharpe_ratio_1y: sharpe1y,
|
||||
sharpe_ratio_3y: sharpe3y,
|
||||
max_drawdown_1y: maxDrawdown1y,
|
||||
max_drawdown_3y: maxDrawdown3y,
|
||||
volatility_1y: volatility1y,
|
||||
volatility_3y: volatility3y,
|
||||
annual_return_1y: annualReturn1y,
|
||||
annual_return_3y: annualReturn3y
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('计算风险指标错误:', e)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// 打开模态框
|
||||
const openModal = (type) => {
|
||||
modalType.value = type
|
||||
@@ -320,6 +442,7 @@ export default {
|
||||
error,
|
||||
processedNetWorthTrend,
|
||||
processedAcWorthTrend,
|
||||
riskMetrics,
|
||||
retry,
|
||||
modalVisible,
|
||||
modalType,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
class="list-item"
|
||||
:class="{
|
||||
'selected': selectedFunds.includes(fund.fund_code),
|
||||
'dragging': isDragging(index)
|
||||
'dragging': isDragging(index),
|
||||
'in-compare': isInCompare(fund.fund_code)
|
||||
}"
|
||||
:draggable="editMode"
|
||||
@dragstart="$emit('drag-start', $event, index, groupId)"
|
||||
@@ -28,8 +29,20 @@
|
||||
<span class="drag-handle">⋮⋮</span>
|
||||
</div>
|
||||
|
||||
<!-- 对比模式:添加按钮 -->
|
||||
<div class="col-compare" v-if="compareMode && !editMode">
|
||||
<button
|
||||
class="btn-compare"
|
||||
:class="{ 'in-compare': isInCompare(fund.fund_code) }"
|
||||
@click.stop="$emit('add-to-compare', { code: fund.fund_code, name: fund.fund_name })"
|
||||
:title="isInCompare(fund.fund_code) ? '已添加到对比' : '添加到对比'"
|
||||
>
|
||||
{{ isInCompare(fund.fund_code) ? '✓' : '+' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 基金名称/代码 -->
|
||||
<div class="col-name" @click="!editMode && $emit('view-fund', fund.fund_code)">
|
||||
<div class="col-name" @click="!editMode && !compareMode && $emit('view-fund', fund.fund_code)">
|
||||
<div class="fund-name">{{ fund.fund_name }}</div>
|
||||
<div class="fund-code">{{ fund.fund_code }}</div>
|
||||
</div>
|
||||
@@ -46,7 +59,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="col-action" v-if="!editMode">
|
||||
<div class="col-action" v-if="!editMode && !compareMode">
|
||||
<button class="btn-icon btn-remove" @click.stop="$emit('remove-fund', fund.fund_code)" title="移除">
|
||||
✕
|
||||
</button>
|
||||
@@ -63,15 +76,20 @@ export default {
|
||||
editMode: { type: Boolean, default: false },
|
||||
selectedFunds: { type: Array, default: () => [] },
|
||||
draggingIndex: { type: Object, default: null },
|
||||
groupId: { type: [Number, null], default: null }
|
||||
groupId: { type: [Number, null], default: null },
|
||||
compareMode: { type: Boolean, default: false },
|
||||
compareFunds: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['toggle-select', 'view-fund', 'remove-fund', 'drag-start', 'drag-end', 'drag-over', 'drop'],
|
||||
emits: ['toggle-select', 'view-fund', 'remove-fund', 'drag-start', 'drag-end', 'drag-over', 'drop', 'add-to-compare'],
|
||||
methods: {
|
||||
isDragging(index) {
|
||||
return this.draggingIndex &&
|
||||
this.draggingIndex.index === index &&
|
||||
this.draggingIndex.groupId === this.groupId
|
||||
},
|
||||
isInCompare(fundCode) {
|
||||
return this.compareFunds && this.compareFunds.some(f => f.code === fundCode)
|
||||
},
|
||||
formatChange(change) {
|
||||
if (!change && change !== 0) return '--'
|
||||
const num = parseFloat(change)
|
||||
@@ -113,6 +131,7 @@ export default {
|
||||
|
||||
.col-checkbox { width: 24px; flex-shrink: 0; }
|
||||
.col-drag { width: 20px; flex-shrink: 0; }
|
||||
.col-compare { width: 28px; flex-shrink: 0; }
|
||||
.col-name { flex: 1; min-width: 0; overflow: hidden; }
|
||||
.col-nav { width: 65px; flex-shrink: 0; text-align: right; }
|
||||
.col-change { width: 60px; flex-shrink: 0; text-align: right; font-weight: 600; font-size: 12px; }
|
||||
@@ -140,6 +159,36 @@ export default {
|
||||
.drag-handle { cursor: grab; color: #9ca3af; font-size: 14px; user-select: none; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.btn-compare {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-compare:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-compare.in-compare {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.list-item.in-compare {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
:selectedFunds="selectedFunds"
|
||||
:draggingIndex="draggingIndex"
|
||||
:groupId="null"
|
||||
:compareMode="compareMode"
|
||||
:compareFunds="compareFunds"
|
||||
@toggle-select="toggleSelect"
|
||||
@view-fund="viewFundDetail"
|
||||
@remove-fund="removeFund"
|
||||
@@ -72,6 +74,7 @@
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-to-compare="addToCompare"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,6 +103,8 @@
|
||||
:selectedFunds="selectedFunds"
|
||||
:draggingIndex="draggingIndex"
|
||||
:groupId="group.id"
|
||||
:compareMode="compareMode"
|
||||
:compareFunds="compareFunds"
|
||||
@toggle-select="toggleSelect"
|
||||
@view-fund="viewFundDetail"
|
||||
@remove-fund="removeFund"
|
||||
@@ -107,6 +112,7 @@
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-to-compare="addToCompare"
|
||||
/>
|
||||
<div v-if="getGroupFunds(group.id).length === 0" class="group-empty">
|
||||
暂无基金,拖拽基金到此分组
|
||||
@@ -139,14 +145,18 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick, toRef } from 'vue'
|
||||
import { watchlistAPI } from '../services/api'
|
||||
import FundListItems from './FundListItems.vue'
|
||||
|
||||
export default {
|
||||
name: 'FundWatchlist',
|
||||
components: { FundListItems },
|
||||
emits: ['view-fund'],
|
||||
props: {
|
||||
compareMode: { type: Boolean, default: false },
|
||||
compareFunds: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['view-fund', 'add-to-compare'],
|
||||
setup(props, { emit }) {
|
||||
const watchlist = ref([])
|
||||
const groups = ref([])
|
||||
@@ -156,6 +166,7 @@ export default {
|
||||
const draggingIndex = ref(null)
|
||||
const dragOverIndex = ref(null)
|
||||
const expandedGroups = ref([null])
|
||||
const isInitialLoad = ref(true)
|
||||
|
||||
// 分组弹窗
|
||||
const showGroupModal = ref(false)
|
||||
@@ -185,8 +196,15 @@ export default {
|
||||
const response = await watchlistAPI.getWatchlist()
|
||||
watchlist.value = response.data.data || []
|
||||
groups.value = response.data.groups || []
|
||||
// 默认展开所有分组
|
||||
// 仅首次加载时展开所有分组,后续刷新保持用户的折叠状态
|
||||
if (isInitialLoad.value) {
|
||||
expandedGroups.value = [null, ...groups.value.map(g => g.id)]
|
||||
isInitialLoad.value = false
|
||||
} else {
|
||||
// 移除已删除分组的展开状态,添加新分组到展开列表
|
||||
const validGroupIds = new Set([null, ...groups.value.map(g => g.id)])
|
||||
expandedGroups.value = expandedGroups.value.filter(id => validGroupIds.has(id))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载自选列表失败:', error)
|
||||
} finally {
|
||||
@@ -261,6 +279,11 @@ export default {
|
||||
emit('view-fund', fundCode)
|
||||
}
|
||||
|
||||
// 添加到对比
|
||||
const addToCompare = (fund) => {
|
||||
emit('add-to-compare', fund)
|
||||
}
|
||||
|
||||
// 拖拽排序
|
||||
const onDragStart = (event, index, groupId) => {
|
||||
draggingIndex.value = { index, groupId }
|
||||
@@ -385,7 +408,11 @@ export default {
|
||||
if (editingGroup.value) {
|
||||
await watchlistAPI.renameGroup(editingGroup.value.id, name)
|
||||
} else {
|
||||
await watchlistAPI.createGroup(name)
|
||||
const response = await watchlistAPI.createGroup(name)
|
||||
// 新创建的分组自动展开
|
||||
if (response.data && response.data.id) {
|
||||
expandedGroups.value.push(response.data.id)
|
||||
}
|
||||
}
|
||||
closeGroupModal()
|
||||
loadWatchlist()
|
||||
@@ -425,6 +452,8 @@ export default {
|
||||
editingGroup,
|
||||
groupName,
|
||||
groupNameInput,
|
||||
compareMode: toRef(props, 'compareMode'),
|
||||
compareFunds: toRef(props, 'compareFunds'),
|
||||
loadWatchlist,
|
||||
refreshWatchlist,
|
||||
toggleGroup,
|
||||
@@ -434,6 +463,7 @@ export default {
|
||||
batchDelete,
|
||||
removeFund,
|
||||
viewFundDetail,
|
||||
addToCompare,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
|
||||
@@ -38,6 +38,12 @@ export const fundAPI = {
|
||||
// 获取基金走势数据
|
||||
getFundTrend(fundCode) {
|
||||
return api.get(`/fund/${fundCode}/trend`)
|
||||
},
|
||||
|
||||
// 获取基金对比数据(带缓存,包含风险指标)
|
||||
getFundCompareData(fundCode, forceRefresh = false) {
|
||||
const params = forceRefresh ? '?refresh=true' : ''
|
||||
return api.get(`/fund/${fundCode}/compare-data${params}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user