278b88ec71
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
387 lines
14 KiB
Python
387 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, trading_config
|
|
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 strategies" in dashboard card: count strategies that enabled AI analysis/filtering.
|
|
# This aligns with the UI toggle `enable_ai_filter` in trading_config.
|
|
def _truthy(v: Any) -> bool:
|
|
if v is True:
|
|
return True
|
|
if isinstance(v, (int, float)) and float(v) == 1:
|
|
return True
|
|
if isinstance(v, str) and v.strip().lower() in ("1", "true", "yes", "y", "on"):
|
|
return True
|
|
return False
|
|
|
|
ai_enabled_strategy_count = 0
|
|
for s in strategies:
|
|
tc = _safe_json_loads(s.get("trading_config"), {}) or {}
|
|
if isinstance(tc, dict) and _truthy(tc.get("enable_ai_filter")):
|
|
ai_enabled_strategy_count += 1
|
|
|
|
# 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_enabled_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
|
|
|