@@ -0,0 +1,370 @@
|
||||
"""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user