diff --git a/backend_api_python/app/__init__.py b/backend_api_python/app/__init__.py index a48a8c9..e97cb1e 100644 --- a/backend_api_python/app/__init__.py +++ b/backend_api_python/app/__init__.py @@ -43,6 +43,30 @@ def get_reflection_worker(): return _reflection_worker +def start_portfolio_monitor(): + """Start the portfolio monitor service if enabled. + + To enable it, set ENABLE_PORTFOLIO_MONITOR=true. + """ + import os + enabled = os.getenv("ENABLE_PORTFOLIO_MONITOR", "true").lower() == "true" + if not enabled: + logger.info("Portfolio monitor is disabled. Set ENABLE_PORTFOLIO_MONITOR=true to enable.") + return + + # Avoid running twice with Flask reloader + debug = os.getenv("PYTHON_API_DEBUG", "false").lower() == "true" + if debug: + if os.environ.get("WERKZEUG_RUN_MAIN") != "true": + return + + try: + from app.services.portfolio_monitor import start_monitor_service + start_monitor_service() + except Exception as e: + logger.error(f"Failed to start portfolio monitor: {e}") + + def start_reflection_worker(): """ Start the reflection worker if enabled. @@ -163,6 +187,7 @@ def create_app(config_name='default'): with app.app_context(): start_pending_order_worker() start_reflection_worker() + start_portfolio_monitor() restore_running_strategies() return app diff --git a/backend_api_python/app/routes/__init__.py b/backend_api_python/app/routes/__init__.py index 15f4764..9cb114d 100644 --- a/backend_api_python/app/routes/__init__.py +++ b/backend_api_python/app/routes/__init__.py @@ -18,6 +18,7 @@ def register_routes(app: Flask): from app.routes.indicator import indicator_bp from app.routes.dashboard import dashboard_bp from app.routes.settings import settings_bp + from app.routes.portfolio import portfolio_bp app.register_blueprint(health_bp) app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login @@ -31,4 +32,5 @@ def register_routes(app: Flask): app.register_blueprint(credentials_bp, url_prefix='/api/credentials') app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard') app.register_blueprint(settings_bp, url_prefix='/api/settings') + app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio') diff --git a/backend_api_python/app/routes/portfolio.py b/backend_api_python/app/routes/portfolio.py new file mode 100644 index 0000000..32d7b26 --- /dev/null +++ b/backend_api_python/app/routes/portfolio.py @@ -0,0 +1,991 @@ +""" +Portfolio API routes (local-only). +Manages manual positions (user's existing holdings) and AI monitoring tasks. +""" +from flask import Blueprint, request, jsonify +import json +import traceback +import time +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.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 +executor = ThreadPoolExecutor(max_workers=10) + +DEFAULT_USER_ID = 1 + + +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) -> dict: + """Get price data for a single symbol.""" + try: + cache_key = f"portfolio_price:{market}:{symbol}" + cached_data = cache.get(cache_key) + + if cached_data: + return { + 'market': market, + 'symbol': symbol, + 'price': cached_data.get('price', 0), + 'change': cached_data.get('change', 0), + 'changePercent': cached_data.get('changePercent', 0) + } + + klines = kline_service.get_kline(market, symbol, '1D', 2) + + if klines and len(klines) > 0: + latest = klines[-1] + prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0) + current_price = latest.get('close', 0) + + change = round(current_price - prev_close, 4) if prev_close else 0 + change_percent = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0 + + result = { + 'market': market, + 'symbol': symbol, + 'price': current_price, + 'change': change, + 'changePercent': change_percent + } + + cache.set(cache_key, result, 60) + return result + else: + return { + 'market': market, + 'symbol': symbol, + 'price': 0, + 'change': 0, + 'changePercent': 0 + } + 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 + } + + +# ==================== Position CRUD ==================== + +@portfolio_bp.route('/positions', methods=['GET']) +def get_positions(): + """Get all manual positions with current prices.""" + try: + 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 + """, + (DEFAULT_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 + 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) + 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']) +def add_position(): + """Add a new manual position.""" + try: + 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) + now = _now_ts() + + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, market, symbol, side) 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 = excluded.updated_at + """, + (DEFAULT_USER_ID, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags_json, group_name, now, now) + ) + 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']) +def update_position(position_id): + """Update an existing position.""" + try: + 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 = ?') + params.append(_now_ts()) + params.append(position_id) + params.append(DEFAULT_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']) +def delete_position(position_id): + """Delete a position.""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?", + (position_id, DEFAULT_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']) +def get_portfolio_summary(): + """Get portfolio summary with total value, PnL, and market distribution.""" + try: + 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 = ? + """, + (DEFAULT_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 + 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) + 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']) +def get_monitors(): + """Get all position monitors.""" + try: + 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 + """, + (DEFAULT_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']) +def add_monitor(): + """Add a new position monitor.""" + try: + 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 + now = _now_ts() + interval_minutes = int(config.get('interval_minutes') or 60) + next_run_at = now + (interval_minutes * 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (DEFAULT_USER_ID, name, position_ids_json, monitor_type, config_json, notification_config_json, + 1 if is_active else 0, next_run_at, now, now) + ) + 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']) +def update_monitor(monitor_id): + """Update an existing monitor.""" + try: + 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()) + + 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 + interval_minutes = int(config.get('interval_minutes') or 60) + updates.append('next_run_at = ?') + params.append(_now_ts() + (interval_minutes * 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 + + updates.append('updated_at = ?') + params.append(_now_ts()) + params.append(monitor_id) + params.append(DEFAULT_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']) +def delete_monitor(monitor_id): + """Delete a monitor.""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?", + (monitor_id, DEFAULT_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']) +def run_monitor_now(monitor_id): + """Manually trigger a monitor to run immediately.""" + try: + from app.services.portfolio_monitor import run_single_monitor + + # Get language from request body or Accept-Language header + data = request.get_json(force=True, silent=True) or {} + language = data.get('language') + + # Fallback to Accept-Language header + if not language: + accept_lang = request.headers.get('Accept-Language', '') + if 'zh' in accept_lang.lower(): + language = 'en-US' + else: + language = 'en-US' + + result = run_single_monitor(monitor_id, override_language=language) + + 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']) +def get_alerts(): + """Get all position alerts.""" + try: + 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 + """, + (DEFAULT_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']) +def add_alert(): + """Add a new position alert.""" + try: + 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, DEFAULT_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 + + now = _now_ts() + 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, DEFAULT_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 = ? + WHERE id = ? + """, + (alert_type, threshold, notification_config_json, + 1 if is_active else 0, repeat_interval, notes, now, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (DEFAULT_USER_ID, position_id, market, symbol, alert_type, threshold, notification_config_json, + 1 if is_active else 0, repeat_interval, notes, now, now) + ) + 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']) +def update_alert(alert_id): + """Update an existing alert.""" + try: + 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 = ?') + params.append(_now_ts()) + params.append(alert_id) + params.append(DEFAULT_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']) +def delete_alert(alert_id): + """Delete an alert.""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?", + (alert_id, DEFAULT_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']) +def get_groups(): + """Get list of all groups with position counts.""" + try: + 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 + """, + (DEFAULT_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 = '')", + (DEFAULT_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']) +def rename_group(): + """Rename a group.""" + try: + 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 + + now = _now_ts() + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "UPDATE qd_manual_positions SET group_name = ?, updated_at = ? WHERE user_id = ? AND group_name = ?", + (new_name, now, DEFAULT_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 diff --git a/backend_api_python/app/routes/strategy.py b/backend_api_python/app/routes/strategy.py index 433c31b..9ff7d5c 100644 --- a/backend_api_python/app/routes/strategy.py +++ b/backend_api_python/app/routes/strategy.py @@ -752,4 +752,60 @@ def get_strategy_notifications(): except Exception as e: logger.error(f"get_strategy_notifications failed: {str(e)}") logger.error(traceback.format_exc()) - return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500 \ No newline at end of file + return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500 + + +@strategy_bp.route('/strategies/notifications/read', methods=['POST']) +def mark_notification_read(): + """Mark a single notification as read.""" + try: + data = request.get_json(force=True, silent=True) or {} + notification_id = data.get('id') + if not notification_id: + return jsonify({'code': 0, 'msg': 'Missing id'}), 400 + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + "UPDATE qd_strategy_notifications SET is_read = 1 WHERE id = ?", + (int(notification_id),) + ) + db.commit() + cur.close() + + return jsonify({'code': 1, 'msg': 'success'}) + except Exception as e: + logger.error(f"mark_notification_read failed: {str(e)}") + return jsonify({'code': 0, 'msg': str(e)}), 500 + + +@strategy_bp.route('/strategies/notifications/read-all', methods=['POST']) +def mark_all_notifications_read(): + """Mark all notifications as read.""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("UPDATE qd_strategy_notifications SET is_read = 1") + db.commit() + cur.close() + + return jsonify({'code': 1, 'msg': 'success'}) + except Exception as e: + logger.error(f"mark_all_notifications_read failed: {str(e)}") + return jsonify({'code': 0, 'msg': str(e)}), 500 + + +@strategy_bp.route('/strategies/notifications/clear', methods=['DELETE']) +def clear_notifications(): + """Clear all notifications (delete from database).""" + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute("DELETE FROM qd_strategy_notifications") + db.commit() + cur.close() + + return jsonify({'code': 1, 'msg': 'success'}) + except Exception as e: + logger.error(f"clear_notifications failed: {str(e)}") + return jsonify({'code': 0, 'msg': str(e)}), 500 \ No newline at end of file diff --git a/backend_api_python/app/services/llm.py b/backend_api_python/app/services/llm.py index 119c874..3d08328 100644 --- a/backend_api_python/app/services/llm.py +++ b/backend_api_python/app/services/llm.py @@ -37,7 +37,7 @@ class LLMService: config = load_addon_config() openrouter_config = config.get('openrouter', {}) - default_model = openrouter_config.get('model', 'google/gemini-3-pro-preview') + default_model = openrouter_config.get('model', 'openai/gpt-4o') if model is None: model = default_model diff --git a/backend_api_python/app/services/portfolio_monitor.py b/backend_api_python/app/services/portfolio_monitor.py new file mode 100644 index 0000000..59760aa --- /dev/null +++ b/backend_api_python/app/services/portfolio_monitor.py @@ -0,0 +1,1236 @@ +""" +Portfolio Monitor Service. +Runs scheduled AI analysis on manual positions and sends notifications. +""" +from __future__ import annotations + +import hashlib +import json +import threading +import time +import traceback +from typing import Any, Dict, List, Optional + +from app.utils.db import get_db_connection +from app.utils.logger import get_logger +from app.services.analysis import AnalysisService +from app.services.signal_notifier import SignalNotifier +from app.services.kline import KlineService + +logger = get_logger(__name__) + +DEFAULT_USER_ID = 1 + +_monitor_thread: Optional[threading.Thread] = None +_stop_event = threading.Event() + +# 多语言消息模板 +ALERT_MESSAGES = { + 'zh-CN': { + 'price_above': '🔔 价格突破预警: {symbol} 当前价格 ${current_price:.4f} 已突破 ${threshold:.4f}', + 'price_below': '🔔 价格跌破预警: {symbol} 当前价格 ${current_price:.4f} 已跌破 ${threshold:.4f}', + 'pnl_above': '🎉 盈利预警: {symbol} 当前盈亏 {pnl_percent:.1f}% 已达到 {threshold:.1f}% 目标', + 'pnl_below': '⚠️ 亏损预警: {symbol} 当前盈亏 {pnl_percent:.1f}% 已触及 {threshold:.1f}% 止损线', + 'alert_title': '价格/盈亏预警' + }, + 'en-US': { + 'price_above': '🔔 Price Alert: {symbol} current price ${current_price:.4f} has exceeded ${threshold:.4f}', + 'price_below': '🔔 Price Alert: {symbol} current price ${current_price:.4f} has dropped below ${threshold:.4f}', + 'pnl_above': '🎉 Profit Alert: {symbol} P&L {pnl_percent:.1f}% has reached {threshold:.1f}% target', + 'pnl_below': '⚠️ Loss Alert: {symbol} P&L {pnl_percent:.1f}% has hit {threshold:.1f}% stop-loss', + 'alert_title': 'Price/P&L Alert' + } +} + + +def _get_alert_message(alert_type: str, language: str = 'en-US', **kwargs) -> str: + """Get localized alert message.""" + lang = 'zh-CN' if language and language.startswith('zh') else 'en-US' + templates = ALERT_MESSAGES.get(lang, ALERT_MESSAGES['en-US']) + template = templates.get(alert_type, '') + if template: + return template.format(**kwargs) + return '' + + +def _get_alert_title(language: str = 'en-US') -> str: + """Get localized alert title.""" + lang = 'zh-CN' if language and language.startswith('zh') else 'en-US' + return ALERT_MESSAGES.get(lang, ALERT_MESSAGES['en-US']).get('alert_title', 'Alert') + + +def _now_ts() -> int: + return int(time.time()) + + +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_positions_for_monitor(position_ids: List[int] = None) -> List[Dict[str, Any]]: + """Get positions, optionally filtered by IDs.""" + try: + kline_service = KlineService() + + with get_db_connection() as db: + cur = db.cursor() + if position_ids: + placeholders = ','.join(['?' for _ in position_ids]) + cur.execute( + f""" + SELECT id, market, symbol, name, side, quantity, entry_price + FROM qd_manual_positions + WHERE user_id = ? AND id IN ({placeholders}) + """, + [DEFAULT_USER_ID] + list(position_ids) + ) + else: + cur.execute( + """ + SELECT id, market, symbol, name, side, quantity, entry_price + FROM qd_manual_positions + WHERE user_id = ? + """, + (DEFAULT_USER_ID,) + ) + rows = cur.fetchall() or [] + cur.close() + + positions = [] + for row in rows: + market = row.get('market') + symbol = row.get('symbol') + entry_price = float(row.get('entry_price') or 0) + quantity = float(row.get('quantity') or 0) + side = row.get('side') or 'long' + + # Get current price + current_price = 0 + try: + klines = kline_service.get_kline(market, symbol, '1D', 1) + if klines: + current_price = float(klines[-1].get('close') or 0) + except Exception: + pass + + # Calculate PnL + if side == 'long': + pnl = (current_price - entry_price) * quantity + else: + pnl = (entry_price - current_price) * quantity + + pnl_percent = round(pnl / (entry_price * quantity) * 100, 2) if entry_price * quantity > 0 else 0 + + positions.append({ + 'id': row.get('id'), + 'market': market, + 'symbol': symbol, + 'name': row.get('name') or symbol, + 'side': side, + 'quantity': quantity, + 'entry_price': entry_price, + 'current_price': current_price, + 'pnl': round(pnl, 2), + 'pnl_percent': pnl_percent + }) + + return positions + except Exception as e: + logger.error(f"_get_positions_for_monitor failed: {e}") + return [] + + +def _run_ai_analysis(positions: List[Dict[str, Any]], config: Dict[str, Any]) -> Dict[str, Any]: + """ + Run full multi-agent AI analysis on positions. + Uses the same 13-agent analysis flow as the AI Analysis page. + """ + try: + language = config.get('language', 'en-US') + custom_prompt = config.get('prompt', '') + + # Analyze each position using the full agent analysis flow + position_analyses = [] + + for pos in positions: + market = pos.get('market') + symbol = pos.get('symbol') + name = pos.get('name') or symbol + + if not market or not symbol: + continue + + try: + logger.info(f"Running multi-agent analysis for {market}:{symbol}") + + # Use the full AnalysisService (13-agent flow) + analysis_result = AnalysisService().analyze( + market=market, + symbol=symbol, + language=language, + timeframe='1D' + ) + + # Extract key information from the analysis + final_decision = analysis_result.get('final_decision', {}) + trader_decision = analysis_result.get('trader_decision', {}) + overview = analysis_result.get('overview', {}) + risk_report = analysis_result.get('risk', {}) + + position_analysis = { + 'market': market, + 'symbol': symbol, + 'name': name, + 'entry_price': pos.get('entry_price'), + 'current_price': pos.get('current_price'), + 'pnl': pos.get('pnl'), + 'pnl_percent': pos.get('pnl_percent'), + 'quantity': pos.get('quantity'), + 'side': pos.get('side'), + # Multi-agent analysis results + 'final_decision': final_decision.get('decision', 'HOLD'), + 'confidence': final_decision.get('confidence', 50), + 'reasoning': final_decision.get('reasoning', ''), + 'trader_decision': trader_decision.get('decision', 'HOLD'), + 'trader_reasoning': trader_decision.get('reasoning', ''), + 'overview_report': overview.get('report', ''), + 'risk_report': risk_report.get('report', ''), + 'error': analysis_result.get('error') + } + + position_analyses.append(position_analysis) + logger.info(f"Analysis completed for {market}:{symbol}: {final_decision.get('decision', 'N/A')}") + + except Exception as e: + logger.error(f"Failed to analyze {market}:{symbol}: {e}") + position_analyses.append({ + 'market': market, + 'symbol': symbol, + 'name': name, + 'error': str(e) + }) + + # Build comprehensive report + analysis_report = _build_comprehensive_report(positions, position_analyses, language, custom_prompt) + + return { + 'success': True, + 'analysis': analysis_report, + 'position_analyses': position_analyses, + 'position_count': len(positions), + 'analyzed_count': len([p for p in position_analyses if not p.get('error')]), + 'timestamp': _now_ts() + } + + except Exception as e: + logger.error(f"_run_ai_analysis failed: {e}") + logger.error(traceback.format_exc()) + return { + 'success': False, + 'error': str(e), + 'timestamp': _now_ts() + } + + +def _build_comprehensive_report( + positions: List[Dict[str, Any]], + position_analyses: List[Dict[str, Any]], + language: str, + custom_prompt: str = '' +) -> str: + """Build a comprehensive text report (backward compatible).""" + # Use HTML report as the main format + return _build_html_report(positions, position_analyses, language, custom_prompt) + + +def _build_html_report( + positions: List[Dict[str, Any]], + position_analyses: List[Dict[str, Any]], + language: str, + custom_prompt: str = '' +) -> str: + """Build a beautiful HTML report with collapsible sections.""" + + # Calculate portfolio summary + total_cost = sum(float(p.get('entry_price', 0)) * float(p.get('quantity', 0)) for p in positions) + total_pnl = sum(float(p.get('pnl', 0)) for p in positions) + total_pnl_percent = round(total_pnl / total_cost * 100, 2) if total_cost > 0 else 0 + total_market_value = sum(float(p.get('current_price', 0)) * float(p.get('quantity', 0)) for p in positions) + + # Count recommendations + buy_count = len([p for p in position_analyses if p.get('final_decision') == 'BUY']) + sell_count = len([p for p in position_analyses if p.get('final_decision') == 'SELL']) + hold_count = len([p for p in position_analyses if p.get('final_decision') == 'HOLD']) + + is_zh = language.startswith('zh') + + # Text translations + texts = { + 'title': '投资组合AI分析报告' if is_zh else 'Portfolio AI Analysis Report', + 'subtitle': '由 QuantDinger 多智能体分析系统生成' if is_zh else 'Generated by QuantDinger Multi-Agent Analysis System', + 'overview': '组合概览' if is_zh else 'Portfolio Overview', + 'positions': '持仓数量' if is_zh else 'Positions', + 'total_value': '总市值' if is_zh else 'Total Value', + 'total_cost': '总成本' if is_zh else 'Total Cost', + 'total_pnl': '总盈亏' if is_zh else 'Total P&L', + 'ai_recommendations': '🤖 AI智能分析建议' if is_zh else '🤖 AI Recommendations', + 'buy': '买入' if is_zh else 'Buy', + 'sell': '卖出' if is_zh else 'Sell', + 'hold': '持有' if is_zh else 'Hold', + 'position_analysis': '📈 各持仓详细分析' if is_zh else '📈 Position Analysis', + 'current_price': '当前价格' if is_zh else 'Current', + 'entry_price': '买入价' if is_zh else 'Entry', + 'pnl': '盈亏' if is_zh else 'P&L', + 'quantity': '数量' if is_zh else 'Qty', + 'side': '方向' if is_zh else 'Side', + 'long': '做多' if is_zh else 'Long', + 'short': '做空' if is_zh else 'Short', + 'ai_decision': 'AI决策' if is_zh else 'AI Decision', + 'confidence': '置信度' if is_zh else 'Confidence', + 'reasoning': '分析摘要' if is_zh else 'Summary', + 'trader_report': '📋 交易员详细评估' if is_zh else '📋 Trader Analysis', + 'risk_report': '⚠️ 风险评估' if is_zh else '⚠️ Risk Assessment', + 'overview_report': '📊 市场概览' if is_zh else '📊 Market Overview', + 'click_expand': '点击展开详情' if is_zh else 'Click to expand', + 'user_focus': '👤 用户关注点' if is_zh else '👤 User Focus', + 'generated_at': '报告生成时间' if is_zh else 'Generated at', + 'disclaimer': '本报告仅供参考,不构成投资建议。' if is_zh else 'For reference only. Not investment advice.', + 'analysis_failed': '分析失败' if is_zh else 'Analysis failed' + } + + # CSS Styles + css = ''' + + ''' + + # Build HTML + pnl_class = 'positive' if total_pnl >= 0 else 'negative' + pnl_sign = '+' if total_pnl >= 0 else '' + + html = f''' + {css} +
+
+

{texts['title']}

+
{texts['subtitle']}
+
+
+ +
+

{texts['overview']}

+
+
+
{texts['positions']}
+
{len(positions)}
+
+
+
{texts['total_value']}
+
${total_market_value:,.2f}
+
+
+
{texts['total_cost']}
+
${total_cost:,.2f}
+
+
+
{texts['total_pnl']}
+
{pnl_sign}${total_pnl:,.2f}({pnl_sign}{total_pnl_percent:.1f}%)
+
+
+
+ + +
+

{texts['ai_recommendations']}

+
+
+
🟢
+
{buy_count}
+
{texts['buy']}
+
+
+
🔴
+
{sell_count}
+
{texts['sell']}
+
+
+
🟡
+
{hold_count}
+
{texts['hold']}
+
+
+
+ + +
+

{texts['position_analysis']}

+ ''' + + for pa in position_analyses: + symbol = pa.get('symbol', '') + name = pa.get('name', symbol) + market = pa.get('market', '') + + if pa.get('error'): + html += f''' +
+
+
+
⚠️
+
+
{name}
+
{market}/{symbol}
+
+
+
+
{texts['analysis_failed']}: {pa.get('error')}
+
+ ''' + continue + + decision = pa.get('final_decision', 'HOLD') + decision_lower = decision.lower() + decision_text = texts.get(decision_lower, decision) + confidence = pa.get('confidence', 50) + + current_price = pa.get('current_price', 0) + entry_price = pa.get('entry_price', 0) + pnl = pa.get('pnl', 0) + pnl_pct = pa.get('pnl_percent', 0) + quantity = pa.get('quantity', 0) + side = pa.get('side', 'long') + side_text = texts['long'] if side == 'long' else texts['short'] + + pnl_class = 'positive' if pnl >= 0 else 'negative' + pnl_sign = '+' if pnl >= 0 else '' + + reasoning = pa.get('reasoning', '') + trader_reasoning = pa.get('trader_reasoning', '') + overview_report = pa.get('overview_report', '') + risk_report = pa.get('risk_report', '') + + html += f''' +
+
+
+
{decision[0]}
+
+
{name}
+
{market}/{symbol}
+
+
+
+
{decision_text}
+
{texts['confidence']}: {confidence}%
+
+
+
+
+
{texts['current_price']}
+
${current_price:.4f}
+
+
+
{texts['entry_price']}
+
${entry_price:.4f}
+
+
+
{texts['pnl']}
+
{pnl_sign}${pnl:.2f} ({pnl_sign}{pnl_pct:.1f}%)
+
+
+
{texts['quantity']} / {texts['side']}
+
{quantity} / {side_text}
+
+
+ ''' + + # Reasoning summary + if reasoning: + html += f''' +
+
{texts['reasoning']}
+
{reasoning[:500]}{'...' if len(reasoning) > 500 else ''}
+
+ ''' + + # Generate unique ID for collapsible sections (use symbol hash to avoid special chars) + section_id_base = hashlib.md5(f"{symbol}_{market}".encode()).hexdigest()[:8] + + # Collapsible: Trader Analysis + if trader_reasoning: + trader_id = f"trader_{section_id_base}" + html += f''' +
+ + +
{trader_reasoning.replace(chr(10), '
')}
+
+ ''' + + # Collapsible: Market Overview + if overview_report: + overview_id = f"overview_{section_id_base}" + html += f''' +
+ + +
{overview_report.replace(chr(10), '
')}
+
+ ''' + + # Collapsible: Risk Assessment + if risk_report: + risk_id = f"risk_{section_id_base}" + html += f''' +
+ + +
{risk_report.replace(chr(10), '
')}
+
+ ''' + + html += ''' +
+ ''' + + # User focus section + if custom_prompt: + html += f''' +
+
+

{texts['user_focus']}

+
{custom_prompt}
+ ''' + + # Footer + html += f''' +
+ +
+
+ ''' + + return html + + +def _build_telegram_report( + positions: List[Dict[str, Any]], + position_analyses: List[Dict[str, Any]], + language: str, + custom_prompt: str = '' +) -> str: + """Build a concise report suitable for Telegram (HTML format).""" + + # Calculate summary + total_cost = sum(float(p.get('entry_price', 0)) * float(p.get('quantity', 0)) for p in positions) + total_pnl = sum(float(p.get('pnl', 0)) for p in positions) + total_pnl_percent = round(total_pnl / total_cost * 100, 2) if total_cost > 0 else 0 + + buy_count = len([p for p in position_analyses if p.get('final_decision') == 'BUY']) + sell_count = len([p for p in position_analyses if p.get('final_decision') == 'SELL']) + hold_count = len([p for p in position_analyses if p.get('final_decision') == 'HOLD']) + + is_zh = language.startswith('zh') + pnl_sign = '+' if total_pnl >= 0 else '' + + if is_zh: + lines = [ + "📊 投资组合AI分析报告", + "", + "📈 组合概览", + f"• 持仓: {len(positions)} 个", + f"• 总成本: ${total_cost:,.2f}", + f"• 总盈亏: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_percent:.1f}%)", + "", + "🤖 AI建议汇总", + f"🟢 买入: {buy_count} | 🔴 卖出: {sell_count} | 🟡 持有: {hold_count}", + "", + "📋 持仓分析" + ] + + for pa in position_analyses: + if pa.get('error'): + lines.append(f"⚠️ {pa.get('name', pa.get('symbol'))}: 分析失败") + continue + + decision = pa.get('final_decision', 'HOLD') + emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '⚪') + text = {'BUY': '买入', 'SELL': '卖出', 'HOLD': '持有'}.get(decision, '持有') + pnl = pa.get('pnl', 0) + pnl_pct = pa.get('pnl_percent', 0) + pnl_s = '+' if pnl >= 0 else '' + + lines.append(f"\n{emoji} {pa.get('name', pa.get('symbol'))} ({pa.get('market')}/{pa.get('symbol')})") + lines.append(f" 💰 ${pa.get('current_price', 0):.2f} | 盈亏: {pnl_s}${pnl:.2f} ({pnl_s}{pnl_pct:.1f}%)") + lines.append(f" 🎯 建议: {text} (置信度 {pa.get('confidence', 50)}%)") + + reasoning = pa.get('reasoning', '') + if reasoning: + lines.append(f" 📝 {reasoning[:150]}{'...' if len(reasoning) > 150 else ''}") + + if custom_prompt: + lines.extend(["", f"👤 关注点: {custom_prompt}"]) + + lines.extend([ + "", + "─────────────────────", + f"⏰ {time.strftime('%Y-%m-%d %H:%M')}", + "由 QuantDinger 多智能体系统生成" + ]) + else: + lines = [ + "📊 Portfolio AI Analysis Report", + "", + "📈 Overview", + f"• Positions: {len(positions)}", + f"• Total Cost: ${total_cost:,.2f}", + f"• Total P&L: {pnl_sign}${total_pnl:,.2f} ({pnl_sign}{total_pnl_percent:.1f}%)", + "", + "🤖 AI Recommendations", + f"🟢 Buy: {buy_count} | 🔴 Sell: {sell_count} | 🟡 Hold: {hold_count}", + "", + "📋 Position Analysis" + ] + + for pa in position_analyses: + if pa.get('error'): + lines.append(f"⚠️ {pa.get('name', pa.get('symbol'))}: Analysis failed") + continue + + decision = pa.get('final_decision', 'HOLD') + emoji = {'BUY': '🟢', 'SELL': '🔴', 'HOLD': '🟡'}.get(decision, '⚪') + pnl = pa.get('pnl', 0) + pnl_pct = pa.get('pnl_percent', 0) + pnl_s = '+' if pnl >= 0 else '' + + lines.append(f"\n{emoji} {pa.get('name', pa.get('symbol'))} ({pa.get('market')}/{pa.get('symbol')})") + lines.append(f" 💰 ${pa.get('current_price', 0):.2f} | P&L: {pnl_s}${pnl:.2f} ({pnl_s}{pnl_pct:.1f}%)") + lines.append(f" 🎯 Rec: {decision} (Conf: {pa.get('confidence', 50)}%)") + + reasoning = pa.get('reasoning', '') + if reasoning: + lines.append(f" 📝 {reasoning[:150]}{'...' if len(reasoning) > 150 else ''}") + + if custom_prompt: + lines.extend(["", f"👤 Focus: {custom_prompt}"]) + + lines.extend([ + "", + "─────────────────────", + f"⏰ {time.strftime('%Y-%m-%d %H:%M')}", + "Generated by QuantDinger Multi-Agent System" + ]) + + return '\n'.join(lines) + + +def _send_monitor_notification( + monitor_name: str, + result: Dict[str, Any], + notification_config: Dict[str, Any], + positions: List[Dict[str, Any]] = None, + position_analyses: List[Dict[str, Any]] = None, + language: str = 'en-US', + custom_prompt: str = '' +) -> None: + """Send notification with analysis result using appropriate format for each channel.""" + try: + notifier = SignalNotifier() + + channels = notification_config.get('channels', ['browser']) + targets = notification_config.get('targets', {}) + + title = f"📊 资产监测: {monitor_name}" if language.startswith('zh') else f"📊 Portfolio Monitor: {monitor_name}" + + if not result.get('success'): + error_title = f"⚠️ 资产监测失败: {monitor_name}" if language.startswith('zh') else f"⚠️ Monitor Failed: {monitor_name}" + error_msg = f"分析失败: {result.get('error', 'Unknown error')}" if language.startswith('zh') else f"Analysis failed: {result.get('error', 'Unknown error')}" + + for channel in channels: + try: + ch = str(channel).strip().lower() + if ch == 'browser': + now = _now_ts() + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_strategy_notifications + (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (0, 'PORTFOLIO', 'ai_monitor', 'browser', error_title, error_msg, + json.dumps(result, ensure_ascii=False), now) + ) + db.commit() + cur.close() + elif ch == 'telegram': + chat_id = targets.get('telegram', '') + if chat_id: + notifier._notify_telegram(chat_id=chat_id, text=f"{error_title}\n\n{error_msg}", parse_mode="HTML") + elif ch == 'email': + to_email = targets.get('email', '') + if to_email: + notifier._notify_email(to_email=to_email, subject=error_title, body_text=error_msg) + except Exception as e: + logger.warning(f"Failed to send error notification to {channel}: {e}") + return + + # Generate reports for different channels + html_report = result.get('analysis', '') # This is already HTML from _build_html_report + + # Generate Telegram-specific report if we have the data + telegram_report = '' + if positions is not None and position_analyses is not None: + telegram_report = _build_telegram_report(positions, position_analyses, language, custom_prompt) + else: + # Fallback: strip HTML tags for Telegram + import re + telegram_report = re.sub(r'<[^>]+>', '', html_report) + if len(telegram_report) > 4000: + telegram_report = telegram_report[:4000] + '...' + + # Send to each channel + for channel in channels: + try: + ch = str(channel).strip().lower() + + if ch == 'browser': + # Browser notification uses HTML report + now = _now_ts() + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_strategy_notifications + (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (0, 'PORTFOLIO', 'ai_monitor', 'browser', title, html_report, + json.dumps(result, ensure_ascii=False), now) + ) + db.commit() + cur.close() + + elif ch == 'telegram': + chat_id = targets.get('telegram', '') + if chat_id: + # Use Telegram-optimized format + notifier._notify_telegram( + chat_id=chat_id, + text=telegram_report, + parse_mode="HTML" + ) + + elif ch == 'email': + to_email = targets.get('email', '') + if to_email: + # Email uses full HTML report + notifier._notify_email( + to_email=to_email, + subject=title, + body_text=html_report, + body_html=html_report # Send as HTML email + ) + + elif ch == 'webhook': + url = targets.get('webhook', '') + if url: + notifier._notify_webhook( + url=url, + payload={ + 'type': 'portfolio_monitor', + 'monitor_name': monitor_name, + 'result': result, + 'html_report': html_report + } + ) + + except Exception as e: + logger.warning(f"Failed to send notification to {channel}: {e}") + + except Exception as e: + logger.error(f"_send_monitor_notification failed: {e}") + + +def run_single_monitor(monitor_id: int, override_language: str = None) -> Dict[str, Any]: + """Run a single monitor and return the result. + + Args: + monitor_id: The monitor ID to run + override_language: Optional language override (e.g., 'zh-CN', 'en-US') + If provided, will override the language in monitor config + """ + try: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, name, position_ids, monitor_type, config, notification_config + FROM qd_position_monitors + WHERE id = ? AND user_id = ? + """, + (monitor_id, DEFAULT_USER_ID) + ) + row = cur.fetchone() + cur.close() + + if not row: + return {'success': False, 'error': 'Monitor not found'} + + name = row.get('name') or f'Monitor #{monitor_id}' + 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'), {}) + + # Override language if provided (from frontend) + if override_language: + config['language'] = override_language + + # Get positions + positions = _get_positions_for_monitor(position_ids if position_ids else None) + + if not positions: + return {'success': False, 'error': 'No positions to analyze'} + + # Run analysis based on type + if monitor_type == 'ai': + result = _run_ai_analysis(positions, config) + else: + # For other types, we can add price_alert, pnl_alert logic later + result = {'success': False, 'error': f'Unsupported monitor type: {monitor_type}'} + + # Update monitor record + now = _now_ts() + interval_minutes = int(config.get('interval_minutes') or 60) + next_run_at = now + (interval_minutes * 60) + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE qd_position_monitors + SET last_run_at = ?, next_run_at = ?, last_result = ?, run_count = run_count + 1, updated_at = ? + WHERE id = ? + """, + (now, next_run_at, json.dumps(result, ensure_ascii=False), now, monitor_id) + ) + db.commit() + cur.close() + + # Send notification + if notification_config.get('channels'): + language = config.get('language', 'en-US') + custom_prompt = config.get('prompt', '') + position_analyses = result.get('position_analyses', []) + _send_monitor_notification( + monitor_name=name, + result=result, + notification_config=notification_config, + positions=positions, + position_analyses=position_analyses, + language=language, + custom_prompt=custom_prompt + ) + + return result + except Exception as e: + logger.error(f"run_single_monitor failed: {e}") + logger.error(traceback.format_exc()) + return {'success': False, 'error': str(e)} + + +def _check_position_alerts(): + """Check all active alerts and trigger notifications if conditions are met.""" + try: + kline_service = KlineService() + notifier = SignalNotifier() + now = _now_ts() + + with get_db_connection() as db: + cur = db.cursor() + # Get active alerts that haven't been triggered (or can repeat) + cur.execute( + """ + SELECT a.id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold, + a.notification_config, a.is_triggered, a.last_triggered_at, a.repeat_interval, + p.entry_price, p.quantity, p.side, p.name as position_name + FROM qd_position_alerts a + LEFT JOIN qd_manual_positions p ON a.position_id = p.id + WHERE a.user_id = ? AND a.is_active = 1 + """, + (DEFAULT_USER_ID,) + ) + alerts = cur.fetchall() or [] + cur.close() + + for alert in alerts: + try: + alert_id = alert.get('id') + alert_type = alert.get('alert_type') + threshold = float(alert.get('threshold') or 0) + market = alert.get('market') + symbol = alert.get('symbol') + is_triggered = bool(alert.get('is_triggered')) + last_triggered_at = alert.get('last_triggered_at') or 0 + repeat_interval = int(alert.get('repeat_interval') or 0) + notification_config = _safe_json_loads(alert.get('notification_config'), {}) + + # Check if we can trigger (not triggered yet, or repeat interval passed) + can_trigger = not is_triggered + if is_triggered and repeat_interval > 0: + if now - last_triggered_at >= repeat_interval: + can_trigger = True + + if not can_trigger: + continue + + # Get current price + current_price = 0 + try: + klines = kline_service.get_kline(market, symbol, '1D', 1) + if klines: + current_price = float(klines[-1].get('close') or 0) + except Exception: + continue + + if current_price <= 0: + continue + + triggered = False + alert_message = "" + + # Get language from notification_config (saved when alert was created) + alert_language = notification_config.get('language', 'en-US') + + if alert_type == 'price_above': + if current_price >= threshold: + triggered = True + alert_message = _get_alert_message( + 'price_above', alert_language, + symbol=symbol, current_price=current_price, threshold=threshold + ) + + elif alert_type == 'price_below': + if current_price <= threshold: + triggered = True + alert_message = _get_alert_message( + 'price_below', alert_language, + symbol=symbol, current_price=current_price, threshold=threshold + ) + + elif alert_type in ('pnl_above', 'pnl_below'): + entry_price = float(alert.get('entry_price') or 0) + quantity = float(alert.get('quantity') or 0) + side = alert.get('side') or 'long' + + if entry_price > 0 and quantity > 0: + if side == 'long': + pnl = (current_price - entry_price) * quantity + else: + pnl = (entry_price - current_price) * quantity + pnl_percent = pnl / (entry_price * quantity) * 100 + + if alert_type == 'pnl_above' and pnl_percent >= threshold: + triggered = True + alert_message = _get_alert_message( + 'pnl_above', alert_language, + symbol=symbol, pnl_percent=pnl_percent, threshold=threshold + ) + elif alert_type == 'pnl_below' and pnl_percent <= threshold: + triggered = True + alert_message = _get_alert_message( + 'pnl_below', alert_language, + symbol=symbol, pnl_percent=pnl_percent, threshold=threshold + ) + + if triggered: + logger.info(f"Alert #{alert_id} triggered: {alert_message}") + + # Update alert status + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + UPDATE qd_position_alerts + SET is_triggered = 1, last_triggered_at = ?, trigger_count = trigger_count + 1, updated_at = ? + WHERE id = ? + """, + (now, now, alert_id) + ) + db.commit() + cur.close() + + # Send notification + channels = notification_config.get('channels', ['browser']) + targets = notification_config.get('targets', {}) + alert_title = _get_alert_title(alert_language) + + for channel in channels: + try: + ch = str(channel).strip().lower() + if ch == 'browser': + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_strategy_notifications + (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (0, symbol, 'price_alert', 'browser', alert_title, alert_message, + json.dumps({'alert_id': alert_id, 'alert_type': alert_type}, ensure_ascii=False), now) + ) + db.commit() + cur.close() + elif ch == 'telegram': + chat_id = targets.get('telegram', '') + if chat_id: + notifier._notify_telegram(chat_id=chat_id, text=alert_message, parse_mode="HTML") + elif ch == 'email': + to_email = targets.get('email', '') + if to_email: + notifier._notify_email(to_email=to_email, subject=alert_title, body_text=alert_message) + except Exception as e: + logger.warning(f"Failed to send alert notification: {e}") + + except Exception as e: + logger.warning(f"Error processing alert: {e}") + + except Exception as e: + logger.error(f"_check_position_alerts failed: {e}") + + +def notify_strategy_signal_for_positions(market: str, symbol: str, signal_type: str, signal_detail: str): + """ + Called when a strategy signal is triggered. + Check if user has manual positions in this symbol and send notification. + """ + try: + symbol = (symbol or '').strip().upper() + if not symbol: + return + + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, market, symbol, name, side, quantity, entry_price, group_name + FROM qd_manual_positions + WHERE user_id = ? AND symbol = ? + """, + (DEFAULT_USER_ID, symbol) + ) + positions = cur.fetchall() or [] + cur.close() + + if not positions: + return + + # User has positions in this symbol - send notification + notifier = SignalNotifier() + now = _now_ts() + + for pos in positions: + pos_name = pos.get('name') or symbol + pos_side = pos.get('side') or 'long' + quantity = float(pos.get('quantity') or 0) + entry_price = float(pos.get('entry_price') or 0) + + title = f"🔗 策略信号联动: {pos_name}" + message = f"""策略发出 {signal_type} 信号! + +标的: {market}/{symbol} +您的持仓: {pos_side.upper()} {quantity} @ {entry_price:.4f} + +信号详情: +{signal_detail} + +请注意检查您的持仓是否需要调整。""" + + # Save browser notification + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + INSERT INTO qd_strategy_notifications + (strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (0, symbol, 'strategy_linkage', 'browser', title, message, + json.dumps({'signal_type': signal_type}, ensure_ascii=False), now) + ) + db.commit() + cur.close() + + logger.info(f"Strategy signal linkage: notified {len(positions)} position(s) for {symbol}") + + except Exception as e: + logger.error(f"notify_strategy_signal_for_positions failed: {e}") + + +def _monitor_loop(): + """Background loop that checks and runs due monitors.""" + logger.info("Portfolio monitor background loop started") + + while not _stop_event.is_set(): + try: + now = _now_ts() + + # 1. Check position alerts (price/pnl alerts) + _check_position_alerts() + + # 2. Find AI monitors that are due + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id FROM qd_position_monitors + WHERE user_id = ? AND is_active = 1 AND next_run_at <= ? + ORDER BY next_run_at ASC + LIMIT 10 + """, + (DEFAULT_USER_ID, now) + ) + rows = cur.fetchall() or [] + cur.close() + + for row in rows: + if _stop_event.is_set(): + break + monitor_id = row.get('id') + if monitor_id: + logger.info(f"Running due monitor #{monitor_id}") + try: + run_single_monitor(monitor_id) + except Exception as e: + logger.error(f"Monitor #{monitor_id} execution failed: {e}") + except Exception as e: + logger.error(f"Monitor loop error: {e}") + + # Sleep for 30 seconds before next check + _stop_event.wait(30) + + logger.info("Portfolio monitor background loop stopped") + + +def start_monitor_service(): + """Start the background monitor service.""" + global _monitor_thread + + if _monitor_thread and _monitor_thread.is_alive(): + logger.info("Portfolio monitor service already running") + return + + _stop_event.clear() + _monitor_thread = threading.Thread(target=_monitor_loop, daemon=True, name="PortfolioMonitor") + _monitor_thread.start() + logger.info("Portfolio monitor service started") + + +def stop_monitor_service(): + """Stop the background monitor service.""" + global _monitor_thread + + _stop_event.set() + if _monitor_thread: + _monitor_thread.join(timeout=5) + _monitor_thread = None + logger.info("Portfolio monitor service stopped") diff --git a/backend_api_python/app/services/trading_executor.py b/backend_api_python/app/services/trading_executor.py index c701b95..5aa9029 100644 --- a/backend_api_python/app/services/trading_executor.py +++ b/backend_api_python/app/services/trading_executor.py @@ -964,6 +964,17 @@ class TradingExecutor: ) if ok: logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}") + # Notify portfolio positions linked to this symbol + try: + from app.services.portfolio_monitor import notify_strategy_signal_for_positions + notify_strategy_signal_for_positions( + market=market_type or 'Crypto', + symbol=symbol, + signal_type=signal_type, + signal_detail=f"策略: {strategy_name}\n信号: {signal_type}\n价格: {execute_price:.4f}" + ) + except Exception as link_e: + logger.warning(f"Strategy signal linkage notification failed: {link_e}") else: logger.warning(f"Strategy {strategy_id} signal rejected/failed: {signal_type}") diff --git a/backend_api_python/app/utils/db.py b/backend_api_python/app/utils/db.py index 6f6a096..f9ebfe3 100644 --- a/backend_api_python/app/utils/db.py +++ b/backend_api_python/app/utils/db.py @@ -242,6 +242,7 @@ def _init_db_schema(conn): "message": "TEXT DEFAULT ''", "payload_json": "TEXT DEFAULT ''", "created_at": "INTEGER", + "is_read": "INTEGER DEFAULT 0", }) # 4. 指标代码表(参考 MySQL: qd_indicator_codes) @@ -424,6 +425,119 @@ def _init_db_schema(conn): "updated_at": "INTEGER" }) + # 11. Manual positions (user's existing holdings outside the system) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_manual_positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 1, + market TEXT NOT NULL, -- Crypto/USStock/AShare/HShare/Forex/Futures + symbol TEXT NOT NULL, + name TEXT DEFAULT '', -- Display name + side TEXT DEFAULT 'long', -- long/short + quantity REAL NOT NULL DEFAULT 0, + entry_price REAL NOT NULL DEFAULT 0, + entry_time INTEGER, -- Position open timestamp + notes TEXT DEFAULT '', -- User notes + tags TEXT DEFAULT '', -- JSON array of tags + group_name TEXT DEFAULT '', -- Group name for categorization + created_at INTEGER, + updated_at INTEGER, + UNIQUE(user_id, market, symbol, side) + ) + """) + + ensure_columns("qd_manual_positions", { + "user_id": "INTEGER NOT NULL DEFAULT 1", + "market": "TEXT NOT NULL DEFAULT ''", + "symbol": "TEXT NOT NULL DEFAULT ''", + "name": "TEXT DEFAULT ''", + "side": "TEXT DEFAULT 'long'", + "quantity": "REAL NOT NULL DEFAULT 0", + "entry_price": "REAL NOT NULL DEFAULT 0", + "entry_time": "INTEGER", + "notes": "TEXT DEFAULT ''", + "tags": "TEXT DEFAULT ''", + "group_name": "TEXT DEFAULT ''", + "created_at": "INTEGER", + "updated_at": "INTEGER" + }) + + # 11.1 Position alerts (price alerts and PnL alerts for manual positions) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_position_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 1, + position_id INTEGER, -- FK to qd_manual_positions (NULL = symbol-level alert) + market TEXT DEFAULT '', + symbol TEXT DEFAULT '', + alert_type TEXT NOT NULL, -- price_above / price_below / pnl_above / pnl_below + threshold REAL NOT NULL DEFAULT 0, + notification_config TEXT DEFAULT '', -- JSON: channels, targets + is_active INTEGER DEFAULT 1, + is_triggered INTEGER DEFAULT 0, + last_triggered_at INTEGER, + trigger_count INTEGER DEFAULT 0, + repeat_interval INTEGER DEFAULT 0, -- 0=once, >0=repeat every N seconds + notes TEXT DEFAULT '', + created_at INTEGER, + updated_at INTEGER + ) + """) + + ensure_columns("qd_position_alerts", { + "user_id": "INTEGER NOT NULL DEFAULT 1", + "position_id": "INTEGER", + "market": "TEXT DEFAULT ''", + "symbol": "TEXT DEFAULT ''", + "alert_type": "TEXT NOT NULL DEFAULT 'price_above'", + "threshold": "REAL NOT NULL DEFAULT 0", + "notification_config": "TEXT DEFAULT ''", + "is_active": "INTEGER DEFAULT 1", + "is_triggered": "INTEGER DEFAULT 0", + "last_triggered_at": "INTEGER", + "trigger_count": "INTEGER DEFAULT 0", + "repeat_interval": "INTEGER DEFAULT 0", + "notes": "TEXT DEFAULT ''", + "created_at": "INTEGER", + "updated_at": "INTEGER" + }) + + # 12. Position monitors (AI analysis tasks for manual positions) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS qd_position_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 1, + name TEXT DEFAULT '', -- Monitor name + position_ids TEXT DEFAULT '', -- JSON array of position IDs (empty = all positions) + monitor_type TEXT DEFAULT 'ai', -- ai / price_alert / pnl_alert + config TEXT DEFAULT '', -- JSON config (interval_minutes, prompt, thresholds, etc.) + notification_config TEXT DEFAULT '', -- JSON notification config (channels, targets) + is_active INTEGER DEFAULT 1, + last_run_at INTEGER, + next_run_at INTEGER, + last_result TEXT DEFAULT '', -- Last analysis result (JSON) + run_count INTEGER DEFAULT 0, + created_at INTEGER, + updated_at INTEGER + ) + """) + + ensure_columns("qd_position_monitors", { + "user_id": "INTEGER NOT NULL DEFAULT 1", + "name": "TEXT DEFAULT ''", + "position_ids": "TEXT DEFAULT ''", + "monitor_type": "TEXT DEFAULT 'ai'", + "config": "TEXT DEFAULT ''", + "notification_config": "TEXT DEFAULT ''", + "is_active": "INTEGER DEFAULT 1", + "last_run_at": "INTEGER", + "next_run_at": "INTEGER", + "last_result": "TEXT DEFAULT ''", + "run_count": "INTEGER DEFAULT 0", + "created_at": "INTEGER", + "updated_at": "INTEGER" + }) + conn.commit() logger.info("Database schema initialized (SQLite)") diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 68dbe56..ad36874 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -31,6 +31,14 @@ SQLITE_DATABASE_FILE= # Local mode default is enabled in code, but you can override here. ENABLE_PENDING_ORDER_WORKER=true +# ========================= +# Portfolio monitor (optional) +# ========================= +# Background service that runs scheduled AI analysis on manual positions +# and sends notifications (email/telegram/browser). +# Default: enabled. Set to false to disable. +ENABLE_PORTFOLIO_MONITOR=true + # Reclaim orders stuck in status=processing after worker crashes (seconds). PENDING_ORDER_STALE_SEC=90 diff --git a/docs/NOTIFICATION_EMAIL_CONFIG_CH.md b/docs/NOTIFICATION_EMAIL_CONFIG_CH.md new file mode 100644 index 0000000..50bb435 --- /dev/null +++ b/docs/NOTIFICATION_EMAIL_CONFIG_CH.md @@ -0,0 +1,230 @@ +# 📧 邮箱 SMTP 通知配置指南 + +> QuantDinger 支持通过邮件推送策略信号通知,适合需要详细通知记录的用户。 + +--- + +## 📋 目录 + +- [前置要求](#前置要求) +- [支持的邮件服务商](#支持的邮件服务商) +- [配置步骤](#配置步骤) + - [第一步:获取 SMTP 服务信息](#第一步获取-smtp-服务信息) + - [第二步:配置环境变量](#第二步配置环境变量) + - [第三步:策略中启用邮件通知](#第三步策略中启用邮件通知) +- [常见邮件服务商配置示例](#常见邮件服务商配置示例) +- [常见问题](#常见问题) + +--- + +## 前置要求 + +- 拥有一个支持 SMTP 发送的邮箱账号 +- 邮箱已开启 SMTP 服务(部分服务商需手动开启) +- 获取邮箱的授权码或应用专用密码(非登录密码) +- QuantDinger 后端服务已部署并运行 + +--- + +## 支持的邮件服务商 + +QuantDinger 支持任何标准 SMTP 协议的邮件服务商,包括但不限于: + +| 服务商 | SMTP 服务器 | 端口 | 加密方式 | +|--------|------------|------|----------| +| Gmail | smtp.gmail.com | 587 (TLS) / 465 (SSL) | TLS / SSL | +| Outlook/Hotmail | smtp.office365.com | 587 | TLS | +| QQ 邮箱 | smtp.qq.com | 587 (TLS) / 465 (SSL) | TLS / SSL | +| 163 邮箱 | smtp.163.com | 465 (SSL) / 25 | SSL | +| 阿里企业邮箱 | smtp.qiye.aliyun.com | 465 | SSL | +| 腾讯企业邮箱 | smtp.exmail.qq.com | 465 | SSL | +| SendGrid | smtp.sendgrid.net | 587 | TLS | +| Mailgun | smtp.mailgun.org | 587 | TLS | +| Amazon SES | email-smtp.{region}.amazonaws.com | 587 | TLS | + +--- + +## 配置步骤 + +### 第一步:获取 SMTP 服务信息 + +以 **QQ 邮箱** 为例: + +1. 登录 QQ 邮箱网页版 → 设置 → 账户 +2. 找到「POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务」 +3. 开启「SMTP 服务」 +4. 按提示生成**授权码**(16位字母) + +> ⚠️ **重要**:授权码不是 QQ 密码,请妥善保管。 + +以 **Gmail** 为例: + +1. 访问 [Google 账号安全设置](https://myaccount.google.com/security) +2. 开启两步验证 +3. 生成「应用专用密码」(App Password) +4. 选择「邮件」和设备类型,获取 16 位密码 + +--- + +### 第二步:配置环境变量 + +在 `backend_api_python/.env` 文件中配置 SMTP 参数: + +```bash +# ========================= +# Email / SMTP 配置 +# ========================= + +# SMTP 服务器地址(必填) +SMTP_HOST=smtp.qq.com + +# SMTP 端口(必填) +# 常用端口:587 (TLS) / 465 (SSL) / 25 (不加密,不推荐) +SMTP_PORT=465 + +# SMTP 登录用户名(必填,通常是邮箱地址) +SMTP_USER=your_email@qq.com + +# SMTP 密码或授权码(必填) +SMTP_PASSWORD=your_authorization_code + +# 发件人地址(可选,默认使用 SMTP_USER) +SMTP_FROM=your_email@qq.com + +# 是否使用 STARTTLS(端口 587 通常设为 true) +SMTP_USE_TLS=false + +# 是否使用 SSL(端口 465 通常设为 true) +SMTP_USE_SSL=true +``` + +配置完成后重启后端服务使配置生效。 + +--- + +### 第三步:策略中启用邮件通知 + +在策略配置页面的「信号通知」设置中: + +1. 勾选启用 **Email** 通知渠道 +2. 在 **收件邮箱** 字段填入接收通知的邮箱地址 + +> 💡 **提示**:支持填入多个邮箱地址(逗号分隔),实现多人通知。 + +--- + +## 常见邮件服务商配置示例 + +### QQ 邮箱 + +```bash +SMTP_HOST=smtp.qq.com +SMTP_PORT=465 +SMTP_USER=123456789@qq.com +SMTP_PASSWORD=abcdefghijklmnop # 16位授权码 +SMTP_FROM=123456789@qq.com +SMTP_USE_TLS=false +SMTP_USE_SSL=true +``` + +### 163 邮箱 + +```bash +SMTP_HOST=smtp.163.com +SMTP_PORT=465 +SMTP_USER=your_email@163.com +SMTP_PASSWORD=your_authorization_code # 授权码 +SMTP_FROM=your_email@163.com +SMTP_USE_TLS=false +SMTP_USE_SSL=true +``` + +### Gmail + +```bash +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your_email@gmail.com +SMTP_PASSWORD=your_app_password # 应用专用密码 +SMTP_FROM=your_email@gmail.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +### Outlook / Office 365 + +```bash +SMTP_HOST=smtp.office365.com +SMTP_PORT=587 +SMTP_USER=your_email@outlook.com +SMTP_PASSWORD=your_password +SMTP_FROM=your_email@outlook.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +### SendGrid + +```bash +SMTP_HOST=smtp.sendgrid.net +SMTP_PORT=587 +SMTP_USER=apikey +SMTP_PASSWORD=SG.xxxxxxxxxxxxxxxxxxxxx # API Key +SMTP_FROM=verified_sender@yourdomain.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +### Amazon SES + +```bash +SMTP_HOST=email-smtp.us-east-1.amazonaws.com # 替换为您的区域 +SMTP_PORT=587 +SMTP_USER=AKIAIOSFODNN7EXAMPLE # SMTP 凭证用户名 +SMTP_PASSWORD=your_smtp_password # SMTP 凭证密码 +SMTP_FROM=verified@yourdomain.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +--- + +## 常见问题 + +### Q: 发送失败,提示认证错误? + +1. 检查用户名和密码(授权码)是否正确 +2. 确认已开启邮箱的 SMTP 服务 +3. 部分邮箱需要使用授权码而非登录密码 + +### Q: 连接超时? + +1. 检查 SMTP 服务器地址和端口是否正确 +2. 确认服务器防火墙是否放行对应端口 +3. 如使用代理,确认代理配置正确 + +### Q: TLS 和 SSL 如何选择? + +| 端口 | 加密方式 | 配置 | +|------|---------|------| +| 587 | STARTTLS | `SMTP_USE_TLS=true`, `SMTP_USE_SSL=false` | +| 465 | 隐式 SSL | `SMTP_USE_TLS=false`, `SMTP_USE_SSL=true` | +| 25 | 无加密 | `SMTP_USE_TLS=false`, `SMTP_USE_SSL=false` (不推荐)| + +### Q: 邮件被标记为垃圾邮件? + +1. 使用与 `SMTP_USER` 一致的 `SMTP_FROM` 地址 +2. 考虑使用专业邮件服务(如 SendGrid、Mailgun) +3. 配置域名 SPF、DKIM、DMARC 记录(企业邮箱) + +### Q: 可以发送 HTML 格式邮件吗? + +是的,QuantDinger 自动发送包含格式化信息的 HTML 邮件,同时附带纯文本备选内容以确保兼容性。 + +--- + +## 相关文档 + +- [Telegram 通知配置](./NOTIFICATION_TELEGRAM_CONFIG_CH.md) +- [手机短信通知配置](./NOTIFICATION_SMS_CONFIG_CH.md) +- [策略开发指南](./STRATEGY_DEV_GUIDE_CN.md) diff --git a/docs/NOTIFICATION_EMAIL_CONFIG_EN.md b/docs/NOTIFICATION_EMAIL_CONFIG_EN.md new file mode 100644 index 0000000..94c5ec3 --- /dev/null +++ b/docs/NOTIFICATION_EMAIL_CONFIG_EN.md @@ -0,0 +1,252 @@ +# 📧 Email SMTP Notification Configuration Guide + +> QuantDinger supports strategy signal notifications via email, ideal for users who need detailed notification records. + +--- + +## 📋 Table of Contents + +- [Prerequisites](#prerequisites) +- [Supported Email Providers](#supported-email-providers) +- [Configuration Steps](#configuration-steps) + - [Step 1: Obtain SMTP Service Information](#step-1-obtain-smtp-service-information) + - [Step 2: Configure Environment Variables](#step-2-configure-environment-variables) + - [Step 3: Enable Email Notifications in Strategy](#step-3-enable-email-notifications-in-strategy) +- [Provider Configuration Examples](#provider-configuration-examples) +- [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +- An email account with SMTP sending capability +- SMTP service enabled (some providers require manual activation) +- App password or authorization code (not your login password) +- QuantDinger backend service deployed and running + +--- + +## Supported Email Providers + +QuantDinger supports any standard SMTP protocol email provider, including: + +| Provider | SMTP Server | Port | Encryption | +|----------|-------------|------|------------| +| Gmail | smtp.gmail.com | 587 (TLS) / 465 (SSL) | TLS / SSL | +| Outlook/Hotmail | smtp.office365.com | 587 | TLS | +| Yahoo Mail | smtp.mail.yahoo.com | 587 | TLS | +| SendGrid | smtp.sendgrid.net | 587 | TLS | +| Mailgun | smtp.mailgun.org | 587 | TLS | +| Amazon SES | email-smtp.{region}.amazonaws.com | 587 | TLS | +| Zoho Mail | smtp.zoho.com | 587 | TLS | +| ProtonMail Bridge | 127.0.0.1 | 1025 | STARTTLS | + +--- + +## Configuration Steps + +### Step 1: Obtain SMTP Service Information + +**For Gmail:** + +1. Visit [Google Account Security Settings](https://myaccount.google.com/security) +2. Enable 2-Step Verification +3. Generate an "App Password" +4. Select "Mail" and your device type to get a 16-character password + +**For Outlook/Hotmail:** + +1. Use your regular email and password +2. Ensure "SMTP AUTH" is enabled in account settings +3. May require app password if 2FA is enabled + +> ⚠️ **Important**: App passwords are different from your login password. Keep them secure. + +--- + +### Step 2: Configure Environment Variables + +Add SMTP parameters to your `backend_api_python/.env` file: + +```bash +# ========================= +# Email / SMTP Configuration +# ========================= + +# SMTP server address (required) +SMTP_HOST=smtp.gmail.com + +# SMTP port (required) +# Common ports: 587 (TLS) / 465 (SSL) / 25 (unencrypted, not recommended) +SMTP_PORT=587 + +# SMTP username (required, usually your email address) +SMTP_USER=your_email@gmail.com + +# SMTP password or app password (required) +SMTP_PASSWORD=your_app_password + +# Sender address (optional, defaults to SMTP_USER) +SMTP_FROM=your_email@gmail.com + +# Enable STARTTLS (typically true for port 587) +SMTP_USE_TLS=true + +# Enable implicit SSL (typically true for port 465) +SMTP_USE_SSL=false +``` + +Restart the backend service after configuration to apply changes. + +--- + +### Step 3: Enable Email Notifications in Strategy + +In the strategy configuration page under "Signal Notifications": + +1. Enable the **Email** notification channel +2. Enter the recipient email address in the designated field + +> 💡 **Tip**: You can enter multiple email addresses (comma-separated) for multi-recipient notifications. + +--- + +## Provider Configuration Examples + +### Gmail + +```bash +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your_email@gmail.com +SMTP_PASSWORD=your_app_password # 16-character app password +SMTP_FROM=your_email@gmail.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +### Outlook / Office 365 + +```bash +SMTP_HOST=smtp.office365.com +SMTP_PORT=587 +SMTP_USER=your_email@outlook.com +SMTP_PASSWORD=your_password +SMTP_FROM=your_email@outlook.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +### Yahoo Mail + +```bash +SMTP_HOST=smtp.mail.yahoo.com +SMTP_PORT=587 +SMTP_USER=your_email@yahoo.com +SMTP_PASSWORD=your_app_password +SMTP_FROM=your_email@yahoo.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +### SendGrid + +```bash +SMTP_HOST=smtp.sendgrid.net +SMTP_PORT=587 +SMTP_USER=apikey +SMTP_PASSWORD=SG.xxxxxxxxxxxxxxxxxxxxx # Your API Key +SMTP_FROM=verified_sender@yourdomain.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +### Mailgun + +```bash +SMTP_HOST=smtp.mailgun.org +SMTP_PORT=587 +SMTP_USER=postmaster@your-domain.mailgun.org +SMTP_PASSWORD=your_mailgun_smtp_password +SMTP_FROM=noreply@yourdomain.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +### Amazon SES + +```bash +SMTP_HOST=email-smtp.us-east-1.amazonaws.com # Replace with your region +SMTP_PORT=587 +SMTP_USER=AKIAIOSFODNN7EXAMPLE # SMTP credential username +SMTP_PASSWORD=your_smtp_password # SMTP credential password +SMTP_FROM=verified@yourdomain.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +### Zoho Mail + +```bash +SMTP_HOST=smtp.zoho.com +SMTP_PORT=587 +SMTP_USER=your_email@zoho.com +SMTP_PASSWORD=your_password +SMTP_FROM=your_email@zoho.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +``` + +--- + +## Troubleshooting + +### Q: Authentication error? + +1. Verify username and password (app password) are correct +2. Confirm SMTP service is enabled in your email settings +3. Some providers require app passwords instead of login passwords +4. Check if "Less secure app access" needs to be enabled (legacy option) + +### Q: Connection timeout? + +1. Verify SMTP server address and port are correct +2. Check if server firewall allows the port +3. If using a proxy, ensure proxy configuration is correct +4. Some networks block SMTP ports; try port 587 or 465 + +### Q: How to choose between TLS and SSL? + +| Port | Encryption | Configuration | +|------|------------|---------------| +| 587 | STARTTLS | `SMTP_USE_TLS=true`, `SMTP_USE_SSL=false` | +| 465 | Implicit SSL | `SMTP_USE_TLS=false`, `SMTP_USE_SSL=true` | +| 25 | None | `SMTP_USE_TLS=false`, `SMTP_USE_SSL=false` (not recommended) | + +### Q: Emails marked as spam? + +1. Use a `SMTP_FROM` address matching your `SMTP_USER` +2. Consider professional email services (SendGrid, Mailgun, Amazon SES) +3. Configure SPF, DKIM, DMARC records for your domain (business email) +4. Avoid spam trigger words in subject/content + +### Q: Does it support HTML emails? + +Yes, QuantDinger automatically sends formatted HTML emails with a plain text fallback for maximum compatibility. + +### Q: Rate limits? + +Most providers have sending limits: +- Gmail: ~500/day (personal), 2000/day (Workspace) +- Outlook: ~300/day +- SendGrid: Based on your plan + +For high-volume notifications, consider dedicated email services. + +--- + +## Related Documentation + +- [Telegram Notification Configuration](./NOTIFICATION_TELEGRAM_CONFIG_EN.md) +- [SMS Notification Configuration](./NOTIFICATION_SMS_CONFIG_EN.md) +- [Strategy Development Guide](./STRATEGY_DEV_GUIDE.md) diff --git a/docs/NOTIFICATION_SMS_CONFIG_CH.md b/docs/NOTIFICATION_SMS_CONFIG_CH.md new file mode 100644 index 0000000..c820251 --- /dev/null +++ b/docs/NOTIFICATION_SMS_CONFIG_CH.md @@ -0,0 +1,211 @@ +# 📲 手机短信通知配置指南 + +> QuantDinger 支持通过 Twilio 发送 SMS 短信通知,确保您在任何情况下都能收到重要的交易信号。 + +--- + +## 📋 目录 + +- [前置要求](#前置要求) +- [Twilio 简介](#twilio-简介) +- [配置步骤](#配置步骤) + - [第一步:注册 Twilio 账号](#第一步注册-twilio-账号) + - [第二步:获取 API 凭证](#第二步获取-api-凭证) + - [第三步:获取发送号码](#第三步获取发送号码) + - [第四步:配置环境变量](#第四步配置环境变量) + - [第五步:策略中启用短信通知](#第五步策略中启用短信通知) +- [费用说明](#费用说明) +- [常见问题](#常见问题) + +--- + +## 前置要求 + +- 有效的手机号码用于接收短信 +- 可用的信用卡/借记卡用于 Twilio 充值(试用账户有免费额度) +- QuantDinger 后端服务已部署并运行 + +--- + +## Twilio 简介 + +[Twilio](https://www.twilio.com) 是全球领先的云通信平台,提供可靠的 SMS 短信服务。 + +**为什么选择 Twilio?** +- ✅ 全球覆盖 180+ 国家和地区 +- ✅ 高送达率和可靠性 +- ✅ 按量计费,无月费 +- ✅ 新用户有免费试用额度 +- ✅ 完善的 API 文档和技术支持 + +--- + +## 配置步骤 + +### 第一步:注册 Twilio 账号 + +1. 访问 [Twilio 官网](https://www.twilio.com/try-twilio) +2. 点击 **Sign Up** 注册新账号 +3. 填写邮箱、密码等基本信息 +4. 验证您的邮箱和手机号码 +5. 完成账号激活 + +> 💡 **提示**:新注册用户可获得 $15 美元的免费试用额度。 + +--- + +### 第二步:获取 API 凭证 + +注册完成后,进入 Twilio 控制台: + +1. 登录 [Twilio Console](https://console.twilio.com) +2. 在 Dashboard 页面找到 **Account Info** 区域 +3. 记录以下信息: + - **Account SID**:格式为 `ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` + - **Auth Token**:点击显示后复制(妥善保管) + +![Twilio Console](https://www.twilio.com/docs/static/img/console-account-info.png) + +> ⚠️ **安全提示**:Auth Token 相当于 API 密码,请勿泄露。如泄露请立即在控制台重新生成。 + +--- + +### 第三步:获取发送号码 + +您需要一个 Twilio 电话号码作为短信发送方: + +1. 在 Twilio Console 左侧菜单选择 **Phone Numbers** → **Manage** → **Buy a number** +2. 选择国家/地区,勾选 **SMS** 功能 +3. 选择一个号码并购买(试用账户可免费获取一个号码) +4. 记录您的 Twilio 电话号码(格式:`+1xxxxxxxxxx`) + +**号码选择建议:** +- 选择与接收方同一国家的号码可降低费用 +- 如需发送到中国大陆,建议使用 Alphanumeric Sender ID 或购买支持中国的号码 + +--- + +### 第四步:配置环境变量 + +在 `backend_api_python/.env` 文件中配置 Twilio 参数: + +```bash +# ========================= +# Phone / SMS 配置 (Twilio) +# ========================= + +# Twilio Account SID(必填) +# 格式:ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Twilio Auth Token(必填) +# 从 Twilio Console 获取 +TWILIO_AUTH_TOKEN=your_auth_token_here + +# Twilio 发送号码(必填) +# 格式:+国家代码+号码,如 +14155552671 +TWILIO_FROM_NUMBER=+14155552671 +``` + +配置完成后重启后端服务使配置生效。 + +--- + +### 第五步:策略中启用短信通知 + +在策略配置页面的「信号通知」设置中: + +1. 勾选启用 **Phone** 通知渠道 +2. 在 **手机号码** 字段填入接收短信的号码 + +**号码格式要求:** +- 必须包含国家代码 +- 格式:`+国家代码号码` +- 示例: + - 美国:`+14155552671` + - 中国大陆:`+8613812345678` + - 香港:`+85212345678` + +> 💡 **提示**:支持填入多个号码(逗号分隔),实现多人通知。 + +--- + +## 费用说明 + +Twilio 采用按量计费模式,不同国家/地区的短信费用不同: + +| 接收地区 | 大约费用(美元/条) | +|---------|-------------------| +| 美国 | $0.0079 | +| 加拿大 | $0.0075 | +| 英国 | $0.04 | +| 德国 | $0.07 | +| 日本 | $0.08 | +| 中国大陆 | $0.06 - $0.10 | +| 香港 | $0.05 | + +> 💰 **提示**:实际费用请参考 [Twilio 定价页面](https://www.twilio.com/sms/pricing),价格可能随时调整。 + +**试用账户限制:** +- $15 美元免费额度 +- 只能发送到已验证的手机号码 +- 短信会带有 "Sent from your Twilio trial account" 前缀 + +升级为正式账户后可解除这些限制。 + +--- + +## 常见问题 + +### Q: 试用账户可以发短信到任意号码吗? + +不可以。试用账户只能发送到已验证的手机号码。您需要在 Twilio Console 的 **Verified Caller IDs** 中添加并验证接收号码。升级为正式账户后可发送到任意号码。 + +### Q: 发送失败,提示号码无效? + +1. 确保号码格式正确(含国家代码,如 `+8613812345678`) +2. 去掉号码中的空格、横线等特殊字符 +3. 确认接收号码可以接收国际短信 + +### Q: 中国大陆号码收不到短信? + +1. 部分运营商可能拦截国际短信 +2. 尝试使用 Alphanumeric Sender ID +3. 确认手机未开启国际短信拦截 +4. 联系 Twilio 支持确认中国短信服务状态 + +### Q: 如何查看发送记录和状态? + +登录 Twilio Console → **Monitor** → **Logs** → **Messaging** 可查看所有短信发送记录、状态和错误信息。 + +### Q: Auth Token 泄露了怎么办? + +立即登录 Twilio Console → **Account** → **API Credentials** → 点击 **Regenerate Auth Token** 重新生成。 + +### Q: 有替代 Twilio 的方案吗? + +QuantDinger 目前仅支持 Twilio 作为 SMS 提供商。如需其他服务商支持,可通过 Webhook 通道自行集成: +- 阿里云短信 +- 腾讯云短信 +- Nexmo (Vonage) +- AWS SNS + +--- + +## 环境变量完整参考 + +```bash +# Twilio SMS Configuration +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Account SID +TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Auth Token +TWILIO_FROM_NUMBER=+14155552671 # 发送号码 +``` + +--- + +## 相关文档 + +- [Telegram 通知配置](./NOTIFICATION_TELEGRAM_CONFIG_CH.md) +- [邮箱 SMTP 通知配置](./NOTIFICATION_EMAIL_CONFIG_CH.md) +- [策略开发指南](./STRATEGY_DEV_GUIDE_CN.md) +- [Twilio 官方文档](https://www.twilio.com/docs/sms) diff --git a/docs/NOTIFICATION_SMS_CONFIG_EN.md b/docs/NOTIFICATION_SMS_CONFIG_EN.md new file mode 100644 index 0000000..209dff1 --- /dev/null +++ b/docs/NOTIFICATION_SMS_CONFIG_EN.md @@ -0,0 +1,211 @@ +# 📲 SMS Notification Configuration Guide + +> QuantDinger supports SMS notifications via Twilio, ensuring you receive critical trading signals anywhere. + +--- + +## 📋 Table of Contents + +- [Prerequisites](#prerequisites) +- [About Twilio](#about-twilio) +- [Configuration Steps](#configuration-steps) + - [Step 1: Create a Twilio Account](#step-1-create-a-twilio-account) + - [Step 2: Obtain API Credentials](#step-2-obtain-api-credentials) + - [Step 3: Get a Phone Number](#step-3-get-a-phone-number) + - [Step 4: Configure Environment Variables](#step-4-configure-environment-variables) + - [Step 5: Enable SMS Notifications in Strategy](#step-5-enable-sms-notifications-in-strategy) +- [Pricing Information](#pricing-information) +- [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +- A valid phone number to receive SMS messages +- A credit/debit card for Twilio billing (trial accounts include free credits) +- QuantDinger backend service deployed and running + +--- + +## About Twilio + +[Twilio](https://www.twilio.com) is a leading cloud communications platform providing reliable SMS services worldwide. + +**Why Twilio?** +- ✅ Global coverage in 180+ countries +- ✅ High deliverability and reliability +- ✅ Pay-as-you-go pricing, no monthly fees +- ✅ Free trial credits for new users +- ✅ Comprehensive API documentation and support + +--- + +## Configuration Steps + +### Step 1: Create a Twilio Account + +1. Visit [Twilio Sign Up](https://www.twilio.com/try-twilio) +2. Click **Sign Up** to create a new account +3. Fill in your email, password, and basic information +4. Verify your email and phone number +5. Complete account activation + +> 💡 **Tip**: New users receive $15 USD in free trial credits. + +--- + +### Step 2: Obtain API Credentials + +After registration, access the Twilio Console: + +1. Log in to [Twilio Console](https://console.twilio.com) +2. Locate the **Account Info** section on the Dashboard +3. Note the following information: + - **Account SID**: Format `ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` + - **Auth Token**: Click to reveal and copy (keep this secure) + +> ⚠️ **Security Notice**: The Auth Token is essentially your API password. Never share it publicly. If compromised, regenerate it immediately in the console. + +--- + +### Step 3: Get a Phone Number + +You need a Twilio phone number as the SMS sender: + +1. In Twilio Console, navigate to **Phone Numbers** → **Manage** → **Buy a number** +2. Select a country and check the **SMS** capability +3. Choose and purchase a number (trial accounts get one free number) +4. Note your Twilio phone number (format: `+1xxxxxxxxxx`) + +**Number Selection Tips:** +- Choose a number from the same country as recipients to reduce costs +- For international recipients, consider the destination country's regulations +- Some countries require sender ID registration + +--- + +### Step 4: Configure Environment Variables + +Add Twilio parameters to your `backend_api_python/.env` file: + +```bash +# ========================= +# Phone / SMS Configuration (Twilio) +# ========================= + +# Twilio Account SID (required) +# Format: ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Twilio Auth Token (required) +# Obtained from Twilio Console +TWILIO_AUTH_TOKEN=your_auth_token_here + +# Twilio Sender Number (required) +# Format: +CountryCodeNumber, e.g., +14155552671 +TWILIO_FROM_NUMBER=+14155552671 +``` + +Restart the backend service after configuration to apply changes. + +--- + +### Step 5: Enable SMS Notifications in Strategy + +In the strategy configuration page under "Signal Notifications": + +1. Enable the **Phone** notification channel +2. Enter the recipient phone number in the designated field + +**Number Format Requirements:** +- Must include country code +- Format: `+CountryCodeNumber` +- Examples: + - United States: `+14155552671` + - United Kingdom: `+447911123456` + - Germany: `+4915112345678` + - Australia: `+61412345678` + +> 💡 **Tip**: You can enter multiple numbers (comma-separated) for multi-recipient notifications. + +--- + +## Pricing Information + +Twilio uses pay-as-you-go pricing. SMS costs vary by destination: + +| Destination | Approx. Cost (USD/message) | +|-------------|---------------------------| +| United States | $0.0079 | +| Canada | $0.0075 | +| United Kingdom | $0.04 | +| Germany | $0.07 | +| Australia | $0.05 | +| Japan | $0.08 | +| India | $0.04 | + +> 💰 **Note**: For current pricing, visit [Twilio SMS Pricing](https://www.twilio.com/sms/pricing). Prices may change. + +**Trial Account Limitations:** +- $15 USD free credits +- Can only send to verified phone numbers +- Messages include "Sent from your Twilio trial account" prefix + +Upgrade to a paid account to remove these limitations. + +--- + +## Troubleshooting + +### Q: Can trial accounts send to any number? + +No. Trial accounts can only send to verified phone numbers. Add and verify recipient numbers in Twilio Console under **Verified Caller IDs**. Upgrade to a paid account for unrestricted sending. + +### Q: Send failed with invalid number error? + +1. Ensure correct format with country code (e.g., `+14155552671`) +2. Remove spaces, dashes, or special characters from the number +3. Verify the recipient can receive international SMS + +### Q: Messages not delivered to certain countries? + +1. Some carriers may block international SMS +2. Check country-specific regulations (some require sender ID registration) +3. Verify the destination country is supported by Twilio +4. Contact Twilio support for country-specific issues + +### Q: How to check delivery status? + +Log in to Twilio Console → **Monitor** → **Logs** → **Messaging** to view all SMS records, delivery status, and error details. + +### Q: Auth Token was compromised? + +Immediately log in to Twilio Console → **Account** → **API Credentials** → Click **Regenerate Auth Token**. + +### Q: Are there alternatives to Twilio? + +QuantDinger currently only supports Twilio as the SMS provider. For other services, use the Webhook channel to integrate: +- Nexmo (Vonage) +- AWS SNS +- MessageBird +- Plivo + +--- + +## Complete Environment Variable Reference + +```bash +# Twilio SMS Configuration +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Account SID +TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Auth Token +TWILIO_FROM_NUMBER=+14155552671 # Sender number +``` + +--- + +## Related Documentation + +- [Telegram Notification Configuration](./NOTIFICATION_TELEGRAM_CONFIG_EN.md) +- [Email SMTP Notification Configuration](./NOTIFICATION_EMAIL_CONFIG_EN.md) +- [Strategy Development Guide](./STRATEGY_DEV_GUIDE.md) +- [Twilio Official Documentation](https://www.twilio.com/docs/sms) diff --git a/docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md b/docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md index e824bfe..fa95f01 100644 --- a/docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md +++ b/docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md @@ -1,14 +1,126 @@ -# TELEGRAM通知配置 +# 📱 Telegram 通知配置指南 -## 1.创建telegram机器人并且获取token -QuantDinger Dashboard +> QuantDinger 支持通过 Telegram Bot 推送策略信号通知,实时获取交易提醒。 -## 2.1 在策略中配置UserID -QuantDinger Dashboard +--- + +## 📋 目录 + +- [前置要求](#前置要求) +- [第一步:创建 Telegram Bot](#第一步创建-telegram-bot) +- [第二步:获取 Bot Token](#第二步获取-bot-token) +- [第三步:获取您的 User ID](#第三步获取您的-user-id) +- [第四步:配置环境变量](#第四步配置环境变量) +- [第五步:策略中启用 Telegram 通知](#第五步策略中启用-telegram-通知) +- [常见问题](#常见问题) + +--- + +## 前置要求 + +- 已安装 Telegram 客户端(手机或桌面版) +- 拥有 Telegram 账号 +- QuantDinger 后端服务已部署并运行 + +--- + +## 第一步:创建 Telegram Bot + +1. 在 Telegram 中搜索 **@BotFather**(官方机器人管理工具) +2. 发送 `/newbot` 命令开始创建新机器人 +3. 按照提示输入机器人名称(如:`QuantDinger Signal Bot`) +4. 输入机器人用户名(必须以 `bot` 结尾,如:`quantdinger_signal_bot`) + +创建 Telegram Bot + +--- + +## 第二步:获取 Bot Token + +创建成功后,BotFather 会返回一个 **HTTP API Token**,格式如下: -## 2.2 UserID的获取 -```aiignore -https://api.telegram.org/bot【你的哪个token】/getUpdates ``` -UserID从这个链接获取,把你的token填进去,token格式是【123:abc】 -![notification_telegram_userid_get.png](screenshots/notification_telegram_userid_get.png) \ No newline at end of file +123456789:ABCdefGHIjklMNOpqrsTUVwxyz +``` + +> ⚠️ **安全提示**:请妥善保管此 Token,不要泄露给他人。如果 Token 泄露,请立即在 BotFather 中使用 `/revoke` 命令重新生成。 + +--- + +## 第三步:获取您的 User ID + +### 方法一:通过 API 获取(推荐) + +1. 先向您刚创建的 Bot 发送任意消息(如 `/start`) +2. 在浏览器中访问以下 URL(将 `YOUR_BOT_TOKEN` 替换为您的 Token): + +``` +https://api.telegram.org/bot{YOUR_BOT_TOKEN}/getUpdates +``` + +**示例**: +``` +https://api.telegram.org/bot123456789:ABCdefGHIjklMNOpqrsTUVwxyz/getUpdates +``` + +3. 在返回的 JSON 中找到 `chat.id` 字段,这就是您的 User ID + +获取 User ID + +### 方法二:通过 @userinfobot 获取 + +1. 在 Telegram 中搜索 **@userinfobot** +2. 发送任意消息,机器人会返回您的 User ID + +--- + +## 第四步:配置环境变量 + +在 `backend_api_python/.env` 文件中配置 Bot Token: + +```bash +# Telegram Bot Token(必填) +TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz +``` + +配置完成后重启后端服务使配置生效。 + +--- + +## 第五步:策略中启用 Telegram 通知 + +在策略配置页面的「信号通知」设置中: + +1. 勾选启用 **Telegram** 通知渠道 +2. 在 **User ID** 字段填入您的 Telegram User ID + +配置 User ID + +> 💡 **提示**:支持填入多个 User ID(逗号分隔)或群组/频道 ID,实现多人通知。 + +--- + +## 常见问题 + +### Q: 收不到通知怎么办? + +1. 确认已向 Bot 发送过消息(Bot 需要先被激活) +2. 检查 `TELEGRAM_BOT_TOKEN` 环境变量是否正确配置 +3. 确认 User ID 填写正确 +4. 查看后端日志是否有错误信息 + +### Q: 可以发送到群组吗? + +可以。将 Bot 添加到群组后,使用群组 ID(负数)作为 User ID 即可。获取群组 ID 的方法与获取个人 ID 类似。 + +### Q: Token 格式是什么? + +Token 格式为 `数字:字母数字字符串`,例如 `123456789:ABCdefGHIjklMNOpqrsTUVwxyz` + +--- + +## 相关文档 + +- [邮箱 SMTP 通知配置](./NOTIFICATION_EMAIL_CONFIG_CH.md) +- [手机短信通知配置](./NOTIFICATION_SMS_CONFIG_CH.md) +- [策略开发指南](./STRATEGY_DEV_GUIDE_CN.md) diff --git a/docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md b/docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md index 77c531b..60652d2 100644 --- a/docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md +++ b/docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md @@ -1,14 +1,126 @@ -# TELEGRAM NOTIFICATION CONFIG +# 📱 Telegram Notification Configuration Guide -## 1.Create telegram Bot and get Bot token -QuantDinger Dashboard +> QuantDinger supports real-time strategy signal notifications via Telegram Bot. -## 2.1 configuration UserID in signal config -QuantDinger Dashboard +--- + +## 📋 Table of Contents + +- [Prerequisites](#prerequisites) +- [Step 1: Create a Telegram Bot](#step-1-create-a-telegram-bot) +- [Step 2: Obtain Bot Token](#step-2-obtain-bot-token) +- [Step 3: Get Your User ID](#step-3-get-your-user-id) +- [Step 4: Configure Environment Variables](#step-4-configure-environment-variables) +- [Step 5: Enable Telegram Notifications in Strategy](#step-5-enable-telegram-notifications-in-strategy) +- [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +- Telegram client installed (mobile or desktop) +- Active Telegram account +- QuantDinger backend service deployed and running + +--- + +## Step 1: Create a Telegram Bot + +1. Search for **@BotFather** in Telegram (official bot management tool) +2. Send the `/newbot` command to create a new bot +3. Enter a display name for your bot (e.g., `QuantDinger Signal Bot`) +4. Choose a unique username ending with `bot` (e.g., `quantdinger_signal_bot`) + +Create Telegram Bot + +--- + +## Step 2: Obtain Bot Token + +Upon successful creation, BotFather will provide an **HTTP API Token** in this format: -## 2.2 UserID edit and get -```aiignore -https://api.telegram.org/bot【your bot token】/getUpdates ``` -UserID from this url,replace your bot token,token like【123:abc】 -![notification_telegram_userid_get.png](screenshots/notification_telegram_userid_get.png) \ No newline at end of file +123456789:ABCdefGHIjklMNOpqrsTUVwxyz +``` + +> ⚠️ **Security Notice**: Keep this token secure and never share it publicly. If compromised, use `/revoke` command in BotFather to regenerate immediately. + +--- + +## Step 3: Get Your User ID + +### Method 1: Via Telegram API (Recommended) + +1. First, send any message to your newly created bot (e.g., `/start`) +2. Visit the following URL in your browser (replace `YOUR_BOT_TOKEN` with your actual token): + +``` +https://api.telegram.org/bot{YOUR_BOT_TOKEN}/getUpdates +``` + +**Example**: +``` +https://api.telegram.org/bot123456789:ABCdefGHIjklMNOpqrsTUVwxyz/getUpdates +``` + +3. Locate the `chat.id` field in the JSON response — this is your User ID + +Get User ID + +### Method 2: Via @userinfobot + +1. Search for **@userinfobot** in Telegram +2. Send any message, and it will reply with your User ID + +--- + +## Step 4: Configure Environment Variables + +Add the Bot Token to your `backend_api_python/.env` file: + +```bash +# Telegram Bot Token (required) +TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz +``` + +Restart the backend service after configuration to apply changes. + +--- + +## Step 5: Enable Telegram Notifications in Strategy + +In the strategy configuration page under "Signal Notifications": + +1. Enable the **Telegram** notification channel +2. Enter your Telegram User ID in the designated field + +Configure User ID + +> 💡 **Tip**: You can enter multiple User IDs (comma-separated) or group/channel IDs for multi-recipient notifications. + +--- + +## Troubleshooting + +### Q: Not receiving notifications? + +1. Ensure you've sent a message to the bot first (bot must be activated) +2. Verify `TELEGRAM_BOT_TOKEN` environment variable is correctly configured +3. Double-check the User ID is correct +4. Review backend logs for error messages + +### Q: Can I send to groups? + +Yes. Add the bot to a group, then use the group ID (negative number) as the target. Obtain the group ID using the same method as personal ID. + +### Q: What's the token format? + +Token format is `numbers:alphanumeric_string`, e.g., `123456789:ABCdefGHIjklMNOpqrsTUVwxyz` + +--- + +## Related Documentation + +- [Email SMTP Notification Configuration](./NOTIFICATION_EMAIL_CONFIG_EN.md) +- [SMS Notification Configuration](./NOTIFICATION_SMS_CONFIG_EN.md) +- [Strategy Development Guide](./STRATEGY_DEV_GUIDE.md) diff --git a/quantdinger_vue/src/api/portfolio.js b/quantdinger_vue/src/api/portfolio.js new file mode 100644 index 0000000..9462366 --- /dev/null +++ b/quantdinger_vue/src/api/portfolio.js @@ -0,0 +1,149 @@ +/** + * Portfolio API - Manual positions and monitoring + */ +import request from '@/utils/request' + +// ==================== Positions ==================== + +export function getPositions () { + return request({ + url: '/api/portfolio/positions', + method: 'get' + }) +} + +export function addPosition (data) { + return request({ + url: '/api/portfolio/positions', + method: 'post', + data + }) +} + +export function updatePosition (id, data) { + return request({ + url: `/api/portfolio/positions/${id}`, + method: 'put', + data + }) +} + +export function deletePosition (id) { + return request({ + url: `/api/portfolio/positions/${id}`, + method: 'delete' + }) +} + +export function getPortfolioSummary () { + return request({ + url: '/api/portfolio/summary', + method: 'get' + }) +} + +// ==================== Monitors ==================== + +export function getMonitors () { + return request({ + url: '/api/portfolio/monitors', + method: 'get' + }) +} + +export function addMonitor (data) { + return request({ + url: '/api/portfolio/monitors', + method: 'post', + data + }) +} + +export function updateMonitor (id, data) { + return request({ + url: `/api/portfolio/monitors/${id}`, + method: 'put', + data + }) +} + +export function deleteMonitor (id) { + return request({ + url: `/api/portfolio/monitors/${id}`, + method: 'delete' + }) +} + +export function runMonitor (id, params = {}) { + return request({ + url: `/api/portfolio/monitors/${id}/run`, + method: 'post', + data: params + }) +} + +// ==================== Alerts ==================== + +export function getAlerts () { + return request({ + url: '/api/portfolio/alerts', + method: 'get' + }) +} + +export function addAlert (data) { + return request({ + url: '/api/portfolio/alerts', + method: 'post', + data + }) +} + +export function updateAlert (id, data) { + return request({ + url: `/api/portfolio/alerts/${id}`, + method: 'put', + data + }) +} + +export function deleteAlert (id) { + return request({ + url: `/api/portfolio/alerts/${id}`, + method: 'delete' + }) +} + +// ==================== Groups ==================== + +export function getGroups () { + return request({ + url: '/api/portfolio/groups', + method: 'get' + }) +} + +export function renameGroup (data) { + return request({ + url: '/api/portfolio/groups/rename', + method: 'post', + data + }) +} + +// ==================== Market (reuse from market.js) ==================== + +export function searchSymbols (data) { + return request({ + url: '/api/market/symbols/search', + method: 'post', + data + }) +} + +export function getMarketTypes () { + return request({ + url: '/api/market/types', + method: 'get' + }) +} diff --git a/quantdinger_vue/src/components/GlobalHeader/RightContent.vue b/quantdinger_vue/src/components/GlobalHeader/RightContent.vue index 4fb795c..9f32806 100644 --- a/quantdinger_vue/src/components/GlobalHeader/RightContent.vue +++ b/quantdinger_vue/src/components/GlobalHeader/RightContent.vue @@ -1,6 +1,7 @@