美化了可视化界面
This commit is contained in:
@@ -33,19 +33,9 @@ def get_fund_detail(fund_code):
|
||||
if not fund_code:
|
||||
return jsonify({"error": "Fund code is required"}), 400
|
||||
|
||||
# 首先检查数据库是否有缓存
|
||||
db = next(get_db())
|
||||
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
|
||||
|
||||
if cached_fund:
|
||||
# 返回缓存数据
|
||||
try:
|
||||
data = json.loads(cached_fund.data_json)
|
||||
return jsonify(data)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 从API获取数据
|
||||
# 直接从API获取最新数据
|
||||
detail_data = fund_api.get_fund_detail(fund_code)
|
||||
basic_info = fund_api.get_fund_basic_info(fund_code)
|
||||
|
||||
@@ -53,7 +43,10 @@ def get_fund_detail(fund_code):
|
||||
# 合并数据
|
||||
detail_data['basic_info'] = basic_info
|
||||
|
||||
# 缓存到数据库或更新现有记录
|
||||
# 检查数据库是否存在记录
|
||||
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
|
||||
|
||||
# 更新或新增记录
|
||||
if cached_fund:
|
||||
cached_fund.data_json = json.dumps(detail_data, ensure_ascii=False)
|
||||
cached_fund.net_worth_trend = json.dumps(detail_data.get('net_worth_trend', []), ensure_ascii=False)
|
||||
@@ -67,11 +60,24 @@ def get_fund_detail(fund_code):
|
||||
)
|
||||
db.add(fund_detail)
|
||||
|
||||
db.commit()
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
print(f"Error saving to database: {e}")
|
||||
db.rollback()
|
||||
|
||||
return jsonify(detail_data)
|
||||
else:
|
||||
return jsonify({"error": "Fund not found"}), 404
|
||||
|
||||
# 如果API获取失败,尝试从数据库获取缓存数据作为兜底
|
||||
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
|
||||
if cached_fund:
|
||||
try:
|
||||
data = json.loads(cached_fund.data_json)
|
||||
return jsonify(data)
|
||||
except:
|
||||
pass
|
||||
|
||||
return jsonify({"error": "Fund not found"}), 404
|
||||
|
||||
@app.route('/api/fund/<fund_code>/basic', methods=['GET'])
|
||||
def get_fund_basic(fund_code):
|
||||
|
||||
BIN
Data/funds.db
BIN
Data/funds.db
Binary file not shown.
@@ -10,18 +10,20 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>资产类型</th>
|
||||
<th v-for="(date, index) in categories" :key="index">{{ date }}</th>
|
||||
<th style="min-width: 100px;">时间</th>
|
||||
<th v-for="(serie, index) in series" :key="index" style="text-align: center; min-width: 100px;">
|
||||
<div class="type-cell" style="justify-content: center;">
|
||||
<span class="type-dot" :style="{ background: getColor(index) }"></span>
|
||||
{{ serie.name }}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(serie, index) in series" :key="index">
|
||||
<td class="type-cell">
|
||||
<span class="type-dot" :style="{ background: getColor(index) }"></span>
|
||||
{{ serie.name }}
|
||||
</td>
|
||||
<td v-for="(value, idx) in serie.data" :key="idx" class="value-cell">
|
||||
{{ formatValue(value, serie.name) }}
|
||||
<tr v-for="(date, dateIndex) in categories" :key="dateIndex">
|
||||
<td style="font-weight: bold;">{{ date }}</td>
|
||||
<td v-for="(serie, index) in series" :key="index" class="value-cell">
|
||||
{{ formatValue(serie.data[dateIndex], serie.name) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -86,6 +88,11 @@ export default {
|
||||
data: serie.data,
|
||||
itemStyle: {
|
||||
color: getColor(index)
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: (p) => p.value > 5 ? p.value + '%' : '' // Show label if wide enough
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -94,13 +101,13 @@ export default {
|
||||
const lineSeries = netAssetSerie ? [{
|
||||
name: '净资产',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
yAxisIndex: 1, // 使用右侧Y轴
|
||||
data: netAssetSerie.data,
|
||||
itemStyle: {
|
||||
color: '#ee6666'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 3
|
||||
width: 3
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 8
|
||||
@@ -126,34 +133,34 @@ export default {
|
||||
},
|
||||
legend: {
|
||||
data: series.value.map(s => s.name),
|
||||
bottom: 0
|
||||
bottom: 0,
|
||||
type: 'scroll'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
top: '10%',
|
||||
right: '5%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories.value
|
||||
data: categories.value,
|
||||
boundaryGap: true
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '占净值比(%)',
|
||||
axisLabel: {
|
||||
formatter: '{value}%'
|
||||
{
|
||||
type: 'value',
|
||||
axisLabel: { formatter: '{value}%' },
|
||||
splitLine: { show: true }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '净资产(亿)',
|
||||
position: 'right',
|
||||
axisLabel: { formatter: '{value}' },
|
||||
splitLine: { show: false }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '净资产(亿)',
|
||||
axisLabel: {
|
||||
formatter: '{value}亿'
|
||||
}
|
||||
}
|
||||
],
|
||||
series: [...barSeries, ...lineSeries]
|
||||
}
|
||||
|
||||
@@ -235,11 +235,11 @@ export default {
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #52c41a;
|
||||
color: #ff6b6b; /* 上涨显示红色 */
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #ff4d4f;
|
||||
color: #2ed573; /* 下跌显示绿色 */
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
>
|
||||
业绩走势
|
||||
</div>
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'comparison' }"
|
||||
@click="switchTab('comparison')"
|
||||
>
|
||||
收益对比
|
||||
</div>
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'drawdown' }"
|
||||
@@ -24,10 +31,9 @@
|
||||
<br>
|
||||
<span class="value" :class="getColor(fundChange)">{{ fundChange > 0 ? '+' : ''}}{{ fundChange }}%</span>
|
||||
</div>
|
||||
<!-- Placeholder for standard/benchmark if data exists -->
|
||||
</div>
|
||||
|
||||
<div class="summary-info drawdown-info" v-if="activeTab === 'drawdown'">
|
||||
<div class="summary-info drawdown-info" v-else-if="activeTab === 'drawdown'">
|
||||
<div class="info-group">
|
||||
<div class="legend-dot-row">
|
||||
<span class="legend-line green"></span>
|
||||
@@ -40,7 +46,17 @@
|
||||
<span class="legend-box pink"></span>
|
||||
<span class="label">最大回撤修复天数</span>
|
||||
</div>
|
||||
<div class="value-row">{{ maxDrawdownInfo.days ? maxDrawdownInfo.days + '天' : '--' }}</div>
|
||||
<div class="value-row">{{ maxDrawdownInfo.days ? maxDrawdownInfo.days + '天' : '正在修复中...' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-info comparison-info" v-else-if="activeTab === 'comparison'">
|
||||
<div class="info-group" v-for="item in comparisonInfo" :key="item.name">
|
||||
<div class="legend-dot-row">
|
||||
<span class="legend-dot" :style="{ background: item.color, width: '12px', height: '3px' }"></span>
|
||||
<span class="label" style="margin-left: 4px;">{{ item.name }}</span>
|
||||
</div>
|
||||
<!-- Optional: Add value at end of period? -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +79,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
@@ -76,6 +92,10 @@ export default {
|
||||
acWorthTrend: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
grandTotal: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
@@ -99,7 +119,13 @@ export default {
|
||||
|
||||
const switchTab = (tab) => {
|
||||
activeTab.value = tab
|
||||
updateChart()
|
||||
nextTick(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
chartInstance = null;
|
||||
}
|
||||
initChart();
|
||||
})
|
||||
}
|
||||
|
||||
const getColor = (val) => {
|
||||
@@ -110,120 +136,145 @@ export default {
|
||||
// Computed properties for summary
|
||||
const fundChange = ref('0.00')
|
||||
const maxDrawdownInfo = ref({ val: '0.00', days: 0 })
|
||||
const comparisonInfo = ref([])
|
||||
|
||||
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.setMonth(now.getMonth() - 3))
|
||||
} else if (range === '6m') {
|
||||
startDate = new Date(now.setMonth(now.getMonth() - 6))
|
||||
} else if (range === '1y') {
|
||||
startDate = new Date(now.setFullYear(now.getFullYear() - 1))
|
||||
} else if (range === '3y') {
|
||||
startDate = new Date(now.setFullYear(now.getFullYear() - 3))
|
||||
}
|
||||
|
||||
return data.filter(item => item[0] >= startDate.getTime())
|
||||
}
|
||||
|
||||
const processData = () => {
|
||||
if (!props.netWorthTrend.length) return { netWorth: [], drawdownInfo: null }
|
||||
const rawData = (props.netWorthTrend || []).map(item => [item.x, item.y]);
|
||||
const filtered = filterByDate(rawData, selectedRange.value);
|
||||
|
||||
if (filtered.length === 0) return { netWorth: [], drawdownInfo: null }
|
||||
|
||||
const now = new Date()
|
||||
let startDate = new Date(0)
|
||||
const startVal = filtered[0][1]
|
||||
const endVal = filtered[filtered.length - 1][1]
|
||||
fundChange.value = startVal !== 0 ? ((endVal - startVal) / startVal * 100).toFixed(2) : '0.00'
|
||||
|
||||
if (selectedRange.value === '3m') {
|
||||
startDate = new Date(now.setMonth(now.getMonth() - 3))
|
||||
} else if (selectedRange.value === '6m') {
|
||||
startDate = new Date(now.setMonth(now.getMonth() - 6))
|
||||
} else if (selectedRange.value === '1y') {
|
||||
startDate = new Date(now.setFullYear(now.getFullYear() - 1))
|
||||
} else if (selectedRange.value === '3y') {
|
||||
startDate = new Date(now.setFullYear(now.getFullYear() - 3))
|
||||
}
|
||||
// Prepare Percentage Data
|
||||
const toPercent = (val) => startVal !== 0 ? parseFloat(((val - startVal) / startVal * 100).toFixed(2)) : 0
|
||||
const percentTrend = filtered.map(item => [item[0], toPercent(item[1])])
|
||||
|
||||
// Filter Data
|
||||
const filtered = props.netWorthTrend.filter(item => item.x >= startDate.getTime())
|
||||
|
||||
if (filtered.length === 0) return { netWorth: [], drawdownInfo: null }
|
||||
// Calculate Max Drawdown & Recovery
|
||||
let curMaxdd = 0;
|
||||
let globalPeakIndex = 0;
|
||||
let globalValleyIndex = 0;
|
||||
|
||||
let runningPeakValue = -Infinity;
|
||||
let runningPeakIndex = 0;
|
||||
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
const val = filtered[i][1];
|
||||
if (val > runningPeakValue) {
|
||||
runningPeakValue = val;
|
||||
runningPeakIndex = i;
|
||||
}
|
||||
|
||||
const dd = (runningPeakValue - val) / runningPeakValue;
|
||||
if (dd > curMaxdd) {
|
||||
curMaxdd = dd;
|
||||
globalPeakIndex = runningPeakIndex;
|
||||
globalValleyIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Check Recovery
|
||||
let recoveryIndex = -1;
|
||||
const peakValRaw = filtered[globalPeakIndex][1];
|
||||
|
||||
for (let i = globalPeakIndex + 1; i < filtered.length; i++) {
|
||||
if (filtered[i][1] >= peakValRaw) {
|
||||
recoveryIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const peakDate = filtered[globalPeakIndex][0];
|
||||
const valleyDate = filtered[globalValleyIndex][0];
|
||||
const recoveryDate = recoveryIndex !== -1 ? filtered[recoveryIndex][0] : null;
|
||||
|
||||
const days = recoveryDate ? Math.ceil((recoveryDate - peakDate) / (1000 * 3600 * 24)) : null;
|
||||
|
||||
// Calculate Fund Change %
|
||||
const startVal = filtered[0].y
|
||||
const endVal = filtered[filtered.length - 1].y
|
||||
fundChange.value = ((endVal - startVal) / startVal * 100).toFixed(2)
|
||||
const ddInfo = {
|
||||
val: (curMaxdd * 100).toFixed(2),
|
||||
peakDate,
|
||||
valleyDate,
|
||||
recoveryDate,
|
||||
days,
|
||||
peakValue: toPercent(peakValRaw),
|
||||
valleyValue: toPercent(filtered[globalValleyIndex][1]),
|
||||
recoveryValue: recoveryIndex !== -1 ? toPercent(filtered[recoveryIndex][1]) : null
|
||||
}
|
||||
|
||||
// Calculate Max Drawdown & Recovery
|
||||
// Logic: Iterate to find the (Peak -> Valley) that gives Max Drawdown
|
||||
// Then find recovery from that specific Peak
|
||||
|
||||
let curMaxdd = 0;
|
||||
let globalPeakIndex = 0;
|
||||
let globalValleyIndex = 0;
|
||||
|
||||
let runningPeakValue = -Infinity;
|
||||
let runningPeakIndex = 0;
|
||||
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
const val = filtered[i].y;
|
||||
if (val > runningPeakValue) {
|
||||
runningPeakValue = val;
|
||||
runningPeakIndex = i;
|
||||
}
|
||||
|
||||
const dd = (runningPeakValue - val) / runningPeakValue;
|
||||
if (dd > curMaxdd) {
|
||||
curMaxdd = dd;
|
||||
globalPeakIndex = runningPeakIndex;
|
||||
globalValleyIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Check Recovery
|
||||
let recoveryIndex = -1;
|
||||
const peakVal = filtered[globalPeakIndex].y;
|
||||
|
||||
// Look for recovery AFTER the valley? Or AFTER the peak?
|
||||
// "Recovery Period" usually starts from Drawdown start (Peak).
|
||||
// Find first point > peakVal after peakIndex
|
||||
for (let i = globalPeakIndex + 1; i < filtered.length; i++) {
|
||||
if (filtered[i].y >= peakVal) {
|
||||
recoveryIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const peakDate = filtered[globalPeakIndex].x;
|
||||
const valleyDate = filtered[globalValleyIndex].x;
|
||||
const recoveryDate = recoveryIndex !== -1 ? filtered[recoveryIndex].x : null;
|
||||
|
||||
const days = recoveryDate ? Math.ceil((recoveryDate - peakDate) / (1000 * 3600 * 24)) : null;
|
||||
|
||||
const ddInfo = {
|
||||
val: (curMaxdd * 100).toFixed(2),
|
||||
peakDate,
|
||||
valleyDate,
|
||||
recoveryDate,
|
||||
days,
|
||||
peakValue: peakVal,
|
||||
valleyValue: filtered[globalValleyIndex].y,
|
||||
recoveryValue: recoveryIndex !== -1 ? filtered[recoveryIndex].y : null
|
||||
}
|
||||
|
||||
maxDrawdownInfo.value = ddInfo
|
||||
|
||||
return {
|
||||
netWorth: filtered.map(item => [item.x, item.y]),
|
||||
drawdownInfo: ddInfo
|
||||
}
|
||||
|
||||
maxDrawdownInfo.value = ddInfo
|
||||
|
||||
// Also process Comparison Data just in case we need to filter for Comparison Tab?
|
||||
// Usually comparison tab shows "All" or follows the range selector if enabled.
|
||||
// User requirements usually imply comparison follows standard range or all.
|
||||
// But the range selector is hidden for comparison in template: v-if="activeTab !== 'comparison'"
|
||||
|
||||
return {
|
||||
netWorth: percentTrend,
|
||||
drawdownInfo: ddInfo
|
||||
}
|
||||
}
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartEl.value) return
|
||||
|
||||
chartInstance = echarts.init(chartEl.value)
|
||||
if (!chartInstance) {
|
||||
chartInstance = echarts.init(chartEl.value)
|
||||
}
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const { netWorth, drawdownInfo } = processData()
|
||||
|
||||
// Common Options
|
||||
chartInstance.clear();
|
||||
|
||||
const option = {
|
||||
grid: { left: '3%', right: '5%', bottom: '3%', top: '10%', containLabel: true },
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '5%', bottom: '10%', top: '15%', containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function (params) {
|
||||
let res = '<div>' + echarts.format.formatTime('yyyy-MM-dd', params[0].value[0]) + '</div>'
|
||||
params.forEach(item => {
|
||||
let val = item.value[1];
|
||||
// If comparison, values are usually percents.
|
||||
// If net worth, values are currency.
|
||||
res += `<div>${item.marker} ${item.seriesName}: ${val}${activeTab.value === 'comparison' ? '%' : ''}</div>`
|
||||
})
|
||||
return res;
|
||||
}
|
||||
},
|
||||
xAxis: { type: 'time', boundaryGap: false, axisLine: { show: false }, axisTick: { show: false } },
|
||||
yAxis: { type: 'value', scale: true, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
splitLine: { lineStyle: { type: 'dashed' } },
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
},
|
||||
series: []
|
||||
}
|
||||
|
||||
|
||||
if (activeTab.value === 'performance') {
|
||||
const { netWorth } = processData()
|
||||
option.series.push({
|
||||
name: '本基金',
|
||||
type: 'line',
|
||||
@@ -238,81 +289,135 @@ export default {
|
||||
])
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Drawdown View
|
||||
const seriesData = {
|
||||
name: '本基金',
|
||||
type: 'line',
|
||||
data: netWorth,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#88aaff' }, // Lighter blue
|
||||
markArea: {
|
||||
itemStyle: { color: 'rgba(255, 230, 230, 0.6)' }, // Light Pink
|
||||
data: []
|
||||
},
|
||||
markPoint: {
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
color: '#fff',
|
||||
padding: [4, 8],
|
||||
borderRadius: 4
|
||||
},
|
||||
data: []
|
||||
|
||||
option.tooltip.formatter = function (params) {
|
||||
let res = '<div>' + echarts.format.formatTime('yyyy-MM-dd', params[0].value[0]) + '</div>'
|
||||
params.forEach(item => {
|
||||
res += `<div>${item.marker} ${item.seriesName}: ${item.value[1]}%</div>`
|
||||
})
|
||||
return res;
|
||||
}
|
||||
} else if (activeTab.value === 'comparison') {
|
||||
const comparisonData = props.grandTotal || []
|
||||
|
||||
if (comparisonData.length > 0) {
|
||||
const colors = ['#007bff', '#91cc75', '#fac858', '#ee6666', '#5470c6'];
|
||||
|
||||
// Update Legend Info
|
||||
comparisonInfo.value = comparisonData.map((item, index) => ({
|
||||
name: item.name,
|
||||
color: colors[index % colors.length]
|
||||
}))
|
||||
|
||||
const series = comparisonData.map((item, index) => {
|
||||
const rawData = item.data || [];
|
||||
const filteredData = filterByDate(rawData, selectedRange.value);
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
data: filteredData,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
width: item.name.includes('本基金') ? 3 : 1.5
|
||||
},
|
||||
itemStyle: {
|
||||
color: colors[index % colors.length]
|
||||
},
|
||||
z: item.name.includes('本基金') ? 3 : 2
|
||||
}
|
||||
});
|
||||
|
||||
option.series = series
|
||||
option.legend = { show: false } // Hide internal legend
|
||||
|
||||
// Adjust tooltip for comparison to show %
|
||||
option.tooltip.formatter = function (params) {
|
||||
let res = '<div>' + echarts.format.formatTime('yyyy-MM-dd', params[0].value[0]) + '</div>'
|
||||
params.forEach(item => {
|
||||
res += `<div>
|
||||
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${item.color};"></span>
|
||||
${item.seriesName}: ${item.value[1]}%
|
||||
</div>`
|
||||
})
|
||||
return res;
|
||||
}
|
||||
}
|
||||
} else if (activeTab.value === 'drawdown') {
|
||||
const { netWorth, drawdownInfo } = processData()
|
||||
|
||||
if (drawdownInfo && drawdownInfo.peakDate) {
|
||||
const endDate = drawdownInfo.recoveryDate || netWorth[netWorth.length - 1][0];
|
||||
if (netWorth.length > 0) {
|
||||
const seriesData = {
|
||||
name: '本基金',
|
||||
type: 'line',
|
||||
data: netWorth,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#88aaff' },
|
||||
markArea: {
|
||||
itemStyle: { color: 'rgba(255, 230, 230, 0.6)' },
|
||||
data: []
|
||||
},
|
||||
markPoint: {
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
label: {
|
||||
show: true,
|
||||
color: '#fff',
|
||||
padding: [4, 8],
|
||||
borderRadius: 4
|
||||
},
|
||||
data: []
|
||||
}
|
||||
}
|
||||
|
||||
// Mark Area: Peak to Recovery (or End)
|
||||
seriesData.markArea.data.push([
|
||||
{ xAxis: drawdownInfo.peakDate },
|
||||
{ xAxis: endDate }
|
||||
]);
|
||||
|
||||
const points = [];
|
||||
|
||||
// 1. Tag at Valley: "Max Drawdown X%"
|
||||
points.push({
|
||||
xAxis: drawdownInfo.valleyDate,
|
||||
yAxis: drawdownInfo.valleyValue,
|
||||
itemStyle: { color: '#00bfa5' }, // Green dot
|
||||
label: {
|
||||
offset: [0, 15],
|
||||
formatter: `最大回撤${drawdownInfo.val}%`,
|
||||
backgroundColor: '#00bfa5',
|
||||
position: 'bottom'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Tag at Recovery (or in middle if recovery): "X Days Recovery"
|
||||
if (drawdownInfo.recoveryDate) {
|
||||
// Middle point for the label? Or at the red line?
|
||||
// Screenshot has "36 Days Recovery" in a Red Box pointing to the area/line.
|
||||
// We put it at the end (Recovery point).
|
||||
points.push({
|
||||
xAxis: drawdownInfo.recoveryDate,
|
||||
yAxis: drawdownInfo.recoveryValue,
|
||||
itemStyle: { color: '#ff5252' }, // Red dot
|
||||
if (drawdownInfo && drawdownInfo.peakDate) {
|
||||
const endDate = drawdownInfo.recoveryDate || netWorth[netWorth.length - 1][0];
|
||||
|
||||
seriesData.markArea.data.push([
|
||||
{ xAxis: drawdownInfo.peakDate },
|
||||
{ xAxis: endDate }
|
||||
]);
|
||||
|
||||
const points = [];
|
||||
points.push({
|
||||
coord: [drawdownInfo.peakDate, drawdownInfo.peakValue],
|
||||
itemStyle: { color: '#ff9800' },
|
||||
label: { show: false }
|
||||
});
|
||||
|
||||
points.push({
|
||||
coord: [drawdownInfo.valleyDate, drawdownInfo.valleyValue],
|
||||
itemStyle: { color: '#00bfa5' },
|
||||
label: {
|
||||
offset: [0, -15],
|
||||
formatter: `${drawdownInfo.days}天修复`,
|
||||
backgroundColor: '#ff5252',
|
||||
offset: [0, 15],
|
||||
formatter: `最大回撤${drawdownInfo.val}%`,
|
||||
backgroundColor: '#00bfa5',
|
||||
position: 'top'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
seriesData.markPoint.data = points;
|
||||
});
|
||||
|
||||
if (drawdownInfo.recoveryDate) {
|
||||
points.push({
|
||||
coord: [drawdownInfo.recoveryDate, drawdownInfo.recoveryValue],
|
||||
itemStyle: { color: '#ff5252' },
|
||||
label: {
|
||||
offset: [0, -15],
|
||||
formatter: `${drawdownInfo.days}天修复`,
|
||||
backgroundColor: '#ff5252',
|
||||
position: 'bottom'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
seriesData.markPoint.data = points;
|
||||
}
|
||||
option.series.push(seriesData)
|
||||
}
|
||||
|
||||
option.series.push(seriesData);
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true) // true = not merge, replace
|
||||
chartInstance.setOption(option)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -327,10 +432,10 @@ export default {
|
||||
window.removeEventListener('resize', () => chartInstance?.resize())
|
||||
})
|
||||
|
||||
watch(() => props.netWorthTrend, () => {
|
||||
updateChart()
|
||||
watch([() => props.netWorthTrend, () => props.grandTotal], () => {
|
||||
nextTick(() => updateChart())
|
||||
}, { deep: true })
|
||||
|
||||
|
||||
return {
|
||||
chartEl,
|
||||
timeRanges,
|
||||
@@ -340,6 +445,7 @@ export default {
|
||||
switchTab,
|
||||
fundChange,
|
||||
maxDrawdownInfo,
|
||||
comparisonInfo,
|
||||
getColor
|
||||
}
|
||||
}
|
||||
@@ -350,10 +456,13 @@ export default {
|
||||
.fund-chart-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
padding-bottom: 10px;
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.top-tabs {
|
||||
|
||||
@@ -8,14 +8,10 @@
|
||||
|
||||
<!-- 中心区域:图表展示 -->
|
||||
<div class="charts-section">
|
||||
<!-- 净值走势图 -->
|
||||
<!-- 净值走势图 (含收益对比、回撤修复) -->
|
||||
<FundChart
|
||||
:netWorthTrend="processedNetWorthTrend"
|
||||
:acWorthTrend="processedAcWorthTrend"
|
||||
/>
|
||||
|
||||
<!-- 累计收益率对比图 -->
|
||||
<FundPerformanceComparison
|
||||
:grandTotal="fundDetail.grand_total"
|
||||
/>
|
||||
|
||||
@@ -65,7 +61,6 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import FundBasicInfo from './FundBasicInfo.vue'
|
||||
import FundChart from './FundChart.vue'
|
||||
import FundPerformanceComparison from './FundPerformanceComparison.vue'
|
||||
import FundRankingTrend from './FundRankingTrend.vue'
|
||||
import FundAssetAllocation from './FundAssetAllocation.vue'
|
||||
import FundScaleChange from './FundScaleChange.vue'
|
||||
@@ -76,7 +71,6 @@ export default {
|
||||
components: {
|
||||
FundBasicInfo,
|
||||
FundChart,
|
||||
FundPerformanceComparison,
|
||||
FundRankingTrend,
|
||||
FundAssetAllocation,
|
||||
FundScaleChange
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
<template>
|
||||
<div class="fund-performance-card">
|
||||
<div class="card-header">
|
||||
<h3>📉 收益率对比分析</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="hasGrandTotalData" class="performance-content">
|
||||
<div ref="performanceChartEl" class="performance-chart"></div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<p>暂无收益率对比数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
name: 'FundPerformanceComparison',
|
||||
props: {
|
||||
grandTotal: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const performanceChartEl = ref(null)
|
||||
let performanceChartInstance = null
|
||||
|
||||
const hasGrandTotalData = computed(() =>
|
||||
props.grandTotal && props.grandTotal.length > 0
|
||||
)
|
||||
|
||||
const initPerformanceChart = () => {
|
||||
if (!performanceChartEl.value || !hasGrandTotalData.value) return
|
||||
|
||||
if (performanceChartInstance) {
|
||||
performanceChartInstance.dispose()
|
||||
}
|
||||
|
||||
performanceChartInstance = echarts.init(performanceChartEl.value)
|
||||
|
||||
const series = props.grandTotal.map((item, index) => {
|
||||
const colors = ['#667eea', '#91cc75', '#fac858']
|
||||
return {
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
data: item.data,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: colors[index % colors.length]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
let result = `<div style="font-weight: bold; margin-bottom: 8px;">${new Date(params[0].axisValue).toLocaleDateString()}</div>`
|
||||
params.forEach(param => {
|
||||
result += `<div style="margin: 4px 0;">
|
||||
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
|
||||
${param.seriesName}: <strong>${param.value[1]}%</strong>
|
||||
</div>`
|
||||
})
|
||||
return result
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: props.grandTotal.map(item => item.name),
|
||||
bottom: 10,
|
||||
icon: 'circle'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
top: '5%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
boundaryGap: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '累计收益率(%)',
|
||||
axisLabel: {
|
||||
formatter: '{value}%'
|
||||
}
|
||||
},
|
||||
series: series
|
||||
}
|
||||
|
||||
performanceChartInstance.setOption(option)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initPerformanceChart()
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.grandTotal, () => {
|
||||
nextTick(() => {
|
||||
initPerformanceChart()
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
performanceChartEl,
|
||||
hasGrandTotalData
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fund-performance-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.performance-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.performance-chart {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.no-data p {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@
|
||||
<th>日期</th>
|
||||
<th>排名</th>
|
||||
<th>同类基金总数</th>
|
||||
<th>排名百分比</th>
|
||||
<th>击败同类</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -21,8 +21,8 @@
|
||||
<td>{{ formatDate(item.x) }}</td>
|
||||
<td class="rank-value">{{ item.y }}/{{ item.sc }}</td>
|
||||
<td>{{ item.sc }}</td>
|
||||
<td :class="getPercentClass(item.percent)">
|
||||
{{ item.percent }}%
|
||||
<td :class="getPercentClass((1 - item.y / item.sc) * 100)">
|
||||
{{ ((1 - item.y / item.sc) * 100).toFixed(2) }}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -84,9 +84,11 @@ export default {
|
||||
return new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const getPercentClass = (percent) => {
|
||||
if (percent <= 20) return 'excellent'
|
||||
if (percent <= 50) return 'good'
|
||||
const getPercentClass = (defeatPercent) => {
|
||||
// defeatPercent is 100 - rankPercent
|
||||
// higher is better
|
||||
if (defeatPercent >= 80) return 'excellent'
|
||||
if (defeatPercent >= 50) return 'good'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
@@ -108,10 +110,11 @@ export default {
|
||||
formatter: (params) => {
|
||||
const dataIndex = params[0].dataIndex
|
||||
const item = combinedData.value[dataIndex]
|
||||
const defeated = ((1 - item.y / item.sc) * 100).toFixed(2);
|
||||
return `
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">${formatDate(item.x)}</div>
|
||||
<div>排名: <strong>${item.y}/${item.sc}</strong></div>
|
||||
<div>百分比: <strong>${item.percent.toFixed(2)}%</strong></div>
|
||||
<div>击败同类: <strong>${defeated}%</strong></div>
|
||||
`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -218,11 +218,11 @@ export default {
|
||||
}
|
||||
|
||||
.mom-value.positive {
|
||||
color: #52c41a;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.mom-value.negative {
|
||||
color: #ff4d4f;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
|
||||
Reference in New Issue
Block a user