feat: 前端收益曲线功能对接

- 新增API接口:getNavHistory、backfillNavHistory、calculateDailyNav
- detail.vue添加收益曲线图表组件
- 使用原生canvas绘制折线图(微信小程序兼容)
- 支持7天/30天/全部时间周期切换
- 显示统计指标:总收益、最大回撤、夏普比率、波动率
- 支持触摸查看详细数据
This commit is contained in:
claw_bot 2026-03-14 06:55:00 +00:00
parent 12057dc019
commit 2f30758d9a
2 changed files with 414 additions and 0 deletions

View File

@ -57,6 +57,72 @@
</view> </view>
</view> </view>
<!-- 收益曲线 -->
<view class="section-container">
<view class="section-header">
<text class="section-title">收益曲线</text>
<view class="period-tabs">
<text :class="['period-tab', {active: navPeriod === '7d'}]" @click="changeNavPeriod('7d')">7天</text>
<text :class="['period-tab', {active: navPeriod === '30d'}]" @click="changeNavPeriod('30d')">30天</text>
<text :class="['period-tab', {active: navPeriod === 'all'}]" @click="changeNavPeriod('all')">全部</text>
</view>
</view>
<view class="nav-chart-container">
<!-- 加载中 -->
<view v-if="navLoading" class="nav-loading">
<text class="loading-text">加载中...</text>
</view>
<!-- 无数据 -->
<view v-else-if="!navHistory || navHistory.length === 0" class="nav-empty">
<text class="empty-text">暂无收益数据</text>
</view>
<!-- 收益曲线 -->
<view v-else class="nav-chart-wrapper">
<canvas
canvas-id="navChart"
id="navChart"
class="nav-canvas"
@touchstart="onChartTouchStart"
@touchmove="onChartTouchMove"
></canvas>
<!-- 悬浮提示 -->
<view v-if="showTooltip" class="chart-tooltip" :style="{left: tooltipX, top: tooltipY}">
<text class="tooltip-date">{{ tooltipDate }}</text>
<text class="tooltip-value">净值: {{ tooltipNav.toFixed(4) }}</text>
<text class="tooltip-return" :class="tooltipReturn >= 0 ? 'positive' : 'negative'">
收益: {{ tooltipReturn >= 0 ? '+' : '' }}{{ tooltipReturn.toFixed(2) }}%
</text>
</view>
</view>
<!-- 统计指标 -->
<view v-if="navStatistics" class="nav-stats">
<view class="stat-item">
<text class="stat-label">总收益</text>
<text class="stat-val" :class="navStatistics.totalReturn >= 0 ? 'text-red' : 'text-green'">
{{ navStatistics.totalReturn >= 0 ? '+' : '' }}{{ navStatistics.totalReturn.toFixed(2) }}%
</text>
</view>
<view class="stat-item">
<text class="stat-label">最大回撤</text>
<text class="stat-val text-green">{{ navStatistics.maxDrawdown.toFixed(2) }}%</text>
</view>
<view class="stat-item">
<text class="stat-label">夏普比率</text>
<text class="stat-val">{{ navStatistics.sharpeRatio.toFixed(2) }}</text>
</view>
<view class="stat-item">
<text class="stat-label">波动率</text>
<text class="stat-val">{{ navStatistics.volatility.toFixed(2) }}%</text>
</view>
</view>
</view>
</view>
<view class="section-container pb-0"> <view class="section-container pb-0">
<view class="section-header"> <view class="section-header">
<text class="section-title">当前逻辑模型</text> <text class="section-title">当前逻辑模型</text>
@ -420,6 +486,18 @@ const portfolioData = ref({
const positions = ref([]); const positions = ref([]);
const logs = ref([]); const logs = ref([]);
//
const navLoading = ref(false);
const navPeriod = ref('30d');
const navHistory = ref([]);
const navStatistics = ref(null);
const showTooltip = ref(false);
const tooltipX = ref('0');
const tooltipY = ref('0');
const tooltipDate = ref('');
const tooltipNav = ref(0);
const tooltipReturn = ref(0);
// //
const showTransactionForm = ref(false); const showTransactionForm = ref(false);
const transactionType = ref('buy'); // buy sell const transactionType = ref('buy'); // buy sell
@ -560,10 +638,195 @@ const fetchTransactions = async () => {
} }
}; };
//
const fetchNavHistory = async () => {
if (!portfolioId.value) return;
navLoading.value = true;
try {
const today = new Date();
let startDate;
if (navPeriod.value === '7d') {
startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
} else if (navPeriod.value === '30d') {
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
} else {
startDate = null; //
}
const params = {};
if (startDate) {
params.startDate = startDate.toISOString().split('T')[0];
}
params.endDate = today.toISOString().split('T')[0];
const response = await api.assets.getNavHistory(portfolioId.value, params);
if (response.code === 200 && response.data) {
navHistory.value = response.data.navHistory || [];
navStatistics.value = response.data.statistics || null;
//
if (navHistory.value.length > 0) {
setTimeout(() => drawNavChart(), 100);
}
}
} catch (error) {
console.error('获取净值历史失败:', error);
} finally {
navLoading.value = false;
}
};
//
const changeNavPeriod = (period) => {
navPeriod.value = period;
fetchNavHistory();
};
// 线
const drawNavChart = () => {
const ctx = uni.createCanvasContext('navChart');
const data = navHistory.value;
if (!data || data.length === 0) return;
// canvasrpxpx
const width = 320; // 640rpx
const height = 200; // 400rpx
const padding = { top: 20, right: 20, bottom: 30, left: 50 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
//
const navValues = data.map(item => item.nav);
const minNav = Math.min(...navValues);
const maxNav = Math.max(...navValues);
const navRange = maxNav - minNav || 1;
//
ctx.clearRect(0, 0, width, height);
// 线
ctx.setStrokeStyle('#E5E7EB');
ctx.setLineWidth(0.5);
for (let i = 0; i <= 4; i++) {
const y = padding.top + (chartHeight / 4) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
// Y
ctx.setFillStyle('#6B7280');
ctx.setFontSize(10);
ctx.setTextAlign('right');
for (let i = 0; i <= 4; i++) {
const y = padding.top + (chartHeight / 4) * i;
const value = maxNav - (navRange / 4) * i;
ctx.fillText(value.toFixed(2), padding.left - 5, y + 3);
}
// 线
ctx.beginPath();
ctx.setStrokeStyle('#064E3B');
ctx.setLineWidth(2);
data.forEach((item, index) => {
const x = padding.left + (chartWidth / (data.length - 1)) * index;
const y = padding.top + chartHeight - ((item.nav - minNav) / navRange) * chartHeight;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
//
ctx.beginPath();
data.forEach((item, index) => {
const x = padding.left + (chartWidth / (data.length - 1)) * index;
const y = padding.top + chartHeight - ((item.nav - minNav) / navRange) * chartHeight;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.lineTo(padding.left + chartWidth, padding.top + chartHeight);
ctx.lineTo(padding.left, padding.top + chartHeight);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
gradient.addColorStop(0, 'rgba(6, 78, 59, 0.3)');
gradient.addColorStop(1, 'rgba(6, 78, 59, 0.05)');
ctx.setFillStyle(gradient);
ctx.fill();
// X
ctx.setFillStyle('#6B7280');
ctx.setFontSize(10);
ctx.setTextAlign('center');
const labelCount = Math.min(5, data.length);
const labelStep = Math.floor(data.length / labelCount);
for (let i = 0; i < labelCount; i++) {
const index = i * labelStep;
if (index < data.length) {
const x = padding.left + (chartWidth / (data.length - 1)) * index;
const dateStr = data[index].date.split('-').slice(1).join('/');
ctx.fillText(dateStr, x, height - 10);
}
}
ctx.draw();
};
//
const onChartTouchStart = (e) => {
showChartTooltip(e);
};
const onChartTouchMove = (e) => {
showChartTooltip(e);
};
const showChartTooltip = (e) => {
if (!navHistory.value || navHistory.value.length === 0) return;
const touch = e.touches[0];
const data = navHistory.value;
//
const width = 320;
const padding = { left: 50, right: 20 };
const chartWidth = width - padding.left - padding.right;
const x = touch.x - padding.left;
const index = Math.round((x / chartWidth) * (data.length - 1));
if (index >= 0 && index < data.length) {
const item = data[index];
tooltipDate.value = item.date;
tooltipNav.value = item.nav;
tooltipReturn.value = item.cumulativeReturn;
// tooltip
tooltipX.value = `${Math.min(Math.max(touch.x - 50, 10), width - 110)}px`;
tooltipY.value = `${Math.max(touch.y - 80, 10)}px`;
showTooltip.value = true;
}
};
onMounted(async () => { onMounted(async () => {
loading.value = true; loading.value = true;
await fetchPortfolioData(); await fetchPortfolioData();
await fetchTransactions(); await fetchTransactions();
await fetchNavHistory();
loading.value = false; loading.value = false;
}); });
@ -1112,4 +1375,126 @@ const deletePortfolio = async () => {
font-size: 22rpx; font-size: 22rpx;
color: #9CA3AF; color: #9CA3AF;
} }
/* 收益曲线样式 */
.nav-chart-container {
background-color: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
min-height: 400rpx;
}
.nav-loading,
.nav-empty {
display: flex;
align-items: center;
justify-content: center;
height: 400rpx;
}
.loading-text,
.empty-text {
font-size: 28rpx;
color: #9CA3AF;
}
.nav-chart-wrapper {
position: relative;
width: 100%;
height: 400rpx;
}
.nav-canvas {
width: 640rpx;
height: 400rpx;
}
.chart-tooltip {
position: absolute;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 12rpx;
padding: 16rpx 20rpx;
pointer-events: none;
z-index: 100;
}
.tooltip-date {
display: block;
font-size: 22rpx;
color: #FFFFFF;
margin-bottom: 8rpx;
}
.tooltip-value {
display: block;
font-size: 24rpx;
color: #FFFFFF;
margin-bottom: 4rpx;
}
.tooltip-return {
display: block;
font-size: 24rpx;
font-weight: 600;
}
.tooltip-return.positive {
color: #EF4444;
}
.tooltip-return.negative {
color: #10B981;
}
.period-tabs {
display: flex;
gap: 16rpx;
}
.period-tab {
font-size: 24rpx;
color: #6B7280;
padding: 8rpx 20rpx;
border-radius: 20rpx;
background-color: #F3F4F6;
}
.period-tab.active {
color: #FFFFFF;
background-color: #064E3B;
}
.nav-stats {
display: flex;
justify-content: space-between;
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #E5E7EB;
}
.nav-stats .stat-item {
text-align: center;
}
.nav-stats .stat-label {
display: block;
font-size: 22rpx;
color: #6B7280;
margin-bottom: 8rpx;
}
.nav-stats .stat-val {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
.nav-stats .text-red {
color: #EF4444;
}
.nav-stats .text-green {
color: #10B981;
}
</style> </style>

View File

@ -361,6 +361,35 @@ export const api = {
getPortfolioSignal: (id) => { getPortfolioSignal: (id) => {
console.log('📤 发起 getPortfolioSignal 请求:', id); console.log('📤 发起 getPortfolioSignal 请求:', id);
return get(`/api/v1/portfolio/${id}/signal`); return get(`/api/v1/portfolio/${id}/signal`);
},
/**
* 获取投资组合净值历史收益曲线
* @param {string|number} id - 投资组合ID
* @param {object} params - 查询参数 {startDate, endDate, interval}
* @returns {Promise} 返回净值历史数据和统计指标
*/
getNavHistory: (id, params = {}) => {
console.log('📤 发起 getNavHistory 请求:', id, params);
return get(`/api/v1/portfolio/${id}/nav-history`, params);
},
/**
* 回填投资组合净值历史
* @param {string|number} id - 投资组合ID
* @param {boolean} force - 是否强制重新计算
* @returns {Promise} 返回回填结果
*/
backfillNavHistory: (id, force = false) => {
console.log('📤 发起 backfillNavHistory 请求:', id, { force });
return post(`/api/v1/portfolio/${id}/nav-history/backfill`, {}, { force });
},
/**
* 计算投资组合当日净值
* @param {string|number} id - 投资组合ID
* @returns {Promise} 返回计算结果
*/
calculateDailyNav: (id) => {
console.log('📤 发起 calculateDailyNav 请求:', id);
return post(`/api/v1/portfolio/${id}/nav-history/calculate`);
} }
}, },