feat: 前端收益曲线功能对接
- 新增API接口:getNavHistory、backfillNavHistory、calculateDailyNav - detail.vue添加收益曲线图表组件 - 使用原生canvas绘制折线图(微信小程序兼容) - 支持7天/30天/全部时间周期切换 - 显示统计指标:总收益、最大回撤、夏普比率、波动率 - 支持触摸查看详细数据
This commit is contained in:
parent
12057dc019
commit
2f30758d9a
@ -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;
|
||||||
|
|
||||||
|
// 获取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 () => {
|
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>
|
||||||
29
utils/api.js
29
utils/api.js
@ -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`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user