a
This commit is contained in:
parent
ed9256899a
commit
7d0b575877
|
|
@ -90,6 +90,17 @@ async def get_realtime_account_data():
|
||||||
logger.error(f" ✗ 币安API连接失败: {e}", exc_info=True)
|
logger.error(f" ✗ 币安API连接失败: {e}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# 读取持仓模式(单向/对冲),用于前端“重点说明/自检”
|
||||||
|
dual_side_position = None
|
||||||
|
position_mode = None
|
||||||
|
try:
|
||||||
|
mode_res = await client.client.futures_get_position_mode()
|
||||||
|
if isinstance(mode_res, dict):
|
||||||
|
dual_side_position = bool(mode_res.get("dualSidePosition"))
|
||||||
|
position_mode = "hedge" if dual_side_position else "one_way"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"读取持仓模式失败(将显示为未知): {e}")
|
||||||
|
|
||||||
# 获取账户余额
|
# 获取账户余额
|
||||||
logger.info("步骤5: 获取账户余额...")
|
logger.info("步骤5: 获取账户余额...")
|
||||||
try:
|
try:
|
||||||
|
|
@ -194,7 +205,10 @@ async def get_realtime_account_data():
|
||||||
# 保证金占用(名义/杠杆汇总)
|
# 保证金占用(名义/杠杆汇总)
|
||||||
"total_margin_value": total_margin_value,
|
"total_margin_value": total_margin_value,
|
||||||
"total_pnl": total_pnl,
|
"total_pnl": total_pnl,
|
||||||
"open_positions": open_positions_count
|
"open_positions": open_positions_count,
|
||||||
|
# 账户持仓模式(重点:建议使用 one_way)
|
||||||
|
"position_mode": position_mode,
|
||||||
|
"dual_side_position": dual_side_position,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
@ -227,6 +241,7 @@ async def get_realtime_account():
|
||||||
@router.get("/positions")
|
@router.get("/positions")
|
||||||
async def get_realtime_positions():
|
async def get_realtime_positions():
|
||||||
"""获取实时持仓数据"""
|
"""获取实时持仓数据"""
|
||||||
|
client = None
|
||||||
try:
|
try:
|
||||||
# 从数据库读取API密钥
|
# 从数据库读取API密钥
|
||||||
api_key = TradingConfig.get_value('BINANCE_API_KEY')
|
api_key = TradingConfig.get_value('BINANCE_API_KEY')
|
||||||
|
|
@ -260,7 +275,6 @@ async def get_realtime_positions():
|
||||||
logger.info("连接币安API获取持仓...")
|
logger.info("连接币安API获取持仓...")
|
||||||
await client.connect()
|
await client.connect()
|
||||||
positions = await client.get_open_positions()
|
positions = await client.get_open_positions()
|
||||||
await client.disconnect()
|
|
||||||
|
|
||||||
logger.info(f"获取到 {len(positions)} 个持仓")
|
logger.info(f"获取到 {len(positions)} 个持仓")
|
||||||
|
|
||||||
|
|
@ -311,26 +325,45 @@ async def get_realtime_positions():
|
||||||
atr_value = None
|
atr_value = None
|
||||||
db_margin_usdt = None
|
db_margin_usdt = None
|
||||||
db_notional_usdt = None
|
db_notional_usdt = None
|
||||||
|
entry_order_id = None
|
||||||
|
entry_order_type = None
|
||||||
try:
|
try:
|
||||||
from database.models import Trade
|
from database.models import Trade
|
||||||
db_trades = Trade.get_by_symbol(pos.get('symbol'), status='open')
|
db_trades = Trade.get_by_symbol(pos.get('symbol'), status='open')
|
||||||
if db_trades:
|
if db_trades:
|
||||||
# 找到匹配的交易记录(通过symbol和entry_price匹配)
|
# 找到匹配的交易记录(优先通过 entry_price 近似匹配;否则取最新一条 open 记录兜底)
|
||||||
|
matched = None
|
||||||
for db_trade in db_trades:
|
for db_trade in db_trades:
|
||||||
|
try:
|
||||||
if abs(float(db_trade.get('entry_price', 0)) - entry_price) < 0.01:
|
if abs(float(db_trade.get('entry_price', 0)) - entry_price) < 0.01:
|
||||||
entry_time = db_trade.get('entry_time')
|
matched = db_trade
|
||||||
# 尝试从数据库获取止损止盈价格(如果存储了)
|
|
||||||
stop_loss_price = db_trade.get('stop_loss_price')
|
|
||||||
take_profit_price = db_trade.get('take_profit_price')
|
|
||||||
take_profit_1 = db_trade.get('take_profit_1')
|
|
||||||
take_profit_2 = db_trade.get('take_profit_2')
|
|
||||||
atr_value = db_trade.get('atr')
|
|
||||||
db_margin_usdt = db_trade.get('margin_usdt')
|
|
||||||
db_notional_usdt = db_trade.get('notional_usdt')
|
|
||||||
break
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if matched is None:
|
||||||
|
matched = db_trades[0]
|
||||||
|
|
||||||
|
entry_time = matched.get('entry_time')
|
||||||
|
stop_loss_price = matched.get('stop_loss_price')
|
||||||
|
take_profit_price = matched.get('take_profit_price')
|
||||||
|
take_profit_1 = matched.get('take_profit_1')
|
||||||
|
take_profit_2 = matched.get('take_profit_2')
|
||||||
|
atr_value = matched.get('atr')
|
||||||
|
db_margin_usdt = matched.get('margin_usdt')
|
||||||
|
db_notional_usdt = matched.get('notional_usdt')
|
||||||
|
entry_order_id = matched.get('entry_order_id')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"获取数据库信息失败: {e}")
|
logger.debug(f"获取数据库信息失败: {e}")
|
||||||
|
|
||||||
|
# 如果数据库中有 entry_order_id,尝试从币安查询订单类型(LIMIT/MARKET)
|
||||||
|
if entry_order_id:
|
||||||
|
try:
|
||||||
|
info = await client.client.futures_get_order(symbol=pos.get('symbol'), orderId=int(entry_order_id))
|
||||||
|
if isinstance(info, dict):
|
||||||
|
entry_order_type = info.get("type")
|
||||||
|
except Exception:
|
||||||
|
entry_order_type = None
|
||||||
|
|
||||||
# 如果没有从数据库获取到止损止盈价格,前端会自己计算
|
# 如果没有从数据库获取到止损止盈价格,前端会自己计算
|
||||||
# 注意:数据库可能没有存储止损止盈价格,这是正常的
|
# 注意:数据库可能没有存储止损止盈价格,这是正常的
|
||||||
|
|
||||||
|
|
@ -357,6 +390,8 @@ async def get_realtime_positions():
|
||||||
"take_profit_1": take_profit_1,
|
"take_profit_1": take_profit_1,
|
||||||
"take_profit_2": take_profit_2,
|
"take_profit_2": take_profit_2,
|
||||||
"atr": atr_value,
|
"atr": atr_value,
|
||||||
|
"entry_order_id": entry_order_id,
|
||||||
|
"entry_order_type": entry_order_type, # LIMIT / MARKET(用于仪表板展示“限价/市价”)
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(f"格式化后 {len(formatted_positions)} 个有效持仓")
|
logger.info(f"格式化后 {len(formatted_positions)} 个有效持仓")
|
||||||
|
|
@ -367,6 +402,13 @@ async def get_realtime_positions():
|
||||||
error_msg = f"获取持仓数据失败: {str(e)}"
|
error_msg = f"获取持仓数据失败: {str(e)}"
|
||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=error_msg)
|
raise HTTPException(status_code=500, detail=error_msg)
|
||||||
|
finally:
|
||||||
|
# 确保断开连接(避免连接泄漏)
|
||||||
|
try:
|
||||||
|
if client is not None:
|
||||||
|
await client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.post("/positions/{symbol}/close")
|
@router.post("/positions/{symbol}/close")
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,99 @@
|
||||||
color: #34495e;
|
color: #34495e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-notice {
|
||||||
|
margin: 12px 0 18px;
|
||||||
|
padding: 14px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-notice.important {
|
||||||
|
border-color: #ffe8a1;
|
||||||
|
background: #fff8db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7a5a00;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
color: #5c4a00;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-label {
|
||||||
|
min-width: 70px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-value.ok {
|
||||||
|
color: #1e7e34;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-value.warn {
|
||||||
|
color: #b04a00;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-text {
|
||||||
|
color: #5c4a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-type-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-type-line {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #444;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-type-badge.limit {
|
||||||
|
background: rgba(33, 150, 243, 0.12);
|
||||||
|
color: #1565c0;
|
||||||
|
border-color: rgba(21, 101, 192, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-type-badge.market {
|
||||||
|
background: rgba(255, 152, 0, 0.14);
|
||||||
|
color: #8a4f00;
|
||||||
|
border-color: rgba(138, 79, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-type-badge.unknown {
|
||||||
|
background: rgba(158, 158, 158, 0.14);
|
||||||
|
color: #555;
|
||||||
|
border-color: rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.account-info {
|
.account-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ const StatsDashboard = () => {
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [tradingConfig, setTradingConfig] = useState(null)
|
const [tradingConfig, setTradingConfig] = useState(null)
|
||||||
|
|
||||||
|
const getCfgVal = (key, fallback = null) => {
|
||||||
|
try {
|
||||||
|
const cfg = tradingConfig || {}
|
||||||
|
const item = cfg?.[key]
|
||||||
|
if (item && typeof item === 'object' && 'value' in item) return item.value
|
||||||
|
return fallback
|
||||||
|
} catch (e) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDashboard()
|
loadDashboard()
|
||||||
loadTradingConfig()
|
loadTradingConfig()
|
||||||
|
|
@ -33,9 +44,9 @@ const StatsDashboard = () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getDashboard()
|
const data = await api.getDashboard()
|
||||||
setDashboardData(data)
|
setDashboardData(data)
|
||||||
// 如果dashboard数据中包含配置,也更新配置状态
|
// dashboard 的 trading_config 可能是“子集”,不要覆盖完整配置;合并即可
|
||||||
if (data.trading_config) {
|
if (data.trading_config) {
|
||||||
setTradingConfig(data.trading_config)
|
setTradingConfig((prev) => ({ ...(prev || {}), ...(data.trading_config || {}) }))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dashboard:', error)
|
console.error('Failed to load dashboard:', error)
|
||||||
|
|
@ -123,6 +134,16 @@ const StatsDashboard = () => {
|
||||||
|
|
||||||
const account = dashboardData?.account
|
const account = dashboardData?.account
|
||||||
const openTrades = dashboardData?.open_trades || []
|
const openTrades = dashboardData?.open_trades || []
|
||||||
|
const entryTypeCounts = openTrades.reduce(
|
||||||
|
(acc, t) => {
|
||||||
|
const tp = String(t?.entry_order_type || '').toUpperCase()
|
||||||
|
if (tp === 'LIMIT') acc.limit += 1
|
||||||
|
else if (tp === 'MARKET') acc.market += 1
|
||||||
|
else acc.unknown += 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ limit: 0, market: 0, unknown: 0 }
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
|
|
@ -151,6 +172,38 @@ const StatsDashboard = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="dashboard-notice important">
|
||||||
|
<div className="notice-title">重要说明:币安账户请使用「单向持仓模式(One-way)」</div>
|
||||||
|
<div className="notice-body">
|
||||||
|
<div className="notice-row">
|
||||||
|
<span className="notice-label">当前检测:</span>
|
||||||
|
<span className={`notice-value ${account?.position_mode === 'one_way' ? 'ok' : 'warn'}`}>
|
||||||
|
{account?.position_mode === 'one_way'
|
||||||
|
? '单向(推荐)'
|
||||||
|
: account?.position_mode === 'hedge'
|
||||||
|
? '对冲(不推荐,容易出现 positionSide/mode 相关错误)'
|
||||||
|
: '未知(建议重启后端/交易系统后再看)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="notice-row">
|
||||||
|
<span className="notice-label">操作指引:</span>
|
||||||
|
<span className="notice-text">
|
||||||
|
币安 U本位合约 → 偏好设置/设置 → 持仓模式(Position Mode) → 选择「单向」。切换通常要求当前无持仓/无挂单。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="notice-row">
|
||||||
|
<span className="notice-label">入场逻辑:</span>
|
||||||
|
<span className="notice-text">
|
||||||
|
智能入场(方案C):先限价回调入场,未成交会有限追价;趋势强时在偏离可控范围内可市价兜底(减少错过)。
|
||||||
|
当前配置:SMART_ENTRY_ENABLED={String(getCfgVal('SMART_ENTRY_ENABLED', true))},
|
||||||
|
LIMIT_ORDER_OFFSET_PCT={String(getCfgVal('LIMIT_ORDER_OFFSET_PCT', '0.5'))},
|
||||||
|
ENTRY_MAX_DRIFT_PCT_TRENDING={String(getCfgVal('ENTRY_MAX_DRIFT_PCT_TRENDING', '0.6'))},
|
||||||
|
ENTRY_MAX_DRIFT_PCT_RANGING={String(getCfgVal('ENTRY_MAX_DRIFT_PCT_RANGING', '0.3'))}。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<div className="dashboard-card">
|
<div className="dashboard-card">
|
||||||
<h3>账户信息</h3>
|
<h3>账户信息</h3>
|
||||||
|
|
@ -184,6 +237,12 @@ const StatsDashboard = () => {
|
||||||
<span className="label">持仓数量:</span>
|
<span className="label">持仓数量:</span>
|
||||||
<span className="value">{account.open_positions}</span>
|
<span className="value">{account.open_positions}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="label">持仓模式:</span>
|
||||||
|
<span className={`value ${account.position_mode === 'one_way' ? 'positive' : 'negative'}`}>
|
||||||
|
{account.position_mode === 'one_way' ? '单向' : account.position_mode === 'hedge' ? '对冲' : '未知'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>暂无数据</div>
|
<div>暂无数据</div>
|
||||||
|
|
@ -243,6 +302,11 @@ const StatsDashboard = () => {
|
||||||
|
|
||||||
<div className="dashboard-card">
|
<div className="dashboard-card">
|
||||||
<h3>当前持仓</h3>
|
<h3>当前持仓</h3>
|
||||||
|
<div className="entry-type-summary">
|
||||||
|
<span className="entry-type-badge limit">限价入场: {entryTypeCounts.limit}</span>
|
||||||
|
<span className="entry-type-badge market">市价入场: {entryTypeCounts.market}</span>
|
||||||
|
<span className="entry-type-badge unknown">未知: {entryTypeCounts.unknown}</span>
|
||||||
|
</div>
|
||||||
{openTrades.length > 0 ? (
|
{openTrades.length > 0 ? (
|
||||||
<div className="trades-list">
|
<div className="trades-list">
|
||||||
{openTrades.map((trade, index) => {
|
{openTrades.map((trade, index) => {
|
||||||
|
|
@ -396,6 +460,25 @@ const StatsDashboard = () => {
|
||||||
{trade.side}
|
{trade.side}
|
||||||
</div>
|
</div>
|
||||||
<div className="trade-info">
|
<div className="trade-info">
|
||||||
|
<div className="entry-type-line">
|
||||||
|
入场类型:{' '}
|
||||||
|
<span
|
||||||
|
className={`entry-type-badge ${
|
||||||
|
String(trade.entry_order_type || '').toUpperCase() === 'LIMIT'
|
||||||
|
? 'limit'
|
||||||
|
: String(trade.entry_order_type || '').toUpperCase() === 'MARKET'
|
||||||
|
? 'market'
|
||||||
|
: 'unknown'
|
||||||
|
}`}
|
||||||
|
title="来自币安 entry_order_id 查询的订单类型"
|
||||||
|
>
|
||||||
|
{String(trade.entry_order_type || '').toUpperCase() === 'LIMIT'
|
||||||
|
? '限价'
|
||||||
|
: String(trade.entry_order_type || '').toUpperCase() === 'MARKET'
|
||||||
|
? '市价'
|
||||||
|
: '未知'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div>数量: {parseFloat(trade.quantity || 0).toFixed(4)}</div>
|
<div>数量: {parseFloat(trade.quantity || 0).toFixed(4)}</div>
|
||||||
<div>名义: {entryValue >= 0.01 ? entryValue.toFixed(2) : entryValue.toFixed(4)} USDT</div>
|
<div>名义: {entryValue >= 0.01 ? entryValue.toFixed(2) : entryValue.toFixed(4)} USDT</div>
|
||||||
<div>保证金: {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT</div>
|
<div>保证金: {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user