feat: live API endpoints (/api/live/*)
- /api/live/summary: 实盘总览(含风控状态+手续费+资金费) - /api/live/positions: 当前持仓(含binance_order_id/滑点/裸奔时间/持仓时长) - /api/live/trades: 历史交易(含成交价/滑点/手续费/资金费) - /api/live/equity-curve: 权益曲线 - /api/live/stats: 详细统计(含滑点P50/P95/按币种) - /api/live/risk-status: 风控状态读取 - POST /api/live/emergency-close: 紧急全平 - POST /api/live/block-new: 禁止新开仓 - POST /api/live/resume: 恢复交易
This commit is contained in:
parent
b08ea8f772
commit
832f78a1d7
287
backend/main.py
287
backend/main.py
@ -1178,3 +1178,290 @@ async def get_server_status(user: dict = Depends(get_current_user)):
|
|||||||
_server_cache["data"] = result
|
_server_cache["data"] = result
|
||||||
_server_cache["ts"] = now
|
_server_cache["ts"] = now
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 实盘 API(/api/live/...)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@app.get("/api/live/summary")
|
||||||
|
async def live_summary(
|
||||||
|
strategy: str = "v52_8signals",
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""实盘总览"""
|
||||||
|
closed = await async_fetch(
|
||||||
|
"SELECT pnl_r, direction, fee_usdt, funding_fee_usdt, slippage_bps "
|
||||||
|
"FROM live_trades WHERE status NOT IN ('active','tp1_hit') AND strategy = $1",
|
||||||
|
strategy,
|
||||||
|
)
|
||||||
|
active = await async_fetch(
|
||||||
|
"SELECT id FROM live_trades WHERE status IN ('active','tp1_hit') AND strategy = $1",
|
||||||
|
strategy,
|
||||||
|
)
|
||||||
|
first = await async_fetchrow(
|
||||||
|
"SELECT MIN(created_at) as start FROM live_trades WHERE strategy = $1",
|
||||||
|
strategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(closed)
|
||||||
|
wins = len([r for r in closed if r["pnl_r"] and r["pnl_r"] > 0])
|
||||||
|
total_pnl = sum(r["pnl_r"] for r in closed if r["pnl_r"])
|
||||||
|
total_fee = sum(r["fee_usdt"] or 0 for r in closed)
|
||||||
|
total_funding = sum(r["funding_fee_usdt"] or 0 for r in closed)
|
||||||
|
win_rate = (wins / total * 100) if total > 0 else 0
|
||||||
|
gross_profit = sum(r["pnl_r"] for r in closed if r["pnl_r"] and r["pnl_r"] > 0)
|
||||||
|
gross_loss = abs(sum(r["pnl_r"] for r in closed if r["pnl_r"] and r["pnl_r"] <= 0))
|
||||||
|
profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else 0
|
||||||
|
|
||||||
|
# 读风控状态
|
||||||
|
risk_status = {}
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
with open("/tmp/risk_guard_state.json") as f:
|
||||||
|
risk_status = _json.load(f)
|
||||||
|
except:
|
||||||
|
risk_status = {"status": "unknown"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_trades": total,
|
||||||
|
"win_rate": round(win_rate, 1),
|
||||||
|
"total_pnl_r": round(total_pnl, 2),
|
||||||
|
"total_pnl_usdt": round(total_pnl * 2, 2), # $2=1R
|
||||||
|
"active_positions": len(active),
|
||||||
|
"profit_factor": round(profit_factor, 2),
|
||||||
|
"total_fee_usdt": round(total_fee, 2),
|
||||||
|
"total_funding_usdt": round(total_funding, 2),
|
||||||
|
"start_time": str(first["start"]) if first and first["start"] else None,
|
||||||
|
"risk_status": risk_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/live/positions")
|
||||||
|
async def live_positions(
|
||||||
|
strategy: str = "v52_8signals",
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""实盘当前持仓"""
|
||||||
|
rows = await async_fetch(
|
||||||
|
"SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, "
|
||||||
|
"tp1_price, tp2_price, sl_price, tp1_hit, status, risk_distance, "
|
||||||
|
"binance_order_id, fill_price, slippage_bps, protection_gap_ms, "
|
||||||
|
"signal_to_order_ms, order_to_fill_ms, score_factors "
|
||||||
|
"FROM live_trades WHERE status IN ('active','tp1_hit') AND strategy = $1 "
|
||||||
|
"ORDER BY entry_ts DESC",
|
||||||
|
strategy,
|
||||||
|
)
|
||||||
|
# 实时价格
|
||||||
|
prices = {}
|
||||||
|
symbols_needed = list(set(r["symbol"] for r in rows))
|
||||||
|
if symbols_needed:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
resp = await client.get("https://fapi.binance.com/fapi/v1/ticker/price")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
for item in resp.json():
|
||||||
|
if item["symbol"] in symbols_needed:
|
||||||
|
prices[item["symbol"]] = float(item["price"])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
current_price = prices.get(r["symbol"], 0)
|
||||||
|
d["current_price"] = current_price
|
||||||
|
entry = r["entry_price"] or 0
|
||||||
|
rd = r.get("risk_distance") or 1
|
||||||
|
if rd > 0 and entry > 0 and current_price > 0:
|
||||||
|
if r["direction"] == "LONG":
|
||||||
|
d["unrealized_pnl_r"] = round((current_price - entry) / rd, 4)
|
||||||
|
else:
|
||||||
|
d["unrealized_pnl_r"] = round((entry - current_price) / rd, 4)
|
||||||
|
d["unrealized_pnl_usdt"] = round(d["unrealized_pnl_r"] * 2, 2)
|
||||||
|
else:
|
||||||
|
d["unrealized_pnl_r"] = 0
|
||||||
|
d["unrealized_pnl_usdt"] = 0
|
||||||
|
# 持仓时间
|
||||||
|
if r["entry_ts"]:
|
||||||
|
import time as _time
|
||||||
|
d["hold_time_min"] = round((_time.time() * 1000 - r["entry_ts"]) / 60000, 1)
|
||||||
|
result.append(d)
|
||||||
|
return {"data": result}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/live/trades")
|
||||||
|
async def live_trades(
|
||||||
|
symbol: str = "all",
|
||||||
|
result: str = "all",
|
||||||
|
strategy: str = "v52_8signals",
|
||||||
|
limit: int = 100,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""实盘历史交易"""
|
||||||
|
conditions = ["status NOT IN ('active','tp1_hit')"]
|
||||||
|
params = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
conditions.append(f"strategy = ${idx}")
|
||||||
|
params.append(strategy)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if symbol != "all":
|
||||||
|
conditions.append(f"symbol = ${idx}")
|
||||||
|
params.append(symbol.upper() + "USDT")
|
||||||
|
idx += 1
|
||||||
|
if result == "win":
|
||||||
|
conditions.append("pnl_r > 0")
|
||||||
|
elif result == "loss":
|
||||||
|
conditions.append("pnl_r <= 0")
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
params.append(limit)
|
||||||
|
rows = await async_fetch(
|
||||||
|
f"SELECT id, symbol, direction, score, tier, strategy, entry_price, exit_price, "
|
||||||
|
f"entry_ts, exit_ts, pnl_r, status, tp1_hit, score_factors, "
|
||||||
|
f"binance_order_id, fill_price, slippage_bps, fee_usdt, funding_fee_usdt, "
|
||||||
|
f"protection_gap_ms, signal_to_order_ms, order_to_fill_ms "
|
||||||
|
f"FROM live_trades WHERE {where} ORDER BY exit_ts DESC LIMIT ${idx}",
|
||||||
|
*params
|
||||||
|
)
|
||||||
|
return {"count": len(rows), "data": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/live/equity-curve")
|
||||||
|
async def live_equity_curve(
|
||||||
|
strategy: str = "v52_8signals",
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""实盘权益曲线"""
|
||||||
|
rows = await async_fetch(
|
||||||
|
"SELECT exit_ts, pnl_r FROM live_trades "
|
||||||
|
"WHERE status NOT IN ('active','tp1_hit') AND strategy = $1 ORDER BY exit_ts ASC",
|
||||||
|
strategy,
|
||||||
|
)
|
||||||
|
cumulative = 0.0
|
||||||
|
curve = []
|
||||||
|
for r in rows:
|
||||||
|
cumulative += r["pnl_r"] or 0
|
||||||
|
curve.append({"ts": r["exit_ts"], "pnl": round(cumulative, 2)})
|
||||||
|
return {"data": curve}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/live/stats")
|
||||||
|
async def live_stats(
|
||||||
|
strategy: str = "v52_8signals",
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""实盘详细统计"""
|
||||||
|
rows = await async_fetch(
|
||||||
|
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts, slippage_bps "
|
||||||
|
"FROM live_trades WHERE status NOT IN ('active','tp1_hit') AND strategy = $1",
|
||||||
|
strategy,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return {"error": "no data"}
|
||||||
|
|
||||||
|
total = len(rows)
|
||||||
|
wins = [r for r in rows if r["pnl_r"] and r["pnl_r"] > 0]
|
||||||
|
losses = [r for r in rows if r["pnl_r"] and r["pnl_r"] <= 0]
|
||||||
|
win_rate = len(wins) / total * 100 if total > 0 else 0
|
||||||
|
avg_win = sum(r["pnl_r"] for r in wins) / len(wins) if wins else 0
|
||||||
|
avg_loss = sum(abs(r["pnl_r"]) for r in losses) / len(losses) if losses else 0
|
||||||
|
win_loss_ratio = avg_win / avg_loss if avg_loss > 0 else 0
|
||||||
|
total_pnl = sum(r["pnl_r"] for r in rows if r["pnl_r"])
|
||||||
|
|
||||||
|
# 滑点统计
|
||||||
|
slippages = [r["slippage_bps"] for r in rows if r["slippage_bps"] is not None]
|
||||||
|
avg_slippage = sum(slippages) / len(slippages) if slippages else 0
|
||||||
|
slippages_sorted = sorted(slippages) if slippages else [0]
|
||||||
|
p50_slip = slippages_sorted[len(slippages_sorted)//2] if slippages_sorted else 0
|
||||||
|
p95_idx = min(int(len(slippages_sorted)*0.95), len(slippages_sorted)-1)
|
||||||
|
p95_slip = slippages_sorted[p95_idx] if slippages_sorted else 0
|
||||||
|
|
||||||
|
# MDD
|
||||||
|
cum = 0
|
||||||
|
peak = 0
|
||||||
|
mdd = 0
|
||||||
|
for r in sorted(rows, key=lambda x: x["exit_ts"] or 0):
|
||||||
|
cum += r["pnl_r"] or 0
|
||||||
|
if cum > peak:
|
||||||
|
peak = cum
|
||||||
|
dd = peak - cum
|
||||||
|
if dd > mdd:
|
||||||
|
mdd = dd
|
||||||
|
|
||||||
|
# 按币种
|
||||||
|
by_symbol = {}
|
||||||
|
for r in rows:
|
||||||
|
s = r["symbol"]
|
||||||
|
if s not in by_symbol:
|
||||||
|
by_symbol[s] = {"wins": 0, "total": 0, "pnl": 0}
|
||||||
|
by_symbol[s]["total"] += 1
|
||||||
|
by_symbol[s]["pnl"] += r["pnl_r"] or 0
|
||||||
|
if r["pnl_r"] and r["pnl_r"] > 0:
|
||||||
|
by_symbol[s]["wins"] += 1
|
||||||
|
for s in by_symbol:
|
||||||
|
by_symbol[s]["win_rate"] = round(by_symbol[s]["wins"]/by_symbol[s]["total"]*100, 1) if by_symbol[s]["total"] > 0 else 0
|
||||||
|
by_symbol[s]["total_pnl"] = round(by_symbol[s]["pnl"], 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"win_rate": round(win_rate, 1),
|
||||||
|
"avg_win": round(avg_win, 3),
|
||||||
|
"avg_loss": round(avg_loss, 3),
|
||||||
|
"win_loss_ratio": round(win_loss_ratio, 2),
|
||||||
|
"total_pnl": round(total_pnl, 2),
|
||||||
|
"mdd": round(mdd, 2),
|
||||||
|
"avg_slippage_bps": round(avg_slippage, 2),
|
||||||
|
"p50_slippage_bps": round(p50_slip, 2),
|
||||||
|
"p95_slippage_bps": round(p95_slip, 2),
|
||||||
|
"by_symbol": by_symbol,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/live/risk-status")
|
||||||
|
async def live_risk_status(user: dict = Depends(get_current_user)):
|
||||||
|
"""风控状态"""
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
with open("/tmp/risk_guard_state.json") as f:
|
||||||
|
return _json.load(f)
|
||||||
|
except:
|
||||||
|
return {"status": "unknown", "error": "risk_guard_state.json not found"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/live/emergency-close")
|
||||||
|
async def live_emergency_close(user: dict = Depends(get_current_user)):
|
||||||
|
"""紧急全平(写标记文件,由risk_guard执行)"""
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
with open("/tmp/risk_guard_emergency.json", "w") as f:
|
||||||
|
_json.dump({"action": "close_all", "time": time.time(), "user": user.get("email", "unknown")}, f)
|
||||||
|
return {"ok": True, "message": "紧急平仓指令已发送"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/live/block-new")
|
||||||
|
async def live_block_new(user: dict = Depends(get_current_user)):
|
||||||
|
"""禁止新开仓"""
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
with open("/tmp/risk_guard_emergency.json", "w") as f:
|
||||||
|
_json.dump({"action": "block_new", "time": time.time(), "user": user.get("email", "unknown")}, f)
|
||||||
|
return {"ok": True, "message": "已禁止新开仓"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/live/resume")
|
||||||
|
async def live_resume(user: dict = Depends(get_current_user)):
|
||||||
|
"""恢复交易"""
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
with open("/tmp/risk_guard_emergency.json", "w") as f:
|
||||||
|
_json.dump({"action": "resume", "time": time.time(), "user": user.get("email", "unknown")}, f)
|
||||||
|
return {"ok": True, "message": "已恢复交易"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user