1
This commit is contained in:
parent
ca959c1f8a
commit
ca0bbeddbf
|
|
@ -599,6 +599,55 @@ async def get_realtime_account(account_id: int = Depends(get_account_id)):
|
||||||
return await get_realtime_account_data(account_id=account_id)
|
return await get_realtime_account_data(account_id=account_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_live_positions_pnl(account_id: int):
|
||||||
|
"""
|
||||||
|
获取指定账号的实时持仓盈亏(仅 mark_price / pnl / pnl_percent),供仪表板合并用。
|
||||||
|
失败时返回空列表,不抛异常。
|
||||||
|
"""
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
|
||||||
|
if not api_key or not api_secret:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
from binance_client import BinanceClient
|
||||||
|
except ImportError:
|
||||||
|
trading_system_path = project_root / 'trading_system'
|
||||||
|
sys.path.insert(0, str(trading_system_path))
|
||||||
|
from binance_client import BinanceClient
|
||||||
|
client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet)
|
||||||
|
await client.connect()
|
||||||
|
positions = await client.get_open_positions()
|
||||||
|
result = []
|
||||||
|
for pos in positions:
|
||||||
|
amt = float(pos.get('positionAmt', 0))
|
||||||
|
if amt == 0:
|
||||||
|
continue
|
||||||
|
entry_price = float(pos.get('entryPrice', 0))
|
||||||
|
mark_price = float(pos.get('markPrice', 0)) or entry_price
|
||||||
|
unrealized_pnl = float(pos.get('unRealizedProfit', 0))
|
||||||
|
leverage = max(1, float(pos.get('leverage', 1)))
|
||||||
|
notional = abs(amt) * mark_price
|
||||||
|
margin = notional / leverage
|
||||||
|
pnl_percent = (unrealized_pnl / margin * 100) if margin > 0 else 0
|
||||||
|
result.append({
|
||||||
|
"symbol": pos.get("symbol"),
|
||||||
|
"mark_price": mark_price,
|
||||||
|
"pnl": unrealized_pnl,
|
||||||
|
"pnl_percent": pnl_percent,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"fetch_live_positions_pnl(account_id={account_id}) 失败: {e}")
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if client is not None:
|
||||||
|
await client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.get("/positions")
|
@router.get("/positions")
|
||||||
async def get_realtime_positions(account_id: int = Depends(get_account_id)):
|
async def get_realtime_positions(account_id: int = Depends(get_account_id)):
|
||||||
"""获取实时持仓数据"""
|
"""获取实时持仓数据"""
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,13 @@ async def grant_user_account(user_id: int, account_id: int, payload: GrantReq, _
|
||||||
a = Account.get(int(account_id))
|
a = Account.get(int(account_id))
|
||||||
if not a:
|
if not a:
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
UserAccountMembership.add(int(user_id), int(account_id), role=payload.role)
|
try:
|
||||||
|
UserAccountMembership.add(int(user_id), int(account_id), role=payload.role)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"关联账号失败: {str(e)}",
|
||||||
|
)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,11 +171,27 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)):
|
||||||
formatted_trade = {
|
formatted_trade = {
|
||||||
**trade,
|
**trade,
|
||||||
'entry_value_usdt': entry_value_usdt,
|
'entry_value_usdt': entry_value_usdt,
|
||||||
'mark_price': trade.get('entry_price', 0), # 数据库中没有标记价,使用入场价
|
'mark_price': trade.get('entry_price', 0), # 默认入场价,下面用实时数据覆盖
|
||||||
'pnl': pnl,
|
'pnl': pnl,
|
||||||
'pnl_percent': pnl_percent # 使用重新计算的收益率
|
'pnl_percent': pnl_percent
|
||||||
}
|
}
|
||||||
open_trades.append(formatted_trade)
|
open_trades.append(formatted_trade)
|
||||||
|
# 合并实时持仓盈亏(mark_price / pnl / pnl_percent),仪表板可显示浮盈浮亏
|
||||||
|
try:
|
||||||
|
from api.routes.account import fetch_live_positions_pnl
|
||||||
|
live_list = await fetch_live_positions_pnl(account_id)
|
||||||
|
by_symbol = {p["symbol"]: p for p in live_list}
|
||||||
|
for t in open_trades:
|
||||||
|
sym = t.get("symbol")
|
||||||
|
if sym and sym in by_symbol:
|
||||||
|
lp = by_symbol[sym]
|
||||||
|
t["mark_price"] = lp.get("mark_price", t.get("entry_price"))
|
||||||
|
t["pnl"] = lp.get("pnl", 0)
|
||||||
|
t["pnl_percent"] = lp.get("pnl_percent", 0)
|
||||||
|
if by_symbol:
|
||||||
|
logger.info(f"已合并 {len(by_symbol)} 个实时持仓盈亏到仪表板")
|
||||||
|
except Exception as merge_err:
|
||||||
|
logger.debug(f"合并实时持仓盈亏失败(仪表板仍显示持仓,盈亏为 0): {merge_err}")
|
||||||
logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓")
|
logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓")
|
||||||
except Exception as db_error:
|
except Exception as db_error:
|
||||||
logger.error(f"从数据库获取持仓记录失败: {db_error}")
|
logger.error(f"从数据库获取持仓记录失败: {db_error}")
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,50 @@
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.users-section-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.users-section-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user-block {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user-title {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user-desc {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user-card {
|
||||||
|
height: auto;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user-card .create-user-card-heading {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.user-group-card {
|
.user-group-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,14 @@ const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => {
|
||||||
|
|
||||||
const handleGrant = async () => {
|
const handleGrant = async () => {
|
||||||
if (!linkAccountId) return
|
if (!linkAccountId) return
|
||||||
|
const accountId = Number(linkAccountId)
|
||||||
|
if (!Number.isInteger(accountId) || accountId < 1) {
|
||||||
|
alert('请选择有效的账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
setAssociating(true)
|
setAssociating(true)
|
||||||
try {
|
try {
|
||||||
await api.grantUserAccount(user.id, linkAccountId, linkRole)
|
await api.grantUserAccount(user.id, accountId, linkRole)
|
||||||
setLinkAccountId('')
|
setLinkAccountId('')
|
||||||
if (onServiceAction) onServiceAction(null, 'refresh')
|
if (onServiceAction) onServiceAction(null, 'refresh')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -141,7 +146,7 @@ const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => {
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
onClick={() => handleServiceAction(acc.id, acc.serviceStatus?.running ? 'stop' : 'start')}
|
onClick={() => onServiceAction && onServiceAction(acc.id, acc.serviceStatus?.running ? 'stop' : 'start')}
|
||||||
title={acc.serviceStatus?.running ? '停止服务' : '启动服务'}
|
title={acc.serviceStatus?.running ? '停止服务' : '启动服务'}
|
||||||
>
|
>
|
||||||
{acc.serviceStatus?.running ? '⏹' : '▶'}
|
{acc.serviceStatus?.running ? '⏹' : '▶'}
|
||||||
|
|
@ -199,6 +204,94 @@ const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CreateUserForm = ({ onSuccess }) => {
|
||||||
|
const [form, setForm] = useState({ username: '', password: '', role: 'user', status: 'active' })
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!form.username.trim()) {
|
||||||
|
setMessage('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.password.trim()) {
|
||||||
|
setMessage('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await api.createUser({
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password,
|
||||||
|
role: form.role,
|
||||||
|
status: form.status,
|
||||||
|
})
|
||||||
|
setMessage('用户已创建')
|
||||||
|
setForm({ username: '', password: '', role: 'user', status: 'active' })
|
||||||
|
if (onSuccess) onSuccess()
|
||||||
|
} catch (e) {
|
||||||
|
setMessage('创建用户失败: ' + (e?.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card create-user-card">
|
||||||
|
<h4 className="create-user-card-heading">新建用户</h4>
|
||||||
|
{message && (
|
||||||
|
<div className={`message ${message.includes('失败') ? 'error' : 'success'}`}>{message}</div>
|
||||||
|
)}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||||
|
placeholder="登录用,例如:trader1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
placeholder="设置登录密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>角色</label>
|
||||||
|
<select
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="user">普通用户 (user)</option>
|
||||||
|
<option value="admin">管理员 (admin)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>状态</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="active">启用</option>
|
||||||
|
<option value="disabled">禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn-primary full-width"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={busy || !form.username.trim() || !form.password}
|
||||||
|
>
|
||||||
|
{busy ? '创建中...' : '创建用户'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const AccountManager = ({ accounts, onRefresh }) => {
|
const AccountManager = ({ accounts, onRefresh }) => {
|
||||||
const [newAccount, setNewAccount] = useState({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' })
|
const [newAccount, setNewAccount] = useState({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' })
|
||||||
const [credEditId, setCredEditId] = useState(null)
|
const [credEditId, setCredEditId] = useState(null)
|
||||||
|
|
@ -561,15 +654,22 @@ const AdminDashboard = () => {
|
||||||
|
|
||||||
<div className="users-section">
|
<div className="users-section">
|
||||||
<h3>用户管理 ({users.length})</h3>
|
<h3>用户管理 ({users.length})</h3>
|
||||||
<div className="users-list">
|
<div className="users-section-grid">
|
||||||
{users.map(user => (
|
<div className="create-user-block">
|
||||||
<UserAccountGroup
|
<h4 className="create-user-title">➕ 添加用户</h4>
|
||||||
key={user.id}
|
<p className="create-user-desc">创建新用户后,可在下方为其关联交易账号。</p>
|
||||||
user={user}
|
<CreateUserForm onSuccess={loadData} />
|
||||||
allAccounts={data.allAccounts}
|
</div>
|
||||||
onServiceAction={handleServiceAction}
|
<div className="users-list">
|
||||||
/>
|
{users.map(user => (
|
||||||
))}
|
<UserAccountGroup
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
allAccounts={data.allAccounts}
|
||||||
|
onServiceAction={handleServiceAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trade-index {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.trade-symbol {
|
.trade-symbol {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
|
|
|
||||||
|
|
@ -677,6 +677,7 @@ const StatsDashboard = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={trade.id || trade.symbol || index} className="trade-item">
|
<div key={trade.id || trade.symbol || index} className="trade-item">
|
||||||
|
<div className="trade-index" title="序号">{index + 1}</div>
|
||||||
<div className="trade-symbol">{trade.symbol}</div>
|
<div className="trade-symbol">{trade.symbol}</div>
|
||||||
<div className={`trade-side ${trade.side === 'BUY' ? 'buy' : 'sell'}`}>
|
<div className={`trade-side ${trade.side === 'BUY' ? 'buy' : 'sell'}`}>
|
||||||
{trade.side}
|
{trade.side}
|
||||||
|
|
|
||||||
|
|
@ -814,10 +814,12 @@ export const api = {
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
grantUserAccount: async (userId, accountId, role = 'viewer') => {
|
grantUserAccount: async (userId, accountId, role = 'viewer') => {
|
||||||
const response = await fetch(buildUrl(`/api/admin/users/${userId}/accounts/${accountId}`), {
|
const uid = Number(userId);
|
||||||
|
const aid = Number(accountId);
|
||||||
|
const response = await fetch(buildUrl(`/api/admin/users/${uid}/accounts/${aid}`), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: withAuthHeaders({ 'Content-Type': 'application/json' }),
|
headers: withAuthHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ role }),
|
body: JSON.stringify({ role: role === 'owner' ? 'owner' : 'viewer' }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: '授权失败' }));
|
const error = await response.json().catch(() => ({ detail: '授权失败' }));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user