This commit is contained in:
aaron 2026-02-22 09:31:12 +08:00
parent d5b6b316dd
commit de07521465
6 changed files with 728 additions and 17 deletions

View File

@ -214,6 +214,29 @@ async def get_statistics_by_symbol():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/daily-returns")
async def get_daily_returns(
days: int = Query(30, description="获取最近多少天的数据", ge=1, le=365)
):
"""
获取每日收益率数据
- days: 获取最近多少天的数据默认30天最大365天
"""
try:
service = get_paper_trading_service()
daily_returns = service.get_daily_returns(days=days)
return {
"success": True,
"data": daily_returns,
"count": len(daily_returns)
}
except Exception as e:
logger.error(f"获取每日收益率失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/monitor/status") @router.get("/monitor/status")
async def get_monitor_status(): async def get_monitor_status():
"""获取价格监控状态和实时价格""" """获取价格监控状态和实时价格"""

View File

@ -1299,14 +1299,40 @@ class LLMSignalAnalyzer:
} }
try: try:
# 尝试提取 JSON json_str = None
# 尝试多种方式提取 JSON
# 1. 尝试提取 ```json ... ``` 代码块
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response)
if json_match: if json_match:
json_str = json_match.group(1) json_str = json_match.group(1).strip()
logger.debug(f"从 ```json 代码块提取 JSON长度: {len(json_str)}")
else: else:
# 尝试直接解析 # 2. 尝试提取 ``` ... ``` 代码块(没有 json 标记)
json_str = response code_match = re.search(r'```\s*([\s\S]*?)\s*```', response)
if code_match:
potential_json = code_match.group(1).strip()
# 检查是否像 JSON以 { 开头)
if potential_json.startswith('{'):
json_str = potential_json
logger.debug(f"从 ``` 代码块提取 JSON长度: {len(json_str)}")
# 3. 如果还没找到,尝试直接找到 { ... } 结构
if not json_str:
brace_match = re.search(r'\{[\s\S]*\}', response)
if brace_match:
json_str = brace_match.group(0).strip()
logger.debug(f"从花括号提取 JSON长度: {len(json_str)}")
# 4. 最后尝试直接解析整个响应
if not json_str:
json_str = response.strip()
logger.debug(f"直接使用整个响应作为 JSON长度: {len(json_str)}")
# 清理 JSON 字符串中的问题字符
json_str = self._clean_json_string(json_str)
# 解析 JSON
parsed = json.loads(json_str) parsed = json.loads(json_str)
result['analysis_summary'] = parsed.get('analysis_summary', '') result['analysis_summary'] = parsed.get('analysis_summary', '')
@ -1320,12 +1346,92 @@ class LLMSignalAnalyzer:
valid_signals.append(sig) valid_signals.append(sig)
result['signals'] = valid_signals result['signals'] = valid_signals
except json.JSONDecodeError: logger.info(f"JSON 解析成功: {len(valid_signals)} 个有效信号")
logger.warning("LLM 响应不是有效 JSON尝试提取关键信息")
except json.JSONDecodeError as e:
logger.warning(f"LLM 响应不是有效 JSON: {e},尝试提取关键信息")
logger.debug(f"无法解析的 JSON 字符串: {json_str[:200] if json_str else response[:200]}...")
result['analysis_summary'] = self._extract_summary(response)
except Exception as e:
logger.error(f"解析响应时出错: {e}")
result['analysis_summary'] = self._extract_summary(response) result['analysis_summary'] = self._extract_summary(response)
return result return result
def _validate_signal(self, signal: Dict[str, Any]) -> bool:
"""验证信号是否有效"""
required_fields = ['type', 'action', 'confidence', 'grade', 'reason']
for field in required_fields:
if field not in signal:
return False
# 验证类型
if signal['type'] not in ['short_term', 'medium_term', 'long_term']:
return False
def _clean_json_string(self, json_str: str) -> str:
"""清理 JSON 字符串中的问题字符"""
# 移除 BOM 标记
json_str = json_str.strip('\ufeff')
# 使用更健壮的方法:使用 json.JSONDecoder 的 raw_decode
# 但首先尝试简单的清理
try:
# 方法1: 直接解析,如果成功就不需要清理
json.loads(json_str)
return json_str
except json.JSONDecodeError:
pass
# 方法2: 移除控制字符(保留换行、制表符等常见字符)
# 控制字符范围: 0x00-0x1F除了 0x09(\t), 0x0A(\n), 0x0D(\r)
def remove_control_chars(s):
"""移除字符串值中的控制字符"""
result = []
in_string = False
escape = False
i = 0
while i < len(s):
char = s[i]
if escape:
# 转义模式下,保留所有字符
result.append(char)
escape = False
elif char == '\\':
# 开始转义
result.append(char)
escape = True
elif char == '"' and (i == 0 or s[i-1] != '\\'):
# 字符串边界
in_string = not in_string
result.append(char)
elif in_string:
# 在字符串内,检查是否为控制字符
code = ord(char)
if code < 0x20 and code not in (0x09, 0x0A, 0x0D):
# 控制字符,跳过或替换
if char == '\n':
result.append('\\n')
elif char == '\r':
result.append('\\r')
elif char == '\t':
result.append('\\t')
# 其他控制字符直接跳过
else:
result.append(char)
else:
# 不在字符串内,保留所有字符(包括空格、换行等)
result.append(char)
i += 1
return ''.join(result)
json_str = remove_control_chars(json_str)
return json_str
def _validate_signal(self, signal: Dict[str, Any]) -> bool: def _validate_signal(self, signal: Dict[str, Any]) -> bool:
"""验证信号是否有效""" """验证信号是否有效"""
required_fields = ['type', 'action', 'confidence', 'grade', 'reason'] required_fields = ['type', 'action', 'confidence', 'grade', 'reason']

View File

@ -40,12 +40,12 @@ class MultiLLMService:
if '.' in api_key and len(api_key) > 10: if '.' in api_key and len(api_key) > 10:
self.clients['zhipu'] = ZhipuAI(api_key=api_key) self.clients['zhipu'] = ZhipuAI(api_key=api_key)
self.model_info['zhipu'] = { self.model_info['zhipu'] = {
'name': 'GLM-4', 'name': 'GLM-4-Flash',
'model_id': 'glm-4', 'model_id': 'glm-4-flash',
'provider': 'zhipu', 'provider': 'zhipu',
'available': True 'available': True
} }
logger.info("智谱AI初始化成功") logger.info("智谱AI初始化成功 (使用模型: glm-4-flash)")
except Exception as e: except Exception as e:
logger.error(f"智谱AI初始化失败: {e}") logger.error(f"智谱AI初始化失败: {e}")
@ -144,12 +144,16 @@ class MultiLLMService:
# 智谱AI调用 # 智谱AI调用
# Zhipu对参数更严格temperature范围是0.0-1.0 # Zhipu对参数更严格temperature范围是0.0-1.0
safe_temperature = max(0.0, min(1.0, temperature)) safe_temperature = max(0.0, min(1.0, temperature))
logger.debug(f"智谱AI请求参数: model={model_id}, temperature={safe_temperature}, max_tokens={max_tokens}")
logger.debug(f"消息内容: {messages[-1]['content'][:200] if messages else 'empty'}...")
response = client.chat.completions.create( response = client.chat.completions.create(
model=model_id, model=model_id,
messages=messages, messages=messages,
temperature=safe_temperature, temperature=safe_temperature,
max_tokens=max_tokens max_tokens=max_tokens
) )
logger.debug(f"智谱AI原始响应: {response}")
elif provider == 'deepseek': elif provider == 'deepseek':
# DeepSeek调用OpenAI兼容 # DeepSeek调用OpenAI兼容
# DeepSeek对参数更严格确保temperature在有效范围内 # DeepSeek对参数更严格确保temperature在有效范围内
@ -164,12 +168,46 @@ class MultiLLMService:
logger.error(f"未知的模型提供商: {provider}") logger.error(f"未知的模型提供商: {provider}")
return None return None
if response.choices: # 详细日志记录响应结构
content = response.choices[0].message.content logger.debug(f"响应对象类型: {type(response)}")
logger.info(f"LLM响应成功长度: {len(content) if content else 0}")
return content if hasattr(response, 'choices') and response.choices:
choice = response.choices[0]
logger.debug(f"Choice类型: {type(choice)}, 索引: {getattr(choice, 'index', 'N/A')}")
message = choice.message
logger.debug(f"Message类型: {type(message)}")
# 智谱AI可能使用不同的字段
content = getattr(message, 'content', None)
if content and content.strip():
logger.info(f"LLM响应成功长度: {len(content)}")
return content
else:
logger.warning(f"LLM响应content为空或空白: content={repr(content)}")
# 尝试从其他字段获取内容智谱AI可能使用不同字段
for attr in ['reasoning_content', 'text', 'result']:
if hasattr(message, attr):
alt_content = getattr(message, attr)
if alt_content and alt_content.strip():
logger.info(f"{attr} 获取内容,长度: {len(alt_content)}")
return alt_content
# 打印完整的message对象用于调试
logger.debug(f"完整Message对象: {message}")
if hasattr(message, '__dict__'):
logger.debug(f"Message属性: {message.__dict__}")
return None
else: else:
logger.warning("LLM响应中没有choices") logger.warning(f"LLM响应中没有choices。响应: {response}")
# 检查是否有其他可能的响应格式
if hasattr(response, 'content'):
content = response.content
if content and content.strip():
logger.info(f"从response.content获取内容长度: {len(content)}")
return content
return None return None
except Exception as e: except Exception as e:

View File

@ -1500,6 +1500,122 @@ class PaperTradingService:
logger.warning(f"获取 {symbol} 当前价格失败: {e}") logger.warning(f"获取 {symbol} 当前价格失败: {e}")
return 0.0 return 0.0
def get_daily_returns(self, days: int = 30) -> List[Dict[str, Any]]:
"""
获取每日收益率数据
Args:
days: 获取最近多少天的数据默认30天
Returns:
每日收益率数据列表格式: [
{
'date': '2024-01-15',
'return_percent': 2.5,
'return_amount': 250.0,
'balance': 10250.0,
'trades_count': 5,
'winning_trades': 3,
'losing_trades': 2
},
...
]
"""
db = db_service.get_session()
try:
from datetime import timedelta
from collections import defaultdict
# 计算起始日期
end_date = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = end_date - timedelta(days=days)
# 获取所有已平仓订单(按日期范围)
closed_orders = db.query(PaperOrder).filter(
PaperOrder.status.in_([
OrderStatus.CLOSED_TP,
OrderStatus.CLOSED_SL,
OrderStatus.CLOSED_BE,
OrderStatus.CLOSED_MANUAL
]),
PaperOrder.closed_at >= start_date
).order_by(PaperOrder.closed_at.asc()).all()
# 获取初始余额(第一个订单前的余额)
initial_balance = self.initial_balance
current_balance = initial_balance
# 按日期分组统计
daily_stats = defaultdict(lambda: {
'pnl': 0.0,
'trades': [],
'balance_start': initial_balance
})
# 记录每日开始余额
daily_balance = {end_date: initial_balance}
# 处理每个订单
for order in closed_orders:
if order.closed_at:
# 获取订单日期UTC 0点
order_date = order.closed_at.replace(hour=0, minute=0, second=0, microsecond=0)
if order_date not in daily_balance:
# 计算该日开始时的余额
daily_balance[order_date] = current_balance
# 更新当前余额
current_balance += (order.pnl_amount or 0)
# 累加每日统计
daily_stats[order_date]['pnl'] += (order.pnl_amount or 0)
daily_stats[order_date]['trades'].append(order)
# 构建结果
results = []
balance_running = initial_balance
# 生成所有日期(包括没有交易的日期)
for i in range(days):
date = end_date - timedelta(days=days - 1 - i)
# 获取该日的统计数据
if date in daily_stats:
stats = daily_stats[date]
daily_pnl = stats['pnl']
trades = stats['trades']
# 计算该日收益率
balance_start = stats.get('balance_start', balance_running)
return_percent = (daily_pnl / balance_start * 100) if balance_start > 0 else 0
balance_running += daily_pnl
winning_count = len([t for t in trades if (t.pnl_amount or 0) > 0])
losing_count = len([t for t in trades if (t.pnl_amount or 0) < 0])
else:
# 没有交易的日期
daily_pnl = 0
return_percent = 0
trades = []
winning_count = 0
losing_count = 0
results.append({
'date': date.strftime('%Y-%m-%d'),
'return_percent': round(return_percent, 2),
'return_amount': round(daily_pnl, 2),
'balance': round(balance_running, 2),
'trades_count': len(trades),
'winning_trades': winning_count,
'losing_trades': losing_count
})
return results
finally:
db.close()
def reset_all_data(self) -> Dict[str, Any]: def reset_all_data(self) -> Dict[str, Any]:
""" """
重置所有模拟交易数据 重置所有模拟交易数据

View File

@ -22,8 +22,9 @@ class SignalDatabaseService:
def _ensure_tables(self): def _ensure_tables(self):
"""确保表已创建""" """确保表已创建"""
try: try:
from app.models.database import Base, engine from app.models.database import Base
Base.metadata.create_all(bind=engine) # 使用 db_service 的 engine
Base.metadata.create_all(bind=self.db_service.engine)
logger.info("交易信号表已创建") logger.info("交易信号表已创建")
except Exception as e: except Exception as e:
logger.error(f"创建交易信号表失败: {e}") logger.error(f"创建交易信号表失败: {e}")

View File

@ -942,6 +942,93 @@
.share-btn.copy:hover { .share-btn.copy:hover {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
} }
/* 收益率图表样式 */
.chart-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 20px;
margin-bottom: 20px;
}
.chart-wrapper {
position: relative;
height: 400px;
width: 100%;
}
.daily-returns-table {
width: 100%;
border-collapse: collapse;
background: var(--bg-secondary);
border: 1px solid var(--border);
}
.daily-returns-table th,
.daily-returns-table td {
padding: 12px 16px;
text-align: center;
border-bottom: 1px solid var(--border);
}
.daily-returns-table th {
background: var(--bg-primary);
color: var(--text-secondary);
font-weight: 400;
font-size: 12px;
text-transform: uppercase;
}
.daily-returns-table td {
color: var(--text-primary);
font-size: 14px;
}
.daily-returns-table tr:hover {
background: var(--bg-tertiary);
}
.daily-returns-table .positive {
color: #00ff41;
}
.daily-returns-table .negative {
color: #ff4444;
}
.summary-chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.summary-chart-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
}
.summary-chart-label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.summary-chart-value {
font-size: 20px;
font-weight: 500;
}
.summary-chart-value.positive {
color: #00ff41;
}
.summary-chart-value.negative {
color: #ff4444;
}
</style> </style>
</head> </head>
<body> <body>
@ -1025,6 +1112,9 @@
<button class="tab" :class="{ active: activeTab === 'stats' }" @click="activeTab = 'stats'"> <button class="tab" :class="{ active: activeTab === 'stats' }" @click="activeTab = 'stats'">
详细统计 详细统计
</button> </button>
<button class="tab" :class="{ active: activeTab === 'returns' }" @click="activeTab = 'returns'">
收益率
</button>
</div> </div>
<!-- 活跃订单 --> <!-- 活跃订单 -->
@ -1229,6 +1319,103 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 收益率图表 -->
<div v-if="activeTab === 'returns'">
<h3 style="color: var(--text-primary); font-weight: 300; margin-bottom: 16px;">每日收益率</h3>
<!-- 汇总卡片 -->
<div class="summary-chart-grid">
<div class="summary-chart-card">
<div class="summary-chart-label">累计收益率</div>
<div class="summary-chart-value" :class="totalReturn >= 0 ? 'positive' : 'negative'">
{{ totalReturn >= 0 ? '+' : '' }}{{ totalReturn.toFixed(2) }}%
</div>
</div>
<div class="summary-chart-card">
<div class="summary-chart-label">累计收益额</div>
<div class="summary-chart-value" :class="totalReturnAmount >= 0 ? 'positive' : 'negative'">
{{ totalReturnAmount >= 0 ? '+' : '' }}${{ totalReturnAmount.toFixed(2) }}
</div>
</div>
<div class="summary-chart-card">
<div class="summary-chart-label">盈利天数</div>
<div class="summary-chart-value positive">{{ profitableDays }}</div>
</div>
<div class="summary-chart-card">
<div class="summary-chart-label">亏损天数</div>
<div class="summary-chart-value negative">{{ losingDays }}</div>
</div>
<div class="summary-chart-card">
<div class="summary-chart-label">胜率(按天)</div>
<div class="summary-chart-value">{{ dailyWinRate.toFixed(1) }}%</div>
</div>
<div class="summary-chart-card">
<div class="summary-chart-label">平均日收益</div>
<div class="summary-chart-value" :class="avgDailyReturn >= 0 ? 'positive' : 'negative'">
{{ avgDailyReturn >= 0 ? '+' : '' }}{{ avgDailyReturn.toFixed(2) }}%
</div>
</div>
</div>
<!-- 图表 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<!-- 净值曲线图 -->
<div class="chart-container">
<h4 style="color: var(--text-primary); font-weight: 300; margin-bottom: 12px;">净值曲线</h4>
<div class="chart-wrapper">
<canvas id="balanceChart"></canvas>
</div>
</div>
<!-- 收益率柱状图 -->
<div class="chart-container">
<h4 style="color: var(--text-primary); font-weight: 300; margin-bottom: 12px;">每日收益率 (%)</h4>
<div class="chart-wrapper">
<canvas id="returnsChart"></canvas>
</div>
</div>
</div>
<!-- 表格 -->
<div v-if="loadingReturns" class="loading">加载中...</div>
<div v-else-if="dailyReturns.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<p>暂无收益率数据</p>
</div>
<div v-else class="table-wrapper">
<table class="daily-returns-table">
<thead>
<tr>
<th>日期</th>
<th>收益率</th>
<th>收益额</th>
<th>余额</th>
<th>交易数</th>
<th>盈利</th>
<th>亏损</th>
</tr>
</thead>
<tbody>
<tr v-for="day in dailyReturns" :key="day.date">
<td>{{ day.date }}</td>
<td :class="day.return_percent >= 0 ? 'positive' : 'negative'">
{{ day.return_percent >= 0 ? '+' : '' }}{{ day.return_percent }}%
</td>
<td :class="day.return_amount >= 0 ? 'positive' : 'negative'">
{{ day.return_amount >= 0 ? '+' : '' }}${{ day.return_amount.toFixed(2) }}
</td>
<td>${{ day.balance.toFixed(2) }}</td>
<td>{{ day.trades_count }}</td>
<td class="positive">{{ day.winning_trades }}</td>
<td class="negative">{{ day.losing_trades }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
@ -1287,6 +1474,8 @@
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script> <script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<script> <script>
const { createApp } = Vue; const { createApp } = Vue;
@ -1339,7 +1528,11 @@
pnlPercent: 0, pnlPercent: 0,
pnlAmount: 0, pnlAmount: 0,
grade: 'B' grade: 'B'
} },
dailyReturns: [],
loadingReturns: false,
balanceChart: null,
returnsChart: null
}; };
}, },
mounted() { mounted() {
@ -1495,6 +1688,11 @@
this.fetchAccountStatus(), this.fetchAccountStatus(),
this.fetchMonitorStatus() this.fetchMonitorStatus()
]); ]);
// 首次加载时也获取每日收益率数据
if (this.isFirstLoad && this.dailyReturns.length === 0) {
await this.fetchDailyReturns();
}
} catch (e) { } catch (e) {
console.error('刷新数据失败:', e); console.error('刷新数据失败:', e);
} finally { } finally {
@ -1699,6 +1897,185 @@
const distance = Math.abs(currentPrice - order.take_profit) / currentPrice; const distance = Math.abs(currentPrice - order.take_profit) / currentPrice;
return distance < 0.01; return distance < 0.01;
},
// 获取每日收益率数据
async fetchDailyReturns() {
this.loadingReturns = true;
try {
const response = await fetch('/api/paper-trading/daily-returns?days=30');
const data = await response.json();
if (data.success) {
this.dailyReturns = data.data;
// 等待 DOM 更新后渲染图表
this.$nextTick(() => {
this.renderCharts();
});
}
} catch (e) {
console.error('获取每日收益率失败:', e);
} finally {
this.loadingReturns = false;
}
},
// 渲染图表
renderCharts() {
this.renderBalanceChart();
this.renderReturnsChart();
},
// 渲染净值曲线图
renderBalanceChart() {
const canvas = document.getElementById('balanceChart');
if (!canvas) return;
// 销毁已存在的图表
if (this.balanceChart) {
this.balanceChart.destroy();
}
const ctx = canvas.getContext('2d');
const labels = this.dailyReturns.map(d => d.date);
const balanceData = this.dailyReturns.map(d => d.balance);
this.balanceChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '账户净值',
data: balanceData,
borderColor: '#00ff41',
backgroundColor: 'rgba(0, 255, 65, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
return '净值: $' + context.parsed.y.toFixed(2);
}
}
}
},
scales: {
x: {
display: true,
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: 'rgba(255, 255, 255, 0.6)',
maxRotation: 45,
minRotation: 45
}
},
y: {
display: true,
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: 'rgba(255, 255, 255, 0.6)',
callback: function(value) {
return '$' + value.toFixed(0);
}
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
},
// 渲染收益率柱状图
renderReturnsChart() {
const canvas = document.getElementById('returnsChart');
if (!canvas) return;
// 销毁已存在的图表
if (this.returnsChart) {
this.returnsChart.destroy();
}
const ctx = canvas.getContext('2d');
const labels = this.dailyReturns.map(d => d.date);
const returnsData = this.dailyReturns.map(d => d.return_percent);
const colors = returnsData.map(v => v >= 0 ? '#00ff41' : '#ff4444');
this.returnsChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '日收益率',
data: returnsData,
backgroundColor: colors,
borderColor: colors,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.parsed.y;
return '收益率: ' + (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false
},
ticks: {
color: 'rgba(255, 255, 255, 0.6)',
maxRotation: 45,
minRotation: 45
}
},
y: {
display: true,
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: 'rgba(255, 255, 255, 0.6)',
callback: function(value) {
return value.toFixed(1) + '%';
}
}
}
}
}
});
} }
}, },
computed: { computed: {
@ -1728,6 +2105,56 @@
totalUnrealizedPnlPercent() { totalUnrealizedPnlPercent() {
if (this.totalPosition === 0) return 0; if (this.totalPosition === 0) return 0;
return (this.totalUnrealizedPnl / this.totalPosition) * 100; return (this.totalUnrealizedPnl / this.totalPosition) * 100;
},
// 累计收益率
totalReturn() {
if (this.dailyReturns.length === 0) return 0;
const firstDay = this.dailyReturns[0];
const lastDay = this.dailyReturns[this.dailyReturns.length - 1];
if (firstDay.balance === 0) return 0;
return ((lastDay.balance - firstDay.balance) / firstDay.balance) * 100;
},
// 累计收益额
totalReturnAmount() {
if (this.dailyReturns.length === 0) return 0;
const lastDay = this.dailyReturns[this.dailyReturns.length - 1];
const firstDay = this.dailyReturns[0];
return lastDay.balance - firstDay.balance;
},
// 盈利天数
profitableDays() {
return this.dailyReturns.filter(d => d.return_percent > 0).length;
},
// 亏损天数
losingDays() {
return this.dailyReturns.filter(d => d.return_percent < 0).length;
},
// 日胜率
dailyWinRate() {
const totalDays = this.dailyReturns.filter(d => d.trades_count > 0).length;
if (totalDays === 0) return 0;
return (this.profitableDays / totalDays) * 100;
},
// 平均日收益
avgDailyReturn() {
const tradingDays = this.dailyReturns.filter(d => d.trades_count > 0).length;
if (tradingDays === 0) return 0;
const totalReturn = this.dailyReturns.reduce((sum, d) => sum + d.return_percent, 0);
return totalReturn / tradingDays;
}
},
watch: {
// 监听标签页切换,加载收益率数据
activeTab(newTab) {
if (newTab === 'returns' && this.dailyReturns.length === 0) {
this.fetchDailyReturns();
}
} }
} }
}).mount('#app'); }).mount('#app');