""" 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, g from app.utils.db import get_db_connection from app.utils.logger import get_logger from app.utils.auth import login_required 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_float(v: Any, default: float = 0.0) -> float: try: return float(v) except Exception: return default def _format_datetime(dt: Any) -> Any: """Convert datetime object to ISO format string for JSON serialization.""" if dt is None: return None if hasattr(dt, 'isoformat'): return dt.isoformat() return dt 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 def _compute_performance_stats(trades: List[Dict[str, Any]]) -> Dict[str, Any]: """ Compute performance statistics from trade history. Returns: { total_trades, winning_trades, losing_trades, win_rate, total_profit, total_loss, profit_factor, avg_win, avg_loss, avg_trade, max_win, max_loss, max_drawdown, max_drawdown_pct } """ total_trades = len(trades) if total_trades == 0: return { "total_trades": 0, "winning_trades": 0, "losing_trades": 0, "win_rate": 0.0, "total_profit": 0.0, "total_loss": 0.0, "profit_factor": 0.0, "avg_win": 0.0, "avg_loss": 0.0, "avg_trade": 0.0, "max_win": 0.0, "max_loss": 0.0, "max_drawdown": 0.0, "max_drawdown_pct": 0.0, "best_day": 0.0, "worst_day": 0.0, } profits = [_safe_float(t.get("profit"), 0.0) for t in trades] wins = [p for p in profits if p > 0] losses = [p for p in profits if p < 0] winning_trades = len(wins) losing_trades = len(losses) win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0.0 total_profit = sum(wins) if wins else 0.0 total_loss = abs(sum(losses)) if losses else 0.0 profit_factor = (total_profit / total_loss) if total_loss > 0 else (total_profit if total_profit > 0 else 0.0) avg_win = (total_profit / winning_trades) if winning_trades > 0 else 0.0 avg_loss = (total_loss / losing_trades) if losing_trades > 0 else 0.0 avg_trade = sum(profits) / total_trades if total_trades > 0 else 0.0 max_win = max(profits) if profits else 0.0 max_loss = min(profits) if profits else 0.0 # Calculate max drawdown from cumulative equity cumulative = [] acc = 0.0 for p in profits: acc += p cumulative.append(acc) peak = 0.0 max_drawdown = 0.0 for val in cumulative: if val > peak: peak = val dd = peak - val if dd > max_drawdown: max_drawdown = dd max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0.0 # Best/worst day day_profits: Dict[str, float] = {} for t in trades: ts = _safe_int(t.get("created_at"), 0) if ts <= 0: continue day = time.strftime("%Y-%m-%d", time.localtime(ts)) profit = _safe_float(t.get("profit"), 0.0) day_profits[day] = day_profits.get(day, 0.0) + profit best_day = max(day_profits.values()) if day_profits else 0.0 worst_day = min(day_profits.values()) if day_profits else 0.0 return { "total_trades": total_trades, "winning_trades": winning_trades, "losing_trades": losing_trades, "win_rate": round(win_rate, 2), "total_profit": round(total_profit, 2), "total_loss": round(total_loss, 2), "profit_factor": round(profit_factor, 2), "avg_win": round(avg_win, 2), "avg_loss": round(avg_loss, 2), "avg_trade": round(avg_trade, 2), "max_win": round(max_win, 2), "max_loss": round(max_loss, 2), "max_drawdown": round(max_drawdown, 2), "max_drawdown_pct": round(max_drawdown_pct, 2), "best_day": round(best_day, 2), "worst_day": round(worst_day, 2), } def _compute_strategy_stats(trades: List[Dict[str, Any]], strategies: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Compute per-strategy statistics. Only includes strategies that still exist (not deleted). """ # Build set of existing strategy IDs existing_strategy_ids: set = set() sid_to_name: Dict[int, str] = {} sid_to_capital: Dict[int, float] = {} for s in strategies: sid = _safe_int(s.get("id"), 0) if sid > 0: existing_strategy_ids.add(sid) sid_to_name[sid] = str(s.get("strategy_name") or f"Strategy_{sid}") sid_to_capital[sid] = _safe_float(s.get("initial_capital"), 0.0) # Group trades by strategy (only for existing strategies) sid_to_trades: Dict[int, List[Dict[str, Any]]] = {} for t in trades: sid = _safe_int(t.get("strategy_id"), 0) # Skip trades from deleted strategies if sid not in existing_strategy_ids: continue if sid not in sid_to_trades: sid_to_trades[sid] = [] sid_to_trades[sid].append(t) result = [] for sid, strades in sid_to_trades.items(): stats = _compute_performance_stats(strades) total_pnl = sum(_safe_float(t.get("profit"), 0.0) for t in strades) capital = sid_to_capital.get(sid, 0.0) roi = (total_pnl / capital * 100) if capital > 0 else 0.0 result.append({ "strategy_id": sid, "strategy_name": sid_to_name.get(sid, f"Strategy_{sid}"), "total_trades": stats["total_trades"], "win_rate": stats["win_rate"], "profit_factor": stats["profit_factor"], "total_pnl": round(total_pnl, 2), "roi": round(roi, 2), "max_drawdown": stats["max_drawdown"], }) # Sort by total PnL descending result.sort(key=lambda x: x.get("total_pnl", 0), reverse=True) return result @dashboard_bp.route("/summary", methods=["GET"]) @login_required def summary(): """ Return dashboard summary used by `quantdinger_vue/src/views/dashboard/index.vue`. """ try: user_id = g.user_id # Strategy counts (filtered by user_id) 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 WHERE user_id = ? """, (user_id,) ) 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, filtered by user_id) 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 WHERE p.user_id = ? ORDER BY p.updated_at DESC """, (user_id,) ) 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, filtered by user_id) 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 WHERE t.user_id = ? ORDER BY t.created_at DESC LIMIT 500 """, (user_id,) ) recent_trades_raw = cur.fetchall() or [] cur.close() # Convert datetime to timestamp for frontend compatibility recent_trades = [] for t in recent_trades_raw: trade = dict(t) if trade.get('created_at') and hasattr(trade['created_at'], 'timestamp'): trade['created_at'] = int(trade['created_at'].timestamp()) recent_trades.append(trade) # Compute performance statistics perf_stats = _compute_performance_stats(recent_trades) # Compute per-strategy statistics strategy_stats = _compute_strategy_stats(recent_trades, strategies) # 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 # Include realized PnL from trades total_realized_pnl = sum(_safe_float(t.get("profit"), 0.0) for t in recent_trades) total_pnl = float(total_unrealized_pnl + total_realized_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()] # Monthly returns for heatmap month_to_profit: Dict[str, float] = {} for trow in recent_trades: ts = _safe_int(trow.get("created_at"), 0) if ts <= 0: continue month = time.strftime("%Y-%m", time.localtime(ts)) try: p = float(trow.get("profit") or 0.0) except Exception: p = 0.0 month_to_profit[month] = month_to_profit.get(month, 0.0) + p monthly_returns = [{"month": m, "profit": round(v, 2)} for m, v in sorted(month_to_profit.items())] # Hourly distribution hour_to_count: Dict[int, int] = {} hour_to_profit: Dict[int, float] = {} for trow in recent_trades: ts = _safe_int(trow.get("created_at"), 0) if ts <= 0: continue hour = int(time.strftime("%H", time.localtime(ts))) hour_to_count[hour] = hour_to_count.get(hour, 0) + 1 hour_to_profit[hour] = hour_to_profit.get(hour, 0.0) + _safe_float(trow.get("profit"), 0.0) hourly_distribution = [ {"hour": h, "count": hour_to_count.get(h, 0), "profit": round(hour_to_profit.get(h, 0.0), 2)} for h in range(24) ] # Calendar data: organized by month for monthly calendar view # Format: { "2024-01": { "days": { "01": 123.45, "02": -50.0, ... }, "total": 500.0 }, ... } import calendar as cal_module from datetime import datetime, timedelta calendar_data: Dict[str, Dict[str, Any]] = {} for d, p in day_to_profit.items(): try: dt = datetime.strptime(d, "%Y-%m-%d") month_key = dt.strftime("%Y-%m") day_num = dt.strftime("%d") if month_key not in calendar_data: # Get number of days in month year, month = int(dt.strftime("%Y")), int(dt.strftime("%m")) _, days_in_month = cal_module.monthrange(year, month) # Get first day of month (0=Monday, 6=Sunday) first_weekday = cal_module.monthrange(year, month)[0] calendar_data[month_key] = { "year": year, "month": month, "days_in_month": days_in_month, "first_weekday": first_weekday, # 0=Mon, 6=Sun "days": {}, "total": 0.0, "win_days": 0, "lose_days": 0, } calendar_data[month_key]["days"][day_num] = round(p, 2) calendar_data[month_key]["total"] = round(calendar_data[month_key]["total"] + p, 2) if p > 0: calendar_data[month_key]["win_days"] += 1 elif p < 0: calendar_data[month_key]["lose_days"] += 1 except Exception: pass # Convert to sorted list for frontend calendar_months = [] for month_key in sorted(calendar_data.keys(), reverse=True): data = calendar_data[month_key] calendar_months.append({ "month_key": month_key, **data }) return jsonify( { "code": 1, "msg": "success", "data": { "ai_strategy_count": int(ai_enabled_strategy_count), "indicator_strategy_count": int(indicator_strategy_count), "total_equity": round(total_equity, 2), "total_pnl": round(total_pnl, 2), "total_realized_pnl": round(total_realized_pnl, 2), "total_unrealized_pnl": round(total_unrealized_pnl, 2), # Performance KPIs "performance": perf_stats, # Strategy-level stats "strategy_stats": strategy_stats, # Chart data "daily_pnl_chart": daily_pnl_chart, "strategy_pnl_chart": strategy_pnl_chart, "monthly_returns": monthly_returns, "hourly_distribution": hourly_distribution, "calendar_months": calendar_months, # Monthly calendar data # Lists "recent_trades": recent_trades[:100], # Limit for frontend "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"]) @login_required def pending_orders(): """ Return pending orders list for dashboard page. """ try: user_id = g.user_id 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 WHERE user_id = ?", (user_id,)) 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 WHERE o.user_id = ? ORDER BY o.id DESC LIMIT ? OFFSET ? """, (user_id, 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 ""), # Format datetime fields for JSON serialization "created_at": _format_datetime(r.get("created_at")), "updated_at": _format_datetime(r.get("updated_at")), "executed_at": _format_datetime(r.get("executed_at")), "processed_at": _format_datetime(r.get("processed_at")), "sent_at": _format_datetime(r.get("sent_at")), } ) # 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/", methods=["DELETE"]) @login_required def delete_pending_order(order_id: int): """ Delete a pending order record (dashboard operation). """ try: user_id = g.user_id 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() # Verify the order belongs to current user cur.execute("SELECT id, status FROM pending_orders WHERE id = ? AND user_id = ?", (oid, user_id)) 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 = ? AND user_id = ?", (oid, user_id)) 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