添加了基金对比功能

This commit is contained in:
Sebastian
2026-01-17 23:28:55 +08:00
parent b4495fb6c0
commit b6eb771007
12 changed files with 1831 additions and 59 deletions

View File

@@ -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)

View File

@@ -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)

Binary file not shown.

View File

@@ -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;

View File

@@ -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));

View File

@@ -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'
}
});

View 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>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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}`)
}
}