""" User Management API Routes Provides endpoints for user CRUD operations, role management, etc. Only accessible by admin users. """ import json from flask import Blueprint, request, jsonify, g from app.services.user_service import get_user_service from app.utils.auth import login_required, admin_required from app.utils.db import get_db_connection from app.utils.logger import get_logger logger = get_logger(__name__) user_bp = Blueprint('user_manage', __name__) @user_bp.route('/list', methods=['GET']) @login_required @admin_required def list_users(): """ List all users (admin only). Query params: page: int (default 1) page_size: int (default 20, max 100) search: str (optional, search by username/email/nickname) """ try: page = request.args.get('page', 1, type=int) page_size = request.args.get('page_size', 20, type=int) search = request.args.get('search', '', type=str) page_size = min(100, max(1, page_size)) result = get_user_service().list_users(page=page, page_size=page_size, search=search) return jsonify({ 'code': 1, 'msg': 'success', 'data': result }) except Exception as e: logger.error(f"list_users failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/detail', methods=['GET']) @login_required @admin_required def get_user_detail(): """Get user detail by ID (admin only)""" try: user_id = request.args.get('id', type=int) if not user_id: return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400 user = get_user_service().get_user_by_id(user_id) if not user: return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 return jsonify({ 'code': 1, 'msg': 'success', 'data': user }) except Exception as e: logger.error(f"get_user_detail failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/create', methods=['POST']) @login_required @admin_required def create_user(): """ Create a new user (admin only). Request body: username: str (required) password: str (required) email: str (optional) nickname: str (optional) role: str (optional, default 'user') """ try: data = request.get_json() or {} user_id = get_user_service().create_user(data) return jsonify({ 'code': 1, 'msg': 'User created successfully', 'data': {'id': user_id} }) except ValueError as e: return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 except Exception as e: logger.error(f"create_user failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/update', methods=['PUT']) @login_required @admin_required def update_user(): """ Update user information (admin only). Query params: id: int (required) Request body: email: str (optional) nickname: str (optional) role: str (optional) status: str (optional) """ try: user_id = request.args.get('id', type=int) if not user_id: return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400 data = request.get_json() or {} success = get_user_service().update_user(user_id, data) if success: return jsonify({'code': 1, 'msg': 'User updated successfully', 'data': None}) else: return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400 except Exception as e: logger.error(f"update_user failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/delete', methods=['DELETE']) @login_required @admin_required def delete_user(): """Delete a user (admin only)""" try: user_id = request.args.get('id', type=int) if not user_id: return jsonify({'code': 0, 'msg': 'Missing user id', 'data': None}), 400 # Prevent deleting self if hasattr(g, 'user_id') and g.user_id == user_id: return jsonify({'code': 0, 'msg': 'Cannot delete yourself', 'data': None}), 400 success = get_user_service().delete_user(user_id) if success: return jsonify({'code': 1, 'msg': 'User deleted successfully', 'data': None}) else: return jsonify({'code': 0, 'msg': 'Delete failed', 'data': None}), 400 except Exception as e: logger.error(f"delete_user failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/reset-password', methods=['POST']) @login_required @admin_required def reset_user_password(): """ Reset a user's password (admin only). Request body: user_id: int (required) new_password: str (required) """ try: data = request.get_json() or {} user_id = data.get('user_id') new_password = data.get('new_password', '') if not user_id: return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 if len(new_password) < 6: return jsonify({'code': 0, 'msg': 'Password must be at least 6 characters', 'data': None}), 400 success = get_user_service().reset_password(user_id, new_password) if success: return jsonify({'code': 1, 'msg': 'Password reset successfully', 'data': None}) else: return jsonify({'code': 0, 'msg': 'Reset failed', 'data': None}), 400 except ValueError as e: return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 except Exception as e: logger.error(f"reset_user_password failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/roles', methods=['GET']) @login_required @admin_required def get_roles(): """Get available roles and their permissions""" service = get_user_service() roles = [] for role in service.ROLES: roles.append({ 'id': role, 'name': role.capitalize(), 'permissions': service.get_user_permissions(role) }) return jsonify({ 'code': 1, 'msg': 'success', 'data': {'roles': roles} }) # ==================== Billing Management (Admin) ==================== @user_bp.route('/set-credits', methods=['POST']) @login_required @admin_required def set_user_credits(): """ Set user credits (admin only). Request body: user_id: int (required) credits: int (required) remark: str (optional) """ try: from app.services.billing_service import get_billing_service data = request.get_json() or {} user_id = data.get('user_id') credits = data.get('credits') remark = data.get('remark', '') if not user_id: return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 if credits is None or credits < 0: return jsonify({'code': 0, 'msg': 'Credits must be a non-negative number', 'data': None}), 400 operator_id = getattr(g, 'user_id', None) success, result = get_billing_service().set_credits(user_id, int(credits), remark, operator_id) if success: return jsonify({'code': 1, 'msg': 'Credits updated successfully', 'data': {'credits': result}}) else: return jsonify({'code': 0, 'msg': result, 'data': None}), 400 except Exception as e: logger.error(f"set_user_credits failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/set-vip', methods=['POST']) @login_required @admin_required def set_user_vip(): """ Set user VIP status (admin only). Request body: user_id: int (required) vip_days: int (optional, 0 to cancel VIP, positive number to grant VIP for days) vip_expires_at: str (optional, ISO format datetime, overrides vip_days if provided) remark: str (optional) """ try: from datetime import datetime, timedelta, timezone from app.services.billing_service import get_billing_service data = request.get_json() or {} user_id = data.get('user_id') vip_days = data.get('vip_days') vip_expires_at_str = data.get('vip_expires_at') remark = data.get('remark', '') if not user_id: return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 # Calculate expires_at expires_at = None if vip_expires_at_str: try: expires_at = datetime.fromisoformat(vip_expires_at_str.replace('Z', '+00:00')) except ValueError: return jsonify({'code': 0, 'msg': 'Invalid vip_expires_at format', 'data': None}), 400 elif vip_days is not None: if vip_days > 0: expires_at = datetime.now(timezone.utc) + timedelta(days=vip_days) else: expires_at = None # Cancel VIP else: return jsonify({'code': 0, 'msg': 'Provide vip_days or vip_expires_at', 'data': None}), 400 operator_id = getattr(g, 'user_id', None) success, result = get_billing_service().set_vip(user_id, expires_at, remark, operator_id) if success: return jsonify({ 'code': 1, 'msg': 'VIP status updated successfully', 'data': {'vip_expires_at': expires_at.isoformat() if expires_at else None} }) else: return jsonify({'code': 0, 'msg': result, 'data': None}), 400 except Exception as e: logger.error(f"set_user_vip failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/credits-log', methods=['GET']) @login_required @admin_required def get_user_credits_log(): """ Get user credits log (admin only). Query params: user_id: int (required) page: int (default 1) page_size: int (default 20) """ try: from app.services.billing_service import get_billing_service user_id = request.args.get('user_id', type=int) if not user_id: return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400 page = request.args.get('page', 1, type=int) page_size = request.args.get('page_size', 20, type=int) page_size = min(100, max(1, page_size)) result = get_billing_service().get_credits_log(user_id, page, page_size) return jsonify({'code': 1, 'msg': 'success', 'data': result}) except Exception as e: logger.error(f"get_user_credits_log failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 # Self-service endpoints (accessible by any logged-in user) @user_bp.route('/profile', methods=['GET']) @login_required def get_profile(): """Get current user's profile with billing info and notification settings""" try: import json from app.services.billing_service import get_billing_service from app.utils.db import get_db_connection user_id = getattr(g, 'user_id', None) if not user_id: return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 user = get_user_service().get_user_by_id(user_id) if not user: return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 # Add permissions user['permissions'] = get_user_service().get_user_permissions(user.get('role', 'user')) # Add billing info billing_info = get_billing_service().get_user_billing_info(user_id) user['billing'] = billing_info # Add notification settings with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT notification_settings FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() settings_str = (row.get('notification_settings') if row else '') or '' notification_settings = {} if settings_str: try: notification_settings = json.loads(settings_str) except Exception: notification_settings = {} # Default values if 'default_channels' not in notification_settings: notification_settings['default_channels'] = ['browser'] user['notification_settings'] = notification_settings return jsonify({ 'code': 1, 'msg': 'success', 'data': user }) except Exception as e: logger.error(f"get_profile failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/profile/update', methods=['PUT']) @login_required def update_profile(): """ Update current user's profile (limited fields). Request body: nickname: str (optional) avatar: str (optional) Note: Email cannot be changed after registration (for security). Only admin can change user email via User Management. """ try: user_id = getattr(g, 'user_id', None) if not user_id: return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 data = request.get_json() or {} # Only allow updating certain fields for self-service # Email is NOT allowed to be changed (security: bound to account) allowed = {} for field in ['nickname', 'avatar']: if field in data: allowed[field] = data[field] if not allowed: return jsonify({'code': 0, 'msg': 'No valid fields to update', 'data': None}), 400 success = get_user_service().update_user(user_id, allowed) if success: return jsonify({'code': 1, 'msg': 'Profile updated successfully', 'data': None}) else: return jsonify({'code': 0, 'msg': 'Update failed', 'data': None}), 400 except Exception as e: logger.error(f"update_profile failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/my-credits-log', methods=['GET']) @login_required def get_my_credits_log(): """ Get current user's credits log. Query params: page: int (default 1) page_size: int (default 20) """ try: from app.services.billing_service import get_billing_service user_id = getattr(g, 'user_id', None) if not user_id: return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 page = request.args.get('page', 1, type=int) page_size = request.args.get('page_size', 20, type=int) page_size = min(100, max(1, page_size)) result = get_billing_service().get_credits_log(user_id, page, page_size) return jsonify({'code': 1, 'msg': 'success', 'data': result}) except Exception as e: logger.error(f"get_my_credits_log failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/my-referrals', methods=['GET']) @login_required def get_my_referrals(): """ Get list of users referred by current user. Query params: page: int (default 1) page_size: int (default 20) Returns: list: Users referred by current user (id, username, nickname, avatar, created_at) total: Total count of referrals referral_code: Current user's referral code (user ID) referral_bonus: Credits earned per referral register_bonus: Credits new users get on registration """ try: import os from app.utils.db import get_db_connection user_id = getattr(g, 'user_id', None) if not user_id: return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 page = request.args.get('page', 1, type=int) page_size = request.args.get('page_size', 20, type=int) page_size = min(100, max(1, page_size)) offset = (page - 1) * page_size with get_db_connection() as db: cur = db.cursor() # Get total count cur.execute( "SELECT COUNT(*) as cnt FROM qd_users WHERE referred_by = ?", (user_id,) ) total = cur.fetchone()['cnt'] # Get referral list cur.execute( """ SELECT id, username, nickname, avatar, created_at FROM qd_users WHERE referred_by = ? ORDER BY created_at DESC LIMIT ? OFFSET ? """, (user_id, page_size, offset) ) rows = cur.fetchall() cur.close() referrals = [] for row in rows: referrals.append({ 'id': row['id'], 'username': row['username'], 'nickname': row['nickname'], 'avatar': row['avatar'], 'created_at': row['created_at'].isoformat() if row['created_at'] else None }) return jsonify({ 'code': 1, 'msg': 'success', 'data': { 'list': referrals, 'total': total, 'page': page, 'page_size': page_size, 'referral_code': str(user_id), 'referral_bonus': int(os.getenv('CREDITS_REFERRAL_BONUS', '0')), 'register_bonus': int(os.getenv('CREDITS_REGISTER_BONUS', '0')) } }) except Exception as e: logger.error(f"get_my_referrals failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/notification-settings', methods=['GET']) @login_required def get_notification_settings(): """ Get current user's notification settings. Returns: notification_settings: { default_channels: ['browser', 'telegram', ...], telegram_chat_id: str, email: str (optional, override for notifications), discord_webhook: str (optional) } """ try: import json from app.utils.db import get_db_connection user_id = getattr(g, 'user_id', None) if not user_id: return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT notification_settings, email FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() if not row: return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 # Parse notification_settings JSON settings_str = row.get('notification_settings') or '' settings = {} if settings_str: try: settings = json.loads(settings_str) except Exception: settings = {} # Default values if 'default_channels' not in settings: settings['default_channels'] = ['browser'] if 'email' not in settings: settings['email'] = row.get('email') or '' return jsonify({ 'code': 1, 'msg': 'success', 'data': settings }) except Exception as e: logger.error(f"get_notification_settings failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/notification-settings', methods=['PUT']) @login_required def update_notification_settings(): """ Update current user's notification settings. Request body: default_channels: list of str (optional, e.g. ['browser', 'telegram']) telegram_bot_token: str (optional, user's own Telegram bot token) telegram_chat_id: str (optional) email: str (optional, for notification override) discord_webhook: str (optional) """ try: import json from app.utils.db import get_db_connection user_id = getattr(g, 'user_id', None) if not user_id: return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 data = request.get_json() or {} # Validate channels valid_channels = ['browser', 'email', 'telegram', 'discord', 'webhook', 'phone'] default_channels = data.get('default_channels', []) if not isinstance(default_channels, list): default_channels = ['browser'] default_channels = [c for c in default_channels if c in valid_channels] if not default_channels: default_channels = ['browser'] # Build settings object settings = { 'default_channels': default_channels, 'telegram_bot_token': str(data.get('telegram_bot_token') or '').strip(), 'telegram_chat_id': str(data.get('telegram_chat_id') or '').strip(), 'email': str(data.get('email') or '').strip(), 'discord_webhook': str(data.get('discord_webhook') or '').strip(), 'webhook_url': str(data.get('webhook_url') or '').strip(), 'phone': str(data.get('phone') or '').strip(), } # Remove empty values (but keep default_channels and telegram_bot_token even if partially filled) settings = {k: v for k, v in settings.items() if v or k == 'default_channels'} settings_json = json.dumps(settings, ensure_ascii=False) with get_db_connection() as db: cur = db.cursor() cur.execute( "UPDATE qd_users SET notification_settings = ?, updated_at = NOW() WHERE id = ?", (settings_json, user_id) ) db.commit() cur.close() return jsonify({ 'code': 1, 'msg': 'Notification settings updated', 'data': settings }) except Exception as e: logger.error(f"update_notification_settings failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @user_bp.route('/change-password', methods=['POST']) @login_required def change_password(): """ Change current user's password. Request body: old_password: str (required) new_password: str (required) """ try: user_id = getattr(g, 'user_id', None) if not user_id: return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401 data = request.get_json() or {} old_password = data.get('old_password', '') new_password = data.get('new_password', '') if not new_password: return jsonify({'code': 0, 'msg': 'New password required', 'data': None}), 400 if len(new_password) < 6: return jsonify({'code': 0, 'msg': 'New password must be at least 6 characters', 'data': None}), 400 # Check if user has a password set user_service = get_user_service() user = user_service.get_user_by_id(user_id) if not user: return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404 # Get password_hash to check if user has no password from app.utils.db import get_db_connection with get_db_connection() as db: cur = db.cursor() cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,)) row = cur.fetchone() cur.close() password_hash = row.get('password_hash', '') if row else '' has_password = password_hash and password_hash.strip() != '' # If user has no password, allow setting password without old password if not has_password: if not old_password: # No old password required for users without password success = user_service.reset_password(user_id, new_password) if success: return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None}) else: return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500 else: # If old_password is provided but user has no password, ignore it success = user_service.reset_password(user_id, new_password) if success: return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None}) else: return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500 else: # User has existing password, require old password verification if not old_password: return jsonify({'code': 0, 'msg': 'Old password required', 'data': None}), 400 success = user_service.change_password(user_id, old_password, new_password) if success: return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None}) else: return jsonify({'code': 0, 'msg': 'Old password incorrect', 'data': None}), 400 except ValueError as e: return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400 except Exception as e: logger.error(f"change_password failed: {e}") return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 # ==================== System Overview (Admin) ==================== def _safe_json_loads(s, default=None): """Safely parse JSON string.""" if not s: return default if isinstance(s, dict): return s try: return json.loads(s) except Exception: return default @user_bp.route('/system-strategies', methods=['GET']) @login_required @admin_required def get_system_strategies(): """ Get all strategies across the entire system (admin only). Returns strategy details with user info, positions, PnL, indicators, etc. Query params: page: int (default 1) page_size: int (default 20, max 100) status: str (optional, filter by status: running/stopped/all) search: str (optional, search by strategy name/symbol/username) """ try: page = request.args.get('page', 1, type=int) page_size = request.args.get('page_size', 20, type=int) status_filter = request.args.get('status', '', type=str).strip().lower() search = request.args.get('search', '', type=str).strip() page_size = min(100, max(1, page_size)) offset = (page - 1) * page_size with get_db_connection() as db: cur = db.cursor() # Build WHERE clause conditions = [] params = [] if status_filter and status_filter != 'all': conditions.append("s.status = ?") params.append(status_filter) if search: conditions.append( "(s.strategy_name ILIKE ? OR s.symbol ILIKE ? OR u.username ILIKE ? OR u.nickname ILIKE ?)" ) like_val = f"%{search}%" params.extend([like_val, like_val, like_val, like_val]) where_clause = "" if conditions: where_clause = "WHERE " + " AND ".join(conditions) # Get total count count_sql = f""" SELECT COUNT(*) as cnt FROM qd_strategies_trading s LEFT JOIN qd_users u ON u.id = s.user_id {where_clause} """ cur.execute(count_sql, tuple(params)) total = cur.fetchone()['cnt'] # Get strategies with user info query_sql = f""" SELECT s.id, s.user_id, s.strategy_name, s.strategy_type, s.market_category, s.execution_mode, s.status, s.symbol, s.timeframe, s.initial_capital, s.leverage, s.market_type, s.indicator_config, s.trading_config, s.exchange_config, s.decide_interval, s.created_at, s.updated_at, u.username, u.nickname FROM qd_strategies_trading s LEFT JOIN qd_users u ON u.id = s.user_id {where_clause} ORDER BY s.status DESC, s.updated_at DESC LIMIT ? OFFSET ? """ cur.execute(query_sql, tuple(params) + (page_size, offset)) strategies = cur.fetchall() or [] # Collect strategy IDs strategy_ids = [s['id'] for s in strategies] # Batch load positions for these strategies positions_map = {} if strategy_ids: placeholders = ','.join(['?'] * len(strategy_ids)) cur.execute( f""" SELECT strategy_id, symbol, side, size, entry_price, current_price, unrealized_pnl, pnl_percent, equity, updated_at FROM qd_strategy_positions WHERE strategy_id IN ({placeholders}) ORDER BY strategy_id, updated_at DESC """, tuple(strategy_ids) ) for pos in (cur.fetchall() or []): sid = pos['strategy_id'] if sid not in positions_map: positions_map[sid] = [] positions_map[sid].append(dict(pos)) # Batch load recent trade stats (realized PnL per strategy) trade_stats_map = {} if strategy_ids: placeholders = ','.join(['?'] * len(strategy_ids)) cur.execute( f""" SELECT strategy_id, COUNT(*) as trade_count, COALESCE(SUM(profit), 0) as total_realized_pnl FROM qd_strategy_trades WHERE strategy_id IN ({placeholders}) GROUP BY strategy_id """, tuple(strategy_ids) ) for row in (cur.fetchall() or []): trade_stats_map[row['strategy_id']] = { 'trade_count': row['trade_count'], 'total_realized_pnl': float(row['total_realized_pnl'] or 0) } cur.close() # Build response items = [] for s in strategies: sid = s['id'] indicator_config = _safe_json_loads(s.get('indicator_config'), {}) trading_config = _safe_json_loads(s.get('trading_config'), {}) exchange_config = _safe_json_loads(s.get('exchange_config'), {}) # Extract indicator name indicator_name = '' if isinstance(indicator_config, dict): indicator_name = indicator_config.get('indicator_name') or indicator_config.get('name') or '' # Extract exchange name exchange_name = '' if isinstance(exchange_config, dict): exchange_name = exchange_config.get('exchange_id') or exchange_config.get('exchange') or '' # Positions data positions = positions_map.get(sid, []) total_unrealized_pnl = sum(float(p.get('unrealized_pnl') or 0) for p in positions) total_equity = sum(float(p.get('equity') or 0) for p in positions) position_count = len(positions) # Trade stats trade_stats = trade_stats_map.get(sid, {'trade_count': 0, 'total_realized_pnl': 0}) total_realized_pnl = trade_stats['total_realized_pnl'] trade_count = trade_stats['trade_count'] # Calculate total PnL and ROI initial_capital = float(s.get('initial_capital') or 0) total_pnl = total_unrealized_pnl + total_realized_pnl roi = (total_pnl / initial_capital * 100) if initial_capital > 0 else 0 # Cross-sectional info cs_type = '' symbol_list = [] if isinstance(trading_config, dict): cs_type = trading_config.get('cs_strategy_type') or 'single' symbol_list = trading_config.get('symbol_list') or [] # Format timestamps created_at = s.get('created_at') updated_at = s.get('updated_at') if hasattr(created_at, 'isoformat'): created_at = created_at.isoformat() if hasattr(updated_at, 'isoformat'): updated_at = updated_at.isoformat() # Format position timestamps for p in positions: if hasattr(p.get('updated_at'), 'isoformat'): p['updated_at'] = p['updated_at'].isoformat() items.append({ 'id': sid, 'user_id': s['user_id'], 'username': s.get('username') or '', 'nickname': s.get('nickname') or '', 'strategy_name': s.get('strategy_name') or '', 'strategy_type': s.get('strategy_type') or '', 'cs_strategy_type': cs_type, 'market_category': s.get('market_category') or '', 'execution_mode': s.get('execution_mode') or '', 'status': s.get('status') or 'stopped', 'symbol': s.get('symbol') or '', 'symbol_list': symbol_list, 'timeframe': s.get('timeframe') or '', 'initial_capital': initial_capital, 'leverage': int(s.get('leverage') or 1), 'market_type': s.get('market_type') or '', 'indicator_name': indicator_name, 'exchange_name': exchange_name, 'decide_interval': s.get('decide_interval') or 300, 'position_count': position_count, 'total_unrealized_pnl': round(total_unrealized_pnl, 4), 'total_realized_pnl': round(total_realized_pnl, 4), 'total_pnl': round(total_pnl, 4), 'total_equity': round(total_equity, 4), 'roi': round(roi, 2), 'trade_count': trade_count, 'positions': positions, 'created_at': created_at, 'updated_at': updated_at }) # Compute summary stats from all matched strategies (not just current page items). with get_db_connection() as db: cur = db.cursor() # Aggregate strategy counts/capital by execution mode and running status. agg_sql = f""" SELECT COUNT(*) AS total_strategies, COALESCE(SUM(s.initial_capital), 0) AS total_capital, COALESCE(SUM(CASE WHEN s.status = 'running' THEN 1 ELSE 0 END), 0) AS running_strategies, COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN 1 ELSE 0 END), 0) AS live_strategies, COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN 1 ELSE 0 END), 0) AS signal_strategies, COALESCE(SUM(CASE WHEN s.status = 'running' AND s.execution_mode = 'live' THEN 1 ELSE 0 END), 0) AS running_live_strategies, COALESCE(SUM(CASE WHEN s.status = 'running' AND s.execution_mode = 'signal' THEN 1 ELSE 0 END), 0) AS running_signal_strategies, COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN s.initial_capital ELSE 0 END), 0) AS live_capital, COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN s.initial_capital ELSE 0 END), 0) AS signal_capital FROM qd_strategies_trading s LEFT JOIN qd_users u ON u.id = s.user_id {where_clause} """ cur.execute(agg_sql, tuple(params)) agg_row = cur.fetchone() or {} # Aggregate unrealized pnl from current positions. unreal_sql = f""" SELECT COALESCE(SUM(p.unrealized_pnl), 0) AS total_unrealized, COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN p.unrealized_pnl ELSE 0 END), 0) AS live_unrealized, COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN p.unrealized_pnl ELSE 0 END), 0) AS signal_unrealized FROM qd_strategy_positions p JOIN qd_strategies_trading s ON s.id = p.strategy_id LEFT JOIN qd_users u ON u.id = s.user_id {where_clause} """ cur.execute(unreal_sql, tuple(params)) unreal_row = cur.fetchone() or {} # Aggregate realized pnl from trade history. realized_sql = f""" SELECT COALESCE(SUM(t.profit), 0) AS total_realized, COALESCE(SUM(CASE WHEN s.execution_mode = 'live' THEN t.profit ELSE 0 END), 0) AS live_realized, COALESCE(SUM(CASE WHEN s.execution_mode = 'signal' THEN t.profit ELSE 0 END), 0) AS signal_realized FROM qd_strategy_trades t JOIN qd_strategies_trading s ON s.id = t.strategy_id LEFT JOIN qd_users u ON u.id = s.user_id {where_clause} """ cur.execute(realized_sql, tuple(params)) realized_row = cur.fetchone() or {} cur.close() total_capital = float(agg_row.get('total_capital') or 0) total_running = int(agg_row.get('running_strategies') or 0) total_system_pnl = float(unreal_row.get('total_unrealized') or 0) + float(realized_row.get('total_realized') or 0) live_pnl = float(unreal_row.get('live_unrealized') or 0) + float(realized_row.get('live_realized') or 0) signal_pnl = float(unreal_row.get('signal_unrealized') or 0) + float(realized_row.get('signal_realized') or 0) return jsonify({ 'code': 1, 'msg': 'success', 'data': { 'items': items, 'total': total, 'page': page, 'page_size': page_size, 'summary': { 'total_strategies': int(agg_row.get('total_strategies') or total), 'running_strategies': total_running, 'total_capital': round(total_capital, 2), 'total_pnl': round(total_system_pnl, 4), 'total_roi': round((total_system_pnl / total_capital * 100) if total_capital > 0 else 0, 2), 'live_strategies': int(agg_row.get('live_strategies') or 0), 'signal_strategies': int(agg_row.get('signal_strategies') or 0), 'running_live_strategies': int(agg_row.get('running_live_strategies') or 0), 'running_signal_strategies': int(agg_row.get('running_signal_strategies') or 0), 'live_capital': round(float(agg_row.get('live_capital') or 0), 2), 'signal_capital': round(float(agg_row.get('signal_capital') or 0), 2), 'live_pnl': round(live_pnl, 4), 'signal_pnl': round(signal_pnl, 4) } } }) except Exception as e: logger.error(f"get_system_strategies failed: {e}") import traceback logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500