""" Portfolio API routes (local-only). Manages manual positions (user's existing holdings) and AI monitoring tasks. """ from flask import Blueprint, request, jsonify, g import json import traceback import time import threading from concurrent.futures import ThreadPoolExecutor, as_completed from app.services.kline import KlineService from app.utils.logger import get_logger from app.utils.cache import CacheManager from app.utils.db import get_db_connection from app.utils.auth import login_required from app.services.symbol_name import resolve_symbol_name from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name logger = get_logger(__name__) portfolio_bp = Blueprint('portfolio', __name__) kline_service = KlineService() cache = CacheManager() # Thread pool for parallel price fetching # Lower concurrency to avoid triggering API limits (especially for forex/US stocks) executor = ThreadPoolExecutor(max_workers=3) # Request interval (seconds) to avoid too frequent requests REQUEST_INTERVAL = 0.3 # Rate limiting related _request_lock = threading.Lock() _last_request_time = {} # {market: timestamp} def _now_ts() -> int: return int(time.time()) def _normalize_symbol(symbol: str) -> str: return (symbol or '').strip().upper() def _safe_json_loads(value, default=None): """Safely parse JSON string.""" if default is None: default = {} if isinstance(value, dict): return value if isinstance(value, list): return value if isinstance(value, str) and value.strip(): try: return json.loads(value) except Exception: return default return default def _get_single_price(market: str, symbol: str, force_refresh: bool = False) -> dict: """ Get price data for a single symbol. 优先使用实时报价 API(ticker),降级使用分钟/日线 K 线数据。 这样可以在交易时段获取更实时的价格,而不是只显示日线收盘价。 内置速率限制:同一市场的请求间隔至少 REQUEST_INTERVAL 秒, 避免触发 API 限制(如 yfinance、Tiingo、Finnhub 等)。 Args: force_refresh: 是否强制刷新(跳过缓存) """ try: # 速率限制:同一市场的请求间隔 with _request_lock: now = time.time() last_time = _last_request_time.get(market, 0) wait_time = REQUEST_INTERVAL - (now - last_time) if wait_time > 0: time.sleep(wait_time) _last_request_time[market] = time.time() # 使用新的 get_realtime_price 方法获取实时价格 price_data = kline_service.get_realtime_price(market, symbol, force_refresh=force_refresh) return { 'market': market, 'symbol': symbol, 'price': price_data.get('price', 0), 'change': price_data.get('change', 0), 'changePercent': price_data.get('changePercent', 0), 'source': price_data.get('source', 'unknown') # 记录数据来源,便于调试 } except Exception as e: logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}") return { 'market': market, 'symbol': symbol, 'price': 0, 'change': 0, 'changePercent': 0, 'source': 'error' } # ==================== Position CRUD ==================== @portfolio_bp.route('/positions', methods=['GET']) @login_required def get_positions(): """Get all manual positions with current prices for the current user.""" try: user_id = g.user_id # Check if force refresh (skip cache) force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes') with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at FROM qd_manual_positions WHERE user_id = ? ORDER BY id DESC """, (user_id,) ) rows = cur.fetchall() or [] cur.close() positions = [] price_futures = {} # Prepare positions and submit price fetch tasks for row in rows: pos = { 'id': row.get('id'), 'market': row.get('market'), 'symbol': row.get('symbol'), 'name': row.get('name') or row.get('symbol'), 'side': row.get('side') or 'long', 'quantity': float(row.get('quantity') or 0), 'entry_price': float(row.get('entry_price') or 0), 'entry_time': row.get('entry_time'), 'notes': row.get('notes') or '', 'tags': _safe_json_loads(row.get('tags'), []), 'group_name': row.get('group_name') or '', 'created_at': row.get('created_at'), 'updated_at': row.get('updated_at'), # Will be filled later 'current_price': 0, 'price_change': 0, 'price_change_percent': 0, 'market_value': 0, 'cost_value': 0, 'pnl': 0, 'pnl_percent': 0 } positions.append(pos) # Submit price fetch task (with force_refresh support) market = row.get('market') symbol = row.get('symbol') if market and symbol: key = f"{market}:{symbol}" if key not in price_futures: future = executor.submit(_get_single_price, market, symbol, force_refresh) price_futures[key] = future # Collect price results price_map = {} for key, future in price_futures.items(): try: result = future.result(timeout=10) price_map[key] = result except Exception as e: logger.warning(f"Price fetch failed for {key}: {e}") # Calculate PnL for each position for pos in positions: key = f"{pos['market']}:{pos['symbol']}" price_data = price_map.get(key, {}) current_price = float(price_data.get('price') or 0) entry_price = pos['entry_price'] quantity = pos['quantity'] side = pos['side'] pos['current_price'] = current_price pos['price_change'] = price_data.get('change', 0) pos['price_change_percent'] = price_data.get('changePercent', 0) # Calculate values pos['market_value'] = current_price * quantity pos['cost_value'] = entry_price * quantity # Calculate PnL based on side if side == 'long': pos['pnl'] = (current_price - entry_price) * quantity else: # short pos['pnl'] = (entry_price - current_price) * quantity if pos['cost_value'] > 0: pos['pnl_percent'] = round(pos['pnl'] / pos['cost_value'] * 100, 2) return jsonify({'code': 1, 'msg': 'success', 'data': positions}) except Exception as e: logger.error(f"get_positions failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 @portfolio_bp.route('/positions', methods=['POST']) @login_required def add_position(): """Add a new manual position for the current user.""" try: user_id = g.user_id data = request.get_json() or {} market = (data.get('market') or '').strip() symbol = _normalize_symbol(data.get('symbol')) name_in = (data.get('name') or '').strip() side = (data.get('side') or 'long').strip().lower() quantity = float(data.get('quantity') or 0) entry_price = float(data.get('entry_price') or 0) entry_time = data.get('entry_time') or _now_ts() notes = (data.get('notes') or '').strip() tags = data.get('tags') or [] group_name = (data.get('group_name') or '').strip() if not market or not symbol: return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400 if quantity <= 0: return jsonify({'code': 0, 'msg': 'Quantity must be positive', 'data': None}), 400 if entry_price <= 0: return jsonify({'code': 0, 'msg': 'Entry price must be positive', 'data': None}), 400 if side not in ('long', 'short'): side = 'long' # Resolve display name resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol) name = name_in or resolved or symbol tags_json = json.dumps(tags if isinstance(tags, list) else [], ensure_ascii=False) with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_manual_positions (user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) ON CONFLICT(user_id, market, symbol, side, group_name) DO UPDATE SET name = excluded.name, quantity = excluded.quantity, entry_price = excluded.entry_price, entry_time = excluded.entry_time, notes = excluded.notes, tags = excluded.tags, group_name = excluded.group_name, updated_at = NOW() """, (user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name) ) position_id = cur.lastrowid db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': {'id': position_id}}) except Exception as e: logger.error(f"add_position failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @portfolio_bp.route('/positions/', methods=['PUT']) @login_required def update_position(position_id): """Update an existing position for the current user.""" try: user_id = g.user_id data = request.get_json() or {} updates = [] params = [] if 'name' in data: updates.append('name = ?') params.append((data.get('name') or '').strip()) if 'quantity' in data: quantity = float(data.get('quantity') or 0) if quantity <= 0: return jsonify({'code': 0, 'msg': 'Quantity must be positive', 'data': None}), 400 updates.append('quantity = ?') params.append(quantity) if 'entry_price' in data: entry_price = float(data.get('entry_price') or 0) if entry_price <= 0: return jsonify({'code': 0, 'msg': 'Entry price must be positive', 'data': None}), 400 updates.append('entry_price = ?') params.append(entry_price) if 'entry_time' in data: updates.append('entry_time = ?') params.append(data.get('entry_time')) if 'notes' in data: updates.append('notes = ?') params.append((data.get('notes') or '').strip()) if 'tags' in data: tags = data.get('tags') or [] updates.append('tags = ?') params.append(json.dumps(tags if isinstance(tags, list) else [], ensure_ascii=False)) if 'group_name' in data: updates.append('group_name = ?') params.append((data.get('group_name') or '').strip()) if not updates: return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400 updates.append('updated_at = NOW()') params.append(position_id) params.append(user_id) with get_db_connection() as db: cur = db.cursor() cur.execute( f"UPDATE qd_manual_positions SET {', '.join(updates)} WHERE id = ? AND user_id = ?", params ) db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': None}) except Exception as e: logger.error(f"update_position failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @portfolio_bp.route('/positions/', methods=['DELETE']) @login_required def delete_position(position_id): """Delete a position for the current user.""" try: user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( "DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?", (position_id, user_id) ) db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': None}) except Exception as e: logger.error(f"delete_position failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @portfolio_bp.route('/summary', methods=['GET']) @login_required def get_portfolio_summary(): """Get portfolio summary with total value, PnL, and market distribution for the current user.""" try: user_id = g.user_id # Check if force refresh force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes') with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT id, market, symbol, side, quantity, entry_price FROM qd_manual_positions WHERE user_id = ? """, (user_id,) ) rows = cur.fetchall() or [] cur.close() if not rows: return jsonify({ 'code': 1, 'msg': 'success', 'data': { 'total_cost': 0, 'total_market_value': 0, 'total_pnl': 0, 'total_pnl_percent': 0, 'position_count': 0, 'market_distribution': [] } }) # Fetch prices in parallel (with force_refresh support) price_futures = {} for row in rows: market = row.get('market') symbol = row.get('symbol') key = f"{market}:{symbol}" if key not in price_futures: future = executor.submit(_get_single_price, market, symbol, force_refresh) price_futures[key] = future price_map = {} for key, future in price_futures.items(): try: result = future.result(timeout=10) price_map[key] = result except Exception: pass # Calculate totals total_cost = 0 total_market_value = 0 total_pnl = 0 market_values = {} # {market: market_value} for row in rows: market = row.get('market') symbol = row.get('symbol') side = row.get('side') or 'long' quantity = float(row.get('quantity') or 0) entry_price = float(row.get('entry_price') or 0) key = f"{market}:{symbol}" price_data = price_map.get(key, {}) current_price = float(price_data.get('price') or 0) cost = entry_price * quantity market_val = current_price * quantity if side == 'long': pnl = (current_price - entry_price) * quantity else: pnl = (entry_price - current_price) * quantity total_cost += cost total_market_value += market_val total_pnl += pnl # Market distribution if market not in market_values: market_values[market] = 0 market_values[market] += market_val total_pnl_percent = round(total_pnl / total_cost * 100, 2) if total_cost > 0 else 0 # Build market distribution market_distribution = [] for market, value in market_values.items(): percent = round(value / total_market_value * 100, 2) if total_market_value > 0 else 0 market_distribution.append({ 'market': market, 'value': round(value, 2), 'percent': percent }) market_distribution.sort(key=lambda x: x['value'], reverse=True) return jsonify({ 'code': 1, 'msg': 'success', 'data': { 'total_cost': round(total_cost, 2), 'total_market_value': round(total_market_value, 2), 'total_pnl': round(total_pnl, 2), 'total_pnl_percent': total_pnl_percent, 'position_count': len(rows), 'market_distribution': market_distribution } }) except Exception as e: logger.error(f"get_portfolio_summary failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 # ==================== Monitor CRUD ==================== @portfolio_bp.route('/monitors', methods=['GET']) @login_required def get_monitors(): """Get all position monitors for the current user.""" try: user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT id, name, position_ids, monitor_type, config, notification_config, is_active, last_run_at, next_run_at, last_result, run_count, created_at, updated_at FROM qd_position_monitors WHERE user_id = ? ORDER BY id DESC """, (user_id,) ) rows = cur.fetchall() or [] cur.close() monitors = [] for row in rows: monitors.append({ 'id': row.get('id'), 'name': row.get('name') or '', 'position_ids': _safe_json_loads(row.get('position_ids'), []), 'monitor_type': row.get('monitor_type') or 'ai', 'config': _safe_json_loads(row.get('config'), {}), 'notification_config': _safe_json_loads(row.get('notification_config'), {}), 'is_active': bool(row.get('is_active')), 'last_run_at': row.get('last_run_at'), 'next_run_at': row.get('next_run_at'), 'last_result': _safe_json_loads(row.get('last_result'), {}), 'run_count': row.get('run_count') or 0, 'created_at': row.get('created_at'), 'updated_at': row.get('updated_at') }) return jsonify({'code': 1, 'msg': 'success', 'data': monitors}) except Exception as e: logger.error(f"get_monitors failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 @portfolio_bp.route('/monitors', methods=['POST']) @login_required def add_monitor(): """Add a new position monitor for the current user.""" try: user_id = g.user_id data = request.get_json() or {} name = (data.get('name') or '').strip() position_ids = data.get('position_ids') or [] monitor_type = (data.get('monitor_type') or 'ai').strip() config = data.get('config') or {} notification_config = data.get('notification_config') or {} is_active = bool(data.get('is_active', True)) if not name: return jsonify({'code': 0, 'msg': 'Monitor name is required', 'data': None}), 400 if monitor_type not in ('ai', 'price_alert', 'pnl_alert'): monitor_type = 'ai' # Calculate next_run_at based on interval interval_minutes = int(config.get('interval_minutes') or 60) position_ids_json = json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False) config_json = json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False) notification_config_json = json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False) with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_position_monitors (user_id, name, position_ids, monitor_type, config, notification_config, is_active, next_run_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW() + INTERVAL '%s minutes', NOW(), NOW()) """, (user_id, name, position_ids_json, monitor_type, config_json, notification_config_json, 1 if is_active else 0, interval_minutes) ) monitor_id = cur.lastrowid db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': {'id': monitor_id}}) except Exception as e: logger.error(f"add_monitor failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @portfolio_bp.route('/monitors/', methods=['PUT']) @login_required def update_monitor(monitor_id): """Update an existing monitor for the current user.""" try: user_id = g.user_id data = request.get_json() or {} updates = [] params = [] if 'name' in data: updates.append('name = ?') params.append((data.get('name') or '').strip()) if 'position_ids' in data: position_ids = data.get('position_ids') or [] updates.append('position_ids = ?') params.append(json.dumps(position_ids if isinstance(position_ids, list) else [], ensure_ascii=False)) if 'monitor_type' in data: updates.append('monitor_type = ?') params.append((data.get('monitor_type') or 'ai').strip()) next_run_interval = None # Will store interval for special handling if 'config' in data: config = data.get('config') or {} updates.append('config = ?') params.append(json.dumps(config if isinstance(config, dict) else {}, ensure_ascii=False)) # Recalculate next_run_at if interval changed (handled separately for PostgreSQL) next_run_interval = int(config.get('interval_minutes') or 60) if 'notification_config' in data: notification_config = data.get('notification_config') or {} updates.append('notification_config = ?') params.append(json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False)) if 'is_active' in data: updates.append('is_active = ?') params.append(1 if data.get('is_active') else 0) if not updates: return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400 # Add next_run_at update if interval was changed if next_run_interval is not None: updates.append(f"next_run_at = NOW() + INTERVAL '{next_run_interval} minutes'") updates.append('updated_at = NOW()') params.append(monitor_id) params.append(user_id) with get_db_connection() as db: cur = db.cursor() cur.execute( f"UPDATE qd_position_monitors SET {', '.join(updates)} WHERE id = ? AND user_id = ?", params ) db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': None}) except Exception as e: logger.error(f"update_monitor failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @portfolio_bp.route('/monitors/', methods=['DELETE']) @login_required def delete_monitor(monitor_id): """Delete a monitor for the current user.""" try: user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( "DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?", (monitor_id, user_id) ) db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': None}) except Exception as e: logger.error(f"delete_monitor failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @portfolio_bp.route('/monitors//run', methods=['POST']) @login_required def run_monitor_now(monitor_id): """Manually trigger a monitor to run immediately. Supports two modes: - async=true (default): Returns immediately, runs in background, notifies via notification system - async=false: Waits for completion and returns result (may timeout for large portfolios) """ try: from app.services.portfolio_monitor import run_single_monitor user_id = g.user_id # Get parameters from request body data = request.get_json(force=True, silent=True) or {} language = data.get('language') async_mode = data.get('async', True) # Default to async mode # Fallback to Accept-Language header for language if not language: accept_lang = request.headers.get('Accept-Language', '') if 'zh' in accept_lang.lower(): language = 'zh-CN' else: language = 'en-US' if async_mode: # Async mode: Start background thread and return immediately import threading def run_in_background(mid, lang, uid): try: run_single_monitor(mid, override_language=lang, user_id=uid) except Exception as e: logger.error(f"Background monitor run failed: {e}") thread = threading.Thread( target=run_in_background, args=(monitor_id, language, user_id), daemon=True ) thread.start() return jsonify({ 'code': 1, 'msg': 'success', 'data': { 'status': 'running', 'message': 'Monitor is running in background. Results will be sent via notification.' } }) else: # Sync mode: Wait for completion (may timeout) result = run_single_monitor(monitor_id, override_language=language, user_id=user_id) return jsonify({'code': 1, 'msg': 'success', 'data': result}) except Exception as e: logger.error(f"run_monitor_now failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 # ==================== Alerts CRUD ==================== @portfolio_bp.route('/alerts', methods=['GET']) @login_required def get_alerts(): """Get all position alerts for the current user.""" try: user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT a.id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold, a.notification_config, a.is_active, a.is_triggered, a.last_triggered_at, a.trigger_count, a.repeat_interval, a.notes, a.created_at, a.updated_at, p.name as position_name, p.side as position_side FROM qd_position_alerts a LEFT JOIN qd_manual_positions p ON a.position_id = p.id WHERE a.user_id = ? ORDER BY a.id DESC """, (user_id,) ) rows = cur.fetchall() or [] cur.close() alerts = [] for row in rows: alerts.append({ 'id': row.get('id'), 'position_id': row.get('position_id'), 'market': row.get('market') or '', 'symbol': row.get('symbol') or '', 'alert_type': row.get('alert_type') or 'price_above', 'threshold': float(row.get('threshold') or 0), 'notification_config': _safe_json_loads(row.get('notification_config'), {}), 'is_active': bool(row.get('is_active')), 'is_triggered': bool(row.get('is_triggered')), 'last_triggered_at': row.get('last_triggered_at'), 'trigger_count': row.get('trigger_count') or 0, 'repeat_interval': row.get('repeat_interval') or 0, 'notes': row.get('notes') or '', 'created_at': row.get('created_at'), 'updated_at': row.get('updated_at'), 'position_name': row.get('position_name') or '', 'position_side': row.get('position_side') or 'long' }) return jsonify({'code': 1, 'msg': 'success', 'data': alerts}) except Exception as e: logger.error(f"get_alerts failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500 @portfolio_bp.route('/alerts', methods=['POST']) @login_required def add_alert(): """Add a new position alert for the current user.""" try: user_id = g.user_id data = request.get_json() or {} position_id = data.get('position_id') # Can be None for symbol-level alerts market = (data.get('market') or '').strip() symbol = _normalize_symbol(data.get('symbol')) alert_type = (data.get('alert_type') or 'price_above').strip() threshold = float(data.get('threshold') or 0) notification_config = data.get('notification_config') or {} is_active = bool(data.get('is_active', True)) repeat_interval = int(data.get('repeat_interval') or 0) notes = (data.get('notes') or '').strip() # Validate alert_type valid_types = ('price_above', 'price_below', 'pnl_above', 'pnl_below') if alert_type not in valid_types: return jsonify({'code': 0, 'msg': f'Invalid alert_type. Must be one of: {valid_types}', 'data': None}), 400 # If position_id provided, get market/symbol from position if position_id: with get_db_connection() as db: cur = db.cursor() cur.execute( "SELECT market, symbol FROM qd_manual_positions WHERE id = ? AND user_id = ?", (position_id, user_id) ) pos = cur.fetchone() cur.close() if pos: market = pos.get('market') or market symbol = pos.get('symbol') or symbol if not market or not symbol: return jsonify({'code': 0, 'msg': 'Market and symbol are required', 'data': None}), 400 if threshold <= 0 and alert_type.startswith('price_'): return jsonify({'code': 0, 'msg': 'Threshold must be positive for price alerts', 'data': None}), 400 notification_config_json = json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False) with get_db_connection() as db: cur = db.cursor() # Check if alert already exists for this position (unique constraint) existing_alert_id = None if position_id: cur.execute( "SELECT id FROM qd_position_alerts WHERE position_id = ? AND user_id = ?", (position_id, user_id) ) existing = cur.fetchone() if existing: existing_alert_id = existing.get('id') if existing_alert_id: # Update existing alert instead of creating a new one cur.execute( """ UPDATE qd_position_alerts SET alert_type = ?, threshold = ?, notification_config = ?, is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = NOW() WHERE id = ? """, (alert_type, threshold, notification_config_json, 1 if is_active else 0, repeat_interval, notes, existing_alert_id) ) alert_id = existing_alert_id else: # Create new alert cur.execute( """ INSERT INTO qd_position_alerts (user_id, position_id, market, symbol, alert_type, threshold, notification_config, is_active, repeat_interval, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) """, (user_id, position_id, market, symbol, alert_type, threshold, notification_config_json, 1 if is_active else 0, repeat_interval, notes) ) alert_id = cur.lastrowid db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': {'id': alert_id}}) except Exception as e: logger.error(f"add_alert failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @portfolio_bp.route('/alerts/', methods=['PUT']) @login_required def update_alert(alert_id): """Update an existing alert for the current user.""" try: user_id = g.user_id data = request.get_json() or {} updates = [] params = [] if 'alert_type' in data: updates.append('alert_type = ?') params.append((data.get('alert_type') or 'price_above').strip()) if 'threshold' in data: updates.append('threshold = ?') params.append(float(data.get('threshold') or 0)) if 'notification_config' in data: notification_config = data.get('notification_config') or {} updates.append('notification_config = ?') params.append(json.dumps(notification_config if isinstance(notification_config, dict) else {}, ensure_ascii=False)) if 'is_active' in data: updates.append('is_active = ?') params.append(1 if data.get('is_active') else 0) # Reset triggered state when re-activating if data.get('is_active'): updates.append('is_triggered = ?') params.append(0) if 'repeat_interval' in data: updates.append('repeat_interval = ?') params.append(int(data.get('repeat_interval') or 0)) if 'notes' in data: updates.append('notes = ?') params.append((data.get('notes') or '').strip()) if not updates: return jsonify({'code': 0, 'msg': 'No fields to update', 'data': None}), 400 updates.append('updated_at = NOW()') params.append(alert_id) params.append(user_id) with get_db_connection() as db: cur = db.cursor() cur.execute( f"UPDATE qd_position_alerts SET {', '.join(updates)} WHERE id = ? AND user_id = ?", params ) db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': None}) except Exception as e: logger.error(f"update_alert failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @portfolio_bp.route('/alerts/', methods=['DELETE']) @login_required def delete_alert(alert_id): """Delete an alert for the current user.""" try: user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( "DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?", (alert_id, user_id) ) db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': None}) except Exception as e: logger.error(f"delete_alert failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 # ==================== Groups ==================== @portfolio_bp.route('/groups', methods=['GET']) @login_required def get_groups(): """Get list of all groups with position counts for the current user.""" try: user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT group_name, COUNT(*) as count FROM qd_manual_positions WHERE user_id = ? AND group_name != '' GROUP BY group_name ORDER BY group_name """, (user_id,) ) rows = cur.fetchall() or [] # Also get count of ungrouped cur.execute( "SELECT COUNT(*) as count FROM qd_manual_positions WHERE user_id = ? AND (group_name IS NULL OR group_name = '')", (user_id,) ) ungrouped = cur.fetchone() cur.close() groups = [] for row in rows: groups.append({ 'name': row.get('group_name'), 'count': row.get('count') or 0 }) # Add ungrouped count ungrouped_count = (ungrouped.get('count') or 0) if ungrouped else 0 return jsonify({ 'code': 1, 'msg': 'success', 'data': { 'groups': groups, 'ungrouped_count': ungrouped_count } }) except Exception as e: logger.error(f"get_groups failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 @portfolio_bp.route('/groups/rename', methods=['POST']) @login_required def rename_group(): """Rename a group for the current user.""" try: user_id = g.user_id data = request.get_json() or {} old_name = (data.get('old_name') or '').strip() new_name = (data.get('new_name') or '').strip() if not old_name: return jsonify({'code': 0, 'msg': 'old_name is required', 'data': None}), 400 with get_db_connection() as db: cur = db.cursor() cur.execute( "UPDATE qd_manual_positions SET group_name = ?, updated_at = NOW() WHERE user_id = ? AND group_name = ?", (new_name, user_id, old_name) ) db.commit() cur.close() return jsonify({'code': 1, 'msg': 'success', 'data': None}) except Exception as e: logger.error(f"rename_group failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500