Files
DinQuant/backend_api_python/app/routes/dashboard.py
T
TIANHE f43312a858 creat
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2025-12-29 03:06:49 +08:00

371 lines
14 KiB
Python

"""
Dashboard APIs (local-first).
Endpoints:
- GET /api/dashboard/summary
- GET /api/dashboard/pendingOrders?page=1&pageSize=20
Notes:
- Paper mode: no real trading execution. Metrics are best-effort based on local DB tables.
"""
from __future__ import annotations
import json
import time
from typing import Any, Dict, List, Tuple
from flask import Blueprint, jsonify, request
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
dashboard_bp = Blueprint("dashboard", __name__)
def _safe_int(v: Any, default: int) -> int:
try:
return int(v)
except Exception:
return default
def _safe_json_loads(value: Any, default: Any) -> Any:
if value is None:
return default
if isinstance(value, (dict, list)):
return value
if not isinstance(value, str):
return default
s = value.strip()
if not s:
return default
try:
return json.loads(s)
except Exception:
return default
def _as_list(value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, list):
return [str(x) for x in value if str(x or "").strip()]
if isinstance(value, str):
s = value.strip()
if not s:
return []
# allow comma-separated
if "," in s:
return [p.strip() for p in s.split(",") if p.strip()]
return [s]
return []
def _calc_unrealized_pnl(side: str, entry_price: float, current_price: float, size: float) -> float:
try:
ep = float(entry_price or 0.0)
cp = float(current_price or 0.0)
sz = float(size or 0.0)
if ep <= 0 or cp <= 0 or sz <= 0:
return 0.0
s = (side or "").strip().lower()
if s == "short":
return (ep - cp) * sz
return (cp - ep) * sz
except Exception:
return 0.0
def _calc_pnl_percent(entry_price: float, size: float, pnl: float, leverage: float = 1.0, market_type: str = "spot") -> float:
try:
denom = float(entry_price or 0.0) * float(size or 0.0)
if denom <= 0:
return 0.0
lev = float(leverage or 1.0)
if lev <= 0:
lev = 1.0
mt = str(market_type or "").strip().lower()
# Margin PnL% (user expectation): pnl / (notional / leverage)
# = pnl / notional * leverage
mult = lev if mt in ("swap", "futures", "future", "perp", "perpetual") else 1.0
return float(pnl) / denom * 100.0 * float(mult)
except Exception:
return 0.0
@dashboard_bp.route("/summary", methods=["GET"])
def summary():
"""
Return dashboard summary used by `quantdinger_vue/src/views/dashboard/index.vue`.
"""
try:
# Strategy counts
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, strategy_name, strategy_type, status, initial_capital
FROM qd_strategies_trading
"""
)
strategies = cur.fetchall() or []
cur.close()
running = [s for s in strategies if (s.get("status") or "").strip().lower() == "running"]
indicator_strategy_count = len([s for s in running if (s.get("strategy_type") or "") == "IndicatorStrategy"])
ai_strategy_count = max(0, len(running) - indicator_strategy_count)
# Positions (best-effort)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT p.*, s.strategy_name, s.initial_capital, s.leverage, s.market_type
FROM qd_strategy_positions p
LEFT JOIN qd_strategies_trading s ON s.id = p.strategy_id
ORDER BY p.updated_at DESC
"""
)
rows = cur.fetchall() or []
cur.close()
current_positions: List[Dict[str, Any]] = []
total_unrealized_pnl = 0.0
for r in rows:
pnl = _calc_unrealized_pnl(
side=str(r.get("side") or ""),
entry_price=float(r.get("entry_price") or 0.0),
current_price=float(r.get("current_price") or 0.0),
size=float(r.get("size") or 0.0),
)
pct = _calc_pnl_percent(
float(r.get("entry_price") or 0.0),
float(r.get("size") or 0.0),
pnl,
leverage=float(r.get("leverage") or 1.0),
market_type=str(r.get("market_type") or "spot"),
)
total_unrealized_pnl += float(pnl)
current_positions.append(
{
**r,
"strategy_name": r.get("strategy_name") or "",
"unrealized_pnl": float(pnl),
"pnl_percent": float(pct),
}
)
# Recent trades (best-effort)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT t.*, s.strategy_name
FROM qd_strategy_trades t
LEFT JOIN qd_strategies_trading s ON s.id = t.strategy_id
ORDER BY t.created_at DESC
LIMIT 200
"""
)
recent_trades = cur.fetchall() or []
cur.close()
# Total equity/pnl (best-effort)
total_initial_capital = 0.0
for s in strategies:
try:
total_initial_capital += float(s.get("initial_capital") or 0.0)
except Exception:
pass
total_pnl = float(total_unrealized_pnl)
total_equity = float(total_initial_capital + total_pnl)
# Daily PnL chart (uses realized profit field if present, otherwise 0)
# Keep output stable even if profit is mostly empty.
day_to_profit: Dict[str, float] = {}
for trow in recent_trades:
ts = _safe_int(trow.get("created_at"), 0)
if ts <= 0:
continue
day = time.strftime("%Y-%m-%d", time.localtime(ts))
try:
p = float(trow.get("profit") or 0.0)
except Exception:
p = 0.0
day_to_profit[day] = float(day_to_profit.get(day, 0.0) + p)
daily_pnl_chart = [{"date": d, "profit": float(v)} for d, v in sorted(day_to_profit.items())]
# Strategy performance pie (use unrealized pnl by strategy as best-effort)
sid_to_unreal: Dict[int, float] = {}
sid_to_name: Dict[int, str] = {}
for p in current_positions:
sid = _safe_int(p.get("strategy_id"), 0)
sid_to_name[sid] = str(p.get("strategy_name") or f"Strategy_{sid}")
sid_to_unreal[sid] = float(sid_to_unreal.get(sid, 0.0) + float(p.get("unrealized_pnl") or 0.0))
strategy_pnl_chart = [{"name": sid_to_name[sid], "value": float(val)} for sid, val in sid_to_unreal.items()]
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
"ai_strategy_count": int(ai_strategy_count),
"indicator_strategy_count": int(indicator_strategy_count),
"total_equity": float(total_equity),
"total_pnl": float(total_pnl),
"daily_pnl_chart": daily_pnl_chart,
"strategy_pnl_chart": strategy_pnl_chart,
"recent_trades": recent_trades,
"current_positions": current_positions,
},
}
)
except Exception as e:
logger.error(f"dashboard summary failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@dashboard_bp.route("/pendingOrders", methods=["GET"])
def pending_orders():
"""
Return pending orders list for dashboard page.
"""
try:
page = max(1, _safe_int(request.args.get("page"), 1))
page_size = max(1, min(200, _safe_int(request.args.get("pageSize"), 20)))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT COUNT(1) AS cnt FROM pending_orders")
total = int((cur.fetchone() or {}).get("cnt") or 0)
cur.close()
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT o.*,
s.strategy_name,
s.notification_config AS strategy_notification_config,
s.exchange_config AS strategy_exchange_config,
s.market_type AS strategy_market_type,
s.market_category AS strategy_market_category,
s.execution_mode AS strategy_execution_mode
FROM pending_orders o
LEFT JOIN qd_strategies_trading s ON s.id = o.strategy_id
ORDER BY o.id DESC
LIMIT %s OFFSET %s
""",
(int(page_size), int(offset)),
)
rows = cur.fetchall() or []
cur.close()
out: List[Dict[str, Any]] = []
for r in rows:
status = (r.get("status") or "").strip().lower()
if status == "sent":
status = "completed"
if status == "deferred":
status = "pending"
# Frontend expects these keys:
# - filled_amount, filled_price, error_message
filled_amount = float(r.get("filled") or 0.0)
filled_price = float(r.get("avg_price") or 0.0) if float(r.get("avg_price") or 0.0) > 0 else float(r.get("price") or 0.0)
# Derive exchange_id + notify channels without leaking secrets to frontend.
ex_cfg = _safe_json_loads(r.get("strategy_exchange_config"), {}) or {}
notify_cfg = _safe_json_loads(r.get("strategy_notification_config"), {}) or {}
exchange_id = (r.get("exchange_id") or ex_cfg.get("exchange_id") or ex_cfg.get("exchangeId") or "").strip().lower()
notify_channels = _as_list((notify_cfg or {}).get("channels"))
if not notify_channels:
notify_channels = ["browser"]
market_type = (r.get("market_type") or r.get("strategy_market_type") or ex_cfg.get("market_type") or ex_cfg.get("marketType") or "").strip().lower()
market_category = str(r.get("strategy_market_category") or "").strip().lower()
execution_mode = str(r.get("strategy_execution_mode") or r.get("execution_mode") or "").strip().lower()
# If non-crypto markets are "signal-only", show SIGNAL instead of blank exchange.
exchange_display = exchange_id
if not exchange_display:
if execution_mode == "signal" or (market_category and market_category != "crypto"):
exchange_display = "signal"
out.append(
{
**r,
"strategy_name": r.get("strategy_name") or "",
"status": status,
"filled_amount": filled_amount,
"filled_price": filled_price,
"error_message": r.get("last_error") or "",
"exchange_id": exchange_id,
"exchange_display": exchange_display,
"notify_channels": notify_channels,
"market_type": market_type or (r.get("market_type") or ""),
}
)
# Never expose these strategy-level config blobs.
for item in out:
try:
item.pop("strategy_exchange_config", None)
item.pop("strategy_notification_config", None)
item.pop("strategy_market_type", None)
item.pop("strategy_market_category", None)
item.pop("strategy_execution_mode", None)
except Exception:
pass
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
"list": out,
"page": page,
"pageSize": page_size,
"total": total,
},
}
)
except Exception as e:
logger.error(f"dashboard pendingOrders failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@dashboard_bp.route("/pendingOrders/<int:order_id>", methods=["DELETE"])
def delete_pending_order(order_id: int):
"""
Delete a pending order record (dashboard operation).
"""
try:
oid = int(order_id or 0)
if oid <= 0:
return jsonify({"code": 0, "msg": "invalid_id", "data": None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT id, status FROM pending_orders WHERE id = %s", (oid,))
row = cur.fetchone() or {}
if not row:
cur.close()
return jsonify({"code": 0, "msg": "not_found", "data": None}), 404
st = (row.get("status") or "").strip().lower()
if st == "processing":
cur.close()
return jsonify({"code": 0, "msg": "cannot_delete_processing", "data": None}), 400
cur.execute("DELETE FROM pending_orders WHERE id = %s", (oid,))
db.commit()
cur.close()
return jsonify({"code": 1, "msg": "success", "data": {"id": oid}})
except Exception as e:
logger.error(f"dashboard delete pendingOrders failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500