u
This commit is contained in:
parent
7376fefc10
commit
588b49ed86
315
backend/app/api/real_trading.py
Normal file
315
backend/app/api/real_trading.py
Normal file
@ -0,0 +1,315 @@
|
||||
"""
|
||||
实盘交易 API
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.real_trading_service import get_real_trading_service
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/real-trading", tags=["实盘交易"])
|
||||
|
||||
|
||||
class OrderResponse(BaseModel):
|
||||
"""订单响应"""
|
||||
success: bool
|
||||
message: str
|
||||
data: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get("/orders")
|
||||
async def get_orders(
|
||||
symbol: Optional[str] = Query(None, description="交易对筛选"),
|
||||
status: Optional[str] = Query(None, description="状态筛选: active, closed"),
|
||||
limit: int = Query(100, description="返回数量限制")
|
||||
):
|
||||
"""
|
||||
获取实盘订单列表
|
||||
|
||||
- symbol: 可选,按交易对筛选
|
||||
- status: 可选,active=活跃订单, closed=已平仓订单
|
||||
- limit: 返回数量限制,默认100
|
||||
"""
|
||||
try:
|
||||
service = get_real_trading_service()
|
||||
|
||||
if not service:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "实盘交易服务未启用",
|
||||
"count": 0,
|
||||
"orders": []
|
||||
}
|
||||
|
||||
if status == "active":
|
||||
orders = service.get_active_orders()
|
||||
elif status == "closed":
|
||||
# 从数据库获取历史订单
|
||||
from app.services.db_service import db_service
|
||||
from app.models.real_trading import RealOrder
|
||||
from app.models.paper_trading import OrderStatus
|
||||
|
||||
db = db_service.get_session()
|
||||
try:
|
||||
query = db.query(RealOrder).filter(
|
||||
RealOrder.status.in_([OrderStatus.CLOSED, OrderStatus.CANCELLED])
|
||||
)
|
||||
|
||||
if symbol:
|
||||
query = query.filter(RealOrder.symbol == symbol)
|
||||
|
||||
orders = [order.to_dict() for order in query.order_by(
|
||||
RealOrder.created_at.desc()
|
||||
).limit(limit).all()]
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
# 返回所有订单
|
||||
active = service.get_active_orders()
|
||||
# TODO: 获取历史订单
|
||||
orders = active
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(orders),
|
||||
"orders": orders
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取实盘订单列表失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/orders/active")
|
||||
async def get_active_orders(
|
||||
symbol: Optional[str] = Query(None, description="交易对筛选")
|
||||
):
|
||||
"""获取活跃实盘订单"""
|
||||
try:
|
||||
service = get_real_trading_service()
|
||||
|
||||
if not service:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "实盘交易服务未启用",
|
||||
"count": 0,
|
||||
"orders": []
|
||||
}
|
||||
|
||||
orders = service.get_active_orders()
|
||||
|
||||
# 如果指定了交易对,进行过滤
|
||||
if symbol:
|
||||
orders = [o for o in orders if o.get('symbol') == symbol]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(orders),
|
||||
"orders": orders
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取活跃实盘订单失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}")
|
||||
async def get_order(order_id: str):
|
||||
"""获取实盘订单详情"""
|
||||
try:
|
||||
service = get_real_trading_service()
|
||||
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail="实盘交易服务未启用")
|
||||
|
||||
order = service.get_order(order_id)
|
||||
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"order": order
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取实盘订单详情失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/positions")
|
||||
async def get_positions():
|
||||
"""获取实盘持仓(从交易所同步)"""
|
||||
try:
|
||||
service = get_real_trading_service()
|
||||
|
||||
if not service:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "实盘交易服务未启用",
|
||||
"positions": []
|
||||
}
|
||||
|
||||
positions = service.sync_positions_from_exchange()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(positions),
|
||||
"positions": positions
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取实盘持仓失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/account")
|
||||
async def get_account_status():
|
||||
"""获取实盘账户状态"""
|
||||
try:
|
||||
service = get_real_trading_service()
|
||||
|
||||
if not service:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "实盘交易服务未启用",
|
||||
"account": None
|
||||
}
|
||||
|
||||
account = service.get_account_status()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"account": account
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取实盘账户状态失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/close")
|
||||
async def close_order(order_id: str, exit_price: float = Query(..., description="平仓价格")):
|
||||
"""
|
||||
手动平仓
|
||||
|
||||
注意:实盘交易通常由交易所自动执行止损/止盈,
|
||||
此接口主要用于紧急情况下的手动平仓
|
||||
"""
|
||||
try:
|
||||
service = get_real_trading_service()
|
||||
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail="实盘交易服务未启用")
|
||||
|
||||
# 获取订单
|
||||
order = service.get_order(order_id)
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
|
||||
# 调用交易所API平仓
|
||||
# TODO: 实现手动平仓逻辑
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "平仓指令已发送",
|
||||
"order_id": order_id
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"实盘手动平仓失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_trading_stats():
|
||||
"""获取实盘交易统计"""
|
||||
try:
|
||||
service = get_real_trading_service()
|
||||
|
||||
if not service:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "实盘交易服务未启用",
|
||||
"stats": None
|
||||
}
|
||||
|
||||
# 获取账户信息
|
||||
account = service.get_account_status()
|
||||
|
||||
# 获取历史订单统计
|
||||
from app.services.db_service import db_service
|
||||
from app.models.real_trading import RealOrder
|
||||
from app.models.paper_trading import OrderStatus
|
||||
|
||||
db = db_service.get_session()
|
||||
try:
|
||||
# 获取已平仓订单
|
||||
closed_orders = db.query(RealOrder).filter(
|
||||
RealOrder.status == OrderStatus.CLOSED
|
||||
).all()
|
||||
|
||||
# 计算统计数据
|
||||
total_trades = len(closed_orders)
|
||||
winning_trades = len([o for o in closed_orders if o.pnl > 0])
|
||||
losing_trades = len([o for o in closed_orders if o.pnl < 0])
|
||||
total_pnl = sum([o.pnl or 0 for o in closed_orders])
|
||||
win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0
|
||||
|
||||
# 计算最大回撤等指标
|
||||
# TODO: 实现更详细的统计
|
||||
|
||||
stats = {
|
||||
"total_trades": total_trades,
|
||||
"winning_trades": winning_trades,
|
||||
"losing_trades": losing_trades,
|
||||
"win_rate": round(win_rate, 2),
|
||||
"total_pnl": round(total_pnl, 2),
|
||||
"current_balance": account.get('current_balance', 0),
|
||||
"available": account.get('available', 0),
|
||||
"used_margin": account.get('used_margin', 0),
|
||||
"total_position_value": account.get('total_position_value', 0),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"stats": stats
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取实盘交易统计失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_service_status():
|
||||
"""获取实盘交易服务状态"""
|
||||
try:
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
service = get_real_trading_service()
|
||||
|
||||
status = {
|
||||
"enabled": settings.real_trading_enabled,
|
||||
"api_configured": bool(settings.bitget_api_key and settings.bitget_api_secret),
|
||||
"use_testnet": settings.bitget_use_testnet,
|
||||
"service_running": service is not None,
|
||||
"max_single_position": settings.real_trading_max_single_position,
|
||||
"default_leverage": settings.real_trading_default_leverage,
|
||||
"max_orders": settings.real_trading_max_orders,
|
||||
}
|
||||
|
||||
if service:
|
||||
account = service.get_account_status()
|
||||
status["account"] = account
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": status
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取实盘交易服务状态失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from app.config import get_settings
|
||||
from app.utils.logger import logger
|
||||
from app.api import chat, stock, skills, llm, auth, admin, paper_trading, stocks, signals, system
|
||||
from app.api import chat, stock, skills, llm, auth, admin, paper_trading, stocks, signals, system, real_trading
|
||||
from app.utils.error_handler import setup_global_exception_handler, init_error_notifier
|
||||
from app.utils.system_status import get_system_monitor
|
||||
import os
|
||||
@ -456,6 +456,7 @@ app.include_router(stock.router, prefix="/api/stock", tags=["股票数据"])
|
||||
app.include_router(skills.router, prefix="/api/skills", tags=["技能管理"])
|
||||
app.include_router(llm.router, tags=["LLM模型"])
|
||||
app.include_router(paper_trading.router, tags=["模拟交易"])
|
||||
app.include_router(real_trading.router, tags=["实盘交易"])
|
||||
app.include_router(stocks.router, prefix="/api/stocks", tags=["美股分析"])
|
||||
app.include_router(signals.router, tags=["信号管理"])
|
||||
app.include_router(system.router, prefix="/api/system", tags=["系统状态"])
|
||||
@ -494,6 +495,14 @@ async def paper_trading_page():
|
||||
return FileResponse(page_path)
|
||||
return {"message": "页面不存在"}
|
||||
|
||||
@app.get("/real-trading")
|
||||
async def real_trading_page():
|
||||
"""实盘交易页面"""
|
||||
page_path = os.path.join(frontend_path, "real-trading.html")
|
||||
if os.path.exists(page_path):
|
||||
return FileResponse(page_path)
|
||||
return {"message": "页面不存在"}
|
||||
|
||||
@app.get("/signals")
|
||||
async def signals_page():
|
||||
"""信号列表页面"""
|
||||
|
||||
703
frontend/real-trading.html
Normal file
703
frontend/real-trading.html
Normal file
@ -0,0 +1,703 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>实盘交易 | Tradus Auto Trading</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: auto;
|
||||
display: block;
|
||||
align-items: initial;
|
||||
justify-content: initial;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.trading-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.trading-container {
|
||||
max-width: 1400px;
|
||||
min-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-primary);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.trading-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 0 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.trading-title {
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trading-title .real-badge {
|
||||
display: inline-block;
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-value.positive {
|
||||
color: #00ff41;
|
||||
}
|
||||
|
||||
.stat-value.negative {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.orders-table-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orders-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.orders-table th,
|
||||
.orders-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.orders-table th {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.orders-table td {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.orders-table tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.orders-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.side-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.side-long {
|
||||
background: rgba(0, 255, 65, 0.2);
|
||||
color: #00ff41;
|
||||
}
|
||||
|
||||
.side-short {
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.grade-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.grade-A { background: rgba(0, 255, 65, 0.2); color: #00ff41; }
|
||||
.grade-B { background: rgba(100, 200, 255, 0.2); color: #64c8ff; }
|
||||
.grade-C { background: rgba(255, 200, 0, 0.2); color: #ffc800; }
|
||||
.grade-D { background: rgba(255, 68, 68, 0.2); color: #ff4444; }
|
||||
|
||||
.pnl-positive {
|
||||
color: #00ff41;
|
||||
}
|
||||
|
||||
.pnl-negative {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-open {
|
||||
background: rgba(0, 255, 65, 0.2);
|
||||
color: #00ff41;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: rgba(255, 200, 0, 0.2);
|
||||
color: #ffc800;
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
border: 1px solid #ff4444;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.warning-banner svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #ff4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.warning-banner-text {
|
||||
color: #ff4444;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.account-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.account-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.account-value {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.account-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="trading-page">
|
||||
<div class="trading-container">
|
||||
<div class="sticky-header">
|
||||
<div class="trading-header">
|
||||
<div class="trading-title">
|
||||
实盘交易
|
||||
<span class="real-badge">LIVE</span>
|
||||
</div>
|
||||
<button class="refresh-btn" @click="refreshData">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 警告横幅 -->
|
||||
<div class="warning-banner" v-if="!serviceEnabled || !apiConfigured">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div class="warning-banner-text">
|
||||
<template v-if="!serviceEnabled">实盘交易服务未启用,请在配置中设置 REAL_TRADING_ENABLED=true</template>
|
||||
<template v-else-if="!apiConfigured">Bitget API 密钥未配置,请检查配置文件</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户信息 -->
|
||||
<div class="account-info" v-if="serviceEnabled">
|
||||
<div class="account-card">
|
||||
<div class="account-label">账户余额</div>
|
||||
<div class="account-value">${{ account.current_balance ? account.current_balance.toLocaleString() : '0' }}</div>
|
||||
<div class="account-sub">可用: ${{ account.available ? account.available.toLocaleString() : '0' }}</div>
|
||||
</div>
|
||||
<div class="account-card">
|
||||
<div class="account-label">已用保证金</div>
|
||||
<div class="account-value">${{ account.used_margin ? account.used_margin.toLocaleString() : '0' }}</div>
|
||||
</div>
|
||||
<div class="account-card">
|
||||
<div class="account-label">持仓价值</div>
|
||||
<div class="account-value">${{ account.total_position_value ? account.total_position_value.toLocaleString() : '0' }}</div>
|
||||
</div>
|
||||
<div class="account-card" v-if="stats">
|
||||
<div class="account-label">总盈亏</div>
|
||||
<div class="account-value" :class="stats.total_pnl >= 0 ? 'positive' : 'negative'">
|
||||
${{ stats.total_pnl ? stats.total_pnl.toLocaleString() : '0' }}
|
||||
</div>
|
||||
<div class="account-sub">{{ stats.total_trades || 0 }} 笔交易</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid" v-if="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">胜率</div>
|
||||
<div class="stat-value" :class="stats.win_rate >= 50 ? 'positive' : 'negative'">
|
||||
{{ stats.win_rate ? stats.win_rate.toFixed(1) : '0' }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">盈利交易</div>
|
||||
<div class="stat-value positive">
|
||||
{{ stats.winning_trades || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">亏损交易</div>
|
||||
<div class="stat-value negative">
|
||||
{{ stats.losing_trades || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">活跃订单</div>
|
||||
<div class="stat-value">
|
||||
{{ activeOrders.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: currentTab === 'active' }"
|
||||
@click="currentTab = 'active'">
|
||||
活跃订单 ({{ activeOrders.length }})
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: currentTab === 'history' }"
|
||||
@click="currentTab = 'history'">
|
||||
历史订单
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: currentTab === 'positions' }"
|
||||
@click="currentTab = 'positions'">
|
||||
交易所持仓
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<div class="orders-table-container">
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p style="margin-top: 12px;">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentTab === 'active' && activeOrders.length === 0" class="empty-state">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>暂无活跃订单</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentTab === 'history' && historyOrders.length === 0" class="empty-state">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>暂无历史订单</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentTab === 'positions' && exchangePositions.length === 0" class="empty-state">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>暂无持仓</p>
|
||||
</div>
|
||||
|
||||
<table class="orders-table" v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>交易对</th>
|
||||
<th>方向</th>
|
||||
<th>等级</th>
|
||||
<th>入场价</th>
|
||||
<th>当前价</th>
|
||||
<th>数量</th>
|
||||
<th>杠杆</th>
|
||||
<th>止损</th>
|
||||
<th>止盈</th>
|
||||
<th>盈亏</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in displayOrders" :key="order.order_id">
|
||||
<td><strong>{{ order.symbol }}</strong></td>
|
||||
<td>
|
||||
<span class="side-badge" :class="'side-' + order.side">
|
||||
{{ order.side === 'long' ? '做多' : '做空' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="grade-badge" :class="'grade-' + (order.signal_grade || 'D')">
|
||||
{{ order.signal_grade || 'D' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>${{ order.entry_price ? order.entry_price.toLocaleString() : '-' }}</td>
|
||||
<td>${{ order.current_price ? order.current_price.toLocaleString() : '-' }}</td>
|
||||
<td>${{ order.quantity ? order.quantity.toLocaleString() : '-' }}</td>
|
||||
<td>{{ order.leverage || 1 }}x</td>
|
||||
<td>${{ order.stop_loss ? order.stop_loss.toLocaleString() : '-' }}</td>
|
||||
<td>${{ order.take_profit ? order.take_profit.toLocaleString() : '-' }}</td>
|
||||
<td>
|
||||
<span v-if="order.pnl !== undefined" :class="order.pnl >= 0 ? 'pnl-positive' : 'pnl-negative'">
|
||||
{{ order.pnl >= 0 ? '+' : '' }}${{ order.pnl.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="'status-' + order.status">
|
||||
{{ formatStatus(order.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatTime(order.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
currentTab: 'active',
|
||||
loading: false,
|
||||
serviceEnabled: false,
|
||||
apiConfigured: false,
|
||||
useTestnet: true,
|
||||
account: {
|
||||
current_balance: 0,
|
||||
available: 0,
|
||||
used_margin: 0,
|
||||
total_position_value: 0
|
||||
},
|
||||
stats: null,
|
||||
activeOrders: [],
|
||||
historyOrders: [],
|
||||
exchangePositions: [],
|
||||
autoRefreshInterval: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
displayOrders() {
|
||||
if (this.currentTab === 'active') return this.activeOrders;
|
||||
if (this.currentTab === 'history') return this.historyOrders;
|
||||
if (this.currentTab === 'positions') return this.exchangePositions;
|
||||
return [];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async refreshData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
this.fetchServiceStatus(),
|
||||
this.fetchAccountStatus(),
|
||||
this.fetchStats(),
|
||||
this.fetchActiveOrders(),
|
||||
this.fetchHistoryOrders(),
|
||||
this.fetchExchangePositions()
|
||||
]);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchServiceStatus() {
|
||||
try {
|
||||
const response = await axios.get('/api/real-trading/status');
|
||||
if (response.data.success) {
|
||||
const status = response.data.status;
|
||||
this.serviceEnabled = status.enabled;
|
||||
this.apiConfigured = status.api_configured;
|
||||
this.useTestnet = status.use_testnet;
|
||||
if (status.account) {
|
||||
this.account = status.account;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取服务状态失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchAccountStatus() {
|
||||
try {
|
||||
const response = await axios.get('/api/real-trading/account');
|
||||
if (response.data.success) {
|
||||
this.account = response.data.account;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取账户状态失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
try {
|
||||
const response = await axios.get('/api/real-trading/stats');
|
||||
if (response.data.success) {
|
||||
this.stats = response.data.stats;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchActiveOrders() {
|
||||
try {
|
||||
const response = await axios.get('/api/real-trading/orders/active');
|
||||
if (response.data.success) {
|
||||
this.activeOrders = response.data.orders;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取活跃订单失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchHistoryOrders() {
|
||||
try {
|
||||
const response = await axios.get('/api/real-trading/orders?status=closed&limit=50');
|
||||
if (response.data.success) {
|
||||
this.historyOrders = response.data.orders;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史订单失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchExchangePositions() {
|
||||
try {
|
||||
const response = await axios.get('/api/real-trading/positions');
|
||||
if (response.data.success) {
|
||||
this.exchangePositions = response.data.positions;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取交易所持仓失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
formatTime(timeStr) {
|
||||
if (!timeStr) return '-';
|
||||
const date = new Date(timeStr);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
formatStatus(status) {
|
||||
const map = {
|
||||
'open': '持仓中',
|
||||
'pending': '挂单中',
|
||||
'closed': '已平仓',
|
||||
'cancelled': '已取消'
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.refreshData();
|
||||
// 每30秒自动刷新
|
||||
this.autoRefreshInterval = setInterval(() => {
|
||||
this.refreshData();
|
||||
}, 30000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.autoRefreshInterval) {
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
}
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user