From 2f30758d9a577fce583a5b99bbbb9d8002fc58bf Mon Sep 17 00:00:00 2001 From: claw_bot Date: Sat, 14 Mar 2026 06:55:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E6=94=B6=E7=9B=8A?= =?UTF-8?q?=E6=9B=B2=E7=BA=BF=E5=8A=9F=E8=83=BD=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增API接口:getNavHistory、backfillNavHistory、calculateDailyNav - detail.vue添加收益曲线图表组件 - 使用原生canvas绘制折线图(微信小程序兼容) - 支持7天/30天/全部时间周期切换 - 显示统计指标:总收益、最大回撤、夏普比率、波动率 - 支持触摸查看详细数据 --- pages/detail/detail.vue | 385 ++++++++++++++++++++++++++++++++++++++++ utils/api.js | 29 +++ 2 files changed, 414 insertions(+) diff --git a/pages/detail/detail.vue b/pages/detail/detail.vue index 9c11091..9e51fb0 100755 --- a/pages/detail/detail.vue +++ b/pages/detail/detail.vue @@ -57,6 +57,72 @@ + + + + 收益曲线 + + 7天 + 30天 + 全部 + + + + + + + 加载中... + + + + + 暂无收益数据 + + + + + + + + + {{ tooltipDate }} + 净值: {{ tooltipNav.toFixed(4) }} + + 收益: {{ tooltipReturn >= 0 ? '+' : '' }}{{ tooltipReturn.toFixed(2) }}% + + + + + + + + 总收益 + + {{ navStatistics.totalReturn >= 0 ? '+' : '' }}{{ navStatistics.totalReturn.toFixed(2) }}% + + + + 最大回撤 + {{ navStatistics.maxDrawdown.toFixed(2) }}% + + + 夏普比率 + {{ navStatistics.sharpeRatio.toFixed(2) }} + + + 波动率 + {{ navStatistics.volatility.toFixed(2) }}% + + + + + 当前逻辑模型 @@ -420,6 +486,18 @@ const portfolioData = ref({ const positions = 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 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; + + // 获取canvas尺寸(rpx转px) + 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 () => { loading.value = true; await fetchPortfolioData(); await fetchTransactions(); + await fetchNavHistory(); loading.value = false; }); @@ -1112,4 +1375,126 @@ const deletePortfolio = async () => { font-size: 22rpx; 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; +} \ No newline at end of file diff --git a/utils/api.js b/utils/api.js index feceb49..266d143 100755 --- a/utils/api.js +++ b/utils/api.js @@ -361,6 +361,35 @@ export const api = { getPortfolioSignal: (id) => { console.log('📤 发起 getPortfolioSignal 请求:', id); 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`); } },