AssetManager.UniApp/pages/strategies/edit/edit.vue
niannian zheng 291024f9e7 refactor(策略编辑): 优化参数处理逻辑并兼容旧格式
将config字段改为parameters以更清晰表达用途,同时添加对旧config格式的兼容处理
2026-03-02 17:30:44 +08:00

738 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page-container">
<view class="nav-bar">
<view class="back-btn" @click="uni.navigateBack()">
<uni-icons type="left" size="20" color="#374151"></uni-icons>
</view>
<text class="nav-title">{{ isEditMode ? '编辑策略' : '创建策略' }}</text>
</view>
<view class="section-title">选择逻辑模型</view>
<scroll-view scroll-x class="strategy-scroll" :show-scrollbar="false">
<view class="strategy-row">
<view
v-for="(item, index) in strategyTypes"
:key="index"
class="strategy-card"
:class="{ 'active': currentType === item.key }"
@click="selectType(item.key)"
>
<view class="icon-circle" :class="currentType === item.key ? 'bg-white text-green' : item.bgClass">
<uni-icons :type="item.icon" size="24" :color="currentType === item.key ? '#064E3B' : item.iconColor"></uni-icons>
</view>
<text class="st-name" :class="{ 'text-white': currentType === item.key }">{{ item.name }}</text>
<text class="st-tag" :class="{ 'text-green-light': currentType === item.key }">{{ item.tag }}</text>
<view class="check-mark" v-if="currentType === item.key">
<uni-icons type="checkmarkempty" size="16" color="#064E3B"></uni-icons>
</view>
</view>
</view>
</scroll-view>
<view class="desc-box" v-if="currentStrategyInfo">
<view class="desc-header">
<uni-icons type="info-filled" size="18" color="#064E3B"></uni-icons>
<text class="desc-title">策略原理</text>
</view>
<text class="desc-content">{{ currentStrategyInfo.description }}</text>
</view>
<view class="config-section">
<view class="section-title">参数配置</view>
<view class="form-card">
<view class="form-item">
<text class="label">策略名称</text>
<input class="input-field" v-model="formData.name" placeholder="例如: 双均线趋势策略" />
</view>
<view class="form-item">
<text class="label">策略描述</text>
<input class="input-field" v-model="formData.description" placeholder="描述策略的用途和特点" />
</view>
<view class="form-item">
<text class="label">风险等级</text>
<picker :range="['low', 'medium', 'high']" @change="onRiskLevelChange">
<view class="picker-display">
<text>{{ formData.riskLevel || 'medium' }}</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
</view>
<view class="form-item">
<text class="label">标签</text>
<input class="input-field" v-model="formData.tags" placeholder="用逗号分隔,如:趋势,均线" />
</view>
<template v-if="currentType === 'ma_trend'">
<view class="flex-row gap-3">
<view class="form-item flex-1">
<text class="label">短期周期</text>
<input class="input-field" type="number" v-model="formData.shortPeriod" placeholder="20" />
</view>
<view class="form-item flex-1">
<text class="label">长期周期</text>
<input class="input-field" type="number" v-model="formData.longPeriod" placeholder="60" />
</view>
</view>
<view class="form-item">
<text class="label">均线类型</text>
<picker :range="['SMA', 'EMA']" @change="onMaTypeChange">
<view class="picker-display">
<text>{{ formData.maType || 'SMA' }}</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
</view>
<view class="info-tag bg-blue-50">
<text class="text-blue-700 text-xs">规则:短期均线上穿长期均线买入,下穿卖出。</text>
</view>
</template>
<template v-if="currentType === 'risk_parity'">
<view class="form-item">
<text class="label">回看周期</text>
<input class="input-field" type="number" v-model="formData.lookbackPeriod" placeholder="60" />
</view>
<view class="form-item">
<text class="label">再平衡阈值</text>
<input class="input-field" type="digit" v-model="formData.rebalanceThreshold" placeholder="0.05" />
</view>
<view class="form-item">
<text class="label">资产配置</text>
<view class="assets-list">
<view class="asset-item" v-for="(asset, index) in formData.assets" :key="index">
<view class="asset-header">
<text class="asset-title">资产 #{{ index + 1 }}</text>
<uni-icons type="trash" size="18" color="#EF4444" @click="removeAsset(index)" v-if="formData.assets.length > 1"></uni-icons>
</view>
<view class="asset-inputs">
<view class="asset-input">
<text class="asset-label">代码</text>
<input class="input-field" v-model="asset.symbol" placeholder="如 AAPL" />
</view>
<view class="asset-input">
<text class="asset-label">目标权重</text>
<input class="input-field" type="digit" v-model="asset.targetWeight" placeholder="0.6" />
</view>
</view>
</view>
<view class="add-asset-btn" @click="addAsset">
<uni-icons type="plus" size="16" color="#064E3B"></uni-icons>
<text class="add-asset-text">添加资产</text>
</view>
</view>
</view>
<view class="info-tag bg-green-50">
<text class="text-green-700 text-xs">当资产权重偏离目标超过此阈值时触发再平衡。</text>
</view>
</template>
<template v-if="currentType === 'chandelier_exit'">
<view class="form-item">
<text class="label">ATR 周期</text>
<input class="input-field" type="number" v-model="formData.period" placeholder="22" />
</view>
<view class="form-item">
<text class="label">ATR 倍数</text>
<input class="input-field" type="digit" v-model="formData.multiplier" placeholder="3.0" />
</view>
<view class="form-item">
<text class="label">使用收盘价</text>
<picker :range="['是', '否']" @change="onUseCloseChange">
<view class="picker-display">
<text>{{ formData.useClose ? '是' : '否' }}</text>
<uni-icons type="bottom" size="14" color="#9CA3AF"></uni-icons>
</view>
</picker>
</view>
<view class="info-tag bg-orange-50">
<text class="text-orange-700 text-xs">动态止损策略,随着价格上涨止损点上移。</text>
</view>
</template>
</view>
</view>
<view class="footer-bar">
<button class="submit-btn" @click="submit">{{ isEditMode ? '更新策略配置' : '保存策略配置' }}</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, getCurrentInstance, onMounted } from 'vue';
const { proxy } = getCurrentInstance();
const api = proxy.$api;
const isEditMode = ref(false);
const strategyId = ref('');
const currentType = ref('ma_trend'); // 当前选中的策略类型
const strategyTypes = [
{
key: 'ma_trend',
name: '双均线策略',
tag: '趋势跟踪',
icon: 'navigate-filled',
bgClass: 'bg-blue-100',
iconColor: '#2563EB',
description: '经典技术分析策略,通过短期和长期移动平均线的金叉死叉信号捕捉价格趋势。金叉买入,死叉卖出,过滤短期噪音。'
},
{
key: 'risk_parity',
name: '风险平价策略',
tag: '资产配置',
icon: 'pie-chart-filled',
bgClass: 'bg-green-100',
iconColor: '#059669',
description: '为投资组合中的每个资产设定固定的目标权重比例通过定期再平衡将资产配置恢复到目标权重。适合长期投资和多元化资产配置如60/40组合。'
},
{
key: 'chandelier_exit',
name: '吊灯止损策略',
tag: '风险控制',
icon: 'fire-filled',
bgClass: 'bg-orange-100',
iconColor: '#EA580C',
description: '结合移动平均线和ATR平均真实波幅生成的动态止损点。随着价格上涨止损点上移价格回撤触及止损点时离场有效锁住利润。'
}
];
const formData = ref({
name: '',
description: '',
riskLevel: 'medium',
tags: [],
maType: 'SMA',
shortPeriod: '',
longPeriod: '',
lookbackPeriod: '',
rebalanceThreshold: '',
period: '',
multiplier: '',
useClose: false,
assets: [
{ symbol: '', targetWeight: '' }
]
});
// 计算当前选中的策略信息
const currentStrategyInfo = computed(() => {
return strategyTypes.find(item => item.key === currentType.value);
});
const selectType = (key) => {
currentType.value = key;
};
const onPeriodChange = (e) => {
const periods = ['每日', '每周', '每月', '每季度', '每年'];
formData.value.period = periods[e.detail.value];
};
const onMaTypeChange = (e) => {
const types = ['SMA', 'EMA'];
formData.value.maType = types[e.detail.value];
};
const onUseCloseChange = (e) => {
formData.value.useClose = e.detail.value === 0;
};
const onRiskLevelChange = (e) => {
const levels = ['low', 'medium', 'high'];
formData.value.riskLevel = levels[e.detail.value];
};
const addAsset = () => {
formData.value.assets.push({ symbol: '', targetWeight: '' });
};
const removeAsset = (index) => {
formData.value.assets.splice(index, 1);
};
const validateRiskParityAssets = () => {
const assets = formData.value.assets;
if (assets.length < 2) {
uni.showToast({ title: '风险平价策略至少需要2个资产', icon: 'none' });
return false;
}
let totalWeight = 0;
for (const asset of assets) {
if (!asset.symbol) {
uni.showToast({ title: '请填写所有资产代码', icon: 'none' });
return false;
}
if (!asset.targetWeight) {
uni.showToast({ title: '请填写所有资产目标权重', icon: 'none' });
return false;
}
totalWeight += parseFloat(asset.targetWeight);
}
if (Math.abs(totalWeight - 1) > 0.01) {
uni.showToast({ title: `资产权重总和必须为100%,当前为${(totalWeight * 100).toFixed(0)}%`, icon: 'none' });
return false;
}
return true;
};
const submit = async () => {
if (!formData.value.name) {
uni.showToast({ title: '请输入策略名称', icon: 'none' });
return;
}
// 处理标签,将逗号分隔的字符串转换为数组
let tags = [];
if (formData.value.tags) {
tags = formData.value.tags.split(',').map(tag => tag.trim()).filter(tag => tag);
}
if (tags.length === 0) {
tags = [currentStrategyInfo.value.tag];
}
// 构建参数对象
const parameters = {};
switch (currentType.value) {
case 'ma_trend':
if (!formData.value.shortPeriod || !formData.value.longPeriod) {
uni.showToast({ title: '请输入短期和长期周期', icon: 'none' });
return;
}
parameters.maType = formData.value.maType;
parameters.shortPeriod = parseInt(formData.value.shortPeriod);
parameters.longPeriod = parseInt(formData.value.longPeriod);
break;
case 'risk_parity':
if (!formData.value.lookbackPeriod || !formData.value.rebalanceThreshold) {
uni.showToast({ title: '请输入回看周期和再平衡阈值', icon: 'none' });
return;
}
if (!validateRiskParityAssets()) {
return;
}
parameters.lookbackPeriod = parseInt(formData.value.lookbackPeriod);
parameters.rebalanceThreshold = parseFloat(formData.value.rebalanceThreshold);
parameters.assets = formData.value.assets.map(asset => ({
symbol: asset.symbol,
targetWeight: parseFloat(asset.targetWeight)
}));
break;
case 'chandelier_exit':
if (!formData.value.period || !formData.value.multiplier) {
uni.showToast({ title: '请输入ATR周期和倍数', icon: 'none' });
return;
}
parameters.period = parseInt(formData.value.period);
parameters.multiplier = parseFloat(formData.value.multiplier);
parameters.useClose = formData.value.useClose;
break;
}
const strategyData = {
name: formData.value.name,
type: currentType.value,
description: formData.value.description || currentStrategyInfo.value.description,
riskLevel: formData.value.riskLevel,
tags: tags,
parameters: parameters
};
console.log('保存策略:', strategyData);
uni.showLoading({ title: isEditMode.value ? '更新中' : '保存中' });
try {
let res;
if (isEditMode.value) {
res = await api.strategies.updateStrategy(strategyId.value, strategyData);
console.log('策略更新成功:', res);
} else {
res = await api.strategies.createStrategy(strategyData);
console.log('策略创建成功:', res);
}
uni.hideLoading();
uni.showToast({ title: isEditMode.value ? '策略已更新' : '策略已保存', icon: 'success' });
setTimeout(() => uni.navigateBack(), 1500);
} catch (error) {
console.error(isEditMode.value ? '策略更新失败:' : '策略创建失败:', error);
uni.hideLoading();
uni.showToast({ title: isEditMode.value ? '更新失败,请重试' : '保存失败,请重试', icon: 'none' });
}
};
// 加载策略详情
const loadStrategyDetail = async (id) => {
try {
uni.showLoading({ title: '加载中' });
const response = await api.strategies.getStrategy(id);
uni.hideLoading();
if (response.code === 200) {
const data = response.data;
isEditMode.value = true;
strategyId.value = data.id;
currentType.value = data.type;
// 填充表单数据
formData.value.name = data.name || '';
formData.value.description = data.description || '';
formData.value.riskLevel = data.riskLevel || 'medium';
formData.value.tags = data.tags ? data.tags.join(', ') : '';
// 根据策略类型填充参数
let params = {};
if (data.parameters) {
params = data.parameters;
} else if (data.config) {
// 兼容旧格式
params = JSON.parse(data.config);
}
switch (data.type) {
case 'ma_trend':
formData.value.maType = params.maType || 'SMA';
formData.value.shortPeriod = params.shortPeriod?.toString() || '';
formData.value.longPeriod = params.longPeriod?.toString() || '';
break;
case 'risk_parity':
formData.value.lookbackPeriod = params.lookbackPeriod?.toString() || '';
formData.value.rebalanceThreshold = params.rebalanceThreshold?.toString() || '';
if (params.assets && params.assets.length > 0) {
formData.value.assets = params.assets.map(asset => ({
symbol: asset.symbol || '',
targetWeight: asset.targetWeight?.toString() || ''
}));
}
break;
case 'chandelier_exit':
formData.value.period = params.period?.toString() || '';
formData.value.multiplier = params.multiplier?.toString() || '';
formData.value.useClose = params.useClose || false;
break;
}
console.log('策略详情加载成功:', data);
}
} catch (error) {
uni.hideLoading();
console.error('加载策略详情失败:', error);
uni.showToast({ title: '加载失败', icon: 'none' });
}
};
// 页面加载时检查是否是编辑模式
onMounted(() => {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const id = currentPage.options?.id;
if (id) {
loadStrategyDetail(id);
}
});
</script>
<style scoped>
.page-container {
min-height: 100vh;
background-color: #F9FAFB;
padding-bottom: 200rpx; /* 增加底部内边距,防止内容被底部按钮遮挡 */
}
/* 导航栏 */
.nav-bar {
background-color: #fff;
padding: var(--status-bar-height) 32rpx 20rpx 32rpx;
display: flex;
align-items: center;
height: 88rpx;
box-sizing: content-box;
position: sticky;
top: 0;
z-index: 100;
border-bottom: 1rpx solid #E5E7EB;
}
.back-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: #111827;
margin-right: 60rpx;
}
/* 导航栏 (简化版) */
.nav-bar {
background-color: #fff;
padding: var(--status-bar-height) 32rpx 20rpx 32rpx;
display: flex;
align-items: center;
height: 88rpx;
box-sizing: content-box;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
/* 标题通用 */
.section-title {
padding: 32rpx 32rpx 20rpx 32rpx;
font-size: 28rpx;
font-weight: 700;
color: #374151;
}
/* 策略选择器 */
.strategy-scroll {
white-space: nowrap;
width: 100%;
padding-bottom: 20rpx;
}
.strategy-row {
display: flex;
padding: 0 32rpx;
gap: 24rpx;
}
.strategy-card {
width: 280rpx;
height: 320rpx;
background-color: #FFFFFF;
border-radius: 32rpx;
padding: 32rpx;
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: center;
border: 2rpx solid transparent;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03);
position: relative;
transition: all 0.2s;
}
.strategy-card.active {
background-color: #064E3B;
border-color: #064E3B;
transform: translateY(-4rpx);
box-shadow: 0 12rpx 24rpx rgba(6, 78, 59, 0.2);
}
.icon-circle {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.bg-green-100 { background-color: #ECFDF5; }
.bg-blue-100 { background-color: #EFF6FF; }
.bg-orange-100 { background-color: #FFF7ED; }
.st-name { font-size: 30rpx; font-weight: 700; color: #1F2937; margin-bottom: 8rpx; white-space: normal; text-align: center; }
.st-tag { font-size: 22rpx; color: #9CA3AF; }
.text-white { color: #fff !important; }
.text-green { color: #064E3B !important; }
.text-green-light { color: rgba(255,255,255,0.7) !important; }
.check-mark {
position: absolute;
top: 16rpx;
right: 16rpx;
width: 40rpx;
height: 40rpx;
background-color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
/* 策略描述 */
.desc-box {
margin: 10rpx 32rpx 30rpx 32rpx;
background-color: #ECFDF5;
padding: 24rpx;
border-radius: 20rpx;
border: 1rpx solid #D1FAE5;
}
.desc-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.desc-title { font-size: 26rpx; font-weight: 700; color: #064E3B; }
.desc-content {
font-size: 24rpx;
color: #047857;
line-height: 1.6;
text-align: justify;
}
/* 表单区域 */
.config-section { margin-top: 20rpx; }
.form-card {
background-color: #fff;
margin: 0 32rpx;
padding: 32rpx;
border-radius: 32rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.02);
}
.form-item { margin-bottom: 32rpx; }
.label { font-size: 26rpx; font-weight: 600; color: #374151; margin-bottom: 16rpx; display: block; }
.input-field {
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 20rpx;
height: 88rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1F2937;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.unit { position: absolute; right: 24rpx; font-size: 26rpx; color: #9CA3AF; }
.picker-display {
background-color: #F9FAFB;
border: 2rpx solid #E5E7EB;
border-radius: 20rpx;
height: 88rpx;
padding: 0 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #1F2937;
}
.helper { font-size: 22rpx; color: #9CA3AF; margin-top: 10rpx; display: block; }
.flex-row { display: flex; flex-direction: row; }
.flex-1 { flex: 1; }
.gap-3 { gap: 24rpx; }
.info-tag { padding: 16rpx; border-radius: 12rpx; margin-top: -10rpx; }
.bg-blue-50 { background-color: #EFF6FF; }
.bg-green-50 { background-color: #ECFDF5; }
.bg-orange-50 { background-color: #FFF7ED; }
.text-blue-700 { color: #1D4ED8; }
.text-green-700 { color: #047857; }
.text-orange-700 { color: #C2410C; }
.text-xs { font-size: 22rpx; }
/* 资产配置列表 */
.assets-list {
margin-top: 16rpx;
}
.asset-item {
background-color: #F9FAFB;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 16rpx;
}
.asset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.asset-title {
font-size: 24rpx;
font-weight: 600;
color: #374151;
}
.asset-inputs {
display: flex;
gap: 16rpx;
}
.asset-input {
flex: 1;
}
.asset-label {
font-size: 22rpx;
color: #6B7280;
margin-bottom: 8rpx;
display: block;
}
.add-asset-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
background-color: #ECFDF5;
border: 2rpx dashed #10B981;
border-radius: 16rpx;
padding: 20rpx;
margin-top: 8rpx;
}
.add-asset-btn:active {
background-color: #D1FAFA;
}
.add-asset-text {
font-size: 24rpx;
color: #064E3B;
font-weight: 600;
}
/* 底部悬浮按钮栏 */
.footer-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 20rpx 32rpx 50rpx 32rpx; /* 适配底部安全区 */
box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.05);
z-index: 99;
}
.submit-btn {
background-color: #064E3B;
color: #fff;
font-weight: 700;
border-radius: 24rpx;
height: 96rpx;
line-height: 96rpx;
font-size: 30rpx;
width: 100%;
}
.submit-btn:active { opacity: 0.9; }
</style>