diff --git a/backend/main.py b/backend/main.py index 38fb6d7..069a126 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1178,3 +1178,290 @@ async def get_server_status(user: dict = Depends(get_current_user)): _server_cache["data"] = result _server_cache["ts"] = now 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)}