update
This commit is contained in:
parent
d5b6b316dd
commit
de07521465
@ -214,6 +214,29 @@ async def get_statistics_by_symbol():
|
||||
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")
|
||||
async def get_monitor_status():
|
||||
"""获取价格监控状态和实时价格"""
|
||||
|
||||
@ -1299,14 +1299,40 @@ class LLMSignalAnalyzer:
|
||||
}
|
||||
|
||||
try:
|
||||
# 尝试提取 JSON
|
||||
json_str = None
|
||||
|
||||
# 尝试多种方式提取 JSON
|
||||
# 1. 尝试提取 ```json ... ``` 代码块
|
||||
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response)
|
||||
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:
|
||||
# 尝试直接解析
|
||||
json_str = response
|
||||
# 2. 尝试提取 ``` ... ``` 代码块(没有 json 标记)
|
||||
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)
|
||||
|
||||
result['analysis_summary'] = parsed.get('analysis_summary', '')
|
||||
@ -1320,12 +1346,92 @@ class LLMSignalAnalyzer:
|
||||
valid_signals.append(sig)
|
||||
result['signals'] = valid_signals
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("LLM 响应不是有效 JSON,尝试提取关键信息")
|
||||
logger.info(f"JSON 解析成功: {len(valid_signals)} 个有效信号")
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
"""验证信号是否有效"""
|
||||
required_fields = ['type', 'action', 'confidence', 'grade', 'reason']
|
||||
|
||||
@ -40,12 +40,12 @@ class MultiLLMService:
|
||||
if '.' in api_key and len(api_key) > 10:
|
||||
self.clients['zhipu'] = ZhipuAI(api_key=api_key)
|
||||
self.model_info['zhipu'] = {
|
||||
'name': 'GLM-4',
|
||||
'model_id': 'glm-4',
|
||||
'name': 'GLM-4-Flash',
|
||||
'model_id': 'glm-4-flash',
|
||||
'provider': 'zhipu',
|
||||
'available': True
|
||||
}
|
||||
logger.info("智谱AI初始化成功")
|
||||
logger.info("智谱AI初始化成功 (使用模型: glm-4-flash)")
|
||||
except Exception as e:
|
||||
logger.error(f"智谱AI初始化失败: {e}")
|
||||
|
||||
@ -144,12 +144,16 @@ class MultiLLMService:
|
||||
# 智谱AI调用
|
||||
# Zhipu对参数更严格,temperature范围是0.0-1.0
|
||||
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(
|
||||
model=model_id,
|
||||
messages=messages,
|
||||
temperature=safe_temperature,
|
||||
max_tokens=max_tokens
|
||||
)
|
||||
logger.debug(f"智谱AI原始响应: {response}")
|
||||
elif provider == 'deepseek':
|
||||
# DeepSeek调用(OpenAI兼容)
|
||||
# DeepSeek对参数更严格,确保temperature在有效范围内
|
||||
@ -164,12 +168,46 @@ class MultiLLMService:
|
||||
logger.error(f"未知的模型提供商: {provider}")
|
||||
return None
|
||||
|
||||
if response.choices:
|
||||
content = response.choices[0].message.content
|
||||
logger.info(f"LLM响应成功,长度: {len(content) if content else 0}")
|
||||
return content
|
||||
# 详细日志记录响应结构
|
||||
logger.debug(f"响应对象类型: {type(response)}")
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@ -1500,6 +1500,122 @@ class PaperTradingService:
|
||||
logger.warning(f"获取 {symbol} 当前价格失败: {e}")
|
||||
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]:
|
||||
"""
|
||||
重置所有模拟交易数据
|
||||
|
||||
@ -22,8 +22,9 @@ class SignalDatabaseService:
|
||||
def _ensure_tables(self):
|
||||
"""确保表已创建"""
|
||||
try:
|
||||
from app.models.database import Base, engine
|
||||
Base.metadata.create_all(bind=engine)
|
||||
from app.models.database import Base
|
||||
# 使用 db_service 的 engine
|
||||
Base.metadata.create_all(bind=self.db_service.engine)
|
||||
logger.info("交易信号表已创建")
|
||||
except Exception as e:
|
||||
logger.error(f"创建交易信号表失败: {e}")
|
||||
|
||||
@ -942,6 +942,93 @@
|
||||
.share-btn.copy:hover {
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@ -1025,6 +1112,9 @@
|
||||
<button class="tab" :class="{ active: activeTab === 'stats' }" @click="activeTab = 'stats'">
|
||||
详细统计
|
||||
</button>
|
||||
<button class="tab" :class="{ active: activeTab === 'returns' }" @click="activeTab = 'returns'">
|
||||
收益率
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 活跃订单 -->
|
||||
@ -1229,6 +1319,103 @@
|
||||
</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>
|
||||
|
||||
@ -1287,6 +1474,8 @@
|
||||
|
||||
<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://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>
|
||||
const { createApp } = Vue;
|
||||
|
||||
@ -1339,7 +1528,11 @@
|
||||
pnlPercent: 0,
|
||||
pnlAmount: 0,
|
||||
grade: 'B'
|
||||
}
|
||||
},
|
||||
dailyReturns: [],
|
||||
loadingReturns: false,
|
||||
balanceChart: null,
|
||||
returnsChart: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@ -1495,6 +1688,11 @@
|
||||
this.fetchAccountStatus(),
|
||||
this.fetchMonitorStatus()
|
||||
]);
|
||||
|
||||
// 首次加载时也获取每日收益率数据
|
||||
if (this.isFirstLoad && this.dailyReturns.length === 0) {
|
||||
await this.fetchDailyReturns();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('刷新数据失败:', e);
|
||||
} finally {
|
||||
@ -1699,6 +1897,185 @@
|
||||
|
||||
const distance = Math.abs(currentPrice - order.take_profit) / currentPrice;
|
||||
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: {
|
||||
@ -1728,6 +2105,56 @@
|
||||
totalUnrealizedPnlPercent() {
|
||||
if (this.totalPosition === 0) return 0;
|
||||
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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user